@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,251 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative } from 'node:path';
|
|
3
|
+
import { parseMarkdown, serializeMarkdown } from './frontmatter.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prefix for relative paths in markdown links
|
|
7
|
+
*/
|
|
8
|
+
const RELATIVE_PREFIX = './';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Directories to exclude when scanning for markdown files
|
|
12
|
+
*/
|
|
13
|
+
const EXCLUDE_DIRS = ['node_modules', '.git', 'dist', 'build', '.cache'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Result of updating references in a file
|
|
17
|
+
*/
|
|
18
|
+
export interface ReferenceUpdateResult {
|
|
19
|
+
filePath: string;
|
|
20
|
+
updatedCount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Statistics for file scanning operations
|
|
25
|
+
*/
|
|
26
|
+
interface ScanStats {
|
|
27
|
+
failedFiles: number;
|
|
28
|
+
failedDirs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Find all markdown files in a directory recursively
|
|
33
|
+
* Excludes node_modules, .git, and other common ignore patterns
|
|
34
|
+
*
|
|
35
|
+
* @param directory - Root directory to scan
|
|
36
|
+
* @param excludeOldFile - Optional file path to exclude
|
|
37
|
+
* @param excludeNewFile - Optional file path to exclude
|
|
38
|
+
* @param stats - Optional stats object to track errors
|
|
39
|
+
* @returns Array of absolute file paths
|
|
40
|
+
*/
|
|
41
|
+
function findMarkdownFiles(
|
|
42
|
+
directory: string,
|
|
43
|
+
excludeOldFile?: string,
|
|
44
|
+
excludeNewFile?: string,
|
|
45
|
+
stats?: ScanStats,
|
|
46
|
+
): string[] {
|
|
47
|
+
const results: string[] = [];
|
|
48
|
+
const excludeDirs = EXCLUDE_DIRS;
|
|
49
|
+
|
|
50
|
+
function scan(dir: string): void {
|
|
51
|
+
try {
|
|
52
|
+
const entries = readdirSync(dir);
|
|
53
|
+
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const fullPath = join(dir, entry);
|
|
56
|
+
|
|
57
|
+
// Skip if this is one of the excluded files
|
|
58
|
+
if ((excludeOldFile && fullPath === excludeOldFile) || (excludeNewFile && fullPath === excludeNewFile)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const stat = statSync(fullPath);
|
|
64
|
+
|
|
65
|
+
if (stat.isDirectory()) {
|
|
66
|
+
// Skip excluded directories
|
|
67
|
+
if (!excludeDirs.includes(entry)) {
|
|
68
|
+
scan(fullPath);
|
|
69
|
+
}
|
|
70
|
+
} else if (stat.isFile() && entry.endsWith('.md')) {
|
|
71
|
+
results.push(fullPath);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Track files/dirs we can't access (likely permission issues)
|
|
75
|
+
if (stats) stats.failedFiles++;
|
|
76
|
+
if (process.env.DEBUG) {
|
|
77
|
+
console.error(`Failed to stat ${fullPath}:`, error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Track directories we can't read (likely permission issues)
|
|
83
|
+
if (stats) stats.failedDirs++;
|
|
84
|
+
if (process.env.DEBUG) {
|
|
85
|
+
console.error(`Failed to read directory ${dir}:`, error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
scan(directory);
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Update references in a markdown file from oldPath to newPath
|
|
96
|
+
* Returns the number of references updated, or -1 if processing failed
|
|
97
|
+
*/
|
|
98
|
+
function updateReferencesInFile(
|
|
99
|
+
filePath: string,
|
|
100
|
+
oldRelativePath: string,
|
|
101
|
+
newRelativePath: string,
|
|
102
|
+
spaceRoot: string,
|
|
103
|
+
): number {
|
|
104
|
+
try {
|
|
105
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
106
|
+
const { frontmatter, content: markdown } = parseMarkdown(content);
|
|
107
|
+
|
|
108
|
+
// Calculate what the old path would look like from this file's perspective
|
|
109
|
+
const fileDir = dirname(filePath);
|
|
110
|
+
const oldAbsolutePath = join(spaceRoot, oldRelativePath);
|
|
111
|
+
const newAbsolutePath = join(spaceRoot, newRelativePath);
|
|
112
|
+
|
|
113
|
+
// Calculate relative paths from this file to the old and new locations
|
|
114
|
+
const oldLinkFromFile = relative(fileDir, oldAbsolutePath);
|
|
115
|
+
const newLinkFromFile = relative(fileDir, newAbsolutePath);
|
|
116
|
+
|
|
117
|
+
// Links in markdown may or may not have ./ prefix, so we need to check both forms
|
|
118
|
+
// Paths starting with . (including ./ and ../) are kept as-is; others get ./ prefix added
|
|
119
|
+
const oldLinkWithPrefix = oldLinkFromFile.startsWith('.')
|
|
120
|
+
? oldLinkFromFile
|
|
121
|
+
: `${RELATIVE_PREFIX}${oldLinkFromFile}`;
|
|
122
|
+
const oldLinkWithoutPrefix = oldLinkFromFile.startsWith('./') ? oldLinkFromFile.slice(2) : oldLinkFromFile;
|
|
123
|
+
const newLinkWithoutPrefix = newLinkFromFile.startsWith('./') ? newLinkFromFile.slice(2) : newLinkFromFile;
|
|
124
|
+
|
|
125
|
+
let updatedMarkdown = markdown;
|
|
126
|
+
let updateCount = 0;
|
|
127
|
+
|
|
128
|
+
// Try matching with ./ prefix first
|
|
129
|
+
const patternWithPrefix = createMarkdownLinkPattern(oldLinkWithPrefix);
|
|
130
|
+
const matchesWithPrefix = markdown.match(patternWithPrefix);
|
|
131
|
+
if (matchesWithPrefix) {
|
|
132
|
+
updateCount += matchesWithPrefix.length;
|
|
133
|
+
updatedMarkdown = updatedMarkdown.replace(patternWithPrefix, (_match, linkText) => {
|
|
134
|
+
return `[${linkText}](${oldLinkWithPrefix.startsWith('./') ? `${RELATIVE_PREFIX}${newLinkWithoutPrefix}` : newLinkWithoutPrefix})`;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Also try matching without ./ prefix (common in markdown)
|
|
139
|
+
if (!oldLinkFromFile.startsWith('.')) {
|
|
140
|
+
const patternWithoutPrefix = createMarkdownLinkPattern(oldLinkWithoutPrefix);
|
|
141
|
+
const matchesWithoutPrefix = updatedMarkdown.match(patternWithoutPrefix);
|
|
142
|
+
if (matchesWithoutPrefix) {
|
|
143
|
+
updateCount += matchesWithoutPrefix.length;
|
|
144
|
+
updatedMarkdown = updatedMarkdown.replace(patternWithoutPrefix, (_match, linkText) => {
|
|
145
|
+
return `[${linkText}](${newLinkWithoutPrefix})`;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Write updated content if any changes were made
|
|
151
|
+
if (updateCount > 0) {
|
|
152
|
+
const updatedContent = serializeMarkdown(frontmatter, updatedMarkdown);
|
|
153
|
+
writeFileSync(filePath, updatedContent, 'utf-8');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return updateCount;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// Track files that can't be processed (likely permission issues or malformed frontmatter)
|
|
159
|
+
if (process.env.DEBUG) {
|
|
160
|
+
console.error(`Failed to update references in ${filePath}:`, error);
|
|
161
|
+
}
|
|
162
|
+
return -1; // Signal processing failure
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Escape special regex characters in a string
|
|
168
|
+
*/
|
|
169
|
+
function escapeRegex(str: string): string {
|
|
170
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Create a regex pattern to match markdown links with a specific href
|
|
175
|
+
* Handles complex link text including nested brackets and parentheses
|
|
176
|
+
*
|
|
177
|
+
* Pattern explanation:
|
|
178
|
+
* - \[ - opening bracket
|
|
179
|
+
* - ([^\[\]]+(?:\[[^\]]*\][^\[\]]*)*) - captures link text including nested brackets:
|
|
180
|
+
* - [^\[\]]+ - one or more non-bracket characters
|
|
181
|
+
* - (?:\[[^\]]*\][^\[\]]*)* - zero or more groups of [nested] followed by non-brackets
|
|
182
|
+
* - \] - closing bracket
|
|
183
|
+
* - \(${escapedLink}\) - the exact link path in parentheses
|
|
184
|
+
*
|
|
185
|
+
* This handles cases like [text], [text [nested]], [text (with) parens], etc.
|
|
186
|
+
*
|
|
187
|
+
* Known limitations (not currently supported):
|
|
188
|
+
* - Links with titles: [text](path.md "title")
|
|
189
|
+
* - Reference-style links: [text][ref] with [ref]: path.md
|
|
190
|
+
*
|
|
191
|
+
* @param link - The link href to match (will be escaped)
|
|
192
|
+
* @returns RegExp pattern that matches markdown links with the given href
|
|
193
|
+
*/
|
|
194
|
+
function createMarkdownLinkPattern(link: string): RegExp {
|
|
195
|
+
const escapedLink = escapeRegex(link);
|
|
196
|
+
return new RegExp(`\\[([^\\[\\]]+(?:\\[[^\\]]*\\][^\\[\\]]*)*)\\]\\(${escapedLink}\\)`, 'g');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Update all references to a renamed file across the entire space
|
|
201
|
+
* Per ADR-0022: When files are renamed, update all markdown links pointing to them
|
|
202
|
+
*
|
|
203
|
+
* Performance note: This scans all markdown files in the space directory.
|
|
204
|
+
* For large repositories, consider implementing an index of links or limiting
|
|
205
|
+
* the scan to common parent directories.
|
|
206
|
+
*
|
|
207
|
+
* @param spaceRoot - Absolute path to space root directory
|
|
208
|
+
* @param oldPath - Old path relative to space root
|
|
209
|
+
* @param newPath - New path relative to space root
|
|
210
|
+
* @returns Array of files that were updated with the number of references changed
|
|
211
|
+
*/
|
|
212
|
+
export function updateReferencesAfterRename(
|
|
213
|
+
spaceRoot: string,
|
|
214
|
+
oldPath: string,
|
|
215
|
+
newPath: string,
|
|
216
|
+
): ReferenceUpdateResult[] {
|
|
217
|
+
const results: ReferenceUpdateResult[] = [];
|
|
218
|
+
const scanStats: ScanStats = { failedFiles: 0, failedDirs: 0 };
|
|
219
|
+
let failedProcessing = 0;
|
|
220
|
+
|
|
221
|
+
// Find all markdown files in the space (except the renamed file at both old and new locations)
|
|
222
|
+
const oldFullPath = join(spaceRoot, oldPath);
|
|
223
|
+
const newFullPath = join(spaceRoot, newPath);
|
|
224
|
+
const markdownFiles = findMarkdownFiles(spaceRoot, oldFullPath, newFullPath, scanStats);
|
|
225
|
+
|
|
226
|
+
// Update references in each file
|
|
227
|
+
for (const filePath of markdownFiles) {
|
|
228
|
+
const updatedCount = updateReferencesInFile(filePath, oldPath, newPath, spaceRoot);
|
|
229
|
+
|
|
230
|
+
if (updatedCount > 0) {
|
|
231
|
+
results.push({
|
|
232
|
+
filePath: relative(spaceRoot, filePath),
|
|
233
|
+
updatedCount,
|
|
234
|
+
});
|
|
235
|
+
} else if (updatedCount === -1) {
|
|
236
|
+
failedProcessing++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Log summary of errors if any occurred
|
|
241
|
+
const totalErrors = scanStats.failedFiles + scanStats.failedDirs + failedProcessing;
|
|
242
|
+
if (totalErrors > 0 && process.env.DEBUG) {
|
|
243
|
+
console.warn(
|
|
244
|
+
`Reference update encountered ${totalErrors} errors: ` +
|
|
245
|
+
`${scanStats.failedFiles} files, ${scanStats.failedDirs} directories (scan), ` +
|
|
246
|
+
`${failedProcessing} files (processing)`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return results;
|
|
251
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slugify a string for use as a filename
|
|
3
|
+
* Converts titles to URL-friendly lowercase strings
|
|
4
|
+
*/
|
|
5
|
+
export function slugify(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
|
|
10
|
+
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
11
|
+
.replace(/-+/g, '-') // Collapse multiple hyphens
|
|
12
|
+
.replace(/^-+|-+$/g, ''); // Trim hyphens from start and end
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate a unique filename by appending a counter if needed
|
|
17
|
+
*/
|
|
18
|
+
export function generateUniqueFilename(baseName: string, existingNames: Set<string>, extension = '.md'): string {
|
|
19
|
+
const slug = slugify(baseName);
|
|
20
|
+
const filename = `${slug}${extension}`;
|
|
21
|
+
|
|
22
|
+
if (!existingNames.has(filename)) {
|
|
23
|
+
return filename;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let counter = 2;
|
|
27
|
+
while (existingNames.has(`${slug}-${counter}${extension}`)) {
|
|
28
|
+
counter++;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return `${slug}-${counter}${extension}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page state module - builds full page info from frontmatter
|
|
3
|
+
* Per ADR-0024: Frontmatter is the source of truth for sync state
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, relative, resolve } 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
|
+
* Default version for pages without a version in frontmatter
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Full page information combining mapping and frontmatter data
|
|
18
|
+
*/
|
|
19
|
+
export interface FullPageInfo {
|
|
20
|
+
pageId: string;
|
|
21
|
+
localPath: string;
|
|
22
|
+
title: string;
|
|
23
|
+
version: number;
|
|
24
|
+
updatedAt?: string;
|
|
25
|
+
syncedAt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cache of all page information built from files
|
|
30
|
+
*/
|
|
31
|
+
export interface PageStateCache {
|
|
32
|
+
pages: Map<string, FullPageInfo>;
|
|
33
|
+
pathToPageId: Map<string, string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Result of building page state, including any warnings
|
|
38
|
+
*/
|
|
39
|
+
export interface PageStateBuildResult extends PageStateCache {
|
|
40
|
+
warnings: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build full page state by scanning markdown files and reading frontmatter
|
|
45
|
+
*
|
|
46
|
+
* @param directory - Space root directory
|
|
47
|
+
* @param pageMappings - Page mappings from .confluence.json (pageId -> localPath)
|
|
48
|
+
* @returns PageStateBuildResult with all page info and any warnings
|
|
49
|
+
*/
|
|
50
|
+
export function buildPageStateFromFiles(directory: string, pageMappings: Record<string, string>): PageStateBuildResult {
|
|
51
|
+
const pages = new Map<string, FullPageInfo>();
|
|
52
|
+
const pathToPageId = new Map<string, string>();
|
|
53
|
+
const warnings: string[] = [];
|
|
54
|
+
|
|
55
|
+
// Read frontmatter from each mapped file
|
|
56
|
+
// Only add to pathToPageId if page is successfully parsed
|
|
57
|
+
const resolvedDirectory = resolve(directory);
|
|
58
|
+
|
|
59
|
+
for (const [pageId, localPath] of Object.entries(pageMappings)) {
|
|
60
|
+
const fullPath = resolve(directory, localPath);
|
|
61
|
+
|
|
62
|
+
// Path traversal protection: ensure resolved path is within directory
|
|
63
|
+
if (!fullPath.startsWith(resolvedDirectory)) {
|
|
64
|
+
warnings.push(`Skipping path outside directory for page ${pageId}: ${localPath}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!existsSync(fullPath)) {
|
|
69
|
+
// File doesn't exist - skip (might have been deleted)
|
|
70
|
+
warnings.push(`File not found for page ${pageId}: ${localPath}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
76
|
+
const { frontmatter } = parseMarkdown(content);
|
|
77
|
+
|
|
78
|
+
// Warn if frontmatter page_id doesn't match mapping key
|
|
79
|
+
if (frontmatter.page_id && frontmatter.page_id !== pageId) {
|
|
80
|
+
warnings.push(
|
|
81
|
+
`Page ID mismatch for ${localPath}: mapping has "${pageId}", frontmatter has "${frontmatter.page_id}"`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pageInfo: FullPageInfo = {
|
|
86
|
+
pageId,
|
|
87
|
+
localPath,
|
|
88
|
+
title: frontmatter.title || '',
|
|
89
|
+
version: frontmatter.version || DEFAULT_VERSION,
|
|
90
|
+
updatedAt: frontmatter.updated_at,
|
|
91
|
+
syncedAt: frontmatter.synced_at,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
pages.set(pageId, pageInfo);
|
|
95
|
+
pathToPageId.set(localPath, pageId);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Failed to read/parse file - skip but warn
|
|
98
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
99
|
+
warnings.push(`Failed to parse frontmatter for ${localPath}: ${errorMsg}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { pages, pathToPageId, warnings };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get page info for a single file by reading its frontmatter
|
|
108
|
+
*
|
|
109
|
+
* @param directory - Space root directory
|
|
110
|
+
* @param localPath - Relative path to the file
|
|
111
|
+
* @returns FullPageInfo or null if file doesn't exist or has no page_id
|
|
112
|
+
*/
|
|
113
|
+
export function getPageInfoByPath(directory: string, localPath: string): FullPageInfo | null {
|
|
114
|
+
const fullPath = join(directory, localPath);
|
|
115
|
+
|
|
116
|
+
if (!existsSync(fullPath)) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
122
|
+
const { frontmatter } = parseMarkdown(content);
|
|
123
|
+
|
|
124
|
+
// Must have page_id to be a tracked page
|
|
125
|
+
if (!frontmatter.page_id) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
pageId: frontmatter.page_id,
|
|
131
|
+
localPath,
|
|
132
|
+
title: frontmatter.title || '',
|
|
133
|
+
version: frontmatter.version || DEFAULT_VERSION,
|
|
134
|
+
updatedAt: frontmatter.updated_at,
|
|
135
|
+
syncedAt: frontmatter.synced_at,
|
|
136
|
+
};
|
|
137
|
+
} catch {
|
|
138
|
+
// Return null for any parse errors (malformed YAML, encoding issues, etc.)
|
|
139
|
+
// This is intentional - callers treat unreadable files the same as files without page_id.
|
|
140
|
+
// Use buildPageStateFromFiles() instead if you need detailed error reporting.
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Scan directory for all markdown files and build page state
|
|
147
|
+
* This is used when we need full state but don't have mappings (e.g., initial scan)
|
|
148
|
+
*
|
|
149
|
+
* @param directory - Space root directory
|
|
150
|
+
* @returns PageStateCache with all discovered pages
|
|
151
|
+
*/
|
|
152
|
+
export function scanDirectoryForPages(directory: string): PageStateCache {
|
|
153
|
+
const pages = new Map<string, FullPageInfo>();
|
|
154
|
+
const pathToPageId = new Map<string, string>();
|
|
155
|
+
|
|
156
|
+
function scanDir(dir: string): void {
|
|
157
|
+
const entries = readdirSync(dir);
|
|
158
|
+
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
// Skip hidden files/directories
|
|
161
|
+
if (entry.startsWith('.')) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Skip excluded directories
|
|
166
|
+
if (EXCLUDED_DIRS.has(entry)) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const fullPath = join(dir, entry);
|
|
171
|
+
const stat = statSync(fullPath);
|
|
172
|
+
|
|
173
|
+
if (stat.isDirectory()) {
|
|
174
|
+
scanDir(fullPath);
|
|
175
|
+
} else if (entry.endsWith('.md')) {
|
|
176
|
+
// Skip reserved filenames (used by coding agents)
|
|
177
|
+
if (RESERVED_FILENAMES.has(entry.toLowerCase())) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const localPath = relative(directory, fullPath);
|
|
182
|
+
const pageInfo = getPageInfoByPath(directory, localPath);
|
|
183
|
+
|
|
184
|
+
if (pageInfo) {
|
|
185
|
+
pages.set(pageInfo.pageId, pageInfo);
|
|
186
|
+
pathToPageId.set(localPath, pageInfo.pageId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
scanDir(directory);
|
|
193
|
+
|
|
194
|
+
return { pages, pathToPageId };
|
|
195
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a page target argument to a page ID.
|
|
3
|
+
*
|
|
4
|
+
* Accepts:
|
|
5
|
+
* - A path ending in .md or containing /: reads frontmatter to extract page_id
|
|
6
|
+
* - A numeric string: used directly as the page ID
|
|
7
|
+
* - Otherwise: throws with a usage hint
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { extractPageId } from './markdown/frontmatter.js';
|
|
12
|
+
|
|
13
|
+
export function resolvePageTarget(target: string): string {
|
|
14
|
+
if (target.endsWith('.md') || target.includes('/')) {
|
|
15
|
+
let content: string;
|
|
16
|
+
try {
|
|
17
|
+
content = readFileSync(target, 'utf-8');
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error(`File not found: ${target}`);
|
|
20
|
+
}
|
|
21
|
+
const pageId = extractPageId(content);
|
|
22
|
+
if (!pageId) {
|
|
23
|
+
throw new Error(`No page_id found in frontmatter of ${target}`);
|
|
24
|
+
}
|
|
25
|
+
return pageId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (/^\d+$/.test(target)) {
|
|
29
|
+
return target;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
throw new Error(`Invalid page target: "${target}". Provide a page ID (numeric) or a path to a .md file.`);
|
|
33
|
+
}
|