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 })}})}