94 lines
3.2 KiB
JavaScript
94 lines
3.2 KiB
JavaScript
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { parse } from "csv-parse/sync";
|
|
import { stringify } from "csv-stringify/sync";
|
|
|
|
// Diese Spalten bilden den festen Aufbau der Bewertungen-CSV.
|
|
const columns = ["createdAt", "email", "title", "review", "rating", "consent"];
|
|
|
|
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
|
|
function protect(value) {
|
|
const text = String(value ?? "");
|
|
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
|
|
}
|
|
|
|
// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her.
|
|
function restore(value) {
|
|
return value.startsWith("'") ? value.slice(1) : value;
|
|
}
|
|
|
|
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
|
|
export function createReviewStorage(filePath) {
|
|
let queue = Promise.resolve();
|
|
|
|
// Liest alle Bewertungen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
|
|
async function readAll() {
|
|
let content;
|
|
try {
|
|
content = await readFile(filePath, "utf8");
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return [];
|
|
throw error;
|
|
}
|
|
if (!content.trim()) throw new Error("Leere Bewertungsdatei");
|
|
return parse(content, {
|
|
bom: true,
|
|
columns(header) {
|
|
if (header.join(",") !== columns.join(",")) {
|
|
throw new Error("Unbekanntes Bewertungs-CSV-Format");
|
|
}
|
|
return header;
|
|
},
|
|
skip_empty_lines: true,
|
|
}).map((row) =>
|
|
Object.fromEntries(
|
|
Object.entries(row).map(([key, value]) => [
|
|
key,
|
|
restore(value),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original.
|
|
async function writeAll(records) {
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
const temporary = `${filePath}.${randomUUID()}.tmp`;
|
|
const content = stringify(
|
|
records.map((row) =>
|
|
Object.fromEntries(
|
|
columns.map((column) => [column, protect(row[column])]),
|
|
),
|
|
),
|
|
{ header: true, columns, record_delimiter: "\r\n" },
|
|
);
|
|
try {
|
|
const handle = await open(temporary, "wx", 0o600);
|
|
try {
|
|
await handle.writeFile(content, "utf8");
|
|
await handle.sync();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
await rename(temporary, filePath);
|
|
} finally {
|
|
await rm(temporary, { force: true });
|
|
}
|
|
}
|
|
|
|
return {
|
|
readAll,
|
|
// Hängt eine Bewertung nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren.
|
|
append(record) {
|
|
const operation = queue.then(async () => {
|
|
const records = await readAll();
|
|
records.push(record);
|
|
await writeAll(records);
|
|
});
|
|
queue = operation.catch(() => {});
|
|
return operation;
|
|
},
|
|
};
|
|
}
|