Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
194c5d5e71 | ||
|
|
5996a0e398 | ||
|
|
ee39585bad | ||
|
|
f5f2065854 |
@@ -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.
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user