@knpkv/confluence-to-markdown 0.2.0 → 0.4.1

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 (336) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +282 -14
  4. package/dist/ConfluenceAuth.d.ts +76 -0
  5. package/dist/ConfluenceAuth.d.ts.map +1 -0
  6. package/dist/ConfluenceAuth.js +356 -0
  7. package/dist/ConfluenceAuth.js.map +1 -0
  8. package/dist/ConfluenceClient.d.ts +26 -2
  9. package/dist/ConfluenceClient.d.ts.map +1 -1
  10. package/dist/ConfluenceClient.js +98 -92
  11. package/dist/ConfluenceClient.js.map +1 -1
  12. package/dist/ConfluenceConfig.d.ts +4 -24
  13. package/dist/ConfluenceConfig.d.ts.map +1 -1
  14. package/dist/ConfluenceConfig.js +45 -7
  15. package/dist/ConfluenceConfig.js.map +1 -1
  16. package/dist/ConfluenceError.d.ts +89 -6
  17. package/dist/ConfluenceError.d.ts.map +1 -1
  18. package/dist/ConfluenceError.js +88 -5
  19. package/dist/ConfluenceError.js.map +1 -1
  20. package/dist/GitError.d.ts +103 -0
  21. package/dist/GitError.d.ts.map +1 -0
  22. package/dist/GitError.js +85 -0
  23. package/dist/GitError.js.map +1 -0
  24. package/dist/GitService.d.ts +175 -0
  25. package/dist/GitService.d.ts.map +1 -0
  26. package/dist/GitService.js +431 -0
  27. package/dist/GitService.js.map +1 -0
  28. package/dist/LocalFileSystem.d.ts +29 -4
  29. package/dist/LocalFileSystem.d.ts.map +1 -1
  30. package/dist/LocalFileSystem.js +80 -6
  31. package/dist/LocalFileSystem.js.map +1 -1
  32. package/dist/MarkdownConverter.d.ts +49 -2
  33. package/dist/MarkdownConverter.d.ts.map +1 -1
  34. package/dist/MarkdownConverter.js +73 -111
  35. package/dist/MarkdownConverter.js.map +1 -1
  36. package/dist/SchemaConverterError.d.ts +108 -0
  37. package/dist/SchemaConverterError.d.ts.map +1 -0
  38. package/dist/SchemaConverterError.js +84 -0
  39. package/dist/SchemaConverterError.js.map +1 -0
  40. package/dist/Schemas.d.ts +225 -1
  41. package/dist/Schemas.d.ts.map +1 -1
  42. package/dist/Schemas.js +155 -6
  43. package/dist/Schemas.js.map +1 -1
  44. package/dist/SyncEngine.d.ts +30 -20
  45. package/dist/SyncEngine.d.ts.map +1 -1
  46. package/dist/SyncEngine.js +566 -117
  47. package/dist/SyncEngine.js.map +1 -1
  48. package/dist/ast/BlockNode.d.ts +468 -0
  49. package/dist/ast/BlockNode.d.ts.map +1 -0
  50. package/dist/ast/BlockNode.js +319 -0
  51. package/dist/ast/BlockNode.js.map +1 -0
  52. package/dist/ast/Document.d.ts +244 -0
  53. package/dist/ast/Document.d.ts.map +1 -0
  54. package/dist/ast/Document.js +69 -0
  55. package/dist/ast/Document.js.map +1 -0
  56. package/dist/ast/InlineNode.d.ts +477 -0
  57. package/dist/ast/InlineNode.d.ts.map +1 -0
  58. package/dist/ast/InlineNode.js +263 -0
  59. package/dist/ast/InlineNode.js.map +1 -0
  60. package/dist/ast/MacroNode.d.ts +267 -0
  61. package/dist/ast/MacroNode.d.ts.map +1 -0
  62. package/dist/ast/MacroNode.js +164 -0
  63. package/dist/ast/MacroNode.js.map +1 -0
  64. package/dist/ast/index.d.ts +10 -0
  65. package/dist/ast/index.d.ts.map +1 -0
  66. package/dist/ast/index.js +14 -0
  67. package/dist/ast/index.js.map +1 -0
  68. package/dist/bin.js +33 -149
  69. package/dist/bin.js.map +1 -1
  70. package/dist/commands/auth.d.ts +15 -0
  71. package/dist/commands/auth.d.ts.map +1 -0
  72. package/dist/commands/auth.js +86 -0
  73. package/dist/commands/auth.js.map +1 -0
  74. package/dist/commands/clone.d.ts +12 -0
  75. package/dist/commands/clone.d.ts.map +1 -0
  76. package/dist/commands/clone.js +93 -0
  77. package/dist/commands/clone.js.map +1 -0
  78. package/dist/commands/delete.d.ts +13 -0
  79. package/dist/commands/delete.d.ts.map +1 -0
  80. package/dist/commands/delete.js +48 -0
  81. package/dist/commands/delete.js.map +1 -0
  82. package/dist/commands/errorHandler.d.ts +14 -0
  83. package/dist/commands/errorHandler.d.ts.map +1 -0
  84. package/dist/commands/errorHandler.js +33 -0
  85. package/dist/commands/errorHandler.js.map +1 -0
  86. package/dist/commands/git.d.ts +22 -0
  87. package/dist/commands/git.d.ts.map +1 -0
  88. package/dist/commands/git.js +72 -0
  89. package/dist/commands/git.js.map +1 -0
  90. package/dist/commands/index.d.ts +11 -0
  91. package/dist/commands/index.d.ts.map +1 -0
  92. package/dist/commands/index.js +11 -0
  93. package/dist/commands/index.js.map +1 -0
  94. package/dist/commands/layers.d.ts +31 -0
  95. package/dist/commands/layers.d.ts.map +1 -0
  96. package/dist/commands/layers.js +137 -0
  97. package/dist/commands/layers.js.map +1 -0
  98. package/dist/commands/new.d.ts +9 -0
  99. package/dist/commands/new.d.ts.map +1 -0
  100. package/dist/commands/new.js +80 -0
  101. package/dist/commands/new.js.map +1 -0
  102. package/dist/commands/pageTree.d.ts +18 -0
  103. package/dist/commands/pageTree.d.ts.map +1 -0
  104. package/dist/commands/pageTree.js +20 -0
  105. package/dist/commands/pageTree.js.map +1 -0
  106. package/dist/commands/shared.d.ts +15 -0
  107. package/dist/commands/shared.d.ts.map +1 -0
  108. package/dist/commands/shared.js +27 -0
  109. package/dist/commands/shared.js.map +1 -0
  110. package/dist/commands/sync.d.ts +15 -0
  111. package/dist/commands/sync.d.ts.map +1 -0
  112. package/dist/commands/sync.js +101 -0
  113. package/dist/commands/sync.js.map +1 -0
  114. package/dist/index.d.ts +10 -1
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +14 -0
  117. package/dist/index.js.map +1 -1
  118. package/dist/internal/NodeLayers.d.ts +7 -0
  119. package/dist/internal/NodeLayers.d.ts.map +1 -0
  120. package/dist/internal/NodeLayers.js +19 -0
  121. package/dist/internal/NodeLayers.js.map +1 -0
  122. package/dist/internal/frontmatter.d.ts +10 -0
  123. package/dist/internal/frontmatter.d.ts.map +1 -1
  124. package/dist/internal/frontmatter.js +16 -0
  125. package/dist/internal/frontmatter.js.map +1 -1
  126. package/dist/internal/gitCommands.d.ts +78 -0
  127. package/dist/internal/gitCommands.d.ts.map +1 -0
  128. package/dist/internal/gitCommands.js +156 -0
  129. package/dist/internal/gitCommands.js.map +1 -0
  130. package/dist/internal/hashUtils.d.ts +42 -1
  131. package/dist/internal/hashUtils.d.ts.map +1 -1
  132. package/dist/internal/hashUtils.js +38 -2
  133. package/dist/internal/hashUtils.js.map +1 -1
  134. package/dist/internal/oauthServer.d.ts +55 -0
  135. package/dist/internal/oauthServer.d.ts.map +1 -0
  136. package/dist/internal/oauthServer.js +110 -0
  137. package/dist/internal/oauthServer.js.map +1 -0
  138. package/dist/internal/pathUtils.d.ts +21 -4
  139. package/dist/internal/pathUtils.d.ts.map +1 -1
  140. package/dist/internal/pathUtils.js +24 -13
  141. package/dist/internal/pathUtils.js.map +1 -1
  142. package/dist/internal/tokenStorage.d.ts +75 -0
  143. package/dist/internal/tokenStorage.d.ts.map +1 -0
  144. package/dist/internal/tokenStorage.js +149 -0
  145. package/dist/internal/tokenStorage.js.map +1 -0
  146. package/dist/internal/userCache.d.ts +42 -0
  147. package/dist/internal/userCache.d.ts.map +1 -0
  148. package/dist/internal/userCache.js +51 -0
  149. package/dist/internal/userCache.js.map +1 -0
  150. package/dist/parsers/ConfluenceParser.d.ts +26 -0
  151. package/dist/parsers/ConfluenceParser.d.ts.map +1 -0
  152. package/dist/parsers/ConfluenceParser.js +792 -0
  153. package/dist/parsers/ConfluenceParser.js.map +1 -0
  154. package/dist/parsers/MarkdownParser.d.ts +26 -0
  155. package/dist/parsers/MarkdownParser.d.ts.map +1 -0
  156. package/dist/parsers/MarkdownParser.js +873 -0
  157. package/dist/parsers/MarkdownParser.js.map +1 -0
  158. package/dist/parsers/index.d.ts +8 -0
  159. package/dist/parsers/index.d.ts.map +1 -0
  160. package/dist/parsers/index.js +8 -0
  161. package/dist/parsers/index.js.map +1 -0
  162. package/dist/schemas/ConfluenceSchema.d.ts +21 -0
  163. package/dist/schemas/ConfluenceSchema.d.ts.map +1 -0
  164. package/dist/schemas/ConfluenceSchema.js +38 -0
  165. package/dist/schemas/ConfluenceSchema.js.map +1 -0
  166. package/dist/schemas/ConversionSchema.d.ts +35 -0
  167. package/dist/schemas/ConversionSchema.d.ts.map +1 -0
  168. package/dist/schemas/ConversionSchema.js +208 -0
  169. package/dist/schemas/ConversionSchema.js.map +1 -0
  170. package/dist/schemas/MarkdownSchema.d.ts +21 -0
  171. package/dist/schemas/MarkdownSchema.d.ts.map +1 -0
  172. package/dist/schemas/MarkdownSchema.js +38 -0
  173. package/dist/schemas/MarkdownSchema.js.map +1 -0
  174. package/dist/schemas/hast/HastFromHtml.d.ts +27 -0
  175. package/dist/schemas/hast/HastFromHtml.d.ts.map +1 -0
  176. package/dist/schemas/hast/HastFromHtml.js +107 -0
  177. package/dist/schemas/hast/HastFromHtml.js.map +1 -0
  178. package/dist/schemas/hast/HastSchema.d.ts +195 -0
  179. package/dist/schemas/hast/HastSchema.d.ts.map +1 -0
  180. package/dist/schemas/hast/HastSchema.js +183 -0
  181. package/dist/schemas/hast/HastSchema.js.map +1 -0
  182. package/dist/schemas/hast/index.d.ts +9 -0
  183. package/dist/schemas/hast/index.d.ts.map +1 -0
  184. package/dist/schemas/hast/index.js +3 -0
  185. package/dist/schemas/hast/index.js.map +1 -0
  186. package/dist/schemas/index.d.ts +14 -0
  187. package/dist/schemas/index.d.ts.map +1 -0
  188. package/dist/schemas/index.js +16 -0
  189. package/dist/schemas/index.js.map +1 -0
  190. package/dist/schemas/mdast/MdastFromMarkdown.d.ts +30 -0
  191. package/dist/schemas/mdast/MdastFromMarkdown.d.ts.map +1 -0
  192. package/dist/schemas/mdast/MdastFromMarkdown.js +79 -0
  193. package/dist/schemas/mdast/MdastFromMarkdown.js.map +1 -0
  194. package/dist/schemas/mdast/MdastSchema.d.ts +385 -0
  195. package/dist/schemas/mdast/MdastSchema.d.ts.map +1 -0
  196. package/dist/schemas/mdast/MdastSchema.js +266 -0
  197. package/dist/schemas/mdast/MdastSchema.js.map +1 -0
  198. package/dist/schemas/mdast/index.d.ts +10 -0
  199. package/dist/schemas/mdast/index.d.ts.map +1 -0
  200. package/dist/schemas/mdast/index.js +4 -0
  201. package/dist/schemas/mdast/index.js.map +1 -0
  202. package/dist/schemas/mdast/mdastToString.d.ts +13 -0
  203. package/dist/schemas/mdast/mdastToString.d.ts.map +1 -0
  204. package/dist/schemas/mdast/mdastToString.js +85 -0
  205. package/dist/schemas/mdast/mdastToString.js.map +1 -0
  206. package/dist/schemas/nodes/block/BlockSchema.d.ts +43 -0
  207. package/dist/schemas/nodes/block/BlockSchema.d.ts.map +1 -0
  208. package/dist/schemas/nodes/block/BlockSchema.js +634 -0
  209. package/dist/schemas/nodes/block/BlockSchema.js.map +1 -0
  210. package/dist/schemas/nodes/block/index.d.ts +7 -0
  211. package/dist/schemas/nodes/block/index.d.ts.map +1 -0
  212. package/dist/schemas/nodes/block/index.js +7 -0
  213. package/dist/schemas/nodes/block/index.js.map +1 -0
  214. package/dist/schemas/nodes/index.d.ts +9 -0
  215. package/dist/schemas/nodes/index.d.ts.map +1 -0
  216. package/dist/schemas/nodes/index.js +12 -0
  217. package/dist/schemas/nodes/index.js.map +1 -0
  218. package/dist/schemas/nodes/inline/InlineSchema.d.ts +48 -0
  219. package/dist/schemas/nodes/inline/InlineSchema.d.ts.map +1 -0
  220. package/dist/schemas/nodes/inline/InlineSchema.js +436 -0
  221. package/dist/schemas/nodes/inline/InlineSchema.js.map +1 -0
  222. package/dist/schemas/nodes/inline/index.d.ts +7 -0
  223. package/dist/schemas/nodes/inline/index.d.ts.map +1 -0
  224. package/dist/schemas/nodes/inline/index.js +7 -0
  225. package/dist/schemas/nodes/inline/index.js.map +1 -0
  226. package/dist/schemas/nodes/macro/MacroSchema.d.ts +27 -0
  227. package/dist/schemas/nodes/macro/MacroSchema.d.ts.map +1 -0
  228. package/dist/schemas/nodes/macro/MacroSchema.js +162 -0
  229. package/dist/schemas/nodes/macro/MacroSchema.js.map +1 -0
  230. package/dist/schemas/nodes/macro/index.d.ts +7 -0
  231. package/dist/schemas/nodes/macro/index.d.ts.map +1 -0
  232. package/dist/schemas/nodes/macro/index.js +7 -0
  233. package/dist/schemas/nodes/macro/index.js.map +1 -0
  234. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts +24 -0
  235. package/dist/schemas/preprocessing/ConfluencePreprocessor.d.ts.map +1 -0
  236. package/dist/schemas/preprocessing/ConfluencePreprocessor.js +351 -0
  237. package/dist/schemas/preprocessing/ConfluencePreprocessor.js.map +1 -0
  238. package/dist/schemas/preprocessing/index.d.ts +8 -0
  239. package/dist/schemas/preprocessing/index.d.ts.map +1 -0
  240. package/dist/schemas/preprocessing/index.js +2 -0
  241. package/dist/schemas/preprocessing/index.js.map +1 -0
  242. package/dist/serializers/ConfluenceSerializer.d.ts +30 -0
  243. package/dist/serializers/ConfluenceSerializer.d.ts.map +1 -0
  244. package/dist/serializers/ConfluenceSerializer.js +551 -0
  245. package/dist/serializers/ConfluenceSerializer.js.map +1 -0
  246. package/dist/serializers/MarkdownSerializer.d.ts +34 -0
  247. package/dist/serializers/MarkdownSerializer.d.ts.map +1 -0
  248. package/dist/serializers/MarkdownSerializer.js +355 -0
  249. package/dist/serializers/MarkdownSerializer.js.map +1 -0
  250. package/dist/serializers/index.d.ts +8 -0
  251. package/dist/serializers/index.d.ts.map +1 -0
  252. package/dist/serializers/index.js +8 -0
  253. package/dist/serializers/index.js.map +1 -0
  254. package/package.json +27 -16
  255. package/src/ConfluenceAuth.ts +571 -0
  256. package/src/ConfluenceClient.ts +188 -156
  257. package/src/ConfluenceConfig.ts +63 -7
  258. package/src/ConfluenceError.ts +110 -14
  259. package/src/GitError.ts +92 -0
  260. package/src/GitService.ts +859 -0
  261. package/src/LocalFileSystem.ts +179 -9
  262. package/src/MarkdownConverter.ts +126 -122
  263. package/src/SchemaConverterError.ts +108 -0
  264. package/src/Schemas.ts +223 -6
  265. package/src/SyncEngine.ts +745 -162
  266. package/src/ast/BlockNode.ts +425 -0
  267. package/src/ast/Document.ts +90 -0
  268. package/src/ast/InlineNode.ts +323 -0
  269. package/src/ast/MacroNode.ts +245 -0
  270. package/src/ast/index.ts +83 -0
  271. package/src/bin.ts +50 -249
  272. package/src/commands/auth.ts +117 -0
  273. package/src/commands/clone.ts +145 -0
  274. package/src/commands/delete.ts +57 -0
  275. package/src/commands/errorHandler.ts +32 -0
  276. package/src/commands/git.ts +114 -0
  277. package/src/commands/index.ts +10 -0
  278. package/src/commands/layers.ts +211 -0
  279. package/src/commands/new.ts +99 -0
  280. package/src/commands/pageTree.ts +40 -0
  281. package/src/commands/shared.ts +35 -0
  282. package/src/commands/sync.ts +129 -0
  283. package/src/index.ts +21 -1
  284. package/src/internal/NodeLayers.ts +21 -0
  285. package/src/internal/frontmatter.ts +21 -0
  286. package/src/internal/gitCommands.ts +229 -0
  287. package/src/internal/hashUtils.ts +65 -3
  288. package/src/internal/oauthServer.ts +199 -0
  289. package/src/internal/pathUtils.ts +34 -17
  290. package/src/internal/tokenStorage.ts +240 -0
  291. package/src/internal/userCache.ts +90 -0
  292. package/src/parsers/ConfluenceParser.ts +950 -0
  293. package/src/parsers/MarkdownParser.ts +1198 -0
  294. package/src/parsers/index.ts +8 -0
  295. package/src/schemas/ConfluenceSchema.ts +56 -0
  296. package/src/schemas/ConversionSchema.ts +318 -0
  297. package/src/schemas/MarkdownSchema.ts +56 -0
  298. package/src/schemas/hast/HastFromHtml.ts +153 -0
  299. package/src/schemas/hast/HastSchema.ts +274 -0
  300. package/src/schemas/hast/index.ts +35 -0
  301. package/src/schemas/index.ts +20 -0
  302. package/src/schemas/mdast/MdastFromMarkdown.ts +118 -0
  303. package/src/schemas/mdast/MdastSchema.ts +566 -0
  304. package/src/schemas/mdast/index.ts +59 -0
  305. package/src/schemas/mdast/mdastToString.ts +102 -0
  306. package/src/schemas/nodes/block/BlockSchema.ts +773 -0
  307. package/src/schemas/nodes/block/index.ts +13 -0
  308. package/src/schemas/nodes/index.ts +20 -0
  309. package/src/schemas/nodes/inline/InlineSchema.ts +523 -0
  310. package/src/schemas/nodes/inline/index.ts +14 -0
  311. package/src/schemas/nodes/macro/MacroSchema.ts +226 -0
  312. package/src/schemas/nodes/macro/index.ts +6 -0
  313. package/src/schemas/preprocessing/ConfluencePreprocessor.ts +446 -0
  314. package/src/schemas/preprocessing/index.ts +8 -0
  315. package/src/serializers/ConfluenceSerializer.ts +717 -0
  316. package/src/serializers/MarkdownSerializer.ts +493 -0
  317. package/src/serializers/index.ts +8 -0
  318. package/test/GitService.test.ts +209 -0
  319. package/test/MarkdownConverter.test.ts +37 -3
  320. package/test/Schemas.test.ts +97 -2
  321. package/test/ast/BlockNode.test.ts +265 -0
  322. package/test/ast/Document.test.ts +126 -0
  323. package/test/ast/InlineNode.test.ts +161 -0
  324. package/test/fixtures/integration-test.html.fixture +103 -0
  325. package/test/fixtures/integration-test.md.expected +257 -0
  326. package/test/integration.test.ts +269 -0
  327. package/test/oauthServer.test.ts +50 -0
  328. package/test/parsers/ConfluenceParser.test.ts +283 -0
  329. package/test/schemas/ConfluencePreprocessor.test.ts +180 -0
  330. package/test/schemas/ConversionSchema.test.ts +159 -0
  331. package/test/schemas/HastSchema.test.ts +138 -0
  332. package/test/schemas/MdastSchema.test.ts +145 -0
  333. package/test/schemas/nodes/block/BlockSchema.test.ts +173 -0
  334. package/test/schemas/nodes/inline/InlineSchema.test.ts +198 -0
  335. package/test/schemas/nodes/macro/MacroSchema.test.ts +142 -0
  336. package/test/tokenStorage.test.ts +99 -0
