@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,314 @@
1
+ import chalk from 'chalk';
2
+ import type { Page, Space } from './confluence-client/types.js';
3
+ import type { SyncDiff } from './sync/sync-engine.js';
4
+
5
+ /**
6
+ * Base formatter interface
7
+ */
8
+ export interface Formatter {
9
+ formatSpaces(spaces: Space[]): string;
10
+ formatPages(pages: Page[], spaceKey: string): string;
11
+ formatSyncDiff(diff: SyncDiff): string;
12
+ formatStatus(status: StatusInfo): string;
13
+ formatTree(nodes: TreeNode[], depth?: number): string;
14
+ }
15
+
16
+ export interface StatusInfo {
17
+ connected: boolean;
18
+ configured: boolean;
19
+ initialized: boolean;
20
+ confluenceUrl?: string;
21
+ email?: string;
22
+ spaceKey?: string;
23
+ spaceName?: string;
24
+ lastSync?: string;
25
+ pageCount?: number;
26
+ pendingChanges?: {
27
+ added: number;
28
+ modified: number;
29
+ deleted: number;
30
+ };
31
+ }
32
+
33
+ export interface TreeNode {
34
+ id: string;
35
+ title: string;
36
+ children: TreeNode[];
37
+ depth?: number;
38
+ }
39
+
40
+ /**
41
+ * XML escape helper
42
+ */
43
+ export function escapeXml(text: string): string {
44
+ return text
45
+ .replace(/&/g, '&')
46
+ .replace(/</g, '&lt;')
47
+ .replace(/>/g, '&gt;')
48
+ .replace(/"/g, '&quot;')
49
+ .replace(/'/g, '&apos;');
50
+ }
51
+
52
+ /**
53
+ * Human-readable formatter with colors
54
+ */
55
+ export class HumanFormatter implements Formatter {
56
+ formatSpaces(spaces: Space[]): string {
57
+ if (spaces.length === 0) {
58
+ return chalk.yellow('No spaces found.');
59
+ }
60
+
61
+ const lines = [chalk.bold('Spaces:'), ''];
62
+ for (const space of spaces) {
63
+ lines.push(` ${chalk.cyan(space.key)} - ${space.name}`);
64
+ if (space.description?.plain?.value) {
65
+ lines.push(chalk.gray(` ${space.description.plain.value.substring(0, 80)}...`));
66
+ }
67
+ }
68
+ return lines.join('\n');
69
+ }
70
+
71
+ formatPages(pages: Page[], spaceKey: string): string {
72
+ if (pages.length === 0) {
73
+ return chalk.yellow(`No pages found in space ${spaceKey}.`);
74
+ }
75
+
76
+ const lines = [chalk.bold(`Pages in ${spaceKey}:`), ''];
77
+ for (const page of pages) {
78
+ lines.push(` ${chalk.cyan(page.id)} - ${page.title}`);
79
+ }
80
+ lines.push('', chalk.gray(`Total: ${pages.length} pages`));
81
+ return lines.join('\n');
82
+ }
83
+
84
+ formatSyncDiff(diff: SyncDiff): string {
85
+ const lines: string[] = [];
86
+
87
+ if (diff.added.length > 0) {
88
+ lines.push(chalk.green.bold('Added:'));
89
+ for (const change of diff.added) {
90
+ lines.push(chalk.green(` + ${change.title}`));
91
+ }
92
+ lines.push('');
93
+ }
94
+
95
+ if (diff.modified.length > 0) {
96
+ lines.push(chalk.yellow.bold('Modified:'));
97
+ for (const change of diff.modified) {
98
+ lines.push(chalk.yellow(` ~ ${change.title}`));
99
+ }
100
+ lines.push('');
101
+ }
102
+
103
+ if (diff.deleted.length > 0) {
104
+ lines.push(chalk.red.bold('Deleted:'));
105
+ for (const change of diff.deleted) {
106
+ lines.push(chalk.red(` - ${change.title}`));
107
+ }
108
+ lines.push('');
109
+ }
110
+
111
+ if (diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0) {
112
+ lines.push(chalk.gray('No changes detected.'));
113
+ } else {
114
+ lines.push(
115
+ chalk.gray(
116
+ `Summary: ${diff.added.length} added, ${diff.modified.length} modified, ${diff.deleted.length} deleted`,
117
+ ),
118
+ );
119
+ }
120
+
121
+ return lines.join('\n');
122
+ }
123
+
124
+ formatStatus(status: StatusInfo): string {
125
+ const lines: string[] = [];
126
+
127
+ if (!status.configured) {
128
+ lines.push(chalk.red('Not configured.'));
129
+ lines.push(chalk.gray('Run "cn setup" to configure Confluence credentials.'));
130
+ return lines.join('\n');
131
+ }
132
+
133
+ lines.push(chalk.bold('Configuration:'));
134
+ lines.push(` URL: ${chalk.cyan(status.confluenceUrl || 'N/A')}`);
135
+ lines.push(` Email: ${chalk.cyan(status.email || 'N/A')}`);
136
+ lines.push('');
137
+
138
+ if (status.connected) {
139
+ lines.push(chalk.green('✓ Connected to Confluence'));
140
+ } else {
141
+ lines.push(chalk.red('✗ Not connected'));
142
+ }
143
+ lines.push('');
144
+
145
+ if (!status.initialized) {
146
+ lines.push(chalk.yellow('No space initialized in current directory.'));
147
+ lines.push(chalk.gray('Run "cn sync --init <SPACE_KEY>" to initialize.'));
148
+ } else {
149
+ lines.push(chalk.bold('Space:'));
150
+ lines.push(` Key: ${chalk.cyan(status.spaceKey || 'N/A')}`);
151
+ lines.push(` Name: ${status.spaceName || 'N/A'}`);
152
+ lines.push(` Last Sync: ${status.lastSync || 'Never'}`);
153
+ lines.push(` Pages: ${status.pageCount || 0}`);
154
+
155
+ if (status.pendingChanges) {
156
+ const { added, modified, deleted } = status.pendingChanges;
157
+ if (added > 0 || modified > 0 || deleted > 0) {
158
+ lines.push('');
159
+ lines.push(chalk.bold('Pending Changes:'));
160
+ if (added > 0) lines.push(chalk.green(` + ${added} new`));
161
+ if (modified > 0) lines.push(chalk.yellow(` ~ ${modified} modified`));
162
+ if (deleted > 0) lines.push(chalk.red(` - ${deleted} deleted`));
163
+ }
164
+ }
165
+ }
166
+
167
+ return lines.join('\n');
168
+ }
169
+
170
+ formatTree(nodes: TreeNode[], depth = 0): string {
171
+ const lines: string[] = [];
172
+
173
+ for (let i = 0; i < nodes.length; i++) {
174
+ const node = nodes[i];
175
+ const isLast = i === nodes.length - 1;
176
+ const prefix = depth === 0 ? '' : ' '.repeat(depth - 1) + (isLast ? '└── ' : '├── ');
177
+
178
+ lines.push(`${prefix}${node.title}`);
179
+
180
+ if (node.children.length > 0) {
181
+ lines.push(this.formatTree(node.children, depth + 1));
182
+ }
183
+ }
184
+
185
+ return lines.join('\n');
186
+ }
187
+ }
188
+
189
+ /**
190
+ * XML formatter for LLM consumption per ADR-0011
191
+ */
192
+ export class XmlFormatter implements Formatter {
193
+ formatSpaces(spaces: Space[]): string {
194
+ const lines = ['<spaces>'];
195
+ for (const space of spaces) {
196
+ lines.push(` <space key="${escapeXml(space.key)}" id="${escapeXml(space.id)}">`);
197
+ lines.push(` <name>${escapeXml(space.name)}</name>`);
198
+ if (space.description?.plain?.value) {
199
+ lines.push(` <description>${escapeXml(space.description.plain.value)}</description>`);
200
+ }
201
+ lines.push(' </space>');
202
+ }
203
+ lines.push('</spaces>');
204
+ return lines.join('\n');
205
+ }
206
+
207
+ formatPages(pages: Page[], spaceKey: string): string {
208
+ const lines = [`<pages space="${escapeXml(spaceKey)}" count="${pages.length}">`];
209
+ for (const page of pages) {
210
+ lines.push(` <page id="${escapeXml(page.id)}">`);
211
+ lines.push(` <title>${escapeXml(page.title)}</title>`);
212
+ if (page.parentId) {
213
+ lines.push(` <parent-id>${escapeXml(page.parentId)}</parent-id>`);
214
+ }
215
+ lines.push(' </page>');
216
+ }
217
+ lines.push('</pages>');
218
+ return lines.join('\n');
219
+ }
220
+
221
+ formatSyncDiff(diff: SyncDiff): string {
222
+ const lines = [
223
+ `<sync-diff added="${diff.added.length}" modified="${diff.modified.length}" deleted="${diff.deleted.length}">`,
224
+ ];
225
+
226
+ if (diff.added.length > 0) {
227
+ lines.push(' <added>');
228
+ for (const change of diff.added) {
229
+ lines.push(` <page id="${escapeXml(change.pageId)}" title="${escapeXml(change.title)}" />`);
230
+ }
231
+ lines.push(' </added>');
232
+ }
233
+
234
+ if (diff.modified.length > 0) {
235
+ lines.push(' <modified>');
236
+ for (const change of diff.modified) {
237
+ lines.push(` <page id="${escapeXml(change.pageId)}" title="${escapeXml(change.title)}" />`);
238
+ }
239
+ lines.push(' </modified>');
240
+ }
241
+
242
+ if (diff.deleted.length > 0) {
243
+ lines.push(' <deleted>');
244
+ for (const change of diff.deleted) {
245
+ lines.push(` <page id="${escapeXml(change.pageId)}" title="${escapeXml(change.title)}" />`);
246
+ }
247
+ lines.push(' </deleted>');
248
+ }
249
+
250
+ lines.push('</sync-diff>');
251
+ return lines.join('\n');
252
+ }
253
+
254
+ formatStatus(status: StatusInfo): string {
255
+ const lines = [
256
+ `<status configured="${status.configured}" connected="${status.connected}" initialized="${status.initialized}">`,
257
+ ];
258
+
259
+ if (status.configured) {
260
+ lines.push(' <configuration>');
261
+ if (status.confluenceUrl) lines.push(` <url>${escapeXml(status.confluenceUrl)}</url>`);
262
+ if (status.email) lines.push(` <email>${escapeXml(status.email)}</email>`);
263
+ lines.push(' </configuration>');
264
+ }
265
+
266
+ if (status.initialized) {
267
+ lines.push(' <space>');
268
+ if (status.spaceKey) lines.push(` <key>${escapeXml(status.spaceKey)}</key>`);
269
+ if (status.spaceName) lines.push(` <name>${escapeXml(status.spaceName)}</name>`);
270
+ if (status.lastSync) lines.push(` <last-sync>${escapeXml(status.lastSync)}</last-sync>`);
271
+ if (status.pageCount !== undefined) lines.push(` <page-count>${status.pageCount}</page-count>`);
272
+ lines.push(' </space>');
273
+
274
+ if (status.pendingChanges) {
275
+ const { added, modified, deleted } = status.pendingChanges;
276
+ lines.push(` <pending-changes added="${added}" modified="${modified}" deleted="${deleted}" />`);
277
+ }
278
+ }
279
+
280
+ lines.push('</status>');
281
+ return lines.join('\n');
282
+ }
283
+
284
+ formatTree(nodes: TreeNode[], depth = 0): string {
285
+ if (depth === 0) {
286
+ const lines = ['<tree>'];
287
+ lines.push(this.formatTreeNodes(nodes));
288
+ lines.push('</tree>');
289
+ return lines.join('\n');
290
+ }
291
+ return this.formatTreeNodes(nodes);
292
+ }
293
+
294
+ private formatTreeNodes(nodes: TreeNode[]): string {
295
+ const lines: string[] = [];
296
+ for (const node of nodes) {
297
+ if (node.children.length > 0) {
298
+ lines.push(` <page id="${escapeXml(node.id)}" title="${escapeXml(node.title)}">`);
299
+ lines.push(this.formatTreeNodes(node.children));
300
+ lines.push(' </page>');
301
+ } else {
302
+ lines.push(` <page id="${escapeXml(node.id)}" title="${escapeXml(node.title)}" />`);
303
+ }
304
+ }
305
+ return lines.join('\n');
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Get the appropriate formatter based on output mode
311
+ */
312
+ export function getFormatter(xml: boolean): Formatter {
313
+ return xml ? new XmlFormatter() : new HumanFormatter();
314
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Health check utilities for detecting issues in synced spaces
3
+ * Detects duplicate page_ids, orphaned files, stale files, etc.
4
+ */
5
+
6
+ import { readFileSync, readdirSync, statSync, type Stats } from 'node:fs';
7
+ import { join, relative } from 'node:path';
8
+ import { EXCLUDED_DIRS, RESERVED_FILENAMES } from './file-scanner.js';
9
+ import { parseMarkdown, type PageFrontmatter } from './markdown/index.js';
10
+
11
+ /**
12
+ * Information about a scanned markdown file
13
+ */
14
+ export interface ScannedFile {
15
+ path: string;
16
+ pageId?: string;
17
+ parentId?: string | null;
18
+ version?: number;
19
+ title?: string;
20
+ syncedAt?: string;
21
+ mtime: Date;
22
+ }
23
+
24
+ /**
25
+ * Information about a duplicate page_id
26
+ */
27
+ export interface DuplicatePageId {
28
+ pageId: string;
29
+ files: ScannedFile[];
30
+ }
31
+
32
+ /**
33
+ * Result of a health check scan
34
+ */
35
+ export interface HealthCheckResult {
36
+ /** All scanned files with their metadata */
37
+ files: ScannedFile[];
38
+ /** Files with duplicate page_ids */
39
+ duplicates: DuplicatePageId[];
40
+ /** Files without page_id (new/untracked) */
41
+ newFiles: ScannedFile[];
42
+ /** Files with page_id (tracked) */
43
+ trackedFiles: ScannedFile[];
44
+ }
45
+
46
+ /**
47
+ * Scan a directory and collect file metadata for health checks
48
+ */
49
+ export function scanFilesForHealthCheck(directory: string): ScannedFile[] {
50
+ const files: ScannedFile[] = [];
51
+
52
+ function scan(dir: string): void {
53
+ let entries: string[];
54
+ try {
55
+ entries = readdirSync(dir);
56
+ } catch {
57
+ return;
58
+ }
59
+
60
+ for (const entry of entries) {
61
+ if (entry.startsWith('.')) continue;
62
+ if (EXCLUDED_DIRS.has(entry)) continue;
63
+
64
+ const fullPath = join(dir, entry);
65
+ let stat: Stats;
66
+ try {
67
+ stat = statSync(fullPath);
68
+ } catch {
69
+ continue;
70
+ }
71
+
72
+ if (stat.isDirectory()) {
73
+ scan(fullPath);
74
+ } else if (stat.isFile() && entry.endsWith('.md')) {
75
+ if (RESERVED_FILENAMES.has(entry.toLowerCase())) continue;
76
+
77
+ const relativePath = relative(directory, fullPath);
78
+ let content: string;
79
+ try {
80
+ content = readFileSync(fullPath, 'utf-8');
81
+ } catch {
82
+ continue;
83
+ }
84
+
85
+ const { frontmatter } = parseMarkdown(content);
86
+
87
+ files.push({
88
+ path: relativePath,
89
+ pageId: frontmatter.page_id,
90
+ parentId: frontmatter.parent_id,
91
+ version: frontmatter.version,
92
+ title: frontmatter.title,
93
+ syncedAt: frontmatter.synced_at,
94
+ mtime: stat.mtime,
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ scan(directory);
101
+ return files;
102
+ }
103
+
104
+ /**
105
+ * Find duplicate page_ids in scanned files
106
+ */
107
+ export function findDuplicatePageIds(files: ScannedFile[]): DuplicatePageId[] {
108
+ const pageIdToFiles = new Map<string, ScannedFile[]>();
109
+
110
+ for (const file of files) {
111
+ if (!file.pageId) continue;
112
+
113
+ const existing = pageIdToFiles.get(file.pageId) || [];
114
+ existing.push(file);
115
+ pageIdToFiles.set(file.pageId, existing);
116
+ }
117
+
118
+ const duplicates: DuplicatePageId[] = [];
119
+ for (const [pageId, fileList] of pageIdToFiles) {
120
+ if (fileList.length > 1) {
121
+ // Sort by syncedAt (newest first) to help identify the "correct" one
122
+ fileList.sort((a, b) => {
123
+ if (!a.syncedAt && !b.syncedAt) return 0;
124
+ if (!a.syncedAt) return 1;
125
+ if (!b.syncedAt) return -1;
126
+ return new Date(b.syncedAt).getTime() - new Date(a.syncedAt).getTime();
127
+ });
128
+
129
+ duplicates.push({ pageId, files: fileList });
130
+ }
131
+ }
132
+
133
+ return duplicates;
134
+ }
135
+
136
+ /**
137
+ * Run a full health check on a directory
138
+ */
139
+ export function runHealthCheck(directory: string): HealthCheckResult {
140
+ const files = scanFilesForHealthCheck(directory);
141
+ const duplicates = findDuplicatePageIds(files);
142
+ const newFiles = files.filter((f) => !f.pageId);
143
+ const trackedFiles = files.filter((f) => f.pageId);
144
+
145
+ return {
146
+ files,
147
+ duplicates,
148
+ newFiles,
149
+ trackedFiles,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Find the "best" file for a duplicate (newest synced_at, highest version)
155
+ * Returns the file that should be kept
156
+ * @throws Error if files array is empty
157
+ */
158
+ export function findBestDuplicate(files: ScannedFile[]): ScannedFile {
159
+ if (files.length === 0) {
160
+ throw new Error('findBestDuplicate called with empty array');
161
+ }
162
+ return files.reduce((best, current) => {
163
+ // Prefer higher version
164
+ if ((current.version || 0) > (best.version || 0)) return current;
165
+ if ((current.version || 0) < (best.version || 0)) return best;
166
+
167
+ // Same version, prefer newer syncedAt
168
+ if (!best.syncedAt) return current;
169
+ if (!current.syncedAt) return best;
170
+
171
+ return new Date(current.syncedAt) > new Date(best.syncedAt) ? current : best;
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Find stale duplicates (files that should be deleted)
177
+ */
178
+ export function findStaleDuplicates(duplicate: DuplicatePageId): ScannedFile[] {
179
+ const best = findBestDuplicate(duplicate.files);
180
+ return duplicate.files.filter((f) => f.path !== best.path);
181
+ }
182
+
183
+ /**
184
+ * Check if a specific file has duplicates
185
+ */
186
+ export function checkFileForDuplicates(
187
+ directory: string,
188
+ filePath: string,
189
+ ): { hasDuplicates: boolean; duplicates: ScannedFile[]; currentFile: ScannedFile | null } {
190
+ const files = scanFilesForHealthCheck(directory);
191
+ const currentFile = files.find((f) => f.path === filePath) || null;
192
+
193
+ if (!currentFile?.pageId) {
194
+ return { hasDuplicates: false, duplicates: [], currentFile };
195
+ }
196
+
197
+ const duplicates = files.filter((f) => f.pageId === currentFile.pageId && f.path !== filePath);
198
+
199
+ return {
200
+ hasDuplicates: duplicates.length > 0,
201
+ duplicates,
202
+ currentFile,
203
+ };
204
+ }