feat: oo_bridge_send — cliente registry del puente OnlyOffice (grupo onlyoffice)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-07-03 18:35:03 +02:00
parent 8a78a70ef6
commit c411df1cfc
3 changed files with 271 additions and 0 deletions
+2
View File
@@ -18,6 +18,7 @@ from .caldav_put_event import caldav_put_event
from .dav_list_resources import dav_list_resources
from .dav_get_resource import dav_get_resource
from .dav_delete_resource import dav_delete_resource
from .oo_bridge_send import oo_bridge_send
from .pg_insert_rows import pg_insert_rows
from .pg_apply_sql import pg_apply_sql
from .pg_query import pg_query
@@ -85,4 +86,5 @@ __all__ = [
"dav_list_resources",
"dav_get_resource",
"dav_delete_resource",
"oo_bridge_send",
]
+126
View File
@@ -0,0 +1,126 @@
---
name: oo_bridge_send
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def oo_bridge_send(cmd: str, ref: str = '', text: str = '', opts: dict = None, target: str = 'cell', port: int = 8791, wait_s: float = 6.0) -> dict"
description: "Cliente programatico del puente OnlyOffice (app onlyoffice_bridge): encola un comando de la Automation API contra el documento OnlyOffice Desktop abierto y en foco, hace polling del resultado y lo devuelve como dict no-throw ({status: ok, result} o {status: error, error}). Lee y edita en vivo hojas (Cell), documentos (Word) y presentaciones sin cerrar/reabrir. Solo stdlib."
tags: [onlyoffice, bridge, live-edit, http, automation, spreadsheet, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, time, urllib.request, urllib.error]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/oo_bridge_send.py"
params:
- name: cmd
desc: "Nombre del comando de la Automation API. Cell lectura: get_cell, get_range, get_formula, get_used_range, get_sheets. Cell escritura: set_cell, set_range, set_formula. Estructura: insert_rows, delete_rows, insert_cols, delete_cols, merge, add_sheet, rename_sheet, delete_sheet, activate_sheet. Formato: set_format, set_borders, set_colwidth, set_rowheight. Datos: add_named_range, sort, autofilter. Graficos/tablas: add_chart, format_as_table, table_chart. Word: insert_text, get_text."
- name: ref
desc: "Referencia A1 o rango sobre la que actua el comando: celda ('B2'), rango ('A1:C10'), filas ('4:6' para insert_rows/delete_rows), columnas ('B:C' para insert_cols/delete_cols). Vacio para comandos sin ref (get_sheets, add_sheet, get_used_range, insert_text)."
- name: text
desc: "Valor textual para comandos de escritura: valor de celda (set_cell), formula '=SUM(A1:A9)' (set_formula), nombre de hoja (add_sheet/rename_sheet/activate_sheet), texto a insertar (insert_text). Vacio para lecturas."
- name: opts
desc: "Dict con parametros extra segun el comando. values (matriz 2D para set_range), sheet (hoja destino por nombre en cualquier comando cell, sin cambiar la activa), bold/italic/underline/fill=[r,g,b]/fontColor=[r,g,b]/numFormat/fontName/fontSize/wrap/alignH/alignV (set_format), style (set_borders), width (set_colwidth), height (set_rowheight), by/desc (sort), type/dataRange/pos (add_chart), dataRange/type (table_chart). None se trata como {}."
- name: target
desc: "Editor destino: 'cell' (Spreadsheet, por defecto), 'word' (Document), 'slide' (Presentation). El server enruta el comando al editor con foco que coincida."
- name: port
desc: "Puerto del server puente loopback (server.py). Default 8791."
- name: wait_s
desc: "Segundos de polling a /pull antes de devolver un error de timeout. Default 6.0. Subir si el editor tarda en responder comandos pesados (add_chart, set_range grande)."
output: "Dict no-throw. Exito: {'status': 'ok', 'result': <valor>} donde <valor> es lo que devuelve el plugin (string de celda, matriz 2D para get_range, lista de nombres de hoja para get_sheets, o 'ok' para escrituras). Error: {'status': 'error', 'error': <msg>} si el plugin devolvio {error:...}, si vencio el timeout (editor sin foco o plugin no arrancado), o si hubo fallo de red (server no corriendo). NUNCA lanza excepcion."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra import oo_bridge_send
# Leer una celda de la hoja con foco
r = oo_bridge_send("get_cell", ref="B2")
print(r) # {'status': 'ok', 'result': '42'}
# Escribir una celda
oo_bridge_send("set_cell", ref="B2", text="123")
# Escritura batch 2D en un rango
oo_bridge_send("set_range", ref="D1:E2",
opts={"values": [["a", 1], ["b", 2]]})
# Formula
oo_bridge_send("set_formula", ref="F1", text="=SUM(A1:A9)")
# Leer un rango entero (matriz 2D)
r = oo_bridge_send("get_range", ref="A1:C10")
for row in r["result"]:
print(row)
# Listar hojas y crear una nueva
print(oo_bridge_send("get_sheets")["result"]) # ['Hoja1', 'Datos']
oo_bridge_send("add_sheet", text="Resumen")
# Formato: negrita + relleno + formato numerico, sobre otra hoja por nombre
oo_bridge_send("set_format", ref="A1:C1",
opts={"bold": True, "fill": [230, 230, 250],
"numFormat": "0.00%", "sheet": "Datos"})
# Insertar 3 filas en la fila 4 / borrar columnas B y C
oo_bridge_send("insert_rows", ref="4:6")
oo_bridge_send("delete_cols", ref="B:C")
# Ordenar un rango por una columna, descendente
oo_bridge_send("sort", ref="A1:D20", opts={"by": "B1", "desc": True})
# Grafico de barras a partir de un rango
oo_bridge_send("add_chart",
opts={"type": "bar", "dataRange": "A1:B10", "pos": [5, 1]})
# Word: insertar y leer texto
oo_bridge_send("insert_text", text="Nuevo parrafo", target="word")
print(oo_bridge_send("get_text", target="word")["result"])
```
## Cuando usarla
Cuando necesites leer o editar EN VIVO un documento OnlyOffice Desktop ya abierto
(hoja de calculo, documento o presentacion) sin cerrarlo ni reabrirlo — por ejemplo
para rellenar celdas, aplicar formato, crear graficos o volcar una matriz calculada
directamente sobre la ventana que el usuario tiene delante. Es el bloque de
construccion de bajo nivel: un comando por llamada. Componla en pipelines o heredocs
para secuencias (crear hoja -> volcar datos -> formatear cabecera -> anadir grafico).
Prefierela sobre editar el `.xlsx`/`.docx` en disco (`write_xlsx_sheets_py_infra`,
etc.) cuando el archivo esta abierto en OnlyOffice, porque el editor Desktop NO
recarga cambios externos del disco (GitHub issue #2313).
## Gotchas
- **El editor debe estar ABIERTO y con la ventana en FOCO** para que el plugin de
sistema arranque y procese comandos. Si no hay foco en el editor destino, el
polling vence y devuelve `{"status": "error", "error": "timeout: ..."}`. Abrir con
`open_onlyoffice_file_bash_shell` y traer la ventana al frente antes de enviar.
- **El server puente (`apps/onlyoffice_bridge/server.py`) debe estar corriendo** en
el puerto indicado (`python3 apps/onlyoffice_bridge/server.py 8791 &`, idealmente
como service systemd --user). Si no corre, la funcion devuelve
`{"status": "error", "error": "push failed: ... (is server.py running ...)"}`.
- **Persistencia a disco = Ctrl+S del usuario.** Estas ediciones ocurren sobre el
documento vivo en memoria; el guardado automatizado es intermitente. Para dejar el
cambio en el archivo, el usuario guarda con Ctrl+S (o usar
`save_onlyoffice_file_bash_shell`, que no siempre confirma).
- **Tablas nativas (ListObjects): solo se pueden CREAR** con `format_as_table`. Este
build de la Automation API NO soporta listar ni re-estilizar tablas nativas
existentes — `get_tables`/`set_table_style` no devuelven datos utiles. Para
visualizar datos tabulados usa rangos + `set_format` o crea la tabla de cero.
- La funcion NUNCA lanza: SIEMPRE inspecciona `result["status"]` antes de usar
`result["result"]`. Un `{"status": "ok", "result": None}` significa que el plugin
respondio vacio (comando aplicado sin valor de retorno).
- `opts.sheet` apunta a una hoja por nombre en cualquier comando cell sin cambiar la
hoja activa; util para editar en segundo plano.
- Comandos pesados (`set_range` de miles de celdas, `add_chart`) pueden tardar mas de
6s; subir `wait_s` para esos casos.
+143
View File
@@ -0,0 +1,143 @@
"""Programmatic client for the OnlyOffice live bridge (apps/onlyoffice_bridge).
Speaks to the loopback server (server.py, 127.0.0.1:8791) that queues commands
for a system plugin running inside OnlyOffice Desktop Editors. The plugin runs
each command with the Automation API (Api.*) over the focused document and
answers. This function pushes one command, polls for its result and returns a
non-throwing dict — it never raises, so callers (pipelines, heredocs, other
functions) can compose it without try/except.
Only stdlib is used (urllib.request, json, time) so the function is importable
from any registry consumer without extra dependencies.
"""
import json
import time
import urllib.error
import urllib.request
# Monotonic per-process counter combined with a millisecond timestamp keeps the
# id unique across quick successive calls without pulling in uuid/random.
_counter = [0]
def _next_id() -> str:
_counter[0] += 1
return "oo%d_%d" % (int(time.time() * 1000), _counter[0])
def oo_bridge_send(
cmd: str,
ref: str = "",
text: str = "",
opts: dict = None,
target: str = "cell",
port: int = 8791,
wait_s: float = 6.0,
) -> dict:
"""Send one command to the OnlyOffice live bridge and return its result.
Pushes ``{cmd, ref, text, opts, id, target}`` to ``POST /push`` on the
loopback bridge server, then long-polls ``GET /pull?id=<id>`` until the
plugin answers or ``wait_s`` elapses. The plugin's answer is a JSON string
such as ``{"result": ...}`` or ``{"error": "..."}``.
Args:
cmd: Automation-API command name. Cell reads: get_cell, get_range,
get_formula, get_used_range, get_sheets. Cell writes: set_cell,
set_range, set_formula. Structure: insert_rows, delete_rows,
insert_cols, delete_cols, merge, add_sheet, rename_sheet,
delete_sheet, activate_sheet. Format: set_format, set_borders,
set_colwidth, set_rowheight. Data: add_named_range, sort,
autofilter. Charts/tables: add_chart, format_as_table,
table_chart. Word: insert_text, get_text.
ref: A1 reference or range the command acts on ("B2", "A1:C10",
"4:6" for rows, "B:C" for cols). Empty for commands with no ref.
text: Text value for write commands (cell value, formula "=SUM(...)",
sheet name, inserted paragraph). Empty for reads.
opts: Extra parameters as a dict, per command. Common keys: values
(2D matrix for set_range), sheet (target sheet by name for any
cell command), bold/italic/underline/fill=[r,g,b]/
fontColor=[r,g,b]/numFormat/fontName/fontSize/wrap/alignH/alignV
(set_format), style (set_borders), width (set_colwidth), height
(set_rowheight), by/desc (sort), type/dataRange/pos (add_chart),
dataRange/type (table_chart). None is treated as {}.
target: Destination editor: "cell" (Spreadsheet), "word" (Document),
"slide" (Presentation). Default "cell".
port: Loopback bridge server port. Default 8791.
wait_s: Seconds to poll /pull before giving up with a timeout error.
Default 6.0.
Returns:
A non-throwing dict. On success: ``{"status": "ok", "result": <value>}``
where ``<value>`` is whatever the plugin returned (a cell string, a 2D
matrix for get_range, a list of sheet names, or "ok" for writes). On
failure: ``{"status": "error", "error": <message>}`` when the plugin
replied with an error, when the poll timed out (editor not focused or
plugin not started), or on any network failure (server not running).
Never raises.
"""
base = "http://127.0.0.1:%d" % port
rid = _next_id()
payload = {
"cmd": cmd,
"ref": ref,
"text": text,
"opts": opts if opts is not None else {},
"id": rid,
"target": target,
}
net_timeout = max(wait_s, 2.0)
# 1) Enqueue the command on the bridge server.
try:
push_body = json.dumps(payload).encode("utf-8")
push_req = urllib.request.Request(
base + "/push",
data=push_body,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(push_req, timeout=net_timeout) as resp:
resp.read()
except Exception as exc: # noqa: BLE001 — no-throw contract
return {
"status": "error",
"error": "push failed: %s (is server.py running on port %d?)"
% (exc, port),
}
# 2) Poll for the plugin's answer until it is ready or wait_s elapses.
deadline = time.time() + wait_s
while time.time() < deadline:
try:
pull_req = urllib.request.Request(
base + "/pull?id=" + rid, method="GET"
)
with urllib.request.urlopen(pull_req, timeout=net_timeout) as resp:
res = json.loads(resp.read().decode("utf-8"))
except Exception as exc: # noqa: BLE001 — no-throw contract
return {"status": "error", "error": "pull failed: %s" % exc}
if res.get("ready"):
raw = res.get("text", "")
if not raw:
return {"status": "ok", "result": None}
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
# Plugin returned non-JSON text; hand it back raw.
return {"status": "ok", "result": raw}
if isinstance(parsed, dict) and "error" in parsed:
return {"status": "error", "error": parsed["error"]}
if isinstance(parsed, dict) and "result" in parsed:
return {"status": "ok", "result": parsed["result"]}
return {"status": "ok", "result": parsed}
time.sleep(0.4)
return {
"status": "error",
"error": "timeout: no reply in %.1fs "
"(editor not focused or plugin not started)" % wait_s,
}