Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34c5b635d3 |
443
mail_exporter.py
443
mail_exporter.py
@@ -2,6 +2,7 @@ import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -28,7 +29,7 @@ except ImportError:
|
||||
win32com = None
|
||||
|
||||
|
||||
APP_TITLE = "Outlook email exporter ver. 1.0"
|
||||
APP_TITLE = "Outlook email exporter ver. 1.0.4"
|
||||
APP_GEOMETRY = "1220x760+0+0"
|
||||
MAIL_CLASS = 43 # OlObjectClass.olMail
|
||||
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, "
|
||||
"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_TEXT = """Outlook Email Exporter 1.0
|
||||
HELP_TEXT = """Outlook Email Exporter 1.0.4
|
||||
|
||||
Overview
|
||||
- Connect to a running Outlook session.
|
||||
- Connect to a running Classic Outlook session.
|
||||
- Select an Outlook folder from the tree.
|
||||
- Choose an output folder.
|
||||
- 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.
|
||||
- Period filters the preview by recent time ranges.
|
||||
- New shows only emails not yet exported.
|
||||
- With attachments shows only emails with Outlook attachments.
|
||||
- Selected limits export to the rows currently selected in the preview.
|
||||
- Office/PDF/images shows only emails with office, PDF, or image attachments.
|
||||
- Selected rows only limits export to the rows currently selected in the preview.
|
||||
|
||||
Export modes
|
||||
- Create zip builds a ZIP archive.
|
||||
@@ -67,9 +74,17 @@ Export modes
|
||||
- Export preview exports only the emails currently visible in the filtered preview.
|
||||
|
||||
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.
|
||||
- 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 = {
|
||||
".pdf",
|
||||
@@ -133,6 +148,49 @@ IMAGE_ATTACHMENT_EXTENSIONS = {
|
||||
".tiff",
|
||||
".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_ATTACH_CONTENT_ID = (
|
||||
"http://schemas.microsoft.com/mapi/proptag/0x3712001F",
|
||||
@@ -422,6 +480,8 @@ class PreferenceStore:
|
||||
|
||||
|
||||
class OutlookService:
|
||||
NEW_OUTLOOK_PROCESS_NAMES = {"olk.exe"}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.outlook = None
|
||||
self.namespace = None
|
||||
@@ -430,7 +490,7 @@ class OutlookService:
|
||||
def connect(self) -> None:
|
||||
if win32com is None or pythoncom is None:
|
||||
raise RuntimeError(
|
||||
"pywin32 non è installato. Esegui: pip install pywin32"
|
||||
"pywin32 is not installed. Run: pip install pywin32"
|
||||
)
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
@@ -438,6 +498,8 @@ class OutlookService:
|
||||
try:
|
||||
self.outlook = win32com.client.GetActiveObject("Outlook.Application")
|
||||
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
|
||||
|
||||
self.namespace = self.outlook.GetNamespace("MAPI")
|
||||
@@ -457,6 +519,25 @@ class OutlookService:
|
||||
pythoncom.CoUninitialize()
|
||||
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:
|
||||
try:
|
||||
self.root_folders = []
|
||||
@@ -549,10 +630,12 @@ class OutlookService:
|
||||
@staticmethod
|
||||
def get_namespace_for_worker() -> object:
|
||||
if win32com is None:
|
||||
raise RuntimeError("pywin32 non disponibile.")
|
||||
raise RuntimeError("pywin32 is not available.")
|
||||
try:
|
||||
outlook = win32com.client.GetActiveObject("Outlook.Application")
|
||||
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
|
||||
return outlook.GetNamespace("MAPI")
|
||||
|
||||
@@ -652,6 +735,11 @@ class NameCodec:
|
||||
ext = cls.sanitize_extension(os.path.splitext(filename or "")[1])
|
||||
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:
|
||||
MANIFEST_FLUSH_INTERVAL_SECONDS = 20.0
|
||||
@@ -1292,6 +1380,7 @@ class OutlookExporterApp(ctk.CTk):
|
||||
self.outlook_service = OutlookService()
|
||||
self.exporter = Exporter(self)
|
||||
self.export_thread = None
|
||||
self.tree_load_thread = None
|
||||
self.main_thread_id = threading.get_ident()
|
||||
self.ui_queue = queue.Queue()
|
||||
self.connected = False
|
||||
@@ -1305,6 +1394,7 @@ class OutlookExporterApp(ctk.CTk):
|
||||
self.loaded_email_headers: List[EmailHeader] = []
|
||||
self.filtered_email_headers: List[EmailHeader] = []
|
||||
self.filter_tree_key_map = {}
|
||||
self.selected_preview_keys: set[str] = set()
|
||||
self.filter_tree = None
|
||||
self.filter_loaded_folder_text = ""
|
||||
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.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_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_with_attachments = ctk.CTkCheckBox(
|
||||
filter_options,
|
||||
text="With attachments",
|
||||
text="Office/PDF/images",
|
||||
variable=self.filter_export_with_attachments_var,
|
||||
command=self.schedule_live_filter,
|
||||
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_selected = ctk.CTkCheckBox(
|
||||
filter_options,
|
||||
text="Selected",
|
||||
text="Selected rows only",
|
||||
variable=self.filter_export_selected_var,
|
||||
command=self.schedule_live_filter,
|
||||
state="disabled",
|
||||
@@ -1691,12 +1781,15 @@ class OutlookExporterApp(ctk.CTk):
|
||||
ctk.CTkLabel(
|
||||
self.busy_overlay,
|
||||
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))
|
||||
ctk.CTkLabel(
|
||||
self.busy_overlay,
|
||||
textvariable=self.busy_detail_var,
|
||||
justify="center",
|
||||
font=ctk.CTkFont(size=12),
|
||||
wraplength=460,
|
||||
).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.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_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_with_attachments, "Show only emails that have Outlook attachments."),
|
||||
(self.chk_filter_export_selected, "Limit the preview and export to the rows currently selected."),
|
||||
(self.chk_filter_export_with_attachments, "Show only emails that have office, PDF, or image attachments."),
|
||||
(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]
|
||||
|
||||
def connect_outlook(self) -> None:
|
||||
if self.tree_load_thread is not None and self.tree_load_thread.is_alive():
|
||||
return
|
||||
self.set_status("Connecting to Outlook...")
|
||||
self.log("Connecting to Outlook...")
|
||||
self.set_busy_overlay(
|
||||
True,
|
||||
"Please wait...",
|
||||
"Connecting to Classic Outlook and loading the folder tree...",
|
||||
)
|
||||
self._set_controls_enabled(False)
|
||||
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:
|
||||
self.set_status("Connecting to Outlook...")
|
||||
self.log("Connecting to Outlook...")
|
||||
self.outlook_service.connect()
|
||||
self.connected = True
|
||||
self.log("Connected to Outlook.")
|
||||
self.reload_tree()
|
||||
self.btn_refresh.configure(state="normal")
|
||||
self.btn_expand.configure(state="normal")
|
||||
self.set_status("Connected to Outlook")
|
||||
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:
|
||||
if OutlookService.is_new_outlook_running():
|
||||
raise RuntimeError(NEW_OUTLOOK_UNSUPPORTED_MESSAGE) from exc
|
||||
raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from 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.connected = False
|
||||
self.log(f"Outlook connection error: {exc}")
|
||||
self.set_status("Connection error")
|
||||
messagebox.showerror(APP_TITLE, f"Unable to connect to Outlook.\n\n{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:
|
||||
if not self.connected:
|
||||
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:
|
||||
try:
|
||||
self.after_cancel(self.filter_live_after_id)
|
||||
@@ -1764,6 +1917,7 @@ class OutlookExporterApp(ctk.CTk):
|
||||
self.loaded_email_headers = []
|
||||
self.filtered_email_headers = []
|
||||
self.filter_tree_key_map = {}
|
||||
self.selected_preview_keys.clear()
|
||||
self.filter_loaded_folder_text = ""
|
||||
self.filter_loaded_folder_var.set("No folder loaded")
|
||||
self.filter_status_var.set("No emails loaded")
|
||||
@@ -1772,50 +1926,123 @@ class OutlookExporterApp(ctk.CTk):
|
||||
self.refresh_filter_tree()
|
||||
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:
|
||||
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:
|
||||
selected = self.tree.focus()
|
||||
if not selected:
|
||||
return
|
||||
children = self.tree.get_children(selected)
|
||||
if len(children) == 1 and self.tree.item(children[0], "text") == "__dummy__":
|
||||
self.tree.delete(children[0])
|
||||
folder_obj = self.tree_folder_map.get(selected)
|
||||
if self.tree_load_thread is not None and self.tree_load_thread.is_alive():
|
||||
return
|
||||
current_path = self.tree_path_map.get(selected, self.tree.item(selected, "text"))
|
||||
try:
|
||||
child_nodes = self.outlook_service.list_children(folder_obj, current_path)
|
||||
for idx, node in enumerate(child_nodes, start=1):
|
||||
node_text = NameCodec.tree_label(node.display_name or node.path, node.mail_count)
|
||||
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
|
||||
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:
|
||||
child_nodes = self.outlook_service.list_children(folder_obj, current_path)
|
||||
for idx, node in enumerate(child_nodes, start=1):
|
||||
node_text = NameCodec.tree_label(node.display_name or node.path, node.mail_count)
|
||||
has_children = False
|
||||
try:
|
||||
has_children = bool(node.folder_obj.Folders.Count > 0)
|
||||
except Exception:
|
||||
has_children = False
|
||||
if 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] = node.folder_obj
|
||||
self.tree_path_map[child_id] = node.path
|
||||
try:
|
||||
if 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(selected, "end", text=node_text, open=False, tags=(row_tag,))
|
||||
self.tree_folder_map[child_id] = node.folder_obj
|
||||
self.tree_path_map[child_id] = node.path
|
||||
try:
|
||||
if has_children:
|
||||
self._insert_dummy(child_id)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
self.log(f"Error loading subfolders: {exc}")
|
||||
self._insert_dummy(child_id)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as 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:
|
||||
selected = self.tree.selection()
|
||||
@@ -1837,6 +2064,7 @@ class OutlookExporterApp(ctk.CTk):
|
||||
self.loaded_email_headers = []
|
||||
self.filtered_email_headers = []
|
||||
self.filter_tree_key_map = {}
|
||||
self.selected_preview_keys.clear()
|
||||
self.filter_loaded_folder_text = ""
|
||||
self.filter_loaded_folder_var.set(f"Selected folder: {folder_path}")
|
||||
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:
|
||||
self.tree.item(node_id, open=True)
|
||||
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):
|
||||
if self.tree.item(child, "text") != "__dummy__":
|
||||
_expand(child)
|
||||
@@ -1971,8 +2199,8 @@ class OutlookExporterApp(ctk.CTk):
|
||||
folder_text = self.selected_folder_display.get().replace("Selected: ", "")
|
||||
self.set_busy_overlay(
|
||||
True,
|
||||
"Please wait... loading many emails may take a few minutes",
|
||||
f"Loading email headers for:\n{folder_text}",
|
||||
"Please wait...",
|
||||
f"Loading email headers for:\n{folder_text}\nLarge folders may take a few minutes.",
|
||||
)
|
||||
self._set_controls_enabled(False)
|
||||
self.filter_status_var.set("Loading in progress...")
|
||||
@@ -2187,8 +2415,8 @@ class OutlookExporterApp(ctk.CTk):
|
||||
self.btn_cancel.configure(state="normal")
|
||||
self.set_busy_overlay(
|
||||
True,
|
||||
"Please wait... exporting many emails may take a few minutes",
|
||||
"Export in progress...",
|
||||
"Please wait...",
|
||||
"Export in progress. Exporting many emails may take several minutes.",
|
||||
)
|
||||
|
||||
def _restore_export_controls(self) -> None:
|
||||
@@ -2326,7 +2554,10 @@ class OutlookExporterApp(ctk.CTk):
|
||||
if received_dt is 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 ""
|
||||
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)
|
||||
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)
|
||||
if inline_score >= 3:
|
||||
continue
|
||||
if not NameCodec.is_allowed_attachment(original_filename):
|
||||
if not NameCodec.is_preview_filter_attachment(original_filename):
|
||||
continue
|
||||
ext = NameCodec.sanitize_extension(os.path.splitext(original_filename or "")[1]).lstrip(".")
|
||||
if ext:
|
||||
@@ -2421,6 +2652,16 @@ class OutlookExporterApp(ctk.CTk):
|
||||
return 0, ""
|
||||
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:
|
||||
entry_id = Exporter._safe_full_str(getattr(item, "EntryID", None))
|
||||
parent = getattr(item, "Parent", None)
|
||||
@@ -2540,6 +2781,52 @@ class OutlookExporterApp(ctk.CTk):
|
||||
self._set_controls_enabled(True)
|
||||
self._flush_file_log()
|
||||
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":
|
||||
_, message, details = event
|
||||
self.set_status("Error during export")
|
||||
@@ -2674,13 +2961,7 @@ class OutlookExporterApp(ctk.CTk):
|
||||
only_new = bool(self.filter_export_new_var.get())
|
||||
only_with_attachments = bool(self.filter_export_with_attachments_var.get())
|
||||
only_selected = bool(self.filter_export_selected_var.get())
|
||||
selected_keys: set[str] = 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
|
||||
}
|
||||
selected_keys = set(self.selected_preview_keys) if only_selected else set()
|
||||
|
||||
filtered: List[EmailHeader] = []
|
||||
for header in self.loaded_email_headers:
|
||||
@@ -2694,7 +2975,7 @@ class OutlookExporterApp(ctk.CTk):
|
||||
continue
|
||||
if only_with_attachments and header.attachment_count <= 0:
|
||||
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
|
||||
filtered.append(header)
|
||||
|
||||
@@ -2714,12 +2995,18 @@ class OutlookExporterApp(ctk.CTk):
|
||||
self.filter_export_new_var.set(0)
|
||||
self.filter_export_with_attachments_var.set(0)
|
||||
self.filter_export_selected_var.set(0)
|
||||
self.selected_preview_keys.clear()
|
||||
self.apply_email_filters()
|
||||
|
||||
def _on_live_filter_input(self, _event=None) -> None:
|
||||
self.schedule_live_filter()
|
||||
|
||||
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()
|
||||
if self.filter_export_selected_var.get():
|
||||
self.schedule_live_filter()
|
||||
@@ -2768,6 +3055,7 @@ class OutlookExporterApp(ctk.CTk):
|
||||
return
|
||||
self.filter_tree.delete(*self.filter_tree.get_children())
|
||||
self.filter_tree_key_map = {}
|
||||
visible_selected_items = []
|
||||
for idx, header in enumerate(self.filtered_email_headers, start=1):
|
||||
state = "Exported" if header.exported else "New"
|
||||
item_id = f"mail_{idx}"
|
||||
@@ -2787,6 +3075,13 @@ class OutlookExporterApp(ctk.CTk):
|
||||
),
|
||||
)
|
||||
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:
|
||||
self._autosize_preview_columns()
|
||||
exported = sum(1 for item in self.filtered_email_headers if item.exported)
|
||||
|
||||
Reference in New Issue
Block a user