Към съдържанието
wannacode

Данни във файлове

Проблем

Текст, а трябват числа

Складът изнася продуктите си във файл. Всеки ред е един продукт: име, цена и наличност, разделени със запетаи. Първият ред казва какво е във всяка колона:

products.csv
Име,Цена,Наличност
Хляб,1.20,30
Мляко,2.10,18
Кафе на зърна,18.90,12

Вече можеш да прочетеш файла. Но за програмата редът е само текст:

const line = "Хляб,1.20,30";
console.log(line.length);
console.log(line[5]);
Изход
12
1

Тя не знае, че 1.20 е цена, а 30 е брой. За да сметнеш колко струва стоката, трябва да превърнеш всеки ред в нещо с полета: в обект Product.

Идеята

CSV: ред към обект

Такъв файл се казва CSV (comma-separated values, „стойности, разделени със запетая“). Чете се от Excel, от бази данни, от всяка програма. Един ред става обект в три стъпки: split, превръщане на всяка част в нужния тип, конструктор.

Най-удобно е това да е статичен метод (static) в самия клас:
function toNumber(text) {
const n = Number(text);
if (text.trim() === "" || Number.isNaN(n)) {
throw new Error(`грешно число (${text.trim()})`);
}
return n;
}
class Product {
constructor(name, price, stock) {
this.name = name;
this.price = price;
this.stock = stock;
}
static fromCsv(line) {
const f = line.split(",");
if (f.length !== 3) {
throw new Error(`очаквам 3 колони, а са ${f.length}`);
}
return new Product(f[0].trim(), toNumber(f[1]), toNumber(f[2]));
}
toCsv() {
return `${this.name},${this.price},${this.stock}`;
}
}
const p = Product.fromCsv("Кафе на зърна,18.90,12");
console.log(`${p.name}: ${p.price.toFixed(2)} € x ${p.stock} = ${(p.price * p.stock).toFixed(2)} €`);
Изход
Кафе на зърна: 18.90 € x 12 = 226.80 €

fromCsv е единственото място, което знае как изглежда редът. Ако форматът се промени, променяш само него. Проверката за броя на колоните хвърля грешка с ясно съобщение, вместо програмата да спре по-късно с неясна.

Number("седем") не хвърля грешка, а дава NaN („не е число“), а Number("") дава 0. Програма, която не проверява, тихо смята с грешни числа. Затова toNumber проверява текста и хвърля сама.

Какво прави fromCsv с реда, стъпка по стъпка, с тялото му, разписано ред по ред:

const line = "Кафе на зърна,18.90,12";
const f = line.split(",");
const name = f[0];
const price = Number(f[1]);
const stock = Number(f[2]);
const p = new Product(name, price, stock);
console.log(`${p.name}: ${p.price} € x ${p.stock}`);
console.log(`${(p.price * p.stock).toFixed(2)} €`);
Стъпка по стъпка, с думи
  1. Редът от файла е един текст. За програмата 18.90 в него е само четири знака.
  2. split реже реда при всяка запетая: масив от три текста.
  3. Името си остава текст.
  4. Number превръща текста "18.90" в числото 18.9. С него вече може да се смята.
  5. Number прави от "12" числото 12. В JavaScript и цената, и бройката са number.
  6. new Product слага трите стойности в полетата на нов обект, а p сочи към него.
  7. p.price се печата като 18.9: нулата в края беше само в текста.
  8. Цената по наличността: вече е сметка с числа.

Виж го

Целият файл

Зареждаш всички продукти в масив и смяташ стойността на стоката. Тук и по-нататък import { readFileSync, writeFileSync } from "node:fs"; е най-горе и не се показва:
try {
writeFileSync("products.csv", "Име,Цена,Наличност\nХляб,1.20,30\nМляко,2.10,18\nКафе на зърна,18.90,12\n");
const lines = readFileSync("products.csv", "utf8").trimEnd().split("\n");
const products = [];
let header = true;
for (const line of lines) {
if (header) {
header = false;
continue;
}
products.push(Product.fromCsv(line));
}
let value = 0;
for (const p of products) {
value += p.price * p.stock;
}
console.log(`${products.length} продукта, стока за ${value.toFixed(2)} €`);
} catch (e) {
console.log(`Не мога да прочета products.csv: ${e.message}`);
}
Изход
3 продукта, стока за 300.60 €
  • products започва като празен масив, а push добавя всеки нов продукт в края му. products.length е колко са.
  • header е true само за първото завъртане. Тогава редът се пропуска с continue.

