@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/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ var DEFAULT_USER_AGENT = "repo-toolkit-confluence/1.0 (+node)";
|
|
|
28
28
|
var MAX_LIMIT = 250;
|
|
29
29
|
var MAX_ERROR_BODY_LENGTH = 8192;
|
|
30
30
|
var MAX_PAGES_PER_QUERY = 100;
|
|
31
|
+
var CONFLUENCE_MANAGED_LABEL = "repo-toolkit-confluence";
|
|
31
32
|
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
32
33
|
var DEFAULT_MAX_RETRIES = 3;
|
|
33
34
|
var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
@@ -155,6 +156,44 @@ var ConfluenceClient = class {
|
|
|
155
156
|
body: JSON.stringify(body)
|
|
156
157
|
});
|
|
157
158
|
}
|
|
159
|
+
async getPageDescendants(pageId) {
|
|
160
|
+
const query = new URLSearchParams({ limit: String(MAX_LIMIT) });
|
|
161
|
+
return this.listAll(this.v2Url(`/pages/${encodeURIComponent(pageId)}/descendants?${query.toString()}`));
|
|
162
|
+
}
|
|
163
|
+
async getPageLabels(pageId) {
|
|
164
|
+
const query = new URLSearchParams({ limit: String(MAX_LIMIT) });
|
|
165
|
+
return this.listAll(this.v2Url(`/pages/${encodeURIComponent(pageId)}/labels?${query.toString()}`));
|
|
166
|
+
}
|
|
167
|
+
async addManagedLabel(pageId) {
|
|
168
|
+
await this.requestJson(this.v1Url(`/content/${encodeURIComponent(pageId)}/label`), {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/json" },
|
|
171
|
+
body: JSON.stringify([{ prefix: "global", name: CONFLUENCE_MANAGED_LABEL }])
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async deletePage(pageId) {
|
|
175
|
+
await this.requestJson(this.v2Url(`/pages/${encodeURIComponent(pageId)}`), { method: "DELETE" });
|
|
176
|
+
}
|
|
177
|
+
async listAll(startUrl) {
|
|
178
|
+
const results = [];
|
|
179
|
+
const visited = /* @__PURE__ */ new Set();
|
|
180
|
+
let pageCount = 0;
|
|
181
|
+
let nextUrl = startUrl;
|
|
182
|
+
while (nextUrl) {
|
|
183
|
+
pageCount += 1;
|
|
184
|
+
if (pageCount > MAX_PAGES_PER_QUERY) {
|
|
185
|
+
throw new ConfluenceApiError(`Pagination limit (${MAX_PAGES_PER_QUERY}) exceeded`, 0, nextUrl, "");
|
|
186
|
+
}
|
|
187
|
+
if (visited.has(nextUrl)) {
|
|
188
|
+
throw new ConfluenceApiError("Confluence pagination loop detected", 0, nextUrl, "");
|
|
189
|
+
}
|
|
190
|
+
visited.add(nextUrl);
|
|
191
|
+
const data = await this.requestJson(nextUrl, { method: "GET" });
|
|
192
|
+
results.push(...data.results);
|
|
193
|
+
nextUrl = resolveNextUrl(this.baseUrl, this.baseUrlOrigin, data._links?.next);
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
158
197
|
async getAttachments(pageId) {
|
|
159
198
|
const results = [];
|
|
160
199
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -1661,6 +1700,141 @@ function runMmdc(cmdPath, source, outFile, timeoutMs, maxStreamBytes) {
|
|
|
1661
1700
|
});
|
|
1662
1701
|
}
|
|
1663
1702
|
|
|
1703
|
+
// src/parent-summary.ts
|
|
1704
|
+
var PARENT_SUMMARY_START_MARKER = "<!-- repo-toolkit-confluence:parent-summary:start -->";
|
|
1705
|
+
var PARENT_SUMMARY_END_MARKER = "<!-- repo-toolkit-confluence:parent-summary:end -->";
|
|
1706
|
+
function escapeCdata(text) {
|
|
1707
|
+
return text.replace(/]]>/g, "]]]]><![CDATA[>");
|
|
1708
|
+
}
|
|
1709
|
+
function pageLink(pageId, title) {
|
|
1710
|
+
const safeTitle = escapeCdata(title);
|
|
1711
|
+
return `<ac:link><ri:page ri:content-id="${escapeXmlAttribute(pageId)}" /><ac:plain-text-link-body><![CDATA[${safeTitle}]]></ac:plain-text-link-body></ac:link>`;
|
|
1712
|
+
}
|
|
1713
|
+
function countOccurrences(haystack, needle) {
|
|
1714
|
+
let count = 0;
|
|
1715
|
+
let idx = 0;
|
|
1716
|
+
while (true) {
|
|
1717
|
+
const found = haystack.indexOf(needle, idx);
|
|
1718
|
+
if (found === -1) {
|
|
1719
|
+
break;
|
|
1720
|
+
}
|
|
1721
|
+
count += 1;
|
|
1722
|
+
idx = found + needle.length;
|
|
1723
|
+
}
|
|
1724
|
+
return count;
|
|
1725
|
+
}
|
|
1726
|
+
function mergeParentSummaryBody(currentBody, generatedRegion) {
|
|
1727
|
+
const startCount = countOccurrences(currentBody, PARENT_SUMMARY_START_MARKER);
|
|
1728
|
+
const endCount = countOccurrences(currentBody, PARENT_SUMMARY_END_MARKER);
|
|
1729
|
+
if (startCount === 0 && endCount === 0) {
|
|
1730
|
+
if (currentBody === "") {
|
|
1731
|
+
return generatedRegion;
|
|
1732
|
+
}
|
|
1733
|
+
const sep2 = currentBody.endsWith("\n") ? "" : "\n";
|
|
1734
|
+
return currentBody + sep2 + generatedRegion;
|
|
1735
|
+
}
|
|
1736
|
+
if (startCount === 1 && endCount === 1) {
|
|
1737
|
+
const startIdx = currentBody.indexOf(PARENT_SUMMARY_START_MARKER);
|
|
1738
|
+
const endIdx = currentBody.indexOf(PARENT_SUMMARY_END_MARKER);
|
|
1739
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
1740
|
+
throw new Error("malformed parent summary markers: missing marker");
|
|
1741
|
+
}
|
|
1742
|
+
if (startIdx > endIdx) {
|
|
1743
|
+
throw new Error("malformed parent summary markers: start after end");
|
|
1744
|
+
}
|
|
1745
|
+
const before = currentBody.slice(0, startIdx);
|
|
1746
|
+
const after = currentBody.slice(endIdx + PARENT_SUMMARY_END_MARKER.length);
|
|
1747
|
+
if (countOccurrences(before, PARENT_SUMMARY_START_MARKER) !== 0 || countOccurrences(before, PARENT_SUMMARY_END_MARKER) !== 0) {
|
|
1748
|
+
throw new Error("malformed parent summary markers: duplicate marker before region");
|
|
1749
|
+
}
|
|
1750
|
+
if (countOccurrences(after, PARENT_SUMMARY_START_MARKER) !== 0 || countOccurrences(after, PARENT_SUMMARY_END_MARKER) !== 0) {
|
|
1751
|
+
throw new Error("malformed parent summary markers: duplicate marker after region");
|
|
1752
|
+
}
|
|
1753
|
+
return before + generatedRegion + after;
|
|
1754
|
+
}
|
|
1755
|
+
throw new Error("malformed or duplicate parent summary markers: expected 0 or 1 managed region");
|
|
1756
|
+
}
|
|
1757
|
+
function renderParentSummary(input) {
|
|
1758
|
+
const lines = [];
|
|
1759
|
+
lines.push(PARENT_SUMMARY_START_MARKER);
|
|
1760
|
+
lines.push("<h2>Synced documentation</h2>");
|
|
1761
|
+
if (input.repositoryUrl) {
|
|
1762
|
+
const url = escapeXmlAttribute(input.repositoryUrl);
|
|
1763
|
+
const text = escapeHtml(input.repositoryUrl);
|
|
1764
|
+
lines.push(
|
|
1765
|
+
`<p><em>This documentation subtree is synced from <a href="${url}">${text}</a> and maintained by <code>repo-toolkit-confluence</code>.</em></p>`
|
|
1766
|
+
);
|
|
1767
|
+
} else {
|
|
1768
|
+
lines.push("<p><em>This documentation subtree is maintained by <code>repo-toolkit-confluence</code>.</em></p>");
|
|
1769
|
+
}
|
|
1770
|
+
lines.push("<h3>Statistics</h3>");
|
|
1771
|
+
lines.push("<ul>");
|
|
1772
|
+
lines.push(`<li>Markdown pages: ${input.stats.markdownPages}</li>`);
|
|
1773
|
+
lines.push(`<li>Directory pages: ${input.stats.directoryPages}</li>`);
|
|
1774
|
+
lines.push(`<li>Total managed pages: ${input.stats.totalPages}</li>`);
|
|
1775
|
+
lines.push(`<li>Maximum depth: ${input.stats.maxDepth}</li>`);
|
|
1776
|
+
lines.push(`<li>Attachment references: ${input.stats.attachmentReferences}</li>`);
|
|
1777
|
+
lines.push(`<li>Mermaid blocks: ${input.stats.mermaidBlocks}</li>`);
|
|
1778
|
+
lines.push("</ul>");
|
|
1779
|
+
lines.push("<h3>Pages</h3>");
|
|
1780
|
+
if (input.pages.length === 0) {
|
|
1781
|
+
lines.push("<p>No managed child pages</p>");
|
|
1782
|
+
} else {
|
|
1783
|
+
lines.push(renderTree(input.pages));
|
|
1784
|
+
}
|
|
1785
|
+
lines.push("<h3>Ownership</h3>");
|
|
1786
|
+
lines.push(
|
|
1787
|
+
`<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>`
|
|
1788
|
+
);
|
|
1789
|
+
lines.push(PARENT_SUMMARY_END_MARKER);
|
|
1790
|
+
return lines.join("\n");
|
|
1791
|
+
}
|
|
1792
|
+
function renderTree(pages) {
|
|
1793
|
+
const sorted = [...pages].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
1794
|
+
const root = { children: /* @__PURE__ */ new Map(), key: "" };
|
|
1795
|
+
for (const page of sorted) {
|
|
1796
|
+
const parts = page.relativePath.split("/");
|
|
1797
|
+
let node = root;
|
|
1798
|
+
let currentPath = "";
|
|
1799
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
1800
|
+
const part = parts[i] ?? "";
|
|
1801
|
+
currentPath = currentPath ? currentPath + "/" + part : part;
|
|
1802
|
+
let child = node.children.get(part);
|
|
1803
|
+
if (!child) {
|
|
1804
|
+
child = { children: /* @__PURE__ */ new Map(), key: part };
|
|
1805
|
+
node.children.set(part, child);
|
|
1806
|
+
}
|
|
1807
|
+
if (i === parts.length - 1) {
|
|
1808
|
+
child.record = page;
|
|
1809
|
+
}
|
|
1810
|
+
node = child;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
const renderNode = (node) => {
|
|
1814
|
+
const entries = [...node.children.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
1815
|
+
if (entries.length === 0) {
|
|
1816
|
+
return "";
|
|
1817
|
+
}
|
|
1818
|
+
let html = "<ul>";
|
|
1819
|
+
for (const [, child] of entries) {
|
|
1820
|
+
const rec = child.record;
|
|
1821
|
+
if (rec) {
|
|
1822
|
+
const link = pageLink(rec.pageId, rec.title);
|
|
1823
|
+
const kindLabel = rec.kind === "directory" ? "directory" : "page";
|
|
1824
|
+
const pathCode = `<code>${escapeHtml(rec.relativePath)}</code>`;
|
|
1825
|
+
const childrenHtml = renderNode(child);
|
|
1826
|
+
html += `<li>${link} \u2014 ${pathCode} <em>(${kindLabel})</em>${childrenHtml}</li>`;
|
|
1827
|
+
} else {
|
|
1828
|
+
const childrenHtml = renderNode(child);
|
|
1829
|
+
html += `<li>${escapeHtml(child.key)}${childrenHtml}</li>`;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
html += "</ul>";
|
|
1833
|
+
return html;
|
|
1834
|
+
};
|
|
1835
|
+
return renderNode(root);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1664
1838
|
// src/index.ts
|
|
1665
1839
|
var LocalSyncValidationAggregateError = class extends Error {
|
|
1666
1840
|
constructor(defects) {
|
|
@@ -1718,6 +1892,129 @@ var SyncMutationError = class extends Error {
|
|
|
1718
1892
|
this.unprocessed = input.unprocessed;
|
|
1719
1893
|
}
|
|
1720
1894
|
};
|
|
1895
|
+
var ReconciliationError = class extends Error {
|
|
1896
|
+
constructor(input) {
|
|
1897
|
+
super(input.failure.error.message);
|
|
1898
|
+
this.name = "ReconciliationError";
|
|
1899
|
+
this.phase = input.phase;
|
|
1900
|
+
this.completed = input.completed;
|
|
1901
|
+
this.failure = input.failure;
|
|
1902
|
+
this.unprocessed = input.unprocessed;
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
var ParentSummaryError = class extends Error {
|
|
1906
|
+
constructor(input) {
|
|
1907
|
+
super(input.failure.error.message);
|
|
1908
|
+
this.name = "ParentSummaryError";
|
|
1909
|
+
this.phase = "parent-summary";
|
|
1910
|
+
this.changes = input.changes;
|
|
1911
|
+
this.labelsAdded = input.labelsAdded;
|
|
1912
|
+
this.cleanDeletions = input.cleanDeletions;
|
|
1913
|
+
this.pruneDeletions = input.pruneDeletions;
|
|
1914
|
+
this.blocked = input.blocked;
|
|
1915
|
+
this.failure = input.failure;
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
function planStalePruning(input) {
|
|
1919
|
+
const nodes = buildInventoryNodes(input.parentPageId, input.inventory);
|
|
1920
|
+
const retained = /* @__PURE__ */ new Set();
|
|
1921
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1922
|
+
const isStale = (entry) => entry.type === "page" && entry.labeled && !input.expectedIds.has(entry.id) && entry.id !== input.parentPageId;
|
|
1923
|
+
const visit = (node) => {
|
|
1924
|
+
if (visited.has(node.entry.id)) {
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
visited.add(node.entry.id);
|
|
1928
|
+
let safe = isStale(node.entry);
|
|
1929
|
+
for (const child of node.children) {
|
|
1930
|
+
visit(child);
|
|
1931
|
+
if (retained.has(child.entry.id)) {
|
|
1932
|
+
safe = false;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
if (!safe) {
|
|
1936
|
+
retained.add(node.entry.id);
|
|
1937
|
+
}
|
|
1938
|
+
};
|
|
1939
|
+
for (const node of nodes.values()) {
|
|
1940
|
+
visit(node);
|
|
1941
|
+
}
|
|
1942
|
+
const deletions = deepestFirst([...nodes.values()].filter((n) => !retained.has(n.entry.id)));
|
|
1943
|
+
const blocked = [...nodes.values()].filter((n) => retained.has(n.entry.id) && isStale(n.entry)).map((n) => n.entry.id).sort();
|
|
1944
|
+
return { deletions, blocked };
|
|
1945
|
+
}
|
|
1946
|
+
function planCleanDeletions(input) {
|
|
1947
|
+
const nodes = buildInventoryNodes(input.parentPageId, input.inventory);
|
|
1948
|
+
for (const node of nodes.values()) {
|
|
1949
|
+
if (node.entry.type !== "page") {
|
|
1950
|
+
throw new Error(
|
|
1951
|
+
`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`
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
return { deletions: deepestFirst([...nodes.values()]), blocked: [] };
|
|
1956
|
+
}
|
|
1957
|
+
function buildInventoryNodes(parentPageId, inventory) {
|
|
1958
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
1959
|
+
for (const entry of inventory) {
|
|
1960
|
+
if (entry.id === parentPageId) {
|
|
1961
|
+
continue;
|
|
1962
|
+
}
|
|
1963
|
+
if (nodes.has(entry.id)) {
|
|
1964
|
+
throw new Error(`Incomplete descendant inventory: duplicate id ${entry.id}`);
|
|
1965
|
+
}
|
|
1966
|
+
nodes.set(entry.id, { entry, depth: -1, children: [] });
|
|
1967
|
+
}
|
|
1968
|
+
const depthOf = (node) => {
|
|
1969
|
+
if (node.depth >= 0) {
|
|
1970
|
+
return node.depth;
|
|
1971
|
+
}
|
|
1972
|
+
if (typeof node.entry.depth === "number") {
|
|
1973
|
+
node.depth = node.entry.depth;
|
|
1974
|
+
return node.depth;
|
|
1975
|
+
}
|
|
1976
|
+
let depth = 0;
|
|
1977
|
+
let current = node;
|
|
1978
|
+
const seen = /* @__PURE__ */ new Set([node.entry.id]);
|
|
1979
|
+
while (true) {
|
|
1980
|
+
const pid = current.entry.parentId;
|
|
1981
|
+
if (pid === parentPageId) {
|
|
1982
|
+
depth += 1;
|
|
1983
|
+
node.depth = depth;
|
|
1984
|
+
return depth;
|
|
1985
|
+
}
|
|
1986
|
+
if (!pid) {
|
|
1987
|
+
throw new Error(`Incomplete descendant inventory: missing parent for ${node.entry.id}`);
|
|
1988
|
+
}
|
|
1989
|
+
if (seen.has(pid)) {
|
|
1990
|
+
throw new Error(`Incomplete descendant inventory: parent cycle at ${pid}`);
|
|
1991
|
+
}
|
|
1992
|
+
seen.add(pid);
|
|
1993
|
+
const parent = nodes.get(pid);
|
|
1994
|
+
if (!parent) {
|
|
1995
|
+
throw new Error(
|
|
1996
|
+
`Incomplete descendant inventory: ancestor ${pid} of ${node.entry.id} is missing from the listing`
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
depth += 1;
|
|
2000
|
+
current = parent;
|
|
2001
|
+
}
|
|
2002
|
+
};
|
|
2003
|
+
for (const node of nodes.values()) {
|
|
2004
|
+
depthOf(node);
|
|
2005
|
+
const pid = node.entry.parentId;
|
|
2006
|
+
if (pid !== void 0 && pid !== parentPageId) {
|
|
2007
|
+
nodes.get(pid)?.children.push(node);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
return nodes;
|
|
2011
|
+
}
|
|
2012
|
+
function deepestFirst(nodes) {
|
|
2013
|
+
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);
|
|
2014
|
+
}
|
|
2015
|
+
function hasManagedMarker(labels) {
|
|
2016
|
+
return labels.some((label) => label.name === CONFLUENCE_MANAGED_LABEL && label.prefix === "global");
|
|
2017
|
+
}
|
|
1721
2018
|
function resolveConfluenceSyncPlan(options = {}) {
|
|
1722
2019
|
const pageTitleStrategy = resolvePageTitleStrategy(options.pageTitleStrategy);
|
|
1723
2020
|
const cwd = resolve2(options.cwd ?? process.cwd());
|
|
@@ -1767,6 +2064,8 @@ function resolveConfluenceSyncPlan(options = {}) {
|
|
|
1767
2064
|
dryRun: options.dryRun ?? false,
|
|
1768
2065
|
renderHtmlBlocks: options.renderHtmlBlocks === true,
|
|
1769
2066
|
repositoryUrl,
|
|
2067
|
+
clean: options.clean ?? false,
|
|
2068
|
+
updateParentPage: options.updateParentPage ?? true,
|
|
1770
2069
|
pageTitleStrategy
|
|
1771
2070
|
};
|
|
1772
2071
|
}
|
|
@@ -1776,7 +2075,6 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
1776
2075
|
const tree = await readDocTree(plan.folder);
|
|
1777
2076
|
if (tree.entries.length === 0) {
|
|
1778
2077
|
log(`No markdown files found under ${plan.folder}`);
|
|
1779
|
-
return;
|
|
1780
2078
|
}
|
|
1781
2079
|
const localPlan = validateLocalSync(tree.entries, plan);
|
|
1782
2080
|
validateLocalHierarchy(localPlan.entries, plan.pageTitleStrategy);
|
|
@@ -1789,6 +2087,32 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
1789
2087
|
`[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"})` : "")
|
|
1790
2088
|
);
|
|
1791
2089
|
}
|
|
2090
|
+
if (plan.clean) {
|
|
2091
|
+
log(
|
|
2092
|
+
"[dry-run] clean requested: a real sync would move every page descendant of the target page to trash before recreating the local hierarchy."
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
log(
|
|
2096
|
+
"[dry-run] a real sync would label every mapped page with the ownership marker and prune stale labeled descendants."
|
|
2097
|
+
);
|
|
2098
|
+
if (plan.updateParentPage) {
|
|
2099
|
+
const stats = computeDryRunStats(localPlan);
|
|
2100
|
+
log(
|
|
2101
|
+
`[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}`
|
|
2102
|
+
);
|
|
2103
|
+
if (localPlan.entries.length === 0) {
|
|
2104
|
+
log("[dry-run] parent tree: No managed child pages");
|
|
2105
|
+
} else {
|
|
2106
|
+
for (const entryPlan of localPlan.entries) {
|
|
2107
|
+
const dirParts = entryPlan.entry.segments.slice(0, -1);
|
|
2108
|
+
for (let i = 0; i < dirParts.length; i += 1) {
|
|
2109
|
+
const dirPath = dirParts.slice(0, i + 1).join("/");
|
|
2110
|
+
log(`[dry-run] parent tree: ${dirPath} (directory) => "${dirParts[i] ?? ""}"`);
|
|
2111
|
+
}
|
|
2112
|
+
log(`[dry-run] parent tree: ${entryPlan.entry.segments.join("/")} (page) => "${entryPlan.title}"`);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
1792
2116
|
return;
|
|
1793
2117
|
}
|
|
1794
2118
|
const client = options.client ?? new ConfluenceClient({
|
|
@@ -1796,13 +2120,29 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
1796
2120
|
username: plan.username,
|
|
1797
2121
|
apiToken: plan.apiToken
|
|
1798
2122
|
});
|
|
2123
|
+
const labelsAdded = [];
|
|
2124
|
+
const cleanDeletions = [];
|
|
2125
|
+
const pruneDeletions = [];
|
|
2126
|
+
const blocked = [];
|
|
2127
|
+
if (plan.clean) {
|
|
2128
|
+
const descendants = await client.getPageDescendants(plan.parentPageId);
|
|
2129
|
+
const inventory = descendants.filter((d) => d.id !== plan.parentPageId).map((d) => toInventoryEntry(d, false));
|
|
2130
|
+
const cleanPlan = planCleanDeletions({ parentPageId: plan.parentPageId, inventory });
|
|
2131
|
+
await executeDeletions(cleanPlan.deletions, "clean", client, log, cleanDeletions);
|
|
2132
|
+
}
|
|
1799
2133
|
const spaceId = await client.getSpaceIdByKey(plan.spaceKey);
|
|
1800
2134
|
const cache = new PageTitleCache(spaceId, client);
|
|
2135
|
+
const syncState = {
|
|
2136
|
+
mappedIds: /* @__PURE__ */ new Set(),
|
|
2137
|
+
ensuredLabels: /* @__PURE__ */ new Set(),
|
|
2138
|
+
labelsAdded,
|
|
2139
|
+
mappedRecords: /* @__PURE__ */ new Map()
|
|
2140
|
+
};
|
|
1801
2141
|
const changes = [];
|
|
1802
2142
|
for (let i = 0; i < localPlan.entries.length; i += 1) {
|
|
1803
2143
|
const entryPlan = localPlan.entries[i];
|
|
1804
2144
|
try {
|
|
1805
|
-
await syncEntry(entryPlan, plan, client, cache, log, changes);
|
|
2145
|
+
await syncEntry(entryPlan, plan, client, cache, log, changes, syncState);
|
|
1806
2146
|
} catch (error) {
|
|
1807
2147
|
throw new SyncMutationError({
|
|
1808
2148
|
changes,
|
|
@@ -1811,15 +2151,202 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
1811
2151
|
});
|
|
1812
2152
|
}
|
|
1813
2153
|
}
|
|
1814
|
-
|
|
2154
|
+
if (!plan.clean) {
|
|
2155
|
+
const descendants = await client.getPageDescendants(plan.parentPageId);
|
|
2156
|
+
const inventory = [];
|
|
2157
|
+
for (const d of descendants) {
|
|
2158
|
+
if (d.id === plan.parentPageId) {
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
if (d.type !== "page") {
|
|
2162
|
+
inventory.push(toInventoryEntry(d, false));
|
|
2163
|
+
continue;
|
|
2164
|
+
}
|
|
2165
|
+
if (syncState.mappedIds.has(d.id)) {
|
|
2166
|
+
inventory.push(toInventoryEntry(d, true));
|
|
2167
|
+
continue;
|
|
2168
|
+
}
|
|
2169
|
+
const labels = await client.getPageLabels(d.id);
|
|
2170
|
+
inventory.push(toInventoryEntry(d, hasManagedMarker(labels)));
|
|
2171
|
+
}
|
|
2172
|
+
const prunePlan = planStalePruning({
|
|
2173
|
+
parentPageId: plan.parentPageId,
|
|
2174
|
+
expectedIds: syncState.mappedIds,
|
|
2175
|
+
inventory
|
|
2176
|
+
});
|
|
2177
|
+
await executeDeletions(prunePlan.deletions, "prune", client, log, pruneDeletions);
|
|
2178
|
+
for (const pageId of prunePlan.blocked) {
|
|
2179
|
+
blocked.push(pageId);
|
|
2180
|
+
log(`blocked: stale page ${pageId} retained because it has unlabeled, non-page, or expected descendants`);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
let parentStatus = "skipped";
|
|
2184
|
+
if (plan.updateParentPage) {
|
|
2185
|
+
try {
|
|
2186
|
+
const parentPage = await client.getPage(plan.parentPageId);
|
|
2187
|
+
const stats = computeParentStats(localPlan, syncState);
|
|
2188
|
+
const pages = [...syncState.mappedRecords.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
2189
|
+
const region = renderParentSummary({ repositoryUrl: plan.repositoryUrl, stats, pages });
|
|
2190
|
+
const currentBody = parentPage.body?.storage?.value ?? "";
|
|
2191
|
+
const merged = mergeParentSummaryBody(currentBody, region);
|
|
2192
|
+
if (merged === currentBody) {
|
|
2193
|
+
log(`parent-unchanged: page ${plan.parentPageId}`);
|
|
2194
|
+
parentStatus = "unchanged";
|
|
2195
|
+
} else {
|
|
2196
|
+
await client.updatePage({
|
|
2197
|
+
id: plan.parentPageId,
|
|
2198
|
+
title: parentPage.title,
|
|
2199
|
+
body: { representation: "storage", value: merged },
|
|
2200
|
+
version: { number: (parentPage.version?.number ?? 0) + 1, message: plan.versionMessage }
|
|
2201
|
+
});
|
|
2202
|
+
log(`parent-updated: page ${plan.parentPageId}`);
|
|
2203
|
+
parentStatus = "updated";
|
|
2204
|
+
}
|
|
2205
|
+
} catch (error) {
|
|
2206
|
+
throw new ParentSummaryError({
|
|
2207
|
+
changes,
|
|
2208
|
+
labelsAdded: [...labelsAdded],
|
|
2209
|
+
cleanDeletions: [...cleanDeletions],
|
|
2210
|
+
pruneDeletions: [...pruneDeletions],
|
|
2211
|
+
blocked: [...blocked],
|
|
2212
|
+
failure: { pageId: plan.parentPageId, error: error instanceof Error ? error : new Error(String(error)) }
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
return { changes, labelsAdded, cleanDeletions, pruneDeletions, blocked, parentStatus };
|
|
2217
|
+
}
|
|
2218
|
+
function computeParentStats(localPlan, state) {
|
|
2219
|
+
const dirSet = /* @__PURE__ */ new Set();
|
|
2220
|
+
let maxDepth = 0;
|
|
2221
|
+
let attachmentReferences = 0;
|
|
2222
|
+
let mermaidBlocks = 0;
|
|
2223
|
+
for (const entryPlan of localPlan.entries) {
|
|
2224
|
+
const segs = entryPlan.entry.segments;
|
|
2225
|
+
if (segs.length > maxDepth) {
|
|
2226
|
+
maxDepth = segs.length;
|
|
2227
|
+
}
|
|
2228
|
+
for (let i = 0; i < segs.length - 1; i += 1) {
|
|
2229
|
+
dirSet.add(segs.slice(0, i + 1).join("/"));
|
|
2230
|
+
}
|
|
2231
|
+
attachmentReferences += entryPlan.attachments.length;
|
|
2232
|
+
mermaidBlocks += entryPlan.mermaidBlocks.length;
|
|
2233
|
+
}
|
|
2234
|
+
const markdownPages = localPlan.entries.length;
|
|
2235
|
+
const directoryPages = dirSet.size;
|
|
2236
|
+
const totalPages = state.mappedRecords.size;
|
|
2237
|
+
const effectiveMaxDepth = localPlan.entries.length === 0 ? 0 : maxDepth;
|
|
2238
|
+
return {
|
|
2239
|
+
markdownPages,
|
|
2240
|
+
directoryPages,
|
|
2241
|
+
totalPages: totalPages > 0 ? totalPages : directoryPages + markdownPages,
|
|
2242
|
+
maxDepth: effectiveMaxDepth,
|
|
2243
|
+
attachmentReferences,
|
|
2244
|
+
mermaidBlocks
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
function computeDryRunStats(localPlan) {
|
|
2248
|
+
const dirSet = /* @__PURE__ */ new Set();
|
|
2249
|
+
let maxDepth = 0;
|
|
2250
|
+
let attachmentReferences = 0;
|
|
2251
|
+
let mermaidBlocks = 0;
|
|
2252
|
+
for (const entryPlan of localPlan.entries) {
|
|
2253
|
+
const segs = entryPlan.entry.segments;
|
|
2254
|
+
if (segs.length > maxDepth) {
|
|
2255
|
+
maxDepth = segs.length;
|
|
2256
|
+
}
|
|
2257
|
+
for (let i = 0; i < segs.length - 1; i += 1) {
|
|
2258
|
+
dirSet.add(segs.slice(0, i + 1).join("/"));
|
|
2259
|
+
}
|
|
2260
|
+
attachmentReferences += entryPlan.attachments.length;
|
|
2261
|
+
mermaidBlocks += entryPlan.mermaidBlocks.length;
|
|
2262
|
+
}
|
|
2263
|
+
const markdownPages = localPlan.entries.length;
|
|
2264
|
+
const directoryPages = dirSet.size;
|
|
2265
|
+
return {
|
|
2266
|
+
markdownPages,
|
|
2267
|
+
directoryPages,
|
|
2268
|
+
totalPages: directoryPages + markdownPages,
|
|
2269
|
+
maxDepth: markdownPages === 0 ? 0 : maxDepth,
|
|
2270
|
+
attachmentReferences,
|
|
2271
|
+
mermaidBlocks
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
2274
|
+
function toInventoryEntry(d, labeled) {
|
|
2275
|
+
const entry = { id: d.id, type: d.type, labeled };
|
|
2276
|
+
if (d.parentId !== void 0) {
|
|
2277
|
+
entry.parentId = d.parentId;
|
|
2278
|
+
}
|
|
2279
|
+
if (d.depth !== void 0) {
|
|
2280
|
+
entry.depth = d.depth;
|
|
2281
|
+
}
|
|
2282
|
+
if (d.title !== void 0) {
|
|
2283
|
+
entry.title = d.title;
|
|
2284
|
+
}
|
|
2285
|
+
return entry;
|
|
1815
2286
|
}
|
|
1816
|
-
async function
|
|
2287
|
+
async function ensureManagedLabel(pageId, client, state, log) {
|
|
2288
|
+
if (state.ensuredLabels.has(pageId)) {
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
const labels = await client.getPageLabels(pageId);
|
|
2292
|
+
if (!hasManagedMarker(labels)) {
|
|
2293
|
+
await client.addManagedLabel(pageId);
|
|
2294
|
+
state.labelsAdded.push(pageId);
|
|
2295
|
+
log(`labeled: page ${pageId}`);
|
|
2296
|
+
}
|
|
2297
|
+
state.ensuredLabels.add(pageId);
|
|
2298
|
+
}
|
|
2299
|
+
async function executeDeletions(ids, phase, client, log, evidence) {
|
|
2300
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
2301
|
+
const pageId = ids[i];
|
|
2302
|
+
if (pageId === void 0) {
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
try {
|
|
2306
|
+
await client.deletePage(pageId);
|
|
2307
|
+
evidence.push(pageId);
|
|
2308
|
+
log(`${phase === "clean" ? "clean" : "pruned"}: trashed page ${pageId}`);
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
throw new ReconciliationError({
|
|
2311
|
+
phase,
|
|
2312
|
+
completed: [...evidence],
|
|
2313
|
+
failure: { pageId, error: error instanceof Error ? error : new Error(String(error)) },
|
|
2314
|
+
unprocessed: ids.slice(i + 1)
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
async function syncEntry(entryPlan, plan, client, cache, log, changes, state) {
|
|
1817
2320
|
const { entry, html: precomputedHtml, mermaidBlocks, markdownDir, hasLocalImages, hasMermaidBlocks } = entryPlan;
|
|
1818
2321
|
const segments = entry.segments;
|
|
1819
2322
|
if (segments.length === 0) {
|
|
1820
2323
|
return;
|
|
1821
2324
|
}
|
|
1822
2325
|
let currentParentId = plan.parentPageId;
|
|
2326
|
+
const recordDirectory = (relativePath, title, pageId, depth) => {
|
|
2327
|
+
if (!state.mappedRecords.has(relativePath)) {
|
|
2328
|
+
state.mappedRecords.set(relativePath, {
|
|
2329
|
+
relativePath,
|
|
2330
|
+
kind: "directory",
|
|
2331
|
+
title,
|
|
2332
|
+
pageId,
|
|
2333
|
+
depth,
|
|
2334
|
+
attachmentCount: 0,
|
|
2335
|
+
mermaidCount: 0
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
};
|
|
2339
|
+
const recordLeaf = (relativePath, title, pageId, depth) => {
|
|
2340
|
+
state.mappedRecords.set(relativePath, {
|
|
2341
|
+
relativePath,
|
|
2342
|
+
kind: "leaf",
|
|
2343
|
+
title,
|
|
2344
|
+
pageId,
|
|
2345
|
+
depth,
|
|
2346
|
+
attachmentCount: entryPlan.attachments.length,
|
|
2347
|
+
mermaidCount: entryPlan.mermaidBlocks.length
|
|
2348
|
+
});
|
|
2349
|
+
};
|
|
1823
2350
|
for (let idx = 0; idx < segments.length; idx += 1) {
|
|
1824
2351
|
const isLast = idx === segments.length - 1;
|
|
1825
2352
|
const segment = segments[idx] ?? "";
|
|
@@ -1833,12 +2360,18 @@ async function syncEntry(entryPlan, plan, client, cache, log, changes) {
|
|
|
1833
2360
|
parentId: currentParentId,
|
|
1834
2361
|
body: { representation: "storage", value: precomputedHtml }
|
|
1835
2362
|
});
|
|
2363
|
+
state.mappedIds.add(pageId2);
|
|
2364
|
+
await ensureManagedLabel(pageId2, client, state, log);
|
|
2365
|
+
recordLeaf(segments.join("/"), title, pageId2, segments.length);
|
|
1836
2366
|
log(`created: ${segments.join("/")} (page ${pageId2})`);
|
|
1837
2367
|
changes.push({ entry, pageId: pageId2, kind: "created" });
|
|
1838
2368
|
return;
|
|
1839
2369
|
}
|
|
1840
2370
|
const existingPage = existing ?? await cache.findOrCreate(title, currentParentId);
|
|
1841
2371
|
const pageId = existingPage.id;
|
|
2372
|
+
state.mappedIds.add(pageId);
|
|
2373
|
+
await ensureManagedLabel(pageId, client, state, log);
|
|
2374
|
+
recordLeaf(segments.join("/"), title, pageId, segments.length);
|
|
1842
2375
|
const current = await client.getPage(pageId);
|
|
1843
2376
|
const currentBody = current.body?.storage?.value ?? "";
|
|
1844
2377
|
let body = precomputedHtml;
|
|
@@ -1885,10 +2418,16 @@ async function syncEntry(entryPlan, plan, client, cache, log, changes) {
|
|
|
1885
2418
|
if (isMarkdownName(segment)) {
|
|
1886
2419
|
const title = pageTitleFromSegments(segments.slice(0, idx + 1), plan.pageTitleStrategy);
|
|
1887
2420
|
const page2 = await cache.findOrCreate(title, currentParentId);
|
|
2421
|
+
state.mappedIds.add(page2.id);
|
|
2422
|
+
await ensureManagedLabel(page2.id, client, state, log);
|
|
2423
|
+
recordDirectory(segments.slice(0, idx + 1).join("/"), title, page2.id, idx + 1);
|
|
1888
2424
|
currentParentId = page2.id;
|
|
1889
2425
|
continue;
|
|
1890
2426
|
}
|
|
1891
2427
|
const page = await cache.findOrCreate(segment, currentParentId);
|
|
2428
|
+
state.mappedIds.add(page.id);
|
|
2429
|
+
await ensureManagedLabel(page.id, client, state, log);
|
|
2430
|
+
recordDirectory(segments.slice(0, idx + 1).join("/"), segment, page.id, idx + 1);
|
|
1892
2431
|
currentParentId = page.id;
|
|
1893
2432
|
}
|
|
1894
2433
|
}
|
|
@@ -2052,11 +2591,13 @@ var SPECS = [
|
|
|
2052
2591
|
{ name: "skip-unchanged", boolean: true, negatable: true },
|
|
2053
2592
|
{ name: "dry-run", boolean: true },
|
|
2054
2593
|
{ name: "render-html-blocks", boolean: true },
|
|
2594
|
+
{ name: "clean", boolean: true },
|
|
2595
|
+
{ name: "update-parent-page", boolean: true, negatable: true },
|
|
2055
2596
|
INTERACTIVE_FLAG
|
|
2056
2597
|
];
|
|
2057
2598
|
var ENV_TRUTHY = /* @__PURE__ */ new Set(["true", "1", "yes", "on"]);
|
|
2058
2599
|
var ENV_FALSY = /* @__PURE__ */ new Set(["false", "0", "no", "off", ""]);
|
|
2059
|
-
var BOOLEAN_ENV_KEYS = /* @__PURE__ */ new Set(["skipUnchanged", "dryRun", "renderHtmlBlocks"]);
|
|
2600
|
+
var BOOLEAN_ENV_KEYS = /* @__PURE__ */ new Set(["skipUnchanged", "dryRun", "renderHtmlBlocks", "clean", "updateParentPage"]);
|
|
2060
2601
|
function isBooleanOption(key) {
|
|
2061
2602
|
return BOOLEAN_ENV_KEYS.has(key);
|
|
2062
2603
|
}
|
|
@@ -2098,6 +2639,8 @@ Environment variables (CLI form; GitHub Action INPUT_* form is also read):
|
|
|
2098
2639
|
CONFLUENCE_SKIP_UNCHANGED true|false (default: true)
|
|
2099
2640
|
CONFLUENCE_DRY_RUN true|false (default: false)
|
|
2100
2641
|
CONFLUENCE_RENDER_HTML_BLOCKS true|false (default: false)
|
|
2642
|
+
CONFLUENCE_CLEAN true|false (default: false)
|
|
2643
|
+
CONFLUENCE_UPDATE_PARENT_PAGE true|false (default: true)
|
|
2101
2644
|
INPUT_<UPPER-FLAG> GitHub Actions input form (lower precedence)
|
|
2102
2645
|
|
|
2103
2646
|
Note: prefer CONFLUENCE_API_TOKEN_FILE or CONFLUENCE_API_TOKEN over
|
|
@@ -2122,6 +2665,11 @@ Options:
|
|
|
2122
2665
|
--dry-run Walk the doc tree and print the plan without API calls
|
|
2123
2666
|
--render-html-blocks Render \`\`\`html fenced blocks as inline HTML via the
|
|
2124
2667
|
Confluence html macro (default: false; emits as code box)
|
|
2668
|
+
--clean Move all page descendants to trash before recreation (default: false).
|
|
2669
|
+
WARNING: destructive \u2014 all page descendants, including manual/unlabeled
|
|
2670
|
+
pages, are moved to trash; parentPageId is retained.
|
|
2671
|
+
--update-parent-page Update parent page summary region (default: true)
|
|
2672
|
+
--no-update-parent-page Do not update parent page summary
|
|
2125
2673
|
-i, --interactive Prompt interactively for missing non-secret required values
|
|
2126
2674
|
-h, --help Show this help message
|
|
2127
2675
|
`);
|
|
@@ -2162,6 +2710,8 @@ var ENV_BINDINGS = [
|
|
|
2162
2710
|
{ envName: "INPUT_DRY-RUN", key: "dryRun", kind: "boolean" },
|
|
2163
2711
|
{ envName: "INPUT_SKIP-UNCHANGED", key: "skipUnchanged", kind: "boolean" },
|
|
2164
2712
|
{ envName: "INPUT_RENDER-HTML-BLOCKS", key: "renderHtmlBlocks", kind: "boolean" },
|
|
2713
|
+
{ envName: "INPUT_CLEAN", key: "clean", kind: "boolean" },
|
|
2714
|
+
{ envName: "INPUT_UPDATE-PARENT-PAGE", key: "updateParentPage", kind: "boolean" },
|
|
2165
2715
|
{ envName: "CONFLUENCE_FOLDER", key: "folder", kind: "string" },
|
|
2166
2716
|
{ envName: "CONFLUENCE_USERNAME", key: "username", kind: "string" },
|
|
2167
2717
|
{ envName: "CONFLUENCE_API_TOKEN", key: "apiToken", kind: "string" },
|
|
@@ -2174,7 +2724,9 @@ var ENV_BINDINGS = [
|
|
|
2174
2724
|
{ envName: "CONFLUENCE_PAGE_TITLE_STRATEGY", key: "pageTitleStrategy", kind: "string" },
|
|
2175
2725
|
{ envName: "CONFLUENCE_DRY_RUN", key: "dryRun", kind: "boolean" },
|
|
2176
2726
|
{ envName: "CONFLUENCE_SKIP_UNCHANGED", key: "skipUnchanged", kind: "boolean" },
|
|
2177
|
-
{ envName: "CONFLUENCE_RENDER_HTML_BLOCKS", key: "renderHtmlBlocks", kind: "boolean" }
|
|
2727
|
+
{ envName: "CONFLUENCE_RENDER_HTML_BLOCKS", key: "renderHtmlBlocks", kind: "boolean" },
|
|
2728
|
+
{ envName: "CONFLUENCE_CLEAN", key: "clean", kind: "boolean" },
|
|
2729
|
+
{ envName: "CONFLUENCE_UPDATE_PARENT_PAGE", key: "updateParentPage", kind: "boolean" }
|
|
2178
2730
|
];
|
|
2179
2731
|
function optionsFromEnv(env = process.env) {
|
|
2180
2732
|
const options = {};
|
|
@@ -2240,6 +2792,12 @@ function buildOptions(result) {
|
|
|
2240
2792
|
if (values["render-html-blocks"] !== void 0) {
|
|
2241
2793
|
options.renderHtmlBlocks = true;
|
|
2242
2794
|
}
|
|
2795
|
+
if (values["clean"] !== void 0) {
|
|
2796
|
+
options.clean = values["clean"] === "true";
|
|
2797
|
+
}
|
|
2798
|
+
if (values["update-parent-page"] !== void 0) {
|
|
2799
|
+
options.updateParentPage = values["update-parent-page"] === "true";
|
|
2800
|
+
}
|
|
2243
2801
|
return options;
|
|
2244
2802
|
}
|
|
2245
2803
|
async function resolveConfluenceOptions(args) {
|