feat(import): warn about the CSV files placed directly in the import folder (#427) #440
19 changed files with 551 additions and 42 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Non publié]
|
||||
|
||||
### Ajouté
|
||||
|
||||
- Import : un avertissement nomme les fichiers CSV posés directement dans le dossier d'import, qui sont ignorés (#427).
|
||||
|
||||
### Modifié
|
||||
|
||||
- Le champ « Catégorie parente » du formulaire de catégorie et le champ « Catégorie » du dialogue « Nouveau mot-clé » (clic droit sur une transaction) acceptent désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie avec son indentation et les noms des catégories standard dans votre langue. La liste des parents ne propose plus la catégorie en cours d'édition, ses propres sous-catégories, ni un parent qui créerait un quatrième niveau ; un parent déjà enregistré reste affiché. Le dialogue de mot-clé propose les mêmes catégories que la page Transactions (actives et autorisées à la saisie) et ne garde la catégorie pré-remplie que si elle en fait partie ; ses deux champs sont placés au-dessus de la liste défilante des correspondances, pour que la liste de catégories ouverte ne soit jamais coupée (#423).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Import: a warning names the CSV files placed directly in the import folder, which are ignored (#427).
|
||||
|
||||
### Changed
|
||||
|
||||
- The "Parent category" field of the category form and the "Category" field of the "New keyword" dialog (right-click on a transaction) now take typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy with indentation and the names of the standard categories in your language. The parent list no longer offers the category being edited, its own sub-categories, or a parent that would create a fourth level; a parent already stored stays shown. The keyword dialog offers the same categories as the Transactions page (active and allowed for input) and keeps the pre-filled category only when it is among them; its two fields sit above the scrolling list of matches, so the open category list is never cut (#423).
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useCategories` | Catégories avec hiérarchie |
|
||||
| `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) |
|
||||
| `useDataImport` | Import de données |
|
||||
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398) |
|
||||
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427) |
|
||||
| `useImportHistory` | Historique des imports |
|
||||
| `useAdjustments` | Ajustements |
|
||||
| `useBudget` | Budget |
|
||||
|
|
@ -408,7 +408,7 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
|
|||
|
||||
### `fs_commands.rs` — Système de fichiers (7)
|
||||
|
||||
- `scan_import_folder` — Scan récursif de dossier pour fichiers CSV/TXT
|
||||
- `scan_import_folder` — Liste les sous-dossiers directs du dossier d'import (une source chacun) avec leurs fichiers CSV/TXT directs, et les CSV/TXT posés à la racine, ignorés (`root_files`)
|
||||
- `read_file_content` — Lecture avec gestion de l'encodage
|
||||
- `hash_file` — Hash SHA-256 (détection de doublons)
|
||||
- `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15)
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ L'historique téléchargeable est limité à **123 jours** au maximum, et à env
|
|||
- 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
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -285,7 +285,13 @@
|
|||
{ id: "v0171-import-card-preview", title: "Import : la carte de l'aperçu parle des signes (#422)",
|
||||
expect: "À l'écran d'aperçu de l'assistant, la carte dit qu'une dépense doit compter parmi les sorties, en négatif, cite « Inverser les signes » comme correction, puis « Vérifier les doublons », chacun entre guillemets ; le contour reste sur « Vérifier les doublons ». Aux écrans suivants, « Confirmer » puis « Importer » sont aussi entre guillemets. En anglais, les mêmes libellés sont entre “ ”." },
|
||||
{ id: "v0171-import-help", title: "Import : l'aide de la page et le guide (#422)",
|
||||
expect: "L'aide de la page Import (icône ?) commence par « Choisissez le dossier parent de vos relevés… » et porte, après « Prévisualisez vos données… », la règle des signes avec « Inverser les signes ». Dans le guide d'utilisation (section Import), l'étape 2 dit « Créez un sous-dossier par compte… », l'étape 6 qu'une dépense doit compter parmi les sorties, et l'étape 7 parle des prochains fichiers « de ce compte »." }
|
||||
expect: "L'aide de la page Import (icône ?) commence par « Choisissez le dossier parent de vos relevés… » et porte, après « Prévisualisez vos données… », la règle des signes avec « Inverser les signes ». Dans le guide d'utilisation (section Import), l'étape 2 dit « Créez un sous-dossier par compte… », l'étape 6 qu'une dépense doit compter parmi les sorties, et l'étape 7 parle des prochains fichiers « de ce compte »." },
|
||||
{ id: "v0171-import-root-files-with-source", title: "Import : fichier posé à la racine, à côté d'une source (#427)",
|
||||
expect: "Dans le dossier d'import, pose a.csv directement à la racine et crée un sous-dossier visa avec un relevé, puis « Actualiser » : la carte de la source visa s'affiche, et au-dessus d'elle un avertissement (icône triangle) dit « 1 fichier est posé directement dans « <nom du dossier> » et est ignoré : a.csv. Déplacez-le dans un sous-dossier par compte. ». Le dossier est nommé par son dernier segment, jamais par son chemin." },
|
||||
{ id: "v0171-import-root-files-no-source", title: "Import : trois fichiers à la racine, aucune source (#427)",
|
||||
expect: "Dans un dossier sans sous-dossier mais avec trois CSV à la racine, « Actualiser » : l'état vide « Aucune source trouvée » s'affiche, et sous lui l'avertissement au pluriel nomme les trois fichiers, séparés par des virgules. Avec sept fichiers, il en nomme cinq puis « et 2 autres ». En anglais : “… sit directly in “<folder>” and are ignored: … and 2 more.”" },
|
||||
{ id: "v0171-import-root-files-lifecycle", title: "Import : l'avertissement suit le dossier (#427)",
|
||||
expect: "Déplace les fichiers de la racine dans un sous-dossier puis « Actualiser » : l'avertissement disparaît. Remets-en un, « Actualiser » : il revient. Ouvre une source puis « Annuler » (ou va au bout d'un import puis « Terminé ») : l'avertissement est toujours là. Dans Paramètres → Systèmes → Journal, aucune ligne ne contient le nom d'un de ces fichiers ni le chemin du dossier." }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,14 +22,39 @@ pub struct ScannedSource {
|
|||
pub files: Vec<ScannedFile>,
|
||||
}
|
||||
|
||||
/// Result of a scan of the import folder: the sources (one per direct
|
||||
/// subfolder) and the names of the statement files placed directly at the
|
||||
/// root, which no source reads and the Import page names in a warning (#427).
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ImportFolderScan {
|
||||
pub sources: Vec<ScannedSource>,
|
||||
pub root_files: Vec<String>,
|
||||
}
|
||||
|
||||
/// True when the file name has a statement extension (`csv` or `txt`, any
|
||||
/// case), the same rule for the root and for the subfolders.
|
||||
fn has_statement_extension(path: &Path) -> bool {
|
||||
let ext = path
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
ext == "csv" || ext == "txt"
|
||||
}
|
||||
|
||||
/// Scans the import folder: each non-hidden direct subfolder is a source with
|
||||
/// its direct CSV/TXT files, sorted by name. CSV/TXT files placed directly at
|
||||
/// the root are read by no source; their names (non-hidden, sorted) are
|
||||
/// returned in `root_files` so the Import page can warn about them (#427).
|
||||
#[tauri::command]
|
||||
pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, String> {
|
||||
pub fn scan_import_folder(folder_path: String) -> Result<ImportFolderScan, String> {
|
||||
let root = Path::new(&folder_path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("Folder does not exist: {}", folder_path));
|
||||
}
|
||||
|
||||
let mut sources: Vec<ScannedSource> = Vec::new();
|
||||
let mut root_files: Vec<String> = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(root).map_err(|e| format!("Cannot read folder: {}", e))?;
|
||||
|
||||
|
|
@ -38,6 +63,16 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
|
|||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
if path.is_file() && has_statement_extension(&path) {
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if !name.starts_with('.') {
|
||||
root_files.push(name);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -60,13 +95,7 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
|
|||
continue;
|
||||
}
|
||||
|
||||
let ext = file_path
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
|
||||
if ext != "csv" && ext != "txt" {
|
||||
if !has_statement_extension(file_path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -105,8 +134,12 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
|
|||
}
|
||||
|
||||
sources.sort_by(|a, b| a.folder_name.cmp(&b.folder_name));
|
||||
root_files.sort();
|
||||
|
||||
Ok(sources)
|
||||
Ok(ImportFolderScan {
|
||||
sources,
|
||||
root_files,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -427,3 +460,94 @@ mod sample_folder_tests {
|
|||
assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scan_import_folder_tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One scratch directory per case: the cases run in parallel in one process.
|
||||
fn sandbox(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"simpl-resultat-scan-{}-{}",
|
||||
std::process::id(),
|
||||
name
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("sandbox created");
|
||||
dir
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
fs::write(path, "Date;Montant\n").expect("file written");
|
||||
}
|
||||
|
||||
fn scan(dir: &Path) -> ImportFolderScan {
|
||||
scan_import_folder(dir.to_string_lossy().to_string()).expect("scan succeeds")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_the_root_statement_files_sorted_by_name() {
|
||||
let dir = sandbox("root-sorted");
|
||||
touch(&dir.join("b.csv"));
|
||||
touch(&dir.join("a.csv"));
|
||||
touch(&dir.join("c.TXT"));
|
||||
fs::create_dir_all(dir.join("visa")).unwrap();
|
||||
touch(&dir.join("visa").join("releve.csv"));
|
||||
|
||||
let result = scan(&dir);
|
||||
assert_eq!(result.root_files, vec!["a.csv", "b.csv", "c.TXT"]);
|
||||
assert_eq!(result.sources.len(), 1);
|
||||
assert_eq!(result.sources[0].folder_name, "visa");
|
||||
assert_eq!(result.sources[0].files.len(), 1);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_subfolder_files_out_of_root_files() {
|
||||
let dir = sandbox("subfolder-only");
|
||||
fs::create_dir_all(dir.join("cheques")).unwrap();
|
||||
touch(&dir.join("cheques").join("a.csv"));
|
||||
|
||||
let result = scan(&dir);
|
||||
assert!(result.root_files.is_empty());
|
||||
assert_eq!(result.sources[0].files[0].filename, "a.csv");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_other_extensions_and_hidden_files_at_the_root() {
|
||||
let dir = sandbox("ignored");
|
||||
touch(&dir.join("notes.pdf"));
|
||||
touch(&dir.join("noextension"));
|
||||
touch(&dir.join(".hidden.csv"));
|
||||
touch(&dir.join("kept.Csv"));
|
||||
|
||||
let result = scan(&dir);
|
||||
assert_eq!(result.root_files, vec!["kept.Csv"]);
|
||||
assert!(result.sources.is_empty());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_no_root_file_for_a_root_without_files() {
|
||||
let dir = sandbox("empty-root");
|
||||
fs::create_dir_all(dir.join("visa")).unwrap();
|
||||
|
||||
let result = scan(&dir);
|
||||
assert!(result.root_files.is_empty());
|
||||
assert_eq!(result.sources.len(), 1);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_root_files_in_snake_case() {
|
||||
let dir = sandbox("serialize");
|
||||
touch(&dir.join("a.csv"));
|
||||
|
||||
let json = serde_json::to_value(scan(&dir)).unwrap();
|
||||
assert_eq!(json["root_files"], serde_json::json!(["a.csv"]));
|
||||
assert!(json["sources"].is_array());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 { 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||
import Papa from "papaparse";
|
||||
import type {
|
||||
ImportWizardStep,
|
||||
ImportFolderScan,
|
||||
ScannedSource,
|
||||
ScannedFile,
|
||||
SourceConfig,
|
||||
|
|
@ -80,6 +81,14 @@ export interface WizardState {
|
|||
*/
|
||||
importFolderLoaded: boolean;
|
||||
scannedSources: ScannedSource[];
|
||||
/**
|
||||
* Names of the CSV/TXT files placed directly at the root of the import
|
||||
* folder, which no source reads (#427). Set with `scannedSources` by the
|
||||
* same action, so the warning and the source list always come from the same
|
||||
* scan; kept by `RESET` like them. Shown on screen only, never logged: a
|
||||
* statement name often carries an account number.
|
||||
*/
|
||||
rootFiles: string[];
|
||||
selectedSource: ScannedSource | null;
|
||||
selectedFiles: ScannedFile[];
|
||||
sourceConfig: SourceConfig;
|
||||
|
|
@ -134,7 +143,7 @@ export type WizardAction =
|
|||
| { type: "SET_STEP"; payload: ImportWizardStep }
|
||||
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
|
||||
| { type: "SET_IMPORT_FOLDER_LOADED" }
|
||||
| { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] }
|
||||
| { type: "SET_SCANNED_SOURCES"; payload: ImportFolderScan }
|
||||
| { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
|
||||
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
|
||||
| { type: "SET_SOURCE_CONFIG"; payload: SourceConfig }
|
||||
|
|
@ -174,6 +183,7 @@ const initialState: WizardState = {
|
|||
importFolder: null,
|
||||
importFolderLoaded: false,
|
||||
scannedSources: [],
|
||||
rootFiles: [],
|
||||
selectedSource: null,
|
||||
selectedFiles: [],
|
||||
sourceConfig: { ...defaultConfig },
|
||||
|
|
@ -214,7 +224,12 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
|
|||
case "SET_IMPORT_FOLDER_LOADED":
|
||||
return { ...state, importFolderLoaded: true };
|
||||
case "SET_SCANNED_SOURCES":
|
||||
return { ...state, scannedSources: action.payload, isLoading: false };
|
||||
return {
|
||||
...state,
|
||||
scannedSources: action.payload.sources,
|
||||
rootFiles: action.payload.root_files,
|
||||
isLoading: false,
|
||||
};
|
||||
case "SET_SELECTED_SOURCE":
|
||||
// The score belongs to the source it was measured on. Carrying it over
|
||||
// would let a source opened on its stored format inherit the confidence
|
||||
|
|
@ -304,13 +319,14 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
|
|||
// Rebuilt from `initialState`, minus what the page read once and keeps:
|
||||
// the folder, whether it was read (#384: the mount read does not run
|
||||
// again, so "Cancel" or "Done" would otherwise hide the empty state and
|
||||
// the step card for good), its scan, the configured sources and the
|
||||
// templates.
|
||||
// the step card for good), its scan (sources and root files, #427), the
|
||||
// configured sources and the templates.
|
||||
return {
|
||||
...initialState,
|
||||
importFolder: state.importFolder,
|
||||
importFolderLoaded: state.importFolderLoaded,
|
||||
scannedSources: state.scannedSources,
|
||||
rootFiles: state.rootFiles,
|
||||
configuredSourceNames: state.configuredSourceNames,
|
||||
importedFilesBySource: state.importedFilesBySource,
|
||||
configTemplates: state.configTemplates,
|
||||
|
|
@ -378,10 +394,11 @@ export function useImportWizard() {
|
|||
async (folder: string) => {
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
try {
|
||||
const sources = await invoke<ScannedSource[]>("scan_import_folder", {
|
||||
// The root file names stay out of the logs (#427): on screen only.
|
||||
const scan = await invoke<ImportFolderScan>("scan_import_folder", {
|
||||
folderPath: folder,
|
||||
});
|
||||
dispatch({ type: "SET_SCANNED_SOURCES", payload: sources });
|
||||
dispatch({ type: "SET_SCANNED_SOURCES", payload: scan });
|
||||
await loadConfiguredSources();
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import ImportProgress from "../components/import/ImportProgress";
|
|||
import ImportReportPanel from "../components/import/ImportReportPanel";
|
||||
import WizardNavigation from "../components/import/WizardNavigation";
|
||||
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
|
||||
import RootFilesWarning from "../components/import/RootFilesWarning";
|
||||
import { AlertCircle, CheckCircle, X } from "lucide-react";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
|
||||
|
|
@ -55,6 +56,16 @@ export default function ImportPage() {
|
|||
|
||||
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
|
||||
const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0;
|
||||
// Statement files placed directly in the import folder, which no source
|
||||
// reads (#427): named in a warning, above the source list or under the "no
|
||||
// source" empty state. Never without a folder (there is nothing scanned).
|
||||
const rootFilesWarning =
|
||||
state.importFolder !== null && state.rootFiles.length > 0 ? (
|
||||
<RootFilesWarning
|
||||
names={state.rootFiles}
|
||||
folderName={folderNameFromPath(state.importFolder)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// The getting-started card follows the wizard (#398). Its target is
|
||||
// highlighted only while the card is on screen: a ring needs the card that
|
||||
|
|
@ -138,6 +149,7 @@ export default function ImportPage() {
|
|||
*/}
|
||||
{state.importFolderLoaded &&
|
||||
(showEmptyState ? (
|
||||
<>
|
||||
<ImportEmptyState
|
||||
hasFolder={state.importFolder !== null}
|
||||
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
|
||||
|
|
@ -147,7 +159,11 @@ export default function ImportPage() {
|
|||
isCreatingSample={state.isLoading}
|
||||
onboardingTarget={target === "emptyStateAction"}
|
||||
/>
|
||||
{rootFilesWarning && <div className="mt-4">{rootFilesWarning}</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{rootFilesWarning}
|
||||
<SourceList
|
||||
sources={state.scannedSources}
|
||||
configuredSourceNames={state.configuredSourceNames}
|
||||
|
|
@ -155,6 +171,7 @@ export default function ImportPage() {
|
|||
onSelectSource={selectSource}
|
||||
onboardingTarget={target === "firstSource"}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
<ImportHistoryPanel onChanged={refreshFolder} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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