diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/.gitignore b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/README.md b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/README.md new file mode 100644 index 0000000..4bc0998 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/README.md @@ -0,0 +1,182 @@ +# SuperDocs Writer Sidebar + +A native LibreOffice Writer extension: a sidebar panel for sending the current +selection (or the whole document) to SuperDocs with a natural-language editing +instruction, reviewing the proposed changes, and applying approved ones back +into the document **range by range** — untouched text is never rewritten, and +existing paragraph/character styles are respected. + +> **Status: v0.3.1 — selection, whole-document, and drafting modes, validated +> on Linux and Windows.** Selection mode: select a passage, describe the edit, +> review, Apply → only the selected range changes, styles intact. +> Whole-document mode (tick the checkbox): the doc is exported as .docx through +> the host's own filter and uploaded; every approved change is located by its +> original text in the live document and replaced range by range — a change +> that can't be found, or that matches more than one place, is **skipped and +> reported, never guessed at**. Drafting: when the instruction makes SuperDocs +> *create* content instead of editing ("create a gym plan"), the API applies it +> server-side with **zero pending changes** — creation bypasses the +> `ask_every_time` approval gate entirely (reported as a bug during this +> project). The sidebar closes that gap locally: the draft becomes a reviewable +> proposal, Apply inserts it at the cursor, Reject discards it — nothing lands +> in your document without a decision. Current limits, stated honestly: +> write-back and draft insertion are plain text into styled ranges (formatting +> *inside* a proposal isn't carried yet); whole-doc write-back covers body +> paragraphs, not table-cell content (those changes are reported as skipped, +> and the approved SuperDocs copy still has them). + +**v0.3.3 — the panel is responsive.** It used to be a fixed-coordinate +layout that claimed a fixed height, so in a small office window the bottom of +the stack — the status line, the one control that says what just happened — +was placed outside the panel and clipped. The panel now reports its real +minimum/preferred/maximum height to the sidebar deck and re-lays itself out on +every resize: the flexible boxes (instruction, preview, status) share the +slack in a tall panel and give it back in a short one, the hint and title drop +out before anything functional does, and **Apply/Reject and the status line +stay on screen at every size**. An empty preview now takes no room at all, and +both the status and preview carry their full text as a tooltip. Sizes scale +with the host's DPI/font scaling rather than a hardcoded factor. + +## Screenshots + +**Selection mode (Linux, WSLg):** + +| Sending a selection | Applied in place | +|---|---| +| ![Sending](screenshots/linux-sending.png) | ![Applied](screenshots/linux-applied.png) | + +A `Heading 1` paragraph edited via the sidebar: the text changed, the heading +style survived untouched. + +**Whole-document + drafting modes (Windows):** + +| Draft requested | Draft gated, then inserted at the cursor | +|---|---| +| ![Requesting](screenshots/windows-draft-requesting.png) | ![Inserted](screenshots/windows-draft-inserted.png) | + +"create a fitness plan" on a near-empty document: SuperDocs *creates* content +(zero pending changes server-side — the approval-gate bypass described above), +the sidebar gates it locally, and Apply inserts 27 paragraphs at the cursor — +the pre-existing sentence at the top is untouched, and the status line says +exactly what happened. + +![Proposals](screenshots/windows-wholedoc-proposals.png) + +A follow-up whole-document *edit* on that same draft ("Add some styles to it +like Markdown."): 22 proposed changes come back as a reviewable proposal with +the actual proposed text quoted — nothing lands until Apply. + +**Small office window (Windows):** + +![Small window](screenshots/windows-small-window.png) + +The same panel in a 900×520 office window. The hint paragraph has dropped out +and the instruction box has shrunk to fit, but the checkbox, Send, Apply/Reject +and the status line ("Ready.") are all still on screen — before v0.3.3 the +status line was positioned below the panel's visible area and simply vanished. +Squeeze further and the title goes too; the working controls and the status +line are the last things standing. Below even that floor layout the sidebar +deck gives you its own scrollbar and the status is still reachable — the old +fixed height claimed the panel was 360px tall while its content was ~500px, so +the deck saw no reason to scroll and the overflow was simply lost. + +## Build + +```bash +python build.py # -> dist/superdocs-writer-sidebar.oxt +``` + +Stdlib-only build; the source layout under `src/` is the .oxt layout. + +## Install (Linux) + +```bash +sudo apt install libreoffice-writer python3-uno # python3-uno = Python component loader +unopkg add -f dist/superdocs-writer-sidebar.oxt +``` + +Then open Writer → **View → Sidebar** → the **SuperDocs** deck appears in the +tab rail. (Windows note for development: use `unopkg.com`, not `unopkg.exe` — +the latter detaches from the console and can fail silently.) + +## How it talks to SuperDocs + +- The selection goes up as passage text (`/v1/documents/upload-base64` into a + fresh session) — SuperDocs takes documents, never raw XML. In whole-document + mode the doc is exported via the host's own filter (`storeToURL`, MS Word + 2007 XML) and uploaded as a file, per the host-app integration contract. +- `POST /v1/chat/async` with `approval_mode: "ask_every_time"`; the panel polls + the job and previews the **actual proposed content** (`new_html`, tag-stripped) + rather than the AI's self-description, which can paraphrase. +- Approvals always send the explicit `changes` array — a bare + `{job_id, approved:true}` is a silent no-op in the API (discovered and + reported during this project). +- Selection write-back: the approved result (plain-text export of the session) + goes through the stored UNO text range with `setString`, inheriting the + range's current styles. Whole-document write-back (`writeback.py`): each + approved change's `old_html` is reduced to plain-text paragraphs, located as + one consecutive paragraph run in the live document, and replaced with a + style-preserving `setString` — no hard formatting, no whole-file replacement, + untouched text is never rewritten. Not-found and ambiguous matches are + skipped and named in the status line. +- All network work runs on a background thread; UI and document updates are + marshaled to the office main thread via `AsyncCallback`. +- API key: `SUPERDOCS_API_KEY` environment variable, else + `~/.superdocs/agent_credentials.json`. Never hardcoded, never in this repo. + +## Tests + +All harnesses (Linux) start a throwaway headless LibreOffice on an isolated +profile; they never touch your normal LibreOffice or its profile. + +- `tests/run_style_test.sh` — builds a document with a Heading 1, a bullet + list, and a table, edits one body paragraph through the exact client + + write-back path the panel uses, and asserts the heading/list/table styles and + content all survived. Costs 1 operation. Ends with `STYLE TEST: ALL PASS`. +- `tests/run_whole_doc_test.sh` — **offline, no API key, 0 operations.** + Drives `writeback.apply_changes` with synthetic changes against a real Writer + doc and proves the write-back invariants: unique match applied in place + (entities decoded, inline tags stripped), a multi-paragraph run replaced as + one range, ambiguous and not-found changes skipped with both copies + untouched, table content untouched. Ends with + `WHOLE-DOC WRITE-BACK TEST: ALL PASS`. +- `tests/run_whole_doc_live_test.sh` — the same flow end to end against the + real API: UNO doc → .docx export → `request_doc_edit` → approve → write-back + from the actual returned `old_html`/`new_html`. Costs 1 operation. Ends with + `WHOLE-DOC LIVE TEST: ALL PASS`. +- `tests/layout_test.py` — **offline, plain `python3`, no LibreOffice, no API + key, 0 operations.** Drives `panel_layout.solve` at every panel height from + below the floor to well above the preferred layout, at three DPI scales, and + asserts the invariants the panel promises: the status line is always fully + inside the panel and always below the decision buttons, the working controls + never disappear, rows never overlap or overflow the width, spare height is + absorbed instead of left dangling. ~38k assertions over ~2.2k panel sizes; + ends with `PANEL LAYOUT TEST: ALL PASS`. +- `tests/draft_live_test.py` — plain HTTP, no LibreOffice needed, ≤1 operation. + Sends a drafting instruction and verifies the client surfaces the created + content as `draft_html` on a completed zero-pending job (the approval-gate + bypass). If the model happens to propose in-place edits instead, they are + rejected (free) and the run reports SKIPPED honestly — model choice between + editing and creating is nondeterministic. + +(Portability note discovered by this test: LibreOffice 26 dropped the classic +"List Bullet" paragraph-style family — bullets are now "List 1".."List 5" — so +the test discovers style names at runtime instead of assuming them.) + +## Files + +| Path | Role | +|------|------| +| `src/description.xml` | Extension identity/version | +| `src/META-INF/manifest.xml` | Declares the two .xcu configs + Python component | +| `src/config/Sidebar.xcu` | Registers the SuperDocs deck + panel in Writer's sidebar | +| `src/config/Factories.xcu` | Maps the panel's resource URL to the Python factory | +| `src/panel.py` | UNO component: panel factory, panel, threading, both modes | +| `src/panel.xdl` | Panel UI (whole-doc checkbox, instruction box, Send, preview, Apply/Reject, status) — authoring geometry; runtime positions come from `panel_layout` | +| `src/pythonpath/panel_layout.py` | Responsive geometry: where every control goes for the panel's current size (pure arithmetic, no UNO) | +| `src/pythonpath/superdocs_client.py` | Stdlib SuperDocs client (upload → async chat → approve → export) | +| `src/pythonpath/writeback.py` | Whole-doc write-back: HTML→paragraphs, unique-run matching, range-by-range apply, draft insertion | +| `src/icons/superdocs_24.png` | Sidebar deck icon | + +Built by Karthik Vanam for the SuperDocs hiring round, with AI-assisted +development (Claude Code). diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/build.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/build.py new file mode 100644 index 0000000..1ed1292 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/build.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Build the .oxt: zip everything under src/ into dist/superdocs-writer-sidebar.oxt.""" +import zipfile +from pathlib import Path + +ROOT = Path(__file__).parent +SRC = ROOT / "src" +DIST = ROOT / "dist" + + +def main(): + DIST.mkdir(exist_ok=True) + oxt = DIST / "superdocs-writer-sidebar.oxt" + with zipfile.ZipFile(oxt, "w", zipfile.ZIP_DEFLATED) as z: + for path in sorted(SRC.rglob("*")): + if path.is_file() and "__pycache__" not in path.parts: + z.write(path, path.relative_to(SRC).as_posix()) + print(f"built {oxt} ({oxt.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/dist/superdocs-writer-sidebar.oxt b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/dist/superdocs-writer-sidebar.oxt new file mode 100644 index 0000000..5a3322c Binary files /dev/null and b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/dist/superdocs-writer-sidebar.oxt differ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/linux-applied.png b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/linux-applied.png new file mode 100644 index 0000000..0b1bb35 Binary files /dev/null and b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/linux-applied.png differ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/linux-sending.png b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/linux-sending.png new file mode 100644 index 0000000..7653cb8 Binary files /dev/null and b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/linux-sending.png differ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-draft-inserted.png b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-draft-inserted.png new file mode 100644 index 0000000..ddd51f8 Binary files /dev/null and b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-draft-inserted.png differ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-draft-requesting.png b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-draft-requesting.png new file mode 100644 index 0000000..41b6783 Binary files /dev/null and b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-draft-requesting.png differ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-small-window.png b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-small-window.png new file mode 100644 index 0000000..710ceca Binary files /dev/null and b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-small-window.png differ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-wholedoc-proposals.png b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-wholedoc-proposals.png new file mode 100644 index 0000000..b3e7d6b Binary files /dev/null and b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/screenshots/windows-wholedoc-proposals.png differ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/META-INF/manifest.xml b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/META-INF/manifest.xml new file mode 100644 index 0000000..be4b175 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/META-INF/manifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/config/Factories.xcu b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/config/Factories.xcu new file mode 100644 index 0000000..05cd055 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/config/Factories.xcu @@ -0,0 +1,23 @@ + + + + + + + toolpanel + + + SuperDocsPanelFactory + + + + + + app.superdocs.writer.PanelFactory + + + + + diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/config/Sidebar.xcu b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/config/Sidebar.xcu new file mode 100644 index 0000000..ceac739 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/config/Sidebar.xcu @@ -0,0 +1,52 @@ + + + + + + + SuperDocs + + + SuperDocsDeck + + + vnd.sun.star.extension://app.superdocs.writer.sidebar.vanamkarthiknetha/icons/superdocs_24.png + + + + WriterVariants, any, visible ; + + + + 500 + + + + + + + AI editing + + + SuperDocsPanel + + + SuperDocsDeck + + + + WriterVariants, any, visible ; + + + + private:resource/toolpanel/SuperDocsPanelFactory/SuperDocsPanel + + + 100 + + + + + diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/description.xml b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/description.xml new file mode 100644 index 0000000..812de85 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/description.xml @@ -0,0 +1,10 @@ + + + + + + + SuperDocs Writer Sidebar + + diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/icons/superdocs_24.png b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/icons/superdocs_24.png new file mode 100644 index 0000000..c0b559f Binary files /dev/null and b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/icons/superdocs_24.png differ diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/panel.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/panel.py new file mode 100644 index 0000000..b443600 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/panel.py @@ -0,0 +1,481 @@ +# SuperDocs Writer Sidebar - UNO component. +# v0.3.x: selection round-trip + whole-document mode + drafting. Selection: +# approved text is written back into the exact selected range. Whole document: +# the doc is exported as .docx and uploaded; each approved change is located by +# its original text and replaced range by range (writeback.py) - unmatched +# changes are skipped and reported, never guessed at. Drafting: when SuperDocs +# CREATES content instead of editing (which bypasses the API's approval gate, +# BUGS B6), the draft becomes a local proposal - Apply inserts it at the +# cursor, Reject discards it; nothing lands without a decision. +import os +import re +import tempfile +import threading +import traceback + +import unohelper + +from com.sun.star.awt import XActionListener, XCallback, XWindowListener +from com.sun.star.awt.PosSize import POSSIZE +from com.sun.star.beans import PropertyValue +from com.sun.star.container import NoSuchElementException +from com.sun.star.lang import XServiceInfo +from com.sun.star.ui import XSidebarPanel, XToolPanel, XUIElement, XUIElementFactory, LayoutSize +from com.sun.star.ui.UIElementType import TOOLPANEL + +import panel_layout +import superdocs_client +import writeback + +EXT_ID = "app.superdocs.writer.sidebar.vanamkarthiknetha" +RESOURCE_PREFIX = "private:resource/toolpanel/SuperDocsPanelFactory" + + +def _strip_html(markup): + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", markup or "")).strip() + + +def _proposal_preview(edit): + """Quote the ACTUAL proposed content (new_html), not the AI's explanation - + the explanation can paraphrase and the user is approving the real change.""" + proposed = " ".join(_strip_html(c.get("new_html")) for c in edit["pending"] + if c.get("new_html")) + if proposed: + clipped = proposed[:300] + ("..." if len(proposed) > 300 else "") + return f'Proposed text: "{clipped}"' + return edit["summary"][:300] + + +def _ext_file_url(ctx, relative): + pip = ctx.getValueByName("/singletons/com.sun.star.deployment.PackageInformationProvider") + return pip.getPackageLocation(EXT_ID) + "/" + relative + + +class _Action(unohelper.Base, XActionListener): + def __init__(self, handler): + self._handler = handler + + def actionPerformed(self, _event): + self._handler() + + def disposing(self, _event): + self._handler = None + + +class _Resize(unohelper.Base, XWindowListener): + """Re-lays the panel out whenever the sidebar hands it a new size.""" + + def __init__(self, handler): + self._handler = handler + + def windowResized(self, _event): + if self._handler: + self._handler() + + def windowShown(self, _event): + if self._handler: + self._handler() + + def windowMoved(self, _event): + pass + + def windowHidden(self, _event): + pass + + def disposing(self, _event): + self._handler = None + + +class _Job(unohelper.Base, XCallback): + """One main-thread job. Holds the callable itself - a Python function must + never be passed as the UNO `aData` argument (not convertible to Any; the + call throws and the failure is silent inside an action listener).""" + + def __init__(self, fn): + self._fn = fn + + def notify(self, _data): + try: + self._fn() + except Exception: + traceback.print_exc() + + +class _MainThread: + """Runs python callables on the office main thread via AsyncCallback.""" + + def __init__(self, ctx): + self._ctx = ctx + + def post(self, fn): + try: + cb = self._ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.AsyncCallback", self._ctx) + cb.addCallback(_Job(fn), None) + except Exception: + fn() # degraded fallback: run inline rather than lose the update + + +class SuperDocsPanel(unohelper.Base, XUIElement, XToolPanel, XSidebarPanel): + def __init__(self, ctx, frame, parent_window, resource_url): + self.ctx = ctx + self.Frame = frame + self.ResourceURL = resource_url + self.Type = TOOLPANEL + self._parent = parent_window + self._window = None + self._main = _MainThread(ctx) + self._edit = None # dict from superdocs_client.request_*edit + self._mode = None # "sel", "doc", or "draft" + self._target_range = None # XTextRange (selection mode) + self._doc = None # text-document model + self._draft_paras = None # drafted paragraphs awaiting the local gate + self._busy = False + self._unit_px = None # measured pixels per appfont unit + self._preview_text = "" # mirrors lblPreview; drives its layout tier + + # ---- XUIElement ---- + def getRealInterface(self): + if self._window is None: + provider = self.ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.ContainerWindowProvider", self.ctx) + self._window = provider.createContainerWindow( + _ext_file_url(self.ctx, "panel.xdl"), "", self._parent, None) + self._window.getControl("btnSend").addActionListener(_Action(self.on_send)) + self._window.getControl("btnApprove").addActionListener(_Action(self.on_apply)) + self._window.getControl("btnReject").addActionListener(_Action(self.on_reject)) + self._window.addWindowListener(_Resize(self._relayout)) + self._window.setVisible(True) + self._relayout() + return self + + # ---- XToolPanel ---- + @property + def Window(self): + return self._window + + def createAccessible(self, _parent): + return self._window.getAccessibleContext() if self._window else None + + # ---- XSidebarPanel ---- + def getHeightForWidth(self, _width): + """Report the real range, not a fixed height: the deck then grows the + panel when there is room and only scrolls once even the floor layout + no longer fits.""" + unit = self._unit() + has_preview = bool(self._preview_text) + size = LayoutSize() + size.Minimum = panel_layout.stack_height(panel_layout.FLOOR, unit, has_preview) + size.Preferred = panel_layout.stack_height(panel_layout.PREF, unit, has_preview) + size.Maximum = size.Preferred * 10 # take whatever slack the deck has + return size + + def getMinimalWidth(self): + return int(round(80 * self._unit())) + + # ---- layout (geometry lives in panel_layout; this only applies it) ---- + def _unit(self): + """Pixels per appfont unit, measured from btnSend's authored height so + the panel tracks the host's DPI and font scaling.""" + if self._unit_px is None and self._window is not None: + try: + measured = (self._window.getControl("btnSend").getPosSize().Height + / panel_layout.SEND_UNITS) + except Exception: + measured = 0 + if 1.0 <= measured <= 8.0: + self._unit_px = measured + return self._unit_px or 2.0 + + def _relayout(self): + if self._window is None: + return + try: + rect = self._window.getPosSize() + if rect.Width <= 0 or rect.Height <= 0: + return + placements = panel_layout.solve(rect.Width, rect.Height, self._unit(), + bool(self._preview_text)) + for control_id, box in placements: + control = self._window.getControl(control_id) + if box: + control.setPosSize(box[0], box[1], box[2], box[3], POSSIZE) + control.setVisible(bool(box)) + except Exception: + traceback.print_exc() # a layout slip must never break the panel + + # ---- ui helpers (safe from any thread via _MainThread.post) ---- + def _ui(self, status=None, preview=None, decisions_enabled=None, send_enabled=None): + def apply_ui(): + if status is not None: + model = self._window.getControl("lblStatus").getModel() + model.Label = status + model.HelpText = status # readable on hover in a short panel + if preview is not None: + self._preview_text = preview + model = self._window.getControl("lblPreview").getModel() + model.Label = preview + model.HelpText = preview + if decisions_enabled is not None: + self._window.getControl("btnApprove").getModel().Enabled = decisions_enabled + self._window.getControl("btnReject").getModel().Enabled = decisions_enabled + if send_enabled is not None: + self._window.getControl("btnSend").getModel().Enabled = send_enabled + if preview is not None: + self._relayout() # the preview box appears/disappears with it + self._main.post(apply_ui) + + def _progress(self, message): + self._ui(status=message) + + def _fail(self, exc): + self._busy = False + self._ui(status=f"Error: {exc}", decisions_enabled=False, send_enabled=True) + traceback.print_exc() + + # ---- actions ---- + def on_send(self): + try: + self._on_send() + except Exception as exc: + self._fail(exc) + + def _export_docx(self, model): + """Export the live document as .docx bytes via the host's own filter.""" + fd, path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + try: + prop = PropertyValue() + prop.Name = "FilterName" + prop.Value = "MS Word 2007 XML" + model.storeToURL(unohelper.systemPathToFileUrl(path), (prop,)) + with open(path, "rb") as f: + return f.read() + finally: + try: + os.remove(path) + except OSError: + pass + + def _on_send(self): + if self._busy: + return + instruction = self._window.getControl("txtInstruction").getText().strip() + if not instruction: + self._ui(status="Type an instruction first.") + return + whole_doc = self._window.getControl("chkWholeDoc").getModel().State == 1 + if whole_doc: + model = self.Frame.getController().getModel() + if model is None or not model.supportsService("com.sun.star.text.TextDocument"): + self._ui(status="Open a Writer text document first.") + return + self._mode = "doc" + self._doc = model + self._target_range = None + base_text = model.getText().getString() + self._ui(status="Exporting document...", preview="", + decisions_enabled=False, send_enabled=False) + docx_bytes = self._export_docx(model) + request = lambda: superdocs_client.request_doc_edit( + docx_bytes, instruction, self._progress) + else: + controller = self.Frame.getController() + selection = controller.getSelection() + if (selection is None + or not selection.supportsService("com.sun.star.text.TextRanges") + or selection.getCount() == 0 + or not selection.getByIndex(0).getString().strip()): + self._ui(status="Select a passage to edit, " + "or tick 'Whole document'.") + return + self._mode = "sel" + self._doc = controller.getModel() + self._target_range = selection.getByIndex(0) + passage = self._target_range.getString() + base_text = passage + request = lambda: superdocs_client.request_edit( + passage, instruction, self._progress) + self._busy = True + self._ui(status="Contacting SuperDocs...", preview="", + decisions_enabled=False, send_enabled=False) + + def work(): + try: + edit = request() + self._edit = edit + if edit["state"] == "completed": + # Drafting instructions bypass the API's approval gate + # entirely (BUGS B6): the job completes with no pending + # changes and the created content rides result. + # document_changes. Gate that draft HERE instead. + draft_paras = writeback.html_paragraphs(edit.get("draft_html")) + if draft_paras and (" ".join(draft_paras) + != writeback._norm(base_text)): + self._mode = "draft" + self._draft_paras = draft_paras + joined = " ".join(draft_paras) + clipped = joined[:300] + ("..." if len(joined) > 300 else "") + self._busy = False + self._ui(status="SuperDocs drafted NEW content (no in-place " + "edits). Apply inserts it at the cursor; " + "Reject discards it.", + preview=f'Draft: "{clipped}"', + decisions_enabled=True, send_enabled=False) + return + # genuinely nothing to review; report honestly + self._busy = False + self._ui(status="SuperDocs made no reviewable proposal " + "(request may already be satisfied). Nothing changed.", + send_enabled=True) + return + self._busy = False + self._ui(status=f"{len(edit['pending'])} proposed change(s) - " + "review and Apply or Reject.", + preview=_proposal_preview(edit), + decisions_enabled=True, send_enabled=False) + except Exception as exc: + self._fail(exc) + + threading.Thread(target=work, daemon=True).start() + + def on_apply(self): + try: + self._on_apply() + except Exception as exc: + self._fail(exc) + + def _on_apply(self): + if self._busy or not self._edit: + return + self._busy = True + self._ui(status="Applying...", decisions_enabled=False) + if self._mode == "draft": + # already billed and committed server-side (B6); the gate is local. + paras = self._draft_paras + + def insert_draft(): + at_range = None + try: + at_range = self._doc.getCurrentController().getViewCursor() + except Exception: + pass # fall back to end of document + count = writeback.insert_paragraphs(self._doc, paras, at_range) + self._edit = None + self._doc = None + self._draft_paras = None + self._busy = False + self._ui(status=f"Draft inserted at the cursor ({count} paragraph(s)). " + "Existing text untouched.", + preview="", send_enabled=True) + self._main.post(insert_draft) + return + if self._mode == "doc": + pending = self._edit["pending"] + + def work_doc(): + try: + superdocs_client.finalize(self._edit, True, self._progress, + want_text=False) + + def write_back(): + results = writeback.apply_changes(self._doc, pending) + self._edit = None + self._doc = None + self._busy = False + self._ui(status=writeback.summarize(results) + " (1 operation)", + preview="", send_enabled=True) + self._main.post(write_back) + except Exception as exc: + self._fail(exc) + + threading.Thread(target=work_doc, daemon=True).start() + return + + def work(): + try: + final_text = superdocs_client.finalize(self._edit, True, self._progress) + + def write_back(): + # setString keeps the range's paragraph/character styles; + # only the selected range is touched. + self._target_range.setString(final_text) + self._edit = None + self._target_range = None + self._busy = False + self._main.post(write_back) + self._ui(status="Applied. Only the selected range was modified (1 operation).", + preview="", send_enabled=True) + except Exception as exc: + self._fail(exc) + + threading.Thread(target=work, daemon=True).start() + + def on_reject(self): + try: + self._on_reject() + except Exception as exc: + self._fail(exc) + + def _on_reject(self): + if self._busy or not self._edit: + return + if self._mode == "draft": + # nothing to reject server-side - the job already completed (B6); + # discarding is purely local + self._edit = None + self._doc = None + self._draft_paras = None + self._ui(status="Draft discarded; document untouched. " + "(It still exists in your SuperDocs session.)", + preview="", decisions_enabled=False, send_enabled=True) + return + self._busy = True + self._ui(status="Rejecting...", decisions_enabled=False) + + def work(): + try: + superdocs_client.finalize(self._edit, False, self._progress) + self._edit = None + self._target_range = None + self._doc = None + self._busy = False + self._ui(status="Rejected. Document untouched, nothing billed.", + preview="", send_enabled=True) + except Exception as exc: + self._fail(exc) + + threading.Thread(target=work, daemon=True).start() + + +class PanelFactory(unohelper.Base, XUIElementFactory, XServiceInfo): + IMPL_NAME = "app.superdocs.writer.PanelFactory" + SERVICE_NAMES = (IMPL_NAME,) + + def __init__(self, ctx): + self.ctx = ctx + + def createUIElement(self, resource_url, arguments): + if not resource_url.startswith(RESOURCE_PREFIX): + raise NoSuchElementException(resource_url, self) + frame = parent = None + for arg in arguments: + if arg.Name == "Frame": + frame = arg.Value + elif arg.Name == "ParentWindow": + parent = arg.Value + return SuperDocsPanel(self.ctx, frame, parent, resource_url) + + def getImplementationName(self): + return self.IMPL_NAME + + def supportsService(self, name): + return name in self.SERVICE_NAMES + + def getSupportedServiceNames(self): + return self.SERVICE_NAMES + + +g_ImplementationHelper = unohelper.ImplementationHelper() +g_ImplementationHelper.addImplementation( + PanelFactory, PanelFactory.IMPL_NAME, PanelFactory.SERVICE_NAMES) diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/panel.xdl b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/panel.xdl new file mode 100644 index 0000000..042fa6d --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/panel.xdl @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/panel_layout.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/panel_layout.py new file mode 100644 index 0000000..feee28f --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/panel_layout.py @@ -0,0 +1,118 @@ +# Geometry for the sidebar panel. +# +# panel.xdl is a bulletin board: every control sits at a fixed coordinate and +# nothing reflows on its own, so in a short office window the bottom of the +# stack - the status line - fell outside the visible panel entirely. This +# module owns the layout instead: given the panel's current pixel size it says +# where every control goes, shrinking the flexible boxes so the decision +# buttons and the status line always stay on screen. +# +# Pure arithmetic, no UNO - tests/layout_test.py drives it directly. +# +# Sizes are in appfont units (what panel.xdl is authored in); panel.py measures +# the pixels-per-unit from a control it already owns, so the layout follows the +# host's DPI and font scaling rather than assuming a factor. + +PAD = 4 # panel margin +GAP = 4 # vertical gutter between blocks +BTN_GAP = 6 # horizontal gutter between Apply and Reject +SEND_UNITS = 14 # btnSend's authored height: the pixel-per-unit yardstick +DECIDE = "__decide" # pseudo-block for the Apply/Reject row + +# Tiers per block: (id, floor, minimum, preferred, grow-weight). Preferred is +# the panel.xdl geometry; minimum is still comfortable; floor is what a block +# collapses to when the panel is genuinely tiny (0 = hidden). Grow-weight +# shares out the slack in a tall panel. +FLOOR, MIN, PREF, GROW = 1, 2, 3, 4 + +# Space is taken back in this order - first down to each block's minimum, then +# down to its floor. The instruction box yields before the preview: once a +# proposal is on screen, reading it matters more than the box that asked for +# it. The decision buttons never shrink, and the status line keeps a line of +# text at every size - that is the whole point of this module. +SHRINK_ORDER = ("lblHint", "lblPreview", "txtInstruction", "lblStatus", "lblTitle") +COLLAPSE_ORDER = ("lblTitle", "lblHint", "txtInstruction", "lblPreview", "lblStatus") + + +def blocks(has_preview): + """The control column, top to bottom, at every sizing tier.""" + # An empty preview takes no room at all - before a proposal arrives that + # space belongs to the instruction box and the status line. + preview = (0, 20, 64, 3) if has_preview else (0, 0, 0, 0) + return ( + ("lblTitle", 0, 10, 10, 0), + ("lblHint", 0, 0, 18, 0), + ("chkWholeDoc", 10, 10, 10, 0), + ("txtInstruction", 14, 22, 48, 2), + ("btnSend", 14, 14, 14, 0), + ("lblPreview",) + preview, + (DECIDE, 14, 14, 14, 0), + ("lblStatus", 10, 20, 40, 1), + ) + + +def _px(units, unit): + return int(round(units * unit)) + + +def _stacked(heights, unit): + """Total height in px of a column of block heights, gutters included. + A zero-height block is hidden and takes its gutter with it.""" + shown = [h for h in heights if h > 0] + return sum(shown) + _px(GAP, unit) * max(0, len(shown) - 1) + 2 * _px(PAD, unit) + + +def stack_height(tier, unit, has_preview=False): + """Height in px the whole column needs at one sizing tier.""" + return _stacked([_px(b[tier], unit) for b in blocks(has_preview)], unit) + + +def solve(width, height, unit, has_preview=False): + """Place every control inside a width x height panel (pixels). + + Returns ((control_id, rect), ...) top to bottom, where rect is + (x, y, width, height) or None when the control is hidden at this size. + """ + pad, gap = _px(PAD, unit), _px(GAP, unit) + column = blocks(has_preview) + size = {b[0]: _px(b[PREF], unit) for b in column} + tiers = {b[0]: b for b in column} + + def used(): + return _stacked([size[b[0]] for b in column], unit) + + # Too tall for the panel: give space back, minimum tier first, then floor. + for tier, order in ((MIN, SHRINK_ORDER), (FLOOR, COLLAPSE_ORDER)): + for bid in order: + short = used() - height + if short <= 0: + break + size[bid] = max(_px(tiers[bid][tier], unit), size[bid] - short) + + # Room to spare: hand it to the boxes that can use it. + extra = height - used() + weights = {b[0]: b[GROW] for b in column if size[b[0]] > 0 and b[GROW]} + if extra > 0 and weights: + total, given, ids = sum(weights.values()), 0, list(weights) + for bid in ids[:-1]: + share = extra * weights[bid] // total + size[bid] += share + given += share + size[ids[-1]] += extra - given + + placed = [] + inner = max(1, width - 2 * pad) + y = pad + for block in column: + bid, tall = block[0], size[block[0]] + if bid == DECIDE: + # Apply and Reject split the row and align with both margins. + wide = max(1, (inner - _px(BTN_GAP, unit)) // 2) + placed.append(("btnApprove", (pad, y, wide, tall) if tall else None)) + placed.append(("btnReject", + (pad + inner - wide, y, wide, tall) if tall else None)) + else: + placed.append((bid, (pad, y, inner, tall) if tall else None)) + if tall > 0: + y += tall + gap + return tuple(placed) diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/superdocs_client.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/superdocs_client.py new file mode 100644 index 0000000..33080b2 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/superdocs_client.py @@ -0,0 +1,171 @@ +"""Minimal SuperDocs API client for the Writer sidebar (stdlib only). + +Flow: upload the selected passage as a small HTML doc -> async chat with +human-in-the-loop approval -> caller shows the proposal -> approve/reject -> +on approval, poll to completion and export as plain text for write-back. + +API key: SUPERDOCS_API_KEY env var, else ~/.superdocs/agent_credentials.json. +Never logged, never stored by this module. +""" +import base64 +import html +import json +import os +import time +import urllib.error +import urllib.request +from pathlib import Path + +API = "https://api.superdocs.app" +POLL_SECONDS = 4 +POLL_LIMIT = 150 # x4s = 10 min + + +class SuperDocsError(Exception): + pass + + +def api_key(): + key = os.environ.get("SUPERDOCS_API_KEY") + if key: + return key + cred = Path.home() / ".superdocs" / "agent_credentials.json" + if cred.exists(): + return json.loads(cred.read_text(encoding="utf-8-sig"))["api_key"] + raise SuperDocsError( + "No API key: set SUPERDOCS_API_KEY or create ~/.superdocs/agent_credentials.json") + + +def _call(method, path, body=None, retries=1, timeout=120): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(API + path, data=data, method=method) + req.add_header("Authorization", "Bearer " + api_key()) + if data: + req.add_header("Content-Type", "application/json") + last = None + for attempt in range(retries + 1): + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + raw = r.read() + if "json" in r.headers.get("Content-Type", ""): + return json.loads(raw) + return raw + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc: + if isinstance(exc, urllib.error.HTTPError): + last = f"HTTP {exc.code}: {exc.read().decode(errors='replace')[:200]}" + else: + last = str(exc) + if attempt < retries: + time.sleep(4) # first call in a fresh session may flake while warming up + raise SuperDocsError(f"{method} {path} failed: {last}") + + +def _passage_to_html(text): + paragraphs = [f"

