Simpl-Resultat/src/hooks/useImportWizard.ts
le king fu ed3a4bb9a0 fix(import): name a source after its subfolder, read-only
The source name typed during configuration was saved, but the next import
looks the source and its imported files up by the subfolder name, so a
renamed source looked new: format guessed again, every file pre-checked,
and a second source created under the folder name.

The name field becomes a read-only SourceNameDisplay showing the subfolder
name with a hint, and executeImport writes selectedSource.folder_name on
create and update, whatever config.name holds, behind a selectedSource
guard. The import card sentence no longer asks to check the source name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:05:46 -04:00

1404 lines
51 KiB
TypeScript

import { useReducer, useCallback, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import Papa from "papaparse";
import type {
ImportWizardStep,
ImportFolderScan,
ScannedSource,
ScannedFile,
SourceConfig,
ParsedRow,
DuplicateCheckResult,
ImportReport,
ImportSource,
ImportConfigTemplate,
} from "../shared/types";
import {
getImportFolder,
setImportFolder,
} from "../services/userPreferenceService";
import {
getAllSources,
getSourceByName,
createSource,
updateSource,
} from "../services/importSourceService";
import {
existsByHash,
createImportedFile,
updateFileStatus,
getFilesBySourceId,
} from "../services/importedFileService";
import {
insertBatch,
findDuplicates,
} from "../services/transactionService";
import { categorizeBatch } from "../services/categorizationService";
import { notifyDataChanged } from "../services/dataEvents";
import {
getAllTemplates,
createTemplate,
updateTemplate,
deleteTemplate as deleteTemplateService,
} from "../services/importConfigTemplateService";
import {
preprocessQuotedCSV,
detectImportFormat as runAutoDetect,
type DetectionScore,
} from "../utils/csvAutoDetect";
import {
buildHeaderSignature,
detectHeaderDrift,
type BankSignatureId,
type HeaderDriftEntry,
} from "../utils/bankSignatures";
import {
detectAmountSeparators,
flipSignFormat,
formatFromRow,
formatToRow,
ImportFormatError,
mapRow,
ROW_ERROR_KEYS,
} from "../utils/importFormat";
import { logInfo } from "../services/logService";
/** Error text for the banner: an i18n key when we have one, the raw message otherwise. */
function errorMessage(e: unknown): string {
if (e instanceof ImportFormatError) return e.i18nKey;
return e instanceof Error ? e.message : String(e);
}
export interface WizardState {
step: ImportWizardStep;
importFolder: string | null;
/**
* The stored import folder has been read at mount, and scanned when there is
* one (#384, #398). Until then `importFolder` is null for lack of reading,
* not for lack of a folder: the empty state and the step card wait for it,
* or the "no folder" variant would flash. Set once, success or failure, and
* kept by `RESET`, which the mount read never runs again to restore.
*/
importFolderLoaded: boolean;
scannedSources: ScannedSource[];
/**
* Names of the CSV/TXT files placed directly at the root of the import
* folder, which no source reads (#427). Set with `scannedSources` by the
* same action, so the warning and the source list always come from the same
* scan; kept by `RESET` like them. Shown on screen only, never logged: a
* statement name often carries an account number.
*/
rootFiles: string[];
selectedSource: ScannedSource | null;
selectedFiles: ScannedFile[];
sourceConfig: SourceConfig;
existingSource: ImportSource | null;
parsedPreview: ParsedRow[];
previewHeaders: string[];
duplicateResult: DuplicateCheckResult | null;
excludedDuplicateIndices: Set<number>;
importReport: ImportReport | null;
importProgress: { current: number; total: number; file: string };
isLoading: boolean;
error: string | null;
configuredSourceNames: Set<string>;
importedFilesBySource: Map<string, Set<string>>;
configTemplates: ImportConfigTemplate[];
selectedTemplateId: number | null;
/**
* Result of the LAST detection run on the selected source, or null when none
* ran — a source opened on its stored format, which is never re-detected.
*/
detectionScore: DetectionScore | null;
/**
* Bank whose documented layout the last detection recognised (#330). Tied to
* `detectionScore`: it is set with it and cleared with it, so a badge naming
* a bank can never outlive the detection that named it.
*/
detectedBank: BankSignatureId | null;
/**
* How the header row of the files being imported differs from the one the
* source recorded at its last successful import — null when there is nothing
* to report, which includes every source that has no stored signature.
*/
formatDrift: HeaderDriftEntry[] | null;
/**
* The configuration re-detected on the drifted file, offered by the panel as
* "adopt". Null when detection could not read the new shape either: the drift
* is still worth reporting, there is just nothing to adopt.
*/
driftConfig: SourceConfig | null;
/**
* Names of the sample folders the last "create a sample folder" action wrote
* (#356), shown in the success banner. Null when there is nothing to report:
* initially, after the banner is dismissed, and whenever the import folder
* changes — a banner naming folders of another folder would be a lie.
*/
sampleCreated: string[] | null;
}
export type WizardAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_STEP"; payload: ImportWizardStep }
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
| { type: "SET_IMPORT_FOLDER_LOADED" }
| { type: "SET_SCANNED_SOURCES"; payload: ImportFolderScan }
| { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
| { type: "SET_SOURCE_CONFIG"; payload: SourceConfig }
| { type: "SET_EXISTING_SOURCE"; payload: ImportSource | null }
| { type: "SET_PARSED_PREVIEW"; payload: { rows: ParsedRow[]; headers: string[] } }
| { type: "SET_DUPLICATE_RESULT"; payload: DuplicateCheckResult }
| { type: "TOGGLE_DUPLICATE_ROW"; payload: number }
| { type: "SET_SKIP_ALL_DUPLICATES"; payload: boolean }
| { type: "SET_IMPORT_REPORT"; payload: ImportReport }
| { type: "SET_IMPORT_PROGRESS"; payload: { current: number; total: number; file: string } }
| { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } }
| { type: "SET_CONFIG_TEMPLATES"; payload: ImportConfigTemplate[] }
| { type: "SET_SELECTED_TEMPLATE_ID"; payload: number | null }
| { type: "SET_DETECTION_SCORE"; payload: DetectionScore | null }
| { type: "SET_DETECTED_BANK"; payload: BankSignatureId | null }
| {
type: "SET_FORMAT_DRIFT";
payload: { entries: HeaderDriftEntry[]; config: SourceConfig | null } | null;
}
| { type: "SET_SAMPLE_CREATED"; payload: string[] | null }
| { type: "RESET" };
const defaultConfig: SourceConfig = {
name: "",
delimiter: ";",
encoding: "utf-8",
dateFormat: "DD/MM/YYYY",
skipLines: 0,
columnMapping: { date: 0, description: 1, amount: 2 },
amountMode: "single",
signConvention: "negative_expense",
hasHeader: true,
};
const initialState: WizardState = {
step: "source-list",
importFolder: null,
importFolderLoaded: false,
scannedSources: [],
rootFiles: [],
selectedSource: null,
selectedFiles: [],
sourceConfig: { ...defaultConfig },
existingSource: null,
parsedPreview: [],
previewHeaders: [],
duplicateResult: null,
excludedDuplicateIndices: new Set(),
importReport: null,
importProgress: { current: 0, total: 0, file: "" },
isLoading: false,
error: null,
configuredSourceNames: new Set(),
importedFilesBySource: new Map(),
configTemplates: [],
selectedTemplateId: null,
detectionScore: null,
detectedBank: null,
formatDrift: null,
driftConfig: null,
sampleCreated: null,
};
/** Exported for the unit tests (#356): the hook is driven through this reducer. */
export const initialWizardState: WizardState = initialState;
export function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_STEP":
return { ...state, step: action.payload };
case "SET_IMPORT_FOLDER":
// The sample-folder banner describes the previous folder: drop it.
return { ...state, importFolder: action.payload, sampleCreated: null };
case "SET_IMPORT_FOLDER_LOADED":
return { ...state, importFolderLoaded: true };
case "SET_SCANNED_SOURCES":
return {
...state,
scannedSources: action.payload.sources,
rootFiles: action.payload.root_files,
isLoading: false,
};
case "SET_SELECTED_SOURCE":
// The score belongs to the source it was measured on. Carrying it over
// would let a source opened on its stored format inherit the confidence
// of the previous one, which is worse than showing nothing. The bank
// badge and the drift report are scoped the same way, for the same
// reason — both describe one source's files.
return {
...state,
selectedSource: action.payload,
detectionScore: null,
detectedBank: null,
formatDrift: null,
driftConfig: null,
};
case "SET_SELECTED_FILES":
return { ...state, selectedFiles: action.payload };
case "SET_SOURCE_CONFIG":
return { ...state, sourceConfig: action.payload };
case "SET_EXISTING_SOURCE":
return { ...state, existingSource: action.payload };
case "SET_PARSED_PREVIEW":
return {
...state,
parsedPreview: action.payload.rows,
previewHeaders: action.payload.headers,
isLoading: false,
};
case "SET_DUPLICATE_RESULT":
return {
...state,
duplicateResult: action.payload,
excludedDuplicateIndices: new Set(action.payload.duplicateRows.map((d) => d.rowIndex)),
isLoading: false,
};
case "TOGGLE_DUPLICATE_ROW": {
const next = new Set(state.excludedDuplicateIndices);
if (next.has(action.payload)) {
next.delete(action.payload);
} else {
next.add(action.payload);
}
return { ...state, excludedDuplicateIndices: next };
}
case "SET_SKIP_ALL_DUPLICATES":
return {
...state,
excludedDuplicateIndices: action.payload
? new Set(state.duplicateResult?.duplicateRows.map((d) => d.rowIndex) ?? [])
: new Set(),
};
case "SET_IMPORT_REPORT":
return { ...state, importReport: action.payload, isLoading: false };
case "SET_IMPORT_PROGRESS":
return { ...state, importProgress: action.payload };
case "SET_CONFIGURED_SOURCES":
return {
...state,
configuredSourceNames: action.payload.names,
importedFilesBySource: action.payload.files,
};
case "SET_CONFIG_TEMPLATES":
return { ...state, configTemplates: action.payload };
case "SET_SELECTED_TEMPLATE_ID":
return { ...state, selectedTemplateId: action.payload };
case "SET_DETECTION_SCORE":
// Clearing the score clears the bank with it, in ONE place: the three
// sites that drop the score (a hand edit, a template, a sign flip) then
// cannot leave "Format Desjardins reconnu" standing over a format
// detection never produced. A bank is only ever set by dispatching
// `SET_DETECTED_BANK` right after a non-null score.
return {
...state,
detectionScore: action.payload,
detectedBank: action.payload === null ? null : state.detectedBank,
};
case "SET_DETECTED_BANK":
return { ...state, detectedBank: action.payload };
case "SET_FORMAT_DRIFT":
return {
...state,
formatDrift: action.payload?.entries ?? null,
driftConfig: action.payload?.config ?? null,
};
case "SET_SAMPLE_CREATED":
return { ...state, sampleCreated: action.payload };
case "RESET":
// Rebuilt from `initialState`, minus what the page read once and keeps:
// the folder, whether it was read (#384: the mount read does not run
// again, so "Cancel" or "Done" would otherwise hide the empty state and
// the step card for good), its scan (sources and root files, #427), the
// configured sources and the templates.
return {
...initialState,
importFolder: state.importFolder,
importFolderLoaded: state.importFolderLoaded,
scannedSources: state.scannedSources,
rootFiles: state.rootFiles,
configuredSourceNames: state.configuredSourceNames,
importedFilesBySource: state.importedFilesBySource,
configTemplates: state.configTemplates,
};
default:
return state;
}
}
/**
* The webview's LOCAL calendar date as `YYYY-MM-DD` (#356). Built from the
* local getters, never `toISOString()`, which is UTC and would hand the Rust
* side yesterday's date to anyone west of Greenwich in the evening.
*/
export function formatLocalDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function useImportWizard() {
const [state, dispatch] = useReducer(wizardReducer, initialState);
// Load import folder on mount
useEffect(() => {
(async () => {
try {
const folder = await getImportFolder();
dispatch({ type: "SET_IMPORT_FOLDER", payload: folder });
if (folder) {
await scanFolderInternal(folder);
}
} catch {
// No folder configured yet
} finally {
// Read, and scanned when there is a folder (#384): the empty state and
// the step card can tell "no folder" from "not read yet". After the
// scan, so the "no source" variant does not flash either.
dispatch({ type: "SET_IMPORT_FOLDER_LOADED" });
}
})();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadConfiguredSources = useCallback(async () => {
const sources = await getAllSources();
const names = new Set(sources.map((s) => s.name));
const files = new Map<string, Set<string>>();
for (const source of sources) {
const imported = await getFilesBySourceId(source.id);
files.set(
source.name,
new Set(imported.map((f) => f.filename))
);
}
dispatch({ type: "SET_CONFIGURED_SOURCES", payload: { names, files } });
const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, []);
const scanFolderInternal = useCallback(
async (folder: string) => {
dispatch({ type: "SET_LOADING", payload: true });
try {
// The root file names stay out of the logs (#427): on screen only.
const scan = await invoke<ImportFolderScan>("scan_import_folder", {
folderPath: folder,
});
dispatch({ type: "SET_SCANNED_SOURCES", payload: scan });
await loadConfiguredSources();
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
},
[loadConfiguredSources]
);
const browseFolder = useCallback(async () => {
try {
const folder = await invoke<string | null>("pick_folder");
if (folder) {
await setImportFolder(folder);
dispatch({ type: "SET_IMPORT_FOLDER", payload: folder });
await scanFolderInternal(folder);
}
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [scanFolderInternal]);
const refreshFolder = useCallback(async () => {
if (state.importFolder) {
await scanFolderInternal(state.importFolder);
}
}, [state.importFolder, scanFolderInternal]);
/**
* Write the two sample bank folders into the import folder (#356), rescan
* it so their cards show up without another click, then raise the success
* banner. The Rust command refuses when either folder already exists and
* writes nothing; its message lands in the regular error banner. Only the
* folder NAMES reach the logs: the logs travel with a feedback report, the
* absolute path of the import folder must not.
*/
const createSampleFolders = useCallback(async () => {
const folder = state.importFolder;
if (!folder) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const names = await invoke<string[]>("create_sample_import_folders", {
importFolder: folder,
today: formatLocalDate(new Date()),
});
logInfo(`[import] Sample folders created: ${names.join(", ")}`);
await scanFolderInternal(folder);
dispatch({ type: "SET_SAMPLE_CREATED", payload: names });
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.importFolder, scanFolderInternal]);
const dismissSampleCreated = useCallback(() => {
dispatch({ type: "SET_SAMPLE_CREATED", payload: null });
}, []);
/**
* Run detection on one file and report the outcome. Returns the detected
* format merged onto `base`, or null when detection produced no usable
* configuration (the reason is dispatched as the page error).
*
* THE single detection path, shared by the two things that start one: the
* magic-wand button and the automatic run on a source that has never been
* configured. Everything it needs is a parameter, so it never reads a piece of
* `state` its caller has just dispatched but not yet re-rendered — which is
* exactly why the automatic run could not simply call the button's callback.
*
* `base` keeps the fields detection does not decide (the source name, the
* encoding), and the spread of `outcome.config` writes every field it does —
* naming them one by one is how a field gets silently dropped (#324).
*/
const detectFormatForFile = useCallback(
async (filePath: string, base: SourceConfig): Promise<SourceConfig | null> => {
const content = await invoke<string>("read_file_content", {
filePath,
encoding: base.encoding,
});
const outcome = runAutoDetect(content);
if (outcome.status !== "ok") {
// A refused format states WHY (#327): "I cannot read this file" and
// "this file is a shape this version would import backwards" are not
// the same news, and only the second one is the app's own limitation.
// `SET_ERROR` clears the loading flag on its own.
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
dispatch({
type: "SET_ERROR",
payload:
outcome.status === "rejected"
? outcome.reason
: "import.errors.autoDetectFailed",
});
return null;
}
dispatch({ type: "SET_DETECTION_SCORE", payload: outcome.score });
// Always dispatched, including the `null` of an unknown file: a bank left
// over from the previously detected file would name the wrong bank.
dispatch({ type: "SET_DETECTED_BANK", payload: outcome.bank });
return { ...base, ...outcome.config };
},
[]
);
const selectSource = useCallback(
async (source: ScannedSource) => {
// The banner is page-wide: an error left by the previous source must not
// survive into this one. Cleared here, before anything that may set a new
// one — a stored format that will not decode, a detection that refuses.
dispatch({ type: "SET_ERROR", payload: null });
// Sort files: new files first, then already-imported
const importedNames = state.importedFilesBySource.get(source.folder_name);
const sorted = [...source.files].sort((a, b) => {
const aImported = importedNames?.has(a.filename) ?? false;
const bImported = importedNames?.has(b.filename) ?? false;
if (aImported !== bImported) return aImported ? 1 : -1;
return a.filename.localeCompare(b.filename);
});
const sortedSource = { ...source, files: sorted };
// Pre-select only new files
const newFiles = sorted.filter((f) => !importedNames?.has(f.filename));
dispatch({ type: "SET_SELECTED_SOURCE", payload: sortedSource });
dispatch({ type: "SET_SELECTED_FILES", payload: newFiles });
try {
// Check if this source already has config in DB
const existing = await getSourceByName(source.folder_name);
dispatch({ type: "SET_EXISTING_SOURCE", payload: existing });
// Provenance of the stored format, not the format itself: the template
// is never re-read, it is only shown as the source's origin. Blindly
// resetting it to null used to make an already-linked source look
// unconfigured.
dispatch({
type: "SET_SELECTED_TEMPLATE_ID",
payload: existing?.template_id ?? null,
});
let activeDelimiter = defaultConfig.delimiter;
let activeEncoding = "utf-8";
let activeSkipLines = 0;
let activeHasHeader = true;
// Restore the format as it was SAVED. Nothing is re-inferred and
// nothing is defaulted here — the amount mode and the sign convention
// are columns now (v17), and reading them is the whole point of #324.
let restored: SourceConfig | null = null;
if (existing) {
try {
restored = { name: existing.name, ...formatFromRow(existing) };
} catch (e) {
// A stored format we cannot decode is REPORTED, not quietly swapped
// for a plausible default — that substitution is how amounts got
// flipped. The wizard then opens on a fresh configuration, because
// "reconfigure this source" has to be an action the user can take.
dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
}
}
if (restored) {
dispatch({ type: "SET_SOURCE_CONFIG", payload: restored });
activeDelimiter = restored.delimiter;
activeEncoding = restored.encoding;
activeSkipLines = restored.skipLines;
activeHasHeader = restored.hasHeader;
} else {
// Auto-detect encoding for first file
if (source.files.length > 0) {
try {
activeEncoding = await invoke<string>("detect_encoding", {
filePath: source.files[0].file_path,
});
} catch {
// fallback to utf-8
}
}
const fresh: SourceConfig = {
...defaultConfig,
name: source.folder_name,
encoding: activeEncoding,
};
// Detection fires on its own HERE and nowhere else: the arm reached
// when the source has no stored format at all. `defaultConfig` is
// `;` + `DD/MM/YYYY` + columns 0/1/2 — plausible enough to import a
// whole file wrong rather than fail, so leaving a never-configured
// source sitting on it is the defect (#328).
//
// The condition is `!existing`, not `!restored`: a source whose stored
// format will not decode already carries an explicit error, and
// re-detecting over it would replace that message with a silent guess.
// Its wand button is still there.
let active = fresh;
if (!existing && source.files.length > 0) {
const detected = await detectFormatForFile(
source.files[0].file_path,
fresh
);
if (detected) active = detected;
}
dispatch({ type: "SET_SOURCE_CONFIG", payload: active });
activeDelimiter = active.delimiter;
activeSkipLines = active.skipLines;
activeHasHeader = active.hasHeader;
}
// Load preview headers from first file
if (source.files.length > 0) {
await loadHeadersWithConfig(
source.files[0].file_path,
activeDelimiter,
activeEncoding,
activeSkipLines,
activeHasHeader
);
}
dispatch({ type: "SET_STEP", payload: "source-config" });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
}
},
[state.importedFilesBySource] // eslint-disable-line react-hooks/exhaustive-deps
);
const loadHeadersWithConfig = useCallback(
async (filePath: string, delimiter: string, encoding: string, skipLines: number, hasHeader: boolean) => {
try {
const preview = await invoke<string>("get_file_preview", {
filePath,
encoding,
maxLines: skipLines + 5,
});
const preprocessed = preprocessQuotedCSV(preview);
const parsed = Papa.parse(preprocessed, { delimiter, skipEmptyLines: true });
const data = parsed.data as string[][];
const headerRow = hasHeader && data.length > skipLines ? skipLines : -1;
if (headerRow >= 0 && data[headerRow]) {
dispatch({
type: "SET_PARSED_PREVIEW",
payload: {
rows: [],
headers: data[headerRow].map((h) => h.trim()),
},
});
} else if (data.length > 0) {
// No header row — generate column indices as headers
const firstDataRow = data[skipLines] || data[0];
dispatch({
type: "SET_PARSED_PREVIEW",
payload: {
rows: [],
headers: firstDataRow.map((_, i) => `Col ${i}`),
},
});
}
} catch {
// ignore preview errors
}
},
[]
);
const updateConfig = useCallback(
(config: SourceConfig) => {
dispatch({ type: "SET_SOURCE_CONFIG", payload: config });
// The score measures the format DETECTION produced. The moment any of
// those eight fields is edited by hand, the number stops describing what
// the wizard would read — a banner still claiming "147 of 150 rows" over
// a mapping nobody measured is the same misinformation this chantier is
// removing. Comparing through the codec keeps a rename (the ninth field,
// which changes nothing about reading) from clearing it.
if (
JSON.stringify(formatToRow(config)) !==
JSON.stringify(formatToRow(state.sourceConfig))
) {
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
}
// Reload headers when delimiter, encoding, skipLines, or hasHeader changes
if (state.selectedFiles.length > 0) {
loadHeadersWithConfig(
state.selectedFiles[0].file_path,
config.delimiter,
config.encoding,
config.skipLines,
config.hasHeader
);
}
},
[state.selectedFiles, state.sourceConfig, loadHeadersWithConfig]
);
const toggleFile = useCallback(
(file: ScannedFile) => {
const exists = state.selectedFiles.some(
(f) => f.file_path === file.file_path
);
if (exists) {
dispatch({
type: "SET_SELECTED_FILES",
payload: state.selectedFiles.filter(
(f) => f.file_path !== file.file_path
),
});
} else {
dispatch({
type: "SET_SELECTED_FILES",
payload: [...state.selectedFiles, file],
});
}
},
[state.selectedFiles]
);
const selectAllFiles = useCallback(() => {
if (state.selectedSource) {
const importedNames = state.importedFilesBySource.get(state.selectedSource.folder_name);
const newFiles = importedNames
? state.selectedSource.files.filter((f) => !importedNames.has(f.filename))
: state.selectedSource.files;
dispatch({
type: "SET_SELECTED_FILES",
payload: newFiles,
});
}
}, [state.selectedSource, state.importedFilesBySource]);
// Internal helper: parses selected files and returns rows + headers.
//
// `configOverride` exists for the sign flip: it re-parses under a format that
// has just been dispatched and is therefore not yet readable in `state`.
// Reading the stale one would redisplay the exact table the user asked to
// correct, which reads as "the button did nothing".
const parseFilesInternal = useCallback(async (
configOverride?: SourceConfig
): Promise<{ rows: ParsedRow[]; headers: string[] }> => {
const config = configOverride ?? state.sourceConfig;
const allRows: ParsedRow[] = [];
let headers: string[] = [];
for (const file of state.selectedFiles) {
const content = await invoke<string>("read_file_content", {
filePath: file.file_path,
encoding: config.encoding,
});
const preprocessed = preprocessQuotedCSV(content);
const parsed = Papa.parse(preprocessed, {
delimiter: config.delimiter,
skipEmptyLines: true,
});
const data = parsed.data as string[][];
const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);
if (config.hasHeader && data.length > config.skipLines) {
headers = data[config.skipLines].map((h) => h.trim());
} else if (!config.hasHeader && headers.length === 0 && data.length > config.skipLines) {
const firstDataRow = data[config.skipLines];
headers = firstDataRow.map((_, i) => `Col ${i}`);
}
// Data rows of THIS file, kept aside so the decimal separator of each
// amount column is arbitrated over the whole column before any row is
// read. `1.234` alone is ambiguous; the column is not.
const dataRows: string[][] = [];
for (let i = startIdx; i < data.length; i++) {
const raw = data[i];
if (raw.length <= 1 && raw[0]?.trim() === "") continue;
dataRows.push(raw);
}
const decimalSeparators = detectAmountSeparators(dataRows, config);
for (const raw of dataRows) {
try {
allRows.push(
mapRow(raw, config, {
rowIndex: allRows.length,
sourceFilename: file.filename,
decimalSeparators,
})
);
} catch {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: ROW_ERROR_KEYS.parseError,
sourceFilename: file.filename,
});
}
}
}
return { rows: allRows, headers };
}, [state.selectedFiles, state.sourceConfig]);
// Parse the selected files and STOP at the preview.
//
// Every import goes through that step now (#329). It used to be an optional
// modal nobody had to open, next to a button that went straight to the
// duplicate check — so a file read under a wrong convention reached the
// database without anyone ever seeing a total. The step is unconditional on
// purpose: the detection score does not gate it, because a perfect score says
// nothing about the sign of what was read.
const parseAndPreview = useCallback(async () => {
if (state.selectedFiles.length === 0) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const result = await parseFilesInternal();
dispatch({
type: "SET_PARSED_PREVIEW",
payload: result,
});
// Format drift (#330). Compared HERE and not at the configuration step
// because this is where the header of the files actually being imported
// is known — `previewHeaders` at the configuration step is read from the
// first file alone, under a delimiter the user may still be editing.
//
// `hasHeader` gates the whole thing: a headerless file has `Col 0`,
// `Col 1` … for headers, which is not a signature and must never be
// compared to one.
const drift = state.sourceConfig.hasHeader
? detectHeaderDrift(
state.existingSource?.header_signature,
result.headers
)
: null;
if (drift) {
// Re-detect on the drifted file so the panel has something to offer.
// This is the THIRD caller of the single detection path, and it goes
// through it rather than around it on purpose: a second detector is the
// divergence class this whole chantier removes. A file the re-detection
// cannot read leaves `null` — the drift is still reported, with nothing
// to adopt.
const reDetected = await detectFormatForFile(
state.selectedFiles[0].file_path,
state.sourceConfig
);
// That run measured the RE-DETECTED format, which is not the one the
// wizard is using — it is only what the panel offers. Leaving its score
// and its bank badge behind would put "Format reconnu — 6 of 6 rows
// read" next to the stored mapping the moment the user steps back to
// the configuration. Same rule as the sign flip: a badge describes the
// format in use or it describes nothing.
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
dispatch({
type: "SET_FORMAT_DRIFT",
payload: { entries: drift, config: reDetected },
});
} else {
dispatch({ type: "SET_FORMAT_DRIFT", payload: null });
}
dispatch({ type: "SET_STEP", payload: "file-preview" });
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [
state.selectedFiles,
state.sourceConfig,
state.existingSource,
parseFilesInternal,
detectFormatForFile,
]);
// Internal helper: runs duplicate checking against parsed rows.
//
// Deliberately writes NOTHING. The source config used to be saved here, so an
// import abandoned at the duplicate step still left a — possibly wrong —
// configuration behind. It is written by `executeImport` now (#324).
const checkDuplicatesInternal = useCallback(async (parsedRows: ParsedRow[]) => {
// Check file-level duplicates (check ALL selected files, not just the first)
let fileAlreadyImported = false;
let existingFileId: number | undefined;
for (const file of state.selectedFiles) {
const hash = await invoke<string>("hash_file", {
filePath: file.file_path,
});
const existing = await existsByHash(hash);
if (existing) {
fileAlreadyImported = true;
existingFileId = existing.id;
break;
}
}
// Check row-level duplicates against DB
const validRows = parsedRows.filter((r) => r.parsed);
const duplicateMatches = await findDuplicates(
validRows.map((r) => ({
date: r.parsed!.date,
description: r.parsed!.description,
amount: r.parsed!.amount,
}))
);
const dbDuplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
const duplicateRows = duplicateMatches.map((d) => ({
rowIndex: d.rowIndex,
date: d.date,
description: d.description,
amount: d.amount,
existingTransactionId: d.existingTransactionId,
}));
// Cross-file duplicate detection: find rows that appear in multiple source files
const seenKeys = new Map<string, number>(); // key → first-seen validRows index
for (let i = 0; i < validRows.length; i++) {
if (dbDuplicateIndices.has(i)) continue; // already flagged as DB duplicate
const row = validRows[i];
const key = `${row.parsed!.date}|${row.parsed!.description}|${row.parsed!.amount}`;
const firstIdx = seenKeys.get(key);
if (firstIdx !== undefined) {
// Only flag as cross-file duplicate if rows come from different files
if (validRows[firstIdx].sourceFilename !== row.sourceFilename) {
duplicateRows.push({
rowIndex: i,
date: row.parsed!.date,
description: row.parsed!.description,
amount: row.parsed!.amount,
existingTransactionId: -1, // signals "within batch" in the UI
});
dbDuplicateIndices.add(i);
}
} else {
seenKeys.set(key, i);
}
}
const newRows = validRows.filter(
(_, i) => !dbDuplicateIndices.has(i)
);
dispatch({
type: "SET_DUPLICATE_RESULT",
payload: {
fileAlreadyImported,
existingFileId,
duplicateRows,
newRows,
},
});
dispatch({ type: "SET_STEP", payload: "duplicate-check" });
}, [state.selectedFiles]);
// Leave the preview for the duplicate check, on the rows it just displayed.
//
// Dead code until #329: the wizard jumped from the configuration straight to
// the duplicates, re-parsing on the way. It is the preview step's "next"
// button now, so the rows the user validated are the rows that get checked —
// no second parse can quietly read the file differently in between.
const checkDuplicates = useCallback(async () => {
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
await checkDuplicatesInternal(state.parsedPreview);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.parsedPreview, checkDuplicatesInternal]);
/**
* Read the file the other way round, and remember it.
*
* The correction lands on the CONFIGURATION (`flipSignFormat`, which knows
* that debit/credit flips by swapping columns rather than by toggling a
* convention `mapRow` ignores there), so it is persisted with the source at
* import time and the next file from that bank reads right on its own.
*
* Headers are deliberately NOT reloaded: a flip touches neither delimiter,
* encoding, skipped lines nor header flag, and `loadHeadersWithConfig`
* dispatches an empty row list — racing it against this re-parse is a coin
* toss between the corrected table and a blank one.
*/
const flipSignConvention = useCallback(async () => {
const flipped: SourceConfig = {
...state.sourceConfig,
...flipSignFormat(state.sourceConfig),
};
dispatch({ type: "SET_SOURCE_CONFIG", payload: flipped });
// Detection did not produce this format. Keeping its badge would vouch for
// a configuration it never measured — the same rule as a manual edit.
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const result = await parseFilesInternal(flipped);
dispatch({
type: "SET_PARSED_PREVIEW",
payload: result,
});
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.sourceConfig, parseFilesInternal]);
const executeImport = useCallback(async () => {
if (!state.duplicateResult || !state.selectedSource) return;
dispatch({ type: "SET_STEP", payload: "importing" });
dispatch({ type: "SET_ERROR", payload: null });
try {
const config = state.sourceConfig;
// Persist the format — the ONLY write point. It happens here rather than
// at the duplicate step so an abandoned import leaves no configuration
// behind, and it goes through `formatToRow` so no field can be dropped.
// It has to precede the file records, which carry a `source_id` FK.
const formatRow = formatToRow(config);
// The header row this import actually read, recorded so the NEXT one can
// be compared against it (#330). It is not a format field and does not go
// through the codec: it says what the file looked like, not how to read
// it. A headerless file writes null — `previewHeaders` holds `Col 0`,
// `Col 1` … there, and storing that would make every later import look
// like drift.
const headerSignature = buildHeaderSignature(
config.hasHeader ? state.previewHeaders : null
);
// A source is named after its subfolder (#426): the next import looks it
// up, and its imported files, by `folder_name`, so a name typed during
// configuration would make it look new. `config.name` is never written.
const sourceName = state.selectedSource.folder_name;
let sourceId: number;
if (state.existingSource) {
sourceId = state.existingSource.id;
await updateSource(sourceId, {
name: sourceName,
...formatRow,
header_signature: headerSignature,
template_id: state.selectedTemplateId,
});
} else {
sourceId = await createSource({
name: sourceName,
...formatRow,
header_signature: headerSignature,
template_id: state.selectedTemplateId,
});
}
// Determine rows to import: new rows + non-excluded duplicates
const includedDuplicates = state.duplicateResult.duplicateRows
.filter((d) => !state.excludedDuplicateIndices.has(d.rowIndex));
const rowsToImport = [
...state.duplicateResult.newRows,
...state.parsedPreview.filter(
(r) =>
r.parsed &&
includedDuplicates.some((d) => d.rowIndex === r.rowIndex)
),
];
const validRows = rowsToImport.filter((r) => r.parsed);
const totalRows = validRows.length;
dispatch({
type: "SET_IMPORT_PROGRESS",
payload: { current: 0, total: totalRows, file: state.selectedFiles[0]?.filename || "" },
});
// Create one imported_files record per file
const fileIdMap = new Map<string, number>();
for (const file of state.selectedFiles) {
const hash = await invoke<string>("hash_file", {
filePath: file.file_path,
});
const rowCount = validRows.filter((r) => r.sourceFilename === file.filename).length;
const fId = await createImportedFile({
source_id: sourceId,
filename: file.filename,
file_hash: hash,
row_count: rowCount,
status: "completed",
});
fileIdMap.set(file.filename, fId);
}
// Auto-categorize
const descriptions = validRows.map((r) => r.parsed!.description);
const categorizations = await categorizeBatch(descriptions);
let categorizedCount = 0;
let uncategorizedCount = 0;
const errors: Array<{ rowIndex: number; message: string }> = [];
// Build transaction records
const transactions = validRows.map((row, i) => {
const cat = categorizations[i];
if (cat.category_id) {
categorizedCount++;
} else {
uncategorizedCount++;
}
return {
date: row.parsed!.date,
description: row.parsed!.description,
amount: row.parsed!.amount,
source_id: sourceId,
file_id: fileIdMap.get(row.sourceFilename || "") ?? 0,
original_description: row.raw.join(config.delimiter),
category_id: cat.category_id,
supplier_id: cat.supplier_id,
};
});
// Insert with progress
let importedCount = 0;
try {
importedCount = await insertBatch(transactions, (inserted) => {
const currentFile = validRows[inserted - 1]?.sourceFilename || "";
dispatch({
type: "SET_IMPORT_PROGRESS",
payload: { current: inserted, total: totalRows, file: currentFile },
});
});
dispatch({
type: "SET_IMPORT_PROGRESS",
payload: { current: importedCount, total: totalRows, file: "done" },
});
} catch (e) {
// Update status on all file records on error
for (const fId of fileIdMap.values()) {
await updateFileStatus(fId, "error", 0, String(e));
}
errors.push({
rowIndex: 0,
message: e instanceof Error ? e.message : String(e),
});
}
// Count errors from parsing
const parseErrors = state.parsedPreview.filter((r) => r.error);
for (const err of parseErrors) {
errors.push({
rowIndex: err.rowIndex,
message: err.error || ROW_ERROR_KEYS.parseError,
});
}
const report: ImportReport = {
totalRows: state.parsedPreview.length,
importedCount,
skippedDuplicates: state.excludedDuplicateIndices.size,
errorCount: errors.length,
categorizedCount,
uncategorizedCount,
errors,
};
dispatch({ type: "SET_IMPORT_REPORT", payload: report });
dispatch({ type: "SET_STEP", payload: "report" });
// Refresh configured sources
await loadConfiguredSources();
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
dispatch({ type: "SET_STEP", payload: "confirm" });
} finally {
// One more announcement once the import is over, succeeded or not, so
// the last re-read of the getting-started path sees its final state:
// inserts done, file statuses settled (ADR 0022).
notifyDataChanged();
}
}, [
state.duplicateResult,
state.selectedSource,
state.sourceConfig,
state.existingSource,
state.selectedTemplateId,
state.excludedDuplicateIndices,
state.parsedPreview,
state.selectedFiles,
loadConfiguredSources,
]);
const goToStep = useCallback((step: ImportWizardStep) => {
dispatch({ type: "SET_STEP", payload: step });
}, []);
const reset = useCallback(() => {
dispatch({ type: "RESET" });
}, []);
/**
* Take the format re-detected on the drifted file, and re-read the preview
* under it (#330).
*
* Same shape as the sign flip, and for the same reason: the adopted format is
* PASSED to the parse instead of being read back from `state`, which has not
* re-rendered yet. Reading the stale one would redisplay the table the user
* just chose to replace.
*
* Nothing is written here. The adopted format reaches `import_sources` at
* `executeImport` like every other configuration — an import abandoned on
* this screen leaves the stored format exactly as it was.
*/
const adoptDriftFormat = useCallback(async () => {
const adopted = state.driftConfig;
if (!adopted) return;
dispatch({ type: "SET_SOURCE_CONFIG", payload: adopted });
dispatch({ type: "SET_FORMAT_DRIFT", payload: null });
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const result = await parseFilesInternal(adopted);
dispatch({ type: "SET_PARSED_PREVIEW", payload: result });
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.driftConfig, parseFilesInternal]);
/**
* Keep the stored configuration and dismiss the panel.
*
* The rows on screen were already parsed under that configuration, so there
* is nothing to re-read. The stored signature is still refreshed at import
* time: the new header IS what this import read, whichever format read it.
*/
const keepCurrentFormat = useCallback(() => {
dispatch({ type: "SET_FORMAT_DRIFT", payload: null });
}, []);
const autoDetectConfig = useCallback(async () => {
if (state.selectedFiles.length === 0) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const filePath = state.selectedFiles[0].file_path;
// Same path the automatic run takes, so the button REPLAYS detection
// rather than running a second, drifting variant of it.
const newConfig = await detectFormatForFile(filePath, state.sourceConfig);
if (!newConfig) return; // reason already dispatched, loading already off
dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
dispatch({ type: "SET_LOADING", payload: false });
// Refresh column headers with new config
await loadHeadersWithConfig(
filePath,
newConfig.delimiter,
newConfig.encoding,
newConfig.skipLines,
newConfig.hasHeader
);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [
state.selectedFiles,
state.sourceConfig,
detectFormatForFile,
loadHeadersWithConfig,
]);
const saveConfigAsTemplate = useCallback(async (name: string) => {
await createTemplate({ name, ...formatToRow(state.sourceConfig) });
const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, [state.sourceConfig]);
const applyConfigTemplate = useCallback((templateId: number) => {
const template = state.configTemplates.find((t) => t.id === templateId);
if (!template) return;
let newConfig: SourceConfig;
try {
newConfig = {
name: state.sourceConfig.name,
...formatFromRow(template),
};
} catch (e) {
dispatch({ type: "SET_ERROR", payload: errorMessage(e) });
return;
}
dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
// Applying a template COPIES its format onto the source. The id recorded
// here is provenance only — the copy is what the next import reads.
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: templateId });
// The format is the template's now, so a score measured on the detected
// one would be vouching for a configuration it never saw.
dispatch({ type: "SET_DETECTION_SCORE", payload: null });
// Reload headers with new config
if (state.selectedFiles.length > 0) {
loadHeadersWithConfig(
state.selectedFiles[0].file_path,
newConfig.delimiter,
newConfig.encoding,
newConfig.skipLines,
newConfig.hasHeader
);
}
}, [state.configTemplates, state.sourceConfig.name, state.selectedFiles, loadHeadersWithConfig]);
const updateConfigTemplate = useCallback(async () => {
if (!state.selectedTemplateId) return;
const template = state.configTemplates.find((t) => t.id === state.selectedTemplateId);
if (!template) return;
// Writes to `import_config_templates` only: a source configured from this
// template keeps its own eight columns and is untouched.
await updateTemplate(state.selectedTemplateId, {
name: template.name,
...formatToRow(state.sourceConfig),
});
const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, [state.selectedTemplateId, state.configTemplates, state.sourceConfig]);
const deleteConfigTemplate = useCallback(async (id: number) => {
await deleteTemplateService(id);
if (state.selectedTemplateId === id) {
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: null });
}
const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, [state.selectedTemplateId]);
return {
state,
browseFolder,
refreshFolder,
createSampleFolders,
dismissSampleCreated,
selectSource,
updateConfig,
toggleFile,
selectAllFiles,
parseAndPreview,
checkDuplicates,
flipSignConvention,
executeImport,
goToStep,
reset,
adoptDriftFormat,
keepCurrentFormat,
autoDetectConfig,
saveConfigAsTemplate,
applyConfigTemplate,
updateConfigTemplate,
deleteConfigTemplate,
toggleDuplicateRow: (index: number) =>
dispatch({ type: "TOGGLE_DUPLICATE_ROW", payload: index }),
setSkipAllDuplicates: (skipAll: boolean) =>
dispatch({ type: "SET_SKIP_ALL_DUPLICATES", payload: skipAll }),
};
}