package/src/SyncEngine.ts CHANGED
@@ -7,21 +7,18 @@ import * as Path from "@effect/platform/Path"
7
7
  import * as Context from "effect/Context"
8
8
  import * as Effect from "effect/Effect"
9
9
  import * as Layer from "effect/Layer"
10
- import type { PageId } from "./Brand.js"
10
+ import { PageId } from "./Brand.js"
11
11
  import { ConfluenceClient } from "./ConfluenceClient.js"
12
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"
13
+ import type { ApiError, ConversionError, FrontMatterError, RateLimitError } from "./ConfluenceError.js"
14
+ import { FileSystemError, StructureError } from "./ConfluenceError.js"
15
+ import type { GitServiceError } from "./GitService.js"
16
+ import { GitService } from "./GitService.js"
17
+ import { computeHash, HashServiceLive } from "./internal/hashUtils.js"
18
+ import { UserCache } from "./internal/userCache.js"
22
19
  import { LocalFileSystem } from "./LocalFileSystem.js"
23
20
  import { MarkdownConverter } from "./MarkdownConverter.js"
24
- import type { PageFrontMatter, PageListItem, PageResponse } from "./Schemas.js"
21
+ import type { AtlassianUser, PageFrontMatter, PageListItem, PageResponse, PageVersionContent } from "./Schemas.js"
25
22
 
