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.
This commit is contained in:
sripwoud
2026-08-18 18:23:35 +02:00
parent 9a5816a458
commit 350ec8da1e
4 changed files with 80 additions and 16 deletions

View File

@@ -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',
})
})
})

View File

@@ -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<FolderHint | null>
// 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;

View File

@@ -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() {
</Badge>
{folderHint && (
<span className="text-[10px] text-muted-foreground">
({t('import.source')}: {folderHintLabel(folderHint)})
({t('import.source')}: {folderHintLabel(folderHint)}{files.length > 1 && `, ${folderHint.fileName}`})
</span>
)}
</div>
@@ -659,16 +659,16 @@ export default function ImportPage() {
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{t('import.processed', { current: progress.success + progress.failed, total: progress.total })}
{t('import.processed', { current: progress.success + progress.failed + progress.duplicates, total: progress.total })}
</span>
<span>
{progress.total > 0
? Math.round(((progress.success + progress.failed) / progress.total) * 100)
? Math.round(((progress.success + progress.failed + progress.duplicates) / progress.total) * 100)
: 0}%
</span>
</div>
<Progress
value={progress.total > 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"
/>
</div>
@@ -684,6 +684,12 @@ export default function ImportPage() {
<AlertTriangle className="h-3.5 w-3.5 text-amber-600" />
{t('import.failedCount', { count: progress.failed })}
</span>
{progress.duplicates > 0 && (
<span className="flex items-center gap-1">
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
{t('import.duplicateCount', { count: progress.duplicates })}
</span>
)}
</div>
)}
@@ -712,7 +718,7 @@ export default function ImportPage() {
<div className="text-xs text-muted-foreground">
{isPstSelected
? t('import.pstFolders', 'PST folder structure will be preserved during import')
: (<>{t('import.willImportTo', 'Will import to')}: <span className="font-medium text-foreground">{effectiveFolder}</span></>)}
: (<>{t('import.willImportTo', 'Will import to')}: <span className="font-medium text-foreground">{effectiveFolder}</span>{files.length > 1 && <> · {t('import.fileCount', { count: files.length })}</>}</>)}
</div>
<Button
onClick={() => importMutation.mutate()}

View File

@@ -739,9 +739,11 @@
"detectedFolder": "Detected",
"detectedFrom": "Detected from",
"dropHere": "Drop .eml / .mbox / .pst files here",
"duplicateCount": "{{count}} duplicates skipped",
"failed": "Import failed",
"failedCount": "{{count}} failed",
"failedDetails": "Failed items",
"fileCount": "{{count}} files",
"folder": "Folder",
"folderMethod": "2. Choose folder method",
"folderMethodDesc": "How should the target mail folder be determined?",