4 Commits
Author SHA1 Message Date
Jason Ross 194c5d5e71 Merge pull request #3 from JMR-dev/feat-readme-update-install-sugar
fixing pathing and core logic
2026-06-03 19:58:25 -05:00
JMR-dev 5996a0e398 fixing pathing and core logic 2026-06-03 19:56:52 -05:00
Jason Ross ee39585bad Merge pull request #2 from JMR-dev/feat-readme-update-install-sugar
Add instructions for auto-install
2026-05-31 22:40:43 -05:00
JMR-dev f5f2065854 Add instructions for auto-install 2026-05-31 22:39:24 -05:00
11 changed files with 591 additions and 24 deletions
+30
View File
@@ -9,6 +9,36 @@ A lightweight, background-running extension for LibreOffice that automatically s
- **Atomic Backups:** Before triggering LibreOffice's internal synchronous `.store()`, the extension executes an atomic file-copy of the last known saved state to a hidden `.bak` file (e.g., `.filename.bak`). This protects your data payload from corruption if your OS or LibreOffice crashes during a complex write cycle.
- **Safe Evaluation:** Will not blindly save new documents that have not been given an initial file path location, protecting you from unexpected file generation.
## Automatic Installation
**For Windows (PowerShell):**
```powershell
# Download the latest release
Invoke-WebRequest -Uri "https://github.com/JMR-dev/LibreOfficeAutoSave/releases/latest/download/AutoSave.oxt" -OutFile "AutoSave.oxt"
# Install the extension
& "C:\Program Files\LibreOffice\program\unopkg.com" add -f AutoSave.oxt
# Clean up the downloaded file
Remove-Item AutoSave.oxt
```
**For macOS / Linux (Bash):**
```bash
# Download the latest release
curl -LO https://github.com/JMR-dev/LibreOfficeAutoSave/releases/latest/download/AutoSave.oxt
# Install the extension
if [[ "$OSTYPE" == "darwin"* ]]; then
/Applications/LibreOffice.app/Contents/MacOS/unopkg add -f AutoSave.oxt
else
unopkg add -f AutoSave.oxt
fi
# Clean up the downloaded file
rm AutoSave.oxt
```
## Manual Installation
1. Grab the compiled `AutoSave.oxt` file from the `dist/` directory or the latest release.
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" oor:name="Addons" oor:package="org.openoffice.Office">
<node oor:name="AddonUI">
<node oor:name="OfficeMenuBar">
<node oor:name="org.libreoffice.extensions.autosave.menubar" oor:op="replace">
<prop oor:name="Title" oor:type="xs:string">
<value xml:lang="en-US">AutoSave</value>
</prop>
<node oor:name="Submenu">
<node oor:name="m1" oor:op="replace">
<prop oor:name="Title" oor:type="xs:string">
<value xml:lang="en-US">Enable debug logging</value>
</prop>
<prop oor:name="URL" oor:type="xs:string">
<value>org.libreoffice.extensions.autosave.autosavejob:ToggleDebugLogging</value>
</prop>
<prop oor:name="Target" oor:type="xs:string">
<value>_self</value>
</prop>
</node>
</node>
</node>
</node>
</node>
</oor:component-data>
+2
View File
@@ -2,4 +2,6 @@
<manifest:manifest xmlns:manifest="http://openoffice.org/2001/manifest">
<manifest:file-entry manifest:media-type="application/vnd.sun.star.uno-component;type=Python" manifest:full-path="autosave.py"/>
<manifest:file-entry manifest:media-type="application/vnd.sun.star.configuration-data" manifest:full-path="Jobs.xcu"/>
<manifest:file-entry manifest:media-type="application/vnd.sun.star.configuration-data" manifest:full-path="Addons.xcu"/>
<manifest:file-entry manifest:media-type="application/vnd.sun.star.configuration-data" manifest:full-path="ProtocolHandler.xcu"/>
</manifest:manifest>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" oor:name="ProtocolHandler" oor:package="org.openoffice.Office">
<node oor:name="HandlerSet">
<node oor:name="org.libreoffice.extensions.autosave.AutoSaveJob" oor:op="replace">
<prop oor:name="Protocols" oor:type="oor:string-list">
<value>org.libreoffice.extensions.autosave.autosavejob:*</value>
</prop>
</node>
</node>
</oor:component-data>
+165 -12
View File
@@ -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"),
)
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" oor:name="Addons" oor:package="org.openoffice.Office">
<node oor:name="AddonUI">
<node oor:name="OfficeMenuBar">
<node oor:name="org.libreoffice.extensions.autosave.menubar" oor:op="replace">
<prop oor:name="Title" oor:type="xs:string">
<value xml:lang="en-US">AutoSave</value>
</prop>
<node oor:name="Submenu">
<node oor:name="m1" oor:op="replace">
<prop oor:name="Title" oor:type="xs:string">
<value xml:lang="en-US">Enable debug logging</value>
</prop>
<prop oor:name="URL" oor:type="xs:string">
<value>org.libreoffice.extensions.autosave.autosavejob:ToggleDebugLogging</value>
</prop>
<prop oor:name="Target" oor:type="xs:string">
<value>_self</value>
</prop>
</node>
</node>
</node>
</node>
</node>
</oor:component-data>
+2
View File
@@ -2,4 +2,6 @@
<manifest:manifest xmlns:manifest="http://openoffice.org/2001/manifest">
<manifest:file-entry manifest:media-type="application/vnd.sun.star.uno-component;type=Python" manifest:full-path="autosave.py"/>
<manifest:file-entry manifest:media-type="application/vnd.sun.star.configuration-data" manifest:full-path="Jobs.xcu"/>
<manifest:file-entry manifest:media-type="application/vnd.sun.star.configuration-data" manifest:full-path="Addons.xcu"/>
<manifest:file-entry manifest:media-type="application/vnd.sun.star.configuration-data" manifest:full-path="ProtocolHandler.xcu"/>
</manifest:manifest>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" oor:name="ProtocolHandler" oor:package="org.openoffice.Office">
<node oor:name="HandlerSet">
<node oor:name="org.libreoffice.extensions.autosave.AutoSaveJob" oor:op="replace">
<prop oor:name="Protocols" oor:type="oor:string-list">
<value>org.libreoffice.extensions.autosave.autosavejob:*</value>
</prop>
</node>
</node>
</oor:component-data>
+165 -12
View File
@@ -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"),
)
+157
View File
@@ -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