view and open

This commit is contained in:
Tim Bendt
2025-05-09 19:54:56 -06:00
parent 9ad483dca8
commit a7a2cfe8dc
3 changed files with 198 additions and 61 deletions

View File

@@ -61,7 +61,8 @@ 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.current_items = {} # Store currently displayed items
self.msal_app = None self.msal_app = None
self.cache = msal.SerializableTokenCache() self.cache = msal.SerializableTokenCache()
# Read Azure app credentials from environment variables # Read Azure app credentials from environment variables
@@ -105,6 +106,7 @@ class OneDriveTUI(App):
self.query_one("#view_options").border_title = "My Files" self.query_one("#view_options").border_title = "My Files"
# Initialize the table # Initialize the table
table = self.query_one("#items_table") table = self.query_one("#items_table")
table.cursor_type = "row"
table.add_columns("Type", "Name", "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
@@ -256,29 +258,60 @@ class OneDriveTUI(App):
if self.selected_drive_id: if self.selected_drive_id:
self.load_root_items() self.load_root_items()
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in the items table."""
selected_id = event.row_key.value
self.open_item(selected_id)
async def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
self.selected_item_id = event.row_key.value
def open_item(self, selected_id: str):
if selected_id:
# Get an item from current items by ID string
selected_row = self.current_items[selected_id]
item_name = selected_row.get("name")
# Check if it's a folder
is_folder = bool(selected_row.get("folder"))
if is_folder:
self.notify(f"Selected folder: {item_name}")
# Load items in the folder
self.query_one("#status_label").update(f"Loading items in folder: {item_name}")
self.load_root_items(folder_id=selected_id, drive_id=selected_row.get("parentReference", {}).get("driveId", self.selected_drive_id))
else:
self.notify(f"Selected file: {item_name}")
self.action_view_document()
@work @work
async def load_root_items(self): async def load_root_items(self, folder_id: str = "", drive_id: str = ""):
"""Load root items from the selected drive.""" """Load root items from the selected drive."""
if not self.access_token or not self.selected_drive_id: if not self.access_token or not self.selected_drive_id:
return return
self.query_one("#status_label").update("Loading root items...") self.query_one("#status_label").update("Loading root items...")
headers = {"Authorization": f"Bearer {self.access_token}"} headers = {"Authorization": f"Bearer {self.access_token}"}
url = f"https://graph.microsoft.com/v1.0/me/drive/root/children"
if folder_id and drive_id:
url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}/children"
self.selected_drive_id = drive_id
try: try:
url = f"https://graph.microsoft.com/v1.0/me/drive/root/children"
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 root items: {response.status}", severity="error") self.notify(f"Failed to load drive items: {response.status}", severity="error")
return return
items_data = await response.json() items_data = await response.json()
self.current_items = items_data.get("value", [])
# Update the table with the root items # Update the table with the root items
self.update_items_table(self.current_items, is_root_view=True) self.update_items_table(items_data.get("value", []), is_root_view=True)
except Exception as e: except Exception as e:
self.notify(f"Error loading root items: {str(e)}", severity="error") self.notify(f"Error loading root items: {str(e)}", severity="error")
@@ -303,11 +336,11 @@ class OneDriveTUI(App):
return return
items_data = await response.json() items_data = await response.json()
self.followed_items = items_data.get("value", []) 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.followed_items) self.update_items_table(followed_items)
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")
@@ -315,7 +348,7 @@ class OneDriveTUI(App):
def update_items_table(self, items, is_root_view=False): def update_items_table(self, items, is_root_view=False):
"""Update the table with the given items.""" """Update the table with the given items."""
table = self.query_one("#items_table") table = self.query_one(DataTable)
table.clear() table.clear()
if not items: if not items:
@@ -356,8 +389,11 @@ class OneDriveTUI(App):
size_str = "N/A" size_str = "N/A"
web_url = item.get("webUrl", "") web_url = item.get("webUrl", "")
item_id = item.get("id")
table.add_row(item_type, name, last_modified, size_str, web_url) item_drive_id = item.get("parentReference", {}).get("driveId", self.selected_drive_id)
row_key = table.add_row(item_type, name, last_modified, size_str, web_url, key=item.get("id"))
# add item to to the list of current items keyed by row_key so we can look up all information later
self.current_items[row_key] = item
async def action_next_view(self) -> None: async def action_next_view(self) -> None:
"""Switch to the next view (Following/Root).""" """Switch to the next view (Following/Root)."""
@@ -400,44 +436,20 @@ class OneDriveTUI(App):
# Use Textual's built-in open_url method # 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: def action_view_document(self) -> None:
"""View the selected document using the DocumentViewerScreen.""" """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 # Get the name of the selected item
selected_row = table.get_row_at(table.cursor_row)
selected_row = self.current_items.get(self.selected_item_id)
if not selected_row: if not selected_row:
return return
selected_name = selected_row[1] selected_name = selected_row.get("name")
drive_id = selected_row.get("parentReference", {}).get("driveId", self.selected_drive_id)
# Find the item in our list to get its ID web_url = selected_row.get("webUrl", "")
selected_item = None # Open the document viewer screen with all required details
for item in self.current_items: viewer = DocumentViewerScreen(self.selected_item_id, selected_name, self.access_token, drive_id)
if item.get("name") == selected_name: viewer.web_url = web_url # Pass the webUrl to the viewer
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) self.push_screen(viewer)
async def action_quit(self) -> None: async def action_quit(self) -> None:

