@aaronshaf/confluence-cli 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/package.json +73 -0
- package/src/cli/commands/attachments.ts +113 -0
- package/src/cli/commands/clone.ts +188 -0
- package/src/cli/commands/comments.ts +56 -0
- package/src/cli/commands/create.ts +58 -0
- package/src/cli/commands/delete.ts +46 -0
- package/src/cli/commands/doctor.ts +161 -0
- package/src/cli/commands/duplicate-check.ts +89 -0
- package/src/cli/commands/file-rename.ts +113 -0
- package/src/cli/commands/folder-hierarchy.ts +241 -0
- package/src/cli/commands/info.ts +56 -0
- package/src/cli/commands/labels.ts +53 -0
- package/src/cli/commands/move.ts +23 -0
- package/src/cli/commands/open.ts +145 -0
- package/src/cli/commands/pull.ts +241 -0
- package/src/cli/commands/push-errors.ts +40 -0
- package/src/cli/commands/push.ts +699 -0
- package/src/cli/commands/search.ts +62 -0
- package/src/cli/commands/setup.ts +124 -0
- package/src/cli/commands/spaces.ts +42 -0
- package/src/cli/commands/status.ts +88 -0
- package/src/cli/commands/tree.ts +190 -0
- package/src/cli/help.ts +425 -0
- package/src/cli/index.ts +413 -0
- package/src/cli/utils/browser.ts +34 -0
- package/src/cli/utils/progress-reporter.ts +49 -0
- package/src/cli.ts +6 -0
- package/src/lib/config.ts +156 -0
- package/src/lib/confluence-client/attachment-operations.ts +221 -0
- package/src/lib/confluence-client/client.ts +653 -0
- package/src/lib/confluence-client/comment-operations.ts +60 -0
- package/src/lib/confluence-client/folder-operations.ts +203 -0
- package/src/lib/confluence-client/index.ts +47 -0
- package/src/lib/confluence-client/label-operations.ts +102 -0
- package/src/lib/confluence-client/page-operations.ts +270 -0
- package/src/lib/confluence-client/search-operations.ts +60 -0
- package/src/lib/confluence-client/types.ts +329 -0
- package/src/lib/confluence-client/user-operations.ts +58 -0
- package/src/lib/dependency-sorter.ts +233 -0
- package/src/lib/errors.ts +237 -0
- package/src/lib/file-scanner.ts +195 -0
- package/src/lib/formatters.ts +314 -0
- package/src/lib/health-check.ts +204 -0
- package/src/lib/markdown/converter.ts +427 -0
- package/src/lib/markdown/frontmatter.ts +116 -0
- package/src/lib/markdown/html-converter.ts +398 -0
- package/src/lib/markdown/index.ts +21 -0
- package/src/lib/markdown/link-converter.ts +189 -0
- package/src/lib/markdown/reference-updater.ts +251 -0
- package/src/lib/markdown/slugify.ts +32 -0
- package/src/lib/page-state.ts +195 -0
- package/src/lib/resolve-page-target.ts +33 -0
- package/src/lib/space-config.ts +264 -0
- package/src/lib/sync/cleanup.ts +50 -0
- package/src/lib/sync/folder-path.ts +61 -0
- package/src/lib/sync/index.ts +2 -0
- package/src/lib/sync/link-resolution-pass.ts +139 -0
- package/src/lib/sync/sync-engine.ts +681 -0
- package/src/lib/sync/sync-specific.ts +221 -0
- package/src/lib/sync/types.ts +42 -0
- package/src/test/attachments.test.ts +68 -0
- package/src/test/clone.test.ts +373 -0
- package/src/test/comments.test.ts +53 -0
- package/src/test/config.test.ts +209 -0
- package/src/test/confluence-client.test.ts +535 -0
- package/src/test/delete.test.ts +39 -0
- package/src/test/dependency-sorter.test.ts +384 -0
- package/src/test/errors.test.ts +199 -0
- package/src/test/file-rename.test.ts +305 -0
- package/src/test/file-scanner.test.ts +331 -0
- package/src/test/folder-hierarchy.test.ts +337 -0
- package/src/test/formatters.test.ts +213 -0
- package/src/test/html-converter.test.ts +399 -0
- package/src/test/info.test.ts +56 -0
- package/src/test/labels.test.ts +70 -0
- package/src/test/link-conversion-integration.test.ts +189 -0
- package/src/test/link-converter.test.ts +413 -0
- package/src/test/link-resolution-pass.test.ts +368 -0
- package/src/test/markdown.test.ts +443 -0
- package/src/test/mocks/handlers.ts +228 -0
- package/src/test/move.test.ts +53 -0
- package/src/test/msw-schema-validation.ts +151 -0
- package/src/test/page-state.test.ts +542 -0
- package/src/test/push.test.ts +551 -0
- package/src/test/reference-updater.test.ts +293 -0
- package/src/test/resolve-page-target.test.ts +55 -0
- package/src/test/search.test.ts +64 -0
- package/src/test/setup-msw.ts +75 -0
- package/src/test/space-config.test.ts +516 -0
- package/src/test/spaces.test.ts +53 -0
- package/src/test/sync-engine.test.ts +486 -0
- package/src/types/turndown-plugin-gfm.d.ts +9 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { resolveLinksSecondPass } from '../lib/sync/link-resolution-pass.js';
|
|
6
|
+
import type { SpaceConfigWithState } from '../lib/space-config.js';
|
|
7
|
+
|
|
8
|
+
describe('Link Resolution Second Pass', () => {
|
|
9
|
+
let testDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
testDir = join(tmpdir(), `cn-link-test-${Date.now()}`);
|
|
13
|
+
mkdirSync(testDir, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (existsSync(testDir)) {
|
|
18
|
+
rmSync(testDir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('resolves unresolved links in second pass', () => {
|
|
23
|
+
// Create markdown files with unresolved links
|
|
24
|
+
const pageAPath = join(testDir, 'page-a.md');
|
|
25
|
+
const pageBPath = join(testDir, 'page-b.md');
|
|
26
|
+
|
|
27
|
+
// Page A has an unresolved link to Page B (as Confluence HTML)
|
|
28
|
+
const pageAContent = `---
|
|
29
|
+
page_id: page-a-id
|
|
30
|
+
title: Page A
|
|
31
|
+
version: 1
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
# Page A
|
|
35
|
+
|
|
36
|
+
This is a link to <ac:link><ri:page ri:content-title="Page B" ri:space-key="TEST"/><ac:plain-text-link-body><![CDATA[Page B]]></ac:plain-text-link-body></ac:link>.
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
// Page B exists
|
|
40
|
+
const pageBContent = `---
|
|
41
|
+
page_id: page-b-id
|
|
42
|
+
title: Page B
|
|
43
|
+
version: 1
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
# Page B
|
|
47
|
+
|
|
48
|
+
Content of page B.
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
writeFileSync(pageAPath, pageAContent, 'utf-8');
|
|
52
|
+
writeFileSync(pageBPath, pageBContent, 'utf-8');
|
|
53
|
+
|
|
54
|
+
const config: SpaceConfigWithState = {
|
|
55
|
+
spaceKey: 'TEST',
|
|
56
|
+
spaceId: 'test-space-id',
|
|
57
|
+
spaceName: 'Test Space',
|
|
58
|
+
pages: {
|
|
59
|
+
'page-a-id': 'page-a.md',
|
|
60
|
+
'page-b-id': 'page-b.md',
|
|
61
|
+
},
|
|
62
|
+
folders: {},
|
|
63
|
+
lastSync: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Run second pass
|
|
67
|
+
const result = resolveLinksSecondPass(testDir, config);
|
|
68
|
+
|
|
69
|
+
// Check results
|
|
70
|
+
expect(result.filesUpdated).toBe(1); // Only page-a.md should be updated
|
|
71
|
+
expect(result.linksResolved).toBe(1);
|
|
72
|
+
expect(result.warnings).toHaveLength(0);
|
|
73
|
+
|
|
74
|
+
// Verify the link was converted
|
|
75
|
+
const updatedPageA = readFileSync(pageAPath, 'utf-8');
|
|
76
|
+
expect(updatedPageA).toContain('[Page B](./page-b.md)');
|
|
77
|
+
expect(updatedPageA).not.toContain('<ac:link>');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('handles multiple unresolved links in one file', () => {
|
|
81
|
+
const pageAPath = join(testDir, 'page-a.md');
|
|
82
|
+
const pageBPath = join(testDir, 'page-b.md');
|
|
83
|
+
const pageCPath = join(testDir, 'page-c.md');
|
|
84
|
+
|
|
85
|
+
const pageAContent = `---
|
|
86
|
+
page_id: page-a-id
|
|
87
|
+
title: Page A
|
|
88
|
+
version: 1
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
# Page A
|
|
92
|
+
|
|
93
|
+
Link to <ac:link><ri:page ri:content-title="Page B"/><ac:plain-text-link-body><![CDATA[Page B]]></ac:plain-text-link-body></ac:link>.
|
|
94
|
+
Link to <ac:link><ri:page ri:content-title="Page C"/><ac:plain-text-link-body><![CDATA[Page C]]></ac:plain-text-link-body></ac:link>.
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
const pageBContent = `---
|
|
98
|
+
page_id: page-b-id
|
|
99
|
+
title: Page B
|
|
100
|
+
version: 1
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
# Page B
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const pageCContent = `---
|
|
107
|
+
page_id: page-c-id
|
|
108
|
+
title: Page C
|
|
109
|
+
version: 1
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
# Page C
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
writeFileSync(pageAPath, pageAContent, 'utf-8');
|
|
116
|
+
writeFileSync(pageBPath, pageBContent, 'utf-8');
|
|
117
|
+
writeFileSync(pageCPath, pageCContent, 'utf-8');
|
|
118
|
+
|
|
119
|
+
const config: SpaceConfigWithState = {
|
|
120
|
+
spaceKey: 'TEST',
|
|
121
|
+
spaceId: 'test-space-id',
|
|
122
|
+
spaceName: 'Test Space',
|
|
123
|
+
pages: {
|
|
124
|
+
'page-a-id': 'page-a.md',
|
|
125
|
+
'page-b-id': 'page-b.md',
|
|
126
|
+
'page-c-id': 'page-c.md',
|
|
127
|
+
},
|
|
128
|
+
folders: {},
|
|
129
|
+
lastSync: new Date().toISOString(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const result = resolveLinksSecondPass(testDir, config);
|
|
133
|
+
|
|
134
|
+
expect(result.filesUpdated).toBe(1);
|
|
135
|
+
expect(result.linksResolved).toBe(2);
|
|
136
|
+
|
|
137
|
+
const updatedPageA = readFileSync(pageAPath, 'utf-8');
|
|
138
|
+
expect(updatedPageA).toContain('[Page B](./page-b.md)');
|
|
139
|
+
expect(updatedPageA).toContain('[Page C](./page-c.md)');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('skips files with no unresolved links', () => {
|
|
143
|
+
const pageAPath = join(testDir, 'page-a.md');
|
|
144
|
+
|
|
145
|
+
const pageAContent = `---
|
|
146
|
+
page_id: page-a-id
|
|
147
|
+
title: Page A
|
|
148
|
+
version: 1
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
# Page A
|
|
152
|
+
|
|
153
|
+
Normal content with no links.
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
writeFileSync(pageAPath, pageAContent, 'utf-8');
|
|
157
|
+
|
|
158
|
+
const config: SpaceConfigWithState = {
|
|
159
|
+
spaceKey: 'TEST',
|
|
160
|
+
spaceId: 'test-space-id',
|
|
161
|
+
spaceName: 'Test Space',
|
|
162
|
+
pages: {
|
|
163
|
+
'page-a-id': 'page-a.md',
|
|
164
|
+
},
|
|
165
|
+
folders: {},
|
|
166
|
+
lastSync: new Date().toISOString(),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const result = resolveLinksSecondPass(testDir, config);
|
|
170
|
+
|
|
171
|
+
expect(result.filesUpdated).toBe(0);
|
|
172
|
+
expect(result.linksResolved).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('leaves unresolvable links as-is', () => {
|
|
176
|
+
const pageAPath = join(testDir, 'page-a.md');
|
|
177
|
+
|
|
178
|
+
// Link to a page that doesn't exist
|
|
179
|
+
const pageAContent = `---
|
|
180
|
+
page_id: page-a-id
|
|
181
|
+
title: Page A
|
|
182
|
+
version: 1
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
# Page A
|
|
186
|
+
|
|
187
|
+
Link to <ac:link><ri:page ri:content-title="Nonexistent Page"/><ac:plain-text-link-body><![CDATA[Missing]]></ac:plain-text-link-body></ac:link>.
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
writeFileSync(pageAPath, pageAContent, 'utf-8');
|
|
191
|
+
|
|
192
|
+
const config: SpaceConfigWithState = {
|
|
193
|
+
spaceKey: 'TEST',
|
|
194
|
+
spaceId: 'test-space-id',
|
|
195
|
+
spaceName: 'Test Space',
|
|
196
|
+
pages: {
|
|
197
|
+
'page-a-id': 'page-a.md',
|
|
198
|
+
},
|
|
199
|
+
folders: {},
|
|
200
|
+
lastSync: new Date().toISOString(),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const result = resolveLinksSecondPass(testDir, config);
|
|
204
|
+
|
|
205
|
+
// No changes should be made
|
|
206
|
+
expect(result.filesUpdated).toBe(0);
|
|
207
|
+
expect(result.linksResolved).toBe(0);
|
|
208
|
+
|
|
209
|
+
// Link should still be unresolved
|
|
210
|
+
const updatedPageA = readFileSync(pageAPath, 'utf-8');
|
|
211
|
+
expect(updatedPageA).toContain('<ac:link>');
|
|
212
|
+
expect(updatedPageA).toContain('Nonexistent Page');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('resolves links in nested directories', () => {
|
|
216
|
+
const subDir = join(testDir, 'subdir');
|
|
217
|
+
mkdirSync(subDir, { recursive: true });
|
|
218
|
+
|
|
219
|
+
const pageAPath = join(subDir, 'page-a.md');
|
|
220
|
+
const pageBPath = join(testDir, 'page-b.md');
|
|
221
|
+
|
|
222
|
+
const pageAContent = `---
|
|
223
|
+
page_id: page-a-id
|
|
224
|
+
title: Page A
|
|
225
|
+
version: 1
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
# Page A
|
|
229
|
+
|
|
230
|
+
Link to <ac:link><ri:page ri:content-title="Page B"/><ac:plain-text-link-body><![CDATA[Page B]]></ac:plain-text-link-body></ac:link>.
|
|
231
|
+
`;
|
|
232
|
+
|
|
233
|
+
const pageBContent = `---
|
|
234
|
+
page_id: page-b-id
|
|
235
|
+
title: Page B
|
|
236
|
+
version: 1
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
# Page B
|
|
240
|
+
`;
|
|
241
|
+
|
|
242
|
+
writeFileSync(pageAPath, pageAContent, 'utf-8');
|
|
243
|
+
writeFileSync(pageBPath, pageBContent, 'utf-8');
|
|
244
|
+
|
|
245
|
+
const config: SpaceConfigWithState = {
|
|
246
|
+
spaceKey: 'TEST',
|
|
247
|
+
spaceId: 'test-space-id',
|
|
248
|
+
spaceName: 'Test Space',
|
|
249
|
+
pages: {
|
|
250
|
+
'page-a-id': 'subdir/page-a.md',
|
|
251
|
+
'page-b-id': 'page-b.md',
|
|
252
|
+
},
|
|
253
|
+
folders: {},
|
|
254
|
+
lastSync: new Date().toISOString(),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const result = resolveLinksSecondPass(testDir, config);
|
|
258
|
+
|
|
259
|
+
expect(result.filesUpdated).toBe(1);
|
|
260
|
+
expect(result.linksResolved).toBe(1);
|
|
261
|
+
|
|
262
|
+
const updatedPageA = readFileSync(pageAPath, 'utf-8');
|
|
263
|
+
// Link should be relative from subdir/ to root
|
|
264
|
+
expect(updatedPageA).toContain('[Page B](../page-b.md)');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('decodes HTML entities in page titles', () => {
|
|
268
|
+
const pageAPath = join(testDir, 'page-a.md');
|
|
269
|
+
const pageBPath = join(testDir, 'page-b.md');
|
|
270
|
+
|
|
271
|
+
// Page A has a link with HTML entities in the title
|
|
272
|
+
const pageAContent = `---
|
|
273
|
+
page_id: page-a-id
|
|
274
|
+
title: Page A
|
|
275
|
+
version: 1
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
# Page A
|
|
279
|
+
|
|
280
|
+
Link to <ac:link><ri:page ri:content-title="Page & Info"/><ac:plain-text-link-body><![CDATA[API & Docs]]></ac:plain-text-link-body></ac:link>.
|
|
281
|
+
`;
|
|
282
|
+
|
|
283
|
+
// Page B has a title with ampersand
|
|
284
|
+
const pageBContent = `---
|
|
285
|
+
page_id: page-b-id
|
|
286
|
+
title: Page & Info
|
|
287
|
+
version: 1
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
# Page & Info
|
|
291
|
+
`;
|
|
292
|
+
|
|
293
|
+
writeFileSync(pageAPath, pageAContent, 'utf-8');
|
|
294
|
+
writeFileSync(pageBPath, pageBContent, 'utf-8');
|
|
295
|
+
|
|
296
|
+
const config: SpaceConfigWithState = {
|
|
297
|
+
spaceKey: 'TEST',
|
|
298
|
+
spaceId: 'test-space-id',
|
|
299
|
+
spaceName: 'Test Space',
|
|
300
|
+
pages: {
|
|
301
|
+
'page-a-id': 'page-a.md',
|
|
302
|
+
'page-b-id': 'page-b.md',
|
|
303
|
+
},
|
|
304
|
+
folders: {},
|
|
305
|
+
lastSync: new Date().toISOString(),
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const result = resolveLinksSecondPass(testDir, config);
|
|
309
|
+
|
|
310
|
+
expect(result.filesUpdated).toBe(1);
|
|
311
|
+
expect(result.linksResolved).toBe(1);
|
|
312
|
+
|
|
313
|
+
const updatedPageA = readFileSync(pageAPath, 'utf-8');
|
|
314
|
+
// Link text should also be decoded
|
|
315
|
+
expect(updatedPageA).toContain('[API & Docs](./page-b.md)');
|
|
316
|
+
expect(updatedPageA).not.toContain('&');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('handles links without CDATA link text', () => {
|
|
320
|
+
const pageAPath = join(testDir, 'page-a.md');
|
|
321
|
+
const pageBPath = join(testDir, 'page-b.md');
|
|
322
|
+
|
|
323
|
+
// Link without ac:plain-text-link-body section
|
|
324
|
+
const pageAContent = `---
|
|
325
|
+
page_id: page-a-id
|
|
326
|
+
title: Page A
|
|
327
|
+
version: 1
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
# Page A
|
|
331
|
+
|
|
332
|
+
Link to <ac:link><ri:page ri:content-title="Page B"/></ac:link>.
|
|
333
|
+
`;
|
|
334
|
+
|
|
335
|
+
const pageBContent = `---
|
|
336
|
+
page_id: page-b-id
|
|
337
|
+
title: Page B
|
|
338
|
+
version: 1
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
# Page B
|
|
342
|
+
`;
|
|
343
|
+
|
|
344
|
+
writeFileSync(pageAPath, pageAContent, 'utf-8');
|
|
345
|
+
writeFileSync(pageBPath, pageBContent, 'utf-8');
|
|
346
|
+
|
|
347
|
+
const config: SpaceConfigWithState = {
|
|
348
|
+
spaceKey: 'TEST',
|
|
349
|
+
spaceId: 'test-space-id',
|
|
350
|
+
spaceName: 'Test Space',
|
|
351
|
+
pages: {
|
|
352
|
+
'page-a-id': 'page-a.md',
|
|
353
|
+
'page-b-id': 'page-b.md',
|
|
354
|
+
},
|
|
355
|
+
folders: {},
|
|
356
|
+
lastSync: new Date().toISOString(),
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const result = resolveLinksSecondPass(testDir, config);
|
|
360
|
+
|
|
361
|
+
expect(result.filesUpdated).toBe(1);
|
|
362
|
+
expect(result.linksResolved).toBe(1);
|
|
363
|
+
|
|
364
|
+
const updatedPageA = readFileSync(pageAPath, 'utf-8');
|
|
365
|
+
// Should use title as link text when CDATA is missing
|
|
366
|
+
expect(updatedPageA).toContain('[Page B](./page-b.md)');
|
|
367
|
+
});
|
|
368
|
+
});
|