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 9429f11..9f5db72 100644 Binary files a/tests/__pycache__/test_autosave.cpython-314-pytest-9.0.3.pyc and b/tests/__pycache__/test_autosave.cpython-314-pytest-9.0.3.pyc differ 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 +