adding file browsing

This commit is contained in:
Tim Bendt
2025-05-09 13:55:12 -06:00
parent e465825f16
commit 9ad483dca8
6 changed files with 509 additions and 155 deletions

View File

@@ -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 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:
if self.current_view == "Following":
self.load_followed_items()
self.notify("Refreshed 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()

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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",
]

63
uv.lock generated
View File

@@ -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"