@knpkv/confluence-to-markdown 0.2.0
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/CHANGELOG.md +12 -0
- package/README.md +62 -0
- package/dist/Brand.d.ts +77 -0
- package/dist/Brand.d.ts.map +1 -0
- package/dist/Brand.js +44 -0
- package/dist/Brand.js.map +1 -0
- package/dist/ConfluenceClient.d.ts +140 -0
- package/dist/ConfluenceClient.d.ts.map +1 -0
- package/dist/ConfluenceClient.js +195 -0
- package/dist/ConfluenceClient.js.map +1 -0
- package/dist/ConfluenceConfig.d.ts +83 -0
- package/dist/ConfluenceConfig.d.ts.map +1 -0
- package/dist/ConfluenceConfig.js +122 -0
- package/dist/ConfluenceConfig.js.map +1 -0
- package/dist/ConfluenceError.d.ts +178 -0
- package/dist/ConfluenceError.d.ts.map +1 -0
- package/dist/ConfluenceError.js +131 -0
- package/dist/ConfluenceError.js.map +1 -0
- package/dist/LocalFileSystem.d.ts +85 -0
- package/dist/LocalFileSystem.d.ts.map +1 -0
- package/dist/LocalFileSystem.js +101 -0
- package/dist/LocalFileSystem.js.map +1 -0
- package/dist/MarkdownConverter.d.ts +50 -0
- package/dist/MarkdownConverter.d.ts.map +1 -0
- package/dist/MarkdownConverter.js +151 -0
- package/dist/MarkdownConverter.js.map +1 -0
- package/dist/Schemas.d.ts +225 -0
- package/dist/Schemas.d.ts.map +1 -0
- package/dist/Schemas.js +164 -0
- package/dist/Schemas.js.map +1 -0
- package/dist/SyncEngine.d.ts +132 -0
- package/dist/SyncEngine.d.ts.map +1 -0
- package/dist/SyncEngine.js +267 -0
- package/dist/SyncEngine.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +163 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/frontmatter.d.ts +38 -0
- package/dist/internal/frontmatter.d.ts.map +1 -0
- package/dist/internal/frontmatter.js +69 -0
- package/dist/internal/frontmatter.js.map +1 -0
- package/dist/internal/hashUtils.d.ts +11 -0
- package/dist/internal/hashUtils.d.ts.map +1 -0
- package/dist/internal/hashUtils.js +17 -0
- package/dist/internal/hashUtils.js.map +1 -0
- package/dist/internal/pathUtils.d.ts +41 -0
- package/dist/internal/pathUtils.d.ts.map +1 -0
- package/dist/internal/pathUtils.js +69 -0
- package/dist/internal/pathUtils.js.map +1 -0
- package/package.json +113 -0
- package/src/Brand.ts +104 -0
- package/src/ConfluenceClient.ts +387 -0
- package/src/ConfluenceConfig.ts +184 -0
- package/src/ConfluenceError.ts +193 -0
- package/src/LocalFileSystem.ts +225 -0
- package/src/MarkdownConverter.ts +187 -0
- package/src/Schemas.ts +235 -0
- package/src/SyncEngine.ts +429 -0
- package/src/bin.ts +269 -0
- package/src/index.ts +35 -0
- package/src/internal/frontmatter.ts +98 -0
- package/src/internal/hashUtils.ts +19 -0
- package/src/internal/pathUtils.ts +77 -0
- package/test/Brand.test.ts +72 -0
- package/test/MarkdownConverter.test.ts +108 -0
- package/test/Schemas.test.ts +99 -0
- package/tsconfig.json +32 -0
- package/vitest.config.integration.ts +12 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confluence REST API v2 client service.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import * as HttpClient from "@effect/platform/HttpClient"
|
|
7
|
+
import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
|
|
8
|
+
import * as Context from "effect/Context"
|
|
9
|
+
import * as Effect from "effect/Effect"
|
|
10
|
+
import * as Layer from "effect/Layer"
|
|
11
|
+
import * as Schedule from "effect/Schedule"
|
|
12
|
+
import * as Schema from "effect/Schema"
|
|
13
|
+
import type { PageId } from "./Brand.js"
|
|
14
|
+
import { ApiError, RateLimitError } from "./ConfluenceError.js"
|
|
15
|
+
import type { PageChildrenResponse, PageListItem, PageResponse } from "./Schemas.js"
|
|
16
|
+
import { PageChildrenResponseSchema, PageResponseSchema } from "./Schemas.js"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Request to create a new page.
|
|
20
|
+
*
|
|
21
|
+
* @category Types
|
|
22
|
+
*/
|
|
23
|
+
export interface CreatePageRequest {
|
|
24
|
+
readonly spaceId: string
|
|
25
|
+
readonly title: string
|
|
26
|
+
readonly parentId?: string
|
|
27
|
+
readonly body: {
|
|
28
|
+
readonly representation: "storage"
|
|
29
|
+
readonly value: string
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Request to update an existing page.
|
|
35
|
+
*
|
|
36
|
+
* @category Types
|
|
37
|
+
*/
|
|
38
|
+
export interface UpdatePageRequest {
|
|
39
|
+
readonly id: string
|
|
40
|
+
readonly title: string
|
|
41
|
+
readonly status?: "current" | "draft"
|
|
42
|
+
readonly version: {
|
|
43
|
+
readonly number: number
|
|
44
|
+
readonly message?: string
|
|
45
|
+
}
|
|
46
|
+
readonly body: {
|
|
47
|
+
readonly representation: "storage"
|
|
48
|
+
readonly value: string
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Confluence REST API v2 client service.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* import { ConfluenceClient } from "@knpkv/confluence-to-markdown/ConfluenceClient"
|
|
58
|
+
* import { Effect } from "effect"
|
|
59
|
+
*
|
|
60
|
+
* const program = Effect.gen(function* () {
|
|
61
|
+
* const client = yield* ConfluenceClient
|
|
62
|
+
* const page = yield* client.getPage("12345")
|
|
63
|
+
* console.log(page.title)
|
|
64
|
+
* })
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* @category Client
|
|
68
|
+
*/
|
|
69
|
+
export class ConfluenceClient extends Context.Tag(
|
|
70
|
+
"@knpkv/confluence-to-markdown/ConfluenceClient"
|
|
71
|
+
)<
|
|
72
|
+
ConfluenceClient,
|
|
73
|
+
{
|
|
74
|
+
/**
|
|
75
|
+
* Get a page by ID.
|
|
76
|
+
*/
|
|
77
|
+
readonly getPage: (id: PageId) => Effect.Effect<PageResponse, ApiError | RateLimitError>
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get children of a page.
|
|
81
|
+
*/
|
|
82
|
+
readonly getChildren: (id: PageId) => Effect.Effect<PageChildrenResponse, ApiError | RateLimitError>
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get all children recursively (handles pagination).
|
|
86
|
+
*/
|
|
87
|
+
readonly getAllChildren: (id: PageId) => Effect.Effect<ReadonlyArray<PageListItem>, ApiError | RateLimitError>
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create a new page.
|
|
91
|
+
*/
|
|
92
|
+
readonly createPage: (request: CreatePageRequest) => Effect.Effect<PageResponse, ApiError | RateLimitError>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Update an existing page.
|
|
96
|
+
*/
|
|
97
|
+
readonly updatePage: (request: UpdatePageRequest) => Effect.Effect<PageResponse, ApiError | RateLimitError>
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Delete a page.
|
|
101
|
+
*/
|
|
102
|
+
readonly deletePage: (id: PageId) => Effect.Effect<void, ApiError | RateLimitError>
|
|
103
|
+
}
|
|
104
|
+
>() {}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Configuration for the Confluence client.
|
|
108
|
+
*
|
|
109
|
+
* @category Config
|
|
110
|
+
*/
|
|
111
|
+
export interface ConfluenceClientConfig {
|
|
112
|
+
readonly baseUrl: string
|
|
113
|
+
readonly auth: {
|
|
114
|
+
readonly type: "token"
|
|
115
|
+
readonly email: string
|
|
116
|
+
readonly token: string
|
|
117
|
+
} | {
|
|
118
|
+
readonly type: "oauth2"
|
|
119
|
+
readonly accessToken: string
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rate limit retry schedule with exponential backoff.
|
|
125
|
+
*/
|
|
126
|
+
const rateLimitSchedule = Schedule.exponential("1 second").pipe(
|
|
127
|
+
Schedule.union(Schedule.spaced("30 seconds")),
|
|
128
|
+
Schedule.whileInput<RateLimitError | ApiError>((error) => error._tag === "RateLimitError"),
|
|
129
|
+
Schedule.intersect(Schedule.recurs(3))
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create the Confluence client service.
|
|
134
|
+
*/
|
|
135
|
+
const make = (
|
|
136
|
+
config: ConfluenceClientConfig
|
|
137
|
+
): Effect.Effect<Context.Tag.Service<typeof ConfluenceClient>, never, HttpClient.HttpClient> =>
|
|
138
|
+
Effect.gen(function*() {
|
|
139
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
140
|
+
|
|
141
|
+
const authHeader = config.auth.type === "token"
|
|
142
|
+
? `Basic ${Buffer.from(`${config.auth.email}:${config.auth.token}`).toString("base64")}`
|
|
143
|
+
: `Bearer ${config.auth.accessToken}`
|
|
144
|
+
|
|
145
|
+
const baseRequest = HttpClientRequest.get(`${config.baseUrl}/wiki/api/v2`).pipe(
|
|
146
|
+
HttpClientRequest.setHeader("Authorization", authHeader),
|
|
147
|
+
HttpClientRequest.setHeader("Accept", "application/json"),
|
|
148
|
+
HttpClientRequest.setHeader("Content-Type", "application/json")
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Make an HTTP request to the Confluence API.
|
|
153
|
+
* Returns raw JSON - callers must validate with Schema.decodeUnknown.
|
|
154
|
+
*/
|
|
155
|
+
const request = (
|
|
156
|
+
method: "GET" | "POST" | "PUT" | "DELETE",
|
|
157
|
+
path: string,
|
|
158
|
+
body?: unknown
|
|
159
|
+
): Effect.Effect<unknown, ApiError | RateLimitError, never> =>
|
|
160
|
+
Effect.gen(function*() {
|
|
161
|
+
let req = baseRequest.pipe(
|
|
162
|
+
HttpClientRequest.setMethod(method),
|
|
163
|
+
HttpClientRequest.setUrl(`${config.baseUrl}/wiki/api/v2${path}`)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if (body !== undefined) {
|
|
167
|
+
req = HttpClientRequest.bodyJson(req, body).pipe(
|
|
168
|
+
Effect.catchAll(() => Effect.succeed(req)),
|
|
169
|
+
Effect.runSync
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const response = yield* httpClient.execute(req).pipe(
|
|
174
|
+
Effect.mapError((error) =>
|
|
175
|
+
new ApiError({
|
|
176
|
+
status: 0,
|
|
177
|
+
message: `Request failed: ${error.message}`,
|
|
178
|
+
endpoint: path
|
|
179
|
+
})
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if (response.status === 429) {
|
|
184
|
+
const retryAfterHeader = response.headers["retry-after"]
|
|
185
|
+
const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : undefined
|
|
186
|
+
return yield* Effect.fail(
|
|
187
|
+
retryAfter !== undefined
|
|
188
|
+
? new RateLimitError({ retryAfter })
|
|
189
|
+
: new RateLimitError({})
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (response.status >= 400) {
|
|
194
|
+
const text = yield* response.text.pipe(
|
|
195
|
+
Effect.catchAll(() => Effect.succeed(""))
|
|
196
|
+
)
|
|
197
|
+
return yield* Effect.fail(
|
|
198
|
+
new ApiError({
|
|
199
|
+
status: response.status,
|
|
200
|
+
message: text || `HTTP ${response.status}`,
|
|
201
|
+
endpoint: path
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (method === "DELETE" && response.status === 204) {
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const json = yield* response.json.pipe(
|
|
211
|
+
Effect.mapError((error) =>
|
|
212
|
+
new ApiError({
|
|
213
|
+
status: response.status,
|
|
214
|
+
message: `Failed to parse response: ${error}`,
|
|
215
|
+
endpoint: path
|
|
216
|
+
})
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return json
|
|
221
|
+
}).pipe(
|
|
222
|
+
Effect.retry(rateLimitSchedule)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
const getPage = (id: PageId): Effect.Effect<PageResponse, ApiError | RateLimitError> =>
|
|
226
|
+
Effect.gen(function*() {
|
|
227
|
+
const raw = yield* request(
|
|
228
|
+
"GET",
|
|
229
|
+
`/pages/${id}?body-format=storage`
|
|
230
|
+
)
|
|
231
|
+
return yield* Schema.decodeUnknown(PageResponseSchema)(raw).pipe(
|
|
232
|
+
Effect.mapError((error) =>
|
|
233
|
+
new ApiError({
|
|
234
|
+
status: 0,
|
|
235
|
+
message: `Invalid response schema: ${error.message}`,
|
|
236
|
+
endpoint: `/pages/${id}`,
|
|
237
|
+
pageId: id
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const getChildren = (id: PageId): Effect.Effect<PageChildrenResponse, ApiError | RateLimitError> =>
|
|
244
|
+
Effect.gen(function*() {
|
|
245
|
+
const raw = yield* request(
|
|
246
|
+
"GET",
|
|
247
|
+
`/pages/${id}/children?body-format=storage`
|
|
248
|
+
)
|
|
249
|
+
return yield* Schema.decodeUnknown(PageChildrenResponseSchema)(raw).pipe(
|
|
250
|
+
Effect.mapError((error) =>
|
|
251
|
+
new ApiError({
|
|
252
|
+
status: 0,
|
|
253
|
+
message: `Invalid response schema: ${error.message}`,
|
|
254
|
+
endpoint: `/pages/${id}/children`,
|
|
255
|
+
pageId: id
|
|
256
|
+
})
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const getAllChildren = (id: PageId): Effect.Effect<ReadonlyArray<PageListItem>, ApiError | RateLimitError> =>
|
|
262
|
+
Effect.gen(function*() {
|
|
263
|
+
const allChildren: Array<PageListItem> = []
|
|
264
|
+
let cursor: string | undefined
|
|
265
|
+
let iterations = 0
|
|
266
|
+
const maxIterations = 100 // Prevent unbounded pagination
|
|
267
|
+
|
|
268
|
+
do {
|
|
269
|
+
if (iterations >= maxIterations) {
|
|
270
|
+
return yield* Effect.fail(
|
|
271
|
+
new ApiError({
|
|
272
|
+
status: 0,
|
|
273
|
+
message: `Pagination limit exceeded: more than ${maxIterations} pages of children`,
|
|
274
|
+
endpoint: `/pages/${id}/children`,
|
|
275
|
+
pageId: id
|
|
276
|
+
})
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const path = cursor
|
|
281
|
+
? `/pages/${id}/children?body-format=storage&cursor=${cursor}`
|
|
282
|
+
: `/pages/${id}/children?body-format=storage`
|
|
283
|
+
|
|
284
|
+
const raw = yield* request("GET", path)
|
|
285
|
+
const response = yield* Schema.decodeUnknown(PageChildrenResponseSchema)(raw).pipe(
|
|
286
|
+
Effect.mapError((error) =>
|
|
287
|
+
new ApiError({
|
|
288
|
+
status: 0,
|
|
289
|
+
message: `Invalid response schema: ${error.message}`,
|
|
290
|
+
endpoint: path,
|
|
291
|
+
pageId: id
|
|
292
|
+
})
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
for (const child of response.results) {
|
|
297
|
+
allChildren.push(child)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Extract cursor from next link if present
|
|
301
|
+
cursor = response._links?.next
|
|
302
|
+
? new URL(response._links.next, config.baseUrl).searchParams.get("cursor") ?? undefined
|
|
303
|
+
: undefined
|
|
304
|
+
|
|
305
|
+
iterations++
|
|
306
|
+
} while (cursor)
|
|
307
|
+
|
|
308
|
+
return allChildren
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
const createPage = (req: CreatePageRequest): Effect.Effect<PageResponse, ApiError | RateLimitError> =>
|
|
312
|
+
Effect.gen(function*() {
|
|
313
|
+
const raw = yield* request("POST", "/pages", req)
|
|
314
|
+
return yield* Schema.decodeUnknown(PageResponseSchema)(raw).pipe(
|
|
315
|
+
Effect.mapError((error) =>
|
|
316
|
+
new ApiError({
|
|
317
|
+
status: 0,
|
|
318
|
+
message: `Invalid response schema: ${error.message}`,
|
|
319
|
+
endpoint: "/pages"
|
|
320
|
+
})
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
const updatePage = (req: UpdatePageRequest): Effect.Effect<PageResponse, ApiError | RateLimitError> =>
|
|
326
|
+
Effect.gen(function*() {
|
|
327
|
+
const raw = yield* request("PUT", `/pages/${req.id}`, req)
|
|
328
|
+
return yield* Schema.decodeUnknown(PageResponseSchema)(raw).pipe(
|
|
329
|
+
Effect.mapError((error) =>
|
|
330
|
+
new ApiError({
|
|
331
|
+
status: 0,
|
|
332
|
+
message: `Invalid response schema: ${error.message}`,
|
|
333
|
+
endpoint: `/pages/${req.id}`,
|
|
334
|
+
pageId: req.id
|
|
335
|
+
})
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
const deletePage = (id: PageId): Effect.Effect<void, ApiError | RateLimitError> =>
|
|
341
|
+
request("DELETE", `/pages/${id}`).pipe(Effect.asVoid)
|
|
342
|
+
|
|
343
|
+
return ConfluenceClient.of({
|
|
344
|
+
getPage,
|
|
345
|
+
getChildren,
|
|
346
|
+
getAllChildren,
|
|
347
|
+
createPage,
|
|
348
|
+
updatePage,
|
|
349
|
+
deletePage
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Layer that provides ConfluenceClient with direct configuration.
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* ```typescript
|
|
358
|
+
* import { ConfluenceClient } from "@knpkv/confluence-to-markdown/ConfluenceClient"
|
|
359
|
+
* import { NodeHttpClient } from "@effect/platform-node"
|
|
360
|
+
* import { Effect } from "effect"
|
|
361
|
+
*
|
|
362
|
+
* const program = Effect.gen(function* () {
|
|
363
|
+
* const client = yield* ConfluenceClient
|
|
364
|
+
* const page = yield* client.getPage("12345")
|
|
365
|
+
* console.log(page.title)
|
|
366
|
+
* })
|
|
367
|
+
*
|
|
368
|
+
* Effect.runPromise(
|
|
369
|
+
* program.pipe(
|
|
370
|
+
* Effect.provide(ConfluenceClient.layer({
|
|
371
|
+
* baseUrl: "https://yoursite.atlassian.net",
|
|
372
|
+
* auth: {
|
|
373
|
+
* type: "token",
|
|
374
|
+
* email: "you@example.com",
|
|
375
|
+
* token: process.env.CONFLUENCE_API_KEY
|
|
376
|
+
* }
|
|
377
|
+
* })),
|
|
378
|
+
* Effect.provide(NodeHttpClient.layer)
|
|
379
|
+
* )
|
|
380
|
+
* )
|
|
381
|
+
* ```
|
|
382
|
+
*
|
|
383
|
+
* @category Layers
|
|
384
|
+
*/
|
|
385
|
+
export const layer = (
|
|
386
|
+
config: ConfluenceClientConfig
|
|
387
|
+
): Layer.Layer<ConfluenceClient, never, HttpClient.HttpClient> => Layer.effect(ConfluenceClient, make(config))
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration service for Confluence sync.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import * as FileSystem from "@effect/platform/FileSystem"
|
|
7
|
+
import * as Path from "@effect/platform/Path"
|
|
8
|
+
import * as Context from "effect/Context"
|
|
9
|
+
import * as Effect from "effect/Effect"
|
|
10
|
+
import * as Layer from "effect/Layer"
|
|
11
|
+
import * as Schema from "effect/Schema"
|
|
12
|
+
import type { PageId } from "./Brand.js"
|
|
13
|
+
import { ConfigNotFoundError, ConfigParseError } from "./ConfluenceError.js"
|
|
14
|
+
import type { ConfluenceConfigFile } from "./Schemas.js"
|
|
15
|
+
import { ConfluenceConfigFileSchema } from "./Schemas.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Configuration service for Confluence operations.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { ConfluenceConfig } from "@knpkv/confluence-to-markdown/ConfluenceConfig"
|
|
23
|
+
* import { Effect } from "effect"
|
|
24
|
+
*
|
|
25
|
+
* const program = Effect.gen(function* () {
|
|
26
|
+
* const config = yield* ConfluenceConfig
|
|
27
|
+
* console.log(config.rootPageId)
|
|
28
|
+
* console.log(config.baseUrl)
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @category Config
|
|
33
|
+
*/
|
|
34
|
+
export class ConfluenceConfig extends Context.Tag(
|
|
35
|
+
"@knpkv/confluence-to-markdown/ConfluenceConfig"
|
|
36
|
+
)<
|
|
37
|
+
ConfluenceConfig,
|
|
38
|
+
{
|
|
39
|
+
/** Root page ID to sync from */
|
|
40
|
+
readonly rootPageId: PageId
|
|
41
|
+
/** Confluence Cloud base URL */
|
|
42
|
+
readonly baseUrl: string
|
|
43
|
+
/** Optional space key */
|
|
44
|
+
readonly spaceKey?: string
|
|
45
|
+
/** Local docs path */
|
|
46
|
+
readonly docsPath: string
|
|
47
|
+
/** Glob patterns to exclude */
|
|
48
|
+
readonly excludePatterns: ReadonlyArray<string>
|
|
49
|
+
}
|
|
50
|
+
>() {}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default config file name.
|
|
54
|
+
*/
|
|
55
|
+
const CONFIG_FILE_NAME = ".confluence.json"
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load configuration from a file.
|
|
59
|
+
*/
|
|
60
|
+
const loadConfig = (
|
|
61
|
+
configPath: string
|
|
62
|
+
): Effect.Effect<ConfluenceConfigFile, ConfigNotFoundError | ConfigParseError, FileSystem.FileSystem> =>
|
|
63
|
+
Effect.gen(function*() {
|
|
64
|
+
const fs = yield* FileSystem.FileSystem
|
|
65
|
+
|
|
66
|
+
const exists = yield* fs.exists(configPath).pipe(
|
|
67
|
+
Effect.catchAll(() => Effect.succeed(false))
|
|
68
|
+
)
|
|
69
|
+
if (!exists) {
|
|
70
|
+
return yield* Effect.fail(new ConfigNotFoundError({ path: configPath }))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const content = yield* fs.readFileString(configPath).pipe(
|
|
74
|
+
Effect.mapError((cause) => new ConfigParseError({ path: configPath, cause }))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const json = yield* Effect.try({
|
|
78
|
+
try: () => JSON.parse(content) as unknown,
|
|
79
|
+
catch: (cause) => new ConfigParseError({ path: configPath, cause })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return yield* Schema.decodeUnknown(ConfluenceConfigFileSchema)(json).pipe(
|
|
83
|
+
Effect.mapError((cause) => new ConfigParseError({ path: configPath, cause }))
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Layer that provides ConfluenceConfig from a config file.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* import { ConfluenceConfig } from "@knpkv/confluence-to-markdown/ConfluenceConfig"
|
|
93
|
+
* import { NodeFileSystem } from "@effect/platform-node"
|
|
94
|
+
* import { Effect } from "effect"
|
|
95
|
+
*
|
|
96
|
+
* const program = Effect.gen(function* () {
|
|
97
|
+
* const config = yield* ConfluenceConfig
|
|
98
|
+
* console.log(config.rootPageId)
|
|
99
|
+
* })
|
|
100
|
+
*
|
|
101
|
+
* Effect.runPromise(
|
|
102
|
+
* program.pipe(
|
|
103
|
+
* Effect.provide(ConfluenceConfig.layer()),
|
|
104
|
+
* Effect.provide(NodeFileSystem.layer)
|
|
105
|
+
* )
|
|
106
|
+
* )
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* @category Layers
|
|
110
|
+
*/
|
|
111
|
+
export const layer = (
|
|
112
|
+
configPath?: string
|
|
113
|
+
): Layer.Layer<ConfluenceConfig, ConfigNotFoundError | ConfigParseError, FileSystem.FileSystem | Path.Path> =>
|
|
114
|
+
Layer.effect(
|
|
115
|
+
ConfluenceConfig,
|
|
116
|
+
Effect.gen(function*() {
|
|
117
|
+
const path = yield* Path.Path
|
|
118
|
+
const resolvedPath = configPath ?? path.join(process.cwd(), CONFIG_FILE_NAME)
|
|
119
|
+
const config = yield* loadConfig(resolvedPath)
|
|
120
|
+
|
|
121
|
+
return ConfluenceConfig.of({
|
|
122
|
+
rootPageId: config.rootPageId,
|
|
123
|
+
baseUrl: config.baseUrl,
|
|
124
|
+
...(config.spaceKey !== undefined ? { spaceKey: config.spaceKey } : {}),
|
|
125
|
+
docsPath: config.docsPath,
|
|
126
|
+
excludePatterns: config.excludePatterns
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Layer that provides ConfluenceConfig with direct values.
|
|
133
|
+
*
|
|
134
|
+
* @category Layers
|
|
135
|
+
*/
|
|
136
|
+
export const layerFromValues = (
|
|
137
|
+
config: ConfluenceConfigFile
|
|
138
|
+
): Layer.Layer<ConfluenceConfig> =>
|
|
139
|
+
Layer.succeed(
|
|
140
|
+
ConfluenceConfig,
|
|
141
|
+
ConfluenceConfig.of({
|
|
142
|
+
rootPageId: config.rootPageId,
|
|
143
|
+
baseUrl: config.baseUrl,
|
|
144
|
+
...(config.spaceKey !== undefined ? { spaceKey: config.spaceKey } : {}),
|
|
145
|
+
docsPath: config.docsPath,
|
|
146
|
+
excludePatterns: config.excludePatterns
|
|
147
|
+
})
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Create a new config file.
|
|
152
|
+
*
|
|
153
|
+
* @category Utilities
|
|
154
|
+
*/
|
|
155
|
+
export const createConfigFile = (
|
|
156
|
+
rootPageId: string,
|
|
157
|
+
baseUrl: string,
|
|
158
|
+
configPath?: string
|
|
159
|
+
): Effect.Effect<string, ConfigParseError, FileSystem.FileSystem | Path.Path> =>
|
|
160
|
+
Effect.gen(function*() {
|
|
161
|
+
const fs = yield* FileSystem.FileSystem
|
|
162
|
+
const pathService = yield* Path.Path
|
|
163
|
+
|
|
164
|
+
const resolvedPath = configPath ?? pathService.join(process.cwd(), CONFIG_FILE_NAME)
|
|
165
|
+
|
|
166
|
+
const config: ConfluenceConfigFile = {
|
|
167
|
+
rootPageId: rootPageId as PageId,
|
|
168
|
+
baseUrl,
|
|
169
|
+
docsPath: ".docs/confluence",
|
|
170
|
+
excludePatterns: []
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Validate the config
|
|
174
|
+
yield* Schema.decodeUnknown(ConfluenceConfigFileSchema)(config).pipe(
|
|
175
|
+
Effect.mapError((cause) => new ConfigParseError({ path: resolvedPath, cause }))
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const content = JSON.stringify(config, null, 2)
|
|
179
|
+
yield* fs.writeFileString(resolvedPath, content).pipe(
|
|
180
|
+
Effect.mapError((cause) => new ConfigParseError({ path: resolvedPath, cause }))
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return resolvedPath
|
|
184
|
+
})
|