feat: initial LibreOffice Writer Ollama sidebar extension scaffold

This commit is contained in:
2026-03-08 12:44:50 +01:00
commit b6bb1e18e6
8 changed files with 588 additions and 0 deletions

23
Addons.xcu Normal file
View File

@@ -0,0 +1,23 @@
<?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="OfficeMenuBarMerging">
<node oor:name="de.aquantico.ollama.menu" oor:op="replace">
<prop oor:name="MergePoint"><value>.uno:ToolsMenu</value></prop>
<prop oor:name="MergeCommand"><value>AddAfter</value></prop>
<prop oor:name="MergeFallback"><value>AddPath</value></prop>
<node oor:name="MenuItems">
<node oor:name="m1" oor:op="replace">
<prop oor:name="URL"><value>service:de.aquantico.ollama.sidebar.Toggle</value></prop>
<prop oor:name="Title"><value>Ollama Sidebar öffnen</value></prop>
<prop oor:name="Target"><value>_self</value></prop>
<prop oor:name="Context"><value>com.sun.star.text.TextDocument</value></prop>
</node>
</node>
</node>
</node>
</node>
</oor:component-data>

7
META-INF/manifest.xml Normal file
View File

@@ -0,0 +1,7 @@
<?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.configuration-data" manifest:full-path="Addons.xcu"/>
<manifest:file-entry manifest:media-type="application/vnd.sun.star.configuration-data" manifest:full-path="Sidebar.xcu"/>
<manifest:file-entry manifest:media-type="application/vnd.sun.star.uno-components" manifest:full-path="OllamaSidebar.components"/>
<manifest:file-entry manifest:media-type="application/binary" manifest:full-path="src/ollama_sidebar.py"/>
</manifest:manifest>

8
OllamaSidebar.components Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<components xmlns="http://openoffice.org/2010/uno-components">
<component loader="com.sun.star.loader.Python" uri="vnd.openoffice.pymodule:ollama_sidebar">
<implementation name="de.aquantico.ollama.sidebar.Toggle">
<service name="com.sun.star.task.Job"/>
</implementation>
</component>
</components>

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# LibreOffice Writer Ollama Sidebar
LibreOffice Writer Extension (`.oxt`) mit Sidebar-Panel:
- Prompt direkt in der Seitenleiste
- Auswahl/Absatz an Ollama senden
- Ergebnis zurück in Writer einfügen oder Auswahl ersetzen
## Status
MVP-Gerüst mit Sidebar-Panel + Python UNO-Komponente.
## Struktur
- `description.xml`
- `Addons.xcu`
- `Sidebar.xcu`
- `src/ollama_sidebar.py`
- `META-INF/manifest.xml`
## Build (.oxt)
```bash
cd libreoffice-writer-ollama-sidebar
zip -r ollama-sidebar.oxt description.xml Addons.xcu Sidebar.xcu META-INF src resources
```
## Installation
- LibreOffice -> Extras -> Erweiterungsmanager -> Hinzufügen -> `ollama-sidebar.oxt`
- Writer öffnen
- Seitenleiste -> Deck/Panel `Ollama Rewrite`
## Konfiguration
In der Sidebar:
- Ollama URL (z. B. `http://127.0.0.1:11434`)
- Modell (z. B. `llama3.1:8b`)
- Prompt
## Hinweis
LibreOffice UNO-Sidebar APIs variieren je Version. Dieses Repo enthält ein solides Startgerüst; bei Bedarf passe ich es auf deine konkrete LO-Version nach.

22
Sidebar.xcu Normal file
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="Sidebar"
oor:package="org.openoffice.Office.UI">
<node oor:name="DeckList">
<node oor:name="de.aquantico.ollama.deck" oor:op="replace">
<prop oor:name="Title"><value>Ollama</value></prop>
<prop oor:name="Id"><value>de.aquantico.ollama.deck</value></prop>
<prop oor:name="IconURL"><value></value></prop>
<prop oor:name="ContextList"><value>com.sun.star.text.TextDocument</value></prop>
<node oor:name="PanelList">
<node oor:name="de.aquantico.ollama.panel" oor:op="replace">
<prop oor:name="Title"><value>Ollama Rewrite</value></prop>
<prop oor:name="Id"><value>de.aquantico.ollama.panel</value></prop>
<prop oor:name="ImplementationURL"><value>vnd.sun.star.expand:$UNO_USER_PACKAGES_CACHE/uno_packages/ollama-sidebar.oxt/src/ollama_sidebar.py</value></prop>
<prop oor:name="ContextList"><value>com.sun.star.text.TextDocument</value></prop>
</node>
</node>
</node>
</node>
</oor:component-data>