26
23
  /**
27
24
  * Sync status for a single page.
@@ -40,12 +37,34 @@ export type SyncStatus =
40
37
  readonly remoteVersion: number
41
38
  }
42
39
 
40
+ /**
41
+ * Progress callback for version replay.
42
+ */
43
+ export type ProgressCallback = (current: number, total: number, message: string) => void
44
+
45
+ /**
46
+ * Options for pull operation.
47
+ */
48
+ export interface PullOptions {
49
+ readonly force: boolean
50
+ /**
51
+ * Replay version history as individual git commits.
52
+ * Only applies when git is initialized.
53
+ */
54
+ readonly replayHistory?: boolean
55
+ /**
56
+ * Progress callback for version replay.
57
+ */
58
+ readonly onProgress?: ProgressCallback
59
+ }
60
+
43
61
  /**
44
62
  * Result of a pull operation.
45
63
  */
46
64
  export interface PullResult {
47
65
  readonly pulled: number
48
66
  readonly skipped: number
67
+ readonly commits: number
49
68
  readonly errors: ReadonlyArray<string>
50
69
  }
51
70
 
@@ -55,21 +74,11 @@ export interface PullResult {
55
74
  export interface PushResult {
56
75
  readonly pushed: number
57
76
  readonly created: number
77
+ readonly deleted: number
58
78
  readonly skipped: number
59
79
  readonly errors: ReadonlyArray<string>
60
80
  }
61
81
 
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
82
  /**
74
83
  * Result of a status operation.
75
84
  */
@@ -83,7 +92,14 @@ export interface StatusResult {
83
92
  readonly files: ReadonlyArray<SyncStatus>
84
93
  }
85
94
 
86
- type SyncError = ApiError | RateLimitError | ConversionError | FileSystemError | FrontMatterError
95
+ type SyncError =
96
+ | ApiError
97
+ | RateLimitError
98
+ | ConversionError
99
+ | FileSystemError
100
+ | FrontMatterError
101
+ | GitServiceError
102
+ | StructureError
87
103
 
88
104
  /**
89
105
  * Sync engine service for Confluence <-> Markdown operations.
@@ -110,17 +126,12 @@ export class SyncEngine extends Context.Tag(
110
126
  /**
111
127
  * Pull pages from Confluence to local markdown.
112
128
  */
113
- readonly pull: (options: { force: boolean }) => Effect.Effect<PullResult, SyncError>
129
+ readonly pull: (options: PullOptions) => Effect.Effect<PullResult, SyncError>
114
130
 
115
131
  /**
116
132
  * Push local markdown changes to Confluence.
117
133
  */
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>
134
+ readonly push: (options: { dryRun: boolean; message?: string }) => Effect.Effect<PushResult, SyncError>
124
135
 
125
136
  /**
126
137
  * Get sync status for all files.
@@ -137,7 +148,7 @@ export class SyncEngine extends Context.Tag(
137
148
  export const layer: Layer.Layer<
138
149
  SyncEngine,
139
150
  never,
140
- ConfluenceClient | ConfluenceConfig | MarkdownConverter | LocalFileSystem | Path.Path
151
+ ConfluenceClient | ConfluenceConfig | MarkdownConverter | LocalFileSystem | Path.Path | GitService | UserCache
141
152
  > = Layer.effect(
142
153
  SyncEngine,
143
154
  Effect.gen(function*() {
@@ -146,59 +157,254 @@ export const layer: Layer.Layer<
146
157
  const converter = yield* MarkdownConverter
147
158
  const localFs = yield* LocalFileSystem
148
159
  const pathService = yield* Path.Path
160
+ const git = yield* GitService
161
+ const userCache = yield* UserCache
149
162
 
150
163
  const docsPath = pathService.join(process.cwd(), config.docsPath)
151
164
 
165
+ /**
166
+ * Build a map of relative path (without .md) to pageId for resolving parents.
167
+ * e.g., "guide" -> pageId, "guide/getting-started" -> pageId
168
+ */
169
+ const buildPageIdMap = (): Effect.Effect<Map<string, string>, SyncError> =>
170
+ Effect.gen(function*() {
171
+ const files = yield* localFs.listMarkdownFiles(docsPath)
172
+ const map = new Map<string, string>()
173
+
174
+ for (const filePath of files) {
175
+ const localFile = yield* localFs.readMarkdownFile(filePath)
176
+ const relativePath = pathService.relative(docsPath, filePath)
177
+ const key = relativePath.replace(/\.md$/, "")
178
+
179
+ if (localFile.frontMatter?.pageId) {
180
+ map.set(key, localFile.frontMatter.pageId)
181
+ }
182
+ }
183
+
184
+ return map
185
+ })
186
+
187
+ /**
188
+ * Resolve parent page ID from directory structure.
189
+ * Rule: foo/ contains children of foo.md
190
+ */
191
+ const resolveParent = (
192
+ filePath: string,
193
+ pageIdMap: Map<string, string>
194
+ ): Effect.Effect<string, StructureError | FileSystemError> =>
195
+ Effect.gen(function*() {
196
+ const relativePath = pathService.relative(docsPath, filePath)
197
+ const dirPath = pathService.dirname(relativePath)
198
+
199
+ // Root level files -> parent is rootPageId
200
+ if (dirPath === ".") {
201
+ return config.rootPageId
202
+ }
203
+
204
+ // Files in subdir -> parent is the directory's parent page
205
+ // e.g., "foo/bar.md" -> parent is "foo.md"
206
+ const parentKey = dirPath
207
+ const parentPageId = pageIdMap.get(parentKey)
208
+
209
+ if (!parentPageId) {
210
+ // Check if the parent .md file exists
211
+ const parentMdPath = pathService.join(docsPath, `${parentKey}.md`)
212
+ const parentExists = yield* localFs.exists(parentMdPath)
213
+
214
+ if (!parentExists) {
215
+ return yield* Effect.fail(
216
+ new StructureError({
217
+ path: filePath,
218
+ message: `Directory '${dirPath}/' has no parent page`,
219
+ advice: `Create '${parentKey}.md' first`
220
+ })
221
+ )
222
+ }
223
+
224
+ // Parent file exists but has no pageId (not pushed yet)
225
+ return yield* Effect.fail(
226
+ new StructureError({
227
+ path: filePath,
228
+ message: `Parent page '${parentKey}.md' not yet pushed`,
229
+ advice: `Push parent before creating children`
230
+ })
231
+ )
232
+ }
233
+
234
+ return parentPageId
235
+ })
236
+
237
+ /**
238
+ * Validate directory structure consistency.
239
+ * - Every directory foo/ must have a corresponding foo.md with pageId
240
+ */
241
+ const validateStructure = (): Effect.Effect<void, SyncError> =>
242
+ Effect.gen(function*() {
243
+ const files = yield* localFs.listMarkdownFiles(docsPath)
244
+ const pageIdMap = yield* buildPageIdMap()
245
+
246
+ // Build set of directories that contain files
247
+ const dirsWithFiles = new Set<string>()
248
+ for (const filePath of files) {
249
+ const relativePath = pathService.relative(docsPath, filePath)
250
+ const dirPath = pathService.dirname(relativePath)
251
+ if (dirPath !== ".") {
252
+ dirsWithFiles.add(dirPath)
253
+ }
254
+ }
255
+
256
+ // Check each directory has a parent .md with pageId
257
+ // Rule: foo/ directory must have foo.md as parent
258
+ for (const dir of dirsWithFiles) {
259
+ const parentPageId = pageIdMap.get(dir)
260
+ if (!parentPageId) {
261
+ const parentMdPath = pathService.join(docsPath, `${dir}.md`)
262
+ const parentExists = yield* localFs.exists(parentMdPath)
263
+
264
+ if (!parentExists) {
265
+ return yield* Effect.fail(
266
+ new StructureError({
267
+ path: dir,
268
+ message: `Directory '${dir}/' has no parent page`,
269
+ advice: `Create '${dir}.md' first`
270
+ })
271
+ )
272
+ }
273
+
274
+ // Parent exists but not pushed - this is OK during push,
275
+ // as long as we push in order (parent before child)
276
+ }
277
+ }
278
+
279
+ // Check parentId in front-matter matches directory structure
280
+ // Only validate new pages or pages where we can determine expected parent
281
+ for (const filePath of files) {
282
+ const localFile = yield* localFs.readMarkdownFile(filePath)
283
+ if (localFile.frontMatter?.parentId) {
284
+ const relativePath = pathService.relative(docsPath, filePath)
285
+ const dirPath = pathService.dirname(relativePath)
286
+
287
+ // Determine expected parent based on directory
288
+ // foo/bar.md -> parent should be foo.md (pageIdMap key: "foo")
289
+ let expectedParentId: string | null = null
290
+ if (dirPath === ".") {
291
+ // Root level - parent should be rootPageId
292
+ // But if the parentId points elsewhere, it might be correct Confluence hierarchy
293
+ // Only validate if it's a new page (no pageId yet)
294
+ if (!localFile.frontMatter.pageId) {
295
+ expectedParentId = config.rootPageId
296
+ }
297
+ } else {
298
+ const parentPageId = pageIdMap.get(dirPath)
299
+ if (parentPageId) {
300
+ expectedParentId = parentPageId
301
+ }
302
+ // If parent not in map, skip validation (parent might be outside our tree)
303
+ }
304
+
305
+ if (expectedParentId !== null && localFile.frontMatter.parentId !== expectedParentId) {
306
+ return yield* Effect.fail(
307
+ new StructureError({
308
+ path: filePath,
309
+ message: `Page parentId '${localFile.frontMatter.parentId}' does not match directory location`,
310
+ advice: `Move file to correct directory or update parentId to '${expectedParentId}'`
311
+ })
312
+ )
313
+ }
314
+ }
315
+ }
316
+ })
317
+
318
+ /**
319
+ * Get user info with caching.
320
+ */
321
+ const getUser = (accountId: string): Effect.Effect<AtlassianUser | undefined, ApiError | RateLimitError> =>
322
+ userCache.getOrFetch(accountId, client.getUser).pipe(
323
+ Effect.catchAll(() => Effect.succeed(undefined))
324
+ )
325
+
326
+ /**
327
+ * Convert version content to markdown and front-matter.
328
+ */
329
+ const versionToMarkdown = (
330
+ pageId: PageId,
331
+ version: PageVersionContent,
332
+ title: string,
333
+ parentId?: string,
334
+ position?: number
335
+ ): Effect.Effect<{ markdown: string; frontMatter: PageFrontMatter }, SyncError> =>
336
+ Effect.gen(function*() {
337
+ const htmlContent = version.body?.storage?.value ?? ""
338
+ const markdown = yield* converter.htmlToMarkdown(htmlContent, {
339
+ includeRawSource: config.saveSource
340
+ })
341
+ const contentHash = yield* computeHash(markdown).pipe(Effect.provide(HashServiceLive))
342
+
343
+ // Get author info
344
+ const author = version.authorId ? yield* getUser(version.authorId) : undefined
345
+
346
+ const frontMatter: PageFrontMatter = {
347
+ pageId,
348
+ version: version.number,
349
+ title,
350
+ updated: new Date(version.createdAt),
351
+ ...(parentId ? { parentId: parentId as PageId } : {}),
352
+ ...(position !== undefined ? { position } : {}),
353
+ contentHash,
354
+ ...(version.message ? { versionMessage: version.message } : {}),
355
+ ...(author?.displayName ? { authorName: author.displayName } : {}),
356
+ ...(author?.email ? { authorEmail: author.email } : {})
357
+ }
358
+
359
+ return { markdown, frontMatter }
360
+ })
361
+
152
362
  /**
153
363
  * Pull a single page and its children recursively.
364
+ * Returns { pulled, commits } count.
154
365
  */
155
366
  const pullPage = (
156
367
  page: PageListItem | PageResponse,
157
368
  parentPath: string,
158
- force: boolean
159
- ): Effect.Effect<number, SyncError> =>
369
+ options: PullOptions,
370
+ gitInitialized: boolean,
371
+ knownParentId?: string
372
+ ): Effect.Effect<{ pulled: number; commits: number }, SyncError> =>
160
373
  Effect.gen(function*() {
374
+ const pageId = page.id as PageId
161
375
  // Get children to determine if this is a folder
162
- const children = yield* client.getAllChildren(page.id as PageId)
376
+ const children = yield* client.getAllChildren(pageId)
163
377
  const hasChildren = children.length > 0
164
378
 
165
- const filePath = localFs.getPagePath(page.title, hasChildren, parentPath)
166
- const dirPath = hasChildren ? localFs.getPageDir(page.title, parentPath) : parentPath
379
+ const filePath = yield* localFs.getPagePath(page.title, hasChildren, parentPath)
380
+ const dirPath = hasChildren ? yield* localFs.getPageDir(page.title, parentPath) : parentPath
167
381
 
168
382
  // 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
- }
383
+ const fullPage = yield* client.getPage(pageId)
183
384
 
184
- const contentHash = computeHash(markdown)
385
+ // Determine parentId: use API response, fall back to known parent
386
+ const effectiveParentId = page.parentId ?? knownParentId
185
387
 
186
- // Check if we need to update
187
- if (!force) {
388
+ // Check existing local version
389
+ let localVersion = 0
390
+ if (!options.force) {
188
391
  const exists = yield* localFs.exists(filePath)
189
392
  if (exists) {
190
393
  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)
394
+ if (localFile.frontMatter) {
395
+ localVersion = localFile.frontMatter.version
396
+ // If local is already at remote version, skip
397
+ if (localVersion >= fullPage.version.number) {
398
+ let childPulled = 0
399
+ let childCommits = 0
400
+ for (const child of children) {
401
+ // Pass current page's ID as parent for children
402
+ const result = yield* pullPage(child, dirPath, options, gitInitialized, pageId)
403
+ childPulled += result.pulled
404
+ childCommits += result.commits
405
+ }
406
+ return { pulled: childPulled, commits: childCommits }
200
407
  }
201
- return count
202
408
  }
203
409
  }
204
410
  }
@@ -208,146 +414,524 @@ export const layer: Layer.Layer<
208
414
  yield* localFs.ensureDir(dirPath)
209
415
  }
