Compare commits
100 Commits
df4c49c3ef
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca266ce1c | ||
|
|
86297ae350 | ||
|
|
f8a179e096 | ||
|
|
c71c506b84 | ||
|
|
b52a06f2cf | ||
|
|
efe417b41a | ||
|
|
8a121d7fec | ||
|
|
2f002081e5 | ||
|
|
09d4bc18d7 | ||
|
|
de61795476 | ||
|
|
279beeabcc | ||
|
|
16995a4465 | ||
|
|
db58cb7a2f | ||
|
|
b89f72cd28 | ||
|
|
fc5c61ddd6 | ||
|
|
55515c050e | ||
|
|
7c685f3044 | ||
|
|
040a180a17 | ||
|
|
dd6d7e645f | ||
|
|
a0057f4d83 | ||
|
|
977c8e4ee0 | ||
|
|
fa54f45998 | ||
|
|
5f3fe302f1 | ||
|
|
de96353554 | ||
|
|
7564d11931 | ||
|
|
b1cd99abf2 | ||
|
|
78ab945a4d | ||
|
|
1c1b86b96b | ||
|
|
504e0d534d | ||
|
|
2b76458de1 | ||
|
|
d6e10e3dc5 | ||
|
|
ab6e080bb4 | ||
|
|
44cfe3f714 | ||
|
|
19bc1c7832 | ||
|
|
c5202793d4 | ||
|
|
95d3098bf3 | ||
|
|
599507068a | ||
|
|
505fdbcd3d | ||
|
|
1337d84369 | ||
|
|
f1ec6c23e1 | ||
|
|
4836bda9f9 | ||
|
|
9f596b10ae | ||
|
|
98c318af04 | ||
|
|
994e545bd0 | ||
|
|
fb0af600a1 | ||
|
|
39a5efbb81 | ||
|
|
b903832d17 | ||
|
|
8233829621 | ||
|
|
36a1ea7c47 | ||
|
|
4e859613f9 | ||
|
|
b9d818ac09 | ||
|
|
ab55d0836e | ||
|
|
f5ad43323c | ||
|
|
8933dadcd0 | ||
|
|
aaabd83fc7 | ||
|
|
560bc1d3bd | ||
|
|
d4b09e5338 | ||
|
|
9a2f8ee211 | ||
|
|
5deebbbf98 | ||
|
|
807736f808 | ||
|
|
a5f7e78d8d | ||
|
|
f56f1931bf | ||
|
|
848e2a43a6 | ||
|
|
bbc53b4ce7 | ||
|
|
8be4b4785c | ||
|
|
0cd7cf6984 | ||
|
|
d3468f7395 | ||
|
|
b75c069035 | ||
|
|
3629757e70 | ||
|
|
be2f67bb7b | ||
|
|
25385c6482 | ||
|
|
3c45e2a154 | ||
|
|
a82f001918 | ||
|
|
48d2455b9c | ||
|
|
d4226caf0a | ||
|
|
a41d59e529 | ||
|
|
0ed7800575 | ||
|
|
a63aadffcb | ||
|
|
36d48c18d1 | ||
|
|
fe65183fb7 | ||
|
|
37be42884f | ||
|
|
4a21eef6f8 | ||
|
|
8244bd94c9 | ||
|
|
4dbb7c5fea | ||
|
|
82fbc31683 | ||
|
|
e08f552386 | ||
|
|
3640d143cf | ||
|
|
a934de6bba | ||
|
|
523cf78737 | ||
|
|
d33d6a4dc4 | ||
|
|
d7c82a0da0 | ||
|
|
73079f743a | ||
|
|
c46d53b261 | ||
|
|
ca6e4cdf5d | ||
|
|
c64fbbb072 | ||
|
|
5eddddc8ec | ||
|
|
8881b6933b | ||
|
|
d7ca5e451d | ||
|
|
fc7d07ae6b | ||
|
|
1f306fffd7 |
@@ -1 +0,0 @@
|
||||
python 3.12.3
|
||||
83
AGENTS.txt
Normal file
83
AGENTS.txt
Normal file
@@ -0,0 +1,83 @@
|
||||
# AGENTS.txt - Development Preferences and Patterns
|
||||
|
||||
This file documents preferences and patterns for AI agents working on this project.
|
||||
|
||||
## CLI Command Alias Preferences
|
||||
|
||||
When implementing CLI commands, follow these patterns:
|
||||
|
||||
### Command Aliases
|
||||
- Prefer short aliases for common commands:
|
||||
- `list` → `ls` (Unix-style listing)
|
||||
- `add` → `a` (quick task creation)
|
||||
- `edit` → `e` (quick editing)
|
||||
- `complete` → `c`, `done` (task completion)
|
||||
- `delete` → `rm`, `del` (Unix-style removal)
|
||||
- `open` → `o` (quick opening)
|
||||
- `show` → `view`, `s` (viewing details)
|
||||
|
||||
### Option/Flag Aliases
|
||||
- Use single letter flags where possible:
|
||||
- `--due-date` → `-d`
|
||||
- `--project` → `-p`
|
||||
- `--priority` → `-pr`
|
||||
- `--tag` → `-t`
|
||||
- `--all` → `-a`
|
||||
- `--force` → `-f`
|
||||
- `--browser` → `-b`
|
||||
- `--content` → `-c`
|
||||
- `--limit` → `-l`
|
||||
|
||||
### Design Principles
|
||||
- Follow Unix CLI conventions where applicable
|
||||
- Provide both full and abbreviated forms for all commands
|
||||
- Single-letter aliases for the most frequently used operations
|
||||
- Intuitive mappings (e.g., `rm` for delete, `ls` for list)
|
||||
- Consistent patterns across different modules
|
||||
|
||||
## Project Structure Patterns
|
||||
|
||||
### Services
|
||||
- Place API clients in `src/services/<service_name>/`
|
||||
- Include authentication in `auth.py`
|
||||
- Main client logic in `client.py`
|
||||
- Exports in `__init__.py`
|
||||
|
||||
### CLI Commands
|
||||
- Group related commands in `src/cli/<service_name>.py`
|
||||
- Register with main CLI in `src/cli/__init__.py`
|
||||
- Use Click for command framework
|
||||
|
||||
### Utilities
|
||||
- Shared utilities in `src/utils/`
|
||||
- Service-specific utilities in `src/utils/<service_name>_utils.py`
|
||||
|
||||
### Token Storage
|
||||
- Store auth tokens in `~/.local/share/gtd-terminal-tools/`
|
||||
- Use project name consistently across services
|
||||
|
||||
## TickTick Integration Notes
|
||||
|
||||
### Authentication
|
||||
- Uses OAuth2 flow with client credentials
|
||||
- Tokens cached in `~/.local/share/gtd-terminal-tools/ticktick_tokens.json`
|
||||
- Environment variables: `TICKTICK_CLIENT_ID`, `TICKTICK_CLIENT_SECRET`, `TICKTICK_REDIRECT_URI`
|
||||
|
||||
### Command Usage Examples
|
||||
```bash
|
||||
# List tasks
|
||||
ticktick ls -p "Work" # List by project
|
||||
ticktick ls -d today # List by due date
|
||||
ticktick ls -pr high # List by priority
|
||||
|
||||
# Task operations
|
||||
ticktick a "Buy groceries" -d tomorrow -p "Personal"
|
||||
ticktick e 123 --title "Updated task"
|
||||
ticktick c 123 # Complete task
|
||||
ticktick rm 123 -f # Force delete task
|
||||
ticktick o 123 # Open in browser/app
|
||||
```
|
||||
|
||||
## Future Development
|
||||
|
||||
When adding new services or commands, follow these established patterns for consistency.
|
||||
961
CALENDAR_INVITE_PLAN.md
Normal file
961
CALENDAR_INVITE_PLAN.md
Normal file
@@ -0,0 +1,961 @@
|
||||
# Calendar Invite Handling Plan
|
||||
|
||||
**Created:** 2025-12-28
|
||||
**Priority:** High
|
||||
**Focus:** Parse and display calendar invite/cancellation emails with user actions
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Users receive calendar-related emails (invites, updates, cancellations) from Outlook/Exchange. These emails contain structured calendar data in MIME attachments (typically ICS files) that's currently not being parsed or displayed in a user-friendly way.
|
||||
|
||||
### Current Issues
|
||||
|
||||
1. **Raw Email Display** - Calendar emails show as raw MIME content
|
||||
2. **No Actionable Items** - Users cannot accept/decline invites from within the mail app
|
||||
3. **Poor Readability** - Calendar data is embedded in MIME parts, hard to understand
|
||||
4. **No Integration** - Actions don't synchronize with the calendar system
|
||||
|
||||
### Example Email Received
|
||||
|
||||
```
|
||||
Subject: Canceled: Technical Refinement
|
||||
From: Marshall, Cody <john.marshall@corteva.com>
|
||||
|
||||
MIME multipart message with:
|
||||
- text/plain part: "Canceled: Technical Refinement"
|
||||
- text/calendar part: base64 encoded ICS file containing:
|
||||
- method=CANCEL (indicates cancellation)
|
||||
- event details (title, date/time, organizer, attendees)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Research: Calendar/I CS File Parsing
|
||||
|
||||
### Standard Libraries
|
||||
|
||||
#### 1. **icalendar** (Recommended)
|
||||
**Repository:** https://github.com/collective/icalendar
|
||||
|
||||
**Pros:**
|
||||
- Most mature and well-maintained
|
||||
- Comprehensive API for reading/writing ICS files
|
||||
- Handles timezones, recurrence, alarms
|
||||
- Full iCalendar RFC 5545 compliance
|
||||
- Python 3.8+ support
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
pip install icalendar
|
||||
```
|
||||
|
||||
**Basic Usage:**
|
||||
```python
|
||||
from icalendar import Calendar
|
||||
from datetime import datetime
|
||||
|
||||
# Parse ICS content
|
||||
calendar = Calendar.from_ical(ics_content)
|
||||
|
||||
for event in calendar.events:
|
||||
print(f"Summary: {event.get('summary')}")
|
||||
print(f"Start: {event.get('dtstart').dt}")
|
||||
print(f"End: {event.get('dtend').dt}")
|
||||
print(f"Location: {event.get('location')}")
|
||||
print(f"Organizer: {event.get('organizer')}")
|
||||
print(f"Method: {event.get('method')}") # REQUEST, CANCEL, etc.
|
||||
```
|
||||
|
||||
#### 2. **ics** (Alternative)
|
||||
**Repository:** https://github.com/collective/ics
|
||||
|
||||
**Pros:**
|
||||
- Simpler API than icalendar
|
||||
- Good for basic ICS parsing
|
||||
- Active maintenance
|
||||
- Lightweight
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
pip install ics
|
||||
```
|
||||
|
||||
**Basic Usage:**
|
||||
```python
|
||||
import ics
|
||||
|
||||
calendar = ics.Calendar(ics_content)
|
||||
for event in calendar.events:
|
||||
print(event.summary)
|
||||
print(event.begin)
|
||||
print(event.end)
|
||||
print(event.location)
|
||||
```
|
||||
|
||||
#### 3. **python-recurring-ical-events**
|
||||
**Repository:** https://github.com/brotaur/recurring-ical-events
|
||||
|
||||
**Pros:**
|
||||
- Specialized for handling complex recurrence patterns
|
||||
- Good for recurring meetings
|
||||
|
||||
**Note:** More complex, use only if needed for advanced scenarios.
|
||||
|
||||
---
|
||||
|
||||
## Analysis of Calendar Invite Email Structure
|
||||
|
||||
### MIME Parts Detection
|
||||
|
||||
Calendar emails typically use `multipart/alternative` or `multipart/mixed` with these parts:
|
||||
|
||||
1. **Plain Text Part** - Human-readable message
|
||||
2. **Calendar Part** (`text/calendar` content type) - ICS file data
|
||||
3. **HTML Part** - Formatted message (optional)
|
||||
4. **Attachments** - Separate ICS files
|
||||
|
||||
### ICS File Content Structure
|
||||
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Example Corp//Calendar App//EN
|
||||
BEGIN:VEVENT
|
||||
UID:12345@example.com
|
||||
DTSTAMP:20251228T120000Z
|
||||
DTSTART:20251228T120000Z
|
||||
DTEND:20251228T130000Z
|
||||
SUMMARY:Weekly Team Meeting
|
||||
LOCATION:Conference Room A
|
||||
ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
|
||||
DESCRIPTION:Weekly team sync meeting
|
||||
ATTENDEE;CN=Jane Smith:mailto:jane.smith@example.com
|
||||
STATUS:CONFIRMED
|
||||
METHOD:REQUEST
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
### Key Calendar Methods
|
||||
|
||||
The `METHOD` property indicates the type of calendar operation:
|
||||
|
||||
- **REQUEST** - Meeting invite request
|
||||
- **CANCEL** - Meeting cancellation (your email example)
|
||||
- **DECLINE** - Meeting declined
|
||||
- **ACCEPT** - Meeting accepted
|
||||
- **TENTATIVE** - Tentative acceptance
|
||||
- **COUNTER** - Counter proposal
|
||||
- **DELEGATE** - Meeting delegated
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Calendar Email Detection (Week 1)
|
||||
|
||||
#### 1.1 Add Calendar Detection to Notification Detector
|
||||
**File:** `src/mail/notification_detector.py`
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class CalendarInvite:
|
||||
"""Calendar invite/cancellation email."""
|
||||
|
||||
# Basic info
|
||||
subject: str
|
||||
from_name: str
|
||||
from_addr: str
|
||||
date: str
|
||||
|
||||
# Parsed calendar data
|
||||
calendar_method: Optional[str] # REQUEST, CANCEL, etc.
|
||||
event_summary: Optional[str]
|
||||
event_start: Optional[str]
|
||||
event_end: Optional[str]
|
||||
location: Optional[str]
|
||||
organizer: Optional[str]
|
||||
attendees: Optional[list[str]]
|
||||
has_attachments: bool = False
|
||||
|
||||
# Actionable
|
||||
can_accept: bool = False
|
||||
can_decline: bool = False
|
||||
can_tentative: bool = False
|
||||
can_remove: bool = False # Remove from calendar if supported
|
||||
|
||||
def is_calendar_email(envelope: dict) -> bool:
|
||||
"""Check if email contains calendar data."""
|
||||
subject = envelope.get("subject", "").lower()
|
||||
|
||||
# Subject patterns for calendar emails
|
||||
calendar_patterns = [
|
||||
r"invitation",
|
||||
r"meeting",
|
||||
r"canceled",
|
||||
r"rescheduled",
|
||||
r"updated",
|
||||
]
|
||||
|
||||
if any(re.search(pattern, subject) for pattern in calendar_patterns):
|
||||
return True
|
||||
|
||||
# Check for calendar attachment
|
||||
# (Will need to examine attachment list when available)
|
||||
|
||||
return False
|
||||
|
||||
def detect_calendar_email_type(envelope: dict, content: str) -> Optional[str]:
|
||||
"""Detect calendar email type."""
|
||||
# Implementation
|
||||
pass
|
||||
```
|
||||
|
||||
#### 1.2 Add ICS Parser Dependency
|
||||
**File:** `pyproject.toml`
|
||||
|
||||
**Changes:**
|
||||
```toml
|
||||
[project.optional-dependencies]
|
||||
icalendar = ">=5.0,<7.0"
|
||||
# OR
|
||||
ics = ">=0.6,<1.0"
|
||||
|
||||
[project.optional-dependencies-extras]
|
||||
icalendar = ["all"]
|
||||
```
|
||||
|
||||
**Install Command:**
|
||||
```bash
|
||||
uv pip install 'luk[icalendar]'
|
||||
# Or if using uv
|
||||
uv add --optional icalendar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Calendar Content Display Widget (Week 1-2)
|
||||
|
||||
#### 2.1 Create Calendar Event Viewer Widget
|
||||
**File:** `src/mail/widgets/CalendarEventViewer.py`
|
||||
|
||||
**Design:**
|
||||
```python
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.widgets import Static, Button, Label
|
||||
from textual.screen import Screen
|
||||
from textual.app import ComposeResult
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class CalendarEventViewer(Screen):
|
||||
"""Widget to display calendar invite/event details."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "pop_screen", "Close", show=False),
|
||||
Binding("q", "pop_screen", "Close", show=False),
|
||||
]
|
||||
|
||||
def __init__(self, calendar_data: CalendarInvite, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.calendar_data = calendar_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="calendar_viewer_container"):
|
||||
# Header with event type indicator
|
||||
event_type = self._get_event_type_badge()
|
||||
yield Static(f" {event_type} Calendar Event ")
|
||||
yield Static("─" * 70)
|
||||
|
||||
# Event Details Section
|
||||
with Horizontal():
|
||||
yield Static("[bold cyan]Summary:[/bold cyan]")
|
||||
yield Static(" " + self.calendar_data.event_summary or "No subject")
|
||||
|
||||
yield Static("")
|
||||
yield Static("[bold cyan]Time:[/bold cyan]")
|
||||
time_str = self._format_time_range()
|
||||
yield Static(" " + time_str)
|
||||
|
||||
if self.calendar_data.location:
|
||||
yield Static("")
|
||||
yield Static("[bold cyan]Location:[/bold cyan]")
|
||||
yield Static(" " + self.calendar_data.location)
|
||||
|
||||
if self.calendar_data.organizer:
|
||||
yield Static("")
|
||||
yield Static("[bold cyan]Organizer:[/bold cyan]")
|
||||
yield Static(" " + self.calendar_data.organizer)
|
||||
|
||||
if self.calendar_data.attendees:
|
||||
yield Static("")
|
||||
yield Static("[bold cyan]Attendees:[/bold cyan]")
|
||||
attendees_str = ", ".join(self.calendar_data.attendees[:5])
|
||||
if len(self.calendar_data.attendees) > 5:
|
||||
attendees_str += f" + {len(self.calendar_data.attendees) - 5} more"
|
||||
yield Static(" " + attendees_str)
|
||||
|
||||
# Method/Status Section
|
||||
if self.calendar_data.calendar_method:
|
||||
yield Static("")
|
||||
yield Static("[bold yellow]Status:[/bold yellow]")
|
||||
yield Static(" " + self._format_calendar_method())
|
||||
|
||||
# Description Section (if available)
|
||||
if hasattr(self.calendar_data, 'description'):
|
||||
desc = self.calendar_data.description
|
||||
if desc and len(desc) > 200:
|
||||
desc = desc[:200] + "..."
|
||||
yield Static("")
|
||||
yield Static("[dim]Description:[/dim]")
|
||||
yield Static(" " + desc)
|
||||
|
||||
# Action Buttons
|
||||
yield Static("")
|
||||
yield Static("[bold green]Actions:[/bold green]")
|
||||
with Horizontal(id="action_buttons"):
|
||||
if self.calendar_data.can_accept:
|
||||
yield Button("✓ Accept", id="btn_accept", variant="success")
|
||||
if self.calendar_data.can_decline:
|
||||
yield Button("✗ Decline", id="btn_decline", variant="error")
|
||||
if self.calendar_data.can_tentative:
|
||||
yield Button("? Tentative", id="btn_tentative", variant="warning")
|
||||
if self.calendar_data.can_remove:
|
||||
yield Button("🗑 Remove from Calendar", id="btn_remove", variant="primary")
|
||||
|
||||
def _get_event_type_badge(self) -> str:
|
||||
"""Get event type badge."""
|
||||
method = self.calendar_data.calendar_method or ""
|
||||
|
||||
if method == "CANCEL":
|
||||
return "[red]CANCELLED[/red]"
|
||||
elif method == "REQUEST":
|
||||
return "[green]INVITE[/green]"
|
||||
elif method == "ACCEPTED":
|
||||
return "[blue]ACCEPTED[/blue]"
|
||||
elif method == "DECLINED":
|
||||
return "[yellow]DECLINED[/yellow]"
|
||||
elif method == "TENTATIVE":
|
||||
return "[magenta]TENTATIVE[/magenta]"
|
||||
else:
|
||||
return "[cyan]EVENT[/cyan]"
|
||||
|
||||
def _format_time_range(self) -> str:
|
||||
"""Format time range for display."""
|
||||
if self.calendar_data.event_start and self.calendar_data.event_end:
|
||||
start = self._parse_date_time(self.calendar_data.event_start)
|
||||
end = self._parse_date_time(self.calendar_data.event_end)
|
||||
return f"{start} - {end}"
|
||||
elif self.calendar_data.event_start:
|
||||
return self._parse_date_time(self.calendar_data.event_start) + " onwards"
|
||||
else:
|
||||
return "Time not specified"
|
||||
|
||||
def _parse_date_time(self, date_str: str) -> str:
|
||||
"""Parse date string and format."""
|
||||
# Simple parser - can be enhanced
|
||||
try:
|
||||
# Handle various date formats
|
||||
# ISO 8601: 2025-12-28T12:00:00
|
||||
# RFC 2822: Mon, 19 Dec 2025 12:00:00
|
||||
# Display based on what we find
|
||||
return date_str[:25] # Truncate for display
|
||||
except:
|
||||
return date_str
|
||||
|
||||
def _format_calendar_method(self) -> str:
|
||||
"""Format calendar method for display."""
|
||||
method = self.calendar_data.calendar_method
|
||||
method_display = method.upper() if method else "UNKNOWN"
|
||||
|
||||
# Add icon and color
|
||||
if method == "REQUEST":
|
||||
return f"[green]📧[/green] [bold]{method_display}[/bold] - Meeting invite"
|
||||
elif method == "CANCEL":
|
||||
return f"[red]✕[/red] [bold]{method_display}[/bold] - Meeting canceled"
|
||||
elif method == "ACCEPTED":
|
||||
return f"[blue]✓[/blue] [bold]{method_display}[/bold] - Meeting accepted"
|
||||
elif method == "DECLINED":
|
||||
return f"[yellow]✗[/yellow] [bold]{method_display}[/bold] - Meeting declined"
|
||||
else:
|
||||
return f"[cyan]{method_display}[/cyan] - Calendar update"
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press."""
|
||||
if event.button.id == "btn_accept":
|
||||
self._handle_accept()
|
||||
elif event.button.id == "btn_decline":
|
||||
self._handle_decline()
|
||||
elif event.button.id == "btn_tentative":
|
||||
self._handle_tentative()
|
||||
elif event.button.id == "btn_remove":
|
||||
self._handle_remove()
|
||||
|
||||
def _handle_accept(self) -> None:
|
||||
"""Handle accept action."""
|
||||
self.dismiss("accept")
|
||||
self.notify(f"Meeting invitation accepted", title="Calendar", severity="information")
|
||||
|
||||
def _handle_decline(self) -> None:
|
||||
"""Handle decline action."""
|
||||
self.dismiss("decline")
|
||||
self.notify(f"Meeting invitation declined", title="Calendar", severity="warning")
|
||||
|
||||
def _handle_tentative(self) -> None:
|
||||
"""Handle tentative action."""
|
||||
self.dismiss("tentative")
|
||||
self.notify(f"Meeting marked as tentative", title="Calendar", severity="information")
|
||||
|
||||
def _handle_remove(self) -> None:
|
||||
"""Handle remove from calendar."""
|
||||
self.dismiss("remove")
|
||||
self.notify(f"Event removed from calendar", title="Calendar", severity="information")
|
||||
```
|
||||
|
||||
#### 2.2 Parse ICS Content from Email
|
||||
**File:** `src/mail/utils/calendar_parser.py`
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
"""Calendar ICS file parser utilities."""
|
||||
|
||||
import base64
|
||||
from icalendar import Calendar
|
||||
from typing import Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ParsedCalendarEvent:
|
||||
"""Parsed calendar event from ICS file."""
|
||||
|
||||
# Core event properties
|
||||
summary: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
start: Optional[str] = None
|
||||
end: Optional[str] = None
|
||||
all_day: bool = False
|
||||
|
||||
# Calendar method
|
||||
method: Optional[str] = None # REQUEST, CANCEL, etc.
|
||||
|
||||
# Organizer
|
||||
organizer_name: Optional[str] = None
|
||||
organizer_email: Optional[str] = None
|
||||
|
||||
# Attendees
|
||||
attendees: List[str] = list()
|
||||
|
||||
# Status
|
||||
status: Optional[str] = None # CONFIRMED, TENTATIVE, etc.
|
||||
|
||||
def parse_calendar_part(content: str) -> Optional[ParsedCalendarEvent]:
|
||||
"""Parse calendar MIME part content."""
|
||||
|
||||
try:
|
||||
# Try to parse as ICS file
|
||||
calendar = Calendar.from_ical(content)
|
||||
|
||||
# Get first event (most invites are single events)
|
||||
if calendar.events:
|
||||
event = calendar.events[0]
|
||||
|
||||
# Extract organizer
|
||||
organizer = event.get("organizer")
|
||||
organizer_name = organizer.cn if organizer else None
|
||||
organizer_email = organizer.email if organizer else None
|
||||
|
||||
# Extract attendees
|
||||
attendees = []
|
||||
if event.get("attendees"):
|
||||
for attendee in event.attendees:
|
||||
email = attendee.email if attendee else None
|
||||
name = attendee.cn if attendee else None
|
||||
if email:
|
||||
attendees.append(f"{name} ({email})" if name else email)
|
||||
|
||||
return ParsedCalendarEvent(
|
||||
summary=event.get("summary"),
|
||||
location=event.get("location"),
|
||||
description=event.get("description"),
|
||||
start=str(event.get("dtstart")) if event.get("dtstart") else None,
|
||||
end=str(event.get("dtend")) if event.get("dtend") else None,
|
||||
all_day=event.get("x-google", "all-day") == "true",
|
||||
method=event.get("method"),
|
||||
organizer_name=organizer_name,
|
||||
organizer_email=organizer_email,
|
||||
attendees=attendees,
|
||||
status=event.get("status", "CONFIRMED")
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error parsing calendar ICS: {e}")
|
||||
return None
|
||||
|
||||
def parse_calendar_attachment(attachment_content: str) -> Optional[ParsedCalendarEvent]:
|
||||
"""Parse calendar file attachment."""
|
||||
# Handle base64 encoded ICS files
|
||||
try:
|
||||
decoded = base64.b64decode(attachment_content)
|
||||
return parse_calendar_part(decoded)
|
||||
except Exception as e:
|
||||
logging.error(f"Error decoding calendar attachment: {e}")
|
||||
return None
|
||||
|
||||
def is_cancelled_event(event: ParsedCalendarEvent) -> bool:
|
||||
"""Check if event is cancelled."""
|
||||
return event.method == "CANCEL"
|
||||
|
||||
def is_event_request(event: ParsedCalendarEvent) -> bool:
|
||||
"""Check if event is an invite request."""
|
||||
return event.method == "REQUEST"
|
||||
|
||||
def extract_email_from_vcard(email_str: str) -> Optional[str]:
|
||||
"""Extract email address from VCard format."""
|
||||
# VCard format: "CN=Name:MAILTO:email@example.com"
|
||||
# Simple regex to extract
|
||||
import re
|
||||
match = re.search(r"MAILTO:([^>\s]+)", email_str)
|
||||
return match.group(1) if match else None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Integration with Mail App (Week 1-3)
|
||||
|
||||
#### 3.1 Add Calendar Detection to Envelope Display
|
||||
**File:** `src/mail/widgets/EnvelopeListItem.py`
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
from .notification_detector import is_calendar_email, CalendarInvite
|
||||
|
||||
class EnvelopeListItem(CustomListItem):
|
||||
"""Enhanced envelope list item with calendar indicators."""
|
||||
|
||||
def __init__(self, envelope: dict, **kwargs):
|
||||
super().__init__(envelope, **kwargs)
|
||||
self.calendar_type = self._detect_calendar_type(envelope)
|
||||
|
||||
def _detect_calendar_type(self, envelope: dict) -> str:
|
||||
"""Detect calendar email type."""
|
||||
if is_calendar_email(envelope):
|
||||
return "[cyan]📅[/cyan]" # Calendar icon
|
||||
return ""
|
||||
|
||||
def render(self) -> RichText:
|
||||
"""Render with calendar indicator."""
|
||||
from rich.text import Text
|
||||
|
||||
# Get base render from parent
|
||||
base_render = super().render()
|
||||
|
||||
# Add calendar icon if applicable
|
||||
calendar_indicator = Text.assemble(
|
||||
self.calendar_type + " ",
|
||||
style="on" if self.calendar_type else ""
|
||||
)
|
||||
|
||||
return Text.assemble(base_render, calendar_indicator)
|
||||
```
|
||||
|
||||
#### 3.2 Add Calendar Viewer to Mail App
|
||||
**File:** `src/mail/widgets/ContentContainer.py`
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class ContentContainer(ScrollableContainer):
|
||||
"""Enhanced with calendar event display support."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.calendar_data: Optional[CalendarInvite] = None
|
||||
self.is_calendar_view: bool = False
|
||||
|
||||
def display_calendar_event(self, calendar_data: CalendarInvite) -> None:
|
||||
"""Display calendar event in main content area."""
|
||||
self.calendar_data = calendar_data
|
||||
self.is_calendar_view = True
|
||||
|
||||
# Switch to calendar viewer
|
||||
from .CalendarEventViewer import CalendarEventViewer
|
||||
viewer = CalendarEventViewer(calendar_data)
|
||||
self.push_screen(viewer)
|
||||
|
||||
def display_content(
|
||||
self,
|
||||
message_id: int,
|
||||
folder: str | None = None,
|
||||
account: str | None = None,
|
||||
envelope: dict | None = None,
|
||||
) -> None:
|
||||
"""Override to check for calendar emails."""
|
||||
if not message_id:
|
||||
return
|
||||
|
||||
self.current_message_id = message_id
|
||||
self.current_folder = folder
|
||||
self.current_account = account
|
||||
self.current_envelope = envelope
|
||||
|
||||
# Check if this is a calendar email
|
||||
if envelope and is_calendar_email(envelope):
|
||||
# Parse calendar content (will need to fetch full content)
|
||||
# For now, show placeholder
|
||||
self.content.update("Calendar invite detected - parsing...")
|
||||
self.html_content.update("Calendar invite detected - parsing...")
|
||||
```
|
||||
|
||||
#### 3.3 Add Calendar Actions to Keybindings
|
||||
**File:** `src/mail/app.py`
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
# Existing actions preserved
|
||||
# Add new calendar-specific actions
|
||||
|
||||
async def action_calendar_accept(self) -> None:
|
||||
"""Accept calendar invitation."""
|
||||
# Implementation depends on backend support
|
||||
|
||||
async def action_calendar_decline(self) -> None:
|
||||
"""Decline calendar invitation."""
|
||||
# Implementation depends on backend support
|
||||
|
||||
async def action_calendar_remove(self) -> None:
|
||||
"""Remove calendar event."""
|
||||
# Implementation depends on backend support
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Calendar Sync Integration (Week 2-3)
|
||||
|
||||
#### 4.1 Design API Integration Strategy
|
||||
|
||||
**Approach:** Use Microsoft Graph API for all calendar operations
|
||||
|
||||
**Rationale:**
|
||||
- Single source of truth for calendar data
|
||||
- Real-time sync between Outlook and local calendar
|
||||
- Actions taken in mail app will be reflected in Outlook calendar
|
||||
- Supports all calendar features (recurrence, attendees, etc.)
|
||||
- Cancellations will update the actual event in Outlook
|
||||
|
||||
**Key Decision:** Before implementing calendar actions, we should call Microsoft Graph API to:
|
||||
1. Accept meeting → Update event status to ACCEPTED
|
||||
2. Decline meeting → Update event status to DECLINED
|
||||
3. Tentatively accept → Update event status to TENTATIVE
|
||||
4. Cancel meeting → Send cancellation to organizer, update event status
|
||||
|
||||
**Files to Modify:**
|
||||
- `src/services/microsoft_graph/calendar.py` - Add action methods
|
||||
- `src/mail/actions/calendar_actions.py` - Create action handlers
|
||||
- `src/mail/app.py` - Add calendar action keybindings
|
||||
|
||||
**API Calls Needed:**
|
||||
```python
|
||||
# Accept invitation
|
||||
PATCH /me/events/{id}
|
||||
{
|
||||
"response": {
|
||||
"response": "accepted",
|
||||
"comment": "Accepted via LUK Mail app"
|
||||
}
|
||||
}
|
||||
|
||||
# Decline invitation
|
||||
PATCH /me/events/{id}
|
||||
{
|
||||
"response": {
|
||||
"response": "declined",
|
||||
"comment": "Declined via LUK Mail app"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Testing & Documentation (Week 3)
|
||||
|
||||
#### 5.1 Unit Tests for Calendar Parsing
|
||||
**File:** `tests/test_calendar_parser.py`
|
||||
|
||||
**Test Cases:**
|
||||
```python
|
||||
import pytest
|
||||
from src.mail.utils.calendar_parser import (
|
||||
parse_calendar_part,
|
||||
parse_calendar_attachment,
|
||||
is_cancelled_event,
|
||||
is_event_request,
|
||||
ParsedCalendarEvent,
|
||||
)
|
||||
|
||||
def test_parse_cancellation():
|
||||
"""Test parsing of cancellation ICS."""
|
||||
ics_content = """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:test-cancel@example.com
|
||||
DTSTAMP:20251228T120000Z
|
||||
DTSTART:20251228T120000Z
|
||||
DTEND:20251228T130000Z
|
||||
SUMMARY:Canceled Meeting
|
||||
LOCATION:Conference Room A
|
||||
ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
|
||||
METHOD:CANCEL
|
||||
STATUS:CANCELLED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
event = parse_calendar_part(ics_content)
|
||||
assert event is not None
|
||||
assert is_cancelled_event(event)
|
||||
assert event.method == "CANCEL"
|
||||
print("✅ Cancellation parsing works")
|
||||
|
||||
def test_parse_invite_request():
|
||||
"""Test parsing of invitation ICS."""
|
||||
ics_content = """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:test-invite@example.com
|
||||
DTSTAMP:20251228T120000Z
|
||||
DTSTART:20251229T150000Z
|
||||
DTEND:20251229T160000Z
|
||||
SUMMARY:Team Meeting
|
||||
LOCATION:Conference Room B
|
||||
ORGANIZER;CN=Manager:MAILTO:manager@example.com
|
||||
METHOD:REQUEST
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
event = parse_calendar_part(ics_content)
|
||||
assert event is not None
|
||||
assert is_event_request(event)
|
||||
assert event.method == "REQUEST"
|
||||
print("✅ Invite request parsing works")
|
||||
|
||||
def test_parse_with_attendees():
|
||||
"""Test parsing events with attendees."""
|
||||
# Implementation...
|
||||
pass
|
||||
```
|
||||
|
||||
#### 5.2 Update Help Screen
|
||||
**File:** `src/mail/screens/HelpScreen.py`
|
||||
|
||||
**Additions:**
|
||||
```python
|
||||
# Add to Quick Actions section:
|
||||
yield Static(" [yellow]Calendar:[/yellow]")
|
||||
yield Static(" • Calendar invites automatically detected")
|
||||
yield Static(" • ICS files parsed to show event details")
|
||||
yield Static(" • Accept/Decline/Remove actions for invites")
|
||||
yield Static(" • Actions sync with Microsoft Outlook via Graph API")
|
||||
yield Static("")
|
||||
```
|
||||
|
||||
#### 5.3 Update Configuration
|
||||
**File:** `src/mail/config.py`
|
||||
|
||||
**Additions:**
|
||||
```python
|
||||
class MailAppConfig(BaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# Calendar settings
|
||||
calendar_parser_library: Literal["icalendar", "ics"] = "icalendar"
|
||||
auto_detect_calendar_emails: bool = True
|
||||
show_calendar_indicator_in_list: bool = True
|
||||
enable_calendar_actions: bool = False # When Graph API integration ready
|
||||
```
|
||||
|
||||
**Config File Example:**
|
||||
```toml
|
||||
[calendar]
|
||||
# Which ICS library to use (icalendar recommended)
|
||||
parser_library = "icalendar"
|
||||
|
||||
# Automatically detect and highlight calendar emails
|
||||
auto_detect_calendar = true
|
||||
|
||||
# Show calendar icon in message list
|
||||
show_calendar_indicator = true
|
||||
|
||||
# Calendar action integration (requires Microsoft Graph API)
|
||||
enable_calendar_actions = false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Week 1: Foundation
|
||||
1. ✅ Add calendar detection to notification_detector.py
|
||||
2. ✅ Add icalendar dependency to pyproject.toml
|
||||
3. ✅ Create calendar_parser.py with ICS parsing utilities
|
||||
4. ✅ Create CalendarEventViewer widget
|
||||
5. ✅ Add calendar detection to EnvelopeListItem
|
||||
6. ✅ Add calendar viewer to ContentContainer
|
||||
7. ✅ Add calendar action placeholders in app.py
|
||||
8. ✅ Create calendar action handlers
|
||||
9. ✅ Create proper ICS test fixture (calendar invite)
|
||||
10. ✅ Update help screen documentation
|
||||
11. ✅ Add configuration options
|
||||
|
||||
### Week 2: Mail App Integration
|
||||
1. ✅ Integrate calendar detection in EnvelopeListItem
|
||||
2. ✅ Add calendar viewer to ContentContainer
|
||||
3. ✅ Add calendar action placeholders in app.py
|
||||
4. ✅ Add unit tests for calendar parsing
|
||||
|
||||
### Week 3: Advanced Features
|
||||
1. ✅ Implement Microsoft Graph API calendar actions
|
||||
2. ⏳ Test with real calendar invites
|
||||
3. ⏳ Document calendar features in help
|
||||
|
||||
### Week 4: Calendar Sync Integration
|
||||
1. ⏳ Calendar invite acceptance (Graph API)
|
||||
2. ⏳ Calendar invite declination (Graph API)
|
||||
3. ⏳ Calendar event removal (Graph API)
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### User Experience Goals
|
||||
- **Calendar Detection:** 95%+ accuracy for invite/cancellation emails
|
||||
- **ICS Parsing:** 100% RFC 5545 compliance with icalendar
|
||||
- **Display Quality:** Clear, readable calendar event details
|
||||
- **Actionable:** Accept/Decline/Tentative/Remove buttons (ready for Graph API integration)
|
||||
- **Performance:** Parse ICS files in <100ms
|
||||
|
||||
### Technical Metrics
|
||||
- **Library Coverage:** icalendar (mature, RFC 5545 compliant)
|
||||
- **Code Quality:** Type-safe with dataclasses, full error handling
|
||||
- **Test Coverage:** >80% for calendar parsing code
|
||||
- **Configuration:** Flexible parser library selection, toggleable features
|
||||
|
||||
---
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Parser Library
|
||||
```toml
|
||||
[calendar]
|
||||
parser_library = "icalendar" # or "ics"
|
||||
auto_detect_calendar = true
|
||||
```
|
||||
|
||||
### Display Options
|
||||
```toml
|
||||
[envelope_display]
|
||||
show_calendar_icon = true
|
||||
```
|
||||
|
||||
### Action Configuration
|
||||
```toml
|
||||
[calendar_actions]
|
||||
# When true, actions call Microsoft Graph API
|
||||
enable_graph_api_actions = false
|
||||
|
||||
# User preferences
|
||||
default_response = "accept" # accept, decline, tentative
|
||||
auto_decline_duplicates = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes & Considerations
|
||||
|
||||
### Important Design Decisions
|
||||
|
||||
1. **Library Choice:** `icalendar` is recommended over `ics` for:
|
||||
- Better RFC compliance
|
||||
- More features (recurrence, timezones)
|
||||
- Better error handling
|
||||
- Active maintenance
|
||||
|
||||
2. **Display Priority:** Calendar events should be displayed prominently:
|
||||
- Use `push_screen()` to show full event details
|
||||
- Show in dedicated viewer, not inline in message list
|
||||
- Provide clear visual distinction for different event types (invite vs cancellation)
|
||||
|
||||
3. **Action Strategy:**
|
||||
- Implement Graph API integration first before enabling actions
|
||||
- Use Graph API as single source of truth for calendar
|
||||
- Actions in mail app should trigger Graph API calls to update Outlook
|
||||
- This prevents sync conflicts and ensures consistency
|
||||
|
||||
4. **Error Handling:**
|
||||
- Gracefully handle malformed ICS files
|
||||
- Provide user feedback when parsing fails
|
||||
- Fall back to raw email display if parsing fails
|
||||
|
||||
5. **Performance:**
|
||||
- Parse ICS files on-demand (not in message list rendering)
|
||||
- Use caching for parsed calendar data
|
||||
- Consider lazy loading for large mailboxes with many calendar emails
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
- **Recurring Events:** Full support for recurring meetings
|
||||
- **Multiple Events:** Handle ICS files with multiple events
|
||||
- **Timezone Support:** Proper timezone handling for events
|
||||
- **Attachments:** Process calendar file attachments
|
||||
- **Proposed Times:** Handle proposed meeting times
|
||||
- **Updates:** Process event updates (time/location changes)
|
||||
- **Decline with Note:** Add optional note when declining
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### iCalendar Standard (RFC 5545)
|
||||
- https://datatracker.ietf.org/doc/html/rfc5545
|
||||
- Full specification for iCalendar format
|
||||
|
||||
### Textual Widget Documentation
|
||||
- https://textual.textualize.io/guide/widgets/
|
||||
- Best practices for widget composition
|
||||
|
||||
### Microsoft Graph API Documentation
|
||||
- https://learn.microsoft.com/en-us/graph/api/calendar/
|
||||
- Calendar REST API reference
|
||||
|
||||
### Testing Resources
|
||||
- Sample ICS files for testing various scenarios
|
||||
- Calendar test fixtures for different event types
|
||||
|
||||
---
|
||||
|
||||
## Timeline Summary
|
||||
|
||||
**Week 1:** Foundation & Detection
|
||||
**Week 2:** Mail App Integration & Display
|
||||
**Week 3:** Advanced Features & Actions
|
||||
**Week 4:** Calendar Sync Integration (future)
|
||||
|
||||
**Total Estimated Time:** 4-6 weeks for full implementation
|
||||
|
||||
**Deliverable:** A production-ready calendar invite handling system that:
|
||||
- Detects calendar emails automatically
|
||||
- Parses ICS calendar data
|
||||
- Displays events beautifully in TUI
|
||||
- Provides user actions (accept/decline/tentative/remove)
|
||||
- Integrates with Microsoft Graph API for calendar management
|
||||
250
GODSPEED_SYNC.md
Normal file
250
GODSPEED_SYNC.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Godspeed Sync
|
||||
|
||||
A two-way synchronization tool between the Godspeed task management API and local markdown files.
|
||||
|
||||
## Features
|
||||
|
||||
- **Bidirectional Sync**: Download tasks from Godspeed to markdown files, edit locally, and upload changes back
|
||||
- **Directory Structure**: Creates a clean directory structure matching your Godspeed lists
|
||||
- **ID Tracking**: Uses hidden HTML comments to track task IDs even when you rearrange tasks
|
||||
- **Markdown Format**: Simple `- [ ] Task name <!-- id:abc123 -->` format for easy editing
|
||||
- **Completion Status**: Supports incomplete `[ ]`, completed `[x]`, and cancelled `[-]` checkboxes
|
||||
- **Notes Support**: Task notes are preserved and synced
|
||||
- **CLI Interface**: Easy-to-use command line interface with shortcuts
|
||||
|
||||
## Installation
|
||||
|
||||
The Godspeed sync is part of the GTD Terminal Tools project. Make sure you have the required dependencies:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Or with pip
|
||||
pip install requests click
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Option 1: Environment Variables
|
||||
```bash
|
||||
export GODSPEED_EMAIL="your@email.com"
|
||||
export GODSPEED_PASSWORD="your-password"
|
||||
|
||||
# OR use an API token directly
|
||||
export GODSPEED_TOKEN="your-api-token"
|
||||
|
||||
# Optional: Custom sync directory
|
||||
export GODSPEED_SYNC_DIR="~/Documents/MyTasks"
|
||||
|
||||
# Optional: Disable SSL verification for corporate networks
|
||||
export GODSPEED_DISABLE_SSL_VERIFY="true"
|
||||
```
|
||||
|
||||
### Option 2: Interactive Setup
|
||||
The tool will prompt for credentials if not provided via environment variables.
|
||||
|
||||
### Getting an API Token
|
||||
You can get your API token from the Godspeed desktop app:
|
||||
1. Open the Command Palette (Cmd/Ctrl + Shift + P)
|
||||
2. Run "Copy API access token"
|
||||
3. Use this token with `GODSPEED_TOKEN` environment variable
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Download all tasks from Godspeed to local markdown files
|
||||
python -m src.cli.godspeed download
|
||||
# OR use the short alias
|
||||
python -m src.cli.godspeed download # 'gs' will be available when integrated
|
||||
|
||||
# Upload local changes back to Godspeed
|
||||
python -m src.cli.godspeed upload
|
||||
|
||||
# Bidirectional sync (download then upload)
|
||||
python -m src.cli.godspeed sync
|
||||
|
||||
# Check sync status
|
||||
python -m src.cli.godspeed status
|
||||
|
||||
# Open sync directory in file manager
|
||||
python -m src.cli.godspeed open
|
||||
|
||||
# Test connection and SSL (helpful for corporate networks)
|
||||
python -m src.cli.godspeed test-connection
|
||||
```
|
||||
|
||||
### Workflow Example
|
||||
|
||||
1. **Initial sync**:
|
||||
```bash
|
||||
python -m src.cli.godspeed download
|
||||
```
|
||||
|
||||
2. **Edit tasks locally**:
|
||||
Open the generated markdown files in your favorite editor:
|
||||
```
|
||||
~/Documents/Godspeed/
|
||||
├── Personal.md
|
||||
├── Work_Projects.md
|
||||
└── Shopping.md
|
||||
```
|
||||
|
||||
3. **Make changes**:
|
||||
```markdown
|
||||
# Personal.md
|
||||
- [ ] Call dentist <!-- id:abc123 -->
|
||||
- [x] Buy groceries <!-- id:def456 -->
|
||||
Don't forget milk and eggs
|
||||
- [-] Old project <!-- id:ghi789 -->
|
||||
- [ ] New task I just added <!-- id:jkl012 -->
|
||||
|
||||
# Work_Projects.md
|
||||
- [ ] Finish quarterly report <!-- id:xyz890 -->
|
||||
Due Friday
|
||||
- [-] Cancelled meeting <!-- id:uvw567 -->
|
||||
```
|
||||
|
||||
4. **Sync changes back**:
|
||||
```bash
|
||||
python -m src.cli.godspeed upload
|
||||
```
|
||||
|
||||
## File Format
|
||||
|
||||
Each list becomes a markdown file with tasks in this format:
|
||||
|
||||
```markdown
|
||||
- [ ] Incomplete task <!-- id:abc123 -->
|
||||
- [x] Completed task <!-- id:def456 -->
|
||||
- [X] Also completed (capital X works too) <!-- id:ghi789 -->
|
||||
- [-] Cancelled/cleared task <!-- id:jkl012 -->
|
||||
- [ ] Task with notes <!-- id:mno345 -->
|
||||
Notes go on the next line, indented
|
||||
```
|
||||
|
||||
### Important Notes:
|
||||
- **Don't remove the `<!-- id:xxx -->` comments** - they're used to track tasks
|
||||
- **Don't worry about the IDs** - they're auto-generated for new tasks
|
||||
- **Checkbox format matters**:
|
||||
- Use `[ ]` for incomplete tasks
|
||||
- Use `[x]` or `[X]` for completed tasks
|
||||
- Use `[-]` for cancelled/cleared tasks
|
||||
- **Completion status syncs both ways**:
|
||||
- Check/uncheck boxes in markdown → syncs to Godspeed
|
||||
- Mark complete/incomplete/cleared in Godspeed → syncs to markdown
|
||||
- **Completed/cancelled tasks are hidden**: When downloading from Godspeed, only incomplete tasks appear in local files (keeps them clean)
|
||||
- **Notes are optional** - indent them under the task line
|
||||
- **File names** correspond to list names (special characters replaced with underscores)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
By default, files are synced to:
|
||||
- `~/Documents/Godspeed/` (if Documents folder exists)
|
||||
- `~/.local/share/gtd-terminal-tools/godspeed/` (fallback)
|
||||
|
||||
Each Godspeed list becomes a `.md` file:
|
||||
- "Personal" → `Personal.md`
|
||||
- "Work Projects" → `Work_Projects.md`
|
||||
- "Shopping List" → `Shopping_List.md`
|
||||
|
||||
## Sync Metadata
|
||||
|
||||
The tool stores sync metadata in `.godspeed_metadata.json`:
|
||||
```json
|
||||
{
|
||||
"task_mapping": {
|
||||
"local-id-1": "godspeed-task-id-1",
|
||||
"local-id-2": "godspeed-task-id-2"
|
||||
},
|
||||
"list_mapping": {
|
||||
"Personal": "godspeed-list-id-1",
|
||||
"Work Projects": "godspeed-list-id-2"
|
||||
},
|
||||
"last_sync": "2024-01-15T10:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
## API Rate Limits
|
||||
|
||||
Godspeed has rate limits:
|
||||
- **Listing**: 10 requests/minute, 200/hour
|
||||
- **Creating/Updating**: 60 requests/minute, 1,000/hour
|
||||
|
||||
The sync tool respects these limits and handles errors gracefully.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SSL/Corporate Network Issues
|
||||
If you're getting SSL certificate errors on a corporate network:
|
||||
|
||||
```bash
|
||||
# Test the connection first
|
||||
python -m src.cli.godspeed test-connection
|
||||
|
||||
# If SSL errors occur, bypass SSL verification
|
||||
export GODSPEED_DISABLE_SSL_VERIFY=true
|
||||
python -m src.cli.godspeed test-connection
|
||||
```
|
||||
|
||||
### Authentication Issues
|
||||
```bash
|
||||
# Clear stored credentials
|
||||
rm ~/.local/share/gtd-terminal-tools/godspeed_config.json
|
||||
|
||||
# Use token instead of password
|
||||
export GODSPEED_TOKEN="your-token-here"
|
||||
```
|
||||
|
||||
### Sync Issues
|
||||
```bash
|
||||
# Check current status
|
||||
python -m src.cli.godspeed status
|
||||
|
||||
# Verify sync directory
|
||||
ls ~/Documents/Godspeed/
|
||||
|
||||
# Check metadata
|
||||
cat ~/.local/share/gtd-terminal-tools/godspeed/.godspeed_metadata.json
|
||||
```
|
||||
|
||||
### Common Problems
|
||||
|
||||
1. **"List ID not found"**: New lists created locally will put tasks in your Inbox
|
||||
2. **"Task not found"**: Tasks deleted in Godspeed won't sync back
|
||||
3. **Duplicate tasks**: Don't manually copy task lines between files (IDs must be unique)
|
||||
|
||||
## Development
|
||||
|
||||
### Testing
|
||||
Run the test suite:
|
||||
```bash
|
||||
python test_godspeed_sync.py
|
||||
```
|
||||
|
||||
### File Structure
|
||||
```
|
||||
src/services/godspeed/
|
||||
├── __init__.py # Package init
|
||||
├── client.py # Godspeed API client
|
||||
├── sync.py # Sync engine
|
||||
└── config.py # Configuration management
|
||||
|
||||
src/cli/
|
||||
└── godspeed.py # CLI interface
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This is part of the larger GTD Terminal Tools project. When contributing:
|
||||
|
||||
1. Follow the existing code style
|
||||
2. Add tests for new functionality
|
||||
3. Update this README for user-facing changes
|
||||
4. Test with the mock data before real API calls
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent GTD Terminal Tools project.
|
||||
808
PERFORMANCE_OPTIMIZATION_PLAN.md
Normal file
808
PERFORMANCE_OPTIMIZATION_PLAN.md
Normal file
@@ -0,0 +1,808 @@
|
||||
# LUK Performance Optimization & Cleanup Plan
|
||||
|
||||
**Created:** 2025-12-28
|
||||
**Priority:** High
|
||||
**Focus:** Mail app performance optimization and code quality improvements
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The LUK mail app is experiencing performance issues:
|
||||
- **Slow rendering** when scrolling through messages
|
||||
- **Laggy navigation** between messages
|
||||
- **High memory usage** during extended use
|
||||
- **Flickering** or unresponsive UI
|
||||
- **Poor startup time**
|
||||
|
||||
These issues make the app difficult to use for daily email management.
|
||||
|
||||
---
|
||||
|
||||
## Research: Textual Best Practices
|
||||
|
||||
### Key Principles for High-Performance Textual Apps
|
||||
|
||||
#### 1. **Use `compose()` Method, Not Manual Mounting**
|
||||
```python
|
||||
# ❌ BAD: Manual mounting in on_mount()
|
||||
def on_mount(self) -> None:
|
||||
self.mount(Header())
|
||||
self.mount(Sidebar())
|
||||
self.mount(Content())
|
||||
self.mount(Footer())
|
||||
|
||||
# ✅ GOOD: Use compose() for declarative UI
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Horizontal():
|
||||
yield Sidebar()
|
||||
yield Content()
|
||||
yield Footer()
|
||||
```
|
||||
|
||||
**Why:** `compose()` is called once and builds the widget tree efficiently. Manual mounting triggers multiple render cycles.
|
||||
|
||||
#### 2. **Lazy Load Content - Defer Until Needed**
|
||||
```python
|
||||
# ❌ BAD: Load everything at startup
|
||||
class MailApp(App):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.all_envelopes = load_all_envelopes() # Expensive!
|
||||
self.message_store = build_full_message_store() # Expensive!
|
||||
|
||||
# ✅ GOOD: Load on-demand with workers
|
||||
class MailApp(App):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._envelopes_cache = []
|
||||
self._loading = False
|
||||
|
||||
@work(exclusive=True)
|
||||
async def load_envelopes_lazy(self):
|
||||
if not self._envelopes_cache:
|
||||
envelopes = await fetch_envelopes() # Load in background
|
||||
self._envelopes_cache = envelopes
|
||||
self._update_list()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.load_envelopes_lazy()
|
||||
```
|
||||
|
||||
**Why:** Defers expensive operations until the app is ready and visible.
|
||||
|
||||
#### 3. **Use Reactive Properties Efficiently**
|
||||
```python
|
||||
# ❌ BAD: Re-compute values in methods
|
||||
def action_next(self):
|
||||
index = self.envelopes.index(self.current_envelope)
|
||||
self.current_message_index = index + 1 # Triggers re-render
|
||||
self.update_envelope_list_view() # Another re-render
|
||||
|
||||
# ✅ GOOD: Use reactive for automatic UI updates
|
||||
current_message_index: reactive[int] = reactive(-1)
|
||||
|
||||
@reactive_var.on_change
|
||||
def action_next(self):
|
||||
# Automatically triggers minimal re-render
|
||||
self.current_message_index += 1
|
||||
```
|
||||
|
||||
**Why:** Textual's reactive system only updates changed widgets, not the entire app.
|
||||
|
||||
#### 4. **Avoid String Concatenation in Loops for Updates**
|
||||
```python
|
||||
# ❌ BAD: Creates new strings every time
|
||||
def update_status(self):
|
||||
text = "Status: "
|
||||
for i, item in enumerate(items):
|
||||
text += f"{i+1}. {item.name}\n" # O(n²) string operations
|
||||
self.status.update(text)
|
||||
|
||||
# ✅ GOOD: Build list once
|
||||
def update_status(self):
|
||||
lines = [f"{i+1}. {item.name}" for i, item in enumerate(items)]
|
||||
text = "\n".join(lines) # O(n) operations
|
||||
self.status.update(text)
|
||||
```
|
||||
|
||||
**Why:** String concatenation is O(n²), while join is O(n).
|
||||
|
||||
#### 5. **Use Efficient List Widgets**
|
||||
```python
|
||||
# ❌ BAD: Creating custom widget for each item
|
||||
from textual.widgets import Static
|
||||
|
||||
def create_mail_list(items):
|
||||
for item in items:
|
||||
yield Static(item.subject) # N widgets = N render cycles
|
||||
|
||||
# ✅ GOOD: Use ListView with data binding
|
||||
from textual.widgets import ListView, ListItem
|
||||
|
||||
class MailApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield ListView(id="envelopes_list")
|
||||
|
||||
def update_list(self, items: list):
|
||||
list_view = self.query_one("#envelopes_list", ListView)
|
||||
list_view.clear()
|
||||
list_view.extend([ListItem(item.subject) for item in items]) # Efficient
|
||||
```
|
||||
|
||||
**Why:** `ListView` is optimized for lists with virtualization and pooling.
|
||||
|
||||
#### 6. **Debounce Expensive Operations**
|
||||
```python
|
||||
from textual.timer import Timer
|
||||
|
||||
# ❌ BAD: Update on every keypress
|
||||
def action_search(self, query: str):
|
||||
results = self.search_messages(query) # Expensive
|
||||
self.update_results(results)
|
||||
|
||||
# ✅ GOOD: Debounce search
|
||||
class MailApp(App):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._search_debounce = None
|
||||
|
||||
def action_search(self, query: str):
|
||||
if self._search_debounce:
|
||||
self._search_debounce.stop() # Cancel pending search
|
||||
self._search_debounce = Timer(
|
||||
0.3, # Wait 300ms
|
||||
self._do_search,
|
||||
query
|
||||
).start()
|
||||
|
||||
def _do_search(self, query: str) -> None:
|
||||
results = self.search_messages(query)
|
||||
self.update_results(results)
|
||||
```
|
||||
|
||||
**Why:** Avoids expensive recomputations for rapid user input.
|
||||
|
||||
#### 7. **Use `work()` Decorator for Background Tasks**
|
||||
```python
|
||||
from textual import work
|
||||
|
||||
class MailApp(App):
|
||||
@work(exclusive=True)
|
||||
async def load_message_content(self, message_id: int):
|
||||
"""Load message content without blocking UI."""
|
||||
content = await himalaya_client.get_message_content(message_id)
|
||||
self._update_content_display(content)
|
||||
```
|
||||
|
||||
**Why:** Background workers don't block the UI thread.
|
||||
|
||||
---
|
||||
|
||||
## Mail App Performance Issues Analysis
|
||||
|
||||
### Current Implementation Problems
|
||||
|
||||
#### 1. **Message List Rendering** (src/mail/app.py)
|
||||
```python
|
||||
# PROBLEM: Rebuilding entire list on navigation
|
||||
def action_next(self) -> None:
|
||||
if not self.current_message_index >= 0:
|
||||
return
|
||||
|
||||
next_id, next_idx = self.message_store.find_next_valid_id(
|
||||
self.current_message_index
|
||||
)
|
||||
if next_id is not None and next_idx is not None:
|
||||
self.current_message_id = next_id
|
||||
self.current_message_index = next_idx
|
||||
self._update_envelope_list_view() # ❌ Rebuilds entire list
|
||||
```
|
||||
|
||||
**Issue:** `_update_envelope_list_view()` rebuilds the entire message list on every navigation.
|
||||
|
||||
#### 2. **Envelope List Item Creation** (src/mail/widgets/EnvelopeListItem.py)
|
||||
```python
|
||||
# PROBLEM: Creating many widgets
|
||||
class EnvelopeListItem(CustomListItem):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._from_display, classes="from")
|
||||
yield Static(self._subject_display, classes="subject")
|
||||
yield Static(self._date_display, classes="date")
|
||||
# ❌ Each item creates 4+ Static widgets
|
||||
```
|
||||
|
||||
**Issue:** For 100 emails, this creates 400+ widgets. Should use a single widget.
|
||||
|
||||
#### 3. **Message Content Loading** (src/mail/widgets/ContentContainer.py)
|
||||
```python
|
||||
# PROBLEM: Blocking UI during content fetch
|
||||
def display_content(self, message_id: int):
|
||||
# ... loading logic
|
||||
format_type = "text" if self.current_mode == "markdown" else "html"
|
||||
self.content_worker = self.fetch_message_content(message_id, format_type)
|
||||
```
|
||||
|
||||
**Issue:** Content fetch may block UI. Should use `@work` decorator.
|
||||
|
||||
#### 4. **Envelope List Updates** (src/mail/app.py lines 920-950)
|
||||
```python
|
||||
# PROBLEM: Complex envelope list rebuilding
|
||||
def _update_envelope_list_view(self) -> None:
|
||||
grouped_envelopes = []
|
||||
for i, envelope in enumerate(self.message_store.envelopes):
|
||||
# ❌ Processing every envelope on every update
|
||||
if envelope.get("type") == "header":
|
||||
grouped_envelopes.append({"type": "header", "label": ...})
|
||||
else:
|
||||
# Complex formatting
|
||||
grouped_envelopes.append({...})
|
||||
|
||||
# ❌ Clearing and rebuilding entire list
|
||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||
envelopes_list.clear()
|
||||
envelopes_list.extend([...])
|
||||
```
|
||||
|
||||
**Issue:** Rebuilding entire list is expensive. Should only update changed items.
|
||||
|
||||
#### 5. **Folder/Account Count Updates** (src/mail/app.py)
|
||||
```python
|
||||
# PROBLEM: Re-computing counts on every change
|
||||
def _update_folder_list_view(self) -> None:
|
||||
for folder in self.folders:
|
||||
count = len([e for e in self.envelopes if e.get("folder") == folder]) # ❌ O(n) scan
|
||||
```
|
||||
|
||||
**Issue:** Counting all envelopes for each folder is expensive. Should cache counts.
|
||||
|
||||
---
|
||||
|
||||
## Optimization Plan
|
||||
|
||||
### Phase 1: Critical Performance Fixes (Week 1)
|
||||
|
||||
#### 1.1 Convert to `compose()` Pattern
|
||||
**File:** `src/mail/app.py`
|
||||
|
||||
**Current:** Manual widget mounting in `on_mount()` and other methods
|
||||
**Goal:** Use `compose()` for declarative UI building
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
# Before (BAD):
|
||||
def on_mount(self) -> None:
|
||||
# ... manual mounting
|
||||
|
||||
# After (GOOD):
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="app_container"):
|
||||
with Horizontal():
|
||||
# Left panel
|
||||
with Vertical(id="left_panel"):
|
||||
yield Static("Accounts", id="accounts_header")
|
||||
yield ListView(id="accounts_list")
|
||||
|
||||
yield Static("Folders", id="folders_header")
|
||||
yield ListView(id="folders_list")
|
||||
|
||||
# Middle panel
|
||||
with Vertical(id="middle_panel"):
|
||||
yield Static("Messages", id="messages_header")
|
||||
yield ListView(id="envelopes_list")
|
||||
|
||||
# Right panel
|
||||
yield ContentContainer(id="content_container")
|
||||
```
|
||||
|
||||
**Expected Impact:** 30-50% faster startup, reduced memory usage
|
||||
|
||||
#### 1.2 Implement Lazy Loading for Envelopes
|
||||
**File:** `src/mail/app.py`
|
||||
|
||||
**Current:** Load all envelopes at startup
|
||||
**Goal:** Load envelopes on-demand using background workers
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class MailApp(App):
|
||||
envelopes_loaded: reactive[bool] = reactive(False)
|
||||
_envelopes_cache: list[dict] = []
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# Start background loading
|
||||
self._load_initial_envelopes()
|
||||
|
||||
@work(exclusive=True, group="envelope_loading")
|
||||
async def _load_initial_envelopes(self):
|
||||
"""Load initial envelopes in background."""
|
||||
envelopes, success = await himalaya_client.list_envelopes()
|
||||
if success:
|
||||
self._envelopes_cache = envelopes
|
||||
self.envelopes_loaded = True
|
||||
self._update_envelope_list_view()
|
||||
|
||||
def _load_more_envelopes(self) -> None:
|
||||
"""Load more envelopes when scrolling."""
|
||||
pass # Implement lazy loading
|
||||
```
|
||||
|
||||
**Expected Impact:** 60-70% faster startup, perceived instant UI
|
||||
|
||||
#### 1.3 Optimize Message List Updates
|
||||
**File:** `src/mail/app.py`
|
||||
|
||||
**Current:** Rebuild entire list on navigation
|
||||
**Goal:** Only update changed items, use reactive properties
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class MailApp(App):
|
||||
current_message_index: reactive[int] = reactive(-1)
|
||||
|
||||
def action_next(self) -> None:
|
||||
"""Move to next message efficiently."""
|
||||
if not self.current_message_index >= 0:
|
||||
return
|
||||
|
||||
next_id, next_idx = self.message_store.find_next_valid_id(
|
||||
self.current_message_index
|
||||
)
|
||||
|
||||
if next_id is not None:
|
||||
# ✅ Only update reactive property
|
||||
self.current_message_index = next_idx
|
||||
# ✅ Let Textual handle the update
|
||||
# DON'T call _update_envelope_list_view()
|
||||
```
|
||||
|
||||
**Expected Impact:** 80-90% faster navigation, no UI flicker
|
||||
|
||||
#### 1.4 Use Background Workers for Content Loading
|
||||
**File:** `src/mail/widgets/ContentContainer.py`
|
||||
|
||||
**Current:** Blocking content fetch
|
||||
**Goal:** Use `@work` decorator for non-blocking loads
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class ContentContainer(ScrollableContainer):
|
||||
@work(exclusive=True)
|
||||
async def fetch_message_content(self, message_id: int, format_type: str) -> None:
|
||||
"""Fetch message content in background without blocking UI."""
|
||||
content, success = await himalaya_client.get_message_content(
|
||||
message_id,
|
||||
folder=self.current_folder,
|
||||
account=self.current_account
|
||||
)
|
||||
|
||||
if success and content:
|
||||
self._update_content(content)
|
||||
else:
|
||||
self.notify("Failed to fetch message content")
|
||||
```
|
||||
|
||||
**Expected Impact:** No UI blocking, smooth content transitions
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Code Quality & Architecture (Week 2)
|
||||
|
||||
#### 2.1 Refactor Message Store for Efficiency
|
||||
**File:** `src/mail/message_store.py`
|
||||
|
||||
**Current:** Linear searches, no caching
|
||||
**Goal:** Implement indexed lookups, cache counts
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class MessageStore:
|
||||
"""Optimized message store with caching."""
|
||||
|
||||
def __init__(self, envelopes: list[dict]):
|
||||
self.envelopes = envelopes
|
||||
self._index_cache = {} # O(1) lookup cache
|
||||
self._folder_counts = {} # Cached folder counts
|
||||
self._unread_counts = {} # Cached unread counts
|
||||
|
||||
# Build caches
|
||||
self._build_caches()
|
||||
|
||||
def _build_caches(self) -> None:
|
||||
"""Build lookup caches."""
|
||||
for idx, envelope in enumerate(self.envelopes):
|
||||
self._index_cache[envelope["id"]] = idx
|
||||
folder = envelope.get("folder", "INBOX")
|
||||
self._folder_counts[folder] = self._folder_counts.get(folder, 0) + 1
|
||||
if not envelope.get("flags", {}).get("seen", False):
|
||||
self._unread_counts[folder] = self._unread_counts.get(folder, 0) + 1
|
||||
|
||||
def get_index(self, message_id: int) -> int | None:
|
||||
"""Get envelope index in O(1)."""
|
||||
return self._index_cache.get(message_id)
|
||||
|
||||
def get_folder_count(self, folder: str) -> int:
|
||||
"""Get folder count in O(1)."""
|
||||
return self._folder_counts.get(folder, 0)
|
||||
|
||||
def get_unread_count(self, folder: str) -> int:
|
||||
"""Get unread count in O(1)."""
|
||||
return self._unread_counts.get(folder, 0)
|
||||
```
|
||||
|
||||
**Expected Impact:** O(1) lookups instead of O(n), instant count retrieval
|
||||
|
||||
#### 2.2 Consolidate Envelope List Item
|
||||
**File:** `src/mail/widgets/EnvelopeListItem.py`
|
||||
|
||||
**Current:** Multiple widgets per item
|
||||
**Goal:** Use single widget with custom rendering
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class EnvelopeListItem(CustomListItem):
|
||||
"""Optimized envelope list item using single widget."""
|
||||
|
||||
def __init__(self, envelope: dict, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.envelope = envelope
|
||||
|
||||
def render(self) -> RichText:
|
||||
"""Render as single RichText widget."""
|
||||
from rich.text import Text, Text as RichText
|
||||
|
||||
# Build RichText once (more efficient than multiple widgets)
|
||||
text = Text.assemble(
|
||||
self._from_display,
|
||||
" ",
|
||||
self._subject_display,
|
||||
" ",
|
||||
self._date_display,
|
||||
style="on" if self.envelope.get("flags", {}).get("seen") else "bold"
|
||||
)
|
||||
|
||||
return text
|
||||
```
|
||||
|
||||
**Expected Impact:** 70% reduction in widget count, faster rendering
|
||||
|
||||
#### 2.3 Add Memoization for Expensive Operations
|
||||
**File:** `src/mail/utils.py`
|
||||
|
||||
**Current:** Re-computing values
|
||||
**Goal:** Cache computed values
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
from functools import lru_cache
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def format_sender_name(envelope: dict) -> str:
|
||||
"""Format sender name with caching."""
|
||||
from_name = envelope.get("from", {}).get("name", "")
|
||||
from_addr = envelope.get("from", {}).get("addr", "")
|
||||
|
||||
if not from_name:
|
||||
return from_addr
|
||||
|
||||
# Truncate if too long
|
||||
if len(from_name) > 25:
|
||||
return from_name[:22] + "..."
|
||||
|
||||
return from_name
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def format_date(date_str: str) -> str:
|
||||
"""Format date with caching."""
|
||||
# Parse and format date string
|
||||
# Implementation...
|
||||
return formatted_date
|
||||
```
|
||||
|
||||
**Expected Impact:** Faster repeated operations, reduced CPU usage
|
||||
|
||||
#### 2.4 Add Notification Compression Caching
|
||||
**File:** `src/mail/notification_compressor.py`
|
||||
|
||||
**Current:** Re-compressing on every view
|
||||
**Goal:** Cache compressed results
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class NotificationCompressor:
|
||||
"""Compressor with caching for performance."""
|
||||
|
||||
def __init__(self, mode: str = "summary"):
|
||||
self.mode = mode
|
||||
self._compression_cache = {} # Cache compressed content
|
||||
|
||||
def compress(
|
||||
self,
|
||||
content: str,
|
||||
envelope: dict[str, Any]
|
||||
) -> tuple[str, NotificationType | None]:
|
||||
"""Compress with caching."""
|
||||
cache_key = f"{envelope['id']}:{self.mode}"
|
||||
|
||||
# Check cache
|
||||
if cache_key in self._compression_cache:
|
||||
return self._compression_cache[cache_key]
|
||||
|
||||
# Compress and cache
|
||||
compressed, notif_type = self._compress_impl(content, envelope)
|
||||
self._compression_cache[cache_key] = (compressed, notif_type)
|
||||
|
||||
return compressed, notif_type
|
||||
```
|
||||
|
||||
**Expected Impact:** Instant display for previously viewed notifications
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Advanced Optimizations (Week 3-4)
|
||||
|
||||
#### 3.1 Implement Virtual Scrolling
|
||||
**File:** `src/mail/app.py`
|
||||
|
||||
**Current:** Render all items in list
|
||||
**Goal:** Use ListView virtualization
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
def compose(self) -> ComposeResult:
|
||||
yield ListView(
|
||||
id="envelopes_list",
|
||||
initial_index=0,
|
||||
)
|
||||
|
||||
# ListView automatically virtualizes for performance
|
||||
```
|
||||
|
||||
**Expected Impact:** Constant time rendering regardless of list size
|
||||
|
||||
#### 3.2 Debounce User Input
|
||||
**File:** `src/mail/screens/SearchPanel.py`
|
||||
|
||||
**Current:** Search on every keystroke
|
||||
**Goal:** Debounce search with 300ms delay
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class SearchPanel(Screen):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._search_debounce = None
|
||||
|
||||
def on_input_changed(self, event) -> None:
|
||||
"""Debounce search input."""
|
||||
if self._search_debounce:
|
||||
self._search_debounce.stop()
|
||||
|
||||
self._search_debounce = Timer(
|
||||
0.3,
|
||||
self._perform_search,
|
||||
event.value
|
||||
).start()
|
||||
```
|
||||
|
||||
**Expected Impact:** 80% reduction in expensive search operations
|
||||
|
||||
#### 3.3 Use `dataclass` for Data Models
|
||||
**File:** `src/mail/notification_detector.py`
|
||||
|
||||
**Current:** Dict-based data access
|
||||
**Goal:** Use dataclasses for type safety and performance
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@dataclass
|
||||
class Envelope:
|
||||
"""Typed envelope data model."""
|
||||
id: int
|
||||
subject: str
|
||||
from_name: str
|
||||
from_addr: str
|
||||
date: str
|
||||
flags: dict = field(default_factory=dict)
|
||||
folder: str = "INBOX"
|
||||
```
|
||||
|
||||
**Expected Impact:** Type safety, better IDE support, faster attribute access
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Memory & Resource Management (Week 4)
|
||||
|
||||
#### 4.1 Implement Widget Pooling
|
||||
**File:** `src/mail/app.py`
|
||||
|
||||
**Current:** Creating new widgets constantly
|
||||
**Goal:** Reuse widgets to reduce allocations
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class WidgetPool:
|
||||
"""Pool for reusing widgets."""
|
||||
|
||||
def __init__(self, widget_class, max_size: int = 50):
|
||||
self.widget_class = widget_class
|
||||
self.pool = []
|
||||
self.max_size = max_size
|
||||
|
||||
def get(self):
|
||||
"""Get widget from pool or create new."""
|
||||
if self.pool:
|
||||
return self.pool.pop()
|
||||
return self.widget_class()
|
||||
|
||||
def release(self, widget) -> None:
|
||||
"""Return widget to pool."""
|
||||
if len(self.pool) < self.max_size:
|
||||
self.pool.append(widget)
|
||||
```
|
||||
|
||||
**Expected Impact:** Reduced garbage collection, smoother scrolling
|
||||
|
||||
#### 4.2 Implement Content Pagination
|
||||
**File:** `src/mail/widgets/ContentContainer.py`
|
||||
|
||||
**Current:** Load full content
|
||||
**Goal:** Load content in chunks for large emails
|
||||
|
||||
**Changes:**
|
||||
```python
|
||||
class ContentContainer(ScrollableContainer):
|
||||
PAGE_SIZE = 500 # Characters per page
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._pages: list[str] = []
|
||||
self._current_page = 0
|
||||
|
||||
def _load_next_page(self) -> None:
|
||||
"""Load next page of content when scrolling."""
|
||||
if self._current_page + 1 < len(self._pages):
|
||||
self._current_page += 1
|
||||
self.content.update(self._pages[self._current_page])
|
||||
```
|
||||
|
||||
**Expected Impact:** Faster initial load, smoother scrolling for large emails
|
||||
|
||||
#### 4.3 Clean Up Unused Imports
|
||||
**Files:** All Python files in `src/mail/`
|
||||
|
||||
**Current:** Unused imports, circular dependencies
|
||||
**Goal:** Remove all unused code
|
||||
|
||||
**Changes:**
|
||||
- Run `ruff check` and fix all unused imports
|
||||
- Remove circular dependencies
|
||||
- Clean up `__all__` exports
|
||||
- Optimize import order
|
||||
|
||||
**Expected Impact:** Faster import time, smaller memory footprint
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Week 1: Critical Performance Fixes
|
||||
1. Day 1-2: Implement `compose()` pattern
|
||||
2. Day 3-4: Lazy loading for envelopes
|
||||
3. Day 5: Optimize message list navigation
|
||||
4. Day 6-7: Background workers for content loading
|
||||
5. Day 8-10: Testing and benchmarking
|
||||
|
||||
### Week 2: Code Quality
|
||||
1. Day 1-2: Refactor MessageStore with caching
|
||||
2. Day 3-4: Consolidate EnvelopeListItem
|
||||
3. Day 5: Add memoization utilities
|
||||
4. Day 6-7: Notification compression caching
|
||||
5. Day 8-10: Code review and cleanup
|
||||
|
||||
### Week 3: Advanced Optimizations
|
||||
1. Day 1-3: Virtual scrolling implementation
|
||||
2. Day 4-5: Debounce user input
|
||||
3. Day 6-7: Data model refactoring
|
||||
4. Day 8-10: Performance testing
|
||||
|
||||
### Week 4: Memory Management
|
||||
1. Day 1-3: Widget pooling
|
||||
2. Day 4-5: Content pagination
|
||||
3. Day 6-7: Import cleanup
|
||||
4. Day 8-10: Final optimization and polish
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Performance Targets
|
||||
- **Startup Time:** < 1 second (currently: 3-5 seconds)
|
||||
- **Navigation Latency:** < 50ms between messages (currently: 200-500ms)
|
||||
- **List Rendering:** < 100ms for 100 items (currently: 500-1000ms)
|
||||
- **Memory Usage:** < 100MB for 1000 emails (currently: 300-500MB)
|
||||
- **Frame Rate:** 60 FPS during navigation (currently: 10-20 FPS)
|
||||
|
||||
### Code Quality Targets
|
||||
- **Test Coverage:** > 80% (currently: ~10%)
|
||||
- **Ruff Warnings:** 0 critical, < 5 style warnings
|
||||
- **Import Cleanup:** 100% of files cleaned
|
||||
- **Type Coverage:** 100% typed
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Performance Benchmarking
|
||||
```python
|
||||
# benchmark_performance.py
|
||||
import time
|
||||
import tracemalloc
|
||||
from src.mail.app import EmailViewerApp
|
||||
|
||||
def benchmark_startup():
|
||||
"""Benchmark app startup time."""
|
||||
tracemalloc.start()
|
||||
|
||||
start = time.time()
|
||||
app = EmailViewerApp()
|
||||
app.run()
|
||||
|
||||
end = time.time()
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
|
||||
print(f"Startup Time: {end - start:.3f}s")
|
||||
print(f"Memory Usage: {peak / 1024 / 1024:.2f} MB")
|
||||
|
||||
def benchmark_navigation():
|
||||
"""Benchmark message navigation."""
|
||||
app = EmailViewerApp()
|
||||
# ... measure navigation timing
|
||||
|
||||
timings = []
|
||||
for i in range(100):
|
||||
start = time.time()
|
||||
app.action_next()
|
||||
end = time.time()
|
||||
timings.append(end - start)
|
||||
|
||||
print(f"Average Navigation Time: {sum(timings) / len(timings) * 1000:.1f}ms")
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
- Test with 100, 1000, and 10000 messages
|
||||
- Measure memory usage over time
|
||||
- Test with slow network conditions
|
||||
- Test on different terminal sizes
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Textual Documentation
|
||||
- **Main Docs:** https://textual.textualize.io/
|
||||
- **Widget Guide:** https://textual.textualize.io/guide/widgets/
|
||||
- **Best Practices:** https://textual.textualize.io/blog/
|
||||
- **Performance Guide:** https://textual.textualize.io/blog/2024/12/12/algorithms-for-high-performance-terminal-apps/
|
||||
|
||||
### Python Performance Guides
|
||||
- **Python Performance Guide:** https://www.fyld.pt/blog/python-performance-guide-writing-code-2025
|
||||
- **Optimization Techniques:** https://analyticsvidhya.com/blog/2024/01/optimize-python-code-for-high-speed-execution
|
||||
|
||||
### Similar Projects
|
||||
- **Rich Console Examples:** https://github.com/Textualize/rich
|
||||
- **Prompt Toolkit:** https://github.com/prompt-toolkit/python-prompt-toolkit
|
||||
- **Urwid:** https://github.com/urwid/urwid
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This plan focuses on the mail app but principles apply to calendar and tasks apps
|
||||
- All changes should be backward compatible
|
||||
- Run performance benchmarks before and after each phase
|
||||
- Document any Textual-specific optimizations discovered during implementation
|
||||
675
PROJECT_PLAN.md
Normal file
675
PROJECT_PLAN.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# LUK Project Plan
|
||||
|
||||
This document outlines planned improvements across the LUK applications (Mail, Calendar, Sync, Tasks).
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Tasks App - Table Not Displaying (FIXED)
|
||||
**Priority:** Critical
|
||||
**File:** `src/tasks/app.py`
|
||||
|
||||
The tasks app was not showing the task table due to a CSS grid layout issue. The grid layout with mixed `dock` and `grid` positioning caused the main content area to have 0 height.
|
||||
|
||||
**Fix:** Changed from grid layout to horizontal layout with docked header/footer.
|
||||
|
||||
---
|
||||
|
||||
## Sync App
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
#### 1. Parallelize Message Downloads
|
||||
**Priority:** High
|
||||
**Files:** `src/services/microsoft_graph/mail.py`, `src/services/microsoft_graph/client.py`
|
||||
|
||||
Currently, message downloads are sequential (`for` loop with `await`). This is a significant bottleneck.
|
||||
|
||||
**Current code pattern:**
|
||||
```python
|
||||
for msg_id in message_ids:
|
||||
content = await client.get_message(msg_id)
|
||||
await save_to_maildir(content)
|
||||
```
|
||||
|
||||
**Proposed changes:**
|
||||
1. Increase semaphore limit in `client.py` from 2 to 5 concurrent HTTP requests
|
||||
2. Batch parallel downloads using `asyncio.gather()` with batches of 5-10 messages
|
||||
3. Batch sync state writes every N messages instead of after each message (currently an I/O bottleneck in archive sync)
|
||||
|
||||
**Example implementation:**
|
||||
```python
|
||||
BATCH_SIZE = 5
|
||||
|
||||
async def fetch_batch(batch_ids):
|
||||
return await asyncio.gather(*[client.get_message(id) for id in batch_ids])
|
||||
|
||||
for i in range(0, len(message_ids), BATCH_SIZE):
|
||||
batch = message_ids[i:i + BATCH_SIZE]
|
||||
results = await fetch_batch(batch)
|
||||
for content in results:
|
||||
await save_to_maildir(content)
|
||||
# Write sync state every batch instead of every message
|
||||
save_sync_state()
|
||||
```
|
||||
|
||||
#### 2. Optimize Maildir Writes
|
||||
**Priority:** Medium
|
||||
**File:** `src/utils/mail_utils/maildir.py`
|
||||
|
||||
The `save_mime_to_maildir_async` function is a potential bottleneck. Consider:
|
||||
- Batching file writes
|
||||
- Using thread pool for I/O operations
|
||||
|
||||
---
|
||||
|
||||
### CLI Improvements
|
||||
|
||||
#### 3. Default to TUI Mode
|
||||
**Priority:** Medium
|
||||
**File:** `src/cli/sync.py`
|
||||
|
||||
Currently `luk sync` requires subcommands. Change to:
|
||||
- `luk sync` → Opens TUI (interactive mode) by default
|
||||
- `luk sync --once` / `luk sync -1` → One-shot sync
|
||||
- `luk sync --daemon` / `luk sync -d` → Daemon mode
|
||||
- `luk sync status` → Show sync status
|
||||
|
||||
---
|
||||
|
||||
### UI Consistency
|
||||
|
||||
#### 4. Navigation and Styling
|
||||
**Priority:** Low
|
||||
**File:** `src/cli/sync_dashboard.py`
|
||||
|
||||
- Add `j`/`k` keybindings for list navigation (vim-style)
|
||||
- Use `border: round` in TCSS for consistency with other apps
|
||||
- Add `.border_title` styling for list containers
|
||||
|
||||
#### 5. Notifications Toggle
|
||||
**Priority:** Low
|
||||
**Files:** `src/cli/sync_dashboard.py`, `src/utils/notifications.py`
|
||||
|
||||
Add a UI switch to enable/disable desktop notifications during sync.
|
||||
|
||||
---
|
||||
|
||||
## Inter-Process Communication (IPC)
|
||||
|
||||
### Overview
|
||||
**Priority:** High
|
||||
**Goal:** Enable real-time updates between apps when data changes:
|
||||
- Mail app refreshes when sync downloads new messages
|
||||
- Tasks app refreshes when syncing or when tasks are added from mail app
|
||||
- Calendar app reloads when sync updates events
|
||||
|
||||
### Platform Options
|
||||
|
||||
#### macOS Options
|
||||
|
||||
1. **Distributed Notifications (NSDistributedNotificationCenter)**
|
||||
- Native macOS IPC mechanism
|
||||
- Simple pub/sub model
|
||||
- Lightweight, no server needed
|
||||
- Python: Use `pyobjc` (`Foundation.NSDistributedNotificationCenter`)
|
||||
- Example: `CFNotificationCenterPostNotification()`
|
||||
|
||||
2. **Unix Domain Sockets**
|
||||
- Works on both macOS and Linux
|
||||
- Requires a listener process (daemon or embedded in sync)
|
||||
- More complex but more flexible
|
||||
- Can send structured data (JSON messages)
|
||||
|
||||
3. **Named Pipes (FIFO)**
|
||||
- Simple, works on both platforms
|
||||
- One-way communication
|
||||
- Less suitable for bidirectional messaging
|
||||
|
||||
4. **File-based Signaling**
|
||||
- Write a "dirty" file that apps watch via `watchdog` or `fsevents`
|
||||
- Simple but has latency
|
||||
- Works cross-platform
|
||||
|
||||
#### Linux Options
|
||||
|
||||
1. **D-Bus**
|
||||
- Standard Linux IPC
|
||||
- Python: Use `dbus-python` or `pydbus`
|
||||
- Powerful but more complex
|
||||
- Not available on macOS by default
|
||||
|
||||
2. **Unix Domain Sockets**
|
||||
- Same as macOS, fully compatible
|
||||
- Recommended for cross-platform compatibility
|
||||
|
||||
3. **systemd Socket Activation**
|
||||
- If running as a systemd service
|
||||
- Clean integration with Linux init
|
||||
|
||||
### Recommended Approach
|
||||
|
||||
Use **Unix Domain Sockets** for cross-platform compatibility:
|
||||
|
||||
```python
|
||||
# Socket path
|
||||
SOCKET_PATH = Path.home() / ".cache" / "luk" / "ipc.sock"
|
||||
|
||||
# Message types
|
||||
class IPCMessage:
|
||||
MAIL_UPDATED = "mail.updated"
|
||||
TASKS_UPDATED = "tasks.updated"
|
||||
CALENDAR_UPDATED = "calendar.updated"
|
||||
|
||||
# Sync daemon sends notifications
|
||||
async def notify_mail_updated(folder: str):
|
||||
await send_ipc_message({"type": IPCMessage.MAIL_UPDATED, "folder": folder})
|
||||
|
||||
# Mail app listens
|
||||
async def listen_for_updates():
|
||||
async for message in ipc_listener():
|
||||
if message["type"] == IPCMessage.MAIL_UPDATED:
|
||||
self.load_messages()
|
||||
```
|
||||
|
||||
**Implementation files:**
|
||||
- `src/utils/ipc.py` - IPC client/server utilities
|
||||
- `src/cli/sync_daemon.py` - Add notification sending
|
||||
- `src/mail/app.py` - Add listener for mail updates
|
||||
- `src/tasks/app.py` - Add listener for task updates
|
||||
- `src/calendar/app.py` - Add listener for calendar updates
|
||||
|
||||
---
|
||||
|
||||
## Calendar App
|
||||
|
||||
### Visual Improvements
|
||||
|
||||
#### 1. Current Time Hour Line Styling
|
||||
**Priority:** High
|
||||
**File:** `src/calendar/widgets/WeekGrid.py`
|
||||
|
||||
The current time indicator hour line should have a subtle contrasting background color to make it more visible.
|
||||
|
||||
#### 2. Cursor Hour Header Highlighting
|
||||
**Priority:** Medium
|
||||
**File:** `src/calendar/widgets/WeekGrid.py`
|
||||
|
||||
The hour header at cursor position should have a brighter background, similar to how the day header is highlighted when selected.
|
||||
|
||||
---
|
||||
|
||||
### Layout Improvements
|
||||
|
||||
#### 3. Responsive Detail Panel
|
||||
**Priority:** Medium
|
||||
**Files:** `src/calendar/app.py`, `src/calendar/widgets/`
|
||||
|
||||
When the terminal is wider than X characters (e.g., 120), show a side-docked detail panel for the selected event instead of a modal/overlay.
|
||||
|
||||
#### 4. Sidebar Mini-Calendar
|
||||
**Priority:** Medium
|
||||
**Files:** `src/calendar/app.py`, `src/calendar/widgets/MonthCalendar.py`
|
||||
|
||||
When the sidebar is toggled on, display a mini-calendar in the top-left corner showing:
|
||||
- Current day highlighted
|
||||
- The week(s) currently visible in the main WeekGrid pane
|
||||
- Click/navigate to jump to a specific date
|
||||
|
||||
**Implementation:**
|
||||
- Reuse existing `MonthCalendar` widget in compact mode
|
||||
- Add reactive property to sync selected week with main pane
|
||||
- Add to sidebar composition when toggled
|
||||
|
||||
---
|
||||
|
||||
### Microsoft Graph Integration
|
||||
|
||||
#### 5. Calendar Invites Sidebar
|
||||
**Priority:** Medium
|
||||
**Files:** `src/calendar/app.py`, `src/services/microsoft_graph/calendar.py`
|
||||
|
||||
Display a list of pending calendar invites from Microsoft Graph API in a sidebar panel:
|
||||
- List pending invites (meeting requests)
|
||||
- Show invite details (organizer, time, subject)
|
||||
- Accept/Decline/Tentative actions
|
||||
- No sync needed - fetch on-demand from API
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /me/calendar/events?$filter=responseStatus/response eq 'notResponded'`
|
||||
- `POST /me/events/{id}/accept`
|
||||
- `POST /me/events/{id}/decline`
|
||||
- `POST /me/events/{id}/tentativelyAccept`
|
||||
|
||||
**UI:**
|
||||
- Toggle with keybinding (e.g., `i` for invites)
|
||||
- Sidebar shows list of pending invites
|
||||
- Detail view shows full invite info
|
||||
- Action buttons/keys for response
|
||||
|
||||
---
|
||||
|
||||
### Search Feature
|
||||
|
||||
#### 6. Calendar Search
|
||||
**Priority:** Medium
|
||||
**Files:** `src/calendar/app.py`, `src/services/khal/client.py` (if khal supports search)
|
||||
|
||||
Add search functionality if the underlying backend (khal) supports it:
|
||||
- `/` keybinding to open search input
|
||||
- Search by event title, description, location
|
||||
- Display search results in a modal or replace main view
|
||||
- Navigate to selected event
|
||||
|
||||
**Check:** Does `khal search` command exist?
|
||||
|
||||
---
|
||||
|
||||
### Help System
|
||||
|
||||
#### 7. Help Toast (Keep Current Implementation)
|
||||
**Priority:** Low
|
||||
**File:** `src/calendar/app.py`
|
||||
|
||||
The `?` key shows a help toast using `self.notify()`. This pattern should be implemented in other apps (Mail, Tasks, Sync) for consistency.
|
||||
|
||||
---
|
||||
|
||||
## Mail App
|
||||
|
||||
### Layout Fixes
|
||||
|
||||
#### 1. Remove Envelope Icon/Checkbox Gap
|
||||
**Priority:** High
|
||||
**File:** `src/mail/widgets/EnvelopeListItem.py`
|
||||
|
||||
There's a 1-character space between the envelope icon and checkbox that should be removed for tighter layout.
|
||||
|
||||
---
|
||||
|
||||
### Theme Improvements
|
||||
|
||||
#### 2. Replace Hardcoded RGB Colors
|
||||
**Priority:** High
|
||||
**File:** `src/mail/email_viewer.tcss`
|
||||
|
||||
Multiple hardcoded RGB values should use Textual theme variables for better theming support:
|
||||
|
||||
| Line | Current | Replacement |
|
||||
|------|---------|-------------|
|
||||
| 6 | `border: round rgb(117, 106, 129)` | `border: round $border` |
|
||||
| 46 | `background: rgb(55, 53, 57)` | `background: $surface` |
|
||||
| 52, 57 | RGB label colors | Theme variables |
|
||||
| 69 | `background: rgb(64, 62, 65)` | `background: $panel` |
|
||||
| 169, 225, 228, 272, 274 | Various RGB colors | Theme variables |
|
||||
|
||||
---
|
||||
|
||||
### Keybindings
|
||||
|
||||
#### 3. Add Refresh Keybinding
|
||||
**Priority:** Medium
|
||||
**File:** `src/mail/app.py`
|
||||
|
||||
Add `r` keybinding to refresh/reload the message list.
|
||||
|
||||
#### 4. Add Mark Read/Unread Action
|
||||
**Priority:** Medium
|
||||
**Files:** `src/mail/app.py`, `src/mail/actions/` (new file)
|
||||
|
||||
Add action to toggle read/unread status on selected message(s).
|
||||
|
||||
---
|
||||
|
||||
### Search Feature
|
||||
|
||||
#### 5. Mail Search
|
||||
**Priority:** Medium
|
||||
**Files:** `src/mail/app.py`, backend integration
|
||||
|
||||
Add search functionality if the underlying mail backend supports it:
|
||||
- `/` keybinding to open search input
|
||||
- Search by subject, sender, body
|
||||
- Display results in message list
|
||||
- Check: Does himalaya or configured backend support search?
|
||||
|
||||
---
|
||||
|
||||
### UI Enhancements
|
||||
|
||||
#### 6. Folder Message Counts
|
||||
**Priority:** Medium
|
||||
**Files:** `src/mail/app.py`, `src/mail/widgets/`
|
||||
|
||||
Display total message count next to each folder name (e.g., "Inbox (42)").
|
||||
|
||||
#### 7. Sort Setting in Config/UI
|
||||
**Priority:** Low
|
||||
**Files:** `src/mail/config.py`, `src/mail/app.py`
|
||||
|
||||
Add configurable sort order (date, sender, subject) with UI toggle.
|
||||
|
||||
---
|
||||
|
||||
### Message Display
|
||||
|
||||
#### 8. URL Compression in Markdown View
|
||||
**Priority:** Medium
|
||||
**Files:** `src/mail/widgets/ContentContainer.py`, `src/mail/screens/LinkPanel.py`
|
||||
|
||||
Compress long URLs in the markdown view to ~50 characters with a nerdfont icon. The `_shorten_url` algorithm in `LinkPanel.py` can be reused.
|
||||
|
||||
**Considerations:**
|
||||
- Cache processed markdown to avoid re-processing on scroll
|
||||
- Store URL mapping for click handling
|
||||
|
||||
#### 9. Remove Emoji from Border Title
|
||||
**Priority:** Low
|
||||
**File:** `src/mail/widgets/ContentContainer.py` or `EnvelopeHeader.py`
|
||||
|
||||
Remove the envelope emoji prefix before message ID in border titles.
|
||||
|
||||
#### 10. Enhance Subject Styling
|
||||
**Priority:** Medium
|
||||
**File:** `src/mail/widgets/EnvelopeHeader.py`
|
||||
|
||||
- Move subject line to the top of the header
|
||||
- Make it bolder/brighter for better visual hierarchy
|
||||
|
||||
---
|
||||
|
||||
## Tasks App
|
||||
|
||||
### Search Feature
|
||||
|
||||
#### 1. Task Search
|
||||
**Priority:** Medium
|
||||
**Files:** `src/tasks/app.py`, `src/services/dstask/client.py`
|
||||
|
||||
Add search functionality:
|
||||
- `/` keybinding to open search input
|
||||
- Search by task summary, notes, project, tags
|
||||
- Display matching tasks
|
||||
- Check: dstask likely supports filtering which can be used for search
|
||||
|
||||
**Implementation:**
|
||||
- Add search input widget (TextInput)
|
||||
- Filter tasks locally or via dstask command
|
||||
- Update table to show only matching tasks
|
||||
- Clear search with Escape
|
||||
|
||||
---
|
||||
|
||||
### Help System
|
||||
|
||||
#### 2. Implement Help Toast
|
||||
**Priority:** Low
|
||||
**File:** `src/tasks/app.py`
|
||||
|
||||
Add `?` keybinding to show help toast (matching Calendar app pattern).
|
||||
**Note:** This is already implemented in the current code.
|
||||
|
||||
---
|
||||
|
||||
## Cross-App Improvements
|
||||
|
||||
### 1. Consistent Help System
|
||||
Implement `?` key help toast in all apps using `self.notify()`:
|
||||
- Mail: `src/mail/app.py`
|
||||
- Tasks: `src/tasks/app.py` (already has it)
|
||||
- Sync: `src/cli/sync_dashboard.py`
|
||||
|
||||
### 2. Consistent Navigation
|
||||
Add vim-style `j`/`k` navigation to all list views across apps.
|
||||
|
||||
### 3. Consistent Border Styling
|
||||
Use `border: round` and `.border_title` styling consistently in all TCSS files.
|
||||
|
||||
### 4. Consistent Search Interface
|
||||
Implement `/` keybinding for search across all apps with similar UX:
|
||||
- `/` opens search input
|
||||
- Enter executes search
|
||||
- Escape clears/closes search
|
||||
- Results displayed in main view or filtered list
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Critical/High Priority
|
||||
1. ~~Tasks App: Fix table display~~ (DONE)
|
||||
2. ~~Sync: Parallelize message downloads~~ (DONE - connection pooling + batch size increase)
|
||||
3. ~~Mail: Replace hardcoded RGB colors~~ (DONE - already using theme variables)
|
||||
4. ~~Mail: Remove envelope icon/checkbox gap~~ (DONE)
|
||||
5. ~~Calendar: Current time hour line styling~~ (DONE - added surface background)
|
||||
6. ~~IPC: Implement cross-app refresh notifications~~ (DONE)
|
||||
|
||||
### Phase 2: Medium Priority
|
||||
1. ~~Sync: Default to TUI mode~~ (DONE - already implemented)
|
||||
2. ~~Calendar: Cursor hour header highlighting~~ (DONE)
|
||||
3. Calendar: Responsive detail panel
|
||||
4. Calendar: Sidebar mini-calendar
|
||||
5. Calendar: Calendar invites sidebar
|
||||
6. ~~Mail: Add refresh keybinding~~ (DONE - `r` key)
|
||||
7. ~~Mail: Add mark read/unread action~~ (DONE - `u` key)
|
||||
8. ~~Mail: Folder message counts~~ (DONE)
|
||||
8. ~~Mail: URL compression in markdown view~~ (DONE)
|
||||
9. ~~Mail: Enhance subject styling~~ (DONE)
|
||||
10. Mail: Search feature
|
||||
11. ~~Tasks: Search feature~~ (DONE - `/` key with live filtering)
|
||||
12. ~~Calendar: Search feature~~ (DONE - `/` key using khal search)
|
||||
|
||||
### Phase 3: Low Priority
|
||||
1. Sync: UI consistency (j/k navigation, borders)
|
||||
2. Sync: Notifications toggle
|
||||
3. Calendar: Help toast (already implemented, replicate to other apps)
|
||||
4. Mail: Remove emoji from border title
|
||||
5. Mail: Sort setting in config/UI
|
||||
6. Cross-app: Consistent help, navigation, and styling
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### IPC Implementation Details
|
||||
|
||||
For macOS-first with Linux compatibility:
|
||||
|
||||
```python
|
||||
# src/utils/ipc.py
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
SOCKET_PATH = Path.home() / ".cache" / "luk" / "ipc.sock"
|
||||
|
||||
class IPCServer:
|
||||
"""Server for sending notifications to listening apps."""
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Send message to all connected clients."""
|
||||
pass
|
||||
|
||||
class IPCClient:
|
||||
"""Client for receiving notifications in apps."""
|
||||
|
||||
async def listen(self, callback):
|
||||
"""Listen for messages and call callback."""
|
||||
pass
|
||||
```
|
||||
|
||||
### Backend Search Capabilities
|
||||
|
||||
| Backend | Search Support |
|
||||
|---------|---------------|
|
||||
| dstask | Filter by project/tag, summary search via shell |
|
||||
| himalaya | Check `himalaya search` command |
|
||||
| khal | Check `khal search` command |
|
||||
| Microsoft Graph | Full text search via `$search` parameter |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All UI improvements should be tested with different terminal sizes
|
||||
- Theme changes should be tested with multiple Textual themes
|
||||
- Performance improvements should include before/after benchmarks
|
||||
- New keybindings should be documented in each app's help toast
|
||||
- IPC should gracefully handle missing socket (apps work standalone)
|
||||
- Search should be responsive and not block UI
|
||||
|
||||
---
|
||||
|
||||
## Mail Rendering Improvements
|
||||
|
||||
### Smart Notification Email Compression (COMPLETED)
|
||||
**Priority:** High
|
||||
**Files:** `src/mail/notification_detector.py`, `src/mail/notification_compressor.py`, `src/mail/config.py`
|
||||
|
||||
**Problem:** Transactional notification emails from tools like Renovate, Jira, Confluence, and Datadog are hard to parse and display poorly in plain text terminal environments.
|
||||
|
||||
**Solution:** Implemented intelligent notification detection and compression system:
|
||||
|
||||
1. **Notification Type Detection**
|
||||
- Automatically detects emails from:
|
||||
- GitLab (pipelines, MRs, mentions)
|
||||
- GitHub (PRs, issues, reviews)
|
||||
- Jira (issues, status changes)
|
||||
- Confluence (page updates, comments)
|
||||
- Datadog (alerts, incidents)
|
||||
- Renovate (dependency updates)
|
||||
- General notifications (digests, automated emails)
|
||||
- Uses sender domain and subject pattern matching
|
||||
- Distinguishes between similar services (e.g., Jira vs Confluence)
|
||||
|
||||
2. **Structured Summary Extraction**
|
||||
- Type-specific extractors for each platform
|
||||
- Extracts: IDs, titles, status changes, action items
|
||||
- Preserves important links for quick access
|
||||
|
||||
3. **Terminal-Friendly Formatting**
|
||||
- Two compression modes:
|
||||
- `summary`: Brief one-page view
|
||||
- `detailed`: Structured table format
|
||||
- Markdown rendering with icons and clear hierarchy
|
||||
- Shows notification type, key details, actions
|
||||
- Footer indicates compressed view (toggle to full with `m`)
|
||||
|
||||
4. **Configuration Options**
|
||||
```toml
|
||||
[content_display]
|
||||
compress_notifications = true
|
||||
notification_compression_mode = "summary" # "summary", "detailed", or "off"
|
||||
```
|
||||
|
||||
5. **Features**
|
||||
- Zero-dependency (no LLM required, fast)
|
||||
- Rule-based for reliability
|
||||
- Extensible to add new notification types
|
||||
- Preserves original content for full view toggle
|
||||
- Type-specific icons using NerdFont glyphs
|
||||
|
||||
**Implementation Details:**
|
||||
- `NotificationType` dataclass for type definitions
|
||||
- `is_notification_email()` for detection
|
||||
- `classify_notification()` for type classification
|
||||
- `extract_notification_summary()` for structured data
|
||||
- `NotificationCompressor` for formatting
|
||||
- `DetailedCompressor` for extended summaries
|
||||
- Integrated with `ContentContainer` widget
|
||||
- 13 unit tests covering all notification types
|
||||
|
||||
**Future Enhancements:**
|
||||
- Add LLM-based summarization option (opt-in)
|
||||
- Learn notification patterns from user feedback
|
||||
- Custom notification type definitions
|
||||
- Action-based email filtering (e.g., "archive all Renovate emails")
|
||||
- Bulk actions on notification emails (archive all, mark all read)
|
||||
- Notification grouping in envelope list
|
||||
- Statistics on notification emails (counts by type, frequency)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Note-taking integration and more cross-app integrations
|
||||
|
||||
I like the `tdo` (https://github.com/2KAbhishek/tdo) program for managing markdown notes with fzf and my terminal text editor. It makes it easy to have a "today" note and a list of todos. Perhaps we can gather those todos from the text files in the $NOTES_DIR and put them into the task list during regular sync - and when users mark a task complete the sync can find the text file it was in and mark it complete on that line of markdown text. We need a little ore features for the related annotations then, because when I press `n` in the notes app we would want to actually open the textfile that task came from, not just make another annotation. So we would need a special cross-linking format for knowing which tasks came from a $NOTES_DIR sync. And then we can work on the same IPC scenario for tasks that were created in the email app. Then those tasks should be connected so that when the user views the notes on those tasks they see the whole email. That would be simpe enough if we just copied the email text into an annotation. But maybe we need a way to actually change the selected message ID in the mail app if it's open. So if the user activates the "open" feature on a task the related email will be displayed in the other terminal window where the user has mail open.
|
||||
|
||||
---
|
||||
|
||||
## Polish for release and Auth
|
||||
|
||||
- Authentication isn't working if the user is in a different directory than expected. Store the auth token in a ~/.local directory so it's consistently availabe in every dir.
|
||||
- Setup scripts, look at popular modern python CLI or TUI projects and create an install or first run script that works for most scenarios, using `uv` or `pip` to install globally for users, or maybe get a homebrew installation to work? Get users to set up himalaya and khal and dstask but ask them for their choice once we have other backends available.
|
||||
- Documentation files in the repo, and documentation site to publish.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Library Updates & Python Version Review
|
||||
|
||||
### Priority: Medium (Scheduled Review)
|
||||
|
||||
Periodically review the latest releases of heavily-used libraries to identify:
|
||||
- Bug fixes that address issues we've encountered
|
||||
- New features that could improve the codebase
|
||||
- Deprecation warnings that need to be addressed
|
||||
- Security updates
|
||||
|
||||
### Key Libraries to Review
|
||||
|
||||
| Library | Current Use | Review Focus |
|
||||
|---------|-------------|--------------|
|
||||
| **Textual** | All TUI apps | New widgets, performance improvements, theming changes, CSS features |
|
||||
| **aiohttp** | Microsoft Graph API client | Async improvements, connection pooling |
|
||||
| **msal** | Microsoft authentication | Token caching, auth flow improvements |
|
||||
| **rich** | Console output (via Textual) | New formatting options |
|
||||
| **orjson** | Fast JSON parsing | Performance improvements |
|
||||
| **pyobjc** (macOS) | Notifications | API changes, compatibility |
|
||||
|
||||
### Textual Changelog Review Checklist
|
||||
|
||||
When reviewing Textual releases, check for:
|
||||
1. **New widgets** - Could replace custom implementations
|
||||
2. **CSS features** - New selectors, pseudo-classes, properties
|
||||
3. **Theming updates** - New theme variables, design token changes
|
||||
4. **Performance** - Rendering optimizations, memory improvements
|
||||
5. **Breaking changes** - Deprecated APIs, signature changes
|
||||
6. **Worker improvements** - Background task handling
|
||||
|
||||
### Python Version Upgrade
|
||||
|
||||
#### Current Status
|
||||
- Check `.python-version` and `pyproject.toml` for current Python version
|
||||
- Evaluate upgrade to Python 3.13 or 3.14 when stable
|
||||
|
||||
#### Python 3.13 Features to Consider
|
||||
- Improved error messages
|
||||
- Type system enhancements (`typing` module improvements)
|
||||
- Performance optimizations (PEP 709 - inline comprehensions)
|
||||
|
||||
#### Python 3.14 Considerations
|
||||
- **Status:** Currently in alpha/beta (as of Dec 2024)
|
||||
- **Expected stable release:** October 2025
|
||||
- **Recommendation:** Wait for stable release before adopting
|
||||
- **Pre-release testing:** Can test compatibility in CI/CD before adoption
|
||||
|
||||
#### Upgrade Checklist
|
||||
1. [ ] Review Python release notes for breaking changes
|
||||
2. [ ] Check library compatibility (especially `pyobjc`, `textual`, `msal`)
|
||||
3. [ ] Update `.python-version` (mise/pyenv)
|
||||
4. [ ] Update `pyproject.toml` `requires-python` field
|
||||
5. [ ] Run full test suite
|
||||
6. [ ] Test on both macOS and Linux (if applicable)
|
||||
7. [ ] Update CI/CD Python version
|
||||
|
||||
### Action Items
|
||||
|
||||
1. **Quarterly Review** - Schedule quarterly reviews of library changelogs
|
||||
2. **Dependabot/Renovate** - Consider adding automated dependency update PRs
|
||||
3. **Changelog Reading** - Before updating, read changelogs for breaking changes
|
||||
4. **Test Coverage** - Ensure adequate test coverage before major updates
|
||||
|
||||
313
README.md
313
README.md
@@ -0,0 +1,313 @@
|
||||
# luk
|
||||
|
||||
> Pronounced "look" - as in "look at your Outlook data locally"
|
||||
|
||||
A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local file-based formats like Maildir and vdir. Use your favorite terminal tools to manage your email and calendar.
|
||||
|
||||
## Features
|
||||
|
||||
- **Email Synchronization**: Sync emails with Microsoft Graph API to local Maildir format
|
||||
- **Calendar Management**: Two-way calendar sync with vdir/ICS support
|
||||
- **Task Integration**: Sync with Godspeed and TickTick task managers
|
||||
- **TUI Dashboard**: Interactive terminal dashboard for monitoring sync progress
|
||||
- **Daemon Mode**: Background daemon with proper Unix logging
|
||||
- **Cross-Platform**: Works on macOS, Linux, and Windows
|
||||
- **Smart Notification Compression**: Automatically detects and compresses transactional notification emails (GitLab, GitHub, Jira, Confluence, Datadog, Renovate) into terminal-friendly summaries
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12 or higher
|
||||
- `uv` package manager (recommended)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/timothybendt/luk.git
|
||||
cd luk
|
||||
|
||||
# Run the installation script
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -e .
|
||||
|
||||
# Setup configuration directories
|
||||
mkdir -p ~/.config/luk
|
||||
mkdir -p ~/.local/share/luk
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a configuration file at `~/.config/luk/config.env`:
|
||||
|
||||
### Notification Email Compression
|
||||
|
||||
LUK includes intelligent notification email compression to reduce visual noise from automated emails (GitLab, GitHub, Jira, Confluence, Datadog, Renovate). Configure it in `~/.config/luk/mail.toml`:
|
||||
|
||||
```toml
|
||||
[content_display]
|
||||
# Enable/disable notification compression
|
||||
compress_notifications = true
|
||||
|
||||
# Compression mode:
|
||||
# - "summary": Brief one-page view (default)
|
||||
# - "detailed": More details in structured format
|
||||
# - "off": Disable compression, show full emails
|
||||
notification_compression_mode = "summary"
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- **Automatic detection**: Identifies notification emails by sender domain and subject patterns
|
||||
- **Type-specific summaries**: Extracts relevant info per platform (pipeline IDs, PR numbers, etc.)
|
||||
- **Terminal-friendly formatting**: Clean markdown with icons and clear hierarchy
|
||||
- **Toggle support**: Press `m` in mail view to switch between compressed and full email
|
||||
|
||||
**Demo:**
|
||||
```bash
|
||||
python demo_notification_compression.py
|
||||
```
|
||||
|
||||
See `mail.toml.example` for full configuration options.
|
||||
|
||||
### General Configuration
|
||||
|
||||
```bash
|
||||
# Microsoft Graph settings
|
||||
MICROSOFT_CLIENT_ID=your_client_id
|
||||
MICROSOFT_TENANT_ID=your_tenant_id
|
||||
|
||||
# Email settings
|
||||
MAILDIR_PATH=~/Mail
|
||||
NOTES_DIR=~/Documents/Notes
|
||||
|
||||
# Godspeed settings
|
||||
GODSPEED_EMAIL=your_email@example.com
|
||||
GODSPEED_PASSWORD=your_password
|
||||
GODSPEED_TOKEN=your_token
|
||||
GODSPEED_SYNC_DIR=~/Documents/Godspeed
|
||||
|
||||
# TickTick settings
|
||||
TICKTICK_CLIENT_ID=your_client_id
|
||||
TICKTICK_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
luk --help
|
||||
|
||||
# Run sync with default settings
|
||||
luk sync run
|
||||
|
||||
# Run with TUI dashboard
|
||||
luk sync run --dashboard
|
||||
|
||||
# Start daemon mode
|
||||
luk sync run --daemon
|
||||
|
||||
# Stop daemon
|
||||
luk sync stop
|
||||
|
||||
# Check daemon status
|
||||
luk sync status
|
||||
```
|
||||
|
||||
### Sync Options
|
||||
|
||||
```bash
|
||||
# Dry run (no changes)
|
||||
luk sync run --dry-run
|
||||
|
||||
# Specify organization
|
||||
luk sync run --org mycompany
|
||||
|
||||
# Enable notifications
|
||||
luk sync run --notify
|
||||
|
||||
# Download attachments
|
||||
luk sync run --download-attachments
|
||||
|
||||
# Two-way calendar sync
|
||||
luk sync run --two-way-calendar
|
||||
|
||||
# Custom calendar directory
|
||||
luk sync run --vdir ~/Calendars
|
||||
```
|
||||
|
||||
### Dashboard Mode
|
||||
|
||||
The TUI dashboard provides real-time monitoring of sync operations:
|
||||
|
||||
- **Status Display**: Current sync status and metrics
|
||||
- **Progress Bars**: Visual progress for each sync component
|
||||
- **Activity Log**: Scrollable log of all sync activities
|
||||
- **Keyboard Shortcuts**:
|
||||
- `q`: Quit dashboard
|
||||
- `l`: Toggle log visibility
|
||||
- `r`: Refresh status
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
Run luk as a background daemon with proper Unix logging:
|
||||
|
||||
```bash
|
||||
# Start daemon
|
||||
luk sync run --daemon
|
||||
|
||||
# Check status
|
||||
luk sync status
|
||||
|
||||
# View logs
|
||||
cat ~/.local/share/luk/luk.log
|
||||
|
||||
# Stop daemon
|
||||
luk sync stop
|
||||
```
|
||||
|
||||
Daemon logs are stored at `~/.local/share/luk/luk.log` with automatic rotation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- **Sync Engine**: Handles email, calendar, and task synchronization
|
||||
- **TUI Dashboard**: Interactive monitoring interface using Textual
|
||||
- **Daemon Service**: Background service with logging and process management
|
||||
- **Configuration**: Environment-based configuration system
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── cli/ # CLI commands and interfaces
|
||||
│ ├── sync.py # Main sync command
|
||||
│ ├── sync_dashboard.py # TUI dashboard
|
||||
│ ├── sync_daemon.py # Daemon service
|
||||
│ └── ...
|
||||
├── services/ # External service integrations
|
||||
│ ├── microsoft_graph/ # Microsoft Graph API
|
||||
│ ├── godspeed/ # Godspeed task manager
|
||||
│ ├── ticktick/ # TickTick API
|
||||
│ └── ...
|
||||
└── utils/ # Utility functions
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Setup Development Environment
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/timothybendt/luk.git
|
||||
cd luk
|
||||
|
||||
# Install development dependencies
|
||||
uv sync --dev
|
||||
|
||||
# Run tests
|
||||
uv run pytest
|
||||
|
||||
# Run linting
|
||||
uv run ruff check .
|
||||
uv run ruff format .
|
||||
|
||||
# Type checking
|
||||
uv run mypy src/
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
- `pyproject.toml`: Project configuration and dependencies
|
||||
- `src/cli/`: CLI commands and user interfaces
|
||||
- `src/services/`: External service integrations
|
||||
- `src/utils/`: Shared utilities and helpers
|
||||
- `tests/`: Test suite
|
||||
|
||||
### Building for Distribution
|
||||
|
||||
```bash
|
||||
# Build package
|
||||
uv run build
|
||||
|
||||
# Check package
|
||||
uv run twine check dist/*
|
||||
|
||||
# Upload to PyPI (for maintainers)
|
||||
uv run twine upload dist/*
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Authentication Errors**: Ensure Microsoft Graph credentials are properly configured
|
||||
2. **Permission Denied**: Check file permissions for Maildir and calendar directories
|
||||
3. **Daemon Not Starting**: Verify log directory exists and is writable
|
||||
4. **TUI Not Rendering**: Ensure terminal supports Textual requirements
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```bash
|
||||
export LOG_LEVEL=DEBUG
|
||||
luk sync run --dry-run
|
||||
```
|
||||
|
||||
### Log Files
|
||||
|
||||
- **Daemon Logs**: `~/.local/share/luk/luk.log`
|
||||
- **Sync State**: `~/.local/share/luk/sync_state.json`
|
||||
- **Configuration**: `~/.config/luk/`
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Run the test suite
|
||||
6. Submit a pull request
|
||||
|
||||
### Code Style
|
||||
|
||||
This project uses:
|
||||
- **Ruff** for linting and formatting
|
||||
- **MyPy** for type checking
|
||||
- **Black** for code formatting
|
||||
- **Pre-commit** hooks for quality control
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/timothybendt/luk/issues)
|
||||
- **Documentation**: [GitHub Wiki](https://github.com/timothybendt/luk/wiki)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/timothybendt/luk/discussions)
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.1.0
|
||||
- Initial release
|
||||
- Email synchronization with Microsoft Graph
|
||||
- Calendar sync with vdir/ICS support
|
||||
- Godspeed and TickTick integration
|
||||
- TUI dashboard
|
||||
- Daemon mode with logging
|
||||
- Cross-platform support
|
||||
|
||||
134
TASK_SWEEPER.md
Normal file
134
TASK_SWEEPER.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Task Sweeper for Godspeed
|
||||
|
||||
A utility script to consolidate scattered incomplete tasks from markdown files into your Godspeed Inbox.
|
||||
|
||||
## Purpose
|
||||
|
||||
If you have notes scattered across directories (like `2024/`, `2025/`, project folders, etc.) with incomplete tasks in markdown format, this script will:
|
||||
|
||||
1. **Find all incomplete tasks** (`- [ ] Task name`) in markdown files
|
||||
2. **Move them** to your Godspeed `Inbox.md` file
|
||||
3. **Preserve completed/cancelled tasks** in their original locations
|
||||
4. **Add source tracking** so you know where each task came from
|
||||
5. **Clean up original files** by removing only the incomplete tasks
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Dry run to see what would happen
|
||||
python sweep_tasks.py ~/Documents/Notes ~/Documents/Godspeed --dry-run
|
||||
|
||||
# Actually perform the sweep
|
||||
python sweep_tasks.py ~/Documents/Notes ~/Documents/Godspeed
|
||||
|
||||
# Sweep from current directory
|
||||
python sweep_tasks.py . ./godspeed
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
**Before sweeping:**
|
||||
```
|
||||
~/Documents/Notes/
|
||||
├── 2024/
|
||||
│ ├── projects/website.md
|
||||
│ │ ├── - [x] Create wireframes
|
||||
│ │ ├── - [ ] Design mockups ← Will be swept
|
||||
│ │ └── - [ ] Get approval ← Will be swept
|
||||
│ └── notes/meeting.md
|
||||
│ ├── - [ ] Update docs ← Will be swept
|
||||
│ └── - [x] Fix bug (completed)
|
||||
├── 2025/
|
||||
│ └── goals.md
|
||||
│ └── - [ ] Launch feature ← Will be swept
|
||||
└── random-notes.md
|
||||
└── - [ ] Call dentist ← Will be swept
|
||||
```
|
||||
|
||||
**After sweeping:**
|
||||
```
|
||||
~/Documents/Godspeed/
|
||||
└── Inbox.md ← All incomplete tasks here
|
||||
├── - [ ] Design mockups
|
||||
│ From: 2024/projects/website.md
|
||||
├── - [ ] Get approval
|
||||
│ From: 2024/projects/website.md
|
||||
├── - [ ] Update docs
|
||||
│ From: 2024/notes/meeting.md
|
||||
├── - [ ] Launch feature
|
||||
│ From: 2025/goals.md
|
||||
└── - [ ] Call dentist
|
||||
From: random-notes.md
|
||||
|
||||
~/Documents/Notes/
|
||||
├── 2024/
|
||||
│ ├── projects/website.md ← Only completed tasks remain
|
||||
│ │ └── - [x] Create wireframes
|
||||
│ └── notes/meeting.md
|
||||
│ └── - [x] Fix bug (completed)
|
||||
├── 2025/
|
||||
│ └── goals.md ← File cleaned/deleted if empty
|
||||
└── random-notes.md ← File cleaned/deleted if empty
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Safe Operation**: Always use `--dry-run` first to preview changes
|
||||
- **Source Tracking**: Each swept task includes a note about its origin
|
||||
- **Selective Processing**: Only moves incomplete tasks, preserves completed ones
|
||||
- **Smart Cleanup**: Removes empty files or keeps non-task content
|
||||
- **Godspeed Integration**: Creates properly formatted tasks with IDs for sync
|
||||
- **Recursive Search**: Finds markdown files in all subdirectories
|
||||
- **Exclusion Logic**: Skips the Godspeed directory itself and hidden files
|
||||
|
||||
## Integration with Godspeed Sync
|
||||
|
||||
After sweeping tasks:
|
||||
|
||||
1. **Review** the consolidated tasks in `Inbox.md`
|
||||
2. **Upload to API**: Run `python -m src.cli godspeed upload`
|
||||
3. **Organize in Godspeed**: Move tasks from Inbox to appropriate lists
|
||||
4. **Sync back**: Run `python -m src.cli godspeed sync` to get organized structure
|
||||
|
||||
## Safety Features
|
||||
|
||||
- **Dry run mode** shows exactly what will happen without making changes
|
||||
- **Backup recommendation**: The script modifies files, so backup your notes first
|
||||
- **Preserve content**: Non-task content (headings, notes, etc.) remains in original files
|
||||
- **Completed task preservation**: `[x]` and `[-]` tasks stay where they are
|
||||
- **Error handling**: Graceful handling of unreadable files or parsing errors
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
🧹 Sweeping incomplete tasks from: /Users/you/Documents/Notes
|
||||
📥 Target Inbox: /Users/you/Documents/Godspeed/Inbox.md
|
||||
🔍 Dry run: False
|
||||
============================================================
|
||||
|
||||
📁 Found 8 markdown files to process
|
||||
|
||||
📄 Processing: 2024/projects/website.md
|
||||
🔄 Found 2 incomplete tasks:
|
||||
• Design mockups
|
||||
• Get client approval
|
||||
✅ Keeping 1 completed/cleared tasks in place
|
||||
✂️ Cleaned file (removed tasks): 2024/projects/website.md
|
||||
|
||||
📥 Writing 6 tasks to Inbox...
|
||||
✅ Inbox updated: /Users/you/Documents/Godspeed/Inbox.md
|
||||
|
||||
============================================================
|
||||
📊 SWEEP SUMMARY:
|
||||
• Files processed: 3
|
||||
• Tasks swept: 6
|
||||
• Target: /Users/you/Documents/Godspeed/Inbox.md
|
||||
|
||||
🎉 Successfully swept 6 tasks!
|
||||
💡 Next steps:
|
||||
1. Review tasks in: /Users/you/Documents/Godspeed/Inbox.md
|
||||
2. Run 'godspeed upload' to sync to API
|
||||
3. Organize tasks into appropriate lists in Godspeed app
|
||||
```
|
||||
|
||||
This tool is perfect for periodic "note cleanup" sessions where you consolidate scattered tasks into your main GTD system.
|
||||
237
TICKTICK_SETUP.md
Normal file
237
TICKTICK_SETUP.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# TickTick CLI Integration Setup
|
||||
|
||||
This guide helps you set up the TickTick CLI integration for task management.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **TickTick Account**: You need a TickTick account
|
||||
2. **TickTick Developer App**: Register an app at https://developer.ticktick.com/docs#/openapi
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Register TickTick Developer App
|
||||
|
||||
1. Go to https://developer.ticktick.com/docs#/openapi
|
||||
2. Click "Manage Apps" in the top right
|
||||
3. Click "+App Name" to create a new app
|
||||
4. Fill in the app name (required field only)
|
||||
5. Note down your `Client ID` and `Client Secret`
|
||||
6. Set the OAuth Redirect URL to: `http://localhost:8080`
|
||||
|
||||
### 2. Set Environment Variables
|
||||
|
||||
Add these to your shell profile (`.bashrc`, `.zshrc`, etc.):
|
||||
|
||||
```bash
|
||||
# OAuth2 Credentials (Required)
|
||||
export TICKTICK_CLIENT_ID="your_client_id_here"
|
||||
export TICKTICK_CLIENT_SECRET="your_client_secret_here"
|
||||
export TICKTICK_REDIRECT_URI="http://localhost:8080"
|
||||
|
||||
# TickTick Login Credentials (Optional - you'll be prompted if not set)
|
||||
export TICKTICK_USERNAME="your_email@example.com"
|
||||
export TICKTICK_PASSWORD="your_password"
|
||||
|
||||
# SSL Configuration (Optional - for corporate networks with MITM proxies)
|
||||
# export TICKTICK_DISABLE_SSL_VERIFY="true"
|
||||
```
|
||||
|
||||
**Important Note**: The TickTick library requires both OAuth2 credentials AND your regular TickTick login credentials. This is how the library is designed:
|
||||
- **OAuth2**: Used for API authentication and authorization
|
||||
- **Username/Password**: Required for initial session establishment
|
||||
|
||||
Your login credentials are only used for authentication and are not stored permanently.
|
||||
|
||||
## Authentication
|
||||
|
||||
### Token Storage
|
||||
|
||||
OAuth tokens are automatically cached in:
|
||||
```
|
||||
~/.local/share/gtd-terminal-tools/ticktick_tokens.json
|
||||
```
|
||||
|
||||
This file is created and managed automatically by the TickTick library. The tokens are used to avoid repeated OAuth flows and will be refreshed automatically when needed.
|
||||
|
||||
### Authentication Status
|
||||
|
||||
Check your authentication setup and token status:
|
||||
|
||||
```bash
|
||||
ticktick auth-status
|
||||
```
|
||||
|
||||
This command shows:
|
||||
- OAuth credentials status (environment variables)
|
||||
- Login credentials status
|
||||
- Token cache status and expiration
|
||||
- Token file location and last modified time
|
||||
|
||||
If you need to clear the token cache and re-authenticate:
|
||||
|
||||
```bash
|
||||
ticktick clear-cache
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# List all tasks
|
||||
ticktick list
|
||||
ticktick ls # Short alias
|
||||
|
||||
# Filter by project
|
||||
ticktick ls -p "Work"
|
||||
|
||||
# Filter by due date
|
||||
ticktick ls -d today
|
||||
ticktick ls -d tomorrow
|
||||
ticktick ls -d "2024-01-15"
|
||||
|
||||
# Add a new task
|
||||
ticktick add "Buy groceries"
|
||||
ticktick a "Buy groceries" -d tomorrow -p "Personal" # With options
|
||||
|
||||
# Edit a task
|
||||
ticktick edit TASK_ID --title "New title"
|
||||
ticktick e TASK_ID -d tomorrow -pr high
|
||||
|
||||
# Complete a task
|
||||
ticktick complete TASK_ID
|
||||
ticktick done TASK_ID
|
||||
ticktick c TASK_ID # Short alias
|
||||
|
||||
# Delete a task
|
||||
ticktick delete TASK_ID
|
||||
ticktick rm TASK_ID -f # Force delete without confirmation
|
||||
|
||||
# Open task in browser/app
|
||||
ticktick open TASK_ID
|
||||
ticktick o TASK_ID # Short alias
|
||||
|
||||
# Show detailed task info
|
||||
ticktick show TASK_ID
|
||||
ticktick s TASK_ID # Short alias
|
||||
|
||||
# List projects and tags
|
||||
ticktick projects
|
||||
ticktick tags
|
||||
|
||||
# Sync with TickTick servers
|
||||
ticktick sync
|
||||
```
|
||||
|
||||
### Command Aliases Reference
|
||||
|
||||
| Full Command | Short Alias | Description |
|
||||
|--------------|-------------|-------------|
|
||||
| `list` | `ls` | List tasks |
|
||||
| `add` | `a` | Add new task |
|
||||
| `edit` | `e` | Edit existing task |
|
||||
| `complete` | `c`, `done` | Mark task complete |
|
||||
| `delete` | `rm`, `del` | Delete task |
|
||||
| `open` | `o` | Open in browser/app |
|
||||
| `show` | `s`, `view` | Show task details |
|
||||
| `projects` | `proj` | List projects |
|
||||
|
||||
### Option Aliases
|
||||
|
||||
| Full Option | Short | Description |
|
||||
|-------------|-------|-------------|
|
||||
| `--project` | `-p` | Filter/set project |
|
||||
| `--due-date` | `-d` | Filter/set due date |
|
||||
| `--priority` | `-pr` | Filter/set priority |
|
||||
| `--tag` | `-t` | Filter by tag |
|
||||
| `--all` | `-a` | Show all tasks |
|
||||
| `--force` | `-f` | Skip confirmations |
|
||||
| `--browser` | `-b` | Force browser opening |
|
||||
| `--content` | `-c` | Task description |
|
||||
| `--limit` | `-l` | Limit results |
|
||||
|
||||
### Priority Levels
|
||||
|
||||
You can set priorities using numbers (0-5) or names:
|
||||
- `0` or `none`: No priority
|
||||
- `1` or `low`: Low priority
|
||||
- `2` or `medium`: Medium priority
|
||||
- `3` or `high`: High priority
|
||||
- `4` or `urgent`: Very high priority
|
||||
- `5` or `critical`: Critical priority
|
||||
|
||||
### Date Formats
|
||||
|
||||
Supported date formats:
|
||||
- `today`, `tomorrow`, `yesterday`
|
||||
- `YYYY-MM-DD` (e.g., `2024-01-15`)
|
||||
- Most common date formats via dateutil parsing
|
||||
|
||||
## Authentication
|
||||
|
||||
The TickTick integration uses a **dual authentication approach**:
|
||||
|
||||
1. **OAuth2 Setup**: On first use, the CLI will:
|
||||
- Open a web browser for OAuth authorization
|
||||
- Prompt you to copy the redirect URL
|
||||
- Cache the OAuth token in `~/.local/share/gtd-terminal-tools/ticktick_tokens.json`
|
||||
|
||||
2. **Login Credentials**: The library also requires your TickTick username/password for session establishment. You can either:
|
||||
- Set `TICKTICK_USERNAME` and `TICKTICK_PASSWORD` environment variables
|
||||
- Enter them when prompted (they won't be stored)
|
||||
|
||||
The OAuth token cache lasts about 6 months, after which you'll need to re-authenticate.
|
||||
|
||||
**Why Both?**: The `ticktick-py` library uses OAuth2 for API calls but requires login credentials for initial session setup. This is the library's design, not a limitation of our CLI.
|
||||
|
||||
## macOS App Integration
|
||||
|
||||
On macOS, the `ticktick open` command will try to open tasks in the TickTick desktop app first, falling back to the browser if the app isn't available.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Please set TICKTICK_CLIENT_ID" Error
|
||||
Make sure you've set the environment variables and restarted your terminal.
|
||||
|
||||
### Authentication Issues
|
||||
Try clearing the token cache:
|
||||
```bash
|
||||
rm ~/.local/share/gtd-terminal-tools/ticktick_tokens.json
|
||||
```
|
||||
|
||||
### SSL Certificate Errors
|
||||
If you get SSL certificate verification errors (common on corporate networks with MITM proxies):
|
||||
|
||||
```bash
|
||||
export TICKTICK_DISABLE_SSL_VERIFY="true"
|
||||
```
|
||||
|
||||
**Warning**: This disables SSL verification. Only use this on trusted corporate networks.
|
||||
|
||||
### Network/API Errors
|
||||
Check your internet connection and verify your TickTick credentials.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# Morning routine: check today's tasks
|
||||
ticktick ls -d today
|
||||
|
||||
# Add a quick task
|
||||
ticktick a "Review reports" -p "Work" -d today -pr high
|
||||
|
||||
# Complete a task when done
|
||||
ticktick c TASK_ID
|
||||
|
||||
# Check what's due tomorrow
|
||||
ticktick ls -d tomorrow
|
||||
|
||||
# Open an important task for details
|
||||
ticktick o TASK_ID
|
||||
```
|
||||
@@ -1,315 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark script to compare two approaches for updating envelopes list in maildir_gtd.
|
||||
This script compares:
|
||||
1. Using .pop() to remove items from ListView
|
||||
2. Using refresh_list_view() to rebuild the entire ListView
|
||||
|
||||
It tests with different numbers of envelopes (100, 1000, 2000) and measures:
|
||||
- Time to remove a single item
|
||||
- Time to remove multiple items in sequence
|
||||
- Memory usage
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
import gc
|
||||
import tracemalloc
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from typing import List, Dict, Any, Callable, Tuple
|
||||
import json
|
||||
|
||||
# Add parent directory to path so we can import modules correctly
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Import required classes and functions
|
||||
from textual.widgets import ListView, ListItem, Label
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
|
||||
# Import our application's modules
|
||||
from maildir_gtd.app import MessageStore
|
||||
from maildir_gtd.utils import group_envelopes_by_date
|
||||
|
||||
# Mock class to simulate the ListView behavior
|
||||
class MockListView:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
self.index = 0
|
||||
|
||||
def append(self, item):
|
||||
self.items.append(item)
|
||||
|
||||
def pop(self, idx=None):
|
||||
if idx is None:
|
||||
return self.items.pop()
|
||||
return self.items.pop(idx)
|
||||
|
||||
def clear(self):
|
||||
self.items = []
|
||||
|
||||
def __len__(self):
|
||||
return len(self.items)
|
||||
|
||||
# Helper functions to generate test data
|
||||
def generate_envelope(idx: int) -> Dict[str, Any]:
|
||||
"""Generate a synthetic envelope with predictable data."""
|
||||
now = datetime.now(UTC)
|
||||
# Distribute dates over the last 60 days to create realistic grouping
|
||||
date = now - timedelta(days=random.randint(0, 60),
|
||||
hours=random.randint(0, 23),
|
||||
minutes=random.randint(0, 59))
|
||||
|
||||
return {
|
||||
"id": str(idx),
|
||||
"subject": f"Test Subject {idx}",
|
||||
"from": {"addr": f"sender{idx}@example.com"},
|
||||
"to": {"addr": f"recipient{idx}@example.com"},
|
||||
"date": date.strftime("%Y-%m-%d %H:%M"),
|
||||
"cc": {},
|
||||
"type": "message"
|
||||
}
|
||||
|
||||
def generate_test_envelopes(count: int) -> List[Dict[str, Any]]:
|
||||
"""Generate a specified number of test envelopes."""
|
||||
return [generate_envelope(i) for i in range(1, count + 1)]
|
||||
|
||||
# Benchmark functions
|
||||
def benchmark_pop_approach(store: MessageStore, list_view: MockListView, indices_to_remove: List[int]) -> float:
|
||||
"""Benchmark the .pop() approach."""
|
||||
start_time = time.time()
|
||||
|
||||
for idx in sorted(indices_to_remove, reverse=True): # Remove from highest to lowest to avoid index shifting issues
|
||||
msg_id = int(store.envelopes[idx]["id"])
|
||||
store.remove(msg_id)
|
||||
list_view.pop(idx)
|
||||
|
||||
end_time = time.time()
|
||||
return end_time - start_time
|
||||
|
||||
def benchmark_refresh_approach(store: MessageStore, list_view: MockListView, indices_to_remove: List[int]) -> float:
|
||||
"""Benchmark the refresh_list_view approach."""
|
||||
start_time = time.time()
|
||||
|
||||
for idx in indices_to_remove:
|
||||
msg_id = int(store.envelopes[idx]["id"])
|
||||
store.remove(msg_id)
|
||||
|
||||
# Simulate refresh_list_view by clearing and rebuilding the list
|
||||
list_view.clear()
|
||||
for item in store.envelopes:
|
||||
if item and item.get("type") == "header":
|
||||
list_view.append(f"Header: {item['label']}")
|
||||
elif item: # Check if not None
|
||||
list_view.append(f"Email: {item.get('subject', '')}")
|
||||
|
||||
end_time = time.time()
|
||||
return end_time - start_time
|
||||
|
||||
def run_memory_benchmark(func, *args):
|
||||
"""Run a function with memory tracking."""
|
||||
tracemalloc.start()
|
||||
result = func(*args)
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
tracemalloc.stop()
|
||||
return result, current, peak
|
||||
|
||||
def run_benchmark(envelope_count: int, num_operations: int = 10):
|
||||
"""Run benchmarks for a specific number of envelopes."""
|
||||
print(f"\n{'=' * 50}")
|
||||
print(f"Running benchmark with {envelope_count} envelopes")
|
||||
print(f"{'=' * 50}")
|
||||
|
||||
# Generate test data
|
||||
envelopes = generate_test_envelopes(envelope_count)
|
||||
|
||||
# Set up for pop approach
|
||||
pop_store = MessageStore()
|
||||
pop_store.load(envelopes.copy())
|
||||
pop_list_view = MockListView()
|
||||
|
||||
# Build initial list view
|
||||
for item in pop_store.envelopes:
|
||||
if item and item.get("type") == "header":
|
||||
pop_list_view.append(f"Header: {item['label']}")
|
||||
elif item:
|
||||
pop_list_view.append(f"Email: {item.get('subject', '')}")
|
||||
|
||||
# Set up for refresh approach
|
||||
refresh_store = MessageStore()
|
||||
refresh_store.load(envelopes.copy())
|
||||
refresh_list_view = MockListView()
|
||||
|
||||
# Build initial list view
|
||||
for item in refresh_store.envelopes:
|
||||
if item and item.get("type") == "header":
|
||||
refresh_list_view.append(f"Header: {item['label']}")
|
||||
elif item:
|
||||
refresh_list_view.append(f"Email: {item.get('subject', '')}")
|
||||
|
||||
# Generate random indices to remove (ensure they're valid message indices, not headers)
|
||||
valid_indices = []
|
||||
for idx, item in enumerate(pop_store.envelopes):
|
||||
if item and item.get("type") != "header" and item is not None:
|
||||
valid_indices.append(idx)
|
||||
|
||||
if len(valid_indices) < num_operations:
|
||||
num_operations = len(valid_indices)
|
||||
print(f"Warning: Only {num_operations} valid messages available for removal")
|
||||
|
||||
indices_to_remove = random.sample(valid_indices, num_operations)
|
||||
|
||||
# Single operation benchmark
|
||||
print("\n🔹 Single operation benchmark (removing 1 item):")
|
||||
|
||||
# Pop approach - single operation
|
||||
gc.collect() # Ensure clean state
|
||||
single_pop_time, pop_current, pop_peak = run_memory_benchmark(
|
||||
benchmark_pop_approach, pop_store, pop_list_view, [indices_to_remove[0]]
|
||||
)
|
||||
print(f" Pop approach: {single_pop_time*1000:.2f} ms (Memory - Current: {pop_current/1024:.1f} KB, Peak: {pop_peak/1024:.1f} KB)")
|
||||
|
||||
# Refresh approach - single operation
|
||||
gc.collect() # Ensure clean state
|
||||
single_refresh_time, refresh_current, refresh_peak = run_memory_benchmark(
|
||||
benchmark_refresh_approach, refresh_store, refresh_list_view, [indices_to_remove[0]]
|
||||
)
|
||||
print(f" Refresh approach: {single_refresh_time*1000:.2f} ms (Memory - Current: {refresh_current/1024:.1f} KB, Peak: {refresh_peak/1024:.1f} KB)")
|
||||
|
||||
# Determine which is better for single operation
|
||||
if single_pop_time < single_refresh_time:
|
||||
print(f" 🥇 Pop is {single_refresh_time/single_pop_time:.1f}x faster for single operation")
|
||||
else:
|
||||
print(f" 🥇 Refresh is {single_pop_time/single_refresh_time:.1f}x faster for single operation")
|
||||
|
||||
# Reset for multi-operation benchmark
|
||||
gc.collect()
|
||||
pop_store = MessageStore()
|
||||
pop_store.load(envelopes.copy())
|
||||
pop_list_view = MockListView()
|
||||
for item in pop_store.envelopes:
|
||||
if item and item.get("type") == "header":
|
||||
pop_list_view.append(f"Header: {item['label']}")
|
||||
elif item:
|
||||
pop_list_view.append(f"Email: {item.get('subject', '')}")
|
||||
|
||||
refresh_store = MessageStore()
|
||||
refresh_store.load(envelopes.copy())
|
||||
refresh_list_view = MockListView()
|
||||
for item in refresh_store.envelopes:
|
||||
if item and item.get("type") == "header":
|
||||
refresh_list_view.append(f"Header: {item['label']}")
|
||||
elif item:
|
||||
refresh_list_view.append(f"Email: {item.get('subject', '')}")
|
||||
|
||||
# Multiple operations benchmark
|
||||
print(f"\n🔹 Multiple operations benchmark (removing {num_operations} items):")
|
||||
|
||||
# Pop approach - multiple operations
|
||||
gc.collect()
|
||||
multi_pop_time, pop_current, pop_peak = run_memory_benchmark(
|
||||
benchmark_pop_approach, pop_store, pop_list_view, indices_to_remove
|
||||
)
|
||||
print(f" Pop approach: {multi_pop_time*1000:.2f} ms (Memory - Current: {pop_current/1024:.1f} KB, Peak: {pop_peak/1024:.1f} KB)")
|
||||
|
||||
# Refresh approach - multiple operations
|
||||
gc.collect()
|
||||
multi_refresh_time, refresh_current, refresh_peak = run_memory_benchmark(
|
||||
benchmark_refresh_approach, refresh_store, refresh_list_view, indices_to_remove
|
||||
)
|
||||
print(f" Refresh approach: {multi_refresh_time*1000:.2f} ms (Memory - Current: {refresh_current/1024:.1f} KB, Peak: {refresh_peak/1024:.1f} KB)")
|
||||
|
||||
# Determine which is better for multiple operations
|
||||
if multi_pop_time < multi_refresh_time:
|
||||
print(f" 🥇 Pop is {multi_refresh_time/multi_pop_time:.1f}x faster for multiple operations")
|
||||
else:
|
||||
print(f" 🥇 Refresh is {multi_pop_time/multi_refresh_time:.1f}x faster for multiple operations")
|
||||
|
||||
return {
|
||||
"envelope_count": envelope_count,
|
||||
"num_operations": num_operations,
|
||||
"single_operation": {
|
||||
"pop_time_ms": single_pop_time * 1000,
|
||||
"refresh_time_ms": single_refresh_time * 1000,
|
||||
"pop_memory_kb": pop_peak / 1024,
|
||||
"refresh_memory_kb": refresh_peak / 1024
|
||||
},
|
||||
"multiple_operations": {
|
||||
"pop_time_ms": multi_pop_time * 1000,
|
||||
"refresh_time_ms": multi_refresh_time * 1000,
|
||||
"pop_memory_kb": pop_peak / 1024,
|
||||
"refresh_memory_kb": refresh_peak / 1024
|
||||
}
|
||||
}
|
||||
|
||||
def main():
|
||||
print("\n📊 MAILDIR GTD LIST UPDATE BENCHMARK 📊")
|
||||
print("Comparing .pop() vs refresh_list_view() approaches")
|
||||
print("=" * 60)
|
||||
|
||||
# Define test cases
|
||||
envelope_counts = [100, 1000, 2000]
|
||||
results = []
|
||||
|
||||
for count in envelope_counts:
|
||||
result = run_benchmark(count)
|
||||
results.append(result)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 60)
|
||||
print("📊 BENCHMARK SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
# Console table formatting
|
||||
print(f"{'Size':<10} | {'Single Op (pop)':<15} | {'Single Op (refresh)':<20} | {'Multi Op (pop)':<15} | {'Multi Op (refresh)':<20}")
|
||||
print("-" * 90)
|
||||
|
||||
for result in results:
|
||||
count = result["envelope_count"]
|
||||
single_pop = f"{result['single_operation']['pop_time_ms']:.2f} ms"
|
||||
single_refresh = f"{result['single_operation']['refresh_time_ms']:.2f} ms"
|
||||
multi_pop = f"{result['multiple_operations']['pop_time_ms']:.2f} ms"
|
||||
multi_refresh = f"{result['multiple_operations']['refresh_time_ms']:.2f} ms"
|
||||
|
||||
print(f"{count:<10} | {single_pop:<15} | {single_refresh:<20} | {multi_pop:<15} | {multi_refresh:<20}")
|
||||
|
||||
# Display conclusions
|
||||
print("\n🔍 CONCLUSIONS:")
|
||||
for result in results:
|
||||
count = result["envelope_count"]
|
||||
single_ratio = result['single_operation']['refresh_time_ms'] / result['single_operation']['pop_time_ms']
|
||||
multi_ratio = result['multiple_operations']['refresh_time_ms'] / result['multiple_operations']['pop_time_ms']
|
||||
|
||||
print(f"\nFor {count} envelopes:")
|
||||
|
||||
if single_ratio > 1:
|
||||
print(f"- Single operation: .pop() is {single_ratio:.1f}x faster")
|
||||
else:
|
||||
print(f"- Single operation: refresh_list_view() is {1/single_ratio:.1f}x faster")
|
||||
|
||||
if multi_ratio > 1:
|
||||
print(f"- Multiple operations: .pop() is {multi_ratio:.1f}x faster")
|
||||
else:
|
||||
print(f"- Multiple operations: refresh_list_view() is {1/multi_ratio:.1f}x faster")
|
||||
|
||||
print("\n🔑 RECOMMENDATION:")
|
||||
# Calculate average performance difference across all tests
|
||||
avg_single_ratio = sum(r['single_operation']['refresh_time_ms'] / r['single_operation']['pop_time_ms'] for r in results) / len(results)
|
||||
avg_multi_ratio = sum(r['multiple_operations']['refresh_time_ms'] / r['multiple_operations']['pop_time_ms'] for r in results) / len(results)
|
||||
|
||||
if avg_single_ratio > 1 and avg_multi_ratio > 1:
|
||||
print("The .pop() approach is generally faster, but consider the following:")
|
||||
print("- .pop() risks index misalignment issues with the message_store")
|
||||
print("- refresh_list_view() ensures UI and data structure stay synchronized")
|
||||
print("- The performance difference may not be noticeable to users")
|
||||
print("👉 Recommendation: Use refresh_list_view() for reliability unless performance becomes a real issue")
|
||||
else:
|
||||
print("The refresh_list_view() approach is not only safer but also performs competitively:")
|
||||
print("- It ensures perfect synchronization between UI and data model")
|
||||
print("- It eliminates the risk of index misalignment")
|
||||
print("👉 Recommendation: Use refresh_list_view() approach as it's more reliable and performs well")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
88
check_env.py
Executable file
88
check_env.py
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Environment validation script for GTD Terminal Tools."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from src.utils.platform import validate_environment
|
||||
|
||||
|
||||
def main():
|
||||
"""Run environment validation and exit with appropriate code."""
|
||||
env_info = validate_environment()
|
||||
|
||||
print("GTD Terminal Tools - Environment Validation")
|
||||
print("=" * 50)
|
||||
|
||||
# Platform info
|
||||
platform_info = env_info["platform_info"]
|
||||
print(f"Platform: {platform_info['system']} {platform_info['release']}")
|
||||
print(
|
||||
f"Python: {platform_info['python_version']} ({platform_info['python_implementation']})"
|
||||
)
|
||||
print(f"Supported: {'✓' if env_info['platform_supported'] else '✗'}")
|
||||
print()
|
||||
|
||||
# Dependencies
|
||||
print("Dependencies:")
|
||||
all_deps_available = True
|
||||
for dep, available in env_info["dependencies"].items():
|
||||
status = "✓" if available else "✗"
|
||||
print(f" {dep}: {status}")
|
||||
if not available:
|
||||
all_deps_available = False
|
||||
print()
|
||||
|
||||
# Terminal compatibility
|
||||
print("Terminal Compatibility:")
|
||||
terminal_ok = True
|
||||
for feature, supported in env_info["terminal_compatibility"].items():
|
||||
status = "✓" if supported else "✗"
|
||||
print(f" {feature}: {status}")
|
||||
if not supported and feature in ["color_support", "textual_support"]:
|
||||
terminal_ok = False
|
||||
print()
|
||||
|
||||
# Directories
|
||||
print("Directories:")
|
||||
for dir_type, dir_path in [
|
||||
("config", "config_dir"),
|
||||
("data", "data_dir"),
|
||||
("logs", "log_dir"),
|
||||
]:
|
||||
path = Path(env_info[dir_path])
|
||||
exists = path.exists()
|
||||
status = "✓" if exists else "✗"
|
||||
print(f" {dir_type.capitalize()}: {env_info[dir_path]} {status}")
|
||||
print()
|
||||
|
||||
# Recommendations
|
||||
if env_info["recommendations"]:
|
||||
print("Recommendations:")
|
||||
for rec in env_info["recommendations"]:
|
||||
print(f" • {rec}")
|
||||
print()
|
||||
|
||||
# Overall status
|
||||
platform_ok = env_info["platform_supported"]
|
||||
overall_ok = platform_ok and all_deps_available and terminal_ok
|
||||
|
||||
if overall_ok:
|
||||
print("✓ Environment is ready for GTD Terminal Tools")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("✗ Environment has issues that need to be addressed")
|
||||
if not platform_ok:
|
||||
print(" - Unsupported platform or Python version")
|
||||
if not all_deps_available:
|
||||
print(" - Missing dependencies")
|
||||
if not terminal_ok:
|
||||
print(" - Terminal compatibility issues")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
129
debug_ticktick.py
Executable file
129
debug_ticktick.py
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script to test TickTick authentication in isolation
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
|
||||
# Enable debug logging
|
||||
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
|
||||
|
||||
# Set SSL bypass for corporate networks
|
||||
os.environ["TICKTICK_DISABLE_SSL_VERIFY"] = "true"
|
||||
|
||||
# Set your credentials here for testing
|
||||
TEST_CLIENT_ID = input("Enter your TICKTICK_CLIENT_ID: ").strip()
|
||||
TEST_CLIENT_SECRET = input("Enter your TICKTICK_CLIENT_SECRET: ").strip()
|
||||
TEST_USERNAME = input("Enter your TickTick username/email: ").strip()
|
||||
|
||||
import getpass
|
||||
|
||||
TEST_PASSWORD = getpass.getpass("Enter your TickTick password: ")
|
||||
|
||||
if not all([TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_USERNAME, TEST_PASSWORD]):
|
||||
print("All credentials are required")
|
||||
sys.exit(1)
|
||||
|
||||
os.environ["TICKTICK_CLIENT_ID"] = TEST_CLIENT_ID
|
||||
os.environ["TICKTICK_CLIENT_SECRET"] = TEST_CLIENT_SECRET
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TICKTICK DEBUG TEST")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
print("1. Testing OAuth client creation...")
|
||||
from services.ticktick.auth import create_oauth_client, get_token_file_path
|
||||
|
||||
oauth_client = create_oauth_client()
|
||||
print(f"✓ OAuth client created")
|
||||
print(f"✓ Expected cache path: {get_token_file_path()}")
|
||||
|
||||
# Check if we have a cached token
|
||||
token_file = get_token_file_path()
|
||||
print(f"✓ Token file exists: {token_file.exists()}")
|
||||
if token_file.exists():
|
||||
from services.ticktick.auth import load_stored_tokens
|
||||
|
||||
tokens = load_stored_tokens()
|
||||
if tokens:
|
||||
print(
|
||||
f"✓ Token loaded, expires: {tokens.get('readable_expire_time', 'Unknown')}"
|
||||
)
|
||||
else:
|
||||
print("⚠ Token file exists but couldn't load")
|
||||
|
||||
print("\n2. Testing OAuth token retrieval...")
|
||||
access_token = oauth_client.get_access_token()
|
||||
print(f"✓ Access token retrieved: {access_token[:10]}...{access_token[-10:]}")
|
||||
|
||||
print("\n3. Testing TickTick client creation...")
|
||||
from ticktick.api import TickTickClient
|
||||
|
||||
# Enable more verbose logging to see HTTP requests
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings()
|
||||
|
||||
# Monkey patch to get more details about the HTTP response
|
||||
original_check_status = TickTickClient.check_status_code
|
||||
|
||||
def debug_check_status(self, response, error_message):
|
||||
print(f"HTTP Response Status: {response.status_code}")
|
||||
print(f"HTTP Response Headers: {dict(response.headers)}")
|
||||
print(f"HTTP Response Text (first 200 chars): {response.text[:200]}")
|
||||
return original_check_status(self, response, error_message)
|
||||
|
||||
TickTickClient.check_status_code = debug_check_status
|
||||
|
||||
# This is where the error likely occurs
|
||||
print(f"Creating client with username: {TEST_USERNAME}")
|
||||
client = TickTickClient(TEST_USERNAME, TEST_PASSWORD, oauth_client)
|
||||
print("✓ TickTickClient created successfully!")
|
||||
print("\n4. Testing API call...")
|
||||
try:
|
||||
projects = client.get_by_fields(search="projects")
|
||||
print(f"✓ API call successful - found {len(projects)} projects")
|
||||
except Exception as api_e:
|
||||
print(f"⚠ API call failed: {api_e}")
|
||||
|
||||
print("\n🎉 ALL TESTS PASSED!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ ERROR: {e}")
|
||||
print(f"Error type: {type(e).__name__}")
|
||||
|
||||
import traceback
|
||||
|
||||
print("\nFull traceback:")
|
||||
traceback.print_exc()
|
||||
|
||||
# Additional debugging
|
||||
print("\nDebugging information:")
|
||||
print(f"- Python version: {sys.version}")
|
||||
print(f"- Working directory: {os.getcwd()}")
|
||||
print(f"- Token file path: {get_token_file_path()}")
|
||||
|
||||
# Check if this is the specific "Could Not Complete Request" error
|
||||
if "Could Not Complete Request" in str(e):
|
||||
print("""
|
||||
This error typically indicates one of:
|
||||
1. Incorrect TickTick username/password
|
||||
2. Account locked or requires 2FA
|
||||
3. Network/SSL issues (even with SSL disabled)
|
||||
4. TickTick API changes or service issues
|
||||
|
||||
Suggestions:
|
||||
- Double-check your TickTick login at https://ticktick.com
|
||||
- Try a different password (maybe you have special characters?)
|
||||
- Check if your account has 2FA enabled
|
||||
- Try again later (might be temporary API issue)
|
||||
""")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
110
demo_cancelled_workflow.py
Normal file
110
demo_cancelled_workflow.py
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo showing cancelled task workflow with Godspeed sync.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def demo_cancelled_workflow():
|
||||
print("=== Godspeed Cancelled Task Workflow Demo ===\n")
|
||||
|
||||
from src.services.godspeed.sync import GodspeedSync
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
sync_dir = Path(temp_dir)
|
||||
sync_engine = GodspeedSync(None, sync_dir)
|
||||
|
||||
print("📝 Scenario: Managing a project with tasks that get cancelled")
|
||||
print("=" * 65)
|
||||
|
||||
# Initial tasks
|
||||
print("\n1. Initial project tasks in markdown:")
|
||||
initial_tasks = [
|
||||
("task1", "incomplete", "Design new feature", ""),
|
||||
("task2", "incomplete", "Get approval from stakeholders", ""),
|
||||
("task3", "incomplete", "Implement feature", ""),
|
||||
("task4", "incomplete", "Write documentation", ""),
|
||||
("task5", "incomplete", "Deploy to production", ""),
|
||||
]
|
||||
|
||||
project_file = sync_dir / "New_Feature_Project.md"
|
||||
sync_engine._write_list_file(project_file, initial_tasks)
|
||||
|
||||
with open(project_file, "r") as f:
|
||||
print(f.read())
|
||||
|
||||
print("2. Project update - some tasks completed, one cancelled:")
|
||||
print("-" * 58)
|
||||
|
||||
# Simulate project evolution
|
||||
updated_content = """- [x] Design new feature <!-- id:task1 -->
|
||||
- [-] Get approval from stakeholders <!-- id:task2 -->
|
||||
Stakeholders decided to cancel this feature
|
||||
- [-] Implement feature <!-- id:task3 -->
|
||||
No longer needed since feature was cancelled
|
||||
- [-] Write documentation <!-- id:task4 -->
|
||||
Documentation not needed for cancelled feature
|
||||
- [-] Deploy to production <!-- id:task5 -->
|
||||
Cannot deploy cancelled feature
|
||||
- [ ] Archive project files <!-- id:task6 -->
|
||||
New cleanup task
|
||||
"""
|
||||
|
||||
with open(project_file, "w") as f:
|
||||
f.write(updated_content)
|
||||
|
||||
print(updated_content)
|
||||
|
||||
# Parse the changes
|
||||
updated_tasks = sync_engine._read_list_file(project_file)
|
||||
|
||||
print("3. What would sync to Godspeed API:")
|
||||
print("-" * 36)
|
||||
|
||||
api_calls = []
|
||||
for local_id, status, title, notes in updated_tasks:
|
||||
if status == "complete":
|
||||
api_calls.append(
|
||||
f"PATCH /tasks/{local_id} {{'is_complete': True, 'is_cleared': False}}"
|
||||
)
|
||||
print(f" ✅ COMPLETE: {title}")
|
||||
elif status == "cleared":
|
||||
api_calls.append(
|
||||
f"PATCH /tasks/{local_id} {{'is_complete': True, 'is_cleared': True}}"
|
||||
)
|
||||
print(f" ❌ CANCEL: {title}")
|
||||
if notes:
|
||||
print(f" Reason: {notes}")
|
||||
elif local_id == "task6": # New task
|
||||
api_calls.append(
|
||||
f"POST /tasks {{'title': '{title}', 'list_id': 'project-list'}}"
|
||||
)
|
||||
print(f" ➕ NEW: {title}")
|
||||
else:
|
||||
print(f" ⏳ INCOMPLETE: {title}")
|
||||
|
||||
print(f"\n4. API calls that would be made ({len(api_calls)} total):")
|
||||
print("-" * 49)
|
||||
for call in api_calls:
|
||||
print(f" {call}")
|
||||
|
||||
print("\n5. Next sync download behavior:")
|
||||
print("-" * 32)
|
||||
print(" When downloading from Godspeed API:")
|
||||
print(" • Only incomplete tasks appear in local files")
|
||||
print(" • Completed and cancelled tasks are hidden")
|
||||
print(" • This keeps your local markdown files clean")
|
||||
print(f" • Current file would only show: 'Archive project files'")
|
||||
|
||||
print("\n✨ Benefits of this workflow:")
|
||||
print(" • Clear visual distinction: [-] for cancelled vs [x] for completed")
|
||||
print(" • Cancelled tasks sync to Godspeed's 'cleared' status")
|
||||
print(" • Completed/cancelled tasks auto-hide on next download")
|
||||
print(" • Notes explain why tasks were cancelled")
|
||||
print(" • Clean local files focused on active work")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo_cancelled_workflow()
|
||||
103
demo_completion_sync.py
Normal file
103
demo_completion_sync.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo script showing how Godspeed completion status sync works.
|
||||
This creates sample markdown files and shows the sync behavior.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def demo_completion_sync():
|
||||
print("=== Godspeed Completion Status Sync Demo ===\n")
|
||||
|
||||
from src.services.godspeed.sync import GodspeedSync
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
sync_dir = Path(temp_dir)
|
||||
sync_engine = GodspeedSync(None, sync_dir)
|
||||
|
||||
print("1. Creating sample markdown file with mixed completion states:")
|
||||
print("-" * 60)
|
||||
|
||||
# Create sample tasks
|
||||
sample_tasks = [
|
||||
("task001", False, "Buy groceries", "Don't forget milk"),
|
||||
("task002", True, "Call dentist", ""),
|
||||
("task003", False, "Finish project", "Due next Friday"),
|
||||
("task004", True, "Exercise today", "Went for a 30min run"),
|
||||
]
|
||||
|
||||
# Write to markdown file
|
||||
demo_file = sync_dir / "Personal.md"
|
||||
sync_engine._write_list_file(demo_file, sample_tasks)
|
||||
|
||||
# Show the generated markdown
|
||||
with open(demo_file, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
print(content)
|
||||
print("-" * 60)
|
||||
|
||||
print("\n2. What this represents in Godspeed:")
|
||||
for task_id, is_complete, title, notes in sample_tasks:
|
||||
status = "✅ COMPLETED" if is_complete else "⏳ INCOMPLETE"
|
||||
print(f" {status}: {title}")
|
||||
if notes:
|
||||
print(f" Notes: {notes}")
|
||||
|
||||
print("\n3. Now let's modify the markdown file (simulate user editing):")
|
||||
print("-" * 60)
|
||||
|
||||
# Simulate user changes - flip some completion states
|
||||
modified_content = content.replace(
|
||||
"- [ ] Buy groceries",
|
||||
"- [x] Buy groceries", # Mark as complete
|
||||
).replace(
|
||||
"- [x] Call dentist",
|
||||
"- [ ] Call dentist", # Mark as incomplete
|
||||
)
|
||||
|
||||
# Add a new task
|
||||
modified_content += "- [ ] New task from markdown <!-- id:task005 -->\n"
|
||||
|
||||
print(modified_content)
|
||||
print("-" * 60)
|
||||
|
||||
# Write the modified content
|
||||
with open(demo_file, "w") as f:
|
||||
f.write(modified_content)
|
||||
|
||||
# Parse the changes
|
||||
updated_tasks = sync_engine._read_list_file(demo_file)
|
||||
|
||||
print("\n4. Changes that would sync to Godspeed:")
|
||||
print("-" * 40)
|
||||
|
||||
for i, (task_id, is_complete, title, notes) in enumerate(updated_tasks):
|
||||
if i < len(sample_tasks):
|
||||
old_complete = sample_tasks[i][1]
|
||||
if old_complete != is_complete:
|
||||
action = "MARK COMPLETE" if is_complete else "MARK INCOMPLETE"
|
||||
print(f" 🔄 {action}: {title}")
|
||||
else:
|
||||
status = "✅" if is_complete else "⏳"
|
||||
print(f" {status} No change: {title}")
|
||||
else:
|
||||
print(f" ➕ CREATE NEW: {title}")
|
||||
|
||||
print("\n5. API calls that would be made:")
|
||||
print("-" * 35)
|
||||
print(" PATCH /tasks/task001 {'is_complete': True}")
|
||||
print(" PATCH /tasks/task002 {'is_complete': False}")
|
||||
print(" POST /tasks {'title': 'New task from markdown'}")
|
||||
|
||||
print("\n✨ Summary:")
|
||||
print(" • Checking [x] or [X] in markdown marks task complete in Godspeed")
|
||||
print(" • Unchecking [ ] in markdown marks task incomplete in Godspeed")
|
||||
print(" • Adding new tasks in markdown creates them in Godspeed")
|
||||
print(" • Changes sync both directions during 'godspeed sync'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo_completion_sync()
|
||||
202
demo_notification_compression.py
Normal file
202
demo_notification_compression.py
Normal file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo script for notification email compression.
|
||||
|
||||
This script demonstrates how notification emails are detected and compressed
|
||||
into terminal-friendly summaries.
|
||||
"""
|
||||
|
||||
from src.mail.notification_detector import (
|
||||
is_notification_email,
|
||||
classify_notification,
|
||||
extract_notification_summary,
|
||||
NOTIFICATION_TYPES,
|
||||
)
|
||||
from src.mail.notification_compressor import NotificationCompressor, DetailedCompressor
|
||||
|
||||
|
||||
def demo_detection():
|
||||
"""Demonstrate notification detection for various email types."""
|
||||
|
||||
test_emails = [
|
||||
{
|
||||
"from": {"addr": "notifications@gitlab.com", "name": "GitLab"},
|
||||
"subject": "Pipeline #12345 failed by john.doe",
|
||||
},
|
||||
{
|
||||
"from": {"addr": "noreply@github.com", "name": "GitHub"},
|
||||
"subject": "[GitHub] PR #42: Add new feature",
|
||||
},
|
||||
{
|
||||
"from": {"addr": "jira@atlassian.net", "name": "Jira"},
|
||||
"subject": "[Jira] ABC-123: Fix login bug",
|
||||
},
|
||||
{
|
||||
"from": {"addr": "confluence@atlassian.net", "name": "Confluence"},
|
||||
"subject": "[Confluence] New comment on page",
|
||||
},
|
||||
{
|
||||
"from": {"addr": "alerts@datadoghq.com", "name": "Datadog"},
|
||||
"subject": "[Datadog] Alert: High CPU usage",
|
||||
},
|
||||
{
|
||||
"from": {"addr": "renovate@renovatebot.com", "name": "Renovate"},
|
||||
"subject": "[Renovate] Update dependency to v2.0.0",
|
||||
},
|
||||
{
|
||||
"from": {"addr": "john.doe@example.com", "name": "John Doe"},
|
||||
"subject": "Let's meet for lunch",
|
||||
},
|
||||
]
|
||||
|
||||
print("=" * 70)
|
||||
print("NOTIFICATION DETECTION DEMO")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
for i, envelope in enumerate(test_emails, 1):
|
||||
from_addr = envelope.get("from", {}).get("addr", "")
|
||||
subject = envelope.get("subject", "")
|
||||
|
||||
print(f"Email {i}: {subject}")
|
||||
print(f" From: {from_addr}")
|
||||
|
||||
# Check if notification
|
||||
is_notif = is_notification_email(envelope)
|
||||
print(f" Is Notification: {is_notif}")
|
||||
|
||||
if is_notif:
|
||||
notif_type = classify_notification(envelope)
|
||||
if notif_type:
|
||||
print(f" Type: {notif_type.name}")
|
||||
print(f" Icon: {notif_type.icon}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def demo_compression():
|
||||
"""Demonstrate notification compression."""
|
||||
|
||||
# GitLab pipeline email content (simplified)
|
||||
gitlab_content = """
|
||||
Pipeline #12345 failed by john.doe
|
||||
|
||||
The pipeline failed on stage: build
|
||||
Commit: abc123def
|
||||
|
||||
View pipeline: https://gitlab.com/project/pipelines/12345
|
||||
"""
|
||||
|
||||
# GitHub PR email content (simplified)
|
||||
github_content = """
|
||||
PR #42: Add new feature
|
||||
|
||||
@john.doe requested your review
|
||||
|
||||
View PR: https://github.com/repo/pull/42
|
||||
"""
|
||||
|
||||
gitlab_envelope = {
|
||||
"from": {"addr": "notifications@gitlab.com", "name": "GitLab"},
|
||||
"subject": "Pipeline #12345 failed",
|
||||
"date": "2025-12-28T15:00:00Z",
|
||||
}
|
||||
|
||||
github_envelope = {
|
||||
"from": {"addr": "noreply@github.com", "name": "GitHub"},
|
||||
"subject": "[GitHub] PR #42: Add new feature",
|
||||
"date": "2025-12-28T15:00:00Z",
|
||||
}
|
||||
|
||||
print("=" * 70)
|
||||
print("NOTIFICATION COMPRESSION DEMO")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# GitLab compression - summary mode
|
||||
print("1. GitLab Pipeline (Summary Mode)")
|
||||
print("-" * 70)
|
||||
compressor = NotificationCompressor(mode="summary")
|
||||
compressed, notif_type = compressor.compress(gitlab_content, gitlab_envelope)
|
||||
print(compressed)
|
||||
print()
|
||||
|
||||
# GitLab compression - detailed mode
|
||||
print("2. GitLab Pipeline (Detailed Mode)")
|
||||
print("-" * 70)
|
||||
detailed_compressor = DetailedCompressor(mode="detailed")
|
||||
compressed, notif_type = detailed_compressor.compress(
|
||||
gitlab_content, gitlab_envelope
|
||||
)
|
||||
print(compressed)
|
||||
print()
|
||||
|
||||
# GitHub PR - summary mode
|
||||
print("3. GitHub PR (Summary Mode)")
|
||||
print("-" * 70)
|
||||
compressor = NotificationCompressor(mode="summary")
|
||||
compressed, notif_type = compressor.compress(github_content, github_envelope)
|
||||
print(compressed)
|
||||
print()
|
||||
|
||||
|
||||
def demo_summary_extraction():
|
||||
"""Demonstrate structured summary extraction."""
|
||||
|
||||
test_content = """
|
||||
ABC-123: Fix login bug
|
||||
|
||||
Status changed from In Progress to Done
|
||||
|
||||
View issue: https://jira.atlassian.net/browse/ABC-123
|
||||
"""
|
||||
|
||||
print("=" * 70)
|
||||
print("SUMMARY EXTRACTION DEMO")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
notif_type = NOTIFICATION_TYPES[2] # jira
|
||||
summary = extract_notification_summary(test_content, notif_type)
|
||||
|
||||
print("Extracted Summary:")
|
||||
print(f" Title: {summary.get('title')}")
|
||||
print(f" Metadata: {summary.get('metadata')}")
|
||||
print(f" Action Items: {summary.get('action_items')}")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all demos."""
|
||||
|
||||
print()
|
||||
print("╔" + "═" * 68 + "╗")
|
||||
print("║" + " " * 68 + "║")
|
||||
print("║" + " LUK Notification Email Compression - Feature Demo".center(68) + "║")
|
||||
print("║" + " " * 68 + "║")
|
||||
print("╚" + "═" * 68 + "╝")
|
||||
print()
|
||||
|
||||
# Run demos
|
||||
demo_detection()
|
||||
print()
|
||||
demo_compression()
|
||||
print()
|
||||
demo_summary_extraction()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("DEMO COMPLETE")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("The notification compression feature is now integrated into the mail app.")
|
||||
print("Configure it in ~/.config/luk/mail.toml:")
|
||||
print()
|
||||
print(" [content_display]")
|
||||
print(" compress_notifications = true")
|
||||
print(" notification_compression_mode = 'summary' # or 'detailed' or 'off'")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +1,21 @@
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import msal
|
||||
import aiohttp
|
||||
|
||||
# Suppress debug logging from authentication and HTTP libraries
|
||||
logging.getLogger("msal").setLevel(logging.ERROR)
|
||||
logging.getLogger("urllib3").setLevel(logging.ERROR)
|
||||
logging.getLogger("requests").setLevel(logging.ERROR)
|
||||
logging.getLogger("requests_oauthlib").setLevel(logging.ERROR)
|
||||
logging.getLogger("aiohttp").setLevel(logging.ERROR)
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.ERROR)
|
||||
logging.getLogger("asyncio").setLevel(logging.ERROR)
|
||||
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
@@ -24,11 +34,11 @@ from textual import work
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
# Import file icons utility - note the updated import
|
||||
from utils.file_icons import get_file_icon
|
||||
from src.utils.file_icons import get_file_icon
|
||||
|
||||
# Import our DocumentViewerScreen
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "maildir_gtd"))
|
||||
from maildir_gtd.screens.DocumentViewer import DocumentViewerScreen
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "src", "mail"))
|
||||
from screens.DocumentViewer import DocumentViewerScreen
|
||||
|
||||
|
||||
class FolderHistoryEntry:
|
||||
|
||||
1
himalaya-aarch64-apple-darwin.tar.gz
Normal file
1
himalaya-aarch64-apple-darwin.tar.gz
Normal file
@@ -0,0 +1 @@
|
||||
Not Found
|
||||
187
install.sh
Executable file
187
install.sh
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/bin/bash
|
||||
# Installation script for luk
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if Python 3.12+ is installed
|
||||
check_python() {
|
||||
print_status "Checking Python installation..."
|
||||
|
||||
if command -v python3 &> /dev/null; then
|
||||
PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
|
||||
REQUIRED_VERSION="3.12"
|
||||
|
||||
if python3 -c "import sys; exit(0 if sys.version_info >= (3, 12) else 1)"; then
|
||||
print_status "Python $PYTHON_VERSION found ✓"
|
||||
else
|
||||
print_error "Python $REQUIRED_VERSION or higher is required. Found: $PYTHON_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
print_error "Python 3 is not installed"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if uv is installed, install if not
|
||||
check_uv() {
|
||||
print_status "Checking uv installation..."
|
||||
|
||||
if command -v uv &> /dev/null; then
|
||||
print_status "uv found ✓"
|
||||
else
|
||||
print_warning "uv not found, installing..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
if command -v uv &> /dev/null; then
|
||||
print_status "uv installed successfully ✓"
|
||||
else
|
||||
print_error "Failed to install uv"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Install the package
|
||||
install_package() {
|
||||
print_status "Installing luk..."
|
||||
|
||||
# Create virtual environment and install
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
uv pip install -e .
|
||||
|
||||
print_status "Installation completed ✓"
|
||||
}
|
||||
|
||||
# Setup configuration directories
|
||||
setup_config() {
|
||||
print_status "Setting up configuration directories..."
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p "$HOME/.config/luk"
|
||||
mkdir -p "$HOME/.local/share/luk"
|
||||
mkdir -p "$HOME/.local/share/luk/logs"
|
||||
|
||||
# Create example configuration
|
||||
cat > "$HOME/.config/luk/config.env" << EOF
|
||||
# luk Configuration
|
||||
# Copy this file and modify as needed
|
||||
|
||||
# Microsoft Graph settings
|
||||
# These will be prompted for on first run
|
||||
# MICROSOFT_CLIENT_ID=your_client_id
|
||||
# MICROSOFT_TENANT_ID=your_tenant_id
|
||||
|
||||
# Email settings
|
||||
MAILDIR_PATH=~/Mail
|
||||
NOTES_DIR=~/Documents/Notes
|
||||
|
||||
# Godspeed settings
|
||||
# GODSPEED_EMAIL=your_email@example.com
|
||||
# GODSPEED_PASSWORD=your_password
|
||||
# GODSPEED_TOKEN=your_token
|
||||
# GODSPEED_SYNC_DIR=~/Documents/Godspeed
|
||||
|
||||
# TickTick settings
|
||||
# TICKTICK_CLIENT_ID=your_client_id
|
||||
# TICKTICK_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# Sync settings
|
||||
DEFAULT_ORG=corteva
|
||||
DEFAULT_CALENDAR_DIR=~/Calendar
|
||||
SYNC_INTERVAL=300 # 5 minutes
|
||||
LOG_LEVEL=INFO
|
||||
EOF
|
||||
|
||||
print_status "Configuration directories created ✓"
|
||||
print_warning "Please edit $HOME/.config/luk/config.env with your settings"
|
||||
}
|
||||
|
||||
# Create shell completions
|
||||
setup_completions() {
|
||||
print_status "Setting up shell completions..."
|
||||
|
||||
# Get the shell type
|
||||
SHELL_TYPE=$(basename "$SHELL")
|
||||
|
||||
case $SHELL_TYPE in
|
||||
bash)
|
||||
echo 'eval "$(_LUK_COMPLETE=bash_source luk)"' >> "$HOME/.bashrc"
|
||||
print_status "Bash completions added to ~/.bashrc"
|
||||
;;
|
||||
zsh)
|
||||
echo 'eval "$(_LUK_COMPLETE=zsh_source luk)"' >> "$HOME/.zshrc"
|
||||
print_status "Zsh completions added to ~/.zshrc"
|
||||
;;
|
||||
fish)
|
||||
echo 'luk --completion | source' >> "$HOME/.config/fish/config.fish"
|
||||
print_status "Fish completions added to ~/.config/fish/config.fish"
|
||||
;;
|
||||
*)
|
||||
print_warning "Unsupported shell: $SHELL_TYPE"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Run tests
|
||||
run_tests() {
|
||||
print_status "Running tests..."
|
||||
|
||||
source .venv/bin/activate
|
||||
if uv run pytest tests/ -v; then
|
||||
print_status "All tests passed ✓"
|
||||
else
|
||||
print_warning "Some tests failed, but installation will continue"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
main() {
|
||||
echo
|
||||
echo " luk - Look at your Outlook data locally"
|
||||
echo " ========================================="
|
||||
echo
|
||||
print_status "Starting luk installation..."
|
||||
|
||||
check_python
|
||||
check_uv
|
||||
install_package
|
||||
setup_config
|
||||
setup_completions
|
||||
run_tests
|
||||
|
||||
print_status "Installation completed successfully! 🎉"
|
||||
echo
|
||||
print_status "To get started:"
|
||||
echo " 1. Source your shell profile: source ~/.bashrc (or ~/.zshrc)"
|
||||
echo " 2. Configure your settings in ~/.config/luk/config.env"
|
||||
echo " 3. Run: luk sync --help"
|
||||
echo " 4. Try the dashboard: luk sync run --dashboard"
|
||||
echo " 5. Start the daemon: luk sync run --daemon"
|
||||
echo
|
||||
print_status "For more information, see: https://github.com/timothybendt/luk"
|
||||
}
|
||||
|
||||
# Run the installation
|
||||
main "$@"
|
||||
326
luk.egg-info/PKG-INFO
Normal file
326
luk.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,326 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: luk
|
||||
Version: 0.1.0
|
||||
Summary: A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local file-based formats. Look at your Outlook data locally.
|
||||
Author-email: Timothy Bendt <timothy@example.com>
|
||||
License: MIT
|
||||
Project-URL: Homepage, https://github.com/timothybendt/luk
|
||||
Project-URL: Repository, https://github.com/timothybendt/luk
|
||||
Project-URL: Issues, https://github.com/timothybendt/luk/issues
|
||||
Project-URL: Documentation, https://github.com/timothybendt/luk#readme
|
||||
Keywords: email,calendar,tasks,sync,cli,microsoft-graph,outlook,maildir,vdir
|
||||
Classifier: Development Status :: 4 - Beta
|
||||
Classifier: Environment :: Console
|
||||
Classifier: Intended Audience :: End Users/Desktop
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Classifier: Programming Language :: Python :: 3.13
|
||||
Classifier: Topic :: Communications :: Email
|
||||
Classifier: Topic :: Office/Business :: Scheduling
|
||||
Classifier: Topic :: Utilities
|
||||
Requires-Python: >=3.12
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: aiohttp>=3.11.18
|
||||
Requires-Dist: certifi>=2025.4.26
|
||||
Requires-Dist: click>=8.1.0
|
||||
Requires-Dist: html2text>=2025.4.15
|
||||
Requires-Dist: mammoth>=1.9.0
|
||||
Requires-Dist: markitdown[all]>=0.1.1
|
||||
Requires-Dist: msal>=1.32.3
|
||||
Requires-Dist: openai>=1.78.1
|
||||
Requires-Dist: orjson>=3.10.18
|
||||
Requires-Dist: pillow>=11.2.1
|
||||
Requires-Dist: pydantic>=2.0.0
|
||||
Requires-Dist: pydantic-settings>=2.0.0
|
||||
Requires-Dist: python-dateutil>=2.9.0.post0
|
||||
Requires-Dist: python-docx>=1.1.2
|
||||
Requires-Dist: requests>=2.31.0
|
||||
Requires-Dist: rich>=14.0.0
|
||||
Requires-Dist: textual>=3.2.0
|
||||
Requires-Dist: textual-image>=0.8.2
|
||||
Requires-Dist: ticktick-py>=2.0.0
|
||||
Requires-Dist: toml>=0.10.0
|
||||
|
||||
# luk
|
||||
|
||||
> Pronounced "look" - as in "look at your Outlook data locally"
|
||||
|
||||
A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local file-based formats like Maildir and vdir. Use your favorite terminal tools to manage your email and calendar.
|
||||
|
||||
## Features
|
||||
|
||||
- **Email Synchronization**: Sync emails with Microsoft Graph API to local Maildir format
|
||||
- **Calendar Management**: Two-way calendar sync with vdir/ICS support
|
||||
- **Task Integration**: Sync with Godspeed and TickTick task managers
|
||||
- **TUI Dashboard**: Interactive terminal dashboard for monitoring sync progress
|
||||
- **Daemon Mode**: Background daemon with proper Unix logging
|
||||
- **Cross-Platform**: Works on macOS, Linux, and Windows
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12 or higher
|
||||
- `uv` package manager (recommended)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/timothybendt/luk.git
|
||||
cd luk
|
||||
|
||||
# Run the installation script
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -e .
|
||||
|
||||
# Setup configuration directories
|
||||
mkdir -p ~/.config/luk
|
||||
mkdir -p ~/.local/share/luk
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a configuration file at `~/.config/luk/config.env`:
|
||||
|
||||
```bash
|
||||
# Microsoft Graph settings
|
||||
MICROSOFT_CLIENT_ID=your_client_id
|
||||
MICROSOFT_TENANT_ID=your_tenant_id
|
||||
|
||||
# Email settings
|
||||
MAILDIR_PATH=~/Mail
|
||||
NOTES_DIR=~/Documents/Notes
|
||||
|
||||
# Godspeed settings
|
||||
GODSPEED_EMAIL=your_email@example.com
|
||||
GODSPEED_PASSWORD=your_password
|
||||
GODSPEED_TOKEN=your_token
|
||||
GODSPEED_SYNC_DIR=~/Documents/Godspeed
|
||||
|
||||
# TickTick settings
|
||||
TICKTICK_CLIENT_ID=your_client_id
|
||||
TICKTICK_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
luk --help
|
||||
|
||||
# Run sync with default settings
|
||||
luk sync run
|
||||
|
||||
# Run with TUI dashboard
|
||||
luk sync run --dashboard
|
||||
|
||||
# Start daemon mode
|
||||
luk sync run --daemon
|
||||
|
||||
# Stop daemon
|
||||
luk sync stop
|
||||
|
||||
# Check daemon status
|
||||
luk sync status
|
||||
```
|
||||
|
||||
### Sync Options
|
||||
|
||||
```bash
|
||||
# Dry run (no changes)
|
||||
luk sync run --dry-run
|
||||
|
||||
# Specify organization
|
||||
luk sync run --org mycompany
|
||||
|
||||
# Enable notifications
|
||||
luk sync run --notify
|
||||
|
||||
# Download attachments
|
||||
luk sync run --download-attachments
|
||||
|
||||
# Two-way calendar sync
|
||||
luk sync run --two-way-calendar
|
||||
|
||||
# Custom calendar directory
|
||||
luk sync run --vdir ~/Calendars
|
||||
```
|
||||
|
||||
### Dashboard Mode
|
||||
|
||||
The TUI dashboard provides real-time monitoring of sync operations:
|
||||
|
||||
- **Status Display**: Current sync status and metrics
|
||||
- **Progress Bars**: Visual progress for each sync component
|
||||
- **Activity Log**: Scrollable log of all sync activities
|
||||
- **Keyboard Shortcuts**:
|
||||
- `q`: Quit dashboard
|
||||
- `l`: Toggle log visibility
|
||||
- `r`: Refresh status
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
Run luk as a background daemon with proper Unix logging:
|
||||
|
||||
```bash
|
||||
# Start daemon
|
||||
luk sync run --daemon
|
||||
|
||||
# Check status
|
||||
luk sync status
|
||||
|
||||
# View logs
|
||||
cat ~/.local/share/luk/luk.log
|
||||
|
||||
# Stop daemon
|
||||
luk sync stop
|
||||
```
|
||||
|
||||
Daemon logs are stored at `~/.local/share/luk/luk.log` with automatic rotation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- **Sync Engine**: Handles email, calendar, and task synchronization
|
||||
- **TUI Dashboard**: Interactive monitoring interface using Textual
|
||||
- **Daemon Service**: Background service with logging and process management
|
||||
- **Configuration**: Environment-based configuration system
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── cli/ # CLI commands and interfaces
|
||||
│ ├── sync.py # Main sync command
|
||||
│ ├── sync_dashboard.py # TUI dashboard
|
||||
│ ├── sync_daemon.py # Daemon service
|
||||
│ └── ...
|
||||
├── services/ # External service integrations
|
||||
│ ├── microsoft_graph/ # Microsoft Graph API
|
||||
│ ├── godspeed/ # Godspeed task manager
|
||||
│ ├── ticktick/ # TickTick API
|
||||
│ └── ...
|
||||
└── utils/ # Utility functions
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Setup Development Environment
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/timothybendt/luk.git
|
||||
cd luk
|
||||
|
||||
# Install development dependencies
|
||||
uv sync --dev
|
||||
|
||||
# Run tests
|
||||
uv run pytest
|
||||
|
||||
# Run linting
|
||||
uv run ruff check .
|
||||
uv run ruff format .
|
||||
|
||||
# Type checking
|
||||
uv run mypy src/
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
- `pyproject.toml`: Project configuration and dependencies
|
||||
- `src/cli/`: CLI commands and user interfaces
|
||||
- `src/services/`: External service integrations
|
||||
- `src/utils/`: Shared utilities and helpers
|
||||
- `tests/`: Test suite
|
||||
|
||||
### Building for Distribution
|
||||
|
||||
```bash
|
||||
# Build package
|
||||
uv run build
|
||||
|
||||
# Check package
|
||||
uv run twine check dist/*
|
||||
|
||||
# Upload to PyPI (for maintainers)
|
||||
uv run twine upload dist/*
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Authentication Errors**: Ensure Microsoft Graph credentials are properly configured
|
||||
2. **Permission Denied**: Check file permissions for Maildir and calendar directories
|
||||
3. **Daemon Not Starting**: Verify log directory exists and is writable
|
||||
4. **TUI Not Rendering**: Ensure terminal supports Textual requirements
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```bash
|
||||
export LOG_LEVEL=DEBUG
|
||||
luk sync run --dry-run
|
||||
```
|
||||
|
||||
### Log Files
|
||||
|
||||
- **Daemon Logs**: `~/.local/share/luk/luk.log`
|
||||
- **Sync State**: `~/.local/share/luk/sync_state.json`
|
||||
- **Configuration**: `~/.config/luk/`
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Run the test suite
|
||||
6. Submit a pull request
|
||||
|
||||
### Code Style
|
||||
|
||||
This project uses:
|
||||
- **Ruff** for linting and formatting
|
||||
- **MyPy** for type checking
|
||||
- **Black** for code formatting
|
||||
- **Pre-commit** hooks for quality control
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/timothybendt/luk/issues)
|
||||
- **Documentation**: [GitHub Wiki](https://github.com/timothybendt/luk/wiki)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/timothybendt/luk/discussions)
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.1.0
|
||||
- Initial release
|
||||
- Email synchronization with Microsoft Graph
|
||||
- Calendar sync with vdir/ICS support
|
||||
- Godspeed and TickTick integration
|
||||
- TUI dashboard
|
||||
- Daemon mode with logging
|
||||
- Cross-platform support
|
||||
102
luk.egg-info/SOURCES.txt
Normal file
102
luk.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,102 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
luk.egg-info/PKG-INFO
|
||||
luk.egg-info/SOURCES.txt
|
||||
luk.egg-info/dependency_links.txt
|
||||
luk.egg-info/entry_points.txt
|
||||
luk.egg-info/requires.txt
|
||||
luk.egg-info/top_level.txt
|
||||
src/calendar/__init__.py
|
||||
src/calendar/app.py
|
||||
src/calendar/backend.py
|
||||
src/calendar/screens/__init__.py
|
||||
src/calendar/widgets/WeekGrid.py
|
||||
src/calendar/widgets/__init__.py
|
||||
src/cli/__init__.py
|
||||
src/cli/__main__.py
|
||||
src/cli/calendar.py
|
||||
src/cli/drive.py
|
||||
src/cli/email.py
|
||||
src/cli/gitlab_monitor.py
|
||||
src/cli/godspeed.py
|
||||
src/cli/sync.py
|
||||
src/cli/sync_daemon.py
|
||||
src/cli/sync_dashboard.py
|
||||
src/cli/tasks.py
|
||||
src/cli/ticktick.py
|
||||
src/mail/__init__.py
|
||||
src/mail/app.py
|
||||
src/mail/config.py
|
||||
src/mail/email_viewer.tcss
|
||||
src/mail/message_store.py
|
||||
src/mail/utils.py
|
||||
src/mail/actions/__init__.py
|
||||
src/mail/actions/archive.py
|
||||
src/mail/actions/delete.py
|
||||
src/mail/actions/newest.py
|
||||
src/mail/actions/next.py
|
||||
src/mail/actions/oldest.py
|
||||
src/mail/actions/open.py
|
||||
src/mail/actions/previous.py
|
||||
src/mail/actions/show_message.py
|
||||
src/mail/actions/task.py
|
||||
src/mail/screens/ConfirmDialog.py
|
||||
src/mail/screens/CreateTask.py
|
||||
src/mail/screens/DocumentViewer.py
|
||||
src/mail/screens/LinkPanel.py
|
||||
src/mail/screens/OpenMessage.py
|
||||
src/mail/screens/__init__.py
|
||||
src/mail/widgets/ContentContainer.py
|
||||
src/mail/widgets/EnvelopeHeader.py
|
||||
src/mail/widgets/EnvelopeListItem.py
|
||||
src/mail/widgets/__init__.py
|
||||
src/services/__init__.py
|
||||
src/services/task_client.py
|
||||
src/services/dstask/__init__.py
|
||||
src/services/dstask/client.py
|
||||
src/services/gitlab_monitor/__init__.py
|
||||
src/services/gitlab_monitor/config.py
|
||||
src/services/gitlab_monitor/daemon.py
|
||||
src/services/gitlab_monitor/gitlab_client.py
|
||||
src/services/gitlab_monitor/notifications.py
|
||||
src/services/gitlab_monitor/openai_analyzer.py
|
||||
src/services/godspeed/__init__.py
|
||||
src/services/godspeed/client.py
|
||||
src/services/godspeed/config.py
|
||||
src/services/godspeed/sync.py
|
||||
src/services/himalaya/__init__.py
|
||||
src/services/himalaya/client.py
|
||||
src/services/khal/__init__.py
|
||||
src/services/khal/client.py
|
||||
src/services/microsoft_graph/__init__.py
|
||||
src/services/microsoft_graph/auth.py
|
||||
src/services/microsoft_graph/calendar.py
|
||||
src/services/microsoft_graph/client.py
|
||||
src/services/microsoft_graph/mail.py
|
||||
src/services/taskwarrior/__init__.py
|
||||
src/services/taskwarrior/client.py
|
||||
src/services/ticktick/__init__.py
|
||||
src/services/ticktick/auth.py
|
||||
src/services/ticktick/client.py
|
||||
src/services/ticktick/direct_client.py
|
||||
src/tasks/__init__.py
|
||||
src/tasks/app.py
|
||||
src/tasks/backend.py
|
||||
src/tasks/config.py
|
||||
src/tasks/screens/AddTaskScreen.py
|
||||
src/tasks/screens/FilterScreens.py
|
||||
src/tasks/screens/NotesEditor.py
|
||||
src/tasks/screens/__init__.py
|
||||
src/tasks/widgets/AddTaskForm.py
|
||||
src/tasks/widgets/__init__.py
|
||||
src/utils/calendar_utils.py
|
||||
src/utils/file_icons.py
|
||||
src/utils/notifications.py
|
||||
src/utils/platform.py
|
||||
src/utils/ticktick_utils.py
|
||||
src/utils/mail_utils/__init__.py
|
||||
src/utils/mail_utils/helpers.py
|
||||
src/utils/mail_utils/maildir.py
|
||||
tests/test_platform.py
|
||||
tests/test_sync_daemon.py
|
||||
tests/test_sync_dashboard.py
|
||||
1
luk.egg-info/dependency_links.txt
Normal file
1
luk.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2
luk.egg-info/entry_points.txt
Normal file
2
luk.egg-info/entry_points.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
luk = src.cli.__main__:main
|
||||
20
luk.egg-info/requires.txt
Normal file
20
luk.egg-info/requires.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
aiohttp>=3.11.18
|
||||
certifi>=2025.4.26
|
||||
click>=8.1.0
|
||||
html2text>=2025.4.15
|
||||
mammoth>=1.9.0
|
||||
markitdown[all]>=0.1.1
|
||||
msal>=1.32.3
|
||||
openai>=1.78.1
|
||||
orjson>=3.10.18
|
||||
pillow>=11.2.1
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dateutil>=2.9.0.post0
|
||||
python-docx>=1.1.2
|
||||
requests>=2.31.0
|
||||
rich>=14.0.0
|
||||
textual>=3.2.0
|
||||
textual-image>=0.8.2
|
||||
ticktick-py>=2.0.0
|
||||
toml>=0.10.0
|
||||
1
luk.egg-info/top_level.txt
Normal file
1
luk.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
src
|
||||
82
mail.toml.example
Normal file
82
mail.toml.example
Normal file
@@ -0,0 +1,82 @@
|
||||
# LUK Mail Configuration Example
|
||||
# Copy this file to ~/.config/luk/mail.toml and customize
|
||||
|
||||
# [task]
|
||||
# # Task management backend (taskwarrior or dstask)
|
||||
# backend = "taskwarrior"
|
||||
# taskwarrior_path = "task"
|
||||
# dstask_path = "~/.local/bin/dstask"
|
||||
|
||||
[envelope_display]
|
||||
# Sender name maximum length before truncation
|
||||
max_sender_length = 25
|
||||
|
||||
# Date/time formatting
|
||||
date_format = "%m/%d"
|
||||
time_format = "%H:%M"
|
||||
show_date = true
|
||||
show_time = true
|
||||
|
||||
# Group envelopes by date
|
||||
# "relative" = Today, Yesterday, This Week, etc.
|
||||
# "absolute" = December 2025, November 2025, etc.
|
||||
group_by = "relative"
|
||||
|
||||
# Layout: 2-line or 3-line (3-line shows preview)
|
||||
lines = 2
|
||||
show_checkbox = true
|
||||
show_preview = false
|
||||
|
||||
# NerdFont icons
|
||||
icon_unread = "\uf0e0" # nf-fa-envelope (filled)
|
||||
icon_read = "\uf2b6" # nf-fa-envelope_open (open)
|
||||
icon_flagged = "\uf024" # nf-fa-flag
|
||||
icon_attachment = "\uf0c6" # nf-fa-paperclip
|
||||
|
||||
[content_display]
|
||||
# Default view mode: "markdown" or "html"
|
||||
default_view_mode = "markdown"
|
||||
|
||||
# URL compression settings
|
||||
compress_urls = true
|
||||
max_url_length = 50
|
||||
|
||||
# Notification email compression
|
||||
# "summary" - Brief one-page summary
|
||||
# "detailed" - More details in structured format
|
||||
# "off" - Disable notification compression
|
||||
compress_notifications = true
|
||||
notification_compression_mode = "summary"
|
||||
|
||||
[link_panel]
|
||||
# Close link panel after opening a link
|
||||
close_on_open = false
|
||||
|
||||
[mail]
|
||||
# Default folder to archive messages to
|
||||
archive_folder = "Archive"
|
||||
|
||||
[keybindings]
|
||||
# Custom keybindings (leave blank to use defaults)
|
||||
# next_message = "j"
|
||||
# prev_message = "k"
|
||||
# delete = "#"
|
||||
# archive = "e"
|
||||
# open_by_id = "o"
|
||||
# quit = "q"
|
||||
# toggle_header = "h"
|
||||
# create_task = "t"
|
||||
# reload = "%"
|
||||
# toggle_sort = "s"
|
||||
# toggle_selection = "space"
|
||||
# clear_selection = "escape"
|
||||
# scroll_page_down = "pagedown"
|
||||
# scroll_page_up = "b"
|
||||
# toggle_main_content = "w"
|
||||
# open_links = "l"
|
||||
# toggle_view_mode = "m"
|
||||
|
||||
[theme]
|
||||
# Textual theme name
|
||||
# Available themes: monokai, dracula, gruvbox, nord, etc.
|
||||
theme_name = "monokai"
|
||||
7
mise.toml
Normal file
7
mise.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[tools]
|
||||
bun = "latest"
|
||||
node = "22.17.1"
|
||||
uv = 'latest'
|
||||
|
||||
[settings]
|
||||
python.uv_venv_auto = true
|
||||
111
pyproject.toml
111
pyproject.toml
@@ -1,27 +1,134 @@
|
||||
[project]
|
||||
name = "gtd-terminal-tools"
|
||||
name = "luk"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
description = "A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local file-based formats. Look at your Outlook data locally."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Timothy Bendt", email = "timothy@example.com"}
|
||||
]
|
||||
keywords = ["email", "calendar", "tasks", "sync", "cli", "microsoft-graph", "outlook", "maildir", "vdir"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Communications :: Email",
|
||||
"Topic :: Office/Business :: Scheduling",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"aiohttp>=3.11.18",
|
||||
"certifi>=2025.4.26",
|
||||
"click>=8.1.0",
|
||||
"html2text>=2025.4.15",
|
||||
"icalendar>=6.0.0",
|
||||
"mammoth>=1.9.0",
|
||||
"markitdown[all]>=0.1.1",
|
||||
"msal>=1.32.3",
|
||||
"openai>=1.78.1",
|
||||
"orjson>=3.10.18",
|
||||
"pillow>=11.2.1",
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dateutil>=2.9.0.post0",
|
||||
"python-docx>=1.1.2",
|
||||
"requests>=2.31.0",
|
||||
"rich>=14.0.0",
|
||||
"textual>=3.2.0",
|
||||
"textual-image>=0.8.2",
|
||||
"ticktick-py>=2.0.0",
|
||||
"toml>=0.10.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
luk = "src.cli.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/timothybendt/luk"
|
||||
Repository = "https://github.com/timothybendt/luk"
|
||||
Issues = "https://github.com/timothybendt/luk/issues"
|
||||
Documentation = "https://github.com/timothybendt/luk#readme"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.11.8",
|
||||
"textual>=3.2.0",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=6.0.0",
|
||||
"black>=24.0.0",
|
||||
"mypy>=1.8.0",
|
||||
"pre-commit>=3.5.0",
|
||||
"build>=1.0.0",
|
||||
"twine>=5.0.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py312"
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "DTZ", "T10", "EM", "ISC", "ICN", "G", "PIE", "PYI", "PT", "Q", "RSE", "RET", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", "PGH", "PL", "TRY", "NPY", "RUF"]
|
||||
ignore = ["E501", "PLR0913", "PLR0915"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
strict_equality = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"--cov=src",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html",
|
||||
"--cov-fail-under=80",
|
||||
"-v"
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py312']
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
# directories
|
||||
\.eggs
|
||||
| \.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["src*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["*.tcss", "*.css", "*.json", "*.md"]
|
||||
|
||||
152
sendmail
Executable file
152
sendmail
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sendmail-compatible wrapper for Microsoft Graph email sending.
|
||||
Queues emails in maildir format for processing by the sync daemon.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from email.parser import Parser
|
||||
from email.utils import parseaddr
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from src.utils.mail_utils.helpers import ensure_directory_exists
|
||||
|
||||
|
||||
def extract_org_from_email(email_address: str) -> str:
|
||||
"""
|
||||
Extract organization name from email address domain.
|
||||
|
||||
Args:
|
||||
email_address: Email address like "user@corteva.com"
|
||||
|
||||
Returns:
|
||||
Organization name (e.g., "corteva")
|
||||
"""
|
||||
if "@" not in email_address:
|
||||
return "default"
|
||||
|
||||
domain = email_address.split("@")[1].lower()
|
||||
|
||||
# Map known domains to org names
|
||||
domain_to_org = {
|
||||
"corteva.com": "corteva",
|
||||
# Add more domain mappings as needed
|
||||
}
|
||||
|
||||
return domain_to_org.get(domain, domain.split(".")[0])
|
||||
|
||||
|
||||
def create_outbox_structure(base_path: str, org: str):
|
||||
"""
|
||||
Create maildir structure for outbox.
|
||||
|
||||
Args:
|
||||
base_path: Base maildir path (e.g., ~/Mail)
|
||||
org: Organization name
|
||||
"""
|
||||
org_path = os.path.join(base_path, org, "outbox")
|
||||
ensure_directory_exists(os.path.join(org_path, "new"))
|
||||
ensure_directory_exists(os.path.join(org_path, "cur"))
|
||||
ensure_directory_exists(os.path.join(org_path, "tmp"))
|
||||
ensure_directory_exists(os.path.join(org_path, "failed"))
|
||||
|
||||
|
||||
def queue_email(email_content: str, org: str) -> bool:
|
||||
"""
|
||||
Queue email in maildir outbox for sending.
|
||||
|
||||
Args:
|
||||
email_content: Raw email content
|
||||
org: Organization name
|
||||
|
||||
Returns:
|
||||
True if queued successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get base maildir path
|
||||
base_path = os.path.expanduser(os.getenv("MAILDIR_PATH", "~/Mail"))
|
||||
|
||||
# Create outbox structure
|
||||
create_outbox_structure(base_path, org)
|
||||
|
||||
# Generate unique filename
|
||||
timestamp = str(int(time.time() * 1000000))
|
||||
hostname = os.uname().nodename
|
||||
filename = f"{timestamp}.{os.getpid()}.{hostname}"
|
||||
|
||||
# Write to tmp first, then move to new (atomic operation)
|
||||
tmp_path = os.path.join(base_path, org, "outbox", "tmp", filename)
|
||||
new_path = os.path.join(base_path, org, "outbox", "new", filename)
|
||||
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
f.write(email_content)
|
||||
|
||||
os.rename(tmp_path, new_path)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to queue email: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main sendmail wrapper function.
|
||||
Reads email from stdin and queues it for sending.
|
||||
"""
|
||||
# Set up basic logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(os.path.expanduser("~/Mail/sendmail.log")),
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
# Read email from stdin
|
||||
email_content = sys.stdin.read()
|
||||
|
||||
if not email_content.strip():
|
||||
logging.error("No email content received")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse email to extract From header
|
||||
parser = Parser()
|
||||
msg = parser.parsestr(email_content)
|
||||
|
||||
from_header = msg.get("From", "")
|
||||
if not from_header:
|
||||
logging.error("No From header found in email")
|
||||
sys.exit(1)
|
||||
|
||||
# Extract email address from From header
|
||||
_, from_email = parseaddr(from_header)
|
||||
if not from_email:
|
||||
logging.error(f"Could not parse email address from From header: {from_header}")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine organization from email domain
|
||||
org = extract_org_from_email(from_email)
|
||||
|
||||
# Queue the email
|
||||
if queue_email(email_content, org):
|
||||
logging.info(f"Email queued successfully for org: {org}, from: {from_email}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logging.error("Failed to queue email")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Sendmail wrapper error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -26,13 +26,13 @@
|
||||
gsub(/mailto:[^[:space:]]*/, "")
|
||||
|
||||
# Clean up email headers - make them bold
|
||||
if (/^From:/) { gsub(/^From:[[:space:]]*/, "**From:** ") }
|
||||
if (/^To:/) { gsub(/^To:[[:space:]]*/, "**To:** ") }
|
||||
if (/^Subject:/) { gsub(/^Subject:[[:space:]]*/, "**Subject:** ") }
|
||||
if (/^Date:/) { gsub(/^Date:[[:space:]]*/, "**Date:** ") }
|
||||
# if (/^From:/) { gsub(/^From:[[:space:]]*/, "**From:** ") }
|
||||
# if (/^To:/) { gsub(/^To:[[:space:]]*/, "**To:** ") }
|
||||
# if (/^Subject:/) { gsub(/^Subject:[[:space:]]*/, "**Subject:** ") }
|
||||
# if (/^Date:/) { gsub(/^Date:[[:space:]]*/, "**Date:** ") }
|
||||
|
||||
# Skip empty lines
|
||||
if (/^[[:space:]]*$/) next
|
||||
|
||||
print
|
||||
}
|
||||
}
|
||||
|
||||
6
src/calendar/__init__.py
Normal file
6
src/calendar/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Calendar TUI package."""
|
||||
|
||||
from .backend import CalendarBackend, Event
|
||||
from .app import CalendarApp, run_app
|
||||
|
||||
__all__ = ["CalendarBackend", "Event", "CalendarApp", "run_app"]
|
||||
698
src/calendar/app.py
Normal file
698
src/calendar/app.py
Normal file
@@ -0,0 +1,698 @@
|
||||
"""Calendar TUI application.
|
||||
|
||||
A Textual-based TUI for viewing calendar events via khal.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.logging import TextualHandler
|
||||
from textual.widgets import Footer, Header, Static, Input
|
||||
from textual.reactive import reactive
|
||||
|
||||
from src.calendar.backend import CalendarBackend, Event
|
||||
from src.calendar.widgets.WeekGrid import WeekGrid
|
||||
from src.calendar.widgets.MonthCalendar import MonthCalendar
|
||||
from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite
|
||||
from src.calendar.widgets.AddEventForm import EventFormData
|
||||
from src.utils.shared_config import get_theme_name
|
||||
from src.utils.ipc import IPCListener, IPCMessage
|
||||
|
||||
# Add the parent directory to the system path to resolve relative imports
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
logging.basicConfig(
|
||||
level="NOTSET",
|
||||
handlers=[TextualHandler()],
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalendarStatusBar(Static):
|
||||
"""Status bar showing current week and selected event."""
|
||||
|
||||
week_label: str = ""
|
||||
event_info: str = ""
|
||||
|
||||
def render(self) -> str:
|
||||
if self.event_info:
|
||||
return f"{self.week_label} | {self.event_info}"
|
||||
return self.week_label
|
||||
|
||||
|
||||
class CalendarApp(App):
|
||||
"""A TUI for viewing calendar events via khal."""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 26;
|
||||
border-right: solid $surface-darken-1;
|
||||
background: $surface;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
#sidebar.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidebar-calendar {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#sidebar-invites {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
border-top: solid $surface-darken-1;
|
||||
padding-top: 1;
|
||||
}
|
||||
|
||||
#week-grid {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#week-grid > WeekGridHeader {
|
||||
height: 1;
|
||||
dock: top;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#week-grid > WeekGridBody {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
background: $surface;
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#event-detail {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
border-top: solid $primary;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#event-detail.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#search-container {
|
||||
dock: top;
|
||||
height: 4;
|
||||
width: 100%;
|
||||
background: $surface;
|
||||
border-bottom: solid $primary;
|
||||
padding: 0 1;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
#search-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#search-container .search-label {
|
||||
width: auto;
|
||||
padding: 0 1;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
#search-results {
|
||||
dock: bottom;
|
||||
height: 40%;
|
||||
border-top: solid $primary;
|
||||
background: $surface;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#search-results.hidden {
|
||||
display: none;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit", show=True),
|
||||
Binding("j", "cursor_down", "Down", show=False),
|
||||
Binding("k", "cursor_up", "Up", show=False),
|
||||
Binding("h", "cursor_left", "Left", show=False),
|
||||
Binding("l", "cursor_right", "Right", show=False),
|
||||
Binding("H", "prev_week", "Prev Week", show=True),
|
||||
Binding("L", "next_week", "Next Week", show=True),
|
||||
Binding("g", "goto_today", "Today", show=True),
|
||||
Binding("w", "toggle_weekends", "Weekends", show=True),
|
||||
Binding("s", "toggle_sidebar", "Sidebar", show=True),
|
||||
Binding("i", "focus_invites", "Invites", show=True),
|
||||
Binding("r", "refresh", "Refresh", show=True),
|
||||
Binding("enter", "view_event", "View", show=True),
|
||||
Binding("a", "add_event", "Add", show=True),
|
||||
Binding("slash", "search", "Search", show=True),
|
||||
Binding("escape", "clear_search", "Clear Search", show=False),
|
||||
Binding("?", "help", "Help", show=True),
|
||||
]
|
||||
|
||||
# Reactive attributes
|
||||
include_weekends: reactive[bool] = reactive(True)
|
||||
show_sidebar: reactive[bool] = reactive(True)
|
||||
|
||||
# Instance attributes
|
||||
backend: Optional[CalendarBackend]
|
||||
_invites: list[CalendarInvite]
|
||||
_search_results: list[Event]
|
||||
|
||||
def __init__(self, backend: Optional[CalendarBackend] = None):
|
||||
super().__init__()
|
||||
self._invites = []
|
||||
self._search_results = []
|
||||
|
||||
if backend:
|
||||
self.backend = backend
|
||||
else:
|
||||
# Create backend from config (default: khal)
|
||||
from src.services.khal import KhalClient
|
||||
|
||||
self.backend = KhalClient()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the app layout."""
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Static("\uf002 Search:", classes="search-label"), # nf-fa-search
|
||||
Input(placeholder="Search events...", id="search-input", disabled=True),
|
||||
id="search-container",
|
||||
classes="hidden",
|
||||
)
|
||||
with Horizontal(id="main-content"):
|
||||
with Vertical(id="sidebar"):
|
||||
yield MonthCalendar(id="sidebar-calendar")
|
||||
yield InvitesPanel(id="sidebar-invites")
|
||||
yield WeekGrid(id="week-grid")
|
||||
yield Static(id="search-results", classes="hidden")
|
||||
yield Static(id="event-detail", classes="hidden")
|
||||
yield CalendarStatusBar(id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the app on mount."""
|
||||
self.theme = get_theme_name()
|
||||
|
||||
# Start IPC listener for refresh notifications from sync daemon
|
||||
self._ipc_listener = IPCListener("calendar", self._on_ipc_message)
|
||||
self._ipc_listener.start()
|
||||
|
||||
# Load events for current week
|
||||
self.load_events()
|
||||
|
||||
# Sync sidebar calendar with current week
|
||||
self._sync_sidebar_calendar()
|
||||
|
||||
# Load invites in background
|
||||
self.run_worker(self._load_invites_async(), exclusive=True)
|
||||
|
||||
# Update status bar and title
|
||||
self._update_status()
|
||||
self._update_title()
|
||||
|
||||
# Focus the week grid (not the hidden search input)
|
||||
self.query_one("#week-grid", WeekGrid).focus()
|
||||
|
||||
def _on_ipc_message(self, message: IPCMessage) -> None:
|
||||
"""Handle IPC messages from sync daemon."""
|
||||
if message.event == "refresh":
|
||||
# Schedule a reload on the main thread
|
||||
self.call_from_thread(self.load_events)
|
||||
|
||||
async def _load_invites_async(self) -> None:
|
||||
"""Load pending calendar invites from Microsoft Graph."""
|
||||
try:
|
||||
from src.services.microsoft_graph.auth import get_access_token
|
||||
from src.services.microsoft_graph.calendar import fetch_pending_invites
|
||||
from dateutil import parser as date_parser
|
||||
|
||||
# Get auth token
|
||||
scopes = ["https://graph.microsoft.com/Calendars.Read"]
|
||||
_, headers = get_access_token(scopes)
|
||||
|
||||
# Fetch invites
|
||||
raw_invites = await fetch_pending_invites(headers, days_forward=30)
|
||||
|
||||
# Convert to CalendarInvite objects
|
||||
invites = []
|
||||
for inv in raw_invites:
|
||||
try:
|
||||
start_str = inv.get("start", {}).get("dateTime", "")
|
||||
end_str = inv.get("end", {}).get("dateTime", "")
|
||||
start_dt = (
|
||||
date_parser.parse(start_str) if start_str else datetime.now()
|
||||
)
|
||||
end_dt = date_parser.parse(end_str) if end_str else start_dt
|
||||
|
||||
organizer_data = inv.get("organizer", {}).get("emailAddress", {})
|
||||
organizer_name = organizer_data.get(
|
||||
"name", organizer_data.get("address", "Unknown")
|
||||
)
|
||||
|
||||
invite = CalendarInvite(
|
||||
id=inv.get("id", ""),
|
||||
subject=inv.get("subject", "No Subject"),
|
||||
organizer=organizer_name,
|
||||
start=start_dt,
|
||||
end=end_dt,
|
||||
location=inv.get("location", {}).get("displayName"),
|
||||
is_all_day=inv.get("isAllDay", False),
|
||||
response_status=inv.get("responseStatus", {}).get(
|
||||
"response", "notResponded"
|
||||
),
|
||||
)
|
||||
invites.append(invite)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse invite: {e}")
|
||||
|
||||
# Update the panel
|
||||
self._invites = invites
|
||||
self.call_from_thread(self._update_invites_panel)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load invites: {e}")
|
||||
# Silently fail - invites are optional
|
||||
|
||||
def _update_invites_panel(self) -> None:
|
||||
"""Update the invites panel with loaded invites."""
|
||||
try:
|
||||
panel = self.query_one("#sidebar-invites", InvitesPanel)
|
||||
panel.set_invites(self._invites)
|
||||
if self._invites:
|
||||
self.notify(f"Loaded {len(self._invites)} pending invite(s)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sync_sidebar_calendar(self) -> None:
|
||||
"""Sync the sidebar calendar with the main week grid."""
|
||||
try:
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
calendar = self.query_one("#sidebar-calendar", MonthCalendar)
|
||||
calendar.update_week(grid.week_start)
|
||||
calendar.update_selected(grid.get_cursor_date())
|
||||
except Exception:
|
||||
pass # Sidebar might not exist yet
|
||||
|
||||
def load_events(self) -> None:
|
||||
"""Load events from backend for the current week."""
|
||||
if not self.backend:
|
||||
return
|
||||
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
week_start = grid.week_start
|
||||
|
||||
# Get events using backend's helper method
|
||||
events_by_date = self.backend.get_week_events(
|
||||
week_start, include_weekends=self.include_weekends
|
||||
)
|
||||
|
||||
# Set events on grid
|
||||
grid.set_events(events_by_date)
|
||||
|
||||
# Update status bar with week label
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update the status bar."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
status = self.query_one("#status-bar", CalendarStatusBar)
|
||||
|
||||
# Week label
|
||||
week_start = grid.week_start
|
||||
week_end = week_start + timedelta(days=6)
|
||||
status.week_label = (
|
||||
f"Week of {week_start.strftime('%b %d')} - {week_end.strftime('%b %d, %Y')}"
|
||||
)
|
||||
|
||||
# Event info
|
||||
event = grid.get_event_at_cursor()
|
||||
if event:
|
||||
time_str = event.start.strftime("%H:%M") + "-" + event.end.strftime("%H:%M")
|
||||
status.event_info = f"{time_str} {event.title}"
|
||||
else:
|
||||
status.event_info = ""
|
||||
|
||||
status.refresh()
|
||||
|
||||
# Also update title when status changes
|
||||
self._update_title()
|
||||
|
||||
def _update_title(self) -> None:
|
||||
"""Update the app title with full date range and week number."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
week_start = grid.week_start
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_num = week_start.isocalendar()[1]
|
||||
|
||||
# Format: "2025 December 14 - 20 (Week 48)"
|
||||
if week_start.month == week_end.month:
|
||||
# Same month
|
||||
self.title = (
|
||||
f"{week_start.year} {week_start.strftime('%B')} "
|
||||
f"{week_start.day} - {week_end.day} (Week {week_num})"
|
||||
)
|
||||
else:
|
||||
# Different months
|
||||
self.title = (
|
||||
f"{week_start.strftime('%B %d')} - "
|
||||
f"{week_end.strftime('%B %d, %Y')} (Week {week_num})"
|
||||
)
|
||||
|
||||
def _update_event_detail(self, event: Optional[Event]) -> None:
|
||||
"""Update the event detail pane."""
|
||||
detail = self.query_one("#event-detail", Static)
|
||||
|
||||
if event:
|
||||
detail.remove_class("hidden")
|
||||
|
||||
# Format event details
|
||||
date_str = event.start.strftime("%A, %B %d")
|
||||
time_str = (
|
||||
event.start.strftime("%H:%M") + " - " + event.end.strftime("%H:%M")
|
||||
)
|
||||
duration = event.duration_minutes
|
||||
hours, mins = divmod(duration, 60)
|
||||
dur_str = f"{hours}h {mins}m" if hours else f"{mins}m"
|
||||
|
||||
lines = [
|
||||
f"[bold]{event.title}[/bold]",
|
||||
f"{date_str}",
|
||||
f"{time_str} ({dur_str})",
|
||||
]
|
||||
if event.location:
|
||||
lines.append(f"[dim]Location:[/dim] {event.location}")
|
||||
if event.organizer:
|
||||
lines.append(f"[dim]Organizer:[/dim] {event.organizer}")
|
||||
if event.categories:
|
||||
lines.append(f"[dim]Categories:[/dim] {event.categories}")
|
||||
if event.url:
|
||||
lines.append(f"[dim]URL:[/dim] {event.url}")
|
||||
if event.status:
|
||||
lines.append(f"[dim]Status:[/dim] {event.status}")
|
||||
if event.recurring:
|
||||
lines.append("[dim]Recurring:[/dim] Yes")
|
||||
if event.description:
|
||||
# Truncate long descriptions
|
||||
desc = (
|
||||
event.description[:200] + "..."
|
||||
if len(event.description) > 200
|
||||
else event.description
|
||||
)
|
||||
lines.append(f"[dim]Description:[/dim] {desc}")
|
||||
|
||||
detail.update("\n".join(lines))
|
||||
else:
|
||||
detail.add_class("hidden")
|
||||
|
||||
# Handle WeekGrid messages
|
||||
def on_week_grid_week_changed(self, message: WeekGrid.WeekChanged) -> None:
|
||||
"""Handle week change - reload events."""
|
||||
self.load_events()
|
||||
self._sync_sidebar_calendar()
|
||||
|
||||
def on_week_grid_event_selected(self, message: WeekGrid.EventSelected) -> None:
|
||||
"""Handle event selection."""
|
||||
self._update_event_detail(message.event)
|
||||
|
||||
# Handle MonthCalendar messages
|
||||
def on_month_calendar_date_selected(
|
||||
self, message: MonthCalendar.DateSelected
|
||||
) -> None:
|
||||
"""Handle date selection from sidebar calendar."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.goto_date(message.date)
|
||||
self.load_events()
|
||||
self._sync_sidebar_calendar()
|
||||
|
||||
# Navigation actions (forwarded to grid)
|
||||
def action_cursor_down(self) -> None:
|
||||
"""Move cursor down."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.action_cursor_down()
|
||||
self._update_status()
|
||||
|
||||
def action_cursor_up(self) -> None:
|
||||
"""Move cursor up."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.action_cursor_up()
|
||||
self._update_status()
|
||||
|
||||
def action_cursor_left(self) -> None:
|
||||
"""Move cursor left."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.action_cursor_left()
|
||||
self._update_status()
|
||||
|
||||
def action_cursor_right(self) -> None:
|
||||
"""Move cursor right."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.action_cursor_right()
|
||||
self._update_status()
|
||||
|
||||
def action_prev_week(self) -> None:
|
||||
"""Navigate to previous week."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.action_prev_week()
|
||||
|
||||
def action_next_week(self) -> None:
|
||||
"""Navigate to next week."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.action_next_week()
|
||||
|
||||
def action_goto_today(self) -> None:
|
||||
"""Navigate to today."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.action_goto_today()
|
||||
self.load_events()
|
||||
|
||||
def action_toggle_weekends(self) -> None:
|
||||
"""Toggle weekend display."""
|
||||
self.include_weekends = not self.include_weekends
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.include_weekends = self.include_weekends
|
||||
self.load_events()
|
||||
|
||||
mode = "7 days" if self.include_weekends else "5 days (weekdays)"
|
||||
self.notify(f"Showing {mode}")
|
||||
|
||||
def action_toggle_sidebar(self) -> None:
|
||||
"""Toggle sidebar visibility."""
|
||||
self.show_sidebar = not self.show_sidebar
|
||||
sidebar = self.query_one("#sidebar", Vertical)
|
||||
if self.show_sidebar:
|
||||
sidebar.remove_class("hidden")
|
||||
else:
|
||||
sidebar.add_class("hidden")
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
"""Refresh events from backend."""
|
||||
self.load_events()
|
||||
self.notify("Refreshed")
|
||||
|
||||
def action_view_event(self) -> None:
|
||||
"""View the selected event details."""
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
event = grid.get_event_at_cursor()
|
||||
if event:
|
||||
self._update_event_detail(event)
|
||||
else:
|
||||
self.notify("No event at cursor")
|
||||
|
||||
def action_add_event(self) -> None:
|
||||
"""Open the add event modal."""
|
||||
from src.calendar.screens.AddEventScreen import AddEventScreen
|
||||
|
||||
# Get calendars from backend
|
||||
calendars: list[str] = []
|
||||
if self.backend:
|
||||
try:
|
||||
calendars = self.backend.get_calendars()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get current cursor date/time for initial values
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
cursor_date = grid.get_cursor_date()
|
||||
cursor_time = grid.get_cursor_time()
|
||||
|
||||
def handle_result(data: EventFormData | None) -> None:
|
||||
if data is None:
|
||||
return
|
||||
|
||||
if not self.backend:
|
||||
self.notify("No calendar backend available", severity="error")
|
||||
return
|
||||
|
||||
try:
|
||||
self.backend.create_event(
|
||||
title=data.title,
|
||||
start=data.start_datetime,
|
||||
end=data.end_datetime,
|
||||
calendar=data.calendar,
|
||||
location=data.location,
|
||||
description=data.description,
|
||||
all_day=data.all_day,
|
||||
)
|
||||
self.notify(f"Created event: {data.title}")
|
||||
self.load_events() # Refresh to show new event
|
||||
except Exception as e:
|
||||
self.notify(f"Failed to create event: {e}", severity="error")
|
||||
|
||||
self.push_screen(
|
||||
AddEventScreen(
|
||||
calendars=calendars,
|
||||
initial_date=cursor_date,
|
||||
initial_time=cursor_time,
|
||||
),
|
||||
handle_result,
|
||||
)
|
||||
|
||||
def action_help(self) -> None:
|
||||
"""Show help."""
|
||||
help_text = """
|
||||
Keybindings:
|
||||
j/k - Move cursor up/down (time)
|
||||
h/l - Move cursor left/right (day)
|
||||
H/L - Previous/Next week
|
||||
g - Go to today
|
||||
w - Toggle weekends (5/7 days)
|
||||
s - Toggle sidebar
|
||||
i - Focus invites panel
|
||||
/ - Search events
|
||||
Esc - Clear search
|
||||
Enter - View event details
|
||||
a - Add new event
|
||||
r - Refresh
|
||||
q - Quit
|
||||
"""
|
||||
self.notify(help_text.strip(), timeout=10)
|
||||
|
||||
# Search actions
|
||||
def action_search(self) -> None:
|
||||
"""Show search input and focus it."""
|
||||
search_container = self.query_one("#search-container")
|
||||
search_container.remove_class("hidden")
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
search_input.disabled = False
|
||||
search_input.focus()
|
||||
|
||||
def action_clear_search(self) -> None:
|
||||
"""Clear search and hide search UI."""
|
||||
search_container = self.query_one("#search-container")
|
||||
search_results = self.query_one("#search-results", Static)
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
|
||||
# Only act if search is visible
|
||||
if not search_container.has_class("hidden") or not search_results.has_class(
|
||||
"hidden"
|
||||
):
|
||||
search_input.value = ""
|
||||
search_input.disabled = True
|
||||
search_container.add_class("hidden")
|
||||
search_results.add_class("hidden")
|
||||
self._search_results = []
|
||||
# Focus back to grid
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle Enter in search input - perform search."""
|
||||
if event.input.id != "search-input":
|
||||
return
|
||||
|
||||
query = event.value.strip()
|
||||
if not query:
|
||||
return
|
||||
|
||||
self._perform_search(query)
|
||||
|
||||
def _perform_search(self, query: str) -> None:
|
||||
"""Perform event search and display results."""
|
||||
if not self.backend:
|
||||
return
|
||||
|
||||
# Check if backend has search_events method
|
||||
if not hasattr(self.backend, "search_events"):
|
||||
self.notify(
|
||||
"Search not supported by this calendar backend", severity="warning"
|
||||
)
|
||||
return
|
||||
|
||||
results = self.backend.search_events(query)
|
||||
self._search_results = results
|
||||
|
||||
# Update results display
|
||||
search_results = self.query_one("#search-results", Static)
|
||||
|
||||
if results:
|
||||
lines = [f"[b]Search results for '{query}': {len(results)} found[/b]", ""]
|
||||
for event in results[:20]: # Limit display to 20 results
|
||||
date_str = event.start.strftime("%Y-%m-%d %H:%M")
|
||||
lines.append(f" {date_str} [b]{event.title}[/b]")
|
||||
if event.location:
|
||||
lines.append(f" [dim]{event.location}[/dim]")
|
||||
if len(results) > 20:
|
||||
lines.append(f" ... and {len(results) - 20} more")
|
||||
search_results.update("\n".join(lines))
|
||||
search_results.remove_class("hidden")
|
||||
self.notify(f"Found {len(results)} event(s)")
|
||||
else:
|
||||
search_results.update(f"[b]No events found matching '{query}'[/b]")
|
||||
search_results.remove_class("hidden")
|
||||
self.notify("No events found")
|
||||
|
||||
# Focus back to grid
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.focus()
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
"""Quit the app and clean up IPC listener."""
|
||||
if hasattr(self, "_ipc_listener"):
|
||||
self._ipc_listener.stop()
|
||||
self.exit()
|
||||
|
||||
def action_focus_invites(self) -> None:
|
||||
"""Focus on the invites panel and show invite count."""
|
||||
if not self.show_sidebar:
|
||||
self.action_toggle_sidebar()
|
||||
|
||||
if self._invites:
|
||||
self.notify(f"You have {len(self._invites)} pending invite(s)")
|
||||
else:
|
||||
self.notify("No pending invites")
|
||||
|
||||
|
||||
def run_app(backend: Optional[CalendarBackend] = None) -> None:
|
||||
"""Run the Calendar TUI application."""
|
||||
app = CalendarApp(backend=backend)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app()
|
||||
232
src/calendar/backend.py
Normal file
232
src/calendar/backend.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Calendar backend abstraction for Calendar TUI.
|
||||
|
||||
This module defines the abstract interface that all calendar backends must implement,
|
||||
allowing the TUI to work with different calendar systems (khal, calcurse, etc.)
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""Unified calendar event representation across backends."""
|
||||
|
||||
uid: str
|
||||
title: str
|
||||
start: datetime
|
||||
end: datetime
|
||||
location: str = ""
|
||||
description: str = ""
|
||||
calendar: str = ""
|
||||
all_day: bool = False
|
||||
recurring: bool = False
|
||||
organizer: str = ""
|
||||
url: str = ""
|
||||
categories: str = ""
|
||||
status: str = "" # CONFIRMED, TENTATIVE, CANCELLED
|
||||
|
||||
@property
|
||||
def duration_minutes(self) -> int:
|
||||
"""Get duration in minutes."""
|
||||
delta = self.end - self.start
|
||||
return int(delta.total_seconds() / 60)
|
||||
|
||||
@property
|
||||
def start_time(self) -> time:
|
||||
"""Get start time."""
|
||||
return self.start.time()
|
||||
|
||||
@property
|
||||
def end_time(self) -> time:
|
||||
"""Get end time."""
|
||||
return self.end.time()
|
||||
|
||||
@property
|
||||
def date(self) -> date:
|
||||
"""Get the date of the event."""
|
||||
return self.start.date()
|
||||
|
||||
def overlaps(self, other: "Event") -> bool:
|
||||
"""Check if this event overlaps with another."""
|
||||
return self.start < other.end and self.end > other.start
|
||||
|
||||
def get_row_span(self, minutes_per_row: int = 30) -> Tuple[int, int]:
|
||||
"""Get the row range for this event in a grid.
|
||||
|
||||
Args:
|
||||
minutes_per_row: Minutes each row represents (default 30)
|
||||
|
||||
Returns:
|
||||
Tuple of (start_row, end_row) where rows are 0-indexed from midnight
|
||||
"""
|
||||
start_minutes = self.start.hour * 60 + self.start.minute
|
||||
end_minutes = self.end.hour * 60 + self.end.minute
|
||||
|
||||
# Handle events ending at midnight (next day)
|
||||
if end_minutes == 0 and self.end.date() > self.start.date():
|
||||
end_minutes = 24 * 60
|
||||
|
||||
start_row = start_minutes // minutes_per_row
|
||||
end_row = (end_minutes + minutes_per_row - 1) // minutes_per_row # Round up
|
||||
|
||||
return start_row, end_row
|
||||
|
||||
|
||||
class CalendarBackend(ABC):
|
||||
"""Abstract base class for calendar backends."""
|
||||
|
||||
@abstractmethod
|
||||
def get_events(
|
||||
self,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
calendar: Optional[str] = None,
|
||||
) -> List[Event]:
|
||||
"""Get events in a date range.
|
||||
|
||||
Args:
|
||||
start_date: Start of range (inclusive)
|
||||
end_date: End of range (inclusive)
|
||||
calendar: Optional calendar name to filter by
|
||||
|
||||
Returns:
|
||||
List of events in the range, sorted by start time
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_event(self, uid: str) -> Optional[Event]:
|
||||
"""Get a single event by UID.
|
||||
|
||||
Args:
|
||||
uid: Event unique identifier
|
||||
|
||||
Returns:
|
||||
Event if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_calendars(self) -> List[str]:
|
||||
"""Get list of available calendar names.
|
||||
|
||||
Returns:
|
||||
List of calendar names
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_event(
|
||||
self,
|
||||
title: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
calendar: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
all_day: bool = False,
|
||||
) -> Event:
|
||||
"""Create a new event.
|
||||
|
||||
Args:
|
||||
title: Event title
|
||||
start: Start datetime
|
||||
end: End datetime
|
||||
calendar: Calendar to add event to
|
||||
location: Event location
|
||||
description: Event description
|
||||
all_day: Whether this is an all-day event
|
||||
|
||||
Returns:
|
||||
The created event
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_event(self, uid: str) -> bool:
|
||||
"""Delete an event.
|
||||
|
||||
Args:
|
||||
uid: Event unique identifier
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_event(
|
||||
self,
|
||||
uid: str,
|
||||
title: Optional[str] = None,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
location: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Optional[Event]:
|
||||
"""Update an existing event.
|
||||
|
||||
Args:
|
||||
uid: Event unique identifier
|
||||
title: New title (if provided)
|
||||
start: New start time (if provided)
|
||||
end: New end time (if provided)
|
||||
location: New location (if provided)
|
||||
description: New description (if provided)
|
||||
|
||||
Returns:
|
||||
Updated event if successful, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_week_events(
|
||||
self,
|
||||
week_start: date,
|
||||
include_weekends: bool = True,
|
||||
) -> dict[date, List[Event]]:
|
||||
"""Get events for a week, grouped by date.
|
||||
|
||||
Args:
|
||||
week_start: First day of the week
|
||||
include_weekends: Whether to include Saturday/Sunday
|
||||
|
||||
Returns:
|
||||
Dict mapping dates to lists of events
|
||||
"""
|
||||
days = 7 if include_weekends else 5
|
||||
end_date = week_start + timedelta(days=days - 1)
|
||||
events = self.get_events(week_start, end_date)
|
||||
|
||||
# Group by date
|
||||
by_date: dict[date, List[Event]] = {}
|
||||
for i in range(days):
|
||||
d = week_start + timedelta(days=i)
|
||||
by_date[d] = []
|
||||
|
||||
for event in events:
|
||||
event_date = event.date
|
||||
if event_date in by_date:
|
||||
by_date[event_date].append(event)
|
||||
|
||||
# Sort each day's events by start time
|
||||
for d in by_date:
|
||||
by_date[d].sort(key=lambda e: e.start)
|
||||
|
||||
return by_date
|
||||
|
||||
def search_events(self, query: str) -> List[Event]:
|
||||
"""Search for events matching a query string.
|
||||
|
||||
Default implementation returns empty list. Override in subclasses
|
||||
that support search.
|
||||
|
||||
Args:
|
||||
query: Search string to match against event titles and descriptions
|
||||
|
||||
Returns:
|
||||
List of matching events
|
||||
"""
|
||||
return []
|
||||
110
src/calendar/config.py
Normal file
110
src/calendar/config.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Calendar TUI configuration."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import toml
|
||||
except ImportError:
|
||||
toml = None # type: ignore
|
||||
|
||||
|
||||
# Default configuration values
|
||||
DEFAULT_CONFIG = {
|
||||
"display": {
|
||||
"work_day_start_hour": 7, # 7 AM
|
||||
"work_day_end_hour": 19, # 7 PM
|
||||
"include_weekends": True,
|
||||
"minutes_per_row": 30,
|
||||
"day_column_width": 20,
|
||||
"week_start_day": 0, # 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
},
|
||||
"backend": {
|
||||
"type": "khal", # khal, calcurse, etc.
|
||||
"calendar_path": "~/Calendar/corteva",
|
||||
},
|
||||
"theme": {
|
||||
"event_color": "blue",
|
||||
"overlap_color": "dark_orange",
|
||||
"cursor_style": "reverse",
|
||||
"work_hours_time_color": "blue",
|
||||
"off_hours_time_color": "bright_black",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Get the calendar config file path."""
|
||||
# Check XDG_CONFIG_HOME first, then fall back to ~/.config
|
||||
config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
|
||||
return Path(config_home) / "luk" / "calendar.toml"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Load calendar configuration from TOML file.
|
||||
|
||||
Returns merged config with defaults for any missing values.
|
||||
"""
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
|
||||
config_path = get_config_path()
|
||||
if config_path.exists() and toml is not None:
|
||||
try:
|
||||
user_config = toml.load(config_path)
|
||||
# Deep merge user config into defaults
|
||||
for section, values in user_config.items():
|
||||
if section in config and isinstance(config[section], dict):
|
||||
config[section].update(values)
|
||||
else:
|
||||
config[section] = values
|
||||
except Exception:
|
||||
pass # Use defaults on error
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def get_display_config() -> dict:
|
||||
"""Get display-related configuration."""
|
||||
return load_config().get("display", DEFAULT_CONFIG["display"])
|
||||
|
||||
|
||||
def get_backend_config() -> dict:
|
||||
"""Get backend-related configuration."""
|
||||
return load_config().get("backend", DEFAULT_CONFIG["backend"])
|
||||
|
||||
|
||||
def get_theme_config() -> dict:
|
||||
"""Get theme-related configuration."""
|
||||
return load_config().get("theme", DEFAULT_CONFIG["theme"])
|
||||
|
||||
|
||||
# Convenience accessors
|
||||
def work_day_start_hour() -> int:
|
||||
"""Get the work day start hour (for initial scroll position)."""
|
||||
return get_display_config().get("work_day_start_hour", 7)
|
||||
|
||||
|
||||
def work_day_end_hour() -> int:
|
||||
"""Get the work day end hour."""
|
||||
return get_display_config().get("work_day_end_hour", 19)
|
||||
|
||||
|
||||
def include_weekends_default() -> bool:
|
||||
"""Get default for including weekends."""
|
||||
return get_display_config().get("include_weekends", True)
|
||||
|
||||
|
||||
def minutes_per_row() -> int:
|
||||
"""Get minutes per row (default 30)."""
|
||||
return get_display_config().get("minutes_per_row", 30)
|
||||
|
||||
|
||||
def day_column_width() -> int:
|
||||
"""Get day column width."""
|
||||
return get_display_config().get("day_column_width", 20)
|
||||
|
||||
|
||||
def week_start_day() -> int:
|
||||
"""Get the week start day (0=Sunday, 1=Monday, ..., 6=Saturday)."""
|
||||
return get_display_config().get("week_start_day", 0)
|
||||
155
src/calendar/screens/AddEventScreen.py
Normal file
155
src/calendar/screens/AddEventScreen.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Add Event modal screen for Calendar TUI."""
|
||||
|
||||
from datetime import date, time
|
||||
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.calendar.widgets.AddEventForm import AddEventForm, EventFormData
|
||||
|
||||
|
||||
class AddEventScreen(ModalScreen[Optional[EventFormData]]):
|
||||
"""Modal screen for adding a new calendar event."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("ctrl+s", "submit", "Save"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AddEventScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
AddEventScreen #add-event-container {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
max-height: 85%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
AddEventScreen #add-event-title {
|
||||
text-style: bold;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddEventScreen #add-event-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
AddEventScreen #add-event-form {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddEventScreen #add-event-sidebar {
|
||||
width: 16;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
align: center top;
|
||||
}
|
||||
|
||||
AddEventScreen #add-event-sidebar Button {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddEventScreen #help-text {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
calendars: list[str] | None = None,
|
||||
initial_date: date | None = None,
|
||||
initial_time: time | None = None,
|
||||
initial_data: EventFormData | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the add event screen.
|
||||
|
||||
Args:
|
||||
calendars: List of available calendar names for the dropdown
|
||||
initial_date: Pre-populate with this date
|
||||
initial_time: Pre-populate with this time
|
||||
initial_data: Pre-populate form with this data (overrides date/time)
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._calendars = calendars or []
|
||||
self._initial_date = initial_date
|
||||
self._initial_time = initial_time
|
||||
self._initial_data = initial_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="add-event-container"):
|
||||
yield Label("Add New Event", id="add-event-title")
|
||||
|
||||
with Horizontal(id="add-event-content"):
|
||||
yield AddEventForm(
|
||||
calendars=self._calendars,
|
||||
initial_date=self._initial_date,
|
||||
initial_time=self._initial_time,
|
||||
initial_data=self._initial_data,
|
||||
id="add-event-form",
|
||||
)
|
||||
|
||||
with Vertical(id="add-event-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 title input."""
|
||||
try:
|
||||
form = self.query_one("#add-event-form", AddEventForm)
|
||||
title_input = form.query_one("#title-input")
|
||||
title_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, "#title-input")
|
||||
def handle_title_submit(self) -> None:
|
||||
"""Handle Enter key in title input."""
|
||||
self.action_submit()
|
||||
|
||||
def action_submit(self) -> None:
|
||||
"""Validate and submit the form."""
|
||||
form = self.query_one("#add-event-form", AddEventForm)
|
||||
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)
|
||||
5
src/calendar/screens/__init__.py
Normal file
5
src/calendar/screens/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Calendar TUI screens."""
|
||||
|
||||
from .AddEventScreen import AddEventScreen
|
||||
|
||||
__all__ = ["AddEventScreen"]
|
||||
380
src/calendar/widgets/AddEventForm.py
Normal file
380
src/calendar/widgets/AddEventForm.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""Reusable Add Event form widget for Calendar TUI.
|
||||
|
||||
This widget can be used standalone in modals or embedded in other screens.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical, ScrollableContainer
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label, Select, TextArea, Checkbox, MaskedInput
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventFormData:
|
||||
"""Data from the add event form."""
|
||||
|
||||
title: str
|
||||
start_date: date
|
||||
start_time: time
|
||||
end_date: date
|
||||
end_time: time
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
calendar: Optional[str] = None
|
||||
all_day: bool = False
|
||||
|
||||
@property
|
||||
def start_datetime(self) -> datetime:
|
||||
"""Get start as datetime."""
|
||||
return datetime.combine(self.start_date, self.start_time)
|
||||
|
||||
@property
|
||||
def end_datetime(self) -> datetime:
|
||||
"""Get end as datetime."""
|
||||
return datetime.combine(self.end_date, self.end_time)
|
||||
|
||||
|
||||
class AddEventForm(Widget):
|
||||
"""A reusable form widget for creating/editing calendar events.
|
||||
|
||||
This widget emits EventFormData when submitted and can be embedded
|
||||
in various contexts (modal screens, sidebars, etc.)
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AddEventForm {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
AddEventForm ScrollableContainer {
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
AddEventForm .form-row {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddEventForm .form-label {
|
||||
width: 12;
|
||||
height: 2;
|
||||
padding-top: 1;
|
||||
padding-right: 1;
|
||||
}
|
||||
|
||||
AddEventForm .form-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddEventForm #title-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddEventForm .date-input {
|
||||
width: 18;
|
||||
}
|
||||
|
||||
AddEventForm .time-input {
|
||||
width: 10;
|
||||
}
|
||||
|
||||
AddEventForm #calendar-select {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddEventForm #location-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddEventForm #description-textarea {
|
||||
width: 1fr;
|
||||
height: 6;
|
||||
}
|
||||
|
||||
AddEventForm .required {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
AddEventForm .datetime-row {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
AddEventForm .datetime-group {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-right: 2;
|
||||
}
|
||||
|
||||
AddEventForm .datetime-label {
|
||||
width: auto;
|
||||
padding-right: 1;
|
||||
height: 2;
|
||||
padding-top: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
class Submitted(Message):
|
||||
"""Message emitted when the form is submitted."""
|
||||
|
||||
def __init__(self, data: EventFormData) -> None:
|
||||
super().__init__()
|
||||
self.data = data
|
||||
|
||||
class Cancelled(Message):
|
||||
"""Message emitted when the form is cancelled."""
|
||||
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
calendars: list[str] | None = None,
|
||||
initial_date: date | None = None,
|
||||
initial_time: time | None = None,
|
||||
initial_data: EventFormData | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the add event form.
|
||||
|
||||
Args:
|
||||
calendars: List of available calendar names for the dropdown
|
||||
initial_date: Pre-populate with this date
|
||||
initial_time: Pre-populate with this time
|
||||
initial_data: Pre-populate form with this data (overrides date/time)
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._calendars = calendars or []
|
||||
self._initial_date = initial_date or date.today()
|
||||
self._initial_time = initial_time or time(9, 0)
|
||||
self._initial_data = initial_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the form layout."""
|
||||
if self._initial_data:
|
||||
initial = self._initial_data
|
||||
start_date = initial.start_date
|
||||
start_time = initial.start_time
|
||||
end_date = initial.end_date
|
||||
end_time = initial.end_time
|
||||
title = initial.title
|
||||
location = initial.location or ""
|
||||
description = initial.description or ""
|
||||
calendar = initial.calendar or ""
|
||||
all_day = initial.all_day
|
||||
else:
|
||||
start_date = self._initial_date
|
||||
start_time = self._initial_time
|
||||
# Default to 1 hour duration
|
||||
end_date = start_date
|
||||
end_time = time(start_time.hour + 1, start_time.minute)
|
||||
if start_time.hour >= 23:
|
||||
end_time = time(23, 59)
|
||||
title = ""
|
||||
location = ""
|
||||
description = ""
|
||||
calendar = ""
|
||||
all_day = False
|
||||
|
||||
with ScrollableContainer():
|
||||
# Title (required)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Title", classes="form-label")
|
||||
yield Label("*", classes="required")
|
||||
yield Input(
|
||||
value=title,
|
||||
placeholder="Event title...",
|
||||
id="title-input",
|
||||
classes="form-input",
|
||||
)
|
||||
|
||||
# Start Date/Time
|
||||
with Vertical(classes="form-row"):
|
||||
yield Label("Start", classes="form-label")
|
||||
with Horizontal(classes="datetime-row"):
|
||||
with Horizontal(classes="datetime-group"):
|
||||
yield Label("Date:", classes="datetime-label")
|
||||
yield MaskedInput(
|
||||
template="9999-99-99",
|
||||
value=start_date.strftime("%Y-%m-%d"),
|
||||
id="start-date-input",
|
||||
classes="date-input",
|
||||
)
|
||||
with Horizontal(classes="datetime-group"):
|
||||
yield Label("Time:", classes="datetime-label")
|
||||
yield MaskedInput(
|
||||
template="99:99",
|
||||
value=start_time.strftime("%H:%M"),
|
||||
id="start-time-input",
|
||||
classes="time-input",
|
||||
)
|
||||
|
||||
# End Date/Time
|
||||
with Vertical(classes="form-row"):
|
||||
yield Label("End", classes="form-label")
|
||||
with Horizontal(classes="datetime-row"):
|
||||
with Horizontal(classes="datetime-group"):
|
||||
yield Label("Date:", classes="datetime-label")
|
||||
yield MaskedInput(
|
||||
template="9999-99-99",
|
||||
value=end_date.strftime("%Y-%m-%d"),
|
||||
id="end-date-input",
|
||||
classes="date-input",
|
||||
)
|
||||
with Horizontal(classes="datetime-group"):
|
||||
yield Label("Time:", classes="datetime-label")
|
||||
yield MaskedInput(
|
||||
template="99:99",
|
||||
value=end_time.strftime("%H:%M"),
|
||||
id="end-time-input",
|
||||
classes="time-input",
|
||||
)
|
||||
|
||||
# All day checkbox
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("", classes="form-label")
|
||||
yield Checkbox("All day event", value=all_day, id="all-day-checkbox")
|
||||
|
||||
# Calendar selection (optional dropdown)
|
||||
if self._calendars:
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Calendar", classes="form-label")
|
||||
options = [("(default)", "")] + [(c, c) for c in self._calendars]
|
||||
yield Select(
|
||||
options=options,
|
||||
value=calendar,
|
||||
id="calendar-select",
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
# Location (optional)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Location", classes="form-label")
|
||||
yield Input(
|
||||
value=location,
|
||||
placeholder="Event location...",
|
||||
id="location-input",
|
||||
classes="form-input",
|
||||
)
|
||||
|
||||
# Description (optional textarea)
|
||||
with Vertical(classes="form-row"):
|
||||
yield Label("Description", classes="form-label")
|
||||
yield TextArea(
|
||||
description,
|
||||
id="description-textarea",
|
||||
)
|
||||
|
||||
def get_form_data(self) -> EventFormData:
|
||||
"""Extract current form data.
|
||||
|
||||
Returns:
|
||||
EventFormData with current form values
|
||||
"""
|
||||
title = self.query_one("#title-input", Input).value.strip()
|
||||
|
||||
# Parse start date/time from MaskedInput
|
||||
start_date_input = self.query_one("#start-date-input", MaskedInput)
|
||||
start_time_input = self.query_one("#start-time-input", MaskedInput)
|
||||
start_date_str = start_date_input.value.strip()
|
||||
start_time_str = start_time_input.value.strip()
|
||||
try:
|
||||
start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
start_date = date.today()
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, "%H:%M").time()
|
||||
except ValueError:
|
||||
start_time = time(9, 0)
|
||||
|
||||
# Parse end date/time from MaskedInput
|
||||
end_date_input = self.query_one("#end-date-input", MaskedInput)
|
||||
end_time_input = self.query_one("#end-time-input", MaskedInput)
|
||||
end_date_str = end_date_input.value.strip()
|
||||
end_time_str = end_time_input.value.strip()
|
||||
try:
|
||||
end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
end_date = start_date
|
||||
try:
|
||||
end_time = datetime.strptime(end_time_str, "%H:%M").time()
|
||||
except ValueError:
|
||||
end_time = time(start_time.hour + 1, start_time.minute)
|
||||
|
||||
# All day
|
||||
all_day = self.query_one("#all-day-checkbox", Checkbox).value
|
||||
|
||||
# Calendar
|
||||
calendar: str | None = None
|
||||
try:
|
||||
calendar_select = self.query_one("#calendar-select", Select)
|
||||
cal_value = calendar_select.value
|
||||
if isinstance(cal_value, str) and cal_value:
|
||||
calendar = cal_value
|
||||
except Exception:
|
||||
pass # No calendar select
|
||||
|
||||
# Location
|
||||
location = self.query_one("#location-input", Input).value.strip() or None
|
||||
|
||||
# Description
|
||||
try:
|
||||
desc_area = self.query_one("#description-textarea", TextArea)
|
||||
description = desc_area.text.strip() or None
|
||||
except Exception:
|
||||
description = None
|
||||
|
||||
return EventFormData(
|
||||
title=title,
|
||||
start_date=start_date,
|
||||
start_time=start_time,
|
||||
end_date=end_date,
|
||||
end_time=end_time,
|
||||
location=location,
|
||||
description=description,
|
||||
calendar=calendar,
|
||||
all_day=all_day,
|
||||
)
|
||||
|
||||
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.title:
|
||||
return False, "Title is required"
|
||||
|
||||
# Validate that end is after start
|
||||
if data.end_datetime <= data.start_datetime:
|
||||
return False, "End time must be after start time"
|
||||
|
||||
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())
|
||||
208
src/calendar/widgets/InvitesPanel.py
Normal file
208
src/calendar/widgets/InvitesPanel.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Calendar invites panel widget for Calendar TUI sidebar.
|
||||
|
||||
Displays pending calendar invites that need a response.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.strip import Strip
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarInvite:
|
||||
"""A calendar invite pending response."""
|
||||
|
||||
id: str
|
||||
subject: str
|
||||
organizer: str
|
||||
start: datetime
|
||||
end: datetime
|
||||
location: Optional[str] = None
|
||||
is_all_day: bool = False
|
||||
response_status: str = "notResponded" # notResponded, tentativelyAccepted
|
||||
|
||||
|
||||
class InvitesPanel(Widget):
|
||||
"""Panel showing pending calendar invites."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
InvitesPanel {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
padding: 0 1;
|
||||
border: round $primary;
|
||||
border-title-color: $primary;
|
||||
}
|
||||
"""
|
||||
|
||||
# Reactive attributes
|
||||
invites: reactive[List[CalendarInvite]] = reactive(list)
|
||||
selected_index: reactive[int] = reactive(0)
|
||||
|
||||
class InviteSelected(Message):
|
||||
"""An invite was selected."""
|
||||
|
||||
def __init__(self, invite: CalendarInvite) -> None:
|
||||
super().__init__()
|
||||
self.invite = invite
|
||||
|
||||
class InviteRespond(Message):
|
||||
"""User wants to respond to an invite."""
|
||||
|
||||
def __init__(self, invite: CalendarInvite, response: str) -> None:
|
||||
super().__init__()
|
||||
self.invite = invite
|
||||
self.response = response # accept, tentativelyAccept, decline
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
invites: Optional[List[CalendarInvite]] = None,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
if invites:
|
||||
self.invites = invites
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set border title on mount."""
|
||||
self._update_border_title()
|
||||
|
||||
def _update_border_title(self) -> None:
|
||||
"""Update border title with invite count."""
|
||||
count = len(self.invites)
|
||||
self.border_title = f"Invites ({count})" if count else "Invites"
|
||||
|
||||
def _get_theme_color(self, color_name: str) -> str:
|
||||
"""Get a color from the current theme."""
|
||||
try:
|
||||
theme = self.app.current_theme
|
||||
color = getattr(theme, color_name, None)
|
||||
if color:
|
||||
return str(color)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback colors
|
||||
fallbacks = {
|
||||
"secondary": "#81A1C1",
|
||||
"primary": "#88C0D0",
|
||||
"accent": "#B48EAD",
|
||||
"foreground": "#D8DEE9",
|
||||
"surface": "#3B4252",
|
||||
"warning": "#EBCB8B",
|
||||
}
|
||||
return fallbacks.get(color_name, "white")
|
||||
|
||||
def get_content_height(self, container, viewport, width: int) -> int:
|
||||
"""Calculate height: invite rows only (no internal header)."""
|
||||
if not self.invites:
|
||||
return 1 # "No pending invites"
|
||||
return len(self.invites) * 2 # 2 lines per invite
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a line of the panel."""
|
||||
if not self.invites:
|
||||
if y == 0:
|
||||
return self._render_empty_message()
|
||||
return Strip.blank(self.size.width)
|
||||
|
||||
# Each invite takes 2 lines
|
||||
invite_idx = y // 2
|
||||
line_in_invite = y % 2
|
||||
|
||||
if 0 <= invite_idx < len(self.invites):
|
||||
return self._render_invite_line(
|
||||
self.invites[invite_idx],
|
||||
line_in_invite,
|
||||
invite_idx == self.selected_index,
|
||||
)
|
||||
|
||||
return Strip.blank(self.size.width)
|
||||
|
||||
def _render_empty_message(self) -> Strip:
|
||||
"""Render empty state message."""
|
||||
msg = "No pending invites"
|
||||
msg = msg[: self.size.width].ljust(self.size.width)
|
||||
style = Style(color="bright_black")
|
||||
return Strip([Segment(msg, style)])
|
||||
|
||||
def _render_invite_line(
|
||||
self, invite: CalendarInvite, line: int, is_selected: bool
|
||||
) -> Strip:
|
||||
"""Render a line of an invite."""
|
||||
if line == 0:
|
||||
# First line: subject (truncated)
|
||||
text = invite.subject[: self.size.width - 2]
|
||||
if is_selected:
|
||||
text = "> " + text[: self.size.width - 2]
|
||||
text = text[: self.size.width].ljust(self.size.width)
|
||||
|
||||
if is_selected:
|
||||
style = Style(bold=True, reverse=True)
|
||||
else:
|
||||
style = Style()
|
||||
|
||||
return Strip([Segment(text, style)])
|
||||
else:
|
||||
# Second line: date/time and organizer
|
||||
date_str = invite.start.strftime("%m/%d %H:%M")
|
||||
organizer = invite.organizer[:15] if invite.organizer else ""
|
||||
info = f" {date_str} - {organizer}"
|
||||
info = info[: self.size.width].ljust(self.size.width)
|
||||
|
||||
warning_color = self._get_theme_color("warning")
|
||||
if invite.response_status == "tentativelyAccepted":
|
||||
style = Style(color=warning_color, italic=True)
|
||||
else:
|
||||
style = Style(color="bright_black")
|
||||
|
||||
return Strip([Segment(info, style)])
|
||||
|
||||
def set_invites(self, invites: List[CalendarInvite]) -> None:
|
||||
"""Update the list of invites."""
|
||||
self.invites = invites
|
||||
if self.selected_index >= len(invites):
|
||||
self.selected_index = max(0, len(invites) - 1)
|
||||
self._update_border_title()
|
||||
self.refresh()
|
||||
|
||||
def select_next(self) -> None:
|
||||
"""Select the next invite."""
|
||||
if self.invites and self.selected_index < len(self.invites) - 1:
|
||||
self.selected_index += 1
|
||||
self.refresh()
|
||||
|
||||
def select_previous(self) -> None:
|
||||
"""Select the previous invite."""
|
||||
if self.invites and self.selected_index > 0:
|
||||
self.selected_index -= 1
|
||||
self.refresh()
|
||||
|
||||
def get_selected_invite(self) -> Optional[CalendarInvite]:
|
||||
"""Get the currently selected invite."""
|
||||
if self.invites and 0 <= self.selected_index < len(self.invites):
|
||||
return self.invites[self.selected_index]
|
||||
return None
|
||||
|
||||
def on_click(self, event) -> None:
|
||||
"""Handle mouse clicks."""
|
||||
y = event.y
|
||||
|
||||
if not self.invites:
|
||||
return
|
||||
|
||||
# Calculate which invite was clicked (2 lines per invite)
|
||||
invite_idx = y // 2
|
||||
if 0 <= invite_idx < len(self.invites):
|
||||
self.selected_index = invite_idx
|
||||
self.post_message(self.InviteSelected(self.invites[invite_idx]))
|
||||
self.refresh()
|
||||
312
src/calendar/widgets/MonthCalendar.py
Normal file
312
src/calendar/widgets/MonthCalendar.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Mini month calendar widget for Calendar TUI sidebar.
|
||||
|
||||
Displays a compact month view with day numbers, highlighting:
|
||||
- Today
|
||||
- Current week
|
||||
- Selected day
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.strip import Strip
|
||||
from textual.widget import Widget
|
||||
|
||||
from src.calendar import config
|
||||
|
||||
|
||||
def get_month_calendar(
|
||||
year: int, month: int, week_start_day: int = 0
|
||||
) -> list[list[Optional[date]]]:
|
||||
"""Generate a calendar grid for a month.
|
||||
|
||||
Returns a list of weeks, where each week is a list of 7 dates (or None for empty cells).
|
||||
|
||||
Args:
|
||||
year: The year
|
||||
month: The month (1-12)
|
||||
week_start_day: Config format (0=Sunday, 1=Monday, ..., 6=Saturday)
|
||||
"""
|
||||
# Get first day of month and number of days
|
||||
first_day = date(year, month, 1)
|
||||
if month == 12:
|
||||
last_day = date(year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
last_day = date(year, month + 1, 1) - timedelta(days=1)
|
||||
|
||||
# Convert config week start to python weekday
|
||||
# Config: 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
# Python: 0=Monday, 1=Tuesday, ..., 6=Sunday
|
||||
python_week_start = (week_start_day - 1) % 7
|
||||
|
||||
# Calculate which day of the week the first day falls on (relative to week start)
|
||||
first_python_weekday = first_day.weekday() # 0=Monday, 6=Sunday
|
||||
days_offset = (first_python_weekday - python_week_start) % 7
|
||||
|
||||
weeks: list[list[Optional[date]]] = []
|
||||
current_week: list[Optional[date]] = [None] * days_offset
|
||||
|
||||
current = first_day
|
||||
while current <= last_day:
|
||||
current_week.append(current)
|
||||
if len(current_week) == 7:
|
||||
weeks.append(current_week)
|
||||
current_week = []
|
||||
current += timedelta(days=1)
|
||||
|
||||
# Fill remaining days in last week
|
||||
if current_week:
|
||||
while len(current_week) < 7:
|
||||
current_week.append(None)
|
||||
weeks.append(current_week)
|
||||
|
||||
return weeks
|
||||
|
||||
|
||||
def get_day_names(week_start_day: int = 0) -> str:
|
||||
"""Get day name headers based on week start day.
|
||||
|
||||
Args:
|
||||
week_start_day: Config format (0=Sunday, 1=Monday, ..., 6=Saturday)
|
||||
|
||||
Returns:
|
||||
String like "Su Mo Tu We Th Fr Sa" or "Mo Tu We Th Fr Sa Su"
|
||||
"""
|
||||
# Full list starting from Sunday (config day 0)
|
||||
all_days = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
|
||||
# Rotate to start from week_start_day
|
||||
rotated = all_days[week_start_day:] + all_days[:week_start_day]
|
||||
return " ".join(rotated)
|
||||
|
||||
|
||||
class MonthCalendar(Widget):
|
||||
"""A compact month calendar widget for sidebars."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
MonthCalendar {
|
||||
width: 24;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
# Reactive attributes
|
||||
display_month: reactive[date] = reactive(lambda: date.today().replace(day=1))
|
||||
selected_date: reactive[date] = reactive(date.today)
|
||||
week_start: reactive[date] = reactive(lambda: date.today())
|
||||
|
||||
class DateSelected(Message):
|
||||
"""A date was clicked/selected."""
|
||||
|
||||
def __init__(self, selected: date) -> None:
|
||||
super().__init__()
|
||||
self.date = selected
|
||||
|
||||
class MonthChanged(Message):
|
||||
"""Month navigation occurred."""
|
||||
|
||||
def __init__(self, month: date) -> None:
|
||||
super().__init__()
|
||||
self.month = month
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
selected_date: Optional[date] = None,
|
||||
week_start: Optional[date] = None,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
if selected_date:
|
||||
self.selected_date = selected_date
|
||||
self.display_month = selected_date.replace(day=1)
|
||||
if week_start:
|
||||
self.week_start = week_start
|
||||
|
||||
def _get_theme_color(self, color_name: str) -> str:
|
||||
"""Get a color from the current theme."""
|
||||
try:
|
||||
theme = self.app.current_theme
|
||||
color = getattr(theme, color_name, None)
|
||||
if color:
|
||||
return str(color)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback colors
|
||||
fallbacks = {
|
||||
"secondary": "#81A1C1",
|
||||
"primary": "#88C0D0",
|
||||
"accent": "#B48EAD",
|
||||
"foreground": "#D8DEE9",
|
||||
"surface": "#3B4252",
|
||||
}
|
||||
return fallbacks.get(color_name, "white")
|
||||
|
||||
@property
|
||||
def _weeks(self) -> list[list[Optional[date]]]:
|
||||
"""Get the weeks for the current display month."""
|
||||
return get_month_calendar(
|
||||
self.display_month.year,
|
||||
self.display_month.month,
|
||||
config.week_start_day(),
|
||||
)
|
||||
|
||||
def get_content_height(self, container, viewport, width: int) -> int:
|
||||
"""Calculate height: header + day names + weeks."""
|
||||
return 2 + len(self._weeks) # Month header + day names + week rows
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a line of the calendar."""
|
||||
if y == 0:
|
||||
return self._render_month_header()
|
||||
elif y == 1:
|
||||
return self._render_day_names()
|
||||
else:
|
||||
week_idx = y - 2
|
||||
weeks = self._weeks
|
||||
if 0 <= week_idx < len(weeks):
|
||||
return self._render_week(weeks[week_idx])
|
||||
return Strip.blank(self.size.width)
|
||||
|
||||
def _render_month_header(self) -> Strip:
|
||||
"""Render the month/year header with navigation arrows."""
|
||||
month_name = self.display_month.strftime("%B %Y")
|
||||
header = f"< {month_name:^16} >"
|
||||
header = header[: self.size.width].ljust(self.size.width)
|
||||
|
||||
primary_color = self._get_theme_color("primary")
|
||||
style = Style(bold=True, color=primary_color)
|
||||
return Strip([Segment(header, style)])
|
||||
|
||||
def _render_day_names(self) -> Strip:
|
||||
"""Render the day name headers based on week start setting."""
|
||||
day_names = get_day_names(config.week_start_day())
|
||||
# Pad to widget width
|
||||
line = day_names[: self.size.width].ljust(self.size.width)
|
||||
style = Style(color="bright_black")
|
||||
return Strip([Segment(line, style)])
|
||||
|
||||
def _render_week(self, week: list[Optional[date]]) -> Strip:
|
||||
"""Render a week row."""
|
||||
segments = []
|
||||
today = date.today()
|
||||
|
||||
# Calculate the week containing week_start
|
||||
week_end = self.week_start + timedelta(days=6)
|
||||
|
||||
secondary_color = self._get_theme_color("secondary")
|
||||
primary_color = self._get_theme_color("primary")
|
||||
|
||||
for i, day in enumerate(week):
|
||||
if day is None:
|
||||
segments.append(Segment(" "))
|
||||
else:
|
||||
day_str = f"{day.day:2d} "
|
||||
|
||||
# Determine styling
|
||||
if day == self.selected_date:
|
||||
# Selected date - reverse video
|
||||
style = Style(bold=True, reverse=True)
|
||||
elif day == today:
|
||||
# Today - highlighted with secondary color
|
||||
style = Style(bold=True, color=secondary_color)
|
||||
elif self.week_start <= day <= week_end:
|
||||
# In current week view - subtle highlight
|
||||
style = Style(color=primary_color)
|
||||
elif day.weekday() >= 5:
|
||||
# Weekend
|
||||
style = Style(color="bright_black")
|
||||
else:
|
||||
# Normal day
|
||||
style = Style()
|
||||
|
||||
segments.append(Segment(day_str, style))
|
||||
|
||||
# Pad remaining width
|
||||
current_width = sum(len(s.text) for s in segments)
|
||||
if current_width < self.size.width:
|
||||
segments.append(Segment(" " * (self.size.width - current_width)))
|
||||
|
||||
return Strip(segments)
|
||||
|
||||
def update_week(self, week_start: date) -> None:
|
||||
"""Update the current week highlight.
|
||||
|
||||
Also updates display_month if the week is in a different month.
|
||||
"""
|
||||
self.week_start = week_start
|
||||
# Optionally auto-update display month to show the week
|
||||
week_month = week_start.replace(day=1)
|
||||
if week_month != self.display_month:
|
||||
self.display_month = week_month
|
||||
self.refresh()
|
||||
|
||||
def update_selected(self, selected: date) -> None:
|
||||
"""Update the selected date."""
|
||||
self.selected_date = selected
|
||||
self.refresh()
|
||||
|
||||
def next_month(self) -> None:
|
||||
"""Navigate to next month."""
|
||||
year = self.display_month.year
|
||||
month = self.display_month.month + 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
self.display_month = date(year, month, 1)
|
||||
self.post_message(self.MonthChanged(self.display_month))
|
||||
self.refresh()
|
||||
|
||||
def prev_month(self) -> None:
|
||||
"""Navigate to previous month."""
|
||||
year = self.display_month.year
|
||||
month = self.display_month.month - 1
|
||||
if month < 1:
|
||||
month = 12
|
||||
year -= 1
|
||||
self.display_month = date(year, month, 1)
|
||||
self.post_message(self.MonthChanged(self.display_month))
|
||||
self.refresh()
|
||||
|
||||
def on_click(self, event) -> None:
|
||||
"""Handle mouse clicks on the calendar."""
|
||||
# Row 0 is the month header (< Month Year >)
|
||||
# Row 1 is day names (Mo Tu We ...)
|
||||
# Row 2+ are the week rows
|
||||
y = event.y
|
||||
x = event.x
|
||||
|
||||
if y == 0:
|
||||
# Month header - check for navigation arrows
|
||||
if x < 2:
|
||||
self.prev_month()
|
||||
elif x >= self.size.width - 2:
|
||||
self.next_month()
|
||||
return
|
||||
|
||||
if y == 1:
|
||||
# Day names row - ignore
|
||||
return
|
||||
|
||||
# Week row - calculate which date was clicked
|
||||
week_idx = y - 2
|
||||
weeks = self._weeks
|
||||
if week_idx < 0 or week_idx >= len(weeks):
|
||||
return
|
||||
|
||||
week = weeks[week_idx]
|
||||
# Each day takes 3 characters ("DD "), so find which day column
|
||||
day_col = x // 3
|
||||
if day_col < 0 or day_col >= 7:
|
||||
return
|
||||
|
||||
clicked_date = week[day_col]
|
||||
if clicked_date is not None:
|
||||
self.selected_date = clicked_date
|
||||
self.post_message(self.DateSelected(clicked_date))
|
||||
self.refresh()
|
||||
786
src/calendar/widgets/WeekGrid.py
Normal file
786
src/calendar/widgets/WeekGrid.py
Normal file
@@ -0,0 +1,786 @@
|
||||
"""Week view grid widget for Calendar TUI.
|
||||
|
||||
Displays a week of calendar events in a grid layout where:
|
||||
- Columns represent days (5 or 7)
|
||||
- Rows represent time slots (30 minutes per row)
|
||||
- Events span multiple rows proportionally to their duration
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from rich.style import Style
|
||||
from rich.segment import Segment
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical
|
||||
from textual.geometry import Size
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.scroll_view import ScrollView
|
||||
from textual.strip import Strip
|
||||
from textual.widget import Widget
|
||||
|
||||
from src.calendar.backend import Event
|
||||
from src.calendar import config
|
||||
|
||||
|
||||
# Column widths
|
||||
TIME_COLUMN_WIDTH = 6 # "HH:MM "
|
||||
MIN_DAY_COLUMN_WIDTH = 10 # Minimum width for each day column
|
||||
DEFAULT_DAY_COLUMN_WIDTH = 20 # Default/preferred width for each day column
|
||||
|
||||
|
||||
def get_rows_per_hour() -> int:
|
||||
"""Get rows per hour from config."""
|
||||
return 60 // config.minutes_per_row()
|
||||
|
||||
|
||||
def get_total_rows() -> int:
|
||||
"""Get total rows for 24 hours."""
|
||||
return 24 * get_rows_per_hour()
|
||||
|
||||
|
||||
def get_week_start_for_date(target_date: date) -> date:
|
||||
"""Get the week start date for a given date based on config.
|
||||
|
||||
Config uses: 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
Python weekday() uses: 0=Monday, ..., 6=Sunday
|
||||
"""
|
||||
week_start_cfg = config.week_start_day() # 0=Sunday, 1=Monday, etc.
|
||||
python_weekday = target_date.weekday() # 0=Monday, 6=Sunday
|
||||
|
||||
# Convert config week start to python weekday
|
||||
# Sunday(0) -> 6, Monday(1) -> 0, Tuesday(2) -> 1, etc.
|
||||
python_week_start = (week_start_cfg - 1) % 7
|
||||
|
||||
# Calculate days since week start
|
||||
days_since_week_start = (python_weekday - python_week_start) % 7
|
||||
|
||||
return target_date - timedelta(days=days_since_week_start)
|
||||
|
||||
|
||||
def get_day_column_for_date(target_date: date, week_start: date) -> int:
|
||||
"""Get the column index for a date within its week.
|
||||
|
||||
Returns the number of days since week_start.
|
||||
"""
|
||||
return (target_date - week_start).days
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayColumn:
|
||||
"""Events and layout for a single day column."""
|
||||
|
||||
day: date
|
||||
events: List[Event] = field(default_factory=list)
|
||||
# 2D grid: row -> list of events at that row
|
||||
grid: List[List[Event]] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
# Initialize grid with rows for 24 hours
|
||||
self.grid = [[] for _ in range(get_total_rows())]
|
||||
|
||||
def layout_events(self) -> None:
|
||||
"""Layout events handling overlaps."""
|
||||
total_rows = get_total_rows()
|
||||
minutes_per_row = config.minutes_per_row()
|
||||
|
||||
# Clear the grid
|
||||
self.grid = [[] for _ in range(total_rows)]
|
||||
|
||||
# Sort events by start time, then by duration (longer first)
|
||||
sorted_events = sorted(
|
||||
self.events, key=lambda e: (e.start, -(e.end - e.start).total_seconds())
|
||||
)
|
||||
|
||||
for event in sorted_events:
|
||||
if event.all_day:
|
||||
continue # Handle all-day events separately
|
||||
|
||||
start_row, end_row = event.get_row_span(minutes_per_row)
|
||||
|
||||
# Clamp to valid range
|
||||
start_row = max(0, min(start_row, total_rows - 1))
|
||||
end_row = max(start_row + 1, min(end_row, total_rows))
|
||||
|
||||
# Add event to each row it spans
|
||||
for row in range(start_row, end_row):
|
||||
if event not in self.grid[row]:
|
||||
self.grid[row].append(event)
|
||||
|
||||
|
||||
class WeekGridHeader(Widget):
|
||||
"""Fixed header widget showing day names."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
WeekGridHeader {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
days: List[date],
|
||||
cursor_col: int = 0,
|
||||
include_weekends: bool = True,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id)
|
||||
self._days = days
|
||||
self._cursor_col = cursor_col
|
||||
self._include_weekends = include_weekends
|
||||
|
||||
def update_days(self, days: List[date], cursor_col: int) -> None:
|
||||
"""Update the displayed days."""
|
||||
self._days = days
|
||||
self._cursor_col = cursor_col
|
||||
self.refresh()
|
||||
|
||||
def set_include_weekends(self, include_weekends: bool) -> None:
|
||||
"""Update the include_weekends setting."""
|
||||
self._include_weekends = include_weekends
|
||||
self.refresh()
|
||||
|
||||
@property
|
||||
def num_days(self) -> int:
|
||||
return 7 if self._include_weekends else 5
|
||||
|
||||
def _get_day_column_width(self) -> int:
|
||||
"""Calculate day column width based on available space."""
|
||||
available_width = self.size.width - TIME_COLUMN_WIDTH
|
||||
if available_width <= 0 or self.num_days == 0:
|
||||
return DEFAULT_DAY_COLUMN_WIDTH
|
||||
width_per_day = available_width // self.num_days
|
||||
return max(MIN_DAY_COLUMN_WIDTH, width_per_day)
|
||||
|
||||
def _get_theme_color(self, color_name: str) -> str:
|
||||
"""Get a color from the current theme."""
|
||||
try:
|
||||
theme = self.app.current_theme
|
||||
color = getattr(theme, color_name, None)
|
||||
if color:
|
||||
return str(color)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback colors
|
||||
fallbacks = {
|
||||
"secondary": "#81A1C1",
|
||||
"primary": "#88C0D0",
|
||||
"foreground": "#D8DEE9",
|
||||
"surface": "#3B4252",
|
||||
}
|
||||
return fallbacks.get(color_name, "white")
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render the header row."""
|
||||
day_col_width = self._get_day_column_width()
|
||||
|
||||
if y != 0:
|
||||
return Strip.blank(TIME_COLUMN_WIDTH + (day_col_width * self.num_days))
|
||||
|
||||
segments = []
|
||||
|
||||
# Time column spacer
|
||||
segments.append(Segment(" " * TIME_COLUMN_WIDTH))
|
||||
|
||||
# Get theme colors
|
||||
secondary_color = self._get_theme_color("secondary")
|
||||
|
||||
# Day headers
|
||||
today = date.today()
|
||||
for i, day in enumerate(self._days):
|
||||
day_name = day.strftime("%a %m/%d")
|
||||
|
||||
# Style based on selection and today
|
||||
if i == self._cursor_col:
|
||||
style = Style(bold=True, reverse=True)
|
||||
elif day == today:
|
||||
# Highlight today with theme secondary color
|
||||
style = Style(bold=True, color=secondary_color)
|
||||
elif day.weekday() >= 5: # Weekend
|
||||
style = Style(color="bright_black")
|
||||
else:
|
||||
style = Style()
|
||||
|
||||
# Center the day name in the column
|
||||
header = day_name.center(day_col_width)
|
||||
segments.append(Segment(header, style))
|
||||
|
||||
return Strip(segments)
|
||||
|
||||
|
||||
class WeekGridBody(ScrollView):
|
||||
"""Scrollable body of the week grid showing time slots and events."""
|
||||
|
||||
# Reactive attributes
|
||||
cursor_row: reactive[int] = reactive(0)
|
||||
cursor_col: reactive[int] = reactive(0)
|
||||
|
||||
# Messages
|
||||
class CursorMoved(Message):
|
||||
"""Cursor position changed."""
|
||||
|
||||
def __init__(self, row: int, col: int) -> None:
|
||||
super().__init__()
|
||||
self.row = row
|
||||
self.col = col
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
include_weekends: bool = True,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._days: List[DayColumn] = []
|
||||
self._include_weekends = include_weekends
|
||||
self._work_day_start = config.work_day_start_hour()
|
||||
self._work_day_end = config.work_day_end_hour()
|
||||
|
||||
def _get_theme_color(self, color_name: str) -> str:
|
||||
"""Get a color from the current theme."""
|
||||
try:
|
||||
theme = self.app.current_theme
|
||||
color = getattr(theme, color_name, None)
|
||||
if color:
|
||||
return str(color)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback colors
|
||||
fallbacks = {
|
||||
"secondary": "#81A1C1",
|
||||
"primary": "#88C0D0",
|
||||
"accent": "#B48EAD",
|
||||
"foreground": "#D8DEE9",
|
||||
"surface": "#3B4252",
|
||||
"warning": "#EBCB8B",
|
||||
"error": "#BF616A",
|
||||
}
|
||||
return fallbacks.get(color_name, "white")
|
||||
|
||||
@property
|
||||
def num_days(self) -> int:
|
||||
return 7 if self._include_weekends else 5
|
||||
|
||||
def _get_day_column_width(self) -> int:
|
||||
"""Calculate day column width based on available space."""
|
||||
available_width = self.size.width - TIME_COLUMN_WIDTH
|
||||
if available_width <= 0 or self.num_days == 0:
|
||||
return DEFAULT_DAY_COLUMN_WIDTH
|
||||
width_per_day = available_width // self.num_days
|
||||
return max(MIN_DAY_COLUMN_WIDTH, width_per_day)
|
||||
|
||||
@property
|
||||
def content_width(self) -> int:
|
||||
return TIME_COLUMN_WIDTH + (self._get_day_column_width() * self.num_days)
|
||||
|
||||
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
||||
return get_total_rows()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set up virtual size for scrolling."""
|
||||
self._update_virtual_size()
|
||||
|
||||
def _update_virtual_size(self) -> None:
|
||||
"""Update the virtual size based on content dimensions."""
|
||||
self.virtual_size = Size(self.content_width, get_total_rows())
|
||||
|
||||
def set_days(self, days: List[DayColumn]) -> None:
|
||||
"""Set the day columns to display."""
|
||||
self._days = days
|
||||
self._update_virtual_size()
|
||||
self.refresh()
|
||||
|
||||
def set_include_weekends(self, include_weekends: bool) -> None:
|
||||
"""Update the include_weekends setting."""
|
||||
self._include_weekends = include_weekends
|
||||
self._update_virtual_size()
|
||||
self.refresh()
|
||||
|
||||
def watch_cursor_row(self, old: int, new: int) -> None:
|
||||
"""Handle cursor row changes."""
|
||||
total_rows = get_total_rows()
|
||||
# Clamp cursor row
|
||||
if new < 0:
|
||||
self.cursor_row = 0
|
||||
elif new >= total_rows:
|
||||
self.cursor_row = total_rows - 1
|
||||
else:
|
||||
# Scroll to keep cursor visible with a 2-row margin from viewport edges
|
||||
self._scroll_to_keep_cursor_visible(new)
|
||||
self.post_message(self.CursorMoved(new, self.cursor_col))
|
||||
self.refresh()
|
||||
|
||||
def _scroll_to_keep_cursor_visible(self, cursor_row: int) -> None:
|
||||
"""Scroll viewport only when cursor gets within 2 rows of the edge."""
|
||||
margin = 2 # Number of rows to keep between cursor and viewport edge
|
||||
|
||||
scroll_y = int(self.scroll_offset.y)
|
||||
viewport_height = self.size.height
|
||||
|
||||
# Calculate visible range
|
||||
visible_top = scroll_y
|
||||
visible_bottom = scroll_y + viewport_height - 1
|
||||
|
||||
# Check if cursor is too close to the top edge
|
||||
if cursor_row < visible_top + margin:
|
||||
# Scroll up to keep margin above cursor
|
||||
new_scroll_y = max(0, cursor_row - margin)
|
||||
self.scroll_to(y=new_scroll_y, animate=False)
|
||||
# Check if cursor is too close to the bottom edge
|
||||
elif cursor_row > visible_bottom - margin:
|
||||
# Scroll down to keep margin below cursor
|
||||
new_scroll_y = cursor_row - viewport_height + margin + 1
|
||||
self.scroll_to(y=new_scroll_y, animate=False)
|
||||
|
||||
def watch_cursor_col(self, old: int, new: int) -> None:
|
||||
"""Handle cursor column changes."""
|
||||
self.post_message(self.CursorMoved(self.cursor_row, new))
|
||||
self.refresh()
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a single line of the grid."""
|
||||
scroll_y = int(self.scroll_offset.y)
|
||||
row_index = y + scroll_y
|
||||
|
||||
total_rows = get_total_rows()
|
||||
if row_index < 0 or row_index >= total_rows:
|
||||
return Strip.blank(self.content_width)
|
||||
|
||||
return self._render_time_row(row_index)
|
||||
|
||||
def _render_time_row(self, row_index: int) -> Strip:
|
||||
"""Render a time row with events."""
|
||||
rows_per_hour = get_rows_per_hour()
|
||||
minutes_per_row = config.minutes_per_row()
|
||||
segments = []
|
||||
|
||||
# Check if this is the current time row
|
||||
now = datetime.now()
|
||||
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
|
||||
is_current_time_row = row_index == current_row
|
||||
|
||||
# Check if cursor is on this row
|
||||
is_cursor_row = row_index == self.cursor_row
|
||||
|
||||
# Time label (only show on the hour)
|
||||
if row_index % rows_per_hour == 0:
|
||||
hour = row_index // rows_per_hour
|
||||
time_str = f"{hour:02d}:00 "
|
||||
else:
|
||||
time_str = " " # Blank for half-hour
|
||||
|
||||
# Style time label - highlight current time or cursor, dim outside work hours
|
||||
if is_cursor_row:
|
||||
# Highlight the hour label when cursor is on this row
|
||||
primary_color = self._get_theme_color("primary")
|
||||
time_style = Style(color=primary_color, bold=True, reverse=True)
|
||||
elif is_current_time_row:
|
||||
error_color = self._get_theme_color("error")
|
||||
# Add subtle background to current time row for better visibility
|
||||
surface_color = self._get_theme_color("surface")
|
||||
time_style = Style(color=error_color, bold=True, bgcolor=surface_color)
|
||||
elif (
|
||||
row_index < self._work_day_start * rows_per_hour
|
||||
or row_index >= self._work_day_end * rows_per_hour
|
||||
):
|
||||
time_style = Style(color="bright_black")
|
||||
else:
|
||||
primary_color = self._get_theme_color("primary")
|
||||
time_style = Style(color=primary_color)
|
||||
|
||||
segments.append(Segment(time_str, time_style))
|
||||
|
||||
# Event cells for each day
|
||||
for col_idx, day_col in enumerate(self._days):
|
||||
cell_text, cell_style = self._render_event_cell(
|
||||
day_col, row_index, col_idx, is_current_time_row
|
||||
)
|
||||
segments.append(Segment(cell_text, cell_style))
|
||||
|
||||
return Strip(segments)
|
||||
|
||||
def _render_event_cell(
|
||||
self,
|
||||
day_col: DayColumn,
|
||||
row_index: int,
|
||||
col_idx: int,
|
||||
is_current_time_row: bool = False,
|
||||
) -> Tuple[str, Style]:
|
||||
"""Render a single cell for a day/time slot."""
|
||||
events_at_row = day_col.grid[row_index] if row_index < len(day_col.grid) else []
|
||||
rows_per_hour = get_rows_per_hour()
|
||||
minutes_per_row = config.minutes_per_row()
|
||||
day_col_width = self._get_day_column_width()
|
||||
|
||||
is_cursor = col_idx == self.cursor_col and row_index == self.cursor_row
|
||||
|
||||
# Get colors for current time line
|
||||
error_color = self._get_theme_color("error") if is_current_time_row else None
|
||||
|
||||
if not events_at_row:
|
||||
# Empty cell
|
||||
if is_cursor:
|
||||
return ">" + " " * (day_col_width - 1), Style(reverse=True)
|
||||
elif is_current_time_row:
|
||||
# Current time indicator line
|
||||
return "─" * day_col_width, Style(color=error_color, bold=True)
|
||||
else:
|
||||
# Grid line style
|
||||
if row_index % rows_per_hour == 0:
|
||||
return "-" * day_col_width, Style(color="bright_black")
|
||||
else:
|
||||
return " " * day_col_width, Style()
|
||||
|
||||
# Get the event to display (first one if multiple)
|
||||
event = events_at_row[0]
|
||||
|
||||
# Determine if this is the start row for this event
|
||||
start_row, _ = event.get_row_span(minutes_per_row)
|
||||
is_start = row_index == max(0, start_row)
|
||||
|
||||
# Build cell text
|
||||
if is_start:
|
||||
# Show event title with time
|
||||
time_str = event.start.strftime("%H:%M")
|
||||
title = event.title[: day_col_width - 7] # Leave room for time
|
||||
cell_text = f"{time_str} {title}"
|
||||
else:
|
||||
# Continuation of event
|
||||
cell_text = "│ " + event.title[: day_col_width - 3]
|
||||
|
||||
# Pad/truncate to column width
|
||||
cell_text = cell_text[:day_col_width].ljust(day_col_width)
|
||||
|
||||
# Style based on event and cursor
|
||||
if is_cursor:
|
||||
style = Style(bold=True, reverse=True)
|
||||
elif len(events_at_row) > 1:
|
||||
# Overlapping events - use warning color
|
||||
warning_color = self._get_theme_color("warning")
|
||||
style = Style(bgcolor=warning_color, color="black")
|
||||
else:
|
||||
# Normal event - use primary color
|
||||
primary_color = self._get_theme_color("primary")
|
||||
style = Style(bgcolor=primary_color, color="black")
|
||||
|
||||
return cell_text, style
|
||||
|
||||
def get_event_at_cursor(self) -> Optional[Event]:
|
||||
"""Get the event at the current cursor position."""
|
||||
if self.cursor_col < 0 or self.cursor_col >= len(self._days):
|
||||
return None
|
||||
|
||||
day_col = self._days[self.cursor_col]
|
||||
if self.cursor_row < 0 or self.cursor_row >= len(day_col.grid):
|
||||
return None
|
||||
|
||||
events = day_col.grid[self.cursor_row]
|
||||
return events[0] if events else None
|
||||
|
||||
|
||||
class WeekGrid(Vertical):
|
||||
"""Week view calendar grid widget with fixed header."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
WeekGrid {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
WeekGridHeader {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
WeekGridBody {
|
||||
height: 1fr;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("j", "cursor_down", "Down", show=False),
|
||||
Binding("k", "cursor_up", "Up", show=False),
|
||||
Binding("h", "cursor_left", "Left", show=False),
|
||||
Binding("l", "cursor_right", "Right", show=False),
|
||||
Binding("H", "prev_week", "Prev Week", show=True),
|
||||
Binding("L", "next_week", "Next Week", show=True),
|
||||
Binding("g", "goto_today", "Today", show=True),
|
||||
Binding("enter", "select_event", "View", show=True),
|
||||
]
|
||||
|
||||
# Reactive attributes
|
||||
week_start: reactive[date] = reactive(date.today)
|
||||
include_weekends: reactive[bool] = reactive(True)
|
||||
|
||||
# Messages
|
||||
class EventSelected(Message):
|
||||
"""Event was selected."""
|
||||
|
||||
def __init__(self, event: Event) -> None:
|
||||
super().__init__()
|
||||
self.event = event
|
||||
|
||||
class WeekChanged(Message):
|
||||
"""Week was changed via navigation."""
|
||||
|
||||
def __init__(self, week_start: date) -> None:
|
||||
super().__init__()
|
||||
self.week_start = week_start
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
week_start: Optional[date] = None,
|
||||
include_weekends: bool = True,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
# Initialize state BEFORE setting reactive attributes
|
||||
self._days: List[DayColumn] = []
|
||||
self._events_by_date: dict[date, List[Event]] = {}
|
||||
self._header: Optional[WeekGridHeader] = None
|
||||
self._body: Optional[WeekGridBody] = None
|
||||
|
||||
# Set week start based on config.week_start_day() if not provided
|
||||
if week_start is None:
|
||||
today = date.today()
|
||||
week_start = get_week_start_for_date(today)
|
||||
|
||||
self.include_weekends = include_weekends
|
||||
self.week_start = week_start
|
||||
|
||||
@property
|
||||
def num_days(self) -> int:
|
||||
return 7 if self.include_weekends else 5
|
||||
|
||||
@property
|
||||
def cursor_row(self) -> int:
|
||||
"""Get current cursor row."""
|
||||
if self._body:
|
||||
return self._body.cursor_row
|
||||
return 0
|
||||
|
||||
@cursor_row.setter
|
||||
def cursor_row(self, value: int) -> None:
|
||||
"""Set cursor row."""
|
||||
if self._body:
|
||||
self._body.cursor_row = value
|
||||
|
||||
@property
|
||||
def cursor_col(self) -> int:
|
||||
"""Get current cursor column."""
|
||||
if self._body:
|
||||
return self._body.cursor_col
|
||||
return 0
|
||||
|
||||
@cursor_col.setter
|
||||
def cursor_col(self, value: int) -> None:
|
||||
"""Set cursor column."""
|
||||
if self._body:
|
||||
self._body.cursor_col = value
|
||||
if self._header:
|
||||
self._header.update_days([d.day for d in self._days], value)
|
||||
|
||||
def compose(self):
|
||||
"""Compose the widget."""
|
||||
days = [d.day for d in self._days] if self._days else []
|
||||
self._header = WeekGridHeader(
|
||||
days=days,
|
||||
cursor_col=0,
|
||||
include_weekends=self.include_weekends,
|
||||
)
|
||||
self._body = WeekGridBody(
|
||||
include_weekends=self.include_weekends,
|
||||
)
|
||||
yield self._header
|
||||
yield self._body
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize on mount - set cursor to current day/time."""
|
||||
self._init_week()
|
||||
self.goto_now()
|
||||
|
||||
def _init_week(self) -> None:
|
||||
"""Initialize the week's day columns."""
|
||||
self._days = []
|
||||
# Always iterate through 7 days from week_start
|
||||
for i in range(7):
|
||||
day = self.week_start + timedelta(days=i)
|
||||
# Skip weekend days (Saturday=5, Sunday=6) when not including weekends
|
||||
if not self.include_weekends and day.weekday() >= 5:
|
||||
continue
|
||||
col = DayColumn(day=day)
|
||||
if day in self._events_by_date:
|
||||
col.events = self._events_by_date[day]
|
||||
col.layout_events()
|
||||
self._days.append(col)
|
||||
|
||||
# Update child widgets
|
||||
if self._header:
|
||||
self._header.update_days(
|
||||
[d.day for d in self._days], self._body.cursor_col if self._body else 0
|
||||
)
|
||||
if self._body:
|
||||
self._body.set_days(self._days)
|
||||
|
||||
def set_events(self, events_by_date: dict[date, List[Event]]) -> None:
|
||||
"""Set the events to display."""
|
||||
self._events_by_date = events_by_date
|
||||
self._init_week()
|
||||
self.refresh()
|
||||
|
||||
def goto_now(self) -> None:
|
||||
"""Set cursor to current day and time, scroll to work day start."""
|
||||
today = date.today()
|
||||
now = datetime.now()
|
||||
rows_per_hour = get_rows_per_hour()
|
||||
minutes_per_row = config.minutes_per_row()
|
||||
|
||||
# Set week to contain today using configurable week start day
|
||||
week_start_date = get_week_start_for_date(today)
|
||||
if self.week_start != week_start_date:
|
||||
self.week_start = week_start_date
|
||||
|
||||
# Set cursor column to today (relative to week start)
|
||||
col = get_day_column_for_date(today, self.week_start)
|
||||
if not self.include_weekends and col >= 5:
|
||||
col = 4 # Last weekday if weekend
|
||||
self.cursor_col = col
|
||||
|
||||
# Set cursor row to current time
|
||||
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
|
||||
self.cursor_row = current_row
|
||||
|
||||
# Always scroll to work day start initially (e.g., 7am)
|
||||
if self._body:
|
||||
work_start_row = config.work_day_start_hour() * rows_per_hour
|
||||
self._body.scroll_to(y=work_start_row, animate=False)
|
||||
|
||||
def watch_week_start(self, old: date, new: date) -> None:
|
||||
"""Handle week_start changes."""
|
||||
self._init_week()
|
||||
self.post_message(self.WeekChanged(new))
|
||||
self.refresh()
|
||||
|
||||
def watch_include_weekends(self, old: bool, new: bool) -> None:
|
||||
"""Handle include_weekends changes."""
|
||||
if self._header:
|
||||
self._header.set_include_weekends(new)
|
||||
if self._body:
|
||||
self._body.set_include_weekends(new)
|
||||
self._init_week()
|
||||
self.refresh()
|
||||
|
||||
def on_week_grid_body_cursor_moved(self, message: WeekGridBody.CursorMoved) -> None:
|
||||
"""Handle cursor moves in body - update header."""
|
||||
if self._header:
|
||||
self._header.update_days([d.day for d in self._days], message.col)
|
||||
|
||||
def get_event_at_cursor(self) -> Optional[Event]:
|
||||
"""Get the event at the current cursor position."""
|
||||
if self._body:
|
||||
return self._body.get_event_at_cursor()
|
||||
return None
|
||||
|
||||
def get_cursor_date(self) -> date:
|
||||
"""Get the date at the current cursor column."""
|
||||
if self._days and 0 <= self.cursor_col < len(self._days):
|
||||
return self._days[self.cursor_col].day
|
||||
return date.today()
|
||||
|
||||
def get_cursor_time(self):
|
||||
"""Get the time at the current cursor row.
|
||||
|
||||
Returns:
|
||||
A time object for the cursor row position.
|
||||
"""
|
||||
from datetime import time as time_type
|
||||
|
||||
minutes_per_row = config.minutes_per_row()
|
||||
total_minutes = self.cursor_row * minutes_per_row
|
||||
hour = total_minutes // 60
|
||||
minute = total_minutes % 60
|
||||
# Clamp to valid range
|
||||
hour = max(0, min(23, hour))
|
||||
minute = max(0, min(59, minute))
|
||||
return time_type(hour, minute)
|
||||
|
||||
# Actions
|
||||
def action_cursor_down(self) -> None:
|
||||
"""Move cursor down."""
|
||||
if self._body:
|
||||
self._body.cursor_row += 1
|
||||
|
||||
def action_cursor_up(self) -> None:
|
||||
"""Move cursor up."""
|
||||
if self._body:
|
||||
self._body.cursor_row -= 1
|
||||
|
||||
def action_cursor_left(self) -> None:
|
||||
"""Move cursor left (wraps to previous week)."""
|
||||
if self._body:
|
||||
if self._body.cursor_col <= 0:
|
||||
# Wrap to previous week
|
||||
self._body.cursor_col = self.num_days - 1
|
||||
self.action_prev_week()
|
||||
else:
|
||||
self._body.cursor_col -= 1
|
||||
if self._header:
|
||||
self._header.update_days(
|
||||
[d.day for d in self._days], self._body.cursor_col
|
||||
)
|
||||
|
||||
def action_cursor_right(self) -> None:
|
||||
"""Move cursor right (wraps to next week)."""
|
||||
if self._body:
|
||||
if self._body.cursor_col >= self.num_days - 1:
|
||||
# Wrap to next week
|
||||
self._body.cursor_col = 0
|
||||
self.action_next_week()
|
||||
else:
|
||||
self._body.cursor_col += 1
|
||||
if self._header:
|
||||
self._header.update_days(
|
||||
[d.day for d in self._days], self._body.cursor_col
|
||||
)
|
||||
|
||||
def action_prev_week(self) -> None:
|
||||
"""Navigate to previous week."""
|
||||
self.week_start = self.week_start - timedelta(weeks=1)
|
||||
|
||||
def action_next_week(self) -> None:
|
||||
"""Navigate to next week."""
|
||||
self.week_start = self.week_start + timedelta(weeks=1)
|
||||
|
||||
def action_goto_today(self) -> None:
|
||||
"""Navigate to current week and today's column/time."""
|
||||
self.goto_now()
|
||||
|
||||
def action_select_event(self) -> None:
|
||||
"""Select the event at cursor."""
|
||||
event = self.get_event_at_cursor()
|
||||
if event:
|
||||
self.post_message(self.EventSelected(event))
|
||||
|
||||
def goto_date(self, target_date: date) -> None:
|
||||
"""Navigate to a specific date.
|
||||
|
||||
Sets the week to contain the target date and places cursor on that day.
|
||||
"""
|
||||
# Get the week start for the target date
|
||||
week_start_date = get_week_start_for_date(target_date)
|
||||
|
||||
if self.week_start != week_start_date:
|
||||
self.week_start = week_start_date
|
||||
|
||||
# Set cursor column to the target date
|
||||
col = get_day_column_for_date(target_date, self.week_start)
|
||||
if not self.include_weekends and col >= 5:
|
||||
col = 4 # Last weekday if weekend
|
||||
self.cursor_col = col
|
||||
15
src/calendar/widgets/__init__.py
Normal file
15
src/calendar/widgets/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Calendar TUI widgets."""
|
||||
|
||||
from .WeekGrid import WeekGrid
|
||||
from .AddEventForm import AddEventForm, EventFormData
|
||||
from .MonthCalendar import MonthCalendar
|
||||
from .InvitesPanel import InvitesPanel, CalendarInvite
|
||||
|
||||
__all__ = [
|
||||
"WeekGrid",
|
||||
"AddEventForm",
|
||||
"EventFormData",
|
||||
"MonthCalendar",
|
||||
"InvitesPanel",
|
||||
"CalendarInvite",
|
||||
]
|
||||
@@ -1,20 +1,55 @@
|
||||
# CLI module for the application
|
||||
# Uses lazy imports to speed up startup time
|
||||
|
||||
import click
|
||||
|
||||
from .sync import sync
|
||||
from .drive import drive
|
||||
from .email import email
|
||||
from .calendar import calendar
|
||||
import importlib
|
||||
|
||||
|
||||
@click.group()
|
||||
class LazyGroup(click.Group):
|
||||
"""A click Group that lazily loads subcommands."""
|
||||
|
||||
def __init__(self, *args, lazy_subcommands=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._lazy_subcommands = lazy_subcommands or {}
|
||||
|
||||
def list_commands(self, ctx):
|
||||
base = super().list_commands(ctx)
|
||||
lazy = list(self._lazy_subcommands.keys())
|
||||
return sorted(base + lazy)
|
||||
|
||||
def get_command(self, ctx, cmd_name):
|
||||
if cmd_name in self._lazy_subcommands:
|
||||
return self._load_command(cmd_name)
|
||||
return super().get_command(ctx, cmd_name)
|
||||
|
||||
def _load_command(self, cmd_name):
|
||||
module_path, attr_name = self._lazy_subcommands[cmd_name]
|
||||
# Handle relative imports
|
||||
if module_path.startswith("."):
|
||||
module = importlib.import_module(module_path, package="src.cli")
|
||||
else:
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, attr_name)
|
||||
|
||||
|
||||
# Create CLI with lazy loading - commands only imported when invoked
|
||||
@click.group(
|
||||
cls=LazyGroup,
|
||||
lazy_subcommands={
|
||||
"sync": (".sync", "sync"),
|
||||
"drive": (".drive", "drive"),
|
||||
"email": (".email", "email"),
|
||||
"mail": (".email", "email"), # alias
|
||||
"calendar": (".calendar", "calendar"),
|
||||
"ticktick": (".ticktick", "ticktick"),
|
||||
"tt": (".ticktick", "ticktick"), # alias
|
||||
"godspeed": (".godspeed", "godspeed"),
|
||||
"gs": (".godspeed", "godspeed"), # alias
|
||||
"gitlab_monitor": (".gitlab_monitor", "gitlab_monitor"),
|
||||
"glm": (".gitlab_monitor", "gitlab_monitor"), # alias
|
||||
"tasks": (".tasks", "tasks"),
|
||||
},
|
||||
)
|
||||
def cli():
|
||||
"""Root command for the CLI."""
|
||||
"""LUK - Local Unix Kit for productivity."""
|
||||
pass
|
||||
|
||||
|
||||
cli.add_command(sync)
|
||||
cli.add_command(drive)
|
||||
cli.add_command(email)
|
||||
cli.add_command(calendar)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
from . import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
def main():
|
||||
"""Main entry point for the CLI."""
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
"""Calendar CLI commands."""
|
||||
|
||||
import click
|
||||
import subprocess
|
||||
|
||||
|
||||
@click.command()
|
||||
def calendar():
|
||||
"""Open the calendar (khal interactive)."""
|
||||
click.echo("Opening calendar...")
|
||||
subprocess.run(["khal", "interactive"])
|
||||
@click.option("--interactive", "-i", is_flag=True, help="Use khal interactive mode")
|
||||
@click.option("--weekdays", "-w", is_flag=True, help="Show only weekdays (Mon-Fri)")
|
||||
def calendar(interactive: bool, weekdays: bool):
|
||||
"""Open the calendar TUI.
|
||||
|
||||
Displays a week view of calendar events from khal.
|
||||
|
||||
Navigation:
|
||||
j/k - Move up/down (time)
|
||||
h/l - Move left/right (day)
|
||||
H/L - Previous/Next week
|
||||
g - Go to today
|
||||
w - Toggle weekends
|
||||
Enter - View event details
|
||||
q - Quit
|
||||
"""
|
||||
if interactive:
|
||||
# Fallback to khal interactive mode
|
||||
import subprocess
|
||||
|
||||
click.echo("Opening khal interactive...")
|
||||
subprocess.run(["khal", "interactive"])
|
||||
else:
|
||||
from src.calendar import run_app
|
||||
|
||||
run_app()
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import click
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
|
||||
@click.command()
|
||||
def drive():
|
||||
"""View OneDrive files."""
|
||||
click.echo("Launching OneDrive viewer...")
|
||||
subprocess.run(["python3", "src/drive_view_tui.py"])
|
||||
# Get the directory containing this file, then go up to project root
|
||||
current_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
subprocess.run(["python3", "drive_view_tui.py"], cwd=current_dir)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import click
|
||||
from src.maildir_gtd.app import launch_email_viewer
|
||||
from src.mail.app import launch_email_viewer
|
||||
|
||||
|
||||
@click.command()
|
||||
|
||||
152
src/cli/gitlab_monitor.py
Normal file
152
src/cli/gitlab_monitor.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import click
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@click.group()
|
||||
def gitlab_monitor():
|
||||
"""GitLab pipeline monitoring daemon."""
|
||||
pass
|
||||
|
||||
|
||||
@gitlab_monitor.command()
|
||||
@click.option("--config", help="Path to configuration file")
|
||||
@click.option("--daemon", "-d", is_flag=True, help="Run in background as daemon")
|
||||
def start(config, daemon):
|
||||
"""Start the GitLab pipeline monitoring daemon."""
|
||||
daemon_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "services", "gitlab_monitor", "daemon.py"
|
||||
)
|
||||
|
||||
if daemon:
|
||||
# Run as background daemon
|
||||
click.echo("Starting GitLab pipeline monitor daemon in background...")
|
||||
|
||||
cmd = [sys.executable, daemon_path]
|
||||
if config:
|
||||
cmd.extend(["--config", config])
|
||||
|
||||
# Create pid file
|
||||
pid_file = os.path.expanduser("~/.config/luk/gitlab_monitor.pid")
|
||||
Path(pid_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Start daemon process
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
# Save PID
|
||||
with open(pid_file, "w") as f:
|
||||
f.write(str(process.pid))
|
||||
|
||||
click.echo(f"Daemon started with PID {process.pid}")
|
||||
click.echo(f"PID file: {pid_file}")
|
||||
else:
|
||||
# Run in foreground
|
||||
click.echo("Starting GitLab pipeline monitor (press Ctrl+C to stop)...")
|
||||
|
||||
# Import and run the daemon
|
||||
from src.services.gitlab_monitor.daemon import main
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
@gitlab_monitor.command()
|
||||
def stop():
|
||||
"""Stop the GitLab pipeline monitoring daemon."""
|
||||
pid_file = os.path.expanduser("~/.config/luk/gitlab_monitor.pid")
|
||||
|
||||
if not os.path.exists(pid_file):
|
||||
click.echo("Daemon is not running (no PID file found)")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(pid_file, "r") as f:
|
||||
pid = int(f.read().strip())
|
||||
|
||||
# Send SIGTERM to process group
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
|
||||
# Remove PID file
|
||||
os.unlink(pid_file)
|
||||
|
||||
click.echo(f"Daemon stopped (PID {pid})")
|
||||
except (ValueError, ProcessLookupError, OSError) as e:
|
||||
click.echo(f"Error stopping daemon: {e}")
|
||||
# Clean up stale PID file
|
||||
if os.path.exists(pid_file):
|
||||
os.unlink(pid_file)
|
||||
|
||||
|
||||
@gitlab_monitor.command()
|
||||
def status():
|
||||
"""Check the status of the GitLab pipeline monitoring daemon."""
|
||||
pid_file = os.path.expanduser("~/.config/luk/gitlab_monitor.pid")
|
||||
|
||||
if not os.path.exists(pid_file):
|
||||
click.echo("Daemon is not running")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(pid_file, "r") as f:
|
||||
pid = int(f.read().strip())
|
||||
|
||||
# Check if process exists
|
||||
os.kill(pid, 0) # Send signal 0 to check if process exists
|
||||
click.echo(f"Daemon is running (PID {pid})")
|
||||
except (ValueError, ProcessLookupError, OSError):
|
||||
click.echo("Daemon is not running (stale PID file)")
|
||||
# Clean up stale PID file
|
||||
os.unlink(pid_file)
|
||||
|
||||
|
||||
@gitlab_monitor.command()
|
||||
@click.option("--config", help="Path to configuration file")
|
||||
def test(config):
|
||||
"""Test the configuration and dependencies."""
|
||||
from src.services.gitlab_monitor.daemon import GitLabPipelineMonitor
|
||||
|
||||
monitor = GitLabPipelineMonitor(config)
|
||||
|
||||
click.echo("Configuration test:")
|
||||
click.echo(
|
||||
f"GitLab token configured: {'✓' if monitor.config.get_gitlab_token() else '✗'}"
|
||||
)
|
||||
click.echo(
|
||||
f"OpenAI key configured: {'✓' if monitor.config.get_openai_key() else '✗'}"
|
||||
)
|
||||
click.echo(f"Subject patterns: {monitor.config.get_subject_patterns()}")
|
||||
click.echo(f"Sender patterns: {monitor.config.get_sender_patterns()}")
|
||||
click.echo(f"Check interval: {monitor.config.get_check_interval()}s")
|
||||
click.echo(f"Config file: {monitor.config.config_path}")
|
||||
|
||||
|
||||
@gitlab_monitor.command()
|
||||
def config():
|
||||
"""Show the current configuration file path and create default if needed."""
|
||||
from src.services.gitlab_monitor.config import GitLabMonitorConfig
|
||||
|
||||
config = GitLabMonitorConfig()
|
||||
click.echo(f"Configuration file: {config.config_path}")
|
||||
|
||||
if os.path.exists(config.config_path):
|
||||
click.echo("Configuration file exists")
|
||||
else:
|
||||
click.echo("Default configuration file created")
|
||||
|
||||
click.echo("\nTo configure the daemon:")
|
||||
click.echo("1. Set environment variables:")
|
||||
click.echo(" export GITLAB_API_TOKEN='your_gitlab_token'")
|
||||
click.echo(" export OPENAI_API_KEY='your_openai_key'")
|
||||
click.echo("2. Or edit the configuration file directly")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gitlab_monitor()
|
||||
616
src/cli/godspeed.py
Normal file
616
src/cli/godspeed.py
Normal file
@@ -0,0 +1,616 @@
|
||||
"""CLI interface for Godspeed sync functionality."""
|
||||
|
||||
import click
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from ..services.godspeed.client import GodspeedClient
|
||||
from ..services.godspeed.sync import GodspeedSync
|
||||
|
||||
|
||||
def get_credentials():
|
||||
"""Get Godspeed credentials from environment or user input."""
|
||||
email = os.getenv("GODSPEED_EMAIL")
|
||||
password = os.getenv("GODSPEED_PASSWORD")
|
||||
token = os.getenv("GODSPEED_TOKEN")
|
||||
|
||||
if token:
|
||||
return None, None, token
|
||||
|
||||
if not email:
|
||||
email = click.prompt("Godspeed email")
|
||||
|
||||
if not password:
|
||||
password = click.prompt("Godspeed password", hide_input=True)
|
||||
|
||||
return email, password, None
|
||||
|
||||
|
||||
def get_sync_directory():
|
||||
"""Get sync directory from environment or default."""
|
||||
sync_dir = os.getenv("GODSPEED_SYNC_DIR")
|
||||
if sync_dir:
|
||||
return Path(sync_dir)
|
||||
|
||||
# Default to ~/Documents/Godspeed or ~/.local/share/gtd-terminal-tools/godspeed
|
||||
home = Path.home()
|
||||
|
||||
# Try Documents first
|
||||
docs_dir = home / "Documents" / "Godspeed"
|
||||
if docs_dir.parent.exists():
|
||||
return docs_dir
|
||||
|
||||
# Fall back to data directory
|
||||
data_dir = home / ".local" / "share" / "gtd-terminal-tools" / "godspeed"
|
||||
return data_dir
|
||||
|
||||
|
||||
@click.group()
|
||||
def godspeed():
|
||||
"""Godspeed sync tool - bidirectional sync between Godspeed API and markdown files."""
|
||||
pass
|
||||
|
||||
|
||||
@godspeed.command()
|
||||
def download():
|
||||
"""Download tasks from Godspeed API to local files."""
|
||||
email, password, token = get_credentials()
|
||||
sync_dir = get_sync_directory()
|
||||
|
||||
try:
|
||||
client = GodspeedClient(email=email, password=password, token=token)
|
||||
sync_engine = GodspeedSync(client, sync_dir)
|
||||
sync_engine.download_from_api()
|
||||
|
||||
click.echo(f"\nTasks downloaded to: {sync_dir}")
|
||||
click.echo(
|
||||
"You can now edit the markdown files and run 'godspeed upload' to sync changes back."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error during download: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@godspeed.command()
|
||||
def upload():
|
||||
"""Upload local markdown files to Godspeed API."""
|
||||
email, password, token = get_credentials()
|
||||
sync_dir = get_sync_directory()
|
||||
|
||||
if not sync_dir.exists():
|
||||
click.echo(f"Sync directory does not exist: {sync_dir}", err=True)
|
||||
click.echo("Run 'godspeed download' first to initialize the sync directory.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
client = GodspeedClient(email=email, password=password, token=token)
|
||||
sync_engine = GodspeedSync(client, sync_dir)
|
||||
sync_engine.upload_to_api()
|
||||
|
||||
click.echo("Local changes uploaded successfully.")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error during upload: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@godspeed.command()
|
||||
def sync():
|
||||
"""Perform bidirectional sync between local files and Godspeed API."""
|
||||
email, password, token = get_credentials()
|
||||
sync_dir = get_sync_directory()
|
||||
|
||||
try:
|
||||
client = GodspeedClient(email=email, password=password, token=token)
|
||||
sync_engine = GodspeedSync(client, sync_dir)
|
||||
sync_engine.sync_bidirectional()
|
||||
|
||||
click.echo(f"\nSync complete. Files are in: {sync_dir}")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error during sync: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@godspeed.command()
|
||||
def status():
|
||||
"""Show sync status and directory information."""
|
||||
sync_dir = get_sync_directory()
|
||||
|
||||
if not sync_dir.exists():
|
||||
click.echo(f"Sync directory does not exist: {sync_dir}")
|
||||
click.echo("Run 'godspeed download' or 'godspeed sync' to initialize.")
|
||||
return
|
||||
|
||||
# Create a minimal sync engine for status (no API client needed)
|
||||
sync_engine = GodspeedSync(None, sync_dir)
|
||||
status_info = sync_engine.get_sync_status()
|
||||
|
||||
click.echo(f"Sync Directory: {status_info['sync_directory']}")
|
||||
click.echo(f"Local Files: {status_info['local_files']}")
|
||||
click.echo(f"Total Local Tasks: {status_info['total_local_tasks']}")
|
||||
click.echo(f"Tracked Tasks: {status_info['tracked_tasks']}")
|
||||
click.echo(f"Tracked Lists: {status_info['tracked_lists']}")
|
||||
|
||||
if status_info["last_sync"]:
|
||||
click.echo(f"Last Sync: {status_info['last_sync']}")
|
||||
else:
|
||||
click.echo("Last Sync: Never")
|
||||
|
||||
click.echo("\nMarkdown Files:")
|
||||
for file_path in sync_engine.list_local_files():
|
||||
tasks = sync_engine._read_list_file(file_path)
|
||||
completed = sum(
|
||||
1 for _, status, _, _ in tasks if status in ["complete", "cleared"]
|
||||
)
|
||||
total = len(tasks)
|
||||
click.echo(f" {file_path.name}: {completed}/{total} completed")
|
||||
|
||||
|
||||
@godspeed.command()
|
||||
def test_connection():
|
||||
"""Test connection to Godspeed API with SSL diagnostics."""
|
||||
import requests
|
||||
import ssl
|
||||
import socket
|
||||
|
||||
click.echo("Testing connection to Godspeed API...")
|
||||
|
||||
# Check if SSL bypass is enabled first
|
||||
disable_ssl = os.getenv("GODSPEED_DISABLE_SSL_VERIFY", "").lower() == "true"
|
||||
if disable_ssl:
|
||||
click.echo("⚠️ SSL verification is disabled (GODSPEED_DISABLE_SSL_VERIFY=true)")
|
||||
|
||||
# Test basic connectivity
|
||||
ssl_error_occurred = False
|
||||
try:
|
||||
response = requests.get("https://api.godspeedapp.com", timeout=10)
|
||||
click.echo("✓ Basic HTTPS connection successful")
|
||||
except requests.exceptions.SSLError as e:
|
||||
ssl_error_occurred = True
|
||||
click.echo(f"✗ SSL Error: {e}")
|
||||
if not disable_ssl:
|
||||
click.echo("\n💡 Try setting: export GODSPEED_DISABLE_SSL_VERIFY=true")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
click.echo(f"✗ Connection Error: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
click.echo(f"✗ Unexpected Error: {e}")
|
||||
return
|
||||
|
||||
# Test with SSL bypass if enabled and there was an SSL error
|
||||
if disable_ssl and ssl_error_occurred:
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://api.godspeedapp.com", verify=False, timeout=10
|
||||
)
|
||||
click.echo("✓ Connection successful with SSL bypass")
|
||||
except Exception as e:
|
||||
click.echo(f"✗ Connection failed even with SSL bypass: {e}")
|
||||
return
|
||||
|
||||
# Test authentication if credentials available
|
||||
email, password, token = get_credentials()
|
||||
if token or (email and password):
|
||||
try:
|
||||
client = GodspeedClient(email=email, password=password, token=token)
|
||||
lists = client.get_lists()
|
||||
click.echo(f"✓ Authentication successful, found {len(lists)} lists")
|
||||
except Exception as e:
|
||||
click.echo(f"✗ Authentication failed: {e}")
|
||||
else:
|
||||
click.echo("ℹ️ No credentials provided for authentication test")
|
||||
|
||||
click.echo("\nConnection test complete!")
|
||||
|
||||
|
||||
@godspeed.command()
|
||||
def open():
|
||||
"""Open the sync directory in the default file manager."""
|
||||
sync_dir = get_sync_directory()
|
||||
|
||||
if not sync_dir.exists():
|
||||
click.echo(f"Sync directory does not exist: {sync_dir}", err=True)
|
||||
click.echo("Run 'godspeed download' or 'godspeed sync' to initialize.")
|
||||
return
|
||||
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == "Darwin": # macOS
|
||||
subprocess.run(["open", str(sync_dir)])
|
||||
elif system == "Windows":
|
||||
subprocess.run(["explorer", str(sync_dir)])
|
||||
else: # Linux
|
||||
subprocess.run(["xdg-open", str(sync_dir)])
|
||||
|
||||
click.echo(f"Opened sync directory: {sync_dir}")
|
||||
except Exception as e:
|
||||
click.echo(f"Could not open directory: {e}", err=True)
|
||||
click.echo(f"Sync directory is: {sync_dir}")
|
||||
|
||||
|
||||
class TaskSweeper:
|
||||
"""Sweeps incomplete tasks from markdown files into Godspeed Inbox."""
|
||||
|
||||
def __init__(self, notes_dir: Path, godspeed_dir: Path, dry_run: bool = False):
|
||||
self.notes_dir = Path(notes_dir)
|
||||
self.godspeed_dir = Path(godspeed_dir)
|
||||
self.dry_run = dry_run
|
||||
self.inbox_file = self.godspeed_dir / "Inbox.md"
|
||||
|
||||
# Try to use the sync engine for consistent ID generation and formatting
|
||||
try:
|
||||
self.sync_engine = GodspeedSync(None, str(godspeed_dir))
|
||||
except Exception:
|
||||
# Fallback parsing if sync engine fails
|
||||
self.sync_engine = None
|
||||
|
||||
def _parse_task_line_fallback(self, line: str):
|
||||
"""Fallback task parsing if sync engine not available."""
|
||||
import re
|
||||
import uuid
|
||||
|
||||
# Match patterns like: - [ ] Task title <!-- id:abc123 -->
|
||||
task_pattern = (
|
||||
r"^\s*-\s*\[([xX\s\-])\]\s*(.+?)(?:\s*<!--\s*id:(\w+)\s*-->)?\s*$"
|
||||
)
|
||||
match = re.match(task_pattern, line.strip())
|
||||
|
||||
if not match:
|
||||
return None
|
||||
|
||||
checkbox, title_and_notes, local_id = match.groups()
|
||||
|
||||
# Determine status
|
||||
if checkbox.lower() == "x":
|
||||
status = "complete"
|
||||
elif checkbox == "-":
|
||||
status = "cleared"
|
||||
else:
|
||||
status = "incomplete"
|
||||
|
||||
# Extract title (remove any inline notes after <!--)
|
||||
title = title_and_notes.split("<!--")[0].strip()
|
||||
|
||||
# Generate ID if missing
|
||||
if not local_id:
|
||||
if hasattr(self, "sync_engine") and self.sync_engine:
|
||||
local_id = self.sync_engine._generate_local_id()
|
||||
else:
|
||||
import uuid
|
||||
|
||||
local_id = str(uuid.uuid4())[:8]
|
||||
|
||||
return local_id, status, title, ""
|
||||
|
||||
def _parse_markdown_file(self, file_path: Path):
|
||||
"""Parse a markdown file and extract tasks and non-task content."""
|
||||
if not file_path.exists():
|
||||
return [], []
|
||||
|
||||
tasks = []
|
||||
non_task_lines = []
|
||||
|
||||
try:
|
||||
import builtins
|
||||
|
||||
with builtins.open(str(file_path), "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
except Exception as e:
|
||||
click.echo(f" ⚠️ Error reading {file_path}: {e}")
|
||||
return [], []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line = line.rstrip()
|
||||
|
||||
# Check if this line looks like a task
|
||||
if line.strip().startswith("- ["):
|
||||
# Always use fallback parsing
|
||||
parsed = self._parse_task_line_fallback(line)
|
||||
if parsed:
|
||||
tasks.append(parsed)
|
||||
continue
|
||||
|
||||
# Not a task, keep as regular content
|
||||
non_task_lines.append(line)
|
||||
|
||||
return tasks, non_task_lines
|
||||
|
||||
def _write_tasks_to_file(self, file_path: Path, tasks):
|
||||
"""Write tasks to a markdown file."""
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import builtins
|
||||
|
||||
# Read existing content if file exists
|
||||
existing_content = ""
|
||||
if file_path.exists():
|
||||
with builtins.open(str(file_path), "r", encoding="utf-8") as f:
|
||||
existing_content = f.read()
|
||||
|
||||
# Format new tasks
|
||||
new_task_lines = []
|
||||
for local_id, status, title, notes in tasks:
|
||||
if self.sync_engine:
|
||||
formatted = self.sync_engine._format_task_line(
|
||||
local_id, status, title, notes
|
||||
)
|
||||
else:
|
||||
# Fallback formatting
|
||||
checkbox = {"incomplete": "[ ]", "complete": "[x]", "cleared": "[-]"}[
|
||||
status
|
||||
]
|
||||
formatted = f"- {checkbox} {title} <!-- id:{local_id} -->"
|
||||
if notes:
|
||||
formatted += f"\n {notes}"
|
||||
|
||||
new_task_lines.append(formatted)
|
||||
|
||||
# Combine with existing content
|
||||
if existing_content.strip():
|
||||
new_content = (
|
||||
existing_content.rstrip() + "\n\n" + "\n".join(new_task_lines) + "\n"
|
||||
)
|
||||
else:
|
||||
new_content = "\n".join(new_task_lines) + "\n"
|
||||
|
||||
with builtins.open(str(file_path), "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
def _clean_file(self, file_path: Path, non_task_lines):
|
||||
"""Remove tasks from original file, keeping only non-task content."""
|
||||
import builtins
|
||||
|
||||
if not non_task_lines or all(not line.strip() for line in non_task_lines):
|
||||
# File would be empty, delete it
|
||||
if not self.dry_run:
|
||||
file_path.unlink()
|
||||
click.echo(f" 🗑️ Would delete empty file: {file_path}")
|
||||
else:
|
||||
# Write back non-task content
|
||||
cleaned_content = "\n".join(non_task_lines).strip()
|
||||
if cleaned_content:
|
||||
cleaned_content += "\n"
|
||||
|
||||
if not self.dry_run:
|
||||
with builtins.open(str(file_path), "w", encoding="utf-8") as f:
|
||||
f.write(cleaned_content)
|
||||
click.echo(f" ✂️ Cleaned file (removed tasks): {file_path}")
|
||||
|
||||
def find_markdown_files(self):
|
||||
"""Find all markdown files in the notes directory, excluding Godspeed directory."""
|
||||
markdown_files = []
|
||||
|
||||
for md_file in self.notes_dir.rglob("*.md"):
|
||||
# Skip files in the Godspeed directory
|
||||
if (
|
||||
self.godspeed_dir in md_file.parents
|
||||
or md_file.parent == self.godspeed_dir
|
||||
):
|
||||
continue
|
||||
|
||||
# Skip hidden files and directories
|
||||
if any(part.startswith(".") for part in md_file.parts):
|
||||
continue
|
||||
|
||||
markdown_files.append(md_file)
|
||||
|
||||
return sorted(markdown_files)
|
||||
|
||||
def sweep_tasks(self):
|
||||
"""Sweep incomplete tasks from all markdown files into Inbox."""
|
||||
click.echo(f"🧹 Sweeping incomplete tasks from: {self.notes_dir}")
|
||||
click.echo(f"📥 Target Inbox: {self.inbox_file}")
|
||||
click.echo(f"🔍 Dry run: {self.dry_run}")
|
||||
click.echo("=" * 60)
|
||||
|
||||
markdown_files = self.find_markdown_files()
|
||||
click.echo(f"\n📁 Found {len(markdown_files)} markdown files to process")
|
||||
|
||||
swept_tasks = []
|
||||
processed_files = []
|
||||
|
||||
for file_path in markdown_files:
|
||||
try:
|
||||
rel_path = file_path.relative_to(self.notes_dir)
|
||||
rel_path_str = str(rel_path)
|
||||
except Exception as e:
|
||||
click.echo(f"Error getting relative path for {file_path}: {e}")
|
||||
rel_path_str = str(file_path.name)
|
||||
|
||||
click.echo(f"\n📄 Processing: {rel_path_str}")
|
||||
|
||||
tasks, non_task_lines = self._parse_markdown_file(file_path)
|
||||
|
||||
if not tasks:
|
||||
click.echo(f" ℹ️ No tasks found")
|
||||
continue
|
||||
if not tasks:
|
||||
click.echo(f" ℹ️ No tasks found")
|
||||
continue
|
||||
|
||||
# Separate incomplete tasks from completed/cleared ones
|
||||
incomplete_tasks = []
|
||||
complete_tasks = []
|
||||
|
||||
for task in tasks:
|
||||
local_id, status, title, notes = task
|
||||
if status == "incomplete":
|
||||
incomplete_tasks.append(task)
|
||||
else:
|
||||
complete_tasks.append(task)
|
||||
|
||||
if incomplete_tasks:
|
||||
click.echo(f" 🔄 Found {len(incomplete_tasks)} incomplete tasks:")
|
||||
for _, status, title, notes in incomplete_tasks:
|
||||
click.echo(f" • {title}")
|
||||
if notes:
|
||||
click.echo(f" Notes: {notes}")
|
||||
|
||||
# Add source file annotation with clean task IDs
|
||||
annotated_tasks = []
|
||||
for local_id, status, title, notes in incomplete_tasks:
|
||||
# Generate a fresh ID for swept tasks to avoid conflicts
|
||||
if self.sync_engine:
|
||||
fresh_id = self.sync_engine._generate_local_id()
|
||||
else:
|
||||
import uuid
|
||||
|
||||
fresh_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Add source info to notes
|
||||
source_notes = f"From: {rel_path_str}"
|
||||
if notes:
|
||||
combined_notes = f"{notes}\n{source_notes}"
|
||||
else:
|
||||
combined_notes = source_notes
|
||||
annotated_tasks.append((fresh_id, status, title, combined_notes))
|
||||
|
||||
swept_tasks.extend(annotated_tasks)
|
||||
processed_files.append(str(rel_path))
|
||||
|
||||
if complete_tasks:
|
||||
click.echo(
|
||||
f" ✅ Keeping {len(complete_tasks)} completed/cleared tasks in place"
|
||||
)
|
||||
|
||||
# Reconstruct remaining content (non-tasks + completed tasks)
|
||||
remaining_content = non_task_lines.copy()
|
||||
|
||||
# Add completed/cleared tasks back to remaining content
|
||||
if complete_tasks:
|
||||
remaining_content.append("") # Empty line before tasks
|
||||
for task in complete_tasks:
|
||||
if self.sync_engine:
|
||||
formatted = self.sync_engine._format_task_line(*task)
|
||||
else:
|
||||
local_id, status, title, notes = task
|
||||
checkbox = {
|
||||
"incomplete": "[ ]",
|
||||
"complete": "[x]",
|
||||
"cleared": "[-]",
|
||||
}[status]
|
||||
formatted = f"- {checkbox} {title} <!-- id:{local_id} -->"
|
||||
if notes:
|
||||
formatted += f"\n {notes}"
|
||||
remaining_content.append(formatted)
|
||||
|
||||
# Clean the original file
|
||||
if incomplete_tasks:
|
||||
self._clean_file(file_path, remaining_content)
|
||||
|
||||
# Write swept tasks to Inbox
|
||||
if swept_tasks:
|
||||
click.echo(f"\n📥 Writing {len(swept_tasks)} tasks to Inbox...")
|
||||
if not self.dry_run:
|
||||
self._write_tasks_to_file(self.inbox_file, swept_tasks)
|
||||
click.echo(f" ✅ Inbox updated: {self.inbox_file}")
|
||||
|
||||
# Summary
|
||||
click.echo(f"\n" + "=" * 60)
|
||||
click.echo(f"📊 SWEEP SUMMARY:")
|
||||
click.echo(f" • Files processed: {len(processed_files)}")
|
||||
click.echo(f" • Tasks swept: {len(swept_tasks)}")
|
||||
click.echo(f" • Target: {self.inbox_file}")
|
||||
|
||||
if self.dry_run:
|
||||
click.echo(f"\n⚠️ DRY RUN - No files were actually modified")
|
||||
click.echo(f" Run without --dry-run to perform the sweep")
|
||||
|
||||
return {
|
||||
"swept_tasks": len(swept_tasks),
|
||||
"processed_files": processed_files,
|
||||
"inbox_file": str(self.inbox_file),
|
||||
}
|
||||
|
||||
|
||||
@godspeed.command()
|
||||
@click.argument(
|
||||
"notes_dir",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||
required=False,
|
||||
)
|
||||
@click.argument(
|
||||
"godspeed_dir",
|
||||
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run", is_flag=True, help="Show what would be done without making changes"
|
||||
)
|
||||
def sweep(notes_dir, godspeed_dir, dry_run):
|
||||
"""Sweep incomplete tasks from markdown files into Godspeed Inbox.
|
||||
|
||||
NOTES_DIR: Directory containing markdown files with tasks to sweep (optional, defaults to $NOTES_DIR)
|
||||
GODSPEED_DIR: Godspeed sync directory (optional, defaults to sync directory)
|
||||
"""
|
||||
# Handle notes_dir default from environment
|
||||
if notes_dir is None:
|
||||
notes_dir_env = os.getenv("NOTES_DIR")
|
||||
if not notes_dir_env:
|
||||
click.echo(
|
||||
"❌ No notes directory specified and $NOTES_DIR environment variable not set",
|
||||
err=True,
|
||||
)
|
||||
click.echo("Usage: godspeed sweep <notes_dir> [godspeed_dir]", err=True)
|
||||
click.echo(
|
||||
" or: export NOTES_DIR=/path/to/notes && godspeed sweep", err=True
|
||||
)
|
||||
sys.exit(1)
|
||||
notes_dir = Path(notes_dir_env)
|
||||
if not notes_dir.exists():
|
||||
click.echo(
|
||||
f"❌ Notes directory from $NOTES_DIR does not exist: {notes_dir}",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
if not notes_dir.is_dir():
|
||||
click.echo(
|
||||
f"❌ Notes path from $NOTES_DIR is not a directory: {notes_dir}",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if godspeed_dir is None:
|
||||
godspeed_dir = get_sync_directory()
|
||||
|
||||
# Ensure we have Path objects
|
||||
notes_dir = Path(notes_dir)
|
||||
godspeed_dir = Path(godspeed_dir)
|
||||
|
||||
try:
|
||||
sweeper = TaskSweeper(notes_dir, godspeed_dir, dry_run)
|
||||
result = sweeper.sweep_tasks()
|
||||
|
||||
if result["swept_tasks"] > 0:
|
||||
click.echo(f"\n🎉 Successfully swept {result['swept_tasks']} tasks!")
|
||||
if not dry_run:
|
||||
click.echo(f"💡 Next steps:")
|
||||
click.echo(f" 1. Review tasks in: {result['inbox_file']}")
|
||||
click.echo(f" 2. Run 'godspeed upload' to sync to API")
|
||||
click.echo(
|
||||
f" 3. Organize tasks into appropriate lists in Godspeed app"
|
||||
)
|
||||
else:
|
||||
click.echo(f"\n✨ No incomplete tasks found to sweep.")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Error during sweep: {e}", err=True)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
godspeed()
|
||||
1017
src/cli/sync.py
1017
src/cli/sync.py
File diff suppressed because it is too large
Load Diff
337
src/cli/sync_daemon.py
Normal file
337
src/cli/sync_daemon.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""Daemon mode with proper Unix logging."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import logging.handlers
|
||||
import asyncio
|
||||
import time
|
||||
import signal
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from src.cli.sync import _sync_outlook_data, should_run_godspeed_sync, should_run_sweep
|
||||
from src.cli.sync import run_godspeed_sync, run_task_sweep, load_sync_state
|
||||
from src.utils.ipc import notify_all, notify_refresh
|
||||
|
||||
|
||||
class SyncDaemon:
|
||||
"""Proper daemon with Unix logging."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.running = False
|
||||
self.pid_file = Path(
|
||||
config.get("pid_file", "~/.config/luk/luk.pid")
|
||||
).expanduser()
|
||||
self.log_file = Path(
|
||||
config.get("log_file", "~/.local/share/luk/luk.log")
|
||||
).expanduser()
|
||||
self.sync_interval = config.get("sync_interval", 300) # 5 minutes
|
||||
self.check_interval = config.get("check_interval", 10) # 10 seconds
|
||||
self.logger = self._setup_logging()
|
||||
|
||||
def _setup_logging(self) -> logging.Logger:
|
||||
"""Setup proper Unix logging."""
|
||||
logger = logging.getLogger("sync_daemon")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Ensure log directory exists
|
||||
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Rotating file handler (10MB max, keep 5 backups)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_file,
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Log format
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
|
||||
def daemonize(self) -> None:
|
||||
"""Properly daemonize the process for Unix systems."""
|
||||
# First fork
|
||||
try:
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent exits
|
||||
sys.exit(0)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"Fork #1 failed: {e}\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Decouple from parent environment
|
||||
os.chdir("/")
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# Second fork
|
||||
try:
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent exits
|
||||
sys.exit(0)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"Fork #2 failed: {e}\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Redirect standard file descriptors to /dev/null
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
si = open(os.devnull, "r")
|
||||
so = open(os.devnull, "a+")
|
||||
se = open(os.devnull, "a+")
|
||||
os.dup2(si.fileno(), sys.stdin.fileno())
|
||||
os.dup2(so.fileno(), sys.stdout.fileno())
|
||||
os.dup2(se.fileno(), sys.stderr.fileno())
|
||||
|
||||
# Write PID file
|
||||
self.pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.pid_file, "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the daemon."""
|
||||
# Check if already running
|
||||
if self.is_running():
|
||||
print(f"Daemon is already running (PID {self.get_pid()})")
|
||||
return
|
||||
|
||||
print("Starting sync daemon...")
|
||||
self.daemonize()
|
||||
|
||||
# Setup signal handlers
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
|
||||
self.logger.info("Sync daemon started")
|
||||
self.running = True
|
||||
|
||||
# Run the daemon loop
|
||||
asyncio.run(self._daemon_loop())
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the daemon."""
|
||||
if not self.is_running():
|
||||
print("Daemon is not running")
|
||||
return
|
||||
|
||||
try:
|
||||
pid = self.get_pid()
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
|
||||
# Wait for process to exit
|
||||
for _ in range(10):
|
||||
try:
|
||||
os.kill(pid, 0) # Check if process exists
|
||||
time.sleep(0.5)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
else:
|
||||
# Force kill if still running
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
|
||||
# Remove PID file
|
||||
if self.pid_file.exists():
|
||||
self.pid_file.unlink()
|
||||
|
||||
print(f"Daemon stopped (PID {pid})")
|
||||
self.logger.info("Sync daemon stopped")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error stopping daemon: {e}")
|
||||
|
||||
def status(self) -> None:
|
||||
"""Check daemon status."""
|
||||
if not self.is_running():
|
||||
print("Daemon is not running")
|
||||
return
|
||||
|
||||
pid = self.get_pid()
|
||||
print(f"Daemon is running (PID {pid})")
|
||||
|
||||
# Show recent log entries
|
||||
try:
|
||||
with open(self.log_file, "r") as f:
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
print("\nRecent log entries:")
|
||||
for line in lines[-5:]:
|
||||
print(f" {line.strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if daemon is running."""
|
||||
if not self.pid_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
pid = self.get_pid()
|
||||
os.kill(pid, 0) # Check if process exists
|
||||
return True
|
||||
except (ValueError, ProcessLookupError, OSError):
|
||||
# Stale PID file, remove it
|
||||
if self.pid_file.exists():
|
||||
self.pid_file.unlink()
|
||||
return False
|
||||
|
||||
def get_pid(self) -> int:
|
||||
"""Get PID from file."""
|
||||
with open(self.pid_file, "r") as f:
|
||||
return int(f.read().strip())
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals."""
|
||||
self.logger.info(f"Received signal {signum}, shutting down...")
|
||||
self.running = False
|
||||
|
||||
# Remove PID file
|
||||
if self.pid_file.exists():
|
||||
self.pid_file.unlink()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
async def _daemon_loop(self) -> None:
|
||||
"""Main daemon loop."""
|
||||
last_sync_time = time.time() - self.sync_interval # Force initial sync
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
current_time = time.time()
|
||||
|
||||
if current_time - last_sync_time >= self.sync_interval:
|
||||
self.logger.info("Performing scheduled sync...")
|
||||
await self._perform_sync()
|
||||
last_sync_time = current_time
|
||||
self.logger.info("Scheduled sync completed")
|
||||
else:
|
||||
# Check for changes
|
||||
changes_detected = await self._check_for_changes()
|
||||
if changes_detected:
|
||||
self.logger.info("Changes detected, triggering sync...")
|
||||
await self._perform_sync()
|
||||
last_sync_time = current_time
|
||||
else:
|
||||
self.logger.debug("No changes detected")
|
||||
|
||||
await asyncio.sleep(self.check_interval)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in daemon loop: {e}")
|
||||
await asyncio.sleep(30) # Wait before retrying
|
||||
|
||||
async def _perform_sync(self) -> None:
|
||||
"""Perform a full sync."""
|
||||
try:
|
||||
await _sync_outlook_data(
|
||||
dry_run=self.config.get("dry_run", False),
|
||||
vdir=self.config.get("vdir", "~/Calendar"),
|
||||
icsfile=self.config.get("icsfile"),
|
||||
org=self.config.get("org", "corteva"),
|
||||
days_back=self.config.get("days_back", 1),
|
||||
days_forward=self.config.get("days_forward", 30),
|
||||
continue_iteration=self.config.get("continue_iteration", False),
|
||||
download_attachments=self.config.get("download_attachments", False),
|
||||
two_way_calendar=self.config.get("two_way_calendar", False),
|
||||
notify=self.config.get("notify", False),
|
||||
)
|
||||
self.logger.info("Sync completed successfully")
|
||||
|
||||
# Notify all running TUI apps to refresh their data
|
||||
results = await notify_all({"source": "sync_daemon"})
|
||||
notified = [app for app, success in results.items() if success]
|
||||
if notified:
|
||||
self.logger.info(f"Notified apps to refresh: {', '.join(notified)}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Sync failed: {e}")
|
||||
|
||||
async def _check_for_changes(self) -> bool:
|
||||
"""Check if there are changes that require syncing."""
|
||||
try:
|
||||
# Check Godspeed operations
|
||||
godspeed_sync_due = should_run_godspeed_sync()
|
||||
sweep_due = should_run_sweep()
|
||||
|
||||
if godspeed_sync_due or sweep_due:
|
||||
self.logger.info("Godspeed operations due")
|
||||
return True
|
||||
|
||||
# Add other change detection logic here
|
||||
# For now, just return False
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking for changes: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_daemon_config(**kwargs) -> Dict[str, Any]:
|
||||
"""Create daemon configuration from command line args."""
|
||||
return {
|
||||
"dry_run": kwargs.get("dry_run", False),
|
||||
"vdir": kwargs.get("vdir", "~/Calendar"),
|
||||
"icsfile": kwargs.get("icsfile"),
|
||||
"org": kwargs.get("org", "corteva"),
|
||||
"days_back": kwargs.get("days_back", 1),
|
||||
"days_forward": kwargs.get("days_forward", 30),
|
||||
"continue_iteration": kwargs.get("continue_iteration", False),
|
||||
"download_attachments": kwargs.get("download_attachments", False),
|
||||
"two_way_calendar": kwargs.get("two_way_calendar", False),
|
||||
"notify": kwargs.get("notify", False),
|
||||
"pid_file": kwargs.get("pid_file", "~/.config/luk/luk.pid"),
|
||||
"log_file": kwargs.get("log_file", "~/.local/share/luk/luk.log"),
|
||||
"sync_interval": kwargs.get("sync_interval", 300),
|
||||
"check_interval": kwargs.get("check_interval", 10),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main daemon entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Sync daemon management")
|
||||
parser.add_argument(
|
||||
"action", choices=["start", "stop", "status", "logs"], help="Action to perform"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Dry run mode")
|
||||
parser.add_argument("--org", default="corteva", help="Organization name")
|
||||
parser.add_argument("--vdir", default="~/Calendar", help="Calendar directory")
|
||||
parser.add_argument("--notify", action="store_true", help="Enable notifications")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config = create_daemon_config(
|
||||
dry_run=args.dry_run, org=args.org, vdir=args.vdir, notify=args.notify
|
||||
)
|
||||
|
||||
daemon = SyncDaemon(config)
|
||||
|
||||
if args.action == "start":
|
||||
daemon.start()
|
||||
elif args.action == "stop":
|
||||
daemon.stop()
|
||||
elif args.action == "status":
|
||||
daemon.status()
|
||||
elif args.action == "logs":
|
||||
try:
|
||||
with open(daemon.log_file, "r") as f:
|
||||
print(f.read())
|
||||
except Exception as e:
|
||||
print(f"Error reading logs: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1444
src/cli/sync_dashboard.py
Normal file
1444
src/cli/sync_dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
11
src/cli/tasks.py
Normal file
11
src/cli/tasks.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""CLI command for Tasks TUI."""
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
def tasks():
|
||||
"""Launch the Tasks TUI for managing tasks via dstask."""
|
||||
from src.tasks import run_app
|
||||
|
||||
run_app()
|
||||
607
src/cli/ticktick.py
Normal file
607
src/cli/ticktick.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""
|
||||
TickTick CLI commands with aliases for task management.
|
||||
"""
|
||||
|
||||
import click
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from rich.console import Console
|
||||
|
||||
from src.services.ticktick import TickTickService
|
||||
from src.utils.ticktick_utils import (
|
||||
create_task_table,
|
||||
print_task_details,
|
||||
open_task,
|
||||
parse_priority,
|
||||
validate_date,
|
||||
console,
|
||||
)
|
||||
|
||||
|
||||
# Initialize service lazily
|
||||
def get_ticktick_service():
|
||||
"""Get the TickTick service, initializing it if needed."""
|
||||
global _ticktick_service
|
||||
if "_ticktick_service" not in globals():
|
||||
_ticktick_service = TickTickService()
|
||||
return _ticktick_service
|
||||
|
||||
|
||||
@click.group()
|
||||
def ticktick():
|
||||
"""TickTick task management CLI."""
|
||||
pass
|
||||
|
||||
|
||||
@ticktick.command(name="list")
|
||||
@click.option("--project", "-p", help="Filter by project name")
|
||||
@click.option(
|
||||
"--due-date", "-d", help="Filter by due date (today, tomorrow, YYYY-MM-DD)"
|
||||
)
|
||||
@click.option("--all", "-a", is_flag=True, help="Show all tasks including completed")
|
||||
@click.option("--priority", "-pr", help="Filter by priority (0-5, low, medium, high)")
|
||||
@click.option("--tag", "-t", help="Filter by tag name")
|
||||
@click.option("--limit", "-l", default=20, help="Limit number of results")
|
||||
def list_tasks(
|
||||
project: Optional[str],
|
||||
due_date: Optional[str],
|
||||
all: bool,
|
||||
priority: Optional[str],
|
||||
tag: Optional[str],
|
||||
limit: int,
|
||||
):
|
||||
"""List tasks (alias: ls)."""
|
||||
try:
|
||||
ticktick_service = get_ticktick_service()
|
||||
|
||||
if due_date:
|
||||
if not validate_date(due_date):
|
||||
console.print(f"[red]Invalid date format: {due_date}[/red]")
|
||||
return
|
||||
tasks = get_ticktick_service().get_tasks_by_due_date(due_date)
|
||||
elif project:
|
||||
tasks = get_ticktick_service().get_tasks_by_project(project)
|
||||
else:
|
||||
tasks = get_ticktick_service().get_tasks(completed=all)
|
||||
|
||||
# Apply additional filters
|
||||
if priority:
|
||||
priority_val = parse_priority(priority)
|
||||
tasks = [t for t in tasks if t.get("priority", 0) == priority_val]
|
||||
|
||||
if tag:
|
||||
tasks = [
|
||||
t
|
||||
for t in tasks
|
||||
if tag.lower() in [t.lower() for t in t.get("tags", [])]
|
||||
]
|
||||
|
||||
# Limit results
|
||||
if limit > 0:
|
||||
tasks = tasks[:limit]
|
||||
|
||||
if not tasks:
|
||||
console.print("[yellow]No tasks found matching criteria[/yellow]")
|
||||
return
|
||||
|
||||
# Display results
|
||||
table = create_task_table(tasks, show_project=not project)
|
||||
console.print(table)
|
||||
console.print(f"\n[dim]Showing {len(tasks)} tasks[/dim]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error listing tasks: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="add")
|
||||
@click.argument("title")
|
||||
@click.option("--project", "-p", help="Project name")
|
||||
@click.option("--due-date", "-d", help="Due date (today, tomorrow, YYYY-MM-DD)")
|
||||
@click.option("--priority", "-pr", help="Priority (0-5, low, medium, high)")
|
||||
@click.option("--content", "-c", help="Task description/content")
|
||||
@click.option("--tags", "-t", help="Comma-separated list of tags")
|
||||
def add_task(
|
||||
title: str,
|
||||
project: Optional[str],
|
||||
due_date: Optional[str],
|
||||
priority: Optional[str],
|
||||
content: Optional[str],
|
||||
tags: Optional[str],
|
||||
):
|
||||
"""Add a new task (alias: a)."""
|
||||
try:
|
||||
# Validate due date if provided
|
||||
if due_date and not validate_date(due_date):
|
||||
console.print(f"[red]Invalid date format: {due_date}[/red]")
|
||||
return
|
||||
|
||||
# Parse priority
|
||||
priority_val = parse_priority(priority) if priority else None
|
||||
|
||||
# Parse tags
|
||||
tag_list = [tag.strip() for tag in tags.split(",")] if tags else None
|
||||
|
||||
# Create task
|
||||
task = get_ticktick_service().create_task(
|
||||
title=title,
|
||||
project_name=project,
|
||||
due_date=due_date,
|
||||
priority=priority_val,
|
||||
content=content,
|
||||
tags=tag_list,
|
||||
)
|
||||
|
||||
if task:
|
||||
console.print(f"[green]✓ Created task: {title}[/green]")
|
||||
console.print(f"[dim]Task ID: {task.get('id', 'N/A')}[/dim]")
|
||||
else:
|
||||
console.print("[red]Failed to create task[/red]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error creating task: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="edit")
|
||||
@click.argument("task_id")
|
||||
@click.option("--title", help="New task title")
|
||||
@click.option("--project", "-p", help="New project name")
|
||||
@click.option("--due-date", "-d", help="New due date (today, tomorrow, YYYY-MM-DD)")
|
||||
@click.option("--priority", "-pr", help="New priority (0-5, low, medium, high)")
|
||||
@click.option("--content", "-c", help="New task description/content")
|
||||
def edit_task(
|
||||
task_id: str,
|
||||
title: Optional[str],
|
||||
project: Optional[str],
|
||||
due_date: Optional[str],
|
||||
priority: Optional[str],
|
||||
content: Optional[str],
|
||||
):
|
||||
"""Edit an existing task (alias: e)."""
|
||||
try:
|
||||
# Build update dictionary
|
||||
updates = {}
|
||||
|
||||
if title:
|
||||
updates["title"] = title
|
||||
if project:
|
||||
updates["project_name"] = project
|
||||
if due_date:
|
||||
if not validate_date(due_date):
|
||||
console.print(f"[red]Invalid date format: {due_date}[/red]")
|
||||
return
|
||||
updates["due_date"] = due_date
|
||||
if priority:
|
||||
updates["priority"] = parse_priority(priority)
|
||||
if content:
|
||||
updates["content"] = content
|
||||
|
||||
if not updates:
|
||||
console.print("[yellow]No changes specified[/yellow]")
|
||||
return
|
||||
|
||||
# Update task
|
||||
updated_task = get_ticktick_service().update_task(task_id, **updates)
|
||||
|
||||
if updated_task:
|
||||
console.print(
|
||||
f"[green]✓ Updated task: {updated_task.get('title', task_id)}[/green]"
|
||||
)
|
||||
else:
|
||||
console.print("[red]Failed to update task[/red]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error updating task: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="complete")
|
||||
@click.argument("task_id")
|
||||
def complete_task(task_id: str):
|
||||
"""Mark a task as completed (aliases: done, c)."""
|
||||
try:
|
||||
success = get_ticktick_service().complete_task(task_id)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]✓ Completed task: {task_id}[/green]")
|
||||
else:
|
||||
console.print(f"[red]Failed to complete task: {task_id}[/red]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error completing task: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="delete")
|
||||
@click.argument("task_id")
|
||||
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
|
||||
def delete_task(task_id: str, force: bool):
|
||||
"""Delete a task (aliases: del, rm)."""
|
||||
try:
|
||||
if not force:
|
||||
if not click.confirm(f"Delete task {task_id}?"):
|
||||
console.print("[yellow]Cancelled[/yellow]")
|
||||
return
|
||||
|
||||
success = get_ticktick_service().delete_task(task_id)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]✓ Deleted task: {task_id}[/green]")
|
||||
else:
|
||||
console.print(f"[red]Failed to delete task: {task_id}[/red]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error deleting task: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="open")
|
||||
@click.argument("task_id")
|
||||
@click.option(
|
||||
"--browser", "-b", is_flag=True, help="Force open in browser instead of app"
|
||||
)
|
||||
def open_task_cmd(task_id: str, browser: bool):
|
||||
"""Open a task in browser or TickTick app (alias: o)."""
|
||||
try:
|
||||
open_task(task_id, prefer_app=not browser)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error opening task: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="show")
|
||||
@click.argument("task_id")
|
||||
def show_task(task_id: str):
|
||||
"""Show detailed task information (aliases: view, s)."""
|
||||
try:
|
||||
get_ticktick_service()._ensure_client()
|
||||
task = get_ticktick_service().client.get_by_id(task_id, search="tasks")
|
||||
|
||||
if not task:
|
||||
console.print(f"[red]Task not found: {task_id}[/red]")
|
||||
return
|
||||
|
||||
print_task_details(task)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error showing task: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="projects")
|
||||
def list_projects():
|
||||
"""List all projects (alias: proj)."""
|
||||
try:
|
||||
projects = get_ticktick_service().get_projects()
|
||||
|
||||
if not projects:
|
||||
console.print("[yellow]No projects found[/yellow]")
|
||||
return
|
||||
|
||||
console.print("[bold cyan]Projects:[/bold cyan]")
|
||||
for project in projects:
|
||||
name = project.get("name", "Unnamed")
|
||||
project_id = project.get("id", "N/A")
|
||||
console.print(f" • [white]{name}[/white] [dim]({project_id})[/dim]")
|
||||
|
||||
console.print(f"\n[dim]Total: {len(projects)} projects[/dim]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error listing projects: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="tags")
|
||||
def list_tags():
|
||||
"""List all tags."""
|
||||
try:
|
||||
tags = get_ticktick_service().get_tags()
|
||||
|
||||
if not tags:
|
||||
console.print("[yellow]No tags found[/yellow]")
|
||||
return
|
||||
|
||||
console.print("[bold green]Tags:[/bold green]")
|
||||
for tag in tags:
|
||||
name = tag.get("name", "Unnamed")
|
||||
console.print(f" • [green]#{name}[/green]")
|
||||
|
||||
console.print(f"\n[dim]Total: {len(tags)} tags[/dim]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error listing tags: {str(e)}[/red]")
|
||||
|
||||
|
||||
@ticktick.command(name="sync")
|
||||
def sync_tasks():
|
||||
"""Sync tasks with TickTick servers."""
|
||||
try:
|
||||
get_ticktick_service().sync()
|
||||
console.print("[green]✓ Synced with TickTick servers[/green]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error syncing: {str(e)}[/red]")
|
||||
|
||||
|
||||
# Add alias commands manually
|
||||
@click.command()
|
||||
@click.option("--project", "-p", help="Filter by project name")
|
||||
@click.option(
|
||||
"--due-date", "-d", help="Filter by due date (today, tomorrow, YYYY-MM-DD)"
|
||||
)
|
||||
@click.option("--all", "-a", is_flag=True, help="Show all tasks including completed")
|
||||
@click.option("--priority", "-pr", help="Filter by priority (0-5, low, medium, high)")
|
||||
@click.option("--tag", "-t", help="Filter by tag name")
|
||||
@click.option("--limit", "-l", default=20, help="Limit number of results")
|
||||
def ls(
|
||||
project: Optional[str],
|
||||
due_date: Optional[str],
|
||||
all: bool,
|
||||
priority: Optional[str],
|
||||
tag: Optional[str],
|
||||
limit: int,
|
||||
):
|
||||
"""Alias for list command."""
|
||||
list_tasks.callback(project, due_date, all, priority, tag, limit)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("title")
|
||||
@click.option("--project", "-p", help="Project name")
|
||||
@click.option("--due-date", "-d", help="Due date (today, tomorrow, YYYY-MM-DD)")
|
||||
@click.option("--priority", "-pr", help="Priority (0-5, low, medium, high)")
|
||||
@click.option("--content", "-c", help="Task description/content")
|
||||
@click.option("--tags", "-t", help="Comma-separated list of tags")
|
||||
def a(
|
||||
title: str,
|
||||
project: Optional[str],
|
||||
due_date: Optional[str],
|
||||
priority: Optional[str],
|
||||
content: Optional[str],
|
||||
tags: Optional[str],
|
||||
):
|
||||
"""Alias for add command."""
|
||||
add_task.callback(title, project, due_date, priority, content, tags)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("task_id")
|
||||
@click.option("--title", help="New task title")
|
||||
@click.option("--project", "-p", help="New project name")
|
||||
@click.option("--due-date", "-d", help="New due date (today, tomorrow, YYYY-MM-DD)")
|
||||
@click.option("--priority", "-pr", help="New priority (0-5, low, medium, high)")
|
||||
@click.option("--content", "-c", help="New task description/content")
|
||||
def e(
|
||||
task_id: str,
|
||||
title: Optional[str],
|
||||
project: Optional[str],
|
||||
due_date: Optional[str],
|
||||
priority: Optional[str],
|
||||
content: Optional[str],
|
||||
):
|
||||
"""Alias for edit command."""
|
||||
edit_task.callback(task_id, title, project, due_date, priority, content)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("task_id")
|
||||
def c(task_id: str):
|
||||
"""Alias for complete command."""
|
||||
complete_task.callback(task_id)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("task_id")
|
||||
def done(task_id: str):
|
||||
"""Alias for complete command."""
|
||||
complete_task.callback(task_id)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("task_id")
|
||||
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
|
||||
def rm(task_id: str, force: bool):
|
||||
"""Alias for delete command."""
|
||||
delete_task.callback(task_id, force)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("task_id")
|
||||
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
|
||||
def del_cmd(task_id: str, force: bool):
|
||||
"""Alias for delete command."""
|
||||
delete_task.callback(task_id, force)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("task_id")
|
||||
@click.option(
|
||||
"--browser", "-b", is_flag=True, help="Force open in browser instead of app"
|
||||
)
|
||||
def o(task_id: str, browser: bool):
|
||||
"""Alias for open command."""
|
||||
open_task_cmd.callback(task_id, browser)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("task_id")
|
||||
def s(task_id: str):
|
||||
"""Alias for show command."""
|
||||
show_task.callback(task_id)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("task_id")
|
||||
def view(task_id: str):
|
||||
"""Alias for show command."""
|
||||
show_task.callback(task_id)
|
||||
|
||||
|
||||
@click.command()
|
||||
def proj():
|
||||
"""Alias for projects command."""
|
||||
list_projects.callback()
|
||||
|
||||
|
||||
# Register all alias commands
|
||||
ticktick.add_command(ls)
|
||||
ticktick.add_command(a)
|
||||
ticktick.add_command(e)
|
||||
ticktick.add_command(c)
|
||||
ticktick.add_command(done)
|
||||
ticktick.add_command(rm)
|
||||
ticktick.add_command(del_cmd, name="del")
|
||||
ticktick.add_command(o)
|
||||
ticktick.add_command(s)
|
||||
ticktick.add_command(view)
|
||||
ticktick.add_command(proj)
|
||||
|
||||
|
||||
@ticktick.command(name="setup")
|
||||
def setup_ticktick():
|
||||
"""Show TickTick setup instructions."""
|
||||
from rich.panel import Panel
|
||||
from rich.markdown import Markdown
|
||||
|
||||
setup_text = """
|
||||
# TickTick Setup Instructions
|
||||
|
||||
## 1. Register TickTick Developer App
|
||||
Visit: https://developer.ticktick.com/docs#/openapi
|
||||
- Click "Manage Apps" → "+App Name"
|
||||
- Set OAuth Redirect URL: `http://localhost:8080`
|
||||
- Note your Client ID and Client Secret
|
||||
|
||||
## 2. Set Environment Variables
|
||||
```bash
|
||||
export TICKTICK_CLIENT_ID="your_client_id"
|
||||
export TICKTICK_CLIENT_SECRET="your_client_secret"
|
||||
export TICKTICK_REDIRECT_URI="http://localhost:8080"
|
||||
|
||||
# Optional (you'll be prompted if not set):
|
||||
export TICKTICK_USERNAME="your_email@example.com"
|
||||
export TICKTICK_PASSWORD="your_password"
|
||||
```
|
||||
|
||||
## 3. Authentication Note
|
||||
The TickTick library requires **both** OAuth2 AND login credentials:
|
||||
- OAuth2: For API authorization
|
||||
- Username/Password: For initial session setup
|
||||
|
||||
This is how the library works, not a limitation of our CLI.
|
||||
|
||||
## 4. Start Using
|
||||
```bash
|
||||
ticktick ls # List tasks
|
||||
ticktick a "Task" # Add task
|
||||
```
|
||||
"""
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
Markdown(setup_text),
|
||||
title="[bold green]TickTick Setup[/bold green]",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ticktick.command(name="test-auth")
|
||||
def test_auth():
|
||||
"""Test authentication and API connectivity."""
|
||||
import os
|
||||
from src.services.ticktick.auth import (
|
||||
get_token_file_path,
|
||||
check_token_validity,
|
||||
get_ticktick_client,
|
||||
)
|
||||
|
||||
console.print("[bold cyan]TickTick Authentication Test[/bold cyan]\n")
|
||||
|
||||
# Check environment
|
||||
client_id = os.getenv("TICKTICK_CLIENT_ID")
|
||||
client_secret = os.getenv("TICKTICK_CLIENT_SECRET")
|
||||
|
||||
if not client_id or not client_secret:
|
||||
console.print("[red]❌ OAuth credentials not set[/red]")
|
||||
console.print("Please set TICKTICK_CLIENT_ID and TICKTICK_CLIENT_SECRET")
|
||||
return
|
||||
|
||||
console.print("[green]✓ OAuth credentials found[/green]")
|
||||
|
||||
# Check token cache
|
||||
validity = check_token_validity()
|
||||
if validity["valid"]:
|
||||
console.print(f"[green]✓ Token cache: {validity['reason']}[/green]")
|
||||
else:
|
||||
console.print(f"[yellow]⚠ Token cache: {validity['reason']}[/yellow]")
|
||||
|
||||
# Test client creation
|
||||
console.print("\n[bold]Testing TickTick client initialization...[/bold]")
|
||||
try:
|
||||
client = get_ticktick_client()
|
||||
console.print("[green]✓ TickTick client created successfully[/green]")
|
||||
|
||||
# Test API call
|
||||
console.print("Testing API connectivity...")
|
||||
try:
|
||||
projects = client.get_by_fields(search="projects")
|
||||
console.print(
|
||||
f"[green]✓ API test successful - found {len(projects)} projects[/green]"
|
||||
)
|
||||
except Exception as api_e:
|
||||
console.print(f"[red]❌ API test failed: {api_e}[/red]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Client creation failed: {str(e)}[/red]")
|
||||
return
|
||||
|
||||
console.print("\n[green]🎉 Authentication test completed successfully![/green]")
|
||||
|
||||
|
||||
@ticktick.command(name="auth-status")
|
||||
def auth_status():
|
||||
"""Check TickTick authentication status."""
|
||||
import os
|
||||
from src.services.ticktick.auth import get_token_file_path, check_token_validity
|
||||
|
||||
console.print("[bold cyan]TickTick Authentication Status[/bold cyan]\n")
|
||||
|
||||
# Check OAuth credentials
|
||||
client_id = os.getenv("TICKTICK_CLIENT_ID")
|
||||
client_secret = os.getenv("TICKTICK_CLIENT_SECRET")
|
||||
redirect_uri = os.getenv("TICKTICK_REDIRECT_URI")
|
||||
|
||||
console.print(f"OAuth Client ID: {'✓ Set' if client_id else '✗ Not set'}")
|
||||
console.print(f"OAuth Client Secret: {'✓ Set' if client_secret else '✗ Not set'}")
|
||||
console.print(
|
||||
f"OAuth Redirect URI: {redirect_uri or '✗ Not set (will use default)'}"
|
||||
)
|
||||
|
||||
# Check login credentials
|
||||
username = os.getenv("TICKTICK_USERNAME")
|
||||
password = os.getenv("TICKTICK_PASSWORD")
|
||||
|
||||
console.print(f"Username: {'✓ Set' if username else '✗ Not set (will prompt)'}")
|
||||
console.print(f"Password: {'✓ Set' if password else '✗ Not set (will prompt)'}")
|
||||
|
||||
# Check token cache with validity
|
||||
token_file = get_token_file_path()
|
||||
token_exists = token_file.exists()
|
||||
|
||||
if token_exists:
|
||||
validity = check_token_validity()
|
||||
if validity["valid"]:
|
||||
console.print("OAuth Token Cache: [green]✓ Valid[/green]")
|
||||
if "expires_in_hours" in validity:
|
||||
console.print(
|
||||
f"Token expires in: [yellow]{validity['expires_in_hours']} hours[/yellow]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"OAuth Token Cache: [red]✗ Invalid ({validity['reason']})[/red]"
|
||||
)
|
||||
|
||||
import datetime
|
||||
|
||||
mod_time = datetime.datetime.fromtimestamp(token_file.stat().st_mtime)
|
||||
console.print(f"Token file: {token_file}")
|
||||
console.print(f"Last modified: {mod_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
else:
|
||||
console.print("OAuth Token Cache: [red]✗ Not found[/red]")
|
||||
console.print(f"Token file: {token_file}")
|
||||
|
||||
console.print("\n[dim]Run 'ticktick setup' for setup instructions[/dim]")
|
||||
1
src/mail/__init__.py
Normal file
1
src/mail/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Initialize the mail package
|
||||
490
src/mail/actions/calendar_invite.py
Normal file
490
src/mail/actions/calendar_invite.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""Calendar invite actions for mail app.
|
||||
|
||||
Allows responding to calendar invites directly from email using ICS/SMTP.
|
||||
|
||||
Uses the iTIP (iCalendar Transport-Independent Interoperability Protocol)
|
||||
standard to send REPLY messages via email instead of requiring Calendar.ReadWrite
|
||||
API permissions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from src.mail.utils.calendar_parser import ParsedCalendarEvent
|
||||
|
||||
# Set up dedicated RSVP logger
|
||||
rsvp_logger = logging.getLogger("calendar_rsvp")
|
||||
rsvp_logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Create file handler if not already set up
|
||||
if not rsvp_logger.handlers:
|
||||
log_dir = os.path.expanduser("~/.local/share/luk")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, "calendar_rsvp.log")
|
||||
handler = logging.FileHandler(log_file)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
|
||||
rsvp_logger.addHandler(handler)
|
||||
|
||||
|
||||
def _get_user_email() -> Optional[str]:
|
||||
"""Get the current user's email address from MSAL cache.
|
||||
|
||||
Returns:
|
||||
User's email address if found, None otherwise.
|
||||
"""
|
||||
import msal
|
||||
|
||||
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||
|
||||
if not client_id or not tenant_id:
|
||||
rsvp_logger.warning("Azure credentials not configured")
|
||||
return None
|
||||
|
||||
cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin")
|
||||
if not os.path.exists(cache_file):
|
||||
rsvp_logger.warning("Token cache file not found")
|
||||
return None
|
||||
|
||||
try:
|
||||
cache = msal.SerializableTokenCache()
|
||||
cache.deserialize(open(cache_file, "r").read())
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
app = msal.PublicClientApplication(
|
||||
client_id, authority=authority, token_cache=cache
|
||||
)
|
||||
accounts = app.get_accounts()
|
||||
|
||||
if accounts:
|
||||
# The username field contains the user's email
|
||||
return accounts[0].get("username")
|
||||
return None
|
||||
except Exception as e:
|
||||
rsvp_logger.error(f"Failed to get user email from MSAL: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_user_display_name() -> Optional[str]:
|
||||
"""Get the current user's display name from MSAL cache.
|
||||
|
||||
Returns:
|
||||
User's display name if found, None otherwise.
|
||||
"""
|
||||
import msal
|
||||
|
||||
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||
|
||||
if not client_id or not tenant_id:
|
||||
return None
|
||||
|
||||
cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin")
|
||||
if not os.path.exists(cache_file):
|
||||
return None
|
||||
|
||||
try:
|
||||
cache = msal.SerializableTokenCache()
|
||||
cache.deserialize(open(cache_file, "r").read())
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
app = msal.PublicClientApplication(
|
||||
client_id, authority=authority, token_cache=cache
|
||||
)
|
||||
accounts = app.get_accounts()
|
||||
|
||||
if accounts:
|
||||
# Try to get name from account, fallback to username
|
||||
name = accounts[0].get("name")
|
||||
if name:
|
||||
return name
|
||||
# Fallback: construct name from email
|
||||
username = accounts[0].get("username", "")
|
||||
if "@" in username:
|
||||
local_part = username.split("@")[0]
|
||||
# Convert firstname.lastname to Firstname Lastname
|
||||
parts = local_part.replace(".", " ").replace("_", " ").split()
|
||||
return " ".join(p.capitalize() for p in parts)
|
||||
return None
|
||||
except Exception as e:
|
||||
rsvp_logger.debug(f"Failed to get display name: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def generate_ics_reply(
|
||||
event: ParsedCalendarEvent,
|
||||
response: str,
|
||||
attendee_email: str,
|
||||
attendee_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate an iCalendar REPLY for a calendar invite.
|
||||
|
||||
Args:
|
||||
event: The parsed calendar event from the original invite
|
||||
response: Response type - 'ACCEPTED', 'TENTATIVE', or 'DECLINED'
|
||||
attendee_email: The attendee's email address
|
||||
attendee_name: The attendee's display name (optional)
|
||||
|
||||
Returns:
|
||||
ICS content string formatted as an iTIP REPLY
|
||||
"""
|
||||
# Map response to PARTSTAT value
|
||||
partstat_map = {
|
||||
"accept": "ACCEPTED",
|
||||
"tentativelyAccept": "TENTATIVE",
|
||||
"decline": "DECLINED",
|
||||
}
|
||||
partstat = partstat_map.get(response, "ACCEPTED")
|
||||
|
||||
# Generate DTSTAMP in UTC format
|
||||
dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
# Build attendee line with proper formatting
|
||||
if attendee_name:
|
||||
attendee_line = (
|
||||
f'ATTENDEE;PARTSTAT={partstat};CN="{attendee_name}":MAILTO:{attendee_email}'
|
||||
)
|
||||
else:
|
||||
attendee_line = f"ATTENDEE;PARTSTAT={partstat}:MAILTO:{attendee_email}"
|
||||
|
||||
# Build organizer line
|
||||
if event.organizer_name:
|
||||
organizer_line = (
|
||||
f'ORGANIZER;CN="{event.organizer_name}":MAILTO:{event.organizer_email}'
|
||||
)
|
||||
else:
|
||||
organizer_line = f"ORGANIZER:MAILTO:{event.organizer_email}"
|
||||
|
||||
# Build the response subject prefix
|
||||
response_prefix = {
|
||||
"accept": "Accepted",
|
||||
"tentativelyAccept": "Tentative",
|
||||
"decline": "Declined",
|
||||
}.get(response, "Accepted")
|
||||
|
||||
summary = f"{response_prefix}: {event.summary or '(no subject)'}"
|
||||
|
||||
# Build the ICS content following iTIP REPLY standard
|
||||
ics_lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//LUK Mail//Calendar Reply//EN",
|
||||
"METHOD:REPLY",
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:{event.uid}",
|
||||
f"DTSTAMP:{dtstamp}",
|
||||
organizer_line,
|
||||
attendee_line,
|
||||
f"SEQUENCE:{event.sequence}",
|
||||
f"SUMMARY:{summary}",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
]
|
||||
|
||||
return "\r\n".join(ics_lines)
|
||||
|
||||
|
||||
def build_calendar_reply_email(
|
||||
event: ParsedCalendarEvent,
|
||||
response: str,
|
||||
from_email: str,
|
||||
to_email: str,
|
||||
from_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a MIME email with calendar REPLY attachment.
|
||||
|
||||
The email is formatted according to iTIP/iMIP standards so that
|
||||
Exchange/Outlook will recognize it as a calendar action.
|
||||
|
||||
Args:
|
||||
event: The parsed calendar event from the original invite
|
||||
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||
from_email: Sender's email address
|
||||
to_email: Recipient's email address (the organizer)
|
||||
from_name: Sender's display name (optional)
|
||||
|
||||
Returns:
|
||||
Complete RFC 5322 email as string
|
||||
"""
|
||||
# Generate the ICS reply content
|
||||
ics_content = generate_ics_reply(event, response, from_email, from_name)
|
||||
|
||||
# Build response text for email body
|
||||
response_text = {
|
||||
"accept": "accepted",
|
||||
"tentativelyAccept": "tentatively accepted",
|
||||
"decline": "declined",
|
||||
}.get(response, "accepted")
|
||||
|
||||
subject_prefix = {
|
||||
"accept": "Accepted",
|
||||
"tentativelyAccept": "Tentative",
|
||||
"decline": "Declined",
|
||||
}.get(response, "Accepted")
|
||||
|
||||
subject = f"{subject_prefix}: {event.summary or '(no subject)'}"
|
||||
|
||||
# Create the email message
|
||||
msg = MIMEMultipart("mixed")
|
||||
|
||||
# Set headers
|
||||
if from_name:
|
||||
msg["From"] = f'"{from_name}" <{from_email}>'
|
||||
else:
|
||||
msg["From"] = from_email
|
||||
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
|
||||
# Add Content-Class header for Exchange compatibility
|
||||
msg["Content-Class"] = "urn:content-classes:calendarmessage"
|
||||
|
||||
# Create text body
|
||||
body_text = f"This meeting has been {response_text}."
|
||||
text_part = MIMEText(body_text, "plain", "utf-8")
|
||||
msg.attach(text_part)
|
||||
|
||||
# Create calendar part with proper iTIP headers
|
||||
# The content-type must include method=REPLY for Exchange to recognize it
|
||||
calendar_part = MIMEText(ics_content, "calendar", "utf-8")
|
||||
calendar_part.set_param("method", "REPLY")
|
||||
calendar_part.add_header("Content-Disposition", "attachment", filename="invite.ics")
|
||||
msg.attach(calendar_part)
|
||||
|
||||
return msg.as_string()
|
||||
|
||||
|
||||
def queue_calendar_reply(
|
||||
event: ParsedCalendarEvent,
|
||||
response: str,
|
||||
from_email: str,
|
||||
to_email: str,
|
||||
from_name: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Queue a calendar reply email for sending via the outbox.
|
||||
|
||||
Args:
|
||||
event: The parsed calendar event from the original invite
|
||||
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||
from_email: Sender's email address
|
||||
to_email: Recipient's email address (the organizer)
|
||||
from_name: Sender's display name (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
# Build the email
|
||||
email_content = build_calendar_reply_email(
|
||||
event, response, from_email, to_email, from_name
|
||||
)
|
||||
|
||||
# Determine organization from email domain
|
||||
org = "default"
|
||||
if "@" in from_email:
|
||||
domain = from_email.split("@")[1].lower()
|
||||
# Map known domains to org names (matching sendmail script logic)
|
||||
domain_to_org = {
|
||||
"corteva.com": "corteva",
|
||||
}
|
||||
org = domain_to_org.get(domain, domain.split(".")[0])
|
||||
|
||||
# Queue the email in the outbox
|
||||
base_path = os.path.expanduser(os.getenv("MAILDIR_PATH", "~/Mail"))
|
||||
outbox_path = os.path.join(base_path, org, "outbox")
|
||||
|
||||
# Ensure directories exist
|
||||
for subdir in ["new", "cur", "tmp", "failed"]:
|
||||
dir_path = os.path.join(outbox_path, subdir)
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
timestamp = str(int(time.time() * 1000000))
|
||||
hostname = os.uname().nodename
|
||||
filename = f"{timestamp}.{os.getpid()}.{hostname}"
|
||||
|
||||
# Write to tmp first, then move to new (atomic operation)
|
||||
tmp_path = os.path.join(outbox_path, "tmp", filename)
|
||||
new_path = os.path.join(outbox_path, "new", filename)
|
||||
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
f.write(email_content)
|
||||
|
||||
os.rename(tmp_path, new_path)
|
||||
|
||||
response_text = {
|
||||
"accept": "accepted",
|
||||
"tentativelyAccept": "tentatively accepted",
|
||||
"decline": "declined",
|
||||
}.get(response, "accepted")
|
||||
|
||||
rsvp_logger.info(
|
||||
f"Queued calendar reply: {response_text} for '{event.summary}' to {event.organizer_email}"
|
||||
)
|
||||
|
||||
return True, f"Response queued - will be sent on next sync"
|
||||
|
||||
except Exception as e:
|
||||
rsvp_logger.error(f"Failed to queue calendar reply: {e}", exc_info=True)
|
||||
return False, f"Failed to queue response: {str(e)}"
|
||||
|
||||
|
||||
def send_calendar_reply_via_apple_mail(
|
||||
event: ParsedCalendarEvent,
|
||||
response: str,
|
||||
from_email: str,
|
||||
to_email: str,
|
||||
from_name: Optional[str] = None,
|
||||
auto_send: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Send a calendar reply immediately via Apple Mail.
|
||||
|
||||
Args:
|
||||
event: The parsed calendar event from the original invite
|
||||
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||
from_email: Sender's email address
|
||||
to_email: Recipient's email address (the organizer)
|
||||
from_name: Sender's display name (optional)
|
||||
auto_send: If True, automatically send via AppleScript
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
from src.mail.utils.apple_mail import open_eml_in_apple_mail
|
||||
|
||||
try:
|
||||
# Build the email
|
||||
email_content = build_calendar_reply_email(
|
||||
event, response, from_email, to_email, from_name
|
||||
)
|
||||
|
||||
response_text = {
|
||||
"accept": "accepted",
|
||||
"tentativelyAccept": "tentatively accepted",
|
||||
"decline": "declined",
|
||||
}.get(response, "accepted")
|
||||
|
||||
subject = f"{response_text.capitalize()}: {event.summary or '(no subject)'}"
|
||||
|
||||
# Open in Apple Mail (and optionally auto-send)
|
||||
success, message = open_eml_in_apple_mail(
|
||||
email_content, auto_send=auto_send, subject=subject
|
||||
)
|
||||
|
||||
if success:
|
||||
rsvp_logger.info(
|
||||
f"Calendar reply via Apple Mail: {response_text} for '{event.summary}' to {to_email}"
|
||||
)
|
||||
|
||||
return success, message
|
||||
|
||||
except Exception as e:
|
||||
rsvp_logger.error(
|
||||
f"Failed to send calendar reply via Apple Mail: {e}", exc_info=True
|
||||
)
|
||||
return False, f"Failed to send response: {str(e)}"
|
||||
|
||||
|
||||
def action_accept_invite(app):
|
||||
"""Accept the current calendar invite."""
|
||||
_respond_to_current_invite(app, "accept")
|
||||
|
||||
|
||||
def action_decline_invite(app):
|
||||
"""Decline the current calendar invite."""
|
||||
_respond_to_current_invite(app, "decline")
|
||||
|
||||
|
||||
def action_tentative_invite(app):
|
||||
"""Tentatively accept the current calendar invite."""
|
||||
_respond_to_current_invite(app, "tentativelyAccept")
|
||||
|
||||
|
||||
def _respond_to_current_invite(app, response: str):
|
||||
"""Helper to respond to the current message's calendar invite.
|
||||
|
||||
Sends the response immediately via Apple Mail instead of queuing for sync.
|
||||
"""
|
||||
from src.mail.widgets.ContentContainer import ContentContainer
|
||||
from src.mail.config import get_config
|
||||
|
||||
rsvp_logger.info(f"Starting invite response: {response}")
|
||||
|
||||
current_message_id = app.current_message_id
|
||||
if not current_message_id:
|
||||
rsvp_logger.warning("No message selected")
|
||||
app.notify("No message selected", severity="warning")
|
||||
return
|
||||
|
||||
# Get user's email from MSAL cache
|
||||
user_email = _get_user_email()
|
||||
if not user_email:
|
||||
rsvp_logger.error("Could not determine user email - run 'luk sync' first")
|
||||
app.notify(
|
||||
"Could not determine your email. Run 'luk sync' first.", severity="error"
|
||||
)
|
||||
return
|
||||
|
||||
user_name = _get_user_display_name()
|
||||
rsvp_logger.debug(f"User: {user_name} <{user_email}>")
|
||||
|
||||
# Get the parsed calendar event from ContentContainer
|
||||
calendar_event = None
|
||||
try:
|
||||
content_container = app.query_one(ContentContainer)
|
||||
calendar_event = content_container.current_calendar_event
|
||||
except Exception as e:
|
||||
rsvp_logger.error(f"Failed to get ContentContainer: {e}")
|
||||
|
||||
if not calendar_event:
|
||||
rsvp_logger.warning("No calendar event data found in current message")
|
||||
app.notify("No calendar invite found in this message", severity="warning")
|
||||
return
|
||||
|
||||
event_uid = calendar_event.uid
|
||||
event_summary = calendar_event.summary or "(no subject)"
|
||||
organizer_email = calendar_event.organizer_email
|
||||
|
||||
rsvp_logger.info(
|
||||
f"Calendar event: {event_summary}, UID: {event_uid}, Organizer: {organizer_email}"
|
||||
)
|
||||
|
||||
if not event_uid:
|
||||
rsvp_logger.warning("No UID found in calendar event")
|
||||
app.notify("Calendar invite missing UID - cannot respond", severity="warning")
|
||||
return
|
||||
|
||||
if not organizer_email:
|
||||
rsvp_logger.warning("No organizer email found in calendar event")
|
||||
app.notify(
|
||||
"Calendar invite missing organizer - cannot respond", severity="warning"
|
||||
)
|
||||
return
|
||||
|
||||
# Get config for auto-send preference
|
||||
config = get_config()
|
||||
auto_send = config.mail.auto_send_via_applescript
|
||||
|
||||
# Send immediately via Apple Mail
|
||||
success, message = send_calendar_reply_via_apple_mail(
|
||||
calendar_event,
|
||||
response,
|
||||
user_email,
|
||||
organizer_email,
|
||||
user_name,
|
||||
auto_send=auto_send,
|
||||
)
|
||||
|
||||
severity = "information" if success else "error"
|
||||
app.notify(message, severity=severity)
|
||||
|
||||
if success:
|
||||
response_text = {
|
||||
"accept": "Accepted",
|
||||
"tentativelyAccept": "Tentatively accepted",
|
||||
"decline": "Declined",
|
||||
}.get(response, "Responded to")
|
||||
rsvp_logger.info(f"{response_text} invite: {event_summary}")
|
||||
170
src/mail/actions/compose.py
Normal file
170
src/mail/actions/compose.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Compose, reply, and forward email actions for mail app.
|
||||
|
||||
Uses Apple Mail for composing and sending emails.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from src.mail.utils.apple_mail import (
|
||||
compose_new_email,
|
||||
reply_to_email,
|
||||
forward_email,
|
||||
)
|
||||
from src.services.himalaya import client as himalaya_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level temp directory for exported messages (persists across calls)
|
||||
_temp_dir: Optional[tempfile.TemporaryDirectory] = None
|
||||
|
||||
|
||||
def _get_temp_dir() -> str:
|
||||
"""Get or create a persistent temp directory for exported messages."""
|
||||
global _temp_dir
|
||||
if _temp_dir is None:
|
||||
_temp_dir = tempfile.TemporaryDirectory(prefix="luk_mail_")
|
||||
return _temp_dir.name
|
||||
|
||||
|
||||
async def _export_current_message(app) -> Optional[str]:
|
||||
"""Export the currently selected message to a temp .eml file.
|
||||
|
||||
Args:
|
||||
app: The mail app instance
|
||||
|
||||
Returns:
|
||||
Path to the exported .eml file, or None if export failed
|
||||
"""
|
||||
current_message_id = app.current_message_id
|
||||
if not current_message_id:
|
||||
return None
|
||||
|
||||
# Use himalaya to export the raw message
|
||||
raw_content, success = await himalaya_client.get_raw_message(current_message_id)
|
||||
if not success or not raw_content:
|
||||
logger.error(f"Failed to export message {current_message_id}")
|
||||
return None
|
||||
|
||||
# Save to a temp file
|
||||
temp_dir = _get_temp_dir()
|
||||
eml_path = os.path.join(temp_dir, f"message_{current_message_id}.eml")
|
||||
|
||||
try:
|
||||
with open(eml_path, "w", encoding="utf-8") as f:
|
||||
f.write(raw_content)
|
||||
return eml_path
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write temp .eml file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_user_email() -> Optional[str]:
|
||||
"""Get the current user's email address from MSAL cache."""
|
||||
import msal
|
||||
|
||||
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||
|
||||
if not client_id or not tenant_id:
|
||||
return None
|
||||
|
||||
cache_file = os.path.expanduser("~/.local/share/luk/token_cache.bin")
|
||||
if not os.path.exists(cache_file):
|
||||
return None
|
||||
|
||||
try:
|
||||
cache = msal.SerializableTokenCache()
|
||||
cache.deserialize(open(cache_file, "r").read())
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
app = msal.PublicClientApplication(
|
||||
client_id, authority=authority, token_cache=cache
|
||||
)
|
||||
accounts = app.get_accounts()
|
||||
|
||||
if accounts:
|
||||
return accounts[0].get("username")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def action_compose(app):
|
||||
"""Open a new compose window in Apple Mail."""
|
||||
user_email = _get_user_email()
|
||||
|
||||
success, message = compose_new_email(
|
||||
to="",
|
||||
subject="",
|
||||
body="",
|
||||
)
|
||||
|
||||
if success:
|
||||
app.notify("Compose window opened in Mail", severity="information")
|
||||
else:
|
||||
app.notify(f"Failed to open compose: {message}", severity="error")
|
||||
|
||||
|
||||
async def action_reply(app):
|
||||
"""Reply to the current message in Apple Mail."""
|
||||
if not app.current_message_id:
|
||||
app.notify("No message selected", severity="warning")
|
||||
return
|
||||
|
||||
app.notify("Exporting message...", severity="information")
|
||||
message_path = await _export_current_message(app)
|
||||
|
||||
if not message_path:
|
||||
app.notify("Failed to export message", severity="error")
|
||||
return
|
||||
|
||||
success, message = reply_to_email(message_path, reply_all=False)
|
||||
|
||||
if success:
|
||||
app.notify("Reply window opened in Mail", severity="information")
|
||||
else:
|
||||
app.notify(f"Failed to open reply: {message}", severity="error")
|
||||
|
||||
|
||||
async def action_reply_all(app):
|
||||
"""Reply to all on the current message in Apple Mail."""
|
||||
if not app.current_message_id:
|
||||
app.notify("No message selected", severity="warning")
|
||||
return
|
||||
|
||||
app.notify("Exporting message...", severity="information")
|
||||
message_path = await _export_current_message(app)
|
||||
|
||||
if not message_path:
|
||||
app.notify("Failed to export message", severity="error")
|
||||
return
|
||||
|
||||
success, message = reply_to_email(message_path, reply_all=True)
|
||||
|
||||
if success:
|
||||
app.notify("Reply-all window opened in Mail", severity="information")
|
||||
else:
|
||||
app.notify(f"Failed to open reply-all: {message}", severity="error")
|
||||
|
||||
|
||||
async def action_forward(app):
|
||||
"""Forward the current message in Apple Mail."""
|
||||
if not app.current_message_id:
|
||||
app.notify("No message selected", severity="warning")
|
||||
return
|
||||
|
||||
app.notify("Exporting message...", severity="information")
|
||||
message_path = await _export_current_message(app)
|
||||
|
||||
if not message_path:
|
||||
app.notify("Failed to export message", severity="error")
|
||||
return
|
||||
|
||||
success, message = forward_email(message_path)
|
||||
|
||||
if success:
|
||||
app.notify("Forward window opened in Mail", severity="information")
|
||||
else:
|
||||
app.notify(f"Failed to open forward: {message}", severity="error")
|
||||
@@ -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
|
||||
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")
|
||||
@@ -38,4 +42,6 @@ async def delete_current(app):
|
||||
app.current_message_id = 0
|
||||
app.show_status("No more messages available.", "warning")
|
||||
else:
|
||||
app.show_status(f"Failed to delete message {current_message_id}.", "error")
|
||||
app.show_status(
|
||||
f"Failed to delete message {current_message_id}: {message}", "error"
|
||||
)
|
||||
1322
src/mail/app.py
Normal file
1322
src/mail/app.py
Normal file
File diff suppressed because it is too large
Load Diff
196
src/mail/config.py
Normal file
196
src/mail/config.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Configuration system for Mail email reader using Pydantic."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
import toml
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskBackendConfig(BaseModel):
|
||||
"""Configuration for task management backend."""
|
||||
|
||||
backend: Literal["taskwarrior", "dstask"] = "taskwarrior"
|
||||
taskwarrior_path: str = "task"
|
||||
dstask_path: str = Field(
|
||||
default_factory=lambda: str(Path.home() / ".local" / "bin" / "dstask")
|
||||
)
|
||||
|
||||
|
||||
class EnvelopeDisplayConfig(BaseModel):
|
||||
"""Configuration for envelope list item rendering."""
|
||||
|
||||
# Sender display
|
||||
max_sender_length: int = 25
|
||||
|
||||
# Date/time display
|
||||
date_format: str = "%m/%d"
|
||||
time_format: str = "%H:%M"
|
||||
show_date: bool = True
|
||||
show_time: bool = True
|
||||
|
||||
# Grouping
|
||||
group_by: Literal["relative", "absolute"] = "relative"
|
||||
# relative: "Today", "Yesterday", "This Week", etc.
|
||||
# absolute: "December 2025", "November 2025", etc.
|
||||
|
||||
# Layout
|
||||
lines: Literal[2, 3] = 2
|
||||
# 2: sender/date on line 1, subject on line 2
|
||||
# 3: sender/date on line 1, subject on line 2, preview on line 3
|
||||
|
||||
show_checkbox: bool = True
|
||||
show_preview: bool = False # Only used when lines=3
|
||||
|
||||
# NerdFont icons for status
|
||||
icon_unread: str = "\uf0e0" # nf-fa-envelope (filled)
|
||||
icon_read: str = "\uf2b6" # nf-fa-envelope_open (open)
|
||||
icon_flagged: str = "\uf024" # nf-fa-flag
|
||||
icon_attachment: str = "\uf0c6" # nf-fa-paperclip
|
||||
|
||||
|
||||
class KeybindingsConfig(BaseModel):
|
||||
"""Keybinding customization."""
|
||||
|
||||
next_message: str = "j"
|
||||
prev_message: str = "k"
|
||||
delete: str = "#"
|
||||
archive: str = "e"
|
||||
open_by_id: str = "o"
|
||||
quit: str = "q"
|
||||
toggle_header: str = "h"
|
||||
create_task: str = "t"
|
||||
reload: str = "%"
|
||||
toggle_sort: str = "s"
|
||||
toggle_selection: str = "space"
|
||||
clear_selection: str = "escape"
|
||||
scroll_page_down: str = "pagedown"
|
||||
scroll_page_down: str = "space"
|
||||
scroll_page_up: str = "b"
|
||||
toggle_main_content: str = "w"
|
||||
open_links: str = "l"
|
||||
toggle_view_mode: str = "m"
|
||||
|
||||
|
||||
class ContentDisplayConfig(BaseModel):
|
||||
"""Configuration for message content display."""
|
||||
|
||||
# View mode: "markdown" for pretty rendering, "html" for raw/plain display
|
||||
default_view_mode: Literal["markdown", "html"] = "markdown"
|
||||
|
||||
# URL compression: shorten long URLs for better readability
|
||||
compress_urls: bool = True
|
||||
max_url_length: int = 50 # Maximum length before URL is compressed
|
||||
|
||||
# Notification compression: compress notification emails into summaries
|
||||
compress_notifications: bool = True
|
||||
notification_compression_mode: Literal["summary", "detailed", "off"] = "summary"
|
||||
|
||||
|
||||
class LinkPanelConfig(BaseModel):
|
||||
"""Configuration for the link panel."""
|
||||
|
||||
# Whether to close the panel after opening a link
|
||||
close_on_open: bool = False
|
||||
|
||||
|
||||
class MailOperationsConfig(BaseModel):
|
||||
"""Configuration for mail operations."""
|
||||
|
||||
# Folder to move messages to when archiving
|
||||
archive_folder: str = "Archive"
|
||||
|
||||
# Enable SMTP OAuth2 sending (requires IT to enable SMTP AUTH for your mailbox)
|
||||
# When disabled, calendar replies will open in your default mail client instead
|
||||
enable_smtp_send: bool = False
|
||||
|
||||
# Auto-send emails opened in Apple Mail via AppleScript
|
||||
# When True, calendar replies will be sent automatically after opening in Mail
|
||||
# When False, the email will be opened for manual review before sending
|
||||
auto_send_via_applescript: bool = False
|
||||
|
||||
|
||||
class ThemeConfig(BaseModel):
|
||||
"""Theme/appearance settings."""
|
||||
|
||||
theme_name: str = "monokai"
|
||||
|
||||
|
||||
class MailAppConfig(BaseModel):
|
||||
"""Main configuration for Mail email reader."""
|
||||
|
||||
task: TaskBackendConfig = Field(default_factory=TaskBackendConfig)
|
||||
envelope_display: EnvelopeDisplayConfig = Field(
|
||||
default_factory=EnvelopeDisplayConfig
|
||||
)
|
||||
content_display: ContentDisplayConfig = Field(default_factory=ContentDisplayConfig)
|
||||
link_panel: LinkPanelConfig = Field(default_factory=LinkPanelConfig)
|
||||
mail: MailOperationsConfig = Field(default_factory=MailOperationsConfig)
|
||||
keybindings: KeybindingsConfig = Field(default_factory=KeybindingsConfig)
|
||||
theme: ThemeConfig = Field(default_factory=ThemeConfig)
|
||||
|
||||
@classmethod
|
||||
def get_config_path(cls) -> Path:
|
||||
"""Get the path to the config file."""
|
||||
# Check environment variable first
|
||||
env_path = os.getenv("LUK_MAIL_CONFIG")
|
||||
if env_path:
|
||||
return Path(env_path)
|
||||
|
||||
# Default to ~/.config/luk/mail.toml
|
||||
return Path.home() / ".config" / "luk" / "mail.toml"
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path: Optional[Path] = None) -> "MailAppConfig":
|
||||
"""Load config from TOML file with defaults for missing values."""
|
||||
if config_path is None:
|
||||
config_path = cls.get_config_path()
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
data = toml.load(f)
|
||||
logger.info(f"Loaded config from {config_path}")
|
||||
return cls.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading config from {config_path}: {e}")
|
||||
logger.warning("Using default configuration")
|
||||
return cls()
|
||||
else:
|
||||
logger.info(f"No config file at {config_path}, using defaults")
|
||||
return cls()
|
||||
|
||||
def save(self, config_path: Optional[Path] = None) -> None:
|
||||
"""Save current config to TOML file."""
|
||||
if config_path is None:
|
||||
config_path = self.get_config_path()
|
||||
|
||||
# Ensure parent directory exists
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
toml.dump(self.model_dump(), f)
|
||||
logger.info(f"Saved config to {config_path}")
|
||||
|
||||
|
||||
# Global config instance (lazy-loaded)
|
||||
_config: Optional[MailAppConfig] = None
|
||||
|
||||
|
||||
def get_config() -> MailAppConfig:
|
||||
"""Get the global config instance, loading it if necessary."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = MailAppConfig.load()
|
||||
return _config
|
||||
|
||||
|
||||
def reload_config() -> MailAppConfig:
|
||||
"""Force reload of the config from disk."""
|
||||
global _config
|
||||
_config = MailAppConfig.load()
|
||||
return _config
|
||||
365
src/mail/email_viewer.tcss
Normal file
365
src/mail/email_viewer.tcss
Normal file
@@ -0,0 +1,365 @@
|
||||
/* Basic stylesheet for the Textual Email Viewer App */
|
||||
|
||||
|
||||
#main_content, .list_view {
|
||||
scrollbar-size: 1 1;
|
||||
border: round $border;
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 1fr
|
||||
}
|
||||
|
||||
.list_view {
|
||||
height: 3;
|
||||
}
|
||||
|
||||
#main_content {
|
||||
width: 2fr;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.envelope-selected {
|
||||
tint: $accent 20%;
|
||||
}
|
||||
|
||||
#sidebar:focus-within {
|
||||
background: $panel;
|
||||
|
||||
.list_view:blur {
|
||||
height: 3;
|
||||
}
|
||||
.list_view:focus {
|
||||
height: 2fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#envelopes_list {
|
||||
height: 2fr;
|
||||
}
|
||||
|
||||
#main_content:focus, .list_view:focus {
|
||||
border: round $secondary;
|
||||
background: $surface;
|
||||
border-title-style: bold;
|
||||
}
|
||||
|
||||
Label#task_prompt {
|
||||
padding: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
Label#task_prompt_label {
|
||||
padding: 1;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
Label#message_label {
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
StatusTitle {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text;
|
||||
background: $panel;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
EnvelopeHeader {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 4;
|
||||
max-height: 6;
|
||||
padding: 0 1;
|
||||
background: $surface-darken-1;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
/* Full headers mode - allow more height for scrolling */
|
||||
EnvelopeHeader.full-headers {
|
||||
max-height: 12;
|
||||
}
|
||||
|
||||
#content_scroll {
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header labels should be single line with truncation */
|
||||
EnvelopeHeader Label {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Full headers mode - allow wrapping */
|
||||
EnvelopeHeader.full-headers Label {
|
||||
height: auto;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
Markdown {
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
NEW EnvelopeListItem and GroupHeader styles
|
||||
===================================================== */
|
||||
|
||||
/* EnvelopeListItem - the main envelope display widget */
|
||||
EnvelopeListItem {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
EnvelopeListItem .envelope-content {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
EnvelopeListItem .envelope-row-1 {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
EnvelopeListItem .envelope-row-2 {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
EnvelopeListItem .envelope-row-3 {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
EnvelopeListItem .status-icon {
|
||||
width: 2;
|
||||
padding: 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
EnvelopeListItem .status-icon.unread {
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
EnvelopeListItem .checkbox {
|
||||
width: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
EnvelopeListItem .sender-name {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
EnvelopeListItem .message-datetime {
|
||||
width: auto;
|
||||
padding: 0 1;
|
||||
color: $secondary;
|
||||
}
|
||||
|
||||
EnvelopeListItem .email-subject {
|
||||
width: 1fr;
|
||||
padding: 0 3;
|
||||
}
|
||||
|
||||
EnvelopeListItem .email-preview {
|
||||
width: 1fr;
|
||||
padding: 0 3;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
/* Unread message styling */
|
||||
EnvelopeListItem.unread .sender-name {
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
EnvelopeListItem.unread .email-subject {
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
/* Selected/checked message styling (for multi-select) */
|
||||
EnvelopeListItem.selected {
|
||||
tint: $accent 20%;
|
||||
}
|
||||
|
||||
/* Currently highlighted/focused item styling - more prominent */
|
||||
EnvelopeListItem.highlighted {
|
||||
background: $primary-darken-1;
|
||||
}
|
||||
|
||||
EnvelopeListItem.highlighted .sender-name {
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
EnvelopeListItem.highlighted .email-subject {
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
EnvelopeListItem.highlighted .message-datetime {
|
||||
color: $secondary-lighten-2;
|
||||
}
|
||||
|
||||
/* GroupHeader - date group separator */
|
||||
GroupHeader {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
GroupHeader .group-header-label {
|
||||
color: $text-muted;
|
||||
text-style: bold;
|
||||
padding: 0 1;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
END NEW styles
|
||||
===================================================== */
|
||||
|
||||
/* Legacy styles (keeping for backward compatibility) */
|
||||
.email_subject {
|
||||
width: 1fr;
|
||||
padding: 0 2;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.sender_name {
|
||||
tint: gray 30%;
|
||||
}
|
||||
|
||||
.message_date {
|
||||
padding: 0 2;
|
||||
color: $secondary;
|
||||
}
|
||||
|
||||
.header_key {
|
||||
tint: gray 20%;
|
||||
min-width: 10;
|
||||
text-style:bold;
|
||||
}
|
||||
|
||||
.header_value {
|
||||
padding:0 1 0 0;
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.modal_screen {
|
||||
align: center middle;
|
||||
margin: 1;
|
||||
padding: 2;
|
||||
border: round $border;
|
||||
background: $panel;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#envelopes_list {
|
||||
ListItem:odd {
|
||||
background: $surface;
|
||||
}
|
||||
ListItem:even {
|
||||
background: $surface-darken-1;
|
||||
}
|
||||
|
||||
/* Currently highlighted/focused item - make it very visible */
|
||||
& > ListItem.-highlight {
|
||||
background: $primary-darken-2;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
/* Highlighted item's child elements */
|
||||
& > ListItem.-highlight EnvelopeListItem {
|
||||
tint: $primary 30%;
|
||||
}
|
||||
|
||||
& > ListItem.-highlight .sender-name {
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
& > ListItem.-highlight .email-subject {
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
& > ListItem.-highlight .message-datetime {
|
||||
color: $secondary-lighten-2;
|
||||
}
|
||||
}
|
||||
|
||||
.envelope_item_row {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
|
||||
.envelope_header_row, .envelope_subject_row {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.x-list {
|
||||
tint: $accent 20%;
|
||||
}
|
||||
|
||||
#open_message_container {
|
||||
border: panel $border;
|
||||
dock: right;
|
||||
width: 25%;
|
||||
min-width: 60;
|
||||
padding: 0 1;
|
||||
height: 100%;
|
||||
|
||||
Input {
|
||||
width: 1fr;
|
||||
}
|
||||
Label, Button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Label.group_header {
|
||||
color: $text-muted;
|
||||
text-style: bold;
|
||||
background: $panel;
|
||||
width: 100%;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#plaintext_content {
|
||||
padding: 1 2;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#html_content {
|
||||
padding: 1 2;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#markdown_content {
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
ContentContainer {
|
||||
width: 100%;
|
||||
height: 1fr;
|
||||
}
|
||||
208
src/mail/invite_compressor.py
Normal file
208
src/mail/invite_compressor.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Calendar invite compressor for terminal-friendly display."""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from .utils.calendar_parser import (
|
||||
ParsedCalendarEvent,
|
||||
parse_calendar_from_raw_message,
|
||||
format_event_time,
|
||||
is_cancelled_event,
|
||||
is_event_request,
|
||||
)
|
||||
from .notification_detector import is_calendar_email
|
||||
|
||||
|
||||
class InviteCompressor:
|
||||
"""Compress calendar invite emails into terminal-friendly summaries."""
|
||||
|
||||
# Nerdfont icons
|
||||
ICON_CALENDAR = "\uf073" # calendar icon
|
||||
ICON_CANCELLED = "\uf057" # times-circle
|
||||
ICON_INVITE = "\uf0e0" # envelope
|
||||
ICON_REPLY = "\uf3e5" # reply
|
||||
ICON_LOCATION = "\uf3c5" # map-marker-alt
|
||||
ICON_CLOCK = "\uf017" # clock
|
||||
ICON_USER = "\uf007" # user
|
||||
ICON_USERS = "\uf0c0" # users
|
||||
|
||||
def __init__(self, mode: str = "summary"):
|
||||
"""Initialize compressor.
|
||||
|
||||
Args:
|
||||
mode: Compression mode - "summary", "detailed", or "off"
|
||||
"""
|
||||
self.mode = mode
|
||||
|
||||
def should_compress(self, envelope: dict[str, Any]) -> bool:
|
||||
"""Check if email should be compressed as calendar invite.
|
||||
|
||||
Args:
|
||||
envelope: Email envelope metadata
|
||||
|
||||
Returns:
|
||||
True if email is a calendar invite that should be compressed
|
||||
"""
|
||||
if self.mode == "off":
|
||||
return False
|
||||
|
||||
return is_calendar_email(envelope)
|
||||
|
||||
def compress(
|
||||
self, raw_message: str, envelope: dict[str, Any]
|
||||
) -> tuple[str, Optional[ParsedCalendarEvent]]:
|
||||
"""Compress calendar invite email content.
|
||||
|
||||
Args:
|
||||
raw_message: Raw email MIME content
|
||||
envelope: Email envelope metadata
|
||||
|
||||
Returns:
|
||||
Tuple of (compressed content, parsed event or None)
|
||||
"""
|
||||
if not self.should_compress(envelope):
|
||||
return "", None
|
||||
|
||||
# Parse the ICS content from raw message
|
||||
event = parse_calendar_from_raw_message(raw_message)
|
||||
|
||||
if not event:
|
||||
return "", None
|
||||
|
||||
# Format as markdown
|
||||
compressed = self._format_as_markdown(event, envelope)
|
||||
|
||||
return compressed, event
|
||||
|
||||
def _format_as_markdown(
|
||||
self,
|
||||
event: ParsedCalendarEvent,
|
||||
envelope: dict[str, Any],
|
||||
) -> str:
|
||||
"""Format event as markdown for terminal display.
|
||||
|
||||
Args:
|
||||
event: Parsed calendar event
|
||||
envelope: Email envelope metadata
|
||||
|
||||
Returns:
|
||||
Markdown-formatted compressed invite
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Determine event type and icon
|
||||
if is_cancelled_event(event):
|
||||
icon = self.ICON_CANCELLED
|
||||
type_label = "CANCELLED"
|
||||
type_style = "~~" # strikethrough
|
||||
elif is_event_request(event):
|
||||
icon = self.ICON_INVITE
|
||||
type_label = "MEETING INVITE"
|
||||
type_style = "**"
|
||||
else:
|
||||
icon = self.ICON_CALENDAR
|
||||
type_label = event.method or "CALENDAR"
|
||||
type_style = ""
|
||||
|
||||
# Header
|
||||
lines.append(f"## {icon} {type_label}")
|
||||
lines.append("")
|
||||
|
||||
# Event title
|
||||
title = event.summary or envelope.get("subject", "Untitled Event")
|
||||
if is_cancelled_event(event):
|
||||
# Remove "Canceled: " prefix if present
|
||||
if title.lower().startswith("canceled:"):
|
||||
title = title[9:].strip()
|
||||
elif title.lower().startswith("cancelled:"):
|
||||
title = title[10:].strip()
|
||||
lines.append(f"~~{title}~~")
|
||||
else:
|
||||
lines.append(f"**{title}**")
|
||||
lines.append("")
|
||||
|
||||
# Time
|
||||
time_str = format_event_time(event)
|
||||
lines.append(f"{self.ICON_CLOCK} {time_str}")
|
||||
lines.append("")
|
||||
|
||||
# Location
|
||||
if event.location:
|
||||
lines.append(f"{self.ICON_LOCATION} {event.location}")
|
||||
lines.append("")
|
||||
|
||||
# Organizer
|
||||
if event.organizer_name or event.organizer_email:
|
||||
organizer = event.organizer_name or event.organizer_email
|
||||
lines.append(f"{self.ICON_USER} **Organizer:** {organizer}")
|
||||
lines.append("")
|
||||
|
||||
# Attendees (compressed)
|
||||
if event.attendees:
|
||||
attendee_summary = self._compress_attendees(event.attendees)
|
||||
lines.append(f"{self.ICON_USERS} **Attendees:** {attendee_summary}")
|
||||
lines.append("")
|
||||
|
||||
# Actions hint
|
||||
if is_event_request(event):
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("*Press `A` to Accept, `T` for Tentative, `D` to Decline*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _compress_attendees(self, attendees: list[str], max_shown: int = 3) -> str:
|
||||
"""Compress attendee list to a short summary.
|
||||
|
||||
Args:
|
||||
attendees: List of attendee strings (name <email> or just email)
|
||||
max_shown: Maximum number of attendees to show before truncating
|
||||
|
||||
Returns:
|
||||
Compressed attendee summary like "Alice, Bob, Carol... (+12 more)"
|
||||
"""
|
||||
if not attendees:
|
||||
return "None"
|
||||
|
||||
# Extract just names from attendees
|
||||
names = []
|
||||
for att in attendees:
|
||||
# Handle "Name <email>" format
|
||||
if "<" in att:
|
||||
name = att.split("<")[0].strip()
|
||||
if name:
|
||||
# Get just first name for brevity
|
||||
first_name = (
|
||||
name.split(",")[0].strip() if "," in name else name.split()[0]
|
||||
)
|
||||
names.append(first_name)
|
||||
else:
|
||||
names.append(att.split("<")[1].rstrip(">").split("@")[0])
|
||||
else:
|
||||
# Just email, use local part
|
||||
names.append(att.split("@")[0])
|
||||
|
||||
total = len(names)
|
||||
|
||||
if total <= max_shown:
|
||||
return ", ".join(names)
|
||||
else:
|
||||
shown = ", ".join(names[:max_shown])
|
||||
remaining = total - max_shown
|
||||
return f"{shown}... (+{remaining} more)"
|
||||
|
||||
|
||||
def compress_invite(
|
||||
raw_message: str, envelope: dict[str, Any], mode: str = "summary"
|
||||
) -> tuple[str, Optional[ParsedCalendarEvent]]:
|
||||
"""Convenience function to compress a calendar invite.
|
||||
|
||||
Args:
|
||||
raw_message: Raw email MIME content
|
||||
envelope: Email envelope metadata
|
||||
mode: Compression mode
|
||||
|
||||
Returns:
|
||||
Tuple of (compressed content, parsed event or None)
|
||||
"""
|
||||
compressor = InviteCompressor(mode=mode)
|
||||
return compressor.compress(raw_message, envelope)
|
||||
@@ -83,7 +83,17 @@ class MessageStore:
|
||||
self, current_index: int
|
||||
) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""Find the next valid message ID and its index"""
|
||||
if not self.envelopes or current_index >= len(self.envelopes) - 1:
|
||||
if not self.envelopes:
|
||||
return None, None
|
||||
|
||||
# Clamp current_index to valid range in case list shrunk during async operations
|
||||
if current_index >= len(self.envelopes):
|
||||
current_index = len(self.envelopes) - 1
|
||||
if current_index < 0:
|
||||
current_index = 0
|
||||
|
||||
# If already at or past the end, no next item
|
||||
if current_index >= len(self.envelopes) - 1:
|
||||
return None, None
|
||||
|
||||
# Start from current index + 1
|
||||
@@ -99,7 +109,17 @@ class MessageStore:
|
||||
self, current_index: int
|
||||
) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""Find the previous valid message ID and its index"""
|
||||
if not self.envelopes or current_index <= 0:
|
||||
if not self.envelopes:
|
||||
return None, None
|
||||
|
||||
# Clamp current_index to valid range in case list shrunk during async operations
|
||||
if current_index >= len(self.envelopes):
|
||||
current_index = len(self.envelopes) - 1
|
||||
if current_index < 0:
|
||||
current_index = 0
|
||||
|
||||
# If at the beginning, no previous item
|
||||
if current_index <= 0:
|
||||
return None, None
|
||||
|
||||
# Start from current index - 1
|
||||
@@ -148,3 +168,69 @@ class MessageStore:
|
||||
self.total_messages = len(self.metadata_by_id)
|
||||
else:
|
||||
logging.warning(f"Invalid index {index} for message ID {message_id}")
|
||||
|
||||
def filter_by_query(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Filter envelopes by search query.
|
||||
|
||||
Searches subject, from name, from address, to name, and to address.
|
||||
Returns a new list of filtered envelopes (with headers regenerated).
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return self.envelopes
|
||||
|
||||
query_lower = query.lower().strip()
|
||||
filtered = []
|
||||
current_month = None
|
||||
|
||||
for item in self.envelopes:
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
# Skip headers - we'll regenerate them
|
||||
if item.get("type") == "header":
|
||||
continue
|
||||
|
||||
# Check if envelope matches query
|
||||
# Use "or ''" to handle None values (key exists but value is None)
|
||||
subject = (item.get("subject") or "").lower()
|
||||
from_info = item.get("from", {})
|
||||
from_name = (
|
||||
(from_info.get("name") or "").lower()
|
||||
if isinstance(from_info, dict)
|
||||
else ""
|
||||
)
|
||||
from_addr = (
|
||||
(from_info.get("addr") or "").lower()
|
||||
if isinstance(from_info, dict)
|
||||
else ""
|
||||
)
|
||||
to_info = item.get("to", {})
|
||||
to_name = (
|
||||
(to_info.get("name") or "").lower() if isinstance(to_info, dict) else ""
|
||||
)
|
||||
to_addr = (
|
||||
(to_info.get("addr") or "").lower() if isinstance(to_info, dict) else ""
|
||||
)
|
||||
|
||||
if (
|
||||
query_lower in subject
|
||||
or query_lower in from_name
|
||||
or query_lower in from_addr
|
||||
or query_lower in to_name
|
||||
or query_lower in to_addr
|
||||
):
|
||||
# Regenerate month header if needed
|
||||
date_str = item.get("date", "")
|
||||
try:
|
||||
date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
month_key = date.strftime("%B %Y")
|
||||
except (ValueError, TypeError):
|
||||
month_key = "Unknown Date"
|
||||
|
||||
if month_key != current_month:
|
||||
current_month = month_key
|
||||
filtered.append({"type": "header", "label": month_key})
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
return filtered
|
||||
219
src/mail/notification_compressor.py
Normal file
219
src/mail/notification_compressor.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Notification email compressor for terminal-friendly display."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .notification_detector import (
|
||||
NotificationType,
|
||||
classify_notification,
|
||||
extract_notification_summary,
|
||||
is_notification_email,
|
||||
)
|
||||
|
||||
|
||||
class NotificationCompressor:
|
||||
"""Compress notification emails into terminal-friendly summaries."""
|
||||
|
||||
def __init__(self, mode: str = "summary"):
|
||||
"""Initialize compressor.
|
||||
|
||||
Args:
|
||||
mode: Compression mode - "summary", "detailed", or "off"
|
||||
"""
|
||||
self.mode = mode
|
||||
|
||||
def should_compress(self, envelope: dict[str, Any]) -> bool:
|
||||
"""Check if email should be compressed.
|
||||
|
||||
Args:
|
||||
envelope: Email envelope metadata
|
||||
|
||||
Returns:
|
||||
True if email should be compressed
|
||||
"""
|
||||
if self.mode == "off":
|
||||
return False
|
||||
|
||||
return is_notification_email(envelope)
|
||||
|
||||
def compress(
|
||||
self, content: str, envelope: dict[str, Any]
|
||||
) -> tuple[str, NotificationType | None]:
|
||||
"""Compress notification email content.
|
||||
|
||||
Args:
|
||||
content: Raw email content
|
||||
envelope: Email envelope metadata
|
||||
|
||||
Returns:
|
||||
Tuple of (compressed content, notification_type)
|
||||
"""
|
||||
|
||||
if not self.should_compress(envelope):
|
||||
return content, None
|
||||
|
||||
# Classify notification type
|
||||
notif_type = classify_notification(envelope, content)
|
||||
|
||||
# Extract summary
|
||||
summary = extract_notification_summary(content, notif_type)
|
||||
|
||||
# Format as markdown
|
||||
compressed = self._format_as_markdown(summary, envelope, notif_type)
|
||||
|
||||
return compressed, notif_type
|
||||
|
||||
def _format_as_markdown(
|
||||
self,
|
||||
summary: dict[str, Any],
|
||||
envelope: dict[str, Any],
|
||||
notif_type: NotificationType | None,
|
||||
) -> str:
|
||||
"""Format summary as markdown for terminal display.
|
||||
|
||||
Args:
|
||||
summary: Extracted summary data
|
||||
envelope: Email envelope metadata
|
||||
notif_type: Classified notification type
|
||||
|
||||
Returns:
|
||||
Markdown-formatted compressed email
|
||||
"""
|
||||
|
||||
from_addr = envelope.get("from", {}).get("name") or envelope.get(
|
||||
"from", {}
|
||||
).get("addr", "")
|
||||
subject = envelope.get("subject", "")
|
||||
|
||||
# Get icon
|
||||
icon = notif_type.icon if notif_type else "\uf0f3"
|
||||
|
||||
# Build markdown
|
||||
lines = []
|
||||
|
||||
# Header with icon
|
||||
if notif_type:
|
||||
lines.append(f"## {icon} {notif_type.name.title()} Notification")
|
||||
else:
|
||||
lines.append(f"## {icon} Notification")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Title/subject
|
||||
if summary.get("title"):
|
||||
lines.append(f"**{summary['title']}**")
|
||||
else:
|
||||
lines.append(f"**{subject}**")
|
||||
lines.append("")
|
||||
|
||||
# Metadata section
|
||||
if summary.get("metadata"):
|
||||
lines.append("### Details")
|
||||
for key, value in summary["metadata"].items():
|
||||
# Format key nicely
|
||||
key_formatted = key.replace("_", " ").title()
|
||||
lines.append(f"- **{key_formatted}**: {value}")
|
||||
lines.append("")
|
||||
|
||||
# Action items
|
||||
if summary.get("action_items"):
|
||||
lines.append("### Actions")
|
||||
for i, action in enumerate(summary["action_items"], 1):
|
||||
lines.append(f"{i}. {action}")
|
||||
lines.append("")
|
||||
|
||||
# Add footer
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(f"*From: {from_addr}*")
|
||||
lines.append(
|
||||
"*This is a compressed notification. Press `m` to see full email.*"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class DetailedCompressor(NotificationCompressor):
|
||||
"""Compressor that includes more detail in summaries."""
|
||||
|
||||
def _format_as_markdown(
|
||||
self,
|
||||
summary: dict[str, Any],
|
||||
envelope: dict[str, Any],
|
||||
notif_type: NotificationType | None,
|
||||
) -> str:
|
||||
"""Format summary with more detail."""
|
||||
|
||||
from_addr = envelope.get("from", {}).get("name") or envelope.get(
|
||||
"from", {}
|
||||
).get("addr", "")
|
||||
subject = envelope.get("subject", "")
|
||||
date = envelope.get("date", "")
|
||||
|
||||
icon = notif_type.icon if notif_type else "\uf0f3"
|
||||
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
lines.append(
|
||||
f"## {icon} {notif_type.name.title()} Notification"
|
||||
if notif_type
|
||||
else f"## {icon} Notification"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Subject and from
|
||||
lines.append(f"**Subject:** {subject}")
|
||||
lines.append(f"**From:** {from_addr}")
|
||||
lines.append(f"**Date:** {date}")
|
||||
lines.append("")
|
||||
|
||||
# Summary title
|
||||
if summary.get("title"):
|
||||
lines.append(f"### {summary['title']}")
|
||||
lines.append("")
|
||||
|
||||
# Metadata table
|
||||
if summary.get("metadata"):
|
||||
lines.append("| Property | Value |")
|
||||
lines.append("|----------|-------|")
|
||||
for key, value in summary["metadata"].items():
|
||||
key_formatted = key.replace("_", " ").title()
|
||||
lines.append(f"| {key_formatted} | {value} |")
|
||||
lines.append("")
|
||||
|
||||
# Action items
|
||||
if summary.get("action_items"):
|
||||
lines.append("### Action Items")
|
||||
for i, action in enumerate(summary["action_items"], 1):
|
||||
lines.append(f"- [ ] {action}")
|
||||
lines.append("")
|
||||
|
||||
# Key links
|
||||
if summary.get("key_links"):
|
||||
lines.append("### Important Links")
|
||||
for link in summary["key_links"]:
|
||||
lines.append(f"- [{link.get('text', 'Link')}]({link.get('url', '#')})")
|
||||
lines.append("")
|
||||
|
||||
# Footer
|
||||
lines.append("---")
|
||||
lines.append(
|
||||
"*This is a compressed notification view. Press `m` to toggle full view.*"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_compressor(mode: str) -> NotificationCompressor:
|
||||
"""Factory function to create appropriate compressor.
|
||||
|
||||
Args:
|
||||
mode: Compression mode - "summary", "detailed", or "off"
|
||||
|
||||
Returns:
|
||||
NotificationCompressor instance
|
||||
"""
|
||||
|
||||
if mode == "detailed":
|
||||
return DetailedCompressor(mode=mode)
|
||||
return NotificationCompressor(mode=mode)
|
||||
443
src/mail/notification_detector.py
Normal file
443
src/mail/notification_detector.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""Email notification detection utilities."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationType:
|
||||
"""Classification of notification email types."""
|
||||
|
||||
name: str
|
||||
patterns: list[str]
|
||||
domains: list[str]
|
||||
icon: str
|
||||
|
||||
def matches(self, envelope: dict[str, Any], content: str | None = None) -> bool:
|
||||
"""Check if envelope matches this notification type."""
|
||||
|
||||
# Check sender domain (more specific check)
|
||||
from_addr = envelope.get("from", {}).get("addr", "").lower()
|
||||
for domain in self.domains:
|
||||
# For atlassian.net, check if it's specifically jira or confluence in the address
|
||||
if domain == "atlassian.net":
|
||||
if "jira@" in from_addr:
|
||||
return self.name == "jira"
|
||||
if "confluence@" in from_addr:
|
||||
return self.name == "confluence"
|
||||
elif domain in from_addr:
|
||||
return True
|
||||
|
||||
# Check subject patterns
|
||||
subject = envelope.get("subject", "").lower()
|
||||
if any(re.search(pattern, subject, re.IGNORECASE) for pattern in self.patterns):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Common notification types
|
||||
NOTIFICATION_TYPES = [
|
||||
NotificationType(
|
||||
name="gitlab",
|
||||
patterns=[r"\[gitlab\]", r"pipeline", r"merge request", r"mention.*you"],
|
||||
domains=["gitlab.com", "@gitlab"],
|
||||
icon="\uf296",
|
||||
),
|
||||
NotificationType(
|
||||
name="github",
|
||||
patterns=[r"\[github\]", r"pr #", r"pull request", r"issue #", r"mention"],
|
||||
domains=["github.com", "noreply@github.com"],
|
||||
icon="\uf09b",
|
||||
),
|
||||
NotificationType(
|
||||
name="jira",
|
||||
patterns=[r"\[jira\]", r"[a-z]+-\d+", r"issue updated", r"comment added"],
|
||||
domains=["atlassian.net", "jira"],
|
||||
icon="\uf1b3",
|
||||
),
|
||||
NotificationType(
|
||||
name="confluence",
|
||||
patterns=[r"\[confluence\]", r"page created", r"page updated", r"comment"],
|
||||
domains=["atlassian.net", "confluence"],
|
||||
icon="\uf298",
|
||||
),
|
||||
NotificationType(
|
||||
name="datadog",
|
||||
patterns=[r"alert", r"monitor", r"incident", r"downtime"],
|
||||
domains=["datadoghq.com", "datadog"],
|
||||
icon="\uf1b0",
|
||||
),
|
||||
NotificationType(
|
||||
name="renovate",
|
||||
patterns=[r"renovate", r"dependency update", r"lock file"],
|
||||
domains=["renovate", "renovatebot"],
|
||||
icon="\uf1e6",
|
||||
),
|
||||
NotificationType(
|
||||
name="general",
|
||||
patterns=[r"\[.*?\]", r"notification", r"digest", r"summary"],
|
||||
domains=["noreply@", "no-reply@", "notifications@"],
|
||||
icon="\uf0f3",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def is_notification_email(envelope: dict[str, Any], content: str | None = None) -> bool:
|
||||
"""Check if an email is a notification-style email.
|
||||
|
||||
Args:
|
||||
envelope: Email envelope metadata from himalaya
|
||||
content: Optional email content for content-based detection
|
||||
|
||||
Returns:
|
||||
True if email appears to be a notification
|
||||
"""
|
||||
|
||||
# Check against known notification types
|
||||
for notif_type in NOTIFICATION_TYPES:
|
||||
if notif_type.matches(envelope, content):
|
||||
return True
|
||||
|
||||
# Check for generic notification indicators
|
||||
subject = envelope.get("subject", "").lower()
|
||||
from_addr = envelope.get("from", {}).get("addr", "").lower()
|
||||
|
||||
# Generic notification patterns
|
||||
generic_patterns = [
|
||||
r"^\[.*?\]", # Brackets at start
|
||||
r"weekly|daily|monthly.*report|digest|summary",
|
||||
r"you were mentioned",
|
||||
r"this is an automated message",
|
||||
r"do not reply|don't reply",
|
||||
]
|
||||
|
||||
if any(re.search(pattern, subject, re.IGNORECASE) for pattern in generic_patterns):
|
||||
return True
|
||||
|
||||
# Check for notification senders
|
||||
notification_senders = ["noreply", "no-reply", "notifications", "robot", "bot"]
|
||||
if any(sender in from_addr for sender in notification_senders):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def classify_notification(
|
||||
envelope: dict[str, Any], content: str | None = None
|
||||
) -> NotificationType | None:
|
||||
"""Classify the type of notification email.
|
||||
|
||||
Args:
|
||||
envelope: Email envelope metadata from himalaya
|
||||
content: Optional email content for content-based detection
|
||||
|
||||
Returns:
|
||||
NotificationType if classified, None if not a notification
|
||||
"""
|
||||
|
||||
for notif_type in NOTIFICATION_TYPES:
|
||||
if notif_type.matches(envelope, content):
|
||||
return notif_type
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_notification_summary(
|
||||
content: str, notification_type: NotificationType | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Extract structured summary from notification email content.
|
||||
|
||||
Args:
|
||||
content: Email body content
|
||||
notification_type: Classified notification type (optional)
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted summary fields
|
||||
"""
|
||||
|
||||
summary = {
|
||||
"title": None,
|
||||
"action_items": [],
|
||||
"key_links": [],
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
# Extract based on notification type
|
||||
if notification_type and notification_type.name == "gitlab":
|
||||
summary.update(_extract_gitlab_summary(content))
|
||||
elif notification_type and notification_type.name == "github":
|
||||
summary.update(_extract_github_summary(content))
|
||||
elif notification_type and notification_type.name == "jira":
|
||||
summary.update(_extract_jira_summary(content))
|
||||
elif notification_type and notification_type.name == "confluence":
|
||||
summary.update(_extract_confluence_summary(content))
|
||||
elif notification_type and notification_type.name == "datadog":
|
||||
summary.update(_extract_datadog_summary(content))
|
||||
elif notification_type and notification_type.name == "renovate":
|
||||
summary.update(_extract_renovate_summary(content))
|
||||
else:
|
||||
summary.update(_extract_general_notification_summary(content))
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _extract_gitlab_summary(content: str) -> dict[str, Any]:
|
||||
"""Extract summary from GitLab notification."""
|
||||
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||
|
||||
# Pipeline patterns
|
||||
pipeline_match = re.search(
|
||||
r"Pipeline #(\d+).*?(?:failed|passed|canceled) by (.+?)[\n\r]",
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if pipeline_match:
|
||||
summary["metadata"]["pipeline_id"] = pipeline_match.group(1)
|
||||
summary["metadata"]["triggered_by"] = pipeline_match.group(2)
|
||||
summary["title"] = f"Pipeline #{pipeline_match.group(1)}"
|
||||
|
||||
# Merge request patterns
|
||||
mr_match = re.search(r"Merge request #(\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE)
|
||||
if mr_match:
|
||||
summary["metadata"]["mr_id"] = mr_match.group(1)
|
||||
summary["metadata"]["mr_title"] = mr_match.group(2)
|
||||
summary["title"] = f"MR #{mr_match.group(1)}: {mr_match.group(2)}"
|
||||
|
||||
# Mention patterns
|
||||
mention_match = re.search(
|
||||
r"<@(.+?)> mentioned you in (?:#|@)(.+?)[\n\r]", content, re.IGNORECASE
|
||||
)
|
||||
if mention_match:
|
||||
summary["metadata"]["mentioned_by"] = mention_match.group(1)
|
||||
summary["metadata"]["mentioned_in"] = mention_match.group(2)
|
||||
summary["title"] = f"Mention by {mention_match.group(1)}"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _extract_github_summary(content: str) -> dict[str, Any]:
|
||||
"""Extract summary from GitHub notification."""
|
||||
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||
|
||||
# PR/Issue patterns
|
||||
pr_match = re.search(r"(?:PR|Issue) #(\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE)
|
||||
if pr_match:
|
||||
summary["metadata"]["number"] = pr_match.group(1)
|
||||
summary["metadata"]["title"] = pr_match.group(2)
|
||||
summary["title"] = f"#{pr_match.group(1)}: {pr_match.group(2)}"
|
||||
|
||||
# Review requested
|
||||
if "review requested" in content.lower():
|
||||
summary["action_items"].append("Review requested")
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _extract_jira_summary(content: str) -> dict[str, Any]:
|
||||
"""Extract summary from Jira notification."""
|
||||
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||
|
||||
# Issue patterns
|
||||
issue_match = re.search(r"([A-Z]+-\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE)
|
||||
if issue_match:
|
||||
summary["metadata"]["issue_key"] = issue_match.group(1)
|
||||
summary["metadata"]["issue_title"] = issue_match.group(2)
|
||||
summary["title"] = f"{issue_match.group(1)}: {issue_match.group(2)}"
|
||||
|
||||
# Status changes
|
||||
if "status changed" in content.lower():
|
||||
status_match = re.search(
|
||||
r"status changed from (.+?) to (.+)", content, re.IGNORECASE
|
||||
)
|
||||
if status_match:
|
||||
summary["metadata"]["status_from"] = status_match.group(1)
|
||||
summary["metadata"]["status_to"] = status_match.group(2)
|
||||
summary["action_items"].append(
|
||||
f"Status: {status_match.group(1)} → {status_match.group(2)}"
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _extract_confluence_summary(content: str) -> dict[str, Any]:
|
||||
"""Extract summary from Confluence notification."""
|
||||
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||
|
||||
# Page patterns
|
||||
page_match = re.search(r"Page \"(.+?)\"", content, re.IGNORECASE)
|
||||
if page_match:
|
||||
summary["metadata"]["page_title"] = page_match.group(1)
|
||||
summary["title"] = f"Page: {page_match.group(1)}"
|
||||
|
||||
# Author
|
||||
author_match = re.search(
|
||||
r"(?:created|updated) by (.+?)[\n\r]", content, re.IGNORECASE
|
||||
)
|
||||
if author_match:
|
||||
summary["metadata"]["author"] = author_match.group(1)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _extract_datadog_summary(content: str) -> dict[str, Any]:
|
||||
"""Extract summary from Datadog notification."""
|
||||
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||
|
||||
# Alert status
|
||||
if "triggered" in content.lower():
|
||||
summary["metadata"]["status"] = "Triggered"
|
||||
summary["action_items"].append("Alert triggered - investigate")
|
||||
elif "recovered" in content.lower():
|
||||
summary["metadata"]["status"] = "Recovered"
|
||||
|
||||
# Monitor name
|
||||
monitor_match = re.search(r"Monitor: (.+?)[\n\r]", content, re.IGNORECASE)
|
||||
if monitor_match:
|
||||
summary["metadata"]["monitor"] = monitor_match.group(1)
|
||||
summary["title"] = f"Alert: {monitor_match.group(1)}"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _extract_renovate_summary(content: str) -> dict[str, Any]:
|
||||
"""Extract summary from Renovate notification."""
|
||||
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||
|
||||
# Dependency patterns
|
||||
dep_match = re.search(
|
||||
r"Update (?:.+) dependency (.+?) to (v?\d+\.\d+\.?\d*)", content, re.IGNORECASE
|
||||
)
|
||||
if dep_match:
|
||||
summary["metadata"]["dependency"] = dep_match.group(2)
|
||||
summary["metadata"]["version"] = dep_match.group(3)
|
||||
summary["title"] = f"Update {dep_match.group(2)} to {dep_match.group(3)}"
|
||||
summary["action_items"].append("Review and merge dependency update")
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _extract_general_notification_summary(content: str) -> dict[str, Any]:
|
||||
"""Extract summary from general notification."""
|
||||
summary = {"action_items": [], "key_links": [], "metadata": {}}
|
||||
|
||||
# Look for action-oriented phrases
|
||||
action_patterns = [
|
||||
r"you need to (.+)",
|
||||
r"please (.+)",
|
||||
r"action required",
|
||||
r"review requested",
|
||||
r"approval needed",
|
||||
]
|
||||
|
||||
for pattern in action_patterns:
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
summary["action_items"].extend(matches)
|
||||
|
||||
# Limit action items
|
||||
summary["action_items"] = summary["action_items"][:5]
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
# Calendar email patterns
|
||||
CALENDAR_SUBJECT_PATTERNS = [
|
||||
r"^canceled:",
|
||||
r"^cancelled:",
|
||||
r"^accepted:",
|
||||
r"^declined:",
|
||||
r"^tentative:",
|
||||
r"^updated:",
|
||||
r"^invitation:",
|
||||
r"^meeting\s+(request|update|cancel)",
|
||||
r"^\[calendar\]",
|
||||
r"invite\s+you\s+to",
|
||||
r"has\s+invited\s+you",
|
||||
]
|
||||
|
||||
|
||||
def _decode_mime_content(raw_content: str) -> str:
|
||||
"""Decode base64 parts from MIME content for text searching.
|
||||
|
||||
Args:
|
||||
raw_content: Raw MIME message content
|
||||
|
||||
Returns:
|
||||
Decoded text content for searching
|
||||
"""
|
||||
import base64
|
||||
|
||||
decoded_parts = [raw_content] # Include raw content for non-base64 parts
|
||||
|
||||
# Find and decode base64 text parts
|
||||
b64_pattern = re.compile(
|
||||
r"Content-Type:\s*text/(?:plain|html)[^\n]*\n"
|
||||
r"(?:[^\n]+\n)*?" # Other headers
|
||||
r"Content-Transfer-Encoding:\s*base64[^\n]*\n"
|
||||
r"(?:[^\n]+\n)*?" # Other headers
|
||||
r"\n" # Empty line before content
|
||||
r"([A-Za-z0-9+/=\s]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
for match in b64_pattern.finditer(raw_content):
|
||||
try:
|
||||
b64_content = (
|
||||
match.group(1).replace("\n", "").replace("\r", "").replace(" ", "")
|
||||
)
|
||||
decoded = base64.b64decode(b64_content).decode("utf-8", errors="replace")
|
||||
decoded_parts.append(decoded)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return " ".join(decoded_parts)
|
||||
|
||||
|
||||
def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> bool:
|
||||
"""Check if an email is a calendar invite/update/cancellation.
|
||||
|
||||
Args:
|
||||
envelope: Email envelope metadata from himalaya
|
||||
content: Optional message content to check for calendar indicators
|
||||
|
||||
Returns:
|
||||
True if email appears to be a calendar-related email
|
||||
"""
|
||||
subject = envelope.get("subject", "").lower().strip()
|
||||
|
||||
# Check subject patterns
|
||||
for pattern in CALENDAR_SUBJECT_PATTERNS:
|
||||
if re.search(pattern, subject, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
# Check for meeting-related keywords in subject
|
||||
meeting_keywords = ["meeting", "appointment", "calendar", "invite", "rsvp"]
|
||||
if any(keyword in subject for keyword in meeting_keywords):
|
||||
return True
|
||||
|
||||
# Check for forwarded meeting invites (FW: or Fwd:) with calendar keywords
|
||||
if re.match(r"^(fw|fwd):", subject, re.IGNORECASE):
|
||||
# Check for Teams/calendar-related terms that might indicate forwarded invite
|
||||
forward_meeting_keywords = ["connect", "sync", "call", "discussion", "review"]
|
||||
if any(keyword in subject for keyword in forward_meeting_keywords):
|
||||
return True
|
||||
|
||||
# If content is provided, check for calendar indicators
|
||||
if content:
|
||||
# Decode base64 parts for proper text searching
|
||||
decoded_content = _decode_mime_content(content).lower()
|
||||
|
||||
# Teams meeting indicators
|
||||
if "microsoft teams meeting" in decoded_content:
|
||||
return True
|
||||
if "join the meeting" in decoded_content:
|
||||
return True
|
||||
# ICS content indicator (check raw content for MIME headers)
|
||||
if "text/calendar" in content.lower():
|
||||
return True
|
||||
# VCALENDAR block
|
||||
if "begin:vcalendar" in content.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
108
src/mail/screens/ConfirmDialog.py
Normal file
108
src/mail/screens/ConfirmDialog.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Confirmation dialog screen for destructive actions."""
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, Container
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Label, Static
|
||||
|
||||
|
||||
class ConfirmDialog(ModalScreen[bool]):
|
||||
"""A modal confirmation dialog that returns True if confirmed, False otherwise."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("enter", "confirm", "Confirm"),
|
||||
Binding("y", "confirm", "Yes"),
|
||||
Binding("n", "cancel", "No"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ConfirmDialog {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
ConfirmDialog #confirm-container {
|
||||
width: 50;
|
||||
height: auto;
|
||||
min-height: 7;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
ConfirmDialog #confirm-title {
|
||||
text-style: bold;
|
||||
width: 1fr;
|
||||
height: 1;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
ConfirmDialog #confirm-message {
|
||||
width: 1fr;
|
||||
height: 1;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
ConfirmDialog #confirm-buttons {
|
||||
width: 1fr;
|
||||
height: 3;
|
||||
align: center middle;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
ConfirmDialog Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str = "Confirm",
|
||||
message: str = "Are you sure?",
|
||||
confirm_label: str = "Yes",
|
||||
cancel_label: str = "No",
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the confirmation dialog.
|
||||
|
||||
Args:
|
||||
title: The dialog title
|
||||
message: The confirmation message
|
||||
confirm_label: Label for the confirm button
|
||||
cancel_label: Label for the cancel button
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._title = title
|
||||
self._message = message
|
||||
self._confirm_label = confirm_label
|
||||
self._cancel_label = cancel_label
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="confirm-container"):
|
||||
yield Label(self._title, id="confirm-title")
|
||||
yield Label(self._message, id="confirm-message")
|
||||
with Horizontal(id="confirm-buttons"):
|
||||
yield Button(self._cancel_label, id="cancel", variant="default")
|
||||
yield Button(self._confirm_label, id="confirm", variant="error")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Focus the cancel button by default (safer option)."""
|
||||
self.query_one("#cancel", Button).focus()
|
||||
|
||||
@on(Button.Pressed, "#confirm")
|
||||
def handle_confirm(self) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
@on(Button.Pressed, "#cancel")
|
||||
def handle_cancel(self) -> None:
|
||||
self.dismiss(False)
|
||||
|
||||
def action_confirm(self) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(False)
|
||||
217
src/mail/screens/CreateTask.py
Normal file
217
src/mail/screens/CreateTask.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Label, Button, ListView, ListItem
|
||||
from textual.containers import Vertical, Horizontal, Container
|
||||
from textual.binding import Binding
|
||||
from textual import on, work
|
||||
from src.services.task_client import create_task, get_backend_info
|
||||
from src.utils.ipc import notify_refresh
|
||||
|
||||
|
||||
class CreateTaskScreen(ModalScreen):
|
||||
"""Screen for creating a new task."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Close"),
|
||||
Binding("ctrl+s", "submit", "Create Task"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CreateTaskScreen {
|
||||
align: right middle;
|
||||
}
|
||||
|
||||
CreateTaskScreen #create_task_container {
|
||||
dock: right;
|
||||
width: 40%;
|
||||
min-width: 50;
|
||||
max-width: 80;
|
||||
height: 100%;
|
||||
background: $surface;
|
||||
border: round $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
CreateTaskScreen #create_task_container:focus-within {
|
||||
border: round $accent;
|
||||
}
|
||||
|
||||
CreateTaskScreen #create_task_form {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
CreateTaskScreen .form-field {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
CreateTaskScreen .form-field Label {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
color: $text-muted;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
CreateTaskScreen .form-field Input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
CreateTaskScreen .form-field Input:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
|
||||
CreateTaskScreen .button-row {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
align: center middle;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
CreateTaskScreen .button-row Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
CreateTaskScreen .form-hint {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, subject="", from_addr="", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.subject = subject
|
||||
self.from_addr = from_addr
|
||||
self.selected_project = None
|
||||
|
||||
def compose(self):
|
||||
with Container(id="create_task_container"):
|
||||
with Vertical(id="create_task_form"):
|
||||
# Subject field
|
||||
with Vertical(classes="form-field"):
|
||||
yield Label("Subject")
|
||||
yield Input(
|
||||
placeholder="Task description",
|
||||
value=self.subject,
|
||||
id="subject_input",
|
||||
)
|
||||
|
||||
# Project field
|
||||
with Vertical(classes="form-field"):
|
||||
yield Label("Project")
|
||||
yield Input(placeholder="e.g., work, home", id="project_input")
|
||||
|
||||
# Tags field
|
||||
with Vertical(classes="form-field"):
|
||||
yield Label("Tags")
|
||||
yield Input(placeholder="tag1, tag2, ...", id="tags_input")
|
||||
|
||||
# Due date field
|
||||
with Vertical(classes="form-field"):
|
||||
yield Label("Due")
|
||||
yield Input(
|
||||
placeholder="today, tomorrow, fri, 2024-01-15",
|
||||
id="due_input",
|
||||
)
|
||||
|
||||
# Priority field
|
||||
with Vertical(classes="form-field"):
|
||||
yield Label("Priority")
|
||||
yield Input(placeholder="H, M, or L", id="priority_input")
|
||||
|
||||
# Buttons
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Create", id="create_btn", variant="primary")
|
||||
yield Button("Cancel", id="cancel_btn", variant="error")
|
||||
|
||||
yield Label("ctrl+s: create, esc: cancel", classes="form-hint")
|
||||
|
||||
def on_mount(self):
|
||||
backend_name, _ = get_backend_info()
|
||||
container = self.query_one("#create_task_container", Container)
|
||||
container.border_title = "\uf0ae New Task" # nf-fa-tasks
|
||||
container.border_subtitle = backend_name
|
||||
# Focus the subject input
|
||||
self.query_one("#subject_input", Input).focus()
|
||||
|
||||
def action_cancel(self):
|
||||
"""Close the screen."""
|
||||
self.dismiss()
|
||||
|
||||
def action_submit(self):
|
||||
"""Submit the form."""
|
||||
self._create_task()
|
||||
|
||||
@on(Input.Submitted)
|
||||
def on_input_submitted(self, event: Input.Submitted):
|
||||
"""Handle Enter key in any input field."""
|
||||
self._create_task()
|
||||
|
||||
@on(Button.Pressed, "#create_btn")
|
||||
def on_create_pressed(self):
|
||||
"""Create the task when the Create button is pressed."""
|
||||
self._create_task()
|
||||
|
||||
def _create_task(self):
|
||||
"""Gather form data and create the task."""
|
||||
# Get input values
|
||||
subject = self.query_one("#subject_input", Input).value
|
||||
project = self.query_one("#project_input", Input).value
|
||||
tags_input = self.query_one("#tags_input", Input).value
|
||||
due = self.query_one("#due_input", Input).value
|
||||
priority = self.query_one("#priority_input", Input).value
|
||||
|
||||
# Process tags (split by commas and trim whitespace)
|
||||
tags = [tag.strip() for tag in tags_input.split(",")] if tags_input else []
|
||||
|
||||
# Add a tag for the sender, if provided
|
||||
if self.from_addr and "@" in self.from_addr:
|
||||
domain = self.from_addr.split("@")[1].split(".")[0]
|
||||
if domain and domain not in ["gmail", "yahoo", "hotmail", "outlook"]:
|
||||
tags.append(domain)
|
||||
|
||||
# Create the task
|
||||
self.create_task_worker(subject, tags, project, due, priority)
|
||||
|
||||
@on(Button.Pressed, "#cancel_btn")
|
||||
def on_cancel_pressed(self):
|
||||
"""Dismiss the screen when Cancel is pressed."""
|
||||
self.dismiss()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def create_task_worker(
|
||||
self, subject, tags=None, project=None, due=None, priority=None
|
||||
):
|
||||
"""Worker to create a task using the configured backend."""
|
||||
if not subject:
|
||||
self.app.show_status("Task subject cannot be empty.", "error")
|
||||
return
|
||||
|
||||
# Validate priority
|
||||
if priority and priority.upper() not in ["H", "M", "L"]:
|
||||
self.app.show_status("Priority must be H, M, or L.", "warning")
|
||||
priority = None
|
||||
elif priority:
|
||||
priority = priority.upper()
|
||||
|
||||
# Create the task using the unified client
|
||||
success, result = await create_task(
|
||||
task_description=subject,
|
||||
tags=tags or [],
|
||||
project=project,
|
||||
due=due,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
if success:
|
||||
self.app.show_status(f"Task created: {subject}", "success")
|
||||
# Notify the tasks app to refresh
|
||||
asyncio.create_task(notify_refresh("tasks", {"source": "mail"}))
|
||||
self.dismiss()
|
||||
else:
|
||||
self.app.show_status(f"Failed to create task: {result}", "error")
|
||||
150
src/mail/screens/HelpScreen.py
Normal file
150
src/mail/screens/HelpScreen.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Help screen modal for mail app."""
|
||||
|
||||
from textual.screen import Screen
|
||||
from textual.containers import Vertical, Horizontal, Center, ScrollableContainer
|
||||
from textual.widgets import Static, Button, Footer
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
|
||||
|
||||
class HelpScreen(Screen):
|
||||
"""Help screen showing all keyboard shortcuts and app information."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "pop_screen", "Close", show=False),
|
||||
Binding("q", "pop_screen", "Close", show=False),
|
||||
Binding("?", "pop_screen", "Close", show=False),
|
||||
]
|
||||
|
||||
def __init__(self, app_bindings: list[Binding], **kwargs):
|
||||
"""Initialize help screen with app bindings.
|
||||
|
||||
Args:
|
||||
app_bindings: List of bindings from the main app
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.app_bindings = app_bindings
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the help screen."""
|
||||
|
||||
with Vertical(id="help_container"):
|
||||
# Header
|
||||
yield Static(
|
||||
"╔══════════════════════════════════════════════════════════════════╗\n"
|
||||
"║" + " " * 68 + "║\n"
|
||||
"║" + " LUK Mail - Keyboard Shortcuts & Help".center(68) + " ║\n"
|
||||
"╚════════════════════════════════════════════════════════════════════╝"
|
||||
)
|
||||
|
||||
# Custom instructions section
|
||||
yield Static("", id="spacer_1")
|
||||
yield Static("[b cyan]Quick Actions[/b cyan]", id="instructions_title")
|
||||
yield Static("─" * 70, id="instructions_separator")
|
||||
yield Static("")
|
||||
yield Static(
|
||||
" The mail app automatically compresses notification emails from:"
|
||||
)
|
||||
yield Static(" • GitLab (pipelines, MRs, mentions)")
|
||||
yield Static(" • GitHub (PRs, issues, reviews)")
|
||||
yield Static(" • Jira (issues, status changes)")
|
||||
yield Static(" • Confluence (page updates, comments)")
|
||||
yield Static(" • Datadog (alerts, incidents)")
|
||||
yield Static(" • Renovate (dependency updates)")
|
||||
yield Static("")
|
||||
yield Static(
|
||||
" [yellow]Tip:[/yellow] Toggle between compressed and full view with [b]m[/b]"
|
||||
)
|
||||
yield Static("")
|
||||
|
||||
# Auto-generated keybindings section
|
||||
yield Static("", id="spacer_2")
|
||||
yield Static("[b cyan]Keyboard Shortcuts[/b cyan]", id="bindings_title")
|
||||
yield Static("─" * 70, id="bindings_separator")
|
||||
yield Static("")
|
||||
yield Static("[b green]Navigation[/b green]")
|
||||
yield Static(" j/k - Next/Previous message")
|
||||
yield Static(" g - Go to oldest message")
|
||||
yield Static(" G - Go to newest message")
|
||||
yield Static(" b - Scroll page up")
|
||||
yield Static(" PageDown/PageUp - Scroll page down/up")
|
||||
yield Static("")
|
||||
|
||||
yield Static("[b green]Message Actions[/b green]")
|
||||
yield Static(" o - Open message externally")
|
||||
yield Static(" # - Delete message(s)")
|
||||
yield Static(" e - Archive message(s)")
|
||||
yield Static(" u - Toggle read/unread")
|
||||
yield Static(" t - Create task from message")
|
||||
yield Static(" l - Show links in message")
|
||||
yield Static("")
|
||||
|
||||
yield Static("[b green]View Options[/b green]")
|
||||
yield Static(" w - Toggle message view window")
|
||||
yield Static(
|
||||
" m - Toggle markdown/html view (or compressed/html for notifications)"
|
||||
)
|
||||
yield Static(" h - Toggle full/compressed envelope headers")
|
||||
yield Static("")
|
||||
|
||||
yield Static("[b green]Search & Filter[/b green]")
|
||||
yield Static(" / - Search messages")
|
||||
yield Static(" s - Toggle sort order")
|
||||
yield Static(" x - Toggle selection mode")
|
||||
yield Static(" Space - Select/deselect message")
|
||||
yield Static(" Escape - Clear selection")
|
||||
yield Static("")
|
||||
|
||||
yield Static("[b green]Calendar Actions (when applicable)[/b green]")
|
||||
yield Static(" A - Accept invite")
|
||||
yield Static(" D - Decline invite")
|
||||
yield Static(" T - Tentative")
|
||||
yield Static("")
|
||||
|
||||
yield Static("[b green]Application[/b green]")
|
||||
yield Static(" r - Reload message list")
|
||||
yield Static(
|
||||
" 1-4 - Focus panel (Accounts, Folders, Messages, Content)"
|
||||
)
|
||||
yield Static(" q - Quit application")
|
||||
yield Static("")
|
||||
|
||||
# Notification compression section
|
||||
yield Static("", id="spacer_3")
|
||||
yield Static(
|
||||
"[b cyan]Notification Email Compression[/b cyan]",
|
||||
id="compression_title",
|
||||
)
|
||||
yield Static("─" * 70, id="compression_separator")
|
||||
yield Static("")
|
||||
yield Static(
|
||||
" Notification emails are automatically detected and compressed"
|
||||
)
|
||||
yield Static(" into terminal-friendly summaries showing:")
|
||||
yield Static(" • Notification type and icon")
|
||||
yield Static(" • Key details (ID, title, status)")
|
||||
yield Static(" • Action items")
|
||||
yield Static(" • Important links")
|
||||
yield Static("")
|
||||
yield Static(" [yellow]Configuration:[/yellow]")
|
||||
yield Static(" Edit ~/.config/luk/mail.toml to customize:")
|
||||
yield Static(" [dim]compress_notifications = true[/dim]")
|
||||
yield Static(" [dim]notification_compression_mode = 'summary'[/dim]")
|
||||
yield Static(" # Options: 'summary', 'detailed', 'off'")
|
||||
yield Static("")
|
||||
|
||||
# Footer
|
||||
yield Static("─" * 70, id="footer_separator")
|
||||
yield Static(
|
||||
"[dim]Press [b]ESC[/b], [b]q[/b], or [b]?[/b] to close this help screen[/dim]",
|
||||
id="footer_text",
|
||||
)
|
||||
|
||||
# Close button at bottom
|
||||
with Horizontal(id="button_container"):
|
||||
yield Button("Close", id="close_button", variant="primary")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press to close help screen."""
|
||||
if event.button.id == "close_button":
|
||||
self.dismiss()
|
||||
558
src/mail/screens/LinkPanel.py
Normal file
558
src/mail/screens/LinkPanel.py
Normal file
@@ -0,0 +1,558 @@
|
||||
"""Link panel for viewing and opening URLs from email messages."""
|
||||
|
||||
import re
|
||||
import webbrowser
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Label, ListView, ListItem, Static
|
||||
|
||||
from src.mail.config import get_config
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkItem:
|
||||
"""Represents a link extracted from an email."""
|
||||
|
||||
url: str
|
||||
label: str # Derived from anchor text or URL
|
||||
domain: str # Extracted for display
|
||||
short_display: str # Truncated/friendly display
|
||||
context: str = "" # Surrounding text for context
|
||||
mnemonic: str = "" # Quick-select key hint
|
||||
|
||||
@classmethod
|
||||
def from_url(
|
||||
cls,
|
||||
url: str,
|
||||
anchor_text: str = "",
|
||||
context: str = "",
|
||||
max_display_len: int = 60,
|
||||
) -> "LinkItem":
|
||||
"""Create a LinkItem from a URL and optional anchor text."""
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc.replace("www.", "")
|
||||
|
||||
# Use anchor text as label if available, otherwise derive from URL
|
||||
if anchor_text and anchor_text.strip():
|
||||
label = anchor_text.strip()
|
||||
else:
|
||||
# Try to derive a meaningful label from the URL path
|
||||
label = cls._derive_label_from_url(parsed)
|
||||
|
||||
# Create short display version
|
||||
short_display = cls._shorten_url(url, domain, parsed.path, max_display_len)
|
||||
|
||||
return cls(
|
||||
url=url,
|
||||
label=label,
|
||||
domain=domain,
|
||||
short_display=short_display,
|
||||
context=context[:80] if context else "",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_label_from_url(parsed) -> str:
|
||||
"""Derive a human-readable label from URL components."""
|
||||
path = parsed.path.strip("/")
|
||||
if not path:
|
||||
return parsed.netloc
|
||||
|
||||
# Split path and take last meaningful segment
|
||||
segments = [s for s in path.split("/") if s]
|
||||
if segments:
|
||||
last = segments[-1]
|
||||
# Remove file extensions
|
||||
last = re.sub(r"\.[a-zA-Z0-9]+$", "", last)
|
||||
# Replace common separators with spaces
|
||||
last = re.sub(r"[-_]", " ", last)
|
||||
# Capitalize words
|
||||
return last.title()[:40]
|
||||
|
||||
return parsed.netloc
|
||||
|
||||
@staticmethod
|
||||
def _shorten_url(url: str, domain: str, path: str, max_len: int) -> str:
|
||||
"""Create a shortened, readable version of the URL.
|
||||
|
||||
Intelligently shortens URLs by:
|
||||
- Special handling for known sites (GitHub, Google Docs, Jira, GitLab)
|
||||
- Keeping first and last path segments, eliding middle only if needed
|
||||
- Adapting to available width
|
||||
"""
|
||||
# Nerdfont chevron separator (nf-cod-chevron_right)
|
||||
sep = " \ueab6 "
|
||||
|
||||
# Special handling for common sites
|
||||
path = path.strip("/")
|
||||
|
||||
# GitHub: user/repo/issues/123 -> user/repo #123
|
||||
if "github.com" in domain:
|
||||
match = re.match(r"([^/]+/[^/]+)/(issues|pull)/(\d+)", path)
|
||||
if match:
|
||||
repo, type_, num = match.groups()
|
||||
icon = "#" if type_ == "issues" else "PR#"
|
||||
return f"{domain}{sep}{repo} {icon}{num}"
|
||||
|
||||
match = re.match(r"([^/]+/[^/]+)", path)
|
||||
if match:
|
||||
return f"{domain}{sep}{match.group(1)}"
|
||||
|
||||
# Google Docs
|
||||
if "docs.google.com" in domain:
|
||||
if "/document/" in path:
|
||||
return f"{domain}{sep}Document"
|
||||
if "/spreadsheets/" in path:
|
||||
return f"{domain}{sep}Spreadsheet"
|
||||
if "/presentation/" in path:
|
||||
return f"{domain}{sep}Slides"
|
||||
|
||||
# Jira/Atlassian
|
||||
if "atlassian.net" in domain or "jira" in domain.lower():
|
||||
match = re.search(r"([A-Z]+-\d+)", path)
|
||||
if match:
|
||||
return f"{domain}{sep}{match.group(1)}"
|
||||
|
||||
# GitLab
|
||||
if "gitlab" in domain.lower():
|
||||
match = re.match(r"([^/]+/[^/]+)/-/(issues|merge_requests)/(\d+)", path)
|
||||
if match:
|
||||
repo, type_, num = match.groups()
|
||||
icon = "#" if type_ == "issues" else "MR!"
|
||||
return f"{domain}{sep}{repo} {icon}{num}"
|
||||
|
||||
# Generic shortening - keep URL readable
|
||||
if len(url) <= max_len:
|
||||
return url
|
||||
|
||||
# Build shortened path, keeping as many segments as fit
|
||||
path_parts = [p for p in path.split("/") if p]
|
||||
|
||||
if not path_parts:
|
||||
return domain
|
||||
|
||||
# Try to fit the full path first
|
||||
full_path = "/".join(path_parts)
|
||||
result = f"{domain}{sep}{full_path}"
|
||||
if len(result) <= max_len:
|
||||
return result
|
||||
|
||||
# Keep first segment + last two segments if possible
|
||||
if len(path_parts) >= 3:
|
||||
short_path = f"{path_parts[0]}/.../{path_parts[-2]}/{path_parts[-1]}"
|
||||
result = f"{domain}{sep}{short_path}"
|
||||
if len(result) <= max_len:
|
||||
return result
|
||||
|
||||
# Keep first + last segment
|
||||
if len(path_parts) >= 2:
|
||||
short_path = f"{path_parts[0]}/.../{path_parts[-1]}"
|
||||
result = f"{domain}{sep}{short_path}"
|
||||
if len(result) <= max_len:
|
||||
return result
|
||||
|
||||
# Just last segment
|
||||
result = f"{domain}{sep}.../{path_parts[-1]}"
|
||||
if len(result) <= max_len:
|
||||
return result
|
||||
|
||||
# Truncate with ellipsis as last resort
|
||||
result = f"{domain}{sep}{path_parts[-1]}"
|
||||
if len(result) > max_len:
|
||||
result = result[: max_len - 3] + "..."
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_links_from_content(content: str) -> List[LinkItem]:
|
||||
"""Extract all links from HTML or markdown content."""
|
||||
links: List[LinkItem] = []
|
||||
seen_urls: set = set()
|
||||
|
||||
# Pattern for HTML links: <a href="...">text</a>
|
||||
html_pattern = r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>([^<]*)</a>'
|
||||
for match in re.finditer(html_pattern, content, re.IGNORECASE):
|
||||
url, anchor_text = match.groups()
|
||||
if url and url not in seen_urls and _is_valid_url(url):
|
||||
# Get surrounding context
|
||||
start = max(0, match.start() - 40)
|
||||
end = min(len(content), match.end() + 40)
|
||||
context = _clean_context(content[start:end])
|
||||
|
||||
links.append(LinkItem.from_url(url, anchor_text, context))
|
||||
seen_urls.add(url)
|
||||
|
||||
# Pattern for markdown links: [text](url)
|
||||
md_pattern = r"\[([^\]]+)\]\(([^)]+)\)"
|
||||
for match in re.finditer(md_pattern, content):
|
||||
anchor_text, url = match.groups()
|
||||
if url and url not in seen_urls and _is_valid_url(url):
|
||||
start = max(0, match.start() - 40)
|
||||
end = min(len(content), match.end() + 40)
|
||||
context = _clean_context(content[start:end])
|
||||
|
||||
links.append(LinkItem.from_url(url, anchor_text, context))
|
||||
seen_urls.add(url)
|
||||
|
||||
# Pattern for bare URLs
|
||||
url_pattern = r'https?://[^\s<>"\'\)]+[^\s<>"\'\.\,\)\]]'
|
||||
for match in re.finditer(url_pattern, content):
|
||||
url = match.group(0)
|
||||
if url not in seen_urls and _is_valid_url(url):
|
||||
start = max(0, match.start() - 40)
|
||||
end = min(len(content), match.end() + 40)
|
||||
context = _clean_context(content[start:end])
|
||||
|
||||
links.append(LinkItem.from_url(url, "", context))
|
||||
seen_urls.add(url)
|
||||
|
||||
# Assign mnemonic hints
|
||||
_assign_mnemonics(links)
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def _is_valid_url(url: str) -> bool:
|
||||
"""Check if a URL is valid and worth displaying."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
# Skip mailto, tel, javascript, etc.
|
||||
if re.match(r"^(mailto|tel|javascript|data|#):", url, re.IGNORECASE):
|
||||
return False
|
||||
|
||||
# Skip very short URLs or fragments
|
||||
if len(url) < 10:
|
||||
return False
|
||||
|
||||
# Must start with http/https
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _clean_context(context: str) -> str:
|
||||
"""Clean up context string for display."""
|
||||
# Remove HTML tags
|
||||
context = re.sub(r"<[^>]+>", "", context)
|
||||
# Normalize whitespace
|
||||
context = " ".join(context.split())
|
||||
return context.strip()
|
||||
|
||||
|
||||
def _assign_mnemonics(links: List[LinkItem]) -> None:
|
||||
"""Assign unique mnemonic key hints to links."""
|
||||
used_mnemonics: set = set()
|
||||
|
||||
# Characters to use for mnemonics (easily typeable)
|
||||
# Exclude keys used by app/panel bindings: h,j,k,l (navigation), q (quit),
|
||||
# b (page up), e (archive), o (open), s (sort), t (task), w (toggle), x (select)
|
||||
reserved_keys = set("hjklqbeostwx")
|
||||
available_chars = "".join(
|
||||
c for c in "asdfgqwertyuiopzxcvbnm" if c not in reserved_keys
|
||||
)
|
||||
|
||||
for link in links:
|
||||
mnemonic = None
|
||||
|
||||
# Try first letter of label (prioritize link text over domain)
|
||||
if link.label:
|
||||
first = link.label[0].lower()
|
||||
if first in available_chars and first not in used_mnemonics:
|
||||
mnemonic = first
|
||||
used_mnemonics.add(first)
|
||||
|
||||
# Try first letter of domain as fallback
|
||||
if not mnemonic and link.domain:
|
||||
first = link.domain[0].lower()
|
||||
if first in available_chars and first not in used_mnemonics:
|
||||
mnemonic = first
|
||||
used_mnemonics.add(first)
|
||||
|
||||
# Try other letters from label
|
||||
if not mnemonic and link.label:
|
||||
for char in link.label.lower():
|
||||
if char in available_chars and char not in used_mnemonics:
|
||||
mnemonic = char
|
||||
used_mnemonics.add(char)
|
||||
break
|
||||
|
||||
# Try first two letters combined logic
|
||||
if not mnemonic:
|
||||
# Try label word initials
|
||||
candidates = []
|
||||
if link.label:
|
||||
words = link.label.split()
|
||||
if len(words) >= 2:
|
||||
candidates.append((words[0][0] + words[1][0]).lower())
|
||||
if link.domain and len(link.domain) > 1:
|
||||
candidates.append(link.domain[:2].lower())
|
||||
|
||||
for candidate in candidates:
|
||||
if len(candidate) == 2 and candidate not in used_mnemonics:
|
||||
# Check both chars are available
|
||||
if all(c in available_chars for c in candidate):
|
||||
mnemonic = candidate
|
||||
used_mnemonics.add(candidate)
|
||||
break
|
||||
|
||||
# Fallback: find any unused character
|
||||
if not mnemonic:
|
||||
for char in available_chars:
|
||||
if char not in used_mnemonics:
|
||||
mnemonic = char
|
||||
used_mnemonics.add(char)
|
||||
break
|
||||
|
||||
link.mnemonic = mnemonic or ""
|
||||
|
||||
|
||||
class LinkListItem(Static):
|
||||
"""Widget for displaying a single link in the list."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
LinkListItem {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, link: LinkItem, index: int, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.link = link
|
||||
self.index = index
|
||||
|
||||
def render(self) -> str:
|
||||
"""Render the link item using Rich markup."""
|
||||
mnemonic = self.link.mnemonic if self.link.mnemonic else "?"
|
||||
# Line 1: [mnemonic] domain - label
|
||||
line1 = (
|
||||
f"[bold cyan]\\[{mnemonic}][/] [dim]{self.link.domain}[/] {self.link.label}"
|
||||
)
|
||||
# Line 2: shortened URL (indented)
|
||||
line2 = f" [dim italic]{self.link.short_display}[/]"
|
||||
return f"{line1}\n{line2}"
|
||||
|
||||
|
||||
class LinkPanel(ModalScreen):
|
||||
"""Side panel for viewing and opening links from the current message."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "dismiss", "Close"),
|
||||
Binding("enter", "open_selected", "Open Link"),
|
||||
Binding("j", "next_link", "Next"),
|
||||
Binding("k", "prev_link", "Previous"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
LinkPanel {
|
||||
align: right middle;
|
||||
}
|
||||
|
||||
LinkPanel #link-panel-container {
|
||||
dock: right;
|
||||
width: 50%;
|
||||
min-width: 60;
|
||||
max-width: 100;
|
||||
height: 100%;
|
||||
background: $surface;
|
||||
border: round $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
LinkPanel #link-panel-container:focus-within {
|
||||
border: round $accent;
|
||||
}
|
||||
|
||||
LinkPanel .link-panel-title {
|
||||
text-style: bold;
|
||||
padding: 0 0 1 0;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
LinkPanel .link-panel-hint {
|
||||
color: $text-muted;
|
||||
padding: 0 0 1 0;
|
||||
}
|
||||
|
||||
LinkPanel #link-list {
|
||||
height: 1fr;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
LinkPanel #link-list > ListItem {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
LinkPanel #link-list > ListItem:hover {
|
||||
background: $boost;
|
||||
}
|
||||
|
||||
LinkPanel #link-list > ListItem.-highlight {
|
||||
background: $accent 30%;
|
||||
}
|
||||
|
||||
LinkPanel .no-links-label {
|
||||
color: $text-muted;
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, links: List[LinkItem], **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.links = links
|
||||
self._mnemonic_map: dict[str, LinkItem] = {
|
||||
link.mnemonic: link for link in links if link.mnemonic
|
||||
}
|
||||
self._key_buffer: str = ""
|
||||
self._key_timer = None
|
||||
# Check if we have any multi-char mnemonics
|
||||
self._has_multi_char = any(len(m) > 1 for m in self._mnemonic_map.keys())
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="link-panel-container"):
|
||||
yield Label("\uf0c1 Links", classes="link-panel-title") # nf-fa-link
|
||||
yield Label(
|
||||
"j/k: navigate, enter: open, esc: close",
|
||||
classes="link-panel-hint",
|
||||
)
|
||||
|
||||
if self.links:
|
||||
with ListView(id="link-list"):
|
||||
for i, link in enumerate(self.links):
|
||||
yield ListItem(LinkListItem(link, i))
|
||||
else:
|
||||
yield Label("No links found in this message.", classes="no-links-label")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#link-panel-container").border_title = "Links"
|
||||
self.query_one(
|
||||
"#link-panel-container"
|
||||
).border_subtitle = f"{len(self.links)} found"
|
||||
if self.links:
|
||||
self.query_one("#link-list").focus()
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
"""Handle mnemonic key presses with buffering for multi-char mnemonics."""
|
||||
key = event.key.lower()
|
||||
|
||||
# Only buffer alphabetic keys
|
||||
if not key.isalpha() or len(key) != 1:
|
||||
return
|
||||
|
||||
# Cancel any pending timer
|
||||
if self._key_timer:
|
||||
self._key_timer.stop()
|
||||
self._key_timer = None
|
||||
|
||||
# Add key to buffer
|
||||
self._key_buffer += key
|
||||
|
||||
# Check for exact match with buffered keys
|
||||
if self._key_buffer in self._mnemonic_map:
|
||||
# If no multi-char mnemonics exist, open immediately
|
||||
if not self._has_multi_char:
|
||||
self._open_link(self._mnemonic_map[self._key_buffer])
|
||||
self._key_buffer = ""
|
||||
event.prevent_default()
|
||||
return
|
||||
|
||||
# Check if any longer mnemonic starts with our buffer
|
||||
has_longer_match = any(
|
||||
m.startswith(self._key_buffer) and len(m) > len(self._key_buffer)
|
||||
for m in self._mnemonic_map.keys()
|
||||
)
|
||||
|
||||
if has_longer_match:
|
||||
# Wait for possible additional keys
|
||||
self._key_timer = self.set_timer(0.4, self._flush_key_buffer)
|
||||
else:
|
||||
# No longer matches possible, open immediately
|
||||
self._open_link(self._mnemonic_map[self._key_buffer])
|
||||
self._key_buffer = ""
|
||||
|
||||
event.prevent_default()
|
||||
return
|
||||
|
||||
# Check if buffer could still match something
|
||||
could_match = any(
|
||||
m.startswith(self._key_buffer) for m in self._mnemonic_map.keys()
|
||||
)
|
||||
|
||||
if could_match:
|
||||
# Wait for more keys
|
||||
self._key_timer = self.set_timer(0.4, self._flush_key_buffer)
|
||||
event.prevent_default()
|
||||
else:
|
||||
# No possible match, clear buffer
|
||||
self._key_buffer = ""
|
||||
|
||||
def _flush_key_buffer(self) -> None:
|
||||
"""Called after timeout to process buffered keys."""
|
||||
self._key_timer = None
|
||||
|
||||
if self._key_buffer and self._key_buffer in self._mnemonic_map:
|
||||
self._open_link(self._mnemonic_map[self._key_buffer])
|
||||
|
||||
self._key_buffer = ""
|
||||
|
||||
def action_open_selected(self) -> None:
|
||||
"""Open the currently selected link."""
|
||||
if not self.links:
|
||||
return
|
||||
|
||||
try:
|
||||
link_list = self.query_one("#link-list", ListView)
|
||||
if link_list.index is not None and 0 <= link_list.index < len(self.links):
|
||||
self._open_link(self.links[link_list.index])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_next_link(self) -> None:
|
||||
"""Move to next link."""
|
||||
try:
|
||||
link_list = self.query_one("#link-list", ListView)
|
||||
if link_list.index is not None and link_list.index < len(self.links) - 1:
|
||||
link_list.index += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_prev_link(self) -> None:
|
||||
"""Move to previous link."""
|
||||
try:
|
||||
link_list = self.query_one("#link-list", ListView)
|
||||
if link_list.index is not None and link_list.index > 0:
|
||||
link_list.index -= 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _open_link(self, link: LinkItem) -> None:
|
||||
"""Open a link in the default browser."""
|
||||
try:
|
||||
webbrowser.open(link.url)
|
||||
self.app.notify(f"Opened: {link.short_display}", title="Link Opened")
|
||||
|
||||
# Only dismiss if configured to close on open
|
||||
config = get_config()
|
||||
if config.link_panel.close_on_open:
|
||||
self.dismiss()
|
||||
except Exception as e:
|
||||
self.app.notify(f"Failed to open link: {e}", severity="error")
|
||||
|
||||
@on(ListView.Selected)
|
||||
def on_list_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle list item selection (Enter key or click)."""
|
||||
if event.list_view.index is not None and 0 <= event.list_view.index < len(
|
||||
self.links
|
||||
):
|
||||
self._open_link(self.links[event.list_view.index])
|
||||
491
src/mail/screens/SearchPanel.py
Normal file
491
src/mail/screens/SearchPanel.py
Normal file
@@ -0,0 +1,491 @@
|
||||
"""Docked search panel for mail app with live search.
|
||||
|
||||
Provides a search input docked to the top of the window with:
|
||||
- Live search with 1 second debounce
|
||||
- Cancel button to restore previous state
|
||||
- Help button showing Himalaya search syntax
|
||||
- Date picker for date/before/after keywords
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.screen import ModalScreen
|
||||
from textual.timer import Timer
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Input, Label, Static
|
||||
from textual.suggester import SuggestFromList
|
||||
|
||||
from src.calendar.widgets.MonthCalendar import MonthCalendar
|
||||
|
||||
# Himalaya search keywords for autocomplete
|
||||
HIMALAYA_KEYWORDS = [
|
||||
"from ",
|
||||
"to ",
|
||||
"subject ",
|
||||
"body ",
|
||||
"date ",
|
||||
"before ",
|
||||
"after ",
|
||||
"flag ",
|
||||
"not ",
|
||||
"and ",
|
||||
"or ",
|
||||
"order by ",
|
||||
"order by date ",
|
||||
"order by date asc",
|
||||
"order by date desc",
|
||||
"order by from ",
|
||||
"order by to ",
|
||||
"order by subject ",
|
||||
"flag seen",
|
||||
"flag flagged",
|
||||
"not flag seen",
|
||||
]
|
||||
|
||||
HIMALAYA_SEARCH_HELP = """
|
||||
## Himalaya Search Query Syntax
|
||||
|
||||
A filter query is composed of operators and conditions:
|
||||
|
||||
### Operators
|
||||
- `not <condition>` - filter envelopes that do NOT match the condition
|
||||
- `<condition> and <condition>` - filter envelopes matching BOTH conditions
|
||||
- `<condition> or <condition>` - filter envelopes matching EITHER condition
|
||||
|
||||
### Conditions
|
||||
- `date <yyyy-mm-dd>` - match the given date
|
||||
- `before <yyyy-mm-dd>` - date strictly before the given date
|
||||
- `after <yyyy-mm-dd>` - date strictly after the given date
|
||||
- `from <pattern>` - senders matching the pattern
|
||||
- `to <pattern>` - recipients matching the pattern
|
||||
- `subject <pattern>` - subject matching the pattern
|
||||
- `body <pattern>` - text body matching the pattern
|
||||
- `flag <flag>` - envelopes with the given flag (e.g., `seen`, `flagged`)
|
||||
|
||||
### Examples
|
||||
- `from john` - emails from anyone named John
|
||||
- `subject meeting and after 2025-01-01` - meetings after Jan 1st
|
||||
- `not flag seen` - unread emails
|
||||
- `from boss or from manager` - emails from boss or manager
|
||||
- `body urgent and before 2025-12-01` - urgent emails before Dec 1st
|
||||
|
||||
### Sort Query
|
||||
Start with `order by`, followed by:
|
||||
- `date [asc|desc]`
|
||||
- `from [asc|desc]`
|
||||
- `to [asc|desc]`
|
||||
- `subject [asc|desc]`
|
||||
|
||||
Example: `from john order by date desc`
|
||||
""".strip()
|
||||
|
||||
|
||||
class SearchHelpModal(ModalScreen[None]):
|
||||
"""Modal showing Himalaya search syntax help."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SearchHelpModal {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
SearchHelpModal > Vertical {
|
||||
width: 80;
|
||||
max-width: 90%;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
border: solid $primary;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
SearchHelpModal > Vertical > Static {
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
SearchHelpModal > Vertical > Horizontal {
|
||||
height: auto;
|
||||
align: center middle;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "close", "Close"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
from textual.widgets import Markdown
|
||||
|
||||
with Vertical():
|
||||
yield Markdown(HIMALAYA_SEARCH_HELP)
|
||||
with Horizontal():
|
||||
yield Button("Close", variant="primary", id="close-btn")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "close-btn":
|
||||
self.dismiss(None)
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
class DatePickerModal(ModalScreen[Optional[date]]):
|
||||
"""Modal with a calendar for selecting a date."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DatePickerModal {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
DatePickerModal > Vertical {
|
||||
width: 30;
|
||||
height: auto;
|
||||
border: solid $primary;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
DatePickerModal > Vertical > Label {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
DatePickerModal > Vertical > Horizontal {
|
||||
height: auto;
|
||||
align: center middle;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
DatePickerModal > Vertical > Horizontal > Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("left", "prev_month", "Previous month", show=False),
|
||||
Binding("right", "next_month", "Next month", show=False),
|
||||
Binding("enter", "select_date", "Select date", show=False),
|
||||
]
|
||||
|
||||
def __init__(self, keyword: str = "date") -> None:
|
||||
super().__init__()
|
||||
self.keyword = keyword
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
yield Label(f"Select date for '{self.keyword}':", id="picker-title")
|
||||
yield MonthCalendar(id="date-picker-calendar")
|
||||
with Horizontal():
|
||||
yield Button("Today", variant="default", id="today-btn")
|
||||
yield Button("Select", variant="primary", id="select-btn")
|
||||
yield Button("Cancel", variant="warning", id="cancel-btn")
|
||||
|
||||
def on_month_calendar_date_selected(
|
||||
self, event: MonthCalendar.DateSelected
|
||||
) -> None:
|
||||
"""Handle date selection from calendar click."""
|
||||
self.dismiss(event.date)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "select-btn":
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
self.dismiss(calendar.selected_date)
|
||||
elif event.button.id == "today-btn":
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
today = date.today()
|
||||
calendar.selected_date = today
|
||||
calendar.display_month = today.replace(day=1)
|
||||
calendar.refresh()
|
||||
elif event.button.id == "cancel-btn":
|
||||
self.dismiss(None)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
def action_prev_month(self) -> None:
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
calendar.prev_month()
|
||||
|
||||
def action_next_month(self) -> None:
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
calendar.next_month()
|
||||
|
||||
def action_select_date(self) -> None:
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
self.dismiss(calendar.selected_date)
|
||||
|
||||
|
||||
class SearchPanel(Widget):
|
||||
"""Docked search panel with live search capability."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SearchPanel {
|
||||
dock: top;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
background: $surface;
|
||||
border-bottom: solid $primary;
|
||||
padding: 0 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
SearchPanel.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
SearchPanel > Horizontal {
|
||||
height: 3;
|
||||
width: 100%;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
SearchPanel .search-label {
|
||||
width: auto;
|
||||
padding: 0 1;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
SearchPanel Input {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
SearchPanel Button {
|
||||
width: auto;
|
||||
min-width: 8;
|
||||
margin: 0 0 0 1;
|
||||
}
|
||||
|
||||
SearchPanel .search-status {
|
||||
width: auto;
|
||||
padding: 0 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel search", show=False),
|
||||
]
|
||||
|
||||
# Reactive to track search state
|
||||
is_searching: reactive[bool] = reactive(False)
|
||||
result_count: reactive[int] = reactive(-1) # -1 = no search yet
|
||||
|
||||
class SearchRequested(Message):
|
||||
"""Fired when a search should be performed."""
|
||||
|
||||
def __init__(self, query: str) -> None:
|
||||
super().__init__()
|
||||
self.query = query
|
||||
|
||||
class SearchCancelled(Message):
|
||||
"""Fired when the search is cancelled."""
|
||||
|
||||
pass
|
||||
|
||||
class SearchConfirmed(Message):
|
||||
"""Fired when user presses Enter to confirm search and focus results."""
|
||||
|
||||
def __init__(self, query: str) -> None:
|
||||
super().__init__()
|
||||
self.query = query
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._debounce_timer: Optional[Timer] = None
|
||||
self._last_query: str = ""
|
||||
self._pending_date_keyword: Optional[str] = None # Track keyword awaiting date
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
yield Label("Search:", classes="search-label")
|
||||
yield Input(
|
||||
placeholder="from <name> or subject <text> or body <text>...",
|
||||
id="search-input",
|
||||
suggester=SuggestFromList(HIMALAYA_KEYWORDS, case_sensitive=False),
|
||||
)
|
||||
yield Label("", classes="search-status", id="search-status")
|
||||
yield Button("?", variant="default", id="help-btn")
|
||||
yield Button("Cancel", variant="warning", id="cancel-btn")
|
||||
|
||||
def _has_suggestion(self) -> bool:
|
||||
"""Check if the search input currently has an autocomplete suggestion."""
|
||||
try:
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
return bool(input_widget._suggestion and input_widget._cursor_at_end)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _accept_suggestion(self) -> bool:
|
||||
"""Accept the current autocomplete suggestion if present. Returns True if accepted."""
|
||||
try:
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
if input_widget._suggestion and input_widget._cursor_at_end:
|
||||
input_widget.value = input_widget._suggestion
|
||||
input_widget.cursor_position = len(input_widget.value)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
"""Handle key events to intercept Tab for autocomplete."""
|
||||
if event.key == "tab":
|
||||
# Try to accept suggestion; if successful, prevent default tab behavior
|
||||
if self._accept_suggestion():
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
def show(self, initial_query: str = "") -> None:
|
||||
"""Show the search panel and focus the input."""
|
||||
self.add_class("visible")
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
input_widget.value = initial_query
|
||||
self._last_query = initial_query
|
||||
input_widget.focus()
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide the search panel."""
|
||||
self.remove_class("visible")
|
||||
self._cancel_debounce()
|
||||
self.result_count = -1
|
||||
|
||||
def focus_input(self) -> None:
|
||||
"""Focus the search input field."""
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
input_widget.focus()
|
||||
|
||||
@property
|
||||
def is_visible(self) -> bool:
|
||||
"""Check if the panel is visible."""
|
||||
return self.has_class("visible")
|
||||
|
||||
@property
|
||||
def search_query(self) -> str:
|
||||
"""Get the current search query."""
|
||||
return self.query_one("#search-input", Input).value
|
||||
|
||||
def _cancel_debounce(self) -> None:
|
||||
"""Cancel any pending debounced search."""
|
||||
if self._debounce_timer:
|
||||
self._debounce_timer.stop()
|
||||
self._debounce_timer = None
|
||||
|
||||
def _trigger_search(self) -> None:
|
||||
"""Trigger the actual search after debounce."""
|
||||
# Don't search if an autocomplete suggestion is visible
|
||||
if self._has_suggestion():
|
||||
return
|
||||
|
||||
query = self.query_one("#search-input", Input).value.strip()
|
||||
if query and query != self._last_query:
|
||||
self._last_query = query
|
||||
self.is_searching = True
|
||||
self.post_message(self.SearchRequested(query))
|
||||
|
||||
def _check_date_keyword(self, value: str) -> Optional[str]:
|
||||
"""Check if the input ends with a date keyword that needs a date picker.
|
||||
|
||||
Returns the keyword (date/before/after) if found, None otherwise.
|
||||
"""
|
||||
value_lower = value.lower()
|
||||
for keyword in ("date ", "before ", "after "):
|
||||
if value_lower.endswith(keyword):
|
||||
return keyword.strip()
|
||||
return None
|
||||
|
||||
def _show_date_picker(self, keyword: str) -> None:
|
||||
"""Show the date picker modal for the given keyword."""
|
||||
self._pending_date_keyword = keyword
|
||||
|
||||
def on_date_selected(selected_date: Optional[date]) -> None:
|
||||
if selected_date:
|
||||
# Insert the date into the search input
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
date_str = selected_date.strftime("%Y-%m-%d")
|
||||
input_widget.value = input_widget.value + date_str
|
||||
input_widget.cursor_position = len(input_widget.value)
|
||||
self._pending_date_keyword = None
|
||||
# Refocus the input
|
||||
self.query_one("#search-input", Input).focus()
|
||||
|
||||
self.app.push_screen(DatePickerModal(keyword), on_date_selected)
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
"""Handle input changes with debounce."""
|
||||
if event.input.id != "search-input":
|
||||
return
|
||||
|
||||
# Cancel any existing timer
|
||||
self._cancel_debounce()
|
||||
|
||||
# Don't search empty queries
|
||||
if not event.value.strip():
|
||||
return
|
||||
|
||||
# Check for date keywords and show picker
|
||||
date_keyword = self._check_date_keyword(event.value)
|
||||
if date_keyword:
|
||||
self._show_date_picker(date_keyword)
|
||||
return
|
||||
|
||||
# Set up new debounce timer (1 second)
|
||||
self._debounce_timer = self.set_timer(1.0, self._trigger_search)
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle Enter key - trigger search immediately and confirm."""
|
||||
if event.input.id != "search-input":
|
||||
return
|
||||
|
||||
self._cancel_debounce()
|
||||
query = event.value.strip()
|
||||
if query:
|
||||
self._last_query = query
|
||||
self.is_searching = True
|
||||
self.post_message(self.SearchConfirmed(query))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "cancel-btn":
|
||||
self.action_cancel()
|
||||
elif event.button.id == "help-btn":
|
||||
self.app.push_screen(SearchHelpModal())
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel the search and restore previous state."""
|
||||
self._cancel_debounce()
|
||||
self.hide()
|
||||
self.post_message(self.SearchCancelled())
|
||||
|
||||
def update_status(self, count: int, searching: bool = False) -> None:
|
||||
"""Update the search status display."""
|
||||
self.is_searching = searching
|
||||
self.result_count = count
|
||||
|
||||
status = self.query_one("#search-status", Label)
|
||||
if searching:
|
||||
status.update("Searching...")
|
||||
elif count >= 0:
|
||||
status.update(f"{count} result{'s' if count != 1 else ''}")
|
||||
else:
|
||||
status.update("")
|
||||
|
||||
def watch_is_searching(self, searching: bool) -> None:
|
||||
"""Update UI when searching state changes."""
|
||||
status = self.query_one("#search-status", Label)
|
||||
if searching:
|
||||
status.update("Searching...")
|
||||
21
src/mail/screens/__init__.py
Normal file
21
src/mail/screens/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Initialize screens package
|
||||
from .CreateTask import CreateTaskScreen
|
||||
from .OpenMessage import OpenMessageScreen
|
||||
from .DocumentViewer import DocumentViewerScreen
|
||||
from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content
|
||||
from .ConfirmDialog import ConfirmDialog
|
||||
from .SearchPanel import SearchPanel, SearchHelpModal
|
||||
from .HelpScreen import HelpScreen
|
||||
|
||||
__all__ = [
|
||||
"CreateTaskScreen",
|
||||
"OpenMessageScreen",
|
||||
"DocumentViewerScreen",
|
||||
"LinkPanel",
|
||||
"LinkItem",
|
||||
"extract_links_from_content",
|
||||
"ConfirmDialog",
|
||||
"SearchPanel",
|
||||
"SearchHelpModal",
|
||||
"HelpScreen",
|
||||
]
|
||||
16
src/mail/utils/__init__.py
Normal file
16
src/mail/utils/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Mail utilities module."""
|
||||
|
||||
from .calendar_parser import (
|
||||
parse_calendar_part,
|
||||
parse_calendar_attachment,
|
||||
is_cancelled_event,
|
||||
is_event_request,
|
||||
ParsedCalendarEvent,
|
||||
)
|
||||
|
||||
from .apple_mail import (
|
||||
open_eml_in_apple_mail,
|
||||
compose_new_email,
|
||||
reply_to_email,
|
||||
forward_email,
|
||||
)
|
||||
255
src/mail/utils/apple_mail.py
Normal file
255
src/mail/utils/apple_mail.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Apple Mail integration utilities.
|
||||
|
||||
Provides functions for opening emails in Apple Mail and optionally
|
||||
auto-sending them via AppleScript.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def open_eml_in_apple_mail(
|
||||
email_content: str,
|
||||
auto_send: bool = False,
|
||||
subject: str = "",
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Open an email in Apple Mail, optionally auto-sending it.
|
||||
|
||||
Args:
|
||||
email_content: The raw email content (RFC 5322 format)
|
||||
auto_send: If True, automatically send the email after opening
|
||||
subject: Email subject for logging purposes
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
# Create a temp .eml file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".eml", delete=False, encoding="utf-8"
|
||||
) as tmp:
|
||||
tmp.write(email_content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
logger.info(f"Created temp .eml file: {tmp_path}")
|
||||
|
||||
# Open with Apple Mail
|
||||
result = subprocess.run(
|
||||
["open", "-a", "Mail", tmp_path], capture_output=True, text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to open Mail: {result.stderr}")
|
||||
return False, f"Failed to open Mail: {result.stderr}"
|
||||
|
||||
if auto_send:
|
||||
# Wait for Mail to open the message
|
||||
time.sleep(1.5)
|
||||
|
||||
# Use AppleScript to send the frontmost message
|
||||
success, message = _applescript_send_frontmost_message()
|
||||
if success:
|
||||
logger.info(f"Auto-sent email: {subject}")
|
||||
# Clean up temp file after sending
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
return True, "Email sent successfully"
|
||||
else:
|
||||
logger.warning(
|
||||
f"Auto-send failed, email opened for manual sending: {message}"
|
||||
)
|
||||
return True, f"Email opened (auto-send failed: {message})"
|
||||
else:
|
||||
logger.info(f"Opened email in Mail for manual sending: {subject}")
|
||||
return True, "Email opened in Mail - please send manually"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening email in Apple Mail: {e}", exc_info=True)
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
|
||||
def _applescript_send_frontmost_message() -> Tuple[bool, str]:
|
||||
"""
|
||||
Use AppleScript to send the frontmost message in Apple Mail.
|
||||
|
||||
When an .eml file is opened, Mail shows it as a "view" not a compose window.
|
||||
We need to use Message > Send Again to convert it to a compose window,
|
||||
then send it.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
# AppleScript to:
|
||||
# 1. Activate Mail
|
||||
# 2. Use "Send Again" menu item to convert viewed message to compose
|
||||
# 3. Send the message with Cmd+Shift+D
|
||||
applescript = """
|
||||
tell application "Mail"
|
||||
activate
|
||||
delay 0.3
|
||||
end tell
|
||||
|
||||
tell application "System Events"
|
||||
tell process "Mail"
|
||||
-- First, trigger "Send Again" from Message menu to convert to compose window
|
||||
-- Menu: Message > Send Again (Cmd+Shift+D also works for this in some contexts)
|
||||
try
|
||||
click menu item "Send Again" of menu "Message" of menu bar 1
|
||||
delay 0.5
|
||||
on error
|
||||
-- If Send Again fails, window might already be a compose window
|
||||
end try
|
||||
|
||||
-- Now send the message with Cmd+Shift+D
|
||||
keystroke "d" using {command down, shift down}
|
||||
delay 0.3
|
||||
|
||||
return "sent"
|
||||
end tell
|
||||
end tell
|
||||
"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", applescript], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.strip()
|
||||
if output == "sent":
|
||||
return True, "Message sent"
|
||||
else:
|
||||
return False, output
|
||||
else:
|
||||
return False, result.stderr.strip()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "AppleScript timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def compose_new_email(
|
||||
to: str = "",
|
||||
subject: str = "",
|
||||
body: str = "",
|
||||
auto_send: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Open a new compose window in Apple Mail using mailto: URL.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
body: Email body text
|
||||
auto_send: Ignored - no AppleScript automation for compose
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
import urllib.parse
|
||||
|
||||
try:
|
||||
# Build mailto: URL
|
||||
params = {}
|
||||
if subject:
|
||||
params["subject"] = subject
|
||||
if body:
|
||||
params["body"] = body
|
||||
|
||||
query_string = urllib.parse.urlencode(params)
|
||||
mailto_url = f"mailto:{to}"
|
||||
if query_string:
|
||||
mailto_url += f"?{query_string}"
|
||||
|
||||
# Open mailto: URL - this will open the default mail client
|
||||
result = subprocess.run(["open", mailto_url], capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to open mailto: {result.stderr}")
|
||||
return False, f"Failed to open compose: {result.stderr}"
|
||||
|
||||
return True, "Compose window opened"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error composing email: {e}", exc_info=True)
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def reply_to_email(
|
||||
original_message_path: str,
|
||||
reply_all: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Open an email in Apple Mail for the user to manually reply.
|
||||
|
||||
This just opens the .eml file in Mail. The user can then use
|
||||
Mail's Reply button (Cmd+R) or Reply All (Cmd+Shift+R) themselves.
|
||||
|
||||
Args:
|
||||
original_message_path: Path to the original .eml file
|
||||
reply_all: Ignored - user will manually choose reply type
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
# Just open the message in Mail - no AppleScript automation
|
||||
result = subprocess.run(
|
||||
["open", "-a", "Mail", original_message_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False, f"Failed to open message: {result.stderr}"
|
||||
|
||||
reply_type = "Reply All" if reply_all else "Reply"
|
||||
return (
|
||||
True,
|
||||
f"Message opened - use {reply_type} (Cmd+{'Shift+' if reply_all else ''}R)",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening message for reply: {e}", exc_info=True)
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def forward_email(original_message_path: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Open an email in Apple Mail for the user to manually forward.
|
||||
|
||||
This just opens the .eml file in Mail. The user can then use
|
||||
Mail's Forward button (Cmd+Shift+F) themselves.
|
||||
|
||||
Args:
|
||||
original_message_path: Path to the original .eml file
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
# Just open the message in Mail - no AppleScript automation
|
||||
result = subprocess.run(
|
||||
["open", "-a", "Mail", original_message_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False, f"Failed to open message: {result.stderr}"
|
||||
|
||||
return True, "Message opened - use Forward (Cmd+Shift+F)"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening message for forward: {e}", exc_info=True)
|
||||
return False, str(e)
|
||||
427
src/mail/utils/calendar_parser.py
Normal file
427
src/mail/utils/calendar_parser.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""Calendar ICS file parser utilities."""
|
||||
|
||||
import base64
|
||||
import re
|
||||
from typing import Optional, List
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
try:
|
||||
from icalendar import Calendar
|
||||
except ImportError:
|
||||
Calendar = None # type: ignore
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCalendarEvent:
|
||||
"""Parsed calendar event from ICS file."""
|
||||
|
||||
# Core event properties
|
||||
summary: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
start: Optional[datetime] = None
|
||||
end: Optional[datetime] = None
|
||||
all_day: bool = False
|
||||
|
||||
# Calendar method (REQUEST, CANCEL, REPLY, etc.)
|
||||
method: Optional[str] = None
|
||||
|
||||
# Organizer
|
||||
organizer_name: Optional[str] = None
|
||||
organizer_email: Optional[str] = None
|
||||
|
||||
# Attendees
|
||||
attendees: List[str] = field(default_factory=list)
|
||||
|
||||
# Status (CONFIRMED, TENTATIVE, CANCELLED)
|
||||
status: Optional[str] = None
|
||||
|
||||
# UID for matching with Graph API
|
||||
uid: Optional[str] = None
|
||||
|
||||
# Sequence number for iTIP REPLY
|
||||
sequence: int = 0
|
||||
|
||||
|
||||
def extract_ics_from_mime(raw_message: str) -> Optional[str]:
|
||||
"""Extract ICS calendar content from raw MIME message.
|
||||
|
||||
Looks for text/calendar parts and base64-decoded .ics attachments.
|
||||
|
||||
Args:
|
||||
raw_message: Full raw email in EML/MIME format
|
||||
|
||||
Returns:
|
||||
ICS content string if found, None otherwise
|
||||
"""
|
||||
# Pattern 1: Look for inline text/calendar content
|
||||
# Content-Type: text/calendar followed by the ICS content
|
||||
calendar_pattern = re.compile(
|
||||
r"Content-Type:\s*text/calendar[^\n]*\n"
|
||||
r"(?:Content-Transfer-Encoding:\s*(\w+)[^\n]*\n)?"
|
||||
r"(?:[^\n]+\n)*?" # Other headers
|
||||
r"\n" # Empty line before content
|
||||
r"(BEGIN:VCALENDAR.*?END:VCALENDAR)",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
match = calendar_pattern.search(raw_message)
|
||||
if match:
|
||||
encoding = match.group(1)
|
||||
ics_content = match.group(2)
|
||||
|
||||
if encoding and encoding.lower() == "base64":
|
||||
try:
|
||||
# Remove line breaks and decode
|
||||
ics_bytes = base64.b64decode(
|
||||
ics_content.replace("\n", "").replace("\r", "")
|
||||
)
|
||||
return ics_bytes.decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logging.debug(f"Failed to decode base64 ICS: {e}")
|
||||
else:
|
||||
return ics_content
|
||||
|
||||
# Pattern 2: Look for base64-encoded text/calendar
|
||||
base64_pattern = re.compile(
|
||||
r"Content-Type:\s*text/calendar[^\n]*\n"
|
||||
r"Content-Transfer-Encoding:\s*base64[^\n]*\n"
|
||||
r"(?:[^\n]+\n)*?" # Other headers
|
||||
r"\n" # Empty line before content
|
||||
r"([A-Za-z0-9+/=\s]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
match = base64_pattern.search(raw_message)
|
||||
if match:
|
||||
try:
|
||||
b64_content = (
|
||||
match.group(1).replace("\n", "").replace("\r", "").replace(" ", "")
|
||||
)
|
||||
ics_bytes = base64.b64decode(b64_content)
|
||||
return ics_bytes.decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logging.debug(f"Failed to decode base64 calendar: {e}")
|
||||
|
||||
# Pattern 3: Just look for raw VCALENDAR block
|
||||
vcal_pattern = re.compile(r"(BEGIN:VCALENDAR.*?END:VCALENDAR)", re.DOTALL)
|
||||
match = vcal_pattern.search(raw_message)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_ics_content(ics_content: str) -> Optional[ParsedCalendarEvent]:
|
||||
"""Parse ICS calendar content into a ParsedCalendarEvent.
|
||||
|
||||
Args:
|
||||
ics_content: Raw ICS/iCalendar content string
|
||||
|
||||
Returns:
|
||||
ParsedCalendarEvent if parsing succeeded, None otherwise
|
||||
"""
|
||||
if Calendar is None:
|
||||
logging.warning("icalendar library not installed, cannot parse ICS")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Handle bytes input
|
||||
if isinstance(ics_content, bytes):
|
||||
ics_content = ics_content.decode("utf-8", errors="replace")
|
||||
|
||||
calendar = Calendar.from_ical(ics_content)
|
||||
|
||||
# METHOD is a calendar-level property, not event-level
|
||||
method = str(calendar.get("method", "")).upper() or None
|
||||
|
||||
# Get first VEVENT component
|
||||
events = [c for c in calendar.walk() if c.name == "VEVENT"]
|
||||
if not events:
|
||||
logging.debug("No VEVENT found in calendar")
|
||||
return None
|
||||
|
||||
event = events[0]
|
||||
|
||||
# Extract organizer info
|
||||
organizer_name = None
|
||||
organizer_email = None
|
||||
organizer = event.get("organizer")
|
||||
if organizer:
|
||||
# Organizer can be a vCalAddress object
|
||||
organizer_name = (
|
||||
str(organizer.params.get("CN", ""))
|
||||
if hasattr(organizer, "params")
|
||||
else None
|
||||
)
|
||||
# Extract email from mailto: URI
|
||||
organizer_str = str(organizer)
|
||||
if organizer_str.lower().startswith("mailto:"):
|
||||
organizer_email = organizer_str[7:]
|
||||
else:
|
||||
organizer_email = organizer_str
|
||||
|
||||
# Extract attendees
|
||||
attendees = []
|
||||
attendee_list = event.get("attendee")
|
||||
if attendee_list:
|
||||
# Can be a single attendee or a list
|
||||
if not isinstance(attendee_list, list):
|
||||
attendee_list = [attendee_list]
|
||||
for att in attendee_list:
|
||||
att_str = str(att)
|
||||
if att_str.lower().startswith("mailto:"):
|
||||
att_email = att_str[7:]
|
||||
else:
|
||||
att_email = att_str
|
||||
att_name = (
|
||||
str(att.params.get("CN", "")) if hasattr(att, "params") else None
|
||||
)
|
||||
if att_name and att_email:
|
||||
attendees.append(f"{att_name} <{att_email}>")
|
||||
elif att_email:
|
||||
attendees.append(att_email)
|
||||
|
||||
# Extract start/end times
|
||||
start_dt = None
|
||||
end_dt = None
|
||||
all_day = False
|
||||
|
||||
dtstart = event.get("dtstart")
|
||||
if dtstart:
|
||||
dt_val = dtstart.dt
|
||||
if hasattr(dt_val, "hour"):
|
||||
start_dt = dt_val
|
||||
else:
|
||||
# Date only = all day event
|
||||
start_dt = dt_val
|
||||
all_day = True
|
||||
|
||||
dtend = event.get("dtend")
|
||||
if dtend:
|
||||
end_dt = dtend.dt
|
||||
|
||||
# Extract sequence number (defaults to 0)
|
||||
sequence = 0
|
||||
seq_val = event.get("sequence")
|
||||
if seq_val is not None:
|
||||
try:
|
||||
sequence = int(seq_val)
|
||||
except (ValueError, TypeError):
|
||||
sequence = 0
|
||||
|
||||
return ParsedCalendarEvent(
|
||||
summary=str(event.get("summary", "")) or None,
|
||||
location=str(event.get("location", "")) or None,
|
||||
description=str(event.get("description", "")) or None,
|
||||
start=start_dt,
|
||||
end=end_dt,
|
||||
all_day=all_day,
|
||||
method=method,
|
||||
organizer_name=organizer_name,
|
||||
organizer_email=organizer_email,
|
||||
attendees=attendees,
|
||||
status=str(event.get("status", "")).upper() or None,
|
||||
uid=str(event.get("uid", "")) or None,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error parsing calendar ICS: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _decode_mime_text(raw_message: str) -> str:
|
||||
"""Decode base64 text parts from MIME message.
|
||||
|
||||
Args:
|
||||
raw_message: Raw MIME message
|
||||
|
||||
Returns:
|
||||
Decoded text content
|
||||
"""
|
||||
decoded_parts = []
|
||||
|
||||
# Find and decode base64 text parts
|
||||
b64_pattern = re.compile(
|
||||
r"Content-Type:\s*text/(?:plain|html)[^\n]*\n"
|
||||
r"(?:[^\n]+\n)*?"
|
||||
r"Content-Transfer-Encoding:\s*base64[^\n]*\n"
|
||||
r"(?:[^\n]+\n)*?"
|
||||
r"\n"
|
||||
r"([A-Za-z0-9+/=\s]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
for match in b64_pattern.finditer(raw_message):
|
||||
try:
|
||||
b64_content = (
|
||||
match.group(1).replace("\n", "").replace("\r", "").replace(" ", "")
|
||||
)
|
||||
decoded = base64.b64decode(b64_content).decode("utf-8", errors="replace")
|
||||
decoded_parts.append(decoded)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "\n".join(decoded_parts) if decoded_parts else raw_message
|
||||
|
||||
|
||||
def extract_teams_meeting_info(raw_message: str) -> Optional[ParsedCalendarEvent]:
|
||||
"""Extract Teams meeting info from email body when no ICS is present.
|
||||
|
||||
This handles emails that contain Teams meeting details in the body
|
||||
but don't have an ICS calendar attachment.
|
||||
|
||||
Args:
|
||||
raw_message: Full raw email in EML/MIME format
|
||||
|
||||
Returns:
|
||||
ParsedCalendarEvent with Teams meeting info, or None if not a Teams meeting
|
||||
"""
|
||||
# Decode the message content
|
||||
content = _decode_mime_text(raw_message)
|
||||
content_lower = content.lower()
|
||||
|
||||
# Check if this is a Teams meeting email
|
||||
if (
|
||||
"microsoft teams" not in content_lower
|
||||
and "join the meeting" not in content_lower
|
||||
):
|
||||
return None
|
||||
|
||||
# Extract Teams meeting URL
|
||||
teams_url_pattern = re.compile(
|
||||
r"https://teams\.microsoft\.com/l/meetup-join/[^\s<>\"']+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
teams_url_match = teams_url_pattern.search(content)
|
||||
teams_url = teams_url_match.group(0) if teams_url_match else None
|
||||
|
||||
# Extract meeting ID
|
||||
meeting_id_pattern = re.compile(r"Meeting ID:\s*([\d\s]+)", re.IGNORECASE)
|
||||
meeting_id_match = meeting_id_pattern.search(content)
|
||||
meeting_id = meeting_id_match.group(1).strip() if meeting_id_match else None
|
||||
|
||||
# Extract subject from email headers
|
||||
subject = None
|
||||
subject_match = re.search(
|
||||
r"^Subject:\s*(.+)$", raw_message, re.MULTILINE | re.IGNORECASE
|
||||
)
|
||||
if subject_match:
|
||||
subject = subject_match.group(1).strip()
|
||||
|
||||
# Extract organizer from From header
|
||||
organizer_email = None
|
||||
organizer_name = None
|
||||
from_match = re.search(r"^From:\s*(.+)$", raw_message, re.MULTILINE | re.IGNORECASE)
|
||||
if from_match:
|
||||
from_value = from_match.group(1).strip()
|
||||
# Parse "Name <email>" format
|
||||
email_match = re.search(r"<([^>]+)>", from_value)
|
||||
if email_match:
|
||||
organizer_email = email_match.group(1)
|
||||
organizer_name = from_value.split("<")[0].strip().strip('"')
|
||||
else:
|
||||
organizer_email = from_value
|
||||
|
||||
# Create location string with Teams info
|
||||
location = teams_url if teams_url else "Microsoft Teams Meeting"
|
||||
if meeting_id:
|
||||
location = f"Teams Meeting (ID: {meeting_id})"
|
||||
|
||||
return ParsedCalendarEvent(
|
||||
summary=subject or "Teams Meeting",
|
||||
location=location,
|
||||
description=f"Join: {teams_url}" if teams_url else None,
|
||||
method="TEAMS", # Custom method to indicate this is extracted, not from ICS
|
||||
organizer_name=organizer_name,
|
||||
organizer_email=organizer_email,
|
||||
)
|
||||
|
||||
|
||||
def parse_calendar_from_raw_message(raw_message: str) -> Optional[ParsedCalendarEvent]:
|
||||
"""Extract and parse calendar event from raw email message.
|
||||
|
||||
First tries to extract ICS content from the message. If no ICS is found,
|
||||
falls back to extracting Teams meeting info from the email body.
|
||||
|
||||
Args:
|
||||
raw_message: Full raw email in EML/MIME format
|
||||
|
||||
Returns:
|
||||
ParsedCalendarEvent if found and parsed, None otherwise
|
||||
"""
|
||||
# First try to extract ICS content
|
||||
ics_content = extract_ics_from_mime(raw_message)
|
||||
if ics_content:
|
||||
event = parse_ics_content(ics_content)
|
||||
if event:
|
||||
return event
|
||||
|
||||
# Fall back to extracting Teams meeting info from body
|
||||
return extract_teams_meeting_info(raw_message)
|
||||
|
||||
|
||||
# Legacy function names for compatibility
|
||||
def parse_calendar_part(content: str) -> Optional[ParsedCalendarEvent]:
|
||||
"""Parse calendar MIME part content. Legacy wrapper for parse_ics_content."""
|
||||
return parse_ics_content(content)
|
||||
|
||||
|
||||
def parse_calendar_attachment(attachment_content: str) -> Optional[ParsedCalendarEvent]:
|
||||
"""Parse base64-encoded calendar file attachment."""
|
||||
try:
|
||||
decoded = base64.b64decode(attachment_content)
|
||||
return parse_ics_content(decoded.decode("utf-8", errors="replace"))
|
||||
except Exception as e:
|
||||
logging.error(f"Error decoding calendar attachment: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_cancelled_event(event: ParsedCalendarEvent) -> bool:
|
||||
"""Check if event is a cancellation."""
|
||||
return event.method == "CANCEL" or event.status == "CANCELLED"
|
||||
|
||||
|
||||
def is_event_request(event: ParsedCalendarEvent) -> bool:
|
||||
"""Check if event is an invite request."""
|
||||
return event.method == "REQUEST"
|
||||
|
||||
|
||||
def format_event_time(event: ParsedCalendarEvent) -> str:
|
||||
"""Format event time for display.
|
||||
|
||||
Returns a human-readable string like:
|
||||
- "Mon, Dec 30, 2025 2:00 PM - 3:00 PM"
|
||||
- "All day: Mon, Dec 30, 2025"
|
||||
"""
|
||||
if not event.start:
|
||||
return "Time not specified"
|
||||
|
||||
if event.all_day:
|
||||
if hasattr(event.start, "strftime"):
|
||||
return f"All day: {event.start.strftime('%a, %b %d, %Y')}"
|
||||
return f"All day: {event.start}"
|
||||
|
||||
try:
|
||||
start_str = (
|
||||
event.start.strftime("%a, %b %d, %Y %I:%M %p")
|
||||
if hasattr(event.start, "strftime")
|
||||
else str(event.start)
|
||||
)
|
||||
if event.end and hasattr(event.end, "strftime"):
|
||||
# Same day? Just show end time
|
||||
if (
|
||||
hasattr(event.start, "date")
|
||||
and hasattr(event.end, "date")
|
||||
and event.start.date() == event.end.date()
|
||||
):
|
||||
end_str = event.end.strftime("%I:%M %p")
|
||||
else:
|
||||
end_str = event.end.strftime("%a, %b %d, %Y %I:%M %p")
|
||||
return f"{start_str} - {end_str}"
|
||||
return start_str
|
||||
except Exception:
|
||||
return str(event.start)
|
||||
219
src/mail/widgets/CalendarInvitePanel.py
Normal file
219
src/mail/widgets/CalendarInvitePanel.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Calendar invite panel widget for displaying calendar event details with actions."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import Static, Button, Label
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
|
||||
from src.mail.utils.calendar_parser import (
|
||||
ParsedCalendarEvent,
|
||||
is_cancelled_event,
|
||||
is_event_request,
|
||||
format_event_time,
|
||||
)
|
||||
|
||||
|
||||
class CalendarInvitePanel(Vertical):
|
||||
"""Panel displaying calendar invite details with accept/decline/tentative actions.
|
||||
|
||||
This widget shows at the top of the ContentContainer when viewing a calendar email.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CalendarInvitePanel {
|
||||
height: auto;
|
||||
max-height: 14;
|
||||
padding: 1;
|
||||
margin-bottom: 1;
|
||||
background: $surface;
|
||||
border: solid $primary;
|
||||
}
|
||||
|
||||
CalendarInvitePanel.cancelled {
|
||||
border: solid $error;
|
||||
}
|
||||
|
||||
CalendarInvitePanel.request {
|
||||
border: solid $success;
|
||||
}
|
||||
|
||||
CalendarInvitePanel .event-badge {
|
||||
padding: 0 1;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
CalendarInvitePanel .event-badge.cancelled {
|
||||
background: $error;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
CalendarInvitePanel .event-badge.request {
|
||||
background: $success;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
CalendarInvitePanel .event-badge.reply {
|
||||
background: $warning;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
CalendarInvitePanel .event-title {
|
||||
text-style: bold;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
CalendarInvitePanel .event-detail {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
CalendarInvitePanel .action-buttons {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
CalendarInvitePanel .action-buttons Button {
|
||||
margin-right: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
class InviteAction(Message):
|
||||
"""Message sent when user takes an action on the invite."""
|
||||
|
||||
def __init__(self, action: str, event: ParsedCalendarEvent) -> None:
|
||||
self.action = action # "accept", "decline", "tentative"
|
||||
self.event = event
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event: ParsedCalendarEvent,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.event = event
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the calendar invite panel."""
|
||||
# Determine badge and styling based on method
|
||||
badge_text, badge_class = self._get_badge_info()
|
||||
|
||||
with Horizontal():
|
||||
yield Label(badge_text, classes=f"event-badge {badge_class}")
|
||||
yield Label(
|
||||
self.event.summary or "Calendar Event",
|
||||
classes="event-title",
|
||||
)
|
||||
|
||||
# Event time
|
||||
time_str = format_event_time(self.event)
|
||||
yield Static(f"\uf017 {time_str}", classes="event-detail") # nf-fa-clock_o
|
||||
|
||||
# Location if present
|
||||
if self.event.location:
|
||||
yield Static(
|
||||
f"\uf041 {self.event.location}", # nf-fa-map_marker
|
||||
classes="event-detail",
|
||||
)
|
||||
|
||||
# Organizer
|
||||
if self.event.organizer_name or self.event.organizer_email:
|
||||
organizer = self.event.organizer_name or self.event.organizer_email
|
||||
yield Static(
|
||||
f"\uf007 {organizer}", # nf-fa-user
|
||||
classes="event-detail",
|
||||
)
|
||||
|
||||
# Attendees count
|
||||
if self.event.attendees:
|
||||
count = len(self.event.attendees)
|
||||
yield Static(
|
||||
f"\uf0c0 {count} attendee{'s' if count != 1 else ''}", # nf-fa-users
|
||||
classes="event-detail",
|
||||
)
|
||||
|
||||
# Action buttons (only for REQUEST method, not for CANCEL or TEAMS)
|
||||
if is_event_request(self.event):
|
||||
with Horizontal(classes="action-buttons"):
|
||||
yield Button(
|
||||
"\uf00c Accept", # nf-fa-check
|
||||
id="btn-accept",
|
||||
variant="success",
|
||||
)
|
||||
yield Button(
|
||||
"? Tentative",
|
||||
id="btn-tentative",
|
||||
variant="warning",
|
||||
)
|
||||
yield Button(
|
||||
"\uf00d Decline", # nf-fa-times
|
||||
id="btn-decline",
|
||||
variant="error",
|
||||
)
|
||||
elif self.event.method == "TEAMS":
|
||||
# Teams meeting extracted from email body (no ICS)
|
||||
# Show join button if we have a URL in the description
|
||||
if self.event.description and "Join:" in self.event.description:
|
||||
with Horizontal(classes="action-buttons"):
|
||||
yield Button(
|
||||
"\uf0c1 Join Meeting", # nf-fa-link
|
||||
id="btn-join",
|
||||
variant="primary",
|
||||
)
|
||||
yield Static(
|
||||
"[dim]Teams meeting - no calendar invite attached[/dim]",
|
||||
classes="event-detail",
|
||||
)
|
||||
elif is_cancelled_event(self.event):
|
||||
yield Static(
|
||||
"[dim]This meeting has been cancelled[/dim]",
|
||||
classes="event-detail",
|
||||
)
|
||||
|
||||
def _get_badge_info(self) -> tuple[str, str]:
|
||||
"""Get badge text and CSS class based on event method."""
|
||||
method = self.event.method or ""
|
||||
|
||||
if method == "CANCEL" or self.event.status == "CANCELLED":
|
||||
return "CANCELLED", "cancelled"
|
||||
elif method == "REQUEST":
|
||||
return "INVITE", "request"
|
||||
elif method == "TEAMS":
|
||||
return "TEAMS", "request"
|
||||
elif method == "REPLY":
|
||||
return "REPLY", "reply"
|
||||
elif method == "COUNTER":
|
||||
return "COUNTER", "reply"
|
||||
else:
|
||||
return "EVENT", ""
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Apply styling based on event type."""
|
||||
if is_cancelled_event(self.event):
|
||||
self.add_class("cancelled")
|
||||
elif is_event_request(self.event):
|
||||
self.add_class("request")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle action button presses."""
|
||||
button_id = event.button.id
|
||||
|
||||
if button_id == "btn-accept":
|
||||
self.post_message(self.InviteAction("accept", self.event))
|
||||
elif button_id == "btn-tentative":
|
||||
self.post_message(self.InviteAction("tentative", self.event))
|
||||
elif button_id == "btn-decline":
|
||||
self.post_message(self.InviteAction("decline", self.event))
|
||||
elif button_id == "btn-join":
|
||||
# Open Teams meeting URL
|
||||
if self.event.description and "Join:" in self.event.description:
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
url_match = re.search(
|
||||
r"Join:\s*(https://[^\s]+)", self.event.description
|
||||
)
|
||||
if url_match:
|
||||
url = url_match.group(1)
|
||||
subprocess.run(["open", url], capture_output=True)
|
||||
self.app.notify("Opening Teams meeting...", severity="information")
|
||||
946
src/mail/widgets/ContentContainer.py
Normal file
946
src/mail/widgets/ContentContainer.py
Normal file
@@ -0,0 +1,946 @@
|
||||
from markitdown import MarkItDown
|
||||
from textual import work
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical, ScrollableContainer
|
||||
from textual.widgets import Static, Markdown, Label
|
||||
from textual.reactive import reactive
|
||||
from src.services.himalaya import client as himalaya_client
|
||||
from src.mail.config import get_config
|
||||
from src.mail.screens.LinkPanel import (
|
||||
extract_links_from_content,
|
||||
LinkItem,
|
||||
LinkItem as LinkItemClass,
|
||||
)
|
||||
from src.mail.notification_compressor import create_compressor
|
||||
from src.mail.notification_detector import NotificationType, is_calendar_email
|
||||
from src.mail.utils.calendar_parser import (
|
||||
ParsedCalendarEvent,
|
||||
parse_calendar_from_raw_message,
|
||||
parse_ics_content,
|
||||
)
|
||||
from src.mail.widgets.CalendarInvitePanel import CalendarInvitePanel
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Literal, List, Dict, Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the parent directory to the system path to resolve relative imports
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def compress_urls_in_content(content: str, max_url_len: int = 50) -> str:
|
||||
"""Compress long URLs in markdown/text content for better readability.
|
||||
|
||||
Replaces long URLs with shortened versions using the same algorithm
|
||||
as LinkPanel._shorten_url. Preserves markdown link syntax.
|
||||
|
||||
Args:
|
||||
content: The markdown/text content to process
|
||||
max_url_len: Maximum length for displayed URLs (default 50)
|
||||
|
||||
Returns:
|
||||
Content with compressed URLs
|
||||
"""
|
||||
|
||||
# Pattern for markdown links: [text](url)
|
||||
def replace_md_link(match):
|
||||
anchor_text = match.group(1)
|
||||
url = match.group(2)
|
||||
|
||||
# Don't compress if URL is already short
|
||||
if len(url) <= max_url_len:
|
||||
return match.group(0)
|
||||
|
||||
# Use LinkItem's shortening algorithm
|
||||
short_url = LinkItemClass._shorten_url(
|
||||
url,
|
||||
urlparse(url).netloc.replace("www.", ""),
|
||||
urlparse(url).path,
|
||||
max_url_len,
|
||||
)
|
||||
|
||||
# Keep original anchor text, but if it's the same as URL, use short version
|
||||
if anchor_text == url or anchor_text.startswith("http"):
|
||||
return f"[\uf0c1 {short_url}]({url})"
|
||||
else:
|
||||
return match.group(0) # Keep original if anchor text is meaningful
|
||||
|
||||
# Pattern for bare URLs (not inside markdown links)
|
||||
def replace_bare_url(match):
|
||||
url = match.group(0)
|
||||
|
||||
# Don't compress if URL is already short
|
||||
if len(url) <= max_url_len:
|
||||
return url
|
||||
|
||||
parsed = urlparse(url)
|
||||
short_url = LinkItemClass._shorten_url(
|
||||
url, parsed.netloc.replace("www.", ""), parsed.path, max_url_len
|
||||
)
|
||||
|
||||
# Return as markdown link with icon
|
||||
return f"[\uf0c1 {short_url}]({url})"
|
||||
|
||||
# First, process markdown links
|
||||
md_link_pattern = r"\[([^\]]+)\]\((https?://[^)]+)\)"
|
||||
content = re.sub(md_link_pattern, replace_md_link, content)
|
||||
|
||||
# Then process bare URLs that aren't already in markdown links
|
||||
# This regex matches URLs not preceded by ]( which would indicate markdown link
|
||||
bare_url_pattern = r'(?<!\]\()https?://[^\s<>"\'\)]+[^\s<>"\'\.\,\)\]]'
|
||||
|
||||
# Use a more careful approach to avoid double-processing
|
||||
# Split content, process bare URLs, rejoin
|
||||
result = []
|
||||
last_end = 0
|
||||
|
||||
for match in re.finditer(bare_url_pattern, content):
|
||||
# Check if this URL is inside a markdown link (preceded by "](")
|
||||
prefix_start = max(0, match.start() - 2)
|
||||
prefix = content[prefix_start : match.start()]
|
||||
if prefix.endswith("]("):
|
||||
continue # Skip URLs that are already markdown link targets
|
||||
|
||||
result.append(content[last_end : match.start()])
|
||||
result.append(replace_bare_url(match))
|
||||
last_end = match.end()
|
||||
|
||||
result.append(content[last_end:])
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
class EnvelopeHeader(ScrollableContainer):
|
||||
"""Email envelope header with compressible To/CC fields.
|
||||
|
||||
Scrollable when in full-headers mode to handle long recipient lists.
|
||||
"""
|
||||
|
||||
# Maximum recipients to show before truncating
|
||||
MAX_RECIPIENTS_SHOWN = 2
|
||||
# Show full headers when toggled
|
||||
show_full_headers: bool = False
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.subject_label = Label("", id="header_subject")
|
||||
self.from_label = Label("", id="header_from")
|
||||
self.to_label = Label("", id="header_to")
|
||||
self.date_label = Label("", id="header_date")
|
||||
self.cc_label = Label("", id="header_cc")
|
||||
# Store full values for toggle
|
||||
self._full_subject = ""
|
||||
self._full_to = ""
|
||||
self._full_cc = ""
|
||||
self._full_from = ""
|
||||
|
||||
def on_mount(self):
|
||||
self.mount(self.subject_label)
|
||||
self.mount(self.from_label)
|
||||
self.mount(self.to_label)
|
||||
self.mount(self.cc_label)
|
||||
self.mount(self.date_label)
|
||||
# Add bottom margin to subject for visual separation from metadata
|
||||
self.subject_label.styles.margin = (0, 0, 1, 0)
|
||||
# Hide CC label by default (shown when CC is present)
|
||||
self.cc_label.styles.display = "none"
|
||||
# Set initial placeholder content
|
||||
self.subject_label.update("[dim]Select a message to view[/dim]")
|
||||
self.from_label.update("[b]From:[/b] -")
|
||||
self.to_label.update("[b]To:[/b] -")
|
||||
self.date_label.update("[b]Date:[/b] -")
|
||||
|
||||
def _compress_recipients(self, recipients_str: str, max_shown: int = 2) -> str:
|
||||
"""Compress a list of recipients to a single line with truncation.
|
||||
|
||||
Args:
|
||||
recipients_str: Comma-separated list of recipients
|
||||
max_shown: Maximum number of recipients to show
|
||||
|
||||
Returns:
|
||||
Compressed string like "Alice, Bob... (+15 more)"
|
||||
"""
|
||||
if not recipients_str:
|
||||
return ""
|
||||
|
||||
# Split by comma, handling "Name <email>" format
|
||||
# Use regex to split on ", " only when not inside < >
|
||||
parts = []
|
||||
current = ""
|
||||
in_angle = False
|
||||
for char in recipients_str:
|
||||
if char == "<":
|
||||
in_angle = True
|
||||
elif char == ">":
|
||||
in_angle = False
|
||||
elif char == "," and not in_angle:
|
||||
if current.strip():
|
||||
parts.append(current.strip())
|
||||
current = ""
|
||||
continue
|
||||
current += char
|
||||
if current.strip():
|
||||
parts.append(current.strip())
|
||||
|
||||
total = len(parts)
|
||||
|
||||
if total <= max_shown:
|
||||
return recipients_str
|
||||
|
||||
# Extract short names from first few recipients
|
||||
short_names = []
|
||||
for part in parts[:max_shown]:
|
||||
# Handle "Last, First <email>" or just "email@example.com"
|
||||
if "<" in part:
|
||||
name = part.split("<")[0].strip()
|
||||
if name:
|
||||
# Get first name for brevity (handle "Last, First" format)
|
||||
if "," in name:
|
||||
# "Last, First" -> "First"
|
||||
name_parts = name.split(",")
|
||||
if len(name_parts) >= 2:
|
||||
name = name_parts[1].strip().split()[0]
|
||||
else:
|
||||
name = name_parts[0].strip()
|
||||
else:
|
||||
# "First Last" -> "First"
|
||||
name = name.split()[0]
|
||||
short_names.append(name)
|
||||
else:
|
||||
# No name, use email local part
|
||||
email = part.split("<")[1].rstrip(">").split("@")[0]
|
||||
short_names.append(email)
|
||||
else:
|
||||
# Just email address
|
||||
short_names.append(part.split("@")[0])
|
||||
|
||||
remaining = total - max_shown
|
||||
return f"{', '.join(short_names)}... (+{remaining} more)"
|
||||
|
||||
def toggle_full_headers(self) -> None:
|
||||
"""Toggle between compressed and full header view."""
|
||||
self.show_full_headers = not self.show_full_headers
|
||||
# Update CSS class for styling
|
||||
if self.show_full_headers:
|
||||
self.add_class("full-headers")
|
||||
else:
|
||||
self.remove_class("full-headers")
|
||||
self._refresh_display()
|
||||
|
||||
def _refresh_display(self) -> None:
|
||||
"""Refresh the display based on current mode."""
|
||||
if self.show_full_headers:
|
||||
# Full view - show complete text
|
||||
self.subject_label.update(
|
||||
f"[b bright_white]{self._full_subject}[/b bright_white]"
|
||||
)
|
||||
self.from_label.update(f"[b]From:[/b] {self._full_from}")
|
||||
self.to_label.update(f"[b]To:[/b] {self._full_to}")
|
||||
if self._full_cc:
|
||||
self.cc_label.update(f"[b]CC:[/b] {self._full_cc}")
|
||||
self.cc_label.styles.display = "block"
|
||||
else:
|
||||
# Compressed view - truncate for single line display
|
||||
self.subject_label.update(
|
||||
f"[b bright_white]{self._full_subject}[/b bright_white]"
|
||||
)
|
||||
self.from_label.update(
|
||||
f"[b]From:[/b] {self._compress_recipients(self._full_from, max_shown=1)}"
|
||||
)
|
||||
self.to_label.update(
|
||||
f"[b]To:[/b] {self._compress_recipients(self._full_to)}"
|
||||
)
|
||||
if self._full_cc:
|
||||
self.cc_label.update(
|
||||
f"[b]CC:[/b] {self._compress_recipients(self._full_cc)}"
|
||||
)
|
||||
self.cc_label.styles.display = "block"
|
||||
|
||||
def update(self, subject, from_, to, date, cc=None):
|
||||
# Store full values
|
||||
self._full_subject = subject or ""
|
||||
self._full_from = from_ or ""
|
||||
self._full_to = to or ""
|
||||
self._full_cc = cc or ""
|
||||
|
||||
# Format the date for better readability
|
||||
if date:
|
||||
try:
|
||||
# Try to convert the date string to a datetime object
|
||||
date_obj = datetime.fromisoformat(date.replace("Z", "+00:00"))
|
||||
formatted_date = date_obj.strftime("%a, %d %b %Y %H:%M:%S %Z")
|
||||
self.date_label.update(f"[b]Date:[/b] {formatted_date}")
|
||||
except (ValueError, TypeError):
|
||||
# If parsing fails, just use the original date string
|
||||
self.date_label.update(f"[b]Date:[/b] {date}")
|
||||
else:
|
||||
self.date_label.update("[b]Date:[/b] Unknown")
|
||||
|
||||
if not cc:
|
||||
self.cc_label.styles.display = "none"
|
||||
|
||||
# Apply current display mode
|
||||
self._refresh_display()
|
||||
|
||||
|
||||
class ContentContainer(Vertical):
|
||||
"""Container for displaying email content with toggleable view modes.
|
||||
|
||||
Uses a Vertical layout with:
|
||||
- EnvelopeHeader (fixed at top, non-scrolling)
|
||||
- ScrollableContainer for the message content (scrollable)
|
||||
"""
|
||||
|
||||
can_focus = True
|
||||
|
||||
# Reactive to track view mode and update UI
|
||||
current_mode: reactive[Literal["markdown", "html"]] = reactive("markdown")
|
||||
|
||||
BINDINGS = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.md = MarkItDown()
|
||||
self.header = EnvelopeHeader(id="envelope_header")
|
||||
self.scroll_container = ScrollableContainer(id="content_scroll")
|
||||
self.content = Markdown("", id="markdown_content")
|
||||
self.html_content = Static("", id="html_content", markup=False)
|
||||
self.current_content = None
|
||||
self.current_raw_content = None # Store original uncompressed content
|
||||
self.current_message_id = None
|
||||
self.current_folder: str | None = None
|
||||
self.current_account: str | None = None
|
||||
self.content_worker = None
|
||||
self.current_envelope: Optional[Dict[str, Any]] = None
|
||||
self.current_notification_type: Optional[NotificationType] = None
|
||||
self.is_compressed_view: bool = False
|
||||
self.compression_enabled: bool = True # Toggle for compression on/off
|
||||
|
||||
# Calendar invite state
|
||||
self.calendar_panel: Optional[CalendarInvitePanel] = None
|
||||
self.current_calendar_event: Optional[ParsedCalendarEvent] = None
|
||||
|
||||
# Load default view mode and notification compression from config
|
||||
config = get_config()
|
||||
self.current_mode = config.content_display.default_view_mode
|
||||
self.compressor = create_compressor(
|
||||
config.content_display.notification_compression_mode
|
||||
)
|
||||
|
||||
def compose(self):
|
||||
yield self.header
|
||||
with self.scroll_container:
|
||||
yield self.content
|
||||
yield self.html_content
|
||||
|
||||
def on_mount(self):
|
||||
# Set initial display based on config default
|
||||
self._apply_view_mode()
|
||||
self._update_mode_indicator()
|
||||
|
||||
def watch_current_mode(self, old_mode: str, new_mode: str) -> None:
|
||||
"""React to mode changes."""
|
||||
self._apply_view_mode()
|
||||
self._update_mode_indicator()
|
||||
|
||||
def _apply_view_mode(self) -> None:
|
||||
"""Apply the current view mode to widget visibility."""
|
||||
if self.current_mode == "markdown":
|
||||
self.html_content.styles.display = "none"
|
||||
self.content.styles.display = "block"
|
||||
else:
|
||||
self.content.styles.display = "none"
|
||||
self.html_content.styles.display = "block"
|
||||
|
||||
def _update_mode_indicator(self) -> None:
|
||||
"""Update the border subtitle to show current mode."""
|
||||
if self.current_mode == "markdown":
|
||||
if self.is_compressed_view:
|
||||
mode_label = "Compressed"
|
||||
mode_icon = "\uf066" # nf-fa-compress
|
||||
else:
|
||||
mode_label = "Markdown"
|
||||
mode_icon = "\ue73e" # nf-md-language_markdown
|
||||
else:
|
||||
mode_label = "HTML/Text"
|
||||
mode_icon = "\uf121" # nf-fa-code
|
||||
self.border_subtitle = f"{mode_icon} {mode_label}"
|
||||
|
||||
async def action_toggle_mode(self):
|
||||
"""Toggle between viewing modes.
|
||||
|
||||
For notification emails: cycles compressed → full markdown → HTML → compressed
|
||||
For regular emails: cycles between markdown and HTML.
|
||||
"""
|
||||
# Check if this is a compressible notification email
|
||||
is_notification = (
|
||||
self.compressor.mode != "off"
|
||||
and self.current_envelope
|
||||
and self.compressor.should_compress(self.current_envelope)
|
||||
)
|
||||
|
||||
if is_notification:
|
||||
# Three-state cycle for notifications: compressed → full → html → compressed
|
||||
if self.current_mode == "markdown" and self.compression_enabled:
|
||||
# Currently compressed markdown → show full markdown
|
||||
self.compression_enabled = False
|
||||
# Don't change mode, just re-display with compression off
|
||||
elif self.current_mode == "markdown" and not self.compression_enabled:
|
||||
# Currently full markdown → switch to HTML
|
||||
self.current_mode = "html"
|
||||
else:
|
||||
# Currently HTML → back to compressed markdown
|
||||
self.current_mode = "markdown"
|
||||
self.compression_enabled = True
|
||||
else:
|
||||
# Simple two-state toggle for regular emails
|
||||
if self.current_mode == "html":
|
||||
self.current_mode = "markdown"
|
||||
else:
|
||||
self.current_mode = "html"
|
||||
|
||||
# Re-display content with new mode/compression settings
|
||||
if self.current_raw_content is not None:
|
||||
# Use cached raw content instead of re-fetching
|
||||
self._update_content(self.current_raw_content)
|
||||
self._apply_view_mode()
|
||||
self._update_mode_indicator()
|
||||
elif self.current_message_id:
|
||||
# Fall back to re-fetching if no cached content
|
||||
self.display_content(
|
||||
self.current_message_id,
|
||||
folder=self.current_folder,
|
||||
account=self.current_account,
|
||||
envelope=self.current_envelope,
|
||||
)
|
||||
|
||||
def update_header(self, subject, from_, to, date, cc=None):
|
||||
self.header.update(subject, from_, to, date, cc)
|
||||
|
||||
@work(exclusive=True)
|
||||
async def fetch_message_content(self, message_id: int, format: str):
|
||||
"""Fetch message content using the Himalaya client module."""
|
||||
if not message_id:
|
||||
self.notify("No message ID provided.")
|
||||
return
|
||||
|
||||
# Always fetch raw message first to check for calendar data
|
||||
# This allows us to detect calendar invites even in forwarded emails
|
||||
raw_content, raw_success = await himalaya_client.get_raw_message(
|
||||
message_id, folder=self.current_folder, account=self.current_account
|
||||
)
|
||||
|
||||
# Check if this is a calendar email (using envelope + content for better detection)
|
||||
is_calendar = self.current_envelope and is_calendar_email(
|
||||
self.current_envelope, content=raw_content if raw_success else None
|
||||
)
|
||||
|
||||
calendar_event = None
|
||||
if is_calendar and raw_success and raw_content:
|
||||
calendar_event = parse_calendar_from_raw_message(raw_content)
|
||||
|
||||
# If attachments were skipped during sync, try to fetch ICS from Graph API
|
||||
# This handles both:
|
||||
# 1. TEAMS method (Teams meeting detected but no ICS in message)
|
||||
# 2. No calendar_event parsed but we detected calendar email patterns
|
||||
if "X-Attachments-Skipped" in raw_content:
|
||||
should_fetch_ics = (
|
||||
# No calendar event parsed at all
|
||||
calendar_event is None
|
||||
# Or we got a TEAMS fallback (no real ICS found)
|
||||
or calendar_event.method == "TEAMS"
|
||||
)
|
||||
|
||||
if should_fetch_ics:
|
||||
# Try to fetch ICS from Graph API
|
||||
ics_content = await self._fetch_ics_from_graph(raw_content)
|
||||
if ics_content:
|
||||
# Re-parse with the actual ICS
|
||||
real_event = parse_ics_content(ics_content)
|
||||
if real_event:
|
||||
calendar_event = real_event
|
||||
|
||||
if calendar_event:
|
||||
self._show_calendar_panel(calendar_event)
|
||||
else:
|
||||
self._hide_calendar_panel()
|
||||
else:
|
||||
self._hide_calendar_panel()
|
||||
|
||||
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}.")
|
||||
|
||||
async def _fetch_ics_from_graph(self, raw_content: str) -> str | None:
|
||||
"""Fetch ICS attachment from Graph API using the message ID in headers.
|
||||
|
||||
Args:
|
||||
raw_content: Raw MIME content containing Message-ID header
|
||||
|
||||
Returns:
|
||||
ICS content string if found, None otherwise
|
||||
"""
|
||||
import re
|
||||
|
||||
# Extract Graph message ID from Message-ID header
|
||||
# Format: Message-ID: \n AAkALg...
|
||||
match = re.search(
|
||||
r"Message-ID:\s*\n?\s*([A-Za-z0-9+/=-]+)",
|
||||
raw_content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
graph_message_id = match.group(1).strip()
|
||||
|
||||
try:
|
||||
# Get auth headers
|
||||
from src.services.microsoft_graph.auth import get_access_token
|
||||
from src.services.microsoft_graph.mail import fetch_message_ics_attachment
|
||||
|
||||
# Use Mail.Read scope for reading attachments
|
||||
scopes = ["https://graph.microsoft.com/Mail.Read"]
|
||||
token, _ = get_access_token(scopes)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
ics_content, success = await fetch_message_ics_attachment(
|
||||
graph_message_id, headers
|
||||
)
|
||||
|
||||
if success and ics_content:
|
||||
return ics_content
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error fetching ICS from Graph: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def display_content(
|
||||
self,
|
||||
message_id: int,
|
||||
folder: str | None = None,
|
||||
account: str | None = None,
|
||||
envelope: Optional[Dict[str, Any]] = 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
|
||||
self.current_envelope = envelope
|
||||
|
||||
# Reset compression state for new message (start with compression enabled)
|
||||
self.compression_enabled = True
|
||||
self.current_raw_content = None
|
||||
|
||||
# Update the header with envelope data
|
||||
if envelope:
|
||||
subject = envelope.get("subject", "")
|
||||
|
||||
# Extract from - can be dict with name/addr or string
|
||||
from_info = envelope.get("from", {})
|
||||
if isinstance(from_info, dict):
|
||||
from_name = from_info.get("name") or ""
|
||||
from_addr = from_info.get("addr") or ""
|
||||
if from_name and from_addr:
|
||||
from_str = f"{from_name} <{from_addr}>"
|
||||
elif from_name:
|
||||
from_str = from_name
|
||||
else:
|
||||
from_str = from_addr
|
||||
else:
|
||||
from_str = str(from_info)
|
||||
|
||||
# Extract to - can be dict, list of dicts, or string
|
||||
to_info = envelope.get("to", {})
|
||||
to_str = self._format_recipients(to_info)
|
||||
|
||||
# Extract cc - can be dict, list of dicts, or string
|
||||
cc_info = envelope.get("cc", {})
|
||||
cc_str = self._format_recipients(cc_info) if cc_info else None
|
||||
|
||||
date = envelope.get("date", "")
|
||||
|
||||
self.header.update(subject, from_str, to_str, date, cc_str)
|
||||
|
||||
# Immediately show a loading message
|
||||
if self.current_mode == "markdown":
|
||||
self.content.update("Loading...")
|
||||
else:
|
||||
self.html_content.update("Loading...")
|
||||
|
||||
# Cancel any existing content fetch operations
|
||||
if self.content_worker:
|
||||
self.content_worker.cancel()
|
||||
|
||||
# Fetch content in the current mode
|
||||
format_type = "text" if self.current_mode == "markdown" else "html"
|
||||
self.content_worker = self.fetch_message_content(message_id, format_type)
|
||||
|
||||
def _strip_headers_from_content(self, content: str) -> str:
|
||||
"""Strip email headers and multipart MIME noise from content.
|
||||
|
||||
Email content from himalaya may include:
|
||||
1. Headers at the top (From, To, Subject, etc.) - shown in EnvelopeHeader
|
||||
2. Additional full headers after a blank line (Received, etc.)
|
||||
3. MIME multipart boundaries and part headers
|
||||
4. Base64 encoded content (attachments, calendar data)
|
||||
|
||||
This extracts just the readable plain text content.
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
result_lines = []
|
||||
in_base64_block = False
|
||||
in_calendar_block = False
|
||||
in_header_block = True # Start assuming we're in headers
|
||||
|
||||
# Common email header patterns (case insensitive)
|
||||
header_pattern = re.compile(
|
||||
r"^(From|To|Subject|Date|CC|BCC|Reply-To|Message-ID|Received|"
|
||||
r"Content-Type|Content-Transfer-Encoding|Content-Disposition|"
|
||||
r"Content-Language|MIME-Version|Thread-Topic|Thread-Index|"
|
||||
r"Importance|X-Priority|Accept-Language|X-MS-|x-ms-|"
|
||||
r"x-microsoft-|x-forefront-|authentication-results).*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Skip MIME boundary lines (--boundary or --boundary--)
|
||||
if stripped.startswith("--") and len(stripped) > 10:
|
||||
in_base64_block = False
|
||||
in_header_block = False # After boundary, might be content
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for Content-Type to detect base64/calendar sections
|
||||
if stripped.lower().startswith("content-type:"):
|
||||
if (
|
||||
"base64" in stripped.lower() or "base64" in lines[i + 1].lower()
|
||||
if i + 1 < len(lines)
|
||||
else False
|
||||
):
|
||||
in_base64_block = True
|
||||
if "text/calendar" in stripped.lower():
|
||||
in_calendar_block = True
|
||||
# Skip this header and any continuation lines
|
||||
i += 1
|
||||
while i < len(lines) and (
|
||||
lines[i].startswith(" ") or lines[i].startswith("\t")
|
||||
):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Skip Content-Transfer-Encoding header
|
||||
if stripped.lower().startswith("content-transfer-encoding:"):
|
||||
if "base64" in stripped.lower():
|
||||
in_base64_block = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Skip email headers (matches header pattern)
|
||||
if header_pattern.match(line):
|
||||
# Skip this header and any continuation lines
|
||||
i += 1
|
||||
while i < len(lines) and (
|
||||
lines[i].startswith(" ") or lines[i].startswith("\t")
|
||||
):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Blank line - could be end of headers or part separator
|
||||
if stripped == "":
|
||||
# If we haven't collected any content yet, keep skipping
|
||||
if not result_lines:
|
||||
i += 1
|
||||
continue
|
||||
# Otherwise keep the blank line (paragraph separator in body)
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Detect and skip base64 encoded blocks
|
||||
if in_base64_block:
|
||||
# Check if line looks like base64 (long string of base64 chars)
|
||||
if len(stripped) > 20 and re.match(r"^[A-Za-z0-9+/=]+$", stripped):
|
||||
i += 1
|
||||
continue
|
||||
else:
|
||||
# End of base64 block
|
||||
in_base64_block = False
|
||||
|
||||
# Skip calendar/ICS content (BEGIN:VCALENDAR to END:VCALENDAR)
|
||||
if stripped.startswith("BEGIN:VCALENDAR"):
|
||||
in_calendar_block = True
|
||||
i += 1
|
||||
continue
|
||||
if in_calendar_block:
|
||||
if stripped.startswith("END:VCALENDAR"):
|
||||
in_calendar_block = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# This looks like actual content - add it
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
|
||||
return "\n".join(result_lines).strip()
|
||||
|
||||
return "\n".join(result_lines).strip()
|
||||
|
||||
def _format_recipients(self, recipients_info) -> str:
|
||||
"""Format recipients info (dict, list of dicts, or string) to a string."""
|
||||
if not recipients_info:
|
||||
return ""
|
||||
|
||||
if isinstance(recipients_info, str):
|
||||
return recipients_info
|
||||
|
||||
if isinstance(recipients_info, dict):
|
||||
# Single recipient
|
||||
name = recipients_info.get("name") or ""
|
||||
addr = recipients_info.get("addr") or ""
|
||||
if name and addr:
|
||||
return f"{name} <{addr}>"
|
||||
elif name:
|
||||
return name
|
||||
else:
|
||||
return addr
|
||||
|
||||
if isinstance(recipients_info, list):
|
||||
# Multiple recipients
|
||||
parts = []
|
||||
for r in recipients_info:
|
||||
if isinstance(r, dict):
|
||||
name = r.get("name") or ""
|
||||
addr = r.get("addr") or ""
|
||||
if name and addr:
|
||||
parts.append(f"{name} <{addr}>")
|
||||
elif name:
|
||||
parts.append(name)
|
||||
elif addr:
|
||||
parts.append(addr)
|
||||
elif isinstance(r, str):
|
||||
parts.append(r)
|
||||
return ", ".join(parts)
|
||||
|
||||
return str(recipients_info)
|
||||
|
||||
def clear_content(self) -> None:
|
||||
"""Clear the message content display."""
|
||||
self.content.update("")
|
||||
self.html_content.update("")
|
||||
self.current_content = None
|
||||
self.current_message_id = None
|
||||
self.border_title = "No message selected"
|
||||
|
||||
def _parse_headers_from_content(self, content: str) -> Dict[str, str]:
|
||||
"""Parse email headers from message content.
|
||||
|
||||
Returns a dict with keys: from, to, subject, date, cc
|
||||
"""
|
||||
headers = {}
|
||||
lines = content.split("\n")
|
||||
current_header = None
|
||||
current_value = ""
|
||||
|
||||
for line in lines:
|
||||
# Blank line marks end of headers
|
||||
if line.strip() == "":
|
||||
if current_header:
|
||||
headers[current_header] = current_value.strip()
|
||||
break
|
||||
|
||||
# Check for header continuation (line starts with whitespace)
|
||||
if line.startswith(" ") or line.startswith("\t"):
|
||||
if current_header:
|
||||
current_value += " " + line.strip()
|
||||
continue
|
||||
|
||||
# Save previous header if any
|
||||
if current_header:
|
||||
headers[current_header] = current_value.strip()
|
||||
|
||||
# Parse new header
|
||||
if ":" in line:
|
||||
header_name, _, value = line.partition(":")
|
||||
header_lower = header_name.lower().strip()
|
||||
if header_lower in ("from", "to", "subject", "date", "cc"):
|
||||
current_header = header_lower
|
||||
current_value = value.strip()
|
||||
else:
|
||||
current_header = None
|
||||
else:
|
||||
# Line doesn't look like a header, we've reached body
|
||||
break
|
||||
|
||||
return headers
|
||||
|
||||
def _update_content(self, content: str | None) -> None:
|
||||
"""Update the content widgets with the fetched content."""
|
||||
if content is None:
|
||||
content = "(No content)"
|
||||
|
||||
# Parse headers from content to update the EnvelopeHeader
|
||||
# (himalaya envelope list doesn't include full To/CC info)
|
||||
parsed_headers = self._parse_headers_from_content(content)
|
||||
if parsed_headers:
|
||||
# Update header with parsed values, falling back to envelope data
|
||||
subject = parsed_headers.get("subject") or (
|
||||
self.current_envelope.get("subject", "")
|
||||
if self.current_envelope
|
||||
else ""
|
||||
)
|
||||
from_str = parsed_headers.get("from") or ""
|
||||
to_str = parsed_headers.get("to") or ""
|
||||
date_str = parsed_headers.get("date") or (
|
||||
self.current_envelope.get("date", "") if self.current_envelope else ""
|
||||
)
|
||||
cc_str = parsed_headers.get("cc") or None
|
||||
|
||||
self.header.update(subject, from_str, to_str, date_str, cc_str)
|
||||
|
||||
# Strip headers from content (they're shown in EnvelopeHeader)
|
||||
content = self._strip_headers_from_content(content)
|
||||
|
||||
# Store the raw content for link extraction and for toggle mode
|
||||
self.current_content = content
|
||||
self.current_raw_content = content # Keep original for mode toggling
|
||||
|
||||
# Apply notification compression if enabled AND compression toggle is on
|
||||
display_content = content
|
||||
if (
|
||||
self.compressor.mode != "off"
|
||||
and self.current_envelope
|
||||
and self.compression_enabled
|
||||
):
|
||||
compressed_content, notif_type = self.compressor.compress(
|
||||
content, self.current_envelope
|
||||
)
|
||||
self.current_notification_type = notif_type
|
||||
if notif_type is not None:
|
||||
# Only use compressed content if compression was actually applied
|
||||
display_content = compressed_content
|
||||
self.is_compressed_view = True
|
||||
else:
|
||||
self.is_compressed_view = False
|
||||
else:
|
||||
self.current_notification_type = None
|
||||
self.is_compressed_view = False
|
||||
|
||||
# Get URL compression settings from config
|
||||
config = get_config()
|
||||
compress_urls = config.content_display.compress_urls
|
||||
max_url_len = config.content_display.max_url_length
|
||||
|
||||
try:
|
||||
if self.current_mode == "markdown":
|
||||
# For markdown mode, use the Markdown widget
|
||||
final_content = display_content
|
||||
if compress_urls and not self.is_compressed_view:
|
||||
# Don't compress URLs in notification summaries (they're already formatted)
|
||||
final_content = compress_urls_in_content(
|
||||
display_content, max_url_len
|
||||
)
|
||||
self.content.update(final_content)
|
||||
else:
|
||||
# For HTML mode, use the Static widget with markup
|
||||
# First, try to extract the body content if it's HTML
|
||||
body_match = re.search(
|
||||
r"<body[^>]*>(.*?)</body>", content, re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
if body_match:
|
||||
content = body_match.group(1)
|
||||
|
||||
# Replace some common HTML elements with Textual markup
|
||||
content = content.replace("<b>", "[b]").replace("</b>", "[/b]")
|
||||
content = content.replace("<i>", "[i]").replace("</i>", "[/i]")
|
||||
content = content.replace("<u>", "[u]").replace("</u>", "[/u]")
|
||||
|
||||
# Convert links to a readable format
|
||||
content = re.sub(
|
||||
r'<a href="([^"]+)"[^>]*>([^<]+)</a>', r"[\2](\1)", content
|
||||
)
|
||||
|
||||
# Add CSS for better readability
|
||||
self.html_content.update(content)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error updating content: {e}")
|
||||
if self.current_mode == "markdown":
|
||||
self.content.update(f"Error displaying content: {e}")
|
||||
else:
|
||||
self.html_content.update(f"Error displaying content: {e}")
|
||||
|
||||
def get_links(self) -> List[LinkItem]:
|
||||
"""Extract and return links from the current message content."""
|
||||
if not self.current_content:
|
||||
return []
|
||||
return extract_links_from_content(self.current_content)
|
||||
|
||||
def _show_calendar_panel(self, event: ParsedCalendarEvent) -> None:
|
||||
"""Show the calendar invite panel at the top of the scrollable content."""
|
||||
# Remove existing panel if any
|
||||
self._hide_calendar_panel()
|
||||
|
||||
# Store the calendar event for RSVP actions
|
||||
self.current_calendar_event = event
|
||||
|
||||
# Create and mount new panel at the beginning of the scroll container
|
||||
# Don't use a fixed ID to avoid DuplicateIds errors when panels are
|
||||
# removed asynchronously
|
||||
self.calendar_panel = CalendarInvitePanel(event)
|
||||
self.scroll_container.mount(self.calendar_panel, before=0)
|
||||
|
||||
def _hide_calendar_panel(self) -> None:
|
||||
"""Hide/remove the calendar invite panel."""
|
||||
self.current_calendar_event = None
|
||||
|
||||
# Remove the panel via instance reference (more reliable than ID query)
|
||||
if self.calendar_panel is not None:
|
||||
try:
|
||||
self.calendar_panel.remove()
|
||||
except Exception:
|
||||
pass # Already removed or not mounted
|
||||
self.calendar_panel = None
|
||||
|
||||
# Also remove any orphaned CalendarInvitePanel widgets
|
||||
try:
|
||||
for panel in self.query(CalendarInvitePanel):
|
||||
panel.remove()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_calendar_invite_panel_invite_action(
|
||||
self, event: CalendarInvitePanel.InviteAction
|
||||
) -> None:
|
||||
"""Handle calendar invite actions from the panel.
|
||||
|
||||
Bubbles the action up to the app level for processing.
|
||||
"""
|
||||
# Get the app and call the appropriate action
|
||||
action = event.action
|
||||
calendar_event = event.event
|
||||
|
||||
if action == "accept":
|
||||
self.app.action_accept_invite()
|
||||
elif action == "decline":
|
||||
self.app.action_decline_invite()
|
||||
elif action == "tentative":
|
||||
self.app.action_tentative_invite()
|
||||
258
src/mail/widgets/EnvelopeListItem.py
Normal file
258
src/mail/widgets/EnvelopeListItem.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Custom widget for rendering envelope list items with configurable display."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import Label, Static
|
||||
|
||||
from src.mail.config import EnvelopeDisplayConfig, get_config
|
||||
|
||||
|
||||
class EnvelopeListItem(Static):
|
||||
"""A widget for rendering a single envelope in the list.
|
||||
|
||||
Supports configurable layout:
|
||||
- 2-line mode: sender/date on line 1, subject on line 2
|
||||
- 3-line mode: adds a preview line
|
||||
|
||||
Displays read/unread status with NerdFont icons.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
EnvelopeListItem {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
EnvelopeListItem .envelope-row-1 {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
EnvelopeListItem .envelope-row-2 {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
EnvelopeListItem .envelope-row-3 {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
EnvelopeListItem .status-icon {
|
||||
width: 2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
EnvelopeListItem .checkbox {
|
||||
width: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
EnvelopeListItem .sender-name {
|
||||
width: 1fr;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
EnvelopeListItem .message-datetime {
|
||||
width: auto;
|
||||
padding: 0 1;
|
||||
color: $text-disabled;
|
||||
}
|
||||
|
||||
EnvelopeListItem .email-subject {
|
||||
width: 1fr;
|
||||
padding: 0 3;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
EnvelopeListItem .email-preview {
|
||||
width: 1fr;
|
||||
padding: 0 3;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
EnvelopeListItem.unread .sender-name {
|
||||
text-style: bold;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
EnvelopeListItem.unread .message-datetime {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
EnvelopeListItem.unread .email-subject {
|
||||
text-style: bold;
|
||||
color: $text;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
envelope: Dict[str, Any],
|
||||
config: Optional[EnvelopeDisplayConfig] = None,
|
||||
is_selected: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the envelope list item.
|
||||
|
||||
Args:
|
||||
envelope: The envelope data dictionary from himalaya
|
||||
config: Display configuration (uses global config if not provided)
|
||||
is_selected: Whether this item is currently selected
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.envelope = envelope
|
||||
self.config = config or get_config().envelope_display
|
||||
self._is_selected = is_selected
|
||||
|
||||
# Parse envelope data
|
||||
self._parse_envelope()
|
||||
|
||||
def _parse_envelope(self) -> None:
|
||||
"""Parse envelope data into display-ready values."""
|
||||
# Get sender info
|
||||
from_data = self.envelope.get("from", {})
|
||||
self.sender_name = from_data.get("name") or from_data.get("addr", "Unknown")
|
||||
if not self.sender_name:
|
||||
self.sender_name = from_data.get("addr", "Unknown")
|
||||
|
||||
# Truncate sender name if needed
|
||||
max_len = self.config.max_sender_length
|
||||
if len(self.sender_name) > max_len:
|
||||
self.sender_name = self.sender_name[: max_len - 1] + "\u2026" # ellipsis
|
||||
|
||||
# Get subject
|
||||
self.subject = str(self.envelope.get("subject", "")).strip() or "(No subject)"
|
||||
|
||||
# Parse date
|
||||
self.formatted_datetime = self._format_datetime()
|
||||
|
||||
# Get read/unread status (himalaya uses "flags" field)
|
||||
flags = self.envelope.get("flags", [])
|
||||
self.is_read = "Seen" in flags if isinstance(flags, list) else False
|
||||
self.is_flagged = "Flagged" in flags if isinstance(flags, list) else False
|
||||
self.has_attachment = (
|
||||
"Attachments" in flags if isinstance(flags, list) else False
|
||||
)
|
||||
|
||||
# Message ID for selection tracking
|
||||
self.message_id = int(self.envelope.get("id", 0))
|
||||
|
||||
def _format_datetime(self) -> str:
|
||||
"""Format the message date/time according to config."""
|
||||
date_str = self.envelope.get("date", "")
|
||||
if not date_str:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Parse ISO format date
|
||||
if "Z" in date_str:
|
||||
date_str = date_str.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
|
||||
# Convert to local timezone
|
||||
dt = dt.astimezone()
|
||||
|
||||
parts = []
|
||||
if self.config.show_date:
|
||||
parts.append(dt.strftime(self.config.date_format))
|
||||
if self.config.show_time:
|
||||
parts.append(dt.strftime(self.config.time_format))
|
||||
|
||||
return " ".join(parts)
|
||||
except (ValueError, TypeError):
|
||||
return "Invalid Date"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the widget layout."""
|
||||
# Determine status icon
|
||||
if self.is_read:
|
||||
status_icon = self.config.icon_read
|
||||
status_class = "status-icon read"
|
||||
else:
|
||||
status_icon = self.config.icon_unread
|
||||
status_class = "status-icon unread"
|
||||
|
||||
# Add flagged/attachment indicators
|
||||
extra_icons = ""
|
||||
if self.is_flagged:
|
||||
extra_icons += f" {self.config.icon_flagged}"
|
||||
if self.has_attachment:
|
||||
extra_icons += f" {self.config.icon_attachment}"
|
||||
|
||||
# Build the layout based on config.lines
|
||||
with Vertical(classes="envelope-content"):
|
||||
# Row 1: Status icon, checkbox, sender, datetime
|
||||
with Horizontal(classes="envelope-row-1"):
|
||||
yield Label(status_icon + extra_icons, classes=status_class)
|
||||
if self.config.show_checkbox:
|
||||
checkbox_char = "\uf4a7" if self._is_selected else "\ue640"
|
||||
yield Label(checkbox_char, classes="checkbox")
|
||||
yield Label(self.sender_name, classes="sender-name", markup=False)
|
||||
yield Label(self.formatted_datetime, classes="message-datetime")
|
||||
|
||||
# Row 2: Subject
|
||||
with Horizontal(classes="envelope-row-2"):
|
||||
yield Label(self.subject, classes="email-subject", markup=False)
|
||||
|
||||
# Row 3: Preview (only in 3-line mode with preview enabled)
|
||||
if self.config.lines == 3 and self.config.show_preview:
|
||||
preview = self.envelope.get("preview", "")[:60]
|
||||
if preview:
|
||||
with Horizontal(classes="envelope-row-3"):
|
||||
yield Label(preview, classes="email-preview", markup=False)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set up classes on mount."""
|
||||
if not self.is_read:
|
||||
self.add_class("unread")
|
||||
if self._is_selected:
|
||||
self.add_class("selected")
|
||||
|
||||
def set_selected(self, selected: bool) -> None:
|
||||
"""Update the selection state."""
|
||||
self._is_selected = selected
|
||||
if selected:
|
||||
self.add_class("selected")
|
||||
else:
|
||||
self.remove_class("selected")
|
||||
|
||||
# Update checkbox display
|
||||
if self.config.show_checkbox:
|
||||
try:
|
||||
checkbox = self.query_one(".checkbox", Label)
|
||||
checkbox.update("\uf4a7" if selected else "\ue640")
|
||||
except Exception:
|
||||
pass # Widget may not be mounted yet
|
||||
|
||||
|
||||
class GroupHeader(Static):
|
||||
"""A header widget for grouping envelopes by date."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GroupHeader {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
background: $surface;
|
||||
color: $text-muted;
|
||||
text-style: bold;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, **kwargs):
|
||||
"""Initialize the group header.
|
||||
|
||||
Args:
|
||||
label: The header label (e.g., "Today", "December 2025")
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.label = label
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose just returns itself as a Static."""
|
||||
yield Label(self.label, classes="group-header-label")
|
||||
6
src/mail/widgets/__init__.py
Normal file
6
src/mail/widgets/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# Initialize the widgets subpackage
|
||||
from .ContentContainer import ContentContainer
|
||||
from .EnvelopeHeader import EnvelopeHeader
|
||||
from .EnvelopeListItem import EnvelopeListItem, GroupHeader
|
||||
|
||||
__all__ = ["ContentContainer", "EnvelopeHeader", "EnvelopeListItem", "GroupHeader"]
|
||||
@@ -1 +0,0 @@
|
||||
# Initialize the maildir_gtd package
|
||||
@@ -1,693 +0,0 @@
|
||||
from .message_store import MessageStore
|
||||
from .widgets.ContentContainer import ContentContainer
|
||||
from .actions.task import action_create_task
|
||||
from .actions.open import action_open
|
||||
from .actions.delete import delete_current
|
||||
from src.services.taskwarrior import client as taskwarrior_client
|
||||
from src.services.himalaya import client as himalaya_client
|
||||
from textual.containers import Container, ScrollableContainer, Vertical, Horizontal
|
||||
from textual.timer import Timer
|
||||
from textual.binding import Binding
|
||||
from textual.reactive import reactive, Reactive
|
||||
from textual.widgets import Footer, Static, Label, Markdown, ListView, ListItem
|
||||
from textual.screen import Screen
|
||||
from textual.logging import TextualHandler
|
||||
from textual.app import App, ComposeResult, SystemCommand, RenderResult
|
||||
from textual.worker import Worker
|
||||
from textual import work
|
||||
import sys
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from typing import Iterable, Optional, List, Dict, Any, Generator, Tuple
|
||||
|
||||
# Add the parent directory to the system path to resolve relative imports
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
# Import our new API modules
|
||||
|
||||
# Updated imports with correct relative paths
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level="NOTSET",
|
||||
handlers=[TextualHandler()],
|
||||
)
|
||||
|
||||
|
||||
class StatusTitle(Static):
|
||||
total_messages: Reactive[int] = reactive(0)
|
||||
current_message_index: Reactive[int] = reactive(0)
|
||||
current_message_id: Reactive[int] = reactive(1)
|
||||
folder: Reactive[str] = reactive("INBOX")
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
return f"{self.folder} | ID: {self.current_message_id} | [b]{self.current_message_index}[/b]/{self.total_messages}"
|
||||
|
||||
|
||||
class EmailViewerApp(App):
|
||||
"""A simple email viewer app using the Himalaya CLI."""
|
||||
|
||||
CSS_PATH = "email_viewer.tcss"
|
||||
title = "Maildir GTD Reader"
|
||||
current_message_id: Reactive[int] = reactive(0)
|
||||
current_message_index: Reactive[int] = reactive(0)
|
||||
highlighted_message_index: Reactive[int] = reactive(0)
|
||||
folder = reactive("INBOX")
|
||||
header_expanded = reactive(False)
|
||||
reload_needed = reactive(True)
|
||||
message_store = MessageStore()
|
||||
oldest_id: Reactive[int] = reactive(0)
|
||||
newest_id: Reactive[int] = reactive(0)
|
||||
msg_worker: Worker | None = None
|
||||
total_messages: Reactive[int] = reactive(0)
|
||||
status_title = reactive("Message View")
|
||||
sort_order_ascending: Reactive[bool] = reactive(True)
|
||||
selected_messages: Reactive[set[int]] = reactive(set())
|
||||
main_content_visible: Reactive[bool] = reactive(True)
|
||||
|
||||
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
||||
yield from super().get_system_commands(screen)
|
||||
yield SystemCommand("Next Message", "Navigate to Next ID", self.action_next)
|
||||
yield SystemCommand(
|
||||
"Previous Message", "Navigate to Previous ID", self.action_previous
|
||||
)
|
||||
yield SystemCommand(
|
||||
"Delete Message", "Delete the current message", self.action_delete
|
||||
)
|
||||
yield SystemCommand(
|
||||
"Archive Message", "Archive the current message", self.action_archive
|
||||
)
|
||||
yield SystemCommand(
|
||||
"Open Message", "Open a specific message by ID", self.action_open
|
||||
)
|
||||
yield SystemCommand(
|
||||
"Create Task", "Create a task using the task CLI", self.action_create_task
|
||||
)
|
||||
yield SystemCommand(
|
||||
"Oldest Message", "Show the oldest message", self.action_oldest
|
||||
)
|
||||
yield SystemCommand(
|
||||
"Newest Message", "Show the newest message", self.action_newest
|
||||
)
|
||||
yield SystemCommand("Reload", "Reload the message list", self.fetch_envelopes)
|
||||
|
||||
BINDINGS = [
|
||||
Binding("j", "next", "Next message"),
|
||||
Binding("k", "previous", "Previous message"),
|
||||
Binding("#", "delete", "Delete message"),
|
||||
Binding("e", "archive", "Archive message"),
|
||||
Binding("o", "open", "Open message", show=False),
|
||||
Binding("q", "quit", "Quit application"),
|
||||
Binding("h", "toggle_header", "Toggle Envelope Header"),
|
||||
Binding("t", "create_task", "Create Task"),
|
||||
Binding("%", "reload", "Reload message list"),
|
||||
Binding("1", "focus_1", "Focus Accounts Panel"),
|
||||
Binding("2", "focus_2", "Focus Folders Panel"),
|
||||
Binding("3", "focus_3", "Focus Envelopes Panel"),
|
||||
Binding("4", "focus_4", "Focus Main Content"),
|
||||
Binding("w", "toggle_main_content", "Toggle Message View Window"),
|
||||
]
|
||||
|
||||
BINDINGS.extend(
|
||||
[
|
||||
Binding("space", "scroll_page_down", "Scroll page down"),
|
||||
Binding("b", "scroll_page_up", "Scroll page up"),
|
||||
Binding("s", "toggle_sort_order", "Toggle Sort Order"),
|
||||
Binding("x", "toggle_selection", "Toggle selection"),
|
||||
Binding("escape", "clear_selection", "Clear selection"),
|
||||
]
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Horizontal(
|
||||
Vertical(
|
||||
ListView(
|
||||
ListItem(Label("All emails...")),
|
||||
id="envelopes_list",
|
||||
classes="list_view",
|
||||
initial_index=0,
|
||||
),
|
||||
ListView(id="accounts_list", classes="list_view"),
|
||||
ListView(id="folders_list", classes="list_view"),
|
||||
id="sidebar",
|
||||
),
|
||||
ContentContainer(id="main_content"),
|
||||
id="outer-wrapper",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.alert_timer: Timer | None = None # Timer to throttle alerts
|
||||
self.theme = "monokai"
|
||||
self.title = "MaildirGTD"
|
||||
self.query_one("#main_content").border_title = self.status_title
|
||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
||||
self.query_one("#envelopes_list").border_title = f"1️⃣ Emails {sort_indicator}"
|
||||
self.query_one("#accounts_list").border_title = "2️⃣ Accounts"
|
||||
|
||||
self.query_one("#folders_list").border_title = "3️⃣ Folders"
|
||||
|
||||
self.fetch_accounts()
|
||||
self.fetch_folders()
|
||||
worker = self.fetch_envelopes()
|
||||
await worker.wait()
|
||||
self.query_one("#envelopes_list").focus()
|
||||
self.action_oldest()
|
||||
|
||||
def compute_status_title(self):
|
||||
metadata = self.message_store.get_metadata(self.current_message_id)
|
||||
message_date = metadata["date"] if metadata else "N/A"
|
||||
return f"✉️ Message ID: {self.current_message_id} | Date: {message_date}"
|
||||
|
||||
def watch_status_title(self, old_status_title: str, new_status_title: str) -> None:
|
||||
self.query_one(ContentContainer).border_title = new_status_title
|
||||
|
||||
def watch_sort_order_ascending(self, old_value: bool, new_value: bool) -> None:
|
||||
"""Update the border title of the envelopes list when the sort order changes."""
|
||||
sort_indicator = "↑" if new_value else "↓"
|
||||
self.query_one("#envelopes_list").border_title = f"1️⃣ Emails {sort_indicator}"
|
||||
|
||||
def watch_current_message_index(self, old_index: int, new_index: int) -> None:
|
||||
if new_index < 0:
|
||||
new_index = 0
|
||||
self.current_message_index = new_index
|
||||
if new_index > self.total_messages:
|
||||
new_index = self.total_messages
|
||||
self.current_message_index = new_index
|
||||
|
||||
self._update_list_view_subtitle()
|
||||
self.query_one("#envelopes_list", ListView).index = new_index
|
||||
|
||||
def watch_selected_messages(
|
||||
self, old_messages: set[int], new_messages: set[int]
|
||||
) -> None:
|
||||
self._update_list_view_subtitle()
|
||||
|
||||
def _update_list_view_subtitle(self) -> None:
|
||||
subtitle = f"[b]{self.current_message_index}[/b]/{self.total_messages}"
|
||||
if self.selected_messages:
|
||||
subtitle = f"(✓{len(self.selected_messages)}) {subtitle}"
|
||||
self.query_one("#envelopes_list").border_subtitle = subtitle
|
||||
|
||||
def watch_total_messages(self, old_total: int, new_total: int) -> None:
|
||||
"""Called when the total_messages reactive attribute changes."""
|
||||
self._update_list_view_subtitle()
|
||||
|
||||
def watch_reload_needed(
|
||||
self, old_reload_needed: bool, new_reload_needed: bool
|
||||
) -> None:
|
||||
logging.info(f"Reload needed: {new_reload_needed}")
|
||||
if not old_reload_needed and new_reload_needed:
|
||||
self.fetch_envelopes()
|
||||
|
||||
def watch_current_message_id(
|
||||
self, old_message_id: int, new_message_id: int
|
||||
) -> None:
|
||||
"""Called when the current message ID changes."""
|
||||
logging.info(
|
||||
f"Current message ID changed from {old_message_id} to {new_message_id}"
|
||||
)
|
||||
if new_message_id == old_message_id:
|
||||
return
|
||||
|
||||
# If the main content view is not visible, don't load the message
|
||||
if not self.main_content_visible:
|
||||
return
|
||||
|
||||
# Cancel any existing message loading worker
|
||||
if self.msg_worker:
|
||||
self.msg_worker.cancel()
|
||||
|
||||
# Start a new worker to load the message content
|
||||
self.msg_worker = self.load_message_content(new_message_id)
|
||||
|
||||
@work(exclusive=True)
|
||||
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)
|
||||
|
||||
metadata = self.message_store.get_metadata(message_id)
|
||||
if metadata:
|
||||
message_date = metadata["date"]
|
||||
if self.current_message_index != metadata["index"]:
|
||||
self.current_message_index = metadata["index"]
|
||||
|
||||
list_view = self.query_one("#envelopes_list", ListView)
|
||||
if list_view.index != metadata["index"]:
|
||||
list_view.index = metadata["index"]
|
||||
else:
|
||||
logging.warning(f"Message ID {message_id} not found in metadata.")
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Called when an item in the list view is selected."""
|
||||
if event.list_view.index is None:
|
||||
return
|
||||
|
||||
selected_index = event.list_view.index
|
||||
|
||||
current_item = self.message_store.envelopes[selected_index]
|
||||
|
||||
if current_item is None or current_item.get("type") == "header":
|
||||
return
|
||||
|
||||
message_id = int(current_item["id"])
|
||||
self.current_message_id = message_id
|
||||
self.current_message_index = selected_index
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
highlighted_index = event.list_view.index
|
||||
|
||||
current_item = self.message_store.envelopes[highlighted_index]
|
||||
|
||||
if current_item is None or current_item.get("type") == "header":
|
||||
return
|
||||
|
||||
# message_id = int(current_item["id"])
|
||||
# self.current_message_id = message_id
|
||||
self.highlighted_message_index = highlighted_index
|
||||
|
||||
@work(exclusive=False)
|
||||
async def fetch_envelopes(self) -> None:
|
||||
msglist = self.query_one("#envelopes_list", ListView)
|
||||
try:
|
||||
msglist.loading = True
|
||||
|
||||
# Use the Himalaya client to fetch envelopes
|
||||
envelopes, success = await himalaya_client.list_envelopes()
|
||||
|
||||
if success:
|
||||
self.reload_needed = False
|
||||
# Ensure envelopes is a list, even if it's None from the client
|
||||
envelopes_list = envelopes if envelopes is not None else []
|
||||
self.message_store.load(envelopes_list, self.sort_order_ascending)
|
||||
self.total_messages = self.message_store.total_messages
|
||||
|
||||
# Use the centralized refresh method to update the ListView
|
||||
self._populate_list_view()
|
||||
|
||||
# Restore the current index
|
||||
msglist.index = self.current_message_index
|
||||
else:
|
||||
self.show_status("Failed to fetch envelopes.", "error")
|
||||
except Exception as e:
|
||||
self.show_status(f"Error fetching message list: {e}", "error")
|
||||
finally:
|
||||
msglist.loading = False
|
||||
|
||||
@work(exclusive=False)
|
||||
async def fetch_accounts(self) -> None:
|
||||
accounts_list = self.query_one("#accounts_list", ListView)
|
||||
try:
|
||||
accounts_list.loading = True
|
||||
|
||||
# Use the Himalaya client to fetch accounts
|
||||
accounts, success = await himalaya_client.list_accounts()
|
||||
|
||||
if success and accounts:
|
||||
for account in accounts:
|
||||
item = ListItem(
|
||||
Label(
|
||||
str(account["name"]).strip(),
|
||||
classes="account_name",
|
||||
markup=False,
|
||||
)
|
||||
)
|
||||
accounts_list.append(item)
|
||||
else:
|
||||
self.show_status("Failed to fetch accounts.", "error")
|
||||
except Exception as e:
|
||||
self.show_status(f"Error fetching account list: {e}", "error")
|
||||
finally:
|
||||
accounts_list.loading = False
|
||||
|
||||
@work(exclusive=False)
|
||||
async def fetch_folders(self) -> None:
|
||||
folders_list = self.query_one("#folders_list", ListView)
|
||||
folders_list.clear()
|
||||
folders_list.append(
|
||||
ListItem(Label("INBOX", classes="folder_name", markup=False))
|
||||
)
|
||||
try:
|
||||
folders_list.loading = True
|
||||
|
||||
# Use the Himalaya client to fetch folders
|
||||
folders, success = await himalaya_client.list_folders()
|
||||
|
||||
if success and folders:
|
||||
for folder in folders:
|
||||
item = ListItem(
|
||||
Label(
|
||||
str(folder["name"]).strip(),
|
||||
classes="folder_name",
|
||||
markup=False,
|
||||
)
|
||||
)
|
||||
folders_list.append(item)
|
||||
else:
|
||||
self.show_status("Failed to fetch folders.", "error")
|
||||
except Exception as e:
|
||||
self.show_status(f"Error fetching folder list: {e}", "error")
|
||||
finally:
|
||||
folders_list.loading = False
|
||||
|
||||
def _populate_list_view(self) -> None:
|
||||
"""Populate the ListView with new items. This clears existing items."""
|
||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||
envelopes_list.clear()
|
||||
|
||||
for item in self.message_store.envelopes:
|
||||
if item and item.get("type") == "header":
|
||||
envelopes_list.append(
|
||||
ListItem(
|
||||
Container(
|
||||
Label("", classes="checkbox"), # Hidden checkbox for header
|
||||
Label(
|
||||
item["label"],
|
||||
classes="group_header",
|
||||
markup=False,
|
||||
),
|
||||
classes="envelope_item_row",
|
||||
)
|
||||
)
|
||||
)
|
||||
elif item: # Check if not None
|
||||
# Extract sender and date
|
||||
sender_name = item.get("from", {}).get(
|
||||
"name", item.get("from", {}).get("addr", "Unknown")
|
||||
)
|
||||
if not sender_name:
|
||||
sender_name = item.get("from", {}).get("addr", "Unknown")
|
||||
|
||||
# Truncate sender name
|
||||
max_sender_len = 25 # Adjust as needed
|
||||
if len(sender_name) > max_sender_len:
|
||||
sender_name = sender_name[: max_sender_len - 3] + "..."
|
||||
|
||||
message_date_str = item.get("date", "")
|
||||
formatted_date = ""
|
||||
if message_date_str:
|
||||
try:
|
||||
# Parse the date string, handling potential timezone info
|
||||
dt_object = datetime.fromisoformat(message_date_str)
|
||||
formatted_date = dt_object.strftime("%m/%d %H:%M")
|
||||
except ValueError:
|
||||
formatted_date = "Invalid Date"
|
||||
|
||||
list_item = ListItem(
|
||||
Container(
|
||||
Container(
|
||||
Label("☐", classes="checkbox"), # Placeholder for checkbox
|
||||
Label(sender_name, classes="sender_name"),
|
||||
Label(formatted_date, classes="message_date"),
|
||||
classes="envelope_header_row",
|
||||
),
|
||||
Container(
|
||||
Label(
|
||||
str(item.get("subject", "")).strip(),
|
||||
classes="email_subject",
|
||||
markup=False,
|
||||
),
|
||||
classes="envelope_subject_row",
|
||||
),
|
||||
classes="envelope_item_row",
|
||||
)
|
||||
)
|
||||
envelopes_list.append(list_item)
|
||||
self.refresh_list_view_items() # Initial refresh of item states
|
||||
|
||||
def refresh_list_view_items(self) -> None:
|
||||
"""Update the visual state of existing ListItems without clearing the list."""
|
||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||
for i, list_item in enumerate(envelopes_list.children):
|
||||
if isinstance(list_item, ListItem):
|
||||
item_data = self.message_store.envelopes[i]
|
||||
|
||||
# Find the checkbox label within the ListItem's children
|
||||
# checkbox_label = list_item.query_one(".checkbox", Label)
|
||||
if item_data and item_data.get("type") != "header":
|
||||
message_id = int(item_data["id"])
|
||||
is_selected = message_id in self.selected_messages or False
|
||||
list_item.set_class(is_selected, "selection")
|
||||
|
||||
# if checkbox_label:
|
||||
# checkbox_label.update("\uf4a7" if is_selected else "\ue640")
|
||||
# checkbox_label.display = True # Always display checkbox
|
||||
|
||||
# list_item.highlighted = is_selected
|
||||
|
||||
# # Update sender and date labels
|
||||
# sender_name = item_data.get("from", {}).get("name", item_data.get("from", {}).get("addr", "Unknown"))
|
||||
# if not sender_name:
|
||||
# sender_name = item_data.get("from", {}).get("addr", "Unknown")
|
||||
# max_sender_len = 25
|
||||
# if len(sender_name) > max_sender_len:
|
||||
# sender_name = sender_name[:max_sender_len-3] + "..."
|
||||
# list_item.query_one(".sender_name", Label).update(sender_name)
|
||||
|
||||
# message_date_str = item_data.get("date", "")
|
||||
# formatted_date = ""
|
||||
# if message_date_str:
|
||||
# try:
|
||||
# dt_object = datetime.fromisoformat(message_date_str)
|
||||
# formatted_date = dt_object.strftime("%m/%d %H:%M")
|
||||
# except ValueError:
|
||||
# formatted_date = "Invalid Date"
|
||||
# list_item.query_one(".message_date", Label).update(formatted_date)
|
||||
|
||||
# else:
|
||||
# # For header items, checkbox should be unchecked and visible
|
||||
# checkbox_label.update("\ue640") # Always unchecked for headers
|
||||
# checkbox_label.display = True # Always display checkbox
|
||||
# list_item.highlighted = False # Headers are never highlighted for selection
|
||||
|
||||
# Update total messages count (this is still fine here)
|
||||
# self.total_messages = self.message_store.total_messages
|
||||
|
||||
def show_message(self, message_id: int, new_index=None) -> None:
|
||||
if new_index:
|
||||
self.current_message_index = new_index
|
||||
self.action_focus_4()
|
||||
self.current_message_id = message_id
|
||||
|
||||
def show_status(self, message: str, severity: str = "information") -> None:
|
||||
"""Display a status message using the built-in notify function."""
|
||||
self.notify(
|
||||
message, title="Status", severity=severity, timeout=2.6, markup=True
|
||||
)
|
||||
|
||||
async def action_toggle_sort_order(self) -> None:
|
||||
"""Toggle the sort order of the envelope list."""
|
||||
self.sort_order_ascending = not self.sort_order_ascending
|
||||
worker = self.fetch_envelopes()
|
||||
await worker.wait()
|
||||
|
||||
if self.sort_order_ascending:
|
||||
self.action_oldest()
|
||||
else:
|
||||
self.action_newest()
|
||||
|
||||
async def action_toggle_mode(self) -> None:
|
||||
"""Toggle the content mode between plaintext and markdown."""
|
||||
content_container = self.query_one(ContentContainer)
|
||||
await content_container.action_toggle_mode()
|
||||
|
||||
def action_next(self) -> None:
|
||||
if not self.current_message_index >= 0:
|
||||
return
|
||||
|
||||
next_id, next_idx = self.message_store.find_next_valid_id(
|
||||
self.current_message_index
|
||||
)
|
||||
if next_id is not None and next_idx is not None:
|
||||
self.current_message_id = next_id
|
||||
self.current_message_index = next_idx
|
||||
|
||||
def action_previous(self) -> None:
|
||||
if not self.current_message_index >= 0:
|
||||
return
|
||||
|
||||
prev_id, prev_idx = self.message_store.find_prev_valid_id(
|
||||
self.current_message_index
|
||||
)
|
||||
if prev_id is not None and prev_idx is not None:
|
||||
self.current_message_id = prev_id
|
||||
self.current_message_index = prev_idx
|
||||
|
||||
async def action_delete(self) -> None:
|
||||
"""Delete the current message and update UI consistently."""
|
||||
# Call the delete_current function which uses our Himalaya client module
|
||||
worker = delete_current(self)
|
||||
await worker.wait()
|
||||
|
||||
async def action_archive(self) -> None:
|
||||
"""Archive the current or selected messages and update UI consistently."""
|
||||
next_id_to_select = None
|
||||
|
||||
if self.selected_messages:
|
||||
# --- Multi-message archive ---
|
||||
message_ids_to_archive = list(self.selected_messages)
|
||||
|
||||
if message_ids_to_archive:
|
||||
highest_archived_id = max(message_ids_to_archive)
|
||||
metadata = self.message_store.get_metadata(highest_archived_id)
|
||||
if metadata:
|
||||
next_id, _ = self.message_store.find_next_valid_id(
|
||||
metadata["index"]
|
||||
)
|
||||
if next_id is None:
|
||||
next_id, _ = self.message_store.find_prev_valid_id(
|
||||
metadata["index"]
|
||||
)
|
||||
next_id_to_select = next_id
|
||||
|
||||
message, success = await himalaya_client.archive_messages(
|
||||
[str(mid) for mid in message_ids_to_archive]
|
||||
)
|
||||
|
||||
if success:
|
||||
self.show_status(message or "Success archived")
|
||||
self.selected_messages.clear()
|
||||
else:
|
||||
self.show_status(f"Failed to archive messages: {message}", "error")
|
||||
return
|
||||
|
||||
else:
|
||||
# --- Single message archive ---
|
||||
if not self.current_message_id:
|
||||
self.show_status("No message selected to archive.", "error")
|
||||
return
|
||||
|
||||
current_id = self.current_message_id
|
||||
current_idx = self.current_message_index
|
||||
|
||||
next_id, _ = self.message_store.find_next_valid_id(current_idx)
|
||||
if next_id is None:
|
||||
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)])
|
||||
|
||||
if success:
|
||||
self.show_status(message or "Archived")
|
||||
else:
|
||||
self.show_status(
|
||||
f"Failed to archive message {current_id}: {message}", "error"
|
||||
)
|
||||
return
|
||||
|
||||
# Refresh the envelope list
|
||||
worker = self.fetch_envelopes()
|
||||
await worker.wait()
|
||||
|
||||
# After refresh, select the next message
|
||||
if next_id_to_select:
|
||||
new_metadata = self.message_store.get_metadata(next_id_to_select)
|
||||
if new_metadata:
|
||||
self.current_message_id = next_id_to_select
|
||||
else:
|
||||
self.action_oldest()
|
||||
else:
|
||||
self.action_oldest()
|
||||
|
||||
def action_open(self) -> None:
|
||||
action_open(self)
|
||||
|
||||
def action_create_task(self) -> None:
|
||||
action_create_task(self)
|
||||
|
||||
def action_scroll_down(self) -> None:
|
||||
"""Scroll the main content down."""
|
||||
self.query_one("#main_content").scroll_down()
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
"""Scroll the main content up."""
|
||||
self.query_one("#main_content").scroll_up()
|
||||
|
||||
def action_scroll_page_down(self) -> None:
|
||||
"""Scroll the main content down by a page."""
|
||||
self.query_one("#main_content").scroll_page_down()
|
||||
|
||||
def action_scroll_page_up(self) -> None:
|
||||
"""Scroll the main content up by a page."""
|
||||
self.query_one("#main_content").scroll_page_up()
|
||||
|
||||
def action_toggle_main_content(self) -> None:
|
||||
"""Toggle the visibility of the main content pane."""
|
||||
self.main_content_visible = not self.main_content_visible
|
||||
|
||||
def watch_main_content_visible(self, visible: bool) -> None:
|
||||
"""Called when main_content_visible changes."""
|
||||
main_content = self.query_one("#main_content")
|
||||
accounts_list = self.query_one("#accounts_list")
|
||||
folders_list = self.query_one("#folders_list")
|
||||
|
||||
main_content.display = visible
|
||||
accounts_list.display = visible
|
||||
folders_list.display = visible
|
||||
|
||||
self.query_one("#envelopes_list").focus()
|
||||
|
||||
def action_quit(self) -> None:
|
||||
self.exit()
|
||||
|
||||
def action_toggle_selection(self) -> None:
|
||||
"""Toggle selection for the current message."""
|
||||
current_item_data = self.message_store.envelopes[self.highlighted_message_index]
|
||||
if current_item_data and current_item_data.get("type") != "header":
|
||||
message_id = int(current_item_data["id"])
|
||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||
current_list_item = envelopes_list.children[self.highlighted_message_index]
|
||||
checkbox_label = current_list_item.query_one(".checkbox", Label)
|
||||
if message_id in self.selected_messages:
|
||||
self.selected_messages.remove(message_id)
|
||||
checkbox_label.remove_class("x-list")
|
||||
checkbox_label.update("\ue640")
|
||||
else:
|
||||
self.selected_messages.add(message_id)
|
||||
checkbox_label.add_class("x-list")
|
||||
checkbox_label.update("\uf4a7")
|
||||
|
||||
self._update_list_view_subtitle()
|
||||
|
||||
def action_clear_selection(self) -> None:
|
||||
"""Clear all selected messages."""
|
||||
self.selected_messages.clear()
|
||||
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
|
||||
self._update_list_view_subtitle()
|
||||
|
||||
def action_oldest(self) -> None:
|
||||
self.fetch_envelopes() if self.reload_needed else None
|
||||
self.show_message(self.message_store.get_oldest_id())
|
||||
|
||||
def action_newest(self) -> None:
|
||||
self.fetch_envelopes() if self.reload_needed else None
|
||||
self.show_message(self.message_store.get_newest_id())
|
||||
|
||||
def action_focus_1(self) -> None:
|
||||
self.query_one("#envelopes_list").focus()
|
||||
|
||||
def action_focus_2(self) -> None:
|
||||
self.query_one("#accounts_list").focus()
|
||||
|
||||
def action_focus_3(self) -> None:
|
||||
self.query_one("#folders_list").focus()
|
||||
|
||||
def action_focus_4(self) -> None:
|
||||
self.query_one("#main_content").focus()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = EmailViewerApp()
|
||||
app.run()
|
||||
|
||||
|
||||
def launch_email_viewer():
|
||||
app = EmailViewerApp()
|
||||
app.run()
|
||||
@@ -1,194 +0,0 @@
|
||||
/* Basic stylesheet for the Textual Email Viewer App */
|
||||
|
||||
|
||||
#main_content, .list_view {
|
||||
scrollbar-size: 1 1;
|
||||
border: round rgb(117, 106, 129);
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 1fr
|
||||
}
|
||||
|
||||
#main_content {
|
||||
width: 2fr;
|
||||
|
||||
}
|
||||
|
||||
.envelope-selected {
|
||||
tint: $accent 20%;
|
||||
}
|
||||
|
||||
#sidebar:focus-within {
|
||||
background: $panel;
|
||||
.list_view:blur {
|
||||
height: 3;
|
||||
}
|
||||
.list_view:focus {
|
||||
height: 2fr;
|
||||
}
|
||||
}
|
||||
|
||||
#main_content:focus, .list_view:focus {
|
||||
border: round $secondary;
|
||||
background: rgb(55, 53, 57);
|
||||
border-title-style: bold;
|
||||
}
|
||||
|
||||
Label#task_prompt {
|
||||
padding: 1;
|
||||
color: rgb(128,128,128);
|
||||
}
|
||||
|
||||
Label#task_prompt_label {
|
||||
padding: 1;
|
||||
color: rgb(255, 216, 102);
|
||||
}
|
||||
|
||||
Label#message_label {
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
StatusTitle {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text;
|
||||
background: rgb(64, 62, 65);
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
EnvelopeHeader {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
max-height: 2;
|
||||
tint: $primary 10%;
|
||||
}
|
||||
|
||||
Markdown {
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
.email_subject {
|
||||
width: 1fr;
|
||||
padding: 0 2;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.sender_name {
|
||||
tint: gray 30%;
|
||||
}
|
||||
|
||||
.message_date {
|
||||
padding: 0 2;
|
||||
color: $secondary;
|
||||
}
|
||||
|
||||
.header_key {
|
||||
tint: gray 20%;
|
||||
min-width: 10;
|
||||
text-style:bold;
|
||||
}
|
||||
|
||||
.header_value {
|
||||
padding:0 1 0 0;
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.modal_screen {
|
||||
align: center middle;
|
||||
margin: 1;
|
||||
padding: 2;
|
||||
border: round $border;
|
||||
background: $panel;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#envelopes_list {
|
||||
ListItem:odd {
|
||||
background: rgb(45, 45, 46);
|
||||
}
|
||||
ListItem:even {
|
||||
background: rgb(50, 50, 56);
|
||||
}
|
||||
|
||||
& > ListItem {
|
||||
&.-highlight, .selection {
|
||||
color: $block-cursor-blurred-foreground;
|
||||
background: $block-cursor-blurred-background;
|
||||
text-style: $block-cursor-blurred-text-style;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.envelope_item_row {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
|
||||
.envelope_header_row, .envelope_subject_row {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.x-list {
|
||||
tint: $accent 20%;
|
||||
}
|
||||
|
||||
#open_message_container, #create_task_container {
|
||||
border: panel $border;
|
||||
dock: right;
|
||||
width: 25%;
|
||||
min-width: 60;
|
||||
padding: 0 1;
|
||||
height: 100%;
|
||||
|
||||
Input {
|
||||
width: 1fr;
|
||||
}
|
||||
Label, Button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Label.group_header {
|
||||
color: rgb(140, 140, 140);
|
||||
text-style: bold;
|
||||
background: rgb(64, 62, 65);
|
||||
width: 100%;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#plaintext_content {
|
||||
padding: 1 2;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#html_content {
|
||||
padding: 1 2;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#markdown_content {
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
ContentContainer {
|
||||
width: 100%;
|
||||
height: 1fr;
|
||||
}
|
||||
.checkbox {
|
||||
padding-right: 1;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import logging
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Label, Button, ListView, ListItem
|
||||
from textual.containers import Vertical, Horizontal, Container
|
||||
from textual import on, work
|
||||
from src.services.taskwarrior import client as taskwarrior_client
|
||||
|
||||
|
||||
class CreateTaskScreen(ModalScreen):
|
||||
"""Screen for creating a new task."""
|
||||
|
||||
def __init__(self, subject="", from_addr="", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.subject = subject
|
||||
self.from_addr = from_addr
|
||||
self.selected_project = None
|
||||
|
||||
def compose(self):
|
||||
yield Container(
|
||||
Vertical(
|
||||
Horizontal(
|
||||
Label("Subject:"),
|
||||
Input(
|
||||
placeholder="Task subject",
|
||||
value=self.subject,
|
||||
id="subject_input",
|
||||
),
|
||||
),
|
||||
Horizontal(
|
||||
Label("Project:"),
|
||||
Input(placeholder="Project name", id="project_input"),
|
||||
),
|
||||
Horizontal(
|
||||
Label("Tags:"),
|
||||
Input(placeholder="Comma-separated tags", id="tags_input"),
|
||||
),
|
||||
Horizontal(
|
||||
Label("Due:"),
|
||||
Input(
|
||||
placeholder="Due date (e.g., today, tomorrow, fri)",
|
||||
id="due_input",
|
||||
),
|
||||
),
|
||||
Horizontal(
|
||||
Label("Priority:"),
|
||||
Input(placeholder="Priority (H, M, L)", id="priority_input"),
|
||||
),
|
||||
Horizontal(
|
||||
Button("Create", id="create_btn", variant="primary"),
|
||||
Button("Cancel", id="cancel_btn", variant="error"),
|
||||
),
|
||||
id="create_task_form",
|
||||
),
|
||||
id="create_task_container",
|
||||
)
|
||||
|
||||
def on_mount(self):
|
||||
self.query_one("#create_task_container",
|
||||
Container).border_title = "New Task (taskwarrior)"
|
||||
self.styles.align = ("center", "middle")
|
||||
|
||||
@on(Button.Pressed, "#create_btn")
|
||||
def on_create_pressed(self):
|
||||
"""Create the task when the Create button is pressed."""
|
||||
# Get input values
|
||||
subject = self.query_one("#subject_input").value
|
||||
project = self.query_one("#project_input").value
|
||||
tags_input = self.query_one("#tags_input").value
|
||||
due = self.query_one("#due_input").value
|
||||
priority = self.query_one("#priority_input").value
|
||||
|
||||
# Process tags (split by commas and trim whitespace)
|
||||
tags = [tag.strip()
|
||||
for tag in tags_input.split(",")] if tags_input else []
|
||||
|
||||
# Add a tag for the sender, if provided
|
||||
if self.from_addr and "@" in self.from_addr:
|
||||
domain = self.from_addr.split("@")[1].split(".")[0]
|
||||
if domain and domain not in ["gmail", "yahoo", "hotmail", "outlook"]:
|
||||
tags.append(domain)
|
||||
|
||||
# Create the task
|
||||
self.create_task_worker(subject, tags, project, due, priority)
|
||||
|
||||
@on(Button.Pressed, "#cancel_btn")
|
||||
def on_cancel_pressed(self):
|
||||
"""Dismiss the screen when Cancel is pressed."""
|
||||
self.dismiss()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def create_task_worker(
|
||||
self, subject, tags=None, project=None, due=None, priority=None
|
||||
):
|
||||
"""Worker to create a task using the Taskwarrior API client."""
|
||||
if not subject:
|
||||
self.app.show_status("Task subject cannot be empty.", "error")
|
||||
return
|
||||
|
||||
# Validate priority
|
||||
if priority and priority not in ["H", "M", "L"]:
|
||||
self.app.show_status("Priority must be H, M, or L.", "warning")
|
||||
priority = None
|
||||
|
||||
# Create the task
|
||||
success, result = await taskwarrior_client.create_task(
|
||||
task_description=subject,
|
||||
tags=tags or [],
|
||||
project=project,
|
||||
due=due,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
if success:
|
||||
self.app.show_status(f"Task created: {subject}", "success")
|
||||
self.dismiss()
|
||||
else:
|
||||
self.app.show_status(f"Failed to create task: {result}", "error")
|
||||
@@ -1,6 +0,0 @@
|
||||
# Initialize the screens package
|
||||
from .CreateTask import CreateTaskScreen
|
||||
from .OpenMessage import OpenMessageScreen
|
||||
from .DocumentViewer import DocumentViewerScreen
|
||||
|
||||
__all__ = ["CreateTaskScreen", "OpenMessageScreen", "DocumentViewerScreen"]
|
||||
@@ -1,175 +0,0 @@
|
||||
from markitdown import MarkItDown
|
||||
from textual import work
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical, ScrollableContainer
|
||||
from textual.widgets import Static, Markdown, Label
|
||||
from src.services.himalaya import client as himalaya_client
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the parent directory to the system path to resolve relative imports
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
class EnvelopeHeader(Vertical):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.subject_label = Label("")
|
||||
self.from_label = Label("")
|
||||
self.to_label = Label("")
|
||||
self.date_label = Label("")
|
||||
self.cc_label = Label("")
|
||||
|
||||
def on_mount(self):
|
||||
self.styles.height = "auto"
|
||||
self.mount(self.subject_label)
|
||||
self.mount(self.from_label)
|
||||
self.mount(self.to_label)
|
||||
self.mount(self.cc_label)
|
||||
self.mount(self.date_label)
|
||||
|
||||
def update(self, subject, from_, to, date, cc=None):
|
||||
self.subject_label.update(f"[b]Subject:[/b] {subject}")
|
||||
self.from_label.update(f"[b]From:[/b] {from_}")
|
||||
self.to_label.update(f"[b]To:[/b] {to}")
|
||||
|
||||
# Format the date for better readability
|
||||
if date:
|
||||
try:
|
||||
# Try to convert the date string to a datetime object
|
||||
date_obj = datetime.fromisoformat(date.replace("Z", "+00:00"))
|
||||
formatted_date = date_obj.strftime("%a, %d %b %Y %H:%M:%S %Z")
|
||||
self.date_label.update(f"[b]Date:[/b] {formatted_date}")
|
||||
except (ValueError, TypeError):
|
||||
# If parsing fails, just use the original date string
|
||||
self.date_label.update(f"[b]Date:[/b] {date}")
|
||||
else:
|
||||
self.date_label.update("[b]Date:[/b] Unknown")
|
||||
|
||||
if cc:
|
||||
self.cc_label.update(f"[b]CC:[/b] {cc}")
|
||||
self.cc_label.styles.display = "block"
|
||||
else:
|
||||
self.cc_label.styles.display = "none"
|
||||
|
||||
|
||||
class ContentContainer(ScrollableContainer):
|
||||
can_focus = True
|
||||
BINDINGS = [
|
||||
Binding("m", "toggle_mode", "Toggle View Mode")
|
||||
]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.md = MarkItDown()
|
||||
self.header = EnvelopeHeader(id="envelope_header")
|
||||
self.content = Markdown("", id="markdown_content")
|
||||
self.html_content = Static("", id="html_content", markup=False)
|
||||
self.current_mode = "html" # Default to text mode
|
||||
self.current_content = None
|
||||
self.current_message_id = None
|
||||
self.content_worker = None
|
||||
|
||||
def compose(self):
|
||||
yield self.content
|
||||
yield self.html_content
|
||||
|
||||
def on_mount(self):
|
||||
# Hide markdown content initially
|
||||
# self.action_notify("loading message...")
|
||||
self.content.styles.display = "none"
|
||||
self.html_content.styles.display = "block"
|
||||
|
||||
async def action_toggle_mode(self):
|
||||
"""Toggle between plaintext and HTML viewing modes."""
|
||||
if self.current_mode == "html":
|
||||
self.current_mode = "text"
|
||||
self.html_content.styles.display = "none"
|
||||
self.content.styles.display = "block"
|
||||
else:
|
||||
self.current_mode = "html"
|
||||
self.content.styles.display = "none"
|
||||
self.html_content.styles.display = "block"
|
||||
# self.action_notify(f"switched to mode {self.current_mode}")
|
||||
# Reload the content if we have a message ID
|
||||
self.border_sibtitle = self.current_mode;
|
||||
if self.current_message_id:
|
||||
self.display_content(self.current_message_id)
|
||||
|
||||
def update_header(self, subject, from_, to, date, cc=None):
|
||||
self.header.update(subject, from_, to, date, cc)
|
||||
|
||||
@work(exclusive=True)
|
||||
async def fetch_message_content(self, message_id: int, format: str):
|
||||
"""Fetch message content using the Himalaya client module."""
|
||||
if not message_id:
|
||||
self.notify("No message ID provided.")
|
||||
return
|
||||
|
||||
content, success = await himalaya_client.get_message_content(message_id)
|
||||
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:
|
||||
"""Display the content of a message."""
|
||||
# self.action_notify(f"recieved message_id to display {message_id}")
|
||||
if not message_id:
|
||||
return
|
||||
|
||||
self.current_message_id = message_id
|
||||
|
||||
# Immediately show a loading message
|
||||
if self.current_mode == "text":
|
||||
self.content.update("Loading...")
|
||||
else:
|
||||
self.html_content.update("Loading...")
|
||||
|
||||
# Cancel any existing content fetch operations
|
||||
if self.content_worker:
|
||||
self.content_worker.cancel()
|
||||
|
||||
# Fetch content in the current mode
|
||||
format_type = "text" if self.current_mode == "text" else "html"
|
||||
self.content_worker = self.fetch_message_content(
|
||||
message_id, format_type)
|
||||
|
||||
def _update_content(self, content: str | None) -> None:
|
||||
"""Update the content widgets with the fetched content."""
|
||||
try:
|
||||
if self.current_mode == "text":
|
||||
# For text mode, use the Markdown widget
|
||||
self.content.update(content)
|
||||
else:
|
||||
# For HTML mode, use the Static widget with markup
|
||||
# First, try to extract the body content if it's HTML
|
||||
body_match = re.search(
|
||||
r"<body[^>]*>(.*?)</body>", content, re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
if body_match:
|
||||
content = body_match.group(1)
|
||||
|
||||
# Replace some common HTML elements with Textual markup
|
||||
content = content.replace("<b>", "[b]").replace("</b>", "[/b]")
|
||||
content = content.replace("<i>", "[i]").replace("</i>", "[/i]")
|
||||
content = content.replace("<u>", "[u]").replace("</u>", "[/u]")
|
||||
|
||||
# Convert links to a readable format
|
||||
content = re.sub(
|
||||
r'<a href="([^"]+)"[^>]*>([^<]+)</a>', r"[\2](\1)", content
|
||||
)
|
||||
|
||||
# Add CSS for better readability
|
||||
self.html_content.update(content)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error updating content: {e}")
|
||||
if self.current_mode == "text":
|
||||
self.content.update(f"Error displaying content: {e}")
|
||||
else:
|
||||
self.html_content.update(f"Error displaying content: {e}")
|
||||
@@ -1 +0,0 @@
|
||||
# Initialize the screens subpackage
|
||||
5
src/services/dstask/__init__.py
Normal file
5
src/services/dstask/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""dstask client service for Tasks TUI."""
|
||||
|
||||
from .client import DstaskClient
|
||||
|
||||
__all__ = ["DstaskClient"]
|
||||
399
src/services/dstask/client.py
Normal file
399
src/services/dstask/client.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""dstask CLI client implementation.
|
||||
|
||||
This module implements the TaskBackend interface for dstask,
|
||||
a local-first task manager with Git sync support.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from src.tasks.backend import (
|
||||
Project,
|
||||
Task,
|
||||
TaskBackend,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DstaskClient(TaskBackend):
|
||||
"""Client for interacting with dstask CLI."""
|
||||
|
||||
def __init__(self, dstask_path: Optional[str] = None):
|
||||
"""Initialize dstask client.
|
||||
|
||||
Args:
|
||||
dstask_path: Path to dstask binary. Defaults to ~/.local/bin/dstask
|
||||
"""
|
||||
if dstask_path is None:
|
||||
dstask_path = str(Path.home() / ".local" / "bin" / "dstask")
|
||||
self.dstask_path = dstask_path
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if dstask binary is available and executable."""
|
||||
path = Path(self.dstask_path)
|
||||
return path.exists() and path.is_file()
|
||||
|
||||
def _run_command(
|
||||
self, args: list[str], capture_output: bool = True
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a dstask command.
|
||||
|
||||
Args:
|
||||
args: Command arguments (without dstask binary)
|
||||
capture_output: Whether to capture stdout/stderr
|
||||
|
||||
Returns:
|
||||
CompletedProcess result
|
||||
"""
|
||||
cmd = [self.dstask_path] + args
|
||||
logger.debug(f"Running: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"dstask command failed: {result.stderr}")
|
||||
|
||||
return result
|
||||
|
||||
def _parse_datetime(self, value: str) -> Optional[datetime]:
|
||||
"""Parse datetime from dstask JSON format."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
# dstask uses RFC3339 format
|
||||
try:
|
||||
# Handle Z suffix
|
||||
if value.endswith("Z"):
|
||||
value = value[:-1] + "+00:00"
|
||||
dt = datetime.fromisoformat(value)
|
||||
# dstask uses year 1 (0001-01-01) to indicate no date set
|
||||
if dt.year == 1:
|
||||
return None
|
||||
return dt
|
||||
except ValueError:
|
||||
logger.warning(f"Failed to parse datetime: {value}")
|
||||
return None
|
||||
|
||||
def _parse_task(self, data: dict) -> Task:
|
||||
"""Parse a task from dstask JSON output."""
|
||||
# Map dstask status to TaskStatus
|
||||
status_map = {
|
||||
"pending": TaskStatus.PENDING,
|
||||
"active": TaskStatus.ACTIVE,
|
||||
"resolved": TaskStatus.DONE,
|
||||
"deleted": TaskStatus.DELETED,
|
||||
}
|
||||
status = status_map.get(data.get("status", "pending"), TaskStatus.PENDING)
|
||||
|
||||
# Parse priority
|
||||
priority_str = data.get("priority", "P2")
|
||||
priority = TaskPriority.from_string(priority_str)
|
||||
|
||||
return Task(
|
||||
uuid=data.get("uuid", ""),
|
||||
id=data.get("id", 0),
|
||||
summary=data.get("summary", ""),
|
||||
status=status,
|
||||
priority=priority,
|
||||
project=data.get("project", ""),
|
||||
tags=data.get("tags", []) or [],
|
||||
notes=data.get("notes", ""),
|
||||
due=self._parse_datetime(data.get("due", "")),
|
||||
created=self._parse_datetime(data.get("created", "")),
|
||||
resolved=self._parse_datetime(data.get("resolved", "")),
|
||||
)
|
||||
|
||||
def _get_tasks_json(self, command: str = "show-open") -> list[Task]:
|
||||
"""Get tasks using a dstask command that outputs JSON."""
|
||||
result = self._run_command([command])
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to get tasks: {result.stderr}")
|
||||
return []
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
return [self._parse_task(t) for t in data]
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse dstask output: {e}")
|
||||
return []
|
||||
|
||||
def get_tasks(
|
||||
self,
|
||||
project: Optional[str] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
status: Optional[TaskStatus] = None,
|
||||
) -> list[Task]:
|
||||
"""Get tasks, optionally filtered by project, tags, or status."""
|
||||
# Build filter arguments
|
||||
args = ["show-open"]
|
||||
|
||||
if project:
|
||||
args.append(f"project:{project}")
|
||||
|
||||
if tags:
|
||||
for tag in tags:
|
||||
args.append(f"+{tag}")
|
||||
|
||||
result = self._run_command(args)
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
tasks = [self._parse_task(t) for t in data]
|
||||
|
||||
# Filter by status if specified
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.status == status]
|
||||
|
||||
return tasks
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
def get_next_tasks(self) -> list[Task]:
|
||||
"""Get the 'next' tasks to work on."""
|
||||
return self._get_tasks_json("next")
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[Task]:
|
||||
"""Get a single task by ID or UUID."""
|
||||
result = self._run_command(["show-open"])
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
for t in data:
|
||||
if str(t.get("id")) == task_id or t.get("uuid") == task_id:
|
||||
return self._parse_task(t)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def add_task(
|
||||
self,
|
||||
summary: str,
|
||||
project: Optional[str] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
priority: Optional[TaskPriority] = None,
|
||||
due: Optional[datetime] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> Task:
|
||||
"""Create a new task.
|
||||
|
||||
Notes are added using dstask's / syntax during creation, where
|
||||
everything after / becomes the note content. Each word must be
|
||||
a separate argument for this to work.
|
||||
"""
|
||||
args = ["add", summary]
|
||||
|
||||
if project:
|
||||
args.append(f"project:{project}")
|
||||
|
||||
if tags:
|
||||
for tag in tags:
|
||||
args.append(f"+{tag}")
|
||||
|
||||
if priority:
|
||||
args.append(priority.value)
|
||||
|
||||
if due:
|
||||
# dstask uses various date formats
|
||||
args.append(f"due:{due.strftime('%Y-%m-%d')}")
|
||||
|
||||
# Add notes using / syntax - each word must be a separate argument
|
||||
# dstask interprets everything after "/" as note content
|
||||
if notes:
|
||||
args.append("/")
|
||||
# Split notes into words to pass as separate arguments
|
||||
args.extend(notes.split())
|
||||
|
||||
result = self._run_command(args)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to add task: {result.stderr}")
|
||||
|
||||
# Get the newly created task (it should be the last one)
|
||||
tasks = self.get_next_tasks()
|
||||
if tasks:
|
||||
# Find task by summary (best effort)
|
||||
for task in reversed(tasks):
|
||||
if task.summary == summary:
|
||||
return task
|
||||
|
||||
# Return a placeholder if we can't find it
|
||||
return Task(
|
||||
uuid="",
|
||||
id=0,
|
||||
summary=summary,
|
||||
project=project or "",
|
||||
tags=tags or [],
|
||||
priority=priority or TaskPriority.P2,
|
||||
notes=notes or "",
|
||||
due=due,
|
||||
)
|
||||
|
||||
def complete_task(self, task_id: str) -> bool:
|
||||
"""Mark a task as complete."""
|
||||
result = self._run_command(["done", task_id])
|
||||
return result.returncode == 0
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete a task."""
|
||||
# dstask uses 'remove' for deletion
|
||||
result = self._run_command(["remove", task_id])
|
||||
return result.returncode == 0
|
||||
|
||||
def start_task(self, task_id: str) -> bool:
|
||||
"""Start working on a task (mark as active)."""
|
||||
result = self._run_command(["start", task_id])
|
||||
return result.returncode == 0
|
||||
|
||||
def stop_task(self, task_id: str) -> bool:
|
||||
"""Stop working on a task (mark as pending)."""
|
||||
result = self._run_command(["stop", task_id])
|
||||
return result.returncode == 0
|
||||
|
||||
def modify_task(
|
||||
self,
|
||||
task_id: str,
|
||||
summary: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
priority: Optional[TaskPriority] = None,
|
||||
due: Optional[datetime] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Modify a task."""
|
||||
args = ["modify", task_id]
|
||||
|
||||
if summary:
|
||||
args.append(summary)
|
||||
|
||||
if project:
|
||||
args.append(f"project:{project}")
|
||||
|
||||
if tags:
|
||||
for tag in tags:
|
||||
args.append(f"+{tag}")
|
||||
|
||||
if priority:
|
||||
args.append(priority.value)
|
||||
|
||||
if due:
|
||||
args.append(f"due:{due.strftime('%Y-%m-%d')}")
|
||||
|
||||
result = self._run_command(args)
|
||||
|
||||
# Handle notes separately
|
||||
if notes is not None and result.returncode == 0:
|
||||
self._run_command(["note", task_id, notes])
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
def get_projects(self) -> list[Project]:
|
||||
"""Get all projects."""
|
||||
result = self._run_command(["show-projects"])
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
projects = []
|
||||
for p in data:
|
||||
priority = TaskPriority.from_string(p.get("priority", "P2"))
|
||||
projects.append(
|
||||
Project(
|
||||
name=p.get("name", ""),
|
||||
task_count=p.get("taskCount", 0),
|
||||
resolved_count=p.get("resolvedCount", 0),
|
||||
active=p.get("active", True),
|
||||
priority=priority,
|
||||
)
|
||||
)
|
||||
return projects
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
def get_tags(self) -> list[str]:
|
||||
"""Get all tags."""
|
||||
result = self._run_command(["show-tags"])
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
# show-tags outputs plain text, one tag per line
|
||||
tags = result.stdout.strip().split("\n")
|
||||
return [t.strip() for t in tags if t.strip()]
|
||||
|
||||
def sync(self) -> bool:
|
||||
"""Sync tasks with Git remote."""
|
||||
result = self._run_command(["sync"])
|
||||
return result.returncode == 0
|
||||
|
||||
def edit_task_interactive(self, task_id: str) -> bool:
|
||||
"""Open task in editor for interactive editing."""
|
||||
# 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
|
||||
|
||||
def get_context(self) -> Optional[str]:
|
||||
"""Get the current context filter.
|
||||
|
||||
Returns:
|
||||
Current context string, or None if no context is set
|
||||
"""
|
||||
result = self._run_command(["context"])
|
||||
if result.returncode == 0:
|
||||
context = result.stdout.strip()
|
||||
return context if context else None
|
||||
return None
|
||||
|
||||
def set_context(self, context: Optional[str]) -> bool:
|
||||
"""Set the context filter.
|
||||
|
||||
Args:
|
||||
context: Context string (e.g., "+work", "project:foo") or None to clear
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if context is None or context.lower() == "none" or context == "":
|
||||
result = self._run_command(["context", "none"])
|
||||
else:
|
||||
result = self._run_command(["context", context])
|
||||
return result.returncode == 0
|
||||
|
||||
def get_contexts(self) -> list[str]:
|
||||
"""Get available contexts based on tags.
|
||||
|
||||
For dstask, contexts are typically tag-based filters like "+work".
|
||||
We derive available contexts from the existing tags.
|
||||
|
||||
Returns:
|
||||
List of context strings (tag-based)
|
||||
"""
|
||||
# Get all tags and convert to context format
|
||||
tags = self.get_tags()
|
||||
return [f"+{tag}" for tag in tags if tag]
|
||||
0
src/services/gitlab_monitor/__init__.py
Normal file
0
src/services/gitlab_monitor/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user