@repo-toolkit/confluence 0.20.0 → 0.21.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/README.md +90 -12
- package/cli.js +564 -6
- package/index.d.ts +130 -1
- package/index.js +548 -4
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -14,6 +14,7 @@ var DEFAULT_USER_AGENT = "repo-toolkit-confluence/1.0 (+node)";
|
|
|
14
14
|
var MAX_LIMIT = 250;
|
|
15
15
|
var MAX_ERROR_BODY_LENGTH = 8192;
|
|
16
16
|
var MAX_PAGES_PER_QUERY = 100;
|
|
17
|
+
var CONFLUENCE_MANAGED_LABEL = "repo-toolkit-confluence";
|
|
17
18
|
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
18
19
|
var DEFAULT_MAX_RETRIES = 3;
|
|
19
20
|
var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
@@ -141,6 +142,44 @@ var ConfluenceClient = class {
|
|
|
141
142
|
body: JSON.stringify(body)
|
|
142
143
|
});
|
|
143
144
|
}
|
|
145
|
+
async getPageDescendants(pageId) {
|
|
146
|
+
const query = new URLSearchParams({ limit: String(MAX_LIMIT) });
|
|
147
|
+
return this.listAll(this.v2Url(`/pages/${encodeURIComponent(pageId)}/descendants?${query.toString()}`));
|
|
148
|
+
}
|
|
149
|
+
async getPageLabels(pageId) {
|
|
150
|
+
const query = new URLSearchParams({ limit: String(MAX_LIMIT) });
|
|
151
|
+
return this.listAll(this.v2Url(`/pages/${encodeURIComponent(pageId)}/labels?${query.toString()}`));
|
|
152
|
+
}
|
|
153
|
+
async addManagedLabel(pageId) {
|
|
154
|
+
await this.requestJson(this.v1Url(`/content/${encodeURIComponent(pageId)}/label`), {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify([{ prefix: "global", name: CONFLUENCE_MANAGED_LABEL }])
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async deletePage(pageId) {
|
|
161
|
+
await this.requestJson(this.v2Url(`/pages/${encodeURIComponent(pageId)}`), { method: "DELETE" });
|
|
162
|
+
}
|
|
163
|
+
async listAll(startUrl) {
|
|
164
|
+
const results = [];
|
|
165
|
+
const visited = /* @__PURE__ */ new Set();
|
|
166
|
+
let pageCount = 0;
|
|
167
|
+
let nextUrl = startUrl;
|
|
168
|
+
while (nextUrl) {
|
|
169
|
+
pageCount += 1;
|
|
170
|
+
if (pageCount > MAX_PAGES_PER_QUERY) {
|
|
171
|
+
throw new ConfluenceApiError(`Pagination limit (${MAX_PAGES_PER_QUERY}) exceeded`, 0, nextUrl, "");
|
|
172
|
+
}
|
|
173
|
+
if (visited.has(nextUrl)) {
|
|
174
|
+
throw new ConfluenceApiError("Confluence pagination loop detected", 0, nextUrl, "");
|
|
175
|
+
}
|
|
176
|
+
visited.add(nextUrl);
|
|
177
|
+
const data = await this.requestJson(nextUrl, { method: "GET" });
|
|
178
|
+
results.push(...data.results);
|
|
179
|
+
nextUrl = resolveNextUrl(this.baseUrl, this.baseUrlOrigin, data._links?.next);
|
|
180
|
+
}
|
|
181
|
+
return results;
|
|
182
|
+
}
|
|
144
183
|
async getAttachments(pageId) {
|
|
145
184
|
const results = [];
|
|
146
185
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -1647,6 +1686,141 @@ function runMmdc(cmdPath, source, outFile, timeoutMs, maxStreamBytes) {
|
|
|
1647
1686
|
});
|
|
1648
1687
|
}
|
|
1649
1688
|
|
|
1689
|
+
// src/parent-summary.ts
|
|
1690
|
+
var PARENT_SUMMARY_START_MARKER = "<!-- repo-toolkit-confluence:parent-summary:start -->";
|
|
1691
|
+
var PARENT_SUMMARY_END_MARKER = "<!-- repo-toolkit-confluence:parent-summary:end -->";
|
|
1692
|
+
function escapeCdata(text) {
|
|
1693
|
+
return text.replace(/]]>/g, "]]]]><![CDATA[>");
|
|
1694
|
+
}
|
|
1695
|
+
function pageLink(pageId, title) {
|
|
1696
|
+
const safeTitle = escapeCdata(title);
|
|
1697
|
+
return `<ac:link><ri:page ri:content-id="${escapeXmlAttribute(pageId)}" /><ac:plain-text-link-body><![CDATA[${safeTitle}]]></ac:plain-text-link-body></ac:link>`;
|
|
1698
|
+
}
|
|
1699
|
+
function countOccurrences(haystack, needle) {
|
|
1700
|
+
let count = 0;
|
|
1701
|
+
let idx = 0;
|
|
1702
|
+
while (true) {
|
|
1703
|
+
const found = haystack.indexOf(needle, idx);
|
|
1704
|
+
if (found === -1) {
|
|
1705
|
+
break;
|
|
1706
|
+
}
|
|
1707
|
+
count += 1;
|
|
1708
|
+
idx = found + needle.length;
|
|
1709
|
+
}
|
|
1710
|
+
return count;
|
|
1711
|
+
}
|
|
1712
|
+
function mergeParentSummaryBody(currentBody, generatedRegion) {
|
|
1713
|
+
const startCount = countOccurrences(currentBody, PARENT_SUMMARY_START_MARKER);
|
|
1714
|
+
const endCount = countOccurrences(currentBody, PARENT_SUMMARY_END_MARKER);
|
|
1715
|
+
if (startCount === 0 && endCount === 0) {
|
|
1716
|
+
if (currentBody === "") {
|
|
1717
|
+
return generatedRegion;
|
|
1718
|
+
}
|
|
1719
|
+
const sep2 = currentBody.endsWith("\n") ? "" : "\n";
|
|
1720
|
+
return currentBody + sep2 + generatedRegion;
|
|
1721
|
+
}
|
|
1722
|
+
if (startCount === 1 && endCount === 1) {
|
|
1723
|
+
const startIdx = currentBody.indexOf(PARENT_SUMMARY_START_MARKER);
|
|
1724
|
+
const endIdx = currentBody.indexOf(PARENT_SUMMARY_END_MARKER);
|
|
1725
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
1726
|
+
throw new Error("malformed parent summary markers: missing marker");
|
|
1727
|
+
}
|
|
1728
|
+
if (startIdx > endIdx) {
|
|
1729
|
+
throw new Error("malformed parent summary markers: start after end");
|
|
1730
|
+
}
|
|
1731
|
+
const before = currentBody.slice(0, startIdx);
|
|
1732
|
+
const after = currentBody.slice(endIdx + PARENT_SUMMARY_END_MARKER.length);
|
|
1733
|
+
if (countOccurrences(before, PARENT_SUMMARY_START_MARKER) !== 0 || countOccurrences(before, PARENT_SUMMARY_END_MARKER) !== 0) {
|
|
1734
|
+
throw new Error("malformed parent summary markers: duplicate marker before region");
|
|
1735
|
+
}
|
|
1736
|
+
if (countOccurrences(after, PARENT_SUMMARY_START_MARKER) !== 0 || countOccurrences(after, PARENT_SUMMARY_END_MARKER) !== 0) {
|
|
1737
|
+
throw new Error("malformed parent summary markers: duplicate marker after region");
|
|
1738
|
+
}
|
|
1739
|
+
return before + generatedRegion + after;
|
|
1740
|
+
}
|
|
1741
|
+
throw new Error("malformed or duplicate parent summary markers: expected 0 or 1 managed region");
|
|
1742
|
+
}
|
|
1743
|
+
function renderParentSummary(input) {
|
|
1744
|
+
const lines = [];
|
|
1745
|
+
lines.push(PARENT_SUMMARY_START_MARKER);
|
|
1746
|
+
lines.push("<h2>Synced documentation</h2>");
|
|
1747
|
+
if (input.repositoryUrl) {
|
|
1748
|
+
const url = escapeXmlAttribute(input.repositoryUrl);
|
|
1749
|
+
const text = escapeHtml(input.repositoryUrl);
|
|
1750
|
+
lines.push(
|
|
1751
|
+
`<p><em>This documentation subtree is synced from <a href="${url}">${text}</a> and maintained by <code>repo-toolkit-confluence</code>.</em></p>`
|
|
1752
|
+
);
|
|
1753
|
+
} else {
|
|
1754
|
+
lines.push("<p><em>This documentation subtree is maintained by <code>repo-toolkit-confluence</code>.</em></p>");
|
|
1755
|
+
}
|
|
1756
|
+
lines.push("<h3>Statistics</h3>");
|
|
1757
|
+
lines.push("<ul>");
|
|
1758
|
+
lines.push(`<li>Markdown pages: ${input.stats.markdownPages}</li>`);
|
|
1759
|
+
lines.push(`<li>Directory pages: ${input.stats.directoryPages}</li>`);
|
|
1760
|
+
lines.push(`<li>Total managed pages: ${input.stats.totalPages}</li>`);
|
|
1761
|
+
lines.push(`<li>Maximum depth: ${input.stats.maxDepth}</li>`);
|
|
1762
|
+
lines.push(`<li>Attachment references: ${input.stats.attachmentReferences}</li>`);
|
|
1763
|
+
lines.push(`<li>Mermaid blocks: ${input.stats.mermaidBlocks}</li>`);
|
|
1764
|
+
lines.push("</ul>");
|
|
1765
|
+
lines.push("<h3>Pages</h3>");
|
|
1766
|
+
if (input.pages.length === 0) {
|
|
1767
|
+
lines.push("<p>No managed child pages</p>");
|
|
1768
|
+
} else {
|
|
1769
|
+
lines.push(renderTree(input.pages));
|
|
1770
|
+
}
|
|
1771
|
+
lines.push("<h3>Ownership</h3>");
|
|
1772
|
+
lines.push(
|
|
1773
|
+
`<p>All generated pages carry the <code>${CONFLUENCE_MANAGED_LABEL}</code> label. On default sync, stale labeled pages not in the local tree are pruned; unlabeled pages are preserved. Use <code>clean: true</code> to move all safely deletable page descendants to trash before recreation.</p>`
|
|
1774
|
+
);
|
|
1775
|
+
lines.push(PARENT_SUMMARY_END_MARKER);
|
|
1776
|
+
return lines.join("\n");
|
|
1777
|
+
}
|
|
1778
|
+
function renderTree(pages) {
|
|
1779
|
+
const sorted = [...pages].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
1780
|
+
const root = { children: /* @__PURE__ */ new Map(), key: "" };
|
|
1781
|
+
for (const page of sorted) {
|
|
1782
|
+
const parts = page.relativePath.split("/");
|
|
1783
|
+
let node = root;
|
|
1784
|
+
let currentPath = "";
|
|
1785
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
1786
|
+
const part = parts[i] ?? "";
|
|
1787
|
+
currentPath = currentPath ? currentPath + "/" + part : part;
|
|
1788
|
+
let child = node.children.get(part);
|
|
1789
|
+
if (!child) {
|
|
1790
|
+
child = { children: /* @__PURE__ */ new Map(), key: part };
|
|
1791
|
+
node.children.set(part, child);
|
|
1792
|
+
}
|
|
1793
|
+
if (i === parts.length - 1) {
|
|
1794
|
+
child.record = page;
|
|
1795
|
+
}
|
|
1796
|
+
node = child;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
const renderNode = (node) => {
|
|
1800
|
+
const entries = [...node.children.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
1801
|
+
if (entries.length === 0) {
|
|
1802
|
+
return "";
|
|
1803
|
+
}
|
|
1804
|
+
let html = "<ul>";
|
|
1805
|
+
for (const [, child] of entries) {
|
|
1806
|
+
const rec = child.record;
|
|
1807
|
+
if (rec) {
|
|
1808
|
+
const link = pageLink(rec.pageId, rec.title);
|
|
1809
|
+
const kindLabel = rec.kind === "directory" ? "directory" : "page";
|
|
1810
|
+
const pathCode = `<code>${escapeHtml(rec.relativePath)}</code>`;
|
|
1811
|
+
const childrenHtml = renderNode(child);
|
|
1812
|
+
html += `<li>${link} \u2014 ${pathCode} <em>(${kindLabel})</em>${childrenHtml}</li>`;
|
|
1813
|
+
} else {
|
|
1814
|
+
const childrenHtml = renderNode(child);
|
|
1815
|
+
html += `<li>${escapeHtml(child.key)}${childrenHtml}</li>`;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
html += "</ul>";
|
|
1819
|
+
return html;
|
|
1820
|
+
};
|
|
1821
|
+
return renderNode(root);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1650
1824
|
// src/index.ts
|
|
1651
1825
|
var INTERACTIVE_FLAG = { name: "interactive", aliases: ["i"], boolean: true };
|
|
1652
1826
|
var LocalSyncValidationAggregateError = class extends Error {
|
|
@@ -1705,6 +1879,129 @@ var SyncMutationError = class extends Error {
|
|
|
1705
1879
|
this.unprocessed = input.unprocessed;
|
|
1706
1880
|
}
|
|
1707
1881
|
};
|
|
1882
|
+
var ReconciliationError = class extends Error {
|
|
1883
|
+
constructor(input) {
|
|
1884
|
+
super(input.failure.error.message);
|
|
1885
|
+
this.name = "ReconciliationError";
|
|
1886
|
+
this.phase = input.phase;
|
|
1887
|
+
this.completed = input.completed;
|
|
1888
|
+
this.failure = input.failure;
|
|
1889
|
+
this.unprocessed = input.unprocessed;
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
var ParentSummaryError = class extends Error {
|
|
1893
|
+
constructor(input) {
|
|
1894
|
+
super(input.failure.error.message);
|
|
1895
|
+
this.name = "ParentSummaryError";
|
|
1896
|
+
this.phase = "parent-summary";
|
|
1897
|
+
this.changes = input.changes;
|
|
1898
|
+
this.labelsAdded = input.labelsAdded;
|
|
1899
|
+
this.cleanDeletions = input.cleanDeletions;
|
|
1900
|
+
this.pruneDeletions = input.pruneDeletions;
|
|
1901
|
+
this.blocked = input.blocked;
|
|
1902
|
+
this.failure = input.failure;
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
function planStalePruning(input) {
|
|
1906
|
+
const nodes = buildInventoryNodes(input.parentPageId, input.inventory);
|
|
1907
|
+
const retained = /* @__PURE__ */ new Set();
|
|
1908
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1909
|
+
const isStale = (entry) => entry.type === "page" && entry.labeled && !input.expectedIds.has(entry.id) && entry.id !== input.parentPageId;
|
|
1910
|
+
const visit = (node) => {
|
|
1911
|
+
if (visited.has(node.entry.id)) {
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
visited.add(node.entry.id);
|
|
1915
|
+
let safe = isStale(node.entry);
|
|
1916
|
+
for (const child of node.children) {
|
|
1917
|
+
visit(child);
|
|
1918
|
+
if (retained.has(child.entry.id)) {
|
|
1919
|
+
safe = false;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
if (!safe) {
|
|
1923
|
+
retained.add(node.entry.id);
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
for (const node of nodes.values()) {
|
|
1927
|
+
visit(node);
|
|
1928
|
+
}
|
|
1929
|
+
const deletions = deepestFirst([...nodes.values()].filter((n) => !retained.has(n.entry.id)));
|
|
1930
|
+
const blocked = [...nodes.values()].filter((n) => retained.has(n.entry.id) && isStale(n.entry)).map((n) => n.entry.id).sort();
|
|
1931
|
+
return { deletions, blocked };
|
|
1932
|
+
}
|
|
1933
|
+
function planCleanDeletions(input) {
|
|
1934
|
+
const nodes = buildInventoryNodes(input.parentPageId, input.inventory);
|
|
1935
|
+
for (const node of nodes.values()) {
|
|
1936
|
+
if (node.entry.type !== "page") {
|
|
1937
|
+
throw new Error(
|
|
1938
|
+
`clean refused: descendant ${node.entry.id} has unsupported type "${node.entry.type}"; deleting its ancestors could remove content that cannot be restored by this tool`
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
return { deletions: deepestFirst([...nodes.values()]), blocked: [] };
|
|
1943
|
+
}
|
|
1944
|
+
function buildInventoryNodes(parentPageId, inventory) {
|
|
1945
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
1946
|
+
for (const entry of inventory) {
|
|
1947
|
+
if (entry.id === parentPageId) {
|
|
1948
|
+
continue;
|
|
1949
|
+
}
|
|
1950
|
+
if (nodes.has(entry.id)) {
|
|
1951
|
+
throw new Error(`Incomplete descendant inventory: duplicate id ${entry.id}`);
|
|
1952
|
+
}
|
|
1953
|
+
nodes.set(entry.id, { entry, depth: -1, children: [] });
|
|
1954
|
+
}
|
|
1955
|
+
const depthOf = (node) => {
|
|
1956
|
+
if (node.depth >= 0) {
|
|
1957
|
+
return node.depth;
|
|
1958
|
+
}
|
|
1959
|
+
if (typeof node.entry.depth === "number") {
|
|
1960
|
+
node.depth = node.entry.depth;
|
|
1961
|
+
return node.depth;
|
|
1962
|
+
}
|
|
1963
|
+
let depth = 0;
|
|
1964
|
+
let current = node;
|
|
1965
|
+
const seen = /* @__PURE__ */ new Set([node.entry.id]);
|
|
1966
|
+
while (true) {
|
|
1967
|
+
const pid = current.entry.parentId;
|
|
1968
|
+
if (pid === parentPageId) {
|
|
1969
|
+
depth += 1;
|
|
1970
|
+
node.depth = depth;
|
|
1971
|
+
return depth;
|
|
1972
|
+
}
|
|
1973
|
+
if (!pid) {
|
|
1974
|
+
throw new Error(`Incomplete descendant inventory: missing parent for ${node.entry.id}`);
|
|
1975
|
+
}
|
|
1976
|
+
if (seen.has(pid)) {
|
|
1977
|
+
throw new Error(`Incomplete descendant inventory: parent cycle at ${pid}`);
|
|
1978
|
+
}
|
|
1979
|
+
seen.add(pid);
|
|
1980
|
+
const parent = nodes.get(pid);
|
|
1981
|
+
if (!parent) {
|
|
1982
|
+
throw new Error(
|
|
1983
|
+
`Incomplete descendant inventory: ancestor ${pid} of ${node.entry.id} is missing from the listing`
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
depth += 1;
|
|
1987
|
+
current = parent;
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1990
|
+
for (const node of nodes.values()) {
|
|
1991
|
+
depthOf(node);
|
|
1992
|
+
const pid = node.entry.parentId;
|
|
1993
|
+
if (pid !== void 0 && pid !== parentPageId) {
|
|
1994
|
+
nodes.get(pid)?.children.push(node);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return nodes;
|
|
1998
|
+
}
|
|
1999
|
+
function deepestFirst(nodes) {
|
|
2000
|
+
return [...nodes].sort((a, b) => b.depth - a.depth !== 0 ? b.depth - a.depth : a.entry.id.localeCompare(b.entry.id)).map((n) => n.entry.id);
|
|
2001
|
+
}
|
|
2002
|
+
function hasManagedMarker(labels) {
|
|
2003
|
+
return labels.some((label) => label.name === CONFLUENCE_MANAGED_LABEL && label.prefix === "global");
|
|
2004
|
+
}
|
|
1708
2005
|
function resolveConfluenceSyncPlan(options = {}) {
|
|
1709
2006
|
const pageTitleStrategy = resolvePageTitleStrategy(options.pageTitleStrategy);
|
|
1710
2007
|
const cwd = resolve2(options.cwd ?? process.cwd());
|
|
@@ -1754,6 +2051,8 @@ function resolveConfluenceSyncPlan(options = {}) {
|
|
|
1754
2051
|
dryRun: options.dryRun ?? false,
|
|
1755
2052
|
renderHtmlBlocks: options.renderHtmlBlocks === true,
|
|
1756
2053
|
repositoryUrl,
|
|
2054
|
+
clean: options.clean ?? false,
|
|
2055
|
+
updateParentPage: options.updateParentPage ?? true,
|
|
1757
2056
|
pageTitleStrategy
|
|
1758
2057
|
};
|
|
1759
2058
|
}
|
|
@@ -1763,7 +2062,6 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
1763
2062
|
const tree = await readDocTree(plan.folder);
|
|
1764
2063
|
if (tree.entries.length === 0) {
|
|
1765
2064
|
log(`No markdown files found under ${plan.folder}`);
|
|
1766
|
-
return;
|
|
1767
2065
|
}
|
|
1768
2066
|
const localPlan = validateLocalSync(tree.entries, plan);
|
|
1769
2067
|
validateLocalHierarchy(localPlan.entries, plan.pageTitleStrategy);
|
|
@@ -1776,6 +2074,32 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
1776
2074
|
`[dry-run] would sync ${entryPlan.entry.segments.join("/")} as "${entryPlan.title}"` + (attCount > 0 ? ` (${attCount} attachment${attCount === 1 ? "" : "s"} validated)` : "") + (mermaidCount > 0 ? ` (${mermaidCount} mermaid block${mermaidCount === 1 ? "" : "s"})` : "")
|
|
1777
2075
|
);
|
|
1778
2076
|
}
|
|
2077
|
+
if (plan.clean) {
|
|
2078
|
+
log(
|
|
2079
|
+
"[dry-run] clean requested: a real sync would move every page descendant of the target page to trash before recreating the local hierarchy."
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
log(
|
|
2083
|
+
"[dry-run] a real sync would label every mapped page with the ownership marker and prune stale labeled descendants."
|
|
2084
|
+
);
|
|
2085
|
+
if (plan.updateParentPage) {
|
|
2086
|
+
const stats = computeDryRunStats(localPlan);
|
|
2087
|
+
log(
|
|
2088
|
+
`[dry-run] parent summary: Markdown pages: ${stats.markdownPages}, Directory pages: ${stats.directoryPages}, Total managed pages: ${stats.totalPages}, Maximum depth: ${stats.maxDepth}, Attachment references: ${stats.attachmentReferences}, Mermaid blocks: ${stats.mermaidBlocks}`
|
|
2089
|
+
);
|
|
2090
|
+
if (localPlan.entries.length === 0) {
|
|
2091
|
+
log("[dry-run] parent tree: No managed child pages");
|
|
2092
|
+
} else {
|
|
2093
|
+
for (const entryPlan of localPlan.entries) {
|
|
2094
|
+
const dirParts = entryPlan.entry.segments.slice(0, -1);
|
|
2095
|
+
for (let i = 0; i < dirParts.length; i += 1) {
|
|
2096
|
+
const dirPath = dirParts.slice(0, i + 1).join("/");
|
|
2097
|
+
log(`[dry-run] parent tree: ${dirPath} (directory) => "${dirParts[i] ?? ""}"`);
|
|
2098
|
+
}
|
|
2099
|
+
log(`[dry-run] parent tree: ${entryPlan.entry.segments.join("/")} (page) => "${entryPlan.title}"`);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
1779
2103
|
return;
|
|
1780
2104
|
}
|
|
1781
2105
|
const client = options.client ?? new ConfluenceClient({
|
|
@@ -1783,13 +2107,29 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
1783
2107
|
username: plan.username,
|
|
1784
2108
|
apiToken: plan.apiToken
|
|
1785
2109
|
});
|
|
2110
|
+
const labelsAdded = [];
|
|
2111
|
+
const cleanDeletions = [];
|
|
2112
|
+
const pruneDeletions = [];
|
|
2113
|
+
const blocked = [];
|
|
2114
|
+
if (plan.clean) {
|
|
2115
|
+
const descendants = await client.getPageDescendants(plan.parentPageId);
|
|
2116
|
+
const inventory = descendants.filter((d) => d.id !== plan.parentPageId).map((d) => toInventoryEntry(d, false));
|
|
2117
|
+
const cleanPlan = planCleanDeletions({ parentPageId: plan.parentPageId, inventory });
|
|
2118
|
+
await executeDeletions(cleanPlan.deletions, "clean", client, log, cleanDeletions);
|
|
2119
|
+
}
|
|
1786
2120
|
const spaceId = await client.getSpaceIdByKey(plan.spaceKey);
|
|
1787
2121
|
const cache = new PageTitleCache(spaceId, client);
|
|
2122
|
+
const syncState = {
|
|
2123
|
+
mappedIds: /* @__PURE__ */ new Set(),
|
|
2124
|
+
ensuredLabels: /* @__PURE__ */ new Set(),
|
|
2125
|
+
labelsAdded,
|
|
2126
|
+
mappedRecords: /* @__PURE__ */ new Map()
|
|
2127
|
+
};
|
|
1788
2128
|
const changes = [];
|
|
1789
2129
|
for (let i = 0; i < localPlan.entries.length; i += 1) {
|
|
1790
2130
|
const entryPlan = localPlan.entries[i];
|
|
1791
2131
|
try {
|
|
1792
|
-
await syncEntry(entryPlan, plan, client, cache, log, changes);
|
|
2132
|
+
await syncEntry(entryPlan, plan, client, cache, log, changes, syncState);
|
|
1793
2133
|
} catch (error) {
|
|
1794
2134
|
throw new SyncMutationError({
|
|
1795
2135
|
changes,
|
|
@@ -1798,15 +2138,202 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
1798
2138
|
});
|
|
1799
2139
|
}
|
|
1800
2140
|
}
|
|
1801
|
-
|
|
2141
|
+
if (!plan.clean) {
|
|
2142
|
+
const descendants = await client.getPageDescendants(plan.parentPageId);
|
|
2143
|
+
const inventory = [];
|
|
2144
|
+
for (const d of descendants) {
|
|
2145
|
+
if (d.id === plan.parentPageId) {
|
|
2146
|
+
continue;
|
|
2147
|
+
}
|
|
2148
|
+
if (d.type !== "page") {
|
|
2149
|
+
inventory.push(toInventoryEntry(d, false));
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
if (syncState.mappedIds.has(d.id)) {
|
|
2153
|
+
inventory.push(toInventoryEntry(d, true));
|
|
2154
|
+
continue;
|
|
2155
|
+
}
|
|
2156
|
+
const labels = await client.getPageLabels(d.id);
|
|
2157
|
+
inventory.push(toInventoryEntry(d, hasManagedMarker(labels)));
|
|
2158
|
+
}
|
|
2159
|
+
const prunePlan = planStalePruning({
|
|
2160
|
+
parentPageId: plan.parentPageId,
|
|
2161
|
+
expectedIds: syncState.mappedIds,
|
|
2162
|
+
inventory
|
|
2163
|
+
});
|
|
2164
|
+
await executeDeletions(prunePlan.deletions, "prune", client, log, pruneDeletions);
|
|
2165
|
+
for (const pageId of prunePlan.blocked) {
|
|
2166
|
+
blocked.push(pageId);
|
|
2167
|
+
log(`blocked: stale page ${pageId} retained because it has unlabeled, non-page, or expected descendants`);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
let parentStatus = "skipped";
|
|
2171
|
+
if (plan.updateParentPage) {
|
|
2172
|
+
try {
|
|
2173
|
+
const parentPage = await client.getPage(plan.parentPageId);
|
|
2174
|
+
const stats = computeParentStats(localPlan, syncState);
|
|
2175
|
+
const pages = [...syncState.mappedRecords.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
2176
|
+
const region = renderParentSummary({ repositoryUrl: plan.repositoryUrl, stats, pages });
|
|
2177
|
+
const currentBody = parentPage.body?.storage?.value ?? "";
|
|
2178
|
+
const merged = mergeParentSummaryBody(currentBody, region);
|
|
2179
|
+
if (merged === currentBody) {
|
|
2180
|
+
log(`parent-unchanged: page ${plan.parentPageId}`);
|
|
2181
|
+
parentStatus = "unchanged";
|
|
2182
|
+
} else {
|
|
2183
|
+
await client.updatePage({
|
|
2184
|
+
id: plan.parentPageId,
|
|
2185
|
+
title: parentPage.title,
|
|
2186
|
+
body: { representation: "storage", value: merged },
|
|
2187
|
+
version: { number: (parentPage.version?.number ?? 0) + 1, message: plan.versionMessage }
|
|
2188
|
+
});
|
|
2189
|
+
log(`parent-updated: page ${plan.parentPageId}`);
|
|
2190
|
+
parentStatus = "updated";
|
|
2191
|
+
}
|
|
2192
|
+
} catch (error) {
|
|
2193
|
+
throw new ParentSummaryError({
|
|
2194
|
+
changes,
|
|
2195
|
+
labelsAdded: [...labelsAdded],
|
|
2196
|
+
cleanDeletions: [...cleanDeletions],
|
|
2197
|
+
pruneDeletions: [...pruneDeletions],
|
|
2198
|
+
blocked: [...blocked],
|
|
2199
|
+
failure: { pageId: plan.parentPageId, error: error instanceof Error ? error : new Error(String(error)) }
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
return { changes, labelsAdded, cleanDeletions, pruneDeletions, blocked, parentStatus };
|
|
2204
|
+
}
|
|
2205
|
+
function computeParentStats(localPlan, state) {
|
|
2206
|
+
const dirSet = /* @__PURE__ */ new Set();
|
|
2207
|
+
let maxDepth = 0;
|
|
2208
|
+
let attachmentReferences = 0;
|
|
2209
|
+
let mermaidBlocks = 0;
|
|
2210
|
+
for (const entryPlan of localPlan.entries) {
|
|
2211
|
+
const segs = entryPlan.entry.segments;
|
|
2212
|
+
if (segs.length > maxDepth) {
|
|
2213
|
+
maxDepth = segs.length;
|
|
2214
|
+
}
|
|
2215
|
+
for (let i = 0; i < segs.length - 1; i += 1) {
|
|
2216
|
+
dirSet.add(segs.slice(0, i + 1).join("/"));
|
|
2217
|
+
}
|
|
2218
|
+
attachmentReferences += entryPlan.attachments.length;
|
|
2219
|
+
mermaidBlocks += entryPlan.mermaidBlocks.length;
|
|
2220
|
+
}
|
|
2221
|
+
const markdownPages = localPlan.entries.length;
|
|
2222
|
+
const directoryPages = dirSet.size;
|
|
2223
|
+
const totalPages = state.mappedRecords.size;
|
|
2224
|
+
const effectiveMaxDepth = localPlan.entries.length === 0 ? 0 : maxDepth;
|
|
2225
|
+
return {
|
|
2226
|
+
markdownPages,
|
|
2227
|
+
directoryPages,
|
|
2228
|
+
totalPages: totalPages > 0 ? totalPages : directoryPages + markdownPages,
|
|
2229
|
+
maxDepth: effectiveMaxDepth,
|
|
2230
|
+
attachmentReferences,
|
|
2231
|
+
mermaidBlocks
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
function computeDryRunStats(localPlan) {
|
|
2235
|
+
const dirSet = /* @__PURE__ */ new Set();
|
|
2236
|
+
let maxDepth = 0;
|
|
2237
|
+
let attachmentReferences = 0;
|
|
2238
|
+
let mermaidBlocks = 0;
|
|
2239
|
+
for (const entryPlan of localPlan.entries) {
|
|
2240
|
+
const segs = entryPlan.entry.segments;
|
|
2241
|
+
if (segs.length > maxDepth) {
|
|
2242
|
+
maxDepth = segs.length;
|
|
2243
|
+
}
|
|
2244
|
+
for (let i = 0; i < segs.length - 1; i += 1) {
|
|
2245
|
+
dirSet.add(segs.slice(0, i + 1).join("/"));
|
|
2246
|
+
}
|
|
2247
|
+
attachmentReferences += entryPlan.attachments.length;
|
|
2248
|
+
mermaidBlocks += entryPlan.mermaidBlocks.length;
|
|
2249
|
+
}
|
|
2250
|
+
const markdownPages = localPlan.entries.length;
|
|
2251
|
+
const directoryPages = dirSet.size;
|
|
2252
|
+
return {
|
|
2253
|
+
markdownPages,
|
|
2254
|
+
directoryPages,
|
|
2255
|
+
totalPages: directoryPages + markdownPages,
|
|
2256
|
+
maxDepth: markdownPages === 0 ? 0 : maxDepth,
|
|
2257
|
+
attachmentReferences,
|
|
2258
|
+
mermaidBlocks
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
function toInventoryEntry(d, labeled) {
|
|
2262
|
+
const entry = { id: d.id, type: d.type, labeled };
|
|
2263
|
+
if (d.parentId !== void 0) {
|
|
2264
|
+
entry.parentId = d.parentId;
|
|
2265
|
+
}
|
|
2266
|
+
if (d.depth !== void 0) {
|
|
2267
|
+
entry.depth = d.depth;
|
|
2268
|
+
}
|
|
2269
|
+
if (d.title !== void 0) {
|
|
2270
|
+
entry.title = d.title;
|
|
2271
|
+
}
|
|
2272
|
+
return entry;
|
|
2273
|
+
}
|
|
2274
|
+
async function ensureManagedLabel(pageId, client, state, log) {
|
|
2275
|
+
if (state.ensuredLabels.has(pageId)) {
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
const labels = await client.getPageLabels(pageId);
|
|
2279
|
+
if (!hasManagedMarker(labels)) {
|
|
2280
|
+
await client.addManagedLabel(pageId);
|
|
2281
|
+
state.labelsAdded.push(pageId);
|
|
2282
|
+
log(`labeled: page ${pageId}`);
|
|
2283
|
+
}
|
|
2284
|
+
state.ensuredLabels.add(pageId);
|
|
2285
|
+
}
|
|
2286
|
+
async function executeDeletions(ids, phase, client, log, evidence) {
|
|
2287
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
2288
|
+
const pageId = ids[i];
|
|
2289
|
+
if (pageId === void 0) {
|
|
2290
|
+
continue;
|
|
2291
|
+
}
|
|
2292
|
+
try {
|
|
2293
|
+
await client.deletePage(pageId);
|
|
2294
|
+
evidence.push(pageId);
|
|
2295
|
+
log(`${phase === "clean" ? "clean" : "pruned"}: trashed page ${pageId}`);
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
throw new ReconciliationError({
|
|
2298
|
+
phase,
|
|
2299
|
+
completed: [...evidence],
|
|
2300
|
+
failure: { pageId, error: error instanceof Error ? error : new Error(String(error)) },
|
|
2301
|
+
unprocessed: ids.slice(i + 1)
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
1802
2305
|
}
|
|
1803
|
-
async function syncEntry(entryPlan, plan, client, cache, log, changes) {
|
|
2306
|
+
async function syncEntry(entryPlan, plan, client, cache, log, changes, state) {
|
|
1804
2307
|
const { entry, html: precomputedHtml, mermaidBlocks, markdownDir, hasLocalImages, hasMermaidBlocks } = entryPlan;
|
|
1805
2308
|
const segments = entry.segments;
|
|
1806
2309
|
if (segments.length === 0) {
|
|
1807
2310
|
return;
|
|
1808
2311
|
}
|
|
1809
2312
|
let currentParentId = plan.parentPageId;
|
|
2313
|
+
const recordDirectory = (relativePath, title, pageId, depth) => {
|
|
2314
|
+
if (!state.mappedRecords.has(relativePath)) {
|
|
2315
|
+
state.mappedRecords.set(relativePath, {
|
|
2316
|
+
relativePath,
|
|
2317
|
+
kind: "directory",
|
|
2318
|
+
title,
|
|
2319
|
+
pageId,
|
|
2320
|
+
depth,
|
|
2321
|
+
attachmentCount: 0,
|
|
2322
|
+
mermaidCount: 0
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
const recordLeaf = (relativePath, title, pageId, depth) => {
|
|
2327
|
+
state.mappedRecords.set(relativePath, {
|
|
2328
|
+
relativePath,
|
|
2329
|
+
kind: "leaf",
|
|
2330
|
+
title,
|
|
2331
|
+
pageId,
|
|
2332
|
+
depth,
|
|
2333
|
+
attachmentCount: entryPlan.attachments.length,
|
|
2334
|
+
mermaidCount: entryPlan.mermaidBlocks.length
|
|
2335
|
+
});
|
|
2336
|
+
};
|
|
1810
2337
|
for (let idx = 0; idx < segments.length; idx += 1) {
|
|
1811
2338
|
const isLast = idx === segments.length - 1;
|
|
1812
2339
|
const segment = segments[idx] ?? "";
|
|
@@ -1820,12 +2347,18 @@ async function syncEntry(entryPlan, plan, client, cache, log, changes) {
|
|
|
1820
2347
|
parentId: currentParentId,
|
|
1821
2348
|
body: { representation: "storage", value: precomputedHtml }
|
|
1822
2349
|
});
|
|
2350
|
+
state.mappedIds.add(pageId2);
|
|
2351
|
+
await ensureManagedLabel(pageId2, client, state, log);
|
|
2352
|
+
recordLeaf(segments.join("/"), title, pageId2, segments.length);
|
|
1823
2353
|
log(`created: ${segments.join("/")} (page ${pageId2})`);
|
|
1824
2354
|
changes.push({ entry, pageId: pageId2, kind: "created" });
|
|
1825
2355
|
return;
|
|
1826
2356
|
}
|
|
1827
2357
|
const existingPage = existing ?? await cache.findOrCreate(title, currentParentId);
|
|
1828
2358
|
const pageId = existingPage.id;
|
|
2359
|
+
state.mappedIds.add(pageId);
|
|
2360
|
+
await ensureManagedLabel(pageId, client, state, log);
|
|
2361
|
+
recordLeaf(segments.join("/"), title, pageId, segments.length);
|
|
1829
2362
|
const current = await client.getPage(pageId);
|
|
1830
2363
|
const currentBody = current.body?.storage?.value ?? "";
|
|
1831
2364
|
let body = precomputedHtml;
|
|
@@ -1872,10 +2405,16 @@ async function syncEntry(entryPlan, plan, client, cache, log, changes) {
|
|
|
1872
2405
|
if (isMarkdownName(segment)) {
|
|
1873
2406
|
const title = pageTitleFromSegments(segments.slice(0, idx + 1), plan.pageTitleStrategy);
|
|
1874
2407
|
const page2 = await cache.findOrCreate(title, currentParentId);
|
|
2408
|
+
state.mappedIds.add(page2.id);
|
|
2409
|
+
await ensureManagedLabel(page2.id, client, state, log);
|
|
2410
|
+
recordDirectory(segments.slice(0, idx + 1).join("/"), title, page2.id, idx + 1);
|
|
1875
2411
|
currentParentId = page2.id;
|
|
1876
2412
|
continue;
|
|
1877
2413
|
}
|
|
1878
2414
|
const page = await cache.findOrCreate(segment, currentParentId);
|
|
2415
|
+
state.mappedIds.add(page.id);
|
|
2416
|
+
await ensureManagedLabel(page.id, client, state, log);
|
|
2417
|
+
recordDirectory(segments.slice(0, idx + 1).join("/"), segment, page.id, idx + 1);
|
|
1879
2418
|
currentParentId = page.id;
|
|
1880
2419
|
}
|
|
1881
2420
|
}
|
|
@@ -2022,12 +2561,15 @@ function validateLocalHierarchy(entries, strategy) {
|
|
|
2022
2561
|
}
|
|
2023
2562
|
}
|
|
2024
2563
|
export {
|
|
2564
|
+
CONFLUENCE_MANAGED_LABEL,
|
|
2025
2565
|
ConfluenceApiError,
|
|
2026
2566
|
ConfluenceClient,
|
|
2027
2567
|
DEFAULT_PAGE_TITLE_STRATEGY,
|
|
2028
2568
|
INTERACTIVE_FLAG,
|
|
2029
2569
|
LocalSyncValidationAggregateError,
|
|
2030
2570
|
PAGE_TITLE_STRATEGIES,
|
|
2571
|
+
ParentSummaryError,
|
|
2572
|
+
ReconciliationError,
|
|
2031
2573
|
SyncMutationError,
|
|
2032
2574
|
escapeAttachmentFilename,
|
|
2033
2575
|
escapeXmlAttribute,
|
|
@@ -2036,6 +2578,8 @@ export {
|
|
|
2036
2578
|
isRemoteUrl,
|
|
2037
2579
|
markdownToStorage,
|
|
2038
2580
|
pageTitleFromSegments,
|
|
2581
|
+
planCleanDeletions,
|
|
2582
|
+
planStalePruning,
|
|
2039
2583
|
preflightImagesToAttachments,
|
|
2040
2584
|
preflightMermaidBlocks,
|
|
2041
2585
|
readDocTree,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@repo-toolkit/confluence",
|
|
3
3
|
"description": "Sync a folder of markdown docs to Confluence pages and attachments (GitHub Action compatible)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.21.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node": ">=20"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@repo-toolkit/publish-package": "0.
|
|
30
|
+
"@repo-toolkit/publish-package": "0.21.0"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"**/*",
|