From 350ec8da1e87f4370938b606bdc1b336c0a5731d Mon Sep 17 00:00:00 2001 From: sripwoud Date: Tue, 18 Aug 2026 18:23:35 +0200 Subject: [PATCH] feat(web): clarify batch import copy and surface duplicates The import footer showed 'Will import to: X' with no sign that the whole selection lands in that one folder, and the detected-folder badge never said which file the hint came from (only the first valid file is consulted). Multi-file selections now show the file count in the footer and the hint's source file in the badge. The results card now renders the aggregated duplicates count and the processed math includes it. The upload-import path currently always reports duplicates as 0 (duplicates return Ok and count as success server-side), so this only becomes visible once the server reports them, but the aggregate and display are ready. Header unfolding in extractFolderHint matched any whitespace-led line in the remaining 64 KB, so body text could be glued onto the folder name; it now stops at the first non-continuation line per RFC 5322. --- .../import/__tests__/folder-hint.test.ts | 54 +++++++++++++++++++ web/src/features/import/folder-hint.ts | 22 ++++---- web/src/features/import/index.tsx | 18 ++++--- web/src/locales/en.json | 2 + 4 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 web/src/features/import/__tests__/folder-hint.test.ts diff --git a/web/src/features/import/__tests__/folder-hint.test.ts b/web/src/features/import/__tests__/folder-hint.test.ts new file mode 100644 index 0000000..507eedc --- /dev/null +++ b/web/src/features/import/__tests__/folder-hint.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { extractFolderHint } from '../folder-hint' + +const emlWithLabels = [ + 'From: a@example.com', + 'To: b@example.com', + 'Subject: hello', + 'X-Gmail-Labels: TestBatch', + '', + 'body', +].join('\n') + +const emlPlain = ['From: a@example.com', 'Subject: hello', '', 'body'].join( + '\n' +) + +describe('extractFolderHint', () => { + it('extracts the folder from X-Gmail-Labels and records the source file', async () => { + const hint = await extractFolderHint( + new File([emlWithLabels], 'sample-01.eml') + ) + + expect(hint).toEqual({ + name: 'TestBatch', + source: 'gmail-labels', + fileName: 'sample-01.eml', + }) + }) + + it('unfolds folded header lines without swallowing the body', async () => { + const folded = [ + 'From: a@example.com', + 'X-Gmail-Labels: Inbox,', + ' Receipts', + 'Subject: hello', + '', + 'body line', + ].join('\n') + + const hint = await extractFolderHint(new File([folded], 'x.eml')) + + expect(hint?.name).toBe('Receipts') + }) + + it('falls back to the file name and records it as the source file', async () => { + const hint = await extractFolderHint(new File([emlPlain], 'Receipts.eml')) + + expect(hint).toEqual({ + name: 'Receipts', + source: 'filename', + fileName: 'Receipts.eml', + }) + }) +}) diff --git a/web/src/features/import/folder-hint.ts b/web/src/features/import/folder-hint.ts index cdf1ebe..5038575 100644 --- a/web/src/features/import/folder-hint.ts +++ b/web/src/features/import/folder-hint.ts @@ -44,14 +44,14 @@ function getHeader(raw: string, name: string): string | null { const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*(.+)$`, 'im'); const m = raw.match(re); if (!m) return null; - // Unfold continuation lines (leading whitespace) + // Unfold continuation lines: only contiguous lines starting with + // horizontal whitespace belong to this header (RFC 5322 folding). let val = m[1].trim(); const startIdx = m.index! + m[0].length; const rest = raw.slice(startIdx); - const contRe = /^\s+(.+)$/gm; - let cm: RegExpExecArray | null; - while ((cm = contRe.exec(rest)) !== null) { - val += ' ' + cm[1].trim(); + for (const line of rest.split(/\r?\n/).slice(1)) { + if (!/^[ \t]+\S/.test(line)) break; + val += ' ' + line.trim(); } return decodeRfc2047(val); } @@ -105,6 +105,8 @@ export interface FolderHint { name: string; /** Where the hint came from. */ source: 'gmail-labels' | 'bichon-metadata' | 'filename' | 'mbox-filename' | 'pst-filename'; + /** Name of the file the hint was extracted from. */ + fileName: string; } /** @@ -127,26 +129,26 @@ export async function extractFolderHint(file: File): Promise // 1. X-Bichon-Metadata (highest priority, explicit) const bichonFolder = folderFromBichonMetadata(headers); - if (bichonFolder) return { name: bichonFolder, source: 'bichon-metadata' }; + if (bichonFolder) return { name: bichonFolder, source: 'bichon-metadata', fileName: file.name }; // 2. X-Gmail-Labels const gmailFolder = folderFromGmailLabels(headers); - if (gmailFolder) return { name: gmailFolder, source: 'gmail-labels' }; + if (gmailFolder) return { name: gmailFolder, source: 'gmail-labels', fileName: file.name }; // 3. For MBOX files, use the filename if (isMbox) { const fnFolder = folderFromFileName(file.name); - if (fnFolder) return { name: fnFolder, source: 'mbox-filename' }; + if (fnFolder) return { name: fnFolder, source: 'mbox-filename', fileName: file.name }; } // 4. For EML files, try the filename const fnFolder = folderFromFileName(file.name); - if (fnFolder) return { name: fnFolder, source: 'filename' }; + if (fnFolder) return { name: fnFolder, source: 'filename', fileName: file.name }; // 5. For PST files, try the filename if (isPst) { const fnFolder = folderFromFileName(file.name); - if (fnFolder) return { name: fnFolder, source: 'pst-filename' }; + if (fnFolder) return { name: fnFolder, source: 'pst-filename', fileName: file.name }; } return null; diff --git a/web/src/features/import/index.tsx b/web/src/features/import/index.tsx index fdeac07..5cb5b63 100644 --- a/web/src/features/import/index.tsx +++ b/web/src/features/import/index.tsx @@ -9,7 +9,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { Upload, FileText, X, CheckCircle2, AlertTriangle, Sparkles, PenLine, ListTree, ChevronsUpDown, Check, - Clock, ChevronRight, + Clock, ChevronRight, Copy, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -419,7 +419,7 @@ export default function ImportPage() { {folderHint && ( - ({t('import.source')}: {folderHintLabel(folderHint)}) + ({t('import.source')}: {folderHintLabel(folderHint)}{files.length > 1 && `, ${folderHint.fileName}`}) )} @@ -659,16 +659,16 @@ export default function ImportPage() {
- {t('import.processed', { current: progress.success + progress.failed, total: progress.total })} + {t('import.processed', { current: progress.success + progress.failed + progress.duplicates, total: progress.total })} {progress.total > 0 - ? Math.round(((progress.success + progress.failed) / progress.total) * 100) + ? Math.round(((progress.success + progress.failed + progress.duplicates) / progress.total) * 100) : 0}%
0 ? ((progress.success + progress.failed) / progress.total) * 100 : 0} + value={progress.total > 0 ? ((progress.success + progress.failed + progress.duplicates) / progress.total) * 100 : 0} className="h-2" />
@@ -684,6 +684,12 @@ export default function ImportPage() { {t('import.failedCount', { count: progress.failed })} + {progress.duplicates > 0 && ( + + + {t('import.duplicateCount', { count: progress.duplicates })} + + )} )} @@ -712,7 +718,7 @@ export default function ImportPage() {
{isPstSelected ? t('import.pstFolders', 'PST folder structure will be preserved during import') - : (<>{t('import.willImportTo', 'Will import to')}: {effectiveFolder})} + : (<>{t('import.willImportTo', 'Will import to')}: {effectiveFolder}{files.length > 1 && <> ยท {t('import.fileCount', { count: files.length })}})}