diff --git a/drive_view_tui.py b/drive_view_tui.py index a2cd3fb..466c641 100644 --- a/drive_view_tui.py +++ b/drive_view_tui.py @@ -61,7 +61,8 @@ class OneDriveTUI(App): self.access_token = None self.drives = [] self.followed_items = [] - self.current_items = [] # Store currently displayed items + + self.current_items = {} # Store currently displayed items self.msal_app = None self.cache = msal.SerializableTokenCache() # Read Azure app credentials from environment variables @@ -105,6 +106,7 @@ class OneDriveTUI(App): self.query_one("#view_options").border_title = "My Files" # Initialize the table table = self.query_one("#items_table") + table.cursor_type = "row" table.add_columns("Type", "Name", "Last Modified", "Size", "Web URL") # Load cached token if available @@ -256,29 +258,60 @@ class OneDriveTUI(App): if self.selected_drive_id: self.load_root_items() + async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle row selection in the items table.""" + selected_id = event.row_key.value + self.open_item(selected_id) + + async def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self.selected_item_id = event.row_key.value + + def open_item(self, selected_id: str): + if selected_id: + # Get an item from current items by ID string + selected_row = self.current_items[selected_id] + + item_name = selected_row.get("name") + + # Check if it's a folder + is_folder = bool(selected_row.get("folder")) + + if is_folder: + self.notify(f"Selected folder: {item_name}") + # Load items in the folder + self.query_one("#status_label").update(f"Loading items in folder: {item_name}") + self.load_root_items(folder_id=selected_id, drive_id=selected_row.get("parentReference", {}).get("driveId", self.selected_drive_id)) + + else: + self.notify(f"Selected file: {item_name}") + self.action_view_document() + @work - async def load_root_items(self): + async def load_root_items(self, folder_id: str = "", drive_id: str = ""): """Load root items from the selected drive.""" if not self.access_token or not self.selected_drive_id: return self.query_one("#status_label").update("Loading root items...") headers = {"Authorization": f"Bearer {self.access_token}"} + url = f"https://graph.microsoft.com/v1.0/me/drive/root/children" + if folder_id and drive_id: + url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}/children" + self.selected_drive_id = drive_id try: - url = f"https://graph.microsoft.com/v1.0/me/drive/root/children" async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers) as response: if response.status != 200: - self.notify(f"Failed to load root items: {response.status}", severity="error") + self.notify(f"Failed to load drive items: {response.status}", severity="error") return items_data = await response.json() - self.current_items = items_data.get("value", []) + # Update the table with the root items - self.update_items_table(self.current_items, is_root_view=True) + self.update_items_table(items_data.get("value", []), is_root_view=True) except Exception as e: self.notify(f"Error loading root items: {str(e)}", severity="error") @@ -303,11 +336,11 @@ class OneDriveTUI(App): return items_data = await response.json() - self.followed_items = items_data.get("value", []) - self.current_items = self.followed_items + followed_items = items_data.get("value", []) + # Update the table with the followed items - self.update_items_table(self.followed_items) + self.update_items_table(followed_items) except Exception as e: self.notify(f"Error loading followed items: {str(e)}", severity="error") @@ -315,7 +348,7 @@ class OneDriveTUI(App): def update_items_table(self, items, is_root_view=False): """Update the table with the given items.""" - table = self.query_one("#items_table") + table = self.query_one(DataTable) table.clear() if not items: @@ -356,8 +389,11 @@ class OneDriveTUI(App): size_str = "N/A" web_url = item.get("webUrl", "") - - table.add_row(item_type, name, last_modified, size_str, web_url) + item_id = item.get("id") + item_drive_id = item.get("parentReference", {}).get("driveId", self.selected_drive_id) + row_key = table.add_row(item_type, name, last_modified, size_str, web_url, key=item.get("id")) + # add item to to the list of current items keyed by row_key so we can look up all information later + self.current_items[row_key] = item async def action_next_view(self) -> None: """Switch to the next view (Following/Root).""" @@ -400,44 +436,20 @@ class OneDriveTUI(App): # Use Textual's built-in open_url method self.app.open_url(web_url) - async def action_view_document(self) -> None: + def action_view_document(self) -> None: """View the selected document using the DocumentViewerScreen.""" - table = self.query_one("#items_table") - if table.cursor_row is None: - return - # Get the name of the selected item - selected_row = table.get_row_at(table.cursor_row) + + selected_row = self.current_items.get(self.selected_item_id) if not selected_row: return - selected_name = selected_row[1] - - # Find the item in our list to get its ID - selected_item = None - for item in self.current_items: - if item.get("name") == selected_name: - selected_item = item - break - - if not selected_item: - self.notify("Could not find the selected item details", severity="error") - return - - # Check if it's a folder - cannot view folders - if selected_item.get("folder"): - self.notify("Cannot preview folders. Use 'Open URL' to view in browser.", severity="warning") - return - - # Get the item ID - item_id = selected_item.get("id") - - if not item_id: - self.notify("Item ID not found", severity="error") - return - - # Open the document viewer screen - viewer = DocumentViewerScreen(item_id, selected_name, self.access_token, selected_item.get("parentReference").get("driveId")) + selected_name = selected_row.get("name") + drive_id = selected_row.get("parentReference", {}).get("driveId", self.selected_drive_id) + web_url = selected_row.get("webUrl", "") + # Open the document viewer screen with all required details + viewer = DocumentViewerScreen(self.selected_item_id, selected_name, self.access_token, drive_id) + viewer.web_url = web_url # Pass the webUrl to the viewer self.push_screen(viewer) async def action_quit(self) -> None: diff --git a/drive_view_tui.tcss b/drive_view_tui.tcss index cea2dd7..af90a5e 100644 --- a/drive_view_tui.tcss +++ b/drive_view_tui.tcss @@ -37,20 +37,18 @@ #content_container { margin-top: 1; height: 1fr; - border: round $accent; + } /* Status and loading elements */ #status_label { text-align: center; - margin-bottom: 1; + color: $accent; + padding:1; } -#loading { - align: center middle; - margin: 2; -} + #view_options { border: round $secondary; @@ -124,13 +122,13 @@ #document_title { color: $accent; - text-align: center; - padding: 0 1; + text-align: left; + text-style: bold; margin-bottom: 0; width: 1fr; - height: 3; - align: left middle; + height: 1; + } #plaintext_content { diff --git a/maildir_gtd/screens/DocumentViewer.py b/maildir_gtd/screens/DocumentViewer.py index 384ffe9..eabf279 100644 --- a/maildir_gtd/screens/DocumentViewer.py +++ b/maildir_gtd/screens/DocumentViewer.py @@ -2,7 +2,8 @@ import os import io import asyncio import tempfile -from typing import Optional +from typing import Optional, Tuple, Set +from pathlib import Path import aiohttp import mammoth @@ -10,20 +11,50 @@ from docx import Document from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Container, ScrollableContainer, Horizontal +from textual.containers import Container, ScrollableContainer, Horizontal, Vertical from textual.screen import Screen from textual.widgets import Label, Markdown, LoadingIndicator, Button, Footer from textual.worker import Worker, get_current_worker from textual import work +from textual.reactive import Reactive, reactive + + +# Define convertible formats +PDF_CONVERTIBLE_FORMATS = { + "doc", "docx", "epub", "eml", "htm", "html", "md", "msg", "odp", + "ods", "odt", "pps", "ppsx", "ppt", "pptx", "rtf", "tif", "tiff", + "xls", "xlsm", "xlsx" +} + +JPG_CONVERTIBLE_FORMATS = { + "3g2", "3gp", "3gp2", "3gpp", "3mf", "ai", "arw", "asf", "avi", + "bas", "bash", "bat", "bmp", "c", "cbl", "cmd", "cool", "cpp", + "cr2", "crw", "cs", "css", "csv", "cur", "dcm", "dcm30", "dic", + "dicm", "dicom", "dng", "doc", "docx", "dwg", "eml", "epi", "eps", + "epsf", "epsi", "epub", "erf", "fbx", "fppx", "gif", "glb", "h", + "hcp", "heic", "heif", "htm", "html", "ico", "icon", "java", "jfif", + "jpeg", "jpg", "js", "json", "key", "log", "m2ts", "m4a", "m4v", + "markdown", "md", "mef", "mov", "movie", "mp3", "mp4", "mp4v", "mrw", + "msg", "mts", "nef", "nrw", "numbers", "obj", "odp", "odt", "ogg", + "orf", "pages", "pano", "pdf", "pef", "php", "pict", "pl", "ply", + "png", "pot", "potm", "potx", "pps", "ppsx", "ppsxm", "ppt", "pptm", + "pptx", "ps", "ps1", "psb", "psd", "py", "raw", "rb", "rtf", "rw1", + "rw2", "sh", "sketch", "sql", "sr2", "stl", "tif", "tiff", "ts", + "txt", "vb", "webm", "wma", "wmv", "xaml", "xbm", "xcf", "xd", "xml", + "xpm", "yaml", "yml" +} class DocumentViewerScreen(Screen): """Screen for viewing document content from OneDrive items.""" + web_url: Reactive[str] = reactive("") + download_url: Reactive[str] = reactive("") BINDINGS = [ Binding("escape", "close", "Close"), Binding("q", "close", "Close"), Binding("m", "toggle_mode", "Toggle Mode"), + Binding("e", "export_and_open", "Export & Open"), ] def __init__(self, item_id: str, item_name: str, access_token: str, drive_id: str): @@ -33,6 +64,7 @@ class DocumentViewerScreen(Screen): item_id: The ID of the item to view. item_name: The name of the item to display. access_token: The access token for API requests. + drive_id: The ID of the drive containing the item. """ super().__init__() self.item_id = item_id @@ -44,15 +76,18 @@ class DocumentViewerScreen(Screen): self.is_markdown_mode = False self.content_type = None self.raw_content = None + self.file_extension = Path(item_name).suffix.lower().lstrip('.') def compose(self) -> ComposeResult: """Compose the document viewer screen.""" yield Container( Horizontal( - Label(f"Viewing: {self.item_name}", id="document_title"), + Container( + Label(f"Viewing: {self.item_name}", id="document_title"), + Label(f'[link="{self.web_url}"]Open on Web[/link] | [link="{self.download_url}"]Download File[/link]', id="document_link"), + ), Container( Button("Close", id="close_button"), - Button("Toggle Mode", id="toggle_mode_button"), id="button_container" ), id="top_container" @@ -77,17 +112,54 @@ class DocumentViewerScreen(Screen): self.dismiss() elif event.button.id == "toggle_mode_button": self.action_toggle_mode() + elif event.button.id == "export_button": + self.action_export_and_open() + + + def is_convertible_format(self) -> bool: + """Check if the current file is convertible to PDF or JPG.""" + return (self.file_extension in PDF_CONVERTIBLE_FORMATS or + self.file_extension in JPG_CONVERTIBLE_FORMATS) + + def get_conversion_format(self) -> str: + """Get the appropriate conversion format (pdf or jpg) for the current file.""" + if self.file_extension in PDF_CONVERTIBLE_FORMATS: + return "pdf" + elif self.file_extension in JPG_CONVERTIBLE_FORMATS: + return "jpg" + return None @work async def download_document(self) -> None: """Download the document content.""" + + headers = {"Authorization": f"Bearer {self.access_token}"} + try: + metadataUrl = f"https://graph.microsoft.com/v1.0/drives/{self.drive_id}/items/{self.item_id}" + async with aiohttp.ClientSession() as session: + async with session.get(metadataUrl, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + self.notify(f"Failed to fetch document metadata: {error_text}", severity="error") + return + + metadata = await response.json() + self.item_name = metadata.get("name", self.item_name) + self.file_extension = Path(self.item_name).suffix.lower().lstrip('.') + self.download_url = metadata.get("@microsoft.graph.downloadUrl", "") + self.web_url = metadata.get("webUrl", "") + + + except Exception as e: + self.notify(f"Error downloading document: {str(e)}", severity="error") + try: url = f"https://graph.microsoft.com/v1.0/drives/{self.drive_id}/items/{self.item_id}/content" - headers = {"Authorization": f"Bearer {self.access_token}"} # Show loading indicator self.query_one("#content_container").loading = True + async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers) as response: if response.status != 200: @@ -129,7 +201,12 @@ class DocumentViewerScreen(Screen): self.update_content_display() else: # For other types, display a generic message - self.document_content = f"*File: {self.item_name}*\n\nContent type: {self.content_type}\n\nThis file type cannot be displayed in the viewer. Use the 'Open URL' command to view this file in your browser." + conversion_info = "" + if self.is_convertible_format(): + conversion_format = self.get_conversion_format() + conversion_info = f"\n\nThis file can be converted to {conversion_format.upper()}. Press 'e' or click 'Export & Open' to convert and view." + self.document_content = f"*File: {self.item_name}*\n\nContent type: {self.content_type}{conversion_info}\n\nThis file type cannot be displayed directly in the viewer. You could [open in your browser]({self.web_url}), or [download the file]({self.download_url})." + self.is_markdown_mode = True self.update_content_display() except Exception as e: self.notify(f"Error processing content: {str(e)}", severity="error") @@ -175,13 +252,63 @@ class DocumentViewerScreen(Screen): plaintext_widget.remove_class("hidden") markdown_widget.add_class("hidden") + @work + async def export_and_open_converted_file(self) -> None: + """Export the file in converted format and open it.""" + if not self.is_convertible_format(): + self.notify("This file format cannot be converted.", severity="warning") + return + + conversion_format = self.get_conversion_format() + if not conversion_format: + self.notify("No appropriate conversion format found.", severity="error") + return + + try: + # Build the URL with the format parameter + url = f"https://graph.microsoft.com/v1.0/drives/{self.drive_id}/items/{self.item_id}/content?format={conversion_format}" + headers = {"Authorization": f"Bearer {self.access_token}"} + + # Download the converted file + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status != 200: + error_text = await response.text() + self.notify(f"Failed to export document: {error_text}", severity="error") + return + + converted_content = await response.read() + + # Create temporary file with the right extension + file_name = f"{os.path.splitext(self.item_name)[0]}.{conversion_format}" + with tempfile.NamedTemporaryFile(suffix=f".{conversion_format}", + delete=False, + prefix=f"onedrive_export_") as temp_file: + temp_file.write(converted_content) + temp_path = temp_file.name + + # Open the file using the system default application + self.notify(f"Opening exported {conversion_format.upper()} file: {file_name}") + self.app.open_url(f"file://{temp_path}") + self.query_one("#content_container").loading = False + + except Exception as e: + self.notify(f"Error exporting document: {str(e)}", severity="error") + async def action_toggle_mode(self) -> None: """Toggle between Markdown and plaintext display modes.""" + self.notify("Switching Modes", severity="info") self.is_markdown_mode = not self.is_markdown_mode self.update_content_display() mode_name = "Markdown" if self.is_markdown_mode else "Plain Text" self.notify(f"Switched to {mode_name} mode") + async def action_export_and_open(self) -> None: + """Export the file in converted format and open it.""" + self.query_one("#content_container").loading = True + self.notify("Exporting and opening the converted file...") + self.export_and_open_converted_file() + async def action_close(self) -> None: """Close the document viewer screen.""" self.dismiss()