From 5996a0e398996343c35a3bc2a3b9e345ec3ba927 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 3 Jun 2026 19:56:52 -0500 Subject: [PATCH] fixing pathing and core logic --- build/Addons.xcu | 25 +++ build/META-INF/manifest.xml | 2 + build/ProtocolHandler.xcu | 10 + build/autosave.py | 177 ++++++++++++++++-- src/Addons.xcu | 25 +++ src/META-INF/manifest.xml | 2 + src/ProtocolHandler.xcu | 10 + src/autosave.py | 177 ++++++++++++++++-- ...test_autosave.cpython-314-pytest-9.0.3.pyc | Bin 7601 -> 26877 bytes tests/test_autosave.py | 157 ++++++++++++++++ 10 files changed, 561 insertions(+), 24 deletions(-) create mode 100644 build/Addons.xcu create mode 100644 build/ProtocolHandler.xcu create mode 100644 src/Addons.xcu create mode 100644 src/ProtocolHandler.xcu diff --git a/build/Addons.xcu b/build/Addons.xcu new file mode 100644 index 0000000..280e7c4 --- /dev/null +++ b/build/Addons.xcu @@ -0,0 +1,25 @@ + + + + + + + AutoSave + + + + + Enable debug logging + + + org.libreoffice.extensions.autosave.autosavejob:ToggleDebugLogging + + + _self + + + + + + + diff --git a/build/META-INF/manifest.xml b/build/META-INF/manifest.xml index 81cf6bf..9012644 100644 --- a/build/META-INF/manifest.xml +++ b/build/META-INF/manifest.xml @@ -2,4 +2,6 @@ + + diff --git a/build/ProtocolHandler.xcu b/build/ProtocolHandler.xcu new file mode 100644 index 0000000..a4bdbd0 --- /dev/null +++ b/build/ProtocolHandler.xcu @@ -0,0 +1,10 @@ + + + + + + org.libreoffice.extensions.autosave.autosavejob:* + + + + diff --git a/build/autosave.py b/build/autosave.py index 187e68e..2169549 100644 --- a/build/autosave.py +++ b/build/autosave.py @@ -1,16 +1,49 @@ import os import shutil import traceback +import json from threading import Timer from datetime import datetime import uno import unohelper -from com.sun.star.util import XModifyListener +from com.sun.star.util import XModifyListener, URL from com.sun.star.task import XJob, XJobExecutor +from com.sun.star.frame import XDispatchProvider, XDispatch, FeatureStateEvent +from com.sun.star.lang import XInitialization +from com.sun.star.awt import XCallback +from pathlib import Path -LOG_FILE = "C:\\Users\\jasonross\\workspace\\LibreOfficeAutoSave\\autosave_debug.log" +LOG_FILE = Path.home() / "libreoffice_autosave_debug.log" +CONFIG_FILE = Path.home() / "libreoffice_autosave_config.json" + +def is_debug_logging_enabled(): + try: + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r") as f: + config = json.load(f) + return config.get("debug_logging", False) + except Exception: + pass + return False + +def set_debug_logging_enabled(enabled): + try: + config = {} + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r") as f: + try: + config = json.load(f) + except Exception: + pass + config["debug_logging"] = enabled + with open(CONFIG_FILE, "w") as f: + json.dump(config, f) + except Exception: + pass def log_debug(msg): + if not is_debug_logging_enabled(): + return try: with open(LOG_FILE, "a") as f: f.write(f"[{datetime.now().isoformat()}] {msg}\n") @@ -19,6 +52,38 @@ def log_debug(msg): log_debug("autosave.py module is being loaded by LibreOffice!") +g_status_listeners = [] +g_listeners = {} + +def notify_all_listeners(): + enabled = is_debug_logging_enabled() + url = URL() + url.Complete = "org.libreoffice.extensions.autosave.autosavejob:ToggleDebugLogging" + for listener in list(g_status_listeners): + try: + event = FeatureStateEvent() + event.FeatureURL = url + event.Source = None + event.IsEnabled = True + event.Requery = False + event.State = enabled + listener.statusChanged(event) + except Exception: + try: + g_status_listeners.remove(listener) + except ValueError: + pass + +class SafeSaveCallback(unohelper.Base, XCallback): + def __init__(self, listener): + self.listener = listener + + def notify(self, data): + try: + self.listener._do_actual_save() + except Exception as e: + log_debug(f"Exception in SafeSaveCallback.notify: {e}\n{traceback.format_exc()}") + class AutoSaveListener(unohelper.Base, XModifyListener): def __init__(self, doc, delay_seconds=1.0): self.doc = doc @@ -34,6 +99,27 @@ class AutoSaveListener(unohelper.Base, XModifyListener): self.timer.start() def execute_safe_save(self): + try: + log_debug("Timer expired, scheduling save on main thread via AsyncCallback...") + ctx = uno.getComponentContext() + smgr = ctx.ServiceManager + async_cb = smgr.createInstanceWithContext("com.sun.star.awt.AsyncCallback", ctx) + if async_cb: + callback = SafeSaveCallback(self) + async_cb.addCallback(callback, None) + log_debug("Save scheduled successfully.") + else: + log_debug("Failed to create AsyncCallback service, falling back to direct save.") + self._do_actual_save() + except Exception as e: + log_debug(f"Error scheduling safe save: {e}\n{traceback.format_exc()}") + # Fallback + try: + self._do_actual_save() + except Exception as e2: + log_debug(f"Fallback save failed: {e2}") + + def _do_actual_save(self): try: log_debug("Timer expired, attempting save...") if self.doc.supportsService("com.sun.star.document.OfficeDocument"): @@ -61,30 +147,97 @@ class AutoSaveListener(unohelper.Base, XModifyListener): def disposing(self, event): if self.timer: self.timer.cancel() + + # Remove from g_listeners dictionary to prevent leaks + doc = getattr(event, "Source", self.doc) + try: + if doc in g_listeners: + del g_listeners[doc] + log_debug("Successfully removed document from g_listeners on dispose.") + elif self.doc in g_listeners: + del g_listeners[self.doc] + log_debug("Successfully removed self.doc from g_listeners on dispose.") + except Exception as e: + log_debug(f"Error removing document from g_listeners: {e}") + log_debug("Listener disposed.") -g_listeners = {} - -class AutoSaveJob(unohelper.Base, XJob, XJobExecutor): +class AutoSaveJob(unohelper.Base, XJob, XJobExecutor, XDispatchProvider, XDispatch, XInitialization): def __init__(self, ctx=None): self.ctx = ctx + self.frame = None log_debug("AutoSaveJob instantiated!") + # XInitialization + def initialize(self, args): + log_debug(f"AutoSaveJob initialize() called with args: {args}") + if len(args) > 0: + self.frame = args[0] + + # XJob & XJobExecutor def execute(self, arguments): log_debug(f"AutoSaveJob execute() called with args: {arguments}") - self._attach_listener() + self._attach_listener(arguments) return () def trigger(self, args): log_debug(f"AutoSaveJob trigger() called with args: {args}") self._attach_listener() - def _attach_listener(self): + # XDispatchProvider + def queryDispatch(self, url, targetFrameName, searchFlags): + log_debug(f"queryDispatch called for URL: {url.Complete}") + if url.Complete.startswith("org.libreoffice.extensions.autosave.autosavejob:"): + return self + return None + + def queryDispatches(self, requests): + return tuple(self.queryDispatch(r.URL, r.FrameName, r.SearchFlags) for r in requests) + + # XDispatch + def dispatch(self, url, arguments): + log_debug(f"dispatch called for URL: {url.Complete}") + if url.Complete.endswith("ToggleDebugLogging"): + current = is_debug_logging_enabled() + set_debug_logging_enabled(not current) + log_debug(f"Toggled debug logging to: {not current}") + notify_all_listeners() + + def addStatusListener(self, listener, url): + log_debug(f"addStatusListener called for URL: {url.Complete}") + if url.Complete.endswith("ToggleDebugLogging"): + if listener not in g_status_listeners: + g_status_listeners.append(listener) + try: + event = FeatureStateEvent() + event.FeatureURL = url + event.Source = self + event.IsEnabled = True + event.Requery = False + event.State = is_debug_logging_enabled() + listener.statusChanged(event) + except Exception as e: + log_debug(f"Error in addStatusListener initial notify: {e}") + + def removeStatusListener(self, listener, url): + log_debug(f"removeStatusListener called for URL: {url.Complete}") + if listener in g_status_listeners: + g_status_listeners.remove(listener) + + def _attach_listener(self, arguments=None): try: - ctx = uno.getComponentContext() - smgr = ctx.ServiceManager - desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) - doc = desktop.getCurrentComponent() + doc = None + if arguments: + for arg in arguments: + if hasattr(arg, "Name") and arg.Name == "Model": + doc = arg.Value + break + + if not doc: + ctx = uno.getComponentContext() + smgr = ctx.ServiceManager + desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) + doc = desktop.getCurrentComponent() if doc: if doc in g_listeners: @@ -103,5 +256,5 @@ g_ImplementationHelper = unohelper.ImplementationHelper() g_ImplementationHelper.addImplementation( AutoSaveJob, "org.libreoffice.extensions.autosave.AutoSaveJob", - ("com.sun.star.task.Job",), + ("com.sun.star.task.Job", "com.sun.star.frame.ProtocolHandler"), ) diff --git a/src/Addons.xcu b/src/Addons.xcu new file mode 100644 index 0000000..280e7c4 --- /dev/null +++ b/src/Addons.xcu @@ -0,0 +1,25 @@ + + + + + + + AutoSave + + + + + Enable debug logging + + + org.libreoffice.extensions.autosave.autosavejob:ToggleDebugLogging + + + _self + + + + + + + diff --git a/src/META-INF/manifest.xml b/src/META-INF/manifest.xml index 81cf6bf..9012644 100644 --- a/src/META-INF/manifest.xml +++ b/src/META-INF/manifest.xml @@ -2,4 +2,6 @@ + + diff --git a/src/ProtocolHandler.xcu b/src/ProtocolHandler.xcu new file mode 100644 index 0000000..a4bdbd0 --- /dev/null +++ b/src/ProtocolHandler.xcu @@ -0,0 +1,10 @@ + + + + + + org.libreoffice.extensions.autosave.autosavejob:* + + + + diff --git a/src/autosave.py b/src/autosave.py index 187e68e..2169549 100644 --- a/src/autosave.py +++ b/src/autosave.py @@ -1,16 +1,49 @@ import os import shutil import traceback +import json from threading import Timer from datetime import datetime import uno import unohelper -from com.sun.star.util import XModifyListener +from com.sun.star.util import XModifyListener, URL from com.sun.star.task import XJob, XJobExecutor +from com.sun.star.frame import XDispatchProvider, XDispatch, FeatureStateEvent +from com.sun.star.lang import XInitialization +from com.sun.star.awt import XCallback +from pathlib import Path -LOG_FILE = "C:\\Users\\jasonross\\workspace\\LibreOfficeAutoSave\\autosave_debug.log" +LOG_FILE = Path.home() / "libreoffice_autosave_debug.log" +CONFIG_FILE = Path.home() / "libreoffice_autosave_config.json" + +def is_debug_logging_enabled(): + try: + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r") as f: + config = json.load(f) + return config.get("debug_logging", False) + except Exception: + pass + return False + +def set_debug_logging_enabled(enabled): + try: + config = {} + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "r") as f: + try: + config = json.load(f) + except Exception: + pass + config["debug_logging"] = enabled + with open(CONFIG_FILE, "w") as f: + json.dump(config, f) + except Exception: + pass def log_debug(msg): + if not is_debug_logging_enabled(): + return try: with open(LOG_FILE, "a") as f: f.write(f"[{datetime.now().isoformat()}] {msg}\n") @@ -19,6 +52,38 @@ def log_debug(msg): log_debug("autosave.py module is being loaded by LibreOffice!") +g_status_listeners = [] +g_listeners = {} + +def notify_all_listeners(): + enabled = is_debug_logging_enabled() + url = URL() + url.Complete = "org.libreoffice.extensions.autosave.autosavejob:ToggleDebugLogging" + for listener in list(g_status_listeners): + try: + event = FeatureStateEvent() + event.FeatureURL = url + event.Source = None + event.IsEnabled = True + event.Requery = False + event.State = enabled + listener.statusChanged(event) + except Exception: + try: + g_status_listeners.remove(listener) + except ValueError: + pass + +class SafeSaveCallback(unohelper.Base, XCallback): + def __init__(self, listener): + self.listener = listener + + def notify(self, data): + try: + self.listener._do_actual_save() + except Exception as e: + log_debug(f"Exception in SafeSaveCallback.notify: {e}\n{traceback.format_exc()}") + class AutoSaveListener(unohelper.Base, XModifyListener): def __init__(self, doc, delay_seconds=1.0): self.doc = doc @@ -34,6 +99,27 @@ class AutoSaveListener(unohelper.Base, XModifyListener): self.timer.start() def execute_safe_save(self): + try: + log_debug("Timer expired, scheduling save on main thread via AsyncCallback...") + ctx = uno.getComponentContext() + smgr = ctx.ServiceManager + async_cb = smgr.createInstanceWithContext("com.sun.star.awt.AsyncCallback", ctx) + if async_cb: + callback = SafeSaveCallback(self) + async_cb.addCallback(callback, None) + log_debug("Save scheduled successfully.") + else: + log_debug("Failed to create AsyncCallback service, falling back to direct save.") + self._do_actual_save() + except Exception as e: + log_debug(f"Error scheduling safe save: {e}\n{traceback.format_exc()}") + # Fallback + try: + self._do_actual_save() + except Exception as e2: + log_debug(f"Fallback save failed: {e2}") + + def _do_actual_save(self): try: log_debug("Timer expired, attempting save...") if self.doc.supportsService("com.sun.star.document.OfficeDocument"): @@ -61,30 +147,97 @@ class AutoSaveListener(unohelper.Base, XModifyListener): def disposing(self, event): if self.timer: self.timer.cancel() + + # Remove from g_listeners dictionary to prevent leaks + doc = getattr(event, "Source", self.doc) + try: + if doc in g_listeners: + del g_listeners[doc] + log_debug("Successfully removed document from g_listeners on dispose.") + elif self.doc in g_listeners: + del g_listeners[self.doc] + log_debug("Successfully removed self.doc from g_listeners on dispose.") + except Exception as e: + log_debug(f"Error removing document from g_listeners: {e}") + log_debug("Listener disposed.") -g_listeners = {} - -class AutoSaveJob(unohelper.Base, XJob, XJobExecutor): +class AutoSaveJob(unohelper.Base, XJob, XJobExecutor, XDispatchProvider, XDispatch, XInitialization): def __init__(self, ctx=None): self.ctx = ctx + self.frame = None log_debug("AutoSaveJob instantiated!") + # XInitialization + def initialize(self, args): + log_debug(f"AutoSaveJob initialize() called with args: {args}") + if len(args) > 0: + self.frame = args[0] + + # XJob & XJobExecutor def execute(self, arguments): log_debug(f"AutoSaveJob execute() called with args: {arguments}") - self._attach_listener() + self._attach_listener(arguments) return () def trigger(self, args): log_debug(f"AutoSaveJob trigger() called with args: {args}") self._attach_listener() - def _attach_listener(self): + # XDispatchProvider + def queryDispatch(self, url, targetFrameName, searchFlags): + log_debug(f"queryDispatch called for URL: {url.Complete}") + if url.Complete.startswith("org.libreoffice.extensions.autosave.autosavejob:"): + return self + return None + + def queryDispatches(self, requests): + return tuple(self.queryDispatch(r.URL, r.FrameName, r.SearchFlags) for r in requests) + + # XDispatch + def dispatch(self, url, arguments): + log_debug(f"dispatch called for URL: {url.Complete}") + if url.Complete.endswith("ToggleDebugLogging"): + current = is_debug_logging_enabled() + set_debug_logging_enabled(not current) + log_debug(f"Toggled debug logging to: {not current}") + notify_all_listeners() + + def addStatusListener(self, listener, url): + log_debug(f"addStatusListener called for URL: {url.Complete}") + if url.Complete.endswith("ToggleDebugLogging"): + if listener not in g_status_listeners: + g_status_listeners.append(listener) + try: + event = FeatureStateEvent() + event.FeatureURL = url + event.Source = self + event.IsEnabled = True + event.Requery = False + event.State = is_debug_logging_enabled() + listener.statusChanged(event) + except Exception as e: + log_debug(f"Error in addStatusListener initial notify: {e}") + + def removeStatusListener(self, listener, url): + log_debug(f"removeStatusListener called for URL: {url.Complete}") + if listener in g_status_listeners: + g_status_listeners.remove(listener) + + def _attach_listener(self, arguments=None): try: - ctx = uno.getComponentContext() - smgr = ctx.ServiceManager - desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) - doc = desktop.getCurrentComponent() + doc = None + if arguments: + for arg in arguments: + if hasattr(arg, "Name") and arg.Name == "Model": + doc = arg.Value + break + + if not doc: + ctx = uno.getComponentContext() + smgr = ctx.ServiceManager + desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) + doc = desktop.getCurrentComponent() if doc: if doc in g_listeners: @@ -103,5 +256,5 @@ g_ImplementationHelper = unohelper.ImplementationHelper() g_ImplementationHelper.addImplementation( AutoSaveJob, "org.libreoffice.extensions.autosave.AutoSaveJob", - ("com.sun.star.task.Job",), + ("com.sun.star.task.Job", "com.sun.star.frame.ProtocolHandler"), ) diff --git a/tests/__pycache__/test_autosave.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_autosave.cpython-314-pytest-9.0.3.pyc index 9429f118bc98368624abe8af72e0725d5d9747c8..9f5db72a8211d13dca42707f424c2f3077394645 100644 GIT binary patch literal 26877 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 Tf2YsZ_a7Ytw$Itfp~L?VKFK5W delta 1179 zcmY+D&u<$=6vubgcH&*f>-_M~H2#^?@isJWnve=8RokE_DNSS+Yj#C?@9()&#vWoE{{3}I?wZK1zyNY#%N07MPm$D zYD9{XF}_3wd-!hRg)`!*;|z~8oi-8>H)AA$Ck-+!>8U3eys68cvSGH`{d({tS^T1u zMOFQP=;Ax|awrE2O8sZ*r2sh)%i2Yf7I(ErLo+bV%HmS&tSHS3Ep|A{1-7e4w-qt}vC&Xqvd(ThF)83}+PTQgzrfs$;^D-8rCa3GR%T3#& z*V=r0#j!cv+zyag0>{3P|z{qI?tt`)64pv(J(l Ivh45sFEnEf+yDRo diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 91121ae..1c838c9 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -17,9 +17,16 @@ sys.modules['unohelper'] = mock_unohelper class MockListener: pass class MockXJob: pass class MockXJobExecutor: pass +class MockXDispatchProvider: pass +class MockXDispatch: pass +class MockXInitialization: pass +class MockFeatureStateEvent: pass +class MockURL: pass +class MockXCallback: pass mock_util = MagicMock() mock_util.XModifyListener = MockListener +mock_util.URL = MockURL sys.modules['com.sun.star.util'] = mock_util mock_task = MagicMock() @@ -27,6 +34,20 @@ mock_task.XJob = MockXJob mock_task.XJobExecutor = MockXJobExecutor sys.modules['com.sun.star.task'] = mock_task +mock_frame = MagicMock() +mock_frame.XDispatchProvider = MockXDispatchProvider +mock_frame.XDispatch = MockXDispatch +mock_frame.FeatureStateEvent = MockFeatureStateEvent +sys.modules['com.sun.star.frame'] = mock_frame + +mock_lang = MagicMock() +mock_lang.XInitialization = MockXInitialization +sys.modules['com.sun.star.lang'] = mock_lang + +mock_awt = MagicMock() +mock_awt.XCallback = MockXCallback +sys.modules['com.sun.star.awt'] = mock_awt + # Now we can safely import our module import src.autosave as autosave from src.autosave import AutoSaveListener, AutoSaveJob @@ -61,6 +82,15 @@ def test_execute_safe_save_success(mock_copy2, mock_exists): mock_doc.isReadonly.return_value = False mock_doc.getURL.return_value = "file:///dummy/path/test.odt" + # Mock context and smgr for AsyncCallback + mock_ctx = MagicMock() + autosave.uno.getComponentContext.return_value = mock_ctx + mock_smgr = MagicMock() + mock_ctx.ServiceManager = mock_smgr + mock_async_cb = MagicMock() + mock_async_cb.addCallback.side_effect = lambda cb, data: cb.notify(data) + mock_smgr.createInstanceWithContext.return_value = mock_async_cb + # Mock uno.fileUrlToSystemPath to just return a string autosave.uno.fileUrlToSystemPath.return_value = "/dummy/path/test.odt" mock_exists.return_value = True @@ -78,6 +108,15 @@ def test_execute_safe_save_no_location(): mock_doc.supportsService.return_value = True mock_doc.hasLocation.return_value = False # Document not saved yet + # Mock context and smgr for AsyncCallback + mock_ctx = MagicMock() + autosave.uno.getComponentContext.return_value = mock_ctx + mock_smgr = MagicMock() + mock_ctx.ServiceManager = mock_smgr + mock_async_cb = MagicMock() + mock_async_cb.addCallback.side_effect = lambda cb, data: cb.notify(data) + mock_smgr.createInstanceWithContext.return_value = mock_async_cb + listener = AutoSaveListener(mock_doc) listener.execute_safe_save() @@ -110,3 +149,121 @@ def test_autosave_job_execution(): # Re-executing should not attach a duplicate listener job.execute(()) assert mock_doc.addModifyListener.call_count == 1 + +def test_autosave_job_execution_with_model(): + job = AutoSaveJob() + + # Create a mock NamedValue for the Model argument + mock_arg = MagicMock() + mock_arg.Name = "Model" + mock_doc = MagicMock() + mock_arg.Value = mock_doc + + # Execute the job with the Model argument + job.execute((mock_arg,)) + + # The listener should be created and attached to the passed mock_doc + assert mock_doc in autosave.g_listeners + mock_doc.addModifyListener.assert_called_once() + +def test_disposing_cleanup(): + mock_doc = MagicMock() + listener = AutoSaveListener(mock_doc) + + # Put listener in g_listeners + autosave.g_listeners[mock_doc] = listener + assert mock_doc in autosave.g_listeners + + # Trigger disposing + mock_event = MagicMock() + mock_event.Source = mock_doc + listener.disposing(mock_event) + + # The document should be removed from g_listeners + assert mock_doc not in autosave.g_listeners + +def test_debug_logging_configuration(tmp_path): + # Temporarily redirect CONFIG_FILE to a temp file + config_file = tmp_path / "autosave_config.json" + with patch('src.autosave.CONFIG_FILE', config_file): + # Default should be False + assert not autosave.is_debug_logging_enabled() + + # Enabling should set it to True + autosave.set_debug_logging_enabled(True) + assert autosave.is_debug_logging_enabled() + + # Disabling should set it to False + autosave.set_debug_logging_enabled(False) + assert not autosave.is_debug_logging_enabled() + +def test_log_debug_only_when_enabled(tmp_path): + config_file = tmp_path / "autosave_config.json" + log_file = tmp_path / "autosave_debug.log" + + with patch('src.autosave.CONFIG_FILE', config_file), patch('src.autosave.LOG_FILE', log_file): + # By default (disabled), log_debug should not write + autosave.log_debug("test message 1") + assert not log_file.exists() + + # When enabled, log_debug should write + autosave.set_debug_logging_enabled(True) + autosave.log_debug("test message 2") + assert log_file.exists() + with open(log_file, "r") as f: + content = f.read() + assert "test message 2" in content + +def test_protocol_handler_dispatch(tmp_path): + config_file = tmp_path / "autosave_config.json" + job = AutoSaveJob() + + with patch('src.autosave.CONFIG_FILE', config_file): + # Set to False initially + autosave.set_debug_logging_enabled(False) + + # Query dispatch + mock_url = MagicMock() + mock_url.Complete = "org.libreoffice.extensions.autosave.autosavejob:ToggleDebugLogging" + dispatcher = job.queryDispatch(mock_url, "_self", 0) + assert dispatcher is job + + # Query dispatch with invalid protocol + invalid_url = MagicMock() + invalid_url.Complete = "invalid.protocol:ToggleDebugLogging" + assert job.queryDispatch(invalid_url, "_self", 0) is None + + # Query dispatches + req = MagicMock() + req.URL = mock_url + req.FrameName = "_self" + req.SearchFlags = 0 + dispatchers = job.queryDispatches((req,)) + assert dispatchers == (job,) + + # Test dispatch + job.dispatch(mock_url, ()) + assert autosave.is_debug_logging_enabled() + + # Test status listener + mock_listener = MagicMock() + job.addStatusListener(mock_listener, mock_url) + assert mock_listener in autosave.g_status_listeners + + # Verifying statusChanged was called with initial state (True) + mock_listener.statusChanged.assert_called_once() + event = mock_listener.statusChanged.call_args[0][0] + assert event.State is True + + # Dispatching again should toggle to False and notify + mock_listener.statusChanged.reset_mock() + job.dispatch(mock_url, ()) + assert not autosave.is_debug_logging_enabled() + mock_listener.statusChanged.assert_called_once() + event = mock_listener.statusChanged.call_args[0][0] + assert event.State is False + + # Remove listener + job.removeStatusListener(mock_listener, mock_url) + assert mock_listener not in autosave.g_status_listeners +