@athenox/talos
Version:
Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.
144 lines (143 loc) • 5.94 kB
JavaScript
/**
* Moving rows in and out: CSV import and export, and JSON APIs read into rows.
* (Mail stays the app's: its transport, templates and sending are not Talos's.)
*/
// --- CSV ---------------------------------------------------------------------
/** CSV text to rows keyed by header; `,`, `;` or tab (whichever the header uses most), quotes and "" escapes. */
export function parseCsv(text) {
const src = text.replace(/^/, '');
const first = src.split(/\r?\n/, 1)[0] ?? '';
const sep = [',', ';', '\t'].map(s => ({ s, n: first.split(s).length })).sort((a, b) => b.n - a.n)[0].s;
const rows = [];
let row = [], cell = '', quoted = false;
for (let i = 0; i < src.length; i++) {
const ch = src[i];
if (quoted) {
if (ch === '"' && src[i + 1] === '"') {
cell += '"';
i++;
}
else if (ch === '"')
quoted = false;
else
cell += ch;
}
else if (ch === '"')
quoted = true;
else if (ch === sep) {
row.push(cell);
cell = '';
}
else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && src[i + 1] === '\n')
i++;
row.push(cell);
cell = '';
if (row.some(c => c.trim()))
rows.push(row);
row = [];
}
else
cell += ch;
}
row.push(cell);
if (row.some(c => c.trim()))
rows.push(row);
const [head, ...body] = rows;
const keys = (head ?? []).map(h => h.trim());
return body.map(r => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()])));
}
/** Rows to CSV, with the columns given (header labels optional). */
export function toCsv(rows, columns, sep = ',') {
const cols = Array.isArray(columns) ? Object.fromEntries(columns.map(c => [c, c])) : columns;
const cell = (v) => {
const s = v === null || v === undefined ? '' : v instanceof Date ? v.toISOString() : String(v);
return /["\n\r]/.test(s) || s.includes(sep) ? `"${s.replace(/"/g, '""')}"` : s;
};
return [Object.values(cols).map(cell).join(sep), ...rows.map(r => Object.keys(cols).map(k => cell(r[k])).join(sep))].join('\n');
}
/**
* Import CSV rows into a table through a write function (a sentence route's
* runner, or anything taking values): headers mapped to columns (several
* spellings each), a key column deciding what is already there, and a report.
*
* await importCsv(text, { map: { email: ['email', 'e-mail', 'courriel'], name: ['nom', 'name'] },
* key: 'email', exists: email => …, write: values => … })
*/
export async function importCsv(text, o) {
const report = { total: 0, inserted: 0, updated: 0, skipped: 0, errors: [] };
const norm = (s) => s.toLowerCase().normalize('NFD').replace(/\p{Mn}/gu, '').replace(/[^a-z0-9]/g, '');
const seen = new Set();
for (const [i, raw] of parseCsv(text).entries()) {
report.total++;
const byHead = new Map(Object.entries(raw).map(([k, v]) => [norm(k), v]));
const values = {};
for (const [col, heads] of Object.entries(o.map)) {
const v = [heads].flat().map(h => byHead.get(norm(h))).find(v => v !== undefined && v !== '');
if (v !== undefined)
values[col] = v;
}
const reason = o.check?.(values);
if (reason) {
report.errors.push({ line: i + 2, reason });
continue;
}
const key = o.key ? String(values[o.key] ?? '').toLowerCase() : '';
if (o.key && (!key || seen.has(key))) {
report.skipped++;
continue;
}
if (o.key)
seen.add(key);
try {
if (o.key && o.exists && await o.exists(key)) {
if (o.update) {
await o.update(values);
report.updated++;
}
else
report.skipped++;
}
else {
await o.write(values);
report.inserted++;
}
}
catch (e) {
report.errors.push({ line: i + 2, reason: e?.message ?? String(e) });
}
}
return report;
}
// --- JSON APIs ---------------------------------------------------------------
/**
* A JSON API read into a row: `{placeholders}` in the URL, dotted paths or
* functions for each field, a timeout, and a small cache. Returns null when
* the API has nothing (404, or `found` says so).
*
* const product = connector({ url: 'https://world.openfoodfacts.org/api/v2/product/{code}.json',
* found: j => j.status === 1, map: { name: 'product.product_name', brand: 'product.brands' } })
* await product({ code: '3017620422003' })
*/
export function connector(o) {
const cache = new Map();
const at = (j, path) => path.split('.').reduce((x, k) => x?.[/^\d+$/.test(k) ? Number(k) : k], j);
return async (params) => {
const url = o.url.replace(/\{(\w+)\}/g, (_, k) => encodeURIComponent(String(params[k] ?? '')));
const hit = cache.get(url);
if (hit && Date.now() - hit.at < (o.cacheMs ?? 10 * 60_000))
return hit.value;
const r = await fetch(url, { headers: { accept: 'application/json', ...o.headers }, signal: AbortSignal.timeout(o.timeoutMs ?? 8000) });
if (r.status === 404) {
cache.set(url, { at: Date.now(), value: null });
return null;
}
if (!r.ok)
throw new Error(`${new URL(url).host} answered ${r.status}`);
const j = await r.json();
const value = o.found && !o.found(j) ? null
: Object.fromEntries(Object.entries(o.map).map(([k, p]) => [k, typeof p === 'function' ? p(j) : at(j, p)]));
cache.set(url, { at: Date.now(), value });
return value;
};
}