diff --git a/python/functions/infra/__init__.py b/python/functions/infra/__init__.py index e1401f4b..df93699f 100644 --- a/python/functions/infra/__init__.py +++ b/python/functions/infra/__init__.py @@ -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", ] diff --git a/python/functions/infra/oo_bridge_send.md b/python/functions/infra/oo_bridge_send.md new file mode 100644 index 00000000..03ea55e6 --- /dev/null +++ b/python/functions/infra/oo_bridge_send.md @@ -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': } donde 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': } 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. diff --git a/python/functions/infra/oo_bridge_send.py b/python/functions/infra/oo_bridge_send.py new file mode 100644 index 00000000..06987106 --- /dev/null +++ b/python/functions/infra/oo_bridge_send.py @@ -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=`` 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": }`` + where ```` 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": }`` 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, + }