@knpkv/confluence-to-markdown 0.2.0 → 0.4.2

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 +73 -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 +366 -0
  7. package/dist/ConfluenceAuth.js.map +1 -0
  8. package/dist/ConfluenceClient.d.ts +26 -12
  9. package/dist/ConfluenceClient.d.ts.map +1 -1
  10. package/dist/ConfluenceClient.js +139 -97
  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 +99 -16
  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 +32 -21
  255. package/src/ConfluenceAuth.ts +581 -0
  256. package/src/ConfluenceClient.ts +230 -165
  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
@@ -7,9 +7,13 @@ 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 { PageId } from "./Brand.js";
10
11
  import { ConfluenceClient } from "./ConfluenceClient.js";
11
12
  import { ConfluenceConfig } from "./ConfluenceConfig.js";
12
- import { computeHash } from "./internal/hashUtils.js";
13
+ import { FileSystemError, StructureError } from "./ConfluenceError.js";
14
+ import { GitService } from "./GitService.js";
15
+ import { computeHash, HashServiceLive } from "./internal/hashUtils.js";
16
+ import { UserCache } from "./internal/userCache.js";
13
17
  import { LocalFileSystem } from "./LocalFileSystem.js";
14
18
  import { MarkdownConverter } from "./MarkdownConverter.js";
15
19
  /**
@@ -42,45 +46,194 @@ export const layer = Layer.effect(SyncEngine, Effect.gen(function* () {
42
46
  const converter = yield* MarkdownConverter;
43
47
  const localFs = yield* LocalFileSystem;
44
48
  const pathService = yield* Path.Path;
49
+ const git = yield* GitService;
50
+ const userCache = yield* UserCache;
45
51
  const docsPath = pathService.join(process.cwd(), config.docsPath);
52
+ /**
53
+ * Build a map of relative path (without .md) to pageId for resolving parents.
54
+ * e.g., "guide" -> pageId, "guide/getting-started" -> pageId
55
+ */
56
+ const buildPageIdMap = () => Effect.gen(function* () {
57
+ const files = yield* localFs.listMarkdownFiles(docsPath);
58
+ const map = new Map();
59
+ for (const filePath of files) {
60
+ const localFile = yield* localFs.readMarkdownFile(filePath);
61
+ const relativePath = pathService.relative(docsPath, filePath);
62
+ const key = relativePath.replace(/\.md$/, "");
63
+ if (localFile.frontMatter?.pageId) {
64
+ map.set(key, localFile.frontMatter.pageId);
65
+ }
66
+ }
67
+ return map;
68
+ });
69
+ /**
70
+ * Resolve parent page ID from directory structure.
71
+ * Rule: foo/ contains children of foo.md
72
+ */
73
+ const resolveParent = (filePath, pageIdMap) => Effect.gen(function* () {
74
+ const relativePath = pathService.relative(docsPath, filePath);
75
+ const dirPath = pathService.dirname(relativePath);
76
+ // Root level files -> parent is rootPageId
77
+ if (dirPath === ".") {
78
+ return config.rootPageId;
79
+ }
80
+ // Files in subdir -> parent is the directory's parent page
81
+ // e.g., "foo/bar.md" -> parent is "foo.md"
82
+ const parentKey = dirPath;
83
+ const parentPageId = pageIdMap.get(parentKey);
84
+ if (!parentPageId) {
85
+ // Check if the parent .md file exists
86
+ const parentMdPath = pathService.join(docsPath, `${parentKey}.md`);
87
+ const parentExists = yield* localFs.exists(parentMdPath);
88
+ if (!parentExists) {
89
+ return yield* Effect.fail(new StructureError({
90
+ path: filePath,
91
+ message: `Directory '${dirPath}/' has no parent page`,
92
+ advice: `Create '${parentKey}.md' first`
93
+ }));
94
+ }
95
+ // Parent file exists but has no pageId (not pushed yet)
96
+ return yield* Effect.fail(new StructureError({
97
+ path: filePath,
98
+ message: `Parent page '${parentKey}.md' not yet pushed`,
99
+ advice: `Push parent before creating children`
100
+ }));
101
+ }
102
+ return parentPageId;
103
+ });
104
+ /**
105
+ * Validate directory structure consistency.
106
+ * - Every directory foo/ must have a corresponding foo.md with pageId
107
+ */
108
+ const validateStructure = () => Effect.gen(function* () {
109
+ const files = yield* localFs.listMarkdownFiles(docsPath);
110
+ const pageIdMap = yield* buildPageIdMap();
111
+ // Build set of directories that contain files
112
+ const dirsWithFiles = new Set();
113
+ for (const filePath of files) {
114
+ const relativePath = pathService.relative(docsPath, filePath);
115
+ const dirPath = pathService.dirname(relativePath);
116
+ if (dirPath !== ".") {
117
+ dirsWithFiles.add(dirPath);
118
+ }
119
+ }
120
+ // Check each directory has a parent .md with pageId
121
+ // Rule: foo/ directory must have foo.md as parent
122
+ for (const dir of dirsWithFiles) {
123
+ const parentPageId = pageIdMap.get(dir);
124
+ if (!parentPageId) {
125
+ const parentMdPath = pathService.join(docsPath, `${dir}.md`);
126
+ const parentExists = yield* localFs.exists(parentMdPath);
127
+ if (!parentExists) {
128
+ return yield* Effect.fail(new StructureError({
129
+ path: dir,
130
+ message: `Directory '${dir}/' has no parent page`,
131
+ advice: `Create '${dir}.md' first`
132
+ }));
133
+ }
134
+ // Parent exists but not pushed - this is OK during push,
135
+ // as long as we push in order (parent before child)
136
+ }
137
+ }
138
+ // Check parentId in front-matter matches directory structure
139
+ // Only validate new pages or pages where we can determine expected parent
140
+ for (const filePath of files) {
141
+ const localFile = yield* localFs.readMarkdownFile(filePath);
142
+ if (localFile.frontMatter?.parentId) {
143
+ const relativePath = pathService.relative(docsPath, filePath);
144
+ const dirPath = pathService.dirname(relativePath);
145
+ // Determine expected parent based on directory
146
+ // foo/bar.md -> parent should be foo.md (pageIdMap key: "foo")
147
+ let expectedParentId = null;
148
+ if (dirPath === ".") {
149
+ // Root level - parent should be rootPageId
150
+ // But if the parentId points elsewhere, it might be correct Confluence hierarchy
151
+ // Only validate if it's a new page (no pageId yet)
152
+ if (!localFile.frontMatter.pageId) {
153
+ expectedParentId = config.rootPageId;
154
+ }
155
+ }
156
+ else {
157
+ const parentPageId = pageIdMap.get(dirPath);
158
+ if (parentPageId) {
159
+ expectedParentId = parentPageId;
160
+ }
161
+ // If parent not in map, skip validation (parent might be outside our tree)
162
+ }
163
+ if (expectedParentId !== null && localFile.frontMatter.parentId !== expectedParentId) {
164
+ return yield* Effect.fail(new StructureError({
165
+ path: filePath,
166
+ message: `Page parentId '${localFile.frontMatter.parentId}' does not match directory location`,
167
+ advice: `Move file to correct directory or update parentId to '${expectedParentId}'`
168
+ }));
169
+ }
170
+ }
171
+ }
172
+ });
173
+ /**
174
+ * Get user info with caching.
175
+ */
176
+ const getUser = (accountId) => userCache.getOrFetch(accountId, client.getUser).pipe(Effect.catchAll(() => Effect.succeed(undefined)));
177
+ /**
178
+ * Convert version content to markdown and front-matter.
179
+ */
180
+ const versionToMarkdown = (pageId, version, title, parentId, position) => Effect.gen(function* () {
181
+ const htmlContent = version.body?.storage?.value ?? "";
182
+ const markdown = yield* converter.htmlToMarkdown(htmlContent, {
183
+ includeRawSource: config.saveSource
184
+ });
185
+ const contentHash = yield* computeHash(markdown).pipe(Effect.provide(HashServiceLive));
186
+ // Get author info
187
+ const author = version.authorId ? yield* getUser(version.authorId) : undefined;
188
+ const frontMatter = {
189
+ pageId,
190
+ version: version.number,
191
+ title,
192
+ updated: new Date(version.createdAt),
193
+ ...(parentId ? { parentId: parentId } : {}),
194
+ ...(position !== undefined ? { position } : {}),
195
+ contentHash,
196
+ ...(version.message ? { versionMessage: version.message } : {}),
197
+ ...(author?.displayName ? { authorName: author.displayName } : {}),
198
+ ...(author?.email ? { authorEmail: author.email } : {})
199
+ };
200
+ return { markdown, frontMatter };
201
+ });
46
202
  /**
47
203
  * Pull a single page and its children recursively.
204
+ * Returns { pulled, commits } count.
48
205
  */
