pgkiln 0.80.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/146_database_model_providers.sql +53 -0
- package/db/migrations/147_condition_types.sql +80 -0
- package/db/migrations/148_validation_types.sql +44 -0
- package/db/migrations/149_dynamic_action_events.sql +45 -0
- package/db/migrations/150_process_types.sql +23 -0
- package/db/migrations/151_item_types.sql +16 -0
- package/db/migrations/152_workflow_diagram_region.sql +10 -0
- package/docs/apex-feature-parity.md +12 -10
- package/docs/guide/02-concepts.md +3 -3
- package/docs/guide/03-builder.md +13 -3
- package/docs/guide/04-pages-and-regions.md +17 -3
- package/docs/guide/05-items.md +5 -0
- package/docs/guide/06-processing.md +104 -17
- package/docs/guide/07-dynamic-actions.md +16 -1
- package/docs/guide/11-from-apex.md +7 -0
- package/examples/hr/hr_79_condition_types.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +12 -0
- package/public/app.js +157 -9
- package/src/ai/embeddings.ts +20 -7
- package/src/appfiles.ts +4 -4
- package/src/builder/advisor.ts +43 -7
- package/src/builder/code-editor.ts +14 -10
- package/src/builder/components.ts +51 -21
- package/src/builder/conditions.ts +64 -0
- package/src/builder/db-users.ts +124 -118
- package/src/builder/designer.ts +9 -1
- package/src/builder/home.ts +42 -40
- package/src/builder/i18n/de.ts +464 -0
- package/src/builder/i18n/en.ts +464 -0
- package/src/builder/i18n/es.ts +464 -0
- package/src/builder/i18n/fr.ts +464 -0
- package/src/builder/i18n/it.ts +464 -0
- package/src/builder/i18n/ja.ts +464 -0
- package/src/builder/i18n/ko.ts +464 -0
- package/src/builder/i18n/nl.ts +464 -0
- package/src/builder/i18n/pt.ts +464 -0
- package/src/builder/i18n/zh-cn.ts +464 -0
- package/src/builder/i18n/zh-tw.ts +464 -0
- package/src/builder/instance.ts +27 -23
- package/src/builder/ldap.ts +50 -47
- package/src/builder/mail-queue.ts +50 -45
- package/src/builder/network-access.ts +46 -43
- package/src/builder/region-help.ts +3 -1
- package/src/builder/region-settings.ts +30 -5
- package/src/builder/vector-column.ts +3 -3
- package/src/builder/vector-providers.ts +16 -5
- package/src/i18n/ar.ts +5 -0
- package/src/i18n/cs.ts +5 -0
- package/src/i18n/da.ts +5 -0
- package/src/i18n/de.ts +5 -0
- package/src/i18n/el.ts +5 -0
- package/src/i18n/es.ts +5 -0
- package/src/i18n/fi.ts +5 -0
- package/src/i18n/fr.ts +5 -0
- package/src/i18n/he.ts +5 -0
- package/src/i18n/it.ts +5 -0
- package/src/i18n/ja.ts +5 -0
- package/src/i18n/ko.ts +5 -0
- package/src/i18n/nb.ts +5 -0
- package/src/i18n/pl.ts +5 -0
- package/src/i18n/pt.ts +5 -0
- package/src/i18n/ru.ts +5 -0
- package/src/i18n/sv.ts +5 -0
- package/src/i18n/tr.ts +5 -0
- package/src/i18n/uk.ts +5 -0
- package/src/i18n/zh.ts +5 -0
- package/src/i18n.ts +10 -0
- package/src/metadata.ts +72 -9
- package/src/runtime/authz.ts +9 -5
- package/src/runtime/context.ts +4 -0
- package/src/runtime/da-styles.ts +37 -0
- package/src/runtime/documents.ts +3 -2
- package/src/runtime/engine.ts +95 -13
- package/src/runtime/items.ts +33 -0
- package/src/runtime/logic.ts +79 -8
- package/src/runtime/maps.ts +50 -4
- package/src/runtime/process-types.ts +139 -0
- package/src/runtime/processes.ts +2 -0
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +13 -2
- package/src/runtime/routes.ts +2 -2
- package/src/runtime/workflow-diagram.ts +45 -0
- package/src/vector.ts +3 -3
- package/src/workflow.ts +10 -2
package/src/metadata.ts
CHANGED
|
@@ -164,7 +164,7 @@ export interface Region {
|
|
|
164
164
|
title: string | null;
|
|
165
165
|
/** (0.31, 073) optional, unique on the page */
|
|
166
166
|
static_id?: string | null;
|
|
167
|
-
type: 'login' | 'report' | 'form' | 'chart' | 'cards' | 'static' | 'grid' | 'calendar' | 'dynamic' | 'facets' | 'tasks' | 'workflows' | 'map' | 'tree' | 'template_component' | 'smart_filters' | 'display_selector' | 'list' | 'data_reporter' | 'ai_assistant' | 'plugin' | 'url' | 'search' | 'form_designer' | 'classic_report' | 'breadcrumb' | 'help_text';
|
|
167
|
+
type: 'login' | 'report' | 'form' | 'chart' | 'cards' | 'static' | 'grid' | 'calendar' | 'dynamic' | 'facets' | 'tasks' | 'workflows' | 'map' | 'tree' | 'template_component' | 'smart_filters' | 'display_selector' | 'list' | 'data_reporter' | 'ai_assistant' | 'plugin' | 'url' | 'search' | 'form_designer' | 'classic_report' | 'breadcrumb' | 'help_text' | 'workflow_diagram';
|
|
168
168
|
source: string | null;
|
|
169
169
|
table_name: string | null;
|
|
170
170
|
pk_column: string | null;
|
|
@@ -172,6 +172,9 @@ export interface Region {
|
|
|
172
172
|
columns: number;
|
|
173
173
|
template: 'standard' | 'plain' | 'collapsible' | 'hero' | 'title_bar' | 'buttons_container' | 'inline_dialog' | 'inline_drawer' | 'wizard';
|
|
174
174
|
condition: string | null;
|
|
175
|
+
/** (147) the condition's type (null: `condition` is SQL) and value */
|
|
176
|
+
condition_type?: ConditionType | null;
|
|
177
|
+
condition_value?: string | null;
|
|
175
178
|
authz: string | null;
|
|
176
179
|
config: Record<string, any>;
|
|
177
180
|
/** a REST data source the region reads (its source, if any, is SQL over the CTE "rest") */
|
|
@@ -189,7 +192,9 @@ export type ItemType =
|
|
|
189
192
|
| 'richtext' | 'markdown' | 'rating' | 'combobox' | 'daterange' | 'qrcode' | 'plugin'
|
|
190
193
|
| 'shuttle' | 'select_one' | 'select_many'
|
|
191
194
|
// (086) display image, geocoded address
|
|
192
|
-
| 'image' | 'address'
|
|
195
|
+
| 'image' | 'address'
|
|
196
|
+
// (151) image upload and autocomplete (presets of file and combobox, see presetItem), percent graph, display map
|
|
197
|
+
| 'image_upload' | 'autocomplete' | 'percent_graph' | 'display_map';
|
|
193
198
|
|
|
194
199
|
export interface Item {
|
|
195
200
|
id: number;
|
|
@@ -206,6 +211,10 @@ export interface Item {
|
|
|
206
211
|
required: boolean;
|
|
207
212
|
help: string | null;
|
|
208
213
|
readonly_condition: string | null;
|
|
214
|
+
/** (147) a server-side condition: when false the item is not rendered and not accepted */
|
|
215
|
+
condition_type?: ConditionType | null;
|
|
216
|
+
condition_expr?: string | null;
|
|
217
|
+
condition_value?: string | null;
|
|
209
218
|
authz: string | null;
|
|
210
219
|
config: Record<string, any>;
|
|
211
220
|
}
|
|
@@ -222,6 +231,9 @@ export interface Button {
|
|
|
222
231
|
document?: string | null;
|
|
223
232
|
target_items: Record<string, string>;
|
|
224
233
|
condition: string | null;
|
|
234
|
+
/** (147) the condition's type (null: `condition` is SQL) and value */
|
|
235
|
+
condition_type?: ConditionType | null;
|
|
236
|
+
condition_value?: string | null;
|
|
225
237
|
authz: string | null;
|
|
226
238
|
hot: boolean;
|
|
227
239
|
confirm: string | null;
|
|
@@ -248,9 +260,20 @@ export interface MenuEntry {
|
|
|
248
260
|
icon?: string;
|
|
249
261
|
}
|
|
250
262
|
|
|
251
|
-
/**
|
|
263
|
+
/** (147) The server-side condition types every component shares (meta.condition_ok). */
|
|
264
|
+
export const CONDITION_TYPES = [
|
|
265
|
+
'sql', 'exists', 'not_exists', 'function_body',
|
|
266
|
+
'request_in', 'request_not_in',
|
|
267
|
+
'item_null', 'item_not_null', 'item_zero', 'item_not_zero', 'item_null_or_zero', 'item_not_null_or_zero',
|
|
268
|
+
'item_equals', 'item_not_equals', 'item_equals_item', 'item_not_equals_item', 'item_in_list', 'item_not_in_list',
|
|
269
|
+
'page_in', 'page_not_in', 'page_is_dialog', 'page_is_not_dialog',
|
|
270
|
+
'user_signed_in', 'user_public', 'language_in', 'language_not_in', 'always', 'never',
|
|
271
|
+
] as const;
|
|
272
|
+
export type ConditionType = (typeof CONDITION_TYPES)[number];
|
|
273
|
+
|
|
274
|
+
/** A server-side condition (migrations 029, 147). */
|
|
252
275
|
export interface Condition {
|
|
253
|
-
condition_type:
|
|
276
|
+
condition_type: ConditionType | null;
|
|
254
277
|
condition_expr: string | null;
|
|
255
278
|
condition_value: string | null;
|
|
256
279
|
}
|
|
@@ -295,9 +318,11 @@ export interface DynamicAction {
|
|
|
295
318
|
/** (085) */
|
|
296
319
|
| 'after_refresh' | 'selection_change' | 'focus' | 'blur' | 'key_release' | 'before_submit' | 'custom'
|
|
297
320
|
/** (124) a menu button's entry {"action": NAME} was chosen (trigger: entry or menu button names) */
|
|
298
|
-
| 'menu_entry'
|
|
321
|
+
| 'menu_entry'
|
|
322
|
+
/** (149) */
|
|
323
|
+
| 'double_click' | 'key_down' | 'key_press' | 'mouse_enter' | 'mouse_leave' | 'select' | 'resize' | 'scroll' | 'page_unload' | 'before_refresh' | 'facets_change';
|
|
299
324
|
trigger_element: string | null;
|
|
300
|
-
condition_type: 'equals' | 'not_equals' | 'in_list' | 'is_null' | 'is_not_null' | null;
|
|
325
|
+
condition_type: 'equals' | 'not_equals' | 'in_list' | 'is_null' | 'is_not_null' | 'greater' | 'greater_equal' | 'less' | 'less_equal' | 'not_in_list' | null;
|
|
301
326
|
condition_value: string | null;
|
|
302
327
|
action: 'show' | 'hide' | 'enable' | 'disable' | 'set_value' | 'execute_sql' | 'refresh_region' | 'refresh_item' | 'alert' | 'submit'
|
|
303
328
|
| 'set_focus' | 'add_class' | 'remove_class' | 'show_success' | 'show_error' | 'clear_errors' | 'ai_generate'
|
|
@@ -310,7 +335,9 @@ export interface DynamicAction {
|
|
|
310
335
|
/** (085) */
|
|
311
336
|
| 'confirm' | 'close_dialog' | 'cancel_dialog' | 'clear' | 'open_region' | 'close_region' | 'download' | 'print' | 'current_position' | 'share'
|
|
312
337
|
/** (124) a modal "please wait" overlay (message: its text) until remove_wait_popup or the next page */
|
|
313
|
-
| 'wait_popup' | 'remove_wait_popup'
|
|
338
|
+
| 'wait_popup' | 'remove_wait_popup'
|
|
339
|
+
/** (149) */
|
|
340
|
+
| 'cancel_event' | 'set_style' | 'collapse_tree' | 'expand_tree' | 'show_ai_assistant' | 'trigger_geocoding' | 'open_report_dialog';
|
|
314
341
|
affected_items: string | null;
|
|
315
342
|
affected_region_id: number | null;
|
|
316
343
|
code: string | null;
|
|
@@ -321,22 +348,40 @@ export interface DynamicAction {
|
|
|
321
348
|
/** (069) a plug-in action's attribute values: {"attributes": {…}} */
|
|
322
349
|
config?: Record<string, any> | null;
|
|
323
350
|
authz: string | null;
|
|
351
|
+
/** (147) a server-side condition: when false the dynamic action isn't on the page */
|
|
352
|
+
server_condition_type?: ConditionType | null;
|
|
353
|
+
server_condition_expr?: string | null;
|
|
354
|
+
server_condition_value?: string | null;
|
|
324
355
|
}
|
|
325
356
|
|
|
326
357
|
export interface Validation {
|
|
327
358
|
id: number;
|
|
328
359
|
name: string;
|
|
329
360
|
item_name: string | null;
|
|
330
|
-
|
|
361
|
+
/** (148) APEX's validation types (migration 148) */
|
|
362
|
+
type: 'sql' | 'exists' | 'not_exists' | 'function_body' | 'function_error'
|
|
363
|
+
| 'not_null' | 'not_null_or_zero' | 'not_zero' | 'no_spaces' | 'alphanumeric' | 'numeric' | 'valid_date' | 'valid_timestamp'
|
|
364
|
+
| 'equals' | 'not_equals' | 'in_list' | 'not_in_list' | 'chars_only' | 'chars_any' | 'chars_none' | 'regex';
|
|
331
365
|
expression: string | null;
|
|
332
366
|
message: string;
|
|
333
367
|
when_button: string | null;
|
|
368
|
+
/** (148) an authorization scheme; Always execute (also for DELETE); where the error shows */
|
|
369
|
+
authz?: string | null;
|
|
370
|
+
always_execute?: boolean;
|
|
371
|
+
display_location?: 'inline_and_notification' | 'inline' | 'notification' | null;
|
|
372
|
+
/** (147) a server-side condition: when false the validation is skipped */
|
|
373
|
+
condition_type?: ConditionType | null;
|
|
374
|
+
condition_expr?: string | null;
|
|
375
|
+
condition_value?: string | null;
|
|
334
376
|
}
|
|
335
377
|
|
|
336
378
|
export interface Process {
|
|
337
379
|
id: number;
|
|
338
380
|
name: string;
|
|
339
|
-
type: 'form_dml' | 'grid_dml' | 'sql' | 'data_load' | 'invoke_api' | 'download' | 'chain' | 'workflow' | 'ai_generate' | 'plugin' | 'send_push' | 'send_email'
|
|
381
|
+
type: 'form_dml' | 'grid_dml' | 'sql' | 'data_load' | 'invoke_api' | 'download' | 'chain' | 'workflow' | 'ai_generate' | 'plugin' | 'send_push' | 'send_email'
|
|
382
|
+
/** (150) */
|
|
383
|
+
| 'close_dialog' | 'clear_session_state' | 'reset_pagination' | 'user_preferences' | 'server_geocoding' | 'print_report'
|
|
384
|
+
| 'human_task_create' | 'human_task_manage' | 'form_init';
|
|
340
385
|
region_id: number | null;
|
|
341
386
|
code: string | null;
|
|
342
387
|
config: Record<string, unknown> | null;
|
|
@@ -420,7 +465,25 @@ export async function loadApp(alias: string) {
|
|
|
420
465
|
);
|
|
421
466
|
}
|
|
422
467
|
|
|
468
|
+
/**
|
|
469
|
+
* (151, CD-5) APEX's item types that are presets of pgkiln's: an Image Upload is a file item for pictures (image/*,
|
|
470
|
+
* cropping, a maximum size before upload), a Text Field with autocomplete a one-value combobox. The runtime sees the
|
|
471
|
+
* preset (every file and list check applies); the builder and exports keep the type as chosen.
|
|
472
|
+
*/
|
|
473
|
+
export function presetItem(i: Item): Item {
|
|
474
|
+
if (i.type === 'image_upload')
|
|
475
|
+
return { ...i, type: 'file', config: { accept: 'image/*', crop: 'free', max_px: 1600, ...(i.config ?? {}), preset: 'image_upload' } };
|
|
476
|
+
if (i.type === 'autocomplete') return { ...i, type: 'combobox', config: { ...(i.config ?? {}), multiple: false, preset: 'autocomplete' } };
|
|
477
|
+
return i;
|
|
478
|
+
}
|
|
479
|
+
|
|
423
480
|
export async function loadPage(appId: number, pageNo: number) {
|
|
481
|
+
const page = await loadPageRows(appId, pageNo);
|
|
482
|
+
if (page) page.items = page.items.map(presetItem);
|
|
483
|
+
return page;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function loadPageRows(appId: number, pageNo: number) {
|
|
424
487
|
return runtime.one<Page>(
|
|
425
488
|
`select p.id, p.app_id, p.page_no, p.name, p.title, p.requires_auth, p.parent_page, p.mode,
|
|
426
489
|
p.protection, p.authz, p.warn_unsaved, p.page_template, p.help,
|
package/src/runtime/authz.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { secondStepWithin } from '../account-mfa.ts';
|
|
2
2
|
import { applyBinds } from '../binds.ts';
|
|
3
|
-
import type { AuthzScheme } from '../metadata.ts';
|
|
3
|
+
import type { AuthzScheme, Condition } from '../metadata.ts';
|
|
4
4
|
import { savepoint } from '../db.ts';
|
|
5
5
|
import { bindValues, publicError, stripSemicolon, type PageContext, type Visibility } from './context.ts';
|
|
6
6
|
import { saveButton, saveRequest } from './grid.ts';
|
|
7
7
|
import { selectionOf } from './report.ts';
|
|
8
|
+
import { conditionHolds, conditionOf, serverConditionOf } from './logic.ts';
|
|
8
9
|
|
|
9
10
|
export class Forbidden extends Error {}
|
|
10
11
|
/** (sprint 131) The page needs a second step more recent than the session's: ask for one, then come back. */
|
|
@@ -90,14 +91,15 @@ export async function pageAllowed(ctx: PageContext, pageNo: number) {
|
|
|
90
91
|
export async function computeVisibility(ctx: PageContext): Promise<Visibility> {
|
|
91
92
|
const vis: Visibility = { regions: new Set(), items: new Set(), editable: new Set(), buttons: new Map(), dynamicActions: new Set() };
|
|
92
93
|
for (const r of ctx.page.regions)
|
|
93
|
-
if ((await isAuthorized(ctx, r.authz)) && (await
|
|
94
|
+
if ((await isAuthorized(ctx, r.authz)) && (await conditionHolds(ctx, conditionOf(r), `condition of region "${r.title ?? r.id}"`)))
|
|
94
95
|
vis.regions.add(r.id);
|
|
95
96
|
|
|
96
97
|
for (const i of ctx.page.items) {
|
|
97
98
|
if (i.region_id !== null && !vis.regions.has(i.region_id)) continue;
|
|
98
99
|
if (!(await isAuthorized(ctx, i.authz))) continue;
|
|
100
|
+
if (i.condition_type && !(await conditionHolds(ctx, i as Condition, `condition of item ${i.name}`))) continue;
|
|
99
101
|
vis.items.add(i.name);
|
|
100
|
-
if (i.type === 'hidden' || i.type === 'display' || i.type === 'qrcode' || i.type === 'image') continue;
|
|
102
|
+
if (i.type === 'hidden' || i.type === 'display' || i.type === 'qrcode' || i.type === 'image' || i.type === 'percent_graph' || i.type === 'display_map') continue;
|
|
101
103
|
// fail closed: a broken read-only condition makes the item read-only
|
|
102
104
|
if (i.readonly_condition && (await sqlTrue(ctx, i.readonly_condition, `read-only condition of ${i.name}`, true))) continue;
|
|
103
105
|
vis.editable.add(i.name);
|
|
@@ -112,7 +114,7 @@ export async function computeVisibility(ctx: PageContext): Promise<Visibility> {
|
|
|
112
114
|
for (const b of ctx.page.buttons) {
|
|
113
115
|
if (b.region_id !== null && !vis.regions.has(b.region_id)) continue;
|
|
114
116
|
if (!(await isAuthorized(ctx, b.authz))) continue;
|
|
115
|
-
if (!(await
|
|
117
|
+
if (!(await conditionHolds(ctx, conditionOf(b), `condition of button ${b.name}`))) continue;
|
|
116
118
|
vis.buttons.set(b.name, b);
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -132,7 +134,9 @@ export async function computeVisibility(ctx: PageContext): Promise<Visibility> {
|
|
|
132
134
|
if (r.type === 'grid' && vis.regions.has(r.id) && ctx.page.processes.some((p) => p.type === 'grid_dml' && p.region_id === r.id))
|
|
133
135
|
vis.buttons.set(saveRequest(r), saveButton(r));
|
|
134
136
|
|
|
135
|
-
for (const d of ctx.page.dynamic_actions)
|
|
137
|
+
for (const d of ctx.page.dynamic_actions)
|
|
138
|
+
if ((await isAuthorized(ctx, d.authz)) && (await conditionHolds(ctx, serverConditionOf(d), `server-side condition of dynamic action "${d.name}"`)))
|
|
139
|
+
vis.dynamicActions.add(d.id);
|
|
136
140
|
ctx.vis = vis;
|
|
137
141
|
return vis;
|
|
138
142
|
}
|
package/src/runtime/context.ts
CHANGED
|
@@ -74,6 +74,10 @@ export interface PageContext {
|
|
|
74
74
|
wholePage?: boolean;
|
|
75
75
|
/** a download process's file, sent instead of the page (processes.ts) */
|
|
76
76
|
download?: Download;
|
|
77
|
+
/** (157) a close_dialog process ran: the items handed to the page below (closes the modal page after the submit) */
|
|
78
|
+
closeDialog?: Record<string, string>;
|
|
79
|
+
/** (157) a reset_pagination process ran: reports and grids start on their first page after the submit */
|
|
80
|
+
resetPagination?: boolean;
|
|
77
81
|
/** running a background chain (process-jobs.ts): no request, no browser */
|
|
78
82
|
background?: boolean;
|
|
79
83
|
/** debug messages of this request, when the app's debug level is on (src/debug.ts) */
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// (sprint 156, CD-3) The Set Style dynamic action: CSS properties from a fixed list, with plain values. The builder
|
|
2
|
+
// refuses others (styleProblem); the page only ever gets the ones that pass (safeStyles), set through the CSSOM
|
|
3
|
+
// (element.style), which the Content-Security-Policy allows without 'unsafe-inline'.
|
|
4
|
+
|
|
5
|
+
export const STYLE_PROPERTIES = new Set([
|
|
6
|
+
'color', 'background-color', 'border-color', 'outline-color', 'font-weight', 'font-style', 'font-size', 'text-decoration', 'text-transform',
|
|
7
|
+
'text-align', 'opacity', 'visibility', 'border-width', 'border-style', 'padding', 'margin', 'width', 'max-width', 'min-height',
|
|
8
|
+
]);
|
|
9
|
+
/** colours, lengths, keywords, numbers: no url(), quotes, semicolons, braces, backslashes or expressions */
|
|
10
|
+
const VALUE = /^[#a-zA-Z0-9 .,%()+-]{1,60}$/;
|
|
11
|
+
const BAD = /url\s*\(|expression|var\s*\(|attr\s*\(|image|\/\*/i;
|
|
12
|
+
|
|
13
|
+
export function styleProblem(styles: unknown): string | null {
|
|
14
|
+
if (styles === undefined || styles === null) return 'Set style: {"styles": {"color": "#b42318", "font-weight": "bold"}} in the attributes.';
|
|
15
|
+
if (typeof styles !== 'object' || Array.isArray(styles)) return 'Set style: "styles" is an object of CSS properties and values.';
|
|
16
|
+
const entries = Object.entries(styles as Record<string, unknown>);
|
|
17
|
+
if (!entries.length || entries.length > 10) return 'Set style: 1 to 10 properties.';
|
|
18
|
+
for (const [k, v] of entries) {
|
|
19
|
+
if (!STYLE_PROPERTIES.has(k)) return `Set style: ${k} isn't one of ${[...STYLE_PROPERTIES].join(', ')}.`;
|
|
20
|
+
if (typeof v !== 'string' || !VALUE.test(v) || BAD.test(v)) return `Set style: the value of ${k} is a colour, length or keyword (letters, digits, # . , % ( ) + -), without url() or var().`;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The properties of a Set Style action that pass, as {property: value}. */
|
|
26
|
+
export function safeStyles(config: Record<string, any> | null | undefined): Record<string, string> {
|
|
27
|
+
const s = config?.styles;
|
|
28
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return {};
|
|
29
|
+
return Object.fromEntries(Object.entries(s).filter(([k, v]) => STYLE_PROPERTIES.has(k) && typeof v === 'string' && VALUE.test(v) && !BAD.test(v)).slice(0, 10)) as Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The client-side comparison of greater / less conditions: numbers as numbers, ISO dates as dates, else text. */
|
|
33
|
+
export function compareValues(a: string, b: string): number {
|
|
34
|
+
const num = /^\s*-?\d+(\.\d+)?\s*$/;
|
|
35
|
+
if (num.test(a) && num.test(b)) return Number(a) - Number(b);
|
|
36
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
37
|
+
}
|
package/src/runtime/documents.ts
CHANGED
|
@@ -23,7 +23,8 @@ interface ReportQuery {
|
|
|
23
23
|
authz: string | null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/** (157) `offered`: a print_report process of the page asks for it (the page offers it). */
|
|
27
|
+
export async function renderDocument(ctx: PageContext, name: string, offered = false) {
|
|
27
28
|
const c = ctx.client!;
|
|
28
29
|
// metadata through the runtime connection: the page's transaction already runs as the app's role
|
|
29
30
|
const tpl = await runtime.one<DocumentTemplate>('select * from meta.document_template where app_id = $1 and name = upper($2)', [ctx.app.id, name.slice(0, 100)]);
|
|
@@ -33,7 +34,7 @@ export async function renderDocument(ctx: PageContext, name: string) {
|
|
|
33
34
|
if (!found || !(await isAuthorized(ctx, found.authz))) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
|
|
34
35
|
// without signing in (a public page of an app that has sign-in) only the documents the page itself offers
|
|
35
36
|
// with a visible document button: ?doc= on any public page would otherwise hand out every template
|
|
36
|
-
if (ctx.app.authentication !== 'none' && !ctx.session.username) {
|
|
37
|
+
if (ctx.app.authentication !== 'none' && !ctx.session.username && !offered) {
|
|
37
38
|
const offered = [...(ctx.vis?.buttons.values() ?? [])].some((b) => b.action === 'document' && (b.document ?? '').toUpperCase() === found.name);
|
|
38
39
|
if (!offered) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
|
|
39
40
|
}
|
package/src/runtime/engine.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
import { applyBinds, literal } from '../binds.ts';
|
|
3
3
|
import { runtime, savepoint, type Client } from '../db.ts';
|
|
4
|
-
import type { Item, Process, Region } from '../metadata.ts';
|
|
4
|
+
import type { Condition, Item, Process, Region, Validation } from '../metadata.ts';
|
|
5
5
|
import { isAuthorized } from './authz.ts';
|
|
6
6
|
import { gridDml } from './grid.ts';
|
|
7
7
|
import { dropObjects, dropObjectsSince, formRegion, isMultiple, isTempId, removals, REMOVE, saveFileLists, storedFiles, storeOf, storeTempFile, tempIds } from './files.ts';
|
|
@@ -14,8 +14,10 @@ import { runPendingAi } from '../ai/requests.ts';
|
|
|
14
14
|
import { runAiProcess } from './ai.ts';
|
|
15
15
|
import { itemMask, LOV_CHECKED, lovContains, MULTI_VALUE, ratingMax, splitValues } from './items.ts';
|
|
16
16
|
import { formatNumber, isPlainNumber } from '../numformat.ts';
|
|
17
|
-
import {
|
|
17
|
+
import { parseDateMask } from './format.ts';
|
|
18
|
+
import { conditionHolds, functionBody } from './logic.ts';
|
|
18
19
|
import { runPluginProcess } from './plugins.ts';
|
|
20
|
+
import { runDeclarative } from './process-types.ts';
|
|
19
21
|
import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
|
|
20
22
|
import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type Errors, type PageContext } from './context.ts';
|
|
21
23
|
|
|
@@ -138,6 +140,76 @@ async function storedFileExists(ctx: PageContext, r: Region, column: string) {
|
|
|
138
140
|
return res.rows[0]?.ok === true;
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
const VALID_TIMESTAMP = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})(:(\d{2})(\.\d{1,6})?)?$/;
|
|
144
|
+
const isZero = (v: string) => isPlainNumber(v) && Number(v) === 0;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* (148, CD-1) Does a validation pass? true, false (its message), or an error text (function_error). Item types
|
|
148
|
+
* test the item's posted value; format checks pass an empty one (as in APEX). SQL types run as the app's role.
|
|
149
|
+
*/
|
|
150
|
+
async function validationPasses(ctx: PageContext, v: Validation, value: string | null): Promise<boolean | string> {
|
|
151
|
+
const c = ctx.client!;
|
|
152
|
+
const expr = v.expression ?? '';
|
|
153
|
+
const sql = async (text: string) => (await savepoint(c, () => c.query(text))).rows[0]?.ok === true;
|
|
154
|
+
switch (v.type) {
|
|
155
|
+
case 'sql':
|
|
156
|
+
return sql(`select (${stripSemicolon(applyBinds(v.expression ?? 'true', bindValues(ctx)))})::boolean as ok`);
|
|
157
|
+
case 'exists':
|
|
158
|
+
case 'not_exists': {
|
|
159
|
+
const found = await sql(`select exists (${stripSemicolon(applyBinds(expr, bindValues(ctx)))}) as ok`);
|
|
160
|
+
return v.type === 'exists' ? found : !found;
|
|
161
|
+
}
|
|
162
|
+
case 'function_body': {
|
|
163
|
+
const r = await savepoint(c, () => functionBody(ctx, expr));
|
|
164
|
+
return r === 'true' || r === 't';
|
|
165
|
+
}
|
|
166
|
+
case 'function_error': {
|
|
167
|
+
const r = await savepoint(c, () => functionBody(ctx, expr));
|
|
168
|
+
return r === null || r.trim() === '' ? true : r;
|
|
169
|
+
}
|
|
170
|
+
case 'not_null':
|
|
171
|
+
return value !== null;
|
|
172
|
+
case 'not_null_or_zero':
|
|
173
|
+
return value !== null && !isZero(value);
|
|
174
|
+
case 'not_zero':
|
|
175
|
+
return value === null || !isZero(value);
|
|
176
|
+
case 'equals':
|
|
177
|
+
return value === (v.expression ?? null) || (value === null && (v.expression ?? '') === '');
|
|
178
|
+
case 'not_equals':
|
|
179
|
+
return !(value === (v.expression ?? null) || (value === null && (v.expression ?? '') === ''));
|
|
180
|
+
case 'in_list':
|
|
181
|
+
return value !== null && expr.split(':').includes(value);
|
|
182
|
+
case 'not_in_list':
|
|
183
|
+
return value === null || !expr.split(':').includes(value);
|
|
184
|
+
}
|
|
185
|
+
// format checks: an empty item passes
|
|
186
|
+
if (value === null) return true;
|
|
187
|
+
switch (v.type) {
|
|
188
|
+
case 'no_spaces':
|
|
189
|
+
return !/\s/.test(value);
|
|
190
|
+
case 'alphanumeric':
|
|
191
|
+
return /^[\p{L}\p{N}]+$/u.test(value);
|
|
192
|
+
case 'numeric':
|
|
193
|
+
return isPlainNumber(value);
|
|
194
|
+
case 'valid_date':
|
|
195
|
+
return parseDateMask(value, ctx.app.date_format ?? 'YYYY-MM-DD', ctx.locale.lang) !== null;
|
|
196
|
+
case 'valid_timestamp': {
|
|
197
|
+
const m = VALID_TIMESTAMP.exec(value.trim());
|
|
198
|
+
return !!m && parseDateMask(m[1], 'YYYY-MM-DD', ctx.locale.lang) !== null && Number(m[2]) < 24 && Number(m[3]) < 60 && Number(m[5] ?? 0) < 60;
|
|
199
|
+
}
|
|
200
|
+
case 'chars_only':
|
|
201
|
+
return [...value].every((ch) => expr.includes(ch));
|
|
202
|
+
case 'chars_any':
|
|
203
|
+
return [...value].some((ch) => expr.includes(ch));
|
|
204
|
+
case 'chars_none':
|
|
205
|
+
return ![...value].some((ch) => expr.includes(ch));
|
|
206
|
+
case 'regex':
|
|
207
|
+
return (await savepoint(c, () => c.query(`select ${literal(value)} ~ ${literal(expr)} as ok`))).rows[0].ok === true;
|
|
208
|
+
default:
|
|
209
|
+
return false; // an unknown type fails closed
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
141
213
|
/**
|
|
142
214
|
* The page's checks before processing. A DELETE request skips the item checks (required, list,
|
|
143
215
|
* number and format: the values don't matter for a delete) and the validations of every button,
|
|
@@ -212,21 +284,26 @@ export async function validate(ctx: PageContext) {
|
|
|
212
284
|
}
|
|
213
285
|
|
|
214
286
|
for (const v of ctx.page.validations) {
|
|
215
|
-
|
|
287
|
+
// (148) Always execute: also for a DELETE request
|
|
288
|
+
if (v.when_button ? v.when_button !== ctx.request : deleting && !v.always_execute) continue;
|
|
216
289
|
if (v.item_name && (!vis.items.has(v.item_name) || errors.items[v.item_name])) continue;
|
|
290
|
+
if (v.authz && !(await isAuthorized(ctx, v.authz))) continue;
|
|
291
|
+
if (v.condition_type && !(await conditionHolds(ctx, v as Condition, `condition of validation ${v.name}`))) continue;
|
|
217
292
|
const value = v.item_name ? (state[v.item_name] ?? null) : null;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
ok = (await savepoint(c, () => c.query(sql))).rows[0].ok === true;
|
|
293
|
+
// (148) where the error shows: inline (as before), inline and in the notification, or the notification only
|
|
294
|
+
const report = (msg: string) => {
|
|
295
|
+
const inline = v.item_name && vis.items.has(v.item_name) && !errors.items[v.item_name];
|
|
296
|
+
if (v.display_location === 'notification' || !inline) errors.page.push(msg);
|
|
297
|
+
else {
|
|
298
|
+
errors.items[v.item_name!] = msg;
|
|
299
|
+
if (v.display_location === 'inline_and_notification') errors.page.push(msg);
|
|
226
300
|
}
|
|
227
|
-
|
|
301
|
+
};
|
|
302
|
+
try {
|
|
303
|
+
const outcome = await validationPasses(ctx, v, value);
|
|
304
|
+
if (outcome !== true) report(typeof outcome === 'string' ? outcome : v.message);
|
|
228
305
|
} catch (e) {
|
|
229
|
-
|
|
306
|
+
report(`${v.message} (${await publicError(ctx, e, `validation ${v.name}`)})`);
|
|
230
307
|
}
|
|
231
308
|
}
|
|
232
309
|
|
|
@@ -547,6 +624,11 @@ async function runOneStep(ctx: PageContext, p: Process, names: Set<string>, dept
|
|
|
547
624
|
case 'send_email': return await sendEmailProcess(ctx, p);
|
|
548
625
|
case 'ai_generate': return (await runAiProcess(ctx, p, names)).message;
|
|
549
626
|
case 'plugin': return await runPluginProcess(ctx, p.config, p.name);
|
|
627
|
+
// (157, CD-4) close_dialog, clear_session_state, reset_pagination, user_preferences, server_geocoding,
|
|
628
|
+
// print_report, human_task_create, human_task_manage, form_init
|
|
629
|
+
case 'close_dialog': case 'clear_session_state': case 'reset_pagination': case 'user_preferences': case 'server_geocoding':
|
|
630
|
+
case 'print_report': case 'human_task_create': case 'human_task_manage': case 'form_init':
|
|
631
|
+
return await runDeclarative(ctx, p, names);
|
|
550
632
|
case 'download':
|
|
551
633
|
// sent instead of the page (routes.ts); the first download of a request wins
|
|
552
634
|
ctx.download ??= await downloadFile(ctx, p);
|
package/src/runtime/items.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tileAttribution, tileUrl } from '../maptiles.ts';
|
|
1
2
|
import { applyBinds } from '../binds.ts';
|
|
2
3
|
import { icon } from '../icons.ts';
|
|
3
4
|
import { pluginAttributes, pluginData, pluginOf } from './plugins.ts';
|
|
@@ -235,6 +236,30 @@ function maskedValue(ctx: PageContext, item: Item, value: string) {
|
|
|
235
236
|
const LOCATION = /^\s*(-?\d{1,2}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)\s*$/;
|
|
236
237
|
|
|
237
238
|
/** Render one item (field wrapper included). Hidden items are never rendered. */
|
|
239
|
+
/**
|
|
240
|
+
* (151) A Display Map item: one map tile around "lat,lng" (the value, or config.coordinates_item's) with a pin, from the
|
|
241
|
+
* map regions' tile server; its position is a class of the page's stylesheet (the CSP allows no inline styles).
|
|
242
|
+
*/
|
|
243
|
+
function displayMap(ctx: PageContext, item: Item, value: string): Raw {
|
|
244
|
+
const source = typeof item.config?.coordinates_item === 'string' ? (ctx.session.state[item.config.coordinates_item.toUpperCase()] ?? '') : value;
|
|
245
|
+
const at = LOCATION.exec(source);
|
|
246
|
+
const lat = at ? Number(at[1]) : NaN;
|
|
247
|
+
const lng = at ? Number(at[2]) : NaN;
|
|
248
|
+
if (!at || Math.abs(lat) > 85 || Math.abs(lng) > 180) return html`<div class="display-value" id="${item.name}"> </div>`;
|
|
249
|
+
const z = Number.isInteger(item.config?.zoom) && item.config.zoom >= 1 && item.config.zoom <= 19 ? item.config.zoom : 15;
|
|
250
|
+
const n = 2 ** z;
|
|
251
|
+
const fx = ((lng + 180) / 360) * n;
|
|
252
|
+
const r = (lat * Math.PI) / 180;
|
|
253
|
+
const fy = ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * n;
|
|
254
|
+
const [x, y] = [Math.floor(fx), Math.floor(fy)];
|
|
255
|
+
const src = tileUrl().replace('{s}', 'a').replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y));
|
|
256
|
+
const pin = ctx.css.cls(`left:${((fx - x) * 100).toFixed(2)}%;top:${((fy - y) * 100).toFixed(2)}%`);
|
|
257
|
+
return html`<figure class="display-map" id="${item.name}">
|
|
258
|
+
<div class="display-map-tile"><img src="${src}" alt="${ctx.locale.t('item.map_of', { position: source.trim() })}" width="256" height="256" loading="lazy"><span class="display-map-pin ${pin}" aria-hidden="true"></span></div>
|
|
259
|
+
<figcaption><small>${tileAttribution()}</small> · <a href="https://www.openstreetmap.org/?mlat=${String(lat)}&mlon=${String(lng)}#map=${String(z)}/${String(lat)}/${String(lng)}" target="_blank" rel="noopener noreferrer">${ctx.locale.t('item.show_map')}</a></figcaption>
|
|
260
|
+
</figure>`;
|
|
261
|
+
}
|
|
262
|
+
|
|
238
263
|
export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = false): Promise<Raw | ''> {
|
|
239
264
|
if (item.type === 'hidden' || !ctx.vis!.items.has(item.name)) return '';
|
|
240
265
|
const value = ctx.session.state[item.name] ?? item.default_value ?? '';
|
|
@@ -270,6 +295,14 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
270
295
|
control = qrControl(ctx, item, value);
|
|
271
296
|
} else if (item.type === 'image') {
|
|
272
297
|
control = imageControl(ctx, item, value);
|
|
298
|
+
} else if (item.type === 'percent_graph') {
|
|
299
|
+
// (151) a 0–100 value as a bar; the number is its accessible text
|
|
300
|
+
const n = /^\s*-?\d+(\.\d+)?\s*$/.test(value) ? Math.max(0, Math.min(100, Number(value))) : null;
|
|
301
|
+
control = n === null
|
|
302
|
+
? html`<div class="display-value" id="${id}"> </div>`
|
|
303
|
+
: html`<div class="percent-graph" id="${id}"><progress max="100" value="${String(n)}" aria-label="${label}">${String(n)}%</progress><span class="percent-graph-value">${String(Math.round(n))}%</span></div>`;
|
|
304
|
+
} else if (item.type === 'display_map') {
|
|
305
|
+
control = displayMap(ctx, item, value);
|
|
273
306
|
} else if (!editable && (item.type === 'richtext' || item.type === 'markdown')) {
|
|
274
307
|
// rebuilt from the allow-list every time: the value may come from the table, not from this form
|
|
275
308
|
control = html`<div class="display-value rich-text" id="${id}">${raw((item.type === 'markdown' ? markdownHtml(value) : sanitizeHtml(value)) || ' ')}</div>`;
|
package/src/runtime/logic.ts
CHANGED
|
@@ -14,17 +14,42 @@ import { logActivity } from '../session.ts';
|
|
|
14
14
|
// own savepoint.
|
|
15
15
|
|
|
16
16
|
const list = (s: string | null | undefined) => (s ?? '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean);
|
|
17
|
+
const isZero = (v: string) => v.trim() !== '' && Number(v) === 0;
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/** The condition types that run SQL (as the application's role); all others are decided without a query. */
|
|
20
|
+
export const SQL_CONDITIONS = new Set(['sql', 'exists', 'not_exists', 'function_body']);
|
|
21
|
+
|
|
22
|
+
/** (147) A region's or button's condition: `condition` is the expression, SQL when no type is set. */
|
|
23
|
+
export const conditionOf = (x: { condition?: string | null; condition_type?: string | null; condition_value?: string | null }): Condition => ({
|
|
24
|
+
condition_type: (x.condition_type || (x.condition?.trim() ? 'sql' : null)) as Condition['condition_type'],
|
|
25
|
+
condition_expr: x.condition ?? null,
|
|
26
|
+
condition_value: x.condition_value ?? null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** (147) A dynamic action's server-side condition (its condition_type is the client-side one). */
|
|
30
|
+
export const serverConditionOf = (d: { server_condition_type?: string | null; server_condition_expr?: string | null; server_condition_value?: string | null }): Condition => ({
|
|
31
|
+
condition_type: (d.server_condition_type || null) as Condition['condition_type'],
|
|
32
|
+
condition_expr: d.server_condition_expr ?? null,
|
|
33
|
+
condition_value: d.server_condition_value ?? null,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Does a server-side condition hold (migrations 029, 147)? The same catalogue for every component. Broken SQL
|
|
38
|
+
* counts as false (and shows a message); an unknown type is false.
|
|
39
|
+
*/
|
|
19
40
|
export async function conditionHolds(ctx: PageContext, cond: Condition, where: string): Promise<boolean> {
|
|
20
|
-
const value = (name: string | null) => {
|
|
21
|
-
const v = name ? bindValues(ctx)[name.toUpperCase()] : null;
|
|
41
|
+
const value = (name: string | null | undefined) => {
|
|
42
|
+
const v = name ? bindValues(ctx)[name.trim().toUpperCase()] : null;
|
|
22
43
|
return v === undefined || v === null ? '' : v;
|
|
23
44
|
};
|
|
45
|
+
const item = value(cond.condition_expr);
|
|
24
46
|
switch (cond.condition_type) {
|
|
25
47
|
case null:
|
|
26
48
|
case undefined:
|
|
49
|
+
case 'always':
|
|
27
50
|
return true;
|
|
51
|
+
case 'never':
|
|
52
|
+
return false;
|
|
28
53
|
case 'sql':
|
|
29
54
|
return sqlTrue(ctx, cond.condition_expr, where);
|
|
30
55
|
case 'exists':
|
|
@@ -39,16 +64,62 @@ export async function conditionHolds(ctx: PageContext, cond: Condition, where: s
|
|
|
39
64
|
return false; // fail closed either way
|
|
40
65
|
}
|
|
41
66
|
}
|
|
67
|
+
case 'function_body':
|
|
68
|
+
try {
|
|
69
|
+
const v = await savepoint(ctx.client!, () => functionBody(ctx, cond.condition_expr ?? ''));
|
|
70
|
+
return v === 'true' || v === 't';
|
|
71
|
+
} catch (e) {
|
|
72
|
+
ctx.errors.page.push(await publicError(ctx, e, where));
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
42
75
|
case 'item_null':
|
|
43
|
-
return
|
|
76
|
+
return item === '';
|
|
44
77
|
case 'item_not_null':
|
|
45
|
-
return
|
|
78
|
+
return item !== '';
|
|
79
|
+
case 'item_zero':
|
|
80
|
+
return isZero(item);
|
|
81
|
+
case 'item_not_zero':
|
|
82
|
+
return !isZero(item);
|
|
83
|
+
case 'item_null_or_zero':
|
|
84
|
+
return item === '' || isZero(item);
|
|
85
|
+
case 'item_not_null_or_zero':
|
|
86
|
+
return item !== '' && !isZero(item);
|
|
46
87
|
case 'item_equals':
|
|
47
|
-
return
|
|
88
|
+
return item === (cond.condition_value ?? '');
|
|
48
89
|
case 'item_not_equals':
|
|
49
|
-
return
|
|
90
|
+
return item !== (cond.condition_value ?? '');
|
|
91
|
+
case 'item_equals_item':
|
|
92
|
+
return item === value(cond.condition_value);
|
|
93
|
+
case 'item_not_equals_item':
|
|
94
|
+
return item !== value(cond.condition_value);
|
|
95
|
+
case 'item_in_list':
|
|
96
|
+
case 'item_not_in_list': {
|
|
97
|
+
const found = (cond.condition_value ?? '').split(':').includes(item);
|
|
98
|
+
return cond.condition_type === 'item_in_list' ? found : !found;
|
|
99
|
+
}
|
|
50
100
|
case 'request_in':
|
|
51
101
|
return list(cond.condition_value).includes(ctx.request);
|
|
102
|
+
case 'request_not_in':
|
|
103
|
+
return !list(cond.condition_value).includes(ctx.request);
|
|
104
|
+
case 'page_in':
|
|
105
|
+
case 'page_not_in': {
|
|
106
|
+
const found = list(cond.condition_value).includes(String(ctx.page.page_no));
|
|
107
|
+
return cond.condition_type === 'page_in' ? found : !found;
|
|
108
|
+
}
|
|
109
|
+
case 'page_is_dialog':
|
|
110
|
+
return ctx.page.mode === 'modal';
|
|
111
|
+
case 'page_is_not_dialog':
|
|
112
|
+
return ctx.page.mode !== 'modal';
|
|
113
|
+
case 'user_signed_in':
|
|
114
|
+
return ctx.user !== 'nobody';
|
|
115
|
+
case 'user_public':
|
|
116
|
+
return ctx.user === 'nobody';
|
|
117
|
+
case 'language_in':
|
|
118
|
+
case 'language_not_in': {
|
|
119
|
+
const lang = ctx.locale.lang.toUpperCase();
|
|
120
|
+
const found = list(cond.condition_value).some((l) => l === lang || lang.startsWith(`${l}-`));
|
|
121
|
+
return cond.condition_type === 'language_in' ? found : !found;
|
|
122
|
+
}
|
|
52
123
|
default:
|
|
53
124
|
return false; // unknown type: fail closed
|
|
54
125
|
}
|
|
@@ -61,7 +132,7 @@ export async function conditionHolds(ctx: PageContext, cond: Condition, where: s
|
|
|
61
132
|
* savepoint, as the application's role (binds are substituted first, as
|
|
62
133
|
* escaped literals). It never outlives the transaction's savepoint.
|
|
63
134
|
*/
|
|
64
|
-
async function functionBody(ctx: PageContext, body: string): Promise<string | null> {
|
|
135
|
+
export async function functionBody(ctx: PageContext, body: string): Promise<string | null> {
|
|
65
136
|
const c = ctx.client!;
|
|
66
137
|
const code = applyBinds(body, bindValues(ctx)).trim();
|
|
67
138
|
const block = /^(declare|begin)\b/i.test(code) ? code : `begin\n${code}\nend`;
|