feat(import): warn about the CSV files placed directly in the import folder

scan_import_folder now returns ImportFolderScan { sources, root_files }:
the CSV/TXT files at the root, which no source reads, are named in a
warning on the Import page (above the source list, or under the "no
source" empty state). One action sets sources and root files, RESET keeps
both. Names stay on screen, never in the Journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-13 20:59:20 -04:00
parent af884ca13b
commit 7d3a616312
14 changed files with 533 additions and 39 deletions

View file

@ -22,14 +22,39 @@ pub struct ScannedSource {
pub files: Vec<ScannedFile>,
}
/// Result of a scan of the import folder: the sources (one per direct
/// subfolder) and the names of the statement files placed directly at the
/// root, which no source reads and the Import page names in a warning (#427).
#[derive(Debug, Serialize, Clone)]
pub struct ImportFolderScan {
pub sources: Vec<ScannedSource>,
pub root_files: Vec<String>,
}
/// True when the file name has a statement extension (`csv` or `txt`, any
/// case), the same rule for the root and for the subfolders.
fn has_statement_extension(path: &Path) -> bool {
let ext = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
ext == "csv" || ext == "txt"
}
/// Scans the import folder: each non-hidden direct subfolder is a source with
/// its direct CSV/TXT files, sorted by name. CSV/TXT files placed directly at
/// the root are read by no source; their names (non-hidden, sorted) are
/// returned in `root_files` so the Import page can warn about them (#427).
#[tauri::command]
pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, String> {
pub fn scan_import_folder(folder_path: String) -> Result<ImportFolderScan, String> {
let root = Path::new(&folder_path);
if !root.is_dir() {
return Err(format!("Folder does not exist: {}", folder_path));
}
let mut sources: Vec<ScannedSource> = Vec::new();
let mut root_files: Vec<String> = Vec::new();
let entries = fs::read_dir(root).map_err(|e| format!("Cannot read folder: {}", e))?;
@ -38,6 +63,16 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
let path = entry.path();
if !path.is_dir() {
if path.is_file() && has_statement_extension(&path) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !name.starts_with('.') {
root_files.push(name);
}
}
continue;
}
@ -60,13 +95,7 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
continue;
}
let ext = file_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if ext != "csv" && ext != "txt" {
if !has_statement_extension(file_path) {
continue;
}
@ -105,8 +134,12 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
}
sources.sort_by(|a, b| a.folder_name.cmp(&b.folder_name));
root_files.sort();
Ok(sources)
Ok(ImportFolderScan {
sources,
root_files,
})
}
#[tauri::command]
@ -427,3 +460,94 @@ mod sample_folder_tests {
assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026"));
}
}
#[cfg(test)]
mod scan_import_folder_tests {
use super::*;
use std::path::PathBuf;
/// One scratch directory per case: the cases run in parallel in one process.
fn sandbox(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"simpl-resultat-scan-{}-{}",
std::process::id(),
name
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("sandbox created");
dir
}
fn touch(path: &Path) {
fs::write(path, "Date;Montant\n").expect("file written");
}
fn scan(dir: &Path) -> ImportFolderScan {
scan_import_folder(dir.to_string_lossy().to_string()).expect("scan succeeds")
}
#[test]
fn lists_the_root_statement_files_sorted_by_name() {
let dir = sandbox("root-sorted");
touch(&dir.join("b.csv"));
touch(&dir.join("a.csv"));
touch(&dir.join("c.TXT"));
fs::create_dir_all(dir.join("visa")).unwrap();
touch(&dir.join("visa").join("releve.csv"));
let result = scan(&dir);
assert_eq!(result.root_files, vec!["a.csv", "b.csv", "c.TXT"]);
assert_eq!(result.sources.len(), 1);
assert_eq!(result.sources[0].folder_name, "visa");
assert_eq!(result.sources[0].files.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn keeps_subfolder_files_out_of_root_files() {
let dir = sandbox("subfolder-only");
fs::create_dir_all(dir.join("cheques")).unwrap();
touch(&dir.join("cheques").join("a.csv"));
let result = scan(&dir);
assert!(result.root_files.is_empty());
assert_eq!(result.sources[0].files[0].filename, "a.csv");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn ignores_other_extensions_and_hidden_files_at_the_root() {
let dir = sandbox("ignored");
touch(&dir.join("notes.pdf"));
touch(&dir.join("noextension"));
touch(&dir.join(".hidden.csv"));
touch(&dir.join("kept.Csv"));
let result = scan(&dir);
assert_eq!(result.root_files, vec!["kept.Csv"]);
assert!(result.sources.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn returns_no_root_file_for_a_root_without_files() {
let dir = sandbox("empty-root");
fs::create_dir_all(dir.join("visa")).unwrap();
let result = scan(&dir);
assert!(result.root_files.is_empty());
assert_eq!(result.sources.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn serializes_root_files_in_snake_case() {
let dir = sandbox("serialize");
touch(&dir.join("a.csv"));
let json = serde_json::to_value(scan(&dir)).unwrap();
assert_eq!(json["root_files"], serde_json::json!(["a.csv"]));
assert!(json["sources"].is_array());
let _ = fs::remove_dir_all(&dir);
}
}

View file

@ -0,0 +1,55 @@
// RootFilesWarning (#427) through the REAL locales: the plural forms
// (`message_one`/`message_other`, `more_one`/`more_other`) and the quotes
// around the folder name only exist in fr.json and en.json, which the marked
// `t` of RootFilesWarning.test.tsx never reads.
import { describe, it, expect, afterAll } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import i18n from "../../i18n/config";
import RootFilesWarning from "./RootFilesWarning";
const text = (names: string[]) =>
renderToStaticMarkup(<RootFilesWarning names={names} folderName="Relevés" />)
.replace(/<[^>]+>/g, "");
const SEVEN = ["1.csv", "2.csv", "3.csv", "4.csv", "5.csv", "6.csv", "7.csv"];
afterAll(async () => {
await i18n.changeLanguage("fr");
});
describe("RootFilesWarning — French (#427)", () => {
it("singular", async () => {
await i18n.changeLanguage("fr");
expect(text(["a.csv"])).toBe(
"1 fichier est posé directement dans « Relevés » et est ignoré : a.csv. Déplacez-le dans un sous-dossier par compte.",
);
});
it("plural, five names then « et 2 autres »", async () => {
await i18n.changeLanguage("fr");
expect(text(SEVEN)).toBe(
"7 fichiers sont posés directement dans « Relevés » et sont ignorés : 1.csv, 2.csv, 3.csv, 4.csv, 5.csv et 2 autres. Déplacez-les dans un sous-dossier par compte.",
);
});
it("one more: « et 1 autre »", async () => {
await i18n.changeLanguage("fr");
expect(text(SEVEN.slice(0, 6))).toContain("5.csv et 1 autre.");
});
});
describe("RootFilesWarning — English (#427)", () => {
it("singular", async () => {
await i18n.changeLanguage("en");
expect(text(["a.csv"])).toBe(
"1 file sits directly in “Relevés” and is ignored: a.csv. Move it into a subfolder per account.",
);
});
it("plural, five names then “and 2 more”", async () => {
await i18n.changeLanguage("en");
expect(text(SEVEN)).toBe(
"7 files sit directly in “Relevés” and are ignored: 1.csv, 2.csv, 3.csv, 4.csv, 5.csv and 2 more. Move them into a subfolder per account.",
);
});
});

View file

@ -0,0 +1,39 @@
// RootFilesWarning (#427). No jsdom: rendered to static markup with the shared
// marked `t`, so the message key, its count and its interpolations are read.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import RootFilesWarning from "./RootFilesWarning";
const render = (names: string[], folderName = "Relevés") =>
renderToStaticMarkup(<RootFilesWarning names={names} folderName={folderName} />);
describe("RootFilesWarning (#427)", () => {
it("one file: the message with count 1, the folder name and the file", () => {
expect(render(["a.csv"])).toContain(
"t(import.rootFiles.message[count=1,folder=Relevés,names=a.csv])",
);
});
it("three files: the message with count 3 and the names joined by a comma", () => {
expect(render(["a.csv", "b.csv", "c.txt"])).toContain(
"t(import.rootFiles.message[count=3,folder=Relevés,names=a.csv, b.csv, c.txt])",
);
});
it("seven files: five names, then 'and 2 more'", () => {
const names = ["1.csv", "2.csv", "3.csv", "4.csv", "5.csv", "6.csv", "7.csv"];
expect(render(names)).toContain(
"t(import.rootFiles.message[count=7,folder=Relevés,names=1.csv, 2.csv, 3.csv, 4.csv, 5.csv t(import.rootFiles.more[count=2])])",
);
});
it("an icon hidden from assistive tech, and no live region", () => {
const html = render(["a.csv"]);
expect(html).toMatch(/<svg[^>]*aria-hidden="true"/);
expect(html).not.toContain("role=");
expect(html).not.toContain("aria-live");
});
});

View file

@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import { formatRootFiles } from "../../utils/rootFiles";
export interface RootFilesWarningProps {
/** Names of the CSV/TXT files placed directly at the root, sorted by the scan. */
names: readonly string[];
/** Last segment of the import folder path, never the full path. */
folderName: string;
}
/**
* Warning about the statement files placed directly in the import folder,
* which no source reads (#427). Names up to five of them, then counts the
* rest. Shown when the page loads, so no live region. On screen only: the
* names never reach the logs. Pure besides `useTranslation`, tested by direct
* render.
*/
export default function RootFilesWarning({ names, folderName }: RootFilesWarningProps) {
const { t } = useTranslation();
const { shown, more } = formatRootFiles(names);
const listed =
shown.join(", ") + (more > 0 ? ` ${t("import.rootFiles.more", { count: more })}` : "");
return (
<div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--accent)] flex items-start gap-2">
<AlertTriangle size={16} aria-hidden="true" className="text-[var(--accent)] shrink-0 mt-0.5" />
<p className="text-sm text-[var(--foreground)]">
{t("import.rootFiles.message", {
count: names.length,
folder: folderName,
names: listed,
})}
</p>
</div>
);
}

View file

@ -55,12 +55,16 @@ vi.mock("../services/importConfigTemplateService", () => ({
import { invoke } from "@tauri-apps/api/core";
import { useImportWizard, initialWizardState } from "./useImportWizard";
import { getImportFolder } from "../services/userPreferenceService";
import type { ScannedSource } from "../shared/types";
import { clearLogs, getLogs, initLogCapture } from "../services/logService";
import type { ImportFolderScan, ScannedSource } from "../shared/types";
const SOURCES: ScannedSource[] = [
{ folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] },
];
/** What `scan_import_folder` returns since #427: the sources and the root files. */
const SCAN: ImportFolderScan = { sources: SOURCES, root_files: ["releve-4510.csv"] };
/** Lets the mount read's promises settle. */
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
@ -87,7 +91,7 @@ beforeEach(() => {
describe("useImportWizard — the mount read marks the folder read (#384, #398)", () => {
it("with a folder: after the folder and its first scan, as the last dispatch", async () => {
vi.mocked(getImportFolder).mockResolvedValue("/releves");
vi.mocked(invoke).mockResolvedValue(SOURCES);
vi.mocked(invoke).mockResolvedValue(SCAN);
await mount();
expect(invoke).toHaveBeenCalledWith("scan_import_folder", { folderPath: "/releves" });
@ -126,3 +130,28 @@ describe("useImportWizard — the mount read marks the folder read (#384, #398)"
expect(order[order.length - 1]).toBe("SET_IMPORT_FOLDER_LOADED");
});
});
describe("useImportWizard — the scan hands over its root files (#427)", () => {
it("dispatches the whole scan, so the state exposes `rootFiles` next to the sources", async () => {
vi.mocked(getImportFolder).mockResolvedValue("/releves");
vi.mocked(invoke).mockResolvedValue(SCAN);
await mount();
const scanned = harness.dispatch.mock.calls
.map((c) => c[0] as { type: string; payload?: unknown })
.filter((a) => a.type === "SET_SCANNED_SOURCES");
expect(scanned).toEqual([{ type: "SET_SCANNED_SOURCES", payload: SCAN }]);
});
it("logs neither a root file name nor the folder path (the Journal travels with a feedback report)", async () => {
initLogCapture();
clearLogs();
vi.mocked(getImportFolder).mockResolvedValue("/releves");
vi.mocked(invoke).mockResolvedValue(SCAN);
await mount();
const journal = getLogs().map((l) => l.message).join("\n");
expect(journal).not.toContain("releve-4510");
expect(journal).not.toContain("/releves");
});
});

View file

@ -29,7 +29,7 @@ import {
type WizardAction,
type WizardState,
} from "./useImportWizard";
import type { ScannedSource } from "../shared/types";
import type { ImportFolderScan, ScannedSource } from "../shared/types";
const NAMES = ["Exemple Desjardins", "Exemple Tangerine"];
@ -45,7 +45,7 @@ const reduce = (actions: WizardAction[], from: WizardState = initialWizardState)
const afterCreation = (): WizardState =>
reduce([
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
{ type: "SET_SCANNED_SOURCES", payload: sources },
{ type: "SET_SCANNED_SOURCES", payload: { sources, root_files: [] } },
{ type: "SET_SAMPLE_CREATED", payload: NAMES },
]);
@ -57,7 +57,7 @@ describe("wizardReducer — sampleCreated (#356)", () => {
it("SET_SAMPLE_CREATED stores the folder names and nothing else", () => {
const before = reduce([
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
{ type: "SET_SCANNED_SOURCES", payload: sources },
{ type: "SET_SCANNED_SOURCES", payload: { sources, root_files: [] } },
]);
const after = wizardReducer(before, { type: "SET_SAMPLE_CREATED", payload: NAMES });
@ -94,7 +94,7 @@ describe("wizardReducer — sampleCreated (#356)", () => {
it("a creation error leaves the scanned sources untouched and raises no banner", () => {
const before = reduce([
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
{ type: "SET_SCANNED_SOURCES", payload: [] },
{ type: "SET_SCANNED_SOURCES", payload: { sources: [], root_files: [] } },
{ type: "SET_LOADING", payload: true },
]);
const after = wizardReducer(before, {
@ -171,3 +171,52 @@ describe("formatLocalDate (#356)", () => {
expect(formatLocalDate(new Date(2026, 11, 31, 0, 0))).toBe("2026-12-31");
});
});
describe("wizardReducer — rootFiles (#427)", () => {
const scan = (root_files: string[], from: ScannedSource[] = sources): ImportFolderScan => ({
sources: from,
root_files,
});
it("starts with no root file", () => {
expect(initialWizardState.rootFiles).toEqual([]);
});
it("SET_SCANNED_SOURCES sets the sources and the root files together", () => {
const after = reduce([
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
{ type: "SET_LOADING", payload: true },
{ type: "SET_SCANNED_SOURCES", payload: scan(["a.csv"]) },
]);
expect(after.scannedSources).toBe(sources);
expect(after.rootFiles).toEqual(["a.csv"]);
expect(after.isLoading).toBe(false);
});
it("a new scan ('Refresh', another folder) replaces both", () => {
const after = reduce([
{ type: "SET_SCANNED_SOURCES", payload: scan(["a.csv", "b.csv"]) },
{ type: "SET_SCANNED_SOURCES", payload: scan([], []) },
]);
expect(after.scannedSources).toEqual([]);
expect(after.rootFiles).toEqual([]);
});
it("RESET ('Cancel', 'Done') keeps both: the warning stays with the source list", () => {
const inWizard = reduce([
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
{ type: "SET_SCANNED_SOURCES", payload: scan(["a.csv"]) },
{ type: "SET_STEP", payload: "report" },
]);
const after = wizardReducer(inWizard, { type: "RESET" });
expect(after.step).toBe("source-list");
expect(after.scannedSources).toBe(sources);
expect(after.rootFiles).toEqual(["a.csv"]);
});
it("a scan error leaves the root files of the last scan in place", () => {
const before = reduce([{ type: "SET_SCANNED_SOURCES", payload: scan(["a.csv"]) }]);
const after = wizardReducer(before, { type: "SET_ERROR", payload: "folder not found" });
expect(after.rootFiles).toBe(before.rootFiles);
});
});

View file

@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import Papa from "papaparse";
import type {
ImportWizardStep,
ImportFolderScan,
ScannedSource,
ScannedFile,
SourceConfig,
@ -80,6 +81,14 @@ export interface WizardState {
*/
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;
@ -134,7 +143,7 @@ export type WizardAction =
| { type: "SET_STEP"; payload: ImportWizardStep }
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
| { type: "SET_IMPORT_FOLDER_LOADED" }
| { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] }
| { type: "SET_SCANNED_SOURCES"; payload: ImportFolderScan }
| { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
| { type: "SET_SOURCE_CONFIG"; payload: SourceConfig }
@ -174,6 +183,7 @@ const initialState: WizardState = {
importFolder: null,
importFolderLoaded: false,
scannedSources: [],
rootFiles: [],
selectedSource: null,
selectedFiles: [],
sourceConfig: { ...defaultConfig },
@ -214,7 +224,12 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
case "SET_IMPORT_FOLDER_LOADED":
return { ...state, importFolderLoaded: true };
case "SET_SCANNED_SOURCES":
return { ...state, scannedSources: action.payload, isLoading: false };
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
@ -304,13 +319,14 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
// 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, the configured sources and the
// templates.
// 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,
@ -378,10 +394,11 @@ export function useImportWizard() {
async (folder: string) => {
dispatch({ type: "SET_LOADING", payload: true });
try {
const sources = await invoke<ScannedSource[]>("scan_import_folder", {
// 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: sources });
dispatch({ type: "SET_SCANNED_SOURCES", payload: scan });
await loadConfiguredSources();
} catch (e) {
dispatch({

View file

@ -322,6 +322,12 @@
"rootNote": "Files placed directly in the parent folder are ignored."
},
"sampleCreated": "Two sample folders were created: {{names}}. Click one of them to start the import. Delete them once your real statements are in place."
},
"rootFiles": {
"message_one": "{{count}} file sits directly in “{{folder}}” and is ignored: {{names}}. Move it into a subfolder per account.",
"message_other": "{{count}} files sit directly in “{{folder}}” and are ignored: {{names}}. Move them into a subfolder per account.",
"more_one": "and {{count}} more",
"more_other": "and {{count}} more"
}
},
"transactions": {
@ -1090,7 +1096,8 @@
"Save your configuration as a template so you don't have to reconfigure each time. A template initialises a source; editing it afterwards changes no source you have already configured.",
"Files already imported are marked with a badge — re-importing them will trigger duplicate detection",
"You can delete an import from the history to remove all its transactions",
"Your import sources and templates are now part of the encrypted backup: restoring a backup no longer forces you to reconfigure everything"
"Your import sources and templates are now part of the encrypted backup: restoring a backup no longer forces you to reconfigure everything",
"If CSV files sit directly in the import folder, a warning names them: move them into a subfolder per account"
]
},
"transactions": {

View file

@ -322,6 +322,12 @@
"rootNote": "Les fichiers posés directement dans le dossier parent sont ignorés."
},
"sampleCreated": "Deux dossiers d'exemple ont été créés : {{names}}. Cliquez sur l'un d'eux pour lancer l'import. Supprimez-les quand vos vrais relevés sont en place."
},
"rootFiles": {
"message_one": "{{count}} fichier est posé directement dans « {{folder}} » et est ignoré : {{names}}. Déplacez-le dans un sous-dossier par compte.",
"message_other": "{{count}} fichiers sont posés directement dans « {{folder}} » et sont ignorés : {{names}}. Déplacez-les dans un sous-dossier par compte.",
"more_one": "et {{count}} autre",
"more_other": "et {{count}} autres"
}
},
"transactions": {
@ -1090,7 +1096,8 @@
"Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois. Un modèle sert à initialiser une source ; le modifier ensuite ne change aucune source déjà configurée.",
"Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons",
"Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions",
"Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer"
"Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer",
"Si des fichiers CSV sont posés directement dans le dossier d'import, un avertissement les nomme : déplacez-les dans un sous-dossier par compte"
]
},
"transactions": {

View file

@ -294,3 +294,68 @@ describe("ImportPage — before the folder is read (#384)", () => {
expect(render()).toContain("t(import.empty.noFolder.title)");
});
});
describe("ImportPage — files placed directly in the import folder (#427)", () => {
const WARNING = "t(import.rootFiles.message[";
it("with sources: the source list, and the warning above it naming the root file", () => {
harness.wizard = wizard({
importFolder: "/home/max/Relevés",
scannedSources: [{ folder_name: "visa", folder_path: "/home/max/Relevés/visa", files: [] }],
rootFiles: ["a.csv"],
});
harness.onboarding = path(ZERO, true);
const html = render();
expect(html).toContain("t(import.rootFiles.message[count=1,folder=Relevés,names=a.csv])");
expect(html).toContain("visa");
expect(html.indexOf(WARNING)).toBeLessThan(html.indexOf("visa"));
// The folder's last segment, never its path.
expect(html).not.toContain("folder=/home");
});
it("without source: the 'no source' empty state, and the warning under it naming the three files", () => {
harness.wizard = wizard({
importFolder: "/home/max/Relevés",
rootFiles: ["a.csv", "b.csv", "c.csv"],
});
harness.onboarding = path(ZERO, true);
const html = render();
expect(html).toContain("t(import.empty.noSources.title)");
expect(html).toContain(
"t(import.rootFiles.message[count=3,folder=Relevés,names=a.csv, b.csv, c.csv])",
);
expect(html.indexOf("t(import.empty.noSources.title)")).toBeLessThan(html.indexOf(WARNING));
});
it("no root file: no warning", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
expect(render()).not.toContain("import.rootFiles");
});
it("no folder: no warning, even with root files left in the state", () => {
harness.wizard = wizard({ importFolder: null, rootFiles: ["a.csv"] });
const html = render();
expect(html).toContain("t(import.empty.noFolder.title)");
expect(html).not.toContain("import.rootFiles");
});
it("not on the wizard steps: the warning belongs to the source list", () => {
harness.wizard = wizard({
step: "source-config",
importFolder: "/home/max/Relevés",
scannedSources: SOURCES,
selectedSource: SOURCES[0],
rootFiles: ["a.csv"],
});
expect(render()).not.toContain("import.rootFiles");
});
it("before the folder is read: no warning", () => {
harness.wizard = wizard({
importFolderLoaded: false,
importFolder: "/home/max/Relevés",
rootFiles: ["a.csv"],
});
expect(render()).not.toContain("import.rootFiles");
});
});

View file

@ -19,6 +19,7 @@ import ImportProgress from "../components/import/ImportProgress";
import ImportReportPanel from "../components/import/ImportReportPanel";
import WizardNavigation from "../components/import/WizardNavigation";
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
import RootFilesWarning from "../components/import/RootFilesWarning";
import { AlertCircle, CheckCircle, X } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
@ -55,6 +56,16 @@ export default function ImportPage() {
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0;
// Statement files placed directly in the import folder, which no source
// reads (#427): named in a warning, above the source list or under the "no
// source" empty state. Never without a folder (there is nothing scanned).
const rootFilesWarning =
state.importFolder !== null && state.rootFiles.length > 0 ? (
<RootFilesWarning
names={state.rootFiles}
folderName={folderNameFromPath(state.importFolder)}
/>
) : null;
// The getting-started card follows the wizard (#398). Its target is
// highlighted only while the card is on screen: a ring needs the card that
@ -138,23 +149,29 @@ export default function ImportPage() {
*/}
{state.importFolderLoaded &&
(showEmptyState ? (
<ImportEmptyState
hasFolder={state.importFolder !== null}
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
onBrowse={browseFolder}
onCreateSample={createSampleFolders}
onRefresh={refreshFolder}
isCreatingSample={state.isLoading}
onboardingTarget={target === "emptyStateAction"}
/>
<>
<ImportEmptyState
hasFolder={state.importFolder !== null}
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
onBrowse={browseFolder}
onCreateSample={createSampleFolders}
onRefresh={refreshFolder}
isCreatingSample={state.isLoading}
onboardingTarget={target === "emptyStateAction"}
/>
{rootFilesWarning && <div className="mt-4">{rootFilesWarning}</div>}
</>
) : (
<SourceList
sources={state.scannedSources}
configuredSourceNames={state.configuredSourceNames}
importedFileNames={state.importedFilesBySource}
onSelectSource={selectSource}
onboardingTarget={target === "firstSource"}
/>
<>
{rootFilesWarning}
<SourceList
sources={state.scannedSources}
configuredSourceNames={state.configuredSourceNames}
importedFileNames={state.importedFilesBySource}
onSelectSource={selectSource}
onboardingTarget={target === "firstSource"}
/>
</>
))}
<ImportHistoryPanel onChanged={refreshFolder} />
</>

View file

@ -221,6 +221,16 @@ export interface ScannedSource {
files: ScannedFile[];
}
/**
* Result of `scan_import_folder` (#427): one source per direct subfolder, and
* the names of the CSV/TXT files placed directly at the root, which no source
* reads.
*/
export interface ImportFolderScan {
sources: ScannedSource[];
root_files: string[];
}
export interface ColumnMapping {
date: number;
description: number;

View file

@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { formatRootFiles } from "./rootFiles";
const names = (n: number) => Array.from({ length: n }, (_, i) => `f${i + 1}.csv`);
describe("formatRootFiles (#427)", () => {
it("no name: nothing shown, nothing more", () => {
expect(formatRootFiles([])).toEqual({ shown: [], more: 0 });
});
it("one name: shown, nothing more", () => {
expect(formatRootFiles(["a.csv"])).toEqual({ shown: ["a.csv"], more: 0 });
});
it("five names: all shown, nothing more", () => {
expect(formatRootFiles(names(5))).toEqual({ shown: names(5), more: 0 });
});
it("seven names: the first five, and 2 more", () => {
expect(formatRootFiles(names(7))).toEqual({ shown: names(5), more: 2 });
});
it("keeps the order it was given (the scan sorts by name)", () => {
expect(formatRootFiles(["b.csv", "a.csv"], 1)).toEqual({ shown: ["b.csv"], more: 1 });
});
});

12
src/utils/rootFiles.ts Normal file
View file

@ -0,0 +1,12 @@
/**
* The root files a warning names (#427): the first `max` names, in the order
* the scan sorted them, and how many more there are. A long list would push
* the source list off the screen; the rest is counted, not named.
*/
export function formatRootFiles(
names: readonly string[],
max = 5,
): { shown: string[]; more: number } {
const shown = names.slice(0, Math.max(0, max));
return { shown, more: names.length - shown.length };
}