@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,189 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { MarkdownConverter } from '../lib/markdown/converter.js';
|
|
3
|
+
import { HtmlConverter } from '../lib/markdown/html-converter.js';
|
|
4
|
+
import { parseMarkdown } from '../lib/markdown/frontmatter.js';
|
|
5
|
+
import { buildPageLookupMapFromCache } from '../lib/markdown/link-converter.js';
|
|
6
|
+
import type { PageStateCache, FullPageInfo } from '../lib/page-state.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Helper to create PageStateCache from a simple record
|
|
10
|
+
*/
|
|
11
|
+
function createPageStateCache(
|
|
12
|
+
pages: Record<string, { pageId: string; localPath: string; title: string }>,
|
|
13
|
+
): PageStateCache {
|
|
14
|
+
const pagesMap = new Map<string, FullPageInfo>();
|
|
15
|
+
const pathToPageId = new Map<string, string>();
|
|
16
|
+
|
|
17
|
+
for (const [pageId, info] of Object.entries(pages)) {
|
|
18
|
+
pagesMap.set(pageId, {
|
|
19
|
+
pageId: info.pageId,
|
|
20
|
+
localPath: info.localPath,
|
|
21
|
+
title: info.title,
|
|
22
|
+
version: 1,
|
|
23
|
+
});
|
|
24
|
+
pathToPageId.set(info.localPath, pageId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { pages: pagesMap, pathToPageId };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Link Conversion Integration Tests', () => {
|
|
31
|
+
// TODO: This test requires enhancements to turndown parsing of ac:link elements
|
|
32
|
+
// The link conversion logic is implemented correctly, but turndown needs custom
|
|
33
|
+
// handling for CDATA content in ac:plain-text-link-body elements
|
|
34
|
+
test.skip('full pull-modify-push cycle converts links correctly', () => {
|
|
35
|
+
// Setup: Create mock page state with multiple pages
|
|
36
|
+
// Per ADR-0024: Use PageStateCache built from frontmatter
|
|
37
|
+
const pageState = createPageStateCache({
|
|
38
|
+
'page-1': {
|
|
39
|
+
pageId: 'page-1',
|
|
40
|
+
localPath: 'home.md',
|
|
41
|
+
title: 'Home',
|
|
42
|
+
},
|
|
43
|
+
'page-2': {
|
|
44
|
+
pageId: 'page-2',
|
|
45
|
+
localPath: 'architecture/overview.md',
|
|
46
|
+
title: 'Architecture Overview',
|
|
47
|
+
},
|
|
48
|
+
'page-3': {
|
|
49
|
+
pageId: 'page-3',
|
|
50
|
+
localPath: 'architecture/database.md',
|
|
51
|
+
title: 'Database Design',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const pageLookupMap = buildPageLookupMapFromCache(pageState);
|
|
56
|
+
|
|
57
|
+
// Step 1: PULL - Convert Confluence HTML with page links to markdown
|
|
58
|
+
const confluenceHtml = `<p>Welcome to our documentation!</p><p>Check out the <ac:link><ri:page ri:content-title="Architecture Overview" ri:space-key="TEST" /><ac:plain-text-link-body><![CDATA[Architecture]]></ac:plain-text-link-body></ac:link> for design details.</p><p>Also see <ac:link><ri:page ri:content-title="Database Design" ri:space-key="TEST" /><ac:plain-text-link-body><![CDATA[Database Schema]]></ac:plain-text-link-body></ac:link>.</p>`;
|
|
59
|
+
|
|
60
|
+
const markdownConverter = new MarkdownConverter();
|
|
61
|
+
const { markdown, warnings: pullWarnings } = markdownConverter.convertPage(
|
|
62
|
+
{
|
|
63
|
+
id: 'page-1',
|
|
64
|
+
status: 'current',
|
|
65
|
+
title: 'Home',
|
|
66
|
+
spaceId: 'space-123',
|
|
67
|
+
authorId: 'user-1',
|
|
68
|
+
body: {
|
|
69
|
+
storage: {
|
|
70
|
+
value: confluenceHtml,
|
|
71
|
+
representation: 'storage',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
version: {
|
|
75
|
+
number: 1,
|
|
76
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
[],
|
|
80
|
+
undefined,
|
|
81
|
+
'',
|
|
82
|
+
undefined,
|
|
83
|
+
undefined,
|
|
84
|
+
'home.md',
|
|
85
|
+
pageLookupMap,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Verify: Links were converted to relative paths during pull
|
|
89
|
+
expect(markdown).toContain('[Architecture](./architecture/overview.md)');
|
|
90
|
+
expect(markdown).toContain('[Database Schema](./architecture/database.md)');
|
|
91
|
+
expect(markdown).not.toContain('ri:page');
|
|
92
|
+
expect(pullWarnings.length).toBe(0);
|
|
93
|
+
|
|
94
|
+
// Step 2: Simulate user modifying the markdown locally
|
|
95
|
+
// Extract just the content (without frontmatter) for the push
|
|
96
|
+
const { content: markdownContent } = parseMarkdown(markdown);
|
|
97
|
+
|
|
98
|
+
// Step 3: PUSH - Convert markdown with relative paths back to Confluence HTML
|
|
99
|
+
const htmlConverter = new HtmlConverter();
|
|
100
|
+
const { html, warnings: pushWarnings } = htmlConverter.convert(
|
|
101
|
+
markdownContent,
|
|
102
|
+
'/test/space',
|
|
103
|
+
'home.md',
|
|
104
|
+
'TEST',
|
|
105
|
+
pageLookupMap,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Verify: Links were converted back to Confluence format during push
|
|
109
|
+
expect(html).toContain('<ac:link>');
|
|
110
|
+
expect(html).toContain('ri:page ri:content-title="Architecture Overview"');
|
|
111
|
+
expect(html).toContain('ri:space-key="TEST"');
|
|
112
|
+
expect(html).toContain('ri:page ri:content-title="Database Design"');
|
|
113
|
+
expect(html).toContain('<ac:plain-text-link-body><![CDATA[Architecture]]></ac:plain-text-link-body>');
|
|
114
|
+
expect(html).toContain('<ac:plain-text-link-body><![CDATA[Database Schema]]></ac:plain-text-link-body>');
|
|
115
|
+
expect(pushWarnings.length).toBe(0);
|
|
116
|
+
|
|
117
|
+
// Verify: Round-trip conversion preserves link semantics
|
|
118
|
+
// (The exact formatting might differ slightly, but the meaning should be the same)
|
|
119
|
+
expect(html).not.toContain('./architecture/overview.md');
|
|
120
|
+
expect(html).not.toContain('./architecture/database.md');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// TODO: Same issue as above - requires turndown enhancement
|
|
124
|
+
test.skip('handles broken links gracefully during pull and push', () => {
|
|
125
|
+
// Per ADR-0024: Use PageStateCache built from frontmatter
|
|
126
|
+
const pageState = createPageStateCache({
|
|
127
|
+
'page-1': {
|
|
128
|
+
pageId: 'page-1',
|
|
129
|
+
localPath: 'home.md',
|
|
130
|
+
title: 'Home',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const pageLookupMap = buildPageLookupMapFromCache(pageState);
|
|
135
|
+
|
|
136
|
+
// PULL: Link to non-existent page in Confluence
|
|
137
|
+
const confluenceHtml = `<p>See <ac:link><ri:page ri:content-title="Non Existent Page" ri:space-key="TEST" /><ac:plain-text-link-body><![CDATA[Non Existent]]></ac:plain-text-link-body></ac:link>.</p>`;
|
|
138
|
+
|
|
139
|
+
const markdownConverter = new MarkdownConverter();
|
|
140
|
+
const { markdown, warnings: pullWarnings } = markdownConverter.convertPage(
|
|
141
|
+
{
|
|
142
|
+
id: 'page-1',
|
|
143
|
+
status: 'current',
|
|
144
|
+
title: 'Home',
|
|
145
|
+
spaceId: 'space-123',
|
|
146
|
+
authorId: 'user-1',
|
|
147
|
+
body: {
|
|
148
|
+
storage: {
|
|
149
|
+
value: confluenceHtml,
|
|
150
|
+
representation: 'storage',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
version: {
|
|
154
|
+
number: 1,
|
|
155
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
[],
|
|
159
|
+
undefined,
|
|
160
|
+
'',
|
|
161
|
+
undefined,
|
|
162
|
+
undefined,
|
|
163
|
+
'home.md',
|
|
164
|
+
pageLookupMap,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Should preserve original content and warn
|
|
168
|
+
expect(pullWarnings.length).toBeGreaterThan(0);
|
|
169
|
+
expect(pullWarnings[0]).toContain('could not be resolved');
|
|
170
|
+
expect(markdown).not.toContain('./non-existent-page.md');
|
|
171
|
+
|
|
172
|
+
// PUSH: Link to non-existent local file
|
|
173
|
+
const localMarkdown = 'See [this page](./non-existent.md).';
|
|
174
|
+
const htmlConverter = new HtmlConverter();
|
|
175
|
+
const { html, warnings: pushWarnings } = htmlConverter.convert(
|
|
176
|
+
localMarkdown,
|
|
177
|
+
'/test/space',
|
|
178
|
+
'home.md',
|
|
179
|
+
'TEST',
|
|
180
|
+
pageLookupMap,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Should fall back to HTML link and warn
|
|
184
|
+
expect(pushWarnings.length).toBeGreaterThan(0);
|
|
185
|
+
expect(pushWarnings[0]).toContain('could not be resolved');
|
|
186
|
+
expect(html).toContain('<a href="./non-existent.md">');
|
|
187
|
+
expect(html).not.toContain('ri:page');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
buildPageLookupMapFromCache,
|
|
4
|
+
confluenceLinkToRelativePath,
|
|
5
|
+
extractPageTitleFromLink,
|
|
6
|
+
relativePathToConfluenceLink,
|
|
7
|
+
type PageLookupMap,
|
|
8
|
+
} from '../lib/markdown/link-converter.js';
|
|
9
|
+
import type { PageStateCache, FullPageInfo } from '../lib/page-state.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Helper to create PageStateCache from a simple record
|
|
13
|
+
* Simplifies test setup by avoiding need to create actual files
|
|
14
|
+
*/
|
|
15
|
+
function createPageStateCache(
|
|
16
|
+
pages: Record<string, { pageId: string; localPath: string; title: string; version?: number }>,
|
|
17
|
+
): PageStateCache {
|
|
18
|
+
const pagesMap = new Map<string, FullPageInfo>();
|
|
19
|
+
const pathToPageId = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
for (const [pageId, info] of Object.entries(pages)) {
|
|
22
|
+
pagesMap.set(pageId, {
|
|
23
|
+
pageId: info.pageId,
|
|
24
|
+
localPath: info.localPath,
|
|
25
|
+
title: info.title,
|
|
26
|
+
version: info.version ?? 1,
|
|
27
|
+
});
|
|
28
|
+
pathToPageId.set(info.localPath, pageId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { pages: pagesMap, pathToPageId };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('buildPageLookupMapFromCache', () => {
|
|
35
|
+
test('builds lookup maps from PageStateCache', () => {
|
|
36
|
+
const pageState = createPageStateCache({
|
|
37
|
+
'page-1': {
|
|
38
|
+
pageId: 'page-1',
|
|
39
|
+
localPath: 'getting-started.md',
|
|
40
|
+
title: 'Getting Started',
|
|
41
|
+
},
|
|
42
|
+
'page-2': {
|
|
43
|
+
pageId: 'page-2',
|
|
44
|
+
localPath: 'architecture/overview.md',
|
|
45
|
+
title: 'Architecture Overview',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const lookupMap = buildPageLookupMapFromCache(pageState);
|
|
50
|
+
|
|
51
|
+
expect(lookupMap.idToPage.get('page-1')?.localPath).toBe('getting-started.md');
|
|
52
|
+
expect(lookupMap.idToPage.get('page-2')?.localPath).toBe('architecture/overview.md');
|
|
53
|
+
expect(lookupMap.titleToPage.get('Getting Started')?.pageId).toBe('page-1');
|
|
54
|
+
expect(lookupMap.titleToPage.get('Architecture Overview')?.pageId).toBe('page-2');
|
|
55
|
+
expect(lookupMap.pathToPage.get('getting-started.md')?.pageId).toBe('page-1');
|
|
56
|
+
expect(lookupMap.pathToPage.get('architecture/overview.md')?.pageId).toBe('page-2');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('handles duplicate titles (deterministic ordering)', () => {
|
|
60
|
+
const pageState = createPageStateCache({
|
|
61
|
+
'page-2': {
|
|
62
|
+
pageId: 'page-2',
|
|
63
|
+
localPath: 'architecture/overview.md',
|
|
64
|
+
title: 'Overview',
|
|
65
|
+
},
|
|
66
|
+
'page-1': {
|
|
67
|
+
pageId: 'page-1',
|
|
68
|
+
localPath: 'overview.md',
|
|
69
|
+
title: 'Overview',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const lookupMap = buildPageLookupMapFromCache(pageState);
|
|
74
|
+
|
|
75
|
+
// Page with lexicographically smallest pageId should win (page-1 < page-2)
|
|
76
|
+
expect(lookupMap.titleToPage.get('Overview')?.pageId).toBe('page-1');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('warns about duplicate titles when enabled', () => {
|
|
80
|
+
const pageState = createPageStateCache({
|
|
81
|
+
'page-2': {
|
|
82
|
+
pageId: 'page-2',
|
|
83
|
+
localPath: 'architecture/overview.md',
|
|
84
|
+
title: 'Overview',
|
|
85
|
+
},
|
|
86
|
+
'page-1': {
|
|
87
|
+
pageId: 'page-1',
|
|
88
|
+
localPath: 'overview.md',
|
|
89
|
+
title: 'Overview',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Capture console.warn calls
|
|
94
|
+
const originalWarn = console.warn;
|
|
95
|
+
const warnings: string[] = [];
|
|
96
|
+
console.warn = (msg: string) => warnings.push(msg);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
buildPageLookupMapFromCache(pageState, true);
|
|
100
|
+
|
|
101
|
+
// Should have warned about duplicate
|
|
102
|
+
expect(warnings.length).toBe(1);
|
|
103
|
+
expect(warnings[0]).toContain('Duplicate page title "Overview"');
|
|
104
|
+
expect(warnings[0]).toContain('overview.md');
|
|
105
|
+
expect(warnings[0]).toContain('architecture/overview.md');
|
|
106
|
+
// Verify it indicates page-1 will be used (lexicographically smallest)
|
|
107
|
+
expect(warnings[0]).toContain('overview.md');
|
|
108
|
+
} finally {
|
|
109
|
+
console.warn = originalWarn;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('handles empty PageStateCache', () => {
|
|
114
|
+
const pageState: PageStateCache = {
|
|
115
|
+
pages: new Map(),
|
|
116
|
+
pathToPageId: new Map(),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const lookupMap = buildPageLookupMapFromCache(pageState);
|
|
120
|
+
|
|
121
|
+
expect(lookupMap.idToPage.size).toBe(0);
|
|
122
|
+
expect(lookupMap.titleToPage.size).toBe(0);
|
|
123
|
+
expect(lookupMap.pathToPage.size).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('confluenceLinkToRelativePath', () => {
|
|
128
|
+
let lookupMap: PageLookupMap;
|
|
129
|
+
|
|
130
|
+
const pageState = createPageStateCache({
|
|
131
|
+
'page-1': {
|
|
132
|
+
pageId: 'page-1',
|
|
133
|
+
localPath: 'getting-started.md',
|
|
134
|
+
title: 'Getting Started',
|
|
135
|
+
},
|
|
136
|
+
'page-2': {
|
|
137
|
+
pageId: 'page-2',
|
|
138
|
+
localPath: 'architecture/overview.md',
|
|
139
|
+
title: 'Architecture Overview',
|
|
140
|
+
},
|
|
141
|
+
'page-3': {
|
|
142
|
+
pageId: 'page-3',
|
|
143
|
+
localPath: 'architecture/database.md',
|
|
144
|
+
title: 'Database Design',
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('converts Confluence link to relative path (same directory)', () => {
|
|
149
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
150
|
+
const result = confluenceLinkToRelativePath('Architecture Overview', 'architecture/database.md', lookupMap);
|
|
151
|
+
|
|
152
|
+
expect(result).toBe('./overview.md');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('converts Confluence link to relative path (parent directory)', () => {
|
|
156
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
157
|
+
const result = confluenceLinkToRelativePath('Getting Started', 'architecture/database.md', lookupMap);
|
|
158
|
+
|
|
159
|
+
expect(result).toBe('../getting-started.md');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('converts Confluence link to relative path (child directory)', () => {
|
|
163
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
164
|
+
const result = confluenceLinkToRelativePath('Architecture Overview', 'getting-started.md', lookupMap);
|
|
165
|
+
|
|
166
|
+
expect(result).toBe('./architecture/overview.md');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('returns null for non-existent page', () => {
|
|
170
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
171
|
+
const result = confluenceLinkToRelativePath('Non Existent Page', 'getting-started.md', lookupMap);
|
|
172
|
+
|
|
173
|
+
expect(result).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('handles paths that need ./ prefix', () => {
|
|
177
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
178
|
+
const result = confluenceLinkToRelativePath('Database Design', 'architecture/overview.md', lookupMap);
|
|
179
|
+
|
|
180
|
+
expect(result).toBe('./database.md');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('extractPageTitleFromLink', () => {
|
|
185
|
+
test('extracts title from ri:page element', () => {
|
|
186
|
+
const html = '<ri:page ri:content-title="Getting Started" />';
|
|
187
|
+
const title = extractPageTitleFromLink(html);
|
|
188
|
+
|
|
189
|
+
expect(title).toBe('Getting Started');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('extracts title from ac:link element', () => {
|
|
193
|
+
const html = '<ac:link><ri:page ri:content-title="Architecture Overview" /></ac:link>';
|
|
194
|
+
const title = extractPageTitleFromLink(html);
|
|
195
|
+
|
|
196
|
+
expect(title).toBe('Architecture Overview');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('handles double quotes', () => {
|
|
200
|
+
const html = '<ri:page ri:content-title="Test Page" />';
|
|
201
|
+
const title = extractPageTitleFromLink(html);
|
|
202
|
+
|
|
203
|
+
expect(title).toBe('Test Page');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('handles single quotes', () => {
|
|
207
|
+
const html = "<ri:page ri:content-title='Test Page' />";
|
|
208
|
+
const title = extractPageTitleFromLink(html);
|
|
209
|
+
|
|
210
|
+
expect(title).toBe('Test Page');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('returns null if no title found', () => {
|
|
214
|
+
const html = '<ac:link></ac:link>';
|
|
215
|
+
const title = extractPageTitleFromLink(html);
|
|
216
|
+
|
|
217
|
+
expect(title).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('decodes HTML entities in titles', () => {
|
|
221
|
+
const html = '<ri:page ri:content-title="Page with & ampersand" />';
|
|
222
|
+
const title = extractPageTitleFromLink(html);
|
|
223
|
+
|
|
224
|
+
// HTML entities should be decoded
|
|
225
|
+
expect(title).toBe('Page with & ampersand');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('decodes angle brackets in titles', () => {
|
|
229
|
+
const html = '<ri:page ri:content-title="Page <with> brackets" />';
|
|
230
|
+
const title = extractPageTitleFromLink(html);
|
|
231
|
+
|
|
232
|
+
expect(title).toBe('Page <with> brackets');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('decodes quotes in titles', () => {
|
|
236
|
+
const html = '<ri:page ri:content-title="Page with "quotes"" />';
|
|
237
|
+
const title = extractPageTitleFromLink(html);
|
|
238
|
+
|
|
239
|
+
expect(title).toBe('Page with "quotes"');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('relativePathToConfluenceLink', () => {
|
|
244
|
+
let lookupMap: PageLookupMap;
|
|
245
|
+
|
|
246
|
+
const pageState = createPageStateCache({
|
|
247
|
+
'page-1': {
|
|
248
|
+
pageId: 'page-1',
|
|
249
|
+
localPath: 'getting-started.md',
|
|
250
|
+
title: 'Getting Started',
|
|
251
|
+
},
|
|
252
|
+
'page-2': {
|
|
253
|
+
pageId: 'page-2',
|
|
254
|
+
localPath: 'architecture/overview.md',
|
|
255
|
+
title: 'Architecture Overview',
|
|
256
|
+
},
|
|
257
|
+
'page-3': {
|
|
258
|
+
pageId: 'page-3',
|
|
259
|
+
localPath: 'architecture/database.md',
|
|
260
|
+
title: 'Database Design',
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('converts relative path to Confluence link (same directory)', () => {
|
|
265
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
266
|
+
const result = relativePathToConfluenceLink('./overview.md', 'architecture/database.md', '/test/space', lookupMap);
|
|
267
|
+
|
|
268
|
+
expect(result).not.toBeNull();
|
|
269
|
+
expect(result?.title).toBe('Architecture Overview');
|
|
270
|
+
expect(result?.pageId).toBe('page-2');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('converts relative path to Confluence link (parent directory)', () => {
|
|
274
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
275
|
+
const result = relativePathToConfluenceLink(
|
|
276
|
+
'../getting-started.md',
|
|
277
|
+
'architecture/database.md',
|
|
278
|
+
'/test/space',
|
|
279
|
+
lookupMap,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(result).not.toBeNull();
|
|
283
|
+
expect(result?.title).toBe('Getting Started');
|
|
284
|
+
expect(result?.pageId).toBe('page-1');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('converts relative path to Confluence link (child directory)', () => {
|
|
288
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
289
|
+
const result = relativePathToConfluenceLink(
|
|
290
|
+
'./architecture/overview.md',
|
|
291
|
+
'getting-started.md',
|
|
292
|
+
'/test/space',
|
|
293
|
+
lookupMap,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(result).not.toBeNull();
|
|
297
|
+
expect(result?.title).toBe('Architecture Overview');
|
|
298
|
+
expect(result?.pageId).toBe('page-2');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('returns null for non-existent file', () => {
|
|
302
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
303
|
+
const result = relativePathToConfluenceLink('./non-existent.md', 'getting-started.md', '/test/space', lookupMap);
|
|
304
|
+
|
|
305
|
+
expect(result).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('handles paths without ./ prefix', () => {
|
|
309
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
310
|
+
const result = relativePathToConfluenceLink('database.md', 'architecture/overview.md', '/test/space', lookupMap);
|
|
311
|
+
|
|
312
|
+
expect(result).not.toBeNull();
|
|
313
|
+
expect(result?.title).toBe('Database Design');
|
|
314
|
+
expect(result?.pageId).toBe('page-3');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('handles deeply nested paths', () => {
|
|
318
|
+
const deepPageState = createPageStateCache({
|
|
319
|
+
'page-deep': {
|
|
320
|
+
pageId: 'page-deep',
|
|
321
|
+
localPath: 'a/b/c/d/e/f/g/h/page.md',
|
|
322
|
+
title: 'Deep Page',
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
lookupMap = buildPageLookupMapFromCache(deepPageState);
|
|
327
|
+
const result = relativePathToConfluenceLink('./a/b/c/d/e/f/g/h/page.md', 'home.md', '/test/space', lookupMap);
|
|
328
|
+
|
|
329
|
+
expect(result).not.toBeNull();
|
|
330
|
+
expect(result?.title).toBe('Deep Page');
|
|
331
|
+
expect(result?.pageId).toBe('page-deep');
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('batch config propagation', () => {
|
|
336
|
+
test('in-memory PageStateCache updates allow subsequent link resolution', () => {
|
|
337
|
+
// This test verifies the fix for batch push link resolution
|
|
338
|
+
// When pushing multiple files, the first file's page info should be available
|
|
339
|
+
// for link resolution in subsequent files
|
|
340
|
+
|
|
341
|
+
// Start with an empty PageStateCache (simulating start of batch push)
|
|
342
|
+
const pageState: PageStateCache = {
|
|
343
|
+
pages: new Map(),
|
|
344
|
+
pathToPageId: new Map(),
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// At this point, links to page-a.md cannot be resolved
|
|
348
|
+
let lookupMap = buildPageLookupMapFromCache(pageState);
|
|
349
|
+
let result = relativePathToConfluenceLink('./page-a.md', 'page-b.md', '/test/space', lookupMap);
|
|
350
|
+
expect(result).toBeNull();
|
|
351
|
+
|
|
352
|
+
// Simulate pushing page-a.md - this updates the in-memory PageStateCache
|
|
353
|
+
const pageAInfo: FullPageInfo = {
|
|
354
|
+
pageId: 'page-a-id',
|
|
355
|
+
localPath: 'page-a.md',
|
|
356
|
+
title: 'Page A',
|
|
357
|
+
version: 1,
|
|
358
|
+
};
|
|
359
|
+
pageState.pages.set('page-a-id', pageAInfo);
|
|
360
|
+
pageState.pathToPageId.set('page-a.md', 'page-a-id');
|
|
361
|
+
|
|
362
|
+
// Now rebuild the lookup map with the updated PageStateCache
|
|
363
|
+
// This is what happens during batch push - the updated state is passed to the next file
|
|
364
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
365
|
+
|
|
366
|
+
// Now links to page-a.md CAN be resolved
|
|
367
|
+
result = relativePathToConfluenceLink('./page-a.md', 'page-b.md', '/test/space', lookupMap);
|
|
368
|
+
expect(result).not.toBeNull();
|
|
369
|
+
expect(result?.pageId).toBe('page-a-id');
|
|
370
|
+
expect(result?.title).toBe('Page A');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('multiple pages pushed in sequence are all resolvable', () => {
|
|
374
|
+
// Simulates pushing three files where each links to the previous
|
|
375
|
+
const pageState: PageStateCache = {
|
|
376
|
+
pages: new Map(),
|
|
377
|
+
pathToPageId: new Map(),
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Push page-a.md
|
|
381
|
+
const pageAInfo: FullPageInfo = {
|
|
382
|
+
pageId: 'page-a-id',
|
|
383
|
+
localPath: 'page-a.md',
|
|
384
|
+
title: 'Page A',
|
|
385
|
+
version: 1,
|
|
386
|
+
};
|
|
387
|
+
pageState.pages.set('page-a-id', pageAInfo);
|
|
388
|
+
pageState.pathToPageId.set('page-a.md', 'page-a-id');
|
|
389
|
+
|
|
390
|
+
// Push page-b.md (can now link to page-a)
|
|
391
|
+
let lookupMap = buildPageLookupMapFromCache(pageState);
|
|
392
|
+
let result = relativePathToConfluenceLink('./page-a.md', 'page-b.md', '/test/space', lookupMap);
|
|
393
|
+
expect(result?.pageId).toBe('page-a-id');
|
|
394
|
+
|
|
395
|
+
const pageBInfo: FullPageInfo = {
|
|
396
|
+
pageId: 'page-b-id',
|
|
397
|
+
localPath: 'page-b.md',
|
|
398
|
+
title: 'Page B',
|
|
399
|
+
version: 1,
|
|
400
|
+
};
|
|
401
|
+
pageState.pages.set('page-b-id', pageBInfo);
|
|
402
|
+
pageState.pathToPageId.set('page-b.md', 'page-b-id');
|
|
403
|
+
|
|
404
|
+
// Push page-c.md (can now link to both page-a and page-b)
|
|
405
|
+
lookupMap = buildPageLookupMapFromCache(pageState);
|
|
406
|
+
|
|
407
|
+
result = relativePathToConfluenceLink('./page-a.md', 'page-c.md', '/test/space', lookupMap);
|
|
408
|
+
expect(result?.pageId).toBe('page-a-id');
|
|
409
|
+
|
|
410
|
+
result = relativePathToConfluenceLink('./page-b.md', 'page-c.md', '/test/space', lookupMap);
|
|
411
|
+
expect(result?.pageId).toBe('page-b-id');
|
|
412
|
+
});
|
|
413
|
+
});
|