{html.escape(p)}

" for p in text.split("\n") if p.strip()] + return "" + "".join(paragraphs or ["

"]) + "" + + +def _poll(job_id, until_states, progress): + for i in range(POLL_LIMIT): + time.sleep(POLL_SECONDS) + job = _call("GET", f"/v1/jobs/{job_id}") + state = job.get("status") + if i % 3 == 2: + progress(f"Working... ({state}, {i * POLL_SECONDS}s)") + if state in until_states or state in ("failed", "cancelled"): + return state, job + raise SuperDocsError("Timed out waiting for SuperDocs job") + + +def _open_edit(session, message, progress): + """Shared tail: async chat -> poll -> pending changes. Returns edit dict.""" + progress("Requesting edit...") + job = _call("POST", "/v1/chat/async", { + "session_id": session, + "message": message, + "approval_mode": "ask_every_time", + }) + state, job_data = _poll(job["job_id"], ("awaiting_approval", "completed"), progress) + if state == "failed": + raise SuperDocsError("SuperDocs reported the edit failed") + pending = (job_data.get("metadata") or {}).get("pending_changes") or [] + parts = [c["ai_explanation"] for c in pending if c.get("ai_explanation")] + summary = " | ".join(parts) if parts else f"{len(pending)} proposed change(s)" + # Drafting instructions ("create X") bypass the approval gate entirely: the + # job completes with NO pending changes and the created content rides + # result.document_changes (observed 2026-08-07, logged as BUGS B6). Expose + # it so the panel can gate the draft locally instead of dropping it. + result = job_data.get("result") or {} + draft_html = "" + response_text = "" + if isinstance(result, dict): + draft_html = (result.get("document_changes") or {}).get("updated_html") or "" + response_text = result.get("response") or "" + return {"session": session, "job_id": job["job_id"], "pending": pending, + "state": state, "summary": summary, + "draft_html": draft_html, "response": response_text} + + +def request_edit(passage_text, instruction, progress=lambda m: None): + """Selection mode. Returns dict: session, job_id, pending, state, summary.""" + session = "sdext-" + time.strftime("%Y%m%d-%H%M%S") + progress("Uploading passage...") + _call("POST", "/v1/documents/upload-base64", { + "filename": "passage.html", + "file_base64": base64.b64encode(_passage_to_html(passage_text).encode()).decode(), + "session_id": session, + }) + return _open_edit( + session, + instruction + "\n\nEdit only this passage; do not add commentary or metadata.", + progress) + + +def request_doc_edit(docx_bytes, instruction, progress=lambda m: None): + """Whole-document mode: upload the exported .docx, then one chat request. + Same return shape as request_edit; pending[].old_html anchors write-back.""" + session = "sdext-doc-" + time.strftime("%Y%m%d-%H%M%S") + progress("Uploading document...") + _call("POST", "/v1/documents/upload-base64", { + "filename": "document.docx", + "file_base64": base64.b64encode(docx_bytes).decode(), + "session_id": session, + }) + return _open_edit( + session, + instruction + "\n\nApply targeted edits to this document; change only what " + "the instruction requires; do not add commentary or metadata.", + progress) + + +def finalize(edit, approve, progress=lambda m: None, want_text=True): + """Approve or reject all pending changes. On approve, returns the final + passage text (plain-text export), or None when want_text=False (whole-doc + mode writes back from the changes themselves); on reject, returns None.""" + decisions = [{"change_id": c["change_id"], "approved": bool(approve)} + for c in edit["pending"]] + if not approve: + for d in decisions: + d["feedback"] = "Rejected by user in the sidebar" + if decisions: + progress("Sending decision...") + # A bare {job_id, approved} is a silent no-op in the API - the explicit + # changes array is required for the job to proceed. + resp = _call("POST", f"/v1/chat/{edit['session']}/approve", + {"job_id": edit["job_id"], "approved": bool(approve), + "changes": decisions}) + if approve and not resp.get("batch_complete"): + raise SuperDocsError(f"Approval incomplete: {resp}") + if not approve: + return None + state, _ = _poll(edit["job_id"], ("completed",), progress) + if state != "completed": + raise SuperDocsError(f"Job ended in state {state}") + if not want_text: + return None + progress("Fetching result...") + blob = _call("POST", "/v1/documents/export", + {"session_id": edit["session"], "format": "txt"}) + if isinstance(blob, dict): + blob = base64.b64decode(blob.get("file_base64") or blob.get("content_base64")) + return blob.decode("utf-8-sig", errors="replace").strip("\n") diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/writeback.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/writeback.py new file mode 100644 index 0000000..0b14763 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/src/pythonpath/writeback.py @@ -0,0 +1,123 @@ +"""Range-by-range write-back of approved SuperDocs changes into the live doc. + +Each pending change carries the HTML it replaces (old_html) and the HTML it +becomes (new_html) — plain HTML on the jobs surface. We reduce old_html to its +plain-text paragraphs, locate them as ONE consecutive paragraph run in the open +document, and replace exactly that range with a style-preserving setString. + +The honesty rule: a change whose original text cannot be found, or matches more +than one place, is SKIPPED and reported — never guessed at. Content outside a +matched range is never touched. +""" +import html as _html +import re + +_BLOCK_BREAK = re.compile(r"|", re.I) +_CELL_END = re.compile(r"", re.I) # cell boundary -> space, or +# "MondayStrength" flattens to "MondayStrength" in drafted tables +_TAG = re.compile(r"<[^>]+>") + + +def _norm(text): + """Whitespace-insensitive comparison form (HTML collapses whitespace).""" + return re.sub(r"\s+", " ", (text or "").replace("\xa0", " ")).strip() + + +def html_paragraphs(markup): + """Plain-text paragraphs of a block-level HTML fragment, in order.""" + paras = [] + for chunk in _BLOCK_BREAK.split(_CELL_END.sub(" ", markup or "")): + # inline tags (, ...) vanish without leaving a space - + # "Friday." must yield "Friday.", not "Friday ."; paragraph + # boundaries were already handled by the block split above + text = _norm(_html.unescape(_TAG.sub("", chunk))) + if text: + paras.append(text) + return paras + + +def _paragraphs(doc): + """Top-level text paragraphs of the document (table cells not included).""" + out = [] + enum = doc.getText().createEnumeration() + while enum.hasMoreElements(): + node = enum.nextElement() + if node.supportsService("com.sun.star.text.Paragraph"): + out.append(node) + return out + + +def _find_runs(paras, wanted): + """Start indexes where consecutive paragraphs equal `wanted` (normalized).""" + texts = [_norm(p.getString()) for p in paras] + n = len(wanted) + return [i for i in range(len(texts) - n + 1) if texts[i:i + n] == wanted] + + +def apply_change(doc, change): + """Apply one approved change. Returns (applied: bool, reason: str).""" + old_paras = html_paragraphs(change.get("old_html")) + new_paras = html_paragraphs(change.get("new_html")) + if not old_paras: + return False, "pure insertion (no anchor text) - apply manually" + # fresh scan for every change: an earlier write-back moves paragraphs + paras = _paragraphs(doc) + hits = _find_runs(paras, old_paras) + if not hits: + return False, "original text not found (inside a table, or already changed)" + if len(hits) > 1: + return False, f"ambiguous ({len(hits)} identical matches)" + first = paras[hits[0]] + last = paras[hits[0] + len(old_paras) - 1] + cursor = doc.getText().createTextCursorByRange(first.getStart()) + cursor.gotoRange(last.getEnd(), True) + # setString keeps the range's styles; "\n" becomes a paragraph break + cursor.setString("\n".join(new_paras)) + return True, "applied" + + +def apply_changes(doc, pending): + """Apply approved changes range by range. Returns per-change result dicts.""" + return [{"change_id": change.get("change_id"), "applied": applied, "reason": reason} + for change in pending + for applied, reason in [apply_change(doc, change)]] + + +PARAGRAPH_BREAK = 0 # com.sun.star.text.ControlCharacter.PARAGRAPH_BREAK + + +def insert_paragraphs(doc, paras, at_range=None): + """Insert plain-text paragraphs at at_range (default: end of document). + + Existing content is never modified: if the insertion point sits in a + non-empty paragraph, a paragraph break is opened first. Returns the number + of paragraphs inserted. + """ + text = at_range.getText() if at_range is not None else doc.getText() + if at_range is not None: + cursor = text.createTextCursorByRange(at_range.getEnd()) + else: + cursor = text.createTextCursor() + cursor.gotoEnd(False) + probe = text.createTextCursorByRange(cursor) + probe.gotoStartOfParagraph(False) + probe.gotoEndOfParagraph(True) + if probe.getString().strip(): + text.insertControlCharacter(cursor, PARAGRAPH_BREAK, False) + for i, para in enumerate(paras): + if i: + text.insertControlCharacter(cursor, PARAGRAPH_BREAK, False) + text.insertString(cursor, para, False) + return len(paras) + + +def summarize(results): + """One status line, honest about anything skipped.""" + done = sum(1 for r in results if r["applied"]) + msg = f"Applied {done} of {len(results)} change(s) in place." + skipped = [r["reason"] for r in results if not r["applied"]] + if skipped: + msg += (" Skipped (document NOT touched for these): " + + "; ".join(skipped) + + ". The approved SuperDocs copy still has them - export it if needed.") + return msg diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/draft_live_test.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/draft_live_test.py new file mode 100644 index 0000000..7413a40 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/draft_live_test.py @@ -0,0 +1,61 @@ +"""Drafting-path LIVE test (plain HTTP, no LibreOffice needed, costs <= 1 op). + +Sends a drafting-style instruction ("create X") the way the sidebar does and +verifies the client surfaces the created content as edit["draft_html"] when the +job completes with zero pending changes (the B6 behavior: creation bypasses the +approval gate). If the model instead proposes in-place edits, we reject them +(free) and report that this run did not exercise the draft path - both outcomes +are honest. + +Needs SUPERDOCS_API_KEY or ~/.superdocs/agent_credentials.json. +Run: python3 tests/draft_live_test.py +""" +import base64 +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src" / "pythonpath")) +import superdocs_client as sc +import writeback + +BASE = "Migration Plan" +# mirror the whole-doc flow (the wording that reliably triggers creation): +# upload a near-empty doc, then the exact message request_doc_edit sends +session = "sdext-drafttest-" + time.strftime("%Y%m%d-%H%M%S") +sc._call("POST", "/v1/documents/upload-base64", { + "filename": "passage.html", + "file_base64": base64.b64encode( + f"

{BASE}

".encode()).decode(), + "session_id": session, +}) +edit = sc._open_edit( + session, + "create a plan to hit gym" + "\n\nApply targeted edits to this document; change only what " + "the instruction requires; do not add commentary or metadata.", + lambda m: print(" ", m)) +print("state:", edit["state"], "| pending:", len(edit["pending"]), + "| response:", (edit.get("response") or "")[:120]) + +if edit["state"] == "awaiting_approval": + sc.finalize(edit, False, progress=lambda m: print(" ", m)) + print("NOTE: model proposed in-place edits for this phrasing; rejected (free).") + print("DRAFT LIVE TEST: SKIPPED (draft path not taken this run - not a failure)") + sys.exit(0) + +paras = writeback.html_paragraphs(edit.get("draft_html")) +checks = [ + ("job completed with no pending changes", not edit["pending"]), + ("draft_html surfaced and non-empty", bool(paras)), + ("draft differs from the uploaded passage", + " ".join(paras) != writeback._norm(BASE)), +] +ok = True +for name, passed in checks: + print(("PASS " if passed else "FAIL ") + name) + ok = ok and passed +if ok: + print("draft paragraphs:", len(paras), "| first:", paras[0][:80]) +print("DRAFT LIVE TEST:", "ALL PASS" if ok else "FAILURES") +sys.exit(0 if ok else 1) diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/layout_test.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/layout_test.py new file mode 100644 index 0000000..28dfe91 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/layout_test.py @@ -0,0 +1,115 @@ +"""Panel layout test (OFFLINE - plain python3, no LibreOffice, no key, 0 operations). + +Drives panel_layout.solve at every panel height from far below the floor to far +above the preferred layout and proves the invariants the sidebar depends on: + + 1. the status line is fully inside the panel at every size >= the reported + minimum - the bug this module exists to fix (a short office window used to + push it off the bottom) + 2. Apply/Reject are always visible, and always above the status line + 3. nothing overlaps and nothing sticks out sideways + 4. the instruction box, Send, the checkbox and the status line never vanish + 5. an empty preview takes no space at all; a filled one is visible whenever + the panel is above its floor + 6. extra height is absorbed, not left as dead space at the bottom + 7. the whole thing scales with the host's pixels-per-unit (HiDPI) + +Run: python3 tests/layout_test.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src" / "pythonpath")) +import panel_layout as pl + +ALWAYS = ("chkWholeDoc", "txtInstruction", "btnSend", "btnApprove", "btnReject", "lblStatus") +checks = [] + + +def check(name, passed): + checks.append((name, passed)) + + +for unit in (1.5, 2.0, 3.0): # 100%, standard, HiDPI + for has_preview in (False, True): + tag = f"[unit={unit} preview={'yes' if has_preview else 'no'}]" + floor = pl.stack_height(pl.FLOOR, unit, has_preview) + preferred = pl.stack_height(pl.PREF, unit, has_preview) + check(f"{tag} floor is well below preferred", 0 < floor < preferred) + + for width in (int(80 * unit), int(130 * unit), int(300 * unit)): + # from half the floor (deck would scroll) to twice the preferred + for height in range(floor // 2, preferred * 2, 7): + boxes = dict(pl.solve(width, height, unit, has_preview)) + shown = {k: v for k, v in boxes.items() if v} + where = f"{tag} {width}x{height}" + + if height >= floor: + status = boxes["lblStatus"] + check(f"{where}: status is placed", bool(status)) + if status: + check(f"{where}: status fits inside the panel", + status[1] + status[3] <= height) + check(f"{where}: status keeps a readable line", + status[3] >= int(round(10 * unit))) + for cid in ALWAYS: + check(f"{where}: {cid} still visible", bool(boxes[cid])) + approve, reject = boxes["btnApprove"], boxes["btnReject"] + check(f"{where}: decision buttons above the status line", + approve[1] + approve[3] <= status[1]) + check(f"{where}: buttons share the row", + approve[1] == reject[1] and approve[3] == reject[3]) + check(f"{where}: buttons align with both margins", + approve[0] == round(pl.PAD * unit) + and reject[0] + reject[2] == width - round(pl.PAD * unit)) + check(f"{where}: buttons do not overlap", + approve[0] + approve[2] <= reject[0]) + + # ordering + no overlap, in the declared top-to-bottom order + stacked = [b for b in shown.values()] + # btnApprove stands in for the row it shares with btnReject + rows = [b for cid, b in pl.solve(width, height, unit, has_preview) + if b and cid != "btnReject"] + check(f"{where}: rows stack without overlapping", + all(a[1] + a[3] <= b[1] for a, b in zip(rows, rows[1:]))) + check(f"{where}: nothing overflows the panel width", + all(b[0] >= 0 and b[0] + b[2] <= width for b in stacked)) + check(f"{where}: full-width rows share one width", + len({b[2] for cid, b in shown.items() + if cid not in ("btnApprove", "btnReject")}) == 1) + check(f"{where}: nothing starts above the top margin", + all(b[1] >= round(pl.PAD * unit) for b in stacked)) + + check(f"{where}: empty preview takes no room", + has_preview or boxes["lblPreview"] is None) + if has_preview and height >= preferred: + check(f"{where}: filled preview is shown", bool(boxes["lblPreview"])) + + if height >= preferred: + bottom = max(b[1] + b[3] for b in stacked) + check(f"{where}: spare height is absorbed, not left dangling", + bottom >= height - round(pl.PAD * unit) - 1) + +# a tall panel gives the flexible boxes more than they ask for +tall = dict(pl.solve(260, 900, 2.0, True)) +pref = dict(pl.solve(260, pl.stack_height(pl.PREF, 2.0, True), 2.0, True)) +for cid in ("lblPreview", "txtInstruction", "lblStatus"): + check(f"tall panel grows {cid}", tall[cid][3] > pref[cid][3]) +for cid in ("btnSend", "btnApprove", "chkWholeDoc"): + check(f"tall panel leaves {cid} at its natural height", tall[cid][3] == pref[cid][3]) + +# a squeezed panel drops the decoration first, never the working controls +squeezed = dict(pl.solve(260, pl.stack_height(pl.FLOOR, 2.0, True), 2.0, True)) +check("floor layout drops the hint text", squeezed["lblHint"] is None) +check("floor layout keeps every working control", + all(squeezed[cid] for cid in ALWAYS)) + +ok = True +for name, passed in checks: + if not passed: + print("FAIL " + name) + ok = ok and passed +print(f"{len(checks)} assertions over " + f"{len({n.split(':')[0] for n, _ in checks})} panel sizes") +print("PANEL LAYOUT TEST:", "ALL PASS" if ok else "FAILURES") +sys.exit(0 if ok else 1) diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_style_test.sh b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_style_test.sh new file mode 100644 index 0000000..198a236 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_style_test.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Style-respect test harness (Linux). +# Starts a throwaway headless LibreOffice on an ISOLATED profile (does not +# touch your normal LibreOffice or its profile) and runs the UNO test on it. +# Needs: libreoffice-writer, python3-uno, and SUPERDOCS_API_KEY (or +# ~/.superdocs/agent_credentials.json). Costs 1 SuperDocs operation. +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +PROFILE=/tmp/lotest-superdocs +rm -rf "$PROFILE" +soffice --headless --invisible --norestore \ + "-env:UserInstallation=file://$PROFILE" \ + --accept="socket,host=localhost,port=2002;urp;" & +LOPID=$! +PYTHONPATH=/usr/lib/libreoffice/program timeout 240 python3 "$HERE/style_respect_test.py" +RC=$? +kill "$LOPID" 2>/dev/null +exit "$RC" diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_whole_doc_live_test.sh b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_whole_doc_live_test.sh new file mode 100644 index 0000000..ec8808e --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_whole_doc_live_test.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Whole-document LIVE round-trip harness (Linux). Costs 1 SuperDocs operation. +# Needs: libreoffice-writer, python3-uno, and SUPERDOCS_API_KEY (or +# ~/.superdocs/agent_credentials.json). Isolated profile, port 2004. +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +PROFILE=/tmp/lotest-superdocs-wholedoc-live +rm -rf "$PROFILE" +soffice --headless --invisible --norestore \ + "-env:UserInstallation=file://$PROFILE" \ + --accept="socket,host=localhost,port=2004;urp;" & +LOPID=$! +PYTHONPATH=/usr/lib/libreoffice/program timeout 600 python3 "$HERE/whole_doc_live_test.py" +RC=$? +kill "$LOPID" 2>/dev/null +exit "$RC" diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_whole_doc_test.sh b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_whole_doc_test.sh new file mode 100644 index 0000000..f34f148 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/run_whole_doc_test.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Whole-document write-back test harness (Linux). OFFLINE: no API key needed, +# costs 0 operations. Starts a throwaway headless LibreOffice on an ISOLATED +# profile (port 2003, so it can run alongside run_style_test.sh) and drives +# writeback.apply_changes against a real Writer document. +# Needs: libreoffice-writer, python3-uno. +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +PROFILE=/tmp/lotest-superdocs-wholedoc +rm -rf "$PROFILE" +soffice --headless --invisible --norestore \ + "-env:UserInstallation=file://$PROFILE" \ + --accept="socket,host=localhost,port=2003;urp;" & +LOPID=$! +PYTHONPATH=/usr/lib/libreoffice/program timeout 240 python3 "$HERE/whole_doc_test.py" +RC=$? +kill "$LOPID" 2>/dev/null +exit "$RC" diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/style_respect_test.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/style_respect_test.py new file mode 100644 index 0000000..9b3614b --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/style_respect_test.py @@ -0,0 +1,122 @@ +"""Styled-document respect test (headless Linux, costs 1 operation). + +Builds a Writer doc with Heading 1 + body + bullet list + table, edits ONE body +paragraph through the same client + setString path the sidebar panel uses, then +asserts every style survived and nothing else changed. + +Run via run_style_test.sh (starts the headless office this connects to). +""" +import sys +import time +from pathlib import Path + +import uno + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src" / "pythonpath")) +import superdocs_client as sc + + +def connect(retries=40): + local = uno.getComponentContext() + resolver = local.ServiceManager.createInstanceWithContext( + "com.sun.star.bridge.UnoUrlResolver", local) + for _ in range(retries): + try: + return resolver.resolve( + "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext") + except Exception: + time.sleep(2) + raise RuntimeError("could not connect to soffice on port 2002") + + +ctx = connect() +smgr = ctx.ServiceManager +desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) +doc = desktop.loadComponentFromURL("private:factory/swriter", "_blank", 0, ()) +text = doc.getText() +cursor = text.createTextCursor() + +SLOPPY = "we gonna move the db next week, dont worry bout downtime it should be fine probably." + +# Style names differ across LibreOffice generations ("List Bullet" family was +# dropped around LO 26; bullets are "List 1".."List 5"). Discover, don't assume. +pstyles = doc.getStyleFamilies().getByName("ParagraphStyles") +_names = list(pstyles.getElementNames()) +_candidates = ([n for n in _names if "bullet" in n.lower()] + + [n for n in ("List 1", "List") if n in _names]) +if not _candidates: + print("no bullet/list style; available:", _names) + sys.exit(2) +BULLET = _candidates[0] +print("using bullet style:", BULLET) + +# heading +cursor.ParaStyleName = "Heading 1" +text.insertString(cursor, "Migration Plan", False) +text.insertControlCharacter(cursor, 0, False) # PARAGRAPH_BREAK +# body paragraph (the edit target) +cursor.ParaStyleName = "Standard" +text.insertString(cursor, SLOPPY, False) +text.insertControlCharacter(cursor, 0, False) +# bullet list +cursor.ParaStyleName = BULLET +text.insertString(cursor, "Backup verification", False) +text.insertControlCharacter(cursor, 0, False) +text.insertString(cursor, "Rollback rehearsal", False) +text.insertControlCharacter(cursor, 0, False) +# table +table = doc.createInstance("com.sun.star.text.TextTable") +table.initialize(2, 2) +cursor.ParaStyleName = "Standard" +text.insertTextContent(cursor, table, False) +table.getCellByName("A1").setString("Owner") +table.getCellByName("B1").setString("DBA team") +table.getCellByName("A2").setString("Window") +table.getCellByName("B2").setString("Sat 02:00") + +# locate the sloppy paragraph and select it as a range +target = None +enum = text.createEnumeration() +while enum.hasMoreElements(): + para = enum.nextElement() + if para.supportsService("com.sun.star.text.Paragraph") and para.getString() == SLOPPY: + target = para + break +assert target is not None, "target paragraph not found" + +rng = text.createTextCursorByRange(target.getStart()) +rng.gotoEndOfParagraph(True) + +print("1) requesting edit via sidebar client ...") +edit = sc.request_edit(rng.getString(), "Rewrite formally and precisely.", + progress=lambda m: print(" ", m)) +assert edit["state"] == "awaiting_approval", edit["state"] +final = sc.finalize(edit, True, progress=lambda m: print(" ", m)) +print(" final:", final[:120]) +rng.setString(final) # exactly what the panel's write_back does + +# ---- assertions ---- +results = [] +paras = [] +enum = text.createEnumeration() +while enum.hasMoreElements(): + p = enum.nextElement() + if p.supportsService("com.sun.star.text.Paragraph"): + paras.append((p.ParaStyleName, p.getString())) + +results.append(("heading style intact", paras[0] == ("Heading 1", "Migration Plan"))) +results.append(("edited para kept Default style", paras[1][0] == "Standard")) +results.append(("edited para text changed", paras[1][1] != SLOPPY and len(paras[1][1]) > 10)) +bullets = [p for p in paras if p[0] == BULLET] +results.append(("both bullets intact", + [b[1] for b in bullets] == ["Backup verification", "Rollback rehearsal"])) +cells = [table.getCellByName(n).getString() for n in ("A1", "B1", "A2", "B2")] +results.append(("table cells untouched", cells == ["Owner", "DBA team", "Window", "Sat 02:00"])) + +ok = True +for name, passed in results: + print(("PASS " if passed else "FAIL ") + name) + ok = ok and passed +doc.close(False) +print("STYLE TEST:", "ALL PASS" if ok else "FAILURES") +sys.exit(0 if ok else 1) diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/whole_doc_live_test.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/whole_doc_live_test.py new file mode 100644 index 0000000..15c2abe --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/whole_doc_live_test.py @@ -0,0 +1,121 @@ +"""Whole-document LIVE round-trip test (headless Linux, costs 1 operation). + +The offline test (whole_doc_test.py) proves the write-back mechanics; this one +proves the integration: a real Writer doc exported as .docx through the host +filter, sent through request_doc_edit, approved, and written back range by +range from the ACTUAL old_html/new_html SuperDocs returned. + +Needs SUPERDOCS_API_KEY or ~/.superdocs/agent_credentials.json. +Run via run_whole_doc_live_test.sh. +""" +import os +import sys +import tempfile +import time +from pathlib import Path + +import uno +import unohelper +from com.sun.star.beans import PropertyValue + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src" / "pythonpath")) +import superdocs_client as sc +import writeback + + +def connect(retries=40): + local = uno.getComponentContext() + resolver = local.ServiceManager.createInstanceWithContext( + "com.sun.star.bridge.UnoUrlResolver", local) + for _ in range(retries): + try: + return resolver.resolve( + "uno:socket,host=localhost,port=2004;urp;StarOffice.ComponentContext") + except Exception: + time.sleep(2) + raise RuntimeError("could not connect to soffice on port 2004") + + +ctx = connect() +desktop = ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) +doc = desktop.loadComponentFromURL("private:factory/swriter", "_blank", 0, ()) +text = doc.getText() +cursor = text.createTextCursor() + +TARGET = "The supplier will try to deliver stuff sometime soon, no promises." +KEEP = "Payment terms are net thirty days from the invoice date." + + +def para(style, content): + cursor.ParaStyleName = style + text.insertString(cursor, content, False) + text.insertControlCharacter(cursor, 0, False) # PARAGRAPH_BREAK + + +para("Heading 1", "Service Agreement") +para("Standard", TARGET) +para("Standard", KEEP) +table = doc.createInstance("com.sun.star.text.TextTable") +table.initialize(1, 2) +cursor.ParaStyleName = "Standard" +text.insertTextContent(cursor, table, False) +table.getCellByName("A1").setString("Owner") +table.getCellByName("B1").setString("Procurement") + +# export exactly the way the panel does +fd, path = tempfile.mkstemp(suffix=".docx") +os.close(fd) +prop = PropertyValue() +prop.Name = "FilterName" +prop.Value = "MS Word 2007 XML" +doc.storeToURL(unohelper.systemPathToFileUrl(path), (prop,)) +with open(path, "rb") as f: + docx_bytes = f.read() +os.remove(path) +print(f"1) exported {len(docx_bytes)} bytes, requesting whole-doc edit ...") + +edit = sc.request_doc_edit( + docx_bytes, + "Rewrite the vague delivery sentence into a formal commitment to deliver " + "within 10 business days. Leave every other part of the document unchanged.", + progress=lambda m: print(" ", m)) +assert edit["state"] == "awaiting_approval", edit["state"] +for c in edit["pending"]: + print(" pending old:", (c.get("old_html") or "")[:100]) + print(" pending new:", (c.get("new_html") or "")[:100]) + +print("2) approving ...") +sc.finalize(edit, True, progress=lambda m: print(" ", m), want_text=False) + +print("3) writing back range by range ...") +results = writeback.apply_changes(doc, edit["pending"]) +for r in results: + print(" ", r) + +paras = [] +enum = text.createEnumeration() +while enum.hasMoreElements(): + p = enum.nextElement() + if p.supportsService("com.sun.star.text.Paragraph"): + paras.append((p.ParaStyleName, p.getString())) +texts = [t for _, t in paras] + +checks = [ + ("at least one change applied", any(r["applied"] for r in results)), + ("vague sentence gone", TARGET not in texts), + ("replacement kept Standard style", + all(s == "Standard" for s, t in paras if TARGET[:20] not in t and t not in + ("Service Agreement",) and t.strip())), + ("untouched paragraph identical", KEEP in texts), + ("heading untouched", paras[0] == ("Heading 1", "Service Agreement")), + ("table untouched", + [table.getCellByName(n).getString() for n in ("A1", "B1")] == ["Owner", "Procurement"]), +] + +ok = True +for name, passed in checks: + print(("PASS " if passed else "FAIL ") + name) + ok = ok and passed +doc.close(False) +print("WHOLE-DOC LIVE TEST:", "ALL PASS" if ok else "FAILURES") +sys.exit(0 if ok else 1) diff --git a/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/whole_doc_test.py b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/whole_doc_test.py new file mode 100644 index 0000000..c39ada7 --- /dev/null +++ b/extensions/vanamkarthiknetha/superdocs-writer-sidebar/tests/whole_doc_test.py @@ -0,0 +1,169 @@ +"""Whole-document write-back test (headless Linux, OFFLINE - no key, 0 operations). + +Exercises writeback.apply_changes with a synthetic pending-changes list against +a real Writer document, proving the write-back invariants the sidebar relies on: + + 1. a unique single-paragraph match is replaced in place, styles intact + 2. a multi-paragraph run is matched as ONE range and replaced + 3. an ambiguous match (duplicate text) is SKIPPED, both copies untouched + 4. text not in the document is SKIPPED + 5. table-cell content is out of scope -> SKIPPED, table untouched + 6. everything not matched stays exactly as it was + +Run via run_whole_doc_test.sh (starts the headless office this connects to). +""" +import sys +import time +from pathlib import Path + +import uno + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src" / "pythonpath")) +import writeback + + +def connect(retries=40): + local = uno.getComponentContext() + resolver = local.ServiceManager.createInstanceWithContext( + "com.sun.star.bridge.UnoUrlResolver", local) + for _ in range(retries): + try: + return resolver.resolve( + "uno:socket,host=localhost,port=2003;urp;StarOffice.ComponentContext") + except Exception: + time.sleep(2) + raise RuntimeError("could not connect to soffice on port 2003") + + +ctx = connect() +smgr = ctx.ServiceManager +desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) +doc = desktop.loadComponentFromURL("private:factory/swriter", "_blank", 0, ()) +text = doc.getText() +cursor = text.createTextCursor() + +BODY = "The vendor shall deliver the report by friday, probably." +RUN_A = "First step of the rollout." +RUN_B = "Second step of the rollout." +DUP = "This sentence appears twice." + + +def para(style, content): + cursor.ParaStyleName = style + text.insertString(cursor, content, False) + text.insertControlCharacter(cursor, 0, False) # PARAGRAPH_BREAK + + +para("Heading 1", "Contract Summary") +para("Standard", BODY) +para("Standard", RUN_A) +para("Standard", RUN_B) +para("Standard", DUP) +para("Standard", DUP) +table = doc.createInstance("com.sun.star.text.TextTable") +table.initialize(1, 2) +cursor.ParaStyleName = "Standard" +text.insertTextContent(cursor, table, False) +table.getCellByName("A1").setString("Owner") +table.getCellByName("B1").setString("Legal team") + +pending = [ + # 1. unique single paragraph -> applied (entities + inline tags on purpose) + {"change_id": "c1", + "old_html": f"

