@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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/package.json +73 -0
  4. package/src/cli/commands/attachments.ts +113 -0
  5. package/src/cli/commands/clone.ts +188 -0
  6. package/src/cli/commands/comments.ts +56 -0
  7. package/src/cli/commands/create.ts +58 -0
  8. package/src/cli/commands/delete.ts +46 -0
  9. package/src/cli/commands/doctor.ts +161 -0
  10. package/src/cli/commands/duplicate-check.ts +89 -0
  11. package/src/cli/commands/file-rename.ts +113 -0
  12. package/src/cli/commands/folder-hierarchy.ts +241 -0
  13. package/src/cli/commands/info.ts +56 -0
  14. package/src/cli/commands/labels.ts +53 -0
  15. package/src/cli/commands/move.ts +23 -0
  16. package/src/cli/commands/open.ts +145 -0
  17. package/src/cli/commands/pull.ts +241 -0
  18. package/src/cli/commands/push-errors.ts +40 -0
  19. package/src/cli/commands/push.ts +699 -0
  20. package/src/cli/commands/search.ts +62 -0
  21. package/src/cli/commands/setup.ts +124 -0
  22. package/src/cli/commands/spaces.ts +42 -0
  23. package/src/cli/commands/status.ts +88 -0
  24. package/src/cli/commands/tree.ts +190 -0
  25. package/src/cli/help.ts +425 -0
  26. package/src/cli/index.ts +413 -0
  27. package/src/cli/utils/browser.ts +34 -0
  28. package/src/cli/utils/progress-reporter.ts +49 -0
  29. package/src/cli.ts +6 -0
  30. package/src/lib/config.ts +156 -0
  31. package/src/lib/confluence-client/attachment-operations.ts +221 -0
  32. package/src/lib/confluence-client/client.ts +653 -0
  33. package/src/lib/confluence-client/comment-operations.ts +60 -0
  34. package/src/lib/confluence-client/folder-operations.ts +203 -0
  35. package/src/lib/confluence-client/index.ts +47 -0
  36. package/src/lib/confluence-client/label-operations.ts +102 -0
  37. package/src/lib/confluence-client/page-operations.ts +270 -0
  38. package/src/lib/confluence-client/search-operations.ts +60 -0
  39. package/src/lib/confluence-client/types.ts +329 -0
  40. package/src/lib/confluence-client/user-operations.ts +58 -0
  41. package/src/lib/dependency-sorter.ts +233 -0
  42. package/src/lib/errors.ts +237 -0
  43. package/src/lib/file-scanner.ts +195 -0
  44. package/src/lib/formatters.ts +314 -0
  45. package/src/lib/health-check.ts +204 -0
  46. package/src/lib/markdown/converter.ts +427 -0
  47. package/src/lib/markdown/frontmatter.ts +116 -0
  48. package/src/lib/markdown/html-converter.ts +398 -0
  49. package/src/lib/markdown/index.ts +21 -0
  50. package/src/lib/markdown/link-converter.ts +189 -0
  51. package/src/lib/markdown/reference-updater.ts +251 -0
  52. package/src/lib/markdown/slugify.ts +32 -0
  53. package/src/lib/page-state.ts +195 -0
  54. package/src/lib/resolve-page-target.ts +33 -0
  55. package/src/lib/space-config.ts +264 -0
  56. package/src/lib/sync/cleanup.ts +50 -0
  57. package/src/lib/sync/folder-path.ts +61 -0
  58. package/src/lib/sync/index.ts +2 -0
  59. package/src/lib/sync/link-resolution-pass.ts +139 -0
  60. package/src/lib/sync/sync-engine.ts +681 -0
  61. package/src/lib/sync/sync-specific.ts +221 -0
  62. package/src/lib/sync/types.ts +42 -0
  63. package/src/test/attachments.test.ts +68 -0
  64. package/src/test/clone.test.ts +373 -0
  65. package/src/test/comments.test.ts +53 -0
  66. package/src/test/config.test.ts +209 -0
  67. package/src/test/confluence-client.test.ts +535 -0
  68. package/src/test/delete.test.ts +39 -0
  69. package/src/test/dependency-sorter.test.ts +384 -0
  70. package/src/test/errors.test.ts +199 -0
  71. package/src/test/file-rename.test.ts +305 -0
  72. package/src/test/file-scanner.test.ts +331 -0
  73. package/src/test/folder-hierarchy.test.ts +337 -0
  74. package/src/test/formatters.test.ts +213 -0
  75. package/src/test/html-converter.test.ts +399 -0
  76. package/src/test/info.test.ts +56 -0
  77. package/src/test/labels.test.ts +70 -0
  78. package/src/test/link-conversion-integration.test.ts +189 -0
  79. package/src/test/link-converter.test.ts +413 -0
  80. package/src/test/link-resolution-pass.test.ts +368 -0
  81. package/src/test/markdown.test.ts +443 -0
  82. package/src/test/mocks/handlers.ts +228 -0
  83. package/src/test/move.test.ts +53 -0
  84. package/src/test/msw-schema-validation.ts +151 -0
  85. package/src/test/page-state.test.ts +542 -0
  86. package/src/test/push.test.ts +551 -0
  87. package/src/test/reference-updater.test.ts +293 -0
  88. package/src/test/resolve-page-target.test.ts +55 -0
  89. package/src/test/search.test.ts +64 -0
  90. package/src/test/setup-msw.ts +75 -0
  91. package/src/test/space-config.test.ts +516 -0
  92. package/src/test/spaces.test.ts +53 -0
  93. package/src/test/sync-engine.test.ts +486 -0
  94. 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 &amp; Info"/><ac:plain-text-link-body><![CDATA[API &amp; 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('&amp;');
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
+ });