From 34c5b635d33611e76e7c29a99850ea56f6134149 Mon Sep 17 00:00:00 2001 From: allebonvi Date: Fri, 12 Jun 2026 11:45:50 +0200 Subject: [PATCH] Release 1.0.4 --- mail_exporter.py | 443 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 369 insertions(+), 74 deletions(-) diff --git a/mail_exporter.py b/mail_exporter.py index 6764dce..d5af643 100644 --- a/mail_exporter.py +++ b/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)