Повече за масивите, които растат, има в модул „Колекции“.

Идеята

Грешни редове

Файловете ги пишат хора и други програми. Рано или късно идва ред без колона, дума вместо число или празен ред:

products.csv
Име,Цена,Наличност
Хляб,1.20,30
Мляко,2.10
Кафе,седем,4
Чай,3.50,25

Една грешка не трябва да спира зареждането на всичко останало. Всеки ред е в свой try, а съобщението казва номера на реда, за да може някой да го поправи:

try {
writeFileSync("products.csv", "Име,Цена,Наличност\nХляб,1.20,30\nМляко,2.10\nКафе,седем,4\n\nЧай,3.50,25\n");
const lines = readFileSync("products.csv", "utf8").trimEnd().split("\n");
const products = [];
let row = 0;
for (const line of lines) {
row++;
if (row === 1 || line.trim() === "") {
continue;
}
try {
products.push(Product.fromCsv(line));
} catch (e) {
console.log(`Ред ${row}: ${e.message}`);
}
}
console.log(`Заредени: ${products.length}`);
} catch (e) {
console.log(`Не мога да прочета products.csv: ${e.message}`);
}
Изход
Ред 3: очаквам 3 колони, а са 2
Ред 4: грешно число (седем)
Заредени: 2
  • Празните редове се пропускат с line.trim() === "".
  • Един catch хваща и двете грешки, които fromCsv хвърля: за броя на колоните и от toNumber. Кое е станало, казва e.message.
  • Програмата казва „Заредени: 2“ и продължава с тях.

Пробвай

Празни колони

При първия ред цената е празна, при втория наличността. Третият ред превръща празна колона в число:

Какво ще отпечата програмата?

console.log("Кафе,,4".split(",").length);
console.log("Кафе,7.50,".split(",").length);
console.log(Number(""));
Отговор

Идеята

Обекти към CSV

Обратната посока е метод, който сглобява реда:

toCsv() {
return `${this.name},${this.price},${this.stock}`;
}

Записваш заглавния ред и после по един ред за всеки обект:

const stock = [new Product("Хляб", 1.2, 30), new Product("Мляко", 2.1, 18)];
try {
let text = "Име,Цена,Наличност\n";
for (const p of stock) {
text += `${p.toCsv()}\n`;
}
writeFileSync("stock.csv", text);
process.stdout.write(readFileSync("stock.csv", "utf8"));
} catch (e) {
console.log(`Не мога да запиша stock.csv: ${e.message}`);
}
Изход
Име,Цена,Наличност
Хляб,1.2,30
Мляко,2.1,18

fromCsv може да прочете този файл обратно. Записът и четенето трябва да ползват един и същ формат.

Внимавай, ако форматираш цените с toLocaleString. Той следва езиковите настройки, а на български десетичният знак е запетая:

const bg = `Хляб,${(1.2).toLocaleString("bg-BG", { minimumFractionDigits: 2 })},30`;
console.log(`${bg} -> ${bg.split(",").length} колони`);
const fixed = `Хляб,${(1.2).toFixed(2)},30`;
console.log(`${fixed} -> ${fixed.split(",").length} колони`);
Изход
Хляб,1,20,30 -> 4 колони
Хляб,1.20,30 -> 3 колони

1,20 става две колони и редовете се разместват. За файлове, които друга програма ще чете, ползвай toFixed(2). Той дава точка винаги, на всеки компютър.

Пробвай

Намери грешката

Програмата трябва да зареди трите продукта от products.csv, а спира още на първия ред:

Грешка
Error: грешно число (Цена)

Избери реда с грешката.

Идеята

Колони по име

Друга програма изнася същите данни, но с колоните в друг ред и с една повече. Ако пишеш f[1] за цената, при нов формат ще четеш грешна колона и няма да разбереш. По-сигурно е да намериш колоната по името ѝ в заглавния ред:

