feat(import): warn about the CSV files placed directly in the import folder
scan_import_folder now returns ImportFolderScan { sources, root_files }:
the CSV/TXT files at the root, which no source reads, are named in a
warning on the Import page (above the source list, or under the "no
source" empty state). One action sets sources and root files, RESET keeps
both. Names stay on screen, never in the Journal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
af884ca13b
commit
7d3a616312
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