plugin-file-preview-auth 1.3.5 → 1.3.8

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 (105) hide show
  1. package/client-v2.d.ts +2 -0
  2. package/client-v2.js +1 -0
  3. package/dist/client/713.79a55458f5b67f39.js +30 -0
  4. package/dist/client/823.8b0ab22c181d4523.js +10 -0
  5. package/dist/client/828.ae8e47a2e7a3bc9e.js +49 -0
  6. package/dist/client/892.a568eb42fd6f0047.js +10 -0
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/index.js +10 -0
  9. package/dist/externalVersion.js +8 -7
  10. package/dist/node_modules/@aws-sdk/client-s3/dist-cjs/index.js +3086 -3725
  11. package/dist/node_modules/@aws-sdk/client-s3/node_modules/.bin/fxparser +16 -0
  12. package/dist/node_modules/@aws-sdk/client-s3/node_modules/.bin/fxparser.cmd +17 -0
  13. package/dist/node_modules/@aws-sdk/client-s3/node_modules/.bin/fxparser.ps1 +28 -0
  14. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-cjs/index.js +110 -0
  15. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-es/SignatureV4MultiRegion.js +66 -0
  16. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-es/index.js +2 -0
  17. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-es/signature-v4-crt-container.js +3 -0
  18. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/SignatureV4MultiRegion.d.ts +30 -0
  19. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/index.d.ts +5 -0
  20. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/signature-v4-crt-container.d.ts +28 -0
  21. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/ts3.4/SignatureV4MultiRegion.d.ts +40 -0
  22. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/ts3.4/index.d.ts +2 -0
  23. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/ts3.4/signature-v4-crt-container.d.ts +20 -0
  24. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/package.json +57 -0
  25. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/AdaptiveRetryStrategy.js +1 -0
  26. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/ConfiguredRetryStrategy.js +1 -0
  27. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/DefaultRateLimiter.js +1 -0
  28. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/StandardRetryStrategy.js +1 -0
  29. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/config.js +1 -0
  30. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/constants.js +1 -0
  31. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/defaultRetryBackoffStrategy.js +1 -0
  32. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/defaultRetryToken.js +1 -0
  33. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/index.js +358 -0
  34. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/types.js +1 -0
  35. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/AdaptiveRetryStrategy.js +24 -0
  36. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/ConfiguredRetryStrategy.js +18 -0
  37. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/DefaultRateLimiter.js +100 -0
  38. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/StandardRetryStrategy.js +65 -0
  39. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/config.js +7 -0
  40. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/constants.js +9 -0
  41. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/defaultRetryBackoffStrategy.js +14 -0
  42. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/defaultRetryToken.js +11 -0
  43. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/index.js +7 -0
  44. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/types.js +1 -0
  45. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/AdaptiveRetryStrategy.d.ts +33 -0
  46. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ConfiguredRetryStrategy.d.ts +32 -0
  47. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/DefaultRateLimiter.d.ts +49 -0
  48. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/StandardRetryStrategy.d.ts +26 -0
  49. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/config.d.ts +20 -0
  50. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/constants.d.ts +59 -0
  51. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/defaultRetryBackoffStrategy.d.ts +5 -0
  52. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/defaultRetryToken.d.ts +9 -0
  53. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/index.d.ts +7 -0
  54. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/AdaptiveRetryStrategy.d.ts +33 -0
  55. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/ConfiguredRetryStrategy.d.ts +32 -0
  56. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/DefaultRateLimiter.d.ts +49 -0
  57. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/StandardRetryStrategy.d.ts +26 -0
  58. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/config.d.ts +20 -0
  59. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/constants.d.ts +59 -0
  60. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/defaultRetryBackoffStrategy.d.ts +5 -0
  61. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/defaultRetryToken.d.ts +9 -0
  62. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/index.d.ts +7 -0
  63. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/types.d.ts +19 -0
  64. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/types.d.ts +19 -0
  65. package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/package.json +68 -0
  66. package/dist/node_modules/@aws-sdk/client-s3/package.json +1 -1
  67. package/dist/node_modules/xlsx/package.json +1 -1
  68. package/dist/server/ocr/tesseract-runner.js +3 -1
  69. package/dist/server/plugin.js +22 -4
  70. package/package.json +57 -45
  71. package/src/client/AIFilePreviewAction.tsx +282 -0
  72. package/src/client/__tests__/ocr-utils.test.ts +85 -0
  73. package/src/client/client.d.ts +258 -0
  74. package/src/client/index.tsx +1807 -0
  75. package/src/client/locale.ts +21 -0
  76. package/src/client-v2/index.tsx +1 -0
  77. package/src/client-v2/plugin.tsx +7 -0
  78. package/{dist/index.d.ts → src/index.ts} +11 -10
  79. package/src/locale/en-US.json +14 -0
  80. package/src/locale/vi-VN.json +14 -0
  81. package/src/locale/zh-CN.json +14 -0
  82. package/src/server/__tests__/smoke.test.ts +17 -0
  83. package/src/server/collections/attachment-ocr-results.ts +40 -0
  84. package/{dist/server/collections/file-preview-auth.d.ts → src/server/collections/file-preview-auth.ts} +15 -14
  85. package/src/server/excel-parser-handler.ts +128 -0
  86. package/{dist/server/index.d.ts → src/server/index.ts} +10 -9
  87. package/src/server/migrations/20260528000000-move-ocr-fields-out-of-attachments.ts +39 -0
  88. package/src/server/ocr/tesseract-runner.ts +389 -0
  89. package/src/server/ocr/tesseract-worker.ts +235 -0
  90. package/src/server/plugin.ts +1470 -0
  91. package/dist/client/166.17caa11c2ba40313.js +0 -10
  92. package/dist/client/351.0f0ce45c92425c8f.js +0 -10
  93. package/dist/client/374.96762d13b15e7467.js +0 -30
  94. package/dist/client/514.2a8b6aa0d2fcd4b2.js +0 -49
  95. package/dist/client/AIFilePreviewAction.d.ts +0 -42
  96. package/dist/client/index.d.ts +0 -14
  97. package/dist/client/locale.d.ts +0 -10
  98. package/dist/node_modules/xlsx/node_modules/.bin/crc32 +0 -15
  99. package/dist/node_modules/xlsx/node_modules/.bin/crc32.cmd +0 -7
  100. package/dist/server/collections/attachment-ocr-results.d.ts +0 -2
  101. package/dist/server/excel-parser-handler.d.ts +0 -60
  102. package/dist/server/migrations/20260528000000-move-ocr-fields-out-of-attachments.d.ts +0 -5
  103. package/dist/server/ocr/tesseract-runner.d.ts +0 -34
  104. package/dist/server/ocr/tesseract-worker.d.ts +0 -27
  105. package/dist/server/plugin.d.ts +0 -54
