adding file browsing
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
from select import select
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -7,7 +6,8 @@ from datetime import datetime
|
|||||||
|
|
||||||
import msal
|
import msal
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich import print as rprint
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
@@ -21,11 +21,17 @@ from textual.widgets import (
|
|||||||
Button,
|
Button,
|
||||||
ListView,
|
ListView,
|
||||||
ListItem,
|
ListItem,
|
||||||
LoadingIndicator
|
LoadingIndicator,
|
||||||
|
OptionList
|
||||||
)
|
)
|
||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
from textual.worker import Worker, get_current_worker
|
from textual.worker import Worker, get_current_worker
|
||||||
from textual import work
|
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):
|
class OneDriveTUI(App):
|
||||||
@@ -37,6 +43,7 @@ class OneDriveTUI(App):
|
|||||||
is_authenticated = reactive(False)
|
is_authenticated = reactive(False)
|
||||||
selected_drive_id = reactive("")
|
selected_drive_id = reactive("")
|
||||||
drive_name = reactive("")
|
drive_name = reactive("")
|
||||||
|
current_view = reactive("Following") # Track current view: "Following" or "Root"
|
||||||
|
|
||||||
# App bindings
|
# App bindings
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@@ -44,7 +51,9 @@ class OneDriveTUI(App):
|
|||||||
Binding("r", "refresh", "Refresh"),
|
Binding("r", "refresh", "Refresh"),
|
||||||
Binding("f", "toggle_follow", "Toggle Follow"),
|
Binding("f", "toggle_follow", "Toggle Follow"),
|
||||||
Binding("o", "open_url", "Open URL"),
|
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):
|
def __init__(self):
|
||||||
@@ -52,11 +61,13 @@ class OneDriveTUI(App):
|
|||||||
self.access_token = None
|
self.access_token = None
|
||||||
self.drives = []
|
self.drives = []
|
||||||
self.followed_items = []
|
self.followed_items = []
|
||||||
|
self.current_items = [] # Store currently displayed items
|
||||||
self.msal_app = None
|
self.msal_app = None
|
||||||
self.cache = None
|
self.cache = msal.SerializableTokenCache()
|
||||||
# Read Azure app credentials from environment variables
|
# Read Azure app credentials from environment variables
|
||||||
self.client_id = os.getenv("AZURE_CLIENT_ID")
|
self.client_id = os.getenv("AZURE_CLIENT_ID")
|
||||||
self.tenant_id = os.getenv("AZURE_TENANT_ID")
|
self.tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||||
|
|
||||||
self.scopes = ["https://graph.microsoft.com/Files.ReadWrite.All"]
|
self.scopes = ["https://graph.microsoft.com/Files.ReadWrite.All"]
|
||||||
self.cache_file = "token_cache.bin"
|
self.cache_file = "token_cache.bin"
|
||||||
|
|
||||||
@@ -65,29 +76,36 @@ class OneDriveTUI(App):
|
|||||||
yield Header(show_clock=True)
|
yield Header(show_clock=True)
|
||||||
|
|
||||||
with Container(id="main_container"):
|
with Container(id="main_container"):
|
||||||
|
yield LoadingIndicator(id="loading")
|
||||||
yield Label("Authenticating with Microsoft Graph API...", id="status_label")
|
yield Label("Authenticating with Microsoft Graph API...", id="status_label")
|
||||||
|
|
||||||
with Container(id="auth_container"):
|
with Container(id="auth_container"):
|
||||||
yield Label("", id="auth_message")
|
yield Label("", id="auth_message")
|
||||||
yield Button("Login", id="login_button", variant="primary")
|
yield Button("Login", id="login_button", variant="primary")
|
||||||
|
|
||||||
with Horizontal(id="content_container", classes="hide"):
|
with Container(id="content_container", classes="hide"):
|
||||||
with Vertical(id="drive_container"):
|
with Horizontal():
|
||||||
yield ListView(id="drive_list")
|
with Vertical(id="navigation_container"):
|
||||||
|
|
||||||
|
yield OptionList(
|
||||||
|
Option("Following", id="following"),
|
||||||
|
Option("Root", id="root"),
|
||||||
|
id="view_options"
|
||||||
|
)
|
||||||
|
|
||||||
with Vertical(id="items_container"):
|
with Vertical(id="items_container"):
|
||||||
yield DataTable(id="items_table")
|
yield DataTable(id="items_table")
|
||||||
yield Label("No items found", id="no_items_label", classes="hide")
|
yield Label("No items found", id="no_items_label", classes="hide")
|
||||||
|
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
async def on_mount(self) -> None:
|
||||||
"""Initialize the app when mounted."""
|
"""Initialize the app when mounted."""
|
||||||
self.cache = msal.SerializableTokenCache()
|
self.query_one("#login_button").styles.width = "20"
|
||||||
self.query_one("#auth_container").ALLOW_SELECT = True
|
self.query_one("#view_options").border_title = "My Files"
|
||||||
# Initialize the table
|
# 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 = 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
|
# Load cached token if available
|
||||||
if os.path.exists(self.cache_file):
|
if os.path.exists(self.cache_file):
|
||||||
@@ -96,9 +114,9 @@ class OneDriveTUI(App):
|
|||||||
|
|
||||||
# Initialize MSAL app
|
# Initialize MSAL app
|
||||||
self.initialize_msal()
|
self.initialize_msal()
|
||||||
self.notify("Initializing MSAL app...", severity="info")
|
|
||||||
|
|
||||||
|
|
||||||
|
# Try silent authentication first
|
||||||
|
self.authenticate_silent()
|
||||||
|
|
||||||
def initialize_msal(self) -> None:
|
def initialize_msal(self) -> None:
|
||||||
"""Initialize the MSAL application."""
|
"""Initialize the MSAL application."""
|
||||||
@@ -114,23 +132,20 @@ class OneDriveTUI(App):
|
|||||||
self.msal_app = msal.PublicClientApplication(
|
self.msal_app = msal.PublicClientApplication(
|
||||||
self.client_id, authority=authority, token_cache=self.cache
|
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:
|
if not self.msal_app:
|
||||||
return
|
return
|
||||||
|
|
||||||
accounts = self.msal_app.get_accounts()
|
accounts = self.msal_app.get_accounts()
|
||||||
if accounts:
|
if accounts:
|
||||||
self.query_one("#status_label").update("Trying silent authentication...")
|
self.query_one("#status_label").update("Trying silent authentication...")
|
||||||
worker = self.get_token_silent(accounts[0])
|
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")
|
|
||||||
else:
|
else:
|
||||||
self.query_one("#status_label").update("Please log in to continue.")
|
self.query_one("#status_label").update("Please log in to continue.")
|
||||||
self.query_one("#auth_container").remove_class("hide")
|
self.query_one("#auth_container").remove_class("hide")
|
||||||
|
self.query_one("#loading").remove()
|
||||||
|
|
||||||
|
|
||||||
@work
|
@work
|
||||||
async def get_token_silent(self, account):
|
async def get_token_silent(self, account):
|
||||||
@@ -143,18 +158,17 @@ class OneDriveTUI(App):
|
|||||||
else:
|
else:
|
||||||
self.query_one("#status_label").update("Silent authentication failed. Please log in.")
|
self.query_one("#status_label").update("Silent authentication failed. Please log in.")
|
||||||
self.query_one("#auth_container").remove_class("hide")
|
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."""
|
"""Handle button presses."""
|
||||||
if event.button.id == "login_button":
|
if event.button.id == "login_button":
|
||||||
self.initiate_device_flow()
|
self.initiate_device_flow()
|
||||||
|
|
||||||
|
@work
|
||||||
def initiate_device_flow(self):
|
async def initiate_device_flow(self):
|
||||||
self.notify("Starting device code flow...", severity="info")
|
|
||||||
"""Initiate the MSAL device code flow."""
|
"""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...")
|
self.query_one("#status_label").update("Initiating device code flow...")
|
||||||
|
|
||||||
# Initiate device flow
|
# Initiate device flow
|
||||||
@@ -163,65 +177,33 @@ class OneDriveTUI(App):
|
|||||||
if "user_code" not in flow:
|
if "user_code" not in flow:
|
||||||
self.notify("Failed to create device flow", severity="error")
|
self.notify("Failed to create device flow", severity="error")
|
||||||
return
|
return
|
||||||
# self.notify(str(flow), severity="info")
|
|
||||||
# Display the device code message
|
# Display the device code message
|
||||||
self.query_one("#auth_message").update(flow["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)
|
if "access_token" not in token_response:
|
||||||
async def wait_for_device_code(self, flow):
|
self.notify("Failed to acquire token", severity="error")
|
||||||
"""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")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Save the token to cache
|
# Save token to cache
|
||||||
with open(self.cache_file, "w") as f:
|
with open(self.cache_file, "w") as f:
|
||||||
f.write(self.cache.serialize())
|
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()
|
self.load_initial_data()
|
||||||
|
|
||||||
@work
|
@work
|
||||||
async def load_initial_data(self):
|
async def load_initial_data(self):
|
||||||
"""Load initial data after authentication."""
|
"""Load initial data after authentication."""
|
||||||
|
self.query_one("#status_label").update("Loading drives...")
|
||||||
|
|
||||||
# Load drives first
|
# 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:
|
if not self.access_token:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -239,20 +221,69 @@ class OneDriveTUI(App):
|
|||||||
|
|
||||||
drives_data = await response.json()
|
drives_data = await response.json()
|
||||||
self.drives = drives_data.get("value", [])
|
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:
|
except Exception as e:
|
||||||
self.notify(f"Error loading drives: {str(e)}", severity="error")
|
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
|
@work
|
||||||
async def load_followed_items(self):
|
async def load_followed_items(self):
|
||||||
"""Load followed items from the selected drive."""
|
"""Load followed items from the selected drive."""
|
||||||
@@ -262,81 +293,44 @@ class OneDriveTUI(App):
|
|||||||
self.query_one("#status_label").update("Loading followed items...")
|
self.query_one("#status_label").update("Loading followed items...")
|
||||||
headers = {"Authorization": f"Bearer {self.access_token}"}
|
headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||||
|
|
||||||
# Update drive label
|
|
||||||
self.query_one("#drive_list").index = 0
|
|
||||||
|
|
||||||
try:
|
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 aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, headers=headers) as response:
|
async with session.get(url, headers=headers) as response:
|
||||||
if response.status != 200:
|
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
|
return
|
||||||
|
|
||||||
items_data = await response.json()
|
items_data = await response.json()
|
||||||
self.followed_items = items_data.get("value", [])
|
self.followed_items = items_data.get("value", [])
|
||||||
|
self.current_items = self.followed_items
|
||||||
|
|
||||||
# Update the table with the followed items
|
# Update the table with the followed items
|
||||||
# self.update_items_table()
|
self.update_items_table(self.followed_items)
|
||||||
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)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.notify(f"Error loading followed items: {str(e)}", severity="error")
|
self.notify(f"Error loading followed items: {str(e)}", severity="error")
|
||||||
|
|
||||||
self.query_one("#status_label").update("Ready")
|
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 = self.query_one("#items_table")
|
||||||
table.clear()
|
table.clear()
|
||||||
|
|
||||||
if not self.followed_items:
|
if not items:
|
||||||
self.query_one("#no_items_label").remove_class("hide")
|
self.query_one("#no_items_label").remove_class("hide")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.query_one("#no_items_label").add_class("hide")
|
self.query_one("#no_items_label").add_class("hide")
|
||||||
|
|
||||||
for item in self.followed_items:
|
for item in items:
|
||||||
name = item.get("name", "Unknown")
|
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
|
# Format the last modified date
|
||||||
last_modified = item.get("lastModifiedDateTime", "")
|
last_modified = item.get("lastModifiedDateTime", "")
|
||||||
@@ -363,13 +357,30 @@ class OneDriveTUI(App):
|
|||||||
|
|
||||||
web_url = item.get("webUrl", "")
|
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:
|
async def action_refresh(self) -> None:
|
||||||
"""Refresh the data."""
|
"""Refresh the data."""
|
||||||
if self.is_authenticated and self.selected_drive_id:
|
if self.is_authenticated and self.selected_drive_id:
|
||||||
|
if self.current_view == "Following":
|
||||||
self.load_followed_items()
|
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:
|
async def action_toggle_follow(self) -> None:
|
||||||
"""Toggle follow status for selected item."""
|
"""Toggle follow status for selected item."""
|
||||||
@@ -386,9 +397,49 @@ class OneDriveTUI(App):
|
|||||||
web_url = selected_row[4]
|
web_url = selected_row[4]
|
||||||
if web_url:
|
if web_url:
|
||||||
self.notify(f"Opening URL: {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)
|
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:
|
async def action_quit(self) -> None:
|
||||||
"""Quit the application."""
|
"""Quit the application."""
|
||||||
self.exit()
|
self.exit()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
/* Main container */
|
/* Main container */
|
||||||
#main_container {
|
#main_container {
|
||||||
padding: 1 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Authentication container */
|
/* Authentication container */
|
||||||
@@ -36,6 +36,9 @@
|
|||||||
/* Content container that holds drives and items */
|
/* Content container that holds drives and items */
|
||||||
#content_container {
|
#content_container {
|
||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
|
height: 1fr;
|
||||||
|
border: round $accent;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status and loading elements */
|
/* Status and loading elements */
|
||||||
@@ -49,6 +52,17 @@
|
|||||||
margin: 2;
|
margin: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#view_options {
|
||||||
|
border: round $secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading_container {
|
||||||
|
height: 3;
|
||||||
|
width: 100%;
|
||||||
|
align: center middle;
|
||||||
|
margin: 2 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Title styles */
|
/* Title styles */
|
||||||
.title {
|
.title {
|
||||||
color: $accent;
|
color: $accent;
|
||||||
@@ -63,12 +77,12 @@
|
|||||||
/* Drive container styles */
|
/* Drive container styles */
|
||||||
#drive_container {
|
#drive_container {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
margin-bottom: 1;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
#drive_list {
|
#drive_list {
|
||||||
border: round $primary;
|
border: round $primary;
|
||||||
padding: 1;
|
padding: 0 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +94,7 @@
|
|||||||
/* Items container and table styles */
|
/* Items container and table styles */
|
||||||
#items_container {
|
#items_container {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 3fr;
|
width: 4fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +109,47 @@
|
|||||||
padding: 2;
|
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 */
|
/* Utility classes */
|
||||||
.hide {
|
.hide, .hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +157,6 @@
|
|||||||
DataTable {
|
DataTable {
|
||||||
border: solid $accent;
|
border: solid $accent;
|
||||||
background: $primary-background-lighten-1;
|
background: $primary-background-lighten-1;
|
||||||
margin: 1 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DataTable > .datatable--header {
|
DataTable > .datatable--header {
|
||||||
@@ -117,10 +169,4 @@ DataTable > .datatable--cursor {
|
|||||||
background: $secondary;
|
background: $secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override scrollbar styles */
|
|
||||||
* {
|
|
||||||
scrollbar-color: $accent $surface;
|
|
||||||
scrollbar-background: $surface;
|
|
||||||
scrollbar-color-hover: $accent-lighten-1;
|
|
||||||
scrollbar-background-hover: $surface-lighten-1;
|
|
||||||
}
|
|
||||||
|
|||||||
187
maildir_gtd/screens/DocumentViewer.py
Normal file
187
maildir_gtd/screens/DocumentViewer.py
Normal 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()
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ requires-python = ">=3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp>=3.11.18",
|
"aiohttp>=3.11.18",
|
||||||
"html2text>=2025.4.15",
|
"html2text>=2025.4.15",
|
||||||
|
"mammoth>=1.9.0",
|
||||||
"msal>=1.32.3",
|
"msal>=1.32.3",
|
||||||
"orjson>=3.10.18",
|
"orjson>=3.10.18",
|
||||||
"python-dateutil>=2.9.0.post0",
|
"python-dateutil>=2.9.0.post0",
|
||||||
|
"python-docx>=1.1.2",
|
||||||
"rich>=14.0.0",
|
"rich>=14.0.0",
|
||||||
"textual>=3.2.0",
|
"textual>=3.2.0",
|
||||||
]
|
]
|
||||||
|
|||||||
63
uv.lock
generated
63
uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "44.0.3"
|
version = "44.0.3"
|
||||||
@@ -203,9 +212,11 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "html2text" },
|
{ name = "html2text" },
|
||||||
|
{ name = "mammoth" },
|
||||||
{ name = "msal" },
|
{ name = "msal" },
|
||||||
{ name = "orjson" },
|
{ name = "orjson" },
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "python-docx" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "textual" },
|
{ name = "textual" },
|
||||||
]
|
]
|
||||||
@@ -220,9 +231,11 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiohttp", specifier = ">=3.11.18" },
|
{ name = "aiohttp", specifier = ">=3.11.18" },
|
||||||
{ name = "html2text", specifier = ">=2025.4.15" },
|
{ name = "html2text", specifier = ">=2025.4.15" },
|
||||||
|
{ name = "mammoth", specifier = ">=1.9.0" },
|
||||||
{ name = "msal", specifier = ">=1.32.3" },
|
{ name = "msal", specifier = ">=1.32.3" },
|
||||||
{ name = "orjson", specifier = ">=3.10.18" },
|
{ name = "orjson", specifier = ">=3.10.18" },
|
||||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||||
|
{ name = "python-docx", specifier = ">=1.1.2" },
|
||||||
{ name = "rich", specifier = ">=14.0.0" },
|
{ name = "rich", specifier = ">=14.0.0" },
|
||||||
{ name = "textual", specifier = ">=3.2.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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "3.0.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.3"
|
version = "2.32.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user