tandem-editor 0.1.2 → 0.2.1
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-B3Hga1qd.js} +56 -47
- package/dist/client/index.html +1 -1
- package/dist/server/index.js +1400 -181
- package/dist/server/index.js.map +1 -1
- package/package.json +121 -120
- package/sample/welcome.md +1 -1
package/dist/server/index.js
CHANGED
|
@@ -71,12 +71,14 @@ async function startHocuspocus(port) {
|
|
|
71
71
|
// stdout is the MCP wire — suppress the startup banner
|
|
72
72
|
async onConnect({ request, documentName }) {
|
|
73
73
|
const origin = request?.headers?.origin;
|
|
74
|
-
if (origin) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
if (!origin) {
|
|
75
|
+
console.error("[Hocuspocus] Rejected connection: missing Origin header");
|
|
76
|
+
throw new Error("Connection rejected: missing origin header");
|
|
77
|
+
}
|
|
78
|
+
const url = new URL(origin);
|
|
79
|
+
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
80
|
+
console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
|
|
81
|
+
throw new Error("Connection rejected: invalid origin");
|
|
80
82
|
}
|
|
81
83
|
console.error(`[Hocuspocus] Client connected to: ${documentName}`);
|
|
82
84
|
},
|
|
@@ -144,7 +146,8 @@ function freePort(port) {
|
|
|
144
146
|
} else {
|
|
145
147
|
freePortUnix(port);
|
|
146
148
|
}
|
|
147
|
-
} catch {
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
async function waitForPort(port, timeoutMs = 5e3) {
|
|
@@ -153,9 +156,7 @@ async function waitForPort(port, timeoutMs = 5e3) {
|
|
|
153
156
|
if (await tryBind(port)) return;
|
|
154
157
|
await new Promise((r) => setTimeout(r, 200));
|
|
155
158
|
}
|
|
156
|
-
|
|
157
|
-
`[Tandem] Warning: port ${port} still not available after ${timeoutMs}ms, proceeding anyway`
|
|
158
|
-
);
|
|
159
|
+
throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);
|
|
159
160
|
}
|
|
160
161
|
function tryBind(port) {
|
|
161
162
|
return new Promise((resolve, reject) => {
|
|
@@ -275,7 +276,16 @@ async function loadSession(filePath) {
|
|
|
275
276
|
try {
|
|
276
277
|
const content = await fs.readFile(sessionPath, "utf-8");
|
|
277
278
|
return JSON.parse(content);
|
|
278
|
-
} catch {
|
|
279
|
+
} catch (err) {
|
|
280
|
+
const code = err.code;
|
|
281
|
+
if (code === "ENOENT") return null;
|
|
282
|
+
if (err instanceof SyntaxError) {
|
|
283
|
+
console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
|
|
284
|
+
await fs.unlink(sessionPath).catch(() => {
|
|
285
|
+
});
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
|
|
279
289
|
return null;
|
|
280
290
|
}
|
|
281
291
|
}
|
|
@@ -344,7 +354,16 @@ async function loadCtrlSession() {
|
|
|
344
354
|
const content = await fs.readFile(sessionPath, "utf-8");
|
|
345
355
|
const data = JSON.parse(content);
|
|
346
356
|
return data.ydocState ?? null;
|
|
347
|
-
} catch {
|
|
357
|
+
} catch (err) {
|
|
358
|
+
const code = err.code;
|
|
359
|
+
if (code === "ENOENT") return null;
|
|
360
|
+
if (err instanceof SyntaxError) {
|
|
361
|
+
console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
|
|
362
|
+
await fs.unlink(sessionPath).catch(() => {
|
|
363
|
+
});
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
console.error(`[Tandem] Failed to read ctrl session:`, err);
|
|
348
367
|
return null;
|
|
349
368
|
}
|
|
350
369
|
}
|
|
@@ -378,18 +397,26 @@ async function listSessionFilePaths() {
|
|
|
378
397
|
}
|
|
379
398
|
async function cleanupSessions() {
|
|
380
399
|
let cleaned = 0;
|
|
400
|
+
let files;
|
|
381
401
|
try {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
402
|
+
files = await fs.readdir(SESSION_DIR);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (err.code === "ENOENT") return 0;
|
|
405
|
+
console.error("[Tandem] Failed to read session directory:", err);
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
const now = Date.now();
|
|
409
|
+
for (const file of files) {
|
|
410
|
+
try {
|
|
385
411
|
const filePath = path2.join(SESSION_DIR, file);
|
|
386
412
|
const stat = await fs.stat(filePath);
|
|
387
413
|
if (now - stat.mtimeMs > SESSION_MAX_AGE) {
|
|
388
414
|
await fs.unlink(filePath);
|
|
389
415
|
cleaned++;
|
|
390
416
|
}
|
|
417
|
+
} catch (err) {
|
|
418
|
+
console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
|
|
391
419
|
}
|
|
392
|
-
} catch {
|
|
393
420
|
}
|
|
394
421
|
return cleaned;
|
|
395
422
|
}
|
|
@@ -1200,9 +1227,11 @@ var init_docx_html = __esm({
|
|
|
1200
1227
|
del: () => ({ strike: {} }),
|
|
1201
1228
|
sup: () => ({ superscript: {} }),
|
|
1202
1229
|
sub: () => ({ subscript: {} }),
|
|
1203
|
-
a: (el) =>
|
|
1204
|
-
|
|
1205
|
-
|
|
1230
|
+
a: (el) => {
|
|
1231
|
+
const href = el.attribs.href || "";
|
|
1232
|
+
const safeHref = /^https?:\/\//i.test(href) || href.startsWith("mailto:") ? href : "";
|
|
1233
|
+
return { link: { href: safeHref } };
|
|
1234
|
+
}
|
|
1206
1235
|
};
|
|
1207
1236
|
BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
1208
1237
|
"h1",
|
|
@@ -1550,9 +1579,138 @@ var init_types2 = __esm({
|
|
|
1550
1579
|
}
|
|
1551
1580
|
});
|
|
1552
1581
|
|
|
1582
|
+
// src/server/file-io/docx-walker.ts
|
|
1583
|
+
import { parseDocument as parseDocument2 } from "htmlparser2";
|
|
1584
|
+
function isElement2(node) {
|
|
1585
|
+
return node.type === "tag";
|
|
1586
|
+
}
|
|
1587
|
+
function getAttr(el, name) {
|
|
1588
|
+
return el.attribs?.[name];
|
|
1589
|
+
}
|
|
1590
|
+
function getTextContent(node) {
|
|
1591
|
+
if (node.type === "text") return node.data;
|
|
1592
|
+
if (!isElement2(node)) return "";
|
|
1593
|
+
return node.children.map(getTextContent).join("");
|
|
1594
|
+
}
|
|
1595
|
+
function findAllByName(name, nodes) {
|
|
1596
|
+
const results = [];
|
|
1597
|
+
for (const node of nodes) {
|
|
1598
|
+
if (isElement2(node)) {
|
|
1599
|
+
if (node.name === name) results.push(node);
|
|
1600
|
+
results.push(...findAllByName(name, node.children));
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return results;
|
|
1604
|
+
}
|
|
1605
|
+
function detectHeadingLevel(paragraph) {
|
|
1606
|
+
for (const child of paragraph.children) {
|
|
1607
|
+
if (!isElement2(child) || child.name !== "w:pPr") continue;
|
|
1608
|
+
for (const prop of child.children) {
|
|
1609
|
+
if (!isElement2(prop) || prop.name !== "w:pStyle") continue;
|
|
1610
|
+
const val = getAttr(prop, "w:val") || "";
|
|
1611
|
+
const match = val.match(/^heading\s*(\d)$/i);
|
|
1612
|
+
if (match) {
|
|
1613
|
+
const level = parseInt(match[1], 10);
|
|
1614
|
+
if (level >= 1 && level <= 6) return level;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
return 0;
|
|
1619
|
+
}
|
|
1620
|
+
function walkDocumentBody(xml, callbacks = {}) {
|
|
1621
|
+
const doc = parseDocument2(xml, { xmlMode: true });
|
|
1622
|
+
let offset = 0;
|
|
1623
|
+
let firstParagraph = true;
|
|
1624
|
+
const textParts = [];
|
|
1625
|
+
let currentParagraph;
|
|
1626
|
+
let currentParagraphId;
|
|
1627
|
+
let currentRun;
|
|
1628
|
+
function walk(nodes) {
|
|
1629
|
+
for (const node of nodes) {
|
|
1630
|
+
if (!isElement2(node)) continue;
|
|
1631
|
+
if (node.name === "w:p") {
|
|
1632
|
+
if (!firstParagraph) {
|
|
1633
|
+
offset += 1;
|
|
1634
|
+
textParts.push("\n");
|
|
1635
|
+
}
|
|
1636
|
+
firstParagraph = false;
|
|
1637
|
+
const prevParagraph = currentParagraph;
|
|
1638
|
+
const prevParagraphId = currentParagraphId;
|
|
1639
|
+
currentParagraph = node;
|
|
1640
|
+
currentParagraphId = getAttr(node, "w14:paraId");
|
|
1641
|
+
const headingLevel = detectHeadingLevel(node);
|
|
1642
|
+
if (headingLevel > 0) {
|
|
1643
|
+
const prefixLen = headingPrefixLength(headingLevel);
|
|
1644
|
+
offset += prefixLen;
|
|
1645
|
+
textParts.push("#".repeat(headingLevel) + " ");
|
|
1646
|
+
}
|
|
1647
|
+
walk(node.children);
|
|
1648
|
+
currentParagraph = prevParagraph;
|
|
1649
|
+
currentParagraphId = prevParagraphId;
|
|
1650
|
+
} else if (node.name === "w:del") {
|
|
1651
|
+
} else if (node.name === "w:commentRangeStart") {
|
|
1652
|
+
const id = getAttr(node, "w:id");
|
|
1653
|
+
if (id) {
|
|
1654
|
+
callbacks.onCommentStart?.({
|
|
1655
|
+
commentId: id,
|
|
1656
|
+
offset,
|
|
1657
|
+
paragraph: currentParagraph,
|
|
1658
|
+
paragraphId: currentParagraphId
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
} else if (node.name === "w:commentRangeEnd") {
|
|
1662
|
+
const id = getAttr(node, "w:id");
|
|
1663
|
+
if (id) {
|
|
1664
|
+
callbacks.onCommentEnd?.(id, offset);
|
|
1665
|
+
}
|
|
1666
|
+
} else if (node.name === "w:instrText") {
|
|
1667
|
+
} else if (node.name === "w:t") {
|
|
1668
|
+
const text = getTextContent(node);
|
|
1669
|
+
if (callbacks.onText && currentRun && currentParagraph) {
|
|
1670
|
+
callbacks.onText({
|
|
1671
|
+
run: currentRun,
|
|
1672
|
+
textNode: node,
|
|
1673
|
+
offsetStart: offset,
|
|
1674
|
+
text,
|
|
1675
|
+
paragraph: currentParagraph,
|
|
1676
|
+
paragraphId: currentParagraphId
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
offset += text.length;
|
|
1680
|
+
textParts.push(text);
|
|
1681
|
+
} else if (SINGLE_CHAR_ELEMENTS.has(node.name)) {
|
|
1682
|
+
offset += 1;
|
|
1683
|
+
textParts.push(" ");
|
|
1684
|
+
} else if (node.name === "w:r") {
|
|
1685
|
+
const prevRun = currentRun;
|
|
1686
|
+
currentRun = node;
|
|
1687
|
+
walk(node.children);
|
|
1688
|
+
currentRun = prevRun;
|
|
1689
|
+
} else {
|
|
1690
|
+
walk(node.children);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
const bodyElements = findAllByName("w:body", doc.children);
|
|
1695
|
+
if (bodyElements.length === 0) {
|
|
1696
|
+
return { totalLength: 0, flatText: "" };
|
|
1697
|
+
}
|
|
1698
|
+
walk(bodyElements[0].children);
|
|
1699
|
+
const flatText = textParts.join("");
|
|
1700
|
+
return { totalLength: offset, flatText };
|
|
1701
|
+
}
|
|
1702
|
+
var SINGLE_CHAR_ELEMENTS;
|
|
1703
|
+
var init_docx_walker = __esm({
|
|
1704
|
+
"src/server/file-io/docx-walker.ts"() {
|
|
1705
|
+
"use strict";
|
|
1706
|
+
init_offsets();
|
|
1707
|
+
SINGLE_CHAR_ELEMENTS = /* @__PURE__ */ new Set(["w:tab", "w:br", "w:noBreakHyphen", "w:softHyphen", "w:sym"]);
|
|
1708
|
+
}
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1553
1711
|
// src/server/file-io/docx-comments.ts
|
|
1554
1712
|
import JSZip from "jszip";
|
|
1555
|
-
import { parseDocument as
|
|
1713
|
+
import { parseDocument as parseDocument3 } from "htmlparser2";
|
|
1556
1714
|
async function extractDocxComments(buffer3) {
|
|
1557
1715
|
const zip = await JSZip.loadAsync(buffer3);
|
|
1558
1716
|
const commentsXml = await zip.file("word/comments.xml")?.async("text");
|
|
@@ -1583,7 +1741,7 @@ async function extractDocxComments(buffer3) {
|
|
|
1583
1741
|
return result;
|
|
1584
1742
|
}
|
|
1585
1743
|
function parseCommentMetadata(xml) {
|
|
1586
|
-
const doc =
|
|
1744
|
+
const doc = parseDocument3(xml, { xmlMode: true });
|
|
1587
1745
|
const map = /* @__PURE__ */ new Map();
|
|
1588
1746
|
for (const comment of findAllByName("w:comment", doc.children)) {
|
|
1589
1747
|
const id = getAttr(comment, "w:id");
|
|
@@ -1597,136 +1755,923 @@ function parseCommentMetadata(xml) {
|
|
|
1597
1755
|
return map;
|
|
1598
1756
|
}
|
|
1599
1757
|
function calculateCommentRanges(xml) {
|
|
1600
|
-
const doc = parseDocument2(xml, { xmlMode: true });
|
|
1601
1758
|
const ranges = /* @__PURE__ */ new Map();
|
|
1602
1759
|
const openRanges = /* @__PURE__ */ new Map();
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
if (
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1760
|
+
walkDocumentBody(xml, {
|
|
1761
|
+
onCommentStart({ commentId, offset }) {
|
|
1762
|
+
openRanges.set(commentId, offset);
|
|
1763
|
+
},
|
|
1764
|
+
onCommentEnd(commentId, offset) {
|
|
1765
|
+
if (openRanges.has(commentId)) {
|
|
1766
|
+
ranges.set(commentId, {
|
|
1767
|
+
from: toFlatOffset(openRanges.get(commentId)),
|
|
1768
|
+
to: toFlatOffset(offset)
|
|
1769
|
+
});
|
|
1770
|
+
openRanges.delete(commentId);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
if (openRanges.size > 0) {
|
|
1775
|
+
console.error(
|
|
1776
|
+
`[docx-comments] ${openRanges.size} comment range(s) had start markers but no end markers: ${[...openRanges.keys()].join(", ")}`
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
return ranges;
|
|
1780
|
+
}
|
|
1781
|
+
function injectCommentsAsAnnotations(doc, comments) {
|
|
1782
|
+
if (comments.length === 0) return 0;
|
|
1783
|
+
const map = doc.getMap(Y_MAP_ANNOTATIONS);
|
|
1784
|
+
let injected = 0;
|
|
1785
|
+
doc.transact(() => {
|
|
1786
|
+
for (const comment of comments) {
|
|
1787
|
+
const result = anchoredRange(doc, toFlatOffset(comment.from), toFlatOffset(comment.to));
|
|
1788
|
+
if (!result.ok) {
|
|
1789
|
+
console.error(
|
|
1790
|
+
`[docx-comments] Skipping imported comment ${comment.commentId}: range [${comment.from}, ${comment.to}] \u2014 ${result.code}`
|
|
1791
|
+
);
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
const id = `import-${comment.commentId}-${Date.now()}`;
|
|
1795
|
+
const content = comment.authorName !== "Unknown" ? `[${comment.authorName}] ${comment.bodyText}` : comment.bodyText;
|
|
1796
|
+
const annotation = {
|
|
1797
|
+
id,
|
|
1798
|
+
author: "import",
|
|
1799
|
+
type: "comment",
|
|
1800
|
+
range: { from: result.range.from, to: result.range.to },
|
|
1801
|
+
content,
|
|
1802
|
+
status: "pending",
|
|
1803
|
+
timestamp: comment.date ? new Date(comment.date).getTime() : Date.now()
|
|
1804
|
+
};
|
|
1805
|
+
if (result.fullyAnchored) {
|
|
1806
|
+
annotation.relRange = result.relRange;
|
|
1807
|
+
}
|
|
1808
|
+
map.set(id, annotation);
|
|
1809
|
+
injected++;
|
|
1810
|
+
}
|
|
1811
|
+
}, MCP_ORIGIN);
|
|
1812
|
+
if (injected > 0 || comments.length > 0) {
|
|
1813
|
+
console.error(`[docx-comments] Imported ${injected}/${comments.length} Word comments`);
|
|
1814
|
+
}
|
|
1815
|
+
return injected;
|
|
1816
|
+
}
|
|
1817
|
+
var init_docx_comments = __esm({
|
|
1818
|
+
"src/server/file-io/docx-comments.ts"() {
|
|
1819
|
+
"use strict";
|
|
1820
|
+
init_constants();
|
|
1821
|
+
init_positions2();
|
|
1822
|
+
init_types2();
|
|
1823
|
+
init_queue();
|
|
1824
|
+
init_docx_walker();
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
// node_modules/domelementtype/dist/index.js
|
|
1829
|
+
function isTag(element) {
|
|
1830
|
+
return element.type === ElementType.Tag || element.type === ElementType.Script || element.type === ElementType.Style;
|
|
1831
|
+
}
|
|
1832
|
+
var ElementType, Root, Text, Directive, Comment, Script, Style, Tag, CDATA, Doctype;
|
|
1833
|
+
var init_dist = __esm({
|
|
1834
|
+
"node_modules/domelementtype/dist/index.js"() {
|
|
1835
|
+
"use strict";
|
|
1836
|
+
(function(ElementType2) {
|
|
1837
|
+
ElementType2["Root"] = "root";
|
|
1838
|
+
ElementType2["Text"] = "text";
|
|
1839
|
+
ElementType2["Directive"] = "directive";
|
|
1840
|
+
ElementType2["Comment"] = "comment";
|
|
1841
|
+
ElementType2["Script"] = "script";
|
|
1842
|
+
ElementType2["Style"] = "style";
|
|
1843
|
+
ElementType2["Tag"] = "tag";
|
|
1844
|
+
ElementType2["CDATA"] = "cdata";
|
|
1845
|
+
ElementType2["Doctype"] = "doctype";
|
|
1846
|
+
})(ElementType || (ElementType = {}));
|
|
1847
|
+
Root = ElementType.Root;
|
|
1848
|
+
Text = ElementType.Text;
|
|
1849
|
+
Directive = ElementType.Directive;
|
|
1850
|
+
Comment = ElementType.Comment;
|
|
1851
|
+
Script = ElementType.Script;
|
|
1852
|
+
Style = ElementType.Style;
|
|
1853
|
+
Tag = ElementType.Tag;
|
|
1854
|
+
CDATA = ElementType.CDATA;
|
|
1855
|
+
Doctype = ElementType.Doctype;
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
// node_modules/domhandler/dist/node.js
|
|
1860
|
+
function isTag2(node) {
|
|
1861
|
+
return isTag(node);
|
|
1862
|
+
}
|
|
1863
|
+
function isCDATA(node) {
|
|
1864
|
+
return node.type === ElementType.CDATA;
|
|
1865
|
+
}
|
|
1866
|
+
function isText2(node) {
|
|
1867
|
+
return node.type === ElementType.Text;
|
|
1868
|
+
}
|
|
1869
|
+
function isComment(node) {
|
|
1870
|
+
return node.type === ElementType.Comment;
|
|
1871
|
+
}
|
|
1872
|
+
function isDirective(node) {
|
|
1873
|
+
return node.type === ElementType.Directive;
|
|
1874
|
+
}
|
|
1875
|
+
function isDocument(node) {
|
|
1876
|
+
return node.type === ElementType.Root;
|
|
1877
|
+
}
|
|
1878
|
+
function cloneNode(node, recursive = false) {
|
|
1879
|
+
let result;
|
|
1880
|
+
if (isText2(node)) {
|
|
1881
|
+
result = new Text2(node.data);
|
|
1882
|
+
} else if (isComment(node)) {
|
|
1883
|
+
result = new Comment2(node.data);
|
|
1884
|
+
} else if (isTag2(node)) {
|
|
1885
|
+
const children = recursive ? cloneChildren(node.children) : [];
|
|
1886
|
+
const clone = new Element(node.name, { ...node.attribs }, children);
|
|
1887
|
+
for (const child of children) {
|
|
1888
|
+
child.parent = clone;
|
|
1889
|
+
}
|
|
1890
|
+
if (node.namespace != null) {
|
|
1891
|
+
clone.namespace = node.namespace;
|
|
1892
|
+
}
|
|
1893
|
+
if (node["x-attribsNamespace"]) {
|
|
1894
|
+
clone["x-attribsNamespace"] = { ...node["x-attribsNamespace"] };
|
|
1895
|
+
}
|
|
1896
|
+
if (node["x-attribsPrefix"]) {
|
|
1897
|
+
clone["x-attribsPrefix"] = { ...node["x-attribsPrefix"] };
|
|
1898
|
+
}
|
|
1899
|
+
result = clone;
|
|
1900
|
+
} else if (isCDATA(node)) {
|
|
1901
|
+
const children = recursive ? cloneChildren(node.children) : [];
|
|
1902
|
+
const clone = new CDATA2(children);
|
|
1903
|
+
for (const child of children) {
|
|
1904
|
+
child.parent = clone;
|
|
1905
|
+
}
|
|
1906
|
+
result = clone;
|
|
1907
|
+
} else if (isDocument(node)) {
|
|
1908
|
+
const children = recursive ? cloneChildren(node.children) : [];
|
|
1909
|
+
const clone = new Document(children);
|
|
1910
|
+
for (const child of children) {
|
|
1911
|
+
child.parent = clone;
|
|
1912
|
+
}
|
|
1913
|
+
if (node["x-mode"]) {
|
|
1914
|
+
clone["x-mode"] = node["x-mode"];
|
|
1915
|
+
}
|
|
1916
|
+
result = clone;
|
|
1917
|
+
} else if (isDirective(node)) {
|
|
1918
|
+
const instruction = new ProcessingInstruction(node.name, node.data);
|
|
1919
|
+
if (node["x-name"] != null) {
|
|
1920
|
+
instruction["x-name"] = node["x-name"];
|
|
1921
|
+
instruction["x-publicId"] = node["x-publicId"];
|
|
1922
|
+
instruction["x-systemId"] = node["x-systemId"];
|
|
1923
|
+
}
|
|
1924
|
+
result = instruction;
|
|
1925
|
+
} else {
|
|
1926
|
+
throw new Error(`Not implemented yet: ${node.type}`);
|
|
1927
|
+
}
|
|
1928
|
+
result.startIndex = node.startIndex;
|
|
1929
|
+
result.endIndex = node.endIndex;
|
|
1930
|
+
if (node.sourceCodeLocation != null) {
|
|
1931
|
+
result.sourceCodeLocation = node.sourceCodeLocation;
|
|
1932
|
+
}
|
|
1933
|
+
return result;
|
|
1934
|
+
}
|
|
1935
|
+
function cloneChildren(childs) {
|
|
1936
|
+
const children = childs.map((child) => cloneNode(child, true));
|
|
1937
|
+
for (let index = 1; index < children.length; index++) {
|
|
1938
|
+
children[index].prev = children[index - 1];
|
|
1939
|
+
children[index - 1].next = children[index];
|
|
1940
|
+
}
|
|
1941
|
+
return children;
|
|
1942
|
+
}
|
|
1943
|
+
var Node, DataNode, Text2, Comment2, ProcessingInstruction, NodeWithChildren, CDATA2, Document, Element;
|
|
1944
|
+
var init_node = __esm({
|
|
1945
|
+
"node_modules/domhandler/dist/node.js"() {
|
|
1946
|
+
"use strict";
|
|
1947
|
+
init_dist();
|
|
1948
|
+
Node = class {
|
|
1949
|
+
/** Parent of the node */
|
|
1950
|
+
parent = null;
|
|
1951
|
+
/** Previous sibling */
|
|
1952
|
+
prev = null;
|
|
1953
|
+
/** Next sibling */
|
|
1954
|
+
next = null;
|
|
1955
|
+
/** The start index of the node. Requires `withStartIndices` on the handler to be `true. */
|
|
1956
|
+
startIndex = null;
|
|
1957
|
+
/** The end index of the node. Requires `withEndIndices` on the handler to be `true. */
|
|
1958
|
+
endIndex = null;
|
|
1959
|
+
// Read-write aliases for properties
|
|
1960
|
+
/**
|
|
1961
|
+
* Same as {@link parent}.
|
|
1962
|
+
* [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
|
|
1963
|
+
*/
|
|
1964
|
+
get parentNode() {
|
|
1965
|
+
return this.parent;
|
|
1966
|
+
}
|
|
1967
|
+
set parentNode(parent) {
|
|
1968
|
+
this.parent = parent;
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Same as {@link prev}.
|
|
1972
|
+
* [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
|
|
1973
|
+
*/
|
|
1974
|
+
get previousSibling() {
|
|
1975
|
+
return this.prev;
|
|
1976
|
+
}
|
|
1977
|
+
set previousSibling(previous) {
|
|
1978
|
+
this.prev = previous;
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Same as {@link next}.
|
|
1982
|
+
* [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
|
|
1983
|
+
*/
|
|
1984
|
+
get nextSibling() {
|
|
1985
|
+
return this.next;
|
|
1986
|
+
}
|
|
1987
|
+
set nextSibling(next) {
|
|
1988
|
+
this.next = next;
|
|
1989
|
+
}
|
|
1990
|
+
/**
|
|
1991
|
+
* Clone this node, and optionally its children.
|
|
1992
|
+
* @param recursive Clone child nodes as well.
|
|
1993
|
+
* @returns A clone of the node.
|
|
1994
|
+
*/
|
|
1995
|
+
cloneNode(recursive = false) {
|
|
1996
|
+
return cloneNode(this, recursive);
|
|
1997
|
+
}
|
|
1998
|
+
};
|
|
1999
|
+
DataNode = class extends Node {
|
|
2000
|
+
data;
|
|
2001
|
+
/**
|
|
2002
|
+
* @param data The content of the data node
|
|
2003
|
+
*/
|
|
2004
|
+
constructor(data) {
|
|
2005
|
+
super();
|
|
2006
|
+
this.data = data;
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Same as {@link data}.
|
|
2010
|
+
* [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
|
|
2011
|
+
*/
|
|
2012
|
+
get nodeValue() {
|
|
2013
|
+
return this.data;
|
|
2014
|
+
}
|
|
2015
|
+
set nodeValue(data) {
|
|
2016
|
+
this.data = data;
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
Text2 = class extends DataNode {
|
|
2020
|
+
type = ElementType.Text;
|
|
2021
|
+
get nodeType() {
|
|
2022
|
+
return 3;
|
|
2023
|
+
}
|
|
2024
|
+
};
|
|
2025
|
+
Comment2 = class extends DataNode {
|
|
2026
|
+
type = ElementType.Comment;
|
|
2027
|
+
get nodeType() {
|
|
2028
|
+
return 8;
|
|
2029
|
+
}
|
|
2030
|
+
};
|
|
2031
|
+
ProcessingInstruction = class extends DataNode {
|
|
2032
|
+
type = ElementType.Directive;
|
|
2033
|
+
name;
|
|
2034
|
+
constructor(name, data) {
|
|
2035
|
+
super(data);
|
|
2036
|
+
this.name = name;
|
|
2037
|
+
}
|
|
2038
|
+
get nodeType() {
|
|
2039
|
+
return 1;
|
|
2040
|
+
}
|
|
2041
|
+
/** If this is a doctype, the document type name (parse5 only). */
|
|
2042
|
+
"x-name";
|
|
2043
|
+
/** If this is a doctype, the document type public identifier (parse5 only). */
|
|
2044
|
+
"x-publicId";
|
|
2045
|
+
/** If this is a doctype, the document type system identifier (parse5 only). */
|
|
2046
|
+
"x-systemId";
|
|
2047
|
+
};
|
|
2048
|
+
NodeWithChildren = class extends Node {
|
|
2049
|
+
children;
|
|
2050
|
+
/**
|
|
2051
|
+
* @param children Children of the node. Only certain node types can have children.
|
|
2052
|
+
*/
|
|
2053
|
+
constructor(children) {
|
|
2054
|
+
super();
|
|
2055
|
+
this.children = children;
|
|
2056
|
+
}
|
|
2057
|
+
// Aliases
|
|
2058
|
+
/** First child of the node. */
|
|
2059
|
+
get firstChild() {
|
|
2060
|
+
return this.children[0] ?? null;
|
|
2061
|
+
}
|
|
2062
|
+
/** Last child of the node. */
|
|
2063
|
+
get lastChild() {
|
|
2064
|
+
return this.children.length > 0 ? this.children[this.children.length - 1] : null;
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Same as {@link children}.
|
|
2068
|
+
* [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
|
|
2069
|
+
*/
|
|
2070
|
+
get childNodes() {
|
|
2071
|
+
return this.children;
|
|
2072
|
+
}
|
|
2073
|
+
set childNodes(children) {
|
|
2074
|
+
this.children = children;
|
|
2075
|
+
}
|
|
2076
|
+
};
|
|
2077
|
+
CDATA2 = class extends NodeWithChildren {
|
|
2078
|
+
type = ElementType.CDATA;
|
|
2079
|
+
get nodeType() {
|
|
2080
|
+
return 4;
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
Document = class extends NodeWithChildren {
|
|
2084
|
+
type = ElementType.Root;
|
|
2085
|
+
get nodeType() {
|
|
2086
|
+
return 9;
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
Element = class extends NodeWithChildren {
|
|
2090
|
+
name;
|
|
2091
|
+
attribs;
|
|
2092
|
+
type;
|
|
2093
|
+
/**
|
|
2094
|
+
* @param name Name of the tag, eg. `div`, `span`.
|
|
2095
|
+
* @param attribs Object mapping attribute names to attribute values.
|
|
2096
|
+
* @param children Children of the node.
|
|
2097
|
+
* @param type Node type used for the new node instance.
|
|
2098
|
+
*/
|
|
2099
|
+
constructor(name, attribs, children = [], type = name === "script" ? ElementType.Script : name === "style" ? ElementType.Style : ElementType.Tag) {
|
|
2100
|
+
super(children);
|
|
2101
|
+
this.name = name;
|
|
2102
|
+
this.attribs = attribs;
|
|
2103
|
+
this.type = type;
|
|
2104
|
+
}
|
|
2105
|
+
get nodeType() {
|
|
2106
|
+
return 1;
|
|
2107
|
+
}
|
|
2108
|
+
// DOM Level 1 aliases
|
|
2109
|
+
/**
|
|
2110
|
+
* Same as {@link name}.
|
|
2111
|
+
* [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
|
|
2112
|
+
*/
|
|
2113
|
+
get tagName() {
|
|
2114
|
+
return this.name;
|
|
2115
|
+
}
|
|
2116
|
+
set tagName(name) {
|
|
2117
|
+
this.name = name;
|
|
2118
|
+
}
|
|
2119
|
+
get attributes() {
|
|
2120
|
+
return Object.keys(this.attribs).map((name) => ({
|
|
2121
|
+
name,
|
|
2122
|
+
value: this.attribs[name],
|
|
2123
|
+
namespace: this["x-attribsNamespace"]?.[name],
|
|
2124
|
+
prefix: this["x-attribsPrefix"]?.[name]
|
|
2125
|
+
}));
|
|
2126
|
+
}
|
|
2127
|
+
/** Element namespace (parse5 only). */
|
|
2128
|
+
namespace;
|
|
2129
|
+
/** Element attribute namespaces (parse5 only). */
|
|
2130
|
+
"x-attribsNamespace";
|
|
2131
|
+
/** Element attribute namespace-related prefixes (parse5 only). */
|
|
2132
|
+
"x-attribsPrefix";
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
// node_modules/domhandler/dist/index.js
|
|
2138
|
+
var init_dist2 = __esm({
|
|
2139
|
+
"node_modules/domhandler/dist/index.js"() {
|
|
2140
|
+
"use strict";
|
|
2141
|
+
init_node();
|
|
2142
|
+
}
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
// src/server/file-io/docx-apply.ts
|
|
2146
|
+
import JSZip2 from "jszip";
|
|
2147
|
+
import { parseDocument as parseDocument4 } from "htmlparser2";
|
|
2148
|
+
import render from "dom-serializer";
|
|
2149
|
+
function buildOffsetMap(xml, targetOffsets) {
|
|
2150
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2151
|
+
const commentParagraphIds = /* @__PURE__ */ new Map();
|
|
2152
|
+
const hits = [];
|
|
2153
|
+
const { totalLength, flatText } = walkDocumentBody(xml, {
|
|
2154
|
+
onText(hit) {
|
|
2155
|
+
hits.push(hit);
|
|
2156
|
+
},
|
|
2157
|
+
onCommentStart(hit) {
|
|
2158
|
+
if (hit.paragraphId) {
|
|
2159
|
+
commentParagraphIds.set(hit.commentId, hit.paragraphId);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
});
|
|
2163
|
+
for (const offset of targetOffsets) {
|
|
2164
|
+
if (offset === totalLength && hits.length > 0) {
|
|
2165
|
+
const lastHit = hits[hits.length - 1];
|
|
2166
|
+
entries.set(offset, {
|
|
2167
|
+
run: lastHit.run,
|
|
2168
|
+
textNode: lastHit.textNode,
|
|
2169
|
+
charIndex: lastHit.text.length,
|
|
2170
|
+
paragraph: lastHit.paragraph,
|
|
2171
|
+
paragraphId: lastHit.paragraphId
|
|
2172
|
+
});
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
for (let i = 0; i < hits.length; i++) {
|
|
2176
|
+
const hit = hits[i];
|
|
2177
|
+
const start = hit.offsetStart;
|
|
2178
|
+
const end = start + hit.text.length;
|
|
2179
|
+
if (offset >= start && offset < end) {
|
|
2180
|
+
entries.set(offset, {
|
|
2181
|
+
run: hit.run,
|
|
2182
|
+
textNode: hit.textNode,
|
|
2183
|
+
charIndex: offset - start,
|
|
2184
|
+
paragraph: hit.paragraph,
|
|
2185
|
+
paragraphId: hit.paragraphId
|
|
2186
|
+
});
|
|
2187
|
+
break;
|
|
2188
|
+
}
|
|
2189
|
+
if (offset === end) {
|
|
2190
|
+
const nextHit = hits[i + 1];
|
|
2191
|
+
if (!nextHit || nextHit.offsetStart !== offset) {
|
|
2192
|
+
entries.set(offset, {
|
|
2193
|
+
run: hit.run,
|
|
2194
|
+
textNode: hit.textNode,
|
|
2195
|
+
charIndex: hit.text.length,
|
|
2196
|
+
paragraph: hit.paragraph,
|
|
2197
|
+
paragraphId: hit.paragraphId
|
|
2198
|
+
});
|
|
2199
|
+
break;
|
|
1614
2200
|
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
let walkerBody;
|
|
2205
|
+
let walkerDoc;
|
|
2206
|
+
if (hits.length > 0) {
|
|
2207
|
+
let node = hits[0].paragraph.parent;
|
|
2208
|
+
while (node && isElement2(node) && node.name !== "w:body") {
|
|
2209
|
+
node = node.parent;
|
|
2210
|
+
}
|
|
2211
|
+
walkerBody = node;
|
|
2212
|
+
if (!walkerBody || walkerBody.name !== "w:body") {
|
|
2213
|
+
throw new Error("Could not recover w:body from walker's parsed DOM");
|
|
2214
|
+
}
|
|
2215
|
+
walkerDoc = walkerBody.parent;
|
|
2216
|
+
} else {
|
|
2217
|
+
const doc = parseDocument4(xml, { xmlMode: true });
|
|
2218
|
+
const bodyElements = findAllByName("w:body", doc.children);
|
|
2219
|
+
walkerBody = bodyElements[0] ?? new Element("w:body", {});
|
|
2220
|
+
walkerDoc = doc;
|
|
2221
|
+
}
|
|
2222
|
+
return {
|
|
2223
|
+
get(offset) {
|
|
2224
|
+
return entries.get(offset);
|
|
2225
|
+
},
|
|
2226
|
+
flatText,
|
|
2227
|
+
totalLength,
|
|
2228
|
+
body: walkerBody,
|
|
2229
|
+
doc: walkerDoc,
|
|
2230
|
+
commentParagraphIds
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
function getNodeText(node) {
|
|
2234
|
+
for (const child of node.children) {
|
|
2235
|
+
if (child.type === "text") return child.data;
|
|
2236
|
+
}
|
|
2237
|
+
return "";
|
|
2238
|
+
}
|
|
2239
|
+
function setNodeText(node, text) {
|
|
2240
|
+
const textChild = new Text2(text);
|
|
2241
|
+
textChild.parent = node;
|
|
2242
|
+
node.children = [textChild];
|
|
2243
|
+
if (text.startsWith(" ") || text.endsWith(" ")) {
|
|
2244
|
+
node.attribs["xml:space"] = "preserve";
|
|
2245
|
+
} else {
|
|
2246
|
+
delete node.attribs["xml:space"];
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
function cloneRPr(rPr) {
|
|
2250
|
+
const serialized = render(rPr, { xmlMode: true });
|
|
2251
|
+
const doc = parseDocument4(serialized, { xmlMode: true });
|
|
2252
|
+
return doc.children[0];
|
|
2253
|
+
}
|
|
2254
|
+
function findRPr(run) {
|
|
2255
|
+
for (const child of run.children) {
|
|
2256
|
+
if (isElement2(child) && child.name === "w:rPr") return child;
|
|
2257
|
+
}
|
|
2258
|
+
return void 0;
|
|
2259
|
+
}
|
|
2260
|
+
function buildRun(textElementName, text, rPrSource) {
|
|
2261
|
+
const children = [];
|
|
2262
|
+
if (rPrSource) {
|
|
2263
|
+
const cloned = cloneRPr(rPrSource);
|
|
2264
|
+
children.push(cloned);
|
|
2265
|
+
}
|
|
2266
|
+
const textChild = new Text2(text);
|
|
2267
|
+
const attribs = {};
|
|
2268
|
+
if (text.startsWith(" ") || text.endsWith(" ")) {
|
|
2269
|
+
attribs["xml:space"] = "preserve";
|
|
2270
|
+
}
|
|
2271
|
+
const textNode = new Element(textElementName, attribs, [textChild]);
|
|
2272
|
+
textChild.parent = textNode;
|
|
2273
|
+
children.push(textNode);
|
|
2274
|
+
const run = new Element("w:r", {}, children);
|
|
2275
|
+
for (const child of children) {
|
|
2276
|
+
child.parent = run;
|
|
2277
|
+
}
|
|
2278
|
+
return run;
|
|
2279
|
+
}
|
|
2280
|
+
function insertChild(parent, index, node) {
|
|
2281
|
+
parent.children.splice(index, 0, node);
|
|
2282
|
+
node.parent = parent;
|
|
2283
|
+
}
|
|
2284
|
+
function removeChild(node) {
|
|
2285
|
+
if (!node.parent) return;
|
|
2286
|
+
const parent = node.parent;
|
|
2287
|
+
const idx = parent.children.indexOf(node);
|
|
2288
|
+
if (idx >= 0) parent.children.splice(idx, 1);
|
|
2289
|
+
node.parent = null;
|
|
2290
|
+
}
|
|
2291
|
+
function applySingleSuggestion(offsetMap, suggestion) {
|
|
2292
|
+
const { from, to, newText, author, date, revisionId } = suggestion;
|
|
2293
|
+
const fromEntry = offsetMap.get(from);
|
|
2294
|
+
const toEntry = offsetMap.get(to);
|
|
2295
|
+
if (!fromEntry || !toEntry) {
|
|
2296
|
+
return { ok: false, reason: `Could not resolve offsets: from=${from} to=${to}` };
|
|
2297
|
+
}
|
|
2298
|
+
if (fromEntry.paragraph !== toEntry.paragraph) {
|
|
2299
|
+
return { ok: false, reason: "Cross-paragraph suggestions not yet supported" };
|
|
2300
|
+
}
|
|
2301
|
+
const paragraph = fromEntry.paragraph;
|
|
2302
|
+
if (fromEntry.charIndex > 0) {
|
|
2303
|
+
splitRun(fromEntry.run, fromEntry.textNode, fromEntry.charIndex, paragraph);
|
|
2304
|
+
const idx = paragraph.children.indexOf(fromEntry.run);
|
|
2305
|
+
const nextRun = paragraph.children[idx + 1];
|
|
2306
|
+
if (!nextRun || !isElement2(nextRun) || nextRun.name !== "w:r") {
|
|
2307
|
+
return { ok: false, reason: "Split failed: no next run after from-split" };
|
|
2308
|
+
}
|
|
2309
|
+
fromEntry.run = nextRun;
|
|
2310
|
+
const nextTextNode = findTextNode(nextRun);
|
|
2311
|
+
if (!nextTextNode) {
|
|
2312
|
+
return { ok: false, reason: "Run contains no text element" };
|
|
2313
|
+
}
|
|
2314
|
+
fromEntry.textNode = nextTextNode;
|
|
2315
|
+
fromEntry.charIndex = 0;
|
|
2316
|
+
}
|
|
2317
|
+
const toText = getNodeText(toEntry.textNode);
|
|
2318
|
+
if (toEntry.charIndex < toText.length && toEntry.charIndex > 0) {
|
|
2319
|
+
splitRun(toEntry.run, toEntry.textNode, toEntry.charIndex, paragraph);
|
|
2320
|
+
} else if (toEntry.charIndex === 0) {
|
|
2321
|
+
}
|
|
2322
|
+
const runsToDelete = [];
|
|
2323
|
+
let collecting = false;
|
|
2324
|
+
for (const child of paragraph.children) {
|
|
2325
|
+
if (!isElement2(child) || child.name !== "w:r") {
|
|
2326
|
+
if (collecting && child === toEntry.run) break;
|
|
2327
|
+
continue;
|
|
2328
|
+
}
|
|
2329
|
+
if (child === fromEntry.run) {
|
|
2330
|
+
collecting = true;
|
|
2331
|
+
}
|
|
2332
|
+
if (collecting) {
|
|
2333
|
+
if (toEntry.charIndex === 0 && child === toEntry.run) {
|
|
2334
|
+
break;
|
|
2335
|
+
}
|
|
2336
|
+
runsToDelete.push(child);
|
|
2337
|
+
if (child === toEntry.run) {
|
|
2338
|
+
break;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
if (runsToDelete.length === 0) {
|
|
2343
|
+
if (from === to && newText.length > 0) {
|
|
2344
|
+
const insertionPoint = paragraph.children.indexOf(fromEntry.run);
|
|
2345
|
+
const rPr = findRPr(fromEntry.run);
|
|
2346
|
+
const insRun = buildRun("w:t", newText, rPr);
|
|
2347
|
+
const ins2 = new Element(
|
|
2348
|
+
"w:ins",
|
|
2349
|
+
{
|
|
2350
|
+
"w:id": String(revisionId + 1),
|
|
2351
|
+
"w:author": author,
|
|
2352
|
+
"w:date": date
|
|
2353
|
+
},
|
|
2354
|
+
[insRun]
|
|
2355
|
+
);
|
|
2356
|
+
insRun.parent = ins2;
|
|
2357
|
+
insertChild(paragraph, insertionPoint, ins2);
|
|
2358
|
+
return { ok: true };
|
|
2359
|
+
}
|
|
2360
|
+
return { ok: false, reason: "No runs found in deletion range" };
|
|
2361
|
+
}
|
|
2362
|
+
const rPrSource = findRPr(runsToDelete[0]);
|
|
2363
|
+
const delChildren = [];
|
|
2364
|
+
for (const run of runsToDelete) {
|
|
2365
|
+
const tn = findTextNode(run);
|
|
2366
|
+
if (!tn) continue;
|
|
2367
|
+
const text = getNodeText(tn);
|
|
2368
|
+
const delRun = buildRun("w:delText", text, findRPr(run));
|
|
2369
|
+
delChildren.push(delRun);
|
|
2370
|
+
}
|
|
2371
|
+
const del = new Element(
|
|
2372
|
+
"w:del",
|
|
2373
|
+
{
|
|
2374
|
+
"w:id": String(revisionId),
|
|
2375
|
+
"w:author": author,
|
|
2376
|
+
"w:date": date
|
|
2377
|
+
},
|
|
2378
|
+
delChildren
|
|
2379
|
+
);
|
|
2380
|
+
for (const child of delChildren) {
|
|
2381
|
+
child.parent = del;
|
|
2382
|
+
}
|
|
2383
|
+
let ins;
|
|
2384
|
+
if (newText.length > 0) {
|
|
2385
|
+
const insRun = buildRun("w:t", newText, rPrSource);
|
|
2386
|
+
ins = new Element(
|
|
2387
|
+
"w:ins",
|
|
2388
|
+
{
|
|
2389
|
+
"w:id": String(revisionId + 1),
|
|
2390
|
+
"w:author": author,
|
|
2391
|
+
"w:date": date
|
|
2392
|
+
},
|
|
2393
|
+
[insRun]
|
|
2394
|
+
);
|
|
2395
|
+
insRun.parent = ins;
|
|
2396
|
+
}
|
|
2397
|
+
const firstRunIndex = paragraph.children.indexOf(runsToDelete[0]);
|
|
2398
|
+
for (const run of runsToDelete) {
|
|
2399
|
+
removeChild(run);
|
|
2400
|
+
}
|
|
2401
|
+
insertChild(paragraph, firstRunIndex, del);
|
|
2402
|
+
if (ins) {
|
|
2403
|
+
insertChild(paragraph, firstRunIndex + 1, ins);
|
|
2404
|
+
}
|
|
2405
|
+
return { ok: true };
|
|
2406
|
+
}
|
|
2407
|
+
function splitRun(run, textNode, charIndex, paragraph) {
|
|
2408
|
+
const fullText = getNodeText(textNode);
|
|
2409
|
+
const before = fullText.slice(0, charIndex);
|
|
2410
|
+
const after = fullText.slice(charIndex);
|
|
2411
|
+
setNodeText(textNode, before);
|
|
2412
|
+
const rPr = findRPr(run);
|
|
2413
|
+
const newRun = buildRun("w:t", after, rPr);
|
|
2414
|
+
const idx = paragraph.children.indexOf(run);
|
|
2415
|
+
insertChild(paragraph, idx + 1, newRun);
|
|
2416
|
+
}
|
|
2417
|
+
function findTextNode(run) {
|
|
2418
|
+
for (const child of run.children) {
|
|
2419
|
+
if (isElement2(child) && child.name === "w:t") return child;
|
|
2420
|
+
}
|
|
2421
|
+
return void 0;
|
|
2422
|
+
}
|
|
2423
|
+
async function applyTrackedChanges(docxBuffer, suggestions, options) {
|
|
2424
|
+
const zip = await JSZip2.loadAsync(docxBuffer);
|
|
2425
|
+
const documentXml = await zip.file("word/document.xml")?.async("text");
|
|
2426
|
+
if (!documentXml) {
|
|
2427
|
+
throw new Error("Missing word/document.xml in .docx archive");
|
|
2428
|
+
}
|
|
2429
|
+
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2430
|
+
const targetOffsets = /* @__PURE__ */ new Set();
|
|
2431
|
+
for (const s of suggestions) {
|
|
2432
|
+
targetOffsets.add(s.from);
|
|
2433
|
+
targetOffsets.add(s.to);
|
|
2434
|
+
}
|
|
2435
|
+
const offsetMap = buildOffsetMap(documentXml, targetOffsets);
|
|
2436
|
+
if (offsetMap.flatText !== options.ydocFlatText) {
|
|
2437
|
+
throw new Error(
|
|
2438
|
+
"Flat text mismatch: the .docx content does not match the Y.Doc flat text. The file may have changed since it was loaded."
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2441
|
+
const sorted = [...suggestions].sort((a, b) => b.from - a.from);
|
|
2442
|
+
const valid = [];
|
|
2443
|
+
const rejectedDetails = [];
|
|
2444
|
+
for (const s of sorted) {
|
|
2445
|
+
if (s.textSnapshot !== void 0) {
|
|
2446
|
+
const actual = offsetMap.flatText.slice(s.from, s.to);
|
|
2447
|
+
if (actual !== s.textSnapshot) {
|
|
2448
|
+
rejectedDetails.push({
|
|
2449
|
+
id: s.id,
|
|
2450
|
+
reason: `Text snapshot mismatch: expected "${s.textSnapshot}", got "${actual}"`
|
|
2451
|
+
});
|
|
2452
|
+
continue;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
const fromEntry = offsetMap.get(s.from);
|
|
2456
|
+
const toEntry = offsetMap.get(s.to);
|
|
2457
|
+
if (!fromEntry || !toEntry) {
|
|
2458
|
+
rejectedDetails.push({
|
|
2459
|
+
id: s.id,
|
|
2460
|
+
reason: `Could not resolve offsets: from=${s.from} to=${s.to}`
|
|
2461
|
+
});
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
valid.push(s);
|
|
2465
|
+
}
|
|
2466
|
+
const validAfterOverlapCheck = [];
|
|
2467
|
+
let lastFrom = Infinity;
|
|
2468
|
+
for (const s of valid) {
|
|
2469
|
+
if (s.to > lastFrom) {
|
|
2470
|
+
rejectedDetails.push({
|
|
2471
|
+
id: s.id,
|
|
2472
|
+
reason: `Overlapping range [${s.from}, ${s.to}) conflicts with another suggestion`
|
|
2473
|
+
});
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
lastFrom = s.from;
|
|
2477
|
+
validAfterOverlapCheck.push(s);
|
|
2478
|
+
}
|
|
2479
|
+
const validAfterComplexCheck = [];
|
|
2480
|
+
for (const s of validAfterOverlapCheck) {
|
|
2481
|
+
const fromEntry = offsetMap.get(s.from);
|
|
2482
|
+
const toEntry = offsetMap.get(s.to);
|
|
2483
|
+
const paragraph = fromEntry.paragraph;
|
|
2484
|
+
let collecting = false;
|
|
2485
|
+
let hasComplex = false;
|
|
2486
|
+
for (const child of paragraph.children) {
|
|
2487
|
+
if (!isElement2(child) || child.name !== "w:r") continue;
|
|
2488
|
+
if (child === fromEntry.run) collecting = true;
|
|
2489
|
+
if (collecting) {
|
|
2490
|
+
for (const rc of child.children) {
|
|
2491
|
+
if (isElement2(rc) && COMPLEX_RUN_ELEMENTS.has(rc.name)) {
|
|
2492
|
+
hasComplex = true;
|
|
2493
|
+
break;
|
|
2494
|
+
}
|
|
1624
2495
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
offset += text.length;
|
|
1628
|
-
} else if (node.name === "w:tab" || node.name === "w:br") {
|
|
1629
|
-
offset += 1;
|
|
1630
|
-
} else {
|
|
1631
|
-
walk(node.children);
|
|
2496
|
+
if (hasComplex) break;
|
|
2497
|
+
if (child === toEntry.run) break;
|
|
1632
2498
|
}
|
|
1633
2499
|
}
|
|
2500
|
+
if (hasComplex) {
|
|
2501
|
+
rejectedDetails.push({
|
|
2502
|
+
id: s.id,
|
|
2503
|
+
reason: "Overlaps a complex element (footnote, drawing, or field) and couldn't be applied"
|
|
2504
|
+
});
|
|
2505
|
+
} else {
|
|
2506
|
+
validAfterComplexCheck.push(s);
|
|
2507
|
+
}
|
|
1634
2508
|
}
|
|
1635
|
-
const
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
);
|
|
1640
|
-
|
|
2509
|
+
const validAfterRunCheck = [];
|
|
2510
|
+
const claimedRuns = /* @__PURE__ */ new Map();
|
|
2511
|
+
for (const s of validAfterComplexCheck) {
|
|
2512
|
+
const fromEntry = offsetMap.get(s.from);
|
|
2513
|
+
const toEntry = offsetMap.get(s.to);
|
|
2514
|
+
const paragraph = fromEntry.paragraph;
|
|
2515
|
+
const touchedRuns = [];
|
|
2516
|
+
let collecting = false;
|
|
2517
|
+
for (const child of paragraph.children) {
|
|
2518
|
+
if (!isElement2(child) || child.name !== "w:r") continue;
|
|
2519
|
+
if (child === fromEntry.run) collecting = true;
|
|
2520
|
+
if (collecting) {
|
|
2521
|
+
touchedRuns.push(child);
|
|
2522
|
+
if (child === toEntry.run) break;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
let conflict = false;
|
|
2526
|
+
for (const run of touchedRuns) {
|
|
2527
|
+
if (claimedRuns.has(run)) {
|
|
2528
|
+
rejectedDetails.push({
|
|
2529
|
+
id: s.id,
|
|
2530
|
+
reason: "Targets the same text run as another suggestion"
|
|
2531
|
+
});
|
|
2532
|
+
conflict = true;
|
|
2533
|
+
break;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
if (!conflict) {
|
|
2537
|
+
for (const run of touchedRuns) {
|
|
2538
|
+
claimedRuns.set(run, s.id);
|
|
2539
|
+
}
|
|
2540
|
+
validAfterRunCheck.push(s);
|
|
2541
|
+
}
|
|
1641
2542
|
}
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
);
|
|
2543
|
+
const doc = offsetMap.doc;
|
|
2544
|
+
const idMatches = documentXml.match(/w:id="(\d+)"/g) || [];
|
|
2545
|
+
let maxId = 0;
|
|
2546
|
+
for (const m of idMatches) {
|
|
2547
|
+
const num = parseInt(m.match(/\d+/)[0], 10);
|
|
2548
|
+
if (num > maxId) maxId = num;
|
|
2549
|
+
}
|
|
2550
|
+
let applied = 0;
|
|
2551
|
+
const appliedSuggestions = [];
|
|
2552
|
+
for (const s of validAfterRunCheck) {
|
|
2553
|
+
maxId += 2;
|
|
2554
|
+
const result = applySingleSuggestion(offsetMap, {
|
|
2555
|
+
from: s.from,
|
|
2556
|
+
to: s.to,
|
|
2557
|
+
newText: s.newText,
|
|
2558
|
+
author: options.author,
|
|
2559
|
+
date,
|
|
2560
|
+
revisionId: maxId - 1
|
|
2561
|
+
});
|
|
2562
|
+
if (result.ok) {
|
|
2563
|
+
applied++;
|
|
2564
|
+
appliedSuggestions.push(s);
|
|
2565
|
+
} else {
|
|
2566
|
+
rejectedDetails.push({ id: s.id, reason: result.reason ?? "Unknown error" });
|
|
2567
|
+
}
|
|
1647
2568
|
}
|
|
1648
|
-
|
|
2569
|
+
const serialized = render(doc, { xmlMode: true });
|
|
2570
|
+
zip.file("word/document.xml", serialized);
|
|
2571
|
+
const commentsResolved = await resolveWordComments(
|
|
2572
|
+
zip,
|
|
2573
|
+
offsetMap.commentParagraphIds,
|
|
2574
|
+
appliedSuggestions
|
|
2575
|
+
);
|
|
2576
|
+
const buffer3 = Buffer.from(await zip.generateAsync({ type: "nodebuffer" }));
|
|
2577
|
+
return {
|
|
2578
|
+
buffer: buffer3,
|
|
2579
|
+
applied,
|
|
2580
|
+
rejected: rejectedDetails.length,
|
|
2581
|
+
rejectedDetails,
|
|
2582
|
+
commentsResolved
|
|
2583
|
+
};
|
|
1649
2584
|
}
|
|
1650
|
-
function
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
const level = parseInt(match[1], 10);
|
|
1659
|
-
if (level >= 1 && level <= 6) return level;
|
|
1660
|
-
}
|
|
2585
|
+
async function resolveWordComments(zip, commentParagraphIds, appliedSuggestions) {
|
|
2586
|
+
const toResolve = [];
|
|
2587
|
+
for (const s of appliedSuggestions) {
|
|
2588
|
+
if (!s.importCommentId) continue;
|
|
2589
|
+
const paraId = commentParagraphIds.get(s.importCommentId);
|
|
2590
|
+
if (!paraId) {
|
|
2591
|
+
console.warn(`[docx-apply] No paraId for comment ${s.importCommentId}; skipping resolution`);
|
|
2592
|
+
continue;
|
|
1661
2593
|
}
|
|
2594
|
+
toResolve.push({ commentId: s.importCommentId, paraId });
|
|
1662
2595
|
}
|
|
1663
|
-
return 0;
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
2596
|
+
if (toResolve.length === 0) return 0;
|
|
2597
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2598
|
+
const unique = toResolve.filter((r) => {
|
|
2599
|
+
if (seen.has(r.commentId)) return false;
|
|
2600
|
+
seen.add(r.commentId);
|
|
2601
|
+
return true;
|
|
2602
|
+
});
|
|
2603
|
+
const existingXml = await zip.file("word/commentsExtended.xml")?.async("text");
|
|
2604
|
+
if (existingXml) {
|
|
2605
|
+
const doc = parseDocument4(existingXml, { xmlMode: true });
|
|
2606
|
+
const root = doc.children.find((c) => isElement2(c) && c.name === "w15:commentsEx");
|
|
2607
|
+
if (root) {
|
|
2608
|
+
const existingParaIds = /* @__PURE__ */ new Set();
|
|
2609
|
+
for (const child of root.children) {
|
|
2610
|
+
if (isElement2(child) && child.name === "w15:commentEx") {
|
|
2611
|
+
const pid = getAttr(child, "w15:paraId");
|
|
2612
|
+
if (pid) existingParaIds.add(pid);
|
|
2613
|
+
}
|
|
1677
2614
|
}
|
|
1678
|
-
const
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
content,
|
|
1686
|
-
status: "pending",
|
|
1687
|
-
timestamp: comment.date ? new Date(comment.date).getTime() : Date.now()
|
|
1688
|
-
};
|
|
1689
|
-
if (result.fullyAnchored) {
|
|
1690
|
-
annotation.relRange = result.relRange;
|
|
2615
|
+
for (const { paraId } of unique) {
|
|
2616
|
+
if (existingParaIds.has(paraId)) continue;
|
|
2617
|
+
const entry = new Element("w15:commentEx", {
|
|
2618
|
+
"w15:paraId": paraId,
|
|
2619
|
+
"w15:done": "1"
|
|
2620
|
+
});
|
|
2621
|
+
insertChild(root, root.children.length, entry);
|
|
1691
2622
|
}
|
|
1692
|
-
|
|
1693
|
-
injected++;
|
|
2623
|
+
zip.file("word/commentsExtended.xml", render(doc, { xmlMode: true }));
|
|
1694
2624
|
}
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
2625
|
+
} else {
|
|
2626
|
+
const entries = unique.map((r) => `<w15:commentEx w15:paraId="${r.paraId}" w15:done="1"/>`).join("");
|
|
2627
|
+
const newXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:commentsEx xmlns:w15="${W15_NS}">${entries}</w15:commentsEx>`;
|
|
2628
|
+
zip.file("word/commentsExtended.xml", newXml);
|
|
2629
|
+
const relsXml = await zip.file("word/_rels/document.xml.rels")?.async("text");
|
|
2630
|
+
if (relsXml) {
|
|
2631
|
+
const relsDoc = parseDocument4(relsXml, { xmlMode: true });
|
|
2632
|
+
const relsRoot = relsDoc.children.find((c) => isElement2(c) && c.name === "Relationships");
|
|
2633
|
+
if (relsRoot) {
|
|
2634
|
+
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));
|
|
2635
|
+
const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 100;
|
|
2636
|
+
const rel = new Element("Relationship", {
|
|
2637
|
+
Id: `rId${nextId}`,
|
|
2638
|
+
Type: "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
|
|
2639
|
+
Target: "commentsExtended.xml"
|
|
2640
|
+
});
|
|
2641
|
+
insertChild(relsRoot, relsRoot.children.length, rel);
|
|
2642
|
+
zip.file("word/_rels/document.xml.rels", render(relsDoc, { xmlMode: true }));
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
const ctXml = await zip.file("[Content_Types].xml")?.async("text");
|
|
2646
|
+
if (ctXml) {
|
|
2647
|
+
const ctDoc = parseDocument4(ctXml, { xmlMode: true });
|
|
2648
|
+
const typesRoot = ctDoc.children.find((c) => isElement2(c) && c.name === "Types");
|
|
2649
|
+
if (typesRoot) {
|
|
2650
|
+
const override = new Element("Override", {
|
|
2651
|
+
PartName: "/word/commentsExtended.xml",
|
|
2652
|
+
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"
|
|
2653
|
+
});
|
|
2654
|
+
insertChild(typesRoot, typesRoot.children.length, override);
|
|
2655
|
+
zip.file("[Content_Types].xml", render(ctDoc, { xmlMode: true }));
|
|
2656
|
+
}
|
|
1718
2657
|
}
|
|
1719
2658
|
}
|
|
1720
|
-
return
|
|
2659
|
+
return unique.length;
|
|
1721
2660
|
}
|
|
1722
|
-
var
|
|
1723
|
-
|
|
2661
|
+
var COMPLEX_RUN_ELEMENTS, W15_NS;
|
|
2662
|
+
var init_docx_apply = __esm({
|
|
2663
|
+
"src/server/file-io/docx-apply.ts"() {
|
|
1724
2664
|
"use strict";
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
2665
|
+
init_dist2();
|
|
2666
|
+
init_docx_walker();
|
|
2667
|
+
COMPLEX_RUN_ELEMENTS = /* @__PURE__ */ new Set([
|
|
2668
|
+
"w:footnoteReference",
|
|
2669
|
+
"w:endnoteReference",
|
|
2670
|
+
"w:drawing",
|
|
2671
|
+
"w:pict",
|
|
2672
|
+
"w:fldChar"
|
|
2673
|
+
]);
|
|
2674
|
+
W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml";
|
|
1730
2675
|
}
|
|
1731
2676
|
});
|
|
1732
2677
|
|
|
@@ -1741,6 +2686,17 @@ async function atomicWrite(filePath, content) {
|
|
|
1741
2686
|
await fs2.writeFile(tempPath, content, "utf-8");
|
|
1742
2687
|
await fs2.rename(tempPath, filePath);
|
|
1743
2688
|
}
|
|
2689
|
+
async function atomicWriteBuffer(filePath, content) {
|
|
2690
|
+
const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
|
|
2691
|
+
await fs2.writeFile(tempPath, content);
|
|
2692
|
+
try {
|
|
2693
|
+
await fs2.rename(tempPath, filePath);
|
|
2694
|
+
} catch (err) {
|
|
2695
|
+
await fs2.unlink(tempPath).catch(() => {
|
|
2696
|
+
});
|
|
2697
|
+
throw err;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
1744
2700
|
var markdownAdapter, plaintextAdapter, docxAdapter, adapters;
|
|
1745
2701
|
var init_file_io = __esm({
|
|
1746
2702
|
"src/server/file-io/index.ts"() {
|
|
@@ -1749,6 +2705,7 @@ var init_file_io = __esm({
|
|
|
1749
2705
|
init_docx();
|
|
1750
2706
|
init_docx_comments();
|
|
1751
2707
|
init_document_model();
|
|
2708
|
+
init_docx_apply();
|
|
1752
2709
|
markdownAdapter = {
|
|
1753
2710
|
canSave: true,
|
|
1754
2711
|
load(doc, content) {
|
|
@@ -1813,7 +2770,13 @@ async function openFileByPath(filePath, options) {
|
|
|
1813
2770
|
let resolved = path5.resolve(filePath);
|
|
1814
2771
|
try {
|
|
1815
2772
|
resolved = fsSync.realpathSync(resolved);
|
|
1816
|
-
} catch {
|
|
2773
|
+
} catch (err) {
|
|
2774
|
+
const code = err.code;
|
|
2775
|
+
if (code !== "ENOENT") {
|
|
2776
|
+
console.error(
|
|
2777
|
+
`[Tandem] realpathSync failed for ${filePath} (${code}), using path.resolve fallback`
|
|
2778
|
+
);
|
|
2779
|
+
}
|
|
1817
2780
|
resolved = path5.resolve(filePath);
|
|
1818
2781
|
}
|
|
1819
2782
|
if (process.platform === "win32" && (resolved.startsWith("\\\\") || resolved.startsWith("//"))) {
|
|
@@ -2098,25 +3061,29 @@ function toDocListEntry(d) {
|
|
|
2098
3061
|
};
|
|
2099
3062
|
}
|
|
2100
3063
|
function broadcastOpenDocs() {
|
|
3064
|
+
const docList = Array.from(openDocs.values()).map(toDocListEntry);
|
|
3065
|
+
const id = activeDocId;
|
|
2101
3066
|
try {
|
|
2102
|
-
const docList = Array.from(openDocs.values()).map(toDocListEntry);
|
|
2103
|
-
const id = activeDocId;
|
|
2104
3067
|
const ctrl = getOrCreateDocument(CTRL_ROOM);
|
|
2105
3068
|
const ctrlMeta = ctrl.getMap(Y_MAP_DOCUMENT_META);
|
|
2106
3069
|
ctrl.transact(() => {
|
|
2107
3070
|
ctrlMeta.set("openDocuments", docList);
|
|
2108
3071
|
ctrlMeta.set("activeDocumentId", id);
|
|
2109
3072
|
}, MCP_ORIGIN);
|
|
2110
|
-
|
|
3073
|
+
} catch (err) {
|
|
3074
|
+
console.error("[Tandem] broadcastOpenDocs: failed to update CTRL_ROOM:", err);
|
|
3075
|
+
}
|
|
3076
|
+
for (const [docId] of openDocs) {
|
|
3077
|
+
try {
|
|
2111
3078
|
const ydoc = getOrCreateDocument(docId);
|
|
2112
3079
|
const meta = ydoc.getMap(Y_MAP_DOCUMENT_META);
|
|
2113
3080
|
ydoc.transact(() => {
|
|
2114
3081
|
meta.set("openDocuments", docList);
|
|
2115
3082
|
meta.set("activeDocumentId", id);
|
|
2116
3083
|
}, MCP_ORIGIN);
|
|
3084
|
+
} catch (err) {
|
|
3085
|
+
console.error(`[Tandem] broadcastOpenDocs: failed to update doc ${docId}:`, err);
|
|
2117
3086
|
}
|
|
2118
|
-
} catch (err) {
|
|
2119
|
-
console.error("[Tandem] broadcastOpenDocs error:", err);
|
|
2120
3087
|
}
|
|
2121
3088
|
}
|
|
2122
3089
|
async function closeDocumentById(id) {
|
|
@@ -2184,7 +3151,8 @@ async function restoreOpenDocuments(previousActiveDocId) {
|
|
|
2184
3151
|
const code = err.code;
|
|
2185
3152
|
if (code === "ENOENT") {
|
|
2186
3153
|
console.error(`[Tandem] Skipping deleted file (removing stale session): ${filePath}`);
|
|
2187
|
-
deleteSession(filePath).catch(() => {
|
|
3154
|
+
deleteSession(filePath).catch((err2) => {
|
|
3155
|
+
console.error(`[Tandem] Failed to delete stale session for ${filePath}:`, err2);
|
|
2188
3156
|
});
|
|
2189
3157
|
} else {
|
|
2190
3158
|
console.error(`[Tandem] Failed to restore ${filePath}:`, err);
|
|
@@ -2355,15 +3323,16 @@ function attachObservers(docName, doc) {
|
|
|
2355
3323
|
if (txn.origin === MCP_ORIGIN) return;
|
|
2356
3324
|
if (event.keysChanged.has("selection")) {
|
|
2357
3325
|
const selection = userAwareness.get("selection");
|
|
3326
|
+
if (!selection || selection.from === selection.to) return;
|
|
2358
3327
|
pushEvent({
|
|
2359
3328
|
id: generateEventId(),
|
|
2360
3329
|
type: "selection:changed",
|
|
2361
3330
|
timestamp: Date.now(),
|
|
2362
3331
|
documentId: docName,
|
|
2363
3332
|
payload: {
|
|
2364
|
-
from: selection
|
|
2365
|
-
to: selection
|
|
2366
|
-
selectedText: selection
|
|
3333
|
+
from: selection.from,
|
|
3334
|
+
to: selection.to,
|
|
3335
|
+
selectedText: selection.selectedText ?? ""
|
|
2367
3336
|
}
|
|
2368
3337
|
});
|
|
2369
3338
|
}
|
|
@@ -2580,7 +3549,7 @@ var init_launcher = __esm({
|
|
|
2580
3549
|
});
|
|
2581
3550
|
|
|
2582
3551
|
// src/server/index.ts
|
|
2583
|
-
import
|
|
3552
|
+
import path9 from "path";
|
|
2584
3553
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2585
3554
|
|
|
2586
3555
|
// src/server/mcp/server.ts
|
|
@@ -3272,7 +4241,14 @@ function createAnnotation(map, ydoc, type, anchored, content, extras) {
|
|
|
3272
4241
|
}
|
|
3273
4242
|
function collectAnnotations(map) {
|
|
3274
4243
|
const result = [];
|
|
3275
|
-
map.forEach((value) =>
|
|
4244
|
+
map.forEach((value, key) => {
|
|
4245
|
+
const ann = value;
|
|
4246
|
+
if (ann && typeof ann === "object" && typeof ann.id === "string" && typeof ann.type === "string" && typeof ann.status === "string" && ann.range && typeof ann.range.from === "number" && typeof ann.range.to === "number") {
|
|
4247
|
+
result.push(ann);
|
|
4248
|
+
} else {
|
|
4249
|
+
console.warn(`[Tandem] Skipping malformed annotation entry: ${key}`);
|
|
4250
|
+
}
|
|
4251
|
+
});
|
|
3276
4252
|
return result;
|
|
3277
4253
|
}
|
|
3278
4254
|
function registerAnnotationTools(server) {
|
|
@@ -3578,6 +4554,189 @@ init_constants();
|
|
|
3578
4554
|
init_document_model();
|
|
3579
4555
|
init_file_opener();
|
|
3580
4556
|
init_document_service();
|
|
4557
|
+
|
|
4558
|
+
// src/server/mcp/docx-apply.ts
|
|
4559
|
+
init_document_service();
|
|
4560
|
+
init_constants();
|
|
4561
|
+
init_positions2();
|
|
4562
|
+
init_document_model();
|
|
4563
|
+
init_file_io();
|
|
4564
|
+
import { z as z4 } from "zod";
|
|
4565
|
+
import fs5 from "fs/promises";
|
|
4566
|
+
import path8 from "path";
|
|
4567
|
+
async function applyChangesCore(documentId, author, backupPath) {
|
|
4568
|
+
const r = requireDocument(documentId);
|
|
4569
|
+
if (!r) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
|
|
4570
|
+
const { doc: ydoc, filePath } = r;
|
|
4571
|
+
const docState = getCurrentDoc(documentId);
|
|
4572
|
+
if (!docState) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
|
|
4573
|
+
if (docState.format !== "docx") {
|
|
4574
|
+
throw Object.assign(
|
|
4575
|
+
new Error(`Apply changes is only supported for .docx files (this is ${docState.format}).`),
|
|
4576
|
+
{ code: "UNSUPPORTED_FORMAT" }
|
|
4577
|
+
);
|
|
4578
|
+
}
|
|
4579
|
+
if (docState.source !== "file") {
|
|
4580
|
+
throw Object.assign(new Error("Cannot apply changes to uploaded files. Save to disk first."), {
|
|
4581
|
+
code: "INVALID_PATH"
|
|
4582
|
+
});
|
|
4583
|
+
}
|
|
4584
|
+
if (backupPath) {
|
|
4585
|
+
const resolvedBp = path8.resolve(backupPath);
|
|
4586
|
+
if (process.platform === "win32" && (resolvedBp.startsWith("\\\\") || resolvedBp.startsWith("//"))) {
|
|
4587
|
+
throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
|
|
4588
|
+
code: "INVALID_PATH"
|
|
4589
|
+
});
|
|
4590
|
+
}
|
|
4591
|
+
}
|
|
4592
|
+
const map = ydoc.getMap(Y_MAP_ANNOTATIONS);
|
|
4593
|
+
const suggestions = [];
|
|
4594
|
+
let pendingCount = 0;
|
|
4595
|
+
for (const [, raw] of map) {
|
|
4596
|
+
const ann = raw;
|
|
4597
|
+
if (ann.type !== "suggestion") continue;
|
|
4598
|
+
if (ann.status === "pending") {
|
|
4599
|
+
pendingCount++;
|
|
4600
|
+
continue;
|
|
4601
|
+
}
|
|
4602
|
+
if (ann.status !== "accepted") continue;
|
|
4603
|
+
let from = ann.range.from;
|
|
4604
|
+
let to = ann.range.to;
|
|
4605
|
+
if (ann.relRange) {
|
|
4606
|
+
const resolvedFrom = relPosToFlatOffset(ydoc, ann.relRange.fromRel);
|
|
4607
|
+
const resolvedTo = relPosToFlatOffset(ydoc, ann.relRange.toRel);
|
|
4608
|
+
if (resolvedFrom !== null && resolvedTo !== null) {
|
|
4609
|
+
if (resolvedFrom > resolvedTo) {
|
|
4610
|
+
console.error(
|
|
4611
|
+
`[docx-apply] Inverted CRDT range for ${ann.id}: [${resolvedFrom}, ${resolvedTo}]; skipping`
|
|
4612
|
+
);
|
|
4613
|
+
continue;
|
|
4614
|
+
}
|
|
4615
|
+
from = resolvedFrom;
|
|
4616
|
+
to = resolvedTo;
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4619
|
+
let newText = "";
|
|
4620
|
+
try {
|
|
4621
|
+
const parsed = JSON.parse(ann.content);
|
|
4622
|
+
newText = parsed.newText;
|
|
4623
|
+
} catch {
|
|
4624
|
+
newText = ann.content;
|
|
4625
|
+
}
|
|
4626
|
+
let importCommentId;
|
|
4627
|
+
if (ann.id.startsWith("import-")) {
|
|
4628
|
+
const withoutPrefix = ann.id.slice("import-".length);
|
|
4629
|
+
const lastDash = withoutPrefix.lastIndexOf("-");
|
|
4630
|
+
if (lastDash > 0) {
|
|
4631
|
+
importCommentId = withoutPrefix.slice(0, lastDash);
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
suggestions.push({
|
|
4635
|
+
id: ann.id,
|
|
4636
|
+
from,
|
|
4637
|
+
to,
|
|
4638
|
+
newText,
|
|
4639
|
+
textSnapshot: ann.textSnapshot,
|
|
4640
|
+
importCommentId
|
|
4641
|
+
});
|
|
4642
|
+
}
|
|
4643
|
+
if (suggestions.length === 0) {
|
|
4644
|
+
throw Object.assign(new Error("No accepted suggestions to apply."), { code: "NO_SUGGESTIONS" });
|
|
4645
|
+
}
|
|
4646
|
+
const ydocFlatText = extractText(ydoc);
|
|
4647
|
+
const buffer3 = await fs5.readFile(filePath);
|
|
4648
|
+
const result = await applyTrackedChanges(buffer3, suggestions, {
|
|
4649
|
+
author: author ?? "Tandem Review",
|
|
4650
|
+
ydocFlatText
|
|
4651
|
+
});
|
|
4652
|
+
let resolvedBackup = backupPath ?? filePath.replace(/\.docx$/i, ".backup.docx");
|
|
4653
|
+
try {
|
|
4654
|
+
await fs5.access(resolvedBackup);
|
|
4655
|
+
const ext = path8.extname(resolvedBackup);
|
|
4656
|
+
const base = resolvedBackup.slice(0, -ext.length);
|
|
4657
|
+
resolvedBackup = `${base}-${Date.now()}${ext}`;
|
|
4658
|
+
} catch {
|
|
4659
|
+
}
|
|
4660
|
+
await fs5.copyFile(filePath, resolvedBackup);
|
|
4661
|
+
const [origStat, backupStat] = await Promise.all([fs5.stat(filePath), fs5.stat(resolvedBackup)]);
|
|
4662
|
+
if (origStat.size !== backupStat.size) {
|
|
4663
|
+
throw Object.assign(new Error("Backup verification failed: file sizes do not match."), {
|
|
4664
|
+
code: "BACKUP_FAILED"
|
|
4665
|
+
});
|
|
4666
|
+
}
|
|
4667
|
+
await atomicWriteBuffer(filePath, result.buffer);
|
|
4668
|
+
const output = {
|
|
4669
|
+
applied: result.applied,
|
|
4670
|
+
rejected: result.rejected,
|
|
4671
|
+
rejectedDetails: result.rejectedDetails,
|
|
4672
|
+
commentsResolved: result.commentsResolved,
|
|
4673
|
+
backupPath: resolvedBackup
|
|
4674
|
+
};
|
|
4675
|
+
if (pendingCount > 0) {
|
|
4676
|
+
output.pendingWarning = `${pendingCount} suggestion(s) are still pending review and were not applied.`;
|
|
4677
|
+
}
|
|
4678
|
+
return output;
|
|
4679
|
+
}
|
|
4680
|
+
function registerApplyTools(server) {
|
|
4681
|
+
server.tool(
|
|
4682
|
+
"tandem_applyChanges",
|
|
4683
|
+
"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.",
|
|
4684
|
+
{
|
|
4685
|
+
documentId: z4.string().optional().describe("Target document ID (defaults to active doc)"),
|
|
4686
|
+
author: z4.string().optional().describe("Author name for tracked changes (default: 'Tandem Review')"),
|
|
4687
|
+
backupPath: z4.string().optional().describe("Custom backup path (default: {name}.backup.docx)")
|
|
4688
|
+
},
|
|
4689
|
+
withErrorBoundary("tandem_applyChanges", async (args) => {
|
|
4690
|
+
try {
|
|
4691
|
+
const result = await applyChangesCore(args.documentId, args.author, args.backupPath);
|
|
4692
|
+
return mcpSuccess(result);
|
|
4693
|
+
} catch (err) {
|
|
4694
|
+
const e = err;
|
|
4695
|
+
if (e.code === "NO_DOCUMENT") return noDocumentError();
|
|
4696
|
+
if (e.code === "NO_SUGGESTIONS") return mcpError("NO_SUGGESTIONS", e.message);
|
|
4697
|
+
if (e.code === "UNSUPPORTED_FORMAT") return mcpError("FORMAT_ERROR", e.message);
|
|
4698
|
+
if (e.code === "INVALID_PATH") return mcpError("FORMAT_ERROR", e.message);
|
|
4699
|
+
if (e.code === "BACKUP_FAILED") return mcpError("BACKUP_FAILED", e.message);
|
|
4700
|
+
throw err;
|
|
4701
|
+
}
|
|
4702
|
+
})
|
|
4703
|
+
);
|
|
4704
|
+
server.tool(
|
|
4705
|
+
"tandem_restoreBackup",
|
|
4706
|
+
"Restore a .docx file from its backup ({name}.backup.docx). Use after tandem_applyChanges if the result is unsatisfactory.",
|
|
4707
|
+
{
|
|
4708
|
+
documentId: z4.string().optional().describe("Target document ID (defaults to active doc)")
|
|
4709
|
+
},
|
|
4710
|
+
withErrorBoundary("tandem_restoreBackup", async (args) => {
|
|
4711
|
+
const r = requireDocument(args.documentId);
|
|
4712
|
+
if (!r) return noDocumentError();
|
|
4713
|
+
const { filePath } = r;
|
|
4714
|
+
const backupPath = filePath.replace(/\.docx$/i, ".backup.docx");
|
|
4715
|
+
try {
|
|
4716
|
+
await fs5.access(backupPath);
|
|
4717
|
+
} catch (err) {
|
|
4718
|
+
if (err.code === "ENOENT") {
|
|
4719
|
+
return mcpError("FILE_NOT_FOUND", `No backup file found at ${backupPath}`);
|
|
4720
|
+
}
|
|
4721
|
+
throw err;
|
|
4722
|
+
}
|
|
4723
|
+
await fs5.copyFile(backupPath, filePath);
|
|
4724
|
+
const [backupStat, restoredStat] = await Promise.all([
|
|
4725
|
+
fs5.stat(backupPath),
|
|
4726
|
+
fs5.stat(filePath)
|
|
4727
|
+
]);
|
|
4728
|
+
if (backupStat.size !== restoredStat.size) {
|
|
4729
|
+
throw new Error("Restore verification failed: file sizes do not match.");
|
|
4730
|
+
}
|
|
4731
|
+
return mcpSuccess({
|
|
4732
|
+
message: `Restored ${path8.basename(filePath)} from backup.`,
|
|
4733
|
+
restoredFrom: backupPath
|
|
4734
|
+
});
|
|
4735
|
+
})
|
|
4736
|
+
);
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
// src/server/mcp/api-routes.ts
|
|
3581
4740
|
function isHostAllowed(host) {
|
|
3582
4741
|
const reqHost = (host ?? "").split(":")[0];
|
|
3583
4742
|
return reqHost === "localhost" || reqHost === "127.0.0.1";
|
|
@@ -3590,9 +4749,11 @@ function errorCodeToHttpStatus(code) {
|
|
|
3590
4749
|
switch (code) {
|
|
3591
4750
|
case "ENOENT":
|
|
3592
4751
|
case "FILE_NOT_FOUND":
|
|
4752
|
+
case "NO_DOCUMENT":
|
|
3593
4753
|
return 404;
|
|
3594
4754
|
case "INVALID_PATH":
|
|
3595
4755
|
case "UNSUPPORTED_FORMAT":
|
|
4756
|
+
case "NO_SUGGESTIONS":
|
|
3596
4757
|
return 400;
|
|
3597
4758
|
case "FILE_TOO_LARGE":
|
|
3598
4759
|
return 413;
|
|
@@ -3601,6 +4762,8 @@ function errorCodeToHttpStatus(code) {
|
|
|
3601
4762
|
return 423;
|
|
3602
4763
|
case "EACCES":
|
|
3603
4764
|
return 403;
|
|
4765
|
+
case "BACKUP_FAILED":
|
|
4766
|
+
return 500;
|
|
3604
4767
|
default:
|
|
3605
4768
|
return 500;
|
|
3606
4769
|
}
|
|
@@ -3624,11 +4787,13 @@ function errorCodeToLabel(code) {
|
|
|
3624
4787
|
switch (code) {
|
|
3625
4788
|
case "ENOENT":
|
|
3626
4789
|
case "FILE_NOT_FOUND":
|
|
3627
|
-
|
|
4790
|
+
case "NO_DOCUMENT":
|
|
4791
|
+
return "NOT_FOUND";
|
|
3628
4792
|
case "INVALID_PATH":
|
|
3629
4793
|
return "INVALID_PATH";
|
|
3630
4794
|
case "UNSUPPORTED_FORMAT":
|
|
3631
|
-
|
|
4795
|
+
case "NO_SUGGESTIONS":
|
|
4796
|
+
return "BAD_REQUEST";
|
|
3632
4797
|
case "FILE_TOO_LARGE":
|
|
3633
4798
|
return "FILE_TOO_LARGE";
|
|
3634
4799
|
case "EBUSY":
|
|
@@ -3636,6 +4801,8 @@ function errorCodeToLabel(code) {
|
|
|
3636
4801
|
return "FILE_LOCKED";
|
|
3637
4802
|
case "EACCES":
|
|
3638
4803
|
return "PERMISSION_DENIED";
|
|
4804
|
+
case "BACKUP_FAILED":
|
|
4805
|
+
return "INTERNAL";
|
|
3639
4806
|
default:
|
|
3640
4807
|
return "INTERNAL";
|
|
3641
4808
|
}
|
|
@@ -3676,7 +4843,11 @@ function notifyStreamHandler(req, res) {
|
|
|
3676
4843
|
const keepalive = setInterval(() => {
|
|
3677
4844
|
try {
|
|
3678
4845
|
if (!res.writableEnded) res.write(": keepalive\n\n");
|
|
3679
|
-
} catch {
|
|
4846
|
+
} catch (err) {
|
|
4847
|
+
console.error(
|
|
4848
|
+
"[NotifyStream] Keepalive write failed, cleaning up:",
|
|
4849
|
+
err instanceof Error ? err.message : err
|
|
4850
|
+
);
|
|
3680
4851
|
cleanup();
|
|
3681
4852
|
}
|
|
3682
4853
|
}, CHANNEL_SSE_KEEPALIVE_MS);
|
|
@@ -3767,11 +4938,37 @@ function registerApiRoutes(app, largeBody) {
|
|
|
3767
4938
|
sendApiError(res, err);
|
|
3768
4939
|
}
|
|
3769
4940
|
});
|
|
4941
|
+
app.options("/api/apply-changes", apiMiddleware);
|
|
4942
|
+
app.post("/api/apply-changes", apiMiddleware, largeBody, async (req, res) => {
|
|
4943
|
+
const { documentId, author, backupPath } = req.body ?? {};
|
|
4944
|
+
if (documentId !== void 0 && typeof documentId !== "string") {
|
|
4945
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "documentId must be a string" });
|
|
4946
|
+
return;
|
|
4947
|
+
}
|
|
4948
|
+
if (author !== void 0 && typeof author !== "string") {
|
|
4949
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "author must be a string" });
|
|
4950
|
+
return;
|
|
4951
|
+
}
|
|
4952
|
+
if (backupPath !== void 0 && typeof backupPath !== "string") {
|
|
4953
|
+
res.status(400).json({ error: "BAD_REQUEST", message: "backupPath must be a string" });
|
|
4954
|
+
return;
|
|
4955
|
+
}
|
|
4956
|
+
try {
|
|
4957
|
+
const result = await applyChangesCore(
|
|
4958
|
+
documentId,
|
|
4959
|
+
author,
|
|
4960
|
+
backupPath
|
|
4961
|
+
);
|
|
4962
|
+
res.json({ data: result });
|
|
4963
|
+
} catch (err) {
|
|
4964
|
+
sendApiError(res, err);
|
|
4965
|
+
}
|
|
4966
|
+
});
|
|
3770
4967
|
}
|
|
3771
4968
|
|
|
3772
4969
|
// src/server/mcp/awareness.ts
|
|
3773
4970
|
init_provider();
|
|
3774
|
-
import { z as
|
|
4971
|
+
import { z as z5 } from "zod";
|
|
3775
4972
|
init_utils();
|
|
3776
4973
|
init_constants();
|
|
3777
4974
|
init_queue();
|
|
@@ -3781,7 +4978,7 @@ function registerAwarenessTools(server) {
|
|
|
3781
4978
|
"tandem_getSelections",
|
|
3782
4979
|
"Get text currently selected by the user in the editor",
|
|
3783
4980
|
{
|
|
3784
|
-
documentId:
|
|
4981
|
+
documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
|
|
3785
4982
|
},
|
|
3786
4983
|
withErrorBoundary("tandem_getSelections", async ({ documentId }) => {
|
|
3787
4984
|
const current = getCurrentDoc(documentId);
|
|
@@ -3802,7 +4999,7 @@ function registerAwarenessTools(server) {
|
|
|
3802
4999
|
"tandem_getActivity",
|
|
3803
5000
|
"Check if the user is actively editing and where their cursor is",
|
|
3804
5001
|
{
|
|
3805
|
-
documentId:
|
|
5002
|
+
documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
|
|
3806
5003
|
},
|
|
3807
5004
|
withErrorBoundary("tandem_getActivity", async ({ documentId }) => {
|
|
3808
5005
|
const current = getCurrentDoc(documentId);
|
|
@@ -3831,7 +5028,7 @@ function registerAwarenessTools(server) {
|
|
|
3831
5028
|
"tandem_checkInbox",
|
|
3832
5029
|
"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
5030
|
{
|
|
3834
|
-
documentId:
|
|
5031
|
+
documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
|
|
3835
5032
|
},
|
|
3836
5033
|
withErrorBoundary("tandem_checkInbox", async ({ documentId }) => {
|
|
3837
5034
|
const current = getCurrentDoc(documentId);
|
|
@@ -3924,9 +5121,9 @@ function registerAwarenessTools(server) {
|
|
|
3924
5121
|
"tandem_reply",
|
|
3925
5122
|
"Send a chat message to the user in the Tandem sidebar. Use this to respond to chat messages from tandem_checkInbox.",
|
|
3926
5123
|
{
|
|
3927
|
-
text:
|
|
3928
|
-
replyTo:
|
|
3929
|
-
documentId:
|
|
5124
|
+
text: z5.string().describe("Your message to the user"),
|
|
5125
|
+
replyTo: z5.string().optional().describe("ID of the user message you are replying to"),
|
|
5126
|
+
documentId: z5.string().optional().describe("Document context for this reply (defaults to active document)")
|
|
3930
5127
|
},
|
|
3931
5128
|
withErrorBoundary("tandem_reply", async ({ text, replyTo, documentId }) => {
|
|
3932
5129
|
const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
|
|
@@ -4120,22 +5317,31 @@ init_constants();
|
|
|
4120
5317
|
init_types();
|
|
4121
5318
|
init_queue();
|
|
4122
5319
|
init_provider();
|
|
4123
|
-
import { z as
|
|
5320
|
+
import { z as z6 } from "zod";
|
|
4124
5321
|
function getFullText(docName) {
|
|
4125
5322
|
const doc = getOrCreateDocument(docName);
|
|
4126
5323
|
return extractText(doc);
|
|
4127
5324
|
}
|
|
4128
5325
|
function searchText(fullText, query, useRegex) {
|
|
5326
|
+
const MAX_MATCHES = 1e4;
|
|
4129
5327
|
const matches = [];
|
|
4130
5328
|
try {
|
|
4131
5329
|
const pattern = useRegex ? new RegExp(query, "gi") : new RegExp(escapeRegex(query), "gi");
|
|
4132
5330
|
let match;
|
|
5331
|
+
const start = Date.now();
|
|
4133
5332
|
while ((match = pattern.exec(fullText)) !== null) {
|
|
4134
5333
|
matches.push({
|
|
4135
5334
|
from: toFlatOffset(match.index),
|
|
4136
5335
|
to: toFlatOffset(match.index + match[0].length),
|
|
4137
5336
|
text: match[0]
|
|
4138
5337
|
});
|
|
5338
|
+
if (matches.length >= MAX_MATCHES) {
|
|
5339
|
+
return { matches, error: `Search capped at ${MAX_MATCHES} matches` };
|
|
5340
|
+
}
|
|
5341
|
+
if (Date.now() - start > 2e3) {
|
|
5342
|
+
return { matches, error: "Search timed out \u2014 simplify the regex pattern" };
|
|
5343
|
+
}
|
|
5344
|
+
if (match[0].length === 0) pattern.lastIndex++;
|
|
4139
5345
|
}
|
|
4140
5346
|
} catch (err) {
|
|
4141
5347
|
return { matches: [], error: `Invalid regex: ${getErrorMessage(err)}` };
|
|
@@ -4176,9 +5382,9 @@ function registerNavigationTools(server) {
|
|
|
4176
5382
|
"tandem_search",
|
|
4177
5383
|
"Search for text in the document. Returns matching positions.",
|
|
4178
5384
|
{
|
|
4179
|
-
query:
|
|
4180
|
-
regex:
|
|
4181
|
-
documentId:
|
|
5385
|
+
query: z6.string().describe("Search query (supports regex)"),
|
|
5386
|
+
regex: z6.boolean().optional().describe("Treat query as regex"),
|
|
5387
|
+
documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
|
|
4182
5388
|
},
|
|
4183
5389
|
withErrorBoundary("tandem_search", async ({ query, regex, documentId }) => {
|
|
4184
5390
|
const current = getCurrentDoc(documentId);
|
|
@@ -4193,9 +5399,9 @@ function registerNavigationTools(server) {
|
|
|
4193
5399
|
"tandem_resolveRange",
|
|
4194
5400
|
"Find text and return a valid range. Safer than raw character offsets under concurrent editing.",
|
|
4195
5401
|
{
|
|
4196
|
-
pattern:
|
|
4197
|
-
occurrence:
|
|
4198
|
-
documentId:
|
|
5402
|
+
pattern: z6.string().describe("Text to find"),
|
|
5403
|
+
occurrence: z6.number().optional().describe("Which occurrence (1-based, default 1)"),
|
|
5404
|
+
documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
|
|
4199
5405
|
},
|
|
4200
5406
|
withErrorBoundary("tandem_resolveRange", async ({ pattern, occurrence = 1, documentId }) => {
|
|
4201
5407
|
const current = getCurrentDoc(documentId);
|
|
@@ -4210,9 +5416,9 @@ function registerNavigationTools(server) {
|
|
|
4210
5416
|
"tandem_setStatus",
|
|
4211
5417
|
'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
5418
|
{
|
|
4213
|
-
text:
|
|
4214
|
-
focusParagraph:
|
|
4215
|
-
documentId:
|
|
5419
|
+
text: z6.string().describe("Status text"),
|
|
5420
|
+
focusParagraph: z6.number().optional().describe("Index of paragraph Claude is focusing on"),
|
|
5421
|
+
documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
|
|
4216
5422
|
},
|
|
4217
5423
|
withErrorBoundary("tandem_setStatus", async ({ text, focusParagraph, documentId }) => {
|
|
4218
5424
|
const current = getCurrentDoc(documentId);
|
|
@@ -4240,10 +5446,10 @@ function registerNavigationTools(server) {
|
|
|
4240
5446
|
"tandem_getContext",
|
|
4241
5447
|
"Read content around a range without pulling the full document. Reduces token usage.",
|
|
4242
5448
|
{
|
|
4243
|
-
from:
|
|
4244
|
-
to:
|
|
4245
|
-
windowSize:
|
|
4246
|
-
documentId:
|
|
5449
|
+
from: z6.number().describe("Start position"),
|
|
5450
|
+
to: z6.number().describe("End position"),
|
|
5451
|
+
windowSize: z6.number().optional().describe("Characters of context before/after (default 500)"),
|
|
5452
|
+
documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
|
|
4247
5453
|
},
|
|
4248
5454
|
withErrorBoundary(
|
|
4249
5455
|
"tandem_getContext",
|
|
@@ -4283,6 +5489,7 @@ function createMcpServer() {
|
|
|
4283
5489
|
registerAnnotationTools(server);
|
|
4284
5490
|
registerNavigationTools(server);
|
|
4285
5491
|
registerAwarenessTools(server);
|
|
5492
|
+
registerApplyTools(server);
|
|
4286
5493
|
return server;
|
|
4287
5494
|
}
|
|
4288
5495
|
function jsonrpcId(body) {
|
|
@@ -4362,14 +5569,18 @@ async function startMcpServerHttp(port, host = "127.0.0.1") {
|
|
|
4362
5569
|
await currentTransport.handleRequest(req, res, req.body);
|
|
4363
5570
|
currentTransport = null;
|
|
4364
5571
|
});
|
|
4365
|
-
app.get(
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
5572
|
+
app.get(
|
|
5573
|
+
"/health",
|
|
5574
|
+
apiMiddleware,
|
|
5575
|
+
(_req, res) => {
|
|
5576
|
+
res.json({
|
|
5577
|
+
status: "ok",
|
|
5578
|
+
version: APP_VERSION,
|
|
5579
|
+
transport: "http",
|
|
5580
|
+
hasSession: currentTransport !== null
|
|
5581
|
+
});
|
|
5582
|
+
}
|
|
5583
|
+
);
|
|
4373
5584
|
app.get(
|
|
4374
5585
|
"/.well-known/oauth-protected-resource/mcp",
|
|
4375
5586
|
(_req, res) => {
|
|
@@ -4438,7 +5649,7 @@ function isKnownHocuspocusError(err) {
|
|
|
4438
5649
|
}
|
|
4439
5650
|
const msg = err.message;
|
|
4440
5651
|
if (msg.startsWith("WebSocket is not open")) return true;
|
|
4441
|
-
if (msg
|
|
5652
|
+
if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
|
|
4442
5653
|
if (msg.startsWith("Received a message with an unknown type:")) return true;
|
|
4443
5654
|
return false;
|
|
4444
5655
|
}
|
|
@@ -4631,7 +5842,11 @@ async function main() {
|
|
|
4631
5842
|
if (transportMode === "http") {
|
|
4632
5843
|
freePort(wsPort);
|
|
4633
5844
|
freePort(mcpPort);
|
|
4634
|
-
|
|
5845
|
+
try {
|
|
5846
|
+
await Promise.all([waitForPort(wsPort), waitForPort(mcpPort)]);
|
|
5847
|
+
} catch (err) {
|
|
5848
|
+
console.error(`[Tandem] ${err instanceof Error ? err.message : err} \u2014 proceeding anyway`);
|
|
5849
|
+
}
|
|
4635
5850
|
const [srv] = await Promise.all([
|
|
4636
5851
|
startMcpServerHttp(mcpPort),
|
|
4637
5852
|
startHocuspocus(wsPort).then(() => {
|
|
@@ -4640,8 +5855,8 @@ async function main() {
|
|
|
4640
5855
|
]);
|
|
4641
5856
|
httpServer = srv;
|
|
4642
5857
|
if (getOpenDocs().size === 0 && !process.env.TANDEM_NO_SAMPLE) {
|
|
4643
|
-
const samplePath =
|
|
4644
|
-
|
|
5858
|
+
const samplePath = path9.resolve(
|
|
5859
|
+
path9.dirname(fileURLToPath2(import.meta.url)),
|
|
4645
5860
|
"../../sample/welcome.md"
|
|
4646
5861
|
);
|
|
4647
5862
|
openFileByPath(samplePath).then(() => {
|
|
@@ -4667,7 +5882,11 @@ async function main() {
|
|
|
4667
5882
|
} else {
|
|
4668
5883
|
(async () => {
|
|
4669
5884
|
freePort(wsPort);
|
|
4670
|
-
|
|
5885
|
+
try {
|
|
5886
|
+
await waitForPort(wsPort);
|
|
5887
|
+
} catch (err) {
|
|
5888
|
+
console.error(`[Tandem] ${err instanceof Error ? err.message : err} \u2014 proceeding anyway`);
|
|
5889
|
+
}
|
|
4671
5890
|
await startHocuspocus(wsPort);
|
|
4672
5891
|
console.error(`[Tandem] Hocuspocus WebSocket server running on ws://localhost:${wsPort}`);
|
|
4673
5892
|
})().catch((err) => {
|