@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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error types for cn CLI with discriminated unions using _tag property
|
|
3
|
+
* These error types follow the Effect pattern for type-safe error handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration-related errors (missing config, invalid config path)
|
|
8
|
+
*/
|
|
9
|
+
export class ConfigError extends Error {
|
|
10
|
+
readonly _tag = 'ConfigError' as const;
|
|
11
|
+
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'ConfigError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* File system operation errors
|
|
20
|
+
*/
|
|
21
|
+
export class FileSystemError extends Error {
|
|
22
|
+
readonly _tag = 'FileSystemError' as const;
|
|
23
|
+
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'FileSystemError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* JSON parsing errors
|
|
32
|
+
*/
|
|
33
|
+
export class ParseError extends Error {
|
|
34
|
+
readonly _tag = 'ParseError' as const;
|
|
35
|
+
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'ParseError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Schema validation errors
|
|
44
|
+
*/
|
|
45
|
+
export class ValidationError extends Error {
|
|
46
|
+
readonly _tag = 'ValidationError' as const;
|
|
47
|
+
|
|
48
|
+
constructor(message: string) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'ValidationError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* API request errors with status code
|
|
56
|
+
*/
|
|
57
|
+
export class ApiError extends Error {
|
|
58
|
+
readonly _tag = 'ApiError' as const;
|
|
59
|
+
readonly statusCode: number;
|
|
60
|
+
|
|
61
|
+
constructor(message: string, statusCode: number) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = 'ApiError';
|
|
64
|
+
this.statusCode = statusCode;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rate limit errors (429 responses)
|
|
70
|
+
*/
|
|
71
|
+
export class RateLimitError extends Error {
|
|
72
|
+
readonly _tag = 'RateLimitError' as const;
|
|
73
|
+
readonly retryAfter?: number;
|
|
74
|
+
|
|
75
|
+
constructor(message: string, retryAfter?: number) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'RateLimitError';
|
|
78
|
+
this.retryAfter = retryAfter;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Authentication errors (401, 403)
|
|
84
|
+
*/
|
|
85
|
+
export class AuthError extends Error {
|
|
86
|
+
readonly _tag = 'AuthError' as const;
|
|
87
|
+
readonly statusCode: number;
|
|
88
|
+
|
|
89
|
+
constructor(message: string, statusCode: number) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = 'AuthError';
|
|
92
|
+
this.statusCode = statusCode;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sync operation errors
|
|
98
|
+
*/
|
|
99
|
+
export class SyncError extends Error {
|
|
100
|
+
readonly _tag = 'SyncError' as const;
|
|
101
|
+
|
|
102
|
+
constructor(message: string) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.name = 'SyncError';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Network/connectivity errors
|
|
110
|
+
*/
|
|
111
|
+
export class NetworkError extends Error {
|
|
112
|
+
readonly _tag = 'NetworkError' as const;
|
|
113
|
+
|
|
114
|
+
constructor(message: string) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.name = 'NetworkError';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Space not found errors
|
|
122
|
+
*/
|
|
123
|
+
export class SpaceNotFoundError extends Error {
|
|
124
|
+
readonly _tag = 'SpaceNotFoundError' as const;
|
|
125
|
+
readonly spaceKey: string;
|
|
126
|
+
|
|
127
|
+
constructor(spaceKey: string) {
|
|
128
|
+
super(`Space not found: ${spaceKey}`);
|
|
129
|
+
this.name = 'SpaceNotFoundError';
|
|
130
|
+
this.spaceKey = spaceKey;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Page not found errors (404 when updating)
|
|
136
|
+
*/
|
|
137
|
+
export class PageNotFoundError extends Error {
|
|
138
|
+
readonly _tag = 'PageNotFoundError' as const;
|
|
139
|
+
readonly pageId: string;
|
|
140
|
+
|
|
141
|
+
constructor(pageId: string) {
|
|
142
|
+
super(`Page not found: ${pageId}`);
|
|
143
|
+
this.name = 'PageNotFoundError';
|
|
144
|
+
this.pageId = pageId;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Version conflict errors (409 when updating with stale version)
|
|
150
|
+
*/
|
|
151
|
+
export class VersionConflictError extends Error {
|
|
152
|
+
readonly _tag = 'VersionConflictError' as const;
|
|
153
|
+
readonly localVersion: number;
|
|
154
|
+
readonly remoteVersion: number;
|
|
155
|
+
|
|
156
|
+
constructor(localVersion: number, remoteVersion: number) {
|
|
157
|
+
super(`Version conflict: local version ${localVersion} does not match remote version ${remoteVersion}`);
|
|
158
|
+
this.name = 'VersionConflictError';
|
|
159
|
+
this.localVersion = localVersion;
|
|
160
|
+
this.remoteVersion = remoteVersion;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Folder not found errors (404 when folder is deleted on Confluence)
|
|
166
|
+
* Per ADR-0023: Folder push workflow support
|
|
167
|
+
*/
|
|
168
|
+
export class FolderNotFoundError extends Error {
|
|
169
|
+
readonly _tag = 'FolderNotFoundError' as const;
|
|
170
|
+
readonly folderId: string;
|
|
171
|
+
|
|
172
|
+
constructor(folderId: string) {
|
|
173
|
+
super(`Folder not found: ${folderId}`);
|
|
174
|
+
this.name = 'FolderNotFoundError';
|
|
175
|
+
this.folderId = folderId;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Union type of all error types for comprehensive error handling
|
|
181
|
+
*/
|
|
182
|
+
export type CnError =
|
|
183
|
+
| ConfigError
|
|
184
|
+
| FileSystemError
|
|
185
|
+
| ParseError
|
|
186
|
+
| ValidationError
|
|
187
|
+
| ApiError
|
|
188
|
+
| RateLimitError
|
|
189
|
+
| AuthError
|
|
190
|
+
| SyncError
|
|
191
|
+
| NetworkError
|
|
192
|
+
| SpaceNotFoundError
|
|
193
|
+
| PageNotFoundError
|
|
194
|
+
| VersionConflictError
|
|
195
|
+
| FolderNotFoundError;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Exit codes for CLI
|
|
199
|
+
*/
|
|
200
|
+
export const EXIT_CODES = {
|
|
201
|
+
SUCCESS: 0,
|
|
202
|
+
GENERAL_ERROR: 1,
|
|
203
|
+
CONFIG_ERROR: 2,
|
|
204
|
+
AUTH_ERROR: 3,
|
|
205
|
+
NETWORK_ERROR: 4,
|
|
206
|
+
SPACE_NOT_FOUND: 5,
|
|
207
|
+
INVALID_ARGUMENTS: 6,
|
|
208
|
+
PAGE_NOT_FOUND: 7,
|
|
209
|
+
VERSION_CONFLICT: 8,
|
|
210
|
+
FOLDER_NOT_FOUND: 9,
|
|
211
|
+
} as const;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get exit code for a given error
|
|
215
|
+
*/
|
|
216
|
+
export function getExitCodeForError(error: CnError): number {
|
|
217
|
+
switch (error._tag) {
|
|
218
|
+
case 'ConfigError':
|
|
219
|
+
case 'ValidationError':
|
|
220
|
+
return EXIT_CODES.CONFIG_ERROR;
|
|
221
|
+
case 'AuthError':
|
|
222
|
+
return EXIT_CODES.AUTH_ERROR;
|
|
223
|
+
case 'NetworkError':
|
|
224
|
+
case 'RateLimitError':
|
|
225
|
+
return EXIT_CODES.NETWORK_ERROR;
|
|
226
|
+
case 'SpaceNotFoundError':
|
|
227
|
+
return EXIT_CODES.SPACE_NOT_FOUND;
|
|
228
|
+
case 'PageNotFoundError':
|
|
229
|
+
return EXIT_CODES.PAGE_NOT_FOUND;
|
|
230
|
+
case 'VersionConflictError':
|
|
231
|
+
return EXIT_CODES.VERSION_CONFLICT;
|
|
232
|
+
case 'FolderNotFoundError':
|
|
233
|
+
return EXIT_CODES.FOLDER_NOT_FOUND;
|
|
234
|
+
default:
|
|
235
|
+
return EXIT_CODES.GENERAL_ERROR;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync, type Stats } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { parseMarkdown } from './markdown/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Directories to exclude from scanning
|
|
7
|
+
*/
|
|
8
|
+
export const EXCLUDED_DIRS = new Set([
|
|
9
|
+
'node_modules',
|
|
10
|
+
'.git',
|
|
11
|
+
'dist',
|
|
12
|
+
'build',
|
|
13
|
+
'coverage',
|
|
14
|
+
'.next',
|
|
15
|
+
'.nuxt',
|
|
16
|
+
'.cache',
|
|
17
|
+
'.turbo',
|
|
18
|
+
'out',
|
|
19
|
+
'vendor',
|
|
20
|
+
'__pycache__',
|
|
21
|
+
'.venv',
|
|
22
|
+
'venv',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reserved filenames that should not be synced (used by coding agents)
|
|
27
|
+
* Checked case-insensitively
|
|
28
|
+
*/
|
|
29
|
+
export const RESERVED_FILENAMES = new Set(['claude.md', 'agents.md']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Scans a directory recursively for markdown files.
|
|
33
|
+
* Excludes common build/dependency directories and hidden files.
|
|
34
|
+
*
|
|
35
|
+
* @param directory - Root directory to scan for markdown files
|
|
36
|
+
* @returns Array of relative paths to markdown files, sorted alphabetically
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const files = scanMarkdownFiles('/path/to/project');
|
|
41
|
+
* // Returns: ['README.md', 'docs/guide.md', 'docs/api/endpoints.md']
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function scanMarkdownFiles(directory: string): string[] {
|
|
45
|
+
const files: string[] = [];
|
|
46
|
+
|
|
47
|
+
function scan(dir: string): void {
|
|
48
|
+
let entries: string[];
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(dir);
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
// Skip hidden files/directories (starting with .)
|
|
57
|
+
if (entry.startsWith('.')) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Skip excluded directories
|
|
62
|
+
if (EXCLUDED_DIRS.has(entry)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fullPath = join(dir, entry);
|
|
67
|
+
let stat: Stats;
|
|
68
|
+
try {
|
|
69
|
+
stat = statSync(fullPath);
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
scan(fullPath);
|
|
76
|
+
} else if (stat.isFile() && entry.endsWith('.md')) {
|
|
77
|
+
// Skip reserved filenames (used by coding agents)
|
|
78
|
+
if (RESERVED_FILENAMES.has(entry.toLowerCase())) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// Return path relative to the root directory
|
|
82
|
+
files.push(relative(directory, fullPath));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
scan(directory);
|
|
88
|
+
return files.sort();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Represents a file that may need to be pushed
|
|
93
|
+
*/
|
|
94
|
+
export interface PushCandidate {
|
|
95
|
+
/** Relative path from directory root */
|
|
96
|
+
path: string;
|
|
97
|
+
/** Whether this is a new file (no page_id) or modified existing file */
|
|
98
|
+
type: 'new' | 'modified';
|
|
99
|
+
/** Title from frontmatter or filename */
|
|
100
|
+
title: string;
|
|
101
|
+
/** Page ID if it exists */
|
|
102
|
+
pageId?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Detects which markdown files need to be pushed to Confluence.
|
|
107
|
+
*
|
|
108
|
+
* @param directory - Root directory to scan for changed files
|
|
109
|
+
* @returns Array of files that are new or modified since last sync
|
|
110
|
+
*
|
|
111
|
+
* Detection logic:
|
|
112
|
+
* - **New files**: have no `page_id` in frontmatter
|
|
113
|
+
* - **Modified files**: have `page_id` and file mtime > `synced_at` + 1 second
|
|
114
|
+
*
|
|
115
|
+
* The 1-second tolerance accounts for filesystem write timing during pull operations.
|
|
116
|
+
* Files without `synced_at` but with `page_id` are treated as modified.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* const candidates = detectPushCandidates('/path/to/project');
|
|
121
|
+
* for (const candidate of candidates) {
|
|
122
|
+
* console.log(`${candidate.type}: ${candidate.path}`);
|
|
123
|
+
* }
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export function detectPushCandidates(directory: string): PushCandidate[] {
|
|
127
|
+
const files = scanMarkdownFiles(directory);
|
|
128
|
+
const candidates: PushCandidate[] = [];
|
|
129
|
+
|
|
130
|
+
for (const relativePath of files) {
|
|
131
|
+
const fullPath = join(directory, relativePath);
|
|
132
|
+
|
|
133
|
+
let content: string;
|
|
134
|
+
try {
|
|
135
|
+
content = readFileSync(fullPath, 'utf-8');
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const { frontmatter } = parseMarkdown(content);
|
|
141
|
+
|
|
142
|
+
// Get file title from frontmatter or filename
|
|
143
|
+
const filename = relativePath.split('/').pop()?.replace(/\.md$/, '') ?? relativePath.replace(/\.md$/, '');
|
|
144
|
+
const title = (frontmatter.title as string) || filename;
|
|
145
|
+
|
|
146
|
+
// New file - no page_id
|
|
147
|
+
if (!frontmatter.page_id) {
|
|
148
|
+
candidates.push({
|
|
149
|
+
path: relativePath,
|
|
150
|
+
type: 'new',
|
|
151
|
+
title,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Existing file - check if modified since last sync
|
|
157
|
+
const syncedAt = frontmatter.synced_at as string | undefined;
|
|
158
|
+
if (!syncedAt) {
|
|
159
|
+
// No synced_at means it was never synced, treat as modified
|
|
160
|
+
candidates.push({
|
|
161
|
+
path: relativePath,
|
|
162
|
+
type: 'modified',
|
|
163
|
+
title,
|
|
164
|
+
pageId: frontmatter.page_id as string,
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Compare file mtime to synced_at
|
|
170
|
+
let stat: Stats;
|
|
171
|
+
try {
|
|
172
|
+
stat = statSync(fullPath);
|
|
173
|
+
} catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const syncedAtTime = new Date(syncedAt).getTime();
|
|
178
|
+
const mtimeMs = stat.mtimeMs;
|
|
179
|
+
|
|
180
|
+
// Add 1 second tolerance to account for filesystem write timing
|
|
181
|
+
// during pull operations (file mtime is set slightly after synced_at)
|
|
182
|
+
const TOLERANCE_MS = 1000;
|
|
183
|
+
|
|
184
|
+
if (mtimeMs > syncedAtTime + TOLERANCE_MS) {
|
|
185
|
+
candidates.push({
|
|
186
|
+
path: relativePath,
|
|
187
|
+
type: 'modified',
|
|
188
|
+
title,
|
|
189
|
+
pageId: frontmatter.page_id as string,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return candidates;
|
|
195
|
+
}
|