210
416
 
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
417
+ let totalCommits = 0
418
+
419
+ // If history replay is enabled and git is initialized, replay versions
420
+ let historyReplayFailed = false
421
+ if (options.replayHistory && gitInitialized && localVersion < fullPage.version.number) {
422
+ // Fetch versions with body content since localVersion
423
+ const versions = yield* client.getPageVersions(pageId, { since: localVersion, includeBody: true })
424
+ // Sort by version number (oldest first)
425
+ const sortedVersions = [...versions].sort((a, b) => a.number - b.number)
426
+ const totalVersions = sortedVersions.length
427
+
428
+ let versionIdx = 0
429
+ for (const versionInfo of sortedVersions) {
430
+ versionIdx++
431
+ // Report progress
432
+ if (options.onProgress) {
433
+ options.onProgress(versionIdx, totalVersions, `${fullPage.title} v${versionInfo.number}`)
434
+ }
435
+
436
+ // Check if body content is available from the versions list
437
+ const bodyContent = versionInfo.page?.body?.storage?.value
438
+ if (!bodyContent) {
439
+ // No body content available - history replay not supported
440
+ historyReplayFailed = true
441
+ break
442
+ }
443
+
444
+ // Build version content from the list response
445
+ const versionContent = {
446
+ number: versionInfo.number,
447
+ authorId: versionInfo.authorId,
448
+ createdAt: versionInfo.createdAt,
449
+ message: versionInfo.message,
450
+ body: {
451
+ storage: {
452
+ value: bodyContent,
453
+ representation: "storage" as const
454
+ }
455
+ }
456
+ }
457
+ const { frontMatter, markdown } = yield* versionToMarkdown(
458
+ pageId,
459
+ versionContent,
460
+ versionInfo.page?.title ?? fullPage.title,
461
+ effectiveParentId,
462
+ page.position
463
+ )
464
+
465
+ // Write file
466
+ yield* localFs.writeMarkdownFile(filePath, frontMatter, markdown)
467
+
468
+ // Save source HTML if configured
469
+ if (config.saveSource && versionContent.body?.storage?.value) {
470
+ const sourceFilePath = filePath.replace(/\.md$/, ".html")
471
+ yield* localFs.writeFile(sourceFilePath, versionContent.body.storage.value)
472
+ }
473
+
474
+ // Commit this version
475
+ const author = versionInfo.authorId ? yield* getUser(versionInfo.authorId) : undefined
476
+ const commitMessage = versionInfo.message || `Update ${fullPage.title} (v${versionInfo.number})`
477
+
478
+ yield* git.addAll()
479
+ const commitOptions = author
480
+ ? {
481
+ message: commitMessage,
482
+ author: { name: author.displayName, email: author.email ?? "unknown@atlassian.com" },
483
+ date: new Date(versionInfo.createdAt)
484
+ }
485
+ : { message: commitMessage, date: new Date(versionInfo.createdAt) }
486
+ yield* git.commit(commitOptions).pipe(Effect.catchTag("GitNoChangesError", () => Effect.void))
487
+
488
+ totalCommits++
489
+ }
220
490
  }
