aboutsummaryrefslogtreecommitdiffstats
path: root/src/format/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/format/index.js')
-rw-r--r--src/format/index.js143
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`;
+}