diff --git a/fetch_outlook.py b/fetch_outlook.py index a4bed74..86c9a04 100644 --- a/fetch_outlook.py +++ b/fetch_outlook.py @@ -97,6 +97,9 @@ def synchronize_maildir(maildir_path, headers): ) if response.status_code != 201: # 201 Created indicates success print(f"Failed to move message to 'Archive': {message_id}, {response.status_code}, {response.text}") + if response.status_code == 404: + os.remove(filepath) # Remove the file from local archive if not found on server + # Save the current sync timestamp save_sync_timestamp() @@ -311,7 +314,7 @@ new_files = set(glob.glob(os.path.join(new_dir, '*.eml'))) cur_files = set(glob.glob(os.path.join(cur_dir, '*.eml'))) for filename in Set.union(cur_files, new_files): - message_id = filename.split('.')[0] # Extract the Message-ID from the filename + message_id = filename.split('.')[0].split('/')[-1] # Extract the Message-ID from the filename if (message_id not in inbox_msg_ids): print(f"Deleting {filename} from inbox") os.remove(filename) diff --git a/maildir_gtd/actions/__pycache__/open.cpython-311.pyc b/maildir_gtd/actions/__pycache__/open.cpython-311.pyc index f16fa57..fe73ff7 100644 Binary files a/maildir_gtd/actions/__pycache__/open.cpython-311.pyc and b/maildir_gtd/actions/__pycache__/open.cpython-311.pyc differ diff --git a/maildir_gtd/actions/open.py b/maildir_gtd/actions/open.py index b9b3632..917bb00 100644 --- a/maildir_gtd/actions/open.py +++ b/maildir_gtd/actions/open.py @@ -3,10 +3,12 @@ from maildir_gtd.screens.OpenMessage import OpenMessageScreen def action_open(app) -> None: """Show the input modal for opening a specific message by ID.""" - def check_id(message_id: str) -> bool: + def check_id(message_id: str | None) -> bool: try: int(message_id) app.show_message(message_id) + if (message_id is not None and message_id > 0): + app.show_message(message_id) except ValueError: app.bell() app.show_status("Invalid message ID. Please enter an integer.", severity="error") diff --git a/maildir_gtd/app.py b/maildir_gtd/app.py index 30f6a31..ad06b0c 100644 --- a/maildir_gtd/app.py +++ b/maildir_gtd/app.py @@ -19,7 +19,7 @@ from textual.widgets import Footer, Static, Label, Markdown, ListView, ListItem from textual.reactive import reactive, Reactive from textual.binding import Binding from textual.timer import Timer -from textual.containers import ScrollableContainer, Grid +from textual.containers import ScrollableContainer, Grid, Vertical, Horizontal from actions.archive import archive_current from actions.delete import delete_current @@ -48,7 +48,6 @@ 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) folder = reactive("INBOX") @@ -61,6 +60,8 @@ class EmailViewerApp(App): newest_id: Reactive[int] = reactive(0) msg_worker: Worker | None = None message_body_cache: dict[int, str] = {} + total_messages: Reactive[int] = reactive(0) + status_title = reactive("Message View") def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield from super().get_system_commands(screen) @@ -72,7 +73,7 @@ class EmailViewerApp(App): 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.action_fetch_list) + yield SystemCommand("Reload", "Reload the message list", self.fetch_envelopes) BINDINGS = [ Binding("j", "next", "Next message"), @@ -83,40 +84,60 @@ class EmailViewerApp(App): Binding("q", "quit", "Quit application"), Binding("h", "toggle_header", "Toggle Envelope Header"), Binding("t", "create_task", "Create Task"), - Binding("ctrl-r", "reload", "Reload message list") + 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") ] BINDINGS.extend([ - Binding("down", "scroll_down", "Scroll down"), - Binding("up", "scroll_up", "Scroll up"), Binding("space", "scroll_page_down", "Scroll page down"), Binding("b", "scroll_page_up", "Scroll page up") ]) def compose(self) -> ComposeResult: - """Create child widgets for the app.""" - yield Grid( - ListView(ListItem(Label("All emails...")), id="list_view", initial_index=0), - ScrollableContainer( - StatusTitle().data_bind(EmailViewerApp.current_message_id), + 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" + ), + ScrollableContainer( EnvelopeHeader(), Markdown(), - id="main_content", - ) - ) + 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 + self.query_one("#envelopes_list").border_title = "\[1] Emails" + self.query_one("#accounts_list").border_title = "\[2] Accounts" + + self.query_one("#folders_list").border_title = "\[3] Folders" + # self.query_one(ListView).data_bind(index=EmailViewerApp.current_message_index) # self.watch(self.query_one(StatusTitle), "current_message_id", update_progress) # Fetch the ID of the most recent message using the Himalaya CLI - worker = self.action_fetch_list() + 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) -> None: + return f"Message ID: {self.current_message_id} | [b]{self.current_message_index}[/b]/{self.total_messages}" + + def watch_status_title(self, old_status_title: str, new_status_title: str) -> None: + self.query_one("#main_content").border_title = new_status_title + def compute_newest_id(self) -> None: if not self.all_envelopes: return 0 @@ -146,7 +167,7 @@ class EmailViewerApp(App): def watch_reload_needed(self, old_reload_needed: bool, new_reload_needed: bool) -> None: logging.info(f"Reload needed: {new_reload_needed}") if (old_reload_needed == False and new_reload_needed == True): - self.action_fetch_list() + self.fetch_envelopes() def watch_current_message_id(self, old_message_id: int, new_message_id: int) -> None: @@ -165,7 +186,7 @@ class EmailViewerApp(App): headers.to = envelope['to']['addr'] headers.date = datetime.strptime(envelope['date'].replace("+00:00", ""), "%Y-%m-%d %H:%M").strftime("%a %b %d %H:%M") headers.cc = envelope['cc']['addr'] if 'cc' in envelope else "" - self.query_one(StatusTitle).current_message_index = index + # self.query_one(StatusTitle).current_message_index = index self.query_one(ListView).index = index break @@ -211,8 +232,8 @@ class EmailViewerApp(App): logging.error(f"Error fetching message content: {e}") @work(exclusive=False) - async def action_fetch_list(self) -> None: - msglist = self.query_one(ListView) + async def fetch_envelopes(self) -> None: + msglist = self.query_one("#envelopes_list") try: msglist.loading = True process = await asyncio.create_subprocess_shell( @@ -227,8 +248,7 @@ class EmailViewerApp(App): envelopes = json.loads(stdout.decode()) if envelopes: self.reload_needed = False - status = self.query_one(StatusTitle) - status.total_messages = len(envelopes) + self.total_messages = len(envelopes) msglist.clear() envelopes = sorted(envelopes, key=lambda x: int(x['id'])) self.all_envelopes = envelopes @@ -243,7 +263,55 @@ class EmailViewerApp(App): finally: msglist.loading = False + @work(exclusive=False) + async def fetch_accounts(self) -> None: + accounts_list = self.query_one("#accounts_list") + try: + accounts_list.loading = True + process = await asyncio.create_subprocess_shell( + "himalaya account list -o json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + logging.info(f"stdout: {stdout.decode()[0:50]}") + if process.returncode == 0: + import json + accounts = json.loads(stdout.decode()) + if accounts: + for account in accounts: + item = ListItem(Label(str(account['name']).strip(), classes="account_name", markup=False)) + accounts_list.append(item) + 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") + folders_list.clear() + folders_list.append(ListItem(Label("INBOX", classes="folder_name", markup=False))) + try: + folders_list.loading = True + process = await asyncio.create_subprocess_shell( + "himalaya folder list -o json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + logging.info(f"stdout: {stdout.decode()[0:50]}") + if process.returncode == 0: + import json + folders = json.loads(stdout.decode()) + if folders: + for folder in folders: + item = ListItem(Label(str(folder['name']).strip(), classes="folder_name", markup=False)) + folders_list.append(item) + except Exception as e: + self.show_status(f"Error fetching folder list: {e}", "error") + finally: + folders_list.loading = False def show_message(self, message_id: int) -> None: self.current_message_id = message_id @@ -260,22 +328,22 @@ class EmailViewerApp(App): def action_next(self) -> None: self.show_message(self.next_id) - self.action_fetch_list() if self.reload_needed else None + self.fetch_envelopes() if self.reload_needed else None def action_previous(self) -> None: - self.action_fetch_list() if self.reload_needed else None + self.fetch_envelopes() if self.reload_needed else None self.show_message(self.previous_id) def action_delete(self) -> None: self.all_envelopes.remove(self.all_envelopes[self.current_message_index]) self.message_body_cache.pop(self.current_message_id, None) - self.query_one(StatusTitle).total_messages = len(self.all_envelopes) + self.total_messages = len(self.all_envelopes) delete_current(self) def action_archive(self) -> None: self.all_envelopes.remove(self.all_envelopes[self.current_message_index]) self.message_body_cache.pop(self.current_message_id, None) - self.query_one(StatusTitle).total_messages = len(self.all_envelopes) + self.total_messages = len(self.all_envelopes) archive_current(self) def action_open(self) -> None: @@ -306,13 +374,22 @@ class EmailViewerApp(App): self.exit() def action_oldest(self) -> None: - self.action_fetch_list() if self.reload_needed else None + self.fetch_envelopes() if self.reload_needed else None self.show_message(self.oldest_id) def action_newest(self) -> None: - self.action_fetch_list() if self.reload_needed else None + self.fetch_envelopes() if self.reload_needed else None self.show_message(self.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() + if __name__ == "__main__": app = EmailViewerApp() app.run() diff --git a/maildir_gtd/email_viewer.tcss b/maildir_gtd/email_viewer.tcss index a10954e..954345f 100644 --- a/maildir_gtd/email_viewer.tcss +++ b/maildir_gtd/email_viewer.tcss @@ -1,5 +1,37 @@ /* 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; + +} + +#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); @@ -25,7 +57,6 @@ StatusTitle { EnvelopeHeader { dock: top; - margin-top: 1; width: 100%; max-height: 2; tint: $primary 10%; @@ -35,21 +66,15 @@ Markdown { padding: 1 2; } -ListView { - dock: left; - width: 30%; - height: 100%; - padding: 0; -} - .email_subject { - width: 100%; + width: 1fr; padding: 0 } .header_key { tint: gray 20%; min-width: 10; + text-style:bold; } .header_value { @@ -57,3 +82,32 @@ ListView { height: auto; width: auto; } + +.modal_screen { + align: center middle; + margin: 1; + padding: 2; + border: round $border; + background: $panel; + width: auto; + height: auto; +} + + +#create_task_container { + width: 50%; + height: 50%; + border: heavy $secondary; + layout: horizontal; + align: center middle; + Label { + width: auto; + } + Input { + width: 1fr; + } +} + +#envelopes-list ListItem:odd { + background: rgb(25, 24, 26); +} diff --git a/maildir_gtd/screens/CreateTask.py b/maildir_gtd/screens/CreateTask.py index ddd433f..7de2c95 100644 --- a/maildir_gtd/screens/CreateTask.py +++ b/maildir_gtd/screens/CreateTask.py @@ -1,15 +1,24 @@ from textual import on -from textual.app import ComposeResult, Screen -from textual.widgets import Input, Label -from textual.containers import Horizontal +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Input, Label, Button +from textual.containers import Horizontal, Vertical -class CreateTaskScreen(Screen[str]): +class CreateTaskScreen(ModalScreen[str]): def compose(self) -> ComposeResult: - yield Horizontal( - Label("$>", id="task_prompt"), - Label("task add ", id="task_prompt_label"), - Input(placeholder="arguments", id="task_input") + yield Vertical( + Horizontal( + Label("$>", id="task_prompt"), + Label("task add ", id="task_prompt_label"), + Input(placeholder="arguments", id="task_input"), + ), + Horizontal( + Button("Cancel"), + Button("Submit") + ), + id="create_task_container", + classes="modal_screen" ) @on(Input.Submitted) diff --git a/maildir_gtd/screens/OpenMessage.py b/maildir_gtd/screens/OpenMessage.py index a9234ee..2cf0003 100644 --- a/maildir_gtd/screens/OpenMessage.py +++ b/maildir_gtd/screens/OpenMessage.py @@ -1,22 +1,24 @@ from textual import on -from textual.app import ComposeResult, Screen +from textual.app import ComposeResult +from textual.screen import ModalScreen from textual.widgets import Input, Label, Button -from textual.containers import Horizontal +from textual.containers import Container + +class OpenMessageScreen(ModalScreen[int | None]): -class OpenMessageScreen(Screen[int]): def compose(self) -> ComposeResult: - yield Horizontal( + yield Container( Label("📨", id="message_label"), Input(placeholder="Enter message ID (integer only)", type="integer", id="open_message_input"), - Button("Open", variant="primary", id="open_message_button") + Button("Open", variant="primary", id="open_message_button"), + id="open_message_container", + classes="modal_screen" ) @on(Input.Submitted) def handle_message_id(self) -> None: input_widget = self.query_one("#open_message_input", Input) - self.disabled = True - self.loading = True - message_id = int(input_widget.value) + message_id = int(input_widget.value if input_widget.value else 0) self.dismiss(message_id) @on(Input._on_key) diff --git a/maildir_gtd/screens/__pycache__/CreateTask.cpython-311.pyc b/maildir_gtd/screens/__pycache__/CreateTask.cpython-311.pyc index 364ca27..c719a93 100644 Binary files a/maildir_gtd/screens/__pycache__/CreateTask.cpython-311.pyc and b/maildir_gtd/screens/__pycache__/CreateTask.cpython-311.pyc differ diff --git a/maildir_gtd/screens/__pycache__/OpenMessage.cpython-311.pyc b/maildir_gtd/screens/__pycache__/OpenMessage.cpython-311.pyc index 596d82b..092e342 100644 Binary files a/maildir_gtd/screens/__pycache__/OpenMessage.cpython-311.pyc and b/maildir_gtd/screens/__pycache__/OpenMessage.cpython-311.pyc differ