tandem-editor 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1550,9 +1550,138 @@ var init_types2 = __esm({
1550
1550
  }
1551
1551
  });
1552
1552
 
1553
+ // src/server/file-io/docx-walker.ts
1554
+ import { parseDocument as parseDocument2 } from "htmlparser2";
1555
+ function isElement2(node) {
1556
+ return node.type === "tag";
1557
+ }
1558
+ function getAttr(el, name) {
1559
+ return el.attribs?.[name];
1560
+ }
1561
+ function getTextContent(node) {
1562
+ if (node.type === "text") return node.data;
1563
+ if (!isElement2(node)) return "";
1564
+ return node.children.map(getTextContent).join("");
1565
+ }
1566
+ function findAllByName(name, nodes) {
1567
+ const results = [];
1568
+ for (const node of nodes) {
1569
+ if (isElement2(node)) {
1570
+ if (node.name === name) results.push(node);
1571
+ results.push(...findAllByName(name, node.children));
1572
+ }
1573
+ }
1574
+ return results;
1575
+ }
1576
+ function detectHeadingLevel(paragraph) {
1577
+ for (const child of paragraph.children) {
1578
+ if (!isElement2(child) || child.name !== "w:pPr") continue;
1579
+ for (const prop of child.children) {
1580
+ if (!isElement2(prop) || prop.name !== "w:pStyle") continue;
1581
+ const val = getAttr(prop, "w:val") || "";
1582
+ const match = val.match(/^heading\s*(\d)$/i);
1583
+ if (match) {
1584
+ const level = parseInt(match[1], 10);
1585
+ if (level >= 1 && level <= 6) return level;
1586
+ }
1587
+ }
1588
+ }
1589
+ return 0;
1590
+ }
1591
+ function walkDocumentBody(xml, callbacks = {}) {
1592
+ const doc = parseDocument2(xml, { xmlMode: true });
1593
+ let offset = 0;
1594
+ let firstParagraph = true;
1595
+ const textParts = [];
1596
+ let currentParagraph;
1597
+ let currentParagraphId;
1598
+ let currentRun;
1599
+ function walk(nodes) {
1600
+ for (const node of nodes) {
1601
+ if (!isElement2(node)) continue;
1602
+ if (node.name === "w:p") {
1603
+ if (!firstParagraph) {
1604
+ offset += 1;
1605
+ textParts.push("\n");
1606
+ }
1607
+ firstParagraph = false;
1608
+ const prevParagraph = currentParagraph;
1609
+ const prevParagraphId = currentParagraphId;
1610
+ currentParagraph = node;
1611
+ currentParagraphId = getAttr(node, "w14:paraId");
1612
+ const headingLevel = detectHeadingLevel(node);
1613
+ if (headingLevel > 0) {
1614
+ const prefixLen = headingPrefixLength(headingLevel);
1615
+ offset += prefixLen;
1616
+ textParts.push("#".repeat(headingLevel) + " ");
1617
+ }
1618
+ walk(node.children);
1619
+ currentParagraph = prevParagraph;
1620
+ currentParagraphId = prevParagraphId;
1621
+ } else if (node.name === "w:del") {
1622
+ } else if (node.name === "w:commentRangeStart") {
1623
+ const id = getAttr(node, "w:id");
1624
+ if (id) {
1625
+ callbacks.onCommentStart?.({
1626
+ commentId: id,
1627
+ offset,
1628
+ paragraph: currentParagraph,
1629
+ paragraphId: currentParagraphId
1630
+ });
1631
+ }
1632
+ } else if (node.name === "w:commentRangeEnd") {
1633
+ const id = getAttr(node, "w:id");
1634
+ if (id) {
1635
+ callbacks.onCommentEnd?.(id, offset);
1636
+ }
1637
+ } else if (node.name === "w:instrText") {
1638
+ } else if (node.name === "w:t") {
1639
+ const text = getTextContent(node);
1640
+ if (callbacks.onText && currentRun && currentParagraph) {
1641
+ callbacks.onText({
1642
+ run: currentRun,
1643
+ textNode: node,
1644
+ offsetStart: offset,
1645
+ text,
1646
+ paragraph: currentParagraph,
1647
+ paragraphId: currentParagraphId
1648
+ });
1649
+ }
1650
+ offset += text.length;
1651
+ textParts.push(text);
1652
+ } else if (SINGLE_CHAR_ELEMENTS.has(node.name)) {
1653
+ offset += 1;
1654
+ textParts.push(" ");
1655
+ } else if (node.name === "w:r") {
1656
+ const prevRun = currentRun;
1657
+ currentRun = node;
1658
+ walk(node.children);
1659
+ currentRun = prevRun;
1660
+ } else {
1661
+ walk(node.children);
1662
+ }
1663
+ }
1664
+ }
1665
+ const bodyElements = findAllByName("w:body", doc.children);
1666
+ if (bodyElements.length === 0) {
1667
+ return { totalLength: 0, flatText: "" };
1668
+ }
1669
+ walk(bodyElements[0].children);
1670
+ const flatText = textParts.join("");
1671
+ return { totalLength: offset, flatText };
1672
+ }
1673
+ var SINGLE_CHAR_ELEMENTS;
1674
+ var init_docx_walker = __esm({
1675
+ "src/server/file-io/docx-walker.ts"() {
1676
+ "use strict";
1677
+ init_offsets();
1678
+ SINGLE_CHAR_ELEMENTS = /* @__PURE__ */ new Set(["w:tab", "w:br", "w:noBreakHyphen", "w:softHyphen", "w:sym"]);
1679
+ }
1680
+ });
1681
+
1553
1682
  // src/server/file-io/docx-comments.ts
1554
1683
  import JSZip from "jszip";
