diff options
Diffstat (limited to 'src/format/index.js')
| -rw-r--r-- | src/format/index.js | 143 |
1 files changed, 143 insertions, 0 deletions
diff --git a/src/format/index.js b/src/format/index.js new file mode 100644 index 0000000..5c77cfa --- /dev/null +++ b/src/format/index.js @@ -0,0 +1,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`; +} |