Lewati ke konten

Perbaiki pemisah CSV

Ubah pemisah pipa menjadi koma, validasi UUID dan baris kembar.

Berkas: alat/hari-3/Praktik 6/tools/perbaiki-csv.mjs

Praktik 6/tools/perbaiki-csv.mjs
#!/usr/bin/env node
// =====================================================================
// Praktik 6 - perbaiki-csv.mjs
//
// Masalah pada berkas latihan:
// "katalog_data_2d.csv" memakai tanda PIPAI (|) sebagai pemisah kolom,
// padahal namanya .csv dan DBeaver/Excel memakai koma. Import lewat
// "Import Data" di DBeaver akan menghasilkan satu kolom raksasa, bukan
// tujuh kolom.
//
// Skrip ini menulis ulang berkas ke CSV yang benar (RFC 4180), sekaligus:
// - memvalidasi setiap data_2d_id berupa UUID
// - menandai baris duplikat
// - melaporkan kolom mana saja yang kosong
//
// Pakai:
// node perbaiki-csv.mjs <input.csv> [output.csv]
// node perbaiki-csv.mjs --cek <input.csv> # hanya lapor, tidak menulis
// =====================================================================
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { basename, dirname } from 'node:path';
// Kolom wajib berbeda antara katalog 2D (layer_name) dan 3D (nama).
// Keduanya wajib punya kolom akses dan sebuah kolom identitas.
// Ditentukan setelah header terbaca, lihat bagian "pemeriksaan".
const NILAI_AKSES_VALID = ['public', 'private'];
const POLA_UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// --- argumen ---------------------------------------------------------
const argv = process.argv.slice(2);
const hanyaCek = argv.includes('--cek');
const posisi = argv.filter((a) => !a.startsWith('--'));
const masukan = posisi[0];
const keluaran = posisi[1] ?? (masukan ? masukan.replace(/\.csv$/i, '') + '.bersih.csv' : null);
if (!masukan) {
console.error('Pakai: node perbaiki-csv.mjs <input.csv> [output.csv] [--cek]');
process.exit(2);
}
// --- deteksi pemisah -------------------------------------------------
// Menghitung pemisah di luar tanda kutip pada baris pertama.
function hitungPemisah(baris, pemisah) {
let jumlah = 0;
let dalamKutip = false;
for (let i = 0; i < baris.length; i++) {
const c = baris[i];
if (c === '"') {
if (dalamKutip && baris[i + 1] === '"') i++;
else dalamKutip = !dalamKutip;
} else if (c === pemisah && !dalamKutip) {
jumlah++;
}
}
return jumlah;
}
const mentah = readFileSync(masukan, 'utf8').replace(/^\uFEFF/, '');
const barisPertama = mentah.split(/\r?\n/).find((b) => b.trim() !== '') ?? '';
const kandidat = ['|', ',', ';', '\t'];
const skor = kandidat.map((p) => ({ pemisah: p, jumlah: hitungPemisah(barisPertama, p) }));
skor.sort((a, b) => b.jumlah - a.jumlah);
const pemisah = skor[0].jumlah > 0 ? skor[0].pemisah : ',';
console.log(`Berkas : ${masukan}`);
console.log(
`Pemisah : ${JSON.stringify(pemisah)} (${skor[0].jumlah + 1} kolom terdeteksi` +
`${pemisah !== ',' ? '; CSV seharusnya memakai koma -> akan dikonversi' : ''})`,
);
// --- parser CSV (mendukung tanda kutip dan newline di dalam sel) -----
function parseCsv(teks, pemisah) {
const baris = [];
let sel = [];
let nilai = '';
let dalamKutip = false;
for (let i = 0; i < teks.length; i++) {
const c = teks[i];
if (dalamKutip) {
if (c === '"') {
if (teks[i + 1] === '"') { nilai += '"'; i++; }
else dalamKutip = false;
} else nilai += c;
continue;
}
if (c === '"') { dalamKutip = true; continue; }
if (c === pemisah) { sel.push(nilai); nilai = ''; continue; }
if (c === '\r') continue;
if (c === '\n') { sel.push(nilai); baris.push(sel); sel = []; nilai = ''; continue; }
nilai += c;
}
if (nilai !== '' || sel.length > 0) { sel.push(nilai); baris.push(sel); }
return baris.filter((b) => b.some((s) => s.trim() !== ''));
}
const baris = parseCsv(mentah, pemisah);
if (baris.length === 0) {
console.error('Berkas kosong atau tidak bisa dibaca.');
process.exit(1);
}
const header = baris[0].map((h) => h.trim());
const data = baris.slice(1);
// --- pemeriksaan -----------------------------------------------------
const masalah = [];
const catatan = [];
const KOLOM_WAJIB = header.includes('layer_name') ? ['layer_name', 'akses'] : ['nama', 'akses'];
const hilang = KOLOM_WAJIB.filter((k) => !header.includes(k));
if (hilang.length > 0) masalah.push(`kolom wajib tidak ada di header: ${hilang.join(', ')}`);
const idx = Object.fromEntries(header.map((h, i) => [h, i]));
// Kolom primary key dideteksi, bukan dipatok: berkas 2D memakai
// data_2d_id, berkas 3D memakai data_3d_id.
const kolomId = header.find((h) => h === 'data_2d_id' || h === 'data_3d_id')
?? header.find((h) => h.endsWith('_id'));
if (kolomId) console.log(`Kunci utama : ${kolomId}`);
else masalah.push('tidak menemukan kolom primary key berakhiran _id');
const terlihat = new Set();
data.forEach((row, n) => {
const nomorBaris = n + 2; // +1 header, +1 karena manusia mulai dari 1
if (row.length !== header.length) {
masalah.push(`baris ${nomorBaris}: ${row.length} sel, header punya ${header.length} kolom`);
}
const id = kolomId ? (row[idx[kolomId]] ?? '').trim() : '';
if (kolomId) {
if (!id) masalah.push(`baris ${nomorBaris}: ${kolomId} kosong`);
else if (!POLA_UUID.test(id)) masalah.push(`baris ${nomorBaris}: ${kolomId} bukan UUID -> ${id}`);
else if (terlihat.has(id)) masalah.push(`baris ${nomorBaris}: ${kolomId} duplikat -> ${id}`);
else terlihat.add(id);
}
const akses = (row[idx.akses] ?? '').trim();
if (idx.akses !== undefined && akses && !NILAI_AKSES_VALID.includes(akses)) {
masalah.push(`baris ${nomorBaris}: akses "${akses}" tidak dikenal (harus public/private)`);
}
const author = (row[idx.author] ?? '').trim();
if (idx.author !== undefined && author && !POLA_UUID.test(author)) {
masalah.push(`baris ${nomorBaris}: author bukan UUID -> ${author}`);
}
});
if (idx.author !== undefined) {
const authors = [...new Set(data.map((r) => (r[idx.author] ?? '').trim()).filter(Boolean))];
catatan.push(
`author yang dipakai CSV: ${authors.length} nilai (${authors.map((a) => a.slice(0, 8)).join(', ')}…)`,
);
catatan.push(
'author ini TIDAK ada di tabel users. Import akan gagal saat foreign key dibuat — ' +
'inilah alasan modul menyuruh menghapus row hasil import lebih dahulu.',
);
}
console.log(`Header : ${header.join(', ')}`);
console.log(`Baris data : ${data.length}`);
for (const c of catatan) console.log(`Catatan : ${c}`);
if (masalah.length > 0) {
console.log(`\nHASIL: ${masalah.length} masalah`);
for (const m of masalah) console.log(` - ${m}`);
} else {
console.log('\nHASIL: isi baris valid');
}
// --- tulis CSV yang benar -------------------------------------------
function selCsv(nilai) {
const s = String(nilai ?? '');
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
if (hanyaCek) {
console.log('\n(--cek: tidak ada berkas yang ditulis)');
process.exit(masalah.length > 0 ? 1 : 0);
}
const keluaranCsv =
[header.map(selCsv).join(','), ...data.map((r) => header.map((_, i) => selCsv(r[i])).join(','))].join(
'\n',
) + '\n';
mkdirSync(dirname(keluaran), { recursive: true });
writeFileSync(keluaran, keluaranCsv, 'utf8');
console.log(`\nDitulis : ${keluaran} (${basename(keluaran)})`);
console.log('Import berkas ini di DBeaver dengan delimiter koma, header di baris pertama.');
process.exit(masalah.length > 0 ? 1 : 0);