documentation-hub 5.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/.eslintrc.json +43 -0
  2. package/.github/workflows/build.yml +64 -0
  3. package/.github/workflows/ci.yml +39 -0
  4. package/.vscode/extensions.json +3 -0
  5. package/Current.md +97 -0
  6. package/DocHub_Image.png +0 -0
  7. package/README.md +666 -0
  8. package/USER_GUIDE.md +1173 -0
  9. package/Updater.md +311 -0
  10. package/build/256x256.png +0 -0
  11. package/build/512x512.png +0 -0
  12. package/build/app-update.yml +4 -0
  13. package/build/create-icon.js +208 -0
  14. package/build/icon.ico +0 -0
  15. package/build/icon.png +0 -0
  16. package/build/icon_1024x1024.png +0 -0
  17. package/dist/assets/Analytics-BpsG9895.js +1 -0
  18. package/dist/assets/Card-IAZin8kp.js +1 -0
  19. package/dist/assets/CurrentSession-B-rFkHvf.js +12 -0
  20. package/dist/assets/Dashboard-C_5gMb0q.js +1 -0
  21. package/dist/assets/Documents-CqZ25axS.js +1 -0
  22. package/dist/assets/Input-l89xwXBi.js +1 -0
  23. package/dist/assets/Reporting-DqdHJY_a.js +1 -0
  24. package/dist/assets/Search-XNbu5z_3.js +1 -0
  25. package/dist/assets/SessionManager-lH9hZfzH.js +1 -0
  26. package/dist/assets/Sessions-ClZOPYNc.js +1 -0
  27. package/dist/assets/Settings-DUEHGURa.js +11 -0
  28. package/dist/assets/index-8xUe8ptc.js +24 -0
  29. package/dist/assets/index-RYyJqF7O.css +1 -0
  30. package/dist/assets/path-BkOl0AGO.js +1 -0
  31. package/dist/assets/promises-ID_B9S-h.js +1 -0
  32. package/dist/assets/urlHelpers-TvgahX0r.js +1 -0
  33. package/dist/assets/useToast-yRSO1dkm.js +1 -0
  34. package/dist/assets/vendor-charts-RkGK5ROP.js +36 -0
  35. package/dist/assets/vendor-db-l0sNRNKZ.js +1 -0
  36. package/dist/assets/vendor-react-BVZ_anCF.js +4 -0
  37. package/dist/assets/vendor-search-Dw8P0qyA.js +1 -0
  38. package/dist/assets/vendor-ui-BU7NfluV.js +53 -0
  39. package/dist/electron/PowerAutomateApiService-LfW09ZGr.js +147 -0
  40. package/dist/electron/main-CXkNtyv-.js +19789 -0
  41. package/dist/electron/main.js +5 -0
  42. package/dist/electron/preload.js +1 -0
  43. package/dist/icon.png +0 -0
  44. package/dist/index.html +27 -0
  45. package/docs/CODEBASE_ANALYSIS_REPORT.md +309 -0
  46. package/docs/DEBUG_LOGGING_GUIDE.md +244 -0
  47. package/docs/README.md +115 -0
  48. package/docs/TOC_WIRING_GUIDE.md +344 -0
  49. package/docs/analysis/Bullet_Symbol_Bug_Analysis.md +136 -0
  50. package/docs/analysis/DOCXMLATER_ANALYSIS_SUMMARY.txt +169 -0
  51. package/docs/analysis/Document_Processing_Issues_Analysis.md +704 -0
  52. package/docs/analysis/FIELD_PRESERVATION_ANALYSIS.md +1200 -0
  53. package/docs/analysis/INDENTATION_PRESERVE_ANALYSIS.md +181 -0
  54. package/docs/analysis/INDENTATION_PRESERVE_IMPLEMENTATION.md +207 -0
  55. package/docs/analysis/List_Implementation.md +206 -0
  56. package/docs/analysis/List_Implementation_Accuracy_Report.md +366 -0
  57. package/docs/analysis/PROCESSING_OPTIONS_UI_UPDATES.md +220 -0
  58. package/docs/analysis/RefactorStyles.md +852 -0
  59. package/docs/analysis/STYLE_PARAMETER_ENHANCEMENT.md +143 -0
  60. package/docs/analysis/docxmlater-comparison-todo-2025-11-13.md +636 -0
  61. package/docs/analysis/docxmlater-implementation-analysis-2025-11-13.md +340 -0
  62. package/docs/analysis/docxmlater-template_ui-integration-analysis.md +263 -0
  63. package/docs/analysis/github-issues-to-create.md +237 -0
  64. package/docs/api/API_README.md +538 -0
  65. package/docs/api/API_REFERENCE.md +751 -0
  66. package/docs/api/TYPE_DEFINITIONS.md +869 -0
  67. package/docs/architecture/FONT_EMBEDDING_GUIDE.md +318 -0
  68. package/docs/architecture/docxmlater-functions-and-structure.md +726 -0
  69. package/docs/docxmlater-readme.md +1341 -0
  70. package/docs/fixes/EXECUTION_LOG_TEST_BASE.md +573 -0
  71. package/docs/fixes/HYPERLINK_TEXT_SANITIZATION.md +253 -0
  72. package/docs/fixes/README.md +37 -0
  73. package/docs/github-issues/issue-1-body.md +125 -0
  74. package/docs/github-issues/issue-10-body.md +850 -0
  75. package/docs/github-issues/issue-2-body.md +200 -0
  76. package/docs/github-issues/issue-3-body.md +270 -0
  77. package/docs/github-issues/issue-4-body.md +169 -0
  78. package/docs/github-issues/issue-5-body.md +173 -0
  79. package/docs/github-issues/issue-6-body.md +158 -0
  80. package/docs/github-issues/issue-7-body.md +171 -0
  81. package/docs/github-issues/issue-8-body.md +407 -0
  82. package/docs/github-issues/issue-9-body.md +515 -0
  83. package/docs/github-issues/issue-tracker.md +274 -0
  84. package/docs/github-issues/predictive-analysis-2025-10-18.md +2131 -0
  85. package/docs/implementation/List_Framework_Refactor_Plan.md +336 -0
  86. package/docs/implementation/PRIMARY_TEXT_COLOR_FEATURE.md +217 -0
  87. package/docs/implementation/RELEASE_PLAN_v2.1.0.md +362 -0
  88. package/docs/implementation/RefactorStyles.md +588 -0
  89. package/docs/implementation/implement-plan.md +489 -0
  90. package/docs/implementation/missing-helpers-implementation.md +391 -0
  91. package/docs/implementation/refactor-plan.md +520 -0
  92. package/docs/implementation/session-implementation-complete.md +233 -0
  93. package/docs/implementation/session-management-plan.md +250 -0
  94. package/docs/setup-checklist.md +77 -0
  95. package/docs/versions/changelog.md +345 -0
  96. package/electron/customUpdater.ts +656 -0
  97. package/electron/main.ts +2441 -0
  98. package/electron/memoryConfig.ts +187 -0
  99. package/electron/preload.ts +394 -0
  100. package/electron/proxyConfig.ts +340 -0
  101. package/electron/services/BackupService.ts +452 -0
  102. package/electron/services/DictionaryService.ts +402 -0
  103. package/electron/services/LocalDictionaryLookupService.ts +147 -0
  104. package/electron/services/PowerAutomateApiService.ts +231 -0
  105. package/electron/services/SharePointSyncService.ts +474 -0
  106. package/electron/windowsCertStore.ts +427 -0
  107. package/electron/zscalerConfig.ts +381 -0
  108. package/eslint.config.js +92 -0
  109. package/jest.config.js +52 -0
  110. package/package.json +214 -0
  111. package/postcss.config.mjs +6 -0
  112. package/public/icon.png +0 -0
  113. package/publish-release.ps1 +5 -0
  114. package/renovate.json +30 -0
  115. package/src/App.tsx +216 -0
  116. package/src/__mocks__/p-limit.js +12 -0
  117. package/src/__mocks__/styleMock.js +1 -0
  118. package/src/components/common/BugReportButton.tsx +44 -0
  119. package/src/components/common/BugReportDialog.tsx +193 -0
  120. package/src/components/common/Button.tsx +153 -0
  121. package/src/components/common/Card.tsx +86 -0
  122. package/src/components/common/ColorPickerDialog.tsx +177 -0
  123. package/src/components/common/ConfirmDialog.tsx +96 -0
  124. package/src/components/common/DebugConsole.tsx +275 -0
  125. package/src/components/common/EmptyState.tsx +183 -0
  126. package/src/components/common/ErrorBoundary.tsx +98 -0
  127. package/src/components/common/ErrorDetailsDialog.tsx +153 -0
  128. package/src/components/common/ErrorFallback.tsx +218 -0
  129. package/src/components/common/Input.tsx +109 -0
  130. package/src/components/common/Skeleton.tsx +184 -0
  131. package/src/components/common/SplashScreen.tsx +81 -0
  132. package/src/components/common/Toast.tsx +155 -0
  133. package/src/components/common/Tooltip.tsx +79 -0
  134. package/src/components/common/UpdateNotification.tsx +320 -0
  135. package/src/components/comparison/ComparisonWindow.tsx +374 -0
  136. package/src/components/comparison/SideBySideDiff.tsx +486 -0
  137. package/src/components/comparison/index.ts +8 -0
  138. package/src/components/document/DocumentUploader.tsx +288 -0
  139. package/src/components/document/HyperlinkPreview.tsx +430 -0
  140. package/src/components/document/HyperlinkService.md +1484 -0
  141. package/src/components/document/Hyperlink_Technical_Documentation.md +496 -0
  142. package/src/components/document/InlineChangesView.tsx +707 -0
  143. package/src/components/document/ProcessingProgress.tsx +303 -0
  144. package/src/components/document/ProcessingResults.tsx +256 -0
  145. package/src/components/document/TrackedChangesDetail.tsx +530 -0
  146. package/src/components/document/TrackedChangesPanel.tsx +546 -0
  147. package/src/components/document/VirtualDocumentList.tsx +240 -0
  148. package/src/components/editor/DocumentEditor.tsx +723 -0
  149. package/src/components/editor/DocumentEditorModal.tsx +640 -0
  150. package/src/components/editor/EditorQuickActions.tsx +502 -0
  151. package/src/components/editor/EditorToolbar.tsx +312 -0
  152. package/src/components/editor/TableEditor.tsx +926 -0
  153. package/src/components/editor/index.ts +18 -0
  154. package/src/components/layout/Header.tsx +190 -0
  155. package/src/components/layout/Sidebar.tsx +313 -0
  156. package/src/components/layout/TitleBar.tsx +190 -0
  157. package/src/components/navigation/CommandPalette.tsx +233 -0
  158. package/src/components/navigation/KeyboardShortcutsModal.tsx +173 -0
  159. package/src/components/sessions/ChangeItem.tsx +408 -0
  160. package/src/components/sessions/ChangeViewer.tsx +1155 -0
  161. package/src/components/sessions/DocumentComparisonModal.tsx +314 -0
  162. package/src/components/sessions/ProcessingOptions.tsx +297 -0
  163. package/src/components/sessions/ReplacementsTab.tsx +438 -0
  164. package/src/components/sessions/RevisionHandlingOptions.tsx +87 -0
  165. package/src/components/sessions/SessionManager.tsx +188 -0
  166. package/src/components/sessions/StylesEditor.tsx +1335 -0
  167. package/src/components/sessions/TabContainer.tsx +151 -0
  168. package/src/components/sessions/VirtualSessionList.tsx +157 -0
  169. package/src/components/sessions/sessionToProcessorManager.tsx +420 -0
  170. package/src/components/settings/CertificateManager.tsx +410 -0
  171. package/src/components/settings/SegmentedControl.tsx +88 -0
  172. package/src/components/settings/SettingRow.tsx +52 -0
  173. package/src/contexts/GlobalStatsContext.tsx +396 -0
  174. package/src/contexts/SessionContext.tsx +2129 -0
  175. package/src/contexts/ThemeContext.tsx +428 -0
  176. package/src/contexts/UserSettingsContext.tsx +290 -0
  177. package/src/contexts/__tests__/GlobalStatsContext.test.tsx +390 -0
  178. package/src/global.d.ts +273 -0
  179. package/src/hooks/useDocumentQueue.tsx +210 -0
  180. package/src/hooks/useToast.tsx +55 -0
  181. package/src/main.tsx +10 -0
  182. package/src/pages/Analytics.tsx +386 -0
  183. package/src/pages/CurrentSession.tsx +1174 -0
  184. package/src/pages/Dashboard.tsx +319 -0
  185. package/src/pages/Documents.tsx +317 -0
  186. package/src/pages/Projects.tsx +250 -0
  187. package/src/pages/Reporting.tsx +386 -0
  188. package/src/pages/Search.tsx +349 -0
  189. package/src/pages/Sessions.tsx +285 -0
  190. package/src/pages/Settings.tsx +2662 -0
  191. package/src/services/HyperlinkService.ts +1085 -0
  192. package/src/services/document/DocXMLaterProcessor.ts +617 -0
  193. package/src/services/document/DocumentProcessingComparison.ts +856 -0
  194. package/src/services/document/DocumentSnapshotService.ts +575 -0
  195. package/src/services/document/WordDocumentProcessor.ts +10509 -0
  196. package/src/services/document/__tests__/DocXMLaterProcessor.hyperlinks.test.md +311 -0
  197. package/src/services/document/__tests__/WordDocumentProcessor.integration.test.ts +515 -0
  198. package/src/services/document/__tests__/WordDocumentProcessor.test.ts +812 -0
  199. package/src/services/document/blanklines/BlankLineManager.ts +658 -0
  200. package/src/services/document/blanklines/__tests__/paragraphChecks.test.ts +281 -0
  201. package/src/services/document/blanklines/helpers/blankLineInsertion.ts +87 -0
  202. package/src/services/document/blanklines/helpers/blankLineSnapshot.ts +251 -0
  203. package/src/services/document/blanklines/helpers/clearCustom.ts +121 -0
  204. package/src/services/document/blanklines/helpers/contextChecks.ts +117 -0
  205. package/src/services/document/blanklines/helpers/imageChecks.ts +51 -0
  206. package/src/services/document/blanklines/helpers/paragraphChecks.ts +236 -0
  207. package/src/services/document/blanklines/helpers/removeBlanksBetweenListItems.ts +91 -0
  208. package/src/services/document/blanklines/helpers/removeTrailingBlanks.ts +35 -0
  209. package/src/services/document/blanklines/helpers/tableGuards.ts +21 -0
  210. package/src/services/document/blanklines/index.ts +67 -0
  211. package/src/services/document/blanklines/rules/additionRules.ts +337 -0
  212. package/src/services/document/blanklines/rules/indentationRules.ts +317 -0
  213. package/src/services/document/blanklines/rules/removalRules.ts +362 -0
  214. package/src/services/document/blanklines/rules/ruleTypes.ts +92 -0
  215. package/src/services/document/blanklines/types.ts +29 -0
  216. package/src/services/document/helpers/ImageBorderCropper.ts +377 -0
  217. package/src/services/document/helpers/__tests__/whitespace.test.ts +272 -0
  218. package/src/services/document/helpers/whitespace.ts +117 -0
  219. package/src/services/document/list/ListNormalizer.ts +947 -0
  220. package/src/services/document/list/index.ts +45 -0
  221. package/src/services/document/list/list-detection.ts +275 -0
  222. package/src/services/document/list/list-types.ts +162 -0
  223. package/src/services/document/processors/HyperlinkProcessor.ts +370 -0
  224. package/src/services/document/processors/ListProcessor.ts +257 -0
  225. package/src/services/document/processors/StructureProcessor.ts +176 -0
  226. package/src/services/document/processors/StyleProcessor.ts +389 -0
  227. package/src/services/document/processors/TableProcessor.ts +2238 -0
  228. package/src/services/document/processors/__tests__/HyperlinkProcessor.test.ts +314 -0
  229. package/src/services/document/processors/__tests__/ListProcessor.test.ts +291 -0
  230. package/src/services/document/processors/__tests__/StructureProcessor.test.ts +257 -0
  231. package/src/services/document/processors/__tests__/TableProcessor.hlp-tips-bullets.test.ts +459 -0
  232. package/src/services/document/processors/__tests__/TableProcessor.test.ts +1604 -0
  233. package/src/services/document/processors/index.ts +28 -0
  234. package/src/services/document/types/docx-processing.ts +310 -0
  235. package/src/services/editor/EditorActionHandlers.ts +901 -0
  236. package/src/services/editor/index.ts +13 -0
  237. package/src/setupTests.ts +47 -0
  238. package/src/styles/global.css +782 -0
  239. package/src/types/backup.ts +132 -0
  240. package/src/types/dictionary.ts +125 -0
  241. package/src/types/document-processing.ts +331 -0
  242. package/src/types/docxmlater-augments.d.ts +142 -0
  243. package/src/types/editor.ts +280 -0
  244. package/src/types/electron.ts +340 -0
  245. package/src/types/globalStats.ts +155 -0
  246. package/src/types/hyperlink.ts +471 -0
  247. package/src/types/operations.ts +354 -0
  248. package/src/types/session.ts +427 -0
  249. package/src/types/settings.ts +112 -0
  250. package/src/utils/MemoryMonitor.ts +248 -0
  251. package/src/utils/cn.ts +6 -0
  252. package/src/utils/colorConvert.ts +306 -0
  253. package/src/utils/diffUtils.ts +347 -0
  254. package/src/utils/documentUtils.ts +202 -0
  255. package/src/utils/electronGuard.ts +62 -0
  256. package/src/utils/indexedDB.ts +915 -0
  257. package/src/utils/logger.ts +717 -0
  258. package/src/utils/pathSecurity.ts +232 -0
  259. package/src/utils/pathValidator.ts +236 -0
  260. package/src/utils/processingTimeEstimator.ts +153 -0
  261. package/src/utils/safeJsonParse.ts +62 -0
  262. package/src/utils/textSanitizer.ts +162 -0
  263. package/src/utils/urlHelpers.ts +304 -0
  264. package/src/utils/urlPatterns.ts +198 -0
  265. package/src/utils/urlSanitizer.ts +152 -0
  266. package/src/vite-env.d.ts +11 -0
  267. package/tsconfig.electron.json +19 -0
  268. package/tsconfig.json +36 -0
  269. package/tsconfig.node.json +12 -0
  270. package/typedoc.json +45 -0
  271. package/vite.config.ts +152 -0
