@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
package/src/Schemas.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema definitions for configuration and data structures.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import * as Schema from "effect/Schema"
|
|
7
|
+
import { ContentHashSchema, PageIdSchema, SpaceKeySchema } from "./Brand.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Schema for .confluence.json configuration file.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* import { ConfluenceConfigFileSchema } from "@knpkv/confluence-to-markdown/Schemas"
|
|
15
|
+
* import * as Schema from "effect/Schema"
|
|
16
|
+
*
|
|
17
|
+
* const config = Schema.decodeUnknownSync(ConfluenceConfigFileSchema)({
|
|
18
|
+
* rootPageId: "12345",
|
|
19
|
+
* baseUrl: "https://mysite.atlassian.net"
|
|
20
|
+
* })
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @category Schema
|
|
24
|
+
*/
|
|
25
|
+
export const ConfluenceConfigFileSchema = Schema.Struct({
|
|
26
|
+
/** Root page ID to sync from */
|
|
27
|
+
rootPageId: PageIdSchema,
|
|
28
|
+
/** Confluence Cloud base URL */
|
|
29
|
+
baseUrl: Schema.String.pipe(
|
|
30
|
+
Schema.pattern(/^https:\/\/[a-z0-9-]+\.atlassian\.net$/)
|
|
31
|
+
),
|
|
32
|
+
/** Optional space key */
|
|
33
|
+
spaceKey: Schema.optional(SpaceKeySchema),
|
|
34
|
+
/** Local docs path (default: .docs/confluence) */
|
|
35
|
+
docsPath: Schema.optionalWith(Schema.String, { default: () => ".docs/confluence" }),
|
|
36
|
+
/** Glob patterns to exclude from sync */
|
|
37
|
+
excludePatterns: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Type for .confluence.json configuration file.
|
|
42
|
+
*
|
|
43
|
+
* @category Types
|
|
44
|
+
*/
|
|
45
|
+
export type ConfluenceConfigFile = Schema.Schema.Type<typeof ConfluenceConfigFileSchema>
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Schema for page front-matter in markdown files.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* import { PageFrontMatterSchema } from "@knpkv/confluence-to-markdown/Schemas"
|
|
53
|
+
* import * as Schema from "effect/Schema"
|
|
54
|
+
*
|
|
55
|
+
* const frontMatter = Schema.decodeUnknownSync(PageFrontMatterSchema)({
|
|
56
|
+
* pageId: "12345",
|
|
57
|
+
* version: 3,
|
|
58
|
+
* title: "My Page",
|
|
59
|
+
* updated: "2025-01-15T10:30:00Z",
|
|
60
|
+
* contentHash: "a".repeat(64)
|
|
61
|
+
* })
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @category Schema
|
|
65
|
+
*/
|
|
66
|
+
export const PageFrontMatterSchema = Schema.Struct({
|
|
67
|
+
/** Confluence page ID */
|
|
68
|
+
pageId: PageIdSchema,
|
|
69
|
+
/** Page version number */
|
|
70
|
+
version: Schema.Number.pipe(Schema.int(), Schema.positive()),
|
|
71
|
+
/** Page title */
|
|
72
|
+
title: Schema.String.pipe(Schema.nonEmptyString()),
|
|
73
|
+
/** Last updated timestamp (ISO8601) */
|
|
74
|
+
updated: Schema.DateFromString,
|
|
75
|
+
/** Parent page ID (optional) */
|
|
76
|
+
parentId: Schema.optional(PageIdSchema),
|
|
77
|
+
/** Position among siblings (optional) */
|
|
78
|
+
position: Schema.optional(Schema.Number),
|
|
79
|
+
/** SHA256 hash of content for change detection */
|
|
80
|
+
contentHash: ContentHashSchema
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Type for page front-matter.
|
|
85
|
+
*
|
|
86
|
+
* @category Types
|
|
87
|
+
*/
|
|
88
|
+
export type PageFrontMatter = Schema.Schema.Type<typeof PageFrontMatterSchema>
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Schema for new page front-matter (no pageId yet).
|
|
92
|
+
*
|
|
93
|
+
* @category Schema
|
|
94
|
+
*/
|
|
95
|
+
export const NewPageFrontMatterSchema = Schema.Struct({
|
|
96
|
+
/** Page title */
|
|
97
|
+
title: Schema.String.pipe(Schema.nonEmptyString()),
|
|
98
|
+
/** Parent page ID (optional, determined by directory structure) */
|
|
99
|
+
parentId: Schema.optional(PageIdSchema)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Type for new page front-matter.
|
|
104
|
+
*
|
|
105
|
+
* @category Types
|
|
106
|
+
*/
|
|
107
|
+
export type NewPageFrontMatter = Schema.Schema.Type<typeof NewPageFrontMatterSchema>
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Schema for sync state file (.sync-state.json).
|
|
111
|
+
*
|
|
112
|
+
* @category Schema
|
|
113
|
+
*/
|
|
114
|
+
export const SyncStateSchema = Schema.Struct({
|
|
115
|
+
/** Last sync timestamp */
|
|
116
|
+
lastSync: Schema.DateFromString,
|
|
117
|
+
/** Map of page ID to sync info */
|
|
118
|
+
pages: Schema.Record({
|
|
119
|
+
key: Schema.String,
|
|
120
|
+
value: Schema.Struct({
|
|
121
|
+
/** Local file path */
|
|
122
|
+
localPath: Schema.String,
|
|
123
|
+
/** Last synced version */
|
|
124
|
+
version: Schema.Number,
|
|
125
|
+
/** Content hash at last sync */
|
|
126
|
+
contentHash: ContentHashSchema
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Type for sync state.
|
|
133
|
+
*
|
|
134
|
+
* @category Types
|
|
135
|
+
*/
|
|
136
|
+
export type SyncState = Schema.Schema.Type<typeof SyncStateSchema>
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Schema for Confluence page API response (full, from getPage).
|
|
140
|
+
*
|
|
141
|
+
* @category Schema
|
|
142
|
+
*/
|
|
143
|
+
export const PageResponseSchema = Schema.Struct({
|
|
144
|
+
id: Schema.String,
|
|
145
|
+
title: Schema.String,
|
|
146
|
+
status: Schema.optional(Schema.String),
|
|
147
|
+
version: Schema.Struct({
|
|
148
|
+
number: Schema.Number,
|
|
149
|
+
createdAt: Schema.optional(Schema.String)
|
|
150
|
+
}),
|
|
151
|
+
body: Schema.optional(
|
|
152
|
+
Schema.Struct({
|
|
153
|
+
storage: Schema.optional(
|
|
154
|
+
Schema.Struct({
|
|
155
|
+
value: Schema.String,
|
|
156
|
+
representation: Schema.optional(Schema.String)
|
|
157
|
+
})
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
),
|
|
161
|
+
parentId: Schema.optional(Schema.String),
|
|
162
|
+
position: Schema.optional(Schema.Number),
|
|
163
|
+
_links: Schema.optional(
|
|
164
|
+
Schema.Struct({
|
|
165
|
+
webui: Schema.optional(Schema.String)
|
|
166
|
+
})
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Type for page API response.
|
|
172
|
+
*
|
|
173
|
+
* @category Types
|
|
174
|
+
*/
|
|
175
|
+
export type PageResponse = Schema.Schema.Type<typeof PageResponseSchema>
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Schema for page list item (children list, version optional).
|
|
179
|
+
*
|
|
180
|
+
* @category Schema
|
|
181
|
+
*/
|
|
182
|
+
export const PageListItemSchema = Schema.Struct({
|
|
183
|
+
id: Schema.String,
|
|
184
|
+
title: Schema.String,
|
|
185
|
+
status: Schema.optional(Schema.String),
|
|
186
|
+
version: Schema.optional(Schema.Struct({
|
|
187
|
+
number: Schema.Number,
|
|
188
|
+
createdAt: Schema.optional(Schema.String)
|
|
189
|
+
})),
|
|
190
|
+
body: Schema.optional(
|
|
191
|
+
Schema.Struct({
|
|
192
|
+
storage: Schema.optional(
|
|
193
|
+
Schema.Struct({
|
|
194
|
+
value: Schema.String,
|
|
195
|
+
representation: Schema.optional(Schema.String)
|
|
196
|
+
})
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
),
|
|
200
|
+
parentId: Schema.optional(Schema.String),
|
|
201
|
+
position: Schema.optional(Schema.Number),
|
|
202
|
+
_links: Schema.optional(
|
|
203
|
+
Schema.Struct({
|
|
204
|
+
webui: Schema.optional(Schema.String)
|
|
205
|
+
})
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Type for page list item.
|
|
211
|
+
*
|
|
212
|
+
* @category Types
|
|
213
|
+
*/
|
|
214
|
+
export type PageListItem = Schema.Schema.Type<typeof PageListItemSchema>
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Schema for page children API response.
|
|
218
|
+
*
|
|
219
|
+
* @category Schema
|
|
220
|
+
*/
|
|
221
|
+
export const PageChildrenResponseSchema = Schema.Struct({
|
|
222
|
+
results: Schema.Array(PageListItemSchema),
|
|
223
|
+
_links: Schema.optional(
|
|
224
|
+
Schema.Struct({
|
|
225
|
+
next: Schema.optional(Schema.String)
|
|
226
|
+
})
|
|
227
|
+
)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Type for page children response.
|
|
232
|
+
*
|
|
233
|
+
* @category Types
|
|
234
|
+
*/
|
|
235
|
+
export type PageChildrenResponse = Schema.Schema.Type<typeof PageChildrenResponseSchema>
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync engine for bidirectional Confluence <-> Markdown synchronization.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import * as Path from "@effect/platform/Path"
|
|
7
|
+
import * as Context from "effect/Context"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as Layer from "effect/Layer"
|
|
10
|
+
import type { PageId } from "./Brand.js"
|
|
11
|
+
import { ConfluenceClient } from "./ConfluenceClient.js"
|
|
12
|
+
import { ConfluenceConfig } from "./ConfluenceConfig.js"
|
|
13
|
+
import type {
|
|
14
|
+
ApiError,
|
|
15
|
+
ConflictError,
|
|
16
|
+
ConversionError,
|
|
17
|
+
FileSystemError,
|
|
18
|
+
FrontMatterError,
|
|
19
|
+
RateLimitError
|
|
20
|
+
} from "./ConfluenceError.js"
|
|
21
|
+
import { computeHash } from "./internal/hashUtils.js"
|
|
22
|
+
import { LocalFileSystem } from "./LocalFileSystem.js"
|
|
23
|
+
import { MarkdownConverter } from "./MarkdownConverter.js"
|
|
24
|
+
import type { PageFrontMatter, PageListItem, PageResponse } from "./Schemas.js"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sync status for a single page.
|
|
28
|
+
*/
|
|
29
|
+
export type SyncStatus =
|
|
30
|
+
| { readonly _tag: "Synced"; readonly path: string }
|
|
31
|
+
| { readonly _tag: "LocalOnly"; readonly path: string; readonly title: string }
|
|
32
|
+
| { readonly _tag: "RemoteOnly"; readonly page: PageResponse }
|
|
33
|
+
| { readonly _tag: "LocalModified"; readonly path: string; readonly page: PageResponse }
|
|
34
|
+
| { readonly _tag: "RemoteModified"; readonly path: string; readonly page: PageResponse }
|
|
35
|
+
| {
|
|
36
|
+
readonly _tag: "Conflict"
|
|
37
|
+
readonly path: string
|
|
38
|
+
readonly page: PageResponse
|
|
39
|
+
readonly localVersion: number
|
|
40
|
+
readonly remoteVersion: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Result of a pull operation.
|
|
45
|
+
*/
|
|
46
|
+
export interface PullResult {
|
|
47
|
+
readonly pulled: number
|
|
48
|
+
readonly skipped: number
|
|
49
|
+
readonly errors: ReadonlyArray<string>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Result of a push operation.
|
|
54
|
+
*/
|
|
55
|
+
export interface PushResult {
|
|
56
|
+
readonly pushed: number
|
|
57
|
+
readonly created: number
|
|
58
|
+
readonly skipped: number
|
|
59
|
+
readonly errors: ReadonlyArray<string>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Result of a sync operation.
|
|
64
|
+
*/
|
|
65
|
+
export interface SyncResult {
|
|
66
|
+
readonly pulled: number
|
|
67
|
+
readonly pushed: number
|
|
68
|
+
readonly created: number
|
|
69
|
+
readonly conflicts: number
|
|
70
|
+
readonly errors: ReadonlyArray<string>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Result of a status operation.
|
|
75
|
+
*/
|
|
76
|
+
export interface StatusResult {
|
|
77
|
+
readonly synced: number
|
|
78
|
+
readonly localModified: number
|
|
79
|
+
readonly remoteModified: number
|
|
80
|
+
readonly conflicts: number
|
|
81
|
+
readonly localOnly: number
|
|
82
|
+
readonly remoteOnly: number
|
|
83
|
+
readonly files: ReadonlyArray<SyncStatus>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type SyncError = ApiError | RateLimitError | ConversionError | FileSystemError | FrontMatterError
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sync engine service for Confluence <-> Markdown operations.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* import { SyncEngine } from "@knpkv/confluence-to-markdown/SyncEngine"
|
|
94
|
+
* import { Effect } from "effect"
|
|
95
|
+
*
|
|
96
|
+
* const program = Effect.gen(function* () {
|
|
97
|
+
* const engine = yield* SyncEngine
|
|
98
|
+
* const result = yield* engine.pull({ force: false })
|
|
99
|
+
* console.log(`Pulled ${result.pulled} pages`)
|
|
100
|
+
* })
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* @category Sync
|
|
104
|
+
*/
|
|
105
|
+
export class SyncEngine extends Context.Tag(
|
|
106
|
+
"@knpkv/confluence-to-markdown/SyncEngine"
|
|
107
|
+
)<
|
|
108
|
+
SyncEngine,
|
|
109
|
+
{
|
|
110
|
+
/**
|
|
111
|
+
* Pull pages from Confluence to local markdown.
|
|
112
|
+
*/
|
|
113
|
+
readonly pull: (options: { force: boolean }) => Effect.Effect<PullResult, SyncError>
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Push local markdown changes to Confluence.
|
|
117
|
+
*/
|
|
118
|
+
readonly push: (options: { dryRun: boolean }) => Effect.Effect<PushResult, SyncError>
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Bidirectional sync with conflict detection.
|
|
122
|
+
*/
|
|
123
|
+
readonly sync: () => Effect.Effect<SyncResult, SyncError | ConflictError>
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get sync status for all files.
|
|
127
|
+
*/
|
|
128
|
+
readonly status: () => Effect.Effect<StatusResult, SyncError>
|
|
129
|
+
}
|
|
130
|
+
>() {}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Layer that provides SyncEngine.
|
|
134
|
+
*
|
|
135
|
+
* @category Layers
|
|
136
|
+
*/
|
|
137
|
+
export const layer: Layer.Layer<
|
|
138
|
+
SyncEngine,
|
|
139
|
+
never,
|
|
140
|
+
ConfluenceClient | ConfluenceConfig | MarkdownConverter | LocalFileSystem | Path.Path
|
|
141
|
+
> = Layer.effect(
|
|
142
|
+
SyncEngine,
|
|
143
|
+
Effect.gen(function*() {
|
|
144
|
+
const client = yield* ConfluenceClient
|
|
145
|
+
const config = yield* ConfluenceConfig
|
|
146
|
+
const converter = yield* MarkdownConverter
|
|
147
|
+
const localFs = yield* LocalFileSystem
|
|
148
|
+
const pathService = yield* Path.Path
|
|
149
|
+
|
|
150
|
+
const docsPath = pathService.join(process.cwd(), config.docsPath)
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Pull a single page and its children recursively.
|
|
154
|
+
*/
|
|
155
|
+
const pullPage = (
|
|
156
|
+
page: PageListItem | PageResponse,
|
|
157
|
+
parentPath: string,
|
|
158
|
+
force: boolean
|
|
159
|
+
): Effect.Effect<number, SyncError> =>
|
|
160
|
+
Effect.gen(function*() {
|
|
161
|
+
// Get children to determine if this is a folder
|
|
162
|
+
const children = yield* client.getAllChildren(page.id as PageId)
|
|
163
|
+
const hasChildren = children.length > 0
|
|
164
|
+
|
|
165
|
+
const filePath = localFs.getPagePath(page.title, hasChildren, parentPath)
|
|
166
|
+
const dirPath = hasChildren ? localFs.getPageDir(page.title, parentPath) : parentPath
|
|
167
|
+
|
|
168
|
+
// Get page content
|
|
169
|
+
const fullPage = yield* client.getPage(page.id as PageId)
|
|
170
|
+
const htmlContent = fullPage.body?.storage?.value ?? ""
|
|
171
|
+
let markdown = yield* converter.htmlToMarkdown(htmlContent)
|
|
172
|
+
|
|
173
|
+
// Add child page links for index pages
|
|
174
|
+
if (hasChildren && config.spaceKey) {
|
|
175
|
+
const childLinks = children
|
|
176
|
+
.map((child) => {
|
|
177
|
+
const pageUrl = `${config.baseUrl}/wiki/spaces/${config.spaceKey}/pages/${child.id}`
|
|
178
|
+
return `- [${child.title}](${pageUrl})`
|
|
179
|
+
})
|
|
180
|
+
.join("\n")
|
|
181
|
+
markdown = markdown.trim() + "\n\n## Child Pages\n\n" + childLinks + "\n"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const contentHash = computeHash(markdown)
|
|
185
|
+
|
|
186
|
+
// Check if we need to update
|
|
187
|
+
if (!force) {
|
|
188
|
+
const exists = yield* localFs.exists(filePath)
|
|
189
|
+
if (exists) {
|
|
190
|
+
const localFile = yield* localFs.readMarkdownFile(filePath)
|
|
191
|
+
if (
|
|
192
|
+
localFile.frontMatter &&
|
|
193
|
+
localFile.frontMatter.version === fullPage.version.number &&
|
|
194
|
+
localFile.frontMatter.contentHash === contentHash
|
|
195
|
+
) {
|
|
196
|
+
// Skip - already in sync
|
|
197
|
+
let count = 0
|
|
198
|
+
for (const child of children) {
|
|
199
|
+
count += yield* pullPage(child, dirPath, force)
|
|
200
|
+
}
|
|
201
|
+
return count
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Ensure directory exists
|
|
207
|
+
if (hasChildren) {
|
|
208
|
+
yield* localFs.ensureDir(dirPath)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Write file
|
|
212
|
+
const frontMatter: PageFrontMatter = {
|
|
213
|
+
pageId: page.id as PageId,
|
|
214
|
+
version: fullPage.version.number,
|
|
215
|
+
title: fullPage.title,
|
|
216
|
+
updated: fullPage.version.createdAt ? new Date(fullPage.version.createdAt) : new Date(),
|
|
217
|
+
...(page.parentId ? { parentId: page.parentId as PageId } : {}),
|
|
218
|
+
...(page.position !== undefined ? { position: page.position } : {}),
|
|
219
|
+
contentHash
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
yield* localFs.writeMarkdownFile(filePath, frontMatter, markdown)
|
|
223
|
+
|
|
224
|
+
// Pull children
|
|
225
|
+
let count = 1
|
|
226
|
+
for (const child of children) {
|
|
227
|
+
count += yield* pullPage(child, dirPath, force)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return count
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const pull = (options: { force: boolean }): Effect.Effect<PullResult, SyncError> =>
|
|
234
|
+
Effect.gen(function*() {
|
|
235
|
+
yield* localFs.ensureDir(docsPath)
|
|
236
|
+
|
|
237
|
+
const rootPage = yield* client.getPage(config.rootPageId)
|
|
238
|
+
const pulled = yield* pullPage(rootPage, docsPath, options.force)
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
pulled,
|
|
242
|
+
skipped: 0,
|
|
243
|
+
errors: [] as ReadonlyArray<string>
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const push = (options: { dryRun: boolean }): Effect.Effect<PushResult, SyncError> =>
|
|
248
|
+
Effect.gen(function*() {
|
|
249
|
+
const files = yield* localFs.listMarkdownFiles(docsPath)
|
|
250
|
+
let pushed = 0
|
|
251
|
+
let created = 0
|
|
252
|
+
let skipped = 0
|
|
253
|
+
const errors: Array<string> = []
|
|
254
|
+
|
|
255
|
+
for (const filePath of files) {
|
|
256
|
+
yield* Effect.gen(function*() {
|
|
257
|
+
const localFile = yield* localFs.readMarkdownFile(filePath)
|
|
258
|
+
|
|
259
|
+
if (localFile.isNew || !localFile.frontMatter) {
|
|
260
|
+
// New file - create page
|
|
261
|
+
if (!options.dryRun) {
|
|
262
|
+
// For now, skip page creation - need space ID in config
|
|
263
|
+
errors.push(
|
|
264
|
+
`Page creation requires space ID in config (not yet supported): ${filePath}`
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
created++
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const fm = localFile.frontMatter
|
|
272
|
+
const currentHash = computeHash(localFile.content)
|
|
273
|
+
|
|
274
|
+
if (currentHash === fm.contentHash) {
|
|
275
|
+
skipped++
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!options.dryRun) {
|
|
280
|
+
// Fetch current version to avoid conflicts
|
|
281
|
+
const remotePage = yield* client.getPage(fm.pageId)
|
|
282
|
+
const html = yield* converter.markdownToHtml(localFile.content)
|
|
283
|
+
const updatedPage = yield* client.updatePage({
|
|
284
|
+
id: fm.pageId,
|
|
285
|
+
title: fm.title,
|
|
286
|
+
status: "current",
|
|
287
|
+
version: {
|
|
288
|
+
number: remotePage.version.number + 1,
|
|
289
|
+
message: "Updated via confluence-to-markdown"
|
|
290
|
+
},
|
|
291
|
+
body: {
|
|
292
|
+
representation: "storage",
|
|
293
|
+
value: html
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// Update front-matter with new version
|
|
298
|
+
const newFrontMatter: PageFrontMatter = {
|
|
299
|
+
...fm,
|
|
300
|
+
version: updatedPage.version.number,
|
|
301
|
+
updated: new Date(),
|
|
302
|
+
contentHash: currentHash
|
|
303
|
+
}
|
|
304
|
+
yield* localFs.writeMarkdownFile(filePath, newFrontMatter, localFile.content)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
pushed++
|
|
308
|
+
}).pipe(
|
|
309
|
+
Effect.catchAll((error) =>
|
|
310
|
+
Effect.sync(() => {
|
|
311
|
+
errors.push(
|
|
312
|
+
`Failed to push ${filePath}: ${error._tag === "ApiError" ? error.message : error._tag}`
|
|
313
|
+
)
|
|
314
|
+
})
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { pushed, created, skipped, errors: errors as ReadonlyArray<string> }
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
const sync = (): Effect.Effect<SyncResult, SyncError | ConflictError> =>
|
|
323
|
+
Effect.gen(function*() {
|
|
324
|
+
// First, check for conflicts
|
|
325
|
+
const statusResult = yield* status()
|
|
326
|
+
const conflictErrors: Array<string> = []
|
|
327
|
+
|
|
328
|
+
if (statusResult.conflicts > 0) {
|
|
329
|
+
for (const file of statusResult.files) {
|
|
330
|
+
if (file._tag === "Conflict") {
|
|
331
|
+
conflictErrors.push(
|
|
332
|
+
`Conflict in ${file.path}: local v${file.localVersion} vs remote v${file.remoteVersion}`
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Pull remote changes
|
|
339
|
+
const pullResult = yield* pull({ force: false })
|
|
340
|
+
|
|
341
|
+
// Push local changes
|
|
342
|
+
const pushResult = yield* push({ dryRun: false })
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
pulled: pullResult.pulled,
|
|
346
|
+
pushed: pushResult.pushed,
|
|
347
|
+
created: pushResult.created,
|
|
348
|
+
conflicts: statusResult.conflicts,
|
|
349
|
+
errors: [...conflictErrors, ...pullResult.errors, ...pushResult.errors] as ReadonlyArray<string>
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const status = (): Effect.Effect<StatusResult, SyncError> =>
|
|
354
|
+
Effect.gen(function*() {
|
|
355
|
+
const files = yield* localFs.listMarkdownFiles(docsPath)
|
|
356
|
+
const statuses: Array<SyncStatus> = []
|
|
357
|
+
|
|
358
|
+
let synced = 0
|
|
359
|
+
let localModified = 0
|
|
360
|
+
let remoteModified = 0
|
|
361
|
+
let conflicts = 0
|
|
362
|
+
let localOnly = 0
|
|
363
|
+
const remoteOnly = 0
|
|
364
|
+
|
|
365
|
+
for (const filePath of files) {
|
|
366
|
+
const localFile = yield* localFs.readMarkdownFile(filePath)
|
|
367
|
+
|
|
368
|
+
if (localFile.isNew || !localFile.frontMatter) {
|
|
369
|
+
statuses.push({ _tag: "LocalOnly", path: filePath, title: pathService.basename(filePath, ".md") })
|
|
370
|
+
localOnly++
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const fm = localFile.frontMatter
|
|
375
|
+
const currentHash = computeHash(localFile.content)
|
|
376
|
+
|
|
377
|
+
// Fetch remote page
|
|
378
|
+
const remotePage = yield* Effect.either(client.getPage(fm.pageId))
|
|
379
|
+
|
|
380
|
+
if (remotePage._tag === "Left") {
|
|
381
|
+
statuses.push({ _tag: "LocalOnly", path: filePath, title: fm.title })
|
|
382
|
+
localOnly++
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const page = remotePage.right
|
|
387
|
+
const localChanged = currentHash !== fm.contentHash
|
|
388
|
+
const remoteChanged = page.version.number > fm.version
|
|
389
|
+
|
|
390
|
+
if (localChanged && remoteChanged) {
|
|
391
|
+
statuses.push({
|
|
392
|
+
_tag: "Conflict",
|
|
393
|
+
path: filePath,
|
|
394
|
+
page,
|
|
395
|
+
localVersion: fm.version,
|
|
396
|
+
remoteVersion: page.version.number
|
|
397
|
+
})
|
|
398
|
+
conflicts++
|
|
399
|
+
} else if (localChanged) {
|
|
400
|
+
statuses.push({ _tag: "LocalModified", path: filePath, page })
|
|
401
|
+
localModified++
|
|
402
|
+
} else if (remoteChanged) {
|
|
403
|
+
statuses.push({ _tag: "RemoteModified", path: filePath, page })
|
|
404
|
+
remoteModified++
|
|
405
|
+
} else {
|
|
406
|
+
statuses.push({ _tag: "Synced", path: filePath })
|
|
407
|
+
synced++
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
synced,
|
|
413
|
+
localModified,
|
|
414
|
+
remoteModified,
|
|
415
|
+
conflicts,
|
|
416
|
+
localOnly,
|
|
417
|
+
remoteOnly,
|
|
418
|
+
files: statuses as ReadonlyArray<SyncStatus>
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
return SyncEngine.of({
|
|
423
|
+
pull,
|
|
424
|
+
push,
|
|
425
|
+
sync,
|
|
426
|
+
status
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
)
|