1555
- import { parseDocument as parseDocument2 } from "htmlparser2";
1684
+ import { parseDocument as parseDocument3 } from "htmlparser2";
1556
1685
  async function extractDocxComments(buffer3) {
1557
1686
  const zip = await JSZip.loadAsync(buffer3);
1558
1687
  const commentsXml = await zip.file("word/comments.xml")?.async("text");
@@ -1583,7 +1712,7 @@ async function extractDocxComments(buffer3) {
1583
1712
  return result;
1584
1713
  }
1585
1714
  function parseCommentMetadata(xml) {
1586
- const doc = parseDocument2(xml, { xmlMode: true });
1715
+ const doc = parseDocument3(xml, { xmlMode: true });
1587
1716
  const map = /* @__PURE__ */ new Map();
1588
1717
  for (const comment of findAllByName("w:comment", doc.children)) {
1589
1718
  const id = getAttr(comment, "w:id");
@@ -1597,49 +1726,22 @@ function parseCommentMetadata(xml) {
1597
1726
  return map;
1598
1727
  }
1599
1728
  function calculateCommentRanges(xml) {
1600
- const doc = parseDocument2(xml, { xmlMode: true });
1601
1729
  const ranges = /* @__PURE__ */ new Map();
1602
1730
  const openRanges = /* @__PURE__ */ new Map();
1603
- let offset = 0;
1604
- let firstParagraph = true;
1605
- function walk(nodes) {
1606
- for (const node of nodes) {
1607
- if (!isElement2(node)) continue;
1608
- if (node.name === "w:p") {
1609
- if (!firstParagraph) offset += 1;
1610
- firstParagraph = false;
1611
- const headingLevel = detectHeadingLevel(node);
1612
- if (headingLevel > 0) {
1613
- offset += headingPrefixLength(headingLevel);
1614
- }
1615
- walk(node.children);
1616
- } else if (node.name === "w:commentRangeStart") {
1617
- const id = getAttr(node, "w:id");
1618
- if (id) openRanges.set(id, offset);
1619
- } else if (node.name === "w:commentRangeEnd") {
1620
- const id = getAttr(node, "w:id");
1621
- if (id && openRanges.has(id)) {
1622
- ranges.set(id, { from: toFlatOffset(openRanges.get(id)), to: toFlatOffset(offset) });
1623
- openRanges.delete(id);
1624
- }
1625
- } else if (node.name === "w:t") {
1626
- const text = getTextContent(node);
1627
- offset += text.length;
1628
- } else if (node.name === "w:tab" || node.name === "w:br") {
1629
- offset += 1;
1630
- } else {
1631
- walk(node.children);
1731
+ walkDocumentBody(xml, {
1732
+ onCommentStart({ commentId, offset }) {
1733
+ openRanges.set(commentId, offset);
1734
+ },
1735
+ onCommentEnd(commentId, offset) {
1736
+ if (openRanges.has(commentId)) {
1737
+ ranges.set(commentId, {
1738
+ from: toFlatOffset(openRanges.get(commentId)),
1739
+ to: toFlatOffset(offset)
1740
+ });
1741
+ openRanges.delete(commentId);
1632
1742
  }
1633
1743
  }
1634
- }
1635
- const bodyElements = findAllByName("w:body", doc.children);
1636
- if (bodyElements.length === 0) {
1637
- console.error(
1638
- "[docx-comments] No <w:body> found in document.xml \u2014 cannot calculate comment ranges"
1639
- );
1640
- return ranges;
1641
- }
1642
- walk(bodyElements[0].children);
1744
+ });
1643
1745
  if (openRanges.size > 0) {
1644
1746
  console.error(
1645
1747
  `[docx-comments] ${openRanges.size} comment range(s) had start markers but no end markers: ${[...openRanges.keys()].join(", ")}`
@@ -1647,21 +1749,6 @@ function calculateCommentRanges(xml) {
1647
1749
  }
1648
1750
  return ranges;
1649
1751
  }
1650
- function detectHeadingLevel(paragraph) {
1651
- for (const child of paragraph.children) {
1652
- if (!isElement2(child) || child.name !== "w:pPr") continue;
1653
- for (const prop of child.children) {
1654
- if (!isElement2(prop) || prop.name !== "w:pStyle") continue;
1655
- const val = getAttr(prop, "w:val") || "";
1656
- const match = val.match(/^heading\s*(\d)$/i);
1657
- if (match) {
1658
- const level = parseInt(match[1], 10);
1659
- if (level >= 1 && level <= 6) return level;
1660
- }
1661
- }
1662
- }
1663
- return 0;
1664
- }
1665
1752
  function injectCommentsAsAnnotations(doc, comments) {
1666
1753
  if (comments.length === 0) return 0;
1667
1754
  const map = doc.getMap(Y_MAP_ANNOTATIONS);
@@ -1698,35 +1785,864 @@ function injectCommentsAsAnnotations(doc, comments) {
1698
1785
  }
1699
1786
  return injected;
1700
1787
  }
1701
- function isElement2(node) {
1702
- return node.type === "tag";
1703
- }
1704
- function getAttr(el, name) {
1705
- return el.attribs?.[name];
1706
- }
1707
- function getTextContent(node) {
1708
- if (node.type === "text") return node.data;
1709
- if (!isElement2(node)) return "";
1710
- return node.children.map(getTextContent).join("");
1711
- }
1712
- function findAllByName(name, nodes) {
1713
- const results = [];
1714
- for (const node of nodes) {
1715
- if (isElement2(node)) {
1716
- if (node.name === name) results.push(node);
1717
- results.push(...findAllByName(name, node.children));
1718
- }
1719
- }
1720
- return results;
1721
- }
1722
1788
  var init_docx_comments = __esm({
1723
1789
  "src/server/file-io/docx-comments.ts"() {
1724
1790
  "use strict";
1725
1791
  init_constants();
1726
- init_offsets();
1727
1792
  init_positions2();
1728
1793
  init_types2();
1729
1794
  init_queue();
1795
+ init_docx_walker();
1796
+ }
1797
+ });
1798
+
1799
+ // node_modules/domelementtype/dist/index.js
1800
+ function isTag(element) {
1801
+ return element.type === ElementType.Tag || element.type === ElementType.Script || element.type === ElementType.Style;
1802
+ }
1803
+ var ElementType, Root, Text, Directive, Comment, Script, Style, Tag, CDATA, Doctype;
1804
+ var init_dist = __esm({
1805
+ "node_modules/domelementtype/dist/index.js"() {
1806
+ "use strict";
1807
+ (function(ElementType2) {
1808
+ ElementType2["Root"] = "root";
1809
+ ElementType2["Text"] = "text";
1810
+ ElementType2["Directive"] = "directive";
1811
+ ElementType2["Comment"] = "comment";
1812
+ ElementType2["Script"] = "script";
1813
+ ElementType2["Style"] = "style";
1814
+ ElementType2["Tag"] = "tag";
1815
+ ElementType2["CDATA"] = "cdata";
1816
+ ElementType2["Doctype"] = "doctype";
1817
+ })(ElementType || (ElementType = {}));
1818
+ Root = ElementType.Root;
1819
+ Text = ElementType.Text;
1820
+ Directive = ElementType.Directive;
1821
+ Comment = ElementType.Comment;
1822
+ Script = ElementType.Script;
1823
+ Style = ElementType.Style;
1824
+ Tag = ElementType.Tag;
1825
+ CDATA = ElementType.CDATA;
1826
+ Doctype = ElementType.Doctype;
1827
+ }
1828
+ });
1829
+
1830
+ // node_modules/domhandler/dist/node.js
1831
+ function isTag2(node) {
1832
+ return isTag(node);
1833
+ }
1834
+ function isCDATA(node) {
1835
+ return node.type === ElementType.CDATA;
1836
+ }
1837
+ function isText2(node) {
1838
+ return node.type === ElementType.Text;
1839
+ }
1840
+ function isComment(node) {
1841
+ return node.type === ElementType.Comment;
1842
+ }
1843
+ function isDirective(node) {
1844
+ return node.type === ElementType.Directive;
1845
+ }
1846
+ function isDocument(node) {
1847
+ return node.type === ElementType.Root;
1848
+ }
1849
+ function cloneNode(node, recursive = false) {
1850
+ let result;
1851
+ if (isText2(node)) {
1852
+ result = new Text2(node.data);
1853
+ } else if (isComment(node)) {
1854
+ result = new Comment2(node.data);
1855
+ } else if (isTag2(node)) {
1856
+ const children = recursive ? cloneChildren(node.children) : [];
1857
+ const clone = new Element(node.name, { ...node.attribs }, children);
1858
+ for (const child of children) {
1859
+ child.parent = clone;
1860
+ }
1861
+ if (node.namespace != null) {
1862
+ clone.namespace = node.namespace;
1863
+ }
1864
+ if (node["x-attribsNamespace"]) {
1865
+ clone["x-attribsNamespace"] = { ...node["x-attribsNamespace"] };
1866
+ }
1867
+ if (node["x-attribsPrefix"]) {
1868
+ clone["x-attribsPrefix"] = { ...node["x-attribsPrefix"] };
1869
+ }
1870
+ result = clone;
1871
+ } else if (isCDATA(node)) {
1872
+ const children = recursive ? cloneChildren(node.children) : [];
1873
+ const clone = new CDATA2(children);
1874
+ for (const child of children) {
1875
+ child.parent = clone;
1876
+ }
1877
+ result = clone;
1878
+ } else if (isDocument(node)) {
1879
+ const children = recursive ? cloneChildren(node.children) : [];
1880
+ const clone = new Document(children);
1881
+ for (const child of children) {
1882
+ child.parent = clone;
1883
+ }
1884
+ if (node["x-mode"]) {
1885
+ clone["x-mode"] = node["x-mode"];
1886
+ }
1887
+ result = clone;
1888
+ } else if (isDirective(node)) {
1889
+ const instruction = new ProcessingInstruction(node.name, node.data);
1890
+ if (node["x-name"] != null) {
1891
+ instruction["x-name"] = node["x-name"];
1892
+ instruction["x-publicId"] = node["x-publicId"];
1893
+ instruction["x-systemId"] = node["x-systemId"];
1894
+ }
1895
+ result = instruction;
1896
+ } else {
1897
+ throw new Error(`Not implemented yet: ${node.type}`);
1898
+ }
1899
+ result.startIndex = node.startIndex;
1900
+ result.endIndex = node.endIndex;
1901
+ if (node.sourceCodeLocation != null) {
1902
+ result.sourceCodeLocation = node.sourceCodeLocation;
1903
+ }
1904
+ return result;
1905
+ }
1906
+ function cloneChildren(childs) {
1907
+ const children = childs.map((child) => cloneNode(child, true));
1908
+ for (let index = 1; index < children.length; index++) {
1909
+ children[index].prev = children[index - 1];
1910
+ children[index - 1].next = children[index];
1911
+ }
1912
+ return children;
1913
+ }
1914
+ var Node, DataNode, Text2, Comment2, ProcessingInstruction, NodeWithChildren, CDATA2, Document, Element;
1915
+ var init_node = __esm({
1916
+ "node_modules/domhandler/dist/node.js"() {
1917
+ "use strict";
1918
+ init_dist();
1919
+ Node = class {
1920
+ /** Parent of the node */
1921
+ parent = null;
1922
+ /** Previous sibling */
1923
+ prev = null;
1924
+ /** Next sibling */
1925
+ next = null;
1926
+ /** The start index of the node. Requires `withStartIndices` on the handler to be `true. */
1927
+ startIndex = null;
1928
+ /** The end index of the node. Requires `withEndIndices` on the handler to be `true. */
1929
+ endIndex = null;
1930
+ // Read-write aliases for properties
1931
+ /**
1932
+ * Same as {@link parent}.
1933
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
1934
+ */
1935
+ get parentNode() {
1936
+ return this.parent;
1937
+ }
1938
+ set parentNode(parent) {
1939
+ this.parent = parent;
1940
+ }
1941
+ /**
1942
+ * Same as {@link prev}.
1943
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
1944
+ */
1945
+ get previousSibling() {
1946
+ return this.prev;
1947
+ }
1948
+ set previousSibling(previous) {
1949
+ this.prev = previous;
1950
+ }
1951
+ /**
1952
+ * Same as {@link next}.
1953
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
1954
+ */
1955
+ get nextSibling() {
1956
+ return this.next;
1957
+ }
1958
+ set nextSibling(next) {
1959
+ this.next = next;
1960
+ }
1961
+ /**
1962
+ * Clone this node, and optionally its children.
1963
+ * @param recursive Clone child nodes as well.
1964
+ * @returns A clone of the node.
1965
+ */
1966
+ cloneNode(recursive = false) {
1967
+ return cloneNode(this, recursive);
1968
+ }
1969
+ };
1970
+ DataNode = class extends Node {
1971
+ data;
1972
+ /**
1973
+ * @param data The content of the data node
1974
+ */
1975
+ constructor(data) {
1976
+ super();
1977
+ this.data = data;
1978
+ }
1979
+ /**
1980
+ * Same as {@link data}.
1981
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
1982
+ */
1983
+ get nodeValue() {
1984
+ return this.data;
1985
+ }
1986
+ set nodeValue(data) {
1987
+ this.data = data;
1988
+ }
1989
+ };
1990
+ Text2 = class extends DataNode {
1991
+ type = ElementType.Text;
1992
+ get nodeType() {
1993
+ return 3;
1994
+ }
1995
+ };
1996
+ Comment2 = class extends DataNode {
1997
+ type = ElementType.Comment;
1998
+ get nodeType() {
1999
+ return 8;
2000
+ }
2001
+ };
2002
+ ProcessingInstruction = class extends DataNode {
2003
+ type = ElementType.Directive;
2004
+ name;
2005
+ constructor(name, data) {
2006
+ super(data);
2007
+ this.name = name;
2008
+ }
2009
+ get nodeType() {
2010
+ return 1;
2011
+ }
2012
+ /** If this is a doctype, the document type name (parse5 only). */
2013
+ "x-name";
2014
+ /** If this is a doctype, the document type public identifier (parse5 only). */
2015
+ "x-publicId";
2016
+ /** If this is a doctype, the document type system identifier (parse5 only). */
2017
+ "x-systemId";
2018
+ };
2019
+ NodeWithChildren = class extends Node {
2020
+ children;
2021
+ /**
2022
+ * @param children Children of the node. Only certain node types can have children.
2023
+ */
2024
+ constructor(children) {
2025
+ super();
2026
+ this.children = children;
2027
+ }
2028
+ // Aliases
2029
+ /** First child of the node. */
2030
+ get firstChild() {
2031
+ return this.children[0] ?? null;
2032
+ }
2033
+ /** Last child of the node. */
2034
+ get lastChild() {
2035
+ return this.children.length > 0 ? this.children[this.children.length - 1] : null;
2036
+ }
2037
+ /**
2038
+ * Same as {@link children}.
2039
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
2040
+ */
2041
+ get childNodes() {
2042
+ return this.children;
2043
+ }
2044
+ set childNodes(children) {
2045
+ this.children = children;
2046
+ }
2047
+ };
2048
+ CDATA2 = class extends NodeWithChildren {
2049
+ type = ElementType.CDATA;
2050
+ get nodeType() {
2051
+ return 4;
2052
+ }
2053
+ };
2054
+ Document = class extends NodeWithChildren {
2055
+ type = ElementType.Root;
2056
+ get nodeType() {
2057
+ return 9;
2058
+ }
2059
+ };
2060
+ Element = class extends NodeWithChildren {
2061
+ name;
2062
+ attribs;
2063
+ type;
2064
+ /**
2065
+ * @param name Name of the tag, eg. `div`, `span`.
2066
+ * @param attribs Object mapping attribute names to attribute values.
2067
+ * @param children Children of the node.
2068
+ * @param type Node type used for the new node instance.
2069
+ */
2070
+ constructor(name, attribs, children = [], type = name === "script" ? ElementType.Script : name === "style" ? ElementType.Style : ElementType.Tag) {
2071
+ super(children);
2072
+ this.name = name;
2073
+ this.attribs = attribs;
2074
+ this.type = type;
2075
+ }
2076
+ get nodeType() {
2077
+ return 1;
2078
+ }
2079
+ // DOM Level 1 aliases
2080
+ /**
2081
+ * Same as {@link name}.
2082
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
2083
+ */
2084
+ get tagName() {
2085
+ return this.name;
2086
+ }
2087
+ set tagName(name) {
2088
+ this.name = name;
2089
+ }
2090
+ get attributes() {
2091
+ return Object.keys(this.attribs).map((name) => ({
2092
+ name,
2093
+ value: this.attribs[name],
2094
+ namespace: this["x-attribsNamespace"]?.[name],
2095
+ prefix: this["x-attribsPrefix"]?.[name]
2096
+ }));
2097
+ }
2098
+ /** Element namespace (parse5 only). */
2099
+ namespace;
2100
+ /** Element attribute namespaces (parse5 only). */
2101
+ "x-attribsNamespace";
2102
+ /** Element attribute namespace-related prefixes (parse5 only). */
2103
+ "x-attribsPrefix";
2104
+ };
2105
+ }
2106
+ });
2107
+
2108
+ // node_modules/domhandler/dist/index.js
2109
+ var init_dist2 = __esm({
2110
+ "node_modules/domhandler/dist/index.js"() {
2111
+ "use strict";
2112
+ init_node();
2113
+ }
2114
+ });
2115
+
2116
+ // src/server/file-io/docx-apply.ts
2117
+ import JSZip2 from "jszip";
2118
+ import { parseDocument as parseDocument4 } from "htmlparser2";
2119
+ import render from "dom-serializer";
2120
+ function buildOffsetMap(xml, targetOffsets) {
2121
+ const entries = /* @__PURE__ */ new Map();
2122
+ const commentParagraphIds = /* @__PURE__ */ new Map();
2123
+ const hits = [];
2124
+ const { totalLength, flatText } = walkDocumentBody(xml, {
2125
+ onText(hit) {
2126
+ hits.push(hit);
2127
+ },
2128
+ onCommentStart(hit) {
2129
+ if (hit.paragraphId) {
2130
+ commentParagraphIds.set(hit.commentId, hit.paragraphId);
2131
+ }
2132
+ }
2133
+ });
2134
+ for (const offset of targetOffsets) {
2135
+ if (offset === totalLength && hits.length > 0) {
2136
+ const lastHit = hits[hits.length - 1];
2137
+ entries.set(offset, {
2138
+ run: lastHit.run,
2139
+ textNode: lastHit.textNode,
2140
+ charIndex: lastHit.text.length,
2141
+ paragraph: lastHit.paragraph,
2142
+ paragraphId: lastHit.paragraphId
2143
+ });
2144
+ continue;
2145
+ }
2146
+ for (let i = 0; i < hits.length; i++) {
2147
+ const hit = hits[i];
2148
+ const start = hit.offsetStart;
2149
+ const end = start + hit.text.length;
2150
+ if (offset >= start && offset < end) {
2151
+ entries.set(offset, {
2152
+ run: hit.run,
2153
+ textNode: hit.textNode,
2154
+ charIndex: offset - start,
2155
+ paragraph: hit.paragraph,
2156
+ paragraphId: hit.paragraphId
2157
+ });
2158
+ break;
2159
+ }
2160
+ if (offset === end) {
2161
+ const nextHit = hits[i + 1];
2162
+ if (!nextHit || nextHit.offsetStart !== offset) {
2163
+ entries.set(offset, {
2164
+ run: hit.run,
2165
+ textNode: hit.textNode,
2166
+ charIndex: hit.text.length,
2167
+ paragraph: hit.paragraph,
2168
+ paragraphId: hit.paragraphId
2169
+ });
2170
+ break;
2171
+ }
2172
+ }
2173
+ }
2174
+ }
2175
+ let walkerBody;
2176
+ let walkerDoc;
2177
+ if (hits.length > 0) {
2178
+ let node = hits[0].paragraph.parent;
2179
+ while (node && isElement2(node) && node.name !== "w:body") {
2180
+ node = node.parent;
2181
+ }
2182
+ walkerBody = node;
2183
+ if (!walkerBody || walkerBody.name !== "w:body") {
2184
+ throw new Error("Could not recover w:body from walker's parsed DOM");
2185
+ }
2186
+ walkerDoc = walkerBody.parent;
2187
+ } else {
2188
+ const doc = parseDocument4(xml, { xmlMode: true });
2189
+ const bodyElements = findAllByName("w:body", doc.children);
2190
+ walkerBody = bodyElements[0] ?? new Element("w:body", {});
2191
+ walkerDoc = doc;
2192
+ }
2193
+ return {
2194
+ get(offset) {
2195
+ return entries.get(offset);
2196
+ },
2197
+ flatText,
2198
+ totalLength,
2199
+ body: walkerBody,
2200
+ doc: walkerDoc,
2201
+ commentParagraphIds
2202
+ };
2203
+ }
2204
+ function getNodeText(node) {
2205
+ for (const child of node.children) {
2206
+ if (child.type === "text") return child.data;
2207
+ }
2208
+ return "";
2209
+ }
2210
+ function setNodeText(node, text) {
2211
+ const textChild = new Text2(text);
2212
+ textChild.parent = node;
2213
+ node.children = [textChild];
2214
+ if (text.startsWith(" ") || text.endsWith(" ")) {
2215
+ node.attribs["xml:space"] = "preserve";
2216
+ } else {
2217
+ delete node.attribs["xml:space"];
2218
+ }
2219
+ }
2220
+ function cloneRPr(rPr) {
2221
+ const serialized = render(rPr, { xmlMode: true });
2222
+ const doc = parseDocument4(serialized, { xmlMode: true });
2223
+ return doc.children[0];
2224
+ }
2225
+ function findRPr(run) {
2226
+ for (const child of run.children) {
2227
+ if (isElement2(child) && child.name === "w:rPr") return child;
2228
+ }
2229
+ return void 0;
2230
+ }
2231
+ function buildRun(textElementName, text, rPrSource) {
2232
+ const children = [];
2233
+ if (rPrSource) {
2234
+ const cloned = cloneRPr(rPrSource);
2235
+ children.push(cloned);
2236
+ }
2237
+ const textChild = new Text2(text);
2238
+ const attribs = {};
2239
+ if (text.startsWith(" ") || text.endsWith(" ")) {
2240
+ attribs["xml:space"] = "preserve";
2241
+ }
2242
+ const textNode = new Element(textElementName, attribs, [textChild]);
2243
+ textChild.parent = textNode;
2244
+ children.push(textNode);
2245
+ const run = new Element("w:r", {}, children);
2246
+ for (const child of children) {
2247
+ child.parent = run;
2248
+ }
2249
+ return run;
2250
+ }
2251
+ function insertChild(parent, index, node) {
2252
+ parent.children.splice(index, 0, node);
2253
+ node.parent = parent;
2254
+ }
2255
+ function removeChild(node) {
2256
+ if (!node.parent) return;
2257
+ const parent = node.parent;
2258
+ const idx = parent.children.indexOf(node);
2259
+ if (idx >= 0) parent.children.splice(idx, 1);
2260
+ node.parent = null;
2261
+ }
2262
+ function applySingleSuggestion(offsetMap, suggestion) {
2263
+ const { from, to, newText, author, date, revisionId } = suggestion;
2264
+ const fromEntry = offsetMap.get(from);
2265
+ const toEntry = offsetMap.get(to);
2266
+ if (!fromEntry || !toEntry) {
2267
+ return { ok: false, reason: `Could not resolve offsets: from=${from} to=${to}` };
2268
+ }
2269
+ if (fromEntry.paragraph !== toEntry.paragraph) {
2270
+ return { ok: false, reason: "Cross-paragraph suggestions not yet supported" };
2271
+ }
2272
+ const paragraph = fromEntry.paragraph;
2273
+ if (fromEntry.charIndex > 0) {
2274
+ splitRun(fromEntry.run, fromEntry.textNode, fromEntry.charIndex, paragraph);
2275
+ const idx = paragraph.children.indexOf(fromEntry.run);
2276
+ const nextRun = paragraph.children[idx + 1];
2277
+ if (!nextRun || !isElement2(nextRun) || nextRun.name !== "w:r") {
2278
+ return { ok: false, reason: "Split failed: no next run after from-split" };
2279
+ }
2280
+ fromEntry.run = nextRun;
2281
+ const nextTextNode = findTextNode(nextRun);
2282
+ if (!nextTextNode) {
2283
+ return { ok: false, reason: "Run contains no text element" };
2284
+ }
2285
+ fromEntry.textNode = nextTextNode;
2286
+ fromEntry.charIndex = 0;
2287
+ }
2288
+ const toText = getNodeText(toEntry.textNode);
2289
+ if (toEntry.charIndex < toText.length && toEntry.charIndex > 0) {
2290
+ splitRun(toEntry.run, toEntry.textNode, toEntry.charIndex, paragraph);
2291
+ } else if (toEntry.charIndex === 0) {
2292
+ }
2293
+ const runsToDelete = [];
2294
+ let collecting = false;
2295
+ for (const child of paragraph.children) {
2296
+ if (!isElement2(child) || child.name !== "w:r") {
2297
+ if (collecting && child === toEntry.run) break;
2298
+ continue;
2299
+ }
2300
+ if (child === fromEntry.run) {
2301
+ collecting = true;
2302
+ }
2303
+ if (collecting) {
2304
+ if (toEntry.charIndex === 0 && child === toEntry.run) {
2305
+ break;
2306
+ }
2307
+ runsToDelete.push(child);
2308
+ if (child === toEntry.run) {
2309
+ break;
2310
+ }
2311
+ }
2312
+ }
2313
+ if (runsToDelete.length === 0) {
2314
+ if (from === to && newText.length > 0) {
2315
+ const insertionPoint = paragraph.children.indexOf(fromEntry.run);
2316
+ const rPr = findRPr(fromEntry.run);
2317
+ const insRun = buildRun("w:t", newText, rPr);
2318
+ const ins2 = new Element(
2319
+ "w:ins",
2320
+ {
2321
+ "w:id": String(revisionId + 1),
2322
+ "w:author": author,
2323
+ "w:date": date
2324
+ },
2325
+ [insRun]
2326
+ );
2327
+ insRun.parent = ins2;
2328
+ insertChild(paragraph, insertionPoint, ins2);
2329
+ return { ok: true };
2330
+ }
2331
+ return { ok: false, reason: "No runs found in deletion range" };
2332
+ }
2333
+ const rPrSource = findRPr(runsToDelete[0]);
2334
+ const delChildren = [];
2335
+ for (const run of runsToDelete) {
2336
+ const tn = findTextNode(run);
2337
+ if (!tn) continue;
2338
+ const text = getNodeText(tn);
2339
+ const delRun = buildRun("w:delText", text, findRPr(run));
2340
+ delChildren.push(delRun);
2341
+ }
2342
+ const del = new Element(
2343
+ "w:del",
2344
+ {
2345
+ "w:id": String(revisionId),
2346
+ "w:author": author,
2347
+ "w:date": date
2348
+ },
2349
+ delChildren
2350
+ );
2351
+ for (const child of delChildren) {
2352
+ child.parent = del;
2353
+ }
2354
+ let ins;
2355
+ if (newText.length > 0) {
2356
+ const insRun = buildRun("w:t", newText, rPrSource);
2357
+ ins = new Element(
2358
+ "w:ins",
2359
+ {
2360
+ "w:id": String(revisionId + 1),
2361
+ "w:author": author,
2362
+ "w:date": date
2363
+ },
2364
+ [insRun]
2365
+ );
2366
+ insRun.parent = ins;
2367
+ }
2368
+ const firstRunIndex = paragraph.children.indexOf(runsToDelete[0]);
2369
+ for (const run of runsToDelete) {
2370
+ removeChild(run);
2371
+ }
2372
+ insertChild(paragraph, firstRunIndex, del);
2373
+ if (ins) {
2374
+ insertChild(paragraph, firstRunIndex + 1, ins);
2375
+ }
2376
+ return { ok: true };
2377
+ }
2378
+ function splitRun(run, textNode, charIndex, paragraph) {
2379
+ const fullText = getNodeText(textNode);
2380
+ const before = fullText.slice(0, charIndex);
2381
+ const after = fullText.slice(charIndex);
2382
+ setNodeText(textNode, before);
2383
+ const rPr = findRPr(run);
2384
+ const newRun = buildRun("w:t", after, rPr);
2385
+ const idx = paragraph.children.indexOf(run);
2386
+ insertChild(paragraph, idx + 1, newRun);
2387
+ }
2388
+ function findTextNode(run) {
2389
+ for (const child of run.children) {
2390
+ if (isElement2(child) && child.name === "w:t") return child;
2391
+ }
2392
+ return void 0;
2393
+ }
2394
+ async function applyTrackedChanges(docxBuffer, suggestions, options) {
2395
+ const zip = await JSZip2.loadAsync(docxBuffer);
2396
+ const documentXml = await zip.file("word/document.xml")?.async("text");
2397
+ if (!documentXml) {
2398
+ throw new Error("Missing word/document.xml in .docx archive");
2399
+ }
2400
+ const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString();
2401
+ const targetOffsets = /* @__PURE__ */ new Set();
2402
+ for (const s of suggestions) {
2403
+ targetOffsets.add(s.from);
2404
+ targetOffsets.add(s.to);
2405
+ }
2406
+ const offsetMap = buildOffsetMap(documentXml, targetOffsets);
2407
+ if (offsetMap.flatText !== options.ydocFlatText) {
2408
+ throw new Error(
2409
+ "Flat text mismatch: the .docx content does not match the Y.Doc flat text. The file may have changed since it was loaded."
2410
+ );
2411
+ }
2412
+ const sorted = [...suggestions].sort((a, b) => b.from - a.from);
2413
+ const valid = [];
2414
+ const rejectedDetails = [];
2415
+ for (const s of sorted) {
2416
+ if (s.textSnapshot !== void 0) {
2417
+ const actual = offsetMap.flatText.slice(s.from, s.to);
2418
+ if (actual !== s.textSnapshot) {
2419
+ rejectedDetails.push({
2420
+ id: s.id,
2421
+ reason: `Text snapshot mismatch: expected "${s.textSnapshot}", got "${actual}"`
2422
+ });
2423
+ continue;
2424
+ }
2425
+ }
2426
+ const fromEntry = offsetMap.get(s.from);
2427
+ const toEntry = offsetMap.get(s.to);
2428
+ if (!fromEntry || !toEntry) {
2429
+ rejectedDetails.push({
2430
+ id: s.id,
2431
+ reason: `Could not resolve offsets: from=${s.from} to=${s.to}`
2432
+ });
2433
+ continue;
2434
+ }
2435
+ valid.push(s);
2436
+ }
2437
+ const validAfterOverlapCheck = [];
2438
+ let lastFrom = Infinity;
2439
+ for (const s of valid) {
2440
+ if (s.to > lastFrom) {
2441
+ rejectedDetails.push({
2442
+ id: s.id,
2443
+ reason: `Overlapping range [${s.from}, ${s.to}) conflicts with another suggestion`
2444
+ });
2445
+ continue;
2446
+ }
2447
+ lastFrom = s.from;
2448
+ validAfterOverlapCheck.push(s);
2449
+ }
2450
+ const validAfterComplexCheck = [];
2451
+ for (const s of validAfterOverlapCheck) {
2452
+ const fromEntry = offsetMap.get(s.from);
2453
+ const toEntry = offsetMap.get(s.to);
2454
+ const paragraph = fromEntry.paragraph;
2455
+ let collecting = false;
2456
+ let hasComplex = false;
2457
+ for (const child of paragraph.children) {
2458
+ if (!isElement2(child) || child.name !== "w:r") continue;
2459
+ if (child === fromEntry.run) collecting = true;
2460
+ if (collecting) {
2461
+ for (const rc of child.children) {
2462
+ if (isElement2(rc) && COMPLEX_RUN_ELEMENTS.has(rc.name)) {
2463
+ hasComplex = true;
2464
+ break;
2465
+ }
2466
+ }
2467
+ if (hasComplex) break;
2468
+ if (child === toEntry.run) break;
2469
+ }
2470
+ }
2471
+ if (hasComplex) {
2472
+ rejectedDetails.push({
2473
+ id: s.id,
2474
+ reason: "Overlaps a complex element (footnote, drawing, or field) and couldn't be applied"
2475
+ });
2476
+ } else {
2477
+ validAfterComplexCheck.push(s);
2478
+ }
2479
+ }
2480
+ const validAfterRunCheck = [];
2481
+ const claimedRuns = /* @__PURE__ */ new Map();
2482
+ for (const s of validAfterComplexCheck) {
2483
+ const fromEntry = offsetMap.get(s.from);
2484
+ const toEntry = offsetMap.get(s.to);
2485
+ const paragraph = fromEntry.paragraph;
2486
+ const touchedRuns = [];
2487
+ let collecting = false;
2488
+ for (const child of paragraph.children) {
2489
+ if (!isElement2(child) || child.name !== "w:r") continue;
2490
+ if (child === fromEntry.run) collecting = true;
2491
+ if (collecting) {
2492
+ touchedRuns.push(child);
2493
+ if (child === toEntry.run) break;
2494
+ }
2495
+ }
2496
+ let conflict = false;
2497
+ for (const run of touchedRuns) {
2498
+ if (claimedRuns.has(run)) {
2499
+ rejectedDetails.push({
2500
+ id: s.id,
2501
+ reason: "Targets the same text run as another suggestion"
2502
+ });
2503
+ conflict = true;
2504
+ break;
2505
+ }
2506
+ }
2507
+ if (!conflict) {
2508
+ for (const run of touchedRuns) {
2509
+ claimedRuns.set(run, s.id);
2510
+ }
2511
+ validAfterRunCheck.push(s);
2512
+ }
2513
+ }
2514
+ const doc = offsetMap.doc;
2515
+ const idMatches = documentXml.match(/w:id="(\d+)"/g) || [];
2516
+ let maxId = 0;
2517
+ for (const m of idMatches) {
2518
+ const num = parseInt(m.match(/\d+/)[0], 10);
2519
+ if (num > maxId) maxId = num;
2520
+ }
2521
+ let applied = 0;
2522
+ const appliedSuggestions = [];
2523
+ for (const s of validAfterRunCheck) {
2524
+ maxId += 2;
2525
+ const result = applySingleSuggestion(offsetMap, {
2526
+ from: s.from,
2527
+ to: s.to,
2528
+ newText: s.newText,
2529
+ author: options.author,
2530
+ date,
2531
+ revisionId: maxId - 1
2532
+ });
2533
+ if (result.ok) {
2534
+ applied++;
2535
+ appliedSuggestions.push(s);
2536
+ } else {
2537
+ rejectedDetails.push({ id: s.id, reason: result.reason ?? "Unknown error" });
2538
+ }
2539
+ }
2540
+ const serialized = render(doc, { xmlMode: true });
2541
+ zip.file("word/document.xml", serialized);
2542
+ const commentsResolved = await resolveWordComments(
2543
+ zip,
2544
+ offsetMap.commentParagraphIds,
2545
+ appliedSuggestions
2546
+ );
2547
+ const buffer3 = Buffer.from(await zip.generateAsync({ type: "nodebuffer" }));
2548
+ return {
2549
+ buffer: buffer3,
2550
+ applied,
2551
+ rejected: rejectedDetails.length,
2552
+ rejectedDetails,
2553
+ commentsResolved
2554
+ };
2555
+ }
2556
+ async function resolveWordComments(zip, commentParagraphIds, appliedSuggestions) {
2557
+ const toResolve = [];
2558
+ for (const s of appliedSuggestions) {
2559
+ if (!s.importCommentId) continue;
2560
+ const paraId = commentParagraphIds.get(s.importCommentId);
2561
+ if (!paraId) {
2562
+ console.warn(`[docx-apply] No paraId for comment ${s.importCommentId}; skipping resolution`);
2563
+ continue;
2564
+ }
2565
+ toResolve.push({ commentId: s.importCommentId, paraId });
2566
+ }
2567
+ if (toResolve.length === 0) return 0;
2568
+ const seen = /* @__PURE__ */ new Set();
2569
+ const unique = toResolve.filter((r) => {
2570
+ if (seen.has(r.commentId)) return false;
2571
+ seen.add(r.commentId);
2572
+ return true;
2573
+ });
2574
+ const existingXml = await zip.file("word/commentsExtended.xml")?.async("text");
2575
+ if (existingXml) {
2576
+ const doc = parseDocument4(existingXml, { xmlMode: true });
2577
+ const root = doc.children.find((c) => isElement2(c) && c.name === "w15:commentsEx");
2578
+ if (root) {
2579
+ const existingParaIds = /* @__PURE__ */ new Set();
2580
+ for (const child of root.children) {
2581
+ if (isElement2(child) && child.name === "w15:commentEx") {
2582
+ const pid = getAttr(child, "w15:paraId");
2583
+ if (pid) existingParaIds.add(pid);
2584
+ }
2585
+ }
2586
+ for (const { paraId } of unique) {
2587
+ if (existingParaIds.has(paraId)) continue;
2588
+ const entry = new Element("w15:commentEx", {
2589
+ "w15:paraId": paraId,
2590
+ "w15:done": "1"
2591
+ });
2592
+ insertChild(root, root.children.length, entry);
2593
+ }
2594
+ zip.file("word/commentsExtended.xml", render(doc, { xmlMode: true }));
2595
+ }
2596
+ } else {
2597
+ const entries = unique.map((r) => `<w15:commentEx w15:paraId="${r.paraId}" w15:done="1"/>`).join("");
2598
+ const newXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:commentsEx xmlns:w15="${W15_NS}">${entries}</w15:commentsEx>`;
2599
+ zip.file("word/commentsExtended.xml", newXml);
2600
+ const relsXml = await zip.file("word/_rels/document.xml.rels")?.async("text");
2601
+ if (relsXml) {
2602
+ const relsDoc = parseDocument4(relsXml, { xmlMode: true });
2603
+ const relsRoot = relsDoc.children.find((c) => isElement2(c) && c.name === "Relationships");
2604
+ if (relsRoot) {
2605
+ const existingIds = findAllByName("Relationship", relsRoot.children).map((r) => getAttr(r, "Id") || "").filter((id) => id.startsWith("rId")).map((id) => parseInt(id.slice(3), 10)).filter((n) => !isNaN(n));
2606
+ const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 100;
2607
+ const rel = new Element("Relationship", {
2608
+ Id: `rId${nextId}`,
2609
+ Type: "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
2610
+ Target: "commentsExtended.xml"
2611
+ });
2612
+ insertChild(relsRoot, relsRoot.children.length, rel);
2613
+ zip.file("word/_rels/document.xml.rels", render(relsDoc, { xmlMode: true }));
2614
+ }
2615
+ }
2616
+ const ctXml = await zip.file("[Content_Types].xml")?.async("text");
2617
+ if (ctXml) {
2618
+ const ctDoc = parseDocument4(ctXml, { xmlMode: true });
2619
+ const typesRoot = ctDoc.children.find((c) => isElement2(c) && c.name === "Types");
2620
+ if (typesRoot) {
2621
+ const override = new Element("Override", {
2622
+ PartName: "/word/commentsExtended.xml",
2623
+ ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"
2624
+ });
2625
+ insertChild(typesRoot, typesRoot.children.length, override);
2626
+ zip.file("[Content_Types].xml", render(ctDoc, { xmlMode: true }));
2627
+ }
2628
+ }
2629
+ }
2630
+ return unique.length;
2631
+ }
2632
+ var COMPLEX_RUN_ELEMENTS, W15_NS;
2633
+ var init_docx_apply = __esm({
2634
+ "src/server/file-io/docx-apply.ts"() {
2635
+ "use strict";
2636
+ init_dist2();
2637
+ init_docx_walker();
2638
+ COMPLEX_RUN_ELEMENTS = /* @__PURE__ */ new Set([
2639
+ "w:footnoteReference",
2640
+ "w:endnoteReference",
2641
+ "w:drawing",
2642
+ "w:pict",
2643
+ "w:fldChar"
2644
+ ]);
2645
+ W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml";
1730
2646
  }
1731
2647
  });
1732
2648
 
@@ -1741,6 +2657,17 @@ async function atomicWrite(filePath, content) {
1741
2657
  await fs2.writeFile(tempPath, content, "utf-8");
1742
2658
  await fs2.rename(tempPath, filePath);
1743
2659
  }
2660
+ async function atomicWriteBuffer(filePath, content) {
2661
+ const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
2662
+ await fs2.writeFile(tempPath, content);
2663
+ try {
2664
+ await fs2.rename(tempPath, filePath);
2665
+ } catch (err) {
2666
+ await fs2.unlink(tempPath).catch(() => {
2667
+ });
2668
+ throw err;
2669
+ }
2670
+ }
1744
2671
  var markdownAdapter, plaintextAdapter, docxAdapter, adapters;
1745
2672
  var init_file_io = __esm({
1746
2673
  "src/server/file-io/index.ts"() {
@@ -1749,6 +2676,7 @@ var init_file_io = __esm({
1749
2676
  init_docx();
1750
2677
  init_docx_comments();
1751
2678
  init_document_model();
2679
+ init_docx_apply();
1752
2680
  markdownAdapter = {
1753
2681
  canSave: true,
1754
2682
  load(doc, content) {
@@ -2355,15 +3283,16 @@ function attachObservers(docName, doc) {
2355
3283
  if (txn.origin === MCP_ORIGIN) return;
2356
3284
  if (event.keysChanged.has("selection")) {
2357
3285
  const selection = userAwareness.get("selection");
3286
+ if (!selection || selection.from === selection.to) return;
2358
3287
  pushEvent({
2359
3288
  id: generateEventId(),
2360
3289
  type: "selection:changed",
2361
3290
  timestamp: Date.now(),
2362
3291
  documentId: docName,
2363
3292
  payload: {
2364
- from: selection?.from ?? 0,
2365
- to: selection?.to ?? 0,
2366
- selectedText: selection?.from !== selection?.to ? selection?.selectedText ?? "" : ""
3293
+ from: selection.from,
3294
+ to: selection.to,
3295
+ selectedText: selection.selectedText ?? ""
2367
3296
  }
2368
3297
  });
2369
3298
  }
@@ -2580,7 +3509,7 @@ var init_launcher = __esm({
2580
3509
  });
2581
3510
 
2582
3511
  // src/server/index.ts
2583
- import path8 from "path";
3512
+ import path9 from "path";
2584
3513
  import { fileURLToPath as fileURLToPath2 } from "url";
2585
3514
 
2586
3515
  // src/server/mcp/server.ts
@@ -3578,6 +4507,181 @@ init_constants();
3578
4507
  init_document_model();
3579
4508
  init_file_opener();
3580
4509
  init_document_service();
4510
+
4511
+ // src/server/mcp/docx-apply.ts
4512
+ init_document_service();
4513
+ init_constants();
4514
+ init_positions2();
4515
+ init_document_model();
4516
+ init_file_io();
4517
+ import { z as z4 } from "zod";
4518
+ import fs5 from "fs/promises";
4519
+ import path8 from "path";
4520
+ async function applyChangesCore(documentId, author, backupPath) {
4521
+ const r = requireDocument(documentId);
4522
+ if (!r) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
4523
+ const { doc: ydoc, filePath } = r;
4524
+ const docState = getCurrentDoc(documentId);
4525
+ if (!docState) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
4526
+ if (docState.format !== "docx") {
4527
+ throw Object.assign(
4528
+ new Error(`Apply changes is only supported for .docx files (this is ${docState.format}).`),
4529
+ { code: "UNSUPPORTED_FORMAT" }
4530
+ );
4531
+ }
4532
+ if (docState.source !== "file") {
4533
+ throw Object.assign(new Error("Cannot apply changes to uploaded files. Save to disk first."), {
4534
+ code: "INVALID_PATH"
4535
+ });
4536
+ }
4537
+ const map = ydoc.getMap(Y_MAP_ANNOTATIONS);
4538
+ const suggestions = [];
4539
+ let pendingCount = 0;
4540
+ for (const [, raw] of map) {
4541
+ const ann = raw;
4542
+ if (ann.type !== "suggestion") continue;
4543
+ if (ann.status === "pending") {
4544
+ pendingCount++;
4545
+ continue;
4546
+ }
4547
+ if (ann.status !== "accepted") continue;
4548
+ let from = ann.range.from;
4549
+ let to = ann.range.to;
4550
+ if (ann.relRange) {
4551
+ const resolvedFrom = relPosToFlatOffset(ydoc, ann.relRange.fromRel);
4552
+ const resolvedTo = relPosToFlatOffset(ydoc, ann.relRange.toRel);
4553
+ if (resolvedFrom !== null && resolvedTo !== null) {
4554
+ if (resolvedFrom > resolvedTo) {
4555
+ console.error(
4556
+ `[docx-apply] Inverted CRDT range for ${ann.id}: [${resolvedFrom}, ${resolvedTo}]; skipping`
4557
+ );
4558
+ continue;
4559
+ }
4560
+ from = resolvedFrom;
4561
+ to = resolvedTo;
4562
+ }
4563
+ }
4564
+ let newText = "";
4565
+ try {
4566
+ const parsed = JSON.parse(ann.content);
4567
+ newText = parsed.newText;
4568
+ } catch {
4569
+ newText = ann.content;
4570
+ }
4571
+ let importCommentId;
4572
+ if (ann.id.startsWith("import-")) {
4573
+ const withoutPrefix = ann.id.slice("import-".length);
4574
+ const lastDash = withoutPrefix.lastIndexOf("-");
4575
+ if (lastDash > 0) {
4576
+ importCommentId = withoutPrefix.slice(0, lastDash);
4577
+ }
4578
+ }
4579
+ suggestions.push({
4580
+ id: ann.id,
4581
+ from,
4582
+ to,
4583
+ newText,
4584
+ textSnapshot: ann.textSnapshot,
4585
+ importCommentId
4586
+ });
4587
+ }
4588
+ if (suggestions.length === 0) {
4589
+ throw Object.assign(new Error("No accepted suggestions to apply."), { code: "NO_SUGGESTIONS" });
4590
+ }
4591
+ const ydocFlatText = extractText(ydoc);
4592
+ const buffer3 = await fs5.readFile(filePath);
4593
+ const result = await applyTrackedChanges(buffer3, suggestions, {
4594
+ author: author ?? "Tandem Review",
4595
+ ydocFlatText
4596
+ });
4597
+ let resolvedBackup = backupPath ?? filePath.replace(/\.docx$/i, ".backup.docx");
4598
+ try {
4599
+ await fs5.access(resolvedBackup);
4600
+ const ext = path8.extname(resolvedBackup);
4601
+ const base = resolvedBackup.slice(0, -ext.length);
4602
+ resolvedBackup = `${base}-${Date.now()}${ext}`;
4603
+ } catch {
4604
+ }
4605
+ await fs5.copyFile(filePath, resolvedBackup);
4606
+ const [origStat, backupStat] = await Promise.all([fs5.stat(filePath), fs5.stat(resolvedBackup)]);
4607
+ if (origStat.size !== backupStat.size) {
4608
+ throw Object.assign(new Error("Backup verification failed: file sizes do not match."), {
4609
+ code: "BACKUP_FAILED"
4610
+ });
4611
+ }
4612
+ await atomicWriteBuffer(filePath, result.buffer);
4613
+ const output = {
4614
+ applied: result.applied,
4615
+ rejected: result.rejected,
4616
+ rejectedDetails: result.rejectedDetails,
4617
+ commentsResolved: result.commentsResolved,
4618
+ backupPath: resolvedBackup
4619
+ };
4620
+ if (pendingCount > 0) {
4621
+ output.pendingWarning = `${pendingCount} suggestion(s) are still pending review and were not applied.`;
4622
+ }
4623
+ return output;
4624
+ }
4625
+ function registerApplyTools(server) {
4626
+ server.tool(
4627
+ "tandem_applyChanges",
4628
+ "Apply all accepted suggestions to the .docx file as tracked changes (w:del + w:ins). Creates a backup before writing. Only works on .docx files opened from disk.",
4629
+ {
4630
+ documentId: z4.string().optional().describe("Target document ID (defaults to active doc)"),
4631
+ author: z4.string().optional().describe("Author name for tracked changes (default: 'Tandem Review')"),
4632
+ backupPath: z4.string().optional().describe("Custom backup path (default: {name}.backup.docx)")
4633
+ },
4634
+ withErrorBoundary("tandem_applyChanges", async (args) => {
4635
+ try {
4636
+ const result = await applyChangesCore(args.documentId, args.author, args.backupPath);
4637
+ return mcpSuccess(result);
4638
+ } catch (err) {
4639
+ const e = err;
4640
+ if (e.code === "NO_DOCUMENT") return noDocumentError();
4641
+ if (e.code === "NO_SUGGESTIONS") return mcpError("NO_SUGGESTIONS", e.message);
4642
+ if (e.code === "UNSUPPORTED_FORMAT") return mcpError("FORMAT_ERROR", e.message);
4643
+ if (e.code === "INVALID_PATH") return mcpError("FORMAT_ERROR", e.message);
4644
+ if (e.code === "BACKUP_FAILED") return mcpError("BACKUP_FAILED", e.message);
4645
+ throw err;
4646
+ }
4647
+ })
4648
+ );
4649
+ server.tool(
4650
+ "tandem_restoreBackup",
4651
+ "Restore a .docx file from its backup ({name}.backup.docx). Use after tandem_applyChanges if the result is unsatisfactory.",
4652
+ {
4653
+ documentId: z4.string().optional().describe("Target document ID (defaults to active doc)")
4654
+ },
4655
+ withErrorBoundary("tandem_restoreBackup", async (args) => {
4656
+ const r = requireDocument(args.documentId);
4657
+ if (!r) return noDocumentError();
4658
+ const { filePath } = r;
4659
+ const backupPath = filePath.replace(/\.docx$/i, ".backup.docx");
4660
+ try {
4661
+ await fs5.access(backupPath);
4662
+ } catch (err) {
4663
+ if (err.code === "ENOENT") {
4664
+ return mcpError("FILE_NOT_FOUND", `No backup file found at ${backupPath}`);
4665
+ }
4666
+ throw err;
4667
+ }
4668
+ await fs5.copyFile(backupPath, filePath);
4669
+ const [backupStat, restoredStat] = await Promise.all([
4670
+ fs5.stat(backupPath),
4671
+ fs5.stat(filePath)
4672
+ ]);
4673
+ if (backupStat.size !== restoredStat.size) {
4674
+ throw new Error("Restore verification failed: file sizes do not match.");
4675
+ }
4676
+ return mcpSuccess({
4677
+ message: `Restored ${path8.basename(filePath)} from backup.`,
4678
+ restoredFrom: backupPath
4679
+ });
4680
+ })
4681
+ );
4682
+ }
4683
+
4684
+ // src/server/mcp/api-routes.ts
3581
4685
  function isHostAllowed(host) {
3582
4686
  const reqHost = (host ?? "").split(":")[0];
3583
4687
  return reqHost === "localhost" || reqHost === "127.0.0.1";
@@ -3590,9 +4694,11 @@ function errorCodeToHttpStatus(code) {
3590
4694
  switch (code) {
3591
4695
  case "ENOENT":
3592
4696
  case "FILE_NOT_FOUND":
4697
+ case "NO_DOCUMENT":
3593
4698
  return 404;
3594
4699
  case "INVALID_PATH":
3595
4700
  case "UNSUPPORTED_FORMAT":
4701
+ case "NO_SUGGESTIONS":
3596
4702
  return 400;
3597
4703
  case "FILE_TOO_LARGE":
3598
4704
  return 413;
@@ -3601,6 +4707,8 @@ function errorCodeToHttpStatus(code) {
3601
4707
  return 423;
3602
4708
  case "EACCES":
3603
4709
  return 403;
4710
+ case "BACKUP_FAILED":
4711
+ return 500;
3604
4712
  default:
3605
4713
  return 500;
3606
4714
  }
@@ -3624,11 +4732,13 @@ function errorCodeToLabel(code) {
3624
4732
  switch (code) {
3625
4733
  case "ENOENT":
3626
4734
  case "FILE_NOT_FOUND":
3627
- return "FILE_NOT_FOUND";
4735
+ case "NO_DOCUMENT":
4736
+ return "NOT_FOUND";
3628
4737
  case "INVALID_PATH":
3629
4738
  return "INVALID_PATH";
3630
4739
  case "UNSUPPORTED_FORMAT":
3631
- return "UNSUPPORTED_FORMAT";
4740
+ case "NO_SUGGESTIONS":
4741
+ return "BAD_REQUEST";
3632
4742
  case "FILE_TOO_LARGE":
3633
4743
  return "FILE_TOO_LARGE";
3634
4744
  case "EBUSY":
@@ -3636,6 +4746,8 @@ function errorCodeToLabel(code) {
3636
4746
  return "FILE_LOCKED";
3637
4747
  case "EACCES":
3638
4748
  return "PERMISSION_DENIED";
4749
+ case "BACKUP_FAILED":
4750
+ return "INTERNAL";
3639
4751
  default:
3640
4752
  return "INTERNAL";
3641
4753
  }
@@ -3767,11 +4879,37 @@ function registerApiRoutes(app, largeBody) {
3767
4879
  sendApiError(res, err);
3768
4880
  }
3769
4881
  });
4882
+ app.options("/api/apply-changes", apiMiddleware);
4883
+ app.post("/api/apply-changes", apiMiddleware, largeBody, async (req, res) => {
4884
+ const { documentId, author, backupPath } = req.body ?? {};
4885
+ if (documentId !== void 0 && typeof documentId !== "string") {
4886
+ res.status(400).json({ error: "BAD_REQUEST", message: "documentId must be a string" });
4887
+ return;
4888
+ }
4889
+ if (author !== void 0 && typeof author !== "string") {
4890
+ res.status(400).json({ error: "BAD_REQUEST", message: "author must be a string" });
4891
+ return;
4892
+ }
4893
+ if (backupPath !== void 0 && typeof backupPath !== "string") {
4894
+ res.status(400).json({ error: "BAD_REQUEST", message: "backupPath must be a string" });
4895
+ return;
4896
+ }
4897
+ try {
4898
+ const result = await applyChangesCore(
4899
+ documentId,
4900
+ author,
4901
+ backupPath
4902
+ );
4903
+ res.json({ data: result });
4904
+ } catch (err) {
4905
+ sendApiError(res, err);
4906
+ }
4907
+ });
3770
4908
  }
3771
4909
 
3772
4910
  // src/server/mcp/awareness.ts
3773
4911
  init_provider();
3774
- import { z as z4 } from "zod";
4912
+ import { z as z5 } from "zod";
3775
4913
  init_utils();
3776
4914
  init_constants();
3777
4915
  init_queue();
@@ -3781,7 +4919,7 @@ function registerAwarenessTools(server) {
3781
4919
  "tandem_getSelections",
3782
4920
  "Get text currently selected by the user in the editor",
3783
4921
  {
3784
- documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
4922
+ documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
3785
4923
  },
3786
4924
  withErrorBoundary("tandem_getSelections", async ({ documentId }) => {
3787
4925
  const current = getCurrentDoc(documentId);
@@ -3802,7 +4940,7 @@ function registerAwarenessTools(server) {
3802
4940
  "tandem_getActivity",
3803
4941
  "Check if the user is actively editing and where their cursor is",
3804
4942
  {
3805
- documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
4943
+ documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
3806
4944
  },
3807
4945
  withErrorBoundary("tandem_getActivity", async ({ documentId }) => {
3808
4946
  const current = getCurrentDoc(documentId);
@@ -3831,7 +4969,7 @@ function registerAwarenessTools(server) {
3831
4969
  "tandem_checkInbox",
3832
4970
  "Check for user actions you haven't seen yet \u2014 new highlights, comments, questions, flags, and responses to your annotations. Call this after completing any task, between steps, and whenever you pause. Low token cost.",
3833
4971
  {
3834
- documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
4972
+ documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
3835
4973
  },
3836
4974
  withErrorBoundary("tandem_checkInbox", async ({ documentId }) => {
3837
4975
  const current = getCurrentDoc(documentId);
@@ -3924,9 +5062,9 @@ function registerAwarenessTools(server) {
3924
5062
  "tandem_reply",
3925
5063
  "Send a chat message to the user in the Tandem sidebar. Use this to respond to chat messages from tandem_checkInbox.",
3926
5064
  {
3927
- text: z4.string().describe("Your message to the user"),
3928
- replyTo: z4.string().optional().describe("ID of the user message you are replying to"),
3929
- documentId: z4.string().optional().describe("Document context for this reply (defaults to active document)")
5065
+ text: z5.string().describe("Your message to the user"),
5066
+ replyTo: z5.string().optional().describe("ID of the user message you are replying to"),
5067
+ documentId: z5.string().optional().describe("Document context for this reply (defaults to active document)")
3930
5068
  },
3931
5069
  withErrorBoundary("tandem_reply", async ({ text, replyTo, documentId }) => {
3932
5070
  const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
@@ -4120,7 +5258,7 @@ init_constants();
4120
5258
  init_types();
4121
5259
  init_queue();
4122
5260
  init_provider();
4123
- import { z as z5 } from "zod";
5261
+ import { z as z6 } from "zod";
4124
5262
  function getFullText(docName) {
4125
5263
  const doc = getOrCreateDocument(docName);
4126
5264
  return extractText(doc);
@@ -4176,9 +5314,9 @@ function registerNavigationTools(server) {
4176
5314
  "tandem_search",
4177
5315
  "Search for text in the document. Returns matching positions.",
4178
5316
  {
4179
- query: z5.string().describe("Search query (supports regex)"),
4180
- regex: z5.boolean().optional().describe("Treat query as regex"),
4181
- documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
5317
+ query: z6.string().describe("Search query (supports regex)"),
5318
+ regex: z6.boolean().optional().describe("Treat query as regex"),
5319
+ documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
4182
5320
  },
4183
5321
  withErrorBoundary("tandem_search", async ({ query, regex, documentId }) => {
4184
5322
  const current = getCurrentDoc(documentId);
@@ -4193,9 +5331,9 @@ function registerNavigationTools(server) {
4193
5331
  "tandem_resolveRange",
4194
5332
  "Find text and return a valid range. Safer than raw character offsets under concurrent editing.",
4195
5333
  {
4196
- pattern: z5.string().describe("Text to find"),
4197
- occurrence: z5.number().optional().describe("Which occurrence (1-based, default 1)"),
4198
- documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
5334
+ pattern: z6.string().describe("Text to find"),
5335
+ occurrence: z6.number().optional().describe("Which occurrence (1-based, default 1)"),
5336
+ documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
4199
5337
  },
4200
5338
  withErrorBoundary("tandem_resolveRange", async ({ pattern, occurrence = 1, documentId }) => {
4201
5339
  const current = getCurrentDoc(documentId);
@@ -4210,9 +5348,9 @@ function registerNavigationTools(server) {
4210
5348
  "tandem_setStatus",
4211
5349
  'Update Claude status text shown to user (e.g., "Reviewing cost figures..."). Tip: call tandem_checkInbox after completing work to see if the user has responded.',
4212
5350
  {
4213
- text: z5.string().describe("Status text"),
4214
- focusParagraph: z5.number().optional().describe("Index of paragraph Claude is focusing on"),
4215
- documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
5351
+ text: z6.string().describe("Status text"),
5352
+ focusParagraph: z6.number().optional().describe("Index of paragraph Claude is focusing on"),
5353
+ documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
4216
5354
  },
4217
5355
  withErrorBoundary("tandem_setStatus", async ({ text, focusParagraph, documentId }) => {
4218
5356
  const current = getCurrentDoc(documentId);
@@ -4240,10 +5378,10 @@ function registerNavigationTools(server) {
4240
5378
  "tandem_getContext",
4241
5379
  "Read content around a range without pulling the full document. Reduces token usage.",
4242
5380
  {
4243
- from: z5.number().describe("Start position"),
4244
- to: z5.number().describe("End position"),
4245
- windowSize: z5.number().optional().describe("Characters of context before/after (default 500)"),
4246
- documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
5381
+ from: z6.number().describe("Start position"),
5382
+ to: z6.number().describe("End position"),
5383
+ windowSize: z6.number().optional().describe("Characters of context before/after (default 500)"),
5384
+ documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
4247
5385
  },
4248
5386
  withErrorBoundary(
4249
5387
  "tandem_getContext",
@@ -4283,6 +5421,7 @@ function createMcpServer() {
4283
5421
  registerAnnotationTools(server);
4284
5422
  registerNavigationTools(server);
4285
5423
  registerAwarenessTools(server);
5424
+ registerApplyTools(server);
4286
5425
  return server;
4287
5426
  }
4288
5427
  function jsonrpcId(body) {
@@ -4640,8 +5779,8 @@ async function main() {
4640
5779
  ]);
4641
5780
  httpServer = srv;
4642
5781
  if (getOpenDocs().size === 0 && !process.env.TANDEM_NO_SAMPLE) {
4643
- const samplePath = path8.resolve(
4644
- path8.dirname(fileURLToPath2(import.meta.url)),
5782
+ const samplePath = path9.resolve(
5783
+ path9.dirname(fileURLToPath2(import.meta.url)),
4645
5784
  "../../sample/welcome.md"
4646
5785
  );
4647
5786
  openFileByPath(samplePath).then(() => {