pgkiln 0.44.0 → 0.45.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/102_app_computations.sql +345 -0
- package/db/migrations/103_shortcuts.sql +341 -0
- package/db/migrations/104_report_queries.sql +413 -0
- package/db/migrations/105_component_settings_groups.sql +367 -0
- package/docs/apex-feature-parity.md +10 -10
- package/docs/guide/02-concepts.md +24 -0
- package/docs/guide/03-builder.md +33 -1
- package/docs/guide/04-pages-and-regions.md +10 -1
- package/docs/guide/06-processing.md +22 -0
- package/docs/guide/09-reference.md +13 -0
- package/docs/guide/12-development.md +2 -1
- package/docs/guide/16-files.md +23 -0
- package/docs/guide/18-cli.md +4 -0
- package/package.json +1 -1
- package/public/app.css +17 -0
- package/public/app.js +63 -0
- package/src/appfiles.ts +18 -2
- package/src/builder/advisor.ts +7 -2
- package/src/builder/code-editor.ts +6 -1
- package/src/builder/components.ts +85 -3
- package/src/builder/forms.ts +1 -1
- package/src/builder/shared.ts +39 -6
- package/src/builder/subscriptions.ts +30 -4
- package/src/cli/replace.ts +6 -2
- package/src/metadata.ts +14 -1
- package/src/runtime/component-settings.ts +42 -0
- package/src/runtime/context.ts +4 -0
- package/src/runtime/documents.ts +29 -3
- package/src/runtime/engine.ts +2 -2
- package/src/runtime/grid.ts +22 -4
- package/src/runtime/items.ts +14 -11
- package/src/runtime/logic.ts +12 -7
- package/src/runtime/regions.ts +4 -3
- package/src/runtime/render.ts +1 -0
- package/src/runtime/report.ts +2 -1
- package/src/runtime/routes.ts +4 -2
- package/src/runtime/shortcuts.ts +43 -0
- package/src/security.ts +2 -1
- package/src/subscriptions.ts +67 -0
|
@@ -4,8 +4,8 @@ import { html, type Raw } from '../html.ts';
|
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
6
6
|
import {
|
|
7
|
-
APP_TYPE_LABELS, inSync, isKind, KINDS, offers, publish, refresh, subscribe, subscribersOf, subscriptionOf, subscriptionsOf,
|
|
8
|
-
SubscriptionError, unsubscribe, type AppType, type Kind,
|
|
7
|
+
APP_TYPE_LABELS, groupsOffered, inSync, isKind, KINDS, offers, publish, refresh, subscribe, subscribersOf, subscriptionOf, subscriptionsOf,
|
|
8
|
+
SubscriptionError, subscribeGroup, unsubscribe, type AppType, type Kind,
|
|
9
9
|
} from '../subscriptions.ts';
|
|
10
10
|
import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
|
|
11
11
|
|
|
@@ -59,7 +59,7 @@ export async function subscriptionRoutes(app: FastifyInstance) {
|
|
|
59
59
|
if (!s) return;
|
|
60
60
|
const a = await appRow(req.params.id);
|
|
61
61
|
if (!a) return reply.code(404).send('Not found');
|
|
62
|
-
const [subs, subscribers, offered] = await Promise.all([subscriptionsOf(a.id), subscribersOf(a.id), offers(a.id)]);
|
|
62
|
+
const [subs, subscribers, offered, groupList] = await Promise.all([subscriptionsOf(a.id), subscribersOf(a.id), offers(a.id), groupsOffered(a.id)]);
|
|
63
63
|
const sync = await Promise.all(subs.map((x) => inSync(x)));
|
|
64
64
|
const subSync = await Promise.all(subscribers.map((x) => inSync(x)));
|
|
65
65
|
const hidden = (kind: string, name: string) => html`<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="name" value="${name}">`;
|
|
@@ -93,7 +93,17 @@ export async function subscriptionRoutes(app: FastifyInstance) {
|
|
|
93
93
|
</select>
|
|
94
94
|
<small class="help">The component is copied now. A component of this application with the same name is replaced.</small></div>
|
|
95
95
|
<div class="buttons"><button class="btn btn-hot">Subscribe</button></div></form>`
|
|
96
|
-
: html`<p class="muted">No theme or library applications offer components. Set an application's type under its Settings.</p>`}
|
|
96
|
+
: html`<p class="muted">No theme or library applications offer components. Set an application's type under its Settings.</p>`}
|
|
97
|
+
${groupList.length
|
|
98
|
+
? html`<h3>Subscribe to a component group</h3>
|
|
99
|
+
<form method="post" action="${BASE}/apps/${a.id}/subscriptions/group">${csrf(s)}
|
|
100
|
+
<div class="field"><label class="label" for="f_group">Component group</label>
|
|
101
|
+
<select id="f_group" name="group" required><option value="">- choose -</option>
|
|
102
|
+
${groupList.map((g) => html`<option value="${g.master_id}|${g.name}">${g.master_name}: ${g.name} (${g.members.length})</option>`)}
|
|
103
|
+
</select>
|
|
104
|
+
<small class="help">Every component of the group is copied now and subscribed (Shared Components → Component Groups of a theme or library application).</small></div>
|
|
105
|
+
<div class="buttons"><button class="btn btn-hot">Subscribe to the group</button></div></form>`
|
|
106
|
+
: ''}`)}
|
|
97
107
|
${a.app_type === 'theme' || a.app_type === 'library' || subscribers.length
|
|
98
108
|
? region('Subscribers', subscribers.length
|
|
99
109
|
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Component</th><th>Application</th><th>Refreshed</th><th>State</th></tr></thead><tbody>
|
|
@@ -138,6 +148,22 @@ export async function subscriptionRoutes(app: FastifyInstance) {
|
|
|
138
148
|
});
|
|
139
149
|
});
|
|
140
150
|
|
|
151
|
+
// (105) all the components of a master's component group
|
|
152
|
+
app.post(`${BASE}/apps/:id/subscriptions/group`, async (req: Req, reply) => {
|
|
153
|
+
const s = await developer(req, reply);
|
|
154
|
+
if (!s) return;
|
|
155
|
+
const a = await appRow(req.params.id);
|
|
156
|
+
if (!a) return reply.code(404).send('Not found');
|
|
157
|
+
const m = /^(\d{1,9})\|(.{1,60})$/s.exec(String(req.body?.group ?? ''));
|
|
158
|
+
return handle(req, reply, s, a.id, async () => {
|
|
159
|
+
if (!m) throw new SubscriptionError('Choose a component group.');
|
|
160
|
+
if (!(await groupsOffered(a.id)).some((g) => g.master_id === Number(m[1]) && g.name === m[2])) throw new SubscriptionError('Choose a component group.');
|
|
161
|
+
const { done, skipped } = await subscribeGroup(a.id, Number(m[1]), m[2], s.username!);
|
|
162
|
+
await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `subscribed component group ${m[2]} from application ${m[1]}: ${done.join(', ')}` });
|
|
163
|
+
return `Subscribed to ${m[2]}: ${done.length} component${done.length === 1 ? '' : 's'} copied${skipped.length ? `; skipped ${skipped.join(', ')}` : ''}.`;
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
141
167
|
app.post(`${BASE}/apps/:id/subscriptions/refresh`, async (req: Req, reply) => {
|
|
142
168
|
const s = await developer(req, reply);
|
|
143
169
|
if (!s) return;
|
package/src/cli/replace.ts
CHANGED
|
@@ -26,8 +26,12 @@ export const REPLACED = [
|
|
|
26
26
|
'search_config', 'email_template',
|
|
27
27
|
// (086) application settings (installed values kept, see below), dynamic translations
|
|
28
28
|
'app_setting', 'dynamic_translation',
|
|
29
|
-
// (089) page groups
|
|
30
|
-
'page_group',
|
|
29
|
+
// (089) page groups, (102) application computations
|
|
30
|
+
'page_group', 'app_computation',
|
|
31
|
+
// (103) shortcuts, (104) report queries
|
|
32
|
+
'shortcut', 'report_query',
|
|
33
|
+
// (105) component groups
|
|
34
|
+
'component_group',
|
|
31
35
|
];
|
|
32
36
|
/** Tables of an application that belong to the installation: kept. */
|
|
33
37
|
export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'saved_report', 'persistent_login', 'task', 'workflow', 'process_job',
|
package/src/metadata.ts
CHANGED
|
@@ -106,6 +106,12 @@ export interface App {
|
|
|
106
106
|
authz_schemes: AuthzScheme[];
|
|
107
107
|
app_items: string[];
|
|
108
108
|
app_processes: AppProcess[];
|
|
109
|
+
/** (102) application computations: run for every page at their point, before the page's own */
|
|
110
|
+
app_computations: AppComputation[];
|
|
111
|
+
/** (105) defaults of the built-in component types (runtime/component-settings.ts) */
|
|
112
|
+
component_settings: import('./runtime/component-settings.ts').ComponentSettings;
|
|
113
|
+
/** (103) shortcuts: "NAME" in static content, help texts and confirm messages (runtime/shortcuts.ts) */
|
|
114
|
+
shortcuts?: { name: string; type: 'text' | 'html' | 'function_body'; value: string }[];
|
|
109
115
|
lovs: { name: string; query: string; rest_source?: string | null }[];
|
|
110
116
|
theme: {
|
|
111
117
|
accent?: string; header?: string; nav?: 'side' | 'top'; mode?: 'auto' | 'light' | 'dark'; user_choice?: boolean;
|
|
@@ -238,6 +244,11 @@ export interface Computation extends Condition {
|
|
|
238
244
|
authz: string | null;
|
|
239
245
|
}
|
|
240
246
|
|
|
247
|
+
/** (102) An application computation: like a page's, for every page of the application. */
|
|
248
|
+
export interface AppComputation extends Omit<Computation, 'point'> {
|
|
249
|
+
point: 'after_authentication' | 'before_header' | 'after_submit';
|
|
250
|
+
}
|
|
251
|
+
|
|
241
252
|
export interface Branch extends Condition {
|
|
242
253
|
id: number;
|
|
243
254
|
seq: number;
|
|
@@ -349,7 +360,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
|
|
|
349
360
|
export async function loadApp(alias: string) {
|
|
350
361
|
return runtime.one<App>(
|
|
351
362
|
`select a.id, a.alias, a.name, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
|
|
352
|
-
a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds,
|
|
363
|
+
a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds, a.component_settings,
|
|
353
364
|
a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
|
|
354
365
|
coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
|
|
355
366
|
coalesce((select jsonb_agg(jsonb_build_object('page_no', p.page_no, 'name', p.name, 'title', p.title,
|
|
@@ -361,6 +372,8 @@ export async function loadApp(alias: string) {
|
|
|
361
372
|
from meta.authz_scheme s where s.app_id = a.id), '[]') as authz_schemes,
|
|
362
373
|
coalesce((select jsonb_agg(i.name) from meta.app_item i where i.app_id = a.id), '[]') as app_items,
|
|
363
374
|
${agg('meta.app_process', 'app_id', 'a', 'a.id')} as app_processes,
|
|
375
|
+
${agg('meta.app_computation', 'app_id', 'a', 'a.id')} as app_computations,
|
|
376
|
+
coalesce((select jsonb_agg(jsonb_build_object('name', s.name, 'type', s.type, 'value', s.value)) from meta.shortcut s where s.app_id = a.id), '[]') as shortcuts,
|
|
364
377
|
${includes('a.static_includes', 'a.id')} as static_includes,
|
|
365
378
|
coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' - 'install_sql') from meta.plugin x where x.app_id = a.id), '[]') as plugins,
|
|
366
379
|
coalesce((select jsonb_agg(jsonb_build_object('name', f.name, 'v', floor(extract(epoch from f.updated_at))::bigint))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Component settings (migration 105; APEX: Shared Components → Component
|
|
2
|
+
// Settings): an application's defaults for its built-in component types, used
|
|
3
|
+
// where a component sets nothing itself. Every value is bounded here, so a
|
|
4
|
+
// hand-edited or imported setting can't make a page read without limits.
|
|
5
|
+
|
|
6
|
+
export interface ComponentSettings {
|
|
7
|
+
report_rows?: number;
|
|
8
|
+
grid_rows?: number;
|
|
9
|
+
popup_lov_rows?: number;
|
|
10
|
+
lov_max_rows?: number;
|
|
11
|
+
rating_max?: number;
|
|
12
|
+
}
|
|
13
|
+
export type ComponentSetting = keyof ComponentSettings;
|
|
14
|
+
|
|
15
|
+
export const COMPONENT_SETTINGS: { key: ComponentSetting; label: string; group: string; min: number; max: number; def: number; help: string }[] = [
|
|
16
|
+
{ key: 'report_rows', label: 'Rows per page', group: 'Reports', min: 1, max: 1000, def: 15, help: 'Of a classic or interactive report without its own page size (users can still choose).' },
|
|
17
|
+
{ key: 'grid_rows', label: 'Rows per page', group: 'Interactive grids', min: 1, max: 200, def: 25, help: 'Of an interactive grid without its own page size.' },
|
|
18
|
+
{ key: 'popup_lov_rows', label: 'Rows per page', group: 'Popup lists of values', min: 1, max: 100, def: 25, help: 'In a popup list of values\' dialog, for items without their own page size.' },
|
|
19
|
+
{ key: 'lov_max_rows', label: 'Maximum rows', group: 'Lists of values', min: 1, max: 50_000, def: 5000, help: 'Rows a select list, radio group, checkbox group or popup list reads, for items without their own maximum.' },
|
|
20
|
+
{ key: 'rating_max', label: 'Stars', group: 'Star ratings', min: 3, max: 10, def: 5, help: 'Of a star rating item without its own maximum.' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** The value of a setting: the application's (bounded), else the built-in default. */
|
|
24
|
+
export function componentSetting(settings: ComponentSettings | null | undefined, key: ComponentSetting): number {
|
|
25
|
+
const spec = COMPONENT_SETTINGS.find((s) => s.key === key)!;
|
|
26
|
+
const n = Math.floor(Number(settings?.[key]));
|
|
27
|
+
return Number.isFinite(n) && n >= spec.min && n <= spec.max ? n : spec.def;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The settings from the builder's form: whole numbers within bounds; empty leaves a setting at its default. */
|
|
31
|
+
export function componentSettingsFrom(form: Record<string, unknown>): { settings: ComponentSettings; problems: string[] } {
|
|
32
|
+
const settings: ComponentSettings = {};
|
|
33
|
+
const problems: string[] = [];
|
|
34
|
+
for (const s of COMPONENT_SETTINGS) {
|
|
35
|
+
const raw = String(form[s.key] ?? '').trim();
|
|
36
|
+
if (raw === '') continue;
|
|
37
|
+
const n = Number(raw);
|
|
38
|
+
if (!Number.isInteger(n) || n < s.min || n > s.max) problems.push(`${s.group}, ${s.label}: a whole number from ${s.min} to ${s.max.toLocaleString('en')}.`);
|
|
39
|
+
else settings[s.key] = n;
|
|
40
|
+
}
|
|
41
|
+
return { settings, problems };
|
|
42
|
+
}
|
package/src/runtime/context.ts
CHANGED
|
@@ -66,6 +66,10 @@ export interface PageContext {
|
|
|
66
66
|
cacheRefresh?: boolean;
|
|
67
67
|
/** the lazy region the request renders (GET …/region/:id), not as a placeholder */
|
|
68
68
|
loadNow?: number;
|
|
69
|
+
/** (103) the values of the function-body shortcuts this request used */
|
|
70
|
+
shortcutValues?: Map<string, string>;
|
|
71
|
+
/** the whole page is rendered (renderPage), not one region on its own */
|
|
72
|
+
wholePage?: boolean;
|
|
69
73
|
/** a download process's file, sent instead of the page (processes.ts) */
|
|
70
74
|
download?: Download;
|
|
71
75
|
/** running a background chain (process-jobs.ts): no request, no browser */
|
package/src/runtime/documents.ts
CHANGED
|
@@ -4,21 +4,47 @@ import { isAuthorized, Forbidden } from './authz.ts';
|
|
|
4
4
|
import { bindValues, stripSemicolon, substitute, type PageContext } from './context.ts';
|
|
5
5
|
import { documentData, documentFilename, documentPdf, fillTemplate, type DocumentTemplate } from './document.ts';
|
|
6
6
|
import { layoutFor, layoutText } from './pdf.ts';
|
|
7
|
+
import { exportDownload, type ExportFormat } from './data-export.ts';
|
|
8
|
+
import type { Process } from '../metadata.ts';
|
|
7
9
|
|
|
8
10
|
// ?doc=NAME on a page: the document template filled with the page's session
|
|
9
|
-
// state (after the URL's checksummed item values), as the app's role
|
|
11
|
+
// state (after the URL's checksummed item values), as the app's role; (104)
|
|
12
|
+
// without a template of that name, the report query of it.
|
|
13
|
+
|
|
14
|
+
/** (104) A report query: a named query printed as PDF, Excel, CSV, JSON, XML or HTML. */
|
|
15
|
+
interface ReportQuery {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string | null;
|
|
18
|
+
query: string;
|
|
19
|
+
format: ExportFormat;
|
|
20
|
+
layout: string | null;
|
|
21
|
+
file_name: string | null;
|
|
22
|
+
title: string | null;
|
|
23
|
+
authz: string | null;
|
|
24
|
+
}
|
|
10
25
|
|
|
11
26
|
export async function renderDocument(ctx: PageContext, name: string) {
|
|
12
27
|
const c = ctx.client!;
|
|
13
28
|
// metadata through the runtime connection: the page's transaction already runs as the app's role
|
|
14
29
|
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)]);
|
|
15
|
-
|
|
30
|
+
// (104) without a document template of that name, a report query of it
|
|
31
|
+
const rq = tpl ? null : await runtime.one<ReportQuery>('select * from meta.report_query where app_id = $1 and name = upper($2)', [ctx.app.id, name.slice(0, 100)]);
|
|
32
|
+
const found = tpl ?? rq;
|
|
33
|
+
if (!found || !(await isAuthorized(ctx, found.authz))) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
|
|
16
34
|
// without signing in (a public page of an app that has sign-in) only the documents the page itself offers
|
|
17
35
|
// with a visible document button: ?doc= on any public page would otherwise hand out every template
|
|
18
36
|
if (ctx.app.authentication !== 'none' && !ctx.session.username) {
|
|
19
|
-
const offered = [...(ctx.vis?.buttons.values() ?? [])].some((b) => b.action === 'document' && (b.document ?? '').toUpperCase() ===
|
|
37
|
+
const offered = [...(ctx.vis?.buttons.values() ?? [])].some((b) => b.action === 'document' && (b.document ?? '').toUpperCase() === found.name);
|
|
20
38
|
if (!offered) throw new Forbidden(ctx.locale.t('error.document_unavailable'));
|
|
21
39
|
}
|
|
40
|
+
if (!tpl) {
|
|
41
|
+
const q = rq!;
|
|
42
|
+
const d = await exportDownload(ctx, { name: q.name, code: q.query } as Process, {
|
|
43
|
+
export: q.format, layout: q.layout ?? undefined, file_name: q.file_name ?? undefined, title: q.title ?? q.description ?? undefined,
|
|
44
|
+
});
|
|
45
|
+
// sent like a download process's file (sandboxed, never shown as a page of the application)
|
|
46
|
+
return { download: { ...d, inline: false } };
|
|
47
|
+
}
|
|
22
48
|
const now = new Date().toISOString();
|
|
23
49
|
const data = await savepoint(c, () =>
|
|
24
50
|
documentData(c, stripSemicolon(applyBinds(tpl.query, bindValues(ctx))), {
|
package/src/runtime/engine.ts
CHANGED
|
@@ -173,8 +173,8 @@ export async function validate(ctx: PageContext) {
|
|
|
173
173
|
const v = state[i.name];
|
|
174
174
|
if (!v || !vis.editable.has(i.name) || errors.items[i.name]) continue;
|
|
175
175
|
const label = i.label ?? i.name;
|
|
176
|
-
if (i.type === 'rating' && !(/^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= ratingMax(i)))
|
|
177
|
-
fail(i.name, ctx.locale.t('error.rating', { label, max: ratingMax(i) }));
|
|
176
|
+
if (i.type === 'rating' && !(/^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= ratingMax(i, ctx.app.component_settings)))
|
|
177
|
+
fail(i.name, ctx.locale.t('error.rating', { label, max: ratingMax(i, ctx.app.component_settings) }));
|
|
178
178
|
// a list item posts return values: each must be one its list of values returns (config.any_value: true allows others)
|
|
179
179
|
if (LOV_CHECKED.has(i.type) && i.lov && i.config?.any_value !== true && !(await lovContains(ctx, i.lov, MULTI_VALUE.has(i.type) ? splitValues(v) : [v])))
|
|
180
180
|
fail(i.name, ctx.locale.t('error.lov_value', { label }));
|
package/src/runtime/grid.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { componentSetting } from './component-settings.ts';
|
|
1
2
|
import pg from 'pg';
|
|
2
3
|
import { literal } from '../binds.ts';
|
|
3
4
|
import { savepoint, type Client } from '../db.ts';
|
|
@@ -116,6 +117,17 @@ function control(col: GridColumn, name: string, value: string, label: string) {
|
|
|
116
117
|
return html`<input type="text" name="${name}" value="${value}"${aria}>`;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* (APEX: cell navigation mode) An editable cell's value as text, with its column's format mask or the
|
|
122
|
+
* option's display value: with JavaScript the cell reads as this text until it is edited (app.css,
|
|
123
|
+
* app.js); the control holds the raw value. Not for a checkbox, which reads as itself.
|
|
124
|
+
*/
|
|
125
|
+
function cellText(col: GridColumn, value: string, shown: string) {
|
|
126
|
+
if (col.typeOid === 16 || value === '') return '';
|
|
127
|
+
const text = col.options ? col.options.find((o) => o.value === value)?.display ?? value : shown;
|
|
128
|
+
return html`<span class="grid-text${NUMERIC.has(col.typeOid) && !col.options ? ' num' : ''}" aria-hidden="true">${text}</span>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
119
131
|
/** Width of each leading column (master selection, delete, row actions), see app.css .grid-lead. */
|
|
120
132
|
const LEAD_WIDTH = 44;
|
|
121
133
|
|
|
@@ -169,7 +181,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
169
181
|
const base = reportState(ctx, r);
|
|
170
182
|
const views = gridViews(r);
|
|
171
183
|
const view: View = views.includes(base.view) ? base.view : 'report';
|
|
172
|
-
const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) ||
|
|
184
|
+
const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'grid_rows')));
|
|
173
185
|
// the single row view: one row of the search at a time (r<id>_row=N from 1), or a new row (r<id>_row=new)
|
|
174
186
|
const rowParam = ctx.params.get(key(r, 'row'));
|
|
175
187
|
const adding = view === 'row' && rowParam === 'new';
|
|
@@ -240,7 +252,13 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
240
252
|
// master-detail: this grid selects rows for detail regions; or it follows a master
|
|
241
253
|
const select = selectRowOf(ctx.page, r);
|
|
242
254
|
const selIdx = select ? fields.findIndex((f) => f.name.toLowerCase() === select.column.toLowerCase()) : -1;
|
|
243
|
-
|
|
255
|
+
let selected = select ? ctx.session.state[select.item] ?? null : null;
|
|
256
|
+
// as in APEX, a page shows the first master row selected: when nothing is selected, or the
|
|
257
|
+
// selected row is not among the rows shown (after a search or filter); the regions below follow it
|
|
258
|
+
if (select && selIdx >= 0 && ctx.wholePage && view === 'report' && info.rows.length) {
|
|
259
|
+
const shown = info.rows.map((row) => toState(row[selIdx]) ?? '');
|
|
260
|
+
if (selected === null || !shown.includes(selected)) ctx.session.state[select.item] = selected = shown[0];
|
|
261
|
+
}
|
|
244
262
|
const details = select ? detailsOf(ctx.page, r, ctx.vis!.regions) : [];
|
|
245
263
|
const masterCol = masterColumnOf(r);
|
|
246
264
|
const masterValue = masterItemOf(r) ? ctx.session.state[masterItemOf(r)!] ?? '' : '';
|
|
@@ -297,7 +315,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
297
315
|
return i === -1 ? undefined : (toState(row[i]) ?? '');
|
|
298
316
|
});
|
|
299
317
|
|
|
300
|
-
// read-only cells show their column's format mask; editable
|
|
318
|
+
// read-only cells show their column's format mask; an editable cell's control keeps the raw value
|
|
301
319
|
const fmtOf = columnFormats(ctx, r);
|
|
302
320
|
const rows = info.rows.map((row, i) => {
|
|
303
321
|
const pk = toState(row[pkIdx]) ?? '';
|
|
@@ -311,7 +329,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
311
329
|
if (!col.editable) return html`<td class="${colCls(p, pIdx, NUMERIC.has(col.typeOid) ? 'num' : null)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cell(row[col.idx], col.typeOid, fmtOf(col.name))}</td>`;
|
|
312
330
|
const name = `${g}_${i}_c${col.ci}`;
|
|
313
331
|
const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
|
|
314
|
-
return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control(col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
|
|
332
|
+
return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cellText(col, value, value === dbValue ? cell(row[col.idx], col.typeOid, fmtOf(col.name)) : value)}${control(col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
|
|
315
333
|
});
|
|
316
334
|
const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
|
|
317
335
|
const selValue = selIdx >= 0 ? toState(row[selIdx]) ?? '' : null;
|
package/src/runtime/items.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { Item } from '../metadata.ts';
|
|
|
8
8
|
import { bindValues, publicError, stripSemicolon, toState, type PageContext } from './context.ts';
|
|
9
9
|
import { canPreview, fileInfo, fileList, fileUrl, formatSize, isMultiple, maxFiles, removals } from './files.ts';
|
|
10
10
|
import { restSql } from './rest-sources.ts';
|
|
11
|
+
import { expandShortcuts } from './shortcuts.ts';
|
|
12
|
+
import { componentSetting, type ComponentSettings } from './component-settings.ts';
|
|
11
13
|
import { markdownHtml, sanitizeHtml } from '../richtext.ts';
|
|
12
14
|
import { qrSvg, type Ecc } from '../qrcode.ts';
|
|
13
15
|
import { formatNumber, isPlainNumber, maskError } from '../numformat.ts';
|
|
@@ -77,9 +79,10 @@ async function lovColumns(ctx: PageContext, sql: string) {
|
|
|
77
79
|
|
|
78
80
|
/** Rows of a popup LOV's dialog: at most 100 per page. */
|
|
79
81
|
export const POPUP_PAGE_MAX = 100;
|
|
80
|
-
export const popupPageSize = (item: Item, asked?: unknown) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
82
|
+
export const popupPageSize = (item: Item, asked?: unknown, settings?: ComponentSettings) => {
|
|
83
|
+
const def = componentSetting(settings, 'popup_lov_rows');
|
|
84
|
+
const n = Math.floor(Number(asked ?? item.config?.page_size ?? def));
|
|
85
|
+
return Number.isFinite(n) && n >= 1 ? Math.min(n, POPUP_PAGE_MAX) : def;
|
|
83
86
|
};
|
|
84
87
|
|
|
85
88
|
export interface LovPage {
|
|
@@ -160,9 +163,9 @@ export const LOV_CHECKED = new Set(['select', 'radio', 'checkbox_group', 'multis
|
|
|
160
163
|
|
|
161
164
|
/** Rows a list of values reads when its item sets no config.max_rows (1 to 50,000). */
|
|
162
165
|
export const LOV_MAX_ROWS = 5000;
|
|
163
|
-
export const lovMax = (item: Item) => {
|
|
166
|
+
export const lovMax = (item: Item, settings?: ComponentSettings) => {
|
|
164
167
|
const n = Math.floor(Number(item.config?.max_rows));
|
|
165
|
-
return Number.isFinite(n) && n >= 1 ? Math.min(n, 50_000) : LOV_MAX_ROWS;
|
|
168
|
+
return Number.isFinite(n) && n >= 1 ? Math.min(n, 50_000) : settings ? componentSetting(settings, 'lov_max_rows') : LOV_MAX_ROWS;
|
|
166
169
|
};
|
|
167
170
|
|
|
168
171
|
const LOV_TYPES = new Set(['select', 'radio', 'checkbox_group', 'multiselect', 'popup_lov', 'combobox']);
|
|
@@ -174,7 +177,7 @@ export const splitValues = (v: string) => (v ? v.split(':') : []);
|
|
|
174
177
|
/** A combobox holds several values (colon-separated) unless {"multiple": false}. */
|
|
175
178
|
export const comboMultiple = (item: Item) => item.config?.multiple !== false;
|
|
176
179
|
/** The highest star of a rating item ({"max": 5}, 3 to 10). */
|
|
177
|
-
export const ratingMax = (item: Item) => Math.min(Math.max(Math.round(Number(item.config?.max)) ||
|
|
180
|
+
export const ratingMax = (item: Item, settings?: ComponentSettings) => Math.min(Math.max(Math.round(Number(item.config?.max)) || componentSetting(settings, 'rating_max'), 3), 10);
|
|
178
181
|
/** A date range item's value "from:to" (ISO dates, either may be empty). */
|
|
179
182
|
export const splitRange = (v: string | null | undefined): [string, string] => {
|
|
180
183
|
const [from = '', to = ''] = (v ?? '').split(':');
|
|
@@ -226,7 +229,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
226
229
|
let lovError: Raw | '' = '';
|
|
227
230
|
if (hasLov(item)) {
|
|
228
231
|
try {
|
|
229
|
-
options = await lovOptions(ctx, item.lov, lovMax(item));
|
|
232
|
+
options = await lovOptions(ctx, item.lov, lovMax(item, ctx.app.component_settings));
|
|
230
233
|
} catch (e) {
|
|
231
234
|
lovError = html`<small class="error">${await publicError(ctx, e, `list of values of ${item.name}`)}</small>`;
|
|
232
235
|
}
|
|
@@ -249,9 +252,9 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
249
252
|
// rebuilt from the allow-list every time: the value may come from the table, not from this form
|
|
250
253
|
control = html`<div class="display-value rich-text" id="${id}">${raw((item.type === 'markdown' ? markdownHtml(value) : sanitizeHtml(value)) || ' ')}</div>`;
|
|
251
254
|
} else if (!editable && item.type === 'rating') {
|
|
252
|
-
const n = /^\d+$/.test(value) ? Math.min(Number(value), ratingMax(item)) : 0;
|
|
255
|
+
const n = /^\d+$/.test(value) ? Math.min(Number(value), ratingMax(item, ctx.app.component_settings)) : 0;
|
|
253
256
|
control = n
|
|
254
|
-
? html`<div class="display-value rating-value" id="${id}"><span aria-hidden="true">${'★'.repeat(n)}<span class="rating-off">${'★'.repeat(ratingMax(item) - n)}</span></span><span class="sr-only">${ctx.locale.t('item.rating_of', { n, max: ratingMax(item) })}</span></div>`
|
|
257
|
+
? html`<div class="display-value rating-value" id="${id}"><span aria-hidden="true">${'★'.repeat(n)}<span class="rating-off">${'★'.repeat(ratingMax(item, ctx.app.component_settings) - n)}</span></span><span class="sr-only">${ctx.locale.t('item.rating_of', { n, max: ratingMax(item, ctx.app.component_settings) })}</span></div>`
|
|
255
258
|
: html`<div class="display-value" id="${id}"> </div>`;
|
|
256
259
|
} else if (!editable && item.type === 'combobox') {
|
|
257
260
|
const values = comboMultiple(item) ? splitValues(value) : value ? [value] : [];
|
|
@@ -352,7 +355,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
352
355
|
case 'rating': {
|
|
353
356
|
// radio buttons (keyboard and screen readers as usual); CSS draws them as stars
|
|
354
357
|
useLegend = true;
|
|
355
|
-
const max = ratingMax(item);
|
|
358
|
+
const max = ratingMax(item, ctx.app.component_settings);
|
|
356
359
|
const stars = [];
|
|
357
360
|
for (let n = 1; n <= max; n++)
|
|
358
361
|
stars.push(
|
|
@@ -461,7 +464,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
461
464
|
const tag = useLegend ? 'fieldset' : 'div';
|
|
462
465
|
return html`${raw(`<${tag}`)} class="field field-${item.type}${error ? ' has-error' : ''}${editable ? '' : ' readonly'}${templateClasses('item', item.template_options)}" data-item="${item.name}"${attrs}${pluginAttrs}>
|
|
463
466
|
${labelHtml}${control}${lovError}
|
|
464
|
-
${item.help ? html`<small class="help" id="${id}_help">${item.help}</small>` : ''}
|
|
467
|
+
${item.help ? html`<small class="help" id="${id}_help">${await expandShortcuts(ctx, item.help, 'text')}</small>` : ''}
|
|
465
468
|
${error ? html`<small class="error" id="${id}_error">${error}</small>` : ''}
|
|
466
469
|
${raw(`</${tag}>`)}`;
|
|
467
470
|
}
|
package/src/runtime/logic.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { applyBinds } from '../binds.ts';
|
|
3
3
|
import { runtime, savepoint } from '../db.ts';
|
|
4
|
-
import type { Branch, Computation, Condition } from '../metadata.ts';
|
|
4
|
+
import type { AppComputation, Branch, Computation, Condition } from '../metadata.ts';
|
|
5
5
|
import { isAuthorized, sqlTrue } from './authz.ts';
|
|
6
6
|
import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type PageContext } from './context.ts';
|
|
7
7
|
import { pageHref } from './links.ts';
|
|
@@ -112,19 +112,24 @@ export class ComputationFailed extends Error {}
|
|
|
112
112
|
* items only. Before the page is shown, an error becomes a message on the
|
|
113
113
|
* page; after a submit, it stops the submit (ComputationFailed).
|
|
114
114
|
*/
|
|
115
|
-
export async function runComputations(ctx: PageContext, point:
|
|
115
|
+
export async function runComputations(ctx: PageContext, point: AppComputation['point']) {
|
|
116
116
|
const names = new Set([...ctx.app.app_items, ...ctx.page.items.map((i) => i.name)]);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
// (102) the application's computations of the point first (as in APEX), then the page's
|
|
118
|
+
const comps: { comp: Computation | AppComputation; app: boolean }[] = [
|
|
119
|
+
...(ctx.app.app_computations ?? []).filter((c) => c.point === point).map((comp) => ({ comp, app: true })),
|
|
120
|
+
...(ctx.page.computations ?? []).filter((c) => c.point === point).map((comp) => ({ comp, app: false })),
|
|
121
|
+
];
|
|
122
|
+
for (const { comp, app } of comps) {
|
|
123
|
+
const where = `${app ? 'application computation' : 'computation'} of ${comp.item_name}`;
|
|
120
124
|
if (!names.has(comp.item_name)) {
|
|
121
|
-
|
|
125
|
+
// an application computation of a page item computes it on that item's page only
|
|
126
|
+
if (!app) ctx.errors.page.push(ctx.locale.t('logic.unknown_item', { item: comp.item_name }));
|
|
122
127
|
continue;
|
|
123
128
|
}
|
|
124
129
|
if (!(await isAuthorized(ctx, comp.authz))) continue;
|
|
125
130
|
if (!(await conditionHolds(ctx, comp, `condition of the ${where}`))) continue;
|
|
126
131
|
try {
|
|
127
|
-
ctx.session.state[comp.item_name] = await timed(ctx, 6, 'computation',
|
|
132
|
+
ctx.session.state[comp.item_name] = await timed(ctx, 6, 'computation', `${where} (${comp.type})`, () => computeValue(ctx, comp));
|
|
128
133
|
dbg(ctx, 9, 'computation', () => `${comp.item_name} := ${ctx.page.items.some((i) => i.name === comp.item_name && i.type === 'password') ? '(password, not shown)' : JSON.stringify((ctx.session.state[comp.item_name] ?? '').slice(0, 200))}`);
|
|
129
134
|
} catch (e) {
|
|
130
135
|
const message = await publicError(ctx, e, where);
|
package/src/runtime/regions.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Button, Region } from '../metadata.ts';
|
|
|
8
8
|
import { isAuthorized, pageAllowed } from './authz.ts';
|
|
9
9
|
import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, type PageContext } from './context.ts';
|
|
10
10
|
import { renderItems } from './items.ts';
|
|
11
|
+
import { expandShortcuts } from './shortcuts.ts';
|
|
11
12
|
import { fillItems, linkAttrs, linkColumns } from './links.ts';
|
|
12
13
|
import { renderCalendar } from './calendar.ts';
|
|
13
14
|
import { CHART_KINDS, renderChartBody, wallClockIn, type GaugeConfig } from './charts.ts';
|
|
@@ -66,7 +67,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
|
|
|
66
67
|
for (const e of b.menu ?? []) {
|
|
67
68
|
if (!(await isAuthorized(ctx, e.authz))) continue;
|
|
68
69
|
const ic = e.icon ? html`${icon(e.icon)} ` : '';
|
|
69
|
-
const confirm = e.confirm ? raw(` data-confirm="${esc(e.confirm)}"`) : '';
|
|
70
|
+
const confirm = e.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, e.confirm, 'text'))}"`) : '';
|
|
70
71
|
if (e.page) {
|
|
71
72
|
if (!(await pageAllowed(ctx, e.page))) continue;
|
|
72
73
|
entries.push(html`<a ${linkAttrs(ctx, e.page, e.items ?? {})}${confirm}>${ic}${e.label}</a>`);
|
|
@@ -82,7 +83,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
|
|
|
82
83
|
|
|
83
84
|
export async function renderButton(ctx: PageContext, b: Button) {
|
|
84
85
|
const cls = `btn${b.hot ? ' btn-hot' : ''}${b.name === 'DELETE' ? ' btn-danger' : ''}${templateClasses('button', b.template_options)}`;
|
|
85
|
-
const confirm = b.confirm ? raw(` data-confirm="${esc(b.confirm)}"`) : '';
|
|
86
|
+
const confirm = b.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, b.confirm, 'text'))}"`) : '';
|
|
86
87
|
const badge = b.badge || b.badge_query ? await badgeOf(ctx, b) : '';
|
|
87
88
|
if (b.action === 'menu') return renderMenu(ctx, b, cls, badge);
|
|
88
89
|
if (b.action === 'redirect') {
|
|
@@ -401,7 +402,7 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
|
|
|
401
402
|
break;
|
|
402
403
|
case 'static': {
|
|
403
404
|
const itemsHtml = await renderItems(ctx, items, hidden);
|
|
404
|
-
body = html`${raw(substitute(r.source ?? '', ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
|
|
405
|
+
body = html`${raw(substitute(await expandShortcuts(ctx, r.source ?? '', 'html'), ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
|
|
405
406
|
break;
|
|
406
407
|
}
|
|
407
408
|
}
|
package/src/runtime/render.ts
CHANGED
|
@@ -364,6 +364,7 @@ const timeZoneMeta = (ctx: PageContext) =>
|
|
|
364
364
|
|
|
365
365
|
export async function renderPage(ctx: PageContext) {
|
|
366
366
|
const t = ctx.locale.t;
|
|
367
|
+
ctx.wholePage = true;
|
|
367
368
|
const hidden = initiallyHidden(ctx);
|
|
368
369
|
const defaultButton = [...ctx.vis!.buttons.values()].find((b) => b.hot && b.action === 'submit');
|
|
369
370
|
const pageItems = await renderItems(ctx, ctx.page.items.filter((i) => i.region_id === null), hidden);
|
package/src/runtime/report.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { componentSetting } from './component-settings.ts';
|
|
1
2
|
import { icon } from '../icons.ts';
|
|
2
3
|
import pg from 'pg';
|
|
3
4
|
import { templateClasses } from './template-options.ts';
|
|
@@ -237,7 +238,7 @@ export function selectionOf(page: { items: { name: string }[] }, r: Region) {
|
|
|
237
238
|
|
|
238
239
|
export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
239
240
|
const p = ctx.params;
|
|
240
|
-
const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) ||
|
|
241
|
+
const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'report_rows');
|
|
241
242
|
const st: ReportState = {
|
|
242
243
|
search: (p.get(key(r, 'q')) ?? '').trim(),
|
|
243
244
|
searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
|
package/src/runtime/routes.ts
CHANGED
|
@@ -141,7 +141,8 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
|
|
|
141
141
|
if (shared) s.state.__SHARED = shared;
|
|
142
142
|
await saveState(s);
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
// the application processes "after login", then (102) the application computations "after authentication"
|
|
145
|
+
if (a.app_processes.some((p) => p.point === 'after_login') || (a.app_computations ?? []).some((c) => c.point === 'after_authentication')) {
|
|
145
146
|
const home = (await loadPage(a.id, a.home_page)) ?? ({ page_no: a.home_page, items: [], regions: [], buttons: [], dynamic_actions: [], validations: [], processes: [], computations: [], branches: [] } as unknown as Page);
|
|
146
147
|
const ctx: PageContext = {
|
|
147
148
|
app: a, page: home, session: s, base, params: new URLSearchParams(), request: '', user: username,
|
|
@@ -151,6 +152,7 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
|
|
|
151
152
|
await appTx(txContext(ctx), async (c) => {
|
|
152
153
|
ctx.client = c;
|
|
153
154
|
await runAppProcesses(ctx, 'after_login');
|
|
155
|
+
await runComputations(ctx, 'after_authentication');
|
|
154
156
|
});
|
|
155
157
|
await saveState(s);
|
|
156
158
|
}
|
|
@@ -1025,7 +1027,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1025
1027
|
if (!item || !vis.editable.has(item.name)) throw new Forbidden(ctx.locale.t('error.unknown_item'));
|
|
1026
1028
|
if (item.config?.cascade_parents) applyPostedItems(ctx, body, list(item.config.cascade_parents));
|
|
1027
1029
|
const page = Math.min(Math.max(Math.floor(Number(body.p)) || 0, 0), 10_000);
|
|
1028
|
-
return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n));
|
|
1030
|
+
return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n, ctx.app.component_settings));
|
|
1029
1031
|
});
|
|
1030
1032
|
return reply.send(out);
|
|
1031
1033
|
} catch (e) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { esc } from '../html.ts';
|
|
2
|
+
import { publicError, type PageContext } from './context.ts';
|
|
3
|
+
import { computeValue } from './logic.ts';
|
|
4
|
+
|
|
5
|
+
// Shortcuts (migration 103; APEX: Shared Components → Shortcuts). "NAME" (an
|
|
6
|
+
// upper-case name in double quotes) in a static region's content, an item's
|
|
7
|
+
// help text and a button's or menu entry's confirm message is replaced by the
|
|
8
|
+
// application's shortcut of that name, before &ITEM. substitutions. Other
|
|
9
|
+
// quoted words (class="NAV" with no shortcut NAV) stay as they are.
|
|
10
|
+
//
|
|
11
|
+
// text the value; escaped where it goes into HTML
|
|
12
|
+
// html the value as HTML
|
|
13
|
+
// function_body PL/pgSQL returning the text (HTML), run once per request
|
|
14
|
+
// as the application's role
|
|
15
|
+
|
|
16
|
+
const NAME = /"([A-Z][A-Z0-9_]{0,59})"/g;
|
|
17
|
+
|
|
18
|
+
/** A shortcut's text (html: as it goes into HTML), computed once per request; null: no such shortcut. */
|
|
19
|
+
async function valueOf(ctx: PageContext, name: string, asHtml: boolean): Promise<string | null> {
|
|
20
|
+
const s = ctx.app.shortcuts?.find((x) => x.name === name);
|
|
21
|
+
if (!s) return null;
|
|
22
|
+
if (s.type === 'text') return asHtml ? esc(s.value) : s.value;
|
|
23
|
+
if (s.type === 'html') return s.value;
|
|
24
|
+
const cache = (ctx.shortcutValues ??= new Map());
|
|
25
|
+
if (!cache.has(name)) {
|
|
26
|
+
try {
|
|
27
|
+
cache.set(name, (await computeValue(ctx, { type: 'function_body', expression: s.value })) ?? '');
|
|
28
|
+
} catch (e) {
|
|
29
|
+
ctx.errors.page.push(await publicError(ctx, e, `shortcut "${name}"`));
|
|
30
|
+
cache.set(name, '');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return cache.get(name)!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The text with its "NAME" shortcuts replaced (as HTML or as plain text). */
|
|
37
|
+
export async function expandShortcuts(ctx: PageContext, text: string, as: 'html' | 'text'): Promise<string> {
|
|
38
|
+
if (!ctx.app.shortcuts?.length || !text.includes('"')) return text;
|
|
39
|
+
const names = new Set([...text.matchAll(NAME)].map((m) => m[1]));
|
|
40
|
+
const values = new Map<string, string | null>();
|
|
41
|
+
for (const n of names) values.set(n, await valueOf(ctx, n, as === 'html'));
|
|
42
|
+
return text.replace(NAME, (m, n: string) => values.get(n) ?? m);
|
|
43
|
+
}
|
package/src/security.ts
CHANGED
|
@@ -96,7 +96,8 @@ export function securityHeaders(app: FastifyInstance) {
|
|
|
96
96
|
// the app's own pages may use the camera (photos, scanning) and the position (location items); nothing else may
|
|
97
97
|
reply.header('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(self)');
|
|
98
98
|
const type = String(reply.getHeader('content-type') ?? '');
|
|
99
|
-
|
|
99
|
+
// a file's own stricter policy (downloads, uploaded files: default-src 'none'; sandbox) is kept
|
|
100
|
+
if (type.startsWith('text/html') && !reply.hasHeader('content-security-policy')) {
|
|
100
101
|
// No inline scripts and no inline styles: behaviour lives in /static/app.js,
|
|
101
102
|
// styling in /static/app.css. The only <style> a page has (theme colours,
|
|
102
103
|
// chart geometry) carries this response's nonce; style="" attributes are refused.
|