From 9ad483dca811864655b0aaea3fcb0faca6c67503 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Fri, 9 May 2025 13:55:12 -0600 Subject: [PATCH] adding file browsing --- drive_view_tui.py | 333 +++++++++++++++----------- drive_view_tui.tcss | 72 +++++- maildir_gtd/screens/DocumentViewer.py | 187 +++++++++++++++ maildir_gtd/screens/__init__.py | 7 +- pyproject.toml | 2 + uv.lock | 63 +++++ 6 files changed, 509 insertions(+), 155 deletions(-) create mode 100644 maildir_gtd/screens/DocumentViewer.py diff --git a/drive_view_tui.py b/drive_view_tui.py index 25c1422..a2cd3fb 100644 --- a/drive_view_tui.py +++ b/drive_view_tui.py @@ -1,5 +1,4 @@ import os -from select import select import sys import json import asyncio @@ -7,7 +6,8 @@ from datetime import datetime import msal import aiohttp - +from rich.panel import Panel +from rich import print as rprint from textual.app import App, ComposeResult from textual.binding import Binding @@ -21,11 +21,17 @@ from textual.widgets import ( Button, ListView, ListItem, - LoadingIndicator + LoadingIndicator, + OptionList ) from textual.reactive import reactive from textual.worker import Worker, get_current_worker from textual import work +from textual.widgets.option_list import Option + +# Import our DocumentViewerScreen +sys.path.append(os.path.join(os.path.dirname(__file__), "maildir_gtd")) +from maildir_gtd.screens.DocumentViewer import DocumentViewerScreen class OneDriveTUI(App): @@ -37,6 +43,7 @@ class OneDriveTUI(App): is_authenticated = reactive(False) selected_drive_id = reactive("") drive_name = reactive("") + current_view = reactive("Following") # Track current view: "Following" or "Root" # App bindings BINDINGS = [ @@ -44,7 +51,9 @@ class OneDriveTUI(App): Binding("r", "refresh", "Refresh"), Binding("f", "toggle_follow", "Toggle Follow"), Binding("o", "open_url", "Open URL"), - + Binding("enter", "open_url", "Open URL"), + Binding("v", "view_document", "View Document"), + Binding("tab", "next_view", "Switch View"), ] def __init__(self): @@ -52,11 +61,13 @@ class OneDriveTUI(App): self.access_token = None self.drives = [] self.followed_items = [] + self.current_items = [] # Store currently displayed items self.msal_app = None - self.cache = None + self.cache = msal.SerializableTokenCache() # Read Azure app credentials from environment variables self.client_id = os.getenv("AZURE_CLIENT_ID") self.tenant_id = os.getenv("AZURE_TENANT_ID") + self.scopes = ["https://graph.microsoft.com/Files.ReadWrite.All"] self.cache_file = "token_cache.bin" @@ -65,29 +76,36 @@ class OneDriveTUI(App): yield Header(show_clock=True) with Container(id="main_container"): + yield LoadingIndicator(id="loading") yield Label("Authenticating with Microsoft Graph API...", id="status_label") + with Container(id="auth_container"): yield Label("", id="auth_message") yield Button("Login", id="login_button", variant="primary") - with Horizontal(id="content_container", classes="hide"): - with Vertical(id="drive_container"): - yield ListView(id="drive_list") - with Vertical(id="items_container"): - yield DataTable(id="items_table") - yield Label("No items found", id="no_items_label", classes="hide") + with Container(id="content_container", classes="hide"): + with Horizontal(): + with Vertical(id="navigation_container"): + + yield OptionList( + Option("Following", id="following"), + Option("Root", id="root"), + id="view_options" + ) + + with Vertical(id="items_container"): + yield DataTable(id="items_table") + yield Label("No items found", id="no_items_label", classes="hide") yield Footer() - def on_mount(self) -> None: + async def on_mount(self) -> None: """Initialize the app when mounted.""" - self.cache = msal.SerializableTokenCache() - self.query_one("#auth_container").ALLOW_SELECT = True + self.query_one("#login_button").styles.width = "20" + self.query_one("#view_options").border_title = "My Files" # Initialize the table - self.query_one("#drive_list").border_title = "Available Drives" - self.query_one("#content_container").border_title = "Followed Items" table = self.query_one("#items_table") - table.add_columns("Name", "Type", "Last Modified", "Size", "Web URL") + table.add_columns("Type", "Name", "Last Modified", "Size", "Web URL") # Load cached token if available if os.path.exists(self.cache_file): @@ -96,9 +114,9 @@ class OneDriveTUI(App): # Initialize MSAL app self.initialize_msal() - self.notify("Initializing MSAL app...", severity="info") - + # Try silent authentication first + self.authenticate_silent() def initialize_msal(self) -> None: """Initialize the MSAL application.""" @@ -114,23 +132,20 @@ class OneDriveTUI(App): self.msal_app = msal.PublicClientApplication( self.client_id, authority=authority, token_cache=self.cache ) - # Try silent authentication first + + def authenticate_silent(self) -> None: + """Try silent authentication first.""" if not self.msal_app: return accounts = self.msal_app.get_accounts() if accounts: self.query_one("#status_label").update("Trying silent authentication...") - worker = self.get_token_silent(accounts[0]) - worker.wait() - self.query_one("#status_label").update("Authenticated successfully.") - self.query_one("#auth_container").add_class("hide") - self.notify("Authenticated successfully.", severity="success") + self.get_token_silent(accounts[0]) else: self.query_one("#status_label").update("Please log in to continue.") self.query_one("#auth_container").remove_class("hide") - - + self.query_one("#loading").remove() @work async def get_token_silent(self, account): @@ -143,18 +158,17 @@ class OneDriveTUI(App): else: self.query_one("#status_label").update("Silent authentication failed. Please log in.") self.query_one("#auth_container").remove_class("hide") - self.query_one("#content_container").loading = False + self.query_one("#loading").remove() - def on_button_pressed(self, event: Button.Pressed) -> None: + async def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "login_button": self.initiate_device_flow() - - def initiate_device_flow(self): - self.notify("Starting device code flow...", severity="info") + @work + async def initiate_device_flow(self): """Initiate the MSAL device code flow.""" - self.query_one("#content_container").loading = True + self.query_one("#loading").styles.display = "block" self.query_one("#status_label").update("Initiating device code flow...") # Initiate device flow @@ -163,65 +177,33 @@ class OneDriveTUI(App): if "user_code" not in flow: self.notify("Failed to create device flow", severity="error") return - # self.notify(str(flow), severity="info") + # Display the device code message self.query_one("#auth_message").update(flow["message"]) - self.query_one("#status_label").update("Waiting for authentication...") - self.wait_for_device_code(flow) + # Wait for the user to authenticate + token_response = self.msal_app.acquire_token_by_device_flow(flow) - @work(thread=True) - async def wait_for_device_code(self, flow): - """Wait for the user to authenticate using the device code.""" - - # Poll for token acquisition - result = self.msal_app.acquire_token_by_device_flow(flow) - if "access_token" in result: - self.access_token = result["access_token"] - self.is_authenticated = True - - elif "error" in result and result["error"] == "authorization_pending": - # Wait before polling again - asyncio.sleep(5) - else: - self.notify(f"Authentication failed: {result.get('error_description', 'Unknown error')}", severity="error") + if "access_token" not in token_response: + self.notify("Failed to acquire token", severity="error") return - # Save the token to cache + # Save token to cache with open(self.cache_file, "w") as f: f.write(self.cache.serialize()) - # Load initial data after authentication + self.access_token = token_response["access_token"] + self.is_authenticated = True + + # Proceed with loading drives and followed items self.load_initial_data() @work async def load_initial_data(self): """Load initial data after authentication.""" - + self.query_one("#status_label").update("Loading drives...") # Load drives first - - # Hide auth container and show content container - self.query_one("#auth_container").add_class("hide") - self.query_one("#content_container").remove_class("hide") - self.query_one("#content_container").loading = False - - worker = self.load_drives() - await worker.wait() - # Find and select the OneDrive drive - for drive in self.drives: - if drive.get("name") == "OneDrive": - self.selected_drive_id = drive.get("id") - self.drive_name = drive.get("name") - break - - # If we have a selected drive, load followed items - if self.selected_drive_id: - self.load_followed_items() - - @work - async def load_drives(self): - """Load OneDrive drives.""" if not self.access_token: return @@ -239,20 +221,69 @@ class OneDriveTUI(App): drives_data = await response.json() self.drives = drives_data.get("value", []) - for drive in self.drives: - drive_name = drive.get("name", "Unknown") - drive_id = drive.get("id", "Unknown") - # Add the drive to the list - self.query_one("#drive_list").append( - ListItem(Label(drive_name)) - ) - - # Update the drives label - if self.drives: - self.query_one("#drive_list").border_subtitle = f"Available: {len(self.drives)}" except Exception as e: self.notify(f"Error loading drives: {str(e)}", severity="error") + # Hide auth container and show content container + self.query_one("#auth_container").add_class("hide") + self.query_one("#content_container").remove_class("hide") + self.query_one("#loading").remove() + + # Find and select the OneDrive drive + for drive in self.drives: + if drive.get("name") == "OneDrive": + self.selected_drive_id = drive.get("id") + self.drive_name = drive.get("name") + break + + # Set Following as default view + option_list = self.query_one("#view_options") + option_list.highlighted = 0 # Select "Following" option + + # If we have a selected drive, load followed items + if self.selected_drive_id: + self.load_followed_items() + + async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Handle option list selection.""" + selected_option = event.option.id + if selected_option == "following": + self.current_view = "Following" + if self.selected_drive_id: + self.load_followed_items() + elif selected_option == "root": + self.current_view = "Root" + if self.selected_drive_id: + self.load_root_items() + + @work + async def load_root_items(self): + """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}"} + + 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") + 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) + except Exception as e: + self.notify(f"Error loading root items: {str(e)}", severity="error") + + self.query_one("#status_label").update("Ready") + @work async def load_followed_items(self): """Load followed items from the selected drive.""" @@ -262,81 +293,44 @@ class OneDriveTUI(App): self.query_one("#status_label").update("Loading followed items...") headers = {"Authorization": f"Bearer {self.access_token}"} - # Update drive label - self.query_one("#drive_list").index = 0 - try: - url = f"https://graph.microsoft.com/v1.0/me/drives/{self.selected_drive_id}/following" + url = f"https://graph.microsoft.com/v1.0/me/drive/following" 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 followed items: {await response.text()}", severity="error") + self.notify(f"Failed to load followed items: {response.status}", severity="error") return items_data = await response.json() self.followed_items = items_data.get("value", []) + self.current_items = self.followed_items # Update the table with the followed items - # self.update_items_table() - table = self.query_one("#items_table") - table.clear() - - if not self.followed_items: - self.query_one("#no_items_label").remove_class("hide") - return - - self.query_one("#no_items_label").add_class("hide") - - for item in self.followed_items: - name = item.get("name", "Unknown") - item_type = "Folder" if item.get("folder") else "File" - - # Format the last modified date - last_modified = item.get("lastModifiedDateTime", "") - if last_modified: - try: - date_obj = datetime.fromisoformat(last_modified.replace('Z', '+00:00')) - last_modified = date_obj.strftime("%Y-%m-%d %H:%M") - except: - pass - - # Format the size - size = item.get("size", 0) - if size: - if size < 1024: - size_str = f"{size} B" - elif size < 1024 * 1024: - size_str = f"{size / 1024:.1f} KB" - elif size < 1024 * 1024 * 1024: - size_str = f"{size / (1024 * 1024):.1f} MB" - else: - size_str = f"{size / (1024 * 1024 * 1024):.1f} GB" - else: - size_str = "N/A" - - web_url = item.get("webUrl", "") - - table.add_row(name, item_type, last_modified, size_str, web_url) + self.update_items_table(self.followed_items) except Exception as e: self.notify(f"Error loading followed items: {str(e)}", severity="error") self.query_one("#status_label").update("Ready") - async def update_items_table(self): - + def update_items_table(self, items, is_root_view=False): + """Update the table with the given items.""" table = self.query_one("#items_table") table.clear() - if not self.followed_items: + if not items: self.query_one("#no_items_label").remove_class("hide") return self.query_one("#no_items_label").add_class("hide") - for item in self.followed_items: + for item in items: name = item.get("name", "Unknown") - item_type = "Folder" if item.get("folder") else "File" + is_folder = bool(item.get("folder")) + + # Add folder icon if it's a folder and we're in root view + + item_type = "📁" if is_folder else "📄" # Format the last modified date last_modified = item.get("lastModifiedDateTime", "") @@ -363,13 +357,30 @@ class OneDriveTUI(App): web_url = item.get("webUrl", "") - table.add_row(name, item_type, last_modified, size_str, web_url) + table.add_row(item_type, name, last_modified, size_str, web_url) + + async def action_next_view(self) -> None: + """Switch to the next view (Following/Root).""" + option_list = self.query_one("#view_options") + if self.current_view == "Following": + option_list.highlighted = 1 # Switch to Root + self.current_view = "Root" + self.load_root_items() + else: + option_list.highlighted = 0 # Switch to Following + self.current_view = "Following" + self.load_followed_items() + + self.notify(f"Switched to {self.current_view} view") async def action_refresh(self) -> None: """Refresh the data.""" if self.is_authenticated and self.selected_drive_id: - self.load_followed_items() - self.notify("Refreshed followed items") + if self.current_view == "Following": + self.load_followed_items() + elif self.current_view == "Root": + self.load_root_items() + self.notify("Refreshed items") async def action_toggle_follow(self) -> None: """Toggle follow status for selected item.""" @@ -386,9 +397,49 @@ class OneDriveTUI(App): web_url = selected_row[4] if web_url: self.notify(f"Opening URL: {web_url}") - # Use Textual's built-in open_url method instead of os.system + # Use Textual's built-in open_url method self.app.open_url(web_url) + async 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) + 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")) + self.push_screen(viewer) + async def action_quit(self) -> None: """Quit the application.""" self.exit() diff --git a/drive_view_tui.tcss b/drive_view_tui.tcss index 7d676cd..cea2dd7 100644 --- a/drive_view_tui.tcss +++ b/drive_view_tui.tcss @@ -2,7 +2,7 @@ /* Main container */ #main_container { - padding: 1 2; + } /* Authentication container */ @@ -36,6 +36,9 @@ /* Content container that holds drives and items */ #content_container { margin-top: 1; + height: 1fr; + border: round $accent; + } /* Status and loading elements */ @@ -49,6 +52,17 @@ margin: 2; } +#view_options { + border: round $secondary; +} + +#loading_container { + height: 3; + width: 100%; + align: center middle; + margin: 2 0; +} + /* Title styles */ .title { color: $accent; @@ -63,12 +77,12 @@ /* Drive container styles */ #drive_container { width: 1fr; - margin-bottom: 1; + height: 100%; } #drive_list { border: round $primary; - padding: 1; + padding: 0 1; height: 100%; } @@ -80,7 +94,7 @@ /* Items container and table styles */ #items_container { padding: 0; - width: 3fr; + width: 4fr; height: 100%; } @@ -95,8 +109,47 @@ padding: 2; } +/* Document Viewer Screen Styles */ +#document_viewer { + padding: 0 1; + height: 100%; + width: 100%; +} + +#top_container { + height: 3; + width: 100%; + background: $boost; +} + +#document_title { + color: $accent; + text-align: center; + padding: 0 1; + text-style: bold; + margin-bottom: 0; + width: 1fr; + height: 3; + align: left middle; +} + +#plaintext_content { + padding: 1; + width: 100%; +} + +#button_container { + width: auto; + height: 3; + align: right middle; +} + +#button_container Button { + min-width: 16; +} + /* Utility classes */ -.hide { +.hide, .hidden { display: none; } @@ -104,7 +157,6 @@ DataTable { border: solid $accent; background: $primary-background-lighten-1; - margin: 1 0; } DataTable > .datatable--header { @@ -117,10 +169,4 @@ DataTable > .datatable--cursor { background: $secondary; } -/* Override scrollbar styles */ -* { - scrollbar-color: $accent $surface; - scrollbar-background: $surface; - scrollbar-color-hover: $accent-lighten-1; - scrollbar-background-hover: $surface-lighten-1; -} + diff --git a/maildir_gtd/screens/DocumentViewer.py b/maildir_gtd/screens/DocumentViewer.py new file mode 100644 index 0000000..384ffe9 --- /dev/null +++ b/maildir_gtd/screens/DocumentViewer.py @@ -0,0 +1,187 @@ +import os +import io +import asyncio +import tempfile +from typing import Optional + +import aiohttp +import mammoth +from docx import Document + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, ScrollableContainer, Horizontal +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 + + +class DocumentViewerScreen(Screen): + """Screen for viewing document content from OneDrive items.""" + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("q", "close", "Close"), + Binding("m", "toggle_mode", "Toggle Mode"), + ] + + def __init__(self, item_id: str, item_name: str, access_token: str, drive_id: str): + """Initialize the document viewer screen. + + Args: + 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. + """ + super().__init__() + self.item_id = item_id + self.drive_id = drive_id + self.item_name = item_name + self.access_token = access_token + self.document_content = "" + self.plain_text_content = "" + self.is_markdown_mode = False + self.content_type = None + self.raw_content = None + + def compose(self) -> ComposeResult: + """Compose the document viewer screen.""" + yield Container( + Horizontal( + Label(f"Viewing: {self.item_name}", id="document_title"), + Container( + Button("Close", id="close_button"), + Button("Toggle Mode", id="toggle_mode_button"), + id="button_container" + ), + id="top_container" + ), + ScrollableContainer( + Markdown("", id="markdown_content"), + Label("", id="plaintext_content", classes="hidden", markup=False), + id="content_container", + ), + + id="document_viewer" + ) + yield Footer() + + def on_mount(self) -> None: + """Handle screen mount event.""" + self.download_document() + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button press events.""" + if event.button.id == "close_button": + self.dismiss() + elif event.button.id == "toggle_mode_button": + self.action_toggle_mode() + + @work + async def download_document(self) -> None: + """Download the document content.""" + 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: + error_text = await response.text() + self.notify(f"Failed to download document: {error_text}", severity="error") + return + + self.content_type = response.headers.get("content-type", "") + self.raw_content = await response.read() + + # Process the content based on content type + self.process_content() + except Exception as e: + self.notify(f"Error downloading document: {str(e)}", severity="error") + finally: + # Hide loading indicator + self.query_one("#content_container").loading = False + + @work + async def process_content(self) -> None: + """Process the downloaded content based on its type.""" + if not self.raw_content: + self.notify("No content to display", severity="warning") + return + + try: + # Check for Office document types + if self.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + # Process as DOCX + self.process_docx() + elif self.content_type.startswith("text/"): + # Process as plain text + text_content = self.raw_content.decode("utf-8", errors="replace") + self.document_content = text_content + self.update_content_display() + elif self.content_type.startswith("image/"): + # For images, just display a message + self.document_content = f"*Image file: {self.item_name}*\n\nUse the 'Open URL' command to view this image in your browser." + 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." + self.update_content_display() + except Exception as e: + self.notify(f"Error processing content: {str(e)}", severity="error") + + @work + async def process_docx(self) -> None: + """Process DOCX content and convert to Markdown and plain text.""" + try: + # Save the DOCX content to a temporary file + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as temp_file: + temp_file.write(self.raw_content) + temp_path = temp_file.name + + # Convert DOCX to Markdown using mammoth + with open(temp_path, "rb") as docx_file: + result = mammoth.convert_to_markdown(docx_file) + markdown_text = result.value + + # Read the document structure with python-docx for plain text + doc = Document(temp_path) + self.plain_text_content = "\n\n".join([para.text for para in doc.paragraphs if para.text]) + self.document_content = markdown_text + + # Clean up temporary file + os.unlink(temp_path) + + # Store both versions + self.update_content_display() + except Exception as e: + self.notify(f"Error processing DOCX: {str(e)}", severity="error") + + def update_content_display(self) -> None: + """Update the content display with the processed document content.""" + markdown_widget = self.query_one("#markdown_content", Markdown) + plaintext_widget = self.query_one("#plaintext_content", Label) + + if self.is_markdown_mode: + markdown_widget.update(self.document_content) + markdown_widget.remove_class("hidden") + plaintext_widget.add_class("hidden") + else: + plaintext_widget.update(self.plain_text_content) + plaintext_widget.remove_class("hidden") + markdown_widget.add_class("hidden") + + async def action_toggle_mode(self) -> None: + """Toggle between Markdown and plaintext display modes.""" + 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_close(self) -> None: + """Close the document viewer screen.""" + self.dismiss() diff --git a/maildir_gtd/screens/__init__.py b/maildir_gtd/screens/__init__.py index e6ab631..7329037 100644 --- a/maildir_gtd/screens/__init__.py +++ b/maildir_gtd/screens/__init__.py @@ -1 +1,6 @@ -# Initialize the screens subpackage +# Initialize the screens package +from maildir_gtd.screens.CreateTask import CreateTaskScreen +from maildir_gtd.screens.OpenMessage import OpenMessageScreen +from maildir_gtd.screens.DocumentViewer import DocumentViewerScreen + +__all__ = ["CreateTaskScreen", "OpenMessageScreen", "DocumentViewerScreen"] diff --git a/pyproject.toml b/pyproject.toml index 4ad8177..0adac69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,11 @@ requires-python = ">=3.13" dependencies = [ "aiohttp>=3.11.18", "html2text>=2025.4.15", + "mammoth>=1.9.0", "msal>=1.32.3", "orjson>=3.10.18", "python-dateutil>=2.9.0.post0", + "python-docx>=1.1.2", "rich>=14.0.0", "textual>=3.2.0", ] diff --git a/uv.lock b/uv.lock index a1731b1..6544597 100644 --- a/uv.lock +++ b/uv.lock @@ -118,6 +118,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] +[[package]] +name = "cobble" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/7a/a507c709be2c96e1bb6102eb7b7f4026c5e5e223ef7d745a17d239e9d844/cobble-0.1.4.tar.gz", hash = "sha256:de38be1539992c8a06e569630717c485a5f91be2192c461ea2b220607dfa78aa", size = 3805, upload-time = "2024-06-01T18:11:09.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/e1/3714a2f371985215c219c2a70953d38e3eed81ef165aed061d21de0e998b/cobble-0.1.4-py3-none-any.whl", hash = "sha256:36c91b1655e599fd428e2b95fdd5f0da1ca2e9f1abb0bc871dec21a0e78a2b44", size = 3984, upload-time = "2024-06-01T18:11:07.911Z" }, +] + [[package]] name = "cryptography" version = "44.0.3" @@ -203,9 +212,11 @@ source = { virtual = "." } dependencies = [ { name = "aiohttp" }, { name = "html2text" }, + { name = "mammoth" }, { name = "msal" }, { name = "orjson" }, { name = "python-dateutil" }, + { name = "python-docx" }, { name = "rich" }, { name = "textual" }, ] @@ -220,9 +231,11 @@ dev = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.11.18" }, { name = "html2text", specifier = ">=2025.4.15" }, + { name = "mammoth", specifier = ">=1.9.0" }, { name = "msal", specifier = ">=1.32.3" }, { name = "orjson", specifier = ">=3.10.18" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, + { name = "python-docx", specifier = ">=1.1.2" }, { name = "rich", specifier = ">=14.0.0" }, { name = "textual", specifier = ">=3.2.0" }, ] @@ -263,6 +276,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, ] +[[package]] +name = "lxml" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" }, + { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" }, + { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" }, + { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" }, + { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" }, + { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" }, +] + +[[package]] +name = "mammoth" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cobble" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/a6/27a13ba068cf3ff764d631b8dd71dee1b33040aa8c143f66ce902b7d1da0/mammoth-1.9.0.tar.gz", hash = "sha256:74f5dae10ca240fd9b7a0e1a6deaebe0aad23bc590633ef6f5e868aa9b7042a6", size = 50906, upload-time = "2024-12-30T10:33:37.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ab/f8e63fcabc127c6efd68b03633c189ee799a5304fa96c036a325a2894bcb/mammoth-1.9.0-py2.py3-none-any.whl", hash = "sha256:0eea277316586f0ca65d86834aec4de5a0572c83ec54b4991f9bb520a891150f", size = 52901, upload-time = "2024-12-30T10:33:34.879Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -478,6 +528,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-docx" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/e4/386c514c53684772885009c12b67a7edd526c15157778ac1b138bc75063e/python_docx-1.1.2.tar.gz", hash = "sha256:0cf1f22e95b9002addca7948e16f2cd7acdfd498047f1941ca5d293db7762efd", size = 5656581, upload-time = "2024-05-01T19:41:57.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/3d/330d9efbdb816d3f60bf2ad92f05e1708e4a1b9abe80461ac3444c83f749/python_docx-1.1.2-py3-none-any.whl", hash = "sha256:08c20d6058916fb19853fcf080f7f42b6270d89eac9fa5f8c15f691c0017fabe", size = 244315, upload-time = "2024-05-01T19:41:47.006Z" }, +] + [[package]] name = "requests" version = "2.32.3"