@striae-org/striae 3.2.0 → 3.2.1
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.
- package/README.md +3 -32
- package/app/components/actions/case-export/data-processing.ts +49 -9
- package/app/components/actions/case-export/download-handlers.ts +125 -40
- package/app/components/actions/case-export/metadata-helpers.ts +31 -13
- package/app/components/form/base-form.tsx +1 -1
- package/app/components/sidebar/case-export/case-export.tsx +15 -15
- package/app/components/sidebar/cases/case-sidebar.tsx +23 -16
- package/app/components/sidebar/sidebar-container.tsx +1 -1
- package/app/entry.client.tsx +12 -12
- package/app/entry.server.tsx +4 -4
- package/app/hooks/useInactivityTimeout.ts +1 -1
- package/app/root.tsx +3 -3
- package/app/routes/auth/emailActionHandler.tsx +1 -1
- package/app/routes/auth/emailVerification.tsx +1 -1
- package/app/routes/auth/login.tsx +1 -1
- package/app/routes/auth/passwordReset.tsx +1 -1
- package/app/routes/auth/route.ts +1 -1
- package/app/types/exceljs-bare.d.ts +7 -0
- package/functions/[[path]].ts +2 -2
- package/package.json +34 -19
- package/public/vendor/exceljs.LICENSE +22 -0
- package/public/vendor/exceljs.bare.min.js +45 -0
- package/scripts/deploy-all.sh +52 -0
- package/scripts/deploy-config.sh +282 -1
- package/tsconfig.json +18 -8
- package/vite.config.ts +6 -22
- package/workers/audit-worker/package.json +8 -4
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +8 -4
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +8 -4
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +8 -4
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +8 -4
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +8 -4
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @striae-org/striae
|
|
2
2
|
|
|
3
|
-
Striae is a cloud-native forensic annotation application for firearms identification, built with
|
|
3
|
+
Striae is a cloud-native forensic annotation application for firearms identification, built with React Router and Cloudflare Workers.
|
|
4
4
|
|
|
5
5
|
This npm package publishes the Striae application source and deployment scaffolding for teams that run their own Striae environment.
|
|
6
6
|
|
|
@@ -40,7 +40,7 @@ npm i @striae-org/striae
|
|
|
40
40
|
cp -R node_modules/@striae-org/striae/. .
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
3) Reinstall using Striae's own package.json (includes dev deps like wrangler/
|
|
43
|
+
3) Reinstall using Striae's own package.json (includes dev deps like wrangler/react-router)
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
rm -rf node_modules package-lock.json
|
|
@@ -62,41 +62,12 @@ cp -f app/config-example/admin-service.json app/config/admin-service.json
|
|
|
62
62
|
npx wrangler login
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
7) Run guided config + full deployment
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
68
|
npm run deploy:all
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
## Publish To npmjs And GitHub Packages
|
|
72
|
-
|
|
73
|
-
1) Verify auth for both registries
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
npm whoami --registry=https://registry.npmjs.org/
|
|
77
|
-
npm whoami --registry=https://npm.pkg.github.com/
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
2) Publish current version to npmjs (public)
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
npm run publish:npm
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
3) Publish the same version to GitHub Packages
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
|
-
npm run publish:github
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
4) Optional dry-runs before a real release
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
npm run publish:npm:dry-run
|
|
96
|
-
npm run publish:github:dry-run
|
|
97
|
-
npm run publish:all:dry-run
|
|
98
|
-
```
|
|
99
|
-
|
|
100
71
|
## NPM Package Content Policy
|
|
101
72
|
|
|
102
73
|
This package intentionally includes only non-sensitive defaults and runtime source needed for setup.
|
|
@@ -3,11 +3,51 @@ import { calculateSHA256Secure } from '~/utils/SHA256';
|
|
|
3
3
|
import { CSV_HEADERS } from './types-constants';
|
|
4
4
|
import { addForensicDataWarning } from './metadata-helpers';
|
|
5
5
|
|
|
6
|
+
export type TabularCell = string | number | boolean | null;
|
|
7
|
+
|
|
8
|
+
const MAX_SPREADSHEET_CELL_LENGTH = 32767;
|
|
9
|
+
const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
10
|
+
const DANGEROUS_FORMULA_PREFIX_PATTERN = /^[\s\u0000-\u001F]*[=+\-@]/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sanitize cell values before CSV/XLSX export.
|
|
14
|
+
* - Strips non-printable control characters (except tab/newline/carriage return)
|
|
15
|
+
* - Prevents formula injection when files are opened in spreadsheet tools
|
|
16
|
+
* - Caps content length to Excel's per-cell limit
|
|
17
|
+
*/
|
|
18
|
+
export function sanitizeTabularCell(value: unknown): TabularCell {
|
|
19
|
+
if (value === null || value === undefined) {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let normalized = String(value).replace(CONTROL_CHAR_PATTERN, '');
|
|
28
|
+
|
|
29
|
+
if (normalized.length > MAX_SPREADSHEET_CELL_LENGTH) {
|
|
30
|
+
normalized = normalized.slice(0, MAX_SPREADSHEET_CELL_LENGTH);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (DANGEROUS_FORMULA_PREFIX_PATTERN.test(normalized)) {
|
|
34
|
+
return `'${normalized}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sanitizeTabularMatrix(
|
|
41
|
+
rows: Array<Array<string | number | boolean | null | undefined>>
|
|
42
|
+
): TabularCell[][] {
|
|
43
|
+
return rows.map((row) => row.map((cell) => sanitizeTabularCell(cell)));
|
|
44
|
+
}
|
|
45
|
+
|
|
6
46
|
/**
|
|
7
47
|
* Generate metadata rows for tabular format
|
|
8
48
|
*/
|
|
9
|
-
export function generateMetadataRows(exportData: CaseExportData):
|
|
10
|
-
return [
|
|
49
|
+
export function generateMetadataRows(exportData: CaseExportData): TabularCell[][] {
|
|
50
|
+
return sanitizeTabularMatrix([
|
|
11
51
|
['Case Export Report'],
|
|
12
52
|
[''],
|
|
13
53
|
['Case Number', exportData.metadata.caseNumber],
|
|
@@ -31,13 +71,13 @@ export function generateMetadataRows(exportData: CaseExportData): string[][] {
|
|
|
31
71
|
['Latest Annotation Date', exportData.summary?.latestAnnotationDate || 'N/A'],
|
|
32
72
|
[''],
|
|
33
73
|
['File Details']
|
|
34
|
-
];
|
|
74
|
+
]);
|
|
35
75
|
}
|
|
36
76
|
|
|
37
77
|
/**
|
|
38
78
|
* Process file data for tabular format (CSV/Excel)
|
|
39
79
|
*/
|
|
40
|
-
export function processFileDataForTabular(fileEntry: CaseExportData['files'][0]):
|
|
80
|
+
export function processFileDataForTabular(fileEntry: CaseExportData['files'][0]): TabularCell[][] {
|
|
41
81
|
// Full file data for the first row (excluding Additional Notes and Last Updated)
|
|
42
82
|
const fullFileData = [
|
|
43
83
|
fileEntry.fileData.id,
|
|
@@ -83,7 +123,7 @@ export function processFileDataForTabular(fileEntry: CaseExportData['files'][0])
|
|
|
83
123
|
const emptyFileData = Array(fileDataColumnCount).fill('');
|
|
84
124
|
const emptyAdditionalData = Array(additionalDataColumnCount).fill('');
|
|
85
125
|
|
|
86
|
-
const rows: string
|
|
126
|
+
const rows: Array<Array<string | number | boolean | null | undefined>> = [];
|
|
87
127
|
|
|
88
128
|
// If there are box annotations, create a row for each one
|
|
89
129
|
if (fileEntry.annotations?.boxAnnotations && fileEntry.annotations.boxAnnotations.length > 0) {
|
|
@@ -120,7 +160,7 @@ export function processFileDataForTabular(fileEntry: CaseExportData['files'][0])
|
|
|
120
160
|
]);
|
|
121
161
|
}
|
|
122
162
|
|
|
123
|
-
return rows;
|
|
163
|
+
return sanitizeTabularMatrix(rows);
|
|
124
164
|
}
|
|
125
165
|
|
|
126
166
|
/**
|
|
@@ -131,16 +171,16 @@ export async function generateCSVContent(exportData: CaseExportData, protectFore
|
|
|
131
171
|
const metadataRows = generateMetadataRows(exportData);
|
|
132
172
|
|
|
133
173
|
// File data rows
|
|
134
|
-
const fileRows:
|
|
174
|
+
const fileRows: TabularCell[][] = [];
|
|
135
175
|
exportData.files.forEach(fileEntry => {
|
|
136
176
|
const processedRows = processFileDataForTabular(fileEntry);
|
|
137
177
|
fileRows.push(...processedRows);
|
|
138
178
|
});
|
|
139
179
|
|
|
140
180
|
// Combine data rows for hash calculation (excluding header comments)
|
|
141
|
-
const dataRows = [
|
|
181
|
+
const dataRows: TabularCell[][] = [
|
|
142
182
|
...metadataRows,
|
|
143
|
-
CSV_HEADERS,
|
|
183
|
+
...sanitizeTabularMatrix([CSV_HEADERS]),
|
|
144
184
|
...fileRows
|
|
145
185
|
];
|
|
146
186
|
|
|
@@ -5,10 +5,96 @@ import { generateForensicManifestSecure, calculateSHA256Secure } from '~/utils/S
|
|
|
5
5
|
import { signForensicManifest } from '~/utils/data-operations';
|
|
6
6
|
import { ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
|
|
7
7
|
import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
|
|
8
|
-
import { generateMetadataRows, generateCSVContent, processFileDataForTabular } from './data-processing';
|
|
8
|
+
import { generateMetadataRows, generateCSVContent, processFileDataForTabular, sanitizeTabularMatrix } from './data-processing';
|
|
9
9
|
import { exportCaseData } from './core-export';
|
|
10
10
|
import { auditService } from '~/services/audit.service';
|
|
11
11
|
|
|
12
|
+
type TabularRow = Array<string | number | boolean | null | undefined>;
|
|
13
|
+
type ExcelJsBrowserBundle = typeof import('exceljs');
|
|
14
|
+
|
|
15
|
+
const EXCELJS_BROWSER_BUNDLE_SRC = '/vendor/exceljs.bare.min.js';
|
|
16
|
+
let excelJsBundlePromise: Promise<ExcelJsBrowserBundle> | null = null;
|
|
17
|
+
|
|
18
|
+
async function loadExcelJsBrowserBundle(): Promise<ExcelJsBrowserBundle> {
|
|
19
|
+
if (typeof window === 'undefined') {
|
|
20
|
+
throw new Error('Excel export is only available in a browser context.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (window.ExcelJS?.Workbook) {
|
|
24
|
+
return window.ExcelJS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!excelJsBundlePromise) {
|
|
28
|
+
excelJsBundlePromise = new Promise((resolve, reject) => {
|
|
29
|
+
const resolveFromWindow = () => {
|
|
30
|
+
if (window.ExcelJS?.Workbook) {
|
|
31
|
+
resolve(window.ExcelJS);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
excelJsBundlePromise = null;
|
|
36
|
+
reject(new Error('ExcelJS bundle loaded but Workbook API is unavailable.'));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const failLoad = () => {
|
|
40
|
+
excelJsBundlePromise = null;
|
|
41
|
+
reject(new Error('Failed to load ExcelJS browser bundle.'));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const existingScript = document.querySelector<HTMLScriptElement>('script[data-exceljs-bundle="true"]');
|
|
45
|
+
|
|
46
|
+
if (existingScript) {
|
|
47
|
+
if (existingScript.dataset.loaded === 'true') {
|
|
48
|
+
resolveFromWindow();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
existingScript.addEventListener('load', resolveFromWindow, { once: true });
|
|
53
|
+
existingScript.addEventListener('error', failLoad, { once: true });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const script = document.createElement('script');
|
|
58
|
+
script.src = EXCELJS_BROWSER_BUNDLE_SRC;
|
|
59
|
+
script.async = true;
|
|
60
|
+
script.dataset.exceljsBundle = 'true';
|
|
61
|
+
script.addEventListener(
|
|
62
|
+
'load',
|
|
63
|
+
() => {
|
|
64
|
+
script.dataset.loaded = 'true';
|
|
65
|
+
resolveFromWindow();
|
|
66
|
+
},
|
|
67
|
+
{ once: true }
|
|
68
|
+
);
|
|
69
|
+
script.addEventListener('error', failLoad, { once: true });
|
|
70
|
+
document.head.appendChild(script);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return excelJsBundlePromise;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sanitizeWorksheetName(name: string): string {
|
|
78
|
+
const cleaned = name.replace(/[\\/?*:\x5B\x5D]/g, '_').trim();
|
|
79
|
+
const normalized = cleaned.length > 0 ? cleaned : 'Sheet';
|
|
80
|
+
return normalized.substring(0, 31);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createUniqueWorksheetName(existingNames: Set<string>, desiredName: string): string {
|
|
84
|
+
const baseName = sanitizeWorksheetName(desiredName);
|
|
85
|
+
let candidate = baseName;
|
|
86
|
+
let suffix = 1;
|
|
87
|
+
|
|
88
|
+
while (existingNames.has(candidate)) {
|
|
89
|
+
const suffixText = `_${suffix}`;
|
|
90
|
+
candidate = `${baseName.substring(0, 31 - suffixText.length)}${suffixText}`;
|
|
91
|
+
suffix += 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
existingNames.add(candidate);
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
|
|
12
98
|
/**
|
|
13
99
|
* Generate export filename with embedded ID to prevent collisions
|
|
14
100
|
* Format: {originalFilename}-{id}.{extension}
|
|
@@ -122,10 +208,22 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
|
|
|
122
208
|
// Start audit workflow
|
|
123
209
|
auditService.startWorkflow('all-cases');
|
|
124
210
|
|
|
125
|
-
|
|
126
|
-
const XLSX = await import('xlsx');
|
|
211
|
+
const ExcelJS = await loadExcelJsBrowserBundle();
|
|
127
212
|
|
|
128
|
-
const workbook =
|
|
213
|
+
const workbook = new ExcelJS.Workbook();
|
|
214
|
+
workbook.creator = exportData.metadata.exportedBy || 'Striae';
|
|
215
|
+
workbook.lastModifiedBy = exportData.metadata.exportedBy || 'Striae';
|
|
216
|
+
workbook.created = new Date();
|
|
217
|
+
workbook.modified = new Date();
|
|
218
|
+
|
|
219
|
+
const existingWorksheetNames = new Set<string>();
|
|
220
|
+
|
|
221
|
+
const appendRowsToWorksheet = (worksheet: { addRow: (row: TabularRow) => unknown }, rows: TabularRow[]) => {
|
|
222
|
+
rows.forEach((row) => {
|
|
223
|
+
worksheet.addRow(row);
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
129
227
|
let exportPassword: string | undefined;
|
|
130
228
|
|
|
131
229
|
// Create summary worksheet
|
|
@@ -146,7 +244,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
|
|
|
146
244
|
];
|
|
147
245
|
|
|
148
246
|
// XLSX files are inherently protected, no hash validation needed
|
|
149
|
-
const summaryData = [
|
|
247
|
+
const summaryData = sanitizeTabularMatrix([
|
|
150
248
|
protectForensicData ? ['CASE DATA - PROTECTED EXPORT'] : ['Striae - All Cases Export Summary'],
|
|
151
249
|
protectForensicData ? ['WARNING: This workbook contains evidence data and is protected from editing.'] : [''],
|
|
152
250
|
[''],
|
|
@@ -195,36 +293,37 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
|
|
|
195
293
|
caseData.summary?.latestAnnotationDate || '',
|
|
196
294
|
caseData.summary?.exportError || ''
|
|
197
295
|
])
|
|
198
|
-
];
|
|
296
|
+
]);
|
|
199
297
|
|
|
200
|
-
const
|
|
298
|
+
const summaryWorksheetName = createUniqueWorksheetName(existingWorksheetNames, 'Summary');
|
|
299
|
+
const summaryWorksheet = workbook.addWorksheet(summaryWorksheetName);
|
|
300
|
+
appendRowsToWorksheet(summaryWorksheet, summaryData);
|
|
201
301
|
|
|
202
302
|
// Protect summary worksheet if forensic protection is enabled
|
|
203
303
|
if (protectForensicData) {
|
|
204
|
-
exportPassword = protectExcelWorksheet(summaryWorksheet);
|
|
304
|
+
exportPassword = await protectExcelWorksheet(summaryWorksheet);
|
|
205
305
|
}
|
|
206
|
-
|
|
207
|
-
XLSX.utils.book_append_sheet(workbook, summaryWorksheet, 'Summary');
|
|
208
306
|
|
|
209
307
|
// Create a worksheet for each case
|
|
210
|
-
exportData.cases
|
|
308
|
+
for (const caseData of exportData.cases) {
|
|
211
309
|
if (caseData.summary?.exportError) {
|
|
212
310
|
// For failed cases, create a simple error sheet
|
|
213
|
-
const errorData = [
|
|
311
|
+
const errorData = sanitizeTabularMatrix([
|
|
214
312
|
[`Case ${caseData.metadata.caseNumber} - Export Failed`],
|
|
215
313
|
[''],
|
|
216
314
|
['Error:', caseData.summary.exportError],
|
|
217
315
|
['Case Number:', caseData.metadata.caseNumber],
|
|
218
316
|
['Total Files:', caseData.metadata.totalFiles]
|
|
219
|
-
];
|
|
220
|
-
const
|
|
317
|
+
]);
|
|
318
|
+
const errorSheetName = createUniqueWorksheetName(existingWorksheetNames, `Case_${caseData.metadata.caseNumber}_Error`);
|
|
319
|
+
const errorWorksheet = workbook.addWorksheet(errorSheetName);
|
|
320
|
+
appendRowsToWorksheet(errorWorksheet, errorData);
|
|
221
321
|
|
|
222
322
|
if (protectForensicData && exportPassword) {
|
|
223
|
-
protectExcelWorksheet(errorWorksheet, exportPassword);
|
|
323
|
+
await protectExcelWorksheet(errorWorksheet, exportPassword);
|
|
224
324
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return;
|
|
325
|
+
|
|
326
|
+
continue;
|
|
228
327
|
}
|
|
229
328
|
|
|
230
329
|
// For successful cases, create detailed worksheets
|
|
@@ -257,36 +356,22 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
|
|
|
257
356
|
caseDetailsData.push(['No detailed file data available for this case']);
|
|
258
357
|
}
|
|
259
358
|
|
|
260
|
-
const
|
|
359
|
+
const sanitizedCaseDetailsData = sanitizeTabularMatrix(caseDetailsData);
|
|
360
|
+
|
|
361
|
+
// Clean sheet name for Excel compatibility and uniqueness
|
|
362
|
+
const sheetName = createUniqueWorksheetName(existingWorksheetNames, `Case_${caseData.metadata.caseNumber}`);
|
|
363
|
+
const caseWorksheet = workbook.addWorksheet(sheetName);
|
|
364
|
+
appendRowsToWorksheet(caseWorksheet, sanitizedCaseDetailsData);
|
|
261
365
|
|
|
262
366
|
// Protect worksheet if forensic protection is enabled
|
|
263
367
|
if (protectForensicData && exportPassword) {
|
|
264
|
-
protectExcelWorksheet(caseWorksheet, exportPassword);
|
|
368
|
+
await protectExcelWorksheet(caseWorksheet, exportPassword);
|
|
265
369
|
}
|
|
266
|
-
|
|
267
|
-
// Clean sheet name for Excel compatibility
|
|
268
|
-
const sheetName = `Case_${caseData.metadata.caseNumber}`.replace(/[\\/?*\x5B\x5D]/g, '_').substring(0, 31);
|
|
269
|
-
XLSX.utils.book_append_sheet(workbook, caseWorksheet, sheetName);
|
|
270
|
-
});
|
|
271
370
|
|
|
272
|
-
// Set workbook protection if forensic protection is enabled
|
|
273
|
-
if (protectForensicData && exportPassword) {
|
|
274
|
-
workbook.Props = {
|
|
275
|
-
Title: 'Striae Case Export - Protected',
|
|
276
|
-
Subject: 'Case Data Export',
|
|
277
|
-
Author: exportData.metadata.exportedBy || 'Striae',
|
|
278
|
-
Comments: `This workbook contains protected case data. Modification may compromise evidence integrity. Worksheets are password protected.`,
|
|
279
|
-
Company: 'Striae'
|
|
280
|
-
};
|
|
281
371
|
}
|
|
282
372
|
|
|
283
373
|
// Generate Excel file
|
|
284
|
-
const excelBuffer =
|
|
285
|
-
bookType: 'xlsx',
|
|
286
|
-
type: 'array',
|
|
287
|
-
bookSST: true, // Shared string table for better compression
|
|
288
|
-
cellStyles: true
|
|
289
|
-
});
|
|
374
|
+
const excelBuffer = await workbook.xlsx.writeBuffer();
|
|
290
375
|
|
|
291
376
|
// Create blob and download
|
|
292
377
|
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
|
@@ -68,16 +68,38 @@ export function generateRandomPassword(): string {
|
|
|
68
68
|
return password.split('').sort(() => Math.random() - 0.5).join('');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
type WorksheetProtectionOptions = {
|
|
72
|
+
selectLockedCells: boolean;
|
|
73
|
+
selectUnlockedCells: boolean;
|
|
74
|
+
formatCells: boolean;
|
|
75
|
+
formatColumns: boolean;
|
|
76
|
+
formatRows: boolean;
|
|
77
|
+
insertColumns: boolean;
|
|
78
|
+
insertRows: boolean;
|
|
79
|
+
insertHyperlinks: boolean;
|
|
80
|
+
deleteColumns: boolean;
|
|
81
|
+
deleteRows: boolean;
|
|
82
|
+
sort: boolean;
|
|
83
|
+
autoFilter: boolean;
|
|
84
|
+
pivotTables: boolean;
|
|
85
|
+
objects: boolean;
|
|
86
|
+
scenarios: boolean;
|
|
87
|
+
spinCount: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type ProtectableWorksheet = {
|
|
91
|
+
protect: (password: string, options: Record<string, unknown>) => Promise<unknown> | unknown;
|
|
92
|
+
};
|
|
93
|
+
|
|
71
94
|
/**
|
|
72
95
|
* Protect Excel worksheet from editing
|
|
73
96
|
*/
|
|
74
|
-
export function protectExcelWorksheet(worksheet:
|
|
97
|
+
export async function protectExcelWorksheet(worksheet: ProtectableWorksheet, sheetPassword?: string): Promise<string> {
|
|
75
98
|
// Generate random password if none provided
|
|
76
99
|
const password = sheetPassword || generateRandomPassword();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
password: password,
|
|
100
|
+
|
|
101
|
+
const protectionOptions: WorksheetProtectionOptions = {
|
|
102
|
+
// Keep read-only defaults and prevent structural edits.
|
|
81
103
|
selectLockedCells: true,
|
|
82
104
|
selectUnlockedCells: true,
|
|
83
105
|
formatCells: false,
|
|
@@ -92,15 +114,11 @@ export function protectExcelWorksheet(worksheet: Record<string, unknown>, sheetP
|
|
|
92
114
|
autoFilter: false,
|
|
93
115
|
pivotTables: false,
|
|
94
116
|
objects: false,
|
|
95
|
-
scenarios: false
|
|
117
|
+
scenarios: false,
|
|
118
|
+
spinCount: 100000
|
|
96
119
|
};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (!worksheet['!cols']) worksheet['!cols'] = [];
|
|
100
|
-
if (!worksheet['!rows']) worksheet['!rows'] = [];
|
|
101
|
-
|
|
102
|
-
// Add protection metadata
|
|
103
|
-
worksheet['!margins'] = { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 };
|
|
120
|
+
|
|
121
|
+
await Promise.resolve(worksheet.protect(password, protectionOptions as Record<string, unknown>));
|
|
104
122
|
|
|
105
123
|
// Return the password for inclusion in metadata
|
|
106
124
|
return password;
|
|
@@ -364,20 +364,6 @@ export const CaseExport = ({
|
|
|
364
364
|
</>
|
|
365
365
|
)}
|
|
366
366
|
|
|
367
|
-
<div className={styles.divider}>
|
|
368
|
-
<span>Verification</span>
|
|
369
|
-
</div>
|
|
370
|
-
|
|
371
|
-
<div className={styles.publicKeySection}>
|
|
372
|
-
<button
|
|
373
|
-
type="button"
|
|
374
|
-
className={styles.publicKeyButton}
|
|
375
|
-
onClick={() => setIsPublicKeyModalOpen(true)}
|
|
376
|
-
>
|
|
377
|
-
View Public Signing Key
|
|
378
|
-
</button>
|
|
379
|
-
</div>
|
|
380
|
-
|
|
381
367
|
{exportProgress && exportProgress.total > 0 && (
|
|
382
368
|
<div className={styles.progressSection}>
|
|
383
369
|
<div className={styles.progressText}>
|
|
@@ -391,7 +377,7 @@ export const CaseExport = ({
|
|
|
391
377
|
</div>
|
|
392
378
|
</div>
|
|
393
379
|
)}
|
|
394
|
-
|
|
380
|
+
|
|
395
381
|
{isExportingAll && !exportProgress && (
|
|
396
382
|
<div className={styles.progressSection}>
|
|
397
383
|
<div className={styles.progressText}>
|
|
@@ -399,6 +385,20 @@ export const CaseExport = ({
|
|
|
399
385
|
</div>
|
|
400
386
|
</div>
|
|
401
387
|
)}
|
|
388
|
+
|
|
389
|
+
<div className={styles.divider}>
|
|
390
|
+
<span>Verification</span>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<div className={styles.publicKeySection}>
|
|
394
|
+
<button
|
|
395
|
+
type="button"
|
|
396
|
+
className={styles.publicKeyButton}
|
|
397
|
+
onClick={() => setIsPublicKeyModalOpen(true)}
|
|
398
|
+
>
|
|
399
|
+
View Public Signing Key
|
|
400
|
+
</button>
|
|
401
|
+
</div>
|
|
402
402
|
|
|
403
403
|
{error && (
|
|
404
404
|
<div className={styles.error}>
|
|
@@ -1,13 +1,4 @@
|
|
|
1
1
|
import { User } from 'firebase/auth';
|
|
2
|
-
import {
|
|
3
|
-
exportCaseData,
|
|
4
|
-
exportAllCases,
|
|
5
|
-
downloadCaseAsJSON,
|
|
6
|
-
downloadCaseAsCSV,
|
|
7
|
-
downloadAllCasesAsJSON,
|
|
8
|
-
downloadAllCasesAsCSV,
|
|
9
|
-
downloadCaseAsZip
|
|
10
|
-
} from '../../actions/case-export';
|
|
11
2
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
12
3
|
import styles from './cases.module.css';
|
|
13
4
|
import { CasesModal } from './cases-modal';
|
|
@@ -66,6 +57,18 @@ interface CaseSidebarProps {
|
|
|
66
57
|
|
|
67
58
|
const SUCCESS_MESSAGE_TIMEOUT = 3000;
|
|
68
59
|
|
|
60
|
+
type CaseExportActionsModule = typeof import('../../actions/case-export');
|
|
61
|
+
|
|
62
|
+
let caseExportActionsPromise: Promise<CaseExportActionsModule> | null = null;
|
|
63
|
+
|
|
64
|
+
const loadCaseExportActions = (): Promise<CaseExportActionsModule> => {
|
|
65
|
+
if (!caseExportActionsPromise) {
|
|
66
|
+
caseExportActionsPromise = import('../../actions/case-export');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return caseExportActionsPromise;
|
|
70
|
+
};
|
|
71
|
+
|
|
69
72
|
export const CaseSidebar = ({
|
|
70
73
|
user,
|
|
71
74
|
onImageSelect,
|
|
@@ -512,20 +515,22 @@ const handleImageSelect = (file: FileData) => {
|
|
|
512
515
|
|
|
513
516
|
const handleExport = async (exportCaseNumber: string, format: ExportFormat, includeImages?: boolean) => {
|
|
514
517
|
try {
|
|
518
|
+
const caseExportActions = await loadCaseExportActions();
|
|
519
|
+
|
|
515
520
|
if (includeImages) {
|
|
516
521
|
// ZIP export with images - only available for single case exports
|
|
517
|
-
await downloadCaseAsZip(user, exportCaseNumber, format);
|
|
522
|
+
await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format);
|
|
518
523
|
} else {
|
|
519
524
|
// Standard data-only export
|
|
520
|
-
const exportData = await exportCaseData(user, exportCaseNumber, {
|
|
525
|
+
const exportData = await caseExportActions.exportCaseData(user, exportCaseNumber, {
|
|
521
526
|
includeMetadata: true
|
|
522
527
|
});
|
|
523
528
|
|
|
524
529
|
// Download the exported data in the selected format
|
|
525
530
|
if (format === 'json') {
|
|
526
|
-
await downloadCaseAsJSON(user, exportData);
|
|
531
|
+
await caseExportActions.downloadCaseAsJSON(user, exportData);
|
|
527
532
|
} else {
|
|
528
|
-
await downloadCaseAsCSV(user, exportData);
|
|
533
|
+
await caseExportActions.downloadCaseAsCSV(user, exportData);
|
|
529
534
|
}
|
|
530
535
|
}
|
|
531
536
|
|
|
@@ -537,16 +542,18 @@ const handleImageSelect = (file: FileData) => {
|
|
|
537
542
|
|
|
538
543
|
const handleExportAll = async (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => {
|
|
539
544
|
try {
|
|
545
|
+
const caseExportActions = await loadCaseExportActions();
|
|
546
|
+
|
|
540
547
|
// Export all cases with progress callback
|
|
541
|
-
const exportData = await exportAllCases(user, {
|
|
548
|
+
const exportData = await caseExportActions.exportAllCases(user, {
|
|
542
549
|
includeMetadata: true
|
|
543
550
|
}, onProgress);
|
|
544
551
|
|
|
545
552
|
// Download the exported data in the selected format
|
|
546
553
|
if (format === 'json') {
|
|
547
|
-
await downloadAllCasesAsJSON(user, exportData);
|
|
554
|
+
await caseExportActions.downloadAllCasesAsJSON(user, exportData);
|
|
548
555
|
} else {
|
|
549
|
-
await downloadAllCasesAsCSV(user, exportData);
|
|
556
|
+
await caseExportActions.downloadAllCasesAsCSV(user, exportData);
|
|
550
557
|
}
|
|
551
558
|
|
|
552
559
|
} catch (error) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
2
2
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
|
3
3
|
import React, { useState, useEffect } from 'react';
|
|
4
|
-
import { Link } from '
|
|
4
|
+
import { Link } from 'react-router';
|
|
5
5
|
import { Sidebar } from './sidebar';
|
|
6
6
|
import { User } from 'firebase/auth';
|
|
7
7
|
import { FileData } from '~/types';
|
package/app/entry.client.tsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { startTransition, StrictMode } from "react";
|
|
3
|
-
import { hydrateRoot } from "react-dom/client";
|
|
4
|
-
|
|
5
|
-
startTransition(() => {
|
|
6
|
-
hydrateRoot(
|
|
7
|
-
document,
|
|
8
|
-
<StrictMode>
|
|
9
|
-
<
|
|
10
|
-
</StrictMode>
|
|
11
|
-
);
|
|
12
|
-
});
|
|
1
|
+
import { HydratedRouter } from "react-router/dom";
|
|
2
|
+
import { startTransition, StrictMode } from "react";
|
|
3
|
+
import { hydrateRoot } from "react-dom/client";
|
|
4
|
+
|
|
5
|
+
startTransition(() => {
|
|
6
|
+
hydrateRoot(
|
|
7
|
+
document,
|
|
8
|
+
<StrictMode>
|
|
9
|
+
<HydratedRouter />
|
|
10
|
+
</StrictMode>
|
|
11
|
+
);
|
|
12
|
+
});
|
package/app/entry.server.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { EntryContext } from "
|
|
2
|
-
import {
|
|
1
|
+
import type { EntryContext } from "react-router";
|
|
2
|
+
import { ServerRouter } from "react-router";
|
|
3
3
|
import { isbot } from "isbot";
|
|
4
4
|
import { renderToReadableStream } from "react-dom/server";
|
|
5
5
|
|
|
@@ -7,10 +7,10 @@ export default async function handleRequest(
|
|
|
7
7
|
request: Request,
|
|
8
8
|
responseStatusCode: number,
|
|
9
9
|
responseHeaders: Headers,
|
|
10
|
-
|
|
10
|
+
reactRouterContext: EntryContext
|
|
11
11
|
) {
|
|
12
12
|
const body = await renderToReadableStream(
|
|
13
|
-
<
|
|
13
|
+
<ServerRouter context={reactRouterContext} url={request.url} />,
|
|
14
14
|
{
|
|
15
15
|
// If you wish to abort the rendering process, you can pass a signal here.
|
|
16
16
|
// Please refer to the templates for example son how to configure this.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useCallback } from 'react';
|
|
2
|
-
import { useLocation } from '
|
|
2
|
+
import { useLocation } from 'react-router';
|
|
3
3
|
import { signOut } from 'firebase/auth';
|
|
4
4
|
import { auth } from '~/services/firebase';
|
|
5
5
|
import { INACTIVITY_CONFIG } from '~/config/inactivity';
|
package/app/root.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LinksFunction } from
|
|
1
|
+
import type { LinksFunction } from 'react-router';
|
|
2
2
|
import {
|
|
3
3
|
Links,
|
|
4
4
|
Meta,
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
useRouteError,
|
|
10
10
|
Link,
|
|
11
11
|
useLocation,
|
|
12
|
-
useMatches
|
|
13
|
-
} from
|
|
12
|
+
useMatches,
|
|
13
|
+
} from 'react-router';
|
|
14
14
|
import {
|
|
15
15
|
ThemeProvider,
|
|
16
16
|
themeStyles
|