49
- const pullPage = (page, parentPath, force) => Effect.gen(function* () {
206
+ const pullPage = (page, parentPath, options, gitInitialized, knownParentId) => Effect.gen(function* () {
207
+ const pageId = page.id;
50
208
  // Get children to determine if this is a folder
51
- const children = yield* client.getAllChildren(page.id);
209
+ const children = yield* client.getAllChildren(pageId);
52
210
  const hasChildren = children.length > 0;
53
- const filePath = localFs.getPagePath(page.title, hasChildren, parentPath);
54
- const dirPath = hasChildren ? localFs.getPageDir(page.title, parentPath) : parentPath;
211
+ const filePath = yield* localFs.getPagePath(page.title, hasChildren, parentPath);
212
+ const dirPath = hasChildren ? yield* localFs.getPageDir(page.title, parentPath) : parentPath;
55
213
  // Get page content
56
- const fullPage = yield* client.getPage(page.id);
57
- const htmlContent = fullPage.body?.storage?.value ?? "";
58
- let markdown = yield* converter.htmlToMarkdown(htmlContent);
59
- // Add child page links for index pages
60
- if (hasChildren && config.spaceKey) {
61
- const childLinks = children
62
- .map((child) => {
63
- const pageUrl = `${config.baseUrl}/wiki/spaces/${config.spaceKey}/pages/${child.id}`;
64
- return `- [${child.title}](${pageUrl})`;
65
- })
66
- .join("\n");
67
- markdown = markdown.trim() + "\n\n## Child Pages\n\n" + childLinks + "\n";
68
- }
69
- const contentHash = computeHash(markdown);
70
- // Check if we need to update
71
- if (!force) {
214
+ const fullPage = yield* client.getPage(pageId);
215
+ // Determine parentId: use API response, fall back to known parent
216
+ const effectiveParentId = page.parentId ?? knownParentId;
217
+ // Check existing local version
218
+ let localVersion = 0;
219
+ if (!options.force) {
72
220
  const exists = yield* localFs.exists(filePath);
73
221
  if (exists) {
74
222
  const localFile = yield* localFs.readMarkdownFile(filePath);
75
- if (localFile.frontMatter &&
76
- localFile.frontMatter.version === fullPage.version.number &&
77
- localFile.frontMatter.contentHash === contentHash) {
78
- // Skip - already in sync
79
- let count = 0;
80
- for (const child of children) {
81
- count += yield* pullPage(child, dirPath, force);
223
+ if (localFile.frontMatter) {
224
+ localVersion = localFile.frontMatter.version;
225
+ // If local is already at remote version, skip
226
+ if (localVersion >= fullPage.version.number) {
227
+ let childPulled = 0;
228
+ let childCommits = 0;
229
+ for (const child of children) {
230
+ // Pass current page's ID as parent for children
231
+ const result = yield* pullPage(child, dirPath, options, gitInitialized, pageId);
232
+ childPulled += result.pulled;
233
+ childCommits += result.commits;
234
+ }
235
+ return { pulled: childPulled, commits: childCommits };
82
236
  }
83
- return count;
84
237
  }
85
238
  }
86
239
  }
@@ -88,113 +241,410 @@ export const layer = Layer.effect(SyncEngine, Effect.gen(function* () {
88
241
  if (hasChildren) {
89
242
  yield* localFs.ensureDir(dirPath);
90
243
  }
91
- // Write file
92
- const frontMatter = {
93
- pageId: page.id,
94
- version: fullPage.version.number,
95
- title: fullPage.title,
96
- updated: fullPage.version.createdAt ? new Date(fullPage.version.createdAt) : new Date(),
97
- ...(page.parentId ? { parentId: page.parentId } : {}),
98
- ...(page.position !== undefined ? { position: page.position } : {}),
99
- contentHash
100
- };
101
- yield* localFs.writeMarkdownFile(filePath, frontMatter, markdown);
244
+ let totalCommits = 0;
245
+ // If history replay is enabled and git is initialized, replay versions
246
+ let historyReplayFailed = false;
247
+ if (options.replayHistory && gitInitialized && localVersion < fullPage.version.number) {
248
+ // Fetch versions with body content since localVersion
249
+ const versions = yield* client.getPageVersions(pageId, { since: localVersion, includeBody: true });
250
+ // Sort by version number (oldest first)
251
+ const sortedVersions = [...versions].sort((a, b) => a.number - b.number);
252
+ const totalVersions = sortedVersions.length;
253
+ let versionIdx = 0;
254
+ for (const versionInfo of sortedVersions) {
255
+ versionIdx++;
256
+ // Report progress
257
+ if (options.onProgress) {
258
+ options.onProgress(versionIdx, totalVersions, `${fullPage.title} v${versionInfo.number}`);
259
+ }
260
+ // Check if body content is available from the versions list
261
+ const bodyContent = versionInfo.page?.body?.storage?.value;
262
+ if (!bodyContent) {
263
+ // No body content available - history replay not supported
264
+ historyReplayFailed = true;
265
+ break;
266
+ }
267
+ // Build version content from the list response
268
+ const versionContent = {
269
+ number: versionInfo.number,
270
+ authorId: versionInfo.authorId,
271
+ createdAt: versionInfo.createdAt,
272
+ message: versionInfo.message,
273
+ body: {
274
+ storage: {
275
+ value: bodyContent,
276
+ representation: "storage"
277
+ }
278
+ }
279
+ };
280
+ const { frontMatter, markdown } = yield* versionToMarkdown(pageId, versionContent, versionInfo.page?.title ?? fullPage.title, effectiveParentId, page.position);
281
+ // Write file
282
+ yield* localFs.writeMarkdownFile(filePath, frontMatter, markdown);
283
+ // Save source HTML if configured
284
+ if (config.saveSource && versionContent.body?.storage?.value) {
285
+ const sourceFilePath = filePath.replace(/\.md$/, ".html");
286
+ yield* localFs.writeFile(sourceFilePath, versionContent.body.storage.value);
287
+ }
288
+ // Commit this version
289
+ const author = versionInfo.authorId ? yield* getUser(versionInfo.authorId) : undefined;
290
+ const commitMessage = versionInfo.message || `Update ${fullPage.title} (v${versionInfo.number})`;
291
+ yield* git.addAll();
292
+ const commitOptions = author
293
+ ? {
294
+ message: commitMessage,
295
+ author: { name: author.displayName, email: author.email ?? "unknown@atlassian.com" },
296
+ date: new Date(versionInfo.createdAt)
297
+ }
298
+ : { message: commitMessage, date: new Date(versionInfo.createdAt) };
299
+ yield* git.commit(commitOptions).pipe(Effect.catchTag("GitNoChangesError", () => Effect.void));
300
+ totalCommits++;
301
+ }
302
+ }
303
+ // Simple pull - either history replay is disabled, not initialized, or body not available
304
+ const needsSimplePull = historyReplayFailed || !options.replayHistory || !gitInitialized ||
305
+ localVersion >= fullPage.version.number;
306
+ if (needsSimplePull) {
307
+ if (historyReplayFailed) {
308
+ yield* Effect.logWarning("History replay not available. Confluence Cloud API does not return body content for historical versions. Falling back to simple pull.");
309
+ }
310
+ // Simple pull without history replay
311
+ const htmlContent = fullPage.body?.storage?.value ?? "";
312
+ let markdown = yield* converter.htmlToMarkdown(htmlContent, {
313
+ includeRawSource: config.saveSource
314
+ });
315
+ // Add child page links for index pages
316
+ if (hasChildren && config.spaceKey) {
317
+ const childLinks = children
318
+ .map((child) => {
319
+ const pageUrl = `${config.baseUrl}/wiki/spaces/${config.spaceKey}/pages/${child.id}`;
320
+ return `- [${child.title}](${pageUrl})`;
321
+ })
322
+ .join("\n");
323
+ markdown = markdown.trim() + "\n\n## Child Pages\n\n" + childLinks + "\n";
324
+ }
325
+ const contentHash = yield* computeHash(markdown).pipe(Effect.provide(HashServiceLive));
326
+ // Get author info
327
+ const author = fullPage.version.authorId ? yield* getUser(fullPage.version.authorId) : undefined;
328
+ // Write file
329
+ const frontMatter = {
330
+ pageId,
331
+ version: fullPage.version.number,
332
+ title: fullPage.title,
333
+ updated: fullPage.version.createdAt ? new Date(fullPage.version.createdAt) : new Date(),
334
+ ...(effectiveParentId ? { parentId: effectiveParentId } : {}),
335
+ ...(page.position !== undefined ? { position: page.position } : {}),
336
+ contentHash,
337
+ ...(fullPage.version.message ? { versionMessage: fullPage.version.message } : {}),
338
+ ...(author?.displayName ? { authorName: author.displayName } : {}),
339
+ ...(author?.email ? { authorEmail: author.email } : {})
340
+ };
341
+ yield* localFs.writeMarkdownFile(filePath, frontMatter, markdown);
342
+ // Save source HTML if configured
343
+ if (config.saveSource && htmlContent) {
344
+ const sourceFilePath = filePath.replace(/\.md$/, ".html");
345
+ yield* localFs.writeFile(sourceFilePath, htmlContent);
346
+ }
347
+ }
102
348
  // Pull children
103
- let count = 1;
349
+ let childPulled = 0;
350
+ let childCommits = 0;
104
351
  for (const child of children) {
105
- count += yield* pullPage(child, dirPath, force);
352
+ // Pass current page's ID as parent for children
353
+ const result = yield* pullPage(child, dirPath, options, gitInitialized, pageId);
354
+ childPulled += result.pulled;
355
+ childCommits += result.commits;
106
356
  }
107
- return count;
357
+ return { pulled: 1 + childPulled, commits: totalCommits + childCommits };
108
358
  });
109
359
  const pull = (options) => Effect.gen(function* () {
110
360
  yield* localFs.ensureDir(docsPath);
361
+ // Check if git is initialized
362
+ const gitInitialized = yield* git.isInitialized();
363
+ // Two-branch model: if origin/confluence exists, work on that branch first
364
+ const hasRemoteBranch = gitInitialized
365
+ ? yield* git.branchExists("origin/confluence")
366
+ : false;
367
+ const originalBranch = gitInitialized ? yield* git.getCurrentBranch() : "";
368
+ if (hasRemoteBranch) {
369
+ // Switch to origin/confluence to pull updates there
370
+ yield* git.checkout("origin/confluence");
371
+ }
111
372
  const rootPage = yield* client.getPage(config.rootPageId);
112
- const pulled = yield* pullPage(rootPage, docsPath, options.force);
373
+ const result = yield* pullPage(rootPage, docsPath, options, gitInitialized);
374
+ // If git is initialized and we have changes but didn't replay history, auto-commit
375
+ if (gitInitialized && !options.replayHistory && result.pulled > 0) {
376
+ yield* git.addAll();
377
+ yield* git.commit({
378
+ message: `Pull from Confluence (${result.pulled} page${result.pulled !== 1 ? "s" : ""})`
379
+ }).pipe(Effect.catchTag("GitNoChangesError", () => Effect.void));
380
+ }
381
+ // Two-branch model: merge origin/confluence into current branch
382
+ if (hasRemoteBranch && originalBranch && originalBranch !== "origin/confluence") {
383
+ yield* git.checkout(originalBranch);
384
+ yield* git.merge("origin/confluence", {
385
+ message: `Merge remote changes from Confluence`
386
+ }).pipe(Effect.catchAll(() => Effect.void)); // May fail if no changes
387
+ }
113
388
  return {
114
- pulled,
389
+ pulled: result.pulled,
115
390
  skipped: 0,
391
+ commits: result.commits,
116
392
  errors: []
117
393
  };
118
394
  });
119
- const push = (options) => Effect.gen(function* () {
395
+ /**
396
+ * Push a single file's content to Confluence.
397
+ * Returns the canonical content after push.
398
+ */
399
+ const pushFile = (filePath, revisionMessage, spaceId, pageIdMap) => Effect.gen(function* () {
400
+ const localFile = yield* localFs.readMarkdownFile(filePath);
401
+ // Handle new page creation
402
+ if (localFile.isNew || !localFile.frontMatter) {
403
+ // Get title from front-matter or filename
404
+ const relativePath = pathService.relative(docsPath, filePath);
405
+ const baseName = pathService.basename(filePath, ".md");
406
+ // For new pages, re-parse front-matter to get title
407
+ // The localFile only has the content (body), not the original front-matter
408
+ const title = yield* Effect.tryPromise({
409
+ try: async () => {
410
+ const fs = await import("node:fs/promises");
411
+ const matter = await import("gray-matter");
412
+ const rawFile = await fs.readFile(filePath, "utf-8");
413
+ const parsed = matter.default(rawFile);
414
+ return parsed.data.title ?? baseName;
415
+ },
416
+ catch: (cause) => new FileSystemError({ operation: "read", path: filePath, cause })
417
+ });
418
+ // Resolve parent from directory structure
419
+ const parentId = yield* resolveParent(filePath, pageIdMap);
420
+ // Convert markdown to HTML
421
+ const html = yield* converter.markdownToHtml(localFile.content);
422
+ // Create the page
423
+ const createdPage = yield* client.createPage({
424
+ spaceId,
425
+ parentId,
426
+ title,
427
+ body: {
428
+ representation: "storage",
429
+ value: html
430
+ }
431
+ });
432
+ // Set editor version to v2 (new editor)
433
+ yield* client.setEditorVersion(createdPage.id, "v2").pipe(Effect.catchAll((error) => {
434
+ // Log warning but don't fail the push
435
+ return Effect.logWarning(`Failed to set editor v2 for page ${createdPage.id}: ${error.message}`);
436
+ }));
437
+ // Fetch canonical content back from Confluence
438
+ const canonicalPage = yield* client.getPage(createdPage.id);
439
+ const canonicalHtml = canonicalPage.body?.storage?.value ?? "";
440
+ const canonicalMarkdown = yield* converter.htmlToMarkdown(canonicalHtml, {
441
+ includeRawSource: config.saveSource
442
+ });
443
+ const canonicalHash = yield* computeHash(canonicalMarkdown).pipe(Effect.provide(HashServiceLive));
444
+ // Write canonical content with full front-matter
445
+ const newFrontMatter = {
446
+ pageId: createdPage.id,
447
+ version: createdPage.version.number,
448
+ title,
449
+ updated: new Date(canonicalPage.version.createdAt ?? new Date().toISOString()),
450
+ parentId: parentId,
451
+ contentHash: canonicalHash
452
+ };
453
+ yield* localFs.writeMarkdownFile(filePath, newFrontMatter, canonicalMarkdown);
454
+ // Update pageIdMap with new page
455
+ const key = relativePath.replace(/\.md$/, "");
456
+ pageIdMap.set(key, createdPage.id);
457
+ return { pushed: false, created: true, newPageId: createdPage.id };
458
+ }
459
+ const fm = localFile.frontMatter;
460
+ const currentHash = yield* computeHash(localFile.content).pipe(Effect.provide(HashServiceLive));
461
+ if (currentHash === fm.contentHash) {
462
+ return { pushed: false, created: false };
463
+ }
464
+ // Fetch current version to avoid conflicts
465
+ const remotePage = yield* client.getPage(fm.pageId);
466
+ const html = yield* converter.markdownToHtml(localFile.content);
467
+ const updatedPage = yield* client.updatePage({
468
+ id: fm.pageId,
469
+ title: fm.title,
470
+ status: "current",
471
+ version: {
472
+ number: remotePage.version.number + 1,
473
+ message: revisionMessage
474
+ },
475
+ body: {
476
+ representation: "storage",
477
+ value: html
478
+ }
479
+ });
480
+ // Fetch canonical content back from Confluence
481
+ const canonicalPage = yield* client.getPage(fm.pageId);
482
+ const canonicalHtml = canonicalPage.body?.storage?.value ?? "";
483
+ const canonicalMarkdown = yield* converter.htmlToMarkdown(canonicalHtml, {
484
+ includeRawSource: config.saveSource
485
+ });
486
+ const canonicalHash = yield* computeHash(canonicalMarkdown).pipe(Effect.provide(HashServiceLive));
487
+ // Write canonical content with updated front-matter
488
+ const newFrontMatter = {
489
+ ...fm,
490
+ version: updatedPage.version.number,
491
+ updated: new Date(canonicalPage.version.createdAt ?? new Date().toISOString()),
492
+ contentHash: canonicalHash
493
+ };
494
+ yield* localFs.writeMarkdownFile(filePath, newFrontMatter, canonicalMarkdown);
495
+ return { pushed: true, created: false };
496
+ });
497
+ /**
498
+ * Find commits that have unpushed changes.
499
+ * Uses two-branch model: finds commits in current branch not in origin/confluence.
500
+ * Returns commits from oldest to newest.
501
+ */
502
+ const findUnpushedCommits = () => Effect.gen(function* () {
503
+ // Two-branch model: find commits in current branch not in origin/confluence
504
+ const hasRemoteBranch = yield* git.branchExists("origin/confluence");
505
+ if (hasRemoteBranch) {
506
+ // Use logRange to find commits not in origin/confluence
507
+ const commits = yield* git.logRange("origin/confluence", "HEAD");
508
+ return commits.map((c) => ({ hash: c.hash, message: c.message }));
509
+ }
510
+ // Fallback: no origin/confluence branch yet, use content hash comparison
120
511
  const files = yield* localFs.listMarkdownFiles(docsPath);
121
- let pushed = 0;
122
- let created = 0;
123
- let skipped = 0;
124
- const errors = [];
125
- for (const filePath of files) {
126
- yield* Effect.gen(function* () {
512
+ if (files.length === 0)
513
+ return [];
514
+ const allCommits = yield* git.log({ n: 100 });
515
+ if (allCommits.length === 0)
516
+ return [];
517
+ const unpushed = [];
518
+ for (const commit of allCommits) {
519
+ yield* git.checkout(commit.hash);
520
+ let hasChanges = false;
521
+ for (const filePath of files) {
522
+ const exists = yield* localFs.exists(filePath);
523
+ if (!exists)
524
+ continue;
127
525
  const localFile = yield* localFs.readMarkdownFile(filePath);
128
- if (localFile.isNew || !localFile.frontMatter) {
129
- // New file - create page
130
- if (!options.dryRun) {
131
- // For now, skip page creation - need space ID in config
132
- errors.push(`Page creation requires space ID in config (not yet supported): ${filePath}`);
133
- }
134
- created++;
135
- return;
526
+ if (!localFile.frontMatter) {
527
+ hasChanges = true;
528
+ break;
136
529
  }
137
- const fm = localFile.frontMatter;
138
- const currentHash = computeHash(localFile.content);
139
- if (currentHash === fm.contentHash) {
140
- skipped++;
141
- return;
530
+ const currentHash = yield* computeHash(localFile.content).pipe(Effect.provide(HashServiceLive));
531
+ if (currentHash !== localFile.frontMatter.contentHash) {
532
+ hasChanges = true;
533
+ break;
142
534
  }
143
- if (!options.dryRun) {
144
- // Fetch current version to avoid conflicts
145
- const remotePage = yield* client.getPage(fm.pageId);
146
- const html = yield* converter.markdownToHtml(localFile.content);
147
- const updatedPage = yield* client.updatePage({
148
- id: fm.pageId,
149
- title: fm.title,
150
- status: "current",
151
- version: {
152
- number: remotePage.version.number + 1,
153
- message: "Updated via confluence-to-markdown"
154
- },
155
- body: {
156
- representation: "storage",
157
- value: html
158
- }
159
- });
160
- // Update front-matter with new version
161
- const newFrontMatter = {
162
- ...fm,
163
- version: updatedPage.version.number,
164
- updated: new Date(),
165
- contentHash: currentHash
166
- };
167
- yield* localFs.writeMarkdownFile(filePath, newFrontMatter, localFile.content);
168
- }
169
- pushed++;
170
- }).pipe(Effect.catchAll((error) => Effect.sync(() => {
171
- errors.push(`Failed to push ${filePath}: ${error._tag === "ApiError" ? error.message : error._tag}`);
172
- })));
535
+ }
536
+ if (!hasChanges)
537
+ break;
538
+ unpushed.push({ hash: commit.hash, message: commit.message });
173
539
  }
174
- return { pushed, created, skipped, errors: errors };
540
+ return unpushed.reverse();
175
541
  });
176
- const sync = () => Effect.gen(function* () {
177
- // First, check for conflicts
178
- const statusResult = yield* status();
179
- const conflictErrors = [];
180
- if (statusResult.conflicts > 0) {
181
- for (const file of statusResult.files) {
182
- if (file._tag === "Conflict") {
183
- conflictErrors.push(`Conflict in ${file.path}: local v${file.localVersion} vs remote v${file.remoteVersion}`);
542
+ const push = (options) => Effect.gen(function* () {
543
+ // Validate structure before push
544
+ yield* validateStructure();
545
+ // Get spaceId from root page
546
+ const spaceId = yield* client.getSpaceId(config.rootPageId);
547
+ // Build pageId map for parent resolution
548
+ const pageIdMap = yield* buildPageIdMap();
549
+ const gitInitialized = yield* git.isInitialized();
550
+ // Get files and sort by depth (parent before child)
551
+ const files = yield* localFs.listMarkdownFiles(docsPath);
552
+ const sortedFiles = [...files].sort((a, b) => {
553
+ const depthA = pathService.relative(docsPath, a).split(pathService.sep).length;
554
+ const depthB = pathService.relative(docsPath, b).split(pathService.sep).length;
555
+ return depthA - depthB;
556
+ });
557
+ if (!gitInitialized) {
558
+ // Non-git mode: just push current content
559
+ let pushed = 0;
560
+ let created = 0;
561
+ const errors = [];
562
+ for (const filePath of sortedFiles) {
563
+ if (options.dryRun) {
564
+ pushed++;
565
+ continue;
184
566
  }
567
+ const result = yield* pushFile(filePath, options.message ?? "Updated via confluence-to-markdown", spaceId, pageIdMap).pipe(Effect.catchAll((error) => Effect.succeed({
568
+ pushed: false,
569
+ created: false,
570
+ error: `Failed: ${error._tag}`
571
+ })));
572
+ if (result.error)
573
+ errors.push(result.error);
574
+ if (result.pushed)
575
+ pushed++;
576
+ if (result.created)
577
+ created++;
185
578
  }
579
+ return { pushed, created, deleted: 0, skipped: 0, errors: errors };
186
580
  }
187
- // Pull remote changes
188
- const pullResult = yield* pull({ force: false });
189
- // Push local changes
190
- const pushResult = yield* push({ dryRun: false });
191
- return {
192
- pulled: pullResult.pulled,
193
- pushed: pushResult.pushed,
194
- created: pushResult.created,
195
- conflicts: statusResult.conflicts,
196
- errors: [...conflictErrors, ...pullResult.errors, ...pushResult.errors]
197
- };
581
+ // Git mode: push current HEAD state to Confluence
582
+ // For simplicity, we push the final state as a single Confluence version
583
+ // with the most recent commit message
584
+ const errors = [];
585
+ let pushed = 0;
586
+ let created = 0;
587
+ let deleted = 0;
588
+ // Get the most recent unpushed commit message for the revision
589
+ const unpushedCommits = yield* findUnpushedCommits();
590
+ if (unpushedCommits.length === 0) {
591
+ return { pushed: 0, created: 0, skipped: 0, deleted: 0, errors: [] };
592
+ }
593
+ if (options.dryRun) {
594
+ return {
595
+ pushed: unpushedCommits.length,
596
+ created: 0,
597
+ skipped: 0,
598
+ deleted: 0,
599
+ errors: []
600
+ };
601
+ }
602
+ // Use the last commit's message as the revision message
603
+ const lastCommit = unpushedCommits[unpushedCommits.length - 1];
604
+ const revisionMessage = options.message ?? lastCommit.message;
605
+ // Find deleted files by comparing origin/confluence with current HEAD
606
+ // Note: Git repo is inside .confluence/, so paths are relative to that
607
+ // (e.g., "docs/page.md" not ".confluence/docs/page.md")
608
+ const hasRemoteBranch = yield* git.branchExists("origin/confluence");
609
+ if (hasRemoteBranch) {
610
+ const deletedFiles = yield* git.getDeletedFiles("origin/confluence", "HEAD", "docs");
611
+ // Delete pages from Confluence
612
+ for (const deletedPath of deletedFiles) {
613
+ // Read the file from origin/confluence to get pageId
614
+ // deletedPath is already relative to git root (e.g., "docs/page.md")
615
+ const pageIdFromOrigin = yield* git.getFileContentAt("origin/confluence", deletedPath).pipe(Effect.map((content) => {
616
+ const match = content.match(/pageId:\s*['"]?(\d+)['"]?/);
617
+ return match ? match[1] : null;
618
+ }), Effect.catchAll(() => Effect.succeed(null)));
619
+ if (pageIdFromOrigin) {
620
+ yield* client.deletePage(PageId(pageIdFromOrigin)).pipe(Effect.tap(() => Effect.sync(() => deleted++)), Effect.catchAll((error) => {
621
+ errors.push(`Failed to delete page ${pageIdFromOrigin}: ${error.message}`);
622
+ return Effect.void;
623
+ }));
624
+ }
625
+ }
626
+ }
627
+ for (const filePath of sortedFiles) {
628
+ const result = yield* pushFile(filePath, revisionMessage, spaceId, pageIdMap).pipe(Effect.catchAll((error) => Effect.succeed({
629
+ pushed: false,
630
+ created: false,
631
+ error: `Failed to push ${filePath}: ${error._tag}`
632
+ })));
633
+ if (result.error)
634
+ errors.push(result.error);
635
+ if (result.pushed)
636
+ pushed++;
637
+ if (result.created)
638
+ created++;
639
+ }
640
+ // Amend the last commit with canonical content
641
+ yield* git.addAll();
642
+ yield* git.amend({ noEdit: true }).pipe(Effect.catchAll(() => Effect.void));
643
+ // Two-branch model: update origin/confluence to match HEAD
644
+ if (hasRemoteBranch) {
645
+ yield* git.updateBranch("origin/confluence", "HEAD");
646
+ }
647
+ return { pushed, created, skipped: 0, deleted, errors: errors };
198
648
  });
199
649
  const status = () => Effect.gen(function* () {
200
650
  const files = yield* localFs.listMarkdownFiles(docsPath);
@@ -213,7 +663,7 @@ export const layer = Layer.effect(SyncEngine, Effect.gen(function* () {
213
663
  continue;
214
664
  }
215
665
  const fm = localFile.frontMatter;
216
- const currentHash = computeHash(localFile.content);
666
+ const currentHash = yield* computeHash(localFile.content).pipe(Effect.provide(HashServiceLive));
217
667
  // Fetch remote page
218
668
  const remotePage = yield* Effect.either(client.getPage(fm.pageId));
219
669
  if (remotePage._tag === "Left") {
@@ -260,7 +710,6 @@ export const layer = Layer.effect(SyncEngine, Effect.gen(function* () {
260
710
  return SyncEngine.of({
261
711
  pull,
262
712
  push,
263
- sync,
264
713
  status
265
714
  });
266
715
  }));