aboutsummaryrefslogtreecommitdiffstats
path: root/src/format/index.js
blob: 5c77cfaf0520884601d0dfbbc32f1c908939049f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
// Output formats. They all get the JSON result from the daemon; none talks to WhatsApp.
// To add a format, add it to FORMATS as (data, opts) => string.

import { toEml, toMbox } from './mail.js';
import { toText } from './text.js';

export const FORMATS = {
    json: (data) => JSON.stringify(data, null, 2),
    ndjson: (data) => (Array.isArray(data) ? data : [data]).map((d) => JSON.stringify(d)).join('\n'),
    table: (data, opts) => table(data, opts),
    csv: (data, opts) => csv(data, opts),
    yaml: (data) => yaml(data).replace(/\n$/, ''),
    text: (data, opts) => toText(data, opts) ?? table(data, opts),
    eml: (data) => toEml(data),
    mbox: (data) => toMbox(data),
};

export function format(data, name, opts = {}) {
    const fn = FORMATS[name];
    if (!fn) throw Object.assign(new Error(`formato desconocido: ${name} (${Object.keys(FORMATS).join(', ')})`), { code: 'EINVAL' });
    return fn(data, opts);
}

// ---------------------------------------------------------------- helpers

export function getPath(obj, key) {
    return key.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
}

function flatten(obj, prefix = '', out = {}) {
    for (const [k, v] of Object.entries(obj ?? {})) {
        const key = prefix ? `${prefix}.${k}` : k;
        if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
        else out[key] = v;
    }
    return out;
}

function cell(v) {
    if (v === null || v === undefined) return '';
    if (Array.isArray(v)) return v.map((x) => (typeof x === 'object' ? JSON.stringify(x) : String(x))).join(', ');
    if (typeof v === 'object') return JSON.stringify(v);
    return String(v);
}

function rowsAndColumns(data, columns) {
    if (!Array.isArray(data)) {
        const flat = flatten(data);
        return { columns: ['key', 'value'], rows: Object.entries(flat).map(([key, value]) => ({ key, value })) };
    }
    if (!columns) {
        const seen = new Set();
        for (const r of data.slice(0, 50)) {
            for (const [k, v] of Object.entries(flatten(r))) if (typeof v !== 'object' || v === null) seen.add(k);
        }
        columns = [...seen];
    }
    return { columns, rows: data };
}

// Approximate terminal width (emoji and CJK take 2 columns).
const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1f300}-\u{1faff}]/u;
function width(s) {
    let w = 0;
    for (const ch of s) w += WIDE.test(ch) ? 2 : /\p{M}|‍|️/u.test(ch) ? 0 : 1;
    return w;
}
function truncate(s, max) {
    if (width(s) <= max) return s;
    let out = '';
    let w = 0;
    for (const ch of s) {
        const cw = width(ch);
        if (w + cw > max - 1) break;
        out += ch;
        w += cw;
    }
    return out + '…';
}
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)));

function table(data, { columns } = {}) {
    if (data === null || data === undefined) return '';
    if (typeof data !== 'object') return String(data);
    const { columns: cols, rows } = rowsAndColumns(data, columns);
    if (!rows.length) return '';
    const cells = rows.map((r) => cols.map((c) => cell(getPath(r, c)).replace(/\s*\n\s*/g, ' ⏎ ')));
    const widths = cols.map((c, i) => Math.max(width(c), ...cells.map((r) => width(r[i]))));
    // Fit the terminal width by shrinking the widest columns.
    const max = process.stdout.columns || 160;
    const sep = 2;
    while (widths.reduce((a, b) => a + b, 0) + sep * (cols.length - 1) > max) {
        const i = widths.indexOf(Math.max(...widths));
        if (widths[i] <= 12) break;
        widths[i]--;
    }
    const line = (vals) => vals.map((v, i) => pad(truncate(v, widths[i]), widths[i])).join(' '.repeat(sep)).trimEnd();
    return [line(cols.map((c) => c.toUpperCase())), ...cells.map(line)].join('\n');
}

function csv(data, { columns } = {}) {
    if (data === null || data === undefined || typeof data !== 'object') return cell(data);
    const { columns: cols, rows } = rowsAndColumns(data, columns);
    const esc = (s) => (/[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
    return [cols.join(','), ...rows.map((r) => cols.map((c) => esc(cell(getPath(r, c)))).join(','))].join('\n');
}

function yamlScalar(v) {
    if (v === null || v === undefined) return 'null';
    if (typeof v === 'number' || typeof v === 'boolean') return String(v);
    const s = String(v);
    if (s === '' || /^[\s-?:,[\]{}#&*!|>'"%@`]|[:#]\s|\s$|\n/.test(s) ||
        /^(true|false|null|yes|no|on|off|~|[-+]?\d[\d_.eE+-]*)$/i.test(s)) {
        return JSON.stringify(s);
    }
    return s;
}

function yaml(v, indent = '') {
    if (Array.isArray(v)) {
        if (!v.length) return `${indent}[]\n`;
        return v.map((item) => {
            if (item && typeof item === 'object' && Object.keys(item).length) {
                const inner = yaml(item, `${indent}  `);
                return `${indent}- ${inner.slice(indent.length + 2)}`;
            }
            return `${indent}- ${item && typeof item === 'object' ? (Array.isArray(item) ? '[]' : '{}') : yamlScalar(item)}\n`;
        }).join('');
    }
    if (v && typeof v === 'object') {
        const entries = Object.entries(v);
        if (!entries.length) return `${indent}{}\n`;
        return entries.map(([k, val]) => {
            const key = yamlScalar(k);
            if (val && typeof val === 'object' && (Array.isArray(val) ? val.length : Object.keys(val).length)) {
                return `${indent}${key}:\n${yaml(val, `${indent}  `)}`;
            }
            const scalar = val && typeof val === 'object' ? (Array.isArray(val) ? '[]' : '{}') : yamlScalar(val);
            return `${indent}${key}: ${scalar}\n`;
        }).join('');
    }
    return `${indent}${yamlScalar(v)}\n`;
}