From 7d3a6163122d968c97def992bf4751298296455f Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 13 Oct 2026 20:59:20 -0400 Subject: [PATCH] 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 --- src-tauri/src/commands/fs_commands.rs | 142 ++++++++++++++++-- .../import/RootFilesWarning.locales.test.tsx | 55 +++++++ .../import/RootFilesWarning.test.tsx | 39 +++++ src/components/import/RootFilesWarning.tsx | 37 +++++ src/hooks/useImportWizard.mount.test.ts | 33 +++- src/hooks/useImportWizard.test.ts | 57 ++++++- src/hooks/useImportWizard.ts | 29 +++- src/i18n/locales/en.json | 9 +- src/i18n/locales/fr.json | 9 +- src/pages/ImportPage.render.test.tsx | 65 ++++++++ src/pages/ImportPage.tsx | 49 ++++-- src/shared/types/index.ts | 10 ++ src/utils/rootFiles.test.ts | 26 ++++ src/utils/rootFiles.ts | 12 ++ 14 files changed, 533 insertions(+), 39 deletions(-) create mode 100644 src/components/import/RootFilesWarning.locales.test.tsx create mode 100644 src/components/import/RootFilesWarning.test.tsx create mode 100644 src/components/import/RootFilesWarning.tsx create mode 100644 src/utils/rootFiles.test.ts create mode 100644 src/utils/rootFiles.ts diff --git a/src-tauri/src/commands/fs_commands.rs b/src-tauri/src/commands/fs_commands.rs index a405322..1f5f53c 100644 --- a/src-tauri/src/commands/fs_commands.rs +++ b/src-tauri/src/commands/fs_commands.rs @@ -22,14 +22,39 @@ pub struct ScannedSource { pub files: Vec, } +/// 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, + pub root_files: Vec, +} + +/// 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, String> { +pub fn scan_import_folder(folder_path: String) -> Result { let root = Path::new(&folder_path); if !root.is_dir() { return Err(format!("Folder does not exist: {}", folder_path)); } let mut sources: Vec = Vec::new(); + let mut root_files: Vec = 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, 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, 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, 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); + } +} diff --git a/src/components/import/RootFilesWarning.locales.test.tsx b/src/components/import/RootFilesWarning.locales.test.tsx new file mode 100644 index 0000000..7b5900d --- /dev/null +++ b/src/components/import/RootFilesWarning.locales.test.tsx @@ -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() + .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.", + ); + }); +}); diff --git a/src/components/import/RootFilesWarning.test.tsx b/src/components/import/RootFilesWarning.test.tsx new file mode 100644 index 0000000..03b2e83 --- /dev/null +++ b/src/components/import/RootFilesWarning.test.tsx @@ -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(); + +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(/]*aria-hidden="true"/); + expect(html).not.toContain("role="); + expect(html).not.toContain("aria-live"); + }); +}); diff --git a/src/components/import/RootFilesWarning.tsx b/src/components/import/RootFilesWarning.tsx new file mode 100644 index 0000000..7c10b43 --- /dev/null +++ b/src/components/import/RootFilesWarning.tsx @@ -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 ( +
+
+ ); +} diff --git a/src/hooks/useImportWizard.mount.test.ts b/src/hooks/useImportWizard.mount.test.ts index 8dc802e..ad82db7 100644 --- a/src/hooks/useImportWizard.mount.test.ts +++ b/src/hooks/useImportWizard.mount.test.ts @@ -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"); + }); +}); diff --git a/src/hooks/useImportWizard.test.ts b/src/hooks/useImportWizard.test.ts index 3081ec1..169c35a 100644 --- a/src/hooks/useImportWizard.test.ts +++ b/src/hooks/useImportWizard.test.ts @@ -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); + }); +}); diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index 1af0bbf..bc66415 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -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("scan_import_folder", { + // The root file names stay out of the logs (#427): on screen only. + const scan = await invoke("scan_import_folder", { folderPath: folder, }); - dispatch({ type: "SET_SCANNED_SOURCES", payload: sources }); + dispatch({ type: "SET_SCANNED_SOURCES", payload: scan }); await loadConfiguredSources(); } catch (e) { dispatch({ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index fa04d2b..2f3693d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ede7365..4f32bc2 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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": { diff --git a/src/pages/ImportPage.render.test.tsx b/src/pages/ImportPage.render.test.tsx index 6127970..770d5cc 100644 --- a/src/pages/ImportPage.render.test.tsx +++ b/src/pages/ImportPage.render.test.tsx @@ -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"); + }); +}); diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index c18f69e..d964e32 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -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 ? ( + + ) : 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 ? ( - + <> + + {rootFilesWarning &&
{rootFilesWarning}
} + ) : ( - + <> + {rootFilesWarning} + + ))} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 2d13df2..47b4428 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -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; diff --git a/src/utils/rootFiles.test.ts b/src/utils/rootFiles.test.ts new file mode 100644 index 0000000..bb70e68 --- /dev/null +++ b/src/utils/rootFiles.test.ts @@ -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 }); + }); +}); diff --git a/src/utils/rootFiles.ts b/src/utils/rootFiles.ts new file mode 100644 index 0000000..e9ea2cc --- /dev/null +++ b/src/utils/rootFiles.ts @@ -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 }; +}