@stravica-ai/digital-operator-pa 0.1.39 → 0.1.41
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 +12 -2
- package/bin/confluence +9 -0
- package/bin/confluence-fetch +9 -0
- package/bin/jira +9 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
- package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
- package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
- package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
- package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
- package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
- package/dist/engineering-pa-scaffold/README.md +32 -1
- package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
- package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
- package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
- package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
- package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
- package/dist/engineering-pa-types-engineering/index.mjs +14 -6
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
- package/dist/engineering-pa-types-engineering/package.json +2 -2
- package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
- package/dist/engineering-pa-workspace/app/home.html +7 -7
- package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
- package/dist/engineering-pa-workspace/src/api.mjs +1 -1
- package/dist/engineering-pa-workspace/src/server.mjs +1 -1
- package/docs/installing.md +78 -0
- package/docs/onboarding-solo.md +162 -0
- package/docs/what-it-can-do.md +85 -0
- package/package.json +6 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// format.mjs - Terminal output formatting for Confluence data.
|
|
2
|
+
//
|
|
3
|
+
// Provides table rendering, page/space/comment formatting, and
|
|
4
|
+
// ANSI color helpers. Used by CLI commands and workspace API handlers.
|
|
5
|
+
//
|
|
6
|
+
// Zero external dependencies.
|
|
7
|
+
|
|
8
|
+
// ── ANSI color helpers ───────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const IS_TTY = process.stdout.isTTY;
|
|
11
|
+
|
|
12
|
+
function ansi(code, text) {
|
|
13
|
+
if (!IS_TTY) return text;
|
|
14
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function bold(text) { return ansi('1', text); }
|
|
18
|
+
export function dim(text) { return ansi('2', text); }
|
|
19
|
+
export function red(text) { return ansi('31', text); }
|
|
20
|
+
export function green(text) { return ansi('32', text); }
|
|
21
|
+
export function yellow(text) { return ansi('33', text); }
|
|
22
|
+
export function blue(text) { return ansi('34', text); }
|
|
23
|
+
export function cyan(text) { return ansi('36', text); }
|
|
24
|
+
|
|
25
|
+
// ── Status color mapping ─────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const STATUS_COLORS = {
|
|
28
|
+
'current': green,
|
|
29
|
+
'archived': dim,
|
|
30
|
+
'draft': yellow,
|
|
31
|
+
'trashed': red,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function colorStatus(status) {
|
|
35
|
+
if (!status) return '';
|
|
36
|
+
const lower = status.toLowerCase();
|
|
37
|
+
const colorFn = STATUS_COLORS[lower] || ((s) => s);
|
|
38
|
+
return colorFn(status);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Table formatting ─────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render rows as an aligned text table.
|
|
45
|
+
*
|
|
46
|
+
* @param {Array<object>} rows Array of data objects
|
|
47
|
+
* @param {Array<object>} columns Column definitions: { key, label, width?, align? }
|
|
48
|
+
* - key: property name on each row
|
|
49
|
+
* - label: column header text
|
|
50
|
+
* - width: max column width (optional, auto-calculated if omitted)
|
|
51
|
+
* - align: 'left' (default) or 'right'
|
|
52
|
+
* @returns {string} Formatted table
|
|
53
|
+
*/
|
|
54
|
+
export function formatTable(rows, columns) {
|
|
55
|
+
if (!rows || rows.length === 0) return '';
|
|
56
|
+
|
|
57
|
+
// Calculate column widths
|
|
58
|
+
const colWidths = columns.map((col) => {
|
|
59
|
+
const headerLen = col.label.length;
|
|
60
|
+
const maxDataLen = rows.reduce((max, row) => {
|
|
61
|
+
const val = String(row[col.key] ?? '');
|
|
62
|
+
return Math.max(max, val.length);
|
|
63
|
+
}, 0);
|
|
64
|
+
const natural = Math.max(headerLen, maxDataLen);
|
|
65
|
+
return col.width ? Math.min(natural, col.width) : natural;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Header
|
|
69
|
+
const header = columns.map((col, i) => {
|
|
70
|
+
return pad(col.label, colWidths[i], col.align || 'left');
|
|
71
|
+
}).join(' ');
|
|
72
|
+
|
|
73
|
+
// Separator
|
|
74
|
+
const sep = colWidths.map((w) => '-'.repeat(w)).join(' ');
|
|
75
|
+
|
|
76
|
+
// Rows
|
|
77
|
+
const body = rows.map((row) => {
|
|
78
|
+
return columns.map((col, i) => {
|
|
79
|
+
const val = String(row[col.key] ?? '');
|
|
80
|
+
const truncated = val.length > colWidths[i]
|
|
81
|
+
? val.slice(0, colWidths[i] - 1) + '\u2026'
|
|
82
|
+
: val;
|
|
83
|
+
return pad(truncated, colWidths[i], col.align || 'left');
|
|
84
|
+
}).join(' ');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return [bold(header), sep, ...body].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pad(str, width, align) {
|
|
91
|
+
if (align === 'right') return str.padStart(width);
|
|
92
|
+
return str.padEnd(width);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Page formatting ──────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format a single Confluence page for terminal display.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} page Page object (from v2 API response)
|
|
101
|
+
* @returns {string} Formatted output
|
|
102
|
+
*/
|
|
103
|
+
export function formatPage(page) {
|
|
104
|
+
if (!page) return '';
|
|
105
|
+
|
|
106
|
+
const lines = [];
|
|
107
|
+
|
|
108
|
+
// Title
|
|
109
|
+
lines.push(`${bold(page.title || '(untitled)')}`);
|
|
110
|
+
lines.push('');
|
|
111
|
+
|
|
112
|
+
// ID + Status
|
|
113
|
+
const parts = [];
|
|
114
|
+
if (page.id) parts.push(`ID: ${page.id}`);
|
|
115
|
+
if (page.status) parts.push(colorStatus(page.status));
|
|
116
|
+
if (parts.length > 0) lines.push(parts.join(' | '));
|
|
117
|
+
|
|
118
|
+
// Space
|
|
119
|
+
if (page.spaceId) lines.push(`Space ID: ${page.spaceId}`);
|
|
120
|
+
if (page._space) lines.push(`Space: ${page._space}`);
|
|
121
|
+
|
|
122
|
+
// Version
|
|
123
|
+
if (page.version) {
|
|
124
|
+
const ver = typeof page.version === 'object' ? page.version.number : page.version;
|
|
125
|
+
lines.push(`Version: ${ver}`);
|
|
126
|
+
if (page.version.message) {
|
|
127
|
+
lines.push(`Version message: ${page.version.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Author
|
|
132
|
+
if (page.authorId) lines.push(`Author: ${page.authorId}`);
|
|
133
|
+
if (page._author) lines.push(`Author: ${page._author}`);
|
|
134
|
+
|
|
135
|
+
// Labels
|
|
136
|
+
if (page._labels && page._labels.length > 0) {
|
|
137
|
+
lines.push(`Labels: ${page._labels.join(', ')}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Dates
|
|
141
|
+
if (page.createdAt) lines.push(`Created: ${formatDate(page.createdAt)}`);
|
|
142
|
+
if (page.version?.createdAt) lines.push(`Modified: ${formatDate(page.version.createdAt)}`);
|
|
143
|
+
|
|
144
|
+
// URL
|
|
145
|
+
if (page._links?.webui) {
|
|
146
|
+
lines.push(`URL: ${page._links.webui}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return lines.join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Format a list of pages as a compact table.
|
|
154
|
+
*
|
|
155
|
+
* @param {Array<object>} pages Array of Confluence pages
|
|
156
|
+
* @returns {string} Formatted table
|
|
157
|
+
*/
|
|
158
|
+
export function formatPageList(pages) {
|
|
159
|
+
if (!pages || pages.length === 0) return dim('No pages found.');
|
|
160
|
+
|
|
161
|
+
const rows = pages.map((page) => ({
|
|
162
|
+
id: page.id || '',
|
|
163
|
+
title: page.title || '',
|
|
164
|
+
status: page.status || '',
|
|
165
|
+
modified: page.version?.createdAt ? formatDate(page.version.createdAt) : '',
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
return formatTable(rows, [
|
|
169
|
+
{ key: 'id', label: 'ID', width: 12 },
|
|
170
|
+
{ key: 'title', label: 'Title', width: 50 },
|
|
171
|
+
{ key: 'status', label: 'Status', width: 10 },
|
|
172
|
+
{ key: 'modified', label: 'Modified', width: 12 },
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Space formatting ─────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Format a single Confluence space for terminal display.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} space Space object
|
|
182
|
+
* @returns {string} Formatted output
|
|
183
|
+
*/
|
|
184
|
+
export function formatSpace(space) {
|
|
185
|
+
if (!space) return '';
|
|
186
|
+
|
|
187
|
+
const lines = [];
|
|
188
|
+
lines.push(`${bold(space.key || '')} ${space.name || ''}`);
|
|
189
|
+
if (space.type) lines.push(`Type: ${space.type}`);
|
|
190
|
+
if (space.status) lines.push(`Status: ${colorStatus(space.status)}`);
|
|
191
|
+
if (space.description?.plain?.value) {
|
|
192
|
+
lines.push(`\n${space.description.plain.value}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return lines.join('\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Format a list of spaces as a compact table.
|
|
200
|
+
*
|
|
201
|
+
* @param {Array<object>} spaces Array of Confluence spaces
|
|
202
|
+
* @returns {string} Formatted table
|
|
203
|
+
*/
|
|
204
|
+
export function formatSpaceList(spaces) {
|
|
205
|
+
if (!spaces || spaces.length === 0) return dim('No spaces found.');
|
|
206
|
+
|
|
207
|
+
const rows = spaces.map((space) => ({
|
|
208
|
+
key: space.key || '',
|
|
209
|
+
name: space.name || '',
|
|
210
|
+
type: space.type || '',
|
|
211
|
+
status: space.status || '',
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
return formatTable(rows, [
|
|
215
|
+
{ key: 'key', label: 'Key', width: 12 },
|
|
216
|
+
{ key: 'name', label: 'Name', width: 40 },
|
|
217
|
+
{ key: 'type', label: 'Type', width: 12 },
|
|
218
|
+
{ key: 'status', label: 'Status', width: 10 },
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Comment formatting ───────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Format a single Confluence comment for terminal display.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} comment Comment object
|
|
228
|
+
* @returns {string} Formatted output
|
|
229
|
+
*/
|
|
230
|
+
export function formatComment(comment) {
|
|
231
|
+
if (!comment) return '';
|
|
232
|
+
|
|
233
|
+
const lines = [];
|
|
234
|
+
|
|
235
|
+
// v1 comment shape
|
|
236
|
+
const author = comment.author?.displayName || comment.authorId || 'Unknown';
|
|
237
|
+
const created = comment.created || comment.createdAt || '';
|
|
238
|
+
const version = comment.version?.number || '';
|
|
239
|
+
|
|
240
|
+
lines.push(`${bold(author)} ${dim(formatDate(created))}${version ? dim(` (v${version})`) : ''}`);
|
|
241
|
+
|
|
242
|
+
// Body - caller should render storage to text/md before passing
|
|
243
|
+
if (comment._bodyText) {
|
|
244
|
+
lines.push(comment._bodyText);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Date formatting ──────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function formatDate(isoString) {
|
|
253
|
+
if (!isoString) return '';
|
|
254
|
+
try {
|
|
255
|
+
const d = new Date(isoString);
|
|
256
|
+
return d.toISOString().slice(0, 10);
|
|
257
|
+
} catch {
|
|
258
|
+
return isoString;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// mirror.mjs - Pure helpers for the folder mirror command.
|
|
2
|
+
//
|
|
3
|
+
// All functions here are pure (no I/O, no network) so they can be unit-tested
|
|
4
|
+
// in isolation. The orchestration that actually walks the Confluence API and
|
|
5
|
+
// writes to disk lives in commands/mirror-folder.mjs.
|
|
6
|
+
//
|
|
7
|
+
// Responsibilities:
|
|
8
|
+
// - Sanitise Confluence page/folder titles into filesystem-safe slugs.
|
|
9
|
+
// - Build a markdown page body with a metadata header block.
|
|
10
|
+
// - Plan a mirror tree (flatten a nested folder/page structure into a list
|
|
11
|
+
// of files-to-write with absolute-relative paths).
|
|
12
|
+
// - Compute the atomic-swap plan (what to preserve, what to discard).
|
|
13
|
+
//
|
|
14
|
+
// Zero external dependencies.
|
|
15
|
+
|
|
16
|
+
// ── Title sanitisation ───────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sanitise a Confluence title into a filesystem-safe slug.
|
|
20
|
+
*
|
|
21
|
+
* Rules:
|
|
22
|
+
* - lowercase
|
|
23
|
+
* - strip diacritics
|
|
24
|
+
* - replace spaces and path separators with hyphens
|
|
25
|
+
* - drop characters outside [a-z0-9._-]
|
|
26
|
+
* - collapse runs of hyphens
|
|
27
|
+
* - trim leading/trailing hyphens, dots, and underscores
|
|
28
|
+
* - cap length at 120 chars (leaves room for .md and numeric disambiguators)
|
|
29
|
+
* - fall back to a stable placeholder if the result is empty
|
|
30
|
+
*
|
|
31
|
+
* @param {string} title
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function sanitiseTitle(title) {
|
|
35
|
+
if (typeof title !== 'string' || title.length === 0) return 'untitled';
|
|
36
|
+
|
|
37
|
+
// Normalise & strip diacritics (NFD → drop combining marks).
|
|
38
|
+
let s = title.normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
|
39
|
+
s = s.toLowerCase();
|
|
40
|
+
|
|
41
|
+
// Replace path separators and whitespace with hyphens.
|
|
42
|
+
s = s.replace(/[\\/]+/g, '-');
|
|
43
|
+
s = s.replace(/\s+/g, '-');
|
|
44
|
+
|
|
45
|
+
// Drop anything that isn't alphanumeric, hyphen, dot, or underscore.
|
|
46
|
+
s = s.replace(/[^a-z0-9._-]/g, '');
|
|
47
|
+
|
|
48
|
+
// Collapse repeated hyphens.
|
|
49
|
+
s = s.replace(/-{2,}/g, '-');
|
|
50
|
+
|
|
51
|
+
// Trim leading/trailing junk.
|
|
52
|
+
s = s.replace(/^[-._]+|[-._]+$/g, '');
|
|
53
|
+
|
|
54
|
+
if (s.length === 0) return 'untitled';
|
|
55
|
+
if (s.length > 120) s = s.slice(0, 120).replace(/-+$/, '');
|
|
56
|
+
if (s.length === 0) return 'untitled';
|
|
57
|
+
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Disambiguate a slug within a directory by appending -<pageId> if a
|
|
63
|
+
* collision already exists. Mutates the provided usedSet so repeated
|
|
64
|
+
* calls stay consistent.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} baseSlug
|
|
67
|
+
* @param {string|number} pageId Used as the disambiguator
|
|
68
|
+
* @param {Set<string>} usedSet Slugs already claimed in this directory
|
|
69
|
+
* @returns {string} A slug not present in usedSet
|
|
70
|
+
*/
|
|
71
|
+
export function disambiguate(baseSlug, pageId, usedSet) {
|
|
72
|
+
if (!usedSet.has(baseSlug)) {
|
|
73
|
+
usedSet.add(baseSlug);
|
|
74
|
+
return baseSlug;
|
|
75
|
+
}
|
|
76
|
+
const withId = `${baseSlug}-${pageId}`;
|
|
77
|
+
if (!usedSet.has(withId)) {
|
|
78
|
+
usedSet.add(withId);
|
|
79
|
+
return withId;
|
|
80
|
+
}
|
|
81
|
+
// Fallback - append a counter.
|
|
82
|
+
let n = 2;
|
|
83
|
+
while (usedSet.has(`${withId}-${n}`)) n++;
|
|
84
|
+
const final = `${withId}-${n}`;
|
|
85
|
+
usedSet.add(final);
|
|
86
|
+
return final;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Metadata header ─────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the frontmatter-like metadata header block for a mirrored page.
|
|
93
|
+
* Uses YAML frontmatter fenced with --- so it's compatible with standard
|
|
94
|
+
* markdown tools and with our retrieval index-builder.
|
|
95
|
+
*
|
|
96
|
+
* Note: per-run timestamps deliberately do NOT live here - every nightly
|
|
97
|
+
* mirror would otherwise rewrite every page's frontmatter even when no
|
|
98
|
+
* Confluence content changed. The run-level timestamp is captured once,
|
|
99
|
+
* centrally, in the domain-root manifest (see buildManifest).
|
|
100
|
+
*
|
|
101
|
+
* @param {object} meta
|
|
102
|
+
* @param {string} meta.title
|
|
103
|
+
* @param {string} meta.pageId
|
|
104
|
+
* @param {string} meta.url
|
|
105
|
+
* @param {string} [meta.updatedAt] ISO-8601 timestamp of Confluence's last edit
|
|
106
|
+
* @param {string} [meta.space]
|
|
107
|
+
* @param {string} [meta.version]
|
|
108
|
+
* @param {string[]} [meta.breadcrumb] Ordered ancestor titles (nearest last)
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function buildMetadataHeader(meta) {
|
|
112
|
+
const lines = ['---'];
|
|
113
|
+
lines.push(`title: ${yamlString(meta.title || '')}`);
|
|
114
|
+
lines.push(`confluencePageId: ${yamlString(String(meta.pageId || ''))}`);
|
|
115
|
+
lines.push(`confluenceUrl: ${yamlString(meta.url || '')}`);
|
|
116
|
+
if (meta.space) lines.push(`confluenceSpace: ${yamlString(meta.space)}`);
|
|
117
|
+
if (meta.version != null) lines.push(`confluenceVersion: ${yamlString(String(meta.version))}`);
|
|
118
|
+
if (meta.updatedAt) lines.push(`confluenceUpdatedAt: ${yamlString(meta.updatedAt)}`);
|
|
119
|
+
if (Array.isArray(meta.breadcrumb) && meta.breadcrumb.length > 0) {
|
|
120
|
+
lines.push('confluenceBreadcrumb:');
|
|
121
|
+
for (const crumb of meta.breadcrumb) {
|
|
122
|
+
lines.push(` - ${yamlString(crumb)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
lines.push('mirrorSource: confluence');
|
|
126
|
+
lines.push('readOnly: true');
|
|
127
|
+
lines.push('---');
|
|
128
|
+
lines.push('');
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Quote a YAML scalar value. Always double-quote and escape backslashes and
|
|
134
|
+
* double quotes - simplest safe path for arbitrary Confluence titles.
|
|
135
|
+
* @param {string} v
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
function yamlString(v) {
|
|
139
|
+
const s = String(v == null ? '' : v);
|
|
140
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compose the full markdown body for a mirrored page: frontmatter header
|
|
145
|
+
* + H1 title + a blank line + the converted markdown content.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} meta Passed to buildMetadataHeader
|
|
148
|
+
* @param {string} markdown Already-converted markdown body (may be '')
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function composeMarkdown(meta, markdown) {
|
|
152
|
+
const header = buildMetadataHeader(meta);
|
|
153
|
+
const title = (meta.title || 'Untitled').trim();
|
|
154
|
+
const body = (markdown || '').trim();
|
|
155
|
+
return `${header}# ${title}\n\n${body}\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Run manifest ────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the run-level manifest object that describes a single mirror run.
|
|
162
|
+
* Written once per run as `manifest.json` at the domain root. Holds the
|
|
163
|
+
* data that previously lived in every page's `mirrorFetchedAt` field plus
|
|
164
|
+
* a sorted list of page IDs so a future drift-detection feature can
|
|
165
|
+
* compare runs without diffing every file.
|
|
166
|
+
*
|
|
167
|
+
* Pure: takes inputs, returns a plain object - the caller serialises and
|
|
168
|
+
* writes it.
|
|
169
|
+
*
|
|
170
|
+
* Page IDs are sorted ascending as strings (numeric Confluence IDs sort
|
|
171
|
+
* lexicographically the same as numerically when zero-padding is absent
|
|
172
|
+
* across same-length IDs; for safety we coerce to numeric where possible
|
|
173
|
+
* and fall back to lexical for non-numeric IDs).
|
|
174
|
+
*
|
|
175
|
+
* @param {object} input
|
|
176
|
+
* @param {string} input.domainId Logical domain id (e.g. 'engineering-standards')
|
|
177
|
+
* @param {string|number} input.sourceFolderId Confluence folder ID being mirrored
|
|
178
|
+
* @param {string} [input.sourceSpaceKey] Confluence space key (or numeric space id)
|
|
179
|
+
* @param {string} input.mirrorRunAt ISO-8601 timestamp of this run
|
|
180
|
+
* @param {Array<string|number>} input.pageIds All page IDs included in the mirror
|
|
181
|
+
* @param {string} [input.mirrorToolVersion] Version of the confluence tool
|
|
182
|
+
* @returns {object}
|
|
183
|
+
*/
|
|
184
|
+
export function buildManifest(input) {
|
|
185
|
+
const pageIds = Array.isArray(input.pageIds)
|
|
186
|
+
? input.pageIds.map((id) => String(id))
|
|
187
|
+
: [];
|
|
188
|
+
const sortedPageIds = pageIds.slice().sort((a, b) => {
|
|
189
|
+
const an = Number(a);
|
|
190
|
+
const bn = Number(b);
|
|
191
|
+
if (Number.isFinite(an) && Number.isFinite(bn) && an !== bn) {
|
|
192
|
+
return an - bn;
|
|
193
|
+
}
|
|
194
|
+
if (a < b) return -1;
|
|
195
|
+
if (a > b) return 1;
|
|
196
|
+
return 0;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const manifest = {
|
|
200
|
+
domainId: String(input.domainId || ''),
|
|
201
|
+
sourceFolderId: String(input.sourceFolderId || ''),
|
|
202
|
+
};
|
|
203
|
+
if (input.sourceSpaceKey != null && input.sourceSpaceKey !== '') {
|
|
204
|
+
manifest.sourceSpaceKey = String(input.sourceSpaceKey);
|
|
205
|
+
}
|
|
206
|
+
manifest.mirrorRunAt = String(input.mirrorRunAt || '');
|
|
207
|
+
if (input.mirrorToolVersion) {
|
|
208
|
+
manifest.mirrorToolVersion = String(input.mirrorToolVersion);
|
|
209
|
+
}
|
|
210
|
+
manifest.pageCount = sortedPageIds.length;
|
|
211
|
+
manifest.pageIds = sortedPageIds;
|
|
212
|
+
return manifest;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Serialise a manifest object to its on-disk JSON form.
|
|
217
|
+
* 2-space indent, trailing newline. camelCase keys (caller's responsibility).
|
|
218
|
+
*
|
|
219
|
+
* @param {object} manifest
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
export function serialiseManifest(manifest) {
|
|
223
|
+
return JSON.stringify(manifest, null, 2) + '\n';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Tree planning ───────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Flatten a nested tree of folders and pages into a flat list of write
|
|
230
|
+
* operations. Each operation is `{ relPath, kind, node }` where `relPath`
|
|
231
|
+
* is a relative filesystem path (POSIX-style, using '/') rooted at the
|
|
232
|
+
* mirror target directory.
|
|
233
|
+
*
|
|
234
|
+
* Tree node shape:
|
|
235
|
+
* {
|
|
236
|
+
* type: 'folder' | 'page',
|
|
237
|
+
* id: string,
|
|
238
|
+
* title: string,
|
|
239
|
+
* children: TreeNode[] // for folder nodes and pages that have sub-pages
|
|
240
|
+
* }
|
|
241
|
+
*
|
|
242
|
+
* Folders become directories; their children are nested under the directory.
|
|
243
|
+
* Pages become .md files. Pages that themselves have sub-pages become a
|
|
244
|
+
* <slug>/ directory containing index.md plus the child pages.
|
|
245
|
+
*
|
|
246
|
+
* @param {object[]} rootChildren Top-level children of the root folder
|
|
247
|
+
* @returns {Array<{relPath: string, kind: 'file'|'dir', node: object, breadcrumb: string[]}>}
|
|
248
|
+
*/
|
|
249
|
+
export function planTree(rootChildren) {
|
|
250
|
+
const plan = [];
|
|
251
|
+
walk(rootChildren || [], '', [], plan);
|
|
252
|
+
return plan;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function walk(nodes, parentRel, parentBreadcrumb, plan) {
|
|
256
|
+
const usedSlugs = new Set();
|
|
257
|
+
for (const node of nodes) {
|
|
258
|
+
const slug = disambiguate(sanitiseTitle(node.title || 'untitled'), node.id, usedSlugs);
|
|
259
|
+
const breadcrumb = [...parentBreadcrumb, node.title || ''];
|
|
260
|
+
|
|
261
|
+
if (node.type === 'folder') {
|
|
262
|
+
const relPath = joinRel(parentRel, slug);
|
|
263
|
+
plan.push({ relPath, kind: 'dir', node, breadcrumb });
|
|
264
|
+
if (Array.isArray(node.children) && node.children.length > 0) {
|
|
265
|
+
walk(node.children, relPath, breadcrumb, plan);
|
|
266
|
+
}
|
|
267
|
+
} else if (node.type === 'page') {
|
|
268
|
+
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
|
|
269
|
+
if (hasChildren) {
|
|
270
|
+
// Page becomes a directory; its content lives in index.md.
|
|
271
|
+
const dirRel = joinRel(parentRel, slug);
|
|
272
|
+
const indexRel = joinRel(dirRel, 'index.md');
|
|
273
|
+
plan.push({ relPath: dirRel, kind: 'dir', node, breadcrumb });
|
|
274
|
+
plan.push({ relPath: indexRel, kind: 'file', node, breadcrumb });
|
|
275
|
+
walk(node.children, dirRel, breadcrumb, plan);
|
|
276
|
+
} else {
|
|
277
|
+
const relPath = joinRel(parentRel, `${slug}.md`);
|
|
278
|
+
plan.push({ relPath, kind: 'file', node, breadcrumb });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Other types (attachment, comment, etc) are ignored.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function joinRel(parent, child) {
|
|
286
|
+
if (!parent) return child;
|
|
287
|
+
return `${parent}/${child}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Atomic-swap planning ────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Compute the atomic-swap plan.
|
|
294
|
+
*
|
|
295
|
+
* After a successful mirror we need to:
|
|
296
|
+
* 1. Preserve the README.md (it documents the domain).
|
|
297
|
+
* 2. Remove every other file/dir currently under the target.
|
|
298
|
+
* 3. Move the staged mirror contents into place.
|
|
299
|
+
*
|
|
300
|
+
* This helper just returns the list of entries to preserve. Call sites
|
|
301
|
+
* decide how to apply it. Keeping the policy in a named function makes
|
|
302
|
+
* it trivial to unit-test and to change in one place.
|
|
303
|
+
*
|
|
304
|
+
* @returns {string[]} Entry names (not paths) to preserve across a swap.
|
|
305
|
+
*/
|
|
306
|
+
export function preservedEntries() {
|
|
307
|
+
return ['README.md'];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Given an existing directory listing and the preservation set, split the
|
|
312
|
+
* listing into { toRemove, toKeep }. Input is a list of entry names (not
|
|
313
|
+
* paths). Purely lexical.
|
|
314
|
+
*
|
|
315
|
+
* @param {string[]} entries
|
|
316
|
+
* @returns {{ toRemove: string[], toKeep: string[] }}
|
|
317
|
+
*/
|
|
318
|
+
export function splitForSwap(entries) {
|
|
319
|
+
const preserve = new Set(preservedEntries());
|
|
320
|
+
const toRemove = [];
|
|
321
|
+
const toKeep = [];
|
|
322
|
+
for (const name of entries) {
|
|
323
|
+
// Always keep dotfiles - if a user or tool ever adds a .git or similar
|
|
324
|
+
// config to the directory, we don't clobber it.
|
|
325
|
+
if (name.startsWith('.')) {
|
|
326
|
+
toKeep.push(name);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (preserve.has(name)) {
|
|
330
|
+
toKeep.push(name);
|
|
331
|
+
} else {
|
|
332
|
+
toRemove.push(name);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { toRemove, toKeep };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── URL builder ─────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build the canonical web URL for a Confluence page.
|
|
342
|
+
* @param {string} domain e.g. 'yoursite'
|
|
343
|
+
* @param {string|number} pageId
|
|
344
|
+
* @param {string} [webuiPath] Optional _links.webui from v2 payload
|
|
345
|
+
* @returns {string}
|
|
346
|
+
*/
|
|
347
|
+
export function pageUrl(domain, pageId, webuiPath) {
|
|
348
|
+
const base = `https://${domain}.atlassian.net`;
|
|
349
|
+
if (webuiPath && typeof webuiPath === 'string') {
|
|
350
|
+
return `${base}/wiki${webuiPath.startsWith('/') ? '' : '/'}${webuiPath}`;
|
|
351
|
+
}
|
|
352
|
+
return `${base}/wiki/pages/viewpage.action?pageId=${encodeURIComponent(pageId)}`;
|
|
353
|
+
}
|