View File

@@ -37,20 +37,18 @@
#content_container { #content_container {
margin-top: 1; margin-top: 1;
height: 1fr; height: 1fr;
border: round $accent;
} }
/* Status and loading elements */ /* Status and loading elements */
#status_label { #status_label {
text-align: center; text-align: center;
margin-bottom: 1; color: $accent;
padding:1;
} }
#loading {
align: center middle;
margin: 2;
}
#view_options { #view_options {
border: round $secondary; border: round $secondary;
@@ -124,13 +122,13 @@
#document_title { #document_title {
color: $accent; color: $accent;
text-align: center; text-align: left;
padding: 0 1;
text-style: bold; text-style: bold;
margin-bottom: 0; margin-bottom: 0;
width: 1fr; width: 1fr;
height: 3; height: 1;
align: left middle;
} }
#plaintext_content { #plaintext_content {

View File

@@ -2,7 +2,8 @@ import os
import io import io
import asyncio import asyncio
import tempfile import tempfile
from typing import Optional from typing import Optional, Tuple, Set
from pathlib import Path
import aiohttp import aiohttp
import mammoth import mammoth
@@ -10,20 +11,50 @@ from docx import Document
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Container, ScrollableContainer, Horizontal from textual.containers import Container, ScrollableContainer, Horizontal, Vertical
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Label, Markdown, LoadingIndicator, Button, Footer from textual.widgets import Label, Markdown, LoadingIndicator, Button, Footer
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.reactive import Reactive, reactive
# Define convertible formats
PDF_CONVERTIBLE_FORMATS = {
"doc", "docx", "epub", "eml", "htm", "html", "md", "msg", "odp",
"ods", "odt", "pps", "ppsx", "ppt", "pptx", "rtf", "tif", "tiff",
"xls", "xlsm", "xlsx"
}
JPG_CONVERTIBLE_FORMATS = {
"3g2", "3gp", "3gp2", "3gpp", "3mf", "ai", "arw", "asf", "avi",
"bas", "bash", "bat", "bmp", "c", "cbl", "cmd", "cool", "cpp",
"cr2", "crw", "cs", "css", "csv", "cur", "dcm", "dcm30", "dic",
"dicm", "dicom", "dng", "doc", "docx", "dwg", "eml", "epi", "eps",
"epsf", "epsi", "epub", "erf", "fbx", "fppx", "gif", "glb", "h",
"hcp", "heic", "heif", "htm", "html", "ico", "icon", "java", "jfif",
"jpeg", "jpg", "js", "json", "key", "log", "m2ts", "m4a", "m4v",
"markdown", "md", "mef", "mov", "movie", "mp3", "mp4", "mp4v", "mrw",
"msg", "mts", "nef", "nrw", "numbers", "obj", "odp", "odt", "ogg",
"orf", "pages", "pano", "pdf", "pef", "php", "pict", "pl", "ply",
"png", "pot", "potm", "potx", "pps", "ppsx", "ppsxm", "ppt", "pptm",
"pptx", "ps", "ps1", "psb", "psd", "py", "raw", "rb", "rtf", "rw1",
"rw2", "sh", "sketch", "sql", "sr2", "stl", "tif", "tiff", "ts",
"txt", "vb", "webm", "wma", "wmv", "xaml", "xbm", "xcf", "xd", "xml",
"xpm", "yaml", "yml"
}
class DocumentViewerScreen(Screen): class DocumentViewerScreen(Screen):
"""Screen for viewing document content from OneDrive items.""" """Screen for viewing document content from OneDrive items."""
web_url: Reactive[str] = reactive("")
download_url: Reactive[str] = reactive("")
BINDINGS = [ BINDINGS = [
Binding("escape", "close", "Close"), Binding("escape", "close", "Close"),
Binding("q", "close", "Close"), Binding("q", "close", "Close"),
Binding("m", "toggle_mode", "Toggle Mode"), Binding("m", "toggle_mode", "Toggle Mode"),
Binding("e", "export_and_open", "Export & Open"),
] ]
def __init__(self, item_id: str, item_name: str, access_token: str, drive_id: str): def __init__(self, item_id: str, item_name: str, access_token: str, drive_id: str):
@@ -33,6 +64,7 @@ class DocumentViewerScreen(Screen):
item_id: The ID of the item to view. item_id: The ID of the item to view.
item_name: The name of the item to display. item_name: The name of the item to display.
access_token: The access token for API requests. access_token: The access token for API requests.
drive_id: The ID of the drive containing the item.
""" """
super().__init__() super().__init__()
self.item_id = item_id self.item_id = item_id
@@ -44,15 +76,18 @@ class DocumentViewerScreen(Screen):
self.is_markdown_mode = False self.is_markdown_mode = False
self.content_type = None self.content_type = None
self.raw_content = None self.raw_content = None
self.file_extension = Path(item_name).suffix.lower().lstrip('.')
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Compose the document viewer screen.""" """Compose the document viewer screen."""
yield Container( yield Container(
Horizontal( Horizontal(
Label(f"Viewing: {self.item_name}", id="document_title"), Container(
Label(f"Viewing: {self.item_name}", id="document_title"),
Label(f'[link="{self.web_url}"]Open on Web[/link] | [link="{self.download_url}"]Download File[/link]', id="document_link"),
),
Container( Container(
Button("Close", id="close_button"), Button("Close", id="close_button"),
Button("Toggle Mode", id="toggle_mode_button"),
id="button_container" id="button_container"
), ),
id="top_container" id="top_container"
@@ -77,17 +112,54 @@ class DocumentViewerScreen(Screen):
self.dismiss() self.dismiss()
elif event.button.id == "toggle_mode_button": elif event.button.id == "toggle_mode_button":
self.action_toggle_mode() self.action_toggle_mode()
elif event.button.id == "export_button":
self.action_export_and_open()
def is_convertible_format(self) -> bool:
"""Check if the current file is convertible to PDF or JPG."""
return (self.file_extension in PDF_CONVERTIBLE_FORMATS or
self.file_extension in JPG_CONVERTIBLE_FORMATS)
def get_conversion_format(self) -> str:
"""Get the appropriate conversion format (pdf or jpg) for the current file."""
if self.file_extension in PDF_CONVERTIBLE_FORMATS:
return "pdf"
elif self.file_extension in JPG_CONVERTIBLE_FORMATS:
return "jpg"
return None
@work @work
async def download_document(self) -> None: async def download_document(self) -> None:
"""Download the document content.""" """Download the document content."""
headers = {"Authorization": f"Bearer {self.access_token}"}
try:
metadataUrl = f"https://graph.microsoft.com/v1.0/drives/{self.drive_id}/items/{self.item_id}"
async with aiohttp.ClientSession() as session:
async with session.get(metadataUrl, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
self.notify(f"Failed to fetch document metadata: {error_text}", severity="error")
return
metadata = await response.json()
self.item_name = metadata.get("name", self.item_name)
self.file_extension = Path(self.item_name).suffix.lower().lstrip('.')
self.download_url = metadata.get("@microsoft.graph.downloadUrl", "")
self.web_url = metadata.get("webUrl", "")
except Exception as e:
self.notify(f"Error downloading document: {str(e)}", severity="error")
try: try:
url = f"https://graph.microsoft.com/v1.0/drives/{self.drive_id}/items/{self.item_id}/content" 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 # Show loading indicator
self.query_one("#content_container").loading = True self.query_one("#content_container").loading = True
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:
@@ -129,7 +201,12 @@ class DocumentViewerScreen(Screen):
self.update_content_display() self.update_content_display()
else: else:
# For other types, display a generic message # For other types, display a generic message
self.document_content = f"*File: {self.item_name}*\n\nContent type: {self.content_type}\n\nThis file type cannot be displayed in the viewer. Use the 'Open URL' command to view this file in your browser." conversion_info = ""
if self.is_convertible_format():
conversion_format = self.get_conversion_format()
conversion_info = f"\n\nThis file can be converted to {conversion_format.upper()}. Press 'e' or click 'Export & Open' to convert and view."
self.document_content = f"*File: {self.item_name}*\n\nContent type: {self.content_type}{conversion_info}\n\nThis file type cannot be displayed directly in the viewer. You could [open in your browser]({self.web_url}), or [download the file]({self.download_url})."
self.is_markdown_mode = True
self.update_content_display() self.update_content_display()
except Exception as e: except Exception as e:
self.notify(f"Error processing content: {str(e)}", severity="error") self.notify(f"Error processing content: {str(e)}", severity="error")
@@ -175,13 +252,63 @@ class DocumentViewerScreen(Screen):
plaintext_widget.remove_class("hidden") plaintext_widget.remove_class("hidden")
markdown_widget.add_class("hidden") markdown_widget.add_class("hidden")
@work
async def export_and_open_converted_file(self) -> None:
"""Export the file in converted format and open it."""
if not self.is_convertible_format():
self.notify("This file format cannot be converted.", severity="warning")
return
conversion_format = self.get_conversion_format()
if not conversion_format:
self.notify("No appropriate conversion format found.", severity="error")
return
try:
# Build the URL with the format parameter
url = f"https://graph.microsoft.com/v1.0/drives/{self.drive_id}/items/{self.item_id}/content?format={conversion_format}"
headers = {"Authorization": f"Bearer {self.access_token}"}
# Download the converted file
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
self.notify(f"Failed to export document: {error_text}", severity="error")
return
converted_content = await response.read()
# Create temporary file with the right extension
file_name = f"{os.path.splitext(self.item_name)[0]}.{conversion_format}"
with tempfile.NamedTemporaryFile(suffix=f".{conversion_format}",
delete=False,
prefix=f"onedrive_export_") as temp_file:
temp_file.write(converted_content)
temp_path = temp_file.name
# Open the file using the system default application
self.notify(f"Opening exported {conversion_format.upper()} file: {file_name}")
self.app.open_url(f"file://{temp_path}")
self.query_one("#content_container").loading = False
except Exception as e:
self.notify(f"Error exporting document: {str(e)}", severity="error")
async def action_toggle_mode(self) -> None: async def action_toggle_mode(self) -> None:
"""Toggle between Markdown and plaintext display modes.""" """Toggle between Markdown and plaintext display modes."""
self.notify("Switching Modes", severity="info")
self.is_markdown_mode = not self.is_markdown_mode self.is_markdown_mode = not self.is_markdown_mode
self.update_content_display() self.update_content_display()
mode_name = "Markdown" if self.is_markdown_mode else "Plain Text" mode_name = "Markdown" if self.is_markdown_mode else "Plain Text"
self.notify(f"Switched to {mode_name} mode") self.notify(f"Switched to {mode_name} mode")
async def action_export_and_open(self) -> None:
"""Export the file in converted format and open it."""
self.query_one("#content_container").loading = True
self.notify("Exporting and opening the converted file...")
self.export_and_open_converted_file()
async def action_close(self) -> None: async def action_close(self) -> None:
"""Close the document viewer screen.""" """Close the document viewer screen."""
self.dismiss() self.dismiss()