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 }; +}