@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,60 @@
|
|
|
1
|
+
import { Effect, Schema, pipe } from 'effect';
|
|
2
|
+
import { ApiError, AuthError, NetworkError, RateLimitError } from '../errors.js';
|
|
3
|
+
import { SearchResponseSchema, type SearchResponse } from './types.js';
|
|
4
|
+
|
|
5
|
+
/** Retry schedule shared with client — exponential backoff capped at ~160s */
|
|
6
|
+
import { Schedule } from 'effect';
|
|
7
|
+
const MAX_RETRIES = 5;
|
|
8
|
+
const BASE_DELAY_MS = 1000;
|
|
9
|
+
const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
|
|
10
|
+
Schedule.jittered,
|
|
11
|
+
Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
|
|
12
|
+
Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Search pages using CQL (Effect version)
|
|
17
|
+
* Uses GET /wiki/rest/api/search (v1 API - not prefixed with /api/v2)
|
|
18
|
+
*/
|
|
19
|
+
export function searchEffect(
|
|
20
|
+
baseUrl: string,
|
|
21
|
+
authHeader: string,
|
|
22
|
+
cql: string,
|
|
23
|
+
limit = 10,
|
|
24
|
+
): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
25
|
+
const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${limit}`;
|
|
26
|
+
|
|
27
|
+
const makeRequest = Effect.tryPromise({
|
|
28
|
+
try: async () => {
|
|
29
|
+
const response = await fetch(url, {
|
|
30
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (response.status === 429) {
|
|
34
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
35
|
+
throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
|
|
36
|
+
}
|
|
37
|
+
if (response.status === 401) throw new AuthError('Invalid credentials', 401);
|
|
38
|
+
if (response.status === 403) throw new AuthError('Access denied', 403);
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const errorText = await response.text();
|
|
41
|
+
throw new ApiError(`Search failed: ${response.status} ${errorText}`, response.status);
|
|
42
|
+
}
|
|
43
|
+
return response.json();
|
|
44
|
+
},
|
|
45
|
+
catch: (error) => {
|
|
46
|
+
if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) return error;
|
|
47
|
+
return new NetworkError(`Network error: ${error}`);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return pipe(
|
|
52
|
+
makeRequest,
|
|
53
|
+
Effect.flatMap((data) =>
|
|
54
|
+
Schema.decodeUnknown(SearchResponseSchema)(data).pipe(
|
|
55
|
+
Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500)),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
Effect.retry(rateLimitRetrySchedule),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { Schema } from 'effect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Confluence API v2 type definitions
|
|
5
|
+
* These schemas are used for both validation and type inference
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* User information
|
|
10
|
+
*/
|
|
11
|
+
export const UserSchema = Schema.Struct({
|
|
12
|
+
accountId: Schema.String,
|
|
13
|
+
displayName: Schema.optional(Schema.String),
|
|
14
|
+
email: Schema.optional(Schema.String),
|
|
15
|
+
});
|
|
16
|
+
export type User = Schema.Schema.Type<typeof UserSchema>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Space information from Confluence API v2
|
|
20
|
+
*/
|
|
21
|
+
export const SpaceSchema = Schema.Struct({
|
|
22
|
+
id: Schema.String,
|
|
23
|
+
key: Schema.String,
|
|
24
|
+
name: Schema.String,
|
|
25
|
+
type: Schema.optional(Schema.String),
|
|
26
|
+
status: Schema.optional(Schema.String),
|
|
27
|
+
homepageId: Schema.optional(Schema.String),
|
|
28
|
+
description: Schema.optional(
|
|
29
|
+
Schema.NullOr(
|
|
30
|
+
Schema.Struct({
|
|
31
|
+
plain: Schema.optional(
|
|
32
|
+
Schema.Struct({
|
|
33
|
+
value: Schema.String,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
_links: Schema.optional(
|
|
40
|
+
Schema.Struct({
|
|
41
|
+
webui: Schema.optional(Schema.String),
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
});
|
|
45
|
+
export type Space = Schema.Schema.Type<typeof SpaceSchema>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* List of spaces response
|
|
49
|
+
*/
|
|
50
|
+
export const SpacesResponseSchema = Schema.Struct({
|
|
51
|
+
results: Schema.Array(SpaceSchema),
|
|
52
|
+
_links: Schema.optional(
|
|
53
|
+
Schema.Struct({
|
|
54
|
+
next: Schema.optional(Schema.String),
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
export type SpacesResponse = Schema.Schema.Type<typeof SpacesResponseSchema>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Page version information
|
|
62
|
+
*/
|
|
63
|
+
export const VersionSchema = Schema.Struct({
|
|
64
|
+
number: Schema.Number,
|
|
65
|
+
createdAt: Schema.optional(Schema.String),
|
|
66
|
+
authorId: Schema.optional(Schema.String),
|
|
67
|
+
});
|
|
68
|
+
export type Version = Schema.Schema.Type<typeof VersionSchema>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Page body content
|
|
72
|
+
*/
|
|
73
|
+
export const BodySchema = Schema.Struct({
|
|
74
|
+
storage: Schema.optional(
|
|
75
|
+
Schema.Struct({
|
|
76
|
+
value: Schema.String,
|
|
77
|
+
representation: Schema.optional(Schema.String),
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
export type Body = Schema.Schema.Type<typeof BodySchema>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Label information
|
|
85
|
+
*/
|
|
86
|
+
export const LabelSchema = Schema.Struct({
|
|
87
|
+
id: Schema.String,
|
|
88
|
+
name: Schema.String,
|
|
89
|
+
prefix: Schema.optional(Schema.String),
|
|
90
|
+
});
|
|
91
|
+
export type Label = Schema.Schema.Type<typeof LabelSchema>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Page information from Confluence API v2
|
|
95
|
+
*/
|
|
96
|
+
export const PageSchema = Schema.Struct({
|
|
97
|
+
id: Schema.String,
|
|
98
|
+
title: Schema.String,
|
|
99
|
+
spaceId: Schema.String,
|
|
100
|
+
status: Schema.optional(Schema.String),
|
|
101
|
+
parentId: Schema.optional(Schema.NullOr(Schema.String)),
|
|
102
|
+
parentType: Schema.optional(Schema.NullOr(Schema.String)),
|
|
103
|
+
authorId: Schema.optional(Schema.String),
|
|
104
|
+
ownerId: Schema.optional(Schema.String),
|
|
105
|
+
createdAt: Schema.optional(Schema.String),
|
|
106
|
+
version: Schema.optional(VersionSchema),
|
|
107
|
+
body: Schema.optional(BodySchema),
|
|
108
|
+
_links: Schema.optional(
|
|
109
|
+
Schema.Struct({
|
|
110
|
+
webui: Schema.optional(Schema.String),
|
|
111
|
+
editui: Schema.optional(Schema.String),
|
|
112
|
+
tinyui: Schema.optional(Schema.String),
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
});
|
|
116
|
+
export type Page = Schema.Schema.Type<typeof PageSchema>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List of pages response
|
|
120
|
+
*/
|
|
121
|
+
export const PagesResponseSchema = Schema.Struct({
|
|
122
|
+
results: Schema.Array(PageSchema),
|
|
123
|
+
_links: Schema.optional(
|
|
124
|
+
Schema.Struct({
|
|
125
|
+
next: Schema.optional(Schema.String),
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
});
|
|
129
|
+
export type PagesResponse = Schema.Schema.Type<typeof PagesResponseSchema>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Labels response
|
|
133
|
+
*/
|
|
134
|
+
export const LabelsResponseSchema = Schema.Struct({
|
|
135
|
+
results: Schema.Array(LabelSchema),
|
|
136
|
+
_links: Schema.optional(
|
|
137
|
+
Schema.Struct({
|
|
138
|
+
next: Schema.optional(Schema.String),
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
});
|
|
142
|
+
export type LabelsResponse = Schema.Schema.Type<typeof LabelsResponseSchema>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Folder information from Confluence API v2
|
|
146
|
+
*/
|
|
147
|
+
export const FolderSchema = Schema.Struct({
|
|
148
|
+
id: Schema.String,
|
|
149
|
+
type: Schema.Literal('folder'),
|
|
150
|
+
title: Schema.String,
|
|
151
|
+
parentId: Schema.optional(Schema.NullOr(Schema.String)),
|
|
152
|
+
parentType: Schema.optional(Schema.NullOr(Schema.String)),
|
|
153
|
+
authorId: Schema.optional(Schema.String),
|
|
154
|
+
ownerId: Schema.optional(Schema.String),
|
|
155
|
+
createdAt: Schema.optional(Schema.Union(Schema.String, Schema.Number)),
|
|
156
|
+
status: Schema.optional(Schema.String),
|
|
157
|
+
version: Schema.optional(VersionSchema),
|
|
158
|
+
});
|
|
159
|
+
export type Folder = Schema.Schema.Type<typeof FolderSchema>;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Content item - either a page or folder
|
|
163
|
+
*/
|
|
164
|
+
export type ContentItem = Page | Folder;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if content item is a folder
|
|
168
|
+
*/
|
|
169
|
+
export function isFolder(item: ContentItem): item is Folder {
|
|
170
|
+
return 'type' in item && item.type === 'folder';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Error response for version conflicts (409)
|
|
175
|
+
*/
|
|
176
|
+
export interface VersionConflictResponse {
|
|
177
|
+
version?: {
|
|
178
|
+
number?: number;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Request body for updating a page
|
|
184
|
+
*/
|
|
185
|
+
export const UpdatePageRequestSchema = Schema.Struct({
|
|
186
|
+
id: Schema.String,
|
|
187
|
+
status: Schema.String,
|
|
188
|
+
title: Schema.String,
|
|
189
|
+
body: Schema.Struct({
|
|
190
|
+
representation: Schema.Literal('storage'),
|
|
191
|
+
value: Schema.String,
|
|
192
|
+
}),
|
|
193
|
+
version: Schema.Struct({
|
|
194
|
+
number: Schema.Number,
|
|
195
|
+
message: Schema.optional(Schema.String),
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
export type UpdatePageRequest = Schema.Schema.Type<typeof UpdatePageRequestSchema>;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Request body for creating a new page
|
|
202
|
+
*/
|
|
203
|
+
export const CreatePageRequestSchema = Schema.Struct({
|
|
204
|
+
spaceId: Schema.String,
|
|
205
|
+
status: Schema.String,
|
|
206
|
+
title: Schema.String,
|
|
207
|
+
parentId: Schema.optional(Schema.String),
|
|
208
|
+
body: Schema.Struct({
|
|
209
|
+
representation: Schema.Literal('storage'),
|
|
210
|
+
value: Schema.String,
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
export type CreatePageRequest = Schema.Schema.Type<typeof CreatePageRequestSchema>;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Request body for creating a new folder
|
|
217
|
+
*/
|
|
218
|
+
export const CreateFolderRequestSchema = Schema.Struct({
|
|
219
|
+
spaceId: Schema.String,
|
|
220
|
+
title: Schema.String,
|
|
221
|
+
parentId: Schema.optional(Schema.String),
|
|
222
|
+
});
|
|
223
|
+
export type CreateFolderRequest = Schema.Schema.Type<typeof CreateFolderRequestSchema>;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Comment information from Confluence API v2
|
|
227
|
+
*/
|
|
228
|
+
export const CommentSchema = Schema.Struct({
|
|
229
|
+
id: Schema.String,
|
|
230
|
+
status: Schema.optional(Schema.String),
|
|
231
|
+
title: Schema.optional(Schema.String),
|
|
232
|
+
body: Schema.optional(BodySchema),
|
|
233
|
+
version: Schema.optional(VersionSchema),
|
|
234
|
+
authorId: Schema.optional(Schema.String),
|
|
235
|
+
createdAt: Schema.optional(Schema.String),
|
|
236
|
+
});
|
|
237
|
+
export type Comment = Schema.Schema.Type<typeof CommentSchema>;
|
|
238
|
+
|
|
239
|
+
export const CommentsResponseSchema = Schema.Struct({
|
|
240
|
+
results: Schema.Array(CommentSchema),
|
|
241
|
+
_links: Schema.optional(
|
|
242
|
+
Schema.Struct({
|
|
243
|
+
next: Schema.optional(Schema.String),
|
|
244
|
+
}),
|
|
245
|
+
),
|
|
246
|
+
});
|
|
247
|
+
export type CommentsResponse = Schema.Schema.Type<typeof CommentsResponseSchema>;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Attachment information from Confluence API v2
|
|
251
|
+
*/
|
|
252
|
+
export const AttachmentSchema = Schema.Struct({
|
|
253
|
+
id: Schema.String,
|
|
254
|
+
title: Schema.String,
|
|
255
|
+
status: Schema.optional(Schema.String),
|
|
256
|
+
mediaType: Schema.optional(Schema.String),
|
|
257
|
+
fileSize: Schema.optional(Schema.Number),
|
|
258
|
+
webuiLink: Schema.optional(Schema.String),
|
|
259
|
+
downloadLink: Schema.optional(Schema.String),
|
|
260
|
+
version: Schema.optional(VersionSchema),
|
|
261
|
+
});
|
|
262
|
+
export type Attachment = Schema.Schema.Type<typeof AttachmentSchema>;
|
|
263
|
+
|
|
264
|
+
export const AttachmentsResponseSchema = Schema.Struct({
|
|
265
|
+
results: Schema.Array(AttachmentSchema),
|
|
266
|
+
_links: Schema.optional(
|
|
267
|
+
Schema.Struct({
|
|
268
|
+
next: Schema.optional(Schema.String),
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
});
|
|
272
|
+
export type AttachmentsResponse = Schema.Schema.Type<typeof AttachmentsResponseSchema>;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Search result item from CQL search
|
|
276
|
+
*/
|
|
277
|
+
export const SearchResultItemSchema = Schema.Struct({
|
|
278
|
+
id: Schema.optional(Schema.String),
|
|
279
|
+
title: Schema.optional(Schema.String),
|
|
280
|
+
type: Schema.optional(Schema.String),
|
|
281
|
+
status: Schema.optional(Schema.String),
|
|
282
|
+
space: Schema.optional(
|
|
283
|
+
Schema.Struct({
|
|
284
|
+
id: Schema.optional(Schema.String),
|
|
285
|
+
key: Schema.optional(Schema.String),
|
|
286
|
+
name: Schema.optional(Schema.String),
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
url: Schema.optional(Schema.String),
|
|
290
|
+
excerpt: Schema.optional(Schema.String),
|
|
291
|
+
lastModified: Schema.optional(Schema.String),
|
|
292
|
+
content: Schema.optional(
|
|
293
|
+
Schema.Struct({
|
|
294
|
+
id: Schema.optional(Schema.String),
|
|
295
|
+
type: Schema.optional(Schema.String),
|
|
296
|
+
title: Schema.optional(Schema.String),
|
|
297
|
+
_links: Schema.optional(
|
|
298
|
+
Schema.Struct({
|
|
299
|
+
webui: Schema.optional(Schema.String),
|
|
300
|
+
}),
|
|
301
|
+
),
|
|
302
|
+
}),
|
|
303
|
+
),
|
|
304
|
+
});
|
|
305
|
+
export type SearchResultItem = Schema.Schema.Type<typeof SearchResultItemSchema>;
|
|
306
|
+
|
|
307
|
+
export const SearchResponseSchema = Schema.Struct({
|
|
308
|
+
results: Schema.Array(SearchResultItemSchema),
|
|
309
|
+
totalSize: Schema.optional(Schema.Number),
|
|
310
|
+
start: Schema.optional(Schema.Number),
|
|
311
|
+
limit: Schema.optional(Schema.Number),
|
|
312
|
+
});
|
|
313
|
+
export type SearchResponse = Schema.Schema.Type<typeof SearchResponseSchema>;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Page with children tree structure (for building hierarchy)
|
|
317
|
+
*/
|
|
318
|
+
export interface PageTreeNode {
|
|
319
|
+
page: Page;
|
|
320
|
+
children: PageTreeNode[];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Content tree node (page or folder with children)
|
|
325
|
+
*/
|
|
326
|
+
export interface ContentTreeNode {
|
|
327
|
+
item: ContentItem;
|
|
328
|
+
children: ContentTreeNode[];
|
|
329
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Effect, Schema, pipe } from 'effect';
|
|
2
|
+
import { Schedule } from 'effect';
|
|
3
|
+
import { ApiError, AuthError, NetworkError, RateLimitError } from '../errors.js';
|
|
4
|
+
import { UserSchema, type User } from './types.js';
|
|
5
|
+
|
|
6
|
+
const MAX_RETRIES = 5;
|
|
7
|
+
const BASE_DELAY_MS = 1000;
|
|
8
|
+
const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
|
|
9
|
+
Schedule.jittered,
|
|
10
|
+
Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
|
|
11
|
+
Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get user information by account ID (Effect version)
|
|
16
|
+
* Uses v1 API as v2 does not have a user endpoint
|
|
17
|
+
*/
|
|
18
|
+
export function getUserEffect(
|
|
19
|
+
baseUrl: string,
|
|
20
|
+
authHeader: string,
|
|
21
|
+
accountId: string,
|
|
22
|
+
): Effect.Effect<User, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
23
|
+
const url = `${baseUrl}/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`;
|
|
24
|
+
|
|
25
|
+
const makeRequest = Effect.tryPromise({
|
|
26
|
+
try: async () => {
|
|
27
|
+
const response = await fetch(url, {
|
|
28
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (response.status === 429) {
|
|
32
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
33
|
+
throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
|
|
34
|
+
}
|
|
35
|
+
if (response.status === 401) throw new AuthError('Invalid credentials', 401);
|
|
36
|
+
if (response.status === 403) throw new AuthError('Access denied', 403);
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const errorText = await response.text();
|
|
39
|
+
throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
|
|
40
|
+
}
|
|
41
|
+
return response.json();
|
|
42
|
+
},
|
|
43
|
+
catch: (error) => {
|
|
44
|
+
if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) return error;
|
|
45
|
+
return new NetworkError(`Network error: ${error}`);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return pipe(
|
|
50
|
+
makeRequest,
|
|
51
|
+
Effect.flatMap((data) =>
|
|
52
|
+
Schema.decodeUnknown(UserSchema)(data).pipe(
|
|
53
|
+
Effect.mapError((e) => new ApiError(`Invalid user response: ${e}`, 500)),
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
Effect.retry(rateLimitRetrySchedule),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, normalize, relative } from 'node:path';
|
|
3
|
+
import type { PushCandidate } from './file-scanner.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract all local markdown links from content.
|
|
7
|
+
*
|
|
8
|
+
* Matches markdown links like [text](path.md) but excludes:
|
|
9
|
+
* - http:// and https:// URLs
|
|
10
|
+
* - Links to non-.md files
|
|
11
|
+
*
|
|
12
|
+
* @param content - Markdown content to extract links from
|
|
13
|
+
* @returns Array of relative paths (as written in the markdown)
|
|
14
|
+
*/
|
|
15
|
+
export function extractLocalLinks(content: string): string[] {
|
|
16
|
+
// Match markdown links: [text](path.md) or [text](path.md#anchor)
|
|
17
|
+
// The text part can contain nested brackets (e.g., [text [nested]](link.md))
|
|
18
|
+
// Pattern explanation:
|
|
19
|
+
// \[([^\[\]]+(?:\[[^\]]*\][^\[\]]*)*)\] - Match [text] allowing nested brackets
|
|
20
|
+
// \(([^)#]+\.md)(?:#[^)]*)?\) - Match (path.md) or (path.md#anchor)
|
|
21
|
+
const linkPattern = /\[([^[\]]+(?:\[[^\]]*\][^[\]]*)*)\]\(([^)#]+\.md)(?:#[^)]*)?\)/g;
|
|
22
|
+
|
|
23
|
+
const links: string[] = [];
|
|
24
|
+
let match: RegExpExecArray | null;
|
|
25
|
+
|
|
26
|
+
while ((match = linkPattern.exec(content)) !== null) {
|
|
27
|
+
const linkPath = match[2];
|
|
28
|
+
|
|
29
|
+
// Skip external URLs
|
|
30
|
+
if (linkPath.startsWith('http://') || linkPath.startsWith('https://')) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Remove any anchor fragments (e.g., file.md#section -> file.md)
|
|
35
|
+
const pathWithoutAnchor = linkPath.split('#')[0];
|
|
36
|
+
|
|
37
|
+
links.push(pathWithoutAnchor);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return links;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a link path relative to the source file's directory.
|
|
45
|
+
*
|
|
46
|
+
* @param linkPath - The link path as written in markdown
|
|
47
|
+
* @param sourceFilePath - The path of the file containing the link (relative to directory)
|
|
48
|
+
* @returns Normalized path relative to directory root
|
|
49
|
+
*/
|
|
50
|
+
function resolveLinkPath(linkPath: string, sourceFilePath: string): string {
|
|
51
|
+
// Get the directory containing the source file
|
|
52
|
+
const sourceDir = dirname(sourceFilePath);
|
|
53
|
+
|
|
54
|
+
// Join and normalize the path
|
|
55
|
+
const resolvedPath = normalize(join(sourceDir, linkPath));
|
|
56
|
+
|
|
57
|
+
return resolvedPath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sort push candidates by dependencies so linked-to pages are pushed first.
|
|
62
|
+
*
|
|
63
|
+
* Uses Kahn's algorithm for topological sorting with cycle detection.
|
|
64
|
+
* If cycles are detected, the files involved in cycles are still included
|
|
65
|
+
* in the output (in their original relative order).
|
|
66
|
+
*
|
|
67
|
+
* @param candidates - Array of push candidates to sort
|
|
68
|
+
* @param directory - Root directory for resolving file paths
|
|
69
|
+
* @returns Object containing:
|
|
70
|
+
* - sorted: Candidates sorted so dependencies come first
|
|
71
|
+
* - cycles: Array of detected cycles (each cycle is array of paths)
|
|
72
|
+
*/
|
|
73
|
+
export function sortByDependencies(
|
|
74
|
+
candidates: PushCandidate[],
|
|
75
|
+
directory: string,
|
|
76
|
+
): { sorted: PushCandidate[]; cycles: string[][] } {
|
|
77
|
+
// Build a set of candidate paths for quick lookup
|
|
78
|
+
const candidatePaths = new Set(candidates.map((c) => c.path));
|
|
79
|
+
|
|
80
|
+
// Build dependency graph: file -> files it depends on (links to)
|
|
81
|
+
// dependsOn[A] = [B, C] means A links to B and C
|
|
82
|
+
const dependsOn = new Map<string, Set<string>>();
|
|
83
|
+
|
|
84
|
+
// Build reverse graph: file -> files that depend on it (link to it)
|
|
85
|
+
// dependedBy[B] = [A] means A links to B
|
|
86
|
+
const dependedBy = new Map<string, Set<string>>();
|
|
87
|
+
|
|
88
|
+
// Initialize maps for all candidates
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
dependsOn.set(candidate.path, new Set());
|
|
91
|
+
dependedBy.set(candidate.path, new Set());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Read each candidate's content and extract dependencies
|
|
95
|
+
for (const candidate of candidates) {
|
|
96
|
+
const fullPath = join(directory, candidate.path);
|
|
97
|
+
|
|
98
|
+
let content: string;
|
|
99
|
+
try {
|
|
100
|
+
content = readFileSync(fullPath, 'utf-8');
|
|
101
|
+
} catch {
|
|
102
|
+
// If we can't read the file, skip dependency analysis for it
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const links = extractLocalLinks(content);
|
|
107
|
+
|
|
108
|
+
for (const link of links) {
|
|
109
|
+
// Resolve the link relative to the candidate's location
|
|
110
|
+
const resolvedLink = resolveLinkPath(link, candidate.path);
|
|
111
|
+
|
|
112
|
+
// Only track dependencies that are also candidates
|
|
113
|
+
if (candidatePaths.has(resolvedLink)) {
|
|
114
|
+
// candidate.path depends on resolvedLink
|
|
115
|
+
dependsOn.get(candidate.path)?.add(resolvedLink);
|
|
116
|
+
dependedBy.get(resolvedLink)?.add(candidate.path);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Kahn's algorithm for topological sort
|
|
122
|
+
// Start with nodes that have no dependencies
|
|
123
|
+
const sorted: PushCandidate[] = [];
|
|
124
|
+
const candidateMap = new Map(candidates.map((c) => [c.path, c]));
|
|
125
|
+
|
|
126
|
+
// Queue of nodes with no remaining dependencies
|
|
127
|
+
const queue: string[] = [];
|
|
128
|
+
|
|
129
|
+
// Count of unprocessed dependencies for each node
|
|
130
|
+
const inDegree = new Map<string, number>();
|
|
131
|
+
|
|
132
|
+
// Initialize in-degrees
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
const deps = dependsOn.get(candidate.path);
|
|
135
|
+
const depCount = deps?.size ?? 0;
|
|
136
|
+
inDegree.set(candidate.path, depCount);
|
|
137
|
+
|
|
138
|
+
if (depCount === 0) {
|
|
139
|
+
queue.push(candidate.path);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Process queue
|
|
144
|
+
while (queue.length > 0) {
|
|
145
|
+
const current = queue.shift();
|
|
146
|
+
if (!current) break;
|
|
147
|
+
|
|
148
|
+
const candidate = candidateMap.get(current);
|
|
149
|
+
if (candidate) {
|
|
150
|
+
sorted.push(candidate);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// For each file that depends on current, decrement its in-degree
|
|
154
|
+
const dependents = dependedBy.get(current);
|
|
155
|
+
if (dependents) {
|
|
156
|
+
for (const dependent of dependents) {
|
|
157
|
+
const currentDegree = inDegree.get(dependent) ?? 0;
|
|
158
|
+
const newDegree = currentDegree - 1;
|
|
159
|
+
inDegree.set(dependent, newDegree);
|
|
160
|
+
|
|
161
|
+
if (newDegree === 0) {
|
|
162
|
+
queue.push(dependent);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Detect cycles: any nodes not in sorted output are part of cycles
|
|
169
|
+
const cycles: string[][] = [];
|
|
170
|
+
const sortedPaths = new Set(sorted.map((c) => c.path));
|
|
171
|
+
const remainingPaths = candidates.filter((c) => !sortedPaths.has(c.path)).map((c) => c.path);
|
|
172
|
+
|
|
173
|
+
if (remainingPaths.length > 0) {
|
|
174
|
+
// Find cycles using DFS
|
|
175
|
+
const visited = new Set<string>();
|
|
176
|
+
const inStack = new Set<string>();
|
|
177
|
+
|
|
178
|
+
function findCycles(node: string, path: string[]): void {
|
|
179
|
+
if (inStack.has(node)) {
|
|
180
|
+
// Found a cycle - extract it from path
|
|
181
|
+
const cycleStart = path.indexOf(node);
|
|
182
|
+
const cycle = path.slice(cycleStart);
|
|
183
|
+
cycles.push(cycle);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (visited.has(node)) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
visited.add(node);
|
|
192
|
+
inStack.add(node);
|
|
193
|
+
path.push(node);
|
|
194
|
+
|
|
195
|
+
const deps = dependsOn.get(node);
|
|
196
|
+
if (deps) {
|
|
197
|
+
for (const dep of deps) {
|
|
198
|
+
if (remainingPaths.includes(dep)) {
|
|
199
|
+
findCycles(dep, [...path]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
inStack.delete(node);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const start of remainingPaths) {
|
|
208
|
+
if (!visited.has(start)) {
|
|
209
|
+
findCycles(start, []);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Add remaining candidates to sorted output, prioritizing new pages first
|
|
214
|
+
// so they get created and receive page_ids before modified pages are pushed.
|
|
215
|
+
// This allows modified pages to resolve links to the newly created pages.
|
|
216
|
+
const remainingCandidates = remainingPaths
|
|
217
|
+
.map((path) => candidateMap.get(path))
|
|
218
|
+
.filter((c): c is PushCandidate => c !== undefined)
|
|
219
|
+
.sort((a, b) => {
|
|
220
|
+
// New pages first, then modified
|
|
221
|
+
if (a.type === 'new' && b.type !== 'new') return -1;
|
|
222
|
+
if (a.type !== 'new' && b.type === 'new') return 1;
|
|
223
|
+
// Within same type, preserve original order (alphabetical)
|
|
224
|
+
return remainingPaths.indexOf(a.path) - remainingPaths.indexOf(b.path);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
for (const candidate of remainingCandidates) {
|
|
228
|
+
sorted.push(candidate);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { sorted, cycles };
|
|
233
|
+
}
|