feat(import): warn about the CSV files placed directly in the import folder (#427) #440
14 changed files with 533 additions and 39 deletions
|
|
@ -22,14 +22,39 @@ pub struct ScannedSource {
|
||||||
pub files: Vec<ScannedFile>,
|
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]
|
#[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);
|
let root = Path::new(&folder_path);
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Err(format!("Folder does not exist: {}", folder_path));
|
return Err(format!("Folder does not exist: {}", folder_path));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut sources: Vec<ScannedSource> = Vec::new();
|
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))?;
|
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();
|
let path = entry.path();
|
||||||
|
|
||||||
if !path.is_dir() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,13 +95,7 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ext = file_path
|
if !has_statement_extension(file_path) {
|
||||||
.extension()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
if ext != "csv" && ext != "txt" {
|
|
||||||
continue;
|
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));
|
sources.sort_by(|a, b| a.folder_name.cmp(&b.folder_name));
|
||||||
|
root_files.sort();
|
||||||
|
|
||||||
Ok(sources)
|
Ok(ImportFolderScan {
|
||||||
|
sources,
|
||||||
|
root_files,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -427,3 +460,94 @@ mod sample_folder_tests {
|
||||||
assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026"));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
55
src/components/import/RootFilesWarning.locales.test.tsx
Normal file
55
src/components/import/RootFilesWarning.locales.test.tsx
Normal 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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
src/components/import/RootFilesWarning.test.tsx
Normal file
39
src/components/import/RootFilesWarning.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/components/import/RootFilesWarning.tsx
Normal file
37
src/components/import/RootFilesWarning.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -55,12 +55,16 @@ vi.mock("../services/importConfigTemplateService", () => ({
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useImportWizard, initialWizardState } from "./useImportWizard";
|
import { useImportWizard, initialWizardState } from "./useImportWizard";
|
||||||
import { getImportFolder } from "../services/userPreferenceService";
|
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[] = [
|
const SOURCES: ScannedSource[] = [
|
||||||
{ folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] },
|
{ 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. */
|
/** Lets the mount read's promises settle. */
|
||||||
const flush = async () => {
|
const flush = async () => {
|
||||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
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)", () => {
|
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 () => {
|
it("with a folder: after the folder and its first scan, as the last dispatch", async () => {
|
||||||
vi.mocked(getImportFolder).mockResolvedValue("/releves");
|
vi.mocked(getImportFolder).mockResolvedValue("/releves");
|
||||||
vi.mocked(invoke).mockResolvedValue(SOURCES);
|
vi.mocked(invoke).mockResolvedValue(SCAN);
|
||||||
await mount();
|
await mount();
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("scan_import_folder", { folderPath: "/releves" });
|
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");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import {
|
||||||
type WizardAction,
|
type WizardAction,
|
||||||
type WizardState,
|
type WizardState,
|
||||||
} from "./useImportWizard";
|
} from "./useImportWizard";
|
||||||
import type { ScannedSource } from "../shared/types";
|
import type { ImportFolderScan, ScannedSource } from "../shared/types";
|
||||||
|
|
||||||
const NAMES = ["Exemple Desjardins", "Exemple Tangerine"];
|
const NAMES = ["Exemple Desjardins", "Exemple Tangerine"];
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ const reduce = (actions: WizardAction[], from: WizardState = initialWizardState)
|
||||||
const afterCreation = (): WizardState =>
|
const afterCreation = (): WizardState =>
|
||||||
reduce([
|
reduce([
|
||||||
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
|
{ 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 },
|
{ 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", () => {
|
it("SET_SAMPLE_CREATED stores the folder names and nothing else", () => {
|
||||||
const before = reduce([
|
const before = reduce([
|
||||||
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
|
{ 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 });
|
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", () => {
|
it("a creation error leaves the scanned sources untouched and raises no banner", () => {
|
||||||
const before = reduce([
|
const before = reduce([
|
||||||
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
|
{ type: "SET_IMPORT_FOLDER", payload: "/releves" },
|
||||||
{ type: "SET_SCANNED_SOURCES", payload: [] },
|
{ type: "SET_SCANNED_SOURCES", payload: { sources: [], root_files: [] } },
|
||||||
{ type: "SET_LOADING", payload: true },
|
{ type: "SET_LOADING", payload: true },
|
||||||
]);
|
]);
|
||||||
const after = wizardReducer(before, {
|
const after = wizardReducer(before, {
|
||||||
|
|
@ -171,3 +171,52 @@ describe("formatLocalDate (#356)", () => {
|
||||||
expect(formatLocalDate(new Date(2026, 11, 31, 0, 0))).toBe("2026-12-31");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||||
import Papa from "papaparse";
|
import Papa from "papaparse";
|
||||||
import type {
|
import type {
|
||||||
ImportWizardStep,
|
ImportWizardStep,
|
||||||
|
ImportFolderScan,
|
||||||
ScannedSource,
|
ScannedSource,
|
||||||
ScannedFile,
|
ScannedFile,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
|
@ -80,6 +81,14 @@ export interface WizardState {
|
||||||
*/
|
*/
|
||||||
importFolderLoaded: boolean;
|
importFolderLoaded: boolean;
|
||||||
scannedSources: ScannedSource[];
|
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;
|
selectedSource: ScannedSource | null;
|
||||||
selectedFiles: ScannedFile[];
|
selectedFiles: ScannedFile[];
|
||||||
sourceConfig: SourceConfig;
|
sourceConfig: SourceConfig;
|
||||||
|
|
@ -134,7 +143,7 @@ export type WizardAction =
|
||||||
| { type: "SET_STEP"; payload: ImportWizardStep }
|
| { type: "SET_STEP"; payload: ImportWizardStep }
|
||||||
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
|
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
|
||||||
| { type: "SET_IMPORT_FOLDER_LOADED" }
|
| { 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_SOURCE"; payload: ScannedSource }
|
||||||
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
|
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
|
||||||
| { type: "SET_SOURCE_CONFIG"; payload: SourceConfig }
|
| { type: "SET_SOURCE_CONFIG"; payload: SourceConfig }
|
||||||
|
|
@ -174,6 +183,7 @@ const initialState: WizardState = {
|
||||||
importFolder: null,
|
importFolder: null,
|
||||||
importFolderLoaded: false,
|
importFolderLoaded: false,
|
||||||
scannedSources: [],
|
scannedSources: [],
|
||||||
|
rootFiles: [],
|
||||||
selectedSource: null,
|
selectedSource: null,
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
sourceConfig: { ...defaultConfig },
|
sourceConfig: { ...defaultConfig },
|
||||||
|
|
@ -214,7 +224,12 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
|
||||||
case "SET_IMPORT_FOLDER_LOADED":
|
case "SET_IMPORT_FOLDER_LOADED":
|
||||||
return { ...state, importFolderLoaded: true };
|
return { ...state, importFolderLoaded: true };
|
||||||
case "SET_SCANNED_SOURCES":
|
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":
|
case "SET_SELECTED_SOURCE":
|
||||||
// The score belongs to the source it was measured on. Carrying it over
|
// 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
|
// 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:
|
// Rebuilt from `initialState`, minus what the page read once and keeps:
|
||||||
// the folder, whether it was read (#384: the mount read does not run
|
// 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
|
// again, so "Cancel" or "Done" would otherwise hide the empty state and
|
||||||
// the step card for good), its scan, the configured sources and the
|
// the step card for good), its scan (sources and root files, #427), the
|
||||||
// templates.
|
// configured sources and the templates.
|
||||||
return {
|
return {
|
||||||
...initialState,
|
...initialState,
|
||||||
importFolder: state.importFolder,
|
importFolder: state.importFolder,
|
||||||
importFolderLoaded: state.importFolderLoaded,
|
importFolderLoaded: state.importFolderLoaded,
|
||||||
scannedSources: state.scannedSources,
|
scannedSources: state.scannedSources,
|
||||||
|
rootFiles: state.rootFiles,
|
||||||
configuredSourceNames: state.configuredSourceNames,
|
configuredSourceNames: state.configuredSourceNames,
|
||||||
importedFilesBySource: state.importedFilesBySource,
|
importedFilesBySource: state.importedFilesBySource,
|
||||||
configTemplates: state.configTemplates,
|
configTemplates: state.configTemplates,
|
||||||
|
|
@ -378,10 +394,11 @@ export function useImportWizard() {
|
||||||
async (folder: string) => {
|
async (folder: string) => {
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
try {
|
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,
|
folderPath: folder,
|
||||||
});
|
});
|
||||||
dispatch({ type: "SET_SCANNED_SOURCES", payload: sources });
|
dispatch({ type: "SET_SCANNED_SOURCES", payload: scan });
|
||||||
await loadConfiguredSources();
|
await loadConfiguredSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,12 @@
|
||||||
"rootNote": "Files placed directly in the parent folder are ignored."
|
"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."
|
"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": {
|
"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.",
|
"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",
|
"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",
|
"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": {
|
"transactions": {
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,12 @@
|
||||||
"rootNote": "Les fichiers posés directement dans le dossier parent sont ignorés."
|
"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."
|
"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": {
|
"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.",
|
"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",
|
"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",
|
"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": {
|
"transactions": {
|
||||||
|
|
|
||||||
|
|
@ -294,3 +294,68 @@ describe("ImportPage — before the folder is read (#384)", () => {
|
||||||
expect(render()).toContain("t(import.empty.noFolder.title)");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import ImportProgress from "../components/import/ImportProgress";
|
||||||
import ImportReportPanel from "../components/import/ImportReportPanel";
|
import ImportReportPanel from "../components/import/ImportReportPanel";
|
||||||
import WizardNavigation from "../components/import/WizardNavigation";
|
import WizardNavigation from "../components/import/WizardNavigation";
|
||||||
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
|
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
|
||||||
|
import RootFilesWarning from "../components/import/RootFilesWarning";
|
||||||
import { AlertCircle, CheckCircle, X } from "lucide-react";
|
import { AlertCircle, CheckCircle, X } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
|
|
||||||
|
|
@ -55,6 +56,16 @@ export default function ImportPage() {
|
||||||
|
|
||||||
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
|
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
|
||||||
const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0;
|
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
|
// 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
|
// highlighted only while the card is on screen: a ring needs the card that
|
||||||
|
|
@ -138,23 +149,29 @@ export default function ImportPage() {
|
||||||
*/}
|
*/}
|
||||||
{state.importFolderLoaded &&
|
{state.importFolderLoaded &&
|
||||||
(showEmptyState ? (
|
(showEmptyState ? (
|
||||||
<ImportEmptyState
|
<>
|
||||||
hasFolder={state.importFolder !== null}
|
<ImportEmptyState
|
||||||
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
|
hasFolder={state.importFolder !== null}
|
||||||
onBrowse={browseFolder}
|
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
|
||||||
onCreateSample={createSampleFolders}
|
onBrowse={browseFolder}
|
||||||
onRefresh={refreshFolder}
|
onCreateSample={createSampleFolders}
|
||||||
isCreatingSample={state.isLoading}
|
onRefresh={refreshFolder}
|
||||||
onboardingTarget={target === "emptyStateAction"}
|
isCreatingSample={state.isLoading}
|
||||||
/>
|
onboardingTarget={target === "emptyStateAction"}
|
||||||
|
/>
|
||||||
|
{rootFilesWarning && <div className="mt-4">{rootFilesWarning}</div>}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<SourceList
|
<>
|
||||||
sources={state.scannedSources}
|
{rootFilesWarning}
|
||||||
configuredSourceNames={state.configuredSourceNames}
|
<SourceList
|
||||||
importedFileNames={state.importedFilesBySource}
|
sources={state.scannedSources}
|
||||||
onSelectSource={selectSource}
|
configuredSourceNames={state.configuredSourceNames}
|
||||||
onboardingTarget={target === "firstSource"}
|
importedFileNames={state.importedFilesBySource}
|
||||||
/>
|
onSelectSource={selectSource}
|
||||||
|
onboardingTarget={target === "firstSource"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
<ImportHistoryPanel onChanged={refreshFolder} />
|
<ImportHistoryPanel onChanged={refreshFolder} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,16 @@ export interface ScannedSource {
|
||||||
files: ScannedFile[];
|
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 {
|
export interface ColumnMapping {
|
||||||
date: number;
|
date: number;
|
||||||
description: number;
|
description: number;
|
||||||
|
|
|
||||||
26
src/utils/rootFiles.test.ts
Normal file
26
src/utils/rootFiles.test.ts
Normal 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
12
src/utils/rootFiles.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue