docxmlater 10.1.4 → 10.1.6

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 (372) hide show
  1. package/README.md +759 -754
  2. package/dist/constants/legacyCompatFlags.js +1 -1
  3. package/dist/constants/legacyCompatFlags.js.map +1 -1
  4. package/dist/constants/limits.js.map +1 -1
  5. package/dist/core/Document.d.ts +51 -50
  6. package/dist/core/Document.d.ts.map +1 -1
  7. package/dist/core/Document.js +486 -471
  8. package/dist/core/Document.js.map +1 -1
  9. package/dist/core/DocumentContent.d.ts +9 -9
  10. package/dist/core/DocumentContent.d.ts.map +1 -1
  11. package/dist/core/DocumentContent.js +1 -1
  12. package/dist/core/DocumentContent.js.map +1 -1
  13. package/dist/core/DocumentGenerator.d.ts +11 -11
  14. package/dist/core/DocumentGenerator.d.ts.map +1 -1
  15. package/dist/core/DocumentGenerator.js +251 -251
  16. package/dist/core/DocumentGenerator.js.map +1 -1
  17. package/dist/core/DocumentIdManager.js.map +1 -1
  18. package/dist/core/DocumentParser.d.ts +15 -15
  19. package/dist/core/DocumentParser.d.ts.map +1 -1
  20. package/dist/core/DocumentParser.js +2123 -2155
  21. package/dist/core/DocumentParser.js.map +1 -1
  22. package/dist/core/DocumentValidator.d.ts.map +1 -1
  23. package/dist/core/DocumentValidator.js +2 -5
  24. package/dist/core/DocumentValidator.js.map +1 -1
  25. package/dist/core/Relationship.js.map +1 -1
  26. package/dist/core/RelationshipManager.d.ts.map +1 -1
  27. package/dist/core/RelationshipManager.js +3 -3
  28. package/dist/core/RelationshipManager.js.map +1 -1
  29. package/dist/elements/AlternateContent.js.map +1 -1
  30. package/dist/elements/Bookmark.d.ts.map +1 -1
  31. package/dist/elements/Bookmark.js +3 -1
  32. package/dist/elements/Bookmark.js.map +1 -1
  33. package/dist/elements/BookmarkManager.d.ts.map +1 -1
  34. package/dist/elements/BookmarkManager.js.map +1 -1
  35. package/dist/elements/Comment.d.ts.map +1 -1
  36. package/dist/elements/Comment.js +9 -6
  37. package/dist/elements/Comment.js.map +1 -1
  38. package/dist/elements/CommentManager.d.ts.map +1 -1
  39. package/dist/elements/CommentManager.js +18 -17
  40. package/dist/elements/CommentManager.js.map +1 -1
  41. package/dist/elements/CommonTypes.d.ts +21 -21
  42. package/dist/elements/CommonTypes.d.ts.map +1 -1
  43. package/dist/elements/CommonTypes.js +56 -56
  44. package/dist/elements/CommonTypes.js.map +1 -1
  45. package/dist/elements/CustomXml.js.map +1 -1
  46. package/dist/elements/Endnote.d.ts.map +1 -1
  47. package/dist/elements/Endnote.js +6 -6
  48. package/dist/elements/Endnote.js.map +1 -1
  49. package/dist/elements/EndnoteManager.d.ts.map +1 -1
  50. package/dist/elements/EndnoteManager.js +6 -7
  51. package/dist/elements/EndnoteManager.js.map +1 -1
  52. package/dist/elements/Field.d.ts.map +1 -1
  53. package/dist/elements/Field.js +82 -25
  54. package/dist/elements/Field.js.map +1 -1
  55. package/dist/elements/FieldHelpers.d.ts.map +1 -1
  56. package/dist/elements/FieldHelpers.js.map +1 -1
  57. package/dist/elements/FontManager.d.ts.map +1 -1
  58. package/dist/elements/FontManager.js +1 -1
  59. package/dist/elements/FontManager.js.map +1 -1
  60. package/dist/elements/Footer.js +2 -2
  61. package/dist/elements/Footer.js.map +1 -1
  62. package/dist/elements/Footnote.d.ts.map +1 -1
  63. package/dist/elements/Footnote.js +6 -6
  64. package/dist/elements/Footnote.js.map +1 -1
  65. package/dist/elements/FootnoteManager.d.ts.map +1 -1
  66. package/dist/elements/FootnoteManager.js +6 -7
  67. package/dist/elements/FootnoteManager.js.map +1 -1
  68. package/dist/elements/Header.js +2 -2
  69. package/dist/elements/Header.js.map +1 -1
  70. package/dist/elements/HeaderFooterManager.js.map +1 -1
  71. package/dist/elements/Hyperlink.d.ts +5 -3
  72. package/dist/elements/Hyperlink.d.ts.map +1 -1
  73. package/dist/elements/Hyperlink.js +134 -76
  74. package/dist/elements/Hyperlink.js.map +1 -1
  75. package/dist/elements/Image.d.ts.map +1 -1
  76. package/dist/elements/Image.js +238 -106
  77. package/dist/elements/Image.js.map +1 -1
  78. package/dist/elements/ImageManager.d.ts.map +1 -1
  79. package/dist/elements/ImageManager.js +1 -1
  80. package/dist/elements/ImageManager.js.map +1 -1
  81. package/dist/elements/ImageRun.js +1 -1
  82. package/dist/elements/ImageRun.js.map +1 -1
  83. package/dist/elements/MathElement.js.map +1 -1
  84. package/dist/elements/Paragraph.d.ts +24 -24
  85. package/dist/elements/Paragraph.d.ts.map +1 -1
  86. package/dist/elements/Paragraph.js +181 -188
  87. package/dist/elements/Paragraph.js.map +1 -1
  88. package/dist/elements/PreservedElement.js.map +1 -1
  89. package/dist/elements/PropertyChangeTypes.d.ts.map +1 -1
  90. package/dist/elements/PropertyChangeTypes.js +6 -6
  91. package/dist/elements/PropertyChangeTypes.js.map +1 -1
  92. package/dist/elements/RangeMarker.d.ts.map +1 -1
  93. package/dist/elements/RangeMarker.js.map +1 -1
  94. package/dist/elements/Revision.d.ts.map +1 -1
  95. package/dist/elements/Revision.js +4 -5
  96. package/dist/elements/Revision.js.map +1 -1
  97. package/dist/elements/RevisionContent.js.map +1 -1
  98. package/dist/elements/RevisionManager.d.ts.map +1 -1
  99. package/dist/elements/RevisionManager.js +40 -48
  100. package/dist/elements/RevisionManager.js.map +1 -1
  101. package/dist/elements/Run.d.ts +16 -16
  102. package/dist/elements/Run.d.ts.map +1 -1
  103. package/dist/elements/Run.js +256 -238
  104. package/dist/elements/Run.js.map +1 -1
  105. package/dist/elements/Section.d.ts.map +1 -1
  106. package/dist/elements/Section.js +36 -11
  107. package/dist/elements/Section.js.map +1 -1
  108. package/dist/elements/Shape.d.ts.map +1 -1
  109. package/dist/elements/Shape.js.map +1 -1
  110. package/dist/elements/StructuredDocumentTag.d.ts +6 -6
  111. package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
  112. package/dist/elements/StructuredDocumentTag.js +99 -104
  113. package/dist/elements/StructuredDocumentTag.js.map +1 -1
  114. package/dist/elements/Table.d.ts +11 -11
  115. package/dist/elements/Table.d.ts.map +1 -1
  116. package/dist/elements/Table.js +102 -107
  117. package/dist/elements/Table.js.map +1 -1
  118. package/dist/elements/TableCell.d.ts +10 -10
  119. package/dist/elements/TableCell.d.ts.map +1 -1
  120. package/dist/elements/TableCell.js +105 -106
  121. package/dist/elements/TableCell.js.map +1 -1
  122. package/dist/elements/TableGridChange.d.ts.map +1 -1
  123. package/dist/elements/TableGridChange.js.map +1 -1
  124. package/dist/elements/TableOfContents.d.ts.map +1 -1
  125. package/dist/elements/TableOfContents.js +4 -4
  126. package/dist/elements/TableOfContents.js.map +1 -1
  127. package/dist/elements/TableOfContentsElement.js.map +1 -1
  128. package/dist/elements/TableRow.d.ts.map +1 -1
  129. package/dist/elements/TableRow.js +13 -6
  130. package/dist/elements/TableRow.js.map +1 -1
  131. package/dist/elements/TextBox.d.ts.map +1 -1
  132. package/dist/elements/TextBox.js +3 -5
  133. package/dist/elements/TextBox.js.map +1 -1
  134. package/dist/formatting/AbstractNumbering.d.ts +4 -4
  135. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  136. package/dist/formatting/AbstractNumbering.js +54 -49
  137. package/dist/formatting/AbstractNumbering.js.map +1 -1
  138. package/dist/formatting/NumberingInstance.d.ts.map +1 -1
  139. package/dist/formatting/NumberingInstance.js +1 -3
  140. package/dist/formatting/NumberingInstance.js.map +1 -1
  141. package/dist/formatting/NumberingLevel.d.ts +5 -5
  142. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  143. package/dist/formatting/NumberingLevel.js +119 -125
  144. package/dist/formatting/NumberingLevel.js.map +1 -1
  145. package/dist/formatting/NumberingManager.d.ts +1 -0
  146. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  147. package/dist/formatting/NumberingManager.js +27 -9
  148. package/dist/formatting/NumberingManager.js.map +1 -1
  149. package/dist/formatting/Style.d.ts +11 -11
  150. package/dist/formatting/Style.d.ts.map +1 -1
  151. package/dist/formatting/Style.js +219 -247
  152. package/dist/formatting/Style.js.map +1 -1
  153. package/dist/formatting/StylesManager.d.ts +2 -2
  154. package/dist/formatting/StylesManager.d.ts.map +1 -1
  155. package/dist/formatting/StylesManager.js +96 -102
  156. package/dist/formatting/StylesManager.js.map +1 -1
  157. package/dist/helpers/CleanupHelper.d.ts +1 -1
  158. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  159. package/dist/helpers/CleanupHelper.js +6 -6
  160. package/dist/helpers/CleanupHelper.js.map +1 -1
  161. package/dist/images/ImageOptimizer.js +7 -7
  162. package/dist/images/ImageOptimizer.js.map +1 -1
  163. package/dist/index.d.ts +9 -9
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js.map +1 -1
  166. package/dist/managers/DrawingManager.js.map +1 -1
  167. package/dist/tracking/DocumentTrackingContext.d.ts.map +1 -1
  168. package/dist/tracking/DocumentTrackingContext.js +23 -7
  169. package/dist/tracking/DocumentTrackingContext.js.map +1 -1
  170. package/dist/tracking/TrackingContext.d.ts.map +1 -1
  171. package/dist/tracking/TrackingContext.js.map +1 -1
  172. package/dist/types/compatibility-types.js.map +1 -1
  173. package/dist/types/formatting.js.map +1 -1
  174. package/dist/types/list-types.d.ts +6 -6
  175. package/dist/types/list-types.js.map +1 -1
  176. package/dist/types/settings-types.js.map +1 -1
  177. package/dist/types/styleConfig.d.ts +2 -2
  178. package/dist/types/styleConfig.js.map +1 -1
  179. package/dist/utils/ChangelogGenerator.d.ts.map +1 -1
  180. package/dist/utils/ChangelogGenerator.js +97 -101
  181. package/dist/utils/ChangelogGenerator.js.map +1 -1
  182. package/dist/utils/CompatibilityUpgrader.d.ts.map +1 -1
  183. package/dist/utils/CompatibilityUpgrader.js +1 -1
  184. package/dist/utils/CompatibilityUpgrader.js.map +1 -1
  185. package/dist/utils/InMemoryRevisionAcceptor.d.ts.map +1 -1
  186. package/dist/utils/InMemoryRevisionAcceptor.js +1 -6
  187. package/dist/utils/InMemoryRevisionAcceptor.js.map +1 -1
  188. package/dist/utils/MoveOperationHelper.d.ts.map +1 -1
  189. package/dist/utils/MoveOperationHelper.js +1 -1
  190. package/dist/utils/MoveOperationHelper.js.map +1 -1
  191. package/dist/utils/RevisionAwareProcessor.d.ts.map +1 -1
  192. package/dist/utils/RevisionAwareProcessor.js +2 -4
  193. package/dist/utils/RevisionAwareProcessor.js.map +1 -1
  194. package/dist/utils/RevisionWalker.d.ts.map +1 -1
  195. package/dist/utils/RevisionWalker.js +4 -12
  196. package/dist/utils/RevisionWalker.js.map +1 -1
  197. package/dist/utils/SelectiveRevisionAcceptor.d.ts.map +1 -1
  198. package/dist/utils/SelectiveRevisionAcceptor.js +2 -6
  199. package/dist/utils/SelectiveRevisionAcceptor.js.map +1 -1
  200. package/dist/utils/ShadingResolver.d.ts.map +1 -1
  201. package/dist/utils/ShadingResolver.js +1 -1
  202. package/dist/utils/ShadingResolver.js.map +1 -1
  203. package/dist/utils/acceptRevisions.d.ts.map +1 -1
  204. package/dist/utils/acceptRevisions.js +23 -12
  205. package/dist/utils/acceptRevisions.js.map +1 -1
  206. package/dist/utils/cnfStyleDecoder.d.ts +1 -1
  207. package/dist/utils/cnfStyleDecoder.d.ts.map +1 -1
  208. package/dist/utils/cnfStyleDecoder.js +40 -40
  209. package/dist/utils/cnfStyleDecoder.js.map +1 -1
  210. package/dist/utils/corruptionDetection.d.ts.map +1 -1
  211. package/dist/utils/corruptionDetection.js.map +1 -1
  212. package/dist/utils/dateFormatting.js.map +1 -1
  213. package/dist/utils/deepClone.js +1 -1
  214. package/dist/utils/deepClone.js.map +1 -1
  215. package/dist/utils/diagnostics.d.ts.map +1 -1
  216. package/dist/utils/diagnostics.js +1 -1
  217. package/dist/utils/diagnostics.js.map +1 -1
  218. package/dist/utils/errorHandling.js.map +1 -1
  219. package/dist/utils/formatting.d.ts.map +1 -1
  220. package/dist/utils/formatting.js +10 -2
  221. package/dist/utils/formatting.js.map +1 -1
  222. package/dist/utils/list-detection.d.ts +2 -2
  223. package/dist/utils/list-detection.d.ts.map +1 -1
  224. package/dist/utils/list-detection.js +21 -23
  225. package/dist/utils/list-detection.js.map +1 -1
  226. package/dist/utils/logger.d.ts.map +1 -1
  227. package/dist/utils/logger.js +12 -7
  228. package/dist/utils/logger.js.map +1 -1
  229. package/dist/utils/parsingHelpers.js.map +1 -1
  230. package/dist/utils/stripTrackedChanges.d.ts.map +1 -1
  231. package/dist/utils/stripTrackedChanges.js +3 -3
  232. package/dist/utils/stripTrackedChanges.js.map +1 -1
  233. package/dist/utils/textDiff.d.ts +1 -1
  234. package/dist/utils/textDiff.js +8 -8
  235. package/dist/utils/textDiff.js.map +1 -1
  236. package/dist/utils/units.js.map +1 -1
  237. package/dist/utils/validation.d.ts.map +1 -1
  238. package/dist/utils/validation.js +24 -7
  239. package/dist/utils/validation.js.map +1 -1
  240. package/dist/utils/xmlSanitization.d.ts.map +1 -1
  241. package/dist/utils/xmlSanitization.js +3 -3
  242. package/dist/utils/xmlSanitization.js.map +1 -1
  243. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  244. package/dist/validation/RevisionAutoFixer.js +5 -5
  245. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  246. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  247. package/dist/validation/RevisionValidator.js +7 -9
  248. package/dist/validation/RevisionValidator.js.map +1 -1
  249. package/dist/validation/ValidationRules.js +3 -3
  250. package/dist/validation/ValidationRules.js.map +1 -1
  251. package/dist/validation/index.js.map +1 -1
  252. package/dist/xml/XMLBuilder.d.ts +1 -1
  253. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  254. package/dist/xml/XMLBuilder.js +98 -100
  255. package/dist/xml/XMLBuilder.js.map +1 -1
  256. package/dist/xml/XMLParser.d.ts.map +1 -1
  257. package/dist/xml/XMLParser.js +61 -66
  258. package/dist/xml/XMLParser.js.map +1 -1
  259. package/dist/zip/ZipHandler.d.ts.map +1 -1
  260. package/dist/zip/ZipHandler.js.map +1 -1
  261. package/dist/zip/ZipReader.d.ts.map +1 -1
  262. package/dist/zip/ZipReader.js +1 -3
  263. package/dist/zip/ZipReader.js.map +1 -1
  264. package/dist/zip/ZipWriter.d.ts +1 -1
  265. package/dist/zip/ZipWriter.d.ts.map +1 -1
  266. package/dist/zip/ZipWriter.js +28 -36
  267. package/dist/zip/ZipWriter.js.map +1 -1
  268. package/dist/zip/types.js +1 -1
  269. package/dist/zip/types.js.map +1 -1
  270. package/package.json +92 -92
  271. package/src/__tests__/helper-methods.test.ts +512 -512
  272. package/src/constants/legacyCompatFlags.ts +138 -138
  273. package/src/constants/limits.ts +50 -50
  274. package/src/core/Document.ts +1010 -1145
  275. package/src/core/DocumentContent.ts +461 -467
  276. package/src/core/DocumentGenerator.ts +1133 -1104
  277. package/src/core/DocumentIdManager.ts +158 -158
  278. package/src/core/DocumentParser.ts +2347 -2716
  279. package/src/core/DocumentValidator.ts +363 -372
  280. package/src/core/Relationship.ts +367 -367
  281. package/src/core/RelationshipManager.ts +429 -428
  282. package/src/elements/AlternateContent.ts +42 -42
  283. package/src/elements/Bookmark.ts +212 -210
  284. package/src/elements/BookmarkManager.ts +247 -250
  285. package/src/elements/Comment.ts +356 -359
  286. package/src/elements/CommentManager.ts +499 -502
  287. package/src/elements/CommonTypes.ts +524 -549
  288. package/src/elements/CustomXml.ts +36 -36
  289. package/src/elements/Endnote.ts +221 -217
  290. package/src/elements/EndnoteManager.ts +246 -249
  291. package/src/elements/Field.ts +1292 -1233
  292. package/src/elements/FieldHelpers.ts +329 -333
  293. package/src/elements/FontManager.ts +336 -339
  294. package/src/elements/Footer.ts +269 -269
  295. package/src/elements/Footnote.ts +221 -217
  296. package/src/elements/FootnoteManager.ts +246 -249
  297. package/src/elements/Header.ts +269 -269
  298. package/src/elements/HeaderFooterManager.ts +219 -219
  299. package/src/elements/Hyperlink.ts +1288 -1193
  300. package/src/elements/Image.ts +1982 -1756
  301. package/src/elements/ImageManager.ts +437 -432
  302. package/src/elements/ImageRun.ts +59 -59
  303. package/src/elements/MathElement.ts +65 -65
  304. package/src/elements/Paragraph.ts +4347 -4287
  305. package/src/elements/PreservedElement.ts +53 -53
  306. package/src/elements/PropertyChangeTypes.ts +458 -442
  307. package/src/elements/RangeMarker.ts +382 -400
  308. package/src/elements/Revision.ts +1198 -1217
  309. package/src/elements/RevisionContent.ts +73 -73
  310. package/src/elements/RevisionManager.ts +1070 -1070
  311. package/src/elements/Run.ts +3103 -3073
  312. package/src/elements/Section.ts +1521 -1421
  313. package/src/elements/Shape.ts +884 -873
  314. package/src/elements/StructuredDocumentTag.ts +1176 -1207
  315. package/src/elements/Table.ts +2468 -2524
  316. package/src/elements/TableCell.ts +1617 -1621
  317. package/src/elements/TableGridChange.ts +149 -151
  318. package/src/elements/TableOfContents.ts +701 -691
  319. package/src/elements/TableOfContentsElement.ts +89 -89
  320. package/src/elements/TableRow.ts +960 -929
  321. package/src/elements/TextBox.ts +766 -768
  322. package/src/formatting/AbstractNumbering.ts +580 -579
  323. package/src/formatting/NumberingInstance.ts +295 -299
  324. package/src/formatting/NumberingLevel.ts +981 -1040
  325. package/src/formatting/NumberingManager.ts +875 -827
  326. package/src/formatting/Style.ts +1785 -1879
  327. package/src/formatting/StylesManager.ts +1090 -1130
  328. package/src/helpers/CleanupHelper.ts +524 -524
  329. package/src/images/ImageOptimizer.ts +274 -274
  330. package/src/index.ts +559 -554
  331. package/src/managers/DrawingManager.ts +319 -319
  332. package/src/tracking/DocumentTrackingContext.ts +687 -674
  333. package/src/tracking/TrackingContext.ts +175 -173
  334. package/src/types/compatibility-types.ts +49 -49
  335. package/src/types/formatting.ts +210 -210
  336. package/src/types/list-types.ts +14 -14
  337. package/src/types/settings-types.ts +59 -59
  338. package/src/types/styleConfig.ts +189 -189
  339. package/src/utils/ChangelogGenerator.ts +1583 -1581
  340. package/src/utils/CompatibilityUpgrader.ts +235 -237
  341. package/src/utils/InMemoryRevisionAcceptor.ts +691 -696
  342. package/src/utils/MoveOperationHelper.ts +233 -238
  343. package/src/utils/RevisionAwareProcessor.ts +518 -526
  344. package/src/utils/RevisionWalker.ts +427 -457
  345. package/src/utils/SelectiveRevisionAcceptor.ts +662 -683
  346. package/src/utils/ShadingResolver.ts +105 -107
  347. package/src/utils/acceptRevisions.ts +723 -714
  348. package/src/utils/cnfStyleDecoder.ts +212 -217
  349. package/src/utils/corruptionDetection.ts +346 -345
  350. package/src/utils/dateFormatting.ts +20 -20
  351. package/src/utils/deepClone.ts +77 -78
  352. package/src/utils/diagnostics.ts +125 -129
  353. package/src/utils/errorHandling.ts +80 -80
  354. package/src/utils/formatting.ts +220 -213
  355. package/src/utils/list-detection.ts +32 -42
  356. package/src/utils/logger.ts +412 -404
  357. package/src/utils/parsingHelpers.ts +190 -190
  358. package/src/utils/stripTrackedChanges.ts +356 -353
  359. package/src/utils/textDiff.ts +100 -100
  360. package/src/utils/units.ts +421 -421
  361. package/src/utils/validation.ts +553 -542
  362. package/src/utils/xmlSanitization.ts +179 -182
  363. package/src/validation/RevisionAutoFixer.ts +541 -542
  364. package/src/validation/RevisionValidator.ts +470 -460
  365. package/src/validation/ValidationRules.ts +338 -338
  366. package/src/validation/index.ts +30 -30
  367. package/src/xml/XMLBuilder.ts +857 -871
  368. package/src/xml/XMLParser.ts +877 -919
  369. package/src/zip/ZipHandler.ts +629 -637
  370. package/src/zip/ZipReader.ts +295 -299
  371. package/src/zip/ZipWriter.ts +374 -390
  372. package/src/zip/types.ts +116 -116
