commit dc2cb7cbc742beb7a63b3074774f7cea5a0c1545 Author: Jason Ross Date: Sun May 31 21:32:16 2026 -0500 built auto save extension for LibreOffice diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b607e54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +.antigravitycli/ +.pytest_cache/ +dist/ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/Jobs.xcu b/build/Jobs.xcu new file mode 100644 index 0000000..8eef9fb --- /dev/null +++ b/build/Jobs.xcu @@ -0,0 +1,22 @@ + + + + + + org.libreoffice.extensions.autosave.AutoSaveJob + + + + + + + + + + + + + + + + diff --git a/build/META-INF/manifest.xml b/build/META-INF/manifest.xml new file mode 100644 index 0000000..81cf6bf --- /dev/null +++ b/build/META-INF/manifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/build/__pycache__/autosave.cpython-314.pyc b/build/__pycache__/autosave.cpython-314.pyc new file mode 100644 index 0000000..b0e97de Binary files /dev/null and b/build/__pycache__/autosave.cpython-314.pyc differ diff --git a/build/autosave.py b/build/autosave.py new file mode 100644 index 0000000..187e68e --- /dev/null +++ b/build/autosave.py @@ -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",), +) diff --git a/build/description.xml b/build/description.xml new file mode 100644 index 0000000..e89d342 --- /dev/null +++ b/build/description.xml @@ -0,0 +1,8 @@ + + + + + + AutoSave + + diff --git a/build_oxt.py b/build_oxt.py new file mode 100644 index 0000000..0026e6c --- /dev/null +++ b/build_oxt.py @@ -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() diff --git a/deploy_and_test.ps1 b/deploy_and_test.ps1 new file mode 100644 index 0000000..34794c4 --- /dev/null +++ b/deploy_and_test.ps1 @@ -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 diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..98f5738 --- /dev/null +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9f3ce84 --- /dev/null +++ b/pyproject.toml @@ -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)" +] diff --git a/src/Jobs.xcu b/src/Jobs.xcu new file mode 100644 index 0000000..8eef9fb --- /dev/null +++ b/src/Jobs.xcu @@ -0,0 +1,22 @@ + + + + + + org.libreoffice.extensions.autosave.AutoSaveJob + + + + + + + + + + + + + + + + diff --git a/src/META-INF/manifest.xml b/src/META-INF/manifest.xml new file mode 100644 index 0000000..81cf6bf --- /dev/null +++ b/src/META-INF/manifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/__pycache__/autosave.cpython-314.pyc b/src/__pycache__/autosave.cpython-314.pyc new file mode 100644 index 0000000..499e46a Binary files /dev/null and b/src/__pycache__/autosave.cpython-314.pyc differ diff --git a/src/autosave.py b/src/autosave.py new file mode 100644 index 0000000..187e68e --- /dev/null +++ b/src/autosave.py @@ -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",), +) diff --git a/src/description.xml b/src/description.xml new file mode 100644 index 0000000..e89d342 --- /dev/null +++ b/src/description.xml @@ -0,0 +1,8 @@ + + + + + + AutoSave + + diff --git a/tests/__pycache__/test_autosave.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_autosave.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..9429f11 Binary files /dev/null and b/tests/__pycache__/test_autosave.cpython-314-pytest-9.0.3.pyc differ diff --git a/tests/test_autosave.py b/tests/test_autosave.py new file mode 100644 index 0000000..91121ae --- /dev/null +++ b/tests/test_autosave.py @@ -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