{BODY}

", + "new_html": "

The vendor shall deliver the report by Friday 17:00.

"}, + # 2. two consecutive paragraphs -> ONE range, collapsed to one paragraph + {"change_id": "c2", + "old_html": f"

{RUN_A}

{RUN_B}

", + "new_html": "

Both rollout steps, merged.

"}, + # 3. ambiguous -> skipped + {"change_id": "c3", + "old_html": f"

{DUP}

", + "new_html": "

should never land

"}, + # 4. not in document -> skipped + {"change_id": "c4", + "old_html": "

text that exists nowhere in this file

", + "new_html": "

should never land

"}, + # 5. table cell -> out of scope, skipped + {"change_id": "c5", + "old_html": "

Owner

", + "new_html": "

should never land

"}, +] + +results = writeback.apply_changes(doc, pending) +by_id = {r["change_id"]: r for r in results} + +paras = [] +enum = text.createEnumeration() +while enum.hasMoreElements(): + p = enum.nextElement() + if p.supportsService("com.sun.star.text.Paragraph"): + paras.append((p.ParaStyleName, p.getString())) +texts = [p[1] for p in paras] + +checks = [ + ("c1 applied", by_id["c1"]["applied"]), + ("c1 text landed (entities decoded, tags stripped)", + "The vendor shall deliver the report by Friday 17:00." in texts), + ("c1 kept Standard style", + any(s == "Standard" and t.startswith("The vendor shall deliver") + for s, t in paras)), + ("c2 applied", by_id["c2"]["applied"]), + ("c2 run collapsed to one paragraph", + texts.count("Both rollout steps, merged.") == 1 + and RUN_A not in texts and RUN_B not in texts), + ("c3 skipped as ambiguous", not by_id["c3"]["applied"] + and "ambiguous" in by_id["c3"]["reason"]), + ("c3 both duplicates untouched", texts.count(DUP) == 2), + ("c4 skipped as not found", not by_id["c4"]["applied"]), + ("c5 skipped (table out of scope)", not by_id["c5"]["applied"]), + ("table untouched", + [table.getCellByName(n).getString() for n in ("A1", "B1")] == ["Owner", "Legal team"]), + ("heading untouched", paras[0] == ("Heading 1", "Contract Summary")), + ("nothing bogus landed", "should never land" not in texts), +] + +# ---- draft insertion (v0.3.1): insert_paragraphs never touches existing text ---- +before_texts = list(texts) +n_end = writeback.insert_paragraphs(doc, ["Draft alpha.", "Draft beta."]) # at end +heading_para = None +enum = text.createEnumeration() +while enum.hasMoreElements(): + p = enum.nextElement() + if p.supportsService("com.sun.star.text.Paragraph") and p.getString() == "Contract Summary": + heading_para = p + break +n_mid = writeback.insert_paragraphs(doc, ["Inserted after heading."], heading_para) + +paras2 = [] +enum = text.createEnumeration() +while enum.hasMoreElements(): + p = enum.nextElement() + if p.supportsService("com.sun.star.text.Paragraph"): + paras2.append((p.ParaStyleName, p.getString())) +texts2 = [t for _, t in paras2] +nonempty2 = [t for t in texts2 if t.strip()] + +checks += [ + ("insert at end returned 2", n_end == 2), + ("draft paragraphs landed at end", nonempty2[-2:] == ["Draft alpha.", "Draft beta."]), + ("insert at range returned 1", n_mid == 1), + ("mid insert landed right after heading", + texts2[texts2.index("Contract Summary") + 1] == "Inserted after heading."), + ("heading text/style survived insertion", paras2[0] == ("Heading 1", "Contract Summary")), + # non-empty only: inserting at the end legitimately consumes the trailing + # EMPTY paragraph (writing into an empty paragraph touches no content) + ("every pre-insert non-empty paragraph still present in order", + [t for t in texts2 if t.strip() and t in before_texts] + == [t for t in before_texts if t.strip()]), + ("table survived insertion", + [table.getCellByName(n).getString() for n in ("A1", "B1")] == ["Owner", "Legal team"]), +] + +ok = True +for name, passed in checks: + print(("PASS " if passed else "FAIL ") + name) + ok = ok and passed +doc.close(False) +print("WHOLE-DOC WRITE-BACK TEST:", "ALL PASS" if ok else "FAILURES") +sys.exit(0 if ok else 1)