@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +62 -0
  3. package/dist/Brand.d.ts +77 -0
  4. package/dist/Brand.d.ts.map +1 -0
  5. package/dist/Brand.js +44 -0
  6. package/dist/Brand.js.map +1 -0
  7. package/dist/ConfluenceClient.d.ts +140 -0
  8. package/dist/ConfluenceClient.d.ts.map +1 -0
  9. package/dist/ConfluenceClient.js +195 -0
  10. package/dist/ConfluenceClient.js.map +1 -0
  11. package/dist/ConfluenceConfig.d.ts +83 -0
  12. package/dist/ConfluenceConfig.d.ts.map +1 -0
  13. package/dist/ConfluenceConfig.js +122 -0
  14. package/dist/ConfluenceConfig.js.map +1 -0
  15. package/dist/ConfluenceError.d.ts +178 -0
  16. package/dist/ConfluenceError.d.ts.map +1 -0
  17. package/dist/ConfluenceError.js +131 -0
  18. package/dist/ConfluenceError.js.map +1 -0
  19. package/dist/LocalFileSystem.d.ts +85 -0
  20. package/dist/LocalFileSystem.d.ts.map +1 -0
  21. package/dist/LocalFileSystem.js +101 -0
  22. package/dist/LocalFileSystem.js.map +1 -0
  23. package/dist/MarkdownConverter.d.ts +50 -0
  24. package/dist/MarkdownConverter.d.ts.map +1 -0
  25. package/dist/MarkdownConverter.js +151 -0
  26. package/dist/MarkdownConverter.js.map +1 -0
  27. package/dist/Schemas.d.ts +225 -0
  28. package/dist/Schemas.d.ts.map +1 -0
  29. package/dist/Schemas.js +164 -0
  30. package/dist/Schemas.js.map +1 -0
  31. package/dist/SyncEngine.d.ts +132 -0
  32. package/dist/SyncEngine.d.ts.map +1 -0
  33. package/dist/SyncEngine.js +267 -0
  34. package/dist/SyncEngine.js.map +1 -0
  35. package/dist/bin.d.ts +3 -0
  36. package/dist/bin.d.ts.map +1 -0
  37. package/dist/bin.js +163 -0
  38. package/dist/bin.js.map +1 -0
  39. package/dist/index.d.ts +16 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +16 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/frontmatter.d.ts +38 -0
  44. package/dist/internal/frontmatter.d.ts.map +1 -0
  45. package/dist/internal/frontmatter.js +69 -0
  46. package/dist/internal/frontmatter.js.map +1 -0
  47. package/dist/internal/hashUtils.d.ts +11 -0
  48. package/dist/internal/hashUtils.d.ts.map +1 -0
  49. package/dist/internal/hashUtils.js +17 -0
  50. package/dist/internal/hashUtils.js.map +1 -0
  51. package/dist/internal/pathUtils.d.ts +41 -0
  52. package/dist/internal/pathUtils.d.ts.map +1 -0
  53. package/dist/internal/pathUtils.js +69 -0
  54. package/dist/internal/pathUtils.js.map +1 -0
  55. package/package.json +113 -0
  56. package/src/Brand.ts +104 -0
  57. package/src/ConfluenceClient.ts +387 -0
  58. package/src/ConfluenceConfig.ts +184 -0
  59. package/src/ConfluenceError.ts +193 -0
  60. package/src/LocalFileSystem.ts +225 -0
  61. package/src/MarkdownConverter.ts +187 -0
  62. package/src/Schemas.ts +235 -0
  63. package/src/SyncEngine.ts +429 -0
  64. package/src/bin.ts +269 -0
  65. package/src/index.ts +35 -0
  66. package/src/internal/frontmatter.ts +98 -0
  67. package/src/internal/hashUtils.ts +19 -0
  68. package/src/internal/pathUtils.ts +77 -0
  69. package/test/Brand.test.ts +72 -0
  70. package/test/MarkdownConverter.test.ts +108 -0
  71. package/test/Schemas.test.ts +99 -0
  72. package/tsconfig.json +32 -0
  73. package/vitest.config.integration.ts +12 -0
  74. 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
+ )