221
491
 
222
- yield* localFs.writeMarkdownFile(filePath, frontMatter, markdown)
492
+ // Simple pull - either history replay is disabled, not initialized, or body not available
493
+ const needsSimplePull = historyReplayFailed || !options.replayHistory || !gitInitialized ||
494
+ localVersion >= fullPage.version.number
495
+
496
+ if (needsSimplePull) {
497
+ if (historyReplayFailed) {
498
+ yield* Effect.logWarning(
499
+ "History replay not available. Confluence Cloud API does not return body content for historical versions. Falling back to simple pull."
500
+ )
501
+ }
502
+
503
+ // Simple pull without history replay
504
+ const htmlContent = fullPage.body?.storage?.value ?? ""
505
+ let markdown = yield* converter.htmlToMarkdown(htmlContent, {
506
+ includeRawSource: config.saveSource
507
+ })
508
+
509
+ // Add child page links for index pages
510
+ if (hasChildren && config.spaceKey) {
511
+ const childLinks = children
512
+ .map((child) => {
513
+ const pageUrl = `${config.baseUrl}/wiki/spaces/${config.spaceKey}/pages/${child.id}`
514
+ return `- [${child.title}](${pageUrl})`
515
+ })
516
+ .join("\n")
517
+ markdown = markdown.trim() + "\n\n## Child Pages\n\n" + childLinks + "\n"
518
+ }
519
+
520
+ const contentHash = yield* computeHash(markdown).pipe(Effect.provide(HashServiceLive))
521
+
522
+ // Get author info
523
+ const author = fullPage.version.authorId ? yield* getUser(fullPage.version.authorId) : undefined
524
+
525
+ // Write file
526
+ const frontMatter: PageFrontMatter = {
527
+ pageId,
528
+ version: fullPage.version.number,
529
+ title: fullPage.title,
530
+ updated: fullPage.version.createdAt ? new Date(fullPage.version.createdAt) : new Date(),
531
+ ...(effectiveParentId ? { parentId: effectiveParentId as PageId } : {}),
532
+ ...(page.position !== undefined ? { position: page.position } : {}),
533
+ contentHash,
534
+ ...(fullPage.version.message ? { versionMessage: fullPage.version.message } : {}),
535
+ ...(author?.displayName ? { authorName: author.displayName } : {}),
536
+ ...(author?.email ? { authorEmail: author.email } : {})
537
+ }
538
+
539
+ yield* localFs.writeMarkdownFile(filePath, frontMatter, markdown)
540
+
541
+ // Save source HTML if configured
542
+ if (config.saveSource && htmlContent) {
543
+ const sourceFilePath = filePath.replace(/\.md$/, ".html")
544
+ yield* localFs.writeFile(sourceFilePath, htmlContent)
545
+ }
546
+ }
223
547
 
