Release 1.0.4
This commit is contained in:
401
mail_exporter.py
401
mail_exporter.py
@@ -2,6 +2,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
@@ -28,7 +29,7 @@ except ImportError:
|
|||||||
win32com = None
|
win32com = None
|
||||||
|
|
||||||
|
|
||||||
APP_TITLE = "Outlook email exporter ver. 1.0"
|
APP_TITLE = "Outlook email exporter ver. 1.0.4"
|
||||||
APP_GEOMETRY = "1220x760+0+0"
|
APP_GEOMETRY = "1220x760+0+0"
|
||||||
MAIL_CLASS = 43 # OlObjectClass.olMail
|
MAIL_CLASS = 43 # OlObjectClass.olMail
|
||||||
MAX_LOG_LINES = 3000
|
MAX_LOG_LINES = 3000
|
||||||
@@ -44,11 +45,17 @@ OUTLOOK_NOT_RUNNING_MESSAGE = (
|
|||||||
"Open Microsoft Outlook with the email profile you want to export, wait for it to finish loading, "
|
"Open Microsoft Outlook with the email profile you want to export, wait for it to finish loading, "
|
||||||
"then minimize it and come back here to press 'Connect Outlook' again."
|
"then minimize it and come back here to press 'Connect Outlook' again."
|
||||||
)
|
)
|
||||||
|
NEW_OUTLOOK_UNSUPPORTED_MESSAGE = (
|
||||||
|
"New Outlook for Windows appears to be running, but this version of Outlook Email Exporter "
|
||||||
|
"requires Classic Outlook for Windows.\n\n"
|
||||||
|
"New Outlook does not expose the classic local COM/MAPI automation interface used by this tool. "
|
||||||
|
"Please open Classic Outlook, wait for it to finish loading, and then press 'Connect Outlook' again."
|
||||||
|
)
|
||||||
HELP_WINDOW_TITLE = "Help"
|
HELP_WINDOW_TITLE = "Help"
|
||||||
HELP_TEXT = """Outlook Email Exporter 1.0
|
HELP_TEXT = """Outlook Email Exporter 1.0.4
|
||||||
|
|
||||||
Overview
|
Overview
|
||||||
- Connect to a running Outlook session.
|
- Connect to a running Classic Outlook session.
|
||||||
- Select an Outlook folder from the tree.
|
- Select an Outlook folder from the tree.
|
||||||
- Choose an output folder.
|
- Choose an output folder.
|
||||||
- Use immediate export to export the selected folder and all subfolders.
|
- Use immediate export to export the selected folder and all subfolders.
|
||||||
@@ -58,8 +65,8 @@ Preview and filters
|
|||||||
- Sender and Subject filters narrow the visible preview.
|
- Sender and Subject filters narrow the visible preview.
|
||||||
- Period filters the preview by recent time ranges.
|
- Period filters the preview by recent time ranges.
|
||||||
- New shows only emails not yet exported.
|
- New shows only emails not yet exported.
|
||||||
- With attachments shows only emails with Outlook attachments.
|
- Office/PDF/images shows only emails with office, PDF, or image attachments.
|
||||||
- Selected limits export to the rows currently selected in the preview.
|
- Selected rows only limits export to the rows currently selected in the preview.
|
||||||
|
|
||||||
Export modes
|
Export modes
|
||||||
- Create zip builds a ZIP archive.
|
- Create zip builds a ZIP archive.
|
||||||
@@ -67,9 +74,17 @@ Export modes
|
|||||||
- Export preview exports only the emails currently visible in the filtered preview.
|
- Export preview exports only the emails currently visible in the filtered preview.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
- Outlook must already be installed and configured on this PC.
|
- Classic Outlook for Windows must already be installed and configured on this PC.
|
||||||
|
- New Outlook for Windows is not supported by this version.
|
||||||
- Large folders can take a few minutes to load or export.
|
- Large folders can take a few minutes to load or export.
|
||||||
- The program uses an incremental manifest to avoid exporting the same email twice.
|
- The program uses an incremental manifest to avoid exporting the same email twice.
|
||||||
|
|
||||||
|
Version history
|
||||||
|
- 1.0.4: Outlook tree loading now runs in background workers, so progress overlays stay responsive while connecting, reloading folders, or opening slow tree nodes.
|
||||||
|
- 1.0.3: Added clearer progress overlay text and improved feedback while connecting to Outlook and loading the folder tree.
|
||||||
|
- 1.0.2: Improved preview selection filtering so selected rows remain stable while live filters refresh the preview.
|
||||||
|
- 1.0.1: Added Classic Outlook compatibility messaging and a warning when New Outlook for Windows is detected.
|
||||||
|
- 1.0.0: First packaged release with incremental export, preview filters, ZIP/file-system output, manifest checks, and log window.
|
||||||
"""
|
"""
|
||||||
ALLOWED_ATTACHMENT_EXTENSIONS = {
|
ALLOWED_ATTACHMENT_EXTENSIONS = {
|
||||||
".pdf",
|
".pdf",
|
||||||
@@ -133,6 +148,49 @@ IMAGE_ATTACHMENT_EXTENSIONS = {
|
|||||||
".tiff",
|
".tiff",
|
||||||
".webp",
|
".webp",
|
||||||
}
|
}
|
||||||
|
PREVIEW_DOCUMENT_ATTACHMENT_EXTENSIONS = {
|
||||||
|
".pdf",
|
||||||
|
".doc",
|
||||||
|
".docx",
|
||||||
|
".docm",
|
||||||
|
".dot",
|
||||||
|
".dotx",
|
||||||
|
".dotm",
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".xlsm",
|
||||||
|
".xlsb",
|
||||||
|
".xlt",
|
||||||
|
".xltx",
|
||||||
|
".xltm",
|
||||||
|
".csv",
|
||||||
|
".ppt",
|
||||||
|
".pptx",
|
||||||
|
".pptm",
|
||||||
|
".pps",
|
||||||
|
".ppsx",
|
||||||
|
".ppsm",
|
||||||
|
".pot",
|
||||||
|
".potx",
|
||||||
|
".potm",
|
||||||
|
".rtf",
|
||||||
|
".fodp",
|
||||||
|
".fods",
|
||||||
|
".fodt",
|
||||||
|
".odb",
|
||||||
|
".odf",
|
||||||
|
".odg",
|
||||||
|
".odt",
|
||||||
|
".ods",
|
||||||
|
".odp",
|
||||||
|
".otg",
|
||||||
|
".oth",
|
||||||
|
".otp",
|
||||||
|
".ots",
|
||||||
|
".ott",
|
||||||
|
".sxc",
|
||||||
|
}
|
||||||
|
PREVIEW_FILTER_ATTACHMENT_EXTENSIONS = PREVIEW_DOCUMENT_ATTACHMENT_EXTENSIONS | IMAGE_ATTACHMENT_EXTENSIONS
|
||||||
MAPI_ATTACHMENT_HIDDEN = "http://schemas.microsoft.com/mapi/proptag/0x7FFE000B"
|
MAPI_ATTACHMENT_HIDDEN = "http://schemas.microsoft.com/mapi/proptag/0x7FFE000B"
|
||||||
MAPI_ATTACH_CONTENT_ID = (
|
MAPI_ATTACH_CONTENT_ID = (
|
||||||
"http://schemas.microsoft.com/mapi/proptag/0x3712001F",
|
"http://schemas.microsoft.com/mapi/proptag/0x3712001F",
|
||||||
@@ -422,6 +480,8 @@ class PreferenceStore:
|
|||||||
|
|
||||||
|
|
||||||
class OutlookService:
|
class OutlookService:
|
||||||
|
NEW_OUTLOOK_PROCESS_NAMES = {"olk.exe"}
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.outlook = None
|
self.outlook = None
|
||||||
self.namespace = None
|
self.namespace = None
|
||||||
@@ -430,7 +490,7 @@ class OutlookService:
|
|||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
if win32com is None or pythoncom is None:
|
if win32com is None or pythoncom is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"pywin32 non è installato. Esegui: pip install pywin32"
|
"pywin32 is not installed. Run: pip install pywin32"
|
||||||
)
|
)
|
||||||
|
|
||||||
pythoncom.CoInitialize()
|
pythoncom.CoInitialize()
|
||||||
@@ -438,6 +498,8 @@ class OutlookService:
|
|||||||
try:
|
try:
|
||||||
self.outlook = win32com.client.GetActiveObject("Outlook.Application")
|
self.outlook = win32com.client.GetActiveObject("Outlook.Application")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if self.is_new_outlook_running():
|
||||||
|
raise RuntimeError(NEW_OUTLOOK_UNSUPPORTED_MESSAGE) from exc
|
||||||
raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc
|
raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc
|
||||||
|
|
||||||
self.namespace = self.outlook.GetNamespace("MAPI")
|
self.namespace = self.outlook.GetNamespace("MAPI")
|
||||||
@@ -457,6 +519,25 @@ class OutlookService:
|
|||||||
pythoncom.CoUninitialize()
|
pythoncom.CoUninitialize()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_new_outlook_running(cls) -> bool:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tasklist", "/fo", "csv", "/nh"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=4,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
process_text = (result.stdout or "").lower()
|
||||||
|
return any(f'"{name}"' in process_text or name in process_text for name in cls.NEW_OUTLOOK_PROCESS_NAMES)
|
||||||
|
|
||||||
def disconnect(self) -> None:
|
def disconnect(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.root_folders = []
|
self.root_folders = []
|
||||||
@@ -549,10 +630,12 @@ class OutlookService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_namespace_for_worker() -> object:
|
def get_namespace_for_worker() -> object:
|
||||||
if win32com is None:
|
if win32com is None:
|
||||||
raise RuntimeError("pywin32 non disponibile.")
|
raise RuntimeError("pywin32 is not available.")
|
||||||
try:
|
try:
|
||||||
outlook = win32com.client.GetActiveObject("Outlook.Application")
|
outlook = win32com.client.GetActiveObject("Outlook.Application")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if OutlookService.is_new_outlook_running():
|
||||||
|
raise RuntimeError(NEW_OUTLOOK_UNSUPPORTED_MESSAGE) from exc
|
||||||
raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc
|
raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc
|
||||||
return outlook.GetNamespace("MAPI")
|
return outlook.GetNamespace("MAPI")
|
||||||
|
|
||||||
@@ -652,6 +735,11 @@ class NameCodec:
|
|||||||
ext = cls.sanitize_extension(os.path.splitext(filename or "")[1])
|
ext = cls.sanitize_extension(os.path.splitext(filename or "")[1])
|
||||||
return ext in ALLOWED_ATTACHMENT_EXTENSIONS
|
return ext in ALLOWED_ATTACHMENT_EXTENSIONS
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_preview_filter_attachment(cls, filename: str) -> bool:
|
||||||
|
ext = cls.sanitize_extension(os.path.splitext(filename or "")[1])
|
||||||
|
return ext in PREVIEW_FILTER_ATTACHMENT_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
class Exporter:
|
class Exporter:
|
||||||
MANIFEST_FLUSH_INTERVAL_SECONDS = 20.0
|
MANIFEST_FLUSH_INTERVAL_SECONDS = 20.0
|
||||||
@@ -1292,6 +1380,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.outlook_service = OutlookService()
|
self.outlook_service = OutlookService()
|
||||||
self.exporter = Exporter(self)
|
self.exporter = Exporter(self)
|
||||||
self.export_thread = None
|
self.export_thread = None
|
||||||
|
self.tree_load_thread = None
|
||||||
self.main_thread_id = threading.get_ident()
|
self.main_thread_id = threading.get_ident()
|
||||||
self.ui_queue = queue.Queue()
|
self.ui_queue = queue.Queue()
|
||||||
self.connected = False
|
self.connected = False
|
||||||
@@ -1305,6 +1394,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.loaded_email_headers: List[EmailHeader] = []
|
self.loaded_email_headers: List[EmailHeader] = []
|
||||||
self.filtered_email_headers: List[EmailHeader] = []
|
self.filtered_email_headers: List[EmailHeader] = []
|
||||||
self.filter_tree_key_map = {}
|
self.filter_tree_key_map = {}
|
||||||
|
self.selected_preview_keys: set[str] = set()
|
||||||
self.filter_tree = None
|
self.filter_tree = None
|
||||||
self.filter_loaded_folder_text = ""
|
self.filter_loaded_folder_text = ""
|
||||||
self.filter_live_after_id = None
|
self.filter_live_after_id = None
|
||||||
@@ -1411,7 +1501,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.btn_connect = ctk.CTkButton(top, text="Connect Outlook", command=self.connect_outlook)
|
self.btn_connect = ctk.CTkButton(top, text="Connect Outlook", command=self.connect_outlook)
|
||||||
self.btn_connect.grid(row=0, column=0, padx=6, pady=6)
|
self.btn_connect.grid(row=0, column=0, padx=6, pady=6)
|
||||||
|
|
||||||
self.btn_refresh = ctk.CTkButton(top, text="Reload folders", command=self.reload_tree, state="disabled")
|
self.btn_refresh = ctk.CTkButton(top, text="Reload folders", command=self.reload_tree_with_overlay, state="disabled")
|
||||||
self.btn_refresh.grid(row=0, column=1, padx=6, pady=6)
|
self.btn_refresh.grid(row=0, column=1, padx=6, pady=6)
|
||||||
|
|
||||||
self.btn_expand = ctk.CTkButton(top, text="Expand tree", command=self.expand_all, state="disabled")
|
self.btn_expand = ctk.CTkButton(top, text="Expand tree", command=self.expand_all, state="disabled")
|
||||||
@@ -1571,7 +1661,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.chk_filter_export_new.grid(row=0, column=0, sticky="w", padx=(0, 12))
|
self.chk_filter_export_new.grid(row=0, column=0, sticky="w", padx=(0, 12))
|
||||||
self.chk_filter_export_with_attachments = ctk.CTkCheckBox(
|
self.chk_filter_export_with_attachments = ctk.CTkCheckBox(
|
||||||
filter_options,
|
filter_options,
|
||||||
text="With attachments",
|
text="Office/PDF/images",
|
||||||
variable=self.filter_export_with_attachments_var,
|
variable=self.filter_export_with_attachments_var,
|
||||||
command=self.schedule_live_filter,
|
command=self.schedule_live_filter,
|
||||||
state="disabled",
|
state="disabled",
|
||||||
@@ -1579,7 +1669,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.chk_filter_export_with_attachments.grid(row=0, column=1, sticky="w", padx=(0, 12))
|
self.chk_filter_export_with_attachments.grid(row=0, column=1, sticky="w", padx=(0, 12))
|
||||||
self.chk_filter_export_selected = ctk.CTkCheckBox(
|
self.chk_filter_export_selected = ctk.CTkCheckBox(
|
||||||
filter_options,
|
filter_options,
|
||||||
text="Selected",
|
text="Selected rows only",
|
||||||
variable=self.filter_export_selected_var,
|
variable=self.filter_export_selected_var,
|
||||||
command=self.schedule_live_filter,
|
command=self.schedule_live_filter,
|
||||||
state="disabled",
|
state="disabled",
|
||||||
@@ -1691,12 +1781,15 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.busy_overlay,
|
self.busy_overlay,
|
||||||
textvariable=self.busy_title_var,
|
textvariable=self.busy_title_var,
|
||||||
font=ctk.CTkFont(size=18, weight="bold"),
|
font=ctk.CTkFont(size=13, weight="bold"),
|
||||||
|
wraplength=460,
|
||||||
).grid(row=0, column=0, sticky="ew", padx=18, pady=(18, 8))
|
).grid(row=0, column=0, sticky="ew", padx=18, pady=(18, 8))
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.busy_overlay,
|
self.busy_overlay,
|
||||||
textvariable=self.busy_detail_var,
|
textvariable=self.busy_detail_var,
|
||||||
justify="center",
|
justify="center",
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
wraplength=460,
|
||||||
).grid(row=1, column=0, sticky="ew", padx=18, pady=(0, 10))
|
).grid(row=1, column=0, sticky="ew", padx=18, pady=(0, 10))
|
||||||
self.busy_progress = ctk.CTkProgressBar(self.busy_overlay, mode="indeterminate")
|
self.busy_progress = ctk.CTkProgressBar(self.busy_overlay, mode="indeterminate")
|
||||||
self.busy_progress.grid(row=2, column=0, sticky="ew", padx=18, pady=(0, 18))
|
self.busy_progress.grid(row=2, column=0, sticky="ew", padx=18, pady=(0, 18))
|
||||||
@@ -1725,31 +1818,91 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
(self.chk_zip, "Create a ZIP archive as export output."),
|
(self.chk_zip, "Create a ZIP archive as export output."),
|
||||||
(self.chk_hierarchy, "Create folders and files directly on the file system."),
|
(self.chk_hierarchy, "Create folders and files directly on the file system."),
|
||||||
(self.chk_filter_export_new, "Show only emails that are not yet exported."),
|
(self.chk_filter_export_new, "Show only emails that are not yet exported."),
|
||||||
(self.chk_filter_export_with_attachments, "Show only emails that have Outlook attachments."),
|
(self.chk_filter_export_with_attachments, "Show only emails that have office, PDF, or image attachments."),
|
||||||
(self.chk_filter_export_selected, "Limit the preview and export to the rows currently selected."),
|
(self.chk_filter_export_selected, "Show and export only the rows currently selected in the preview."),
|
||||||
]
|
]
|
||||||
self.tooltips = [ToolTip(widget, text) for widget, text in tooltip_specs]
|
self.tooltips = [ToolTip(widget, text) for widget, text in tooltip_specs]
|
||||||
|
|
||||||
def connect_outlook(self) -> None:
|
def connect_outlook(self) -> None:
|
||||||
try:
|
if self.tree_load_thread is not None and self.tree_load_thread.is_alive():
|
||||||
|
return
|
||||||
self.set_status("Connecting to Outlook...")
|
self.set_status("Connecting to Outlook...")
|
||||||
self.log("Connecting to Outlook...")
|
self.log("Connecting to Outlook...")
|
||||||
self.outlook_service.connect()
|
self.set_busy_overlay(
|
||||||
self.connected = True
|
True,
|
||||||
self.log("Connected to Outlook.")
|
"Please wait...",
|
||||||
self.reload_tree()
|
"Connecting to Classic Outlook and loading the folder tree...",
|
||||||
self.btn_refresh.configure(state="normal")
|
)
|
||||||
self.btn_expand.configure(state="normal")
|
self._set_controls_enabled(False)
|
||||||
self.set_status("Connected to Outlook")
|
self.tree_load_thread = threading.Thread(target=self._run_root_tree_load_worker, args=(True,), daemon=True)
|
||||||
|
self.tree_load_thread.start()
|
||||||
|
|
||||||
|
def reload_tree_with_overlay(self) -> None:
|
||||||
|
if not self.connected:
|
||||||
|
return
|
||||||
|
if self.tree_load_thread is not None and self.tree_load_thread.is_alive():
|
||||||
|
return
|
||||||
|
self.set_status("Reloading Outlook folder tree...")
|
||||||
|
self.log("Reloading Outlook folder tree...")
|
||||||
|
self.set_busy_overlay(
|
||||||
|
True,
|
||||||
|
"Please wait...",
|
||||||
|
"Loading Outlook folder tree...",
|
||||||
|
)
|
||||||
|
self._set_controls_enabled(False)
|
||||||
|
self.tree_load_thread = threading.Thread(target=self._run_root_tree_load_worker, args=(False,), daemon=True)
|
||||||
|
self.tree_load_thread.start()
|
||||||
|
|
||||||
|
def _run_root_tree_load_worker(self, is_connect: bool) -> None:
|
||||||
|
if pythoncom is not None:
|
||||||
|
pythoncom.CoInitialize()
|
||||||
|
try:
|
||||||
|
if win32com is None or pythoncom is None:
|
||||||
|
raise RuntimeError("pywin32 is not installed. Run: pip install pywin32")
|
||||||
|
try:
|
||||||
|
outlook = win32com.client.GetActiveObject("Outlook.Application")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.connected = False
|
if OutlookService.is_new_outlook_running():
|
||||||
self.log(f"Outlook connection error: {exc}")
|
raise RuntimeError(NEW_OUTLOOK_UNSUPPORTED_MESSAGE) from exc
|
||||||
self.set_status("Connection error")
|
raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc
|
||||||
messagebox.showerror(APP_TITLE, f"Unable to connect to Outlook.\n\n{exc}")
|
namespace = outlook.GetNamespace("MAPI")
|
||||||
|
roots = []
|
||||||
|
for i in range(1, namespace.Folders.Count + 1):
|
||||||
|
store = namespace.Folders.Item(i)
|
||||||
|
store_name = str(store.Name)
|
||||||
|
display_name = NameCodec.store_display_name(store_name)
|
||||||
|
roots.append(
|
||||||
|
{
|
||||||
|
"display_name": display_name,
|
||||||
|
"path": display_name,
|
||||||
|
"folder_ref": self._marshal_folder_for_worker(store),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.ui_queue.put(("root_tree_loaded", is_connect, roots))
|
||||||
|
except Exception as exc:
|
||||||
|
self.ui_queue.put(("root_tree_error", is_connect, str(exc), traceback.format_exc()))
|
||||||
|
finally:
|
||||||
|
if pythoncom is not None:
|
||||||
|
try:
|
||||||
|
pythoncom.CoUninitialize()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def reload_tree(self) -> None:
|
def reload_tree(self) -> None:
|
||||||
if not self.connected:
|
if not self.connected:
|
||||||
return
|
return
|
||||||
|
self._clear_tree_preview_state()
|
||||||
|
|
||||||
|
for root_node in self.outlook_service.list_root_folders():
|
||||||
|
node_text = NameCodec.tree_label(root_node.display_name or root_node.path, root_node.mail_count)
|
||||||
|
node_id = self.tree.insert("", "end", text=node_text, open=False, tags=("root",))
|
||||||
|
self.tree_folder_map[node_id] = root_node.folder_obj
|
||||||
|
self.tree_path_map[node_id] = root_node.path
|
||||||
|
self._insert_dummy(node_id)
|
||||||
|
|
||||||
|
self.log("Folder tree reloaded.")
|
||||||
|
|
||||||
|
def _clear_tree_preview_state(self) -> None:
|
||||||
if self.filter_live_after_id is not None:
|
if self.filter_live_after_id is not None:
|
||||||
try:
|
try:
|
||||||
self.after_cancel(self.filter_live_after_id)
|
self.after_cancel(self.filter_live_after_id)
|
||||||
@@ -1764,6 +1917,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.loaded_email_headers = []
|
self.loaded_email_headers = []
|
||||||
self.filtered_email_headers = []
|
self.filtered_email_headers = []
|
||||||
self.filter_tree_key_map = {}
|
self.filter_tree_key_map = {}
|
||||||
|
self.selected_preview_keys.clear()
|
||||||
self.filter_loaded_folder_text = ""
|
self.filter_loaded_folder_text = ""
|
||||||
self.filter_loaded_folder_var.set("No folder loaded")
|
self.filter_loaded_folder_var.set("No folder loaded")
|
||||||
self.filter_status_var.set("No emails loaded")
|
self.filter_status_var.set("No emails loaded")
|
||||||
@@ -1772,27 +1926,83 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.refresh_filter_tree()
|
self.refresh_filter_tree()
|
||||||
self._update_filter_controls_state()
|
self._update_filter_controls_state()
|
||||||
|
|
||||||
for root_node in self.outlook_service.list_root_folders():
|
|
||||||
node_text = NameCodec.tree_label(root_node.display_name or root_node.path, root_node.mail_count)
|
|
||||||
node_id = self.tree.insert("", "end", text=node_text, open=False, tags=("root",))
|
|
||||||
self.tree_folder_map[node_id] = root_node.folder_obj
|
|
||||||
self.tree_path_map[node_id] = root_node.path
|
|
||||||
self._insert_dummy(node_id)
|
|
||||||
|
|
||||||
self.log("Folder tree reloaded.")
|
|
||||||
|
|
||||||
def _insert_dummy(self, node_id: str) -> None:
|
def _insert_dummy(self, node_id: str) -> None:
|
||||||
self.tree.insert(node_id, "end", text="__dummy__")
|
self.tree.insert(node_id, "end", text="__dummy__")
|
||||||
|
|
||||||
|
def _insert_loaded_tree_roots(self, roots: List[dict]) -> None:
|
||||||
|
self._clear_tree_preview_state()
|
||||||
|
for root in roots:
|
||||||
|
folder_obj = self._resolve_worker_folder(root["folder_ref"])
|
||||||
|
node_text = NameCodec.tree_label(root["display_name"] or root["path"], None)
|
||||||
|
node_id = self.tree.insert("", "end", text=node_text, open=False, tags=("root",))
|
||||||
|
self.tree_folder_map[node_id] = folder_obj
|
||||||
|
self.tree_path_map[node_id] = root["path"]
|
||||||
|
self._insert_dummy(node_id)
|
||||||
|
|
||||||
def on_tree_open(self, _event=None) -> None:
|
def on_tree_open(self, _event=None) -> None:
|
||||||
selected = self.tree.focus()
|
selected = self.tree.focus()
|
||||||
if not selected:
|
if not selected:
|
||||||
return
|
return
|
||||||
children = self.tree.get_children(selected)
|
children = self.tree.get_children(selected)
|
||||||
if len(children) == 1 and self.tree.item(children[0], "text") == "__dummy__":
|
if len(children) == 1 and self.tree.item(children[0], "text") == "__dummy__":
|
||||||
self.tree.delete(children[0])
|
if self.tree_load_thread is not None and self.tree_load_thread.is_alive():
|
||||||
folder_obj = self.tree_folder_map.get(selected)
|
return
|
||||||
current_path = self.tree_path_map.get(selected, self.tree.item(selected, "text"))
|
current_path = self.tree_path_map.get(selected, self.tree.item(selected, "text"))
|
||||||
|
self.set_status("Loading Outlook subfolders...")
|
||||||
|
self.set_busy_overlay(
|
||||||
|
True,
|
||||||
|
"Please wait...",
|
||||||
|
f"Loading subfolders for:\n{current_path}",
|
||||||
|
)
|
||||||
|
self._set_controls_enabled(False)
|
||||||
|
folder_ref = self._marshal_folder_for_worker(self.tree_folder_map.get(selected))
|
||||||
|
self.tree_load_thread = threading.Thread(
|
||||||
|
target=self._run_tree_node_load_worker,
|
||||||
|
args=(selected, folder_ref, current_path),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self.tree_load_thread.start()
|
||||||
|
|
||||||
|
def _run_tree_node_load_worker(self, node_id: str, folder_ref: object, current_path: str) -> None:
|
||||||
|
if pythoncom is not None:
|
||||||
|
pythoncom.CoInitialize()
|
||||||
|
try:
|
||||||
|
folder_obj = self._resolve_worker_folder(folder_ref)
|
||||||
|
child_nodes = self.outlook_service.list_children(folder_obj, current_path)
|
||||||
|
children = []
|
||||||
|
for node in child_nodes:
|
||||||
|
has_children = False
|
||||||
|
try:
|
||||||
|
has_children = bool(node.folder_obj.Folders.Count > 0)
|
||||||
|
except Exception:
|
||||||
|
has_children = False
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
"display_name": node.display_name,
|
||||||
|
"path": node.path,
|
||||||
|
"mail_count": node.mail_count,
|
||||||
|
"has_children": has_children,
|
||||||
|
"folder_ref": self._marshal_folder_for_worker(node.folder_obj),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.ui_queue.put(("tree_node_loaded", node_id, children))
|
||||||
|
except Exception as exc:
|
||||||
|
self.ui_queue.put(("tree_node_error", node_id, str(exc), traceback.format_exc()))
|
||||||
|
finally:
|
||||||
|
if pythoncom is not None:
|
||||||
|
try:
|
||||||
|
pythoncom.CoUninitialize()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _load_tree_node_children(self, node_id: str) -> None:
|
||||||
|
children = self.tree.get_children(node_id)
|
||||||
|
if not (len(children) == 1 and self.tree.item(children[0], "text") == "__dummy__"):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.tree.delete(children[0])
|
||||||
|
folder_obj = self.tree_folder_map.get(node_id)
|
||||||
|
current_path = self.tree_path_map.get(node_id, self.tree.item(node_id, "text"))
|
||||||
try:
|
try:
|
||||||
child_nodes = self.outlook_service.list_children(folder_obj, current_path)
|
child_nodes = self.outlook_service.list_children(folder_obj, current_path)
|
||||||
for idx, node in enumerate(child_nodes, start=1):
|
for idx, node in enumerate(child_nodes, start=1):
|
||||||
@@ -1806,7 +2016,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
row_tag = "branch_even" if idx % 2 == 0 else "branch_odd"
|
row_tag = "branch_even" if idx % 2 == 0 else "branch_odd"
|
||||||
else:
|
else:
|
||||||
row_tag = "even" if idx % 2 == 0 else "odd"
|
row_tag = "even" if idx % 2 == 0 else "odd"
|
||||||
child_id = self.tree.insert(selected, "end", text=node_text, open=False, tags=(row_tag,))
|
child_id = self.tree.insert(node_id, "end", text=node_text, open=False, tags=(row_tag,))
|
||||||
self.tree_folder_map[child_id] = node.folder_obj
|
self.tree_folder_map[child_id] = node.folder_obj
|
||||||
self.tree_path_map[child_id] = node.path
|
self.tree_path_map[child_id] = node.path
|
||||||
try:
|
try:
|
||||||
@@ -1817,6 +2027,23 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log(f"Error loading subfolders: {exc}")
|
self.log(f"Error loading subfolders: {exc}")
|
||||||
|
|
||||||
|
def _insert_loaded_tree_children(self, node_id: str, children: List[dict]) -> None:
|
||||||
|
current_children = self.tree.get_children(node_id)
|
||||||
|
if len(current_children) == 1 and self.tree.item(current_children[0], "text") == "__dummy__":
|
||||||
|
self.tree.delete(current_children[0])
|
||||||
|
for idx, node in enumerate(children, start=1):
|
||||||
|
folder_obj = self._resolve_worker_folder(node["folder_ref"])
|
||||||
|
node_text = NameCodec.tree_label(node["display_name"] or node["path"], node["mail_count"])
|
||||||
|
if node["has_children"]:
|
||||||
|
row_tag = "branch_even" if idx % 2 == 0 else "branch_odd"
|
||||||
|
else:
|
||||||
|
row_tag = "even" if idx % 2 == 0 else "odd"
|
||||||
|
child_id = self.tree.insert(node_id, "end", text=node_text, open=False, tags=(row_tag,))
|
||||||
|
self.tree_folder_map[child_id] = folder_obj
|
||||||
|
self.tree_path_map[child_id] = node["path"]
|
||||||
|
if node["has_children"]:
|
||||||
|
self._insert_dummy(child_id)
|
||||||
|
|
||||||
def on_tree_select(self, _event=None) -> None:
|
def on_tree_select(self, _event=None) -> None:
|
||||||
selected = self.tree.selection()
|
selected = self.tree.selection()
|
||||||
if not selected:
|
if not selected:
|
||||||
@@ -1837,6 +2064,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.loaded_email_headers = []
|
self.loaded_email_headers = []
|
||||||
self.filtered_email_headers = []
|
self.filtered_email_headers = []
|
||||||
self.filter_tree_key_map = {}
|
self.filter_tree_key_map = {}
|
||||||
|
self.selected_preview_keys.clear()
|
||||||
self.filter_loaded_folder_text = ""
|
self.filter_loaded_folder_text = ""
|
||||||
self.filter_loaded_folder_var.set(f"Selected folder: {folder_path}")
|
self.filter_loaded_folder_var.set(f"Selected folder: {folder_path}")
|
||||||
self.filter_status_var.set("No emails loaded for the selected folder")
|
self.filter_status_var.set("No emails loaded for the selected folder")
|
||||||
@@ -1945,7 +2173,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
def _expand(node_id: str) -> None:
|
def _expand(node_id: str) -> None:
|
||||||
self.tree.item(node_id, open=True)
|
self.tree.item(node_id, open=True)
|
||||||
self.tree.focus(node_id)
|
self.tree.focus(node_id)
|
||||||
self.on_tree_open()
|
self._load_tree_node_children(node_id)
|
||||||
for child in self.tree.get_children(node_id):
|
for child in self.tree.get_children(node_id):
|
||||||
if self.tree.item(child, "text") != "__dummy__":
|
if self.tree.item(child, "text") != "__dummy__":
|
||||||
_expand(child)
|
_expand(child)
|
||||||
@@ -1971,8 +2199,8 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
folder_text = self.selected_folder_display.get().replace("Selected: ", "")
|
folder_text = self.selected_folder_display.get().replace("Selected: ", "")
|
||||||
self.set_busy_overlay(
|
self.set_busy_overlay(
|
||||||
True,
|
True,
|
||||||
"Please wait... loading many emails may take a few minutes",
|
"Please wait...",
|
||||||
f"Loading email headers for:\n{folder_text}",
|
f"Loading email headers for:\n{folder_text}\nLarge folders may take a few minutes.",
|
||||||
)
|
)
|
||||||
self._set_controls_enabled(False)
|
self._set_controls_enabled(False)
|
||||||
self.filter_status_var.set("Loading in progress...")
|
self.filter_status_var.set("Loading in progress...")
|
||||||
@@ -2187,8 +2415,8 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.btn_cancel.configure(state="normal")
|
self.btn_cancel.configure(state="normal")
|
||||||
self.set_busy_overlay(
|
self.set_busy_overlay(
|
||||||
True,
|
True,
|
||||||
"Please wait... exporting many emails may take a few minutes",
|
"Please wait...",
|
||||||
"Export in progress...",
|
"Export in progress. Exporting many emails may take several minutes.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _restore_export_controls(self) -> None:
|
def _restore_export_controls(self) -> None:
|
||||||
@@ -2326,7 +2554,10 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
if received_dt is None:
|
if received_dt is None:
|
||||||
received_dt = Exporter._convert_outlook_datetime(getattr(item, "SentOn", None))
|
received_dt = Exporter._convert_outlook_datetime(getattr(item, "SentOn", None))
|
||||||
received_text = received_dt.strftime("%Y-%m-%d %H:%M:%S") if received_dt else ""
|
received_text = received_dt.strftime("%Y-%m-%d %H:%M:%S") if received_dt else ""
|
||||||
attachment_count, attachment_types = self._collect_preview_attachment_info_minimal(item)
|
html_body = ""
|
||||||
|
if self._has_outlook_attachments(item):
|
||||||
|
html_body = getattr(item, "HTMLBody", "") or ""
|
||||||
|
attachment_count, attachment_types = self._collect_preview_attachment_info(item, html_body)
|
||||||
|
|
||||||
key = self._build_manifest_key_for_item(item, Exporter._safe_str(subject_raw), received_text)
|
key = self._build_manifest_key_for_item(item, Exporter._safe_str(subject_raw), received_text)
|
||||||
exported = bool(manifest and manifest.has_record(key) and manifest.record_file_exists(key))
|
exported = bool(manifest and manifest.has_record(key) and manifest.record_file_exists(key))
|
||||||
@@ -2391,7 +2622,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
inline_score, _ = self.exporter._signature_image_score(attachment, original_filename, html_body)
|
inline_score, _ = self.exporter._signature_image_score(attachment, original_filename, html_body)
|
||||||
if inline_score >= 3:
|
if inline_score >= 3:
|
||||||
continue
|
continue
|
||||||
if not NameCodec.is_allowed_attachment(original_filename):
|
if not NameCodec.is_preview_filter_attachment(original_filename):
|
||||||
continue
|
continue
|
||||||
ext = NameCodec.sanitize_extension(os.path.splitext(original_filename or "")[1]).lstrip(".")
|
ext = NameCodec.sanitize_extension(os.path.splitext(original_filename or "")[1]).lstrip(".")
|
||||||
if ext:
|
if ext:
|
||||||
@@ -2421,6 +2652,16 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
return 0, ""
|
return 0, ""
|
||||||
return attachment_total, ""
|
return attachment_total, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_outlook_attachments(item: object) -> bool:
|
||||||
|
attachments = getattr(item, "Attachments", None)
|
||||||
|
if attachments is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(attachments.Count or 0) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def _build_manifest_key_for_item(self, item: object, subject: str, received_text: str) -> str:
|
def _build_manifest_key_for_item(self, item: object, subject: str, received_text: str) -> str:
|
||||||
entry_id = Exporter._safe_full_str(getattr(item, "EntryID", None))
|
entry_id = Exporter._safe_full_str(getattr(item, "EntryID", None))
|
||||||
parent = getattr(item, "Parent", None)
|
parent = getattr(item, "Parent", None)
|
||||||
@@ -2540,6 +2781,52 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self._set_controls_enabled(True)
|
self._set_controls_enabled(True)
|
||||||
self._flush_file_log()
|
self._flush_file_log()
|
||||||
messagebox.showerror(APP_TITLE, f"Error while loading emails.\n\n{message}")
|
messagebox.showerror(APP_TITLE, f"Error while loading emails.\n\n{message}")
|
||||||
|
elif event_type == "root_tree_loaded":
|
||||||
|
_, is_connect, roots = event
|
||||||
|
self.tree_load_thread = None
|
||||||
|
self.connected = True
|
||||||
|
self._insert_loaded_tree_roots(roots)
|
||||||
|
if is_connect:
|
||||||
|
self.log("Connected to Outlook.")
|
||||||
|
self.set_status("Connected to Outlook")
|
||||||
|
else:
|
||||||
|
self.log("Folder tree reloaded.")
|
||||||
|
self.set_status("Outlook folder tree loaded")
|
||||||
|
self.set_busy_overlay(False)
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
elif event_type == "root_tree_error":
|
||||||
|
_, is_connect, message, details = event
|
||||||
|
self.tree_load_thread = None
|
||||||
|
if is_connect:
|
||||||
|
self.connected = False
|
||||||
|
self.set_status("Connection error")
|
||||||
|
self.log(f"Outlook connection error: {message}")
|
||||||
|
title = "Unable to connect to Outlook."
|
||||||
|
else:
|
||||||
|
self.set_status("Folder tree loading error")
|
||||||
|
self.log(f"Folder tree loading error: {message}")
|
||||||
|
title = "Unable to load the Outlook folder tree."
|
||||||
|
self.log(details)
|
||||||
|
self.set_busy_overlay(False)
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
messagebox.showerror(APP_TITLE, f"{title}\n\n{message}")
|
||||||
|
elif event_type == "tree_node_loaded":
|
||||||
|
_, node_id, children = event
|
||||||
|
self.tree_load_thread = None
|
||||||
|
self._insert_loaded_tree_children(node_id, children)
|
||||||
|
self.set_status(f"Outlook subfolders loaded | folders: {len(children)}")
|
||||||
|
self.log(f"Outlook subfolders loaded: {len(children)}")
|
||||||
|
self.set_busy_overlay(False)
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
elif event_type == "tree_node_error":
|
||||||
|
_, _node_id, message, details = event
|
||||||
|
self.tree_load_thread = None
|
||||||
|
self.set_status("Error while loading Outlook subfolders")
|
||||||
|
self.log(f"Outlook subfolder loading error: {message}")
|
||||||
|
self.log(details)
|
||||||
|
self.set_busy_overlay(False)
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
messagebox.showerror(APP_TITLE, f"Unable to load Outlook subfolders.\n\n{message}")
|
||||||
elif event_type == "error":
|
elif event_type == "error":
|
||||||
_, message, details = event
|
_, message, details = event
|
||||||
self.set_status("Error during export")
|
self.set_status("Error during export")
|
||||||
@@ -2674,13 +2961,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
only_new = bool(self.filter_export_new_var.get())
|
only_new = bool(self.filter_export_new_var.get())
|
||||||
only_with_attachments = bool(self.filter_export_with_attachments_var.get())
|
only_with_attachments = bool(self.filter_export_with_attachments_var.get())
|
||||||
only_selected = bool(self.filter_export_selected_var.get())
|
only_selected = bool(self.filter_export_selected_var.get())
|
||||||
selected_keys: set[str] = set()
|
selected_keys = set(self.selected_preview_keys) if only_selected else set()
|
||||||
if only_selected and self.filter_tree is not None:
|
|
||||||
selected_keys = {
|
|
||||||
self.filter_tree_key_map[item_id]
|
|
||||||
for item_id in self.filter_tree.selection()
|
|
||||||
if item_id in self.filter_tree_key_map
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered: List[EmailHeader] = []
|
filtered: List[EmailHeader] = []
|
||||||
for header in self.loaded_email_headers:
|
for header in self.loaded_email_headers:
|
||||||
@@ -2694,7 +2975,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
continue
|
continue
|
||||||
if only_with_attachments and header.attachment_count <= 0:
|
if only_with_attachments and header.attachment_count <= 0:
|
||||||
continue
|
continue
|
||||||
if only_selected and header.key not in selected_keys:
|
if only_selected and (not selected_keys or header.key not in selected_keys):
|
||||||
continue
|
continue
|
||||||
filtered.append(header)
|
filtered.append(header)
|
||||||
|
|
||||||
@@ -2714,12 +2995,18 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
self.filter_export_new_var.set(0)
|
self.filter_export_new_var.set(0)
|
||||||
self.filter_export_with_attachments_var.set(0)
|
self.filter_export_with_attachments_var.set(0)
|
||||||
self.filter_export_selected_var.set(0)
|
self.filter_export_selected_var.set(0)
|
||||||
|
self.selected_preview_keys.clear()
|
||||||
self.apply_email_filters()
|
self.apply_email_filters()
|
||||||
|
|
||||||
def _on_live_filter_input(self, _event=None) -> None:
|
def _on_live_filter_input(self, _event=None) -> None:
|
||||||
self.schedule_live_filter()
|
self.schedule_live_filter()
|
||||||
|
|
||||||
def _on_filter_tree_select(self, _event=None) -> None:
|
def _on_filter_tree_select(self, _event=None) -> None:
|
||||||
|
if self.filter_tree is not None:
|
||||||
|
for item_id in self.filter_tree.selection():
|
||||||
|
key = self.filter_tree_key_map.get(item_id)
|
||||||
|
if key:
|
||||||
|
self.selected_preview_keys.add(key)
|
||||||
self._update_filter_controls_state()
|
self._update_filter_controls_state()
|
||||||
if self.filter_export_selected_var.get():
|
if self.filter_export_selected_var.get():
|
||||||
self.schedule_live_filter()
|
self.schedule_live_filter()
|
||||||
@@ -2768,6 +3055,7 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
return
|
return
|
||||||
self.filter_tree.delete(*self.filter_tree.get_children())
|
self.filter_tree.delete(*self.filter_tree.get_children())
|
||||||
self.filter_tree_key_map = {}
|
self.filter_tree_key_map = {}
|
||||||
|
visible_selected_items = []
|
||||||
for idx, header in enumerate(self.filtered_email_headers, start=1):
|
for idx, header in enumerate(self.filtered_email_headers, start=1):
|
||||||
state = "Exported" if header.exported else "New"
|
state = "Exported" if header.exported else "New"
|
||||||
item_id = f"mail_{idx}"
|
item_id = f"mail_{idx}"
|
||||||
@@ -2787,6 +3075,13 @@ class OutlookExporterApp(ctk.CTk):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.filter_tree_key_map[item_id] = header.key
|
self.filter_tree_key_map[item_id] = header.key
|
||||||
|
if header.key in self.selected_preview_keys:
|
||||||
|
visible_selected_items.append(item_id)
|
||||||
|
if visible_selected_items:
|
||||||
|
try:
|
||||||
|
self.filter_tree.selection_set(visible_selected_items)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if autosize:
|
if autosize:
|
||||||
self._autosize_preview_columns()
|
self._autosize_preview_columns()
|
||||||
exported = sum(1 for item in self.filtered_email_headers if item.exported)
|
exported = sum(1 for item in self.filtered_email_headers if item.exported)
|
||||||
|
|||||||
Reference in New Issue
Block a user