@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,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, '<')
|
|
47
|
+
.replace(/>/g, '>')
|
|
48
|
+
.replace(/"/g, '"')
|
|
49
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|