224
548
  // Pull children
225
- let count = 1
549
+ let childPulled = 0
550
+ let childCommits = 0
226
551
  for (const child of children) {
227
- count += yield* pullPage(child, dirPath, force)
552
+ // Pass current page's ID as parent for children
553
+ const result = yield* pullPage(child, dirPath, options, gitInitialized, pageId)
554
+ childPulled += result.pulled
555
+ childCommits += result.commits
228
556
  }
229
557
 
230
- return count
558
+ return { pulled: 1 + childPulled, commits: totalCommits + childCommits }
231
559
  })
232
560
 
233
- const pull = (options: { force: boolean }): Effect.Effect<PullResult, SyncError> =>
561
+ const pull = (options: PullOptions): Effect.Effect<PullResult, SyncError> =>
234
562
  Effect.gen(function*() {
235
563
  yield* localFs.ensureDir(docsPath)
236
564
 
565
+ // Check if git is initialized
566
+ const gitInitialized = yield* git.isInitialized()
567
+
568
+ // Two-branch model: if origin/confluence exists, work on that branch first
569
+ const hasRemoteBranch = gitInitialized
570
+ ? yield* git.branchExists("origin/confluence")
571
+ : false
572
+ const originalBranch = gitInitialized ? yield* git.getCurrentBranch() : ""
573
+
574
+ if (hasRemoteBranch) {
575
+ // Switch to origin/confluence to pull updates there
576
+ yield* git.checkout("origin/confluence")
577
+ }
578
+
237
579
  const rootPage = yield* client.getPage(config.rootPageId)
238
- const pulled = yield* pullPage(rootPage, docsPath, options.force)
580
+ const result = yield* pullPage(rootPage, docsPath, options, gitInitialized)
581
+
582
+ // If git is initialized and we have changes but didn't replay history, auto-commit
583
+ if (gitInitialized && !options.replayHistory && result.pulled > 0) {
584
+ yield* git.addAll()
585
+ yield* git.commit({
586
+ message: `Pull from Confluence (${result.pulled} page${result.pulled !== 1 ? "s" : ""})`
587
+ }).pipe(Effect.catchTag("GitNoChangesError", () => Effect.void))
588
+ }
589
+
590
+ // Two-branch model: merge origin/confluence into current branch
591
+ if (hasRemoteBranch && originalBranch && originalBranch !== "origin/confluence") {
592
+ yield* git.checkout(originalBranch)
593
+ yield* git.merge("origin/confluence", {
594
+ message: `Merge remote changes from Confluence`
595
+ }).pipe(Effect.catchAll(() => Effect.void)) // May fail if no changes
596
+ }
239
597
 
240
598
  return {
241
- pulled,
599
+ pulled: result.pulled,
242
600
  skipped: 0,
601
+ commits: result.commits,
243
602
  errors: [] as ReadonlyArray<string>
244
603
  }
245
604
  })
