From 1d03ee40543900c9684d262d80a3086b3392a6a0 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sun, 25 Oct 2026 10:49:23 +0300 Subject: [PATCH] feat: add JSON and CSV export Co-Authored-By: Claude Sonnet 5.5 --- src/lib/export.js | 34 +++++++++++++++++++++++++ test/export.test.js | 61 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/lib/export.js create mode 100644 test/export.test.js diff --git a/src/lib/export.js b/src/lib/export.js new file mode 100644 index 0000000..3d2b6d2 --- /dev/null +++ b/src/lib/export.js @@ -0,0 +1,34 @@ +export const COLUMNS = [ + 'videoId', + 'title', + 'url', + 'thumbnailUrl', + 'channelId', + 'channelTitle', + 'channelUrl', + 'subscribers', + 'views', + 'publishedAt', + 'durationSec', + 'isShort', + 'score', + 'vidiqScore', + 'foundOn', + 'firstSeenAt', + 'updatedAt', +]; + +export function toJson(records) { + return JSON.stringify(records, null, 2); +} + +function csvCell(value) { + if (value === null || value === undefined) return ''; + const text = String(value); + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +export function toCsv(records) { + const lines = [COLUMNS.join(','), ...records.map((r) => COLUMNS.map((c) => csvCell(r[c])).join(','))]; + return lines.join('\r\n') + '\r\n'; +} diff --git a/test/export.test.js b/test/export.test.js new file mode 100644 index 0000000..24e3f25 --- /dev/null +++ b/test/export.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { COLUMNS, toCsv, toJson } from '../src/lib/export.js'; + +const record = (over = {}) => ({ + videoId: 'dQw4w9WgXcQ', + title: 'Hello', + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', + channelId: 'UC1', + channelTitle: 'Chan', + channelUrl: 'https://www.youtube.com/@chan', + subscribers: 1000, + views: 50000, + publishedAt: '2026-10-01T00:00:00Z', + durationSec: 600, + isShort: false, + score: 50, + vidiqScore: null, + foundOn: 'home', + firstSeenAt: '2026-10-25T10:00:00.000Z', + updatedAt: '2026-10-25T10:00:00.000Z', + ...over, +}); + +test('toCsv writes a header and one row per record', () => { + const csv = toCsv([record()]); + assert.equal( + csv, + COLUMNS.join(',') + + '\r\n' + + 'dQw4w9WgXcQ,Hello,https://www.youtube.com/watch?v=dQw4w9WgXcQ,' + + 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg,UC1,Chan,https://www.youtube.com/@chan,' + + '1000,50000,2026-10-01T00:00:00Z,600,false,50,,home,' + + '2026-10-25T10:00:00.000Z,2026-10-25T10:00:00.000Z\r\n', + ); +}); + +test('toCsv quotes commas, quotes and newlines', () => { + const csv = toCsv([record({ title: 'He said "hi", then\nleft' })]); + assert.ok(csv.includes('"He said ""hi"", then\nleft"')); +}); + +test('toCsv writes null and undefined as empty cells and keeps booleans readable', () => { + const csv = toCsv([record({ subscribers: null, vidiqScore: 7.5, isShort: true, channelUrl: undefined })]); + const row = csv.split('\r\n')[1]; + assert.ok(row.includes(',true,')); + assert.ok(row.includes(',7.5,')); + assert.ok(row.includes('Chan,,,50000')); // channelUrl and subscribers empty +}); + +test('toCsv of no records is just the header', () => { + assert.equal(toCsv([]), COLUMNS.join(',') + '\r\n'); +}); + +test('toJson pretty-prints and round-trips', () => { + const records = [record(), record({ videoId: 'abcdefghijk' })]; + const text = toJson(records); + assert.ok(text.includes('\n ')); + assert.deepEqual(JSON.parse(text), records); +});