function column(header, name) {
for (let i = 0; i < header.length; i++) {
if (header[i].trim().toLowerCase() === name.toLowerCase()) {
return i;
}
}
throw new Error(`Няма колона ${name}`);
}
try {
writeFileSync("export.csv", "Наличност,Име,Код,Цена\n30,Хляб,1001,1.20\n18,Мляко,1002,2.10\n");
let nameCol = -1;
let priceCol = -1;
for (const line of readFileSync("export.csv", "utf8").trimEnd().split("\n")) {
const f = line.split(",");
if (nameCol === -1) {
nameCol = column(f, "име");
priceCol = column(f, "цена");
continue;
}
console.log(`${f[nameCol]}: ${f[priceCol]} €`);
}
} catch (e) {
console.log(e.message);
}
Изход
Хляб: 1.20 €
Мляко: 2.10 €

Първото завъртане само намира къде са колоните. След това всеки ред се чете по тези индекси. Ако колоната я няма, column хвърля изключение веднага, вместо да чете грешни данни.

По-точно, за любопитните
  • Запетая в стойността. „Сирене, краве“ съдържа запетая. В CSV такава стойност се огражда с кавички: "Сирене, краве",9.80,5, а кавичка вътре се пише двойна: "". Тогава split(",") вече не стига. Node няма вграден модул за CSV. В истински проекти се ползва библиотека като csv-parse или Papa Parse, която знае всички правила.
  • Друг разделител. Excel на компютър с български настройки често записва CSV с ;, именно защото запетаята е десетичният знак. Тогава редът се реже със split(";").
  • JSON. За данни, които само твоя програма ще чете, в JavaScript най-често се ползва JSON: JSON.stringify(product) прави от обекта текст, а JSON.parse(text) го връща обратно.

Упражнение

Данни, които оживяват

Реши задачите в редактора си. Програмата първо сама създава входния файл.

1. Анализ на CSV. Създай people.csv със съдържанието по-долу. Отпечатай средната възраст (целочислено деление), кой е най-възрастен и колко души има от всеки град, в реда, в който градовете се появяват. Градовете пази в един масив, а броевете в друг, на същите индекси.

people.csv
Име,Възраст,Град
Иван,34,София
Мария,28,Пловдив
Георги,41,София
Елена,25,Варна
Изход
Средна възраст: 32
Най-възрастен: Георги, 41
Брой хора от София: 2
Брой хора от Пловдив: 1
Брой хора от Варна: 1

2. Запис на героя. Клас Hero с име, ниво, живот и злато. Метод save(path) записва героя като редове ключ=стойност, а статичен метод load(path) създава нов Hero от такъв файл. Създай „Арин“ с ниво 6, 100 живот и 120 злато, вдигни му нивото с 1, махни 15 живот, дай му 220 злато, запиши го, отпечатай файла и го зареди обратно.

Изход
name=Арин
level=7
health=85
gold=340
Заредих: Арин, ниво 7, живот 85, злато 340

3. Дублиран текст. Създай text.txt с текста по-долу. Раздели го на изречения по точката (и интервалите след нея) и отпечатай всяко изречение, което се среща повече от веднъж, по веднъж.

text.txt
Това е текст. Това също е текст. Това е текст.
Изход
Дублиран текст: "Това е текст."

Още задачи

  • Най-скъпият продукт. Зареди products.csv от урока и отпечатай продукта с най-висока цена и колко продукта имат наличност под 20.
  • Поправени редове. Зареди файла с грешните редове и запиши верните в products-clean.csv, а грешните, с номера и причината, в rejected.txt.
  • Отчет за продажбите. Файл sales.csv с колони Дата,Продукт,Брой,Цена. Редовете са подредени по дата. Отпечатай оборота за всеки ден с padEnd и toFixed в колони.

Тест

Провери се

1. Какво е първият ред на повечето CSV файлове?
2. Какво връща Number("седем")?
3. Колко е "Кафе,7.50,".split(",").length?
4. Записваш цени с price.toLocaleString("bg-BG", { minimumFractionDigits: 2 }). Какво ще стане със CSV файла?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·