@@ -0,0 +1,1807 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
+ import {
12
+ DownloadOutlined,
13
+ LeftOutlined,
14
+ RightOutlined,
15
+ CopyOutlined,
16
+ SyncOutlined,
17
+ ClockCircleOutlined,
18
+ CheckCircleOutlined,
19
+ ScanOutlined,
20
+ ThunderboltOutlined,
21
+ EyeOutlined,
22
+ } from '@ant-design/icons';
23
+ import { Modal, Button, Spin, Alert, Space, message, Tabs, Tag } from 'antd';
24
+ import { Plugin, useAPIClient, attachmentFileTypes, matchMimetype, useComponent } from '@nocobase/client';
25
+ // @ts-ignore
26
+ import { filePreviewTypes } from '@nocobase/plugin-file-manager/client';
27
+ import { useT } from './locale';
28
+ import { AIFilePreviewAction, registerFilePreviewAIWorkContext } from './AIFilePreviewAction';
29
+
30
+ // ─── Supported MIME types ────────────────────────────────────────────
31
+
32
+ const PDF_MIME_TYPES = ['application/pdf'];
33
+ const IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp'];
34
+ const TEXT_MIME_TYPES = [
35
+ 'text/plain',
36
+ 'text/csv',
37
+ 'text/html',
38
+ 'text/css',
39
+ 'text/javascript',
40
+ 'application/json',
41
+ 'application/xml',
42
+ 'text/xml',
43
+ 'text/yaml',
44
+ 'application/x-yaml',
45
+ ];
46
+ const DOCX_MIME_TYPES = ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
47
+ const XLSX_MIME_TYPES = [
48
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
49
+ 'application/vnd.ms-excel',
50
+ ];
51
+
52
+ const PDF_EXTS = ['pdf'];
53
+ const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
54
+ const TEXT_EXTS = ['txt', 'csv', 'html', 'css', 'js', 'json', 'xml', 'log', 'md', 'yaml', 'yml', 'xaml'];
55
+ const DOCX_EXTS = ['docx'];
56
+ const XLSX_EXTS = ['xlsx', 'xls'];
57
+ const PPTX_MIME_TYPES = [
58
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
59
+ 'application/vnd.ms-powerpoint',
60
+ ];
61
+ const PPTX_EXTS = ['pptx', 'ppt'];
62
+ const IMAGE_PLACEHOLDER_ICON_MAP: Record<string, string> = {
63
+ png: 'png-200-200.png',
64
+ jpg: 'jpeg-200-200.png',
65
+ jpeg: 'jpeg-200-200.png',
66
+ gif: 'gif-200-200.png',
67
+ webp: 'png-200-200.png',
68
+ bmp: 'png-200-200.png',
69
+ svg: 'svg-200-200.png',
70
+ };
71
+ const MIME_IMAGE_PLACEHOLDER_ICON_MAP: Record<string, string> = {
72
+ 'image/png': IMAGE_PLACEHOLDER_ICON_MAP.png,
73
+ 'image/jpeg': IMAGE_PLACEHOLDER_ICON_MAP.jpeg,
74
+ 'image/jpg': IMAGE_PLACEHOLDER_ICON_MAP.jpeg,
75
+ 'image/gif': IMAGE_PLACEHOLDER_ICON_MAP.gif,
76
+ 'image/webp': IMAGE_PLACEHOLDER_ICON_MAP.webp,
77
+ 'image/bmp': IMAGE_PLACEHOLDER_ICON_MAP.bmp,
78
+ 'image/svg+xml': IMAGE_PLACEHOLDER_ICON_MAP.svg,
79
+ };
80
+
81
+ // ─── Utility functions ──────────────────────────────────────────────
82
+
83
+ function getPreviewFileRecord(file: any) {
84
+ if (!file || typeof file === 'string') {
85
+ return file;
86
+ }
87
+
88
+ const response = file.response;
89
+ if (!response || typeof response !== 'object') {
90
+ return file;
91
+ }
92
+
93
+ return {
94
+ ...response,
95
+ ...file,
96
+ id: file.id ?? response.id,
97
+ uid: file.uid ?? response.uid,
98
+ url: file.url ?? response.url,
99
+ preview: file.preview || response.preview,
100
+ filename: file.filename ?? response.filename,
101
+ name: file.name ?? response.name,
102
+ title: file.title ?? response.title,
103
+ extname: file.extname ?? response.extname,
104
+ mimetype: file.mimetype ?? response.mimetype,
105
+ size: file.size ?? response.size,
106
+ path: file.path ?? response.path,
107
+ storageId: file.storageId ?? response.storageId,
108
+ storageType: file.storageType ?? response.storageType ?? file.storage?.type ?? response.storage?.type,
109
+ storageName: file.storageName ?? response.storageName ?? file.storage?.name ?? response.storage?.name,
110
+ storage: file.storage ?? response.storage,
111
+ collectionName: file.collectionName ?? response.collectionName,
112
+ lastModified: file.lastModified ?? response.lastModified,
113
+ };
114
+ }
115
+
116
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
117
+ return value !== null && typeof value === 'object';
118
+ }
119
+
120
+ export function normalizeOcrAttachmentId(value: unknown): string | number | null {
121
+ if (typeof value === 'number') {
122
+ return Number.isInteger(value) && value > 0 ? value : null;
123
+ }
124
+
125
+ if (typeof value === 'string') {
126
+ const trimmed = value.trim();
127
+ return /^\d+$/.test(trimmed) ? trimmed : null;
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ export function getOcrAttachmentId(file: unknown): string | number | null {
134
+ const inputRecord = isObjectRecord(file) ? file : {};
135
+ const responseRecord = isObjectRecord(inputRecord.response) ? inputRecord.response : {};
136
+ const previewRecord = getPreviewFileRecord(file);
137
+ const normalizedPreviewRecord = isObjectRecord(previewRecord) ? previewRecord : {};
138
+ const candidates = [
139
+ normalizedPreviewRecord.attachmentId,
140
+ normalizedPreviewRecord.id,
141
+ responseRecord.attachmentId,
142
+ responseRecord.id,
143
+ inputRecord.attachmentId,
144
+ inputRecord.id,
145
+ normalizedPreviewRecord.uid,
146
+ responseRecord.uid,
147
+ inputRecord.uid,
148
+ ];
149
+
150
+ for (const candidate of candidates) {
151
+ const attachmentId = normalizeOcrAttachmentId(candidate);
152
+ if (attachmentId != null) {
153
+ return attachmentId;
154
+ }
155
+ }
156
+
157
+ return null;
158
+ }
159
+
160
+ const getFileExt = (file: any): string => {
161
+ const record = getPreviewFileRecord(file);
162
+ const value =
163
+ typeof record === 'string' ? record : record?.extname || record?.name || record?.filename || record?.url || '';
164
+ const clean = value.split('?')[0].split('#')[0];
165
+ const index = clean.lastIndexOf('.');
166
+ return index !== -1
167
+ ? clean
168
+ .slice(index + 1)
169
+ .toLowerCase()
170
+ .replace(/^\./, '')
171
+ : '';
172
+ };
173
+
174
+ const resolveFileUrl = (file: any): string => {
175
+ const record = getPreviewFileRecord(file);
176
+ const url = typeof record === 'string' ? record : record?.url || record?.preview || record?.path;
177
+ if (!url) {
178
+ return '';
179
+ }
180
+ return url.startsWith('https://') || url.startsWith('http://') ? url : `${location.origin}/${url.replace(/^\//, '')}`;
181
+ };
182
+
183
+ const getFileDependencyKey = (file: any): string => {
184
+ if (typeof file === 'string') return file;
185
+ const record = getPreviewFileRecord(file);
186
+ if (!record) return '';
187
+ return [
188
+ record.id,
189
+ record.uid,
190
+ record.url,
191
+ record.preview,
192
+ record.path,
193
+ record.storageId,
194
+ record.storageType,
195
+ record.storageName,
196
+ record.collectionName,
197
+ record.lastModified,
198
+ record.size,
199
+ ]
200
+ .filter((value) => value != null && value !== '')
201
+ .join(':');
202
+ };
203
+
204
+ const isPdfFile = (file: any): boolean => {
205
+ const record = getPreviewFileRecord(file);
206
+ if (record?.mimetype && PDF_MIME_TYPES.includes(record.mimetype)) return true;
207
+ const ext = getFileExt(file);
208
+ return !!ext && PDF_EXTS.includes(ext);
209
+ };
210
+
211
+ const isImageFile = (file: any): boolean => {
212
+ const record = getPreviewFileRecord(file);
213
+ if (record?.mimetype && IMAGE_MIME_TYPES.includes(record.mimetype)) return true;
214
+ if (record?.mimetype && matchMimetype(record, 'image/*')) return true;
215
+ const ext = getFileExt(file);
216
+ return !!ext && IMAGE_EXTS.includes(ext);
217
+ };
218
+
219
+ const isTextFile = (file: any): boolean => {
220
+ const record = getPreviewFileRecord(file);
221
+ if (record?.mimetype && TEXT_MIME_TYPES.includes(record.mimetype)) return true;
222
+ const ext = getFileExt(file);
223
+ return !!ext && TEXT_EXTS.includes(ext);
224
+ };
225
+
226
+ const isDocxFile = (file: any): boolean => {
227
+ const record = getPreviewFileRecord(file);
228
+ if (record?.mimetype && DOCX_MIME_TYPES.includes(record.mimetype)) return true;
229
+ const ext = getFileExt(file);
230
+ return !!ext && DOCX_EXTS.includes(ext);
231
+ };
232
+
233
+ const isXlsxFile = (file: any): boolean => {
234
+ const record = getPreviewFileRecord(file);
235
+ if (record?.mimetype && XLSX_MIME_TYPES.includes(record.mimetype)) return true;
236
+ const ext = getFileExt(file);
237
+ return !!ext && XLSX_EXTS.includes(ext);
238
+ };
239
+
240
+ const isPptxFile = (file: any): boolean => {
241
+ const record = getPreviewFileRecord(file);
242
+ if (record?.mimetype && PPTX_MIME_TYPES.includes(record.mimetype)) return true;
243
+ const ext = getFileExt(file);
244
+ return !!ext && PPTX_EXTS.includes(ext);
245
+ };
246
+
247
+ const isPreviewableFile = (file: any): boolean => {
248
+ return (
249
+ isPdfFile(file) || isImageFile(file) || isTextFile(file) || isDocxFile(file) || isXlsxFile(file) || isPptxFile(file)
250
+ );
251
+ };
252
+
253
+ export function isOcrCompleteStatus(status?: string): boolean {
254
+ return ['waiting-verify', 'success', 'verified', 'accepted', 'rejected'].includes(status || '');
255
+ }
256
+
257
+ type OcrStatusRecord = {
258
+ id?: string | number | null;
259
+ attachmentId?: string | number | null;
260
+ status?: string;
261
+ error?: string | null;
262
+ };
263
+
264
+ function isOcrStatusRecord(value: unknown): value is OcrStatusRecord {
265
+ if (!isObjectRecord(value)) return false;
266
+ return (
267
+ typeof value.status === 'string' ||
268
+ normalizeOcrAttachmentId(value.attachmentId) != null ||
269
+ normalizeOcrAttachmentId(value.id) != null
270
+ );
271
+ }
272
+
273
+ export function extractOcrStatusRecord(response: unknown): OcrStatusRecord | null {
274
+ let current = response;
275
+ for (let depth = 0; depth < 5; depth += 1) {
276
+ if (isOcrStatusRecord(current)) {
277
+ return current;
278
+ }
279
+ if (!isObjectRecord(current)) {
280
+ return null;
281
+ }
282
+ current = current.data;
283
+ }
284
+ return null;
285
+ }
286
+
287
+ const getFileDisplayName = (file: any): string => {
288
+ const record = getPreviewFileRecord(file);
289
+ if (!record) return 'download';
290
+ if (record.title && record.extname) return `${record.title}${record.extname}`;
291
+ return record.filename || record.name || record.title || 'download';
292
+ };
293
+
294
+ const loadedBlobCache = new Map<string, Promise<Blob>>();
295
+
296
+ function getBlobCacheKey(file: any, token: string, downloadUrl: string): string {
297
+ return `${token ? 'auth' : 'anon'}:${token || ''}:${getFileDependencyKey(file) || downloadUrl}`;
298
+ }
299
+
300
+ function normalizeFileForServer(file: any) {
301
+ const record = getPreviewFileRecord(file);
302
+ return {
303
+ id: record?.id,
304
+ uid: record?.uid,
305
+ url: record?.url,
306
+ preview: record?.preview,
307
+ filename: record?.filename || record?.name,
308
+ name: record?.name || record?.filename,
309
+ title: record?.title,
310
+ extname: record?.extname,
311
+ mimetype: record?.mimetype,
312
+ size: record?.size,
313
+ path: record?.path,
314
+ storageId: record?.storageId ?? record?.storage_id ?? record?.storage?.id,
315
+ storage_id: record?.storage_id,
316
+ storageType: record?.storageType || record?.storage?.type,
317
+ storageName: record?.storageName || record?.storage?.name,
318
+ storage: record?.storage,
319
+ collectionName: record?.collectionName,
320
+ lastModified: record?.lastModified,
321
+ };
322
+ }
323
+
324
+ function isInternalAuthenticatedDownloadUrl(url: string): boolean {
325
+ if (!url) return false;
326
+ try {
327
+ const parsed = new URL(url, location.origin);
328
+ if (parsed.origin !== location.origin) {
329
+ return false;
330
+ }
331
+ return [
332
+ '/api/filePreviewAuth:download',
333
+ '/api/extStorage:download',
334
+ '/api/skillHub:download',
335
+ '/api/worker-monitor',
336
+ '/api/carboneTemplates:download',
337
+ '/api/attachments:stream',
338
+ '/api/attachments:sftpStream',
339
+ ].some(
340
+ (path) =>
341
+ parsed.pathname === path || parsed.pathname.startsWith(`${path}/`) || parsed.pathname.startsWith(`${path}:`),
342
+ );
343
+ } catch {
344
+ return false;
345
+ }
346
+ }
347
+
348
+ function getPublicAssetUrl(path: string): string {
349
+ const publicPath =
350
+ typeof window === 'undefined'
351
+ ? '/'
352
+ : window['__nocobase_dev_public_path__'] || window['__nocobase_public_path__'] || '/';
353
+ return `${publicPath.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`;
354
+ }
355
+
356
+ function getImageThumbnailPlaceholderUrl(file: any): string {
357
+ const record = getPreviewFileRecord(file);
358
+ const ext = getFileExt(record);
359
+ const mimetype = typeof record?.mimetype === 'string' ? record.mimetype.toLowerCase() : '';
360
+ const icon = IMAGE_PLACEHOLDER_ICON_MAP[ext] || MIME_IMAGE_PLACEHOLDER_ICON_MAP[mimetype] || 'unknown-200-200.png';
361
+ return getPublicAssetUrl(`file-placeholder/${icon}`);
362
+ }
363
+
364
+ function getSafeImageThumbnailUrl(file: any): string {
365
+ const record = getPreviewFileRecord(file);
366
+ const thumbnailUrl = [record?.preview, record?.url].find((url) => typeof url === 'string' && url);
367
+ if (thumbnailUrl && !isInternalAuthenticatedDownloadUrl(thumbnailUrl)) {
368
+ return thumbnailUrl;
369
+ }
370
+ return getImageThumbnailPlaceholderUrl(record);
371
+ }
372
+
373
+ function isS3PrivateFile(file: any): boolean {
374
+ const normalized = normalizeFileForServer(typeof file === 'string' ? { url: file } : file || {});
375
+ const storageType = normalized.storageType || normalized.storage?.type;
376
+ return storageType === 's3-private' || storageType === 'aws-s3-private';
377
+ }
378
+
379
+ function isAttachmentStreamCandidate(file: any, sourceUrl: string): boolean {
380
+ const normalized = normalizeFileForServer(typeof file === 'string' ? { url: file } : file || {});
381
+ const collection = normalized.collectionName || 'attachments';
382
+ const id = normalized.id || normalized.uid;
383
+ if (id == null) {
384
+ return false;
385
+ }
386
+
387
+ if (isS3PrivateFile(file)) {
388
+ return true;
389
+ }
390
+
391
+ return collection === 'attachments' && !sourceUrl;
392
+ }
393
+
394
+ function buildAttachmentStreamUrl(file: any, mode: 'inline' | 'attachment'): string {
395
+ const normalized = normalizeFileForServer(typeof file === 'string' ? { url: file } : file || {});
396
+ const id = normalized.id || normalized.uid;
397
+ if (id == null) {
398
+ return '';
399
+ }
400
+
401
+ const params = new URLSearchParams({
402
+ filterByTk: String(id),
403
+ mode,
404
+ collection: normalized.collectionName || 'attachments',
405
+ });
406
+ return `/api/attachments:stream?${params.toString()}`;
407
+ }
408
+
409
+ function buildAuthenticatedDownloadUrl(file: any, mode: 'inline' | 'attachment' = 'inline'): string {
410
+ const normalized = normalizeFileForServer(typeof file === 'string' ? { url: file } : file || {});
411
+ const sourceUrl = resolveFileUrl(file);
412
+ if (sourceUrl && isInternalAuthenticatedDownloadUrl(sourceUrl)) {
413
+ return sourceUrl;
414
+ }
415
+
416
+ if (isAttachmentStreamCandidate(file, sourceUrl)) {
417
+ return buildAttachmentStreamUrl(file, mode);
418
+ }
419
+
420
+ const id = normalized.id || normalized.uid;
421
+ if (id != null) {
422
+ const collection = normalized.collectionName || 'attachments';
423
+ const params = new URLSearchParams();
424
+ params.set('id', String(id));
425
+ params.set('collection', collection);
426
+ if (sourceUrl) {
427
+ params.set('url', sourceUrl);
428
+ }
429
+ const storageId = normalized.storageId || normalized.storage_id || normalized.storage?.id;
430
+ if (storageId != null && storageId !== '') {
431
+ params.set('storageId', String(storageId));
432
+ }
433
+ if (normalized.filename || normalized.name) {
434
+ params.set('filename', normalized.filename || normalized.name);
435
+ }
436
+ if (normalized.mimetype) {
437
+ params.set('mimetype', normalized.mimetype);
438
+ }
439
+ return `/api/filePreviewAuth:download?${params.toString()}`;
440
+ }
441
+
442
+ return sourceUrl || '';
443
+ }
444
+
445
+ // ─── fetchFileAsBlob: fetch with Bearer auth ────────────────────────
446
+
447
+ async function fetchFileAsBlob(url: string, token: string): Promise<Blob> {
448
+ const headers: Record<string, string> = {};
449
+ if (token) {
450
+ headers['Authorization'] = `Bearer ${token}`;
451
+ }
452
+ const response = await fetch(url, {
453
+ method: 'GET',
454
+ headers,
455
+ credentials: 'include',
456
+ });
457
+ if (!response.ok) {
458
+ throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
459
+ }
460
+ return response.blob();
461
+ }
462
+
463
+ async function getLoadedFileBlob(file: any, token: string, mode: 'inline' | 'attachment' = 'inline'): Promise<Blob> {
464
+ const downloadUrl = buildAuthenticatedDownloadUrl(file, mode);
465
+ if (!downloadUrl || downloadUrl.endsWith('?')) {
466
+ throw new Error('No file URL');
467
+ }
468
+ const cacheKey = getBlobCacheKey(file, token, downloadUrl);
469
+ let pending = loadedBlobCache.get(cacheKey);
470
+ if (!pending) {
471
+ pending = fetchFileAsBlob(downloadUrl, token).catch((err) => {
472
+ loadedBlobCache.delete(cacheKey);
473
+ throw err;
474
+ });
475
+ loadedBlobCache.set(cacheKey, pending);
476
+ }
477
+ return pending;
478
+ }
479
+
480
+ // ─── Authenticated download helper ─────────────────────────────────
481
+
482
+ async function downloadFileWithAuth(file: any, token: string): Promise<void> {
483
+ const blob = await getLoadedFileBlob(file, token, 'attachment');
484
+ const fileName = getFileDisplayName(file);
485
+ const a = document.createElement('a');
486
+ const objectUrl = URL.createObjectURL(blob);
487
+ a.href = objectUrl;
488
+ a.download = fileName;
489
+ document.body.appendChild(a);
490
+ a.click();
491
+ document.body.removeChild(a);
492
+ // Small delay before revoking to ensure download starts
493
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
494
+ }
495
+
496
+ // ─── useBlobUrl hook ────────────────────────────────────────────────
497
+
498
+ function useBlobUrl(file: any, token: string) {
499
+ const [blobUrl, setBlobUrl] = useState<string | null>(null);
500
+ const [loading, setLoading] = useState(true);
501
+ const [error, setError] = useState<string | null>(null);
502
+ const blobUrlRef = useRef<string | null>(null);
503
+ const fileRef = useRef(file);
504
+ fileRef.current = file;
505
+ const fileDependencyKey = getFileDependencyKey(file);
506
+
507
+ useEffect(() => {
508
+ let cancelled = false;
509
+ const currentFile = fileRef.current;
510
+ const hasSource = !!(
511
+ resolveFileUrl(currentFile) ||
512
+ (typeof currentFile !== 'string' && (currentFile?.id || currentFile?.uid))
513
+ );
514
+ if (!hasSource) {
515
+ setLoading(false);
516
+ setError('No file URL');
517
+ return;
518
+ }
519
+
520
+ setLoading(true);
521
+ setError(null);
522
+
523
+ getLoadedFileBlob(currentFile, token)
524
+ .then((blob) => {
525
+ if (cancelled) return;
526
+ const objectUrl = URL.createObjectURL(blob);
527
+ blobUrlRef.current = objectUrl;
528
+ setBlobUrl(objectUrl);
529
+ setLoading(false);
530
+ })
531
+ .catch((err) => {
532
+ if (cancelled) return;
533
+ setError(err.message || 'Failed to load');
534
+ setLoading(false);
535
+ });
536
+
537
+ return () => {
538
+ cancelled = true;
539
+ if (blobUrlRef.current) {
540
+ URL.revokeObjectURL(blobUrlRef.current);
541
+ blobUrlRef.current = null;
542
+ }
543
+ };
544
+ }, [fileDependencyKey, token]);
545
+
546
+ return { blobUrl, loading, error };
547
+ }
548
+
549
+ // ─── useTextContent hook ────────────────────────────────────────────
550
+
551
+ function useTextContent(file: any, token: string) {
552
+ const [text, setText] = useState<string | null>(null);
553
+ const [loading, setLoading] = useState(true);
554
+ const [error, setError] = useState<string | null>(null);
555
+ const fileRef = useRef(file);
556
+ fileRef.current = file;
557
+ const fileDependencyKey = getFileDependencyKey(file);
558
+
559
+ useEffect(() => {
560
+ let cancelled = false;
561
+ const currentFile = fileRef.current;
562
+ const hasSource = !!(
563
+ resolveFileUrl(currentFile) ||
564
+ (typeof currentFile !== 'string' && (currentFile?.id || currentFile?.uid))
565
+ );
566
+ if (!hasSource) {
567
+ setLoading(false);
568
+ setError('No file URL');
569
+ return;
570
+ }
571
+
572
+ setLoading(true);
573
+ setError(null);
574
+
575
+ getLoadedFileBlob(currentFile, token)
576
+ .then((blob) => blob.text())
577
+ .then((content) => {
578
+ if (cancelled) return;
579
+ setText(content);
580
+ setLoading(false);
581
+ })
582
+ .catch((err) => {
583
+ if (cancelled) return;
584
+ setError(err.message || 'Failed to load');
585
+ setLoading(false);
586
+ });
587
+
588
+ return () => {
589
+ cancelled = true;
590
+ };
591
+ }, [fileDependencyKey, token]);
592
+
593
+ return { text, loading, error };
594
+ }
595
+
596
+ // ─── Loading / Error shared components ──────────────────────────────
597
+
598
+ function LoadingIndicator({ message: msg }: { message: string }) {
599
+ return (
600
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', width: '100%' }}>
601
+ <Spin tip={msg} />
602
+ </div>
603
+ );
604
+ }
605
+
606
+ function ErrorMessage({ message: msg }: { message: string }) {
607
+ return <div style={{ padding: 20, textAlign: 'center', color: '#ff4d4f' }}>{msg}</div>;
608
+ }
609
+
610
+ function PreviewModalTitle({
611
+ file,
612
+ title,
613
+ ocrStatus,
614
+ isOcrSupported,
615
+ }: {
616
+ file: any;
617
+ title: string;
618
+ ocrStatus?: string;
619
+ isOcrSupported?: boolean;
620
+ }) {
621
+ const t = useT();
622
+ const renderOcrTag = () => {
623
+ if (!isOcrSupported || !ocrStatus) return null;
624
+ switch (ocrStatus) {
625
+ case 'pending-ocr':
626
+ return (
627
+ <Tag color="processing" icon={<SyncOutlined spin />}>
628
+ {t('OCR Pending')}
629
+ </Tag>
630
+ );
631
+ case 'waiting-verify':
632
+ case 'success':
633
+ return (
634
+ <Tag color="warning" icon={<ClockCircleOutlined />}>
635
+ {t('Waiting Verify')}
636
+ </Tag>
637
+ );
638
+ case 'verified':
639
+ case 'accepted':
640
+ return (
641
+ <Tag color="success" icon={<CheckCircleOutlined />}>
642
+ {t('OCR Verified')}
643
+ </Tag>
644
+ );
645
+ case 'rejected':
646
+ return <Tag color="error">{t('OCR Rejected')}</Tag>;
647
+ case 'failed':
648
+ return <Tag color="error">{t('OCR Failed')}</Tag>;
649
+ case 'no-ocr':
650
+ default:
651
+ return <Tag color="default">{t('No OCR')}</Tag>;
652
+ }
653
+ };
654
+
655
+ return (
656
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, paddingRight: 40 }}>
657
+ <Space size={8} style={{ minWidth: 0, overflow: 'hidden' }}>
658
+ <span
659
+ style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 600 }}
660
+ title={title}
661
+ >
662
+ {title}
663
+ </span>
664
+ {renderOcrTag()}
665
+ </Space>
666
+ <AIFilePreviewAction file={file} />
667
+ </div>
668
+ );
669
+ }
670
+
671
+ // ─── Inline Previewers (used inside modals) ─────────────────────────
672
+
673
+ function AuthPdfInlinePreviewer({ file }: any) {
674
+ const apiClient = useAPIClient();
675
+ const t = useT();
676
+ const token = apiClient.auth?.token || '';
677
+ const { blobUrl, loading, error } = useBlobUrl(file, token);
678
+
679
+ if (loading) return <LoadingIndicator message={t('Loading preview...')} />;
680
+ if (error || !blobUrl) return <ErrorMessage message={t('Failed to load file preview')} />;
681
+ return <iframe src={blobUrl} width="100%" height="100%" style={{ border: 'none' }} />;
682
+ }
683
+
684
+ function AuthImageInlinePreviewer({ file }: any) {
685
+ const apiClient = useAPIClient();
686
+ const t = useT();
687
+ const token = apiClient.auth?.token || '';
688
+ const { blobUrl, loading, error } = useBlobUrl(file, token);
689
+
690
+ if (loading) return <LoadingIndicator message={t('Loading preview...')} />;
691
+ if (error || !blobUrl) return <ErrorMessage message={t('Failed to load file preview')} />;
692
+ return (
693
+ <img
694
+ src={blobUrl}
695
+ style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
696
+ alt={file?.title || file?.filename || ''}
697
+ />
698
+ );
699
+ }
700
+
701
+ function AuthTextInlinePreviewer({ file }: any) {
702
+ const apiClient = useAPIClient();
703
+ const t = useT();
704
+ const token = apiClient.auth?.token || '';
705
+ const { text, loading, error } = useTextContent(file, token);
706
+
707
+ if (loading) return <LoadingIndicator message={t('Loading preview...')} />;
708
+ if (error || text === null) return <ErrorMessage message={t('Failed to load file preview')} />;
709
+ return (
710
+ <pre
711
+ style={{
712
+ width: '100%',
713
+ height: '100%',
714
+ overflow: 'auto',
715
+ padding: 16,
716
+ margin: 0,
717
+ fontSize: 13,
718
+ lineHeight: 1.6,
719
+ whiteSpace: 'pre-wrap',
720
+ wordWrap: 'break-word',
721
+ background: '#f5f5f5',
722
+ border: 'none',
723
+ }}
724
+ >
725
+ {text}
726
+ </pre>
727
+ );
728
+ }
729
+
730
+ function AuthDocxInlinePreviewer({ file }: any) {
731
+ const apiClient = useAPIClient();
732
+ const t = useT();
733
+ const token = apiClient.auth?.token || '';
734
+ const containerRef = useRef<HTMLDivElement>(null);
735
+ const fileRef = useRef(file);
736
+ fileRef.current = file;
737
+ const fileDependencyKey = getFileDependencyKey(file);
738
+ const [loading, setLoading] = useState(true);
739
+ const [error, setError] = useState<string | null>(null);
740
+
741
+ useEffect(() => {
742
+ let cancelled = false;
743
+ const currentFile = fileRef.current;
744
+ const hasSource = !!(
745
+ resolveFileUrl(currentFile) ||
746
+ (typeof currentFile !== 'string' && (currentFile?.id || currentFile?.uid))
747
+ );
748
+ if (!hasSource || !containerRef.current) {
749
+ setLoading(false);
750
+ setError('No file URL');
751
+ return;
752
+ }
753
+
754
+ setLoading(true);
755
+ setError(null);
756
+
757
+ (async () => {
758
+ try {
759
+ const blob = await getLoadedFileBlob(currentFile, token);
760
+ if (cancelled) return;
761
+ // Dynamic import for code-splitting (bundled, no CDN needed)
762
+ // @ts-ignore
763
+ const docxPreview = await import('docx-preview');
764
+ if (cancelled || !containerRef.current) return;
765
+ containerRef.current.innerHTML = '';
766
+ await docxPreview.renderAsync(blob, containerRef.current, undefined, {
767
+ className: 'docx-preview-wrapper',
768
+ inWrapper: true,
769
+ ignoreWidth: false,
770
+ ignoreHeight: false,
771
+ ignoreFonts: false,
772
+ breakPages: true,
773
+ ignoreLastRenderedPageBreak: true,
774
+ experimental: false,
775
+ trimXmlDeclaration: true,
776
+ useBase64URL: true,
777
+ renderHeaders: true,
778
+ renderFooters: true,
779
+ renderFootnotes: true,
780
+ renderEndnotes: true,
781
+ });
782
+ setLoading(false);
783
+ } catch (err: any) {
784
+ if (cancelled) return;
785
+ setError(err.message || 'Failed to render DOCX');
786
+ setLoading(false);
787
+ }
788
+ })();
789
+
790
+ return () => {
791
+ cancelled = true;
792
+ };
793
+ }, [fileDependencyKey, token]);
794
+
795
+ return (
796
+ <div style={{ width: '100%', height: '100%', position: 'relative' }}>
797
+ {loading && <LoadingIndicator message={t('Loading preview...')} />}
798
+ {error && <ErrorMessage message={t('Failed to load file preview')} />}
799
+ <div
800
+ ref={containerRef}
801
+ style={{
802
+ width: '100%',
803
+ height: '100%',
804
+ overflow: 'auto',
805
+ display: loading || error ? 'none' : 'block',
806
+ }}
807
+ />
808
+ </div>
809
+ );
810
+ }
811
+
812
+ function AuthXlsxInlinePreviewer({ file }: any) {
813
+ const apiClient = useAPIClient();
814
+ const t = useT();
815
+ const token = apiClient.auth?.token || '';
816
+ const containerRef = useRef<HTMLDivElement>(null);
817
+ const fileRef = useRef(file);
818
+ fileRef.current = file;
819
+ const fileDependencyKey = getFileDependencyKey(file);
820
+ const [loading, setLoading] = useState(true);
821
+ const [error, setError] = useState<string | null>(null);
822
+ const [sheetNames, setSheetNames] = useState<string[]>([]);
823
+ const [activeSheet, setActiveSheet] = useState<string>('');
824
+ const [sheetsHtml, setSheetsHtml] = useState<Record<string, string>>({});
825
+
826
+ useEffect(() => {
827
+ let cancelled = false;
828
+ const currentFile = fileRef.current;
829
+ const hasSource = !!(
830
+ resolveFileUrl(currentFile) ||
831
+ (typeof currentFile !== 'string' && (currentFile?.id || currentFile?.uid))
832
+ );
833
+ if (!hasSource) {
834
+ setLoading(false);
835
+ setError('No file URL');
836
+ return;
837
+ }
838
+
839
+ setLoading(true);
840
+ setError(null);
841
+
842
+ (async () => {
843
+ try {
844
+ const blob = await getLoadedFileBlob(currentFile, token);
845
+ if (cancelled) return;
846
+ // Dynamic import for code-splitting (bundled, no CDN needed)
847
+ // @ts-ignore
848
+ const XLSX = await import('xlsx');
849
+ if (cancelled) return;
850
+ const arrayBuffer = await blob.arrayBuffer();
851
+ if (cancelled) return;
852
+ const workbook = XLSX.read(arrayBuffer, { type: 'array' });
853
+ const names = workbook.SheetNames as string[];
854
+ const htmlMap: Record<string, string> = {};
855
+ for (const name of names) {
856
+ const sheet = workbook.Sheets[name];
857
+ htmlMap[name] = XLSX.utils.sheet_to_html(sheet, { id: 'xlsx-preview-table' });
858
+ }
859
+ if (cancelled) return;
860
+ setSheetNames(names);
861
+ setActiveSheet(names[0] || '');
862
+ setSheetsHtml(htmlMap);
863
+ setLoading(false);
864
+ } catch (err: any) {
865
+ if (cancelled) return;
866
+ setError(err.message || 'Failed to render XLSX');
867
+ setLoading(false);
868
+ }
869
+ })();
870
+
871
+ return () => {
872
+ cancelled = true;
873
+ };
874
+ }, [fileDependencyKey, token]);
875
+
876
+ return (
877
+ <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
878
+ {loading && <LoadingIndicator message={t('Loading preview...')} />}
879
+ {error && <ErrorMessage message={t('Failed to load file preview')} />}
880
+ {!loading && !error && (
881
+ <>
882
+ {sheetNames.length > 1 && (
883
+ <div
884
+ style={{
885
+ display: 'flex',
886
+ gap: 0,
887
+ borderBottom: '1px solid #e8e8e8',
888
+ background: '#fafafa',
889
+ padding: '0 8px',
890
+ flexShrink: 0,
891
+ overflowX: 'auto',
892
+ }}
893
+ >
894
+ {sheetNames.map((name) => (
895
+ <button
896
+ key={name}
897
+ onClick={() => setActiveSheet(name)}
898
+ style={{
899
+ padding: '8px 16px',
900
+ border: 'none',
901
+ borderBottom: activeSheet === name ? '2px solid #1890ff' : '2px solid transparent',
902
+ background: activeSheet === name ? '#fff' : 'transparent',
903
+ color: activeSheet === name ? '#1890ff' : '#666',
904
+ fontWeight: activeSheet === name ? 600 : 400,
905
+ cursor: 'pointer',
906
+ fontSize: 13,
907
+ whiteSpace: 'nowrap',
908
+ transition: 'all 0.2s',
909
+ }}
910
+ >
911
+ {name}
912
+ </button>
913
+ ))}
914
+ </div>
915
+ )}
916
+ <div
917
+ ref={containerRef}
918
+ style={{ flex: 1, overflow: 'auto', padding: 0 }}
919
+ dangerouslySetInnerHTML={{ __html: sheetsHtml[activeSheet] || '' }}
920
+ />
921
+ <style>{`
922
+ #xlsx-preview-table {
923
+ border-collapse: collapse;
924
+ width: 100%;
925
+ font-size: 13px;
926
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
927
+ }
928
+ #xlsx-preview-table td,
929
+ #xlsx-preview-table th {
930
+ border: 1px solid #e8e8e8;
931
+ padding: 6px 10px;
932
+ text-align: left;
933
+ max-width: 33vw;
934
+ white-space: normal;
935
+ word-break: break-word;
936
+ }
937
+ #xlsx-preview-table tr:first-child td,
938
+ #xlsx-preview-table tr:first-child th {
939
+ background: #fafafa;
940
+ font-weight: 600;
941
+ position: sticky;
942
+ top: 0;
943
+ z-index: 1;
944
+ }
945
+ #xlsx-preview-table tr:nth-child(even) {
946
+ background: #fafafa;
947
+ }
948
+ #xlsx-preview-table tr:hover {
949
+ background: #f0f7ff;
950
+ }
951
+ `}</style>
952
+ </>
953
+ )}
954
+ </div>
955
+ );
956
+ }
957
+
958
+ function AuthPptxInlinePreviewer({ file }: any) {
959
+ const apiClient = useAPIClient();
960
+ const t = useT();
961
+ const token = apiClient.auth?.token || '';
962
+ const [loading, setLoading] = useState(true);
963
+ const [error, setError] = useState<string | null>(null);
964
+ const [fileBlob, setFileBlob] = useState<Blob | null>(null);
965
+ const [PptxPreviewer, setPptxPreviewer] = useState<any>(null);
966
+ const fileRef = useRef(file);
967
+ fileRef.current = file;
968
+ const fileDependencyKey = getFileDependencyKey(file);
969
+
970
+ useEffect(() => {
971
+ let cancelled = false;
972
+ const currentFile = fileRef.current;
973
+ const hasSource = !!(
974
+ resolveFileUrl(currentFile) ||
975
+ (typeof currentFile !== 'string' && (currentFile?.id || currentFile?.uid))
976
+ );
977
+ if (!hasSource) {
978
+ setLoading(false);
979
+ setError('No file URL');
980
+ return;
981
+ }
982
+
983
+ setLoading(true);
984
+ setError(null);
985
+
986
+ (async () => {
987
+ try {
988
+ const [blob, module] = await Promise.all([
989
+ getLoadedFileBlob(currentFile, token),
990
+ // @ts-ignore
991
+ import('react-pptx-preview-kit'),
992
+ ]);
993
+ if (cancelled) return;
994
+ setFileBlob(blob);
995
+ setPptxPreviewer(() => module.PptxPreview);
996
+ setLoading(false);
997
+ } catch (err: any) {
998
+ if (cancelled) return;
999
+ setError(err.message || 'Failed to render PPTX');
1000
+ setLoading(false);
1001
+ }
1002
+ })();
1003
+
1004
+ return () => {
1005
+ cancelled = true;
1006
+ };
1007
+ }, [fileDependencyKey, token]);
1008
+
1009
+ return (
1010
+ <div style={{ width: '100%', height: '100%', position: 'relative' }}>
1011
+ {loading && <LoadingIndicator message={t('Loading preview...')} />}
1012
+ {error && <ErrorMessage message={t('Failed to load file preview')} />}
1013
+ {!loading && !error && PptxPreviewer && (
1014
+ <div style={{ width: '100%', height: '100%', overflow: 'auto' }}>
1015
+ <PptxPreviewer file={fileBlob} />
1016
+ </div>
1017
+ )}
1018
+ </div>
1019
+ );
1020
+ }
1021
+
1022
+ // ─── wrapWithAuthModalPreviewer ─────────────────────────────────────
1023
+ // Custom wrapper that replaces the original wrapWithModalPreviewer.
1024
+ // The key difference: it OVERRIDES the onDownload prop from parent
1025
+ // components (DisplayPreviewFieldModel, UploadFieldModel) which use
1026
+ // fetch(url) without auth — replacing it with our authenticated version.
1027
+
1028
+ const wrapWithAuthModalPreviewer = (Previewer: React.ComponentType<any>) => {
1029
+ return function AuthWrappedPreviewer(props: any) {
1030
+ const { open, onOpenChange, onClose, file, index, list, onSwitchIndex, onDownload: _originalOnDownload } = props;
1031
+ const apiClient = useAPIClient();
1032
+ const t = useT();
1033
+ const [previewMode, setPreviewMode] = useState<'visual' | 'raw'>('visual');
1034
+
1035
+ // Override onDownload with authenticated version
1036
+ const authOnDownload = useCallback(
1037
+ async (fileOverride?: any) => {
1038
+ const target = fileOverride || file;
1039
+ if (!target) return;
1040
+ const token = apiClient.auth?.token || '';
1041
+ try {
1042
+ await downloadFileWithAuth(target, token);
1043
+ } catch (err) {
1044
+ message.error(t('Failed to download file'));
1045
+ }
1046
+ },
1047
+ [file, apiClient, t],
1048
+ );
1049
+
1050
+ if (typeof open !== 'boolean') {
1051
+ return <Previewer {...props} onDownload={authOnDownload} />;
1052
+ }
1053
+
1054
+ const title = getFileDisplayName(file);
1055
+ const canPrev = typeof index === 'number' && !!onSwitchIndex && index > 0;
1056
+ const canNext = typeof index === 'number' && !!onSwitchIndex && index < list.length - 1;
1057
+
1058
+ return (
1059
+ <Modal
1060
+ open={open}
1061
+ title={<PreviewModalTitle file={file} title={title} />}
1062
+ onCancel={() => {
1063
+ onOpenChange?.(false);
1064
+ onClose?.();
1065
+ setPreviewMode('visual');
1066
+ }}
1067
+ footer={
1068
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
1069
+ <div key="left-actions">
1070
+ <Button onClick={() => setPreviewMode((prev) => (prev === 'visual' ? 'raw' : 'visual'))}>
1071
+ {previewMode === 'visual' ? t('View Raw Parsed Text') : t('View Visual Preview')}
1072
+ </Button>
1073
+ </div>
1074
+ <Space size={14} style={{ fontSize: '20px' }}>
1075
+ <LeftOutlined
1076
+ style={{ cursor: canPrev ? 'pointer' : 'not-allowed' }}
1077
+ onClick={() => canPrev && onSwitchIndex?.(index - 1)}
1078
+ />
1079
+ <RightOutlined
1080
+ style={{ cursor: canNext ? 'pointer' : 'not-allowed' }}
1081
+ onClick={() => canNext && onSwitchIndex?.(index + 1)}
1082
+ />
1083
+ <DownloadOutlined onClick={() => authOnDownload(file)} />
1084
+ </Space>
1085
+ </div>
1086
+ }
1087
+ width="90%"
1088
+ centered={true}
1089
+ >
1090
+ <div
1091
+ style={{
1092
+ maxWidth: '100%',
1093
+ maxHeight: 'calc(100vh - 256px)',
1094
+ height: '80vh',
1095
+ width: '100%',
1096
+ background: 'white',
1097
+ display: 'flex',
1098
+ flexDirection: 'column',
1099
+ justifyContent: 'center',
1100
+ alignItems: 'center',
1101
+ overflowY: 'auto',
1102
+ }}
1103
+ >
1104
+ {previewMode === 'raw' ? (
1105
+ <AuthRawTextPreviewer file={file} />
1106
+ ) : (
1107
+ <Previewer {...props} onDownload={authOnDownload} />
1108
+ )}
1109
+ </div>
1110
+ </Modal>
1111
+ );
1112
+ };
1113
+ };
1114
+
1115
+ function AuthRawTextPreviewer({ file }: any) {
1116
+ const apiClient = useAPIClient();
1117
+ const t = useT();
1118
+ const [content, setContent] = useState('');
1119
+ const [loading, setLoading] = useState(true);
1120
+ const [error, setError] = useState<string | null>(null);
1121
+
1122
+ // Use NocoBase's Markdown renderer if available (e.g. from plugin-field-markdown-vditor)
1123
+ const MarkdownVditor = useComponent('MarkdownVditor');
1124
+ const MarkdownVoid = useComponent('Markdown.Void');
1125
+
1126
+ useEffect(() => {
1127
+ let cancelled = false;
1128
+ (async () => {
1129
+ setLoading(true);
1130
+ setError(null);
1131
+ try {
1132
+ const token = apiClient.auth?.token || '';
1133
+ const blob = await getLoadedFileBlob(file, token);
1134
+ if (cancelled) return;
1135
+
1136
+ const formData = new FormData();
1137
+ formData.append('file', blob, getFileDisplayName(file));
1138
+ formData.append('attachment', JSON.stringify(normalizeFileForServer(file)));
1139
+
1140
+ const response = await apiClient.request({
1141
+ url: 'filePreviewAuth:getContent',
1142
+ method: 'post',
1143
+ data: formData,
1144
+ headers: { 'Content-Type': 'multipart/form-data' },
1145
+ });
1146
+ if (cancelled) return;
1147
+ const text = response?.data?.data?.content || '';
1148
+
1149
+ // The server wraps content in <file_preview> XML tags, let's strip it for a cleaner raw text view
1150
+ let cleanText = text;
1151
+ const match = text.match(/<file_preview[^>]*>([\s\S]*?)<\/file_preview>/i);
1152
+ if (match) {
1153
+ cleanText = match[1].trim();
1154
+ }
1155
+
1156
+ setContent(cleanText);
1157
+ setLoading(false);
1158
+ } catch (err: any) {
1159
+ if (cancelled) return;
1160
+ setError(err.message || 'Failed to fetch raw text');
1161
+ setLoading(false);
1162
+ }
1163
+ })();
1164
+ return () => {
1165
+ cancelled = true;
1166
+ };
1167
+ }, [file, apiClient]);
1168
+
1169
+ if (loading) return <Spin size="large" tip={t('Extracting raw text...')} style={{ marginTop: '40px' }} />;
1170
+ if (error) return <Alert type="error" message={error} style={{ width: '100%', margin: '20px' }} />;
1171
+
1172
+ if (!content) {
1173
+ return (
1174
+ <Alert
1175
+ type="info"
1176
+ style={{ width: '100%', margin: '20px' }}
1177
+ description={t('No text content could be extracted from this file.')}
1178
+ showIcon
1179
+ />
1180
+ );
1181
+ }
1182
+
1183
+ const handleCopy = () => {
1184
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1185
+ navigator.clipboard
1186
+ .writeText(content)
1187
+ .then(() => {
1188
+ message.success(t('Copied to clipboard'));
1189
+ })
1190
+ .catch((err) => {
1191
+ message.error(t('Failed to copy'));
1192
+ console.error('Copy error', err);
1193
+ });
1194
+ } else {
1195
+ // Fallback
1196
+ const textArea = document.createElement('textarea');
1197
+ textArea.value = content;
1198
+ document.body.appendChild(textArea);
1199
+ textArea.select();
1200
+ try {
1201
+ document.execCommand('copy');
1202
+ message.success(t('Copied to clipboard'));
1203
+ } catch (err) {
1204
+ message.error(t('Failed to copy'));
1205
+ }
1206
+ document.body.removeChild(textArea);
1207
+ }
1208
+ };
1209
+
1210
+ return (
1211
+ <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
1212
+ <span
1213
+ onClick={handleCopy}
1214
+ style={{
1215
+ position: 'absolute',
1216
+ top: '20px',
1217
+ right: '25px',
1218
+ zIndex: 10,
1219
+ cursor: 'pointer',
1220
+ padding: '4px 10px',
1221
+ background: 'rgba(255, 255, 255, 0.85)',
1222
+ border: '1px solid #e8e8e8',
1223
+ borderRadius: '4px',
1224
+ color: '#1890ff',
1225
+ display: 'flex',
1226
+ alignItems: 'center',
1227
+ gap: '6px',
1228
+ boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
1229
+ fontSize: '13px',
1230
+ }}
1231
+ title={t('Copy')}
1232
+ >
1233
+ <CopyOutlined />
1234
+ {t('Copy')}
1235
+ </span>
1236
+ <div style={{ flex: 1, overflow: 'auto', padding: '20px', textAlign: 'left' }}>
1237
+ <style>
1238
+ {`
1239
+ .hide-vditor-toolbar .vditor-toolbar {
1240
+ display: none !important;
1241
+ }
1242
+ .hide-vditor-toolbar .vditor {
1243
+ border: none !important;
1244
+ }
1245
+ `}
1246
+ </style>
1247
+ <div className="hide-vditor-toolbar" style={{ height: '100%' }}>
1248
+ {MarkdownVditor ? (
1249
+ <MarkdownVditor value={content} disabled={false} />
1250
+ ) : MarkdownVoid ? (
1251
+ <MarkdownVoid content={content} />
1252
+ ) : (
1253
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '13px' }}>
1254
+ {content}
1255
+ </pre>
1256
+ )}
1257
+ </div>
1258
+ </div>
1259
+ </div>
1260
+ );
1261
+ }
1262
+
1263
+ // ─── Catch-all Modal Previewer (for attachmentFileTypes) ────────────
1264
+ // Intercepts ALL file clicks in Upload/Attachment components and provides:
1265
+ // - Authenticated preview for PDF/image/text
1266
+ // - Authenticated download for ALL files (including non-previewable)
1267
+
1268
+ function AuthCatchAllModalPreviewer({ index, list, onSwitchIndex }: any) {
1269
+ const t = useT();
1270
+ const apiClient = useAPIClient();
1271
+ const file = list[index];
1272
+ const [downloading, setDownloading] = useState(false);
1273
+ const [previewMode, setPreviewMode] = useState<'visual' | 'raw'>('visual');
1274
+ const [activeTab, setActiveTab] = useState<'preview' | 'ocr'>('preview');
1275
+
1276
+ // OCR state
1277
+ const [ocrStatus, setOcrStatus] = useState<string>('no-ocr');
1278
+ const [ocrResultId, setOcrResultId] = useState<string | number | null>(null);
1279
+ const [ocrError, setOcrError] = useState<string | null>(null);
1280
+ const ocrAttachmentId = useMemo(() => getOcrAttachmentId(file), [file]);
1281
+
1282
+ const isOcrSupported = useMemo(() => {
1283
+ if (!file || !ocrAttachmentId) return false;
1284
+ return isPdfFile(file) || isImageFile(file);
1285
+ }, [file, ocrAttachmentId]);
1286
+
1287
+ const OcrVerifyBlock = useComponent('OcrVerifyBlock');
1288
+
1289
+ const applyOcrRecord = useCallback((record: OcrStatusRecord | null) => {
1290
+ if (!record) return;
1291
+ setOcrResultId(record.id || null);
1292
+ setOcrStatus(record.status || 'no-ocr');
1293
+ setOcrError(record.error || null);
1294
+ }, []);
1295
+
1296
+ const loadOcrStatus = useCallback(
1297
+ async ({ updateState = true }: { updateState?: boolean } = {}) => {
1298
+ if (!ocrAttachmentId) return null;
1299
+ const res = await apiClient.request({
1300
+ url: 'filePreviewAuth:getOcrStatus',
1301
+ method: 'post',
1302
+ data: {
1303
+ attachmentId: ocrAttachmentId,
1304
+ },
1305
+ });
1306
+ const record = extractOcrStatusRecord(res);
1307
+ if (record && updateState) {
1308
+ applyOcrRecord(record);
1309
+ }
1310
+ return record;
1311
+ },
1312
+ [apiClient, applyOcrRecord, ocrAttachmentId],
1313
+ );
1314
+
1315
+ // Load / Sync initial OCR status from the separate OCR result collection.
1316
+ useEffect(() => {
1317
+ if (!ocrAttachmentId) return;
1318
+ let cancelled = false;
1319
+ setOcrResultId(null);
1320
+ setOcrStatus('no-ocr');
1321
+ setOcrError(null);
1322
+
1323
+ loadOcrStatus()
1324
+ .then(() => {
1325
+ if (cancelled) return;
1326
+ })
1327
+ .catch(console.error);
1328
+
1329
+ return () => {
1330
+ cancelled = true;
1331
+ };
1332
+ }, [ocrAttachmentId, loadOcrStatus]);
1333
+
1334
+ // Polling for OCR job completion when ocrStatus is 'pending-ocr'
1335
+ useEffect(() => {
1336
+ if (ocrStatus !== 'pending-ocr' || !ocrAttachmentId) return;
1337
+ let timer: any = null;
1338
+ let cancelled = false;
1339
+ let noOcrPollCount = 0;
1340
+
1341
+ const poll = async () => {
1342
+ try {
1343
+ const record = await loadOcrStatus({ updateState: false });
1344
+ if (cancelled) return;
1345
+ if (record) {
1346
+ const status = record.status || 'no-ocr';
1347
+ if (status === 'pending-ocr') {
1348
+ applyOcrRecord(record);
1349
+ timer = setTimeout(poll, 3000);
1350
+ return;
1351
+ }
1352
+
1353
+ if (isOcrCompleteStatus(status)) {
1354
+ applyOcrRecord(record);
1355
+ setActiveTab('ocr');
1356
+ message.success(t('OCR processing completed!'));
1357
+ return;
1358
+ }
1359
+
1360
+ if (status === 'failed') {
1361
+ applyOcrRecord(record);
1362
+ message.error(record.error || t('OCR processing failed'));
1363
+ return;
1364
+ }
1365
+
1366
+ if (status === 'no-ocr' && noOcrPollCount < 3) {
1367
+ noOcrPollCount += 1;
1368
+ timer = setTimeout(poll, 3000);
1369
+ return;
1370
+ }
1371
+
1372
+ applyOcrRecord(record);
1373
+ } else if (noOcrPollCount < 3) {
1374
+ noOcrPollCount += 1;
1375
+ timer = setTimeout(poll, 3000);
1376
+ } else {
1377
+ setOcrResultId(null);
1378
+ setOcrStatus('no-ocr');
1379
+ setOcrError(null);
1380
+ }
1381
+ } catch (err) {
1382
+ console.error('Polling error', err);
1383
+ timer = setTimeout(poll, 3000);
1384
+ }
1385
+ };
1386
+
1387
+ timer = setTimeout(poll, 3000);
1388
+ return () => {
1389
+ cancelled = true;
1390
+ if (timer) clearTimeout(timer);
1391
+ };
1392
+ }, [applyOcrRecord, ocrStatus, ocrAttachmentId, loadOcrStatus, t]);
1393
+
1394
+ const handleRunOcr = async () => {
1395
+ if (!ocrAttachmentId) return;
1396
+ try {
1397
+ setOcrStatus('pending-ocr');
1398
+ const res = await apiClient.request({
1399
+ url: 'filePreviewAuth:runOcr',
1400
+ method: 'post',
1401
+ data: {
1402
+ attachmentId: ocrAttachmentId,
1403
+ },
1404
+ });
1405
+ const record = extractOcrStatusRecord(res);
1406
+ if (record) {
1407
+ applyOcrRecord({ ...record, status: record.status || 'pending-ocr' });
1408
+ }
1409
+ message.info(t('OCR process started in the background.'));
1410
+ } catch (err: any) {
1411
+ message.error(err?.message || t('Failed to start OCR process.'));
1412
+ setOcrStatus('no-ocr');
1413
+ }
1414
+ };
1415
+
1416
+ const onDownload = useCallback(
1417
+ async (e: any) => {
1418
+ e?.preventDefault?.();
1419
+ e?.stopPropagation?.();
1420
+ const token = apiClient.auth?.token || '';
1421
+ setDownloading(true);
1422
+ try {
1423
+ await downloadFileWithAuth(file, token);
1424
+ } catch (err) {
1425
+ message.error(t('Failed to download file'));
1426
+ } finally {
1427
+ setDownloading(false);
1428
+ }
1429
+ },
1430
+ [file, apiClient, t],
1431
+ );
1432
+
1433
+ const onClose = useCallback(() => {
1434
+ onSwitchIndex(null);
1435
+ setPreviewMode('visual');
1436
+ setActiveTab('preview');
1437
+ }, [onSwitchIndex]);
1438
+
1439
+ // Determine which inline previewer to use (null for non-previewable)
1440
+ const PreviewerComponent = useMemo(() => {
1441
+ if (isPdfFile(file)) return AuthPdfInlinePreviewer;
1442
+ if (isImageFile(file)) return AuthImageInlinePreviewer;
1443
+ if (isTextFile(file)) return AuthTextInlinePreviewer;
1444
+ if (isDocxFile(file)) return AuthDocxInlinePreviewer;
1445
+ if (isXlsxFile(file)) return AuthXlsxInlinePreviewer;
1446
+ if (isPptxFile(file)) return AuthPptxInlinePreviewer;
1447
+ return null;
1448
+ }, [file]);
1449
+
1450
+ const canPreview = PreviewerComponent != null || previewMode === 'raw';
1451
+
1452
+ const tabItems = [
1453
+ {
1454
+ key: 'preview',
1455
+ label: (
1456
+ <span>
1457
+ <EyeOutlined /> {t('Visual Preview')}
1458
+ </span>
1459
+ ),
1460
+ children: (
1461
+ <div
1462
+ style={{
1463
+ height: '70vh',
1464
+ width: '100%',
1465
+ overflow: 'auto',
1466
+ background: '#f5f5f5',
1467
+ display: 'flex',
1468
+ justifyContent: 'center',
1469
+ alignItems: 'center',
1470
+ }}
1471
+ >
1472
+ {PreviewerComponent ? <PreviewerComponent file={file} /> : null}
1473
+ </div>
1474
+ ),
1475
+ },
1476
+ {
1477
+ key: 'ocr',
1478
+ label: (
1479
+ <span>
1480
+ <ScanOutlined /> {t('OCR & Verify')}
1481
+ </span>
1482
+ ),
1483
+ children: (
1484
+ <div style={{ height: '70vh', width: '100%', display: 'flex', flexDirection: 'column' }}>
1485
+ {ocrStatus === 'no-ocr' && (
1486
+ <div
1487
+ style={{
1488
+ display: 'flex',
1489
+ flexDirection: 'column',
1490
+ justifyContent: 'center',
1491
+ alignItems: 'center',
1492
+ height: '100%',
1493
+ padding: '40px',
1494
+ background: '#fafafa',
1495
+ borderRadius: '8px',
1496
+ }}
1497
+ >
1498
+ <ScanOutlined style={{ fontSize: '64px', color: '#1890ff', marginBottom: '20px' }} />
1499
+ <h3 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '10px' }}>
1500
+ {t('Word-level Song ngữ (English & Vietnamese) OCR')}
1501
+ </h3>
1502
+ <p style={{ color: '#8c8c8c', maxWidth: '480px', textAlign: 'center', marginBottom: '24px' }}>
1503
+ {t(
1504
+ 'Chưa có dữ liệu OCR cấp độ Từ (Word-level) cho tệp này. Hãy chạy nhận dạng Tesseract-OCR để bắt đầu đối soát và verify toạ độ.',
1505
+ )}
1506
+ </p>
1507
+ <Button type="primary" size="large" icon={<ThunderboltOutlined />} onClick={handleRunOcr}>
1508
+ {t('Run Tesseract OCR')}
1509
+ </Button>
1510
+ </div>
1511
+ )}
1512
+ {ocrStatus === 'pending-ocr' && (
1513
+ <div
1514
+ style={{
1515
+ display: 'flex',
1516
+ flexDirection: 'column',
1517
+ justifyContent: 'center',
1518
+ alignItems: 'center',
1519
+ height: '100%',
1520
+ padding: '40px',
1521
+ }}
1522
+ >
1523
+ <Spin size="large" tip={t('Analyzing layout structure and running song ngữ OCR...')} />
1524
+ <p style={{ color: '#8c8c8c', marginTop: '20px', fontSize: '13px', textAlign: 'center' }}>
1525
+ {t(
1526
+ 'Extracting word-level coordinates via Tesseract-OCR. This will automatically refresh when complete.',
1527
+ )}
1528
+ </p>
1529
+ </div>
1530
+ )}
1531
+ {ocrStatus === 'failed' && (
1532
+ <div style={{ padding: '40px' }}>
1533
+ <Alert
1534
+ type="error"
1535
+ showIcon
1536
+ message={t('OCR processing failed')}
1537
+ description={ocrError || t('Please try running OCR again.')}
1538
+ style={{ marginBottom: 24 }}
1539
+ />
1540
+ <Button type="primary" icon={<ThunderboltOutlined />} onClick={handleRunOcr}>
1541
+ {t('Run Tesseract OCR')}
1542
+ </Button>
1543
+ </div>
1544
+ )}
1545
+ {isOcrCompleteStatus(ocrStatus) && (
1546
+ <div style={{ flex: 1, height: '100%', overflow: 'hidden' }}>
1547
+ {OcrVerifyBlock && ocrResultId ? (
1548
+ <OcrVerifyBlock
1549
+ collection="attachmentOcrResults"
1550
+ recordId={ocrResultId}
1551
+ pdfField="attachment"
1552
+ jsonField="data"
1553
+ statusField="status"
1554
+ />
1555
+ ) : OcrVerifyBlock ? (
1556
+ <div style={{ padding: '20px' }}>
1557
+ <Alert
1558
+ type="error"
1559
+ message={t('OCR result record not found')}
1560
+ description={t('Please try running OCR again.')}
1561
+ showIcon
1562
+ />
1563
+ </div>
1564
+ ) : (
1565
+ <div style={{ padding: '20px' }}>
1566
+ <Alert
1567
+ type="error"
1568
+ message={t('Plugin OCR Verify Block is not enabled')}
1569
+ description={t(
1570
+ 'Please enable the plugin-ocr-verify-block plugin to display the verify splitter layout.',
1571
+ )}
1572
+ showIcon
1573
+ />
1574
+ </div>
1575
+ )}
1576
+ </div>
1577
+ )}
1578
+ </div>
1579
+ ),
1580
+ },
1581
+ ];
1582
+
1583
+ return (
1584
+ <Modal
1585
+ open={index != null}
1586
+ title={
1587
+ <PreviewModalTitle
1588
+ file={file}
1589
+ title={file?.title || file?.filename || file?.name || 'File'}
1590
+ ocrStatus={ocrStatus}
1591
+ isOcrSupported={isOcrSupported}
1592
+ />
1593
+ }
1594
+ onCancel={onClose}
1595
+ footer={
1596
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
1597
+ <div key="left-actions">
1598
+ {!isOcrSupported && (
1599
+ <Button onClick={() => setPreviewMode((prev) => (prev === 'visual' ? 'raw' : 'visual'))}>
1600
+ {previewMode === 'visual' ? t('View Raw Parsed Text') : t('View Visual Preview')}
1601
+ </Button>
1602
+ )}
1603
+ </div>
1604
+ <Space>
1605
+ <Button key="download" onClick={onDownload} loading={downloading}>
1606
+ {t('Download')}
1607
+ </Button>
1608
+ <Button key="close" onClick={onClose}>
1609
+ {t('Close')}
1610
+ </Button>
1611
+ </Space>
1612
+ </div>
1613
+ }
1614
+ width={canPreview ? '90%' : 520}
1615
+ centered={true}
1616
+ >
1617
+ {isOcrSupported ? (
1618
+ <Tabs
1619
+ activeKey={activeTab}
1620
+ onChange={(key: any) => setActiveTab(key)}
1621
+ items={tabItems}
1622
+ style={{ width: '100%', height: '100%' }}
1623
+ />
1624
+ ) : (
1625
+ <div
1626
+ style={{
1627
+ maxWidth: '100%',
1628
+ maxHeight: canPreview ? 'calc(100vh - 256px)' : 'auto',
1629
+ height: canPreview ? '70vh' : 'auto',
1630
+ width: '100%',
1631
+ background: 'white',
1632
+ display: 'flex',
1633
+ flexDirection: 'column',
1634
+ justifyContent: 'center',
1635
+ alignItems: 'center',
1636
+ overflowY: 'auto',
1637
+ }}
1638
+ >
1639
+ {previewMode === 'raw' ? (
1640
+ <AuthRawTextPreviewer file={file} />
1641
+ ) : PreviewerComponent ? (
1642
+ <PreviewerComponent file={file} />
1643
+ ) : (
1644
+ <Alert
1645
+ type="info"
1646
+ style={{ width: '100%' }}
1647
+ description={t('This file type cannot be previewed. Click Download to save the file.')}
1648
+ showIcon
1649
+ />
1650
+ )}
1651
+ </div>
1652
+ )}
1653
+ </Modal>
1654
+ );
1655
+ }
1656
+
1657
+ // ─── Download-only previewer (for filePreviewTypes non-previewable) ──
1658
+
1659
+ function AuthDownloadPreviewer({ file }: any) {
1660
+ const apiClient = useAPIClient();
1661
+ const t = useT();
1662
+
1663
+ const authDownload = useCallback(async () => {
1664
+ const token = apiClient.auth?.token || '';
1665
+ try {
1666
+ await downloadFileWithAuth(file, token);
1667
+ } catch (err) {
1668
+ message.error(t('Failed to download file'));
1669
+ }
1670
+ }, [file, apiClient, t]);
1671
+
1672
+ return (
1673
+ <Alert
1674
+ type="info"
1675
+ style={{ width: '100%' }}
1676
+ description={
1677
+ <span>
1678
+ {t('This file type cannot be previewed. ')}{' '}
1679
+ <a onClick={authDownload} style={{ textDecoration: 'underline', cursor: 'pointer' }}>
1680
+ {t('Download')}
1681
+ </a>
1682
+ </span>
1683
+ }
1684
+ showIcon
1685
+ />
1686
+ );
1687
+ }
1688
+
1689
+ // ─── Plugin class ───────────────────────────────────────────────────
1690
+
1691
+ export class PluginFilePreviewAuthClient extends Plugin {
1692
+ async load() {
1693
+ this.patchUploadPreviewBase64Fallback();
1694
+ registerFilePreviewAIWorkContext(this.app);
1695
+
1696
+ // ────────────────────────────────────────────────────────────────
1697
+ // 1) attachmentFileTypes: Catch-ALL handler for Upload/Attachment
1698
+ // This intercepts ALL file clicks (any type) and provides:
1699
+ // - Authenticated preview for PDF/image/text
1700
+ // - Authenticated download for ALL files (including non-previewable)
1701
+ // ────────────────────────────────────────────────────────────────
1702
+ attachmentFileTypes.add({
1703
+ match() {
1704
+ return true; // Match ALL files
1705
+ },
1706
+ Previewer: AuthCatchAllModalPreviewer,
1707
+ });
1708
+
1709
+ // ────────────────────────────────────────────────────────────────
1710
+ // 2) filePreviewTypes: Handlers for File Manager previews
1711
+ // Uses custom wrapWithAuthModalPreviewer that OVERRIDES the
1712
+ // onDownload prop from parent with authenticated fetch version.
1713
+ // This ensures the download icon (DownloadOutlined) in the
1714
+ // modal footer bar also uses Bearer token authentication.
1715
+ // ────────────────────────────────────────────────────────────────
1716
+
1717
+ // Catch-all for non-previewable files (download with auth)
1718
+ filePreviewTypes.add({
1719
+ match() {
1720
+ return true; // Fallback for all files
1721
+ },
1722
+ Previewer: wrapWithAuthModalPreviewer(AuthDownloadPreviewer),
1723
+ });
1724
+
1725
+ // PDF preview
1726
+ filePreviewTypes.add({
1727
+ match: isPdfFile,
1728
+ Previewer: wrapWithAuthModalPreviewer(AuthPdfInlinePreviewer),
1729
+ });
1730
+
1731
+ // Image preview
1732
+ filePreviewTypes.add({
1733
+ match: isImageFile,
1734
+ getThumbnailURL(file: any) {
1735
+ return getSafeImageThumbnailUrl(file);
1736
+ },
1737
+ Previewer: wrapWithAuthModalPreviewer(AuthImageInlinePreviewer),
1738
+ });
1739
+
1740
+ // Text preview
1741
+ filePreviewTypes.add({
1742
+ match: isTextFile,
1743
+ Previewer: wrapWithAuthModalPreviewer(AuthTextInlinePreviewer),
1744
+ });
1745
+
1746
+ // DOCX preview
1747
+ filePreviewTypes.add({
1748
+ match: isDocxFile,
1749
+ Previewer: wrapWithAuthModalPreviewer(AuthDocxInlinePreviewer),
1750
+ });
1751
+
1752
+ // XLSX preview
1753
+ filePreviewTypes.add({
1754
+ match: isXlsxFile,
1755
+ Previewer: wrapWithAuthModalPreviewer(AuthXlsxInlinePreviewer),
1756
+ });
1757
+
1758
+ // PPTX preview
1759
+ filePreviewTypes.add({
1760
+ match: isPptxFile,
1761
+ Previewer: wrapWithAuthModalPreviewer(AuthPptxInlinePreviewer),
1762
+ });
1763
+ }
1764
+
1765
+ private patchUploadPreviewBase64Fallback() {
1766
+ if (typeof window === 'undefined') {
1767
+ return;
1768
+ }
1769
+
1770
+ const fileReaderProto = window.FileReader?.prototype as any;
1771
+ if (!fileReaderProto || fileReaderProto.__filePreviewAuthBase64FallbackPatched) {
1772
+ return;
1773
+ }
1774
+
1775
+ const originalReadAsDataURL = fileReaderProto.readAsDataURL;
1776
+ if (typeof originalReadAsDataURL !== 'function') {
1777
+ return;
1778
+ }
1779
+
1780
+ Object.defineProperty(fileReaderProto, '__filePreviewAuthBase64FallbackPatched', {
1781
+ value: true,
1782
+ configurable: true,
1783
+ });
1784
+
1785
+ fileReaderProto.readAsDataURL = function readAsDataURLWithEmptyFallback(blob: Blob | null | undefined) {
1786
+ if (blob != null) {
1787
+ return originalReadAsDataURL.call(this, blob);
1788
+ }
1789
+
1790
+ setTimeout(() => {
1791
+ try {
1792
+ Object.defineProperty(this, 'result', {
1793
+ value: '',
1794
+ configurable: true,
1795
+ });
1796
+ } catch {
1797
+ // Ignore readonly result assignment failures; the caller will still continue via onload.
1798
+ }
1799
+
1800
+ this.onload?.(new ProgressEvent('load'));
1801
+ this.onloadend?.(new ProgressEvent('loadend'));
1802
+ }, 0);
1803
+ };
1804
+ }
1805
+ }
1806
+
1807
+ export default PluginFilePreviewAuthClient;