@@ -1,1581 +1,1583 @@
1
- /**
2
- * ChangelogGenerator - Generates structured changelog from Word tracked changes
3
- *
4
- * Converts Word revisions (w:ins, w:del, property changes) into structured
5
- * changelog data with support for consolidation, categorization, and
6
- * multiple output formats.
7
- *
8
- * Follows ECMA-376 revision semantics.
9
- *
10
- * @module ChangelogGenerator
11
- */
12
-
13
- import type { Document } from '../core/Document';
14
- import { Revision, RevisionType } from '../elements/Revision';
15
- import { getGlobalLogger, createScopedLogger, ILogger } from './logger';
16
-
17
- // Scoped logger for ChangelogGenerator
18
- function getLogger(): ILogger {
19
- return createScopedLogger(getGlobalLogger(), 'ChangelogGenerator');
20
- }
21
-
22
- /**
23
- * Semantic category for grouping changes.
24
- */
25
- export type ChangeCategory =
26
- | 'content' // Text insertions, deletions
27
- | 'formatting' // Run/paragraph property changes
28
- | 'structural' // Moves, section changes
29
- | 'table' // Table structure changes
30
- | 'hyperlink' // Hyperlink URL, text, or formatting changes
31
- | 'image' // Image insertion, deletion, or property changes
32
- | 'field' // Field insertion, deletion, or value changes
33
- | 'comment' // Comment changes
34
- | 'bookmark' // Bookmark changes
35
- | 'contentControl'; // Content control (SDT) changes
36
-
37
- /**
38
- * Location of a change within the document.
39
- */
40
- export interface ChangeLocation {
41
- /** Section index (0-based) */
42
- sectionIndex?: number;
43
- /** Paragraph index within body (0-based) */
44
- paragraphIndex: number;
45
- /** Run index within paragraph (0-based) */
46
- runIndex?: number;
47
- /** Nearest heading for context */
48
- nearestHeading?: string;
49
- /** Character offset within paragraph */
50
- characterOffset?: number;
51
- }
52
-
53
- /**
54
- * Represents a single change entry in the changelog.
55
- * Follows ECMA-376 revision semantics.
56
- */
57
- export interface ChangeEntry {
58
- /** Unique identifier (matches revision ID) */
59
- id: string;
60
-
61
- /** ECMA-376 revision type */
62
- revisionType: RevisionType;
63
-
64
- /** Semantic category for grouping */
65
- category: ChangeCategory;
66
-
67
- /** Human-readable description */
68
- description: string;
69
-
70
- /** Author who made the change */
71
- author: string;
72
-
73
- /** ISO 8601 timestamp */
74
- date: Date;
75
-
76
- /** Location in document */
77
- location: ChangeLocation;
78
-
79
- /** Content details */
80
- content: {
81
- /** Text before change (for deletions/modifications) */
82
- before?: string;
83
- /** Text after change (for insertions/modifications) */
84
- after?: string;
85
- /** Affected text (for property changes) */
86
- affectedText?: string;
87
- };
88
-
89
- /** Property change details (for formatting changes) */
90
- propertyChange?: {
91
- property: string;
92
- oldValue?: string;
93
- newValue?: string;
94
- };
95
- }
96
-
97
- /**
98
- * Output format for changelog generation.
99
- */
100
- export type ChangelogFormat = 'json' | 'markdown' | 'text' | 'html' | 'csv';
101
-
102
- /**
103
- * Options for changelog generation.
104
- */
105
- export interface ChangelogOptions {
106
- /** Include formatting/property changes (default: true) */
107
- includeFormattingChanges?: boolean;
108
- /** Consolidate similar changes (default: false) */
109
- consolidate?: boolean;
110
- /** Maximum context length for descriptions (default: 50) */
111
- maxContextLength?: number;
112
- /** Filter by authors */
113
- filterAuthors?: string[];
114
- /** Filter by date range */
115
- filterDateRange?: { start: Date; end: Date };
116
- /** Filter by categories */
117
- filterCategories?: ChangeCategory[];
118
- /** Output format (default: 'markdown') */
119
- format?: ChangelogFormat;
120
- /** Sort changes by field */
121
- sortBy?: 'date' | 'author' | 'type' | 'category';
122
- /** Sort order (default: 'asc') */
123
- sortOrder?: 'asc' | 'desc';
124
- /** Group changes by element type */
125
- groupByElement?: boolean;
126
- /** Include document context (nearest heading, section) */
127
- includeDocumentContext?: boolean;
128
- }
129
-
130
- /**
131
- * Consolidated change grouping similar changes together.
132
- */
133
- export interface ConsolidatedChange {
134
- /** Description of the consolidated change */
135
- description: string;
136
- /** Number of individual changes */
137
- count: number;
138
- /** Category of changes */
139
- category: ChangeCategory;
140
- /** Common attributes shared by all changes */
141
- commonAttributes: {
142
- author?: string;
143
- revisionType?: RevisionType;
144
- propertyChanged?: string;
145
- newValue?: string;
146
- };
147
- /** Individual change IDs */
148
- changeIds: string[];
149
- }
150
-
151
- /**
152
- * Summary statistics for changelog entries.
153
- */
154
- export interface ChangelogSummary {
155
- /** Total number of changes */
156
- total: number;
157
- /** Breakdown by category */
158
- byCategory: Record<ChangeCategory, number>;
159
- /** Breakdown by revision type */
160
- byType: Record<string, number>;
161
- /** Breakdown by author */
162
- byAuthor: Record<string, number>;
163
- /** Date range of changes */
164
- dateRange: { earliest: Date; latest: Date } | null;
165
- }
166
-
167
- /**
168
- * Generates changelog from Word tracked changes.
169
- * Follows ECMA-376 revision semantics.
170
- */
171
- export class ChangelogGenerator {
172
- /**
173
- * Generate changelog entries from a document.
174
- * Document must be loaded with { revisionHandling: 'preserve' }.
175
- *
176
- * @param doc - Document to extract revisions from
177
- * @param options - Changelog generation options
178
- * @returns Array of changelog entries
179
- */
180
- static fromDocument(doc: Document, options?: ChangelogOptions): ChangeEntry[] {
181
- const revisionManager = doc.getRevisionManager();
182
- if (!revisionManager) {
183
- return [];
184
- }
185
-
186
- const revisions = revisionManager.getAllRevisions();
187
- return this.fromRevisions(revisions, options, doc);
188
- }
189
-
190
- /**
191
- * Generate changelog entries from specific revisions.
192
- *
193
- * @param revisions - Array of revisions to convert
194
- * @param options - Changelog generation options
195
- * @param doc - Optional document for context (paragraph indices, headings)
196
- * @returns Array of changelog entries
197
- */
198
- static fromRevisions(
199
- revisions: Revision[],
200
- options?: ChangelogOptions,
201
- doc?: Document
202
- ): ChangeEntry[] {
203
- const logger = getLogger();
204
- const opts = {
205
- includeFormattingChanges: true,
206
- consolidate: false,
207
- maxContextLength: 50,
208
- ...options,
209
- };
210
-
211
- logger.debug('Processing revisions', {
212
- total: revisions.length,
213
- filters: {
214
- categories: opts.filterCategories?.length ?? 0,
215
- authors: opts.filterAuthors?.length ?? 0,
216
- dateRange: !!opts.filterDateRange
217
- }
218
- });
219
-
220
- const entries: ChangeEntry[] = [];
221
- let filtered = 0;
222
-
223
- for (let i = 0; i < revisions.length; i++) {
224
- const revision = revisions[i];
225
- if (!revision) continue;
226
-
227
- const category = this.categorize(revision);
228
-
229
- // Filter by category
230
- if (opts.filterCategories && !opts.filterCategories.includes(category)) {
231
- filtered++;
232
- continue;
233
- }
234
-
235
- // Filter out formatting changes if requested
236
- if (!opts.includeFormattingChanges && category === 'formatting') {
237
- filtered++;
238
- continue;
239
- }
240
-
241
- // Filter by author
242
- if (opts.filterAuthors && !opts.filterAuthors.includes(revision.getAuthor())) {
243
- filtered++;
244
- continue;
245
- }
246
-
247
- // Filter by date range
248
- if (opts.filterDateRange) {
249
- const revDate = revision.getDate();
250
- if (revDate < opts.filterDateRange.start || revDate > opts.filterDateRange.end) {
251
- filtered++;
252
- continue;
253
- }
254
- }
255
-
256
- const entry = this.revisionToEntry(revision, i, opts.maxContextLength);
257
- entries.push(entry);
258
- }
259
-
260
- if (filtered > 0) {
261
- logger.debug('Revisions filtered', { included: entries.length, filtered });
262
- }
263
-
264
- return entries;
265
- }
266
-
267
- /**
268
- * Convert a single revision to a changelog entry.
269
- *
270
- * @param revision - Revision to convert
271
- * @param index - Index for paragraph location (default location)
272
- * @param maxContextLength - Maximum length for text context
273
- * @returns Changelog entry
274
- */
275
- private static revisionToEntry(
276
- revision: Revision,
277
- index: number,
278
- maxContextLength: number
279
- ): ChangeEntry {
280
- const type = revision.getType();
281
- const category = this.categorize(revision);
282
- const runs = revision.getRuns();
283
-
284
- // Extract text content from runs
285
- const text = runs.map(r => r.getText()).join('');
286
- const truncatedText = text.length > maxContextLength
287
- ? text.substring(0, maxContextLength) + '...'
288
- : text;
289
-
290
- // Build content object based on revision type
291
- const content: ChangeEntry['content'] = {};
292
- if (type === 'insert' || type === 'moveTo') {
293
- content.after = truncatedText;
294
- } else if (type === 'delete' || type === 'moveFrom') {
295
- content.before = truncatedText;
296
- } else if (this.isPropertyChangeType(type)) {
297
- content.affectedText = truncatedText;
298
- }
299
-
300
- // Handle hyperlink changes specially
301
- const prevProps = revision.getPreviousProperties();
302
- const newProps = revision.getNewProperties();
303
- if (type === 'hyperlinkChange' && (prevProps || newProps)) {
304
- (content as any).hyperlinkChange = {
305
- urlBefore: prevProps?.url,
306
- urlAfter: newProps?.url,
307
- textBefore: prevProps?.text,
308
- textAfter: newProps?.text,
309
- };
310
- // Set before/after for standard diff view
311
- if (prevProps?.url !== newProps?.url) {
312
- content.before = prevProps?.url;
313
- content.after = newProps?.url;
314
- } else if (prevProps?.text !== newProps?.text) {
315
- content.before = prevProps?.text;
316
- content.after = newProps?.text;
317
- }
318
- }
319
-
320
- // Build property change details if applicable
321
- let propertyChange: ChangeEntry['propertyChange'] | undefined;
322
- if (prevProps || newProps) {
323
- // Get the first property that changed
324
- const allKeys = new Set([
325
- ...Object.keys(prevProps || {}),
326
- ...Object.keys(newProps || {}),
327
- ]);
328
- const firstKey = Array.from(allKeys)[0];
329
- if (firstKey) {
330
- propertyChange = {
331
- property: firstKey,
332
- oldValue: this.formatPropertyValue(prevProps?.[firstKey]),
333
- newValue: this.formatPropertyValue(newProps?.[firstKey]),
334
- };
335
- }
336
- }
337
-
338
- // Use revision's location if available, otherwise fall back to index
339
- const revisionLocation = revision.getLocation();
340
- const location: ChangeLocation = {
341
- paragraphIndex: revisionLocation?.paragraphIndex ?? index, // Use actual or fallback
342
- sectionIndex: revisionLocation?.sectionIndex,
343
- runIndex: revisionLocation?.runIndex,
344
- };
345
-
346
- return {
347
- id: revision.getId().toString(),
348
- revisionType: type,
349
- category,
350
- description: this.describeRevision(revision, maxContextLength),
351
- author: revision.getAuthor(),
352
- date: revision.getDate(),
353
- location,
354
- content,
355
- propertyChange,
356
- };
357
- }
358
-
359
- /**
360
- * Get summary statistics for changelog entries.
361
- *
362
- * @param entries - Array of changelog entries
363
- * @returns Summary statistics
364
- */
365
- static getSummary(entries: ChangeEntry[]): ChangelogSummary {
366
- const byCategory: Record<ChangeCategory, number> = {
367
- content: 0,
368
- formatting: 0,
369
- structural: 0,
370
- table: 0,
371
- hyperlink: 0,
372
- image: 0,
373
- field: 0,
374
- comment: 0,
375
- bookmark: 0,
376
- contentControl: 0,
377
- };
378
- const byType: Record<string, number> = {};
379
- const byAuthor: Record<string, number> = {};
380
- let earliest: Date | null = null;
381
- let latest: Date | null = null;
382
-
383
- for (const entry of entries) {
384
- // Count by category
385
- byCategory[entry.category]++;
386
-
387
- // Count by type
388
- byType[entry.revisionType] = (byType[entry.revisionType] || 0) + 1;
389
-
390
- // Count by author
391
- byAuthor[entry.author] = (byAuthor[entry.author] || 0) + 1;
392
-
393
- // Track date range
394
- if (!earliest || entry.date < earliest) {
395
- earliest = entry.date;
396
- }
397
- if (!latest || entry.date > latest) {
398
- latest = entry.date;
399
- }
400
- }
401
-
402
- return {
403
- total: entries.length,
404
- byCategory,
405
- byType,
406
- byAuthor,
407
- dateRange: earliest && latest ? { earliest, latest } : null,
408
- };
409
- }
410
-
411
- /**
412
- * Consolidate similar changes into groups.
413
- * Groups changes that share: same type, same property, same new value.
414
- *
415
- * @param entries - Array of changelog entries
416
- * @returns Array of consolidated changes
417
- */
418
- static consolidate(entries: ChangeEntry[]): ConsolidatedChange[] {
419
- const groups = new Map<string, ChangeEntry[]>();
420
-
421
- for (const entry of entries) {
422
- // Create grouping key
423
- let key = `${entry.revisionType}_${entry.category}`;
424
-
425
- // For property changes, include the property name and new value
426
- if (entry.propertyChange) {
427
- key += `_${entry.propertyChange.property}_${entry.propertyChange.newValue || ''}`;
428
- }
429
-
430
- // For content changes by same author, group them
431
- key += `_${entry.author}`;
432
-
433
- if (!groups.has(key)) {
434
- groups.set(key, []);
435
- }
436
- groups.get(key)!.push(entry);
437
- }
438
-
439
- const consolidated: ConsolidatedChange[] = [];
440
-
441
- for (const [_, groupEntries] of groups) {
442
- const first = groupEntries[0];
443
- if (!first) continue;
444
-
445
- let description: string;
446
-
447
- if (groupEntries.length === 1) {
448
- description = first.description;
449
- } else {
450
- // Generate consolidated description
451
- description = this.generateConsolidatedDescription(groupEntries);
452
- }
453
-
454
- consolidated.push({
455
- description,
456
- count: groupEntries.length,
457
- category: first.category,
458
- commonAttributes: {
459
- author: this.allSame(groupEntries.map(e => e.author)) ? first.author : undefined,
460
- revisionType: first.revisionType,
461
- propertyChanged: first.propertyChange?.property,
462
- newValue: first.propertyChange?.newValue,
463
- },
464
- changeIds: groupEntries.map(e => e.id),
465
- });
466
- }
467
-
468
- // Sort by count descending
469
- consolidated.sort((a, b) => b.count - a.count);
470
-
471
- if (entries.length > 0) {
472
- getLogger().info('Entries consolidated', {
473
- input: entries.length,
474
- groups: consolidated.length,
475
- reduction: `${Math.round((1 - consolidated.length / entries.length) * 100)}%`
476
- });
477
- }
478
-
479
- return consolidated;
480
- }
481
-
482
- /**
483
- * Generate a consolidated description for a group of similar changes.
484
- */
485
- private static generateConsolidatedDescription(entries: ChangeEntry[]): string {
486
- const first = entries[0];
487
- if (!first) {
488
- return `${entries.length} changes`;
489
- }
490
-
491
- const count = entries.length;
492
-
493
- switch (first.revisionType) {
494
- case 'insert':
495
- return `Inserted text in ${count} locations`;
496
- case 'delete':
497
- return `Deleted text from ${count} locations`;
498
- case 'moveFrom':
499
- case 'moveTo':
500
- return `Moved content (${count} operations)`;
501
- case 'runPropertiesChange':
502
- if (first.propertyChange) {
503
- return `Changed ${first.propertyChange.property} to "${first.propertyChange.newValue}" (${count} times)`;
504
- }
505
- return `Changed run formatting (${count} times)`;
506
- case 'paragraphPropertiesChange':
507
- if (first.propertyChange) {
508
- return `Changed paragraph ${first.propertyChange.property} (${count} times)`;
509
- }
510
- return `Changed paragraph formatting (${count} times)`;
511
- case 'tablePropertiesChange':
512
- case 'tableRowPropertiesChange':
513
- case 'tableCellPropertiesChange':
514
- return `Changed table formatting (${count} times)`;
515
- case 'tableCellInsert':
516
- return `Inserted ${count} table cells`;
517
- case 'tableCellDelete':
518
- return `Deleted ${count} table cells`;
519
- case 'tableCellMerge':
520
- return `Merged table cells (${count} operations)`;
521
- case 'numberingChange':
522
- return `Changed list numbering (${count} times)`;
523
- case 'sectionPropertiesChange':
524
- return `Changed section properties (${count} times)`;
525
- default:
526
- return `${count} changes of type ${first.revisionType}`;
527
- }
528
- }
529
-
530
- /**
531
- * Check if all values in an array are the same.
532
- */
533
- private static allSame<T>(arr: T[]): boolean {
534
- if (arr.length === 0) return true;
535
- const first = arr[0];
536
- return arr.every(v => v === first);
537
- }
538
-
539
- /**
540
- * Categorize a revision into a semantic category.
541
- *
542
- * @param revision - Revision to categorize
543
- * @returns Semantic category
544
- */
545
- static categorize(revision: Revision): ChangeCategory {
546
- const type = revision.getType();
547
-
548
- switch (type) {
549
- // Content changes
550
- case 'insert':
551
- case 'delete':
552
- return 'content';
553
-
554
- // Structural changes
555
- case 'moveFrom':
556
- case 'moveTo':
557
- case 'sectionPropertiesChange':
558
- return 'structural';
559
-
560
- // Formatting changes
561
- case 'runPropertiesChange':
562
- case 'paragraphPropertiesChange':
563
- case 'numberingChange':
564
- return 'formatting';
565
-
566
- // Table changes
567
- case 'tablePropertiesChange':
568
- case 'tableExceptionPropertiesChange':
569
- case 'tableRowPropertiesChange':
570
- case 'tableCellPropertiesChange':
571
- case 'tableCellInsert':
572
- case 'tableCellDelete':
573
- case 'tableCellMerge':
574
- return 'table';
575
-
576
- // Hyperlink changes
577
- case 'hyperlinkChange':
578
- return 'hyperlink';
579
-
580
- // Rich content changes
581
- case 'imageChange':
582
- return 'image';
583
-
584
- case 'fieldChange':
585
- return 'field';
586
-
587
- case 'commentChange':
588
- return 'comment';
589
-
590
- case 'bookmarkChange':
591
- return 'bookmark';
592
-
593
- case 'contentControlChange':
594
- return 'contentControl';
595
-
596
- default:
597
- return 'content';
598
- }
599
- }
600
-
601
- /**
602
- * Check if a revision type is a property change type.
603
- */
604
- private static isPropertyChangeType(type: RevisionType): boolean {
605
- return [
606
- 'runPropertiesChange',
607
- 'paragraphPropertiesChange',
608
- 'tablePropertiesChange',
609
- 'tableExceptionPropertiesChange',
610
- 'tableRowPropertiesChange',
611
- 'tableCellPropertiesChange',
612
- 'sectionPropertiesChange',
613
- 'numberingChange',
614
- ].includes(type);
615
- }
616
-
617
- /**
618
- * Generate human-readable description for a revision.
619
- *
620
- * @param revision - Revision to describe
621
- * @param maxLength - Maximum length for text excerpts
622
- * @returns Human-readable description
623
- */
624
- static describeRevision(revision: Revision, maxLength = 50): string {
625
- const type = revision.getType();
626
- const author = revision.getAuthor();
627
- const runs = revision.getRuns();
628
- const text = runs.map(r => r.getText()).join('');
629
- const excerpt = text.length > maxLength
630
- ? `"${text.substring(0, maxLength)}..."`
631
- : text ? `"${text}"` : '';
632
-
633
- switch (type) {
634
- case 'insert':
635
- return excerpt ? `Inserted ${excerpt}` : 'Inserted content';
636
- case 'delete':
637
- return excerpt ? `Deleted ${excerpt}` : 'Deleted content';
638
- case 'moveFrom':
639
- return excerpt ? `Moved ${excerpt} from here` : 'Moved content from here';
640
- case 'moveTo':
641
- return excerpt ? `Moved ${excerpt} to here` : 'Moved content to here';
642
- case 'runPropertiesChange':
643
- return this.describePropertyChange(revision, 'run formatting');
644
- case 'paragraphPropertiesChange':
645
- return this.describePropertyChange(revision, 'paragraph formatting');
646
- case 'tablePropertiesChange':
647
- return 'Changed table properties';
648
- case 'tableExceptionPropertiesChange':
649
- return 'Changed table exception properties';
650
- case 'tableRowPropertiesChange':
651
- return 'Changed table row properties';
652
- case 'tableCellPropertiesChange':
653
- return 'Changed table cell properties';
654
- case 'sectionPropertiesChange':
655
- return 'Changed section properties';
656
- case 'tableCellInsert':
657
- return 'Inserted table cell';
658
- case 'tableCellDelete':
659
- return 'Deleted table cell';
660
- case 'tableCellMerge':
661
- return 'Merged table cells';
662
- case 'numberingChange':
663
- return 'Changed list numbering';
664
- case 'hyperlinkChange':
665
- return this.describeHyperlinkChange(revision, maxLength);
666
- case 'imageChange':
667
- return this.describeImageChange(revision);
668
- case 'fieldChange':
669
- return this.describeFieldChange(revision);
670
- case 'commentChange':
671
- return this.describeCommentChange(revision);
672
- case 'bookmarkChange':
673
- return this.describeBookmarkChange(revision);
674
- case 'contentControlChange':
675
- return this.describeContentControlChange(revision);
676
- default:
677
- return `Changed (${type})`;
678
- }
679
- }
680
-
681
- /**
682
- * Generate description for a hyperlink change revision.
683
- */
684
- private static describeHyperlinkChange(revision: Revision, maxLength: number): string {
685
- const prevProps = revision.getPreviousProperties() || {};
686
- const newProps = revision.getNewProperties() || {};
687
- const changes: string[] = [];
688
-
689
- if (prevProps.url !== newProps.url) {
690
- changes.push('URL');
691
- }
692
- if (prevProps.text !== newProps.text) {
693
- changes.push('display text');
694
- }
695
- if (prevProps.formatting !== newProps.formatting) {
696
- changes.push('formatting');
697
- }
698
-
699
- if (changes.length === 0) {
700
- return 'Updated hyperlink';
701
- }
702
-
703
- return `Changed hyperlink ${changes.join(' and ')}`;
704
- }
705
-
706
- /**
707
- * Generate description for an image change revision.
708
- */
709
- private static describeImageChange(revision: Revision): string {
710
- const prevProps = revision.getPreviousProperties() || {};
711
- const newProps = revision.getNewProperties() || {};
712
- const changes: string[] = [];
713
-
714
- // Detect type of change
715
- if (!prevProps.imageId && newProps.imageId) {
716
- return `Inserted image${newProps.filename ? ` "${newProps.filename}"` : ''}`;
717
- }
718
- if (prevProps.imageId && !newProps.imageId) {
719
- return `Deleted image${prevProps.filename ? ` "${prevProps.filename}"` : ''}`;
720
- }
721
-
722
- // Property changes
723
- if (prevProps.width !== newProps.width || prevProps.height !== newProps.height) {
724
- changes.push('size');
725
- }
726
- if (prevProps.position !== newProps.position) {
727
- changes.push('position');
728
- }
729
- if (prevProps.wrapping !== newProps.wrapping) {
730
- changes.push('wrapping');
731
- }
732
- if (prevProps.altText !== newProps.altText) {
733
- changes.push('alt text');
734
- }
735
-
736
- if (changes.length === 0) {
737
- return 'Updated image';
738
- }
739
-
740
- return `Changed image ${changes.join(' and ')}`;
741
- }
742
-
743
- /**
744
- * Generate description for a field change revision.
745
- */
746
- private static describeFieldChange(revision: Revision): string {
747
- const prevProps = revision.getPreviousProperties() || {};
748
- const newProps = revision.getNewProperties() || {};
749
-
750
- // Detect type of change
751
- if (!prevProps.fieldType && newProps.fieldType) {
752
- return `Inserted ${newProps.fieldType || 'field'}`;
753
- }
754
- if (prevProps.fieldType && !newProps.fieldType) {
755
- return `Deleted ${prevProps.fieldType || 'field'}`;
756
- }
757
-
758
- // Value/formula changes
759
- if (prevProps.value !== newProps.value) {
760
- return `Updated ${newProps.fieldType || 'field'} value`;
761
- }
762
- if (prevProps.formula !== newProps.formula) {
763
- return `Changed ${newProps.fieldType || 'field'} formula`;
764
- }
765
-
766
- return `Updated ${newProps.fieldType || 'field'}`;
767
- }
768
-
769
- /**
770
- * Generate description for a comment change revision.
771
- */
772
- private static describeCommentChange(revision: Revision): string {
773
- const prevProps = revision.getPreviousProperties() || {};
774
- const newProps = revision.getNewProperties() || {};
775
-
776
- // Detect type of change
777
- if (!prevProps.commentId && newProps.commentId) {
778
- return `Added comment${newProps.author ? ` by ${newProps.author}` : ''}`;
779
- }
780
- if (prevProps.commentId && !newProps.commentId) {
781
- return `Deleted comment${prevProps.author ? ` by ${prevProps.author}` : ''}`;
782
- }
783
- if (prevProps.text !== newProps.text) {
784
- return `Edited comment${newProps.author ? ` by ${newProps.author}` : ''}`;
785
- }
786
-
787
- return 'Updated comment';
788
- }
789
-
790
- /**
791
- * Generate description for a bookmark change revision.
792
- */
793
- private static describeBookmarkChange(revision: Revision): string {
794
- const prevProps = revision.getPreviousProperties() || {};
795
- const newProps = revision.getNewProperties() || {};
796
-
797
- // Detect type of change
798
- if (!prevProps.bookmarkId && newProps.bookmarkId) {
799
- return `Created bookmark "${newProps.name || 'unnamed'}"`;
800
- }
801
- if (prevProps.bookmarkId && !newProps.bookmarkId) {
802
- return `Deleted bookmark "${prevProps.name || 'unnamed'}"`;
803
- }
804
- if (prevProps.name !== newProps.name) {
805
- return `Renamed bookmark from "${prevProps.name}" to "${newProps.name}"`;
806
- }
807
- if (prevProps.rangeStart !== newProps.rangeStart || prevProps.rangeEnd !== newProps.rangeEnd) {
808
- return `Changed bookmark "${newProps.name || 'unnamed'}" range`;
809
- }
810
-
811
- return `Updated bookmark "${newProps.name || prevProps.name || 'unnamed'}"`;
812
- }
813
-
814
- /**
815
- * Generate description for a content control change revision.
816
- */
817
- private static describeContentControlChange(revision: Revision): string {
818
- const prevProps = revision.getPreviousProperties() || {};
819
- const newProps = revision.getNewProperties() || {};
820
-
821
- // Detect type of change
822
- if (!prevProps.sdtId && newProps.sdtId) {
823
- return `Inserted content control${newProps.title ? ` "${newProps.title}"` : ''}`;
824
- }
825
- if (prevProps.sdtId && !newProps.sdtId) {
826
- return `Deleted content control${prevProps.title ? ` "${prevProps.title}"` : ''}`;
827
- }
828
- if (prevProps.title !== newProps.title) {
829
- return `Renamed content control to "${newProps.title}"`;
830
- }
831
- if (prevProps.content !== newProps.content) {
832
- return `Changed content control${newProps.title ? ` "${newProps.title}"` : ''} content`;
833
- }
834
-
835
- return `Updated content control${newProps.title || prevProps.title ? ` "${newProps.title || prevProps.title}"` : ''}`;
836
- }
837
-
838
- /**
839
- * Generate description for a property change revision.
840
- */
841
- private static describePropertyChange(revision: Revision, context: string): string {
842
- const prevProps = revision.getPreviousProperties();
843
- const newProps = revision.getNewProperties();
844
-
845
- if (!prevProps && !newProps) {
846
- return `Changed ${context}`;
847
- }
848
-
849
- // Get meaningful property names
850
- const propNames: string[] = [];
851
- const allKeys = new Set([
852
- ...Object.keys(prevProps || {}),
853
- ...Object.keys(newProps || {}),
854
- ]);
855
-
856
- for (const key of allKeys) {
857
- const oldVal = prevProps?.[key];
858
- const newVal = newProps?.[key];
859
- if (oldVal !== newVal) {
860
- propNames.push(this.friendlyPropertyName(key));
861
- }
862
- }
863
-
864
- if (propNames.length === 0) {
865
- return `Changed ${context}`;
866
- }
867
-
868
- if (propNames.length === 1) {
869
- return `Changed ${propNames[0]}`;
870
- }
871
-
872
- if (propNames.length <= 3) {
873
- return `Changed ${propNames.join(', ')}`;
874
- }
875
-
876
- return `Changed ${propNames.slice(0, 2).join(', ')} and ${propNames.length - 2} more`;
877
- }
878
-
879
- /**
880
- * Format a property value for display.
881
- * Handles objects, arrays, and primitives properly.
882
- */
883
- private static formatPropertyValue(value: unknown): string | undefined {
884
- if (value === null || value === undefined) {
885
- return undefined;
886
- }
887
- if (typeof value === 'object') {
888
- try {
889
- // For objects, use JSON.stringify for proper representation
890
- const json = JSON.stringify(value);
891
- // Truncate if too long
892
- return json.length > 100 ? json.substring(0, 97) + '...' : json;
893
- } catch (e) {
894
- getLogger().debug('Failed to stringify value', { error: String(e) });
895
- return '[complex value]';
896
- }
897
- }
898
- // For primitives, use String() for safe conversion
899
- return String(value);
900
- }
901
-
902
- /**
903
- * Convert property key to friendly name.
904
- */
905
- private static friendlyPropertyName(key: string): string {
906
- const friendlyNames: Record<string, string> = {
907
- // Run (character) properties
908
- b: 'bold',
909
- i: 'italic',
910
- u: 'underline',
911
- strike: 'strikethrough',
912
- dstrike: 'double strikethrough',
913
- sz: 'font size',
914
- szCs: 'complex script font size',
915
- color: 'text color',
916
- highlight: 'highlight',
917
- rFonts: 'font',
918
- rStyle: 'character style',
919
- vertAlign: 'vertical alignment',
920
- vanish: 'hidden text',
921
- caps: 'all capitals',
922
- smallCaps: 'small capitals',
923
- outline: 'outline effect',
924
- shadow: 'shadow effect',
925
- emboss: 'emboss effect',
926
- imprint: 'imprint effect',
927
- kern: 'kerning',
928
- w: 'character width',
929
- spacing: 'character spacing',
930
- position: 'text position',
931
- shd: 'shading',
932
- bdr: 'border',
933
- lang: 'language',
934
- eastAsianLayout: 'East Asian layout',
935
- specVanish: 'special vanish',
936
- oMath: 'math mode',
937
-
938
- // Paragraph properties
939
- jc: 'alignment',
940
- ind: 'indentation',
941
- pStyle: 'paragraph style',
942
- numPr: 'list numbering',
943
- pBdr: 'paragraph border',
944
- tabs: 'tab stops',
945
- suppressAutoHyphens: 'hyphenation',
946
- kinsoku: 'kinsoku rules',
947
- wordWrap: 'word wrap',
948
- overflowPunct: 'overflow punctuation',
949
- topLinePunct: 'top line punctuation',
950
- autoSpaceDE: 'auto space (DE)',
951
- autoSpaceDN: 'auto space (DN)',
952
- bidi: 'bidirectional',
953
- adjustRightInd: 'right indent adjustment',
954
- snapToGrid: 'snap to grid',
955
- contextualSpacing: 'contextual spacing',
956
- mirrorIndents: 'mirror indents',
957
- suppressOverlap: 'suppress overlap',
958
- outlineLvl: 'outline level',
959
- divId: 'HTML division',
960
- keepNext: 'keep with next',
961
- keepLines: 'keep lines together',
962
- pageBreakBefore: 'page break before',
963
- widowControl: 'widow/orphan control',
964
- suppressLineNumbers: 'suppress line numbers',
965
- textboxTightWrap: 'textbox tight wrap',
966
-
967
- // Table properties
968
- tblStyle: 'table style',
969
- tblW: 'table width',
970
- tblInd: 'table indent',
971
- tblBorders: 'table borders',
972
- tblCellMar: 'table cell margins',
973
- tblLook: 'table look',
974
- tblLayout: 'table layout',
975
- gridSpan: 'column span',
976
- vMerge: 'vertical merge',
977
- tcW: 'cell width',
978
- tcBorders: 'cell borders',
979
- vAlign: 'vertical alignment',
980
- textDirection: 'text direction',
981
- noWrap: 'no wrap',
982
- tcMar: 'cell margins',
983
- tcFitText: 'fit text',
984
- hideMark: 'hide mark',
985
-
986
- // Section properties
987
- sectPr: 'section properties',
988
- pgSz: 'page size',
989
- pgMar: 'page margins',
990
- cols: 'columns',
991
- docGrid: 'document grid',
992
- headerReference: 'header',
993
- footerReference: 'footer',
994
- pgNumType: 'page numbering',
995
- formProt: 'form protection',
996
- titlePg: 'different first page',
997
- type: 'section type',
998
- };
999
-
1000
- return friendlyNames[key] || key;
1001
- }
1002
-
1003
- /**
1004
- * Export changelog to Markdown format.
1005
- *
1006
- * @param entries - Array of changelog entries
1007
- * @param options - Export options
1008
- * @returns Markdown string
1009
- */
1010
- static toMarkdown(
1011
- entries: ChangeEntry[],
1012
- options?: { includeMetadata?: boolean }
1013
- ): string {
1014
- const opts = { includeMetadata: true, ...options };
1015
- const lines: string[] = [];
1016
-
1017
- lines.push('# Document Changes');
1018
- lines.push('');
1019
-
1020
- if (opts.includeMetadata) {
1021
- const summary = this.getSummary(entries);
1022
- lines.push(`**Total Changes:** ${summary.total}`);
1023
- lines.push('');
1024
-
1025
- if (summary.dateRange) {
1026
- lines.push(`**Date Range:** ${summary.dateRange.earliest.toLocaleDateString()} - ${summary.dateRange.latest.toLocaleDateString()}`);
1027
- lines.push('');
1028
- }
1029
-
1030
- const authors = Object.keys(summary.byAuthor);
1031
- if (authors.length > 0) {
1032
- lines.push(`**Authors:** ${authors.join(', ')}`);
1033
- lines.push('');
1034
- }
1035
-
1036
- lines.push('---');
1037
- lines.push('');
1038
- }
1039
-
1040
- // Group by category
1041
- const byCategory = new Map<ChangeCategory, ChangeEntry[]>();
1042
- for (const entry of entries) {
1043
- if (!byCategory.has(entry.category)) {
1044
- byCategory.set(entry.category, []);
1045
- }
1046
- byCategory.get(entry.category)!.push(entry);
1047
- }
1048
-
1049
- const categoryTitles: Record<ChangeCategory, string> = {
1050
- content: 'Content Changes',
1051
- formatting: 'Formatting Changes',
1052
- structural: 'Structural Changes',
1053
- table: 'Table Changes',
1054
- hyperlink: 'Hyperlink Changes',
1055
- image: 'Image Changes',
1056
- field: 'Field Changes',
1057
- comment: 'Comment Changes',
1058
- bookmark: 'Bookmark Changes',
1059
- contentControl: 'Content Control Changes',
1060
- };
1061
-
1062
- for (const [category, categoryEntries] of byCategory) {
1063
- if (categoryEntries.length === 0) continue;
1064
-
1065
- lines.push(`## ${categoryTitles[category]}`);
1066
- lines.push('');
1067
-
1068
- for (const entry of categoryEntries) {
1069
- const date = entry.date.toLocaleDateString();
1070
- lines.push(`- ${entry.description} *(${entry.author}, ${date})*`);
1071
-
1072
- if (entry.content.before) {
1073
- lines.push(` - Removed: "${entry.content.before}"`);
1074
- }
1075
- if (entry.content.after) {
1076
- lines.push(` - Added: "${entry.content.after}"`);
1077
- }
1078
- }
1079
-
1080
- lines.push('');
1081
- }
1082
-
1083
- return lines.join('\n');
1084
- }
1085
-
1086
- /**
1087
- * Export changelog to plain text format.
1088
- *
1089
- * @param entries - Array of changelog entries
1090
- * @returns Plain text string
1091
- */
1092
- static toPlainText(entries: ChangeEntry[]): string {
1093
- const lines: string[] = [];
1094
-
1095
- lines.push('DOCUMENT CHANGES');
1096
- lines.push('================');
1097
- lines.push('');
1098
-
1099
- for (const entry of entries) {
1100
- const date = entry.date.toLocaleDateString();
1101
- lines.push(`[${date}] ${entry.author}: ${entry.description}`);
1102
-
1103
- if (entry.content.before) {
1104
- lines.push(` - ${entry.content.before}`);
1105
- }
1106
- if (entry.content.after) {
1107
- lines.push(` + ${entry.content.after}`);
1108
- }
1109
- }
1110
-
1111
- return lines.join('\n');
1112
- }
1113
-
1114
- /**
1115
- * Export changelog to JSON (for programmatic consumption).
1116
- *
1117
- * @param entries - Array of changelog entries
1118
- * @returns JSON string
1119
- */
1120
- static toJSON(entries: ChangeEntry[]): string {
1121
- return JSON.stringify({
1122
- generated: new Date().toISOString(),
1123
- summary: this.getSummary(entries),
1124
- entries,
1125
- }, null, 2);
1126
- }
1127
-
1128
- // ============================================================
1129
- // Unified API (Phase 2 Enhancement)
1130
- // ============================================================
1131
-
1132
- /**
1133
- * Unified changelog generation - single entry point for all formats.
1134
- *
1135
- * Generates changelog from a document in the specified format.
1136
- * This is the recommended method for most use cases.
1137
- *
1138
- * @param doc - Document to extract revisions from
1139
- * @param options - Generation options including format
1140
- * @returns Formatted changelog string
1141
- *
1142
- * @example
1143
- * ```typescript
1144
- * // Generate Markdown changelog
1145
- * const md = ChangelogGenerator.generate(doc, { format: 'markdown' });
1146
- *
1147
- * // Generate HTML changelog
1148
- * const html = ChangelogGenerator.generate(doc, { format: 'html' });
1149
- *
1150
- * // Generate CSV with filtering
1151
- * const csv = ChangelogGenerator.generate(doc, {
1152
- * format: 'csv',
1153
- * filterAuthors: ['John Doe'],
1154
- * sortBy: 'date',
1155
- * sortOrder: 'desc'
1156
- * });
1157
- * ```
1158
- */
1159
- static generate(doc: Document, options?: ChangelogOptions): string {
1160
- const logger = getLogger();
1161
- const format = options?.format || 'markdown';
1162
-
1163
- logger.info('Generating changelog', { format });
1164
-
1165
- // Get entries with filtering
1166
- let entries = this.fromDocument(doc, options);
1167
-
1168
- // Apply sorting if specified
1169
- if (options?.sortBy) {
1170
- entries = this.sortEntries(entries, options.sortBy, options.sortOrder || 'asc');
1171
- }
1172
-
1173
- logger.info('Changelog entries processed', {
1174
- entries: entries.length,
1175
- format,
1176
- sorted: !!options?.sortBy
1177
- });
1178
-
1179
- // Generate output in specified format
1180
- switch (format) {
1181
- case 'json':
1182
- return this.toJSON(entries);
1183
- case 'markdown':
1184
- return this.toMarkdown(entries, { includeMetadata: true });
1185
- case 'text':
1186
- return this.toPlainText(entries);
1187
- case 'html':
1188
- return this.toHTML(entries, options);
1189
- case 'csv':
1190
- return this.toCSV(entries);
1191
- default:
1192
- return this.toMarkdown(entries);
1193
- }
1194
- }
1195
-
1196
- /**
1197
- * Export changelog to HTML format.
1198
- *
1199
- * Generates a complete HTML document with styling and structure.
1200
- *
1201
- * @param entries - Array of changelog entries
1202
- * @param options - HTML generation options
1203
- * @returns Complete HTML document string
1204
- *
1205
- * @example
1206
- * ```typescript
1207
- * const html = ChangelogGenerator.toHTML(entries);
1208
- * fs.writeFileSync('changelog.html', html);
1209
- * ```
1210
- */
1211
- static toHTML(entries: ChangeEntry[], options?: ChangelogOptions): string {
1212
- const summary = this.getSummary(entries);
1213
-
1214
- // Group entries by category
1215
- const byCategory = new Map<ChangeCategory, ChangeEntry[]>();
1216
- for (const entry of entries) {
1217
- if (!byCategory.has(entry.category)) {
1218
- byCategory.set(entry.category, []);
1219
- }
1220
- byCategory.get(entry.category)!.push(entry);
1221
- }
1222
-
1223
- const categoryTitles: Record<ChangeCategory, string> = {
1224
- content: 'Content Changes',
1225
- formatting: 'Formatting Changes',
1226
- structural: 'Structural Changes',
1227
- table: 'Table Changes',
1228
- hyperlink: 'Hyperlink Changes',
1229
- image: 'Image Changes',
1230
- field: 'Field Changes',
1231
- comment: 'Comment Changes',
1232
- bookmark: 'Bookmark Changes',
1233
- contentControl: 'Content Control Changes',
1234
- };
1235
-
1236
- // Build HTML
1237
- let html = `<!DOCTYPE html>
1238
- <html lang="en">
1239
- <head>
1240
- <meta charset="UTF-8">
1241
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1242
- <title>Document Changes</title>
1243
- <style>
1244
- body {
1245
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1246
- max-width: 900px;
1247
- margin: 0 auto;
1248
- padding: 20px;
1249
- line-height: 1.6;
1250
- color: #333;
1251
- }
1252
- h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
1253
- h2 { color: #34495e; margin-top: 30px; }
1254
- .summary { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
1255
- .summary p { margin: 5px 0; }
1256
- .category { margin-bottom: 30px; }
1257
- .change-list { list-style: none; padding: 0; }
1258
- .change-item {
1259
- background: #fff;
1260
- border: 1px solid #e9ecef;
1261
- border-radius: 6px;
1262
- padding: 12px 15px;
1263
- margin-bottom: 10px;
1264
- }
1265
- .change-item:hover { box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
1266
- .change-description { font-weight: 500; color: #2c3e50; }
1267
- .change-meta { font-size: 0.85em; color: #7f8c8d; margin-top: 5px; }
1268
- .change-content { margin-top: 8px; font-size: 0.9em; }
1269
- .removed { color: #c0392b; text-decoration: line-through; }
1270
- .added { color: #27ae60; }
1271
- .badge {
1272
- display: inline-block;
1273
- padding: 2px 8px;
1274
- border-radius: 4px;
1275
- font-size: 0.75em;
1276
- font-weight: 600;
1277
- text-transform: uppercase;
1278
- }
1279
- .badge-insert { background: #d4edda; color: #155724; }
1280
- .badge-delete { background: #f8d7da; color: #721c24; }
1281
- .badge-formatting { background: #cce5ff; color: #004085; }
1282
- .badge-structural { background: #fff3cd; color: #856404; }
1283
- .badge-table { background: #e2e3e5; color: #383d41; }
1284
- </style>
1285
- </head>
1286
- <body>
1287
- <h1>Document Changes</h1>
1288
-
1289
- <div class="summary">
1290
- <p><strong>Total Changes:</strong> ${summary.total}</p>`;
1291
-
1292
- if (summary.dateRange) {
1293
- html += `
1294
- <p><strong>Date Range:</strong> ${summary.dateRange.earliest.toLocaleDateString()} - ${summary.dateRange.latest.toLocaleDateString()}</p>`;
1295
- }
1296
-
1297
- const authors = Object.keys(summary.byAuthor);
1298
- if (authors.length > 0) {
1299
- html += `
1300
- <p><strong>Authors:</strong> ${authors.join(', ')}</p>`;
1301
- }
1302
-
1303
- html += `
1304
- </div>
1305
- `;
1306
-
1307
- // Add sections by category
1308
- for (const [category, categoryEntries] of byCategory) {
1309
- if (categoryEntries.length === 0) continue;
1310
-
1311
- html += `
1312
- <section class="category">
1313
- <h2>${categoryTitles[category]}</h2>
1314
- <ul class="change-list">`;
1315
-
1316
- for (const entry of categoryEntries) {
1317
- const badgeClass = this.getBadgeClass(entry.revisionType);
1318
- const date = entry.date.toLocaleDateString();
1319
-
1320
- html += `
1321
- <li class="change-item">
1322
- <span class="badge ${badgeClass}">${entry.revisionType}</span>
1323
- <div class="change-description">${this.escapeHTML(entry.description)}</div>
1324
- <div class="change-meta">${this.escapeHTML(entry.author)} - ${date}</div>`;
1325
-
1326
- if (entry.content.before || entry.content.after) {
1327
- html += `
1328
- <div class="change-content">`;
1329
- if (entry.content.before) {
1330
- html += `<span class="removed">${this.escapeHTML(entry.content.before || '')}</span> `;
1331
- }
1332
- if (entry.content.after) {
1333
- html += `<span class="added">${this.escapeHTML(entry.content.after || '')}</span>`;
1334
- }
1335
- html += `</div>`;
1336
- }
1337
-
1338
- html += `
1339
- </li>`;
1340
- }
1341
-
1342
- html += `
1343
- </ul>
1344
- </section>`;
1345
- }
1346
-
1347
- html += `
1348
- </body>
1349
- </html>`;
1350
-
1351
- return html;
1352
- }
1353
-
1354
- /**
1355
- * Export changelog to CSV format.
1356
- *
1357
- * Generates CSV data suitable for spreadsheet applications.
1358
- *
1359
- * @param entries - Array of changelog entries
1360
- * @param options - CSV generation options
1361
- * @returns CSV string
1362
- *
1363
- * @example
1364
- * ```typescript
1365
- * const csv = ChangelogGenerator.toCSV(entries);
1366
- * fs.writeFileSync('changelog.csv', csv);
1367
- * ```
1368
- */
1369
- static toCSV(entries: ChangeEntry[], options?: {
1370
- delimiter?: string;
1371
- includeHeaders?: boolean;
1372
- }): string {
1373
- const delimiter = options?.delimiter || ',';
1374
- const includeHeaders = options?.includeHeaders !== false;
1375
-
1376
- const headers = [
1377
- 'ID',
1378
- 'Type',
1379
- 'Category',
1380
- 'Author',
1381
- 'Date',
1382
- 'Description',
1383
- 'Before',
1384
- 'After',
1385
- 'Paragraph',
1386
- 'Run'
1387
- ];
1388
-
1389
- const lines: string[] = [];
1390
-
1391
- if (includeHeaders) {
1392
- lines.push(headers.join(delimiter));
1393
- }
1394
-
1395
- for (const entry of entries) {
1396
- const row = [
1397
- entry.id,
1398
- entry.revisionType,
1399
- entry.category,
1400
- this.escapeCSV(entry.author),
1401
- entry.date.toISOString(),
1402
- this.escapeCSV(entry.description),
1403
- this.escapeCSV(entry.content.before || ''),
1404
- this.escapeCSV(entry.content.after || ''),
1405
- entry.location.paragraphIndex?.toString() || '',
1406
- entry.location.runIndex?.toString() || '',
1407
- ];
1408
- lines.push(row.join(delimiter));
1409
- }
1410
-
1411
- return lines.join('\n');
1412
- }
1413
-
1414
- /**
1415
- * Get timeline view - changes organized by date.
1416
- *
1417
- * Groups changelog entries by date (YYYY-MM-DD) for timeline visualization.
1418
- *
1419
- * @param entries - Array of changelog entries
1420
- * @returns Map of date strings to entries for that date
1421
- *
1422
- * @example
1423
- * ```typescript
1424
- * const timeline = ChangelogGenerator.getTimeline(entries);
1425
- * for (const [date, dayEntries] of timeline) {
1426
- * console.log(`${date}: ${dayEntries.length} changes`);
1427
- * }
1428
- * ```
1429
- */
1430
- static getTimeline(entries: ChangeEntry[]): Map<string, ChangeEntry[]> {
1431
- const timeline = new Map<string, ChangeEntry[]>();
1432
-
1433
- for (const entry of entries) {
1434
- const dateKey = entry.date.toISOString().split('T')[0]!; // YYYY-MM-DD
1435
- if (!timeline.has(dateKey)) {
1436
- timeline.set(dateKey, []);
1437
- }
1438
- timeline.get(dateKey)!.push(entry);
1439
- }
1440
-
1441
- // Sort the map by date
1442
- const sortedTimeline = new Map<string, ChangeEntry[]>(
1443
- [...timeline.entries()].sort((a, b) => a[0].localeCompare(b[0]))
1444
- );
1445
-
1446
- return sortedTimeline;
1447
- }
1448
-
1449
- /**
1450
- * Get summary by element type.
1451
- *
1452
- * Groups entries by the type of document element they affect.
1453
- *
1454
- * @param entries - Array of changelog entries
1455
- * @returns Object with entries grouped by element type
1456
- */
1457
- static getSummaryByElement(entries: ChangeEntry[]): {
1458
- paragraphs: ChangeEntry[];
1459
- tables: ChangeEntry[];
1460
- sections: ChangeEntry[];
1461
- runs: ChangeEntry[];
1462
- hyperlinks: ChangeEntry[];
1463
- } {
1464
- return {
1465
- paragraphs: entries.filter(e =>
1466
- e.revisionType === 'paragraphPropertiesChange' ||
1467
- e.revisionType === 'numberingChange' ||
1468
- (e.revisionType === 'insert' && !e.location.runIndex) ||
1469
- (e.revisionType === 'delete' && !e.location.runIndex)
1470
- ),
1471
- tables: entries.filter(e =>
1472
- ['tablePropertiesChange', 'tableRowPropertiesChange',
1473
- 'tableCellPropertiesChange', 'tableExceptionPropertiesChange',
1474
- 'tableCellInsert', 'tableCellDelete', 'tableCellMerge'].includes(e.revisionType)
1475
- ),
1476
- sections: entries.filter(e =>
1477
- e.revisionType === 'sectionPropertiesChange'
1478
- ),
1479
- runs: entries.filter(e =>
1480
- e.revisionType === 'runPropertiesChange' ||
1481
- (e.revisionType === 'insert' && e.location.runIndex !== undefined) ||
1482
- (e.revisionType === 'delete' && e.location.runIndex !== undefined)
1483
- ),
1484
- hyperlinks: entries.filter(e =>
1485
- e.revisionType === 'hyperlinkChange'
1486
- ),
1487
- };
1488
- }
1489
-
1490
- // ============================================================
1491
- // Helper Methods
1492
- // ============================================================
1493
-
1494
- /**
1495
- * Sort entries by specified field.
1496
- * @internal
1497
- */
1498
- private static sortEntries(
1499
- entries: ChangeEntry[],
1500
- sortBy: 'date' | 'author' | 'type' | 'category',
1501
- order: 'asc' | 'desc'
1502
- ): ChangeEntry[] {
1503
- const sorted = [...entries].sort((a, b) => {
1504
- let comparison = 0;
1505
-
1506
- switch (sortBy) {
1507
- case 'date':
1508
- comparison = a.date.getTime() - b.date.getTime();
1509
- break;
1510
- case 'author':
1511
- comparison = a.author.localeCompare(b.author);
1512
- break;
1513
- case 'type':
1514
- comparison = a.revisionType.localeCompare(b.revisionType);
1515
- break;
1516
- case 'category':
1517
- comparison = a.category.localeCompare(b.category);
1518
- break;
1519
- }
1520
-
1521
- return order === 'desc' ? -comparison : comparison;
1522
- });
1523
-
1524
- return sorted;
1525
- }
1526
-
1527
- /**
1528
- * Get CSS badge class for revision type.
1529
- * @internal
1530
- */
1531
- private static getBadgeClass(type: RevisionType): string {
1532
- switch (type) {
1533
- case 'insert':
1534
- case 'moveTo':
1535
- case 'tableCellInsert':
1536
- return 'badge-insert';
1537
- case 'delete':
1538
- case 'moveFrom':
1539
- case 'tableCellDelete':
1540
- return 'badge-delete';
1541
- case 'runPropertiesChange':
1542
- case 'paragraphPropertiesChange':
1543
- case 'numberingChange':
1544
- return 'badge-formatting';
1545
- case 'sectionPropertiesChange':
1546
- return 'badge-structural';
1547
- case 'tablePropertiesChange':
1548
- case 'tableRowPropertiesChange':
1549
- case 'tableCellPropertiesChange':
1550
- case 'tableCellMerge':
1551
- return 'badge-table';
1552
- default:
1553
- return 'badge-formatting';
1554
- }
1555
- }
1556
-
1557
- /**
1558
- * Escape HTML special characters.
1559
- * @internal
1560
- */
1561
- private static escapeHTML(str: string): string {
1562
- return str
1563
- .replace(/&/g, '&amp;')
1564
- .replace(/</g, '&lt;')
1565
- .replace(/>/g, '&gt;')
1566
- .replace(/"/g, '&quot;')
1567
- .replace(/'/g, '&#039;');
1568
- }
1569
-
1570
- /**
1571
- * Escape CSV special characters.
1572
- * @internal
1573
- */
1574
- private static escapeCSV(str: string): string {
1575
- // If string contains delimiter, newline, or quotes, wrap in quotes
1576
- if (str.includes(',') || str.includes('\n') || str.includes('"')) {
1577
- return `"${str.replace(/"/g, '""')}"`;
1578
- }
1579
- return str;
1580
- }
1581
- }
1
+ /**
2
+ * ChangelogGenerator - Generates structured changelog from Word tracked changes
3
+ *
4
+ * Converts Word revisions (w:ins, w:del, property changes) into structured
5
+ * changelog data with support for consolidation, categorization, and
6
+ * multiple output formats.
7
+ *
8
+ * Follows ECMA-376 revision semantics.
9
+ *
10
+ * @module ChangelogGenerator
11
+ */
12
+
13
+ import type { Document } from '../core/Document';
14
+ import { Revision, RevisionType } from '../elements/Revision';
15
+ import { getGlobalLogger, createScopedLogger, ILogger } from './logger';
16
+
17
+ // Scoped logger for ChangelogGenerator
18
+ function getLogger(): ILogger {
19
+ return createScopedLogger(getGlobalLogger(), 'ChangelogGenerator');
20
+ }
21
+
22
+ /**
23
+ * Semantic category for grouping changes.
24
+ */
25
+ export type ChangeCategory =
26
+ | 'content' // Text insertions, deletions
27
+ | 'formatting' // Run/paragraph property changes
28
+ | 'structural' // Moves, section changes
29
+ | 'table' // Table structure changes
30
+ | 'hyperlink' // Hyperlink URL, text, or formatting changes
31
+ | 'image' // Image insertion, deletion, or property changes
32
+ | 'field' // Field insertion, deletion, or value changes
33
+ | 'comment' // Comment changes
34
+ | 'bookmark' // Bookmark changes
35
+ | 'contentControl'; // Content control (SDT) changes
36
+
37
+ /**
38
+ * Location of a change within the document.
39
+ */
40
+ export interface ChangeLocation {
41
+ /** Section index (0-based) */
42
+ sectionIndex?: number;
43
+ /** Paragraph index within body (0-based) */
44
+ paragraphIndex: number;
45
+ /** Run index within paragraph (0-based) */
46
+ runIndex?: number;
47
+ /** Nearest heading for context */
48
+ nearestHeading?: string;
49
+ /** Character offset within paragraph */
50
+ characterOffset?: number;
51
+ }
52
+
53
+ /**
54
+ * Represents a single change entry in the changelog.
55
+ * Follows ECMA-376 revision semantics.
56
+ */
57
+ export interface ChangeEntry {
58
+ /** Unique identifier (matches revision ID) */
59
+ id: string;
60
+
61
+ /** ECMA-376 revision type */
62
+ revisionType: RevisionType;
63
+
64
+ /** Semantic category for grouping */
65
+ category: ChangeCategory;
66
+
67
+ /** Human-readable description */
68
+ description: string;
69
+
70
+ /** Author who made the change */
71
+ author: string;
72
+
73
+ /** ISO 8601 timestamp */
74
+ date: Date;
75
+
76
+ /** Location in document */
77
+ location: ChangeLocation;
78
+
79
+ /** Content details */
80
+ content: {
81
+ /** Text before change (for deletions/modifications) */
82
+ before?: string;
83
+ /** Text after change (for insertions/modifications) */
84
+ after?: string;
85
+ /** Affected text (for property changes) */
86
+ affectedText?: string;
87
+ };
88
+
89
+ /** Property change details (for formatting changes) */
90
+ propertyChange?: {
91
+ property: string;
92
+ oldValue?: string;
93
+ newValue?: string;
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Output format for changelog generation.
99
+ */
100
+ export type ChangelogFormat = 'json' | 'markdown' | 'text' | 'html' | 'csv';
101
+
102
+ /**
103
+ * Options for changelog generation.
104
+ */
105
+ export interface ChangelogOptions {
106
+ /** Include formatting/property changes (default: true) */
107
+ includeFormattingChanges?: boolean;
108
+ /** Consolidate similar changes (default: false) */
109
+ consolidate?: boolean;
110
+ /** Maximum context length for descriptions (default: 50) */
111
+ maxContextLength?: number;
112
+ /** Filter by authors */
113
+ filterAuthors?: string[];
114
+ /** Filter by date range */
115
+ filterDateRange?: { start: Date; end: Date };
116
+ /** Filter by categories */
117
+ filterCategories?: ChangeCategory[];
118
+ /** Output format (default: 'markdown') */
119
+ format?: ChangelogFormat;
120
+ /** Sort changes by field */
121
+ sortBy?: 'date' | 'author' | 'type' | 'category';
122
+ /** Sort order (default: 'asc') */
123
+ sortOrder?: 'asc' | 'desc';
124
+ /** Group changes by element type */
125
+ groupByElement?: boolean;
126
+ /** Include document context (nearest heading, section) */
127
+ includeDocumentContext?: boolean;
128
+ }
129
+
130
+ /**
131
+ * Consolidated change grouping similar changes together.
132
+ */
133
+ export interface ConsolidatedChange {
134
+ /** Description of the consolidated change */
135
+ description: string;
136
+ /** Number of individual changes */
137
+ count: number;
138
+ /** Category of changes */
139
+ category: ChangeCategory;
140
+ /** Common attributes shared by all changes */
141
+ commonAttributes: {
142
+ author?: string;
143
+ revisionType?: RevisionType;
144
+ propertyChanged?: string;
145
+ newValue?: string;
146
+ };
147
+ /** Individual change IDs */
148
+ changeIds: string[];
149
+ }
150
+
151
+ /**
152
+ * Summary statistics for changelog entries.
153
+ */
154
+ export interface ChangelogSummary {
155
+ /** Total number of changes */
156
+ total: number;
157
+ /** Breakdown by category */
158
+ byCategory: Record<ChangeCategory, number>;
159
+ /** Breakdown by revision type */
160
+ byType: Record<string, number>;
161
+ /** Breakdown by author */
162
+ byAuthor: Record<string, number>;
163
+ /** Date range of changes */
164
+ dateRange: { earliest: Date; latest: Date } | null;
165
+ }
166
+
167
+ /**
168
+ * Generates changelog from Word tracked changes.
169
+ * Follows ECMA-376 revision semantics.
170
+ */
171
+ export class ChangelogGenerator {
172
+ /**
173
+ * Generate changelog entries from a document.
174
+ * Document must be loaded with { revisionHandling: 'preserve' }.
175
+ *
176
+ * @param doc - Document to extract revisions from
177
+ * @param options - Changelog generation options
178
+ * @returns Array of changelog entries
179
+ */
180
+ static fromDocument(doc: Document, options?: ChangelogOptions): ChangeEntry[] {
181
+ const revisionManager = doc.getRevisionManager();
182
+ if (!revisionManager) {
183
+ return [];
184
+ }
185
+
186
+ const revisions = revisionManager.getAllRevisions();
187
+ return this.fromRevisions(revisions, options, doc);
188
+ }
189
+
190
+ /**
191
+ * Generate changelog entries from specific revisions.
192
+ *
193
+ * @param revisions - Array of revisions to convert
194
+ * @param options - Changelog generation options
195
+ * @param doc - Optional document for context (paragraph indices, headings)
196
+ * @returns Array of changelog entries
197
+ */
198
+ static fromRevisions(
199
+ revisions: Revision[],
200
+ options?: ChangelogOptions,
201
+ doc?: Document
202
+ ): ChangeEntry[] {
203
+ const logger = getLogger();
204
+ const opts = {
205
+ includeFormattingChanges: true,
206
+ consolidate: false,
207
+ maxContextLength: 50,
208
+ ...options,
209
+ };
210
+
211
+ logger.debug('Processing revisions', {
212
+ total: revisions.length,
213
+ filters: {
214
+ categories: opts.filterCategories?.length ?? 0,
215
+ authors: opts.filterAuthors?.length ?? 0,
216
+ dateRange: !!opts.filterDateRange,
217
+ },
218
+ });
219
+
220
+ const entries: ChangeEntry[] = [];
221
+ let filtered = 0;
222
+
223
+ for (let i = 0; i < revisions.length; i++) {
224
+ const revision = revisions[i];
225
+ if (!revision) continue;
226
+
227
+ const category = this.categorize(revision);
228
+
229
+ // Filter by category
230
+ if (opts.filterCategories && !opts.filterCategories.includes(category)) {
231
+ filtered++;
232
+ continue;
233
+ }
234
+
235
+ // Filter out formatting changes if requested
236
+ if (!opts.includeFormattingChanges && category === 'formatting') {
237
+ filtered++;
238
+ continue;
239
+ }
240
+
241
+ // Filter by author
242
+ if (opts.filterAuthors && !opts.filterAuthors.includes(revision.getAuthor())) {
243
+ filtered++;
244
+ continue;
245
+ }
246
+
247
+ // Filter by date range
248
+ if (opts.filterDateRange) {
249
+ const revDate = revision.getDate();
250
+ if (revDate < opts.filterDateRange.start || revDate > opts.filterDateRange.end) {
251
+ filtered++;
252
+ continue;
253
+ }
254
+ }
255
+
256
+ const entry = this.revisionToEntry(revision, i, opts.maxContextLength);
257
+ entries.push(entry);
258
+ }
259
+
260
+ if (filtered > 0) {
261
+ logger.debug('Revisions filtered', { included: entries.length, filtered });
262
+ }
263
+
264
+ return entries;
265
+ }
266
+
267
+ /**
268
+ * Convert a single revision to a changelog entry.
269
+ *
270
+ * @param revision - Revision to convert
271
+ * @param index - Index for paragraph location (default location)
272
+ * @param maxContextLength - Maximum length for text context
273
+ * @returns Changelog entry
274
+ */
275
+ private static revisionToEntry(
276
+ revision: Revision,
277
+ index: number,
278
+ maxContextLength: number
279
+ ): ChangeEntry {
280
+ const type = revision.getType();
281
+ const category = this.categorize(revision);
282
+ const runs = revision.getRuns();
283
+
284
+ // Extract text content from runs
285
+ const text = runs.map((r) => r.getText()).join('');
286
+ const truncatedText =
287
+ text.length > maxContextLength ? text.substring(0, maxContextLength) + '...' : text;
288
+
289
+ // Build content object based on revision type
290
+ const content: ChangeEntry['content'] = {};
291
+ if (type === 'insert' || type === 'moveTo') {
292
+ content.after = truncatedText;
293
+ } else if (type === 'delete' || type === 'moveFrom') {
294
+ content.before = truncatedText;
295
+ } else if (this.isPropertyChangeType(type)) {
296
+ content.affectedText = truncatedText;
297
+ }
298
+
299
+ // Handle hyperlink changes specially
300
+ const prevProps = revision.getPreviousProperties();
301
+ const newProps = revision.getNewProperties();
302
+ if (type === 'hyperlinkChange' && (prevProps || newProps)) {
303
+ (content as any).hyperlinkChange = {
304
+ urlBefore: prevProps?.url,
305
+ urlAfter: newProps?.url,
306
+ textBefore: prevProps?.text,
307
+ textAfter: newProps?.text,
308
+ };
309
+ // Set before/after for standard diff view
310
+ if (prevProps?.url !== newProps?.url) {
311
+ content.before = prevProps?.url;
312
+ content.after = newProps?.url;
313
+ } else if (prevProps?.text !== newProps?.text) {
314
+ content.before = prevProps?.text;
315
+ content.after = newProps?.text;
316
+ }
317
+ }
318
+
319
+ // Build property change details if applicable
320
+ let propertyChange: ChangeEntry['propertyChange'] | undefined;
321
+ if (prevProps || newProps) {
322
+ // Get the first property that changed
323
+ const allKeys = new Set([...Object.keys(prevProps || {}), ...Object.keys(newProps || {})]);
324
+ const firstKey = Array.from(allKeys)[0];
325
+ if (firstKey) {
326
+ propertyChange = {
327
+ property: firstKey,
328
+ oldValue: this.formatPropertyValue(prevProps?.[firstKey]),
329
+ newValue: this.formatPropertyValue(newProps?.[firstKey]),
330
+ };
331
+ }
332
+ }
333
+
334
+ // Use revision's location if available, otherwise fall back to index
335
+ const revisionLocation = revision.getLocation();
336
+ const location: ChangeLocation = {
337
+ paragraphIndex: revisionLocation?.paragraphIndex ?? index, // Use actual or fallback
338
+ sectionIndex: revisionLocation?.sectionIndex,
339
+ runIndex: revisionLocation?.runIndex,
340
+ };
341
+
342
+ return {
343
+ id: revision.getId().toString(),
344
+ revisionType: type,
345
+ category,
346
+ description: this.describeRevision(revision, maxContextLength),
347
+ author: revision.getAuthor(),
348
+ date: revision.getDate(),
349
+ location,
350
+ content,
351
+ propertyChange,
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Get summary statistics for changelog entries.
357
+ *
358
+ * @param entries - Array of changelog entries
359
+ * @returns Summary statistics
360
+ */
361
+ static getSummary(entries: ChangeEntry[]): ChangelogSummary {
362
+ const byCategory: Record<ChangeCategory, number> = {
363
+ content: 0,
364
+ formatting: 0,
365
+ structural: 0,
366
+ table: 0,
367
+ hyperlink: 0,
368
+ image: 0,
369
+ field: 0,
370
+ comment: 0,
371
+ bookmark: 0,
372
+ contentControl: 0,
373
+ };
374
+ const byType: Record<string, number> = {};
375
+ const byAuthor: Record<string, number> = {};
376
+ let earliest: Date | null = null;
377
+ let latest: Date | null = null;
378
+
379
+ for (const entry of entries) {
380
+ // Count by category
381
+ byCategory[entry.category]++;
382
+
383
+ // Count by type
384
+ byType[entry.revisionType] = (byType[entry.revisionType] || 0) + 1;
385
+
386
+ // Count by author
387
+ byAuthor[entry.author] = (byAuthor[entry.author] || 0) + 1;
388
+
389
+ // Track date range
390
+ if (!earliest || entry.date < earliest) {
391
+ earliest = entry.date;
392
+ }
393
+ if (!latest || entry.date > latest) {
394
+ latest = entry.date;
395
+ }
396
+ }
397
+
398
+ return {
399
+ total: entries.length,
400
+ byCategory,
401
+ byType,
402
+ byAuthor,
403
+ dateRange: earliest && latest ? { earliest, latest } : null,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Consolidate similar changes into groups.
409
+ * Groups changes that share: same type, same property, same new value.
410
+ *
411
+ * @param entries - Array of changelog entries
412
+ * @returns Array of consolidated changes
413
+ */
414
+ static consolidate(entries: ChangeEntry[]): ConsolidatedChange[] {
415
+ const groups = new Map<string, ChangeEntry[]>();
416
+
417
+ for (const entry of entries) {
418
+ // Create grouping key
419
+ let key = `${entry.revisionType}_${entry.category}`;
420
+
421
+ // For property changes, include the property name and new value
422
+ if (entry.propertyChange) {
423
+ key += `_${entry.propertyChange.property}_${entry.propertyChange.newValue || ''}`;
424
+ }
425
+
426
+ // For content changes by same author, group them
427
+ key += `_${entry.author}`;
428
+
429
+ if (!groups.has(key)) {
430
+ groups.set(key, []);
431
+ }
432
+ groups.get(key)!.push(entry);
433
+ }
434
+
435
+ const consolidated: ConsolidatedChange[] = [];
436
+
437
+ for (const [_, groupEntries] of groups) {
438
+ const first = groupEntries[0];
439
+ if (!first) continue;
440
+
441
+ let description: string;
442
+
443
+ if (groupEntries.length === 1) {
444
+ description = first.description;
445
+ } else {
446
+ // Generate consolidated description
447
+ description = this.generateConsolidatedDescription(groupEntries);
448
+ }
449
+
450
+ consolidated.push({
451
+ description,
452
+ count: groupEntries.length,
453
+ category: first.category,
454
+ commonAttributes: {
455
+ author: this.allSame(groupEntries.map((e) => e.author)) ? first.author : undefined,
456
+ revisionType: first.revisionType,
457
+ propertyChanged: first.propertyChange?.property,
458
+ newValue: first.propertyChange?.newValue,
459
+ },
460
+ changeIds: groupEntries.map((e) => e.id),
461
+ });
462
+ }
463
+
464
+ // Sort by count descending
465
+ consolidated.sort((a, b) => b.count - a.count);
466
+
467
+ if (entries.length > 0) {
468
+ getLogger().info('Entries consolidated', {
469
+ input: entries.length,
470
+ groups: consolidated.length,
471
+ reduction: `${Math.round((1 - consolidated.length / entries.length) * 100)}%`,
472
+ });
473
+ }
474
+
475
+ return consolidated;
476
+ }
477
+
478
+ /**
479
+ * Generate a consolidated description for a group of similar changes.
480
+ */
481
+ private static generateConsolidatedDescription(entries: ChangeEntry[]): string {
482
+ const first = entries[0];
483
+ if (!first) {
484
+ return `${entries.length} changes`;
485
+ }
486
+
487
+ const count = entries.length;
488
+
489
+ switch (first.revisionType) {
490
+ case 'insert':
491
+ return `Inserted text in ${count} locations`;
492
+ case 'delete':
493
+ return `Deleted text from ${count} locations`;
494
+ case 'moveFrom':
495
+ case 'moveTo':
496
+ return `Moved content (${count} operations)`;
497
+ case 'runPropertiesChange':
498
+ if (first.propertyChange) {
499
+ return `Changed ${first.propertyChange.property} to "${first.propertyChange.newValue}" (${count} times)`;
500
+ }
501
+ return `Changed run formatting (${count} times)`;
502
+ case 'paragraphPropertiesChange':
503
+ if (first.propertyChange) {
504
+ return `Changed paragraph ${first.propertyChange.property} (${count} times)`;
505
+ }
506
+ return `Changed paragraph formatting (${count} times)`;
507
+ case 'tablePropertiesChange':
508
+ case 'tableRowPropertiesChange':
509
+ case 'tableCellPropertiesChange':
510
+ return `Changed table formatting (${count} times)`;
511
+ case 'tableCellInsert':
512
+ return `Inserted ${count} table cells`;
513
+ case 'tableCellDelete':
514
+ return `Deleted ${count} table cells`;
515
+ case 'tableCellMerge':
516
+ return `Merged table cells (${count} operations)`;
517
+ case 'numberingChange':
518
+ return `Changed list numbering (${count} times)`;
519
+ case 'sectionPropertiesChange':
520
+ return `Changed section properties (${count} times)`;
521
+ default:
522
+ return `${count} changes of type ${first.revisionType}`;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Check if all values in an array are the same.
528
+ */
529
+ private static allSame<T>(arr: T[]): boolean {
530
+ if (arr.length === 0) return true;
531
+ const first = arr[0];
532
+ return arr.every((v) => v === first);
533
+ }
534
+
535
+ /**
536
+ * Categorize a revision into a semantic category.
537
+ *
538
+ * @param revision - Revision to categorize
539
+ * @returns Semantic category
540
+ */
541
+ static categorize(revision: Revision): ChangeCategory {
542
+ const type = revision.getType();
543
+
544
+ switch (type) {
545
+ // Content changes
546
+ case 'insert':
547
+ case 'delete':
548
+ return 'content';
549
+
550
+ // Structural changes
551
+ case 'moveFrom':
552
+ case 'moveTo':
553
+ case 'sectionPropertiesChange':
554
+ return 'structural';
555
+
556
+ // Formatting changes
557
+ case 'runPropertiesChange':
558
+ case 'paragraphPropertiesChange':
559
+ case 'numberingChange':
560
+ return 'formatting';
561
+
562
+ // Table changes
563
+ case 'tablePropertiesChange':
564
+ case 'tableExceptionPropertiesChange':
565
+ case 'tableRowPropertiesChange':
566
+ case 'tableCellPropertiesChange':
567
+ case 'tableCellInsert':
568
+ case 'tableCellDelete':
569
+ case 'tableCellMerge':
570
+ return 'table';
571
+
572
+ // Hyperlink changes
573
+ case 'hyperlinkChange':
574
+ return 'hyperlink';
575
+
576
+ // Rich content changes
577
+ case 'imageChange':
578
+ return 'image';
579
+
580
+ case 'fieldChange':
581
+ return 'field';
582
+
583
+ case 'commentChange':
584
+ return 'comment';
585
+
586
+ case 'bookmarkChange':
587
+ return 'bookmark';
588
+
589
+ case 'contentControlChange':
590
+ return 'contentControl';
591
+
592
+ default:
593
+ return 'content';
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Check if a revision type is a property change type.
599
+ */
600
+ private static isPropertyChangeType(type: RevisionType): boolean {
601
+ return [
602
+ 'runPropertiesChange',
603
+ 'paragraphPropertiesChange',
604
+ 'tablePropertiesChange',
605
+ 'tableExceptionPropertiesChange',
606
+ 'tableRowPropertiesChange',
607
+ 'tableCellPropertiesChange',
608
+ 'sectionPropertiesChange',
609
+ 'numberingChange',
610
+ ].includes(type);
611
+ }
612
+
613
+ /**
614
+ * Generate human-readable description for a revision.
615
+ *
616
+ * @param revision - Revision to describe
617
+ * @param maxLength - Maximum length for text excerpts
618
+ * @returns Human-readable description
619
+ */
620
+ static describeRevision(revision: Revision, maxLength = 50): string {
621
+ const type = revision.getType();
622
+ const author = revision.getAuthor();
623
+ const runs = revision.getRuns();
624
+ const text = runs.map((r) => r.getText()).join('');
625
+ const excerpt =
626
+ text.length > maxLength ? `"${text.substring(0, maxLength)}..."` : text ? `"${text}"` : '';
627
+
628
+ switch (type) {
629
+ case 'insert':
630
+ return excerpt ? `Inserted ${excerpt}` : 'Inserted content';
631
+ case 'delete':
632
+ return excerpt ? `Deleted ${excerpt}` : 'Deleted content';
633
+ case 'moveFrom':
634
+ return excerpt ? `Moved ${excerpt} from here` : 'Moved content from here';
635
+ case 'moveTo':
636
+ return excerpt ? `Moved ${excerpt} to here` : 'Moved content to here';
637
+ case 'runPropertiesChange':
638
+ return this.describePropertyChange(revision, 'run formatting');
639
+ case 'paragraphPropertiesChange':
640
+ return this.describePropertyChange(revision, 'paragraph formatting');
641
+ case 'tablePropertiesChange':
642
+ return 'Changed table properties';
643
+ case 'tableExceptionPropertiesChange':
644
+ return 'Changed table exception properties';
645
+ case 'tableRowPropertiesChange':
646
+ return 'Changed table row properties';
647
+ case 'tableCellPropertiesChange':
648
+ return 'Changed table cell properties';
649
+ case 'sectionPropertiesChange':
650
+ return 'Changed section properties';
651
+ case 'tableCellInsert':
652
+ return 'Inserted table cell';
653
+ case 'tableCellDelete':
654
+ return 'Deleted table cell';
655
+ case 'tableCellMerge':
656
+ return 'Merged table cells';
657
+ case 'numberingChange':
658
+ return 'Changed list numbering';
659
+ case 'hyperlinkChange':
660
+ return this.describeHyperlinkChange(revision, maxLength);
661
+ case 'imageChange':
662
+ return this.describeImageChange(revision);
663
+ case 'fieldChange':
664
+ return this.describeFieldChange(revision);
665
+ case 'commentChange':
666
+ return this.describeCommentChange(revision);
667
+ case 'bookmarkChange':
668
+ return this.describeBookmarkChange(revision);
669
+ case 'contentControlChange':
670
+ return this.describeContentControlChange(revision);
671
+ default:
672
+ return `Changed (${type})`;
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Generate description for a hyperlink change revision.
678
+ */
679
+ private static describeHyperlinkChange(revision: Revision, maxLength: number): string {
680
+ const prevProps = revision.getPreviousProperties() || {};
681
+ const newProps = revision.getNewProperties() || {};
682
+ const changes: string[] = [];
683
+
684
+ if (prevProps.url !== newProps.url) {
685
+ changes.push('URL');
686
+ }
687
+ if (prevProps.text !== newProps.text) {
688
+ changes.push('display text');
689
+ }
690
+ if (prevProps.formatting !== newProps.formatting) {
691
+ changes.push('formatting');
692
+ }
693
+
694
+ if (changes.length === 0) {
695
+ return 'Updated hyperlink';
696
+ }
697
+
698
+ return `Changed hyperlink ${changes.join(' and ')}`;
699
+ }
700
+
701
+ /**
702
+ * Generate description for an image change revision.
703
+ */
704
+ private static describeImageChange(revision: Revision): string {
705
+ const prevProps = revision.getPreviousProperties() || {};
706
+ const newProps = revision.getNewProperties() || {};
707
+ const changes: string[] = [];
708
+
709
+ // Detect type of change
710
+ if (!prevProps.imageId && newProps.imageId) {
711
+ return `Inserted image${newProps.filename ? ` "${newProps.filename}"` : ''}`;
712
+ }
713
+ if (prevProps.imageId && !newProps.imageId) {
714
+ return `Deleted image${prevProps.filename ? ` "${prevProps.filename}"` : ''}`;
715
+ }
716
+
717
+ // Property changes
718
+ if (prevProps.width !== newProps.width || prevProps.height !== newProps.height) {
719
+ changes.push('size');
720
+ }
721
+ if (prevProps.position !== newProps.position) {
722
+ changes.push('position');
723
+ }
724
+ if (prevProps.wrapping !== newProps.wrapping) {
725
+ changes.push('wrapping');
726
+ }
727
+ if (prevProps.altText !== newProps.altText) {
728
+ changes.push('alt text');
729
+ }
730
+
731
+ if (changes.length === 0) {
732
+ return 'Updated image';
733
+ }
734
+
735
+ return `Changed image ${changes.join(' and ')}`;
736
+ }
737
+
738
+ /**
739
+ * Generate description for a field change revision.
740
+ */
741
+ private static describeFieldChange(revision: Revision): string {
742
+ const prevProps = revision.getPreviousProperties() || {};
743
+ const newProps = revision.getNewProperties() || {};
744
+
745
+ // Detect type of change
746
+ if (!prevProps.fieldType && newProps.fieldType) {
747
+ return `Inserted ${newProps.fieldType || 'field'}`;
748
+ }
749
+ if (prevProps.fieldType && !newProps.fieldType) {
750
+ return `Deleted ${prevProps.fieldType || 'field'}`;
751
+ }
752
+
753
+ // Value/formula changes
754
+ if (prevProps.value !== newProps.value) {
755
+ return `Updated ${newProps.fieldType || 'field'} value`;
756
+ }
757
+ if (prevProps.formula !== newProps.formula) {
758
+ return `Changed ${newProps.fieldType || 'field'} formula`;
759
+ }
760
+
761
+ return `Updated ${newProps.fieldType || 'field'}`;
762
+ }
763
+
764
+ /**
765
+ * Generate description for a comment change revision.
766
+ */
767
+ private static describeCommentChange(revision: Revision): string {
768
+ const prevProps = revision.getPreviousProperties() || {};
769
+ const newProps = revision.getNewProperties() || {};
770
+
771
+ // Detect type of change
772
+ if (!prevProps.commentId && newProps.commentId) {
773
+ return `Added comment${newProps.author ? ` by ${newProps.author}` : ''}`;
774
+ }
775
+ if (prevProps.commentId && !newProps.commentId) {
776
+ return `Deleted comment${prevProps.author ? ` by ${prevProps.author}` : ''}`;
777
+ }
778
+ if (prevProps.text !== newProps.text) {
779
+ return `Edited comment${newProps.author ? ` by ${newProps.author}` : ''}`;
780
+ }
781
+
782
+ return 'Updated comment';
783
+ }
784
+
785
+ /**
786
+ * Generate description for a bookmark change revision.
787
+ */
788
+ private static describeBookmarkChange(revision: Revision): string {
789
+ const prevProps = revision.getPreviousProperties() || {};
790
+ const newProps = revision.getNewProperties() || {};
791
+
792
+ // Detect type of change
793
+ if (!prevProps.bookmarkId && newProps.bookmarkId) {
794
+ return `Created bookmark "${newProps.name || 'unnamed'}"`;
795
+ }
796
+ if (prevProps.bookmarkId && !newProps.bookmarkId) {
797
+ return `Deleted bookmark "${prevProps.name || 'unnamed'}"`;
798
+ }
799
+ if (prevProps.name !== newProps.name) {
800
+ return `Renamed bookmark from "${prevProps.name}" to "${newProps.name}"`;
801
+ }
802
+ if (prevProps.rangeStart !== newProps.rangeStart || prevProps.rangeEnd !== newProps.rangeEnd) {
803
+ return `Changed bookmark "${newProps.name || 'unnamed'}" range`;
804
+ }
805
+
806
+ return `Updated bookmark "${newProps.name || prevProps.name || 'unnamed'}"`;
807
+ }
808
+
809
+ /**
810
+ * Generate description for a content control change revision.
811
+ */
812
+ private static describeContentControlChange(revision: Revision): string {
813
+ const prevProps = revision.getPreviousProperties() || {};
814
+ const newProps = revision.getNewProperties() || {};
815
+
816
+ // Detect type of change
817
+ if (!prevProps.sdtId && newProps.sdtId) {
818
+ return `Inserted content control${newProps.title ? ` "${newProps.title}"` : ''}`;
819
+ }
820
+ if (prevProps.sdtId && !newProps.sdtId) {
821
+ return `Deleted content control${prevProps.title ? ` "${prevProps.title}"` : ''}`;
822
+ }
823
+ if (prevProps.title !== newProps.title) {
824
+ return `Renamed content control to "${newProps.title}"`;
825
+ }
826
+ if (prevProps.content !== newProps.content) {
827
+ return `Changed content control${newProps.title ? ` "${newProps.title}"` : ''} content`;
828
+ }
829
+
830
+ return `Updated content control${newProps.title || prevProps.title ? ` "${newProps.title || prevProps.title}"` : ''}`;
831
+ }
832
+
833
+ /**
834
+ * Generate description for a property change revision.
835
+ */
836
+ private static describePropertyChange(revision: Revision, context: string): string {
837
+ const prevProps = revision.getPreviousProperties();
838
+ const newProps = revision.getNewProperties();
839
+
840
+ if (!prevProps && !newProps) {
841
+ return `Changed ${context}`;
842
+ }
843
+
844
+ // Get meaningful property names
845
+ const propNames: string[] = [];
846
+ const allKeys = new Set([...Object.keys(prevProps || {}), ...Object.keys(newProps || {})]);
847
+
848
+ for (const key of allKeys) {
849
+ const oldVal = prevProps?.[key];
850
+ const newVal = newProps?.[key];
851
+ if (oldVal !== newVal) {
852
+ propNames.push(this.friendlyPropertyName(key));
853
+ }
854
+ }
855
+
856
+ if (propNames.length === 0) {
857
+ return `Changed ${context}`;
858
+ }
859
+
860
+ if (propNames.length === 1) {
861
+ return `Changed ${propNames[0]}`;
862
+ }
863
+
864
+ if (propNames.length <= 3) {
865
+ return `Changed ${propNames.join(', ')}`;
866
+ }
867
+
868
+ return `Changed ${propNames.slice(0, 2).join(', ')} and ${propNames.length - 2} more`;
869
+ }
870
+
871
+ /**
872
+ * Format a property value for display.
873
+ * Handles objects, arrays, and primitives properly.
874
+ */
875
+ private static formatPropertyValue(value: unknown): string | undefined {
876
+ if (value === null || value === undefined) {
877
+ return undefined;
878
+ }
879
+ if (typeof value === 'object') {
880
+ try {
881
+ // For objects, use JSON.stringify for proper representation
882
+ const json = JSON.stringify(value);
883
+ // Truncate if too long
884
+ return json.length > 100 ? json.substring(0, 97) + '...' : json;
885
+ } catch (e) {
886
+ getLogger().debug('Failed to stringify value', { error: String(e) });
887
+ return '[complex value]';
888
+ }
889
+ }
890
+ // For primitives, use String() for safe conversion
891
+ return String(value);
892
+ }
893
+
894
+ /**
895
+ * Convert property key to friendly name.
896
+ */
897
+ private static friendlyPropertyName(key: string): string {
898
+ const friendlyNames: Record<string, string> = {
899
+ // Run (character) properties
900
+ b: 'bold',
901
+ i: 'italic',
902
+ u: 'underline',
903
+ strike: 'strikethrough',
904
+ dstrike: 'double strikethrough',
905
+ sz: 'font size',
906
+ szCs: 'complex script font size',
907
+ color: 'text color',
908
+ highlight: 'highlight',
909
+ rFonts: 'font',
910
+ rStyle: 'character style',
911
+ vertAlign: 'vertical alignment',
912
+ vanish: 'hidden text',
913
+ caps: 'all capitals',
914
+ smallCaps: 'small capitals',
915
+ outline: 'outline effect',
916
+ shadow: 'shadow effect',
917
+ emboss: 'emboss effect',
918
+ imprint: 'imprint effect',
919
+ kern: 'kerning',
920
+ w: 'character width',
921
+ spacing: 'character spacing',
922
+ position: 'text position',
923
+ shd: 'shading',
924
+ bdr: 'border',
925
+ lang: 'language',
926
+ eastAsianLayout: 'East Asian layout',
927
+ specVanish: 'special vanish',
928
+ oMath: 'math mode',
929
+
930
+ // Paragraph properties
931
+ jc: 'alignment',
932
+ ind: 'indentation',
933
+ pStyle: 'paragraph style',
934
+ numPr: 'list numbering',
935
+ pBdr: 'paragraph border',
936
+ tabs: 'tab stops',
937
+ suppressAutoHyphens: 'hyphenation',
938
+ kinsoku: 'kinsoku rules',
939
+ wordWrap: 'word wrap',
940
+ overflowPunct: 'overflow punctuation',
941
+ topLinePunct: 'top line punctuation',
942
+ autoSpaceDE: 'auto space (DE)',
943
+ autoSpaceDN: 'auto space (DN)',
944
+ bidi: 'bidirectional',
945
+ adjustRightInd: 'right indent adjustment',
946
+ snapToGrid: 'snap to grid',
947
+ contextualSpacing: 'contextual spacing',
948
+ mirrorIndents: 'mirror indents',
949
+ suppressOverlap: 'suppress overlap',
950
+ outlineLvl: 'outline level',
951
+ divId: 'HTML division',
952
+ keepNext: 'keep with next',
953
+ keepLines: 'keep lines together',
954
+ pageBreakBefore: 'page break before',
955
+ widowControl: 'widow/orphan control',
956
+ suppressLineNumbers: 'suppress line numbers',
957
+ textboxTightWrap: 'textbox tight wrap',
958
+
959
+ // Table properties
960
+ tblStyle: 'table style',
961
+ tblW: 'table width',
962
+ tblInd: 'table indent',
963
+ tblBorders: 'table borders',
964
+ tblCellMar: 'table cell margins',
965
+ tblLook: 'table look',
966
+ tblLayout: 'table layout',
967
+ gridSpan: 'column span',
968
+ vMerge: 'vertical merge',
969
+ tcW: 'cell width',
970
+ tcBorders: 'cell borders',
971
+ vAlign: 'vertical alignment',
972
+ textDirection: 'text direction',
973
+ noWrap: 'no wrap',
974
+ tcMar: 'cell margins',
975
+ tcFitText: 'fit text',
976
+ hideMark: 'hide mark',
977
+
978
+ // Section properties
979
+ sectPr: 'section properties',
980
+ pgSz: 'page size',
981
+ pgMar: 'page margins',
982
+ cols: 'columns',
983
+ docGrid: 'document grid',
984
+ headerReference: 'header',
985
+ footerReference: 'footer',
986
+ pgNumType: 'page numbering',
987
+ formProt: 'form protection',
988
+ titlePg: 'different first page',
989
+ type: 'section type',
990
+ };
991
+
992
+ return friendlyNames[key] || key;
993
+ }
994
+
995
+ /**
996
+ * Export changelog to Markdown format.
997
+ *
998
+ * @param entries - Array of changelog entries
999
+ * @param options - Export options
1000
+ * @returns Markdown string
1001
+ */
1002
+ static toMarkdown(entries: ChangeEntry[], options?: { includeMetadata?: boolean }): string {
1003
+ const opts = { includeMetadata: true, ...options };
1004
+ const lines: string[] = [];
1005
+
1006
+ lines.push('# Document Changes');
1007
+ lines.push('');
1008
+
1009
+ if (opts.includeMetadata) {
1010
+ const summary = this.getSummary(entries);
1011
+ lines.push(`**Total Changes:** ${summary.total}`);
1012
+ lines.push('');
1013
+
1014
+ if (summary.dateRange) {
1015
+ lines.push(
1016
+ `**Date Range:** ${summary.dateRange.earliest.toLocaleDateString()} - ${summary.dateRange.latest.toLocaleDateString()}`
1017
+ );
1018
+ lines.push('');
1019
+ }
1020
+
1021
+ const authors = Object.keys(summary.byAuthor);
1022
+ if (authors.length > 0) {
1023
+ lines.push(`**Authors:** ${authors.join(', ')}`);
1024
+ lines.push('');
1025
+ }
1026
+
1027
+ lines.push('---');
1028
+ lines.push('');
1029
+ }
1030
+
1031
+ // Group by category
1032
+ const byCategory = new Map<ChangeCategory, ChangeEntry[]>();
1033
+ for (const entry of entries) {
1034
+ if (!byCategory.has(entry.category)) {
1035
+ byCategory.set(entry.category, []);
1036
+ }
1037
+ byCategory.get(entry.category)!.push(entry);
1038
+ }
1039
+
1040
+ const categoryTitles: Record<ChangeCategory, string> = {
1041
+ content: 'Content Changes',
1042
+ formatting: 'Formatting Changes',
1043
+ structural: 'Structural Changes',
1044
+ table: 'Table Changes',
1045
+ hyperlink: 'Hyperlink Changes',
1046
+ image: 'Image Changes',
1047
+ field: 'Field Changes',
1048
+ comment: 'Comment Changes',
1049
+ bookmark: 'Bookmark Changes',
1050
+ contentControl: 'Content Control Changes',
1051
+ };
1052
+
1053
+ for (const [category, categoryEntries] of byCategory) {
1054
+ if (categoryEntries.length === 0) continue;
1055
+
1056
+ lines.push(`## ${categoryTitles[category]}`);
1057
+ lines.push('');
1058
+
1059
+ for (const entry of categoryEntries) {
1060
+ const date = entry.date.toLocaleDateString();
1061
+ lines.push(`- ${entry.description} *(${entry.author}, ${date})*`);
1062
+
1063
+ if (entry.content.before) {
1064
+ lines.push(` - Removed: "${entry.content.before}"`);
1065
+ }
1066
+ if (entry.content.after) {
1067
+ lines.push(` - Added: "${entry.content.after}"`);
1068
+ }
1069
+ }
1070
+
1071
+ lines.push('');
1072
+ }
1073
+
1074
+ return lines.join('\n');
1075
+ }
1076
+
1077
+ /**
1078
+ * Export changelog to plain text format.
1079
+ *
1080
+ * @param entries - Array of changelog entries
1081
+ * @returns Plain text string
1082
+ */
1083
+ static toPlainText(entries: ChangeEntry[]): string {
1084
+ const lines: string[] = [];
1085
+
1086
+ lines.push('DOCUMENT CHANGES');
1087
+ lines.push('================');
1088
+ lines.push('');
1089
+
1090
+ for (const entry of entries) {
1091
+ const date = entry.date.toLocaleDateString();
1092
+ lines.push(`[${date}] ${entry.author}: ${entry.description}`);
1093
+
1094
+ if (entry.content.before) {
1095
+ lines.push(` - ${entry.content.before}`);
1096
+ }
1097
+ if (entry.content.after) {
1098
+ lines.push(` + ${entry.content.after}`);
1099
+ }
1100
+ }
1101
+
1102
+ return lines.join('\n');
1103
+ }
1104
+
1105
+ /**
1106
+ * Export changelog to JSON (for programmatic consumption).
1107
+ *
1108
+ * @param entries - Array of changelog entries
1109
+ * @returns JSON string
1110
+ */
1111
+ static toJSON(entries: ChangeEntry[]): string {
1112
+ return JSON.stringify(
1113
+ {
1114
+ generated: new Date().toISOString(),
1115
+ summary: this.getSummary(entries),
1116
+ entries,
1117
+ },
1118
+ null,
1119
+ 2
1120
+ );
1121
+ }
1122
+
1123
+ // ============================================================
1124
+ // Unified API (Phase 2 Enhancement)
1125
+ // ============================================================
1126
+
1127
+ /**
1128
+ * Unified changelog generation - single entry point for all formats.
1129
+ *
1130
+ * Generates changelog from a document in the specified format.
1131
+ * This is the recommended method for most use cases.
1132
+ *
1133
+ * @param doc - Document to extract revisions from
1134
+ * @param options - Generation options including format
1135
+ * @returns Formatted changelog string
1136
+ *
1137
+ * @example
1138
+ * ```typescript
1139
+ * // Generate Markdown changelog
1140
+ * const md = ChangelogGenerator.generate(doc, { format: 'markdown' });
1141
+ *
1142
+ * // Generate HTML changelog
1143
+ * const html = ChangelogGenerator.generate(doc, { format: 'html' });
1144
+ *
1145
+ * // Generate CSV with filtering
1146
+ * const csv = ChangelogGenerator.generate(doc, {
1147
+ * format: 'csv',
1148
+ * filterAuthors: ['John Doe'],
1149
+ * sortBy: 'date',
1150
+ * sortOrder: 'desc'
1151
+ * });
1152
+ * ```
1153
+ */
1154
+ static generate(doc: Document, options?: ChangelogOptions): string {
1155
+ const logger = getLogger();
1156
+ const format = options?.format || 'markdown';
1157
+
1158
+ logger.info('Generating changelog', { format });
1159
+
1160
+ // Get entries with filtering
1161
+ let entries = this.fromDocument(doc, options);
1162
+
1163
+ // Apply sorting if specified
1164
+ if (options?.sortBy) {
1165
+ entries = this.sortEntries(entries, options.sortBy, options.sortOrder || 'asc');
1166
+ }
1167
+
1168
+ logger.info('Changelog entries processed', {
1169
+ entries: entries.length,
1170
+ format,
1171
+ sorted: !!options?.sortBy,
1172
+ });
1173
+
1174
+ // Generate output in specified format
1175
+ switch (format) {
1176
+ case 'json':
1177
+ return this.toJSON(entries);
1178
+ case 'markdown':
1179
+ return this.toMarkdown(entries, { includeMetadata: true });
1180
+ case 'text':
1181
+ return this.toPlainText(entries);
1182
+ case 'html':
1183
+ return this.toHTML(entries, options);
1184
+ case 'csv':
1185
+ return this.toCSV(entries);
1186
+ default:
1187
+ return this.toMarkdown(entries);
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Export changelog to HTML format.
1193
+ *
1194
+ * Generates a complete HTML document with styling and structure.
1195
+ *
1196
+ * @param entries - Array of changelog entries
1197
+ * @param options - HTML generation options
1198
+ * @returns Complete HTML document string
1199
+ *
1200
+ * @example
1201
+ * ```typescript
1202
+ * const html = ChangelogGenerator.toHTML(entries);
1203
+ * fs.writeFileSync('changelog.html', html);
1204
+ * ```
1205
+ */
1206
+ static toHTML(entries: ChangeEntry[], options?: ChangelogOptions): string {
1207
+ const summary = this.getSummary(entries);
1208
+
1209
+ // Group entries by category
1210
+ const byCategory = new Map<ChangeCategory, ChangeEntry[]>();
1211
+ for (const entry of entries) {
1212
+ if (!byCategory.has(entry.category)) {
1213
+ byCategory.set(entry.category, []);
1214
+ }
1215
+ byCategory.get(entry.category)!.push(entry);
1216
+ }
1217
+
1218
+ const categoryTitles: Record<ChangeCategory, string> = {
1219
+ content: 'Content Changes',
1220
+ formatting: 'Formatting Changes',
1221
+ structural: 'Structural Changes',
1222
+ table: 'Table Changes',
1223
+ hyperlink: 'Hyperlink Changes',
1224
+ image: 'Image Changes',
1225
+ field: 'Field Changes',
1226
+ comment: 'Comment Changes',
1227
+ bookmark: 'Bookmark Changes',
1228
+ contentControl: 'Content Control Changes',
1229
+ };
1230
+
1231
+ // Build HTML
1232
+ let html = `<!DOCTYPE html>
1233
+ <html lang="en">
1234
+ <head>
1235
+ <meta charset="UTF-8">
1236
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1237
+ <title>Document Changes</title>
1238
+ <style>
1239
+ body {
1240
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1241
+ max-width: 900px;
1242
+ margin: 0 auto;
1243
+ padding: 20px;
1244
+ line-height: 1.6;
1245
+ color: #333;
1246
+ }
1247
+ h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
1248
+ h2 { color: #34495e; margin-top: 30px; }
1249
+ .summary { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
1250
+ .summary p { margin: 5px 0; }
1251
+ .category { margin-bottom: 30px; }
1252
+ .change-list { list-style: none; padding: 0; }
1253
+ .change-item {
1254
+ background: #fff;
1255
+ border: 1px solid #e9ecef;
1256
+ border-radius: 6px;
1257
+ padding: 12px 15px;
1258
+ margin-bottom: 10px;
1259
+ }
1260
+ .change-item:hover { box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
1261
+ .change-description { font-weight: 500; color: #2c3e50; }
1262
+ .change-meta { font-size: 0.85em; color: #7f8c8d; margin-top: 5px; }
1263
+ .change-content { margin-top: 8px; font-size: 0.9em; }
1264
+ .removed { color: #c0392b; text-decoration: line-through; }
1265
+ .added { color: #27ae60; }
1266
+ .badge {
1267
+ display: inline-block;
1268
+ padding: 2px 8px;
1269
+ border-radius: 4px;
1270
+ font-size: 0.75em;
1271
+ font-weight: 600;
1272
+ text-transform: uppercase;
1273
+ }
1274
+ .badge-insert { background: #d4edda; color: #155724; }
1275
+ .badge-delete { background: #f8d7da; color: #721c24; }
1276
+ .badge-formatting { background: #cce5ff; color: #004085; }
1277
+ .badge-structural { background: #fff3cd; color: #856404; }
1278
+ .badge-table { background: #e2e3e5; color: #383d41; }
1279
+ </style>
1280
+ </head>
1281
+ <body>
1282
+ <h1>Document Changes</h1>
1283
+
1284
+ <div class="summary">
1285
+ <p><strong>Total Changes:</strong> ${summary.total}</p>`;
1286
+
1287
+ if (summary.dateRange) {
1288
+ html += `
1289
+ <p><strong>Date Range:</strong> ${summary.dateRange.earliest.toLocaleDateString()} - ${summary.dateRange.latest.toLocaleDateString()}</p>`;
1290
+ }
1291
+
1292
+ const authors = Object.keys(summary.byAuthor);
1293
+ if (authors.length > 0) {
1294
+ html += `
1295
+ <p><strong>Authors:</strong> ${authors.join(', ')}</p>`;
1296
+ }
1297
+
1298
+ html += `
1299
+ </div>
1300
+ `;
1301
+
1302
+ // Add sections by category
1303
+ for (const [category, categoryEntries] of byCategory) {
1304
+ if (categoryEntries.length === 0) continue;
1305
+
1306
+ html += `
1307
+ <section class="category">
1308
+ <h2>${categoryTitles[category]}</h2>
1309
+ <ul class="change-list">`;
1310
+
1311
+ for (const entry of categoryEntries) {
1312
+ const badgeClass = this.getBadgeClass(entry.revisionType);
1313
+ const date = entry.date.toLocaleDateString();
1314
+
1315
+ html += `
1316
+ <li class="change-item">
1317
+ <span class="badge ${badgeClass}">${entry.revisionType}</span>
1318
+ <div class="change-description">${this.escapeHTML(entry.description)}</div>
1319
+ <div class="change-meta">${this.escapeHTML(entry.author)} - ${date}</div>`;
1320
+
1321
+ if (entry.content.before || entry.content.after) {
1322
+ html += `
1323
+ <div class="change-content">`;
1324
+ if (entry.content.before) {
1325
+ html += `<span class="removed">${this.escapeHTML(entry.content.before || '')}</span> `;
1326
+ }
1327
+ if (entry.content.after) {
1328
+ html += `<span class="added">${this.escapeHTML(entry.content.after || '')}</span>`;
1329
+ }
1330
+ html += `</div>`;
1331
+ }
1332
+
1333
+ html += `
1334
+ </li>`;
1335
+ }
1336
+
1337
+ html += `
1338
+ </ul>
1339
+ </section>`;
1340
+ }
1341
+
1342
+ html += `
1343
+ </body>
1344
+ </html>`;
1345
+
1346
+ return html;
1347
+ }
1348
+
1349
+ /**
1350
+ * Export changelog to CSV format.
1351
+ *
1352
+ * Generates CSV data suitable for spreadsheet applications.
1353
+ *
1354
+ * @param entries - Array of changelog entries
1355
+ * @param options - CSV generation options
1356
+ * @returns CSV string
1357
+ *
1358
+ * @example
1359
+ * ```typescript
1360
+ * const csv = ChangelogGenerator.toCSV(entries);
1361
+ * fs.writeFileSync('changelog.csv', csv);
1362
+ * ```
1363
+ */
1364
+ static toCSV(
1365
+ entries: ChangeEntry[],
1366
+ options?: {
1367
+ delimiter?: string;
1368
+ includeHeaders?: boolean;
1369
+ }
1370
+ ): string {
1371
+ const delimiter = options?.delimiter || ',';
1372
+ const includeHeaders = options?.includeHeaders !== false;
1373
+
1374
+ const headers = [
1375
+ 'ID',
1376
+ 'Type',
1377
+ 'Category',
1378
+ 'Author',
1379
+ 'Date',
1380
+ 'Description',
1381
+ 'Before',
1382
+ 'After',
1383
+ 'Paragraph',
1384
+ 'Run',
1385
+ ];
1386
+
1387
+ const lines: string[] = [];
1388
+
1389
+ if (includeHeaders) {
1390
+ lines.push(headers.join(delimiter));
1391
+ }
1392
+
1393
+ for (const entry of entries) {
1394
+ const row = [
1395
+ entry.id,
1396
+ entry.revisionType,
1397
+ entry.category,
1398
+ this.escapeCSV(entry.author),
1399
+ entry.date.toISOString(),
1400
+ this.escapeCSV(entry.description),
1401
+ this.escapeCSV(entry.content.before || ''),
1402
+ this.escapeCSV(entry.content.after || ''),
1403
+ entry.location.paragraphIndex?.toString() || '',
1404
+ entry.location.runIndex?.toString() || '',
1405
+ ];
1406
+ lines.push(row.join(delimiter));
1407
+ }
1408
+
1409
+ return lines.join('\n');
1410
+ }
1411
+
1412
+ /**
1413
+ * Get timeline view - changes organized by date.
1414
+ *
1415
+ * Groups changelog entries by date (YYYY-MM-DD) for timeline visualization.
1416
+ *
1417
+ * @param entries - Array of changelog entries
1418
+ * @returns Map of date strings to entries for that date
1419
+ *
1420
+ * @example
1421
+ * ```typescript
1422
+ * const timeline = ChangelogGenerator.getTimeline(entries);
1423
+ * for (const [date, dayEntries] of timeline) {
1424
+ * console.log(`${date}: ${dayEntries.length} changes`);
1425
+ * }
1426
+ * ```
1427
+ */
1428
+ static getTimeline(entries: ChangeEntry[]): Map<string, ChangeEntry[]> {
1429
+ const timeline = new Map<string, ChangeEntry[]>();
1430
+
1431
+ for (const entry of entries) {
1432
+ const dateKey = entry.date.toISOString().split('T')[0]!; // YYYY-MM-DD
1433
+ if (!timeline.has(dateKey)) {
1434
+ timeline.set(dateKey, []);
1435
+ }
1436
+ timeline.get(dateKey)!.push(entry);
1437
+ }
1438
+
1439
+ // Sort the map by date
1440
+ const sortedTimeline = new Map<string, ChangeEntry[]>(
1441
+ [...timeline.entries()].sort((a, b) => a[0].localeCompare(b[0]))
1442
+ );
1443
+
1444
+ return sortedTimeline;
1445
+ }
1446
+
1447
+ /**
1448
+ * Get summary by element type.
1449
+ *
1450
+ * Groups entries by the type of document element they affect.
1451
+ *
1452
+ * @param entries - Array of changelog entries
1453
+ * @returns Object with entries grouped by element type
1454
+ */
1455
+ static getSummaryByElement(entries: ChangeEntry[]): {
1456
+ paragraphs: ChangeEntry[];
1457
+ tables: ChangeEntry[];
1458
+ sections: ChangeEntry[];
1459
+ runs: ChangeEntry[];
1460
+ hyperlinks: ChangeEntry[];
1461
+ } {
1462
+ return {
1463
+ paragraphs: entries.filter(
1464
+ (e) =>
1465
+ e.revisionType === 'paragraphPropertiesChange' ||
1466
+ e.revisionType === 'numberingChange' ||
1467
+ (e.revisionType === 'insert' && !e.location.runIndex) ||
1468
+ (e.revisionType === 'delete' && !e.location.runIndex)
1469
+ ),
1470
+ tables: entries.filter((e) =>
1471
+ [
1472
+ 'tablePropertiesChange',
1473
+ 'tableRowPropertiesChange',
1474
+ 'tableCellPropertiesChange',
1475
+ 'tableExceptionPropertiesChange',
1476
+ 'tableCellInsert',
1477
+ 'tableCellDelete',
1478
+ 'tableCellMerge',
1479
+ ].includes(e.revisionType)
1480
+ ),
1481
+ sections: entries.filter((e) => e.revisionType === 'sectionPropertiesChange'),
1482
+ runs: entries.filter(
1483
+ (e) =>
1484
+ e.revisionType === 'runPropertiesChange' ||
1485
+ (e.revisionType === 'insert' && e.location.runIndex !== undefined) ||
1486
+ (e.revisionType === 'delete' && e.location.runIndex !== undefined)
1487
+ ),
1488
+ hyperlinks: entries.filter((e) => e.revisionType === 'hyperlinkChange'),
1489
+ };
1490
+ }
1491
+
1492
+ // ============================================================
1493
+ // Helper Methods
1494
+ // ============================================================
1495
+
1496
+ /**
1497
+ * Sort entries by specified field.
1498
+ * @internal
1499
+ */
1500
+ private static sortEntries(
1501
+ entries: ChangeEntry[],
1502
+ sortBy: 'date' | 'author' | 'type' | 'category',
1503
+ order: 'asc' | 'desc'
1504
+ ): ChangeEntry[] {
1505
+ const sorted = [...entries].sort((a, b) => {
1506
+ let comparison = 0;
1507
+
1508
+ switch (sortBy) {
1509
+ case 'date':
1510
+ comparison = a.date.getTime() - b.date.getTime();
1511
+ break;
1512
+ case 'author':
1513
+ comparison = a.author.localeCompare(b.author);
1514
+ break;
1515
+ case 'type':
1516
+ comparison = a.revisionType.localeCompare(b.revisionType);
1517
+ break;
1518
+ case 'category':
1519
+ comparison = a.category.localeCompare(b.category);
1520
+ break;
1521
+ }
1522
+
1523
+ return order === 'desc' ? -comparison : comparison;
1524
+ });
1525
+
1526
+ return sorted;
1527
+ }
1528
+
1529
+ /**
1530
+ * Get CSS badge class for revision type.
1531
+ * @internal
1532
+ */
1533
+ private static getBadgeClass(type: RevisionType): string {
1534
+ switch (type) {
1535
+ case 'insert':
1536
+ case 'moveTo':
1537
+ case 'tableCellInsert':
1538
+ return 'badge-insert';
1539
+ case 'delete':
1540
+ case 'moveFrom':
1541
+ case 'tableCellDelete':
1542
+ return 'badge-delete';
1543
+ case 'runPropertiesChange':
1544
+ case 'paragraphPropertiesChange':
1545
+ case 'numberingChange':
1546
+ return 'badge-formatting';
1547
+ case 'sectionPropertiesChange':
1548
+ return 'badge-structural';
1549
+ case 'tablePropertiesChange':
1550
+ case 'tableRowPropertiesChange':
1551
+ case 'tableCellPropertiesChange':
1552
+ case 'tableCellMerge':
1553
+ return 'badge-table';
1554
+ default:
1555
+ return 'badge-formatting';
1556
+ }
1557
+ }
1558
+
1559
+ /**
1560
+ * Escape HTML special characters.
1561
+ * @internal
1562
+ */
1563
+ private static escapeHTML(str: string): string {
1564
+ return str
1565
+ .replace(/&/g, '&amp;')
1566
+ .replace(/</g, '&lt;')
1567
+ .replace(/>/g, '&gt;')
1568
+ .replace(/"/g, '&quot;')
1569
+ .replace(/'/g, '&#039;');
1570
+ }
1571
+
1572
+ /**
1573
+ * Escape CSV special characters.
1574
+ * @internal
1575
+ */
1576
+ private static escapeCSV(str: string): string {
1577
+ // If string contains delimiter, newline, or quotes, wrap in quotes
1578
+ if (str.includes(',') || str.includes('\n') || str.includes('"')) {
1579
+ return `"${str.replace(/"/g, '""')}"`;
1580
+ }
1581
+ return str;
1582
+ }
1583
+ }