246
605
 
247
- const push = (options: { dryRun: boolean }): Effect.Effect<PushResult, SyncError> =>
606
+ /**
607
+ * Push a single file's content to Confluence.
608
+ * Returns the canonical content after push.
609
+ */
610
+ const pushFile = (
611
+ filePath: string,
612
+ revisionMessage: string,
613
+ spaceId: string,
614
+ pageIdMap: Map<string, string>
615
+ ): Effect.Effect<
616
+ { pushed: boolean; created: boolean; newPageId?: string; error?: string },
617
+ SyncError
618
+ > =>
248
619
  Effect.gen(function*() {
620
+ const localFile = yield* localFs.readMarkdownFile(filePath)
621
+
622
+ // Handle new page creation
623
+ if (localFile.isNew || !localFile.frontMatter) {
624
+ // Get title from front-matter or filename
625
+ const relativePath = pathService.relative(docsPath, filePath)
626
+ const baseName = pathService.basename(filePath, ".md")
627
+
628
+ // For new pages, re-parse front-matter to get title
629
+ // The localFile only has the content (body), not the original front-matter
630
+ const title = yield* Effect.tryPromise({
631
+ try: async () => {
632
+ const fs = await import("node:fs/promises")
633
+ const matter = await import("gray-matter")
634
+ const rawFile = await fs.readFile(filePath, "utf-8")
635
+ const parsed = matter.default(rawFile)
636
+ return (parsed.data as { title?: string }).title ?? baseName
637
+ },
638
+ catch: (cause) => new FileSystemError({ operation: "read", path: filePath, cause })
639
+ })
640
+
641
+ // Resolve parent from directory structure
642
+ const parentId = yield* resolveParent(filePath, pageIdMap)
643
+
644
+ // Convert markdown to HTML
645
+ const html = yield* converter.markdownToHtml(localFile.content)
646
+
647
+ // Create the page
648
+ const createdPage = yield* client.createPage({
649
+ spaceId,
650
+ parentId,
651
+ title,
652
+ body: {
653
+ representation: "storage",
654
+ value: html
655
+ }
656
+ })
657
+
658
+ // Set editor version to v2 (new editor)
659
+ yield* client.setEditorVersion(createdPage.id as PageId, "v2").pipe(
660
+ Effect.catchAll((error) => {
661
+ // Log warning but don't fail the push
662
+ return Effect.logWarning(`Failed to set editor v2 for page ${createdPage.id}: ${error.message}`)
663
+ })
664
+ )
665
+
666
+ // Fetch canonical content back from Confluence
667
+ const canonicalPage = yield* client.getPage(createdPage.id as PageId)
668
+ const canonicalHtml = canonicalPage.body?.storage?.value ?? ""
669
+ const canonicalMarkdown = yield* converter.htmlToMarkdown(canonicalHtml, {
670
+ includeRawSource: config.saveSource
671
+ })
672
+ const canonicalHash = yield* computeHash(canonicalMarkdown).pipe(Effect.provide(HashServiceLive))
673
+
674
+ // Write canonical content with full front-matter
675
+ const newFrontMatter: PageFrontMatter = {
676
+ pageId: createdPage.id as PageId,
677
+ version: createdPage.version.number,
678
+ title,
679
+ updated: new Date(canonicalPage.version.createdAt ?? new Date().toISOString()),
680
+ parentId: parentId as PageId,
681
+ contentHash: canonicalHash
682
+ }
683
+ yield* localFs.writeMarkdownFile(filePath, newFrontMatter, canonicalMarkdown)
684
+
685
+ // Update pageIdMap with new page
686
+ const key = relativePath.replace(/\.md$/, "")
687
+ pageIdMap.set(key, createdPage.id)
688
+
689
+ return { pushed: false, created: true, newPageId: createdPage.id }
690
+ }
691
+
692
+ const fm = localFile.frontMatter
693
+ const currentHash = yield* computeHash(localFile.content).pipe(Effect.provide(HashServiceLive))
694
+
695
+ if (currentHash === fm.contentHash) {
696
+ return { pushed: false, created: false }
697
+ }
698
+
699
+ // Fetch current version to avoid conflicts
700
+ const remotePage = yield* client.getPage(fm.pageId)
701
+ const html = yield* converter.markdownToHtml(localFile.content)
702
+ const updatedPage = yield* client.updatePage({
703
+ id: fm.pageId,
704
+ title: fm.title,
705
+ status: "current",
706
+ version: {
707
+ number: remotePage.version.number + 1,
708
+ message: revisionMessage
709
+ },
710
+ body: {
711
+ representation: "storage",
712
+ value: html
713
+ }
714
+ })
715
+
716
+ // Fetch canonical content back from Confluence
717
+ const canonicalPage = yield* client.getPage(fm.pageId)
718
+ const canonicalHtml = canonicalPage.body?.storage?.value ?? ""
719
+ const canonicalMarkdown = yield* converter.htmlToMarkdown(canonicalHtml, {
720
+ includeRawSource: config.saveSource
721
+ })
722
+ const canonicalHash = yield* computeHash(canonicalMarkdown).pipe(Effect.provide(HashServiceLive))
723
+
724
+ // Write canonical content with updated front-matter
725
+ const newFrontMatter: PageFrontMatter = {
726
+ ...fm,
727
+ version: updatedPage.version.number,
728
+ updated: new Date(canonicalPage.version.createdAt ?? new Date().toISOString()),
729
+ contentHash: canonicalHash
730
+ }
731
+ yield* localFs.writeMarkdownFile(filePath, newFrontMatter, canonicalMarkdown)
732
+
733
+ return { pushed: true, created: false }
734
+ })
735
+
736
+ /**
737
+ * Find commits that have unpushed changes.
738
+ * Uses two-branch model: finds commits in current branch not in origin/confluence.
739
+ * Returns commits from oldest to newest.
740
+ */
741
+ const findUnpushedCommits = (): Effect.Effect<
742
+ ReadonlyArray<{ hash: string; message: string }>,
743
+ SyncError
744
+ > =>
745
+ Effect.gen(function*() {
746
+ // Two-branch model: find commits in current branch not in origin/confluence
747
+ const hasRemoteBranch = yield* git.branchExists("origin/confluence")
748
+
749
+ if (hasRemoteBranch) {
750
+ // Use logRange to find commits not in origin/confluence
751
+ const commits = yield* git.logRange("origin/confluence", "HEAD")
752
+ return commits.map((c) => ({ hash: c.hash, message: c.message }))
753
+ }
754
+
755
+ // Fallback: no origin/confluence branch yet, use content hash comparison
249
756
  const files = yield* localFs.listMarkdownFiles(docsPath)
250
- let pushed = 0
251
- let created = 0
252
- let skipped = 0
253
- const errors: Array<string> = []
757
+ if (files.length === 0) return []
254
758
 
255
- for (const filePath of files) {
256
- yield* Effect.gen(function*() {
257
- const localFile = yield* localFs.readMarkdownFile(filePath)
759
+ const allCommits = yield* git.log({ n: 100 })
760
+ if (allCommits.length === 0) return []
258
761
 
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
- }
762
+ const unpushed: Array<{ hash: string; message: string }> = []
270
763
 
271
- const fm = localFile.frontMatter
272
- const currentHash = computeHash(localFile.content)
764
+ for (const commit of allCommits) {
765
+ yield* git.checkout(commit.hash)
273
766
 
274
- if (currentHash === fm.contentHash) {
275
- skipped++
276
- return
277
- }
767
+ let hasChanges = false
768
+ for (const filePath of files) {
769
+ const exists = yield* localFs.exists(filePath)
770
+ if (!exists) continue
278
771
 
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
- })
772
+ const localFile = yield* localFs.readMarkdownFile(filePath)
773
+ if (!localFile.frontMatter) {
774
+ hasChanges = true
775
+ break
776
+ }
296
777
 
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)
778
+ const currentHash = yield* computeHash(localFile.content).pipe(Effect.provide(HashServiceLive))
779
+ if (currentHash !== localFile.frontMatter.contentHash) {
780
+ hasChanges = true
781
+ break
305
782
  }
783
+ }
306
784
 
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
- )
785
+ if (!hasChanges) break
786
+ unpushed.push({ hash: commit.hash, message: commit.message })
317
787
  }
