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.
|
- **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.
|
- **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
|
## Manual Installation
|
||||||
|
|
||||||
1. Grab the compiled `AutoSave.oxt` file from the `dist/` directory or the latest release.
|
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: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.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="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>
|
</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 os
|
||||||
import shutil
|
import shutil
|
||||||
import traceback
|
import traceback
|
||||||
|
import json
|
||||||
from threading import Timer
|
from threading import Timer
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uno
|
import uno
|
||||||
import unohelper
|
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.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):
|
def log_debug(msg):
|
||||||
|
if not is_debug_logging_enabled():
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
with open(LOG_FILE, "a") as f:
|
with open(LOG_FILE, "a") as f:
|
||||||
f.write(f"[{datetime.now().isoformat()}] {msg}\n")
|
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!")
|
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):
|
class AutoSaveListener(unohelper.Base, XModifyListener):
|
||||||
def __init__(self, doc, delay_seconds=1.0):
|
def __init__(self, doc, delay_seconds=1.0):
|
||||||
self.doc = doc
|
self.doc = doc
|
||||||
@@ -34,6 +99,27 @@ class AutoSaveListener(unohelper.Base, XModifyListener):
|
|||||||
self.timer.start()
|
self.timer.start()
|
||||||
|
|
||||||
def execute_safe_save(self):
|
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:
|
try:
|
||||||
log_debug("Timer expired, attempting save...")
|
log_debug("Timer expired, attempting save...")
|
||||||
if self.doc.supportsService("com.sun.star.document.OfficeDocument"):
|
if self.doc.supportsService("com.sun.star.document.OfficeDocument"):
|
||||||
@@ -61,30 +147,97 @@ class AutoSaveListener(unohelper.Base, XModifyListener):
|
|||||||
def disposing(self, event):
|
def disposing(self, event):
|
||||||
if self.timer:
|
if self.timer:
|
||||||
self.timer.cancel()
|
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.")
|
log_debug("Listener disposed.")
|
||||||
|
|
||||||
g_listeners = {}
|
class AutoSaveJob(unohelper.Base, XJob, XJobExecutor, XDispatchProvider, XDispatch, XInitialization):
|
||||||
|
|
||||||
class AutoSaveJob(unohelper.Base, XJob, XJobExecutor):
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
|
self.frame = None
|
||||||
log_debug("AutoSaveJob instantiated!")
|
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):
|
def execute(self, arguments):
|
||||||
log_debug(f"AutoSaveJob execute() called with args: {arguments}")
|
log_debug(f"AutoSaveJob execute() called with args: {arguments}")
|
||||||
self._attach_listener()
|
self._attach_listener(arguments)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
def trigger(self, args):
|
def trigger(self, args):
|
||||||
log_debug(f"AutoSaveJob trigger() called with args: {args}")
|
log_debug(f"AutoSaveJob trigger() called with args: {args}")
|
||||||
self._attach_listener()
|
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:
|
try:
|
||||||
ctx = uno.getComponentContext()
|
doc = None
|
||||||
smgr = ctx.ServiceManager
|
if arguments:
|
||||||
desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
|
for arg in arguments:
|
||||||
doc = desktop.getCurrentComponent()
|
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:
|
||||||
if doc in g_listeners:
|
if doc in g_listeners:
|
||||||
@@ -103,5 +256,5 @@ g_ImplementationHelper = unohelper.ImplementationHelper()
|
|||||||
g_ImplementationHelper.addImplementation(
|
g_ImplementationHelper.addImplementation(
|
||||||
AutoSaveJob,
|
AutoSaveJob,
|
||||||
"org.libreoffice.extensions.autosave.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: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.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="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>
|
</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 os
|
||||||
import shutil
|
import shutil
|
||||||
import traceback
|
import traceback
|
||||||
|
import json
|
||||||
from threading import Timer
|
from threading import Timer
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uno
|
import uno
|
||||||
import unohelper
|
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.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):
|
def log_debug(msg):
|
||||||
|
if not is_debug_logging_enabled():
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
with open(LOG_FILE, "a") as f:
|
with open(LOG_FILE, "a") as f:
|
||||||
f.write(f"[{datetime.now().isoformat()}] {msg}\n")
|
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!")
|
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):
|
class AutoSaveListener(unohelper.Base, XModifyListener):
|
||||||
def __init__(self, doc, delay_seconds=1.0):
|
def __init__(self, doc, delay_seconds=1.0):
|
||||||
self.doc = doc
|
self.doc = doc
|
||||||
@@ -34,6 +99,27 @@ class AutoSaveListener(unohelper.Base, XModifyListener):
|
|||||||
self.timer.start()
|
self.timer.start()
|
||||||
|
|
||||||
def execute_safe_save(self):
|
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:
|
try:
|
||||||
log_debug("Timer expired, attempting save...")
|
log_debug("Timer expired, attempting save...")
|
||||||
if self.doc.supportsService("com.sun.star.document.OfficeDocument"):
|
if self.doc.supportsService("com.sun.star.document.OfficeDocument"):
|
||||||
@@ -61,30 +147,97 @@ class AutoSaveListener(unohelper.Base, XModifyListener):
|
|||||||
def disposing(self, event):
|
def disposing(self, event):
|
||||||
if self.timer:
|
if self.timer:
|
||||||
self.timer.cancel()
|
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.")
|
log_debug("Listener disposed.")
|
||||||
|
|
||||||
g_listeners = {}
|
class AutoSaveJob(unohelper.Base, XJob, XJobExecutor, XDispatchProvider, XDispatch, XInitialization):
|
||||||
|
|
||||||
class AutoSaveJob(unohelper.Base, XJob, XJobExecutor):
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
|
self.frame = None
|
||||||
log_debug("AutoSaveJob instantiated!")
|
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):
|
def execute(self, arguments):
|
||||||
log_debug(f"AutoSaveJob execute() called with args: {arguments}")
|
log_debug(f"AutoSaveJob execute() called with args: {arguments}")
|
||||||
self._attach_listener()
|
self._attach_listener(arguments)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
def trigger(self, args):
|
def trigger(self, args):
|
||||||
log_debug(f"AutoSaveJob trigger() called with args: {args}")
|
log_debug(f"AutoSaveJob trigger() called with args: {args}")
|
||||||
self._attach_listener()
|
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:
|
try:
|
||||||
ctx = uno.getComponentContext()
|
doc = None
|
||||||
smgr = ctx.ServiceManager
|
if arguments:
|
||||||
desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
|
for arg in arguments:
|
||||||
doc = desktop.getCurrentComponent()
|
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:
|
||||||
if doc in g_listeners:
|
if doc in g_listeners:
|
||||||
@@ -103,5 +256,5 @@ g_ImplementationHelper = unohelper.ImplementationHelper()
|
|||||||
g_ImplementationHelper.addImplementation(
|
g_ImplementationHelper.addImplementation(
|
||||||
AutoSaveJob,
|
AutoSaveJob,
|
||||||
"org.libreoffice.extensions.autosave.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 MockListener: pass
|
||||||
class MockXJob: pass
|
class MockXJob: pass
|
||||||
class MockXJobExecutor: 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 = MagicMock()
|
||||||
mock_util.XModifyListener = MockListener
|
mock_util.XModifyListener = MockListener
|
||||||
|
mock_util.URL = MockURL
|
||||||
sys.modules['com.sun.star.util'] = mock_util
|
sys.modules['com.sun.star.util'] = mock_util
|
||||||
|
|
||||||
mock_task = MagicMock()
|
mock_task = MagicMock()
|
||||||
@@ -27,6 +34,20 @@ mock_task.XJob = MockXJob
|
|||||||
mock_task.XJobExecutor = MockXJobExecutor
|
mock_task.XJobExecutor = MockXJobExecutor
|
||||||
sys.modules['com.sun.star.task'] = mock_task
|
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
|
# Now we can safely import our module
|
||||||
import src.autosave as autosave
|
import src.autosave as autosave
|
||||||
from src.autosave import AutoSaveListener, AutoSaveJob
|
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.isReadonly.return_value = False
|
||||||
mock_doc.getURL.return_value = "file:///dummy/path/test.odt"
|
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
|
# Mock uno.fileUrlToSystemPath to just return a string
|
||||||
autosave.uno.fileUrlToSystemPath.return_value = "/dummy/path/test.odt"
|
autosave.uno.fileUrlToSystemPath.return_value = "/dummy/path/test.odt"
|
||||||
mock_exists.return_value = True
|
mock_exists.return_value = True
|
||||||
@@ -78,6 +108,15 @@ def test_execute_safe_save_no_location():
|
|||||||
mock_doc.supportsService.return_value = True
|
mock_doc.supportsService.return_value = True
|
||||||
mock_doc.hasLocation.return_value = False # Document not saved yet
|
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 = AutoSaveListener(mock_doc)
|
||||||
listener.execute_safe_save()
|
listener.execute_safe_save()
|
||||||
|
|
||||||
@@ -110,3 +149,121 @@ def test_autosave_job_execution():
|
|||||||
# Re-executing should not attach a duplicate listener
|
# Re-executing should not attach a duplicate listener
|
||||||
job.execute(())
|
job.execute(())
|
||||||
assert mock_doc.addModifyListener.call_count == 1
|
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