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,2129 @@
1
+ import type { HyperlinkProcessingOptions } from '@/types/hyperlink';
2
+ import {
3
+ CustomSessionDefaults,
4
+ Document,
5
+ ListBulletSettings,
6
+ ReplacementRule,
7
+ RevisionHandlingMode,
8
+ Session,
9
+ SessionContextType,
10
+ SessionStats,
11
+ SessionStyle,
12
+ TableOfContentsSettings,
13
+ TableShadingSettings,
14
+ TableUniformitySettings,
15
+ } from '@/types/session';
16
+ import { DocumentSnapshotService } from '@/services/document/DocumentSnapshotService';
17
+ import { requireElectronAPI } from '@/utils/electronGuard';
18
+ import {
19
+ deleteSession as deleteSessionFromDB,
20
+ ensureDBSizeLimit,
21
+ handleQuotaExceededError,
22
+ loadSessions,
23
+ migrateFromLocalStorage,
24
+ saveSession as saveSessionToDB,
25
+ truncateSessionChanges,
26
+ } from '@/utils/indexedDB';
27
+ import { logger, debugModes, isDebugEnabled, createDebugLogger } from '@/utils/logger';
28
+ import { isPathSafe } from '@/utils/pathSecurity';
29
+ import { safeJsonParse, safeJsonStringify } from '@/utils/safeJsonParse';
30
+ import {
31
+ createContext,
32
+ ReactNode,
33
+ useCallback,
34
+ useContext,
35
+ useEffect,
36
+ useRef,
37
+ useState,
38
+ } from 'react';
39
+ import { useGlobalStats } from './GlobalStatsContext';
40
+
41
+ const SessionContext = createContext<SessionContextType | undefined>(undefined);
42
+
43
+ /**
44
+ * Wraps a promise with a timeout to prevent hanging operations.
45
+ * @param promise The promise to wrap
46
+ * @param ms Timeout in milliseconds
47
+ * @param operation Name of the operation for error messages
48
+ */
49
+ const withTimeout = <T,>(promise: Promise<T>, ms: number, operation: string): Promise<T> =>
50
+ Promise.race([
51
+ promise,
52
+ new Promise<never>((_, reject) =>
53
+ setTimeout(() => reject(new Error(`${operation} timed out after ${ms}ms`)), ms)
54
+ ),
55
+ ]);
56
+
57
+ // Constants for IPC operations
58
+ const IPC_TIMEOUT_MS = 300000; // 5 minutes for document processing (large docs can take time)
59
+ const TIME_SAVED_SECONDS_PER_HYPERLINK = 101;
60
+ const SECONDS_PER_MINUTE = 60;
61
+
62
+ type SerializedDocument = Omit<Document, 'processedAt'> & {
63
+ processedAt?: string;
64
+ };
65
+
66
+ type SerializedSession = Omit<Session, 'createdAt' | 'lastModified' | 'closedAt' | 'documents'> & {
67
+ createdAt: string;
68
+ lastModified: string;
69
+ closedAt?: string;
70
+ documents: SerializedDocument[];
71
+ };
72
+
73
+ /**
74
+ * Default list bullet settings factory
75
+ * Creates standard 5-level indentation configuration matching StylesEditor defaults
76
+ * Used for new sessions and backfilling existing sessions without list settings
77
+ *
78
+ * Cascading indentation: L0 symbol indent defaults to 0.25"
79
+ * Each level: text indent = symbol indent + 0.25", next symbol = prev text + 0.25"
80
+ */
81
+ const createDefaultListBulletSettings = (): ListBulletSettings => ({
82
+ enabled: true,
83
+ indentationLevels: [
84
+ { level: 0, symbolIndent: 0.25, textIndent: 0.50, bulletChar: '•', numberedFormat: '1.' },
85
+ { level: 1, symbolIndent: 0.75, textIndent: 1.00, bulletChar: '○', numberedFormat: 'a.' },
86
+ { level: 2, symbolIndent: 1.25, textIndent: 1.50, bulletChar: '•', numberedFormat: 'i.' },
87
+ { level: 3, symbolIndent: 1.75, textIndent: 2.00, bulletChar: '○', numberedFormat: 'A.' },
88
+ { level: 4, symbolIndent: 2.25, textIndent: 2.50, bulletChar: '•', numberedFormat: 'I.' },
89
+ ],
90
+ });
91
+
92
+ /**
93
+ * Default session styles - Shared between createSession and resetSessionToDefaults
94
+ * Matches StylesEditor defaults for consistency
95
+ */
96
+ const DEFAULT_SESSION_STYLES: SessionStyle[] = [
97
+ {
98
+ id: 'header1',
99
+ name: 'Heading 1',
100
+ fontSize: 18,
101
+ fontFamily: 'Verdana',
102
+ bold: true,
103
+ italic: false,
104
+ underline: false,
105
+ alignment: 'left',
106
+ spaceBefore: 0,
107
+ spaceAfter: 12,
108
+ lineSpacing: 1.0,
109
+ color: '#000000',
110
+ },
111
+ {
112
+ id: 'header2',
113
+ name: 'Heading 2',
114
+ fontSize: 14,
115
+ fontFamily: 'Verdana',
116
+ bold: true,
117
+ italic: false,
118
+ underline: false,
119
+ alignment: 'left',
120
+ spaceBefore: 6,
121
+ spaceAfter: 6,
122
+ lineSpacing: 1.0,
123
+ color: '#000000',
124
+ },
125
+ {
126
+ id: 'header3',
127
+ name: 'Heading 3',
128
+ fontSize: 12,
129
+ fontFamily: 'Verdana',
130
+ bold: true,
131
+ italic: false,
132
+ underline: false,
133
+ alignment: 'left',
134
+ spaceBefore: 3,
135
+ spaceAfter: 3,
136
+ lineSpacing: 1.0,
137
+ color: '#000000',
138
+ },
139
+ {
140
+ id: 'normal',
141
+ name: 'Normal',
142
+ fontSize: 12,
143
+ fontFamily: 'Verdana',
144
+ bold: false,
145
+ italic: false,
146
+ underline: false,
147
+ preserveBold: true,
148
+ preserveItalic: false,
149
+ preserveUnderline: false,
150
+ preserveCenterAlignment: true,
151
+ alignment: 'left',
152
+ spaceBefore: 3,
153
+ spaceAfter: 3,
154
+ lineSpacing: 1.0,
155
+ color: '#000000',
156
+ noSpaceBetweenSame: false,
157
+ },
158
+ {
159
+ id: 'listParagraph',
160
+ name: 'List Paragraph',
161
+ fontSize: 12,
162
+ fontFamily: 'Verdana',
163
+ bold: false,
164
+ italic: false,
165
+ underline: false,
166
+ preserveBold: true,
167
+ preserveItalic: false,
168
+ preserveUnderline: false,
169
+ alignment: 'left',
170
+ spaceBefore: 0,
171
+ spaceAfter: 6,
172
+ lineSpacing: 1.0,
173
+ color: '#000000',
174
+ noSpaceBetweenSame: true,
175
+ // Indentation removed: numbering level definitions (symbolIndent/textIndent sliders)
176
+ // control list indentation via numbering.xml. Adding style-level indentation here
177
+ // creates conflicting w:ind in styles.xml that can produce negative bullet positions.
178
+ },
179
+ ];
180
+
181
+ /**
182
+ * Default processing options - Shared between createSession and resetSessionToDefaults
183
+ */
184
+ const DEFAULT_PROCESSING_OPTIONS = {
185
+ validateUrls: true,
186
+ createBackup: true,
187
+ processInternalLinks: true,
188
+ processExternalLinks: true,
189
+ autoAcceptRevisions: false,
190
+ enabledOperations: [
191
+ 'remove-italics',
192
+ 'normalize-dashes',
193
+ 'replace-outdated-titles',
194
+ 'validate-document-styles',
195
+ 'update-top-hyperlinks',
196
+ 'update-toc-hyperlinks',
197
+ 'force-remove-heading1-toc',
198
+ 'fix-internal-hyperlinks',
199
+ 'fix-content-ids',
200
+ 'center-border-images',
201
+ 'remove-whitespace',
202
+ 'remove-paragraph-lines',
203
+ 'remove-headers-footers',
204
+ 'add-document-warning',
205
+ 'validate-header2-tables',
206
+ 'set-landscape-margins',
207
+ 'list-indentation',
208
+ 'bullet-uniformity',
209
+ 'normalize-table-lists',
210
+ 'smart-tables',
211
+ 'adjust-table-padding',
212
+ 'standardize-table-borders',
213
+ 'correct-misapplied-styles',
214
+ ],
215
+ };
216
+
217
+ /**
218
+ * Default table shading settings
219
+ */
220
+ const DEFAULT_TABLE_SHADING_SETTINGS: TableShadingSettings = {
221
+ header2Shading: '#BFBFBF',
222
+ otherShading: '#DFDFDF',
223
+ imageBorderWidth: 1.0,
224
+ // 1x1 Tables padding (in inches)
225
+ padding1x1Top: 0,
226
+ padding1x1Bottom: 0,
227
+ padding1x1Left: 0.08,
228
+ padding1x1Right: 0.08,
229
+ // Other Tables padding (in inches)
230
+ paddingOtherTop: 0,
231
+ paddingOtherBottom: 0,
232
+ paddingOtherLeft: 0.08,
233
+ paddingOtherRight: 0.08,
234
+ // Cell border thickness (in points)
235
+ cellBorderThickness: 0.5,
236
+ };
237
+
238
+ /**
239
+ * localStorage key for custom session defaults
240
+ * Stores user's preferred defaults set via "Save as Default" button
241
+ */
242
+ const CUSTOM_DEFAULTS_KEY = 'dochub_custom_defaults';
243
+
244
+ /**
245
+ * Load custom defaults from localStorage if available
246
+ * Returns null if no custom defaults exist or if parsing fails
247
+ */
248
+ const loadCustomDefaults = (): CustomSessionDefaults | null => {
249
+ try {
250
+ const stored = localStorage.getItem(CUSTOM_DEFAULTS_KEY);
251
+ if (!stored) {
252
+ return null;
253
+ }
254
+ const parsed = safeJsonParse<CustomSessionDefaults | null>(stored, null, 'loadCustomDefaults');
255
+ if (!parsed) {
256
+ return null;
257
+ }
258
+ // Validate that we have at least some valid data
259
+ if (!parsed.styles && !parsed.listBulletSettings && !parsed.processingOptions && !parsed.tableShadingSettings) {
260
+ return null;
261
+ }
262
+ return parsed;
263
+ } catch (error) {
264
+ logger.warn('[SessionContext] Failed to load custom defaults:', error);
265
+ return null;
266
+ }
267
+ };
268
+
269
+ /**
270
+ * Ensures session has valid listBulletSettings with non-empty indentation levels
271
+ * Backfills with defaults if missing or invalid
272
+ */
273
+ const ensureListBulletSettings = (session: Session): Session => {
274
+ const needsBackfill =
275
+ !session.listBulletSettings ||
276
+ !session.listBulletSettings.indentationLevels ||
277
+ session.listBulletSettings.indentationLevels.length === 0;
278
+
279
+ if (needsBackfill) {
280
+ return {
281
+ ...session,
282
+ listBulletSettings: createDefaultListBulletSettings(),
283
+ };
284
+ }
285
+
286
+ return session;
287
+ };
288
+
289
+ /**
290
+ * Ensures session has valid styles array with all required styles
291
+ * Backfills with defaults if missing or invalid
292
+ *
293
+ * This is critical for:
294
+ * 1. Historical sessions created before styles were added
295
+ * 2. Sessions loaded from storage that may have corrupted/missing styles
296
+ * 3. Ensuring font color is properly applied (requires styles to be defined)
297
+ */
298
+ const ensureSessionStyles = (session: Session): Session => {
299
+ const needsBackfill =
300
+ !session.styles ||
301
+ !Array.isArray(session.styles) ||
302
+ session.styles.length === 0;
303
+
304
+ if (needsBackfill) {
305
+ return {
306
+ ...session,
307
+ styles: [...DEFAULT_SESSION_STYLES],
308
+ };
309
+ }
310
+
311
+ return session;
312
+ };
313
+
314
+ /**
315
+ * Ensures session has all default-enabled processing options
316
+ * Adds any new options that have enabled: true in defaultOptions but are missing from the session
317
+ * This is critical for existing sessions to get newly added processing options
318
+ */
319
+ const ensureProcessingOptions = (session: Session): Session => {
320
+ // List of all options that should be enabled by default
321
+ // Must match defaultOptions in ProcessingOptions.tsx where enabled: true
322
+ // IMPORTANT: These IDs must exactly match the option IDs in ProcessingOptions.tsx
323
+ // This list is used to backfill existing sessions with newly added default-enabled options
324
+ const defaultEnabledOptionIds = [
325
+ 'remove-italics',
326
+ 'normalize-dashes',
327
+ 'validate-document-styles',
328
+ 'replace-outdated-titles',
329
+ 'update-top-hyperlinks',
330
+ 'update-toc-hyperlinks',
331
+ 'force-remove-heading1-toc',
332
+ 'fix-internal-hyperlinks',
333
+ 'fix-content-ids',
334
+ 'center-border-images',
335
+ 'remove-whitespace',
336
+ 'remove-paragraph-lines',
337
+ 'remove-headers-footers',
338
+ 'add-document-warning',
339
+ 'validate-header2-tables',
340
+ 'set-landscape-margins',
341
+ 'list-indentation',
342
+ 'bullet-uniformity',
343
+ 'normalize-table-lists',
344
+ 'smart-tables',
345
+ 'adjust-table-padding',
346
+ 'standardize-table-borders',
347
+ 'correct-misapplied-styles',
348
+ ];
349
+
350
+ const currentEnabled = session.processingOptions?.enabledOperations || [];
351
+
352
+ // Add any missing default-enabled options
353
+ const missingOptions = defaultEnabledOptionIds.filter(id => !currentEnabled.includes(id));
354
+
355
+ if (missingOptions.length === 0) {
356
+ return session;
357
+ }
358
+
359
+ return {
360
+ ...session,
361
+ processingOptions: {
362
+ validateUrls: session.processingOptions?.validateUrls ?? true,
363
+ createBackup: session.processingOptions?.createBackup ?? true,
364
+ processInternalLinks: session.processingOptions?.processInternalLinks ?? true,
365
+ processExternalLinks: session.processingOptions?.processExternalLinks ?? true,
366
+ ...session.processingOptions,
367
+ enabledOperations: [...currentEnabled, ...missingOptions],
368
+ },
369
+ };
370
+ };
371
+
372
+ export function SessionProvider({ children }: { children: ReactNode }) {
373
+ const log = logger.namespace('SessionContext');
374
+ // Conditional verbose logger - only logs when SESSION_STATE debug mode is enabled
375
+ const debugLog = createDebugLogger(debugModes.SESSION_STATE, 'SessionState');
376
+
377
+ const [sessions, setSessions] = useState<Session[]>([]);
378
+ const [activeSessions, setActiveSessions] = useState<Session[]>([]);
379
+ const [currentSession, setCurrentSession] = useState<Session | null>(null);
380
+ const { updateStats: updateGlobalStats } = useGlobalStats();
381
+
382
+ // Ref to store debounce timer
383
+ const persistTimerRef = useRef<NodeJS.Timeout | null>(null);
384
+ // Ref to store latest sessions for persistence
385
+ const sessionsRef = useRef(sessions);
386
+ const activeSessionsRef = useRef(activeSessions);
387
+ // RACE CONDITION FIX: Flag to prevent double-loading sessions
388
+ // Prevents re-running loadSessionsFromStorage if component re-mounts
389
+ const hasLoadedRef = useRef(false);
390
+
391
+ const loadSessionsFromStorage = useCallback(async () => {
392
+ // RACE CONDITION FIX: Prevent double-loading if already loaded
393
+ if (hasLoadedRef.current) {
394
+ log.warn(
395
+ '[Session] loadSessionsFromStorage called but already loaded - skipping to prevent race condition'
396
+ );
397
+ return;
398
+ }
399
+
400
+ hasLoadedRef.current = true;
401
+
402
+ try {
403
+ // CRITICAL RECOVERY: Check for emergency backup from beforeunload
404
+ // This recovers data that may not have been saved to IndexedDB before app close
405
+ const emergencyBackup = localStorage.getItem('sessions_emergency_backup');
406
+ if (emergencyBackup) {
407
+ log.warn('[Session] Found emergency backup - attempting recovery...');
408
+ try {
409
+ const backup = safeJsonParse<{
410
+ sessions: SerializedSession[];
411
+ activeSessions: SerializedSession[];
412
+ timestamp: number;
413
+ reason: string;
414
+ } | null>(emergencyBackup, null, 'SessionContext.emergencyRecover');
415
+
416
+ if (backup && backup.sessions && backup.timestamp) {
417
+ const backupAge = Date.now() - backup.timestamp;
418
+ const backupAgeMinutes = Math.floor(backupAge / 60000);
419
+
420
+ log.info(`[Session] Emergency backup is ${backupAgeMinutes} minutes old`);
421
+
422
+ // Only restore if backup is recent (< 5 minutes old)
423
+ // Older backups are likely stale and shouldn't override IndexedDB
424
+ if (backupAge < 5 * 60 * 1000) {
425
+ log.info(
426
+ `[Session] Restoring ${backup.sessions.length} sessions from emergency backup`
427
+ );
428
+
429
+ // Save backup sessions to IndexedDB immediately
430
+ // backup.sessions is already in SerializedSession format (strings for dates)
431
+ for (const session of backup.sessions) {
432
+ await saveSessionToDB(session).catch((err) =>
433
+ log.error(`Failed to restore session ${session.id}:`, err)
434
+ );
435
+ }
436
+
437
+ log.info('[Session] Emergency backup successfully restored to IndexedDB');
438
+ } else {
439
+ log.info('[Session] Emergency backup too old - using IndexedDB instead');
440
+ }
441
+ }
442
+
443
+ // Clear emergency backup after processing (successful or not)
444
+ localStorage.removeItem('sessions_emergency_backup');
445
+ } catch (err) {
446
+ log.error('[Session] Failed to restore emergency backup:', err);
447
+ // Clear corrupted backup
448
+ localStorage.removeItem('sessions_emergency_backup');
449
+ }
450
+ }
451
+
452
+ // Check if localStorage has sessions that need migration
453
+ const hasLocalStorageSessions = localStorage.getItem('sessions');
454
+ if (hasLocalStorageSessions) {
455
+ log.info('[Session] Found sessions in localStorage, migrating to IndexedDB...');
456
+ await migrateFromLocalStorage();
457
+ // Clear localStorage after migration
458
+ localStorage.removeItem('sessions');
459
+ localStorage.removeItem('activeSessions');
460
+ }
461
+
462
+ // Load sessions from IndexedDB
463
+ const storedSessions = await loadSessions();
464
+ const storedActiveSessions = localStorage.getItem('activeSessions');
465
+
466
+ if (storedSessions && storedSessions.length > 0) {
467
+ const parsed: SerializedSession[] = storedSessions;
468
+ const restored: Session[] = parsed.map((s) => ({
469
+ ...s,
470
+ createdAt: new Date(s.createdAt),
471
+ lastModified: new Date(s.lastModified),
472
+ closedAt: s.closedAt ? new Date(s.closedAt) : undefined,
473
+ documents: s.documents.map((d) => ({
474
+ ...d,
475
+ processedAt: d.processedAt ? new Date(d.processedAt) : undefined,
476
+ })),
477
+ }));
478
+
479
+ // Clean up sessions older than 30 days (only if closed)
480
+ const thirtyDaysAgo = new Date();
481
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
482
+
483
+ const cleanedSessions = restored.filter((s) => {
484
+ // Keep all active sessions
485
+ if (s.status === 'active') return true;
486
+
487
+ // For closed sessions, check if they're older than 30 days
488
+ if (s.status === 'closed' && s.closedAt) {
489
+ const shouldKeep = s.closedAt > thirtyDaysAgo;
490
+ if (!shouldKeep) {
491
+ // Remove from IndexedDB as well
492
+ deleteSessionFromDB(s.id).catch((err) =>
493
+ log.error(`Failed to delete old session ${s.id}:`, err)
494
+ );
495
+ }
496
+ return shouldKeep;
497
+ }
498
+
499
+ // Keep sessions without closedAt (shouldn't happen, but be safe)
500
+ return true;
501
+ });
502
+
503
+ // Log cleanup if any sessions were removed
504
+ const removedCount = restored.length - cleanedSessions.length;
505
+ if (removedCount > 0) {
506
+ log.info(`[Session] Cleaned up ${removedCount} old session(s) (>30 days)`);
507
+ }
508
+
509
+ // BACKFILL FIX: Ensure all loaded sessions have valid listBulletSettings
510
+ // This repairs historical sessions that were created before list settings were added
511
+ const listBackfilledSessions = cleanedSessions.map(ensureListBulletSettings);
512
+
513
+ // Log how many sessions were backfilled for list settings
514
+ const listBackfillCount = listBackfilledSessions.filter(
515
+ (s, idx) => s.listBulletSettings !== cleanedSessions[idx].listBulletSettings
516
+ ).length;
517
+ if (listBackfillCount > 0) {
518
+ log.info(
519
+ `[Session] Backfilled ${listBackfillCount} session(s) with default list bullet settings`
520
+ );
521
+ }
522
+
523
+ // BACKFILL FIX: Ensure all loaded sessions have valid styles
524
+ // This is critical for font color to be applied - without styles, applyStyles() is skipped
525
+ const stylesBackfilledSessions = listBackfilledSessions.map(ensureSessionStyles);
526
+
527
+ // Log how many sessions were backfilled for styles
528
+ const stylesBackfillCount = stylesBackfilledSessions.filter(
529
+ (s, idx) => s.styles !== listBackfilledSessions[idx].styles
530
+ ).length;
531
+ if (stylesBackfillCount > 0) {
532
+ log.info(
533
+ `[Session] Backfilled ${stylesBackfillCount} session(s) with default styles`
534
+ );
535
+ }
536
+
537
+ // BACKFILL FIX: Ensure all loaded sessions have new default-enabled processing options
538
+ // This adds new options (like standardize-cell-borders) to existing sessions
539
+ const backfilledSessions = stylesBackfilledSessions.map(ensureProcessingOptions);
540
+
541
+ // Log how many sessions were backfilled for processing options
542
+ const optionsBackfillCount = backfilledSessions.filter(
543
+ (s, idx) => s.processingOptions?.enabledOperations?.length !== stylesBackfilledSessions[idx].processingOptions?.enabledOperations?.length
544
+ ).length;
545
+ if (optionsBackfillCount > 0) {
546
+ log.info(
547
+ `[Session] Backfilled ${optionsBackfillCount} session(s) with default processing options`
548
+ );
549
+ }
550
+
551
+ setSessions(backfilledSessions);
552
+ if (storedActiveSessions) {
553
+ const activeIds = safeJsonParse<string[]>(
554
+ storedActiveSessions,
555
+ [],
556
+ 'SessionContext.activeSessions'
557
+ );
558
+ const active = backfilledSessions.filter((s) => activeIds.includes(s.id));
559
+ setActiveSessions(active);
560
+ if (active.length > 0) {
561
+ setCurrentSession(active[0]);
562
+ }
563
+ }
564
+ }
565
+ } catch (err) {
566
+ log.error('Failed to load sessions from storage', err);
567
+ }
568
+ }, [log]); // Memoize with log dependency
569
+
570
+ // Load all sessions & active sessions from localStorage on mount
571
+ // CRITICAL: Only run ONCE on mount - do NOT reload when dependencies change
572
+ // Re-loading after mount causes race conditions where new data gets overwritten
573
+ useEffect(() => {
574
+ loadSessionsFromStorage();
575
+ // eslint-disable-next-line react-hooks/exhaustive-deps
576
+ }, []); // Empty deps - ONLY run on mount
577
+
578
+ // Update refs when sessions change
579
+ useEffect(() => {
580
+ sessionsRef.current = sessions;
581
+ }, [sessions]);
582
+
583
+ useEffect(() => {
584
+ activeSessionsRef.current = activeSessions;
585
+ }, [activeSessions]);
586
+
587
+ // Debounced persist function to reduce database writes
588
+ // PERFORMANCE: Debounces by 1 second to batch rapid state updates
589
+ // Using refs to access latest state without causing dependency issues
590
+ const debouncedPersistSessions = useCallback(async () => {
591
+ try {
592
+ // Critical: Ensure database size limit to prevent quota exceeded errors
593
+ await ensureDBSizeLimit(200); // 200MB limit
594
+
595
+ // Use refs to get latest state values
596
+ const currentSessions = sessionsRef.current;
597
+ const currentActiveSessions = activeSessionsRef.current;
598
+
599
+ const serializedSessions: SerializedSession[] = currentSessions.map((s) => ({
600
+ ...s,
601
+ createdAt: s.createdAt.toISOString(),
602
+ lastModified: s.lastModified.toISOString(),
603
+ closedAt: s.closedAt ? s.closedAt.toISOString() : undefined,
604
+ documents: s.documents.map((d) => ({
605
+ ...d,
606
+ processedAt: d.processedAt ? d.processedAt.toISOString() : undefined,
607
+ })),
608
+ }));
609
+
610
+ // Save each session to IndexedDB with quota error recovery
611
+ for (const session of serializedSessions) {
612
+ // Truncate large change arrays to prevent excessive storage
613
+ const truncatedSession = truncateSessionChanges(session, 100);
614
+
615
+ // Use quota error handler for automatic recovery if needed
616
+ await handleQuotaExceededError(async () => saveSessionToDB(truncatedSession), session.id);
617
+ }
618
+
619
+ // Keep active sessions in localStorage for quick access
620
+ const activeSessionIds = safeJsonStringify(
621
+ currentActiveSessions.map((s) => s.id),
622
+ undefined,
623
+ 'SessionContext.saveActiveSessions'
624
+ );
625
+ if (activeSessionIds) {
626
+ localStorage.setItem('activeSessions', activeSessionIds);
627
+ }
628
+ } catch (err) {
629
+ if (err instanceof Error && err.message.includes('DATABASE_QUOTA_EXCEEDED')) {
630
+ // User should be notified about quota issues - this should trigger a UI notification
631
+ log.error(
632
+ 'Database quota exceeded - archive old sessions or export data to free up space',
633
+ err
634
+ );
635
+ } else {
636
+ log.error('Failed to persist sessions:', err);
637
+ }
638
+ }
639
+ }, [log]); // No dependencies - uses refs instead
640
+
641
+ // Persist sessions and active sessions whenever they change (debounced)
642
+ useEffect(() => {
643
+ if (sessions.length > 0) {
644
+ // Clear existing timer
645
+ if (persistTimerRef.current) {
646
+ clearTimeout(persistTimerRef.current);
647
+ }
648
+
649
+ // PERFORMANCE FIX: Increased debounce from 1s to 3s for better UI responsiveness
650
+ // This reduces database writes during active editing (drag-drop, processing, etc.)
651
+ // and makes the UI feel much snappier
652
+ persistTimerRef.current = setTimeout(() => {
653
+ debouncedPersistSessions();
654
+ }, 3000); // 3 second debounce
655
+ }
656
+
657
+ // Cleanup on unmount
658
+ return () => {
659
+ if (persistTimerRef.current) {
660
+ clearTimeout(persistTimerRef.current);
661
+ persistTimerRef.current = null; // Clear the ref
662
+ }
663
+ };
664
+ }, [sessions, activeSessions, debouncedPersistSessions]); // FIX: Removed debouncedPersistSessions - it's stable so doesn't need to be a dependency
665
+
666
+ // CRITICAL FIX: Flush pending saves before window closes
667
+ // Without this, sessions created/modified within the debounce window are lost
668
+ useEffect(() => {
669
+ const handleBeforeUnload = () => {
670
+ // Cancel the debounce timer
671
+ if (persistTimerRef.current) {
672
+ clearTimeout(persistTimerRef.current);
673
+ persistTimerRef.current = null;
674
+ }
675
+
676
+ // Immediately save all sessions (synchronous)
677
+ // Note: This must be synchronous - async operations may not complete before unload
678
+ const currentSessions = sessionsRef.current;
679
+ const currentActiveSessions = activeSessionsRef.current;
680
+
681
+ if (currentSessions.length > 0) {
682
+ log.info('[beforeunload] Flushing pending session saves...');
683
+
684
+ // Save active session IDs to localStorage (synchronous)
685
+ const activeSessionIds = safeJsonStringify(
686
+ currentActiveSessions.map((s) => s.id),
687
+ undefined,
688
+ 'SessionContext.beforeunload'
689
+ );
690
+ if (activeSessionIds) {
691
+ localStorage.setItem('activeSessions', activeSessionIds);
692
+ }
693
+
694
+ // CRITICAL DATA LOSS FIX: Add emergency backup to localStorage
695
+ // This ensures data is preserved even if the async IndexedDB save doesn't complete
696
+ // before window closes (within the 3-second debounce window)
697
+ try {
698
+ // Helper function to serialize Session to SerializedSession
699
+ const serializeSession = (session: Session): SerializedSession => ({
700
+ ...session,
701
+ createdAt: session.createdAt.toISOString(),
702
+ lastModified: session.lastModified.toISOString(),
703
+ closedAt: session.closedAt?.toISOString(),
704
+ documents: session.documents.map((doc) => ({
705
+ ...doc,
706
+ processedAt: doc.processedAt?.toISOString(),
707
+ })),
708
+ });
709
+
710
+ const emergencyBackup = safeJsonStringify(
711
+ {
712
+ sessions: currentSessions.map(serializeSession),
713
+ activeSessions: currentActiveSessions.map(serializeSession),
714
+ timestamp: Date.now(),
715
+ reason: 'beforeunload_emergency_backup',
716
+ },
717
+ undefined,
718
+ 'SessionContext.emergencyBackup'
719
+ );
720
+
721
+ if (emergencyBackup) {
722
+ localStorage.setItem('sessions_emergency_backup', emergencyBackup);
723
+ log.info('[beforeunload] Emergency backup saved to localStorage');
724
+ }
725
+ } catch (error) {
726
+ // Silent fail - localStorage might be full or disabled
727
+ log.error('[beforeunload] Failed to create emergency backup:', error);
728
+ }
729
+
730
+ // Trigger the async save (may not complete, but we try)
731
+ debouncedPersistSessions().catch((error) => {
732
+ log.error('[beforeunload] Failed to flush sessions:', error);
733
+ });
734
+ }
735
+ };
736
+
737
+ window.addEventListener('beforeunload', handleBeforeUnload);
738
+
739
+ return () => {
740
+ window.removeEventListener('beforeunload', handleBeforeUnload);
741
+ };
742
+ }, [debouncedPersistSessions, log]);
743
+
744
+ const createSession = (name: string): Session => {
745
+ // Load custom defaults if user has saved them via "Save as Default"
746
+ const customDefaults = loadCustomDefaults();
747
+ const hasCustomDefaults = customDefaults !== null;
748
+
749
+ if (hasCustomDefaults) {
750
+ log.info('[createSession] Using custom defaults for new session');
751
+ }
752
+
753
+ const newSession: Session = {
754
+ id: `session-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
755
+ name,
756
+ createdAt: new Date(),
757
+ lastModified: new Date(),
758
+ documents: [],
759
+ stats: {
760
+ documentsProcessed: 0,
761
+ hyperlinksChecked: 0,
762
+ feedbackImported: 0,
763
+ timeSaved: 0,
764
+ },
765
+ status: 'active',
766
+ // Use custom defaults if available, otherwise fall back to factory defaults
767
+ styles: customDefaults?.styles
768
+ ? [...customDefaults.styles]
769
+ : [...DEFAULT_SESSION_STYLES],
770
+ listBulletSettings: customDefaults?.listBulletSettings
771
+ ? { ...customDefaults.listBulletSettings, indentationLevels: [...customDefaults.listBulletSettings.indentationLevels] }
772
+ : createDefaultListBulletSettings(),
773
+ tableShadingSettings: customDefaults?.tableShadingSettings
774
+ ? { ...customDefaults.tableShadingSettings }
775
+ : { ...DEFAULT_TABLE_SHADING_SETTINGS },
776
+ processingOptions: customDefaults?.processingOptions
777
+ ? {
778
+ ...customDefaults.processingOptions,
779
+ enabledOperations: [...(customDefaults.processingOptions.enabledOperations || [])],
780
+ }
781
+ : {
782
+ ...DEFAULT_PROCESSING_OPTIONS,
783
+ enabledOperations: [...DEFAULT_PROCESSING_OPTIONS.enabledOperations],
784
+ },
785
+ };
786
+
787
+ // DEBUG: Log state transition
788
+ debugLog.debug('Creating session - state before update', {
789
+ sessionCount: sessions.length,
790
+ activeCount: activeSessions.length,
791
+ currentSessionId: currentSession?.id,
792
+ });
793
+
794
+ setSessions((prev) => [...prev, newSession]);
795
+ setActiveSessions((prev) => [...prev, newSession]);
796
+ setCurrentSession(newSession);
797
+
798
+ // DEBUG: Log new state
799
+ debugLog.debug('Session created - state after update', {
800
+ newSessionId: newSession.id,
801
+ sessionCount: sessions.length + 1,
802
+ activeCount: activeSessions.length + 1,
803
+ });
804
+
805
+ // CRITICAL FIX: Immediately persist new session to prevent loss
806
+ // Don't wait for 3-second debounce - save right away
807
+ const serializedSession: SerializedSession = {
808
+ ...newSession,
809
+ createdAt: newSession.createdAt.toISOString(),
810
+ lastModified: newSession.lastModified.toISOString(),
811
+ closedAt: undefined, // New sessions are always active
812
+ documents: [],
813
+ };
814
+
815
+ // Fire-and-forget immediate save
816
+ saveSessionToDB(serializedSession).catch((error) => {
817
+ log.error(`[createSession] Failed to immediately save session "${name}":`, error);
818
+ });
819
+
820
+ log.info(`[createSession] Created and immediately saved session: ${name} (${newSession.id})`);
821
+
822
+ return newSession;
823
+ };
824
+
825
+ const loadSession = (id: string) => {
826
+ const session = sessions.find((s) => s.id === id);
827
+ if (session) {
828
+ // Only load if session is already active or explicitly reopening
829
+ if (activeSessions.find((s) => s.id === id)) {
830
+ // Session is already active, just switch to it
831
+ setCurrentSession(session);
832
+ } else if (session.status === 'closed') {
833
+ // CRITICAL FIX: Don't auto-reopen closed sessions
834
+ // User must explicitly reopen via reopenSession() or the Sessions page
835
+ log.warn(
836
+ `[loadSession] Attempted to load closed session: ${id}. Use reopenSession() instead.`
837
+ );
838
+ return; // EXIT without reopening
839
+ } else {
840
+ // Session exists but not in active list (shouldn't happen, but handle gracefully)
841
+ setActiveSessions((prev) => [...prev, session]);
842
+ setCurrentSession(session);
843
+ }
844
+ }
845
+ };
846
+
847
+ const reopenSession = (id: string) => {
848
+ const session = sessions.find((s) => s.id === id);
849
+ if (session && session.status === 'closed') {
850
+ // Explicitly reopen a closed session
851
+ const updatedSession = { ...session, status: 'active' as const, lastModified: new Date() };
852
+ setSessions((prev) => prev.map((s) => (s.id === id ? updatedSession : s)));
853
+ setActiveSessions((prev) => [...prev, updatedSession]);
854
+ setCurrentSession(updatedSession);
855
+
856
+ log.info(`[reopenSession] Reopened session: ${session.name}`);
857
+ }
858
+ };
859
+
860
+ const closeSession = (id: string) => {
861
+ // Get session info for logging
862
+ const session = sessions.find((s) => s.id === id);
863
+ const closedAt = new Date();
864
+
865
+ // DEBUG: Log state before close
866
+ debugLog.debug('Closing session - state before', {
867
+ closingSessionId: id,
868
+ closingSessionName: session?.name,
869
+ activeCount: activeSessions.length,
870
+ isCurrentSession: currentSession?.id === id,
871
+ });
872
+
873
+ // Remove from active sessions (sidebar) but keep in sessions list
874
+ setActiveSessions((prev) => prev.filter((s) => s.id !== id));
875
+
876
+ // Switch to another active session if closing current one
877
+ if (currentSession?.id === id) {
878
+ const remaining = activeSessions.filter((s) => s.id !== id);
879
+ setCurrentSession(remaining.length > 0 ? remaining[0] : null);
880
+ }
881
+
882
+ // Update session status to 'closed' but keep in sessions list for history
883
+ setSessions((prev) =>
884
+ prev.map((s) =>
885
+ s.id === id ? { ...s, status: 'closed' as const, lastModified: new Date(), closedAt } : s
886
+ )
887
+ );
888
+
889
+ // Log session closure
890
+ if (session) {
891
+ log.info('[Session] Closed:', {
892
+ id: session.id,
893
+ name: session.name,
894
+ closedAt: closedAt.toISOString(),
895
+ documentsProcessed: session.documents.length,
896
+ });
897
+ }
898
+ };
899
+
900
+ const deleteSession = (id: string) => {
901
+ // Get session info for logging before deletion
902
+ const session = sessions.find((s) => s.id === id);
903
+
904
+ // Permanently delete session from storage
905
+ setSessions((prev) => prev.filter((s) => s.id !== id));
906
+ setActiveSessions((prev) => prev.filter((s) => s.id !== id));
907
+ if (currentSession?.id === id) {
908
+ setCurrentSession(null);
909
+ }
910
+
911
+ // Also remove individual session from localStorage if it exists
912
+ localStorage.removeItem(`session_${id}`);
913
+
914
+ // Log session deletion
915
+ if (session) {
916
+ log.info('[Session] Deleted:', {
917
+ id: session.id,
918
+ name: session.name,
919
+ status: session.status,
920
+ createdAt: session.createdAt.toISOString(),
921
+ });
922
+ }
923
+ };
924
+
925
+ const switchSession = (id: string) => {
926
+ const session = activeSessions.find((s) => s.id === id);
927
+ if (session) {
928
+ setCurrentSession(session);
929
+ }
930
+ };
931
+
932
+ const addDocuments = async (sessionId: string, files: File[]) => {
933
+ // Convert files to documents with strict validation
934
+ const newDocuments: Document[] = [];
935
+ const invalidFiles: Array<{ name: string; reason: string }> = [];
936
+
937
+ log.info(`[addDocuments] Processing ${files.length} file(s) for session ${sessionId}`);
938
+
939
+ for (const file of files) {
940
+ const fileWithPath = file as File & { path?: string };
941
+
942
+ // Log detailed file information for debugging
943
+ log.debug(`[addDocuments] File: "${file.name}"`, {
944
+ path: fileWithPath.path,
945
+ size: file.size,
946
+ type: file.type,
947
+ hasPathProperty: 'path' in file,
948
+ pathValue: fileWithPath.path,
949
+ });
950
+
951
+ // STRICT VALIDATION: Only accept files with valid filesystem paths
952
+ // This is critical for Electron processing which requires absolute paths
953
+ if (!fileWithPath.path || fileWithPath.path.trim() === '') {
954
+ const reason = 'No file path provided';
955
+ log.error(`[addDocuments] File "${file.name}" rejected: ${reason}`);
956
+ invalidFiles.push({ name: file.name, reason });
957
+ continue;
958
+ }
959
+
960
+ // Enhanced security validation with path traversal protection
961
+ // Check for .docx/.doc extensions and security threats
962
+ if (!isPathSafe(fileWithPath.path, ['.docx', '.doc'])) {
963
+ const reason = `Failed security validation for path: "${fileWithPath.path}"`;
964
+ log.error(`[addDocuments] File "${file.name}" rejected: ${reason}`);
965
+ invalidFiles.push({ name: file.name, reason });
966
+ continue;
967
+ }
968
+
969
+ // Create document with validated path
970
+ newDocuments.push({
971
+ id: `doc-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
972
+ name: file.name,
973
+ path: fileWithPath.path,
974
+ size: file.size || 0,
975
+ type: file.type,
976
+ status: 'pending' as const,
977
+ // No fileData - will be read by backend using the path
978
+ });
979
+ }
980
+
981
+ // Log detailed results
982
+ if (invalidFiles.length > 0) {
983
+ log.warn(`[addDocuments] Rejected ${invalidFiles.length} file(s):`);
984
+ invalidFiles.forEach(({ name, reason }) => {
985
+ log.warn(` - ${name}: ${reason}`);
986
+ });
987
+ }
988
+ if (newDocuments.length > 0) {
989
+ log.info(`[addDocuments] ✅ Successfully added ${newDocuments.length} valid document(s)`);
990
+ newDocuments.forEach((doc) => {
991
+ log.debug(` ✓ ${doc.name} (${doc.size} bytes)`);
992
+ });
993
+ }
994
+
995
+ // Only update state if we have valid documents
996
+ if (newDocuments.length === 0) {
997
+ log.error('[addDocuments] ❌ No valid documents to add - all files were rejected');
998
+ return;
999
+ }
1000
+
1001
+ setSessions((prev) =>
1002
+ prev.map((session) =>
1003
+ session.id === sessionId
1004
+ ? {
1005
+ ...session,
1006
+ documents: [...session.documents, ...newDocuments],
1007
+ lastModified: new Date(),
1008
+ }
1009
+ : session
1010
+ )
1011
+ );
1012
+
1013
+ // Update active sessions
1014
+ setActiveSessions((prev) =>
1015
+ prev.map((session) =>
1016
+ session.id === sessionId
1017
+ ? {
1018
+ ...session,
1019
+ documents: [...session.documents, ...newDocuments],
1020
+ lastModified: new Date(),
1021
+ }
1022
+ : session
1023
+ )
1024
+ );
1025
+ };
1026
+
1027
+ const removeDocument = (sessionId: string, documentId: string) => {
1028
+ setSessions((prev) =>
1029
+ prev.map((session) =>
1030
+ session.id === sessionId
1031
+ ? {
1032
+ ...session,
1033
+ documents: session.documents.filter((d) => d.id !== documentId),
1034
+ lastModified: new Date(),
1035
+ }
1036
+ : session
1037
+ )
1038
+ );
1039
+ };
1040
+
1041
+ // PERFORMANCE FIX: Wrap in useCallback to prevent child component re-renders
1042
+ // This is critical for StylesEditor and other components that depend on this function
1043
+ const processDocument = useCallback(
1044
+ async (sessionId: string, documentId: string): Promise<void> => {
1045
+ const session = sessions.find((s) => s.id === sessionId);
1046
+ const document = session?.documents.find((d) => d.id === documentId);
1047
+ const processingStartTime = Date.now();
1048
+
1049
+ // =========================================================================
1050
+ // COMPREHENSIVE LOGGING - DOCUMENT PROCESSING START
1051
+ // =========================================================================
1052
+ log.info('═══════════════════════════════════════════════════════════════════════');
1053
+ log.info('[SessionContext] DOCUMENT PROCESSING STARTED');
1054
+ log.info('═══════════════════════════════════════════════════════════════════════');
1055
+ log.info(`[SessionContext] Timestamp: ${new Date().toISOString()}`);
1056
+ log.info(`[SessionContext] Session ID: ${sessionId}`);
1057
+ log.info(`[SessionContext] Document ID: ${documentId}`);
1058
+ log.info(`[SessionContext] Document Name: ${document?.name || 'Unknown'}`);
1059
+ log.info(`[SessionContext] Document Path: ${document?.path || 'No path'}`);
1060
+
1061
+ // DEBUG: Log document processing start
1062
+ debugLog.debug('Processing document - starting', {
1063
+ sessionId,
1064
+ documentId,
1065
+ documentName: document?.name,
1066
+ documentPath: document?.path ? '[path exists]' : '[no path]',
1067
+ });
1068
+
1069
+ if (!session || !document || !document.path) {
1070
+ log.error('[SessionContext] ERROR: Session, document, or document path not found');
1071
+ log.error(`[SessionContext] Session exists: ${!!session}`);
1072
+ log.error(`[SessionContext] Document exists: ${!!document}`);
1073
+ log.error(`[SessionContext] Document path exists: ${!!document?.path}`);
1074
+ return;
1075
+ }
1076
+
1077
+ // ENSURE SESSION HAS REQUIRED DATA: Backfill styles and list settings if missing
1078
+ // This ensures font color and other style-dependent features work correctly
1079
+ // even if the session was created before these features were added
1080
+ const ensuredSession = ensureSessionStyles(ensureListBulletSettings(session));
1081
+ if (ensuredSession !== session) {
1082
+ log.info('[Session] Backfilled session with missing styles or list settings during processing');
1083
+ // Update the session in state with the backfilled data
1084
+ setSessions((prev) =>
1085
+ prev.map((s) => (s.id === sessionId ? ensuredSession : s))
1086
+ );
1087
+ }
1088
+
1089
+ // Use the ensured session for processing
1090
+ const sessionToProcess = ensuredSession;
1091
+
1092
+ // PERFORMANCE: Update document status to processing (first setState)
1093
+ setSessions((prev) =>
1094
+ prev.map((s) =>
1095
+ s.id === sessionId
1096
+ ? {
1097
+ ...s,
1098
+ documents: s.documents.map((d) =>
1099
+ d.id === documentId ? { ...d, status: 'processing' as const, errors: undefined, errorType: undefined } : d
1100
+ ),
1101
+ lastModified: new Date(),
1102
+ }
1103
+ : s
1104
+ )
1105
+ );
1106
+
1107
+ try {
1108
+ // Get user settings from localStorage
1109
+ const userSettings = localStorage.getItem('userSettings');
1110
+ const settings = safeJsonParse<any>(
1111
+ userSettings,
1112
+ { apiConnections: { powerAutomateUrl: '' }, profile: {} },
1113
+ 'SessionContext.processDocument.userSettings'
1114
+ );
1115
+
1116
+ log.debug(
1117
+ 'Processing document with PowerAutomate URL:',
1118
+ settings.apiConnections.powerAutomateUrl
1119
+ );
1120
+
1121
+ // Extract profile data from settings for API request
1122
+ const userProfile = settings.profile
1123
+ ? {
1124
+ firstName: settings.profile.firstName || '',
1125
+ lastName: settings.profile.lastName || '',
1126
+ email: settings.profile.email || '',
1127
+ }
1128
+ : undefined;
1129
+
1130
+ log.debug('Processing document with user profile:', userProfile);
1131
+
1132
+ // Convert session processing options to hyperlink processing options
1133
+ // Extract style spacing from session styles
1134
+ log.debug('\n=== SESSION CONTEXT: Extracting Style Spacing ===');
1135
+ log.debug('sessionToProcess.styles:', sessionToProcess.styles);
1136
+
1137
+ // Default style spacing (applied when sessionToProcess.styles is undefined/empty)
1138
+ const defaultStyleSpacing = {
1139
+ header1: {
1140
+ spaceBefore: 0,
1141
+ spaceAfter: 12,
1142
+ lineSpacing: 1.0,
1143
+ },
1144
+ header2: {
1145
+ spaceBefore: 6,
1146
+ spaceAfter: 6,
1147
+ lineSpacing: 1.0,
1148
+ },
1149
+ normal: {
1150
+ spaceBefore: 3,
1151
+ spaceAfter: 3,
1152
+ lineSpacing: 1.0,
1153
+ },
1154
+ };
1155
+
1156
+ // Check if session has configured styles (should always be true after ensureSessionStyles)
1157
+ const hasSessionStyles = sessionToProcess.styles && sessionToProcess.styles.length > 0;
1158
+
1159
+ if (!hasSessionStyles) {
1160
+ log.debug(' No styles configured in session - using default spacing values');
1161
+ log.debug(' Default Header 1: 0pt before, 12pt after, 1.0 line spacing');
1162
+ log.debug(' Default Header 2: 6pt before, 6pt after, 1.0 line spacing');
1163
+ log.debug(' Default Normal: 3pt before, 3pt after, 1.0 line spacing');
1164
+ }
1165
+
1166
+ const header1Style = sessionToProcess.styles?.find((s: SessionStyle) => s.id === 'header1');
1167
+ const header2Style = sessionToProcess.styles?.find((s: SessionStyle) => s.id === 'header2');
1168
+ const normalStyle = sessionToProcess.styles?.find((s: SessionStyle) => s.id === 'normal');
1169
+
1170
+ log.debug('Found header1Style:', header1Style);
1171
+ log.debug('Found header2Style:', header2Style);
1172
+ log.debug('Found normalStyle:', normalStyle);
1173
+
1174
+ // Define custom style spacing with proper type structure
1175
+ interface CustomStyleSpacing {
1176
+ header1?: { spaceBefore: number; spaceAfter: number; lineSpacing?: number };
1177
+ header2?: { spaceBefore: number; spaceAfter: number; lineSpacing?: number };
1178
+ normal?: {
1179
+ spaceBefore: number;
1180
+ spaceAfter: number;
1181
+ lineSpacing?: number;
1182
+ noSpaceBetweenSame?: boolean;
1183
+ };
1184
+ }
1185
+ const customStyleSpacing: CustomStyleSpacing = {};
1186
+
1187
+ // Header 1 spacing (use session style or default)
1188
+ if (
1189
+ header1Style &&
1190
+ (header1Style.spaceBefore !== undefined ||
1191
+ header1Style.spaceAfter !== undefined ||
1192
+ header1Style.lineSpacing !== undefined)
1193
+ ) {
1194
+ customStyleSpacing.header1 = {
1195
+ spaceBefore: header1Style.spaceBefore ?? 0,
1196
+ spaceAfter: header1Style.spaceAfter ?? 0,
1197
+ lineSpacing: header1Style.lineSpacing ?? 1.0,
1198
+ };
1199
+ log.debug('✓ Added header1 spacing from session:', customStyleSpacing.header1);
1200
+ } else if (!hasSessionStyles) {
1201
+ customStyleSpacing.header1 = defaultStyleSpacing.header1;
1202
+ log.debug('✓ Added header1 spacing from defaults:', customStyleSpacing.header1);
1203
+ }
1204
+
1205
+ // Header 2 spacing (use session style or default)
1206
+ if (
1207
+ header2Style &&
1208
+ (header2Style.spaceBefore !== undefined ||
1209
+ header2Style.spaceAfter !== undefined ||
1210
+ header2Style.lineSpacing !== undefined)
1211
+ ) {
1212
+ customStyleSpacing.header2 = {
1213
+ spaceBefore: header2Style.spaceBefore ?? 0,
1214
+ spaceAfter: header2Style.spaceAfter ?? 0,
1215
+ lineSpacing: header2Style.lineSpacing ?? 1.0,
1216
+ };
1217
+ log.debug('✓ Added header2 spacing from session:', customStyleSpacing.header2);
1218
+ } else if (!hasSessionStyles) {
1219
+ customStyleSpacing.header2 = defaultStyleSpacing.header2;
1220
+ log.debug('✓ Added header2 spacing from defaults:', customStyleSpacing.header2);
1221
+ }
1222
+
1223
+ // Normal spacing (use session style or default)
1224
+ if (
1225
+ normalStyle &&
1226
+ (normalStyle.spaceBefore !== undefined ||
1227
+ normalStyle.spaceAfter !== undefined ||
1228
+ normalStyle.lineSpacing !== undefined)
1229
+ ) {
1230
+ customStyleSpacing.normal = {
1231
+ spaceBefore: normalStyle.spaceBefore ?? 0,
1232
+ spaceAfter: normalStyle.spaceAfter ?? 0,
1233
+ lineSpacing: normalStyle.lineSpacing ?? 1.0,
1234
+ };
1235
+ log.debug('✓ Added normal spacing from session:', customStyleSpacing.normal);
1236
+ } else if (!hasSessionStyles) {
1237
+ customStyleSpacing.normal = defaultStyleSpacing.normal;
1238
+ log.debug('✓ Added normal spacing from defaults:', customStyleSpacing.normal);
1239
+ }
1240
+
1241
+ log.debug('Final customStyleSpacing object:', customStyleSpacing);
1242
+ log.debug(
1243
+ 'Will pass to processor:',
1244
+ Object.keys(customStyleSpacing).length > 0 ? customStyleSpacing : undefined
1245
+ );
1246
+
1247
+ // DEBUG: Log enabled operations before processing
1248
+ log.info('\n=== PROCESSING DOCUMENT - OPTIONS DEBUG ===');
1249
+ log.info('Session enabled operations:', sessionToProcess.processingOptions?.enabledOperations || []);
1250
+
1251
+ const processingOptions: HyperlinkProcessingOptions & {
1252
+ // User Profile for API
1253
+ userProfile?: {
1254
+ firstName: string;
1255
+ lastName: string;
1256
+ email: string;
1257
+ };
1258
+
1259
+ // Text Formatting Options
1260
+ removeWhitespace?: boolean;
1261
+ removeParagraphLines?: boolean;
1262
+ preserveBlankLinesAfterHeader2Tables?: boolean;
1263
+ preserveUserBlankStructures?: boolean;
1264
+ removeItalics?: boolean;
1265
+ preserveRedFont?: boolean;
1266
+ normalizeDashes?: boolean;
1267
+ standardizeHyperlinkFormatting?: boolean;
1268
+ standardizeListPrefixFormatting?: boolean;
1269
+ correctMisappliedStyles?: boolean;
1270
+
1271
+ // Content Structure Options
1272
+ assignStyles?: boolean;
1273
+ centerAndBorderImages?: boolean;
1274
+ removeHeadersFooters?: boolean;
1275
+ addDocumentWarning?: boolean;
1276
+
1277
+ // Lists & Tables Options
1278
+ listBulletSettings?: ListBulletSettings;
1279
+ bulletUniformity?: boolean;
1280
+ normalizeTableLists?: boolean;
1281
+ tableUniformity?: boolean;
1282
+ smartTables?: boolean;
1283
+ standardizeTableBorders?: boolean;
1284
+ setLandscapeMargins?: boolean;
1285
+ tableShadingSettings?: {
1286
+ header2Shading: string;
1287
+ otherShading: string;
1288
+ // Table cell padding in inches
1289
+ padding1x1Top?: number;
1290
+ padding1x1Bottom?: number;
1291
+ padding1x1Left?: number;
1292
+ padding1x1Right?: number;
1293
+ paddingOtherTop?: number;
1294
+ paddingOtherBottom?: number;
1295
+ paddingOtherLeft?: number;
1296
+ paddingOtherRight?: number;
1297
+ };
1298
+ tableOfContentsSettings?: TableOfContentsSettings;
1299
+
1300
+ // Processing Options Control
1301
+ enabledOperations?: string[];
1302
+
1303
+ // Legacy
1304
+ tableUniformitySettings?: TableUniformitySettings;
1305
+
1306
+ // Word Tracked Changes Handling
1307
+ revisionHandlingMode?: RevisionHandlingMode;
1308
+ revisionAuthor?: string;
1309
+ autoAcceptRevisions?: boolean;
1310
+
1311
+ // Local Dictionary Settings
1312
+ localDictionary?: {
1313
+ enabled: boolean;
1314
+ totalEntries: number;
1315
+ };
1316
+ } = {
1317
+ apiEndpoint: settings.apiConnections.powerAutomateUrl || '',
1318
+ userProfile, // Pass profile data to backend for API request
1319
+
1320
+ // Hyperlink Operations (operations object)
1321
+ operations: {
1322
+ fixContentIds:
1323
+ sessionToProcess.processingOptions?.enabledOperations?.includes('fix-content-ids'),
1324
+ updateTitles:
1325
+ sessionToProcess.processingOptions?.enabledOperations?.includes('replace-outdated-titles'),
1326
+ replaceOutdatedTitles:
1327
+ sessionToProcess.processingOptions?.enabledOperations?.includes('replace-outdated-titles'), // Same flag, standalone fallback
1328
+ fixInternalHyperlinks:
1329
+ sessionToProcess.processingOptions?.enabledOperations?.includes('fix-internal-hyperlinks'),
1330
+ updateTopHyperlinks:
1331
+ sessionToProcess.processingOptions?.enabledOperations?.includes('update-top-hyperlinks'),
1332
+ updateTocHyperlinks: true, // Always enabled - no UI control
1333
+ standardizeHyperlinkColor: true, // Always enabled - removed from UI
1334
+ validateHeader2Tables:
1335
+ sessionToProcess.processingOptions?.enabledOperations?.includes('validate-header2-tables'),
1336
+ validateDocumentStyles: sessionToProcess.processingOptions?.enabledOperations?.includes(
1337
+ 'validate-document-styles'
1338
+ ),
1339
+ },
1340
+
1341
+ // Text replacements and styles
1342
+ textReplacements: sessionToProcess.replacements?.filter((r) => r.enabled) || [],
1343
+ // Transform session styles array to include all formatting properties
1344
+ // This matches the format expected by WordDocumentProcessor for custom style application
1345
+ styles:
1346
+ sessionToProcess.styles && Array.isArray(sessionToProcess.styles) && sessionToProcess.styles.length > 0
1347
+ ? sessionToProcess.styles.map((style: any) => {
1348
+ // DUAL TOGGLE FORMATTING SYSTEM
1349
+ // For formatting properties (bold, italic, underline):
1350
+ // - If preserveBold/preserveItalic/preserveUnderline === true: Don't call setter (preserve existing)
1351
+ // - If preserve flag === false/undefined: Apply bold/italic/underline value (true = apply, false = remove)
1352
+ // WordDocumentProcessor checks preserve flags before calling setters!
1353
+
1354
+ return {
1355
+ id: style.id,
1356
+ name: style.name,
1357
+ fontFamily: style.fontFamily,
1358
+ fontSize: style.fontSize,
1359
+ // Pass through formatting values
1360
+ bold: style.bold ?? false,
1361
+ italic: style.italic ?? false,
1362
+ underline: style.underline ?? false,
1363
+ // Pass through preserve flags
1364
+ preserveBold: style.preserveBold,
1365
+ preserveItalic: style.preserveItalic,
1366
+ preserveUnderline: style.preserveUnderline,
1367
+ preserveCenterAlignment: style.preserveCenterAlignment,
1368
+ alignment: style.alignment,
1369
+ color: style.color,
1370
+ spaceBefore: style.spaceBefore ?? 0,
1371
+ spaceAfter: style.spaceAfter ?? 0,
1372
+ lineSpacing: style.lineSpacing ?? 1.0,
1373
+ noSpaceBetweenSame: style.noSpaceBetweenSame,
1374
+ indentation: style.indentation
1375
+ ? {
1376
+ left: style.indentation.left,
1377
+ firstLine: style.indentation.firstLine,
1378
+ }
1379
+ : undefined,
1380
+ };
1381
+ })
1382
+ : [],
1383
+ customStyleSpacing:
1384
+ Object.keys(customStyleSpacing).length > 0 ? customStyleSpacing : undefined,
1385
+
1386
+ // Text Formatting Options (mapped from ProcessingOptions UI)
1387
+ removeWhitespace:
1388
+ sessionToProcess.processingOptions?.enabledOperations?.includes('remove-whitespace'),
1389
+ removeParagraphLines:
1390
+ sessionToProcess.processingOptions?.enabledOperations?.includes('remove-paragraph-lines'),
1391
+ removeItalics: sessionToProcess.processingOptions?.enabledOperations?.includes('remove-italics'),
1392
+ preserveRedFont: sessionToProcess.processingOptions?.enabledOperations?.includes('preserve-red-font'),
1393
+ normalizeDashes: sessionToProcess.processingOptions?.enabledOperations?.includes('normalize-dashes'),
1394
+
1395
+ // ALWAYS ENABLED: Standardize hyperlink formatting (remove bold/italic from all hyperlinks)
1396
+ // This is intentional and required for the work environment to maintain professional document standards.
1397
+ // Hyperlinks should never be bolded or italicized - they must always use standard blue underlined style.
1398
+ standardizeHyperlinkFormatting: true,
1399
+
1400
+ // ALWAYS ENABLED: Standardize list prefix formatting (Verdana 12pt black for all lists)
1401
+ // This ensures all bullet points and numbered list symbols have consistent professional formatting.
1402
+ standardizeListPrefixFormatting: true,
1403
+
1404
+ // Correct misapplied TOC/Hyperlink paragraph styles (user-togglable)
1405
+ correctMisappliedStyles:
1406
+ sessionToProcess.processingOptions?.enabledOperations?.includes('correct-misapplied-styles'),
1407
+
1408
+ // Content Structure Options (ALWAYS ENABLED - automatic processing)
1409
+ // These operations are now always applied when processing documents
1410
+ // UI checkboxes have been removed as these are essential formatting operations
1411
+ assignStyles: true, // Always apply custom styles from Styles tab
1412
+ centerAndBorderImages: true, // Always center and border large images (>1" either dimension)
1413
+ removeHeadersFooters:
1414
+ sessionToProcess.processingOptions?.enabledOperations?.includes('remove-headers-footers'),
1415
+ addDocumentWarning:
1416
+ sessionToProcess.processingOptions?.enabledOperations?.includes('add-document-warning'),
1417
+
1418
+ // Lists & Tables Options (mapped from ProcessingOptions UI)
1419
+ // Map list-indentation checkbox to listBulletSettings.enabled
1420
+ // This controls Phase 3 (indentation), while bullet-uniformity controls Phases 1+2 (symbols)
1421
+ listBulletSettings: sessionToProcess.processingOptions?.enabledOperations?.includes(
1422
+ 'list-indentation'
1423
+ )
1424
+ ? {
1425
+ enabled: true,
1426
+ indentationLevels: sessionToProcess.listBulletSettings?.indentationLevels || [],
1427
+ }
1428
+ : undefined,
1429
+ bulletUniformity:
1430
+ sessionToProcess.processingOptions?.enabledOperations?.includes('bullet-uniformity'),
1431
+ normalizeTableLists:
1432
+ sessionToProcess.processingOptions?.enabledOperations?.includes('normalize-table-lists'),
1433
+ tableUniformity: sessionToProcess.processingOptions?.enabledOperations?.includes('smart-tables'),
1434
+ smartTables: sessionToProcess.processingOptions?.enabledOperations?.includes('smart-tables'),
1435
+ standardizeTableBorders: sessionToProcess.processingOptions?.enabledOperations?.includes('standardize-table-borders'),
1436
+ setLandscapeMargins: sessionToProcess.processingOptions?.enabledOperations?.includes('set-landscape-margins'),
1437
+ // Table shading settings with values derived from paragraph styles
1438
+ // This ensures table cell formatting inherits from the existing UI controls
1439
+ tableShadingSettings: (() => {
1440
+ // Find relevant paragraph styles to derive table-specific settings
1441
+ const normalStyle = sessionToProcess.styles?.find((s: any) => s.id === 'normal');
1442
+ const heading2Style = sessionToProcess.styles?.find((s: any) => s.id === 'header2');
1443
+
1444
+ return sessionToProcess.tableShadingSettings
1445
+ ? {
1446
+ // Settings from TableShadingSettings UI
1447
+ header2Shading: sessionToProcess.tableShadingSettings.header2Shading,
1448
+ otherShading: sessionToProcess.tableShadingSettings.otherShading,
1449
+ imageBorderWidth: sessionToProcess.tableShadingSettings.imageBorderWidth ?? 1.0,
1450
+ // Table cell padding settings (in inches)
1451
+ padding1x1Top: sessionToProcess.tableShadingSettings.padding1x1Top ?? 0,
1452
+ padding1x1Bottom: sessionToProcess.tableShadingSettings.padding1x1Bottom ?? 0,
1453
+ padding1x1Left: sessionToProcess.tableShadingSettings.padding1x1Left ?? 0.08,
1454
+ padding1x1Right: sessionToProcess.tableShadingSettings.padding1x1Right ?? 0.08,
1455
+ paddingOtherTop: sessionToProcess.tableShadingSettings.paddingOtherTop ?? 0,
1456
+ paddingOtherBottom: sessionToProcess.tableShadingSettings.paddingOtherBottom ?? 0,
1457
+ paddingOtherLeft: sessionToProcess.tableShadingSettings.paddingOtherLeft ?? 0.08,
1458
+ paddingOtherRight: sessionToProcess.tableShadingSettings.paddingOtherRight ?? 0.08,
1459
+ // Cell border thickness setting
1460
+ cellBorderThickness: sessionToProcess.tableShadingSettings.cellBorderThickness ?? 0.5,
1461
+ // Derived from Heading 2 paragraph style
1462
+ heading2FontFamily: heading2Style?.fontFamily || 'Verdana',
1463
+ heading2FontSize: heading2Style?.fontSize || 14,
1464
+ // Derived from Normal paragraph style
1465
+ normalAlignment: normalStyle?.alignment || 'left',
1466
+ normalFontFamily: normalStyle?.fontFamily || 'Verdana',
1467
+ normalFontSize: normalStyle?.fontSize || 12,
1468
+ normalSpaceBefore: normalStyle?.spaceBefore ?? 3,
1469
+ normalSpaceAfter: normalStyle?.spaceAfter ?? 3,
1470
+ normalLineSpacing: normalStyle?.lineSpacing ?? 1.0,
1471
+ preserveBold: normalStyle?.preserveBold ?? true,
1472
+ preserveCenterAlignment: normalStyle?.preserveCenterAlignment ?? true,
1473
+ }
1474
+ : undefined;
1475
+ })(),
1476
+
1477
+ // Pass enabled operations array for condition checks in processor
1478
+ enabledOperations: sessionToProcess.processingOptions?.enabledOperations,
1479
+
1480
+ // Table of Contents Settings - Simplified to enabled flag only
1481
+ tableOfContentsSettings: sessionToProcess.tableOfContentsSettings,
1482
+
1483
+ // Word Tracked Changes Handling
1484
+ revisionHandlingMode: sessionToProcess.processingOptions?.revisionHandlingMode || 'accept_all',
1485
+ revisionAuthor: sessionToProcess.processingOptions?.revisionAuthor,
1486
+ autoAcceptRevisions: sessionToProcess.processingOptions?.autoAcceptRevisions ?? false, // Default: false
1487
+
1488
+ // DocHub Change Tracking (for Document Changes UI)
1489
+ trackChanges: true, // Enable hyperlink change tracking for DocumentProcessingComparison
1490
+
1491
+ // Legacy (deprecated, kept for backwards compatibility)
1492
+ tableUniformitySettings: sessionToProcess.tableUniformitySettings,
1493
+
1494
+ // Local Dictionary Settings (for offline hyperlink lookup)
1495
+ localDictionary: settings.localDictionary?.enabled
1496
+ ? {
1497
+ enabled: true,
1498
+ totalEntries: settings.localDictionary.totalEntries || 0,
1499
+ }
1500
+ : undefined,
1501
+ };
1502
+
1503
+ // DEBUG: Log final operations object being passed to processor
1504
+ log.info('Operations object being passed to WordDocumentProcessor:');
1505
+ log.info(' - updateTocHyperlinks:', processingOptions.operations?.updateTocHyperlinks);
1506
+ log.info(
1507
+ ' - validateDocumentStyles:',
1508
+ processingOptions.operations?.validateDocumentStyles
1509
+ );
1510
+ log.info(' - validateHeader2Tables:', processingOptions.operations?.validateHeader2Tables);
1511
+ log.info(' - styles length:', processingOptions.styles?.length || 0);
1512
+ if (processingOptions.styles && processingOptions.styles.length > 0) {
1513
+ log.info(
1514
+ ' - Available style IDs:',
1515
+ processingOptions.styles.map((s: any) => s.id).join(', ')
1516
+ );
1517
+ }
1518
+ log.info(
1519
+ ' - tableOfContentsSettings.enabled:',
1520
+ processingOptions.tableOfContentsSettings?.enabled ?? false
1521
+ );
1522
+ // CRITICAL DEBUG: Log listBulletSettings details
1523
+ log.info(' - listBulletSettings enabled:', processingOptions.listBulletSettings?.enabled);
1524
+ log.info(
1525
+ ' - listBulletSettings indentationLevels length:',
1526
+ processingOptions.listBulletSettings?.indentationLevels?.length || 0
1527
+ );
1528
+ if (processingOptions.listBulletSettings?.indentationLevels) {
1529
+ processingOptions.listBulletSettings.indentationLevels.forEach(
1530
+ (level: any, idx: number) => {
1531
+ log.info(
1532
+ ` - Level ${level.level}: symbol=${level.symbolIndent}", text=${level.textIndent}", char="${level.bulletChar}"`
1533
+ );
1534
+ }
1535
+ );
1536
+ }
1537
+ // Also log session state right before IPC call
1538
+ log.info('Session state right before IPC:');
1539
+ log.info(' - Session has listBulletSettings?', !!sessionToProcess.listBulletSettings);
1540
+ log.info(' - Session listBulletSettings enabled?', sessionToProcess.listBulletSettings?.enabled);
1541
+ log.info(
1542
+ ' - Session indentationLevels length:',
1543
+ sessionToProcess.listBulletSettings?.indentationLevels?.length || 0
1544
+ );
1545
+ if (sessionToProcess.listBulletSettings?.indentationLevels) {
1546
+ sessionToProcess.listBulletSettings.indentationLevels.forEach((level, idx) => {
1547
+ log.info(
1548
+ ` - Session Level ${level.level}: symbol=${level.symbolIndent}", text=${level.textIndent}", char="${level.bulletChar}"`
1549
+ );
1550
+ });
1551
+ }
1552
+ // Check enabled operations
1553
+ log.info(' - Enabled operations:', sessionToProcess.processingOptions?.enabledOperations || []);
1554
+ // DEBUG: Show formatting preservation for Normal/ListParagraph styles
1555
+ const normalStyleInOptions = processingOptions.styles?.find((s: any) => s.id === 'normal');
1556
+ const listParaStyleInOptions = processingOptions.styles?.find(
1557
+ (s: any) => s.id === 'listParagraph'
1558
+ );
1559
+ if (normalStyleInOptions || listParaStyleInOptions) {
1560
+ log.info(' - Formatting Preservation (bold & alignment only):');
1561
+ if (normalStyleInOptions) {
1562
+ log.info(
1563
+ ` - Normal: bold=${normalStyleInOptions.bold}, alignment=${normalStyleInOptions.alignment} (undefined = preserve), italic=${normalStyleInOptions.italic}, underline=${normalStyleInOptions.underline}`
1564
+ );
1565
+ }
1566
+ if (listParaStyleInOptions) {
1567
+ log.info(
1568
+ ` - ListParagraph: bold=${listParaStyleInOptions.bold}, alignment=${listParaStyleInOptions.alignment} (undefined = preserve), italic=${listParaStyleInOptions.italic}, underline=${listParaStyleInOptions.underline}`
1569
+ );
1570
+ }
1571
+ }
1572
+
1573
+ // Capture snapshot BEFORE processing for comparison feature
1574
+ // This must happen in the renderer process (IndexedDB not available in main process)
1575
+ try {
1576
+ log.info(`[SessionContext] Capturing pre-processing snapshot for ${document.name}`);
1577
+
1578
+ // Ensure Electron API is available for file operations
1579
+ const electronAPI = requireElectronAPI('snapshot capture');
1580
+
1581
+ // Read original file into buffer
1582
+ const fileBuffer = await electronAPI.readFileAsBuffer(document.path);
1583
+
1584
+ // Extract original text content before processing
1585
+ const textResult = await electronAPI.extractDocumentText(document.path);
1586
+ const originalText = textResult.success && textResult.textContent ? textResult.textContent : [];
1587
+
1588
+ // Store snapshot in IndexedDB (renderer process)
1589
+ await DocumentSnapshotService.captureSnapshot(
1590
+ fileBuffer,
1591
+ sessionId,
1592
+ documentId,
1593
+ originalText,
1594
+ [] // hyperlinks optional
1595
+ );
1596
+ log.info(`[SessionContext] Snapshot captured: ${originalText.length} paragraphs, ${(fileBuffer.byteLength / 1024).toFixed(1)}KB`);
1597
+ } catch (snapshotError) {
1598
+ log.warn('[SessionContext] Failed to capture snapshot (comparison will be unavailable):', snapshotError);
1599
+ // Continue with processing even if snapshot fails
1600
+ }
1601
+
1602
+ // =========================================================================
1603
+ // LOGGING - IPC CALL TO MAIN PROCESS
1604
+ // =========================================================================
1605
+ log.info('───────────────────────────────────────────────────────────────────────');
1606
+ log.info('[SessionContext] Sending document to main process for processing');
1607
+ log.info(`[SessionContext] API Endpoint: ${processingOptions.apiEndpoint ? 'Configured' : 'NOT CONFIGURED'}`);
1608
+ log.info(`[SessionContext] Operations enabled: ${sessionToProcess.processingOptions?.enabledOperations?.join(', ') || 'None'}`);
1609
+ log.info(`[SessionContext] IPC Timeout: ${IPC_TIMEOUT_MS}ms`);
1610
+ log.info('───────────────────────────────────────────────────────────────────────');
1611
+
1612
+ // Process the document using Electron IPC with timeout protection
1613
+ const processingAPI = requireElectronAPI('document processing');
1614
+ const rawResult = await withTimeout(
1615
+ processingAPI.processHyperlinkDocument(document.path, processingOptions),
1616
+ IPC_TIMEOUT_MS,
1617
+ 'Document processing'
1618
+ );
1619
+
1620
+ // Validate the response structure before using it
1621
+ if (!rawResult || typeof rawResult !== 'object') {
1622
+ throw new Error('Invalid response from document processor: expected an object');
1623
+ }
1624
+
1625
+ // Type guard to ensure required fields exist
1626
+ const hasRequiredFields =
1627
+ 'success' in rawResult &&
1628
+ 'totalHyperlinks' in rawResult &&
1629
+ typeof rawResult.success === 'boolean' &&
1630
+ typeof rawResult.totalHyperlinks === 'number';
1631
+
1632
+ if (!hasRequiredFields) {
1633
+ throw new Error(
1634
+ 'Invalid response from document processor: missing required fields (success, totalHyperlinks)'
1635
+ );
1636
+ }
1637
+
1638
+ const result = rawResult as {
1639
+ success: boolean;
1640
+ totalHyperlinks: number;
1641
+ processedHyperlinks: number;
1642
+ modifiedHyperlinks: number;
1643
+ appendedContentIds?: number;
1644
+ backupPath?: string;
1645
+ duration: number;
1646
+ errorMessages?: string[];
1647
+ changes?: import('@/types/session').DocumentChange[];
1648
+ previousRevisions?: import('@/types/session').PreviousRevisionState;
1649
+ wordRevisions?: import('@/types/session').WordRevisionState;
1650
+ };
1651
+
1652
+ // PERFORMANCE: Update document status AND stats in single setState (batched)
1653
+ // This reduces re-renders from 2 to 1 per document
1654
+ setSessions((prev) =>
1655
+ prev.map((s) =>
1656
+ s.id === sessionId
1657
+ ? {
1658
+ ...s,
1659
+ documents: s.documents.map((d) =>
1660
+ d.id === documentId
1661
+ ? {
1662
+ ...d,
1663
+ status: result.success ? ('completed' as const) : ('error' as const),
1664
+ processedAt: new Date(),
1665
+ errors: result.errorMessages,
1666
+ errorType: !result.success
1667
+ ? (result.errorMessages?.some((msg) => msg.toLowerCase().includes('close the file'))
1668
+ ? 'file_locked'
1669
+ : result.errorMessages?.some((msg) => msg.toLowerCase().includes('timeout'))
1670
+ ? 'api_timeout'
1671
+ : result.errorMessages?.some((msg) =>
1672
+ msg.toLowerCase().includes('compatibility_mode') ||
1673
+ msg.toLowerCase().includes('outdated functions'))
1674
+ ? 'word_compatibility'
1675
+ : 'general')
1676
+ : undefined,
1677
+ // Store pre-existing revisions (from before DocHub processing)
1678
+ previousRevisions: result.previousRevisions,
1679
+ // Store Word revisions state from DocHub processing
1680
+ wordRevisions: result.wordRevisions,
1681
+ processingResult: {
1682
+ hyperlinksProcessed: result.processedHyperlinks,
1683
+ hyperlinksModified: result.modifiedHyperlinks,
1684
+ contentIdsAppended:
1685
+ result.appendedContentIds || result.processedHyperlinks,
1686
+ backupPath: result.backupPath,
1687
+ duration: result.duration,
1688
+ // Use the enhanced changes array from processor with full context
1689
+ changes: result.changes || [],
1690
+ },
1691
+ }
1692
+ : d
1693
+ ),
1694
+ stats: {
1695
+ ...s.stats,
1696
+ documentsProcessed: s.stats.documentsProcessed + (result.success ? 1 : 0),
1697
+ hyperlinksChecked: s.stats.hyperlinksChecked + result.totalHyperlinks,
1698
+ feedbackImported: s.stats.feedbackImported,
1699
+ timeSaved:
1700
+ s.stats.timeSaved +
1701
+ Math.round(
1702
+ (result.totalHyperlinks * TIME_SAVED_SECONDS_PER_HYPERLINK) /
1703
+ SECONDS_PER_MINUTE
1704
+ ),
1705
+ },
1706
+ lastModified: new Date(),
1707
+ }
1708
+ : s
1709
+ )
1710
+ );
1711
+
1712
+ // Update global stats if processing was successful
1713
+ if (result.success) {
1714
+ await updateGlobalStats({
1715
+ documentsProcessed: 1,
1716
+ hyperlinksChecked: result.totalHyperlinks,
1717
+ timeSaved: Math.round(
1718
+ (result.totalHyperlinks * TIME_SAVED_SECONDS_PER_HYPERLINK) / SECONDS_PER_MINUTE
1719
+ ),
1720
+ });
1721
+
1722
+ const totalDuration = Date.now() - processingStartTime;
1723
+
1724
+ // Enhanced success logging for user visibility
1725
+ log.info('═══════════════════════════════════════════════════════════════════════');
1726
+ log.info('[SessionContext] DOCUMENT PROCESSING COMPLETE - SUCCESS');
1727
+ log.info('═══════════════════════════════════════════════════════════════════════');
1728
+ log.info(`[SessionContext] Document: ${document.name}`);
1729
+ log.info(`[SessionContext] Location: ${document.path}`);
1730
+ log.info(`[SessionContext] Hyperlinks Processed: ${result.totalHyperlinks}`);
1731
+ log.info(`[SessionContext] Hyperlinks Modified: ${result.modifiedHyperlinks}`);
1732
+ log.info(`[SessionContext] Content IDs Appended: ${result.appendedContentIds || 0}`);
1733
+ log.info(`[SessionContext] Processor Duration: ${result.duration}ms`);
1734
+ log.info(`[SessionContext] Total Duration: ${totalDuration}ms`);
1735
+ log.info(
1736
+ `[SessionContext] Time Saved: ${Math.round((result.totalHyperlinks * TIME_SAVED_SECONDS_PER_HYPERLINK) / SECONDS_PER_MINUTE)} seconds`
1737
+ );
1738
+ log.info('═══════════════════════════════════════════════════════════════════════');
1739
+ } else {
1740
+ const totalDuration = Date.now() - processingStartTime;
1741
+ log.error('═══════════════════════════════════════════════════════════════════════');
1742
+ log.error('[SessionContext] DOCUMENT PROCESSING COMPLETE - FAILED');
1743
+ log.error('═══════════════════════════════════════════════════════════════════════');
1744
+ log.error(`[SessionContext] Document: ${document.name}`);
1745
+ log.error(`[SessionContext] Errors: ${result.errorMessages?.join(', ') || 'Unknown error'}`);
1746
+ log.error(`[SessionContext] Duration: ${totalDuration}ms`);
1747
+ log.error('═══════════════════════════════════════════════════════════════════════');
1748
+ }
1749
+ } catch (error) {
1750
+ const totalDuration = Date.now() - processingStartTime;
1751
+ log.error('═══════════════════════════════════════════════════════════════════════');
1752
+ log.error('[SessionContext] DOCUMENT PROCESSING EXCEPTION');
1753
+ log.error('═══════════════════════════════════════════════════════════════════════');
1754
+ log.error(`[SessionContext] Document: ${document.name}`);
1755
+ log.error(`[SessionContext] Error: ${error instanceof Error ? error.message : String(error)}`);
1756
+ if (error instanceof Error && error.stack) {
1757
+ log.error(`[SessionContext] Stack Trace:`);
1758
+ error.stack.split('\n').forEach(line => {
1759
+ log.error(`[SessionContext] ${line}`);
1760
+ });
1761
+ }
1762
+ log.error(`[SessionContext] Duration: ${totalDuration}ms`);
1763
+ log.error('═══════════════════════════════════════════════════════════════════════');
1764
+ log.error('Error processing document:', error);
1765
+
1766
+ // Update document status to error
1767
+ setSessions((prev) =>
1768
+ prev.map((s) =>
1769
+ s.id === sessionId
1770
+ ? {
1771
+ ...s,
1772
+ documents: s.documents.map((d) =>
1773
+ d.id === documentId
1774
+ ? {
1775
+ ...d,
1776
+ status: 'error' as const,
1777
+ errors: [error instanceof Error ? error.message : 'Processing failed'],
1778
+ errorType: (error instanceof Error && error.message.toLowerCase().includes('close the file'))
1779
+ ? 'file_locked'
1780
+ : (error instanceof Error && error.message.toLowerCase().includes('timeout'))
1781
+ ? 'api_timeout'
1782
+ : (error instanceof Error && (
1783
+ error.message.toLowerCase().includes('compatibility_mode') ||
1784
+ error.message.toLowerCase().includes('outdated functions')))
1785
+ ? 'word_compatibility'
1786
+ : 'general',
1787
+ }
1788
+ : d
1789
+ ),
1790
+ lastModified: new Date(),
1791
+ }
1792
+ : s
1793
+ )
1794
+ );
1795
+ }
1796
+ },
1797
+ [sessions, log, updateGlobalStats]
1798
+ ); // Dependencies: sessions for finding docs, log for logging, updateGlobalStats for stats
1799
+
1800
+ const revertChange = async (
1801
+ sessionId: string,
1802
+ documentId: string,
1803
+ changeId: string
1804
+ ): Promise<void> => {
1805
+ const session = sessions.find((s) => s.id === sessionId);
1806
+ const document = session?.documents.find((d) => d.id === documentId);
1807
+
1808
+ if (!session || !document) {
1809
+ log.error('Session or document not found');
1810
+ return;
1811
+ }
1812
+
1813
+ // Remove the change from the tracked changes list
1814
+ setSessions((prev) =>
1815
+ prev.map((s) =>
1816
+ s.id === sessionId
1817
+ ? {
1818
+ ...s,
1819
+ documents: s.documents.map((d) =>
1820
+ d.id === documentId && d.processingResult
1821
+ ? {
1822
+ ...d,
1823
+ processingResult: {
1824
+ ...d.processingResult,
1825
+ changes: d.processingResult.changes?.filter((c) => c.id !== changeId) || [],
1826
+ },
1827
+ }
1828
+ : d
1829
+ ),
1830
+ lastModified: new Date(),
1831
+ }
1832
+ : s
1833
+ )
1834
+ );
1835
+
1836
+ log.info(`[Session] Reverted change ${changeId} from document ${documentId}`);
1837
+ };
1838
+
1839
+ const revertAllChanges = async (sessionId: string, documentId: string): Promise<void> => {
1840
+ const session = sessions.find((s) => s.id === sessionId);
1841
+ const document = session?.documents.find((d) => d.id === documentId);
1842
+
1843
+ if (!session || !document || !document.path) {
1844
+ log.error('Session, document, or document path not found');
1845
+ return;
1846
+ }
1847
+
1848
+ const backupPath = document.processingResult?.backupPath;
1849
+ if (!backupPath) {
1850
+ log.error('No backup path found for document');
1851
+ throw new Error('No backup available for this document');
1852
+ }
1853
+
1854
+ try {
1855
+ // Call Electron IPC to restore from backup
1856
+ const restoreAPI = requireElectronAPI('backup restore');
1857
+ await restoreAPI.restoreFromBackup(backupPath, document.path);
1858
+
1859
+ // Clear all tracked changes and reset processing status
1860
+ setSessions((prev) =>
1861
+ prev.map((s) =>
1862
+ s.id === sessionId
1863
+ ? {
1864
+ ...s,
1865
+ documents: s.documents.map((d) =>
1866
+ d.id === documentId
1867
+ ? {
1868
+ ...d,
1869
+ status: 'pending' as const,
1870
+ processedAt: undefined,
1871
+ errors: undefined,
1872
+ processingResult: undefined,
1873
+ }
1874
+ : d
1875
+ ),
1876
+ lastModified: new Date(),
1877
+ }
1878
+ : s
1879
+ )
1880
+ );
1881
+
1882
+ log.info(
1883
+ `[Session] Reverted all changes for document ${documentId} from backup ${backupPath}`
1884
+ );
1885
+ } catch (error) {
1886
+ log.error('Error reverting all changes:', error);
1887
+ throw error;
1888
+ }
1889
+ };
1890
+
1891
+ // UNIFIED STATE UPDATE HELPER
1892
+ // Prevents state synchronization issues by updating all three state variables atomically
1893
+ // This replaces the previous pattern of updating sessions, activeSessions, and currentSession separately
1894
+ const updateSessionById = useCallback(
1895
+ (sessionId: string, updater: (session: Session) => Session) => {
1896
+ const updateFn = (sessions: Session[]) =>
1897
+ sessions.map((s) => (s.id === sessionId ? updater(s) : s));
1898
+
1899
+ setSessions(updateFn);
1900
+ setActiveSessions(updateFn);
1901
+ setCurrentSession((prev) => (prev?.id === sessionId ? updater(prev) : prev));
1902
+ },
1903
+ []
1904
+ );
1905
+
1906
+ const updateSessionStats = useCallback(
1907
+ (sessionId: string, stats: Partial<SessionStats>) => {
1908
+ updateSessionById(sessionId, (session) => ({
1909
+ ...session,
1910
+ stats: { ...session.stats, ...stats },
1911
+ lastModified: new Date(),
1912
+ }));
1913
+ },
1914
+ [updateSessionById]
1915
+ );
1916
+
1917
+ const updateSessionName = useCallback(
1918
+ (sessionId: string, name: string) => {
1919
+ updateSessionById(sessionId, (session) => ({
1920
+ ...session,
1921
+ name,
1922
+ lastModified: new Date(),
1923
+ }));
1924
+ },
1925
+ [updateSessionById]
1926
+ );
1927
+
1928
+ const updateSessionOptions = useCallback(
1929
+ (sessionId: string, processingOptions: Session['processingOptions']) => {
1930
+ // DEBUG: Log session options update
1931
+ log.info('[SessionContext] Updating session options for session:', sessionId);
1932
+ log.info(' - Enabled operations:', processingOptions?.enabledOperations || []);
1933
+ log.info(' - Options object:', processingOptions);
1934
+
1935
+ updateSessionById(sessionId, (session) => ({
1936
+ ...session,
1937
+ processingOptions,
1938
+ lastModified: new Date(),
1939
+ }));
1940
+ },
1941
+ [updateSessionById, log]
1942
+ );
1943
+
1944
+ const updateSessionReplacements = useCallback(
1945
+ (sessionId: string, replacements: ReplacementRule[]) => {
1946
+ updateSessionById(sessionId, (session) => ({
1947
+ ...session,
1948
+ replacements,
1949
+ lastModified: new Date(),
1950
+ }));
1951
+ },
1952
+ [updateSessionById]
1953
+ );
1954
+
1955
+ const updateSessionStyles = useCallback(
1956
+ (sessionId: string, styles: SessionStyle[]) => {
1957
+ updateSessionById(sessionId, (session) => ({
1958
+ ...session,
1959
+ styles,
1960
+ lastModified: new Date(),
1961
+ }));
1962
+ },
1963
+ [updateSessionById]
1964
+ );
1965
+
1966
+ const updateSessionListBulletSettings = useCallback(
1967
+ (sessionId: string, listBulletSettings: ListBulletSettings) => {
1968
+ updateSessionById(sessionId, (session) => ({
1969
+ ...session,
1970
+ listBulletSettings,
1971
+ lastModified: new Date(),
1972
+ }));
1973
+ },
1974
+ [updateSessionById]
1975
+ );
1976
+
1977
+ const updateSessionTableUniformitySettings = useCallback(
1978
+ (sessionId: string, tableUniformitySettings: TableUniformitySettings) => {
1979
+ updateSessionById(sessionId, (session) => ({
1980
+ ...session,
1981
+ tableUniformitySettings,
1982
+ lastModified: new Date(),
1983
+ }));
1984
+ },
1985
+ [updateSessionById]
1986
+ );
1987
+
1988
+ const updateSessionTableShadingSettings = useCallback(
1989
+ (sessionId: string, tableShadingSettings: TableShadingSettings) => {
1990
+ updateSessionById(sessionId, (session) => ({
1991
+ ...session,
1992
+ tableShadingSettings,
1993
+ lastModified: new Date(),
1994
+ }));
1995
+ },
1996
+ [updateSessionById]
1997
+ );
1998
+
1999
+ const updateSessionTableOfContentsSettings = useCallback(
2000
+ (sessionId: string, tableOfContentsSettings: TableOfContentsSettings) => {
2001
+ log.info('[SessionContext] Updating Table of Contents settings for session:', sessionId);
2002
+
2003
+ updateSessionById(sessionId, (session) => ({
2004
+ ...session,
2005
+ tableOfContentsSettings,
2006
+ lastModified: new Date(),
2007
+ }));
2008
+ },
2009
+ [updateSessionById, log]
2010
+ );
2011
+
2012
+ const saveSession = (session: Session) => {
2013
+ const jsonString = safeJsonStringify(session, undefined, 'SessionContext.saveSession');
2014
+ if (jsonString) {
2015
+ localStorage.setItem(`session_${session.id}`, jsonString);
2016
+ }
2017
+ };
2018
+
2019
+ const loadSessionFromStorage = (id: string): Session | null => {
2020
+ const stored = localStorage.getItem(`session_${id}`);
2021
+ if (stored) {
2022
+ const parsed = safeJsonParse<any>(stored, null, 'SessionContext.loadSessionFromStorage');
2023
+ if (parsed) {
2024
+ return {
2025
+ ...parsed,
2026
+ createdAt: new Date(parsed.createdAt),
2027
+ lastModified: new Date(parsed.lastModified),
2028
+ };
2029
+ }
2030
+ }
2031
+ return null;
2032
+ };
2033
+
2034
+ // Reset session to factory defaults
2035
+ const resetSessionToDefaults = (sessionId: string) => {
2036
+ log.info('[SessionContext] Resetting session to defaults:', sessionId);
2037
+
2038
+ updateSessionById(sessionId, (session) => ({
2039
+ ...session,
2040
+ styles: [...DEFAULT_SESSION_STYLES],
2041
+ listBulletSettings: createDefaultListBulletSettings(),
2042
+ processingOptions: {
2043
+ ...DEFAULT_PROCESSING_OPTIONS,
2044
+ enabledOperations: [...DEFAULT_PROCESSING_OPTIONS.enabledOperations],
2045
+ },
2046
+ tableShadingSettings: { ...DEFAULT_TABLE_SHADING_SETTINGS },
2047
+ lastModified: new Date(),
2048
+ }));
2049
+
2050
+ log.info('[SessionContext] Session reset to defaults');
2051
+ };
2052
+
2053
+ // Save current session settings as custom defaults for new sessions
2054
+ const saveAsCustomDefaults = (sessionId: string) => {
2055
+ const session = sessions.find((s) => s.id === sessionId);
2056
+ if (!session) {
2057
+ log.error('[SessionContext] Session not found for saving defaults:', sessionId);
2058
+ return;
2059
+ }
2060
+
2061
+ log.info('[SessionContext] Saving session settings as custom defaults:', sessionId);
2062
+
2063
+ const customDefaults = {
2064
+ styles: session.styles,
2065
+ listBulletSettings: session.listBulletSettings,
2066
+ processingOptions: session.processingOptions,
2067
+ tableShadingSettings: session.tableShadingSettings,
2068
+ };
2069
+
2070
+ const jsonString = safeJsonStringify(
2071
+ customDefaults,
2072
+ undefined,
2073
+ 'SessionContext.saveAsCustomDefaults'
2074
+ );
2075
+ if (jsonString) {
2076
+ localStorage.setItem(CUSTOM_DEFAULTS_KEY, jsonString);
2077
+ log.info('[SessionContext] Custom defaults saved successfully');
2078
+ }
2079
+ };
2080
+
2081
+ const recentSessions = sessions
2082
+ .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime())
2083
+ .slice(0, 5);
2084
+
2085
+ return (
2086
+ <SessionContext.Provider
2087
+ value={{
2088
+ sessions,
2089
+ activeSessions,
2090
+ currentSession,
2091
+ recentSessions,
2092
+ createSession,
2093
+ loadSession,
2094
+ reopenSession,
2095
+ closeSession,
2096
+ deleteSession,
2097
+ switchSession,
2098
+ addDocuments,
2099
+ removeDocument,
2100
+ processDocument,
2101
+ revertChange,
2102
+ revertAllChanges,
2103
+ updateSessionStats,
2104
+ updateSessionName,
2105
+ updateSessionOptions,
2106
+ updateSessionReplacements,
2107
+ updateSessionStyles,
2108
+ updateSessionListBulletSettings,
2109
+ updateSessionTableUniformitySettings,
2110
+ updateSessionTableShadingSettings,
2111
+ updateSessionTableOfContentsSettings,
2112
+ saveSession,
2113
+ loadSessionFromStorage,
2114
+ resetSessionToDefaults,
2115
+ saveAsCustomDefaults,
2116
+ }}
2117
+ >
2118
+ {children}
2119
+ </SessionContext.Provider>
2120
+ );
2121
+ }
2122
+
2123
+ export function useSession() {
2124
+ const context = useContext(SessionContext);
2125
+ if (context === undefined) {
2126
+ throw new Error('useSession must be used within a SessionProvider');
2127
+ }
2128
+ return context;
2129
+ }