Transformace dat, oprava ukladani udaju konektoru
Transformace dat ve dvou rezimech plus oprava chyby, kvuli ktere se neukladaly
pristupove udaje konektoru. Popis v documentation/13-transformace-dat.md.
Kroky si predavaji i cele struktury:
- FieldType ma object a list. Do sablony se nedosazuji, predavaji se jako celek
dalsimu kroku - proto je u nich v builderu vyber a ne textove pole. Z podminek
nad nimi ma smysl jen "prisla / neprisla"
- strop na velikost struktury (SCRIPT_MAX_VALUE_BYTES, vychozi 256 kB). Radek
s vystupem kroku je nejrychleji rostouci tabulka v systemu
Dva rezimy transformace, oba nad enginem v src/scripts/mapping.ts:
- transform.map-fields: pole na pole s prevody, klikatelne
- transform.to-json: sablona cileveho objektu s ${cesta}
Marker ${...} je zamerne jiny nez {{...}}. Sablony kroku se dosazuji driv, nez
krok bezi, takze {{total}} by strom stihl vyhodnotit, nenasel by parametr toho
jmena a dosadil by prazdno. Cely retezec navic zachova typ, takze
"unitPrice": "${total}" vyrobi cislo - jinak by cizi sluzba dostala castku jako
text a odmitla ji.
Prevod map pro seznamy je to, bez ceho by priklad nesel dokoncit. Bez nej jde
prevest hlavicku dokladu, ale ne polozky objednavky, a doklad by byl na nulu.
Dal pridano:
- idoklad.create-invoice-from-object: druha polovina prikladu, bere hotove telo
dokladu z transformace a doplni povinna pole ze vzoru iDokladu
- spoustec e-shopu predava celou objednavku jako objekt a polozky jako seznam
- klikaci editor pravidel vcetne rezimu JSON pro vnorena pravidla u map
- kontrola JSONu a tvaru pravidel uz pri ulozeni stromu. Preklep je nedodelek,
ne chyba ukladani - rozdelana prace se nezahazuje
Opraveno: konektor neukladal pristupove udaje. Server byl v poradku, overeno
volanim POST i PATCH. Chyba byla v prohlizeci: u pole type="password" prohlizec
ignoruje autocomplete="off" a dosazuje ulozene prihlaseni. Uzivatel pak videl
jednu hodnotu, React drzel jinou, a ulozilo se to, co drzel React, tedy nic.
Resi to autocomplete="new-password", jmena poli, ktera nepripominaji heslo,
a prepinac zobrazeni, aby slo overit, co je opravdu zapsane.
Zakladani a uprava konektoru se presunuly do dialogu, na strance jsou jen male
karty. Formulare rozlozene po strance byly u vic konektoru neprehledne.
Overeno: npm run typecheck prochazi na serveru i webu, node --check na skriptech.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8ad91a6c28
commit
ad56c7f513
+15
-1
@@ -59,12 +59,26 @@ export function parseManifest(raw: unknown, expectedId: string): ParseResult {
|
||||
/**
|
||||
* Nastavitelne pole akce pro builder.
|
||||
* `kind` se odvodi z manifestu, aby se nemuselo psat dvakrat.
|
||||
*
|
||||
* Poradi rozhodovani: vyslovne urceny `control` vyhrava, pak vyber z hodnot,
|
||||
* pak typ struktury, pak vic radku. Vyslovne urceni je prvni zamerne - jen
|
||||
* autor skriptu vi, ze `rules` je mapovani a ne obycejny seznam.
|
||||
*/
|
||||
function toOperationField(field: ScriptField): OperationField {
|
||||
const kind: OperationField['kind'] = field.control
|
||||
? field.control
|
||||
: field.options
|
||||
? 'choice'
|
||||
: field.type === 'object' || field.type === 'list'
|
||||
? 'object'
|
||||
: field.multiline
|
||||
? 'longtext'
|
||||
: 'text';
|
||||
|
||||
return {
|
||||
id: field.id,
|
||||
label: field.label,
|
||||
kind: field.options ? 'choice' : field.multiline ? 'longtext' : 'text',
|
||||
kind,
|
||||
required: field.required,
|
||||
...(field.options ? { options: field.options } : {}),
|
||||
...(field.hint ? { hint: field.hint } : {}),
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Transformace dat: prevod jedne struktury na druhou.
|
||||
*
|
||||
* Je to odpoved na typickou vec v automatizaci: prijde objednavka z e-shopu
|
||||
* a do iDokladu potrebuju uplne jiny objekt. Mezi tim musi byt krok, ktery
|
||||
* rekne, co se kam prepise a jak se to po cest prevede.
|
||||
*
|
||||
* Dva rezimy, oba stoji na tomhle souboru:
|
||||
* - **pravidla** (`applyRules`): pole na pole, klikatelne, s prevody,
|
||||
* - **sablona JSON** (`fillJson`): cilovy objekt se napise a do nej se dosadi
|
||||
* `${cesta}` ze zdroje.
|
||||
*
|
||||
* Engine je tady na serveru a skripty ho dostanou na `ctx.util`. Nemuze byt
|
||||
* v souboru skriptu, protoze skripty nic neimportuji - a nema tam byt, protoze
|
||||
* by ho pak kazdy skript mel ve vlastni verzi.
|
||||
*/
|
||||
|
||||
import { ScriptError, type ScriptJson } from './types.js';
|
||||
|
||||
/** Kolik urovni zanoreni se jeste prochazi. Chrani proti cyklickym datum. */
|
||||
const MAX_DEPTH = 20;
|
||||
|
||||
// ---------------------------------------------------------------------- cesty
|
||||
|
||||
/**
|
||||
* Hodnota na ceste. Prijima `customer.email`, `items.0.name` i `items[0].name`,
|
||||
* protoze lidi pisou oboji a hadat, ktere je spravne, nema smysl.
|
||||
*
|
||||
* `undefined` znamena "na te ceste nic neni". Zamerne se nerozlisuje od `null`
|
||||
* na vyssi urovni - pro dosazeni do vystupu je to totez.
|
||||
*/
|
||||
export function getPath(source: unknown, path: string): unknown {
|
||||
if (path.trim() === '') return source;
|
||||
|
||||
const parts = path
|
||||
.replace(/\[(\d+)\]/g, '.$1')
|
||||
.split('.')
|
||||
.filter((part) => part !== '');
|
||||
|
||||
let current: unknown = source;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(part);
|
||||
if (!Number.isInteger(index)) return undefined;
|
||||
current = current[index];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof current !== 'object') return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Zapise hodnotu na cestu a cestou vytvori chybejici objekty. */
|
||||
function setPath(target: Record<string, ScriptJson>, path: string, value: ScriptJson): void {
|
||||
const parts = path.split('.').filter((part) => part !== '');
|
||||
if (parts.length === 0) return;
|
||||
|
||||
let current: Record<string, ScriptJson> = target;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const next = current[part];
|
||||
if (next === undefined || next === null || typeof next !== 'object' || Array.isArray(next)) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, ScriptJson>;
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- prevody
|
||||
|
||||
/**
|
||||
* Jeden prevod hodnoty. Zamerne mala uzavrena sada - pri volnem vyrazu by
|
||||
* transformace prestala byt nastaveni a stala se dalsim jazykem k ladeni.
|
||||
*/
|
||||
export type TransformOp =
|
||||
| { op: 'trim' }
|
||||
| { op: 'lower' }
|
||||
| { op: 'upper' }
|
||||
| { op: 'string' }
|
||||
| { op: 'number' }
|
||||
| { op: 'boolean' }
|
||||
| { op: 'date'; format?: 'iso' | 'day' }
|
||||
| { op: 'round'; decimals?: number }
|
||||
| { op: 'multiply'; by: number }
|
||||
| { op: 'add'; by: number }
|
||||
| { op: 'default'; value: ScriptJson }
|
||||
| { op: 'replace'; find: string; with: string }
|
||||
| { op: 'slice'; start?: number; end?: number }
|
||||
| { op: 'split'; separator: string }
|
||||
| { op: 'join'; separator?: string }
|
||||
| { op: 'sum'; path?: string }
|
||||
| { op: 'count' }
|
||||
/** Nad seznamem objektu: kazdy prvek prevede podle vlastnich pravidel. */
|
||||
| { op: 'map'; rules: MappingRule[] };
|
||||
|
||||
export interface MappingRule {
|
||||
/** Kam se hodnota ulozi. Tecka znamena zanoreni: `partner.id`. */
|
||||
to: string;
|
||||
/** Cesta ve zdroji. Bez markeru, je to cesta, ne sablona. */
|
||||
from?: string;
|
||||
/** Pevna hodnota. Pouzije se, kdyz `from` chybi. */
|
||||
value?: ScriptJson;
|
||||
/** Prevody v uvedenem poradi. */
|
||||
transforms?: TransformOp[];
|
||||
/** Kdyz je vysledek prazdny, pouzije se tohle. */
|
||||
fallback?: ScriptJson;
|
||||
/** Prazdny vysledek se do vystupu vubec nezapise. */
|
||||
omitIfEmpty?: boolean;
|
||||
/** Prazdny vysledek je chyba. Pro pole, bez kterych cizi sluzba doklad neprijme. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return true;
|
||||
if (typeof value === 'string') return value.trim() === '';
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, label: string): number {
|
||||
if (typeof value === 'number') return value;
|
||||
// Ceska desetinna carka je bezna, nema smysl na ni padat.
|
||||
const parsed = Number(String(value ?? '').trim().replace(',', '.'));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new ScriptError('terminal', `${label}: "${String(value)}" není číslo.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function applyOne(value: unknown, operation: TransformOp, label: string, depth: number): ScriptJson {
|
||||
switch (operation.op) {
|
||||
case 'trim':
|
||||
return String(value ?? '').trim();
|
||||
case 'lower':
|
||||
return String(value ?? '').toLowerCase();
|
||||
case 'upper':
|
||||
return String(value ?? '').toUpperCase();
|
||||
case 'string':
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
case 'number':
|
||||
return toNumber(value, label);
|
||||
case 'boolean': {
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = String(value ?? '').trim().toLowerCase();
|
||||
return text === 'true' || text === '1' || text === 'yes' || text === 'ano';
|
||||
}
|
||||
case 'date': {
|
||||
const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value ?? ''));
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new ScriptError('terminal', `${label}: "${String(value)}" není platné datum.`);
|
||||
}
|
||||
const iso = new Date(parsed).toISOString();
|
||||
return operation.format === 'day' ? iso.slice(0, 10) : iso;
|
||||
}
|
||||
case 'round': {
|
||||
const factor = 10 ** (operation.decimals ?? 2);
|
||||
return Math.round(toNumber(value, label) * factor) / factor;
|
||||
}
|
||||
case 'multiply':
|
||||
return toNumber(value, label) * operation.by;
|
||||
case 'add':
|
||||
return toNumber(value, label) + operation.by;
|
||||
case 'default':
|
||||
return isEmpty(value) ? operation.value : (value as ScriptJson);
|
||||
case 'replace':
|
||||
return String(value ?? '').split(operation.find).join(operation.with);
|
||||
case 'slice': {
|
||||
if (Array.isArray(value)) return value.slice(operation.start ?? 0, operation.end) as ScriptJson;
|
||||
return String(value ?? '').slice(operation.start ?? 0, operation.end);
|
||||
}
|
||||
case 'split':
|
||||
return String(value ?? '')
|
||||
.split(operation.separator)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '');
|
||||
case 'join':
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => String(item ?? '')).join(operation.separator ?? ', ')
|
||||
: String(value ?? '');
|
||||
case 'sum': {
|
||||
if (!Array.isArray(value)) return toNumber(value, label);
|
||||
return value.reduce<number>((total, item) => {
|
||||
const part = operation.path ? getPath(item, operation.path) : item;
|
||||
return total + toNumber(part ?? 0, label);
|
||||
}, 0);
|
||||
}
|
||||
case 'count':
|
||||
return Array.isArray(value) ? value.length : isEmpty(value) ? 0 : 1;
|
||||
case 'map': {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new ScriptError('terminal', `${label}: prevod map potřebuje seznam.`);
|
||||
}
|
||||
return value.map((item) => applyRules(item, operation.rules, depth + 1));
|
||||
}
|
||||
default: {
|
||||
// Neznamy prevod je chyba nastaveni, ne dat. Tise ho preskocit by znamenalo
|
||||
// odeslat neprevedena data a divit se az u odberatele.
|
||||
const unknown = operation as { op?: string };
|
||||
throw new ScriptError('terminal', `Neznámý převod "${String(unknown.op)}" u ${label}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ pravidla
|
||||
|
||||
/** Prevede zdroj na novy objekt podle pravidel. */
|
||||
export function applyRules(
|
||||
source: unknown,
|
||||
rules: MappingRule[],
|
||||
depth = 0,
|
||||
): Record<string, ScriptJson> {
|
||||
if (depth > MAX_DEPTH) {
|
||||
throw new ScriptError('terminal', 'Mapování je zanořené příliš hluboko.');
|
||||
}
|
||||
|
||||
const result: Record<string, ScriptJson> = {};
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.to || rule.to.trim() === '') {
|
||||
throw new ScriptError('terminal', 'Pravidlo mapování musí mít vyplněné cílové pole.');
|
||||
}
|
||||
const label = `pole ${rule.to}`;
|
||||
|
||||
let value: unknown = rule.from !== undefined ? getPath(source, rule.from) : rule.value;
|
||||
|
||||
for (const operation of rule.transforms ?? []) {
|
||||
value = applyOne(value, operation, label, depth);
|
||||
}
|
||||
|
||||
if (isEmpty(value) && rule.fallback !== undefined) value = rule.fallback;
|
||||
|
||||
if (isEmpty(value)) {
|
||||
if (rule.required) {
|
||||
const where = rule.from ? ` (zdroj: ${rule.from})` : '';
|
||||
throw new ScriptError('terminal', `${label} je povinné, ale nic do něj nedošlo${where}.`);
|
||||
}
|
||||
if (rule.omitIfEmpty) continue;
|
||||
}
|
||||
|
||||
setPath(result, rule.to, (value ?? null) as ScriptJson);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- sablona JSON
|
||||
|
||||
/**
|
||||
* Dosadi `${cesta}` ze zdroje do sablony.
|
||||
*
|
||||
* Marker je zamerne `${...}` a ne `{{...}}`. Sablony kroku se dosazuji driv,
|
||||
* nez se krok spusti, takze `{{order.total}}` v sablone JSON by stihl vyhodnotit
|
||||
* strom, nenasel by parametr toho jmena a dosadil prazdno. Rozdilny marker to
|
||||
* nemuze splest.
|
||||
*
|
||||
* Kdyz je hodnota **cely** retezec, zachova se puvodni typ. `"${order.total}"`
|
||||
* tedy vyrobi cislo, ne text - jinak by cizi sluzba dostala castku jako string.
|
||||
*/
|
||||
const wholeToken = /^\$\{([^}]+)\}$/;
|
||||
const anyToken = /\$\{([^}]+)\}/g;
|
||||
|
||||
export function fillJson(template: ScriptJson, source: unknown, depth = 0): ScriptJson {
|
||||
if (depth > MAX_DEPTH) {
|
||||
throw new ScriptError('terminal', 'Šablona JSON je zanořená příliš hluboko.');
|
||||
}
|
||||
|
||||
if (typeof template === 'string') {
|
||||
const whole = wholeToken.exec(template);
|
||||
if (whole) {
|
||||
const value = getPath(source, whole[1]);
|
||||
return (value ?? null) as ScriptJson;
|
||||
}
|
||||
return template.replace(anyToken, (_match, path: string) => {
|
||||
const value = getPath(source, path);
|
||||
if (value === undefined || value === null) return '';
|
||||
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(template)) {
|
||||
return template.map((item) => fillJson(item, source, depth + 1));
|
||||
}
|
||||
|
||||
if (template !== null && typeof template === 'object') {
|
||||
const result: Record<string, ScriptJson> = {};
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
result[key] = fillJson(value, source, depth + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- kontrola
|
||||
|
||||
/**
|
||||
* Overi tvar pravidel prisly z nastaveni kroku.
|
||||
* Vraci popisy problemu, prazdne pole znamena v poradku.
|
||||
*
|
||||
* Kontroluje se pri ukladani stromu, aby se preklep v pravidlech nedozvedel
|
||||
* uzivatel az z padleho behu.
|
||||
*/
|
||||
export function validateRules(value: unknown, path = 'pravidla'): string[] {
|
||||
if (!Array.isArray(value)) return [`${path}: očekává se seznam pravidel.`];
|
||||
|
||||
const problems: string[] = [];
|
||||
value.forEach((rule, index) => {
|
||||
const where = `${path}[${index}]`;
|
||||
if (rule === null || typeof rule !== 'object' || Array.isArray(rule)) {
|
||||
problems.push(`${where}: pravidlo musí být objekt.`);
|
||||
return;
|
||||
}
|
||||
const record = rule as Record<string, unknown>;
|
||||
if (typeof record.to !== 'string' || record.to.trim() === '') {
|
||||
problems.push(`${where}: chybí cílové pole "to".`);
|
||||
}
|
||||
if (record.from === undefined && record.value === undefined) {
|
||||
problems.push(`${where}: vyplňte "from" (cesta ve zdroji) nebo "value" (pevná hodnota).`);
|
||||
}
|
||||
if (record.transforms !== undefined) {
|
||||
if (!Array.isArray(record.transforms)) {
|
||||
problems.push(`${where}: "transforms" musí být seznam.`);
|
||||
} else {
|
||||
record.transforms.forEach((operation, opIndex) => {
|
||||
if (operation === null || typeof operation !== 'object') {
|
||||
problems.push(`${where}.transforms[${opIndex}]: převod musí být objekt.`);
|
||||
return;
|
||||
}
|
||||
const op = (operation as { op?: unknown }).op;
|
||||
if (typeof op !== 'string') {
|
||||
problems.push(`${where}.transforms[${opIndex}]: chybí "op".`);
|
||||
return;
|
||||
}
|
||||
if (op === 'map') {
|
||||
problems.push(
|
||||
...validateRules(
|
||||
(operation as { rules?: unknown }).rules,
|
||||
`${where}.transforms[${opIndex}].rules`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return problems;
|
||||
}
|
||||
+49
-3
@@ -13,16 +13,31 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { MappingRule } from './mapping.js';
|
||||
|
||||
// ------------------------------------------------------------------- hodnoty
|
||||
|
||||
/** Skript pracuje jen s temito hodnotami. Zadne objekty ani pole. */
|
||||
export type ScriptValue = string | number | boolean | null;
|
||||
/**
|
||||
* Hodnota, se kterou skript pracuje. Cokoliv, co jde vyjadrit JSONem.
|
||||
*
|
||||
* Objekty a seznamy tu jsou kvuli transformacim: krok muze predat dal celou
|
||||
* objednavku, ne jen jeji jednotliva pole. Do sablon se ale nedosazuji,
|
||||
* predavaji se jen jako celek dalsimu kroku.
|
||||
*/
|
||||
export type ScriptJson =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| ScriptJson[]
|
||||
| { [key: string]: ScriptJson };
|
||||
|
||||
export type ScriptValue = ScriptJson;
|
||||
export type ScriptValues = Record<string, ScriptValue>;
|
||||
|
||||
// -------------------------------------------------------------------- schema
|
||||
|
||||
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
|
||||
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']);
|
||||
|
||||
/**
|
||||
* Jeden parametr, vstupni nebo vystupni. Zamerne je to jeden typ pro obe
|
||||
@@ -53,8 +68,24 @@ export const scriptFieldSchema = z
|
||||
pattern: z.string().optional(),
|
||||
/** Jen u typu string: pole na vic radku. Builder ho vykresli jako longtext. */
|
||||
multiline: z.boolean().optional(),
|
||||
/**
|
||||
* Cim se pole vyplnuje v builderu, kdyz to z typu nejde poznat.
|
||||
*
|
||||
* `mapping` pravidla transformace, klikatelny editor
|
||||
* `json` JSON, kontroluje se uz pri ulozeni stromu
|
||||
* `object` vyber parametru typu objekt nebo seznam z predchoziho kroku
|
||||
*
|
||||
* Explicitne, ne odvozene z nazvu pole. Hadat podle `id === 'rules'` by
|
||||
* fungovalo do prvniho skriptu, ktery to pole pojmenuje jinak.
|
||||
*/
|
||||
control: z.enum(['mapping', 'json', 'object']).optional(),
|
||||
/** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */
|
||||
default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
|
||||
/**
|
||||
* Jen u `object` a `list`: strop na velikost v bajtech.
|
||||
* Bez nej by jeden krok mohl protahnout logem megabajty.
|
||||
*/
|
||||
maxBytes: z.number().int().min(256).max(4_000_000).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -210,6 +241,21 @@ export interface ScriptUtil {
|
||||
* poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome.
|
||||
*/
|
||||
need<T>(value: T | null | undefined, label: string): T;
|
||||
/**
|
||||
* Hodnota na ceste: `customer.email`, `items.0.name` i `items[0].name`.
|
||||
* Chybejici cesta vraci `undefined`, ne vyjimku.
|
||||
*/
|
||||
get(source: unknown, path: string): unknown;
|
||||
/**
|
||||
* Prevede zdroj na novy objekt podle pravidel.
|
||||
* Tohle je rezim "pole na pole" u transformace dat.
|
||||
*/
|
||||
applyRules(source: unknown, rules: MappingRule[]): Record<string, ScriptJson>;
|
||||
/**
|
||||
* Dosadi `${cesta}` ze zdroje do sablony JSON.
|
||||
* Marker je zamerne jiny nez `{{...}}` u sablon kroku, duvod je v mapping.ts.
|
||||
*/
|
||||
fillJson(template: ScriptJson, source: unknown): ScriptJson;
|
||||
}
|
||||
|
||||
export interface ScriptContext {
|
||||
|
||||
+15
-1
@@ -7,6 +7,7 @@
|
||||
* z nich by to resil spatne.
|
||||
*/
|
||||
|
||||
import { applyRules, fillJson, getPath } from './mapping.js';
|
||||
import { ScriptError, type ScriptUtil } from './types.js';
|
||||
|
||||
/** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */
|
||||
@@ -83,7 +84,20 @@ function need<T>(value: T | null | undefined, label: string): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
export const scriptUtil: ScriptUtil = { unwrap, pick, first, text, num, bool, date, round, need };
|
||||
export const scriptUtil: ScriptUtil = {
|
||||
unwrap,
|
||||
pick,
|
||||
first,
|
||||
text,
|
||||
num,
|
||||
bool,
|
||||
date,
|
||||
round,
|
||||
need,
|
||||
get: getPath,
|
||||
applyRules: (source, rules) => applyRules(source, rules),
|
||||
fillJson: (template, source) => fillJson(template, source),
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------- redakce
|
||||
|
||||
|
||||
+62
-2
@@ -11,7 +11,8 @@
|
||||
* - parametr, ktery v manifestu neni, se zahodi a zaloguje.
|
||||
*/
|
||||
|
||||
import type { FieldIssue, ScriptField, ScriptValue, ScriptValues } from './types.js';
|
||||
import { config } from '../config.js';
|
||||
import type { FieldIssue, ScriptField, ScriptJson, ScriptValue, ScriptValues } from './types.js';
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; values: ScriptValues }
|
||||
@@ -25,6 +26,43 @@ function isMissing(value: unknown): boolean {
|
||||
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rozparsuje JSON, kdyz hodnota prisla jako text.
|
||||
*
|
||||
* Deje se to bezne: z nastaveni kroku i z testovaciho formulare prijde struktura
|
||||
* jako retezec. Odmitnout ji by znamenalo, ze uzivatel musi resit, kde presne
|
||||
* se to prevadi.
|
||||
*/
|
||||
function parseIfText(raw: unknown): unknown {
|
||||
if (typeof raw !== 'string') return raw;
|
||||
const text = raw.trim();
|
||||
if (text === '') return raw;
|
||||
if (!text.startsWith('{') && !text.startsWith('[')) return raw;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strop na velikost struktury.
|
||||
*
|
||||
* Bez nej by jeden krok mohl protahnout logem a databazi megabajty. Radek
|
||||
* `run_step` je nejrychleji rostouci tabulka v systemu, viz dokument 10.
|
||||
*/
|
||||
function sizeProblem(field: ScriptField, value: ScriptJson): string | null {
|
||||
const limit = field.maxBytes ?? config.scriptMaxValueBytes;
|
||||
let size: number;
|
||||
try {
|
||||
size = Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');
|
||||
} catch {
|
||||
return 'strukturu nelze zapsat jako JSON (asi obsahuje cyklus).';
|
||||
}
|
||||
if (size > limit) return `je ${size} B, což je nad povolený strop ${limit} B.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevede jednu hodnotu na deklarovany typ.
|
||||
* Vraci bud hodnotu, nebo text chyby - nikdy nehada.
|
||||
@@ -70,6 +108,22 @@ function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { er
|
||||
return { value: new Date(parsed).toISOString() };
|
||||
}
|
||||
|
||||
case 'object': {
|
||||
const parsed = parseIfText(raw);
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { error: 'Očekává se objekt.' };
|
||||
}
|
||||
const problem = sizeProblem(field, parsed as ScriptJson);
|
||||
return problem ? { error: problem } : { value: parsed as ScriptJson };
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const parsed = parseIfText(raw);
|
||||
if (!Array.isArray(parsed)) return { error: 'Očekává se seznam.' };
|
||||
const problem = sizeProblem(field, parsed as ScriptJson);
|
||||
return problem ? { error: problem } : { value: parsed as ScriptJson };
|
||||
}
|
||||
|
||||
default: {
|
||||
// Vetev je nedosazitelna, dokud FieldType nema dalsi hodnotu.
|
||||
return { error: 'Neznámý typ parametru.' };
|
||||
@@ -115,7 +169,13 @@ export function validateValues(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.options && !field.options.some((option) => option.value === String(result.value))) {
|
||||
// Vyber ma smysl jen u skalarni hodnoty. U struktury by se porovnaval JSON.
|
||||
if (
|
||||
field.options &&
|
||||
field.type !== 'object' &&
|
||||
field.type !== 'list' &&
|
||||
!field.options.some((option) => option.value === String(result.value))
|
||||
) {
|
||||
const allowed = field.options.map((option) => option.value).join(', ');
|
||||
issues.push({
|
||||
field: field.id,
|
||||
|
||||
Reference in New Issue
Block a user