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.
- package/LICENSE +21 -21
- package/README.md +95 -126
- package/dist/channel/index.js +37 -0
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +129 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/{index-fcpi1vLr.js → index-D5KAGBBp.js} +55 -46
- package/dist/client/index.html +1 -1
- package/dist/server/index.js +1246 -107
- package/dist/server/index.js.map +1 -1
- package/package.json +2 -1
- package/sample/welcome.md +1 -1
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
if (
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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
|
|
2365
|
-
to: selection
|
|
2366
|
-
selectedText: selection
|
|
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
|
|
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
|
-
|
|
4735
|
+
case "NO_DOCUMENT":
|
|
4736
|
+
return "NOT_FOUND";
|
|
3628
4737
|
case "INVALID_PATH":
|
|
3629
4738
|
return "INVALID_PATH";
|
|
3630
4739
|
case "UNSUPPORTED_FORMAT":
|
|
3631
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
3928
|
-
replyTo:
|
|
3929
|
-
documentId:
|
|
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
|
|
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:
|
|
4180
|
-
regex:
|
|
4181
|
-
documentId:
|
|
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:
|
|
4197
|
-
occurrence:
|
|
4198
|
-
documentId:
|
|
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:
|
|
4214
|
-
focusParagraph:
|
|
4215
|
-
documentId:
|
|
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:
|
|
4244
|
-
to:
|
|
4245
|
-
windowSize:
|
|
4246
|
-
documentId:
|
|
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 =
|
|
4644
|
-
|
|
5782
|
+
const samplePath = path9.resolve(
|
|
5783
|
+
path9.dirname(fileURLToPath2(import.meta.url)),
|
|
4645
5784
|
"../../sample/welcome.md"
|
|
4646
5785
|
);
|
|
4647
5786
|
openFileByPath(samplePath).then(() => {
|