318
788
 
319
- return { pushed, created, skipped, errors: errors as ReadonlyArray<string> }
789
+ return unpushed.reverse()
320
790
  })
321
791
 
322
- const sync = (): Effect.Effect<SyncResult, SyncError | ConflictError> =>
792
+ const push = (options: { dryRun: boolean; message?: string }): Effect.Effect<PushResult, SyncError> =>
323
793
  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}`
794
+ // Validate structure before push
795
+ yield* validateStructure()
796
+
797
+ // Get spaceId from root page
798
+ const spaceId = yield* client.getSpaceId(config.rootPageId)
799
+
800
+ // Build pageId map for parent resolution
801
+ const pageIdMap = yield* buildPageIdMap()
802
+
803
+ const gitInitialized = yield* git.isInitialized()
804
+
805
+ // Get files and sort by depth (parent before child)
806
+ const files = yield* localFs.listMarkdownFiles(docsPath)
807
+ const sortedFiles = [...files].sort((a, b) => {
808
+ const depthA = pathService.relative(docsPath, a).split(pathService.sep).length
809
+ const depthB = pathService.relative(docsPath, b).split(pathService.sep).length
810
+ return depthA - depthB
811
+ })
812
+
813
+ if (!gitInitialized) {
814
+ // Non-git mode: just push current content
815
+ let pushed = 0
816
+ let created = 0
817
+ const errors: Array<string> = []
818
+
819
+ for (const filePath of sortedFiles) {
820
+ if (options.dryRun) {
821
+ pushed++
822
+ continue
823
+ }
824
+ const result = yield* pushFile(
825
+ filePath,
826
+ options.message ?? "Updated via confluence-to-markdown",
827
+ spaceId,
828
+ pageIdMap
829
+ ).pipe(
830
+ Effect.catchAll((error) =>
831
+ Effect.succeed({
832
+ pushed: false,
833
+ created: false,
834
+ error: `Failed: ${error._tag}`
835
+ })
836
+ )
837
+ )
838
+ if (result.error) errors.push(result.error)
839
+ if (result.pushed) pushed++
840
+ if (result.created) created++
841
+ }
842
+
843
+ return { pushed, created, deleted: 0, skipped: 0, errors: errors as ReadonlyArray<string> }
844
+ }
845
+
846
+ // Git mode: push current HEAD state to Confluence
847
+ // For simplicity, we push the final state as a single Confluence version
848
+ // with the most recent commit message
849
+ const errors: Array<string> = []
850
+ let pushed = 0
851
+ let created = 0
852
+ let deleted = 0
853
+
854
+ // Get the most recent unpushed commit message for the revision
855
+ const unpushedCommits = yield* findUnpushedCommits()
856
+ if (unpushedCommits.length === 0) {
857
+ return { pushed: 0, created: 0, skipped: 0, deleted: 0, errors: [] as ReadonlyArray<string> }
858
+ }
859
+
860
+ if (options.dryRun) {
861
+ return {
862
+ pushed: unpushedCommits.length,
863
+ created: 0,
864
+ skipped: 0,
865
+ deleted: 0,
866
+ errors: [] as ReadonlyArray<string>
867
+ }
868
+ }
869
+
870
+ // Use the last commit's message as the revision message
871
+ const lastCommit = unpushedCommits[unpushedCommits.length - 1]!
872
+ const revisionMessage = options.message ?? lastCommit.message
873
+
874
+ // Find deleted files by comparing origin/confluence with current HEAD
875
+ // Note: Git repo is inside .confluence/, so paths are relative to that
876
+ // (e.g., "docs/page.md" not ".confluence/docs/page.md")
877
+ const hasRemoteBranch = yield* git.branchExists("origin/confluence")
878
+ if (hasRemoteBranch) {
879
+ const deletedFiles = yield* git.getDeletedFiles("origin/confluence", "HEAD", "docs")
880
+
881
+ // Delete pages from Confluence
882
+ for (const deletedPath of deletedFiles) {
883
+ // Read the file from origin/confluence to get pageId
884
+ // deletedPath is already relative to git root (e.g., "docs/page.md")
885
+ const pageIdFromOrigin = yield* git.getFileContentAt(
886
+ "origin/confluence",
887
+ deletedPath
888
+ ).pipe(
889
+ Effect.map((content) => {
890
+ const match = content.match(/pageId:\s*['"]?(\d+)['"]?/)
891
+ return match ? match[1] : null
892
+ }),
893
+ Effect.catchAll(() => Effect.succeed(null))
894
+ )
895
+
896
+ if (pageIdFromOrigin) {
897
+ yield* client.deletePage(PageId(pageIdFromOrigin)).pipe(
898
+ Effect.tap(() => Effect.sync(() => deleted++)),
899
+ Effect.catchAll((error) => {
900
+ errors.push(`Failed to delete page ${pageIdFromOrigin}: ${error.message}`)
901
+ return Effect.void
902
+ })
333
903
  )
334
904
  }
335
905
  }
336
906
  }
337
907
 
338
- // Pull remote changes
339
- const pullResult = yield* pull({ force: false })
908
+ for (const filePath of sortedFiles) {
909
+ const result = yield* pushFile(filePath, revisionMessage, spaceId, pageIdMap).pipe(
910
+ Effect.catchAll((error) =>
911
+ Effect.succeed({
912
+ pushed: false,
913
+ created: false,
914
+ error: `Failed to push ${filePath}: ${error._tag}`
915
+ })
916
+ )
917
+ )
918
+ if (result.error) errors.push(result.error)
919
+ if (result.pushed) pushed++
920
+ if (result.created) created++
921
+ }
340
922
 
341
- // Push local changes
342
- const pushResult = yield* push({ dryRun: false })
923
+ // Amend the last commit with canonical content
924
+ yield* git.addAll()
925
+ yield* git.amend({ noEdit: true }).pipe(
926
+ Effect.catchAll(() => Effect.void)
927
+ )
343
928
 
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>
929
+ // Two-branch model: update origin/confluence to match HEAD
930
+ if (hasRemoteBranch) {
931
+ yield* git.updateBranch("origin/confluence", "HEAD")
350
932
  }
933
+
934
+ return { pushed, created, skipped: 0, deleted, errors: errors as ReadonlyArray<string> }
351
935
  })
352
936
 
353
937
  const status = (): Effect.Effect<StatusResult, SyncError> =>
@@ -372,7 +956,7 @@ export const layer: Layer.Layer<
372
956
  }
373
957
 
374
958
  const fm = localFile.frontMatter
375
- const currentHash = computeHash(localFile.content)
959
+ const currentHash = yield* computeHash(localFile.content).pipe(Effect.provide(HashServiceLive))
376
960
 
377
961
  // Fetch remote page
378
962
  const remotePage = yield* Effect.either(client.getPage(fm.pageId))
@@ -422,7 +1006,6 @@ export const layer: Layer.Layer<
422
1006
  return SyncEngine.of({
423
1007
  pull,
424
1008
  push,
425
- sync,
426
1009
  status
427
1010
  })
428
1011
  })