image display basic functionality
This commit is contained in:
@@ -1,55 +1,199 @@
|
||||
import os
|
||||
import io
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional, Tuple, Set
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ByteString
|
||||
|
||||
import aiohttp
|
||||
import mammoth
|
||||
from docx import Document
|
||||
|
||||
from textual_image.renderable import Image
|
||||
from openai import OpenAI
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, ScrollableContainer, Horizontal, Vertical
|
||||
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
|
||||
from textual.reactive import Reactive, reactive
|
||||
from textual.widgets import Label, Markdown, Button, Footer, Static
|
||||
|
||||
from textual import work
|
||||
from textual.reactive import reactive
|
||||
from PIL import Image as PILImage
|
||||
|
||||
# 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"
|
||||
"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"
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
# Enum for display modes
|
||||
class DisplayMode:
|
||||
IMAGE = "image"
|
||||
TEXT = "text"
|
||||
MARKDOWN = "markdown"
|
||||
|
||||
|
||||
class DocumentViewerScreen(Screen):
|
||||
"""Screen for viewing document content from OneDrive items."""
|
||||
web_url: Reactive[str] = reactive("")
|
||||
download_url: Reactive[str] = reactive("")
|
||||
|
||||
web_url = reactive("")
|
||||
download_url = reactive("")
|
||||
use_markitdown = True
|
||||
image_bytes: ByteString = b""
|
||||
BINDINGS = [
|
||||
Binding("escape", "close", "Close"),
|
||||
Binding("q", "close", "Close"),
|
||||
@@ -73,37 +217,42 @@ class DocumentViewerScreen(Screen):
|
||||
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
|
||||
self.file_extension = Path(item_name).suffix.lower().lstrip('.')
|
||||
self.file_extension = Path(item_name).suffix.lower().lstrip(".")
|
||||
self.mode: DisplayMode = DisplayMode.TEXT
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the document viewer screen."""
|
||||
yield Container(
|
||||
Horizontal(
|
||||
Container(Button("✕", id="close_button"), id="button_container"),
|
||||
Container(
|
||||
Button("✕", id="close_button"),
|
||||
id="button_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(
|
||||
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"),
|
||||
),
|
||||
id="top_container"
|
||||
id="top_container",
|
||||
),
|
||||
ScrollableContainer(
|
||||
Markdown("", id="markdown_content"),
|
||||
Static(
|
||||
"",
|
||||
id="image_content",
|
||||
expand=True,
|
||||
),
|
||||
Label("", id="plaintext_content", classes="hidden", markup=False),
|
||||
id="content_container",
|
||||
),
|
||||
|
||||
id="document_viewer"
|
||||
id="document_viewer",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Handle screen mount event."""
|
||||
|
||||
self.query_one("#content_container").focus()
|
||||
self.download_document()
|
||||
|
||||
@@ -116,11 +265,12 @@ class DocumentViewerScreen(Screen):
|
||||
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)
|
||||
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."""
|
||||
@@ -128,7 +278,7 @@ class DocumentViewerScreen(Screen):
|
||||
return "pdf"
|
||||
elif self.file_extension in JPG_CONVERTIBLE_FORMATS:
|
||||
return "jpg"
|
||||
return None
|
||||
return ""
|
||||
|
||||
@work
|
||||
async def download_document(self) -> None:
|
||||
@@ -141,16 +291,20 @@ class DocumentViewerScreen(Screen):
|
||||
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")
|
||||
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.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")
|
||||
|
||||
@@ -160,12 +314,14 @@ class DocumentViewerScreen(Screen):
|
||||
# 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")
|
||||
self.notify(
|
||||
f"Failed to download document: {error_text}",
|
||||
severity="error",
|
||||
)
|
||||
return
|
||||
|
||||
self.content_type = response.headers.get("content-type", "")
|
||||
@@ -187,18 +343,82 @@ class DocumentViewerScreen(Screen):
|
||||
return
|
||||
|
||||
try:
|
||||
# Check for Office document types
|
||||
if self.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
# Process as DOCX
|
||||
if self.content_type.startswith("image/"):
|
||||
from PIL import Image as PILImage
|
||||
from io import BytesIO
|
||||
|
||||
self.notify("Attempting to display image in terminal")
|
||||
if self.raw_content and len(self.raw_content) > 0:
|
||||
self.image_bytes = self.raw_content
|
||||
self.mode = DisplayMode.IMAGE
|
||||
|
||||
# Decode the image using BytesIO and Pillow
|
||||
img = PILImage.open(BytesIO(self.image_bytes))
|
||||
|
||||
# Convert the image to RGB mode if it's not already
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Create a Textual Image renderable
|
||||
textual_img = Image(img)
|
||||
textual_img.expand = True
|
||||
textual_img.width = 120
|
||||
self.query_one("#image_content", Static).update(textual_img)
|
||||
self.update_content_display()
|
||||
return
|
||||
except Exception as e:
|
||||
self.notify(
|
||||
f"Error displaying image in terminal: {str(e)}", severity="error"
|
||||
)
|
||||
|
||||
try:
|
||||
if self.use_markitdown:
|
||||
self.notify(
|
||||
"Attempting to convert file into Markdown with Markitdown...",
|
||||
title="This could take a moment",
|
||||
severity="info",
|
||||
)
|
||||
from markitdown import MarkItDown
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=f".{self.file_extension}", delete=False
|
||||
) as temp_file:
|
||||
temp_file.write(self.raw_content)
|
||||
temp_path = temp_file.name
|
||||
client = OpenAI()
|
||||
md = MarkItDown(
|
||||
enable_plugins=True, llm_client=client, llm_model="gpt-4o"
|
||||
) # Set to True to enable plugins
|
||||
result = md.convert(
|
||||
temp_path,
|
||||
)
|
||||
self.mode = DisplayMode.MARKDOWN
|
||||
self.document_content = result.markdown
|
||||
self.plain_text_content = result.text_content
|
||||
self.update_content_display()
|
||||
return
|
||||
except Exception as e:
|
||||
self.notify(f"Error using MarkItDown: {str(e)}", severity="error")
|
||||
try:
|
||||
if (
|
||||
self.content_type
|
||||
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
):
|
||||
self.notify(
|
||||
"Processing DOCX file into Markdown using Mammoth...",
|
||||
severity="info",
|
||||
)
|
||||
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.mode = DisplayMode.TEXT
|
||||
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.mode = DisplayMode.MARKDOWN
|
||||
self.update_content_display()
|
||||
else:
|
||||
# For other types, display a generic message
|
||||
@@ -207,7 +427,7 @@ class DocumentViewerScreen(Screen):
|
||||
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.mode = DisplayMode.MARKDOWN
|
||||
self.update_content_display()
|
||||
except Exception as e:
|
||||
self.notify(f"Error processing content: {str(e)}", severity="error")
|
||||
@@ -228,7 +448,9 @@ class DocumentViewerScreen(Screen):
|
||||
|
||||
# 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.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
|
||||
@@ -243,14 +465,20 @@ class DocumentViewerScreen(Screen):
|
||||
"""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:
|
||||
image_widget = self.query_one("#image_content", Static)
|
||||
if self.mode == DisplayMode.IMAGE:
|
||||
image_widget.remove_class("hidden")
|
||||
markdown_widget.add_class("hidden")
|
||||
plaintext_widget.add_class("hidden")
|
||||
elif self.mode == DisplayMode.MARKDOWN:
|
||||
markdown_widget.update(self.document_content)
|
||||
markdown_widget.remove_class("hidden")
|
||||
image_widget.add_class("hidden")
|
||||
plaintext_widget.add_class("hidden")
|
||||
else:
|
||||
plaintext_widget.update(self.plain_text_content)
|
||||
plaintext_widget.remove_class("hidden")
|
||||
image_widget.add_class("hidden")
|
||||
markdown_widget.add_class("hidden")
|
||||
|
||||
@work
|
||||
@@ -275,21 +503,29 @@ class DocumentViewerScreen(Screen):
|
||||
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")
|
||||
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:
|
||||
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.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
|
||||
|
||||
@@ -299,9 +535,13 @@ class DocumentViewerScreen(Screen):
|
||||
async def action_toggle_mode(self) -> None:
|
||||
"""Toggle between Markdown and plaintext display modes."""
|
||||
self.notify("Switching Modes", severity="info")
|
||||
self.is_markdown_mode = not self.is_markdown_mode
|
||||
self.mode = (
|
||||
DisplayMode.MARKDOWN
|
||||
if self.mode != DisplayMode.MARKDOWN
|
||||
else DisplayMode.TEXT
|
||||
)
|
||||
self.update_content_display()
|
||||
mode_name = "Markdown" if self.is_markdown_mode else "Plain Text"
|
||||
mode_name = self.mode.name.capitalize()
|
||||
self.notify(f"Switched to {mode_name} mode")
|
||||
|
||||
async def action_export_and_open(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user