@@ -0,0 +1,1484 @@
1
+ using DocumentFormat.OpenXml;
2
+ using DocumentFormat.OpenXml.Packaging;
3
+ using DocumentFormat.OpenXml.Wordprocessing;
4
+ using System;
5
+ using System.Collections.Generic;
6
+ using System.IO;
7
+ using System.Linq;
8
+ using System.Text.RegularExpressions;
9
+ using System.Threading.Tasks;
10
+ using DocxFormatter.Core.Interfaces;
11
+ using DocxFormatter.Core.Models;
12
+
13
+ namespace DocxFormatter.Core.Services
14
+ {
15
+ /// <summary>
16
+ /// Core service for managing hyperlinks in Word documents.
17
+ /// Provides basic hyperlink operations and coordination with specialized services.
18
+ /// </summary>
19
+ public class HyperlinkService : IHyperlinkService
20
+ {
21
+ // Regex patterns for identifying theSource URLs that should have ContentId appended
22
+ private static readonly Regex ContentIdPattern = new(
23
+ @"(TSRC|CMS)-([a-zA-Z0-9]+)-(\d{6})",
24
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
25
+
26
+ private static readonly Regex DocumentIdPattern = new(
27
+ @"docid=([a-zA-Z0-9-]+)(?:[^a-zA-Z0-9-]|$)",
28
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
29
+
30
+ private readonly BackupService _backupService;
31
+ private readonly IHyperlinkApiService _apiService;
32
+ private readonly ChangeHistoryManager _changeHistoryManager;
33
+ private static readonly Serilog.ILogger _logger = Serilog.Log.ForContext<HyperlinkService>();
34
+
35
+ public HyperlinkService(
36
+ IHyperlinkApiService apiService,
37
+ ChangeHistoryManager? changeHistoryManager = null)
38
+ {
39
+ _backupService = new BackupService();
40
+ _apiService = apiService ?? throw new ArgumentNullException(nameof(apiService));
41
+ _changeHistoryManager = changeHistoryManager ?? new ChangeHistoryManager();
42
+ }
43
+
44
+ // Backward compatibility constructor
45
+ public HyperlinkService(ChangeHistoryManager? changeHistoryManager = null)
46
+ {
47
+ _backupService = new BackupService();
48
+ _apiService = new HyperlinkApiService();
49
+ _changeHistoryManager = changeHistoryManager ?? new ChangeHistoryManager();
50
+ }
51
+
52
+ // Nested classes removed - now in separate model files:
53
+ // HyperlinkInfo -> DetailedHyperlinkInfo.cs (internal use)
54
+ // HyperlinkProcessingOptions -> HyperlinkProcessingOptions.cs
55
+ // HyperlinkProcessingResult -> HyperlinkProcessingResult.cs
56
+
57
+ /// <summary>
58
+ /// Scans a document for all hyperlinks and returns detailed information.
59
+ /// </summary>
60
+ /// <param name="document">The document to scan</param>
61
+ /// <param name="validateLinks">Whether to validate each hyperlink</param>
62
+ /// <returns>List of detailed hyperlink information</returns>
63
+ public async Task<List<DetailedHyperlinkInfo>> ScanHyperlinksAsync(WordprocessingDocument document, bool validateLinks = false)
64
+ {
65
+ if (document == null)
66
+ throw new ArgumentNullException(nameof(document));
67
+
68
+ var hyperlinks = new List<DetailedHyperlinkInfo>();
69
+
70
+ await Task.Run(() =>
71
+ {
72
+ // Scan main document
73
+ if (document.MainDocumentPart?.Document?.Body != null)
74
+ {
75
+ ScanHyperlinksInElement(document.MainDocumentPart.Document.Body,
76
+ document.MainDocumentPart, hyperlinks, validateLinks);
77
+ }
78
+
79
+ // Scan headers
80
+ var headerParts = document.MainDocumentPart?.HeaderParts;
81
+ if (headerParts != null)
82
+ {
83
+ foreach (var headerPart in headerParts)
84
+ {
85
+ ScanHyperlinksInElement(headerPart.Header, headerPart, hyperlinks, validateLinks);
86
+ }
87
+ }
88
+
89
+ // Scan footers
90
+ var footerParts = document.MainDocumentPart?.FooterParts;
91
+ if (footerParts != null)
92
+ {
93
+ foreach (var footerPart in footerParts)
94
+ {
95
+ ScanHyperlinksInElement(footerPart.Footer, footerPart, hyperlinks, validateLinks);
96
+ }
97
+ }
98
+ });
99
+
100
+ return hyperlinks;
101
+ }
102
+
103
+ /// <summary>
104
+ /// Fixes source hyperlinks by appending Content IDs and performing updates.
105
+ /// This is the main functionality requested by the user.
106
+ /// </summary>
107
+ /// <param name="filePath">Path to the document to process</param>
108
+ /// <param name="options">Processing options</param>
109
+ /// <param name="progress">Progress reporting</param>
110
+ /// <returns>Processing result</returns>
111
+ public async Task<HyperlinkProcessingResult> FixSourceHyperlinksAsync(string filePath,
112
+ HyperlinkProcessingOptions? options = null, IProgress<double>? progress = null)
113
+ {
114
+ if (string.IsNullOrWhiteSpace(filePath))
115
+ throw new ArgumentException("File path cannot be null or empty", nameof(filePath));
116
+
117
+ if (!File.Exists(filePath))
118
+ throw new FileNotFoundException($"File not found: {filePath}");
119
+
120
+ options ??= new HyperlinkProcessingOptions();
121
+ var result = new HyperlinkProcessingResult();
122
+ var startTime = DateTime.Now;
123
+
124
+ try
125
+ {
126
+ // Create backup if requested
127
+ if (options.CreateBackup)
128
+ {
129
+ progress?.Report(5);
130
+ result.BackupPath = _backupService.CreateBackupWithCleanup(filePath);
131
+ }
132
+
133
+ progress?.Report(10);
134
+
135
+ using var document = WordprocessingDocument.Open(filePath, true);
136
+
137
+ // Use internal advanced fixing functionality
138
+ var fixingOptions = ConvertToFixingOptions(options);
139
+ var fixingResult = await PerformAdvancedHyperlinkFixingAsync(document, fixingOptions);
140
+
141
+ progress?.Report(90);
142
+
143
+ // Convert result back to original format for compatibility
144
+ var convertedResult = ConvertFixingResult(fixingResult, result.BackupPath);
145
+
146
+ // Track the operation for undo/redo if successful
147
+ if (convertedResult.Success && convertedResult.ProcessedHyperlinks > 0)
148
+ {
149
+ var operation = new HyperlinkProcessingOperation
150
+ {
151
+ DocumentPath = filePath,
152
+ Description = $"Fixed {convertedResult.ProcessedHyperlinks} hyperlinks in {Path.GetFileName(filePath)}",
153
+ OperationType = "HyperlinkFix",
154
+ BackupPath = result.BackupPath,
155
+ Options = fixingOptions
156
+ };
157
+
158
+ // Track changes made
159
+ foreach (var hyperlink in convertedResult.ProcessedLinks)
160
+ {
161
+ operation.Changes.Add(new HyperlinkChange
162
+ {
163
+ HyperlinkId = hyperlink.Id,
164
+ OriginalUrl = hyperlink.Url,
165
+ NewUrl = hyperlink.Url,
166
+ OriginalDisplayText = hyperlink.DisplayText,
167
+ NewDisplayText = hyperlink.DisplayText,
168
+ Type = HyperlinkChangeType.BothChanged
169
+ });
170
+ }
171
+
172
+ // Add to history
173
+ _changeHistoryManager.AddOperation(operation);
174
+ }
175
+
176
+ progress?.Report(100);
177
+
178
+ return convertedResult;
179
+ }
180
+ catch (Exception ex)
181
+ {
182
+ result.Success = false;
183
+ result.ErrorMessages.Add($"Fatal error: {ex.Message}");
184
+ result.Duration = DateTime.Now - startTime;
185
+ return result;
186
+ }
187
+ }
188
+
189
+ /// <summary>
190
+ /// Fixes source hyperlinks using the new HyperlinkFixingOptions.
191
+ /// This overload provides access to the enhanced fixing functionality.
192
+ /// </summary>
193
+ /// <param name="filePath">Path to the document to process</param>
194
+ /// <param name="fixingOptions">Advanced fixing options</param>
195
+ /// <param name="progress">Progress reporting</param>
196
+ /// <returns>Processing result</returns>
197
+ public async Task<HyperlinkProcessingResult> FixSourceHyperlinksAsync(string filePath,
198
+ HyperlinkFixingOptions? fixingOptions = null, IProgress<double>? progress = null)
199
+ {
200
+ if (string.IsNullOrWhiteSpace(filePath))
201
+ throw new ArgumentException("File path cannot be null or empty", nameof(filePath));
202
+
203
+ if (!File.Exists(filePath))
204
+ throw new FileNotFoundException($"File not found: {filePath}");
205
+
206
+ fixingOptions ??= new HyperlinkFixingOptions();
207
+ var startTime = DateTime.Now;
208
+ var backupPath = string.Empty;
209
+
210
+ try
211
+ {
212
+ // Create backup if requested
213
+ if (fixingOptions.CreateBackup)
214
+ {
215
+ progress?.Report(5);
216
+ backupPath = _backupService.CreateBackupWithCleanup(filePath);
217
+ }
218
+
219
+ progress?.Report(10);
220
+
221
+ using var document = WordprocessingDocument.Open(filePath, true);
222
+
223
+ // Use internal advanced fixing functionality
224
+ var fixingResult = await PerformAdvancedHyperlinkFixingAsync(document, fixingOptions);
225
+
226
+ progress?.Report(90);
227
+
228
+ // Convert result to original format for compatibility
229
+ var result = ConvertFixingResult(fixingResult, backupPath);
230
+
231
+ // Track the operation for undo/redo if successful
232
+ if (result.Success && result.ProcessedHyperlinks > 0)
233
+ {
234
+ var operation = new HyperlinkProcessingOperation
235
+ {
236
+ DocumentPath = filePath,
237
+ Description = $"Fixed {result.ProcessedHyperlinks} hyperlinks in {Path.GetFileName(filePath)}",
238
+ OperationType = "HyperlinkFix",
239
+ BackupPath = backupPath,
240
+ Options = fixingOptions ?? new HyperlinkFixingOptions()
241
+ };
242
+
243
+ // Track changes made
244
+ foreach (var hyperlink in result.ProcessedLinks)
245
+ {
246
+ operation.Changes.Add(new HyperlinkChange
247
+ {
248
+ HyperlinkId = hyperlink.Id,
249
+ OriginalUrl = hyperlink.Url, // This would need to be tracked better in a real implementation
250
+ NewUrl = hyperlink.Url,
251
+ OriginalDisplayText = hyperlink.DisplayText,
252
+ NewDisplayText = hyperlink.DisplayText,
253
+ Type = HyperlinkChangeType.BothChanged
254
+ });
255
+ }
256
+
257
+ // Add to history
258
+ _changeHistoryManager.AddOperation(operation);
259
+ }
260
+
261
+ progress?.Report(100);
262
+
263
+ return result;
264
+ }
265
+ catch (Exception ex)
266
+ {
267
+ var result = new HyperlinkProcessingResult
268
+ {
269
+ Success = false,
270
+ BackupPath = backupPath,
271
+ Duration = DateTime.Now - startTime
272
+ };
273
+ result.ErrorMessages.Add($"Fatal error: {ex.Message}");
274
+ return result;
275
+ }
276
+ }
277
+
278
+ /// <summary>
279
+ /// Validates hyperlinks in a document and reports issues.
280
+ /// </summary>
281
+ /// <param name="document">The document to validate</param>
282
+ /// <returns>List of validation issues</returns>
283
+ public async Task<List<DetailedHyperlinkInfo>> ValidateHyperlinksAsync(WordprocessingDocument document)
284
+ {
285
+ var hyperlinks = await ScanHyperlinksAsync(document, true);
286
+ return hyperlinks.Where(h => !h.IsValid).ToList();
287
+ }
288
+
289
+ /// <summary>
290
+ /// Appends Content ID to hyperlinks that match specific criteria.
291
+ /// </summary>
292
+ /// <param name="document">The document to process</param>
293
+ /// <param name="contentId">The Content ID to append (e.g., "#content")</param>
294
+ /// <param name="urlPattern">Pattern to match URLs (optional)</param>
295
+ /// <returns>Number of hyperlinks updated</returns>
296
+ public async Task<int> AppendContentIdToHyperlinksAsync(WordprocessingDocument document,
297
+ string contentId, string? urlPattern = null)
298
+ {
299
+ if (document == null)
300
+ throw new ArgumentNullException(nameof(document));
301
+
302
+ if (string.IsNullOrWhiteSpace(contentId))
303
+ throw new ArgumentException("Content ID cannot be null or empty", nameof(contentId));
304
+
305
+ var updateCount = 0;
306
+ var urlRegex = !string.IsNullOrWhiteSpace(urlPattern)
307
+ ? new Regex(urlPattern, RegexOptions.IgnoreCase)
308
+ : null;
309
+
310
+ await Task.Run(() =>
311
+ {
312
+ // Process main document
313
+ if (document.MainDocumentPart?.Document?.Body != null)
314
+ {
315
+ updateCount += AppendContentIdInElement(document.MainDocumentPart.Document.Body,
316
+ document.MainDocumentPart, contentId, urlRegex);
317
+ }
318
+
319
+ // Process headers
320
+ var headerParts = document.MainDocumentPart?.HeaderParts;
321
+ if (headerParts != null)
322
+ {
323
+ foreach (var headerPart in headerParts)
324
+ {
325
+ updateCount += AppendContentIdInElement(headerPart.Header, headerPart, contentId, urlRegex);
326
+ }
327
+ }
328
+
329
+ // Process footers
330
+ var footerParts = document.MainDocumentPart?.FooterParts;
331
+ if (footerParts != null)
332
+ {
333
+ foreach (var footerPart in footerParts)
334
+ {
335
+ updateCount += AppendContentIdInElement(footerPart.Footer, footerPart, contentId, urlRegex);
336
+ }
337
+ }
338
+ });
339
+
340
+ return updateCount;
341
+ }
342
+
343
+ /// <summary>
344
+ /// Creates a batch operation for hyperlink processing.
345
+ /// </summary>
346
+ /// <param name="filePaths">Files to process</param>
347
+ /// <param name="options">Processing options</param>
348
+ /// <returns>Batch operation</returns>
349
+ public IBatchOperation CreateBatchOperation(List<string> filePaths, HyperlinkProcessingOptions options)
350
+ {
351
+ return new HyperlinkBatchOperation(this, filePaths, options);
352
+ }
353
+
354
+ /// <summary>
355
+ /// Creates a batch operation for hyperlink processing with advanced options.
356
+ /// </summary>
357
+ /// <param name="filePaths">Files to process</param>
358
+ /// <param name="fixingOptions">Advanced fixing options</param>
359
+ /// <returns>Batch operation</returns>
360
+ public IBatchOperation CreateBatchOperation(List<string> filePaths, HyperlinkFixingOptions fixingOptions)
361
+ {
362
+ return new HyperlinkBatchOperation(this, filePaths, fixingOptions);
363
+ }
364
+
365
+ /// <summary>
366
+ /// Processes multiple documents for hyperlink fixes.
367
+ /// </summary>
368
+ /// <param name="filePaths">Files to process</param>
369
+ /// <param name="options">Processing options</param>
370
+ /// <param name="progress">Progress reporting</param>
371
+ /// <param name="cancellationToken">Cancellation token</param>
372
+ /// <returns>List of processing results</returns>
373
+ public async Task<List<HyperlinkProcessingResult>> ProcessDocumentsAsync(
374
+ List<string> filePaths,
375
+ HyperlinkProcessingOptions options,
376
+ IProgress<double>? progress = null,
377
+ CancellationToken cancellationToken = default)
378
+ {
379
+ var results = new List<HyperlinkProcessingResult>();
380
+ var totalFiles = filePaths.Count;
381
+ var processedFiles = 0;
382
+
383
+ foreach (var filePath in filePaths)
384
+ {
385
+ if (cancellationToken.IsCancellationRequested)
386
+ break;
387
+
388
+ try
389
+ {
390
+ var fileProgress = new Progress<double>(p =>
391
+ {
392
+ var overallProgress = (processedFiles * 100.0 + p) / totalFiles;
393
+ progress?.Report(overallProgress);
394
+ });
395
+
396
+ var result = await FixSourceHyperlinksAsync(filePath, options, fileProgress);
397
+ results.Add(result);
398
+ }
399
+ catch (Exception ex)
400
+ {
401
+ _logger.Error(ex, "Error processing file {FilePath} in batch operation", filePath);
402
+ results.Add(new HyperlinkProcessingResult
403
+ {
404
+ Success = false,
405
+ ErrorMessages = new List<string> { $"Error processing {filePath}: {ex.Message}" }
406
+ });
407
+ }
408
+
409
+ processedFiles++;
410
+ progress?.Report((processedFiles * 100.0) / totalFiles);
411
+ }
412
+
413
+ return results;
414
+ }
415
+
416
+ /// <summary>
417
+ /// Processes multiple documents for hyperlink fixes with advanced options.
418
+ /// </summary>
419
+ /// <param name="filePaths">Files to process</param>
420
+ /// <param name="fixingOptions">Advanced fixing options</param>
421
+ /// <param name="progress">Progress reporting</param>
422
+ /// <param name="cancellationToken">Cancellation token</param>
423
+ /// <returns>List of processing results</returns>
424
+ public async Task<List<HyperlinkProcessingResult>> ProcessDocumentsAsync(
425
+ List<string> filePaths,
426
+ HyperlinkFixingOptions fixingOptions,
427
+ IProgress<double>? progress = null,
428
+ CancellationToken cancellationToken = default)
429
+ {
430
+ var results = new List<HyperlinkProcessingResult>();
431
+ var totalFiles = filePaths.Count;
432
+ var processedFiles = 0;
433
+
434
+ foreach (var filePath in filePaths)
435
+ {
436
+ if (cancellationToken.IsCancellationRequested)
437
+ break;
438
+
439
+ try
440
+ {
441
+ var fileProgress = new Progress<double>(p =>
442
+ {
443
+ var overallProgress = (processedFiles * 100.0 + p) / totalFiles;
444
+ progress?.Report(overallProgress);
445
+ });
446
+
447
+ var result = await FixSourceHyperlinksAsync(filePath, fixingOptions, fileProgress);
448
+ results.Add(result);
449
+ }
450
+ catch (Exception ex)
451
+ {
452
+ _logger.Error(ex, "Error processing file {FilePath} in batch operation", filePath);
453
+ results.Add(new HyperlinkProcessingResult
454
+ {
455
+ Success = false,
456
+ ErrorMessages = new List<string> { $"Error processing {filePath}: {ex.Message}" }
457
+ });
458
+ }
459
+
460
+ processedFiles++;
461
+ progress?.Report((processedFiles * 100.0) / totalFiles);
462
+ }
463
+
464
+ return results;
465
+ }
466
+
467
+ #region Helper Methods
468
+
469
+ /// <summary>
470
+ /// Converts HyperlinkFixingResult to HyperlinkProcessingResult for compatibility.
471
+ /// </summary>
472
+ /// <param name="fixingResult">The fixing result to convert</param>
473
+ /// <param name="backupPath">The backup path to include</param>
474
+ /// <returns>Converted processing result</returns>
475
+ private HyperlinkProcessingResult ConvertFixingResult(HyperlinkFixingResult fixingResult, string backupPath = "")
476
+ {
477
+ var result = new HyperlinkProcessingResult
478
+ {
479
+ Success = fixingResult.Success,
480
+ TotalHyperlinks = fixingResult.TotalHyperlinks,
481
+ ProcessedHyperlinks = fixingResult.ProcessedHyperlinks,
482
+ SkippedHyperlinks = Math.Max(0, fixingResult.TotalHyperlinks - fixingResult.ProcessedHyperlinks),
483
+ ErrorCount = fixingResult.ErrorMessages.Count,
484
+ ErrorMessages = new List<string>(fixingResult.ErrorMessages),
485
+ BackupPath = backupPath,
486
+ Duration = fixingResult.Duration
487
+ };
488
+
489
+ // For compatibility, treat updated URLs as processed hyperlinks
490
+ if (fixingResult.UpdatedUrls > 0 || fixingResult.UpdatedDisplayTexts > 0)
491
+ {
492
+ result.ProcessedHyperlinks = Math.Max(result.ProcessedHyperlinks,
493
+ fixingResult.UpdatedUrls + fixingResult.UpdatedDisplayTexts);
494
+ }
495
+
496
+ return result;
497
+ }
498
+
499
+ /// <summary>
500
+ /// Converts HyperlinkProcessingOptions to HyperlinkFixingOptions.
501
+ /// </summary>
502
+ /// <param name="processingOptions">The processing options to convert</param>
503
+ /// <returns>Converted fixing options</returns>
504
+ private HyperlinkFixingOptions ConvertToFixingOptions(HyperlinkProcessingOptions processingOptions)
505
+ {
506
+ var fixingOptions = new HyperlinkFixingOptions
507
+ {
508
+ CreateBackup = processingOptions.CreateBackup,
509
+ UpdateTitles = false, // Default to false for backward compatibility
510
+ AppendContentId = !string.IsNullOrEmpty(processingOptions.ContentIdToAppend),
511
+ PowerAutomateUrl = GetPowerAutomateUrlFromSettings()
512
+ };
513
+
514
+ return fixingOptions;
515
+ }
516
+
517
+ #endregion
518
+
519
+ #region Private Methods
520
+
521
+ // Validation methods moved to HyperlinkValidationService
522
+
523
+ private bool ShouldProcessHyperlink(DetailedHyperlinkInfo hyperlink, HyperlinkProcessingOptions options)
524
+ {
525
+ if (!options.ProcessInternalLinks && hyperlink.IsInternal)
526
+ return false;
527
+
528
+ if (!options.ProcessExternalLinks && !hyperlink.IsInternal)
529
+ return false;
530
+
531
+ if (!string.IsNullOrEmpty(options.UrlPattern))
532
+ {
533
+ var urlRegex = new Regex(options.UrlPattern, RegexOptions.IgnoreCase);
534
+ if (!urlRegex.IsMatch(hyperlink.Url))
535
+ return false;
536
+ }
537
+
538
+ if (!string.IsNullOrEmpty(options.DisplayTextPattern))
539
+ {
540
+ var displayRegex = new Regex(options.DisplayTextPattern, RegexOptions.IgnoreCase);
541
+ if (!displayRegex.IsMatch(hyperlink.DisplayText))
542
+ return false;
543
+ }
544
+
545
+ return true;
546
+ }
547
+
548
+ private async Task<bool> ProcessSingleHyperlinkAsync(DetailedHyperlinkInfo hyperlink, HyperlinkProcessingOptions options)
549
+ {
550
+ return await Task.Run(() =>
551
+ {
552
+ try
553
+ {
554
+ bool wasModified = false;
555
+
556
+ // Append Content ID only to theSource hyperlinks with matching patterns
557
+ if (!string.IsNullOrEmpty(options.ContentIdToAppend) && !hyperlink.IsInternal)
558
+ {
559
+ // Check if this is a theSource URL with ContentId or DocumentId pattern
560
+ if (hyperlink.Url.Contains("thesource", StringComparison.OrdinalIgnoreCase) &&
561
+ (ContentIdPattern.IsMatch(hyperlink.Url) || DocumentIdPattern.IsMatch(hyperlink.Url)))
562
+ {
563
+ if (!hyperlink.Url.Contains(options.ContentIdToAppend))
564
+ {
565
+ var newUrl = hyperlink.Url + options.ContentIdToAppend;
566
+ if (UpdateHyperlinkUrl(hyperlink, newUrl))
567
+ {
568
+ hyperlink.Url = newUrl;
569
+ wasModified = true;
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ return wasModified;
576
+ }
577
+ catch
578
+ {
579
+ return false;
580
+ }
581
+ });
582
+ }
583
+
584
+ private bool UpdateHyperlinkUrl(DetailedHyperlinkInfo hyperlinkInfo, string newUrl)
585
+ {
586
+ try
587
+ {
588
+ var relationshipId = hyperlinkInfo.Id;
589
+ if (string.IsNullOrEmpty(relationshipId))
590
+ return false;
591
+
592
+ var part = hyperlinkInfo.ContainingPart;
593
+ var relationship = part.ExternalRelationships.FirstOrDefault(r => r.Id == relationshipId);
594
+ if (relationship == null)
595
+ return false;
596
+
597
+ // Delete old relationship and create new one
598
+ part.DeleteExternalRelationship(relationship);
599
+ var newRelationship = part.AddExternalRelationship(
600
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
601
+ new Uri(newUrl, UriKind.Absolute));
602
+
603
+ hyperlinkInfo.Element.Id = newRelationship.Id;
604
+ hyperlinkInfo.Id = newRelationship.Id;
605
+
606
+ return true;
607
+ }
608
+ catch
609
+ {
610
+ return false;
611
+ }
612
+ }
613
+
614
+ private int AppendContentIdInElement(OpenXmlElement element, OpenXmlPart part, string contentId, Regex? urlRegex)
615
+ {
616
+ int updateCount = 0;
617
+ var hyperlinks = element.Descendants<Hyperlink>().ToList();
618
+
619
+ foreach (var hyperlink in hyperlinks)
620
+ {
621
+ try
622
+ {
623
+ var hyperlinkInfo = CreateDetailedHyperlinkInfo(hyperlink, part);
624
+
625
+ if (hyperlinkInfo.IsInternal || string.IsNullOrEmpty(hyperlinkInfo.Url))
626
+ continue;
627
+
628
+ if (urlRegex != null && !urlRegex.IsMatch(hyperlinkInfo.Url))
629
+ continue;
630
+
631
+ // Check if URL is a theSource URL with ContentId or DocumentId pattern
632
+ if (!hyperlinkInfo.Url.Contains("thesource", StringComparison.OrdinalIgnoreCase) ||
633
+ (!ContentIdPattern.IsMatch(hyperlinkInfo.Url) && !DocumentIdPattern.IsMatch(hyperlinkInfo.Url)))
634
+ continue;
635
+
636
+ // Process ALL theSource hyperlinks, even if they already have a content ID
637
+ // (as the existing one might be incorrect)
638
+ var newUrl = hyperlinkInfo.Url + contentId;
639
+ if (UpdateHyperlinkUrl(hyperlinkInfo, newUrl))
640
+ {
641
+ updateCount++;
642
+ }
643
+ }
644
+ catch
645
+ {
646
+ // Skip this hyperlink on error
647
+ continue;
648
+ }
649
+ }
650
+
651
+ return updateCount;
652
+ }
653
+
654
+ private async Task ValidateProcessedHyperlinksAsync(List<DetailedHyperlinkInfo> hyperlinks)
655
+ {
656
+ await Task.Run(() =>
657
+ {
658
+ foreach (var hyperlink in hyperlinks)
659
+ {
660
+ ValidateHyperlink(hyperlink);
661
+ }
662
+ });
663
+ }
664
+
665
+ /// <summary>
666
+ /// Helper method to create detailed hyperlink info for internal processing.
667
+ /// </summary>
668
+ private DetailedHyperlinkInfo CreateDetailedHyperlinkInfo(Hyperlink hyperlink, OpenXmlPart part)
669
+ {
670
+ var info = new DetailedHyperlinkInfo
671
+ {
672
+ Element = hyperlink,
673
+ ContainingPart = part,
674
+ DisplayText = string.Join("", hyperlink.Descendants<Text>().Select(t => t.Text))
675
+ };
676
+
677
+ var relationshipId = hyperlink.Id?.Value;
678
+ if (!string.IsNullOrEmpty(relationshipId))
679
+ {
680
+ info.Id = relationshipId;
681
+
682
+ try
683
+ {
684
+ var relationship = part.ExternalRelationships.FirstOrDefault(r => r.Id == relationshipId);
685
+ if (relationship != null)
686
+ {
687
+ info.Url = relationship.Uri?.ToString() ?? string.Empty;
688
+ info.IsInternal = false;
689
+ }
690
+ else
691
+ {
692
+ // Check for internal relationships
693
+ var internalRel = part.HyperlinkRelationships.FirstOrDefault(r => r.Id == relationshipId);
694
+ if (internalRel != null)
695
+ {
696
+ info.Url = internalRel.Uri?.ToString() ?? string.Empty;
697
+ info.IsInternal = true;
698
+ }
699
+ }
700
+ }
701
+ catch (Exception ex)
702
+ {
703
+ info.IsValid = false;
704
+ info.ValidationMessage = "Unable to resolve hyperlink relationship";
705
+ _logger.Warning(ex, "Error resolving hyperlink relationship {RelationshipId}", relationshipId);
706
+ }
707
+ }
708
+
709
+ // Get context
710
+ var paragraph = hyperlink.Ancestors<Paragraph>().FirstOrDefault();
711
+ if (paragraph != null)
712
+ {
713
+ var paragraphText = string.Join("", paragraph.Descendants<Text>().Select(t => t.Text));
714
+ info.Context = paragraphText.Length > 100 ? paragraphText.Substring(0, 100) + "..." : paragraphText;
715
+ }
716
+
717
+ return info;
718
+ }
719
+
720
+ /// <summary>
721
+ /// Retrieves the PowerAutomate URL from user settings with fallback to default.
722
+ /// </summary>
723
+ /// <returns>The configured PowerAutomate URL or default URL if not configured</returns>
724
+ private string GetPowerAutomateUrlFromSettings()
725
+ {
726
+ try
727
+ {
728
+ var settings = UserSettings.Load();
729
+ return !string.IsNullOrEmpty(settings.PowerAutomateUrl)
730
+ ? settings.PowerAutomateUrl
731
+ : "https://default-powerautomate-url.com";
732
+ }
733
+ catch (Exception ex)
734
+ {
735
+ // Log warning but don't throw - return default URL
736
+ _logger.Warning(ex, "Could not load PowerAutomate URL from settings");
737
+ return "https://default-powerautomate-url.com";
738
+ }
739
+ }
740
+
741
+ /// <summary>
742
+ /// Scans hyperlinks in a specific OpenXML element.
743
+ /// </summary>
744
+ private void ScanHyperlinksInElement(OpenXmlElement element, OpenXmlPart part,
745
+ List<DetailedHyperlinkInfo> hyperlinks, bool validateLinks)
746
+ {
747
+ var hyperlinkElements = element.Descendants<Hyperlink>().ToList();
748
+
749
+ foreach (var hyperlink in hyperlinkElements)
750
+ {
751
+ try
752
+ {
753
+ var hyperlinkInfo = CreateDetailedHyperlinkInfo(hyperlink, part);
754
+
755
+ if (validateLinks)
756
+ {
757
+ ValidateHyperlink(hyperlinkInfo);
758
+ }
759
+
760
+ hyperlinks.Add(hyperlinkInfo);
761
+ }
762
+ catch (Exception ex)
763
+ {
764
+ // Create a partial hyperlink info for error cases
765
+ var errorInfo = new DetailedHyperlinkInfo
766
+ {
767
+ Element = hyperlink,
768
+ ContainingPart = part,
769
+ IsValid = false,
770
+ ValidationMessage = $"Error scanning hyperlink: {ex.Message}",
771
+ DisplayText = string.Join("", hyperlink.Descendants<Text>().Select(t => t.Text))
772
+ };
773
+ hyperlinks.Add(errorInfo);
774
+ _logger.Warning(ex, "Error scanning hyperlink in document");
775
+ }
776
+ }
777
+ }
778
+
779
+ /// <summary>
780
+ /// Advanced hyperlink fixing using API services and intelligent updates.
781
+ /// </summary>
782
+ private async Task<HyperlinkFixingResult> PerformAdvancedHyperlinkFixingAsync(WordprocessingDocument document, HyperlinkFixingOptions options)
783
+ {
784
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
785
+ var result = new HyperlinkFixingResult();
786
+
787
+ try
788
+ {
789
+ // Validate document structure
790
+ if (document.MainDocumentPart?.Document?.Body == null)
791
+ {
792
+ throw new InvalidOperationException("Document structure is invalid or corrupted");
793
+ }
794
+
795
+ // Phase 1: Extract hyperlink data with relationship IDs
796
+ var hyperlinkData = await ExtractHyperlinkDataAsync(document);
797
+ result.TotalHyperlinks = hyperlinkData.Count;
798
+
799
+ // Filter hyperlinks that have extractable IDs
800
+ var processableHyperlinks = hyperlinkData.Where(h => !string.IsNullOrEmpty(h.Url)).ToList();
801
+ result.ProcessedHyperlinks = processableHyperlinks.Count;
802
+
803
+ if (processableHyperlinks.Count == 0)
804
+ {
805
+ result.Success = true;
806
+ result.ErrorMessages.Add("No processable hyperlinks found in the document");
807
+ return result;
808
+ }
809
+
810
+ // Phase 2: API Communication (if configured)
811
+ if (!string.IsNullOrEmpty(options.PowerAutomateUrl))
812
+ {
813
+ var urls = processableHyperlinks.Select(h => h.Url).ToList();
814
+ var apiSettings = new HyperlinkApiSettings
815
+ {
816
+ ApiUrl = options.PowerAutomateUrl,
817
+ TimeoutSeconds = 30
818
+ };
819
+
820
+ try
821
+ {
822
+ var apiResponse = await _apiService.ProcessHyperlinksAsync(urls, apiSettings);
823
+ if (apiResponse?.Body?.Results != null)
824
+ {
825
+ // Apply API-based fixes
826
+ result.UpdatedUrls += ApplyApiBasedFixes(processableHyperlinks, apiResponse, options);
827
+ }
828
+ }
829
+ catch (Exception ex)
830
+ {
831
+ _logger.Warning(ex, "API communication failed, falling back to basic fixing");
832
+ // Continue with basic fixing even if API fails
833
+ }
834
+ }
835
+
836
+ // Phase 3: Apply basic content ID appending if configured
837
+ if (options.AppendContentId)
838
+ {
839
+ result.UpdatedUrls += await AppendContentIdToHyperlinksAsync(document, "#content");
840
+ }
841
+
842
+ // Phase 4: Update titles if requested
843
+ if (options.UpdateTitles)
844
+ {
845
+ result.UpdatedDisplayTexts += UpdateHyperlinkTitles(processableHyperlinks);
846
+ }
847
+
848
+ result.Success = true;
849
+ }
850
+ catch (Exception ex)
851
+ {
852
+ result.ErrorMessages.Add($"Unexpected error during hyperlink fixing: {ex.Message}");
853
+ _logger.Error(ex, "Error in advanced hyperlink fixing");
854
+ }
855
+ finally
856
+ {
857
+ stopwatch.Stop();
858
+ result.Duration = stopwatch.Elapsed;
859
+ }
860
+
861
+ return result;
862
+ }
863
+
864
+ /// <summary>
865
+ /// Extracts hyperlink data from the document for advanced processing.
866
+ /// </summary>
867
+ private async Task<List<DetailedHyperlinkInfo>> ExtractHyperlinkDataAsync(WordprocessingDocument document)
868
+ {
869
+ return await ScanHyperlinksAsync(document, false);
870
+ }
871
+
872
+ /// <summary>
873
+ /// Applies API-based fixes to hyperlinks.
874
+ /// </summary>
875
+ private int ApplyApiBasedFixes(List<DetailedHyperlinkInfo> hyperlinks, HyperlinkApiResponse apiResponse, HyperlinkFixingOptions options)
876
+ {
877
+ int updatedCount = 0;
878
+
879
+ if (apiResponse?.Body?.Results == null)
880
+ return updatedCount;
881
+
882
+ foreach (var hyperlink in hyperlinks)
883
+ {
884
+ // Find matching API result
885
+ var apiResult = apiResponse.Body.Results.FirstOrDefault(r =>
886
+ hyperlink.Url.Contains(r.Document_ID, StringComparison.OrdinalIgnoreCase) ||
887
+ hyperlink.Url.Contains(r.Content_ID, StringComparison.OrdinalIgnoreCase));
888
+
889
+ if (apiResult != null && !string.IsNullOrEmpty(apiResult.Title))
890
+ {
891
+ // Update hyperlink with API data
892
+ if (UpdateHyperlinkUrl(hyperlink, $"https://thesource.cvshealth.com/nuxeo/thesource/#!/view?docid={apiResult.Document_ID}"))
893
+ {
894
+ updatedCount++;
895
+ }
896
+ }
897
+ }
898
+
899
+ return updatedCount;
900
+ }
901
+
902
+ /// <summary>
903
+ /// Updates hyperlink titles based on their URLs.
904
+ /// </summary>
905
+ private int UpdateHyperlinkTitles(List<DetailedHyperlinkInfo> hyperlinks)
906
+ {
907
+ int updatedCount = 0;
908
+
909
+ foreach (var hyperlink in hyperlinks)
910
+ {
911
+ try
912
+ {
913
+ // Extract meaningful title from URL or existing text
914
+ var newTitle = ExtractTitleFromUrl(hyperlink.Url) ?? hyperlink.DisplayText;
915
+ if (!string.IsNullOrEmpty(newTitle) && newTitle != hyperlink.DisplayText)
916
+ {
917
+ // Update display text
918
+ var textElements = hyperlink.Element.Descendants<Text>().ToList();
919
+ if (textElements.Any())
920
+ {
921
+ textElements.First().Text = newTitle;
922
+ foreach (var extraText in textElements.Skip(1))
923
+ {
924
+ extraText.Remove();
925
+ }
926
+ updatedCount++;
927
+ }
928
+ }
929
+ }
930
+ catch (Exception ex)
931
+ {
932
+ _logger.Warning(ex, "Error updating hyperlink title for {Url}", hyperlink.Url);
933
+ }
934
+ }
935
+
936
+ return updatedCount;
937
+ }
938
+
939
+ /// <summary>
940
+ /// Finds all hyperlinks in a document that match the specified patterns.
941
+ /// </summary>
942
+ /// <param name="documentPath">Path to the document to search</param>
943
+ /// <param name="urlPattern">Regex pattern to match URLs</param>
944
+ /// <param name="displayPattern">Optional regex pattern to match display text</param>
945
+ /// <returns>List of matching hyperlinks</returns>
946
+ public async Task<List<HyperlinkMatch>> FindHyperlinksAsync(string documentPath, string urlPattern, string? displayPattern = null)
947
+ {
948
+ var matches = new List<HyperlinkMatch>();
949
+
950
+ if (!File.Exists(documentPath))
951
+ {
952
+ _logger.Warning("Document not found: {DocumentPath}", documentPath);
953
+ return matches;
954
+ }
955
+
956
+ try
957
+ {
958
+ using var document = WordprocessingDocument.Open(documentPath, false);
959
+
960
+ var urlRegex = new Regex(urlPattern, RegexOptions.IgnoreCase);
961
+ var displayRegex = !string.IsNullOrWhiteSpace(displayPattern)
962
+ ? new Regex(displayPattern, RegexOptions.IgnoreCase)
963
+ : null;
964
+
965
+ await Task.Run(() =>
966
+ {
967
+ // Search in main document
968
+ if (document.MainDocumentPart?.Document?.Body != null)
969
+ {
970
+ var mainMatches = FindHyperlinksInPart(document.MainDocumentPart.Document.Body,
971
+ document.MainDocumentPart, urlRegex, displayRegex, documentPath);
972
+ matches.AddRange(mainMatches);
973
+ }
974
+
975
+ // Search in headers
976
+ var headerParts = document.MainDocumentPart?.HeaderParts;
977
+ if (headerParts != null)
978
+ {
979
+ foreach (var headerPart in headerParts)
980
+ {
981
+ var headerMatches = FindHyperlinksInPart(headerPart.Header, headerPart, urlRegex, displayRegex, documentPath);
982
+ matches.AddRange(headerMatches);
983
+ }
984
+ }
985
+
986
+ // Search in footers
987
+ var footerParts = document.MainDocumentPart?.FooterParts;
988
+ if (footerParts != null)
989
+ {
990
+ foreach (var footerPart in footerParts)
991
+ {
992
+ var footerMatches = FindHyperlinksInPart(footerPart.Footer, footerPart, urlRegex, displayRegex, documentPath);
993
+ matches.AddRange(footerMatches);
994
+ }
995
+ }
996
+ });
997
+ }
998
+ catch (Exception ex)
999
+ {
1000
+ _logger.Error(ex, "Error finding hyperlinks in document: {DocumentPath}", documentPath);
1001
+ }
1002
+
1003
+ return matches;
1004
+ }
1005
+
1006
+ /// <summary>
1007
+ /// Replaces hyperlinks matching specified patterns with new values.
1008
+ /// </summary>
1009
+ /// <param name="documentPath">Path to the document to update</param>
1010
+ /// <param name="urlPattern">Regex pattern to match URLs for replacement</param>
1011
+ /// <param name="newUrl">New URL to replace matches with</param>
1012
+ /// <param name="newDisplayText">Optional new display text</param>
1013
+ /// <param name="displayPattern">Optional regex pattern to match display text</param>
1014
+ /// <returns>Result containing replacement statistics</returns>
1015
+ public async Task<HyperlinkReplacementResult> ReplaceHyperlinksAsync(string documentPath, string urlPattern,
1016
+ string newUrl, string? newDisplayText = null, string? displayPattern = null)
1017
+ {
1018
+ var result = new HyperlinkReplacementResult();
1019
+
1020
+ if (!File.Exists(documentPath))
1021
+ {
1022
+ result.Errors.Add($"Document not found: {documentPath}");
1023
+ return result;
1024
+ }
1025
+
1026
+ try
1027
+ {
1028
+ using var document = WordprocessingDocument.Open(documentPath, true);
1029
+
1030
+ var urlRegex = new Regex(urlPattern, RegexOptions.IgnoreCase);
1031
+ var displayRegex = !string.IsNullOrWhiteSpace(displayPattern)
1032
+ ? new Regex(displayPattern, RegexOptions.IgnoreCase)
1033
+ : null;
1034
+
1035
+ await Task.Run(() =>
1036
+ {
1037
+ // Replace in main document
1038
+ if (document.MainDocumentPart?.Document?.Body != null)
1039
+ {
1040
+ var mainResult = ReplaceHyperlinksInPart(document.MainDocumentPart.Document.Body,
1041
+ document.MainDocumentPart, urlRegex, newUrl, newDisplayText, displayRegex);
1042
+ result.TotalMatches += mainResult.matches;
1043
+ result.SuccessfulReplacements += mainResult.replacements;
1044
+ result.FailedReplacements += mainResult.failures;
1045
+ }
1046
+
1047
+ // Replace in headers
1048
+ var headerParts = document.MainDocumentPart?.HeaderParts;
1049
+ if (headerParts != null)
1050
+ {
1051
+ foreach (var headerPart in headerParts)
1052
+ {
1053
+ var headerResult = ReplaceHyperlinksInPart(headerPart.Header, headerPart,
1054
+ urlRegex, newUrl, newDisplayText, displayRegex);
1055
+ result.TotalMatches += headerResult.matches;
1056
+ result.SuccessfulReplacements += headerResult.replacements;
1057
+ result.FailedReplacements += headerResult.failures;
1058
+ }
1059
+ }
1060
+
1061
+ // Replace in footers
1062
+ var footerParts = document.MainDocumentPart?.FooterParts;
1063
+ if (footerParts != null)
1064
+ {
1065
+ foreach (var footerPart in footerParts)
1066
+ {
1067
+ var footerResult = ReplaceHyperlinksInPart(footerPart.Footer, footerPart,
1068
+ urlRegex, newUrl, newDisplayText, displayRegex);
1069
+ result.TotalMatches += footerResult.matches;
1070
+ result.SuccessfulReplacements += footerResult.replacements;
1071
+ result.FailedReplacements += footerResult.failures;
1072
+ }
1073
+ }
1074
+ });
1075
+ }
1076
+ catch (Exception ex)
1077
+ {
1078
+ _logger.Error(ex, "Error replacing hyperlinks in document: {DocumentPath}", documentPath);
1079
+ result.Errors.Add($"Error replacing hyperlinks: {ex.Message}");
1080
+ }
1081
+
1082
+ return result;
1083
+ }
1084
+
1085
+ /// <summary>
1086
+ /// Finds hyperlinks in a specific document part.
1087
+ /// </summary>
1088
+ private List<HyperlinkMatch> FindHyperlinksInPart(OpenXmlElement element, OpenXmlPart part,
1089
+ Regex urlRegex, Regex? displayRegex, string documentPath)
1090
+ {
1091
+ var matches = new List<HyperlinkMatch>();
1092
+ var hyperlinks = element.Descendants<Hyperlink>().ToList();
1093
+
1094
+ foreach (var hyperlink in hyperlinks)
1095
+ {
1096
+ try
1097
+ {
1098
+ var hyperlinkInfo = CreateDetailedHyperlinkInfo(hyperlink, part);
1099
+
1100
+ // Check if URL matches pattern
1101
+ if (!string.IsNullOrEmpty(hyperlinkInfo.Url) && urlRegex.IsMatch(hyperlinkInfo.Url))
1102
+ {
1103
+ // Check display text pattern if specified
1104
+ if (displayRegex == null || displayRegex.IsMatch(hyperlinkInfo.DisplayText))
1105
+ {
1106
+ matches.Add(new HyperlinkMatch
1107
+ {
1108
+ CurrentUrl = hyperlinkInfo.Url,
1109
+ DisplayText = hyperlinkInfo.DisplayText,
1110
+ Context = hyperlinkInfo.Context ?? string.Empty,
1111
+ DocumentPath = documentPath,
1112
+ RelationshipId = hyperlinkInfo.Id,
1113
+ HyperlinkElement = hyperlink,
1114
+ ContainingPart = part
1115
+ });
1116
+ }
1117
+ }
1118
+ }
1119
+ catch (Exception ex)
1120
+ {
1121
+ _logger.Warning(ex, "Error processing hyperlink during search");
1122
+ }
1123
+ }
1124
+
1125
+ return matches;
1126
+ }
1127
+
1128
+ /// <summary>
1129
+ /// Replaces hyperlinks in a specific document part.
1130
+ /// </summary>
1131
+ private (int matches, int replacements, int failures) ReplaceHyperlinksInPart(OpenXmlElement element, OpenXmlPart part,
1132
+ Regex urlRegex, string newUrl, string? newDisplayText, Regex? displayRegex)
1133
+ {
1134
+ int matches = 0, replacements = 0, failures = 0;
1135
+ var hyperlinks = element.Descendants<Hyperlink>().ToList();
1136
+
1137
+ foreach (var hyperlink in hyperlinks)
1138
+ {
1139
+ try
1140
+ {
1141
+ var hyperlinkInfo = CreateDetailedHyperlinkInfo(hyperlink, part);
1142
+
1143
+ // Check if URL matches pattern
1144
+ if (!string.IsNullOrEmpty(hyperlinkInfo.Url) && urlRegex.IsMatch(hyperlinkInfo.Url))
1145
+ {
1146
+ // Check display text pattern if specified
1147
+ if (displayRegex == null || displayRegex.IsMatch(hyperlinkInfo.DisplayText))
1148
+ {
1149
+ matches++;
1150
+
1151
+ // Replace URL
1152
+ if (UpdateHyperlinkUrl(hyperlinkInfo, newUrl))
1153
+ {
1154
+ // Replace display text if specified
1155
+ if (!string.IsNullOrEmpty(newDisplayText))
1156
+ {
1157
+ var textElements = hyperlink.Descendants<Text>().ToList();
1158
+ if (textElements.Any())
1159
+ {
1160
+ textElements.First().Text = newDisplayText;
1161
+ foreach (var extraText in textElements.Skip(1))
1162
+ {
1163
+ extraText.Remove();
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ replacements++;
1169
+ }
1170
+ else
1171
+ {
1172
+ failures++;
1173
+ }
1174
+ }
1175
+ }
1176
+ }
1177
+ catch (Exception ex)
1178
+ {
1179
+ _logger.Warning(ex, "Error replacing hyperlink");
1180
+ failures++;
1181
+ }
1182
+ }
1183
+
1184
+ return (matches, replacements, failures);
1185
+ }
1186
+
1187
+ /// <summary>
1188
+ /// Extracts a meaningful title from a URL.
1189
+ /// </summary>
1190
+ private string? ExtractTitleFromUrl(string url)
1191
+ {
1192
+ try
1193
+ {
1194
+ if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
1195
+ {
1196
+ // Extract title from path or query parameters
1197
+ var segments = uri.Segments;
1198
+ if (segments.Length > 0)
1199
+ {
1200
+ var lastSegment = segments[segments.Length - 1].Trim('/', '\\');
1201
+ if (!string.IsNullOrEmpty(lastSegment) && lastSegment.Length > 3)
1202
+ {
1203
+ return Uri.UnescapeDataString(lastSegment);
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+ catch
1209
+ {
1210
+ // Return null if extraction fails
1211
+ }
1212
+
1213
+ return null;
1214
+ }
1215
+
1216
+ #endregion
1217
+
1218
+ #region IHyperlinkService Implementation
1219
+
1220
+ /// <summary>
1221
+ /// Extracts hyperlinks from a document file
1222
+ /// </summary>
1223
+ /// <param name="filePath">Path to the document file</param>
1224
+ /// <returns>List of hyperlink information</returns>
1225
+ public async Task<List<Models.HyperlinkInfo>> ExtractHyperlinksAsync(string filePath)
1226
+ {
1227
+ if (string.IsNullOrWhiteSpace(filePath))
1228
+ throw new ArgumentException("File path cannot be null or empty", nameof(filePath));
1229
+
1230
+ if (!File.Exists(filePath))
1231
+ throw new FileNotFoundException($"File not found: {filePath}");
1232
+
1233
+ return await Task.Run(() =>
1234
+ {
1235
+ using var document = WordprocessingDocument.Open(filePath, false);
1236
+ return ExtractHyperlinks(document);
1237
+ });
1238
+ }
1239
+
1240
+ /// <summary>
1241
+ /// Updates a specific hyperlink URL in a document
1242
+ /// </summary>
1243
+ /// <param name="filePath">Path to the document file</param>
1244
+ /// <param name="oldUrl">The URL to replace</param>
1245
+ /// <param name="newUrl">The new URL</param>
1246
+ /// <returns>True if successful</returns>
1247
+ public async Task<bool> UpdateHyperlinkAsync(string filePath, string oldUrl, string newUrl)
1248
+ {
1249
+ if (string.IsNullOrWhiteSpace(filePath))
1250
+ throw new ArgumentException("File path cannot be null or empty", nameof(filePath));
1251
+
1252
+ if (!File.Exists(filePath))
1253
+ throw new FileNotFoundException($"File not found: {filePath}");
1254
+
1255
+ return await Task.Run(() =>
1256
+ {
1257
+ try
1258
+ {
1259
+ using var document = WordprocessingDocument.Open(filePath, true);
1260
+ var hyperlinks = ExtractHyperlinks(document);
1261
+ var targetHyperlink = hyperlinks.FirstOrDefault(h => h.Url == oldUrl);
1262
+
1263
+ if (targetHyperlink != null)
1264
+ {
1265
+ // Find and update the hyperlink element directly
1266
+ var hyperlinkElements = document.MainDocumentPart?.Document?.Body?.Descendants<Hyperlink>() ?? Enumerable.Empty<Hyperlink>();
1267
+ foreach (var hyperlink in hyperlinkElements)
1268
+ {
1269
+ if (hyperlink.Id?.Value == targetHyperlink.Id)
1270
+ {
1271
+ // Update the hyperlink URL
1272
+ var relationshipId = hyperlink.Id.Value;
1273
+ var relationship = document.MainDocumentPart?.ExternalRelationships?.FirstOrDefault(r => r.Id == relationshipId);
1274
+ if (relationship != null && document.MainDocumentPart != null)
1275
+ {
1276
+ var mainPart = document.MainDocumentPart; // Store reference to avoid null warnings
1277
+ mainPart.DeleteExternalRelationship(relationship);
1278
+ var newRelationship = mainPart.AddExternalRelationship(
1279
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
1280
+ new Uri(newUrl, UriKind.Absolute));
1281
+ hyperlink.Id = newRelationship.Id;
1282
+ return true;
1283
+ }
1284
+ }
1285
+ }
1286
+ }
1287
+ return false;
1288
+ }
1289
+ catch (Exception ex)
1290
+ {
1291
+ _logger.Error(ex, "Error updating hyperlink in document: {FilePath}", filePath);
1292
+ return false;
1293
+ }
1294
+ });
1295
+ }
1296
+
1297
+ /// <summary>
1298
+ /// Updates multiple hyperlinks in a document based on URL mappings
1299
+ /// </summary>
1300
+ /// <param name="filePath">Path to the document file</param>
1301
+ /// <param name="urlMappings">Dictionary mapping old URLs to new URLs</param>
1302
+ /// <returns>Number of hyperlinks updated</returns>
1303
+ public async Task<int> UpdateAllHyperlinksAsync(string filePath, Dictionary<string, string> urlMappings)
1304
+ {
1305
+ if (string.IsNullOrWhiteSpace(filePath))
1306
+ throw new ArgumentException("File path cannot be null or empty", nameof(filePath));
1307
+
1308
+ if (!File.Exists(filePath))
1309
+ throw new FileNotFoundException($"File not found: {filePath}");
1310
+
1311
+ if (urlMappings == null || urlMappings.Count == 0)
1312
+ return 0;
1313
+
1314
+ return await Task.Run(() =>
1315
+ {
1316
+ try
1317
+ {
1318
+ using var document = WordprocessingDocument.Open(filePath, true);
1319
+ var hyperlinks = ExtractHyperlinks(document);
1320
+ int updatedCount = 0;
1321
+
1322
+ foreach (var hyperlink in hyperlinks)
1323
+ {
1324
+ if (urlMappings.TryGetValue(hyperlink.Url, out var newUrl))
1325
+ {
1326
+ // Find and update the hyperlink element directly
1327
+ var hyperlinkElements = document.MainDocumentPart?.Document?.Body?.Descendants<Hyperlink>() ?? Enumerable.Empty<Hyperlink>();
1328
+ foreach (var hyperlinkElement in hyperlinkElements)
1329
+ {
1330
+ if (hyperlinkElement.Id?.Value == hyperlink.Id)
1331
+ {
1332
+ var relationshipId = hyperlinkElement.Id.Value;
1333
+ var relationship = document.MainDocumentPart?.ExternalRelationships?.FirstOrDefault(r => r.Id == relationshipId);
1334
+ if (relationship != null && document.MainDocumentPart != null)
1335
+ {
1336
+ document.MainDocumentPart.DeleteExternalRelationship(relationship);
1337
+ var newRelationship = document.MainDocumentPart.AddExternalRelationship(
1338
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
1339
+ new Uri(newUrl, UriKind.Absolute));
1340
+ hyperlinkElement.Id = newRelationship.Id;
1341
+ updatedCount++;
1342
+ break;
1343
+ }
1344
+ }
1345
+ }
1346
+ }
1347
+ }
1348
+
1349
+ return updatedCount;
1350
+ }
1351
+ catch (Exception ex)
1352
+ {
1353
+ _logger.Error(ex, "Error updating hyperlinks in document: {FilePath}", filePath);
1354
+ return 0;
1355
+ }
1356
+ });
1357
+ }
1358
+
1359
+ /// <summary>
1360
+ /// Validates if a URL is well-formed and uses valid schemes.
1361
+ /// </summary>
1362
+ /// <param name="url">The URL to validate</param>
1363
+ /// <returns>True if valid</returns>
1364
+ public async Task<bool> ValidateHyperlinkAsync(string url)
1365
+ {
1366
+ return await Task.Run(() =>
1367
+ {
1368
+ if (string.IsNullOrWhiteSpace(url))
1369
+ return false;
1370
+
1371
+ // Allow internal links starting with #
1372
+ if (url.StartsWith("#"))
1373
+ return true;
1374
+
1375
+ // Validate as URI
1376
+ if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
1377
+ {
1378
+ // Allow common schemes
1379
+ return uri.Scheme == Uri.UriSchemeHttp ||
1380
+ uri.Scheme == Uri.UriSchemeHttps ||
1381
+ uri.Scheme == Uri.UriSchemeFtp ||
1382
+ uri.Scheme == Uri.UriSchemeMailto ||
1383
+ uri.Scheme == Uri.UriSchemeFile;
1384
+ }
1385
+
1386
+ return false;
1387
+ });
1388
+ }
1389
+
1390
+ /// <summary>
1391
+ /// Performs detailed validation of a single hyperlink.
1392
+ /// </summary>
1393
+ /// <param name="hyperlinkInfo">The hyperlink to validate</param>
1394
+ public void ValidateHyperlink(DetailedHyperlinkInfo hyperlinkInfo)
1395
+ {
1396
+ if (string.IsNullOrEmpty(hyperlinkInfo.Url))
1397
+ {
1398
+ hyperlinkInfo.IsValid = false;
1399
+ hyperlinkInfo.ValidationMessage = "Hyperlink has no URL";
1400
+ return;
1401
+ }
1402
+
1403
+ if (hyperlinkInfo.IsInternal)
1404
+ {
1405
+ // For internal links, basic validation
1406
+ if (!hyperlinkInfo.Url.StartsWith("#"))
1407
+ {
1408
+ hyperlinkInfo.IsValid = false;
1409
+ hyperlinkInfo.ValidationMessage = "Internal hyperlink should start with #";
1410
+ }
1411
+ }
1412
+ else
1413
+ {
1414
+ // For external links, basic URL format validation
1415
+ if (!Uri.TryCreate(hyperlinkInfo.Url, UriKind.Absolute, out var uri))
1416
+ {
1417
+ hyperlinkInfo.IsValid = false;
1418
+ hyperlinkInfo.ValidationMessage = "Invalid URL format";
1419
+ }
1420
+ else if (uri.Scheme != "http" && uri.Scheme != "https" && uri.Scheme != "mailto")
1421
+ {
1422
+ hyperlinkInfo.IsValid = false;
1423
+ hyperlinkInfo.ValidationMessage = $"Unsupported URL scheme: {uri.Scheme}";
1424
+ }
1425
+ }
1426
+ }
1427
+
1428
+ /// <summary>
1429
+ /// Extracts hyperlinks from an open WordprocessingDocument
1430
+ /// </summary>
1431
+ /// <param name="document">The document to extract hyperlinks from</param>
1432
+ /// <returns>List of hyperlink information</returns>
1433
+ public List<Models.HyperlinkInfo> ExtractHyperlinks(WordprocessingDocument document)
1434
+ {
1435
+ if (document == null)
1436
+ throw new ArgumentNullException(nameof(document));
1437
+
1438
+ // Use internal scanning method and convert to public model
1439
+ var detailedHyperlinks = ScanHyperlinksAsync(document, false).GetAwaiter().GetResult();
1440
+
1441
+ // Convert DetailedHyperlinkInfo to public Models.HyperlinkInfo for backward compatibility
1442
+ return detailedHyperlinks.Select(h => new Models.HyperlinkInfo
1443
+ {
1444
+ Id = h.Id,
1445
+ Url = h.Url,
1446
+ DisplayText = h.DisplayText,
1447
+ Type = h.IsInternal ? HyperlinkType.Internal : HyperlinkType.External,
1448
+ IsValid = h.IsValid,
1449
+ ValidationMessage = h.ValidationMessage,
1450
+ Tooltip = string.Empty // Not available in DetailedHyperlinkInfo
1451
+ }).ToList();
1452
+ }
1453
+
1454
+ #endregion
1455
+ }
1456
+
1457
+
1458
+ /// <summary>
1459
+ /// Represents a hyperlink found in the document.
1460
+ /// </summary>
1461
+ public class HyperlinkMatch
1462
+ {
1463
+ public string CurrentUrl { get; set; } = string.Empty;
1464
+ public string DisplayText { get; set; } = string.Empty;
1465
+ public string Context { get; set; } = string.Empty;
1466
+ public string DocumentPath { get; set; } = string.Empty;
1467
+ public string RelationshipId { get; set; } = string.Empty;
1468
+ public Hyperlink HyperlinkElement { get; set; } = null!;
1469
+ public OpenXmlPart ContainingPart { get; set; } = null!;
1470
+ }
1471
+
1472
+ /// <summary>
1473
+ /// Result of hyperlink replacement operation.
1474
+ /// </summary>
1475
+ public class HyperlinkReplacementResult
1476
+ {
1477
+ public int TotalMatches { get; set; }
1478
+ public int SuccessfulReplacements { get; set; }
1479
+ public int FailedReplacements { get; set; }
1480
+ public List<string> Errors { get; set; } = new();
1481
+ public bool IsSuccess => FailedReplacements == 0;
1482
+ }
1483
+
1484
+ }