built auto save extension for LibreOffice

This commit is contained in:
2026-05-31 21:32:16 -05:00
commit dc2cb7cbc7
17 changed files with 783 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
.antigravitycli/
.pytest_cache/
dist/
+22
View File
@@ -0,0 +1,22 @@
<?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="Jobs" oor:package="org.openoffice.Office">
<node oor:name="Jobs">
<node oor:name="org.libreoffice.extensions.autosave.AutoSaveJob" oor:op="replace">
<prop oor:name="Service">
<value>org.libreoffice.extensions.autosave.AutoSaveJob</value>
</prop>
</node>
</node>
<node oor:name="Events">
<node oor:name="OnLoad" oor:op="fuse">
<node oor:name="JobList">
<node oor:name="org.libreoffice.extensions.autosave.AutoSaveJob" oor:op="replace"/>
</node>
</node>
<node oor:name="OnNew" oor:op="fuse">
<node oor:name="JobList">
<node oor:name="org.libreoffice.extensions.autosave.AutoSaveJob" oor:op="replace"/>
</node>
</node>
</node>
</oor:component-data>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<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:manifest>
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
import os
import shutil
import traceback
from threading import Timer
from datetime import datetime
import uno
import unohelper
from com.sun.star.util import XModifyListener
from com.sun.star.task import XJob, XJobExecutor
LOG_FILE = "C:\\Users\\jasonross\\workspace\\LibreOfficeAutoSave\\autosave_debug.log"
def log_debug(msg):
try:
with open(LOG_FILE, "a") as f:
f.write(f"[{datetime.now().isoformat()}] {msg}\n")
except Exception:
pass
log_debug("autosave.py module is being loaded by LibreOffice!")
class AutoSaveListener(unohelper.Base, XModifyListener):
def __init__(self, doc, delay_seconds=1.0):
self.doc = doc
self.delay = delay_seconds
self.timer = None
log_debug(f"AutoSaveListener initialized for document: {doc}")
def modified(self, event):
if self.timer:
self.timer.cancel()
self.timer = Timer(self.delay, self.execute_safe_save)
self.timer.start()
def execute_safe_save(self):
try:
log_debug("Timer expired, attempting save...")
if self.doc.supportsService("com.sun.star.document.OfficeDocument"):
if self.doc.hasLocation() and not self.doc.isReadonly():
file_url = self.doc.getURL()
log_debug(f"File has location: {file_url}")
if file_url.startswith("file:///"):
system_path = uno.fileUrlToSystemPath(file_url)
bak_path = os.path.join(os.path.dirname(system_path), f".{os.path.basename(system_path)}.bak")
try:
if os.path.exists(system_path):
shutil.copy2(system_path, bak_path)
log_debug(f"Created backup at {bak_path}")
except Exception as e:
log_debug(f"Backup failed: {e}")
self.doc.store()
log_debug("Document successfully saved.")
else:
log_debug("Document is readonly or has no location (not saved yet).")
except Exception as e:
log_debug(f"Auto-save exception: {e}\n{traceback.format_exc()}")
def disposing(self, event):
if self.timer:
self.timer.cancel()
log_debug("Listener disposed.")
g_listeners = {}
class AutoSaveJob(unohelper.Base, XJob, XJobExecutor):
def __init__(self, ctx=None):
self.ctx = ctx
log_debug("AutoSaveJob instantiated!")
def execute(self, arguments):
log_debug(f"AutoSaveJob execute() called with args: {arguments}")
self._attach_listener()
return ()
def trigger(self, args):
log_debug(f"AutoSaveJob trigger() called with args: {args}")
self._attach_listener()
def _attach_listener(self):
try:
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:
log_debug("Listener already attached to this document.")
return
listener = AutoSaveListener(doc, delay_seconds=1.0)
doc.addModifyListener(listener)
g_listeners[doc] = listener
log_debug("Successfully attached listener to current document.")
else:
log_debug("No active document found.")
except Exception as e:
log_debug(f"Error in AutoSaveJob attach: {e}\n{traceback.format_exc()}")
g_ImplementationHelper = unohelper.ImplementationHelper()
g_ImplementationHelper.addImplementation(
AutoSaveJob,
"org.libreoffice.extensions.autosave.AutoSaveJob",
("com.sun.star.task.Job",),
)
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<description xmlns="http://openoffice.org/extensions/description/2006" xmlns:d="http://openoffice.org/extensions/description/2006" xmlns:xlink="http://www.w3.org/1999/xlink">
<identifier value="org.libreoffice.extensions.autosave"/>
<version value="1.0.0"/>
<display-name>
<name lang="en-US">AutoSave</name>
</display-name>
</description>
+32
View File
@@ -0,0 +1,32 @@
import os
import zipfile
import shutil
def build_oxt():
build_dir = "build"
dist_dir = "dist"
oxt_name = "AutoSave.oxt"
# Clean previous builds
if os.path.exists(build_dir):
shutil.rmtree(build_dir)
if not os.path.exists(dist_dir):
os.makedirs(dist_dir)
# Copy source files to build directory
shutil.copytree("src", build_dir)
# Create the .oxt file (which is a zip archive)
oxt_path = os.path.join(dist_dir, oxt_name)
with zipfile.ZipFile(oxt_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, _, files in os.walk(build_dir):
for file in files:
file_path = os.path.join(root, file)
# The archive root should be the contents of the 'build' dir
arcname = os.path.relpath(file_path, build_dir).replace("\\", "/")
zipf.write(file_path, arcname)
print(f"Successfully built extension: {oxt_path}")
if __name__ == "__main__":
build_oxt()
+33
View File
@@ -0,0 +1,33 @@
$ErrorActionPreference = "Stop"
$LO_PATH = "C:\Program Files\LibreOffice\program"
$UNOPKG = Join-Path $LO_PATH "unopkg.com"
$SOFFICE = Join-Path $LO_PATH "soffice.exe"
$EXT_ID = "org.libreoffice.extensions.autosave"
Write-Host "1. Building the extension..." -ForegroundColor Cyan
python build_oxt.py
Write-Host "2. Killing open LibreOffice instances..." -ForegroundColor Cyan
# Kill both the launcher and the main binary, silently ignoring if they aren't running
Stop-Process -Name "soffice" -Force -ErrorAction SilentlyContinue
Stop-Process -Name "soffice.bin" -Force -ErrorAction SilentlyContinue
# Give the OS a moment to fully release file locks on the UNO packages
Start-Sleep -Seconds 2
Write-Host "3. Uninstalling existing extension..." -ForegroundColor Cyan
# We catch and ignore errors here because 'unopkg remove' will throw an error if the extension isn't currently installed
try {
& $UNOPKG remove $EXT_ID *>&1 | Out-Null
} catch {
# Ignore "package not found" errors
}
Write-Host "4. Installing the new extension..." -ForegroundColor Cyan
& $UNOPKG add -f dist\AutoSave.oxt
Write-Host "5. Starting LibreOffice Writer..." -ForegroundColor Cyan
Start-Process -FilePath $SOFFICE -ArgumentList "--writer"
Write-Host "Deployment complete! Writer is launching in the background." -ForegroundColor Green
Generated
+273
View File
@@ -0,0 +1,273 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "black"
version = "26.5.1"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893"},
{file = "black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90"},
{file = "black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4"},
{file = "black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef"},
{file = "black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22"},
{file = "black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c"},
{file = "black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7"},
{file = "black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59"},
{file = "black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3"},
{file = "black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe"},
{file = "black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8"},
{file = "black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217"},
{file = "black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d"},
{file = "black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264"},
{file = "black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418"},
{file = "black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3"},
{file = "black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0"},
{file = "black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294"},
{file = "black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a"},
{file = "black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52"},
{file = "black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168"},
{file = "black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3"},
{file = "black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18"},
{file = "black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50"},
{file = "black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae"},
{file = "black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2"},
{file = "black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=1.0.0"
platformdirs = ">=2"
pytokens = ">=0.4.0,<0.5.0"
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.10)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""]
[[package]]
name = "click"
version = "8.4.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2"},
{file = "click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "isort"
version = "8.0.1"
description = "A Python utility / library to sort Python imports."
optional = false
python-versions = ">=3.10.0"
groups = ["dev"]
files = [
{file = "isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"},
{file = "isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d"},
]
[package.extras]
colors = ["colorama"]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "packaging"
version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]]
name = "pathspec"
version = "1.1.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"},
{file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"},
]
[package.extras]
hyperscan = ["hyperscan (>=0.7)"]
optional = ["typing-extensions (>=4)"]
re2 = ["google-re2 (>=1.1)"]
[[package]]
name = "platformdirs"
version = "4.10.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"},
{file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
version = "9.0.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytokens"
version = "0.4.1"
description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"},
{file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"},
{file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"},
{file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"},
{file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"},
{file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"},
{file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"},
{file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"},
{file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"},
{file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"},
{file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"},
{file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"},
{file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"},
{file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"},
{file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"},
{file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"},
{file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"},
{file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"},
{file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"},
{file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"},
{file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"},
{file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"},
{file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"},
{file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"},
{file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"},
{file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"},
{file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"},
{file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"},
{file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"},
{file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"},
{file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"},
{file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"},
{file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"},
{file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"},
{file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"},
{file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"},
{file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"},
{file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"},
{file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"},
{file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"},
{file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"},
{file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"},
]
[package.extras]
dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.14"
content-hash = "677487ac55f2b53800a1f496f6b30a79dcec9f89aae9c147f61e95914fd22dd9"
+22
View File
@@ -0,0 +1,22 @@
[project]
name = "libreoffice-autosave"
version = "0.1.0"
description = "LibreOffice AutoSave Extension"
authors = [
{name = "Jason Ross",email = "jason.ross841@gmail.com"}
]
requires-python = ">=3.14"
dependencies = [
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"pytest (>=9.0.3,<10.0.0)",
"black (>=26.5.1,<27.0.0)",
"isort (>=8.0.1,<9.0.0)"
]
+22
View File
@@ -0,0 +1,22 @@
<?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="Jobs" oor:package="org.openoffice.Office">
<node oor:name="Jobs">
<node oor:name="org.libreoffice.extensions.autosave.AutoSaveJob" oor:op="replace">
<prop oor:name="Service">
<value>org.libreoffice.extensions.autosave.AutoSaveJob</value>
</prop>
</node>
</node>
<node oor:name="Events">
<node oor:name="OnLoad" oor:op="fuse">
<node oor:name="JobList">
<node oor:name="org.libreoffice.extensions.autosave.AutoSaveJob" oor:op="replace"/>
</node>
</node>
<node oor:name="OnNew" oor:op="fuse">
<node oor:name="JobList">
<node oor:name="org.libreoffice.extensions.autosave.AutoSaveJob" oor:op="replace"/>
</node>
</node>
</node>
</oor:component-data>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<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:manifest>
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
import os
import shutil
import traceback
from threading import Timer
from datetime import datetime
import uno
import unohelper
from com.sun.star.util import XModifyListener
from com.sun.star.task import XJob, XJobExecutor
LOG_FILE = "C:\\Users\\jasonross\\workspace\\LibreOfficeAutoSave\\autosave_debug.log"
def log_debug(msg):
try:
with open(LOG_FILE, "a") as f:
f.write(f"[{datetime.now().isoformat()}] {msg}\n")
except Exception:
pass
log_debug("autosave.py module is being loaded by LibreOffice!")
class AutoSaveListener(unohelper.Base, XModifyListener):
def __init__(self, doc, delay_seconds=1.0):
self.doc = doc
self.delay = delay_seconds
self.timer = None
log_debug(f"AutoSaveListener initialized for document: {doc}")
def modified(self, event):
if self.timer:
self.timer.cancel()
self.timer = Timer(self.delay, self.execute_safe_save)
self.timer.start()
def execute_safe_save(self):
try:
log_debug("Timer expired, attempting save...")
if self.doc.supportsService("com.sun.star.document.OfficeDocument"):
if self.doc.hasLocation() and not self.doc.isReadonly():
file_url = self.doc.getURL()
log_debug(f"File has location: {file_url}")
if file_url.startswith("file:///"):
system_path = uno.fileUrlToSystemPath(file_url)
bak_path = os.path.join(os.path.dirname(system_path), f".{os.path.basename(system_path)}.bak")
try:
if os.path.exists(system_path):
shutil.copy2(system_path, bak_path)
log_debug(f"Created backup at {bak_path}")
except Exception as e:
log_debug(f"Backup failed: {e}")
self.doc.store()
log_debug("Document successfully saved.")
else:
log_debug("Document is readonly or has no location (not saved yet).")
except Exception as e:
log_debug(f"Auto-save exception: {e}\n{traceback.format_exc()}")
def disposing(self, event):
if self.timer:
self.timer.cancel()
log_debug("Listener disposed.")
g_listeners = {}
class AutoSaveJob(unohelper.Base, XJob, XJobExecutor):
def __init__(self, ctx=None):
self.ctx = ctx
log_debug("AutoSaveJob instantiated!")
def execute(self, arguments):
log_debug(f"AutoSaveJob execute() called with args: {arguments}")
self._attach_listener()
return ()
def trigger(self, args):
log_debug(f"AutoSaveJob trigger() called with args: {args}")
self._attach_listener()
def _attach_listener(self):
try:
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:
log_debug("Listener already attached to this document.")
return
listener = AutoSaveListener(doc, delay_seconds=1.0)
doc.addModifyListener(listener)
g_listeners[doc] = listener
log_debug("Successfully attached listener to current document.")
else:
log_debug("No active document found.")
except Exception as e:
log_debug(f"Error in AutoSaveJob attach: {e}\n{traceback.format_exc()}")
g_ImplementationHelper = unohelper.ImplementationHelper()
g_ImplementationHelper.addImplementation(
AutoSaveJob,
"org.libreoffice.extensions.autosave.AutoSaveJob",
("com.sun.star.task.Job",),
)
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<description xmlns="http://openoffice.org/extensions/description/2006" xmlns:d="http://openoffice.org/extensions/description/2006" xmlns:xlink="http://www.w3.org/1999/xlink">
<identifier value="org.libreoffice.extensions.autosave"/>
<version value="1.0.0"/>
<display-name>
<name lang="en-US">AutoSave</name>
</display-name>
</description>
+112
View File
@@ -0,0 +1,112 @@
import sys
import os
import time
from unittest.mock import MagicMock, patch
# Add the project root to sys.path so we can import src
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Mock the uno and unohelper modules before importing our module
sys.modules['uno'] = MagicMock()
class MockBase: pass
mock_unohelper = MagicMock()
mock_unohelper.Base = MockBase
sys.modules['unohelper'] = mock_unohelper
class MockListener: pass
class MockXJob: pass
class MockXJobExecutor: pass
mock_util = MagicMock()
mock_util.XModifyListener = MockListener
sys.modules['com.sun.star.util'] = mock_util
mock_task = MagicMock()
mock_task.XJob = MockXJob
mock_task.XJobExecutor = MockXJobExecutor
sys.modules['com.sun.star.task'] = mock_task
# Now we can safely import our module
import src.autosave as autosave
from src.autosave import AutoSaveListener, AutoSaveJob
def setup_function():
# Clear the global listeners dictionary before each test
autosave.g_listeners.clear()
def test_debounce_timer():
mock_doc = MagicMock()
listener = AutoSaveListener(mock_doc, delay_seconds=0.1)
# Simulate multiple rapid modifications
listener.modified(None)
first_timer = listener.timer
listener.modified(None)
second_timer = listener.timer
# The first timer should be cancelled, the second should be active
assert first_timer is not second_timer
# Ensure execute_safe_save has not executed synchronously
mock_doc.store.assert_not_called()
@patch('src.autosave.os.path.exists')
@patch('src.autosave.shutil.copy2')
def test_execute_safe_save_success(mock_copy2, mock_exists):
mock_doc = MagicMock()
mock_doc.supportsService.return_value = True
mock_doc.hasLocation.return_value = True
mock_doc.isReadonly.return_value = False
mock_doc.getURL.return_value = "file:///dummy/path/test.odt"
# Mock uno.fileUrlToSystemPath to just return a string
autosave.uno.fileUrlToSystemPath.return_value = "/dummy/path/test.odt"
mock_exists.return_value = True
listener = AutoSaveListener(mock_doc, delay_seconds=0.1)
listener.execute_safe_save()
# Assert copy2 was called to create the backup
mock_copy2.assert_called_once()
# Assert store() was called to save the document
mock_doc.store.assert_called_once()
def test_execute_safe_save_no_location():
mock_doc = MagicMock()
mock_doc.supportsService.return_value = True
mock_doc.hasLocation.return_value = False # Document not saved yet
listener = AutoSaveListener(mock_doc)
listener.execute_safe_save()
# store() should NOT be called
mock_doc.store.assert_not_called()
def test_autosave_job_execution():
job = AutoSaveJob()
# Mock context and document
mock_ctx = MagicMock()
autosave.uno.getComponentContext.return_value = mock_ctx
mock_smgr = MagicMock()
mock_ctx.ServiceManager = mock_smgr
mock_desktop = MagicMock()
mock_smgr.createInstanceWithContext.return_value = mock_desktop
mock_doc = MagicMock()
mock_desktop.getCurrentComponent.return_value = mock_doc
# Execute the job
job.execute(())
# The listener should be created and attached
assert mock_doc in autosave.g_listeners
mock_doc.addModifyListener.assert_called_once()
# Re-executing should not attach a duplicate listener
job.execute(())
assert mock_doc.addModifyListener.call_count == 1