16
description.xml Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<description xmlns="http://openoffice.org/extensions/description/2006"
xmlns:xlink="http://www.w3.org/1999/xlink">
<identifier value="de.aquantico.libreoffice.ollama.sidebar"/>
<version value="0.1.0"/>
<display-name>
<name lang="en">Ollama Writer Sidebar</name>
<name lang="de">Ollama Writer Seitenleiste</name>
</display-name>
<publisher>
<name xlink:href="https://aquantico.lan">Aquantico</name>
</publisher>
<dependencies>
<OpenOffice.org-minimal-version value="7.0"/>
</dependencies>
</description>

235
ollama_sidebar.py Normal file
View File

@@ -0,0 +1,235 @@
import json
import os
import urllib.request
import uno
import unohelper
from com.sun.star.task import XJobExecutor
from com.sun.star.awt import XActionListener
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".ollama_writer_sidebar")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
DEFAULT_CFG = {
"ollama_url": "http://127.0.0.1:11434",
"model": "llama3.1:8b",
"prompt": "Überarbeite den Text klarer, Sinn beibehalten."
}
def load_cfg():
try:
os.makedirs(CONFIG_DIR, exist_ok=True)
if not os.path.exists(CONFIG_FILE):
save_cfg(DEFAULT_CFG)
return dict(DEFAULT_CFG)
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CFG.items():
data.setdefault(k, v)
return data
except Exception:
return dict(DEFAULT_CFG)
def save_cfg(cfg):
os.makedirs(CONFIG_DIR, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def ollama_generate(url, model, prompt, source):
full_prompt = f"{prompt}\n\nTEXT:\n{source}"
payload = json.dumps({"model": model, "prompt": full_prompt, "stream": False}).encode("utf-8")
req = urllib.request.Request(url.rstrip("/") + "/api/generate", data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data.get("response", "")
def get_doc_text(doc):
return doc.Text.String if hasattr(doc, "Text") else ""
def get_selected_text(doc):
try:
sel = doc.getCurrentSelection()
if sel and sel.getCount() > 0:
part = sel.getByIndex(0)
return getattr(part, "String", "")
except Exception:
pass
return ""
def replace_selection_or_append(doc, txt):
try:
sel = doc.getCurrentSelection()
if sel and sel.getCount() > 0:
part = sel.getByIndex(0)
if hasattr(part, "String"):
part.String = txt
return
except Exception:
pass
try:
doc.Text.insertString(doc.Text.End, "\n" + txt, False)
except Exception:
pass
class _BtnListener(unohelper.Base, XActionListener):
def __init__(self, owner):
self.owner = owner
def actionPerformed(self, event):
cmd = event.ActionCommand
self.owner.on_action(cmd)
def disposing(self, event):
pass
class OllamaDialog:
def __init__(self, ctx):
self.ctx = ctx
self.smgr = ctx.getServiceManager()
self.desktop = self.smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
self.doc = self.desktop.getCurrentComponent()
self.cfg = load_cfg()
self.dialog = None
self.controls = {}
def _add_control(self, model, name, ctype, x, y, w, h, props):
c = model.createInstance(ctype)
c.Name = name
c.PositionX = x
c.PositionY = y
c.Width = w
c.Height = h
for k, v in props.items():
setattr(c, k, v)
model.insertByName(name, c)
def create(self):
dm = self.smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialogModel", self.ctx)
dm.PositionX = 120
dm.PositionY = 80
dm.Width = 300
dm.Height = 230
dm.Title = "Ollama Rewrite (Sidebar-MVP)"
self._add_control(dm, "lbl1", "com.sun.star.awt.UnoControlFixedTextModel", 6, 6, 40, 10, {"Label": "URL"})
self._add_control(dm, "url", "com.sun.star.awt.UnoControlEditModel", 48, 4, 245, 12, {"Text": self.cfg.get("ollama_url", "")})
self._add_control(dm, "lbl2", "com.sun.star.awt.UnoControlFixedTextModel", 6, 20, 40, 10, {"Label": "Model"})
self._add_control(dm, "model", "com.sun.star.awt.UnoControlEditModel", 48, 18, 245, 12, {"Text": self.cfg.get("model", "")})
self._add_control(dm, "lbl3", "com.sun.star.awt.UnoControlFixedTextModel", 6, 34, 40, 10, {"Label": "Prompt"})
self._add_control(dm, "prompt", "com.sun.star.awt.UnoControlEditModel", 48, 32, 245, 28, {
"MultiLine": True,
"VScroll": True,
"Text": self.cfg.get("prompt", "")
})
src = get_selected_text(self.doc) or get_doc_text(self.doc)
self._add_control(dm, "lbl4", "com.sun.star.awt.UnoControlFixedTextModel", 6, 62, 40, 10, {"Label": "Quelle"})
self._add_control(dm, "source", "com.sun.star.awt.UnoControlEditModel", 48, 60, 245, 58, {
"MultiLine": True,
"VScroll": True,
"Text": src
})
self._add_control(dm, "lbl5", "com.sun.star.awt.UnoControlFixedTextModel", 6, 122, 40, 10, {"Label": "Ziel"})
self._add_control(dm, "target", "com.sun.star.awt.UnoControlEditModel", 48, 120, 245, 58, {
"MultiLine": True,
"VScroll": True,
"Text": ""
})
self._add_control(dm, "rewrite", "com.sun.star.awt.UnoControlButtonModel", 48, 184, 58, 14, {"Label": "Bearbeiten", "PushButtonType": 0})
self._add_control(dm, "copy", "com.sun.star.awt.UnoControlButtonModel", 110, 184, 58, 14, {"Label": "Clipboard", "PushButtonType": 0})
self._add_control(dm, "insert", "com.sun.star.awt.UnoControlButtonModel", 172, 184, 58, 14, {"Label": "Einfügen", "PushButtonType": 0})
self._add_control(dm, "close", "com.sun.star.awt.UnoControlButtonModel", 234, 184, 58, 14, {"Label": "Schließen", "PushButtonType": 0})
self._add_control(dm, "status", "com.sun.star.awt.UnoControlFixedTextModel", 48, 202, 245, 12, {"Label": "Bereit"})
self.dialog = self.smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialog", self.ctx)
self.dialog.setModel(dm)
toolkit = self.smgr.createInstanceWithContext("com.sun.star.awt.ExtToolkit", self.ctx)
self.dialog.createPeer(toolkit, None)
listener = _BtnListener(self)
for btn, cmd in [("rewrite", "rewrite"), ("copy", "copy"), ("insert", "insert"), ("close", "close")]:
c = self.dialog.getControl(btn)
c.setActionCommand(cmd)
c.addActionListener(listener)
self.listener = listener
def set_status(self, txt):
self.dialog.getControl("status").getModel().Label = txt
def on_action(self, cmd):
if cmd == "close":
self.dialog.endExecute()
return
if cmd == "rewrite":
try:
url = self.dialog.getControl("url").getModel().Text.strip()
model = self.dialog.getControl("model").getModel().Text.strip()
prompt = self.dialog.getControl("prompt").getModel().Text.strip()
source = self.dialog.getControl("source").getModel().Text
self.cfg.update({"ollama_url": url, "model": model, "prompt": prompt})
save_cfg(self.cfg)
self.set_status("Bearbeite…")
out = ollama_generate(url, model, prompt, source)
self.dialog.getControl("target").getModel().Text = out
self.set_status("Fertig")
except Exception as e:
self.set_status(f"Fehler: {e}")
return
if cmd == "copy":
txt = self.dialog.getControl("target").getModel().Text
try:
service = self.smgr.createInstanceWithContext("com.sun.star.datatransfer.clipboard.SystemClipboard", self.ctx)
transferable = self.smgr.createInstanceWithContext("com.sun.star.datatransfer.DataFlavor", self.ctx)
_ = service, transferable
except Exception:
pass
# Simpler fallback: keep in target and close
self.dialog.endExecute()
return
if cmd == "insert":
txt = self.dialog.getControl("target").getModel().Text
replace_selection_or_append(self.doc, txt)
self.dialog.endExecute()
return
def run(self):
self.create()
self.dialog.execute()
class Toggle(unohelper.Base, XJobExecutor):
def __init__(self, ctx):
self.ctx = ctx
def trigger(self, args):
dlg = OllamaDialog(self.ctx)
dlg.run()
g_ImplementationHelper = unohelper.ImplementationHelper()
g_ImplementationHelper.addImplementation(
Toggle,
"de.aquantico.ollama.sidebar.Toggle",
("com.sun.star.task.Job", "com.sun.star.task.XJobExecutor",)
)

235
src/ollama_sidebar.py Normal file
View File

@@ -0,0 +1,235 @@
import json
import os
import urllib.request
import uno
import unohelper
from com.sun.star.task import XJobExecutor
from com.sun.star.awt import XActionListener
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".ollama_writer_sidebar")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
DEFAULT_CFG = {
"ollama_url": "http://127.0.0.1:11434",
"model": "llama3.1:8b",
"prompt": "Überarbeite den Text klarer, Sinn beibehalten."
}
def load_cfg():
try:
os.makedirs(CONFIG_DIR, exist_ok=True)
if not os.path.exists(CONFIG_FILE):
save_cfg(DEFAULT_CFG)
return dict(DEFAULT_CFG)
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CFG.items():
data.setdefault(k, v)
return data
except Exception:
return dict(DEFAULT_CFG)
def save_cfg(cfg):
os.makedirs(CONFIG_DIR, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def ollama_generate(url, model, prompt, source):
full_prompt = f"{prompt}\n\nTEXT:\n{source}"
payload = json.dumps({"model": model, "prompt": full_prompt, "stream": False}).encode("utf-8")
req = urllib.request.Request(url.rstrip("/") + "/api/generate", data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data.get("response", "")
def get_doc_text(doc):
return doc.Text.String if hasattr(doc, "Text") else ""
def get_selected_text(doc):
try:
sel = doc.getCurrentSelection()
if sel and sel.getCount() > 0:
part = sel.getByIndex(0)
return getattr(part, "String", "")
except Exception:
pass
return ""
def replace_selection_or_append(doc, txt):
try:
sel = doc.getCurrentSelection()
if sel and sel.getCount() > 0:
part = sel.getByIndex(0)
if hasattr(part, "String"):
part.String = txt
return
except Exception:
pass
try:
doc.Text.insertString(doc.Text.End, "\n" + txt, False)
except Exception:
pass
class _BtnListener(unohelper.Base, XActionListener):
def __init__(self, owner):
self.owner = owner
def actionPerformed(self, event):
cmd = event.ActionCommand
self.owner.on_action(cmd)
def disposing(self, event):
pass
class OllamaDialog:
def __init__(self, ctx):
self.ctx = ctx
self.smgr = ctx.getServiceManager()
self.desktop = self.smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
self.doc = self.desktop.getCurrentComponent()
self.cfg = load_cfg()
self.dialog = None
self.controls = {}
def _add_control(self, model, name, ctype, x, y, w, h, props):
c = model.createInstance(ctype)
c.Name = name
c.PositionX = x
c.PositionY = y
c.Width = w
c.Height = h
for k, v in props.items():
setattr(c, k, v)
model.insertByName(name, c)
def create(self):
dm = self.smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialogModel", self.ctx)
dm.PositionX = 120
dm.PositionY = 80
dm.Width = 300
dm.Height = 230
dm.Title = "Ollama Rewrite (Sidebar-MVP)"
self._add_control(dm, "lbl1", "com.sun.star.awt.UnoControlFixedTextModel", 6, 6, 40, 10, {"Label": "URL"})
self._add_control(dm, "url", "com.sun.star.awt.UnoControlEditModel", 48, 4, 245, 12, {"Text": self.cfg.get("ollama_url", "")})
self._add_control(dm, "lbl2", "com.sun.star.awt.UnoControlFixedTextModel", 6, 20, 40, 10, {"Label": "Model"})
self._add_control(dm, "model", "com.sun.star.awt.UnoControlEditModel", 48, 18, 245, 12, {"Text": self.cfg.get("model", "")})
self._add_control(dm, "lbl3", "com.sun.star.awt.UnoControlFixedTextModel", 6, 34, 40, 10, {"Label": "Prompt"})
self._add_control(dm, "prompt", "com.sun.star.awt.UnoControlEditModel", 48, 32, 245, 28, {
"MultiLine": True,
"VScroll": True,
"Text": self.cfg.get("prompt", "")
})
src = get_selected_text(self.doc) or get_doc_text(self.doc)
self._add_control(dm, "lbl4", "com.sun.star.awt.UnoControlFixedTextModel", 6, 62, 40, 10, {"Label": "Quelle"})
self._add_control(dm, "source", "com.sun.star.awt.UnoControlEditModel", 48, 60, 245, 58, {
"MultiLine": True,
"VScroll": True,
"Text": src
})
self._add_control(dm, "lbl5", "com.sun.star.awt.UnoControlFixedTextModel", 6, 122, 40, 10, {"Label": "Ziel"})
self._add_control(dm, "target", "com.sun.star.awt.UnoControlEditModel", 48, 120, 245, 58, {
"MultiLine": True,
"VScroll": True,
"Text": ""
})
self._add_control(dm, "rewrite", "com.sun.star.awt.UnoControlButtonModel", 48, 184, 58, 14, {"Label": "Bearbeiten", "PushButtonType": 0})
self._add_control(dm, "copy", "com.sun.star.awt.UnoControlButtonModel", 110, 184, 58, 14, {"Label": "Clipboard", "PushButtonType": 0})
self._add_control(dm, "insert", "com.sun.star.awt.UnoControlButtonModel", 172, 184, 58, 14, {"Label": "Einfügen", "PushButtonType": 0})
self._add_control(dm, "close", "com.sun.star.awt.UnoControlButtonModel", 234, 184, 58, 14, {"Label": "Schließen", "PushButtonType": 0})
self._add_control(dm, "status", "com.sun.star.awt.UnoControlFixedTextModel", 48, 202, 245, 12, {"Label": "Bereit"})
self.dialog = self.smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialog", self.ctx)
self.dialog.setModel(dm)
toolkit = self.smgr.createInstanceWithContext("com.sun.star.awt.ExtToolkit", self.ctx)
self.dialog.createPeer(toolkit, None)
listener = _BtnListener(self)
for btn, cmd in [("rewrite", "rewrite"), ("copy", "copy"), ("insert", "insert"), ("close", "close")]:
c = self.dialog.getControl(btn)
c.setActionCommand(cmd)
c.addActionListener(listener)
self.listener = listener
def set_status(self, txt):
self.dialog.getControl("status").getModel().Label = txt
def on_action(self, cmd):
if cmd == "close":
self.dialog.endExecute()
return
if cmd == "rewrite":
try:
url = self.dialog.getControl("url").getModel().Text.strip()
model = self.dialog.getControl("model").getModel().Text.strip()
prompt = self.dialog.getControl("prompt").getModel().Text.strip()
source = self.dialog.getControl("source").getModel().Text
self.cfg.update({"ollama_url": url, "model": model, "prompt": prompt})
save_cfg(self.cfg)
self.set_status("Bearbeite…")
out = ollama_generate(url, model, prompt, source)
self.dialog.getControl("target").getModel().Text = out
self.set_status("Fertig")
except Exception as e:
self.set_status(f"Fehler: {e}")
return
if cmd == "copy":
txt = self.dialog.getControl("target").getModel().Text
try:
service = self.smgr.createInstanceWithContext("com.sun.star.datatransfer.clipboard.SystemClipboard", self.ctx)
transferable = self.smgr.createInstanceWithContext("com.sun.star.datatransfer.DataFlavor", self.ctx)
_ = service, transferable
except Exception:
pass
# Simpler fallback: keep in target and close
self.dialog.endExecute()
return
if cmd == "insert":
txt = self.dialog.getControl("target").getModel().Text
replace_selection_or_append(self.doc, txt)
self.dialog.endExecute()
return
def run(self):
self.create()
self.dialog.execute()
class Toggle(unohelper.Base, XJobExecutor):
def __init__(self, ctx):
self.ctx = ctx
def trigger(self, args):
dlg = OllamaDialog(self.ctx)
dlg.run()
g_ImplementationHelper = unohelper.ImplementationHelper()
g_ImplementationHelper.addImplementation(
Toggle,
"de.aquantico.ollama.sidebar.Toggle",
("com.sun.star.task.Job", "com.sun.star.task.XJobExecutor",)
)