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 |
+|---|---|
+|  |  |
+
+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 |
+|---|---|
+|  |  |
+
+"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.
+
+
+
+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):**
+
+
+
+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"(?:p|h[1-6]|li|div|tr)\s*>|
", 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)
|