pgkiln 0.83.0 → 0.85.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/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +3 -2
- package/docs/guide/03-builder.md +16 -5
- package/docs/guide/04-pages-and-regions.md +42 -0
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +1 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +13 -1
- package/package.json +1 -1
- package/public/app.css +29 -0
- package/public/app.js +78 -0
- package/public/sw.js +5 -1
- package/src/app.ts +11 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +10 -6
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3461 -0
- package/src/builder/i18n/en.ts +3461 -0
- package/src/builder/i18n/es.ts +3461 -0
- package/src/builder/i18n/fr.ts +3461 -0
- package/src/builder/i18n/it.ts +3461 -0
- package/src/builder/i18n/ja.ts +3461 -0
- package/src/builder/i18n/ko.ts +3461 -0
- package/src/builder/i18n/nl.ts +3461 -0
- package/src/builder/i18n/pt.ts +3461 -0
- package/src/builder/i18n/zh-cn.ts +3461 -0
- package/src/builder/i18n/zh-tw.ts +3461 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +30 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +12 -3
- package/src/runtime/routes.ts +12 -4
- package/src/runtime/signin.ts +2 -1
- package/src/session.ts +13 -4
package/src/builder/newapp.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { BASE_STYLES, NEW_APP_BASE } from '../runtime/styles.ts';
|
|
|
7
7
|
import { LANGUAGE_NAMES } from '../i18n.ts';
|
|
8
8
|
import { isIcon } from '../icons.ts';
|
|
9
9
|
import { secretKeyConfigured } from '../secrets.ts';
|
|
10
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
10
11
|
|
|
11
12
|
/** (097) The icon of a new application's home page when the developer chose none. */
|
|
12
13
|
export const DEFAULT_APP_ICON = 'layers';
|
|
@@ -64,6 +65,8 @@ export const FEATURES: [keyof Features, string, string, boolean][] = [
|
|
|
64
65
|
['pwa', 'Install as an app (PWA)', 'Installable on phones and desktops, with offline pages.', false],
|
|
65
66
|
['push', 'Push notifications', 'Notifications to the users\' devices (with Install as an app).', false],
|
|
66
67
|
];
|
|
68
|
+
/** A feature's label and help in the builder language (FEATURES stays English). */
|
|
69
|
+
export const featureText = (k: string, label: string, help: string): [string, string] => [tx.dyn(`nwa.f.${k}`, label), tx.dyn(`nwa.fh.${k}`, help)];
|
|
67
70
|
/** The features ticked on a form (a form without them: the defaults). */
|
|
68
71
|
export const featuresFrom = (b: Record<string, unknown>, posted: boolean): Features =>
|
|
69
72
|
Object.fromEntries(FEATURES.map(([k, , , on]) => [k, posted ? b[`f_${k}`] === 'true' : on])) as unknown as Features;
|
|
@@ -122,7 +125,7 @@ async function applyFeatures(c: Client, appId: number, f: Partial<Features>, not
|
|
|
122
125
|
if (f.pwa || f.push) await c.query('update meta.app set pwa = true where id = $1', [appId]);
|
|
123
126
|
if (f.push) {
|
|
124
127
|
if (secretKeyConfigured()) await c.query('update meta.app set pwa_push = true where id = $1', [appId]);
|
|
125
|
-
else notes.push('
|
|
128
|
+
else notes.push(tx('nwa.push_off'));
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
131
|
|
|
@@ -214,19 +217,19 @@ export const reservedSchema = (schema: string) => /^pg_/i.test(schema) || ['meta
|
|
|
214
217
|
|
|
215
218
|
/** Validate the form (outside the transaction: the password check may look up the account). Throws an Error with a message for the developer. */
|
|
216
219
|
export async function checkNewApp(b: NewAppInput, workspaceId: number): Promise<CheckedApp> {
|
|
217
|
-
if (!(workspaceId > 0)) throw new Error('
|
|
220
|
+
if (!(workspaceId > 0)) throw new Error(tx('nwa.no_workspace'));
|
|
218
221
|
const alias = (b.alias ?? '').trim().toLowerCase();
|
|
219
|
-
if (!/^[a-z][a-z0-9_-]*$/.test(alias) || alias.length > 50) throw new Error('
|
|
222
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(alias) || alias.length > 50) throw new Error(tx('nwa.bad_alias'));
|
|
220
223
|
const name = (b.name ?? '').trim();
|
|
221
|
-
if (!name) throw new Error('
|
|
224
|
+
if (!name) throw new Error(tx('nwa.enter_name'));
|
|
222
225
|
const schema = (b.schema ?? '').trim() || alias.replace(/-/g, '_');
|
|
223
|
-
if (reservedSchema(schema) || schema.length > 63) throw new Error(
|
|
226
|
+
if (reservedSchema(schema) || schema.length > 63) throw new Error(tx('nwa.bad_schema', { schema }));
|
|
224
227
|
const authentication = (b.authentication ?? '').trim() || 'app_users';
|
|
225
228
|
let adminUser: string | null = null;
|
|
226
229
|
let adminPassword: string | null = null;
|
|
227
230
|
if (authentication !== 'none') {
|
|
228
231
|
adminUser = (b.admin_user ?? '').trim();
|
|
229
|
-
if (!adminUser) throw new Error('
|
|
232
|
+
if (!adminUser) throw new Error(tx('nwa.first_user'));
|
|
230
233
|
const known = await owner.one('select 1 from meta.account where lower(username) = lower($1)', [adminUser]);
|
|
231
234
|
const problem = known ? null : await passwordProblem(b.admin_password);
|
|
232
235
|
if (problem) throw new Error(problem);
|
|
@@ -234,11 +237,11 @@ export async function checkNewApp(b: NewAppInput, workspaceId: number): Promise<
|
|
|
234
237
|
}
|
|
235
238
|
const description = (b.description ?? '').trim().slice(0, 300) || null;
|
|
236
239
|
const appIcon = (b.icon ?? '').trim();
|
|
237
|
-
if (appIcon && !isIcon(appIcon)) throw new Error('
|
|
240
|
+
if (appIcon && !isIcon(appIcon)) throw new Error(tx('nwa.choose_icon'));
|
|
238
241
|
const base = b.base === 'lavender' || b.base === 'standard' ? b.base : 'iris';
|
|
239
242
|
const accent = /^#[0-9a-f]{6}$/i.test(b.accent ?? '') && b.accent!.toLowerCase() !== BASE_STYLES[base].accent ? b.accent!.toLowerCase() : null;
|
|
240
243
|
const language = (b.language ?? '').trim() || 'en';
|
|
241
|
-
if (!(language in LANGUAGE_NAMES)) throw new Error('
|
|
244
|
+
if (!(language in LANGUAGE_NAMES)) throw new Error(tx('nwa.choose_language'));
|
|
242
245
|
return {
|
|
243
246
|
name, alias, schema, role: `app_${alias.replace(/-/g, '_')}`, authentication, adminUser, adminPassword, workspaceId, description, icon: appIcon || null,
|
|
244
247
|
base, topNav: b.nav === 'top', accent, language,
|
|
@@ -257,13 +260,13 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
|
|
|
257
260
|
if (isolated) {
|
|
258
261
|
// (a taken alias fails below, with its own message)
|
|
259
262
|
const aliasTaken = (await c.query('select 1 from meta.app where alias = $1', [a.alias])).rowCount;
|
|
260
|
-
if (roleExists && !aliasTaken) throw new Error(
|
|
263
|
+
if (roleExists && !aliasTaken) throw new Error(tx('nwa.role_exists', { role: a.role }));
|
|
261
264
|
const foreign = await c.query(
|
|
262
265
|
`select 1 from pg_namespace n where n.nspname = $1
|
|
263
266
|
and n.nspowner::regrole::text not in (select role_name from meta.workspace_role where workspace_id = $2)`,
|
|
264
267
|
[a.schema, a.workspaceId],
|
|
265
268
|
);
|
|
266
|
-
if (foreign.rowCount) throw new Error(
|
|
269
|
+
if (foreign.rowCount) throw new Error(tx('nwa.schema_foreign', { schema: a.schema }));
|
|
267
270
|
}
|
|
268
271
|
if (!roleExists) await c.query(`create role ${R} nologin`);
|
|
269
272
|
await c.query(`create schema if not exists ${S}${isolated ? ` authorization ${R}` : ''}`);
|
|
@@ -318,7 +321,7 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
|
|
|
318
321
|
*/
|
|
319
322
|
export async function startFromBoilerplate(c: Client, appId: number, a: CheckedApp, boilerplateId: number) {
|
|
320
323
|
const bp = (await c.query<{ alias: string }>(`select alias from meta.app where id = $1 and app_type = 'boilerplate'`, [boilerplateId])).rows[0];
|
|
321
|
-
if (!bp) throw new Error('
|
|
324
|
+
if (!bp) throw new Error(tx('nwa.choose_boilerplate'));
|
|
322
325
|
const doc = (await c.query('select meta.export_app($1) as d', [bp.alias])).rows[0].d;
|
|
323
326
|
await replaceApp(c, doc, a.alias, { backup: null });
|
|
324
327
|
await c.query(`update meta.app set name = $2, db_role = $3, authentication = $4, app_type = 'standard', api_role = null, updated_at = now() where id = $1`, [
|
|
@@ -329,6 +332,6 @@ export async function startFromBoilerplate(c: Client, appId: number, a: CheckedA
|
|
|
329
332
|
/** A friendlier message for the errors creating an application can raise. */
|
|
330
333
|
export function createAppError(e: unknown, alias: string) {
|
|
331
334
|
const err = e as { code?: string; constraint?: string; message: string };
|
|
332
|
-
if (err.code === '23505' && /alias/.test(err.constraint ?? err.message)) return
|
|
335
|
+
if (err.code === '23505' && /alias/.test(err.constraint ?? err.message)) return tx('nwa.alias_taken', { alias });
|
|
333
336
|
return err.message;
|
|
334
337
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { owner } from '../db.ts';
|
|
2
|
+
import { html, raw } from '../html.ts';
|
|
3
|
+
import type { BuilderTranslate } from './i18n.ts';
|
|
4
|
+
import { input } from './ui.ts';
|
|
5
|
+
|
|
6
|
+
// (sprint 165, migration 154) Settings → User Interface → Notifications: the notification bell
|
|
7
|
+
// (meta.app.notifications) and live badges (meta.app.live_badges_s, 10 to 3600 seconds or none).
|
|
8
|
+
|
|
9
|
+
/** The fields, in the User Interface tab of the application's settings. */
|
|
10
|
+
export function notificationSettings(a: { notifications?: boolean | null; live_badges_s?: number | null }, tx: BuilderTranslate) {
|
|
11
|
+
return html`<h3 id="notifications">${tx('nfy.heading')}</h3>
|
|
12
|
+
<input type="hidden" name="notification_settings" value="1">
|
|
13
|
+
<div class="field"><label class="check"><input type="checkbox" name="notifications" value="true"${a.notifications ? raw(' checked') : ''}> ${tx('nfy.bell')}</label>
|
|
14
|
+
<small class="help">${tx.code('nfy.bell_help')}</small></div>
|
|
15
|
+
<div class="form-grid">
|
|
16
|
+
${input('live_badges_s', tx('nfy.live'), a.live_badges_s ?? '', { type: 'number', placeholder: tx('nfy.live_ph'), help: tx('nfy.live_help') })}
|
|
17
|
+
</div>`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Save them (only when the form had them); a number outside 10 to 3600 is an error. */
|
|
21
|
+
export async function saveNotificationSettings(appId: string, b: Record<string, string | undefined>, tx: BuilderTranslate) {
|
|
22
|
+
if (b.notification_settings !== '1') return;
|
|
23
|
+
const raw = (b.live_badges_s ?? '').trim();
|
|
24
|
+
const seconds = raw === '' ? null : Number(raw);
|
|
25
|
+
if (seconds !== null && !(Number.isInteger(seconds) && seconds >= 10 && seconds <= 3600)) throw new Error(tx('nfy.live_invalid'));
|
|
26
|
+
await owner.query('update meta.app set notifications = $2, live_badges_s = $3 where id = $1', [appId, b.notifications === 'true', seconds]);
|
|
27
|
+
}
|
package/src/builder/plugins.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { clearCompletions } from './code-editor.ts';
|
|
|
9
9
|
import { appOr404 } from './forms.ts';
|
|
10
10
|
import { resultsHtml, runScripts, type RunResult } from './supporting.ts';
|
|
11
11
|
import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Body, type Req } from './ui.ts';
|
|
12
|
+
import { scopedTx as tx, type BuilderKey } from './i18n.ts';
|
|
12
13
|
|
|
13
14
|
// Shared Components → Plug-ins (migration 069; APEX: plug-ins): import a
|
|
14
15
|
// plug-in file (format pgkiln-plugin/2), look at what it brings, run its
|
|
@@ -20,25 +21,30 @@ import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, typ
|
|
|
20
21
|
|
|
21
22
|
const MAX_FILE_MB = 10;
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
+
const TYPE_KEY: Record<string, BuilderKey> = { region: 'plg.type.region', item: 'plg.type.item', dynamic_action: 'plg.type.dynamic_action', process: 'plg.type.process' };
|
|
25
|
+
const HEADING_KEY: Record<string, BuilderKey> = { region: 'plg.heading.region', item: 'plg.heading.item', dynamic_action: 'plg.heading.dynamic_action', process: 'plg.heading.process' };
|
|
26
|
+
const USE_KEY = { region: 'plg.use.region', item: 'plg.use.item', process: 'plg.use.process', dynamic_action: 'plg.use.dynamic_action' } as const satisfies Record<string, BuilderKey>;
|
|
27
|
+
/** How to use a plug-in of a type in a component: a sentence with `code`. */
|
|
28
|
+
const HOW_KEY: Record<string, BuilderKey> = { region: 'plg.how.region', item: 'plg.how.item', dynamic_action: 'plg.how.dynamic_action', process: 'plg.how.process' };
|
|
24
29
|
|
|
25
30
|
/** Where an application uses each plug-in: "page 3: region Ratings", … */
|
|
26
31
|
async function usages(appId: number) {
|
|
27
|
-
const rows = (await owner.query<{ plugin: string;
|
|
28
|
-
`select r.config->>'plugin' as plugin, '
|
|
32
|
+
const rows = (await owner.query<{ plugin: string; kind: 'region' | 'item' | 'process' | 'dynamic_action'; page_no: number; name: string }>(
|
|
33
|
+
`select r.config->>'plugin' as plugin, 'region' as kind, p.page_no, coalesce(r.title, r.id::text) as name,
|
|
34
|
+
'page ' || p.page_no || ': region ' || coalesce(r.title, r.id::text) as sort
|
|
29
35
|
from meta.region r join meta.page p on p.id = r.page_id where p.app_id = $1 and r.type = 'plugin'
|
|
30
36
|
union all
|
|
31
|
-
select i.config->>'plugin', 'page ' || p.page_no || ': item ' || i.name
|
|
37
|
+
select i.config->>'plugin', 'item', p.page_no, i.name, 'page ' || p.page_no || ': item ' || i.name
|
|
32
38
|
from meta.item i join meta.page p on p.id = i.page_id where p.app_id = $1 and i.type = 'plugin'
|
|
33
39
|
union all
|
|
34
|
-
select x.config->>'plugin', 'page ' || p.page_no || ': process ' || x.name
|
|
40
|
+
select x.config->>'plugin', 'process', p.page_no, x.name, 'page ' || p.page_no || ': process ' || x.name
|
|
35
41
|
from meta.process x join meta.page p on p.id = x.page_id where p.app_id = $1 and x.type = 'plugin'
|
|
36
42
|
union all
|
|
37
|
-
select trim(d.code), 'page ' || p.page_no || ': dynamic action ' || d.name
|
|
43
|
+
select trim(d.code), 'dynamic_action', p.page_no, d.name, 'page ' || p.page_no || ': dynamic action ' || d.name
|
|
38
44
|
from meta.dynamic_action d join meta.page p on p.id = d.page_id where p.app_id = $1 and d.action = 'plugin'
|
|
39
|
-
order by
|
|
45
|
+
order by 5`, [appId])).rows;
|
|
40
46
|
const out = new Map<string, string[]>();
|
|
41
|
-
for (const r of rows) out.set(r.plugin, [...(out.get(r.plugin) ?? []), r.
|
|
47
|
+
for (const r of rows) out.set(r.plugin, [...(out.get(r.plugin) ?? []), tx(USE_KEY[r.kind], { page: r.page_no, name: r.name })]);
|
|
42
48
|
return out;
|
|
43
49
|
}
|
|
44
50
|
|
|
@@ -55,55 +61,52 @@ async function page(s: Session, a: any, selected: string | undefined, result?: {
|
|
|
55
61
|
: [];
|
|
56
62
|
|
|
57
63
|
const detail = p
|
|
58
|
-
? region(
|
|
64
|
+
? region(tx(HEADING_KEY[p.type], { label: p.label }), html`
|
|
59
65
|
<dl class="props">
|
|
60
|
-
<dt
|
|
61
|
-
${p.help ? html`<dt
|
|
62
|
-
<dt
|
|
63
|
-
<dt
|
|
64
|
-
${p.template_component ? html`<dt
|
|
65
|
-
${p.sql_function ? html`<dt
|
|
66
|
+
<dt>${tx('plg.name')}</dt><dd><code>${p.name}</code>${p.version ? html` · ${tx('plg.version_n', { version: p.version })}` : ''}</dd>
|
|
67
|
+
${p.help ? html`<dt>${tx('plg.help')}</dt><dd>${p.help}</dd>` : ''}
|
|
68
|
+
<dt>${tx('plg.used_by')}</dt><dd>${(used.get(p.name) ?? []).join('; ') || html`<span class="muted">${tx('plg.nothing_yet')}</span>`}</dd>
|
|
69
|
+
<dt>${tx('plg.files')}</dt><dd>${p.files.length ? p.files.map((f, i) => html`${i ? ', ' : ''}<a href="${BASE}/apps/${a.id}/static-files?edit=${encodeURIComponent(f)}">${f}</a>${missingFiles.includes(f) ? html` <span class="badge badge-warning">${tx('plg.missing')}</span>` : ''}`) : html`<span class="muted">${tx('plg.none')}</span>`}</dd>
|
|
70
|
+
${p.template_component ? html`<dt>${tx('plg.template')}</dt><dd>${tc ? html`<a href="${BASE}/apps/${a.id}/shared?c=template_component-${tc.id}">${p.template_component}</a>` : html`${p.template_component} <span class="badge badge-warning">${tx('plg.missing')}</span>`}</dd>` : ''}
|
|
71
|
+
${p.sql_function ? html`<dt>${tx('plg.function')}</dt><dd><code>${p.sql_function}(attributes jsonb) returns text</code></dd>` : ''}
|
|
66
72
|
</dl>
|
|
67
73
|
${p.attributes.length
|
|
68
|
-
? html`<div class="table-wrap"><table class="report"><caption
|
|
74
|
+
? html`<div class="table-wrap"><table class="report"><caption>${tx('plg.attributes')}</caption><thead><tr><th scope="col">${tx('plg.name')}</th><th scope="col">${tx('plg.label')}</th><th scope="col">${tx('plg.type')}</th><th scope="col">${tx('plg.default')}</th></tr></thead>
|
|
69
75
|
<tbody>${p.attributes.map((x) => html`<tr><td><code>${x.name}</code></td><td>${x.label ?? ''}</td><td>${x.type ?? 'text'}${x.options ? `: ${x.options.join(', ')}` : ''}</td><td>${x.default ?? ''}</td></tr>`)}</tbody></table></div>`
|
|
70
76
|
: ''}
|
|
71
|
-
<p class="muted"
|
|
72
|
-
? html`a dynamic action with action <code>plugin</code>, Code <code>${p.name}</code> and Plug-in attributes <code>{"attributes": {…}}</code>`
|
|
73
|
-
: html`a ${p.type} of type <code>plugin</code> with configuration <code>{"plugin": "${p.name}", "attributes": {…}}</code>`}.</p>
|
|
77
|
+
<p class="muted">${tx.code(HOW_KEY[p.type], { name: p.name })}</p>
|
|
74
78
|
${p.install_sql
|
|
75
|
-
? html`<h3
|
|
79
|
+
? html`<h3>${tx('plg.install_sql')}</h3><p class="muted">${tx.rich('plg.install_help', { role: html`<code>${a.db_role ?? tx('plg.role_none')}</code>` })}</p>
|
|
76
80
|
<pre class="source">${p.install_sql}</pre>
|
|
77
81
|
<form method="post" action="${base}/install" class="u-mt1">${csrf(s)}<input type="hidden" name="name" value="${p.name}">
|
|
78
|
-
<button class="btn" data-confirm="
|
|
82
|
+
<button class="btn" data-confirm="${a.db_role ? tx('plg.confirm_install', { label: p.label, role: a.db_role }) : tx('plg.confirm_install_runtime', { label: p.label })}">${icon('play')} ${tx('plg.run_install')}</button></form>`
|
|
79
83
|
: ''}
|
|
80
84
|
<div class="buttons u-mt1">
|
|
81
|
-
<a class="btn" href="${base}/download?name=${encodeURIComponent(p.name)}">${icon('download')}
|
|
85
|
+
<a class="btn" href="${base}/download?name=${encodeURIComponent(p.name)}">${icon('download')} ${tx('plg.download')}</a>
|
|
82
86
|
<form method="post" action="${base}/delete" class="inline">${csrf(s)}<input type="hidden" name="name" value="${p.name}">
|
|
83
|
-
<button class="btn btn-danger" data-confirm="
|
|
87
|
+
<button class="btn btn-danger" data-confirm="${tx('plg.confirm_remove', { label: p.label })}">${icon('trash')} ${tx('plg.remove')}</button></form>
|
|
84
88
|
</div>`)
|
|
85
89
|
: '';
|
|
86
90
|
|
|
87
91
|
return html`${appHeader(a, 'shared', s)}
|
|
88
|
-
${result ? region(
|
|
92
|
+
${result ? region(tx('plg.result', { name: result.name }), resultsHtml('install', result.run)) : ''}
|
|
89
93
|
${detail}
|
|
90
|
-
${region('
|
|
91
|
-
|
|
92
|
-
A plug-in's code runs with this application's rights: import only plug-ins you trust and read their files.</p>
|
|
94
|
+
${region(tx('plg.title'), html`<p class="muted u-mt0">${tx.rich('plg.intro', { files: html`<a href="${BASE}/apps/${a.id}/static-files">${tx('plg.static_files')}</a>`, register: html`<code>pgkiln.plugins.register</code>` })}
|
|
95
|
+
${tx('plg.intro_trust')}</p>
|
|
93
96
|
${list.length
|
|
94
|
-
? html`<div class="table-wrap"><table class="report"><caption class="sr-only"
|
|
95
|
-
<thead><tr><th scope="col"
|
|
97
|
+
? html`<div class="table-wrap"><table class="report"><caption class="sr-only">${tx('plg.title')}</caption>
|
|
98
|
+
<thead><tr><th scope="col">${tx('plg.plugin')}</th><th scope="col">${tx('plg.type')}</th><th scope="col">${tx('plg.version')}</th><th scope="col">${tx('plg.used_by')}</th></tr></thead>
|
|
96
99
|
<tbody>${list.map((x) => html`<tr><td><a href="${base}?p=${x.name}"${x.name === selected ? raw(' aria-current="page"') : ''}>${x.label}</a> <code class="muted">${x.name}</code></td>
|
|
97
|
-
<td>${
|
|
98
|
-
: html`<p class="muted"
|
|
99
|
-
${region('
|
|
100
|
-
<div class="field"><label class="label" for="f_plugin_file"
|
|
100
|
+
<td>${tx(TYPE_KEY[x.type])}</td><td>${x.version ?? ''}</td><td>${(used.get(x.name) ?? []).length || ''}</td></tr>`)}</tbody></table></div>`
|
|
101
|
+
: html`<p class="muted">${tx('plg.no_plugins')}</p>`}`)}
|
|
102
|
+
${region(tx('plg.import_title'), html`<form method="post" action="${base}/import" enctype="multipart/form-data">${csrf(s)}
|
|
103
|
+
<div class="field"><label class="label" for="f_plugin_file">${tx.code('plg.file_label')}</label>
|
|
101
104
|
<input type="file" id="f_plugin_file" name="file" accept=".json,application/json"></div>
|
|
102
|
-
<div class="field" data-wide><label class="label" for="f_plugin_json"
|
|
105
|
+
<div class="field" data-wide><label class="label" for="f_plugin_json">${tx('plg.or_paste')}</label>
|
|
103
106
|
<textarea id="f_plugin_json" name="plugin" class="code" rows="6" spellcheck="false" placeholder='{"format": "pgkiln-plugin/2", "type": "region", "name": …}'></textarea></div>
|
|
104
|
-
<div class="field"><label class="check"><input type="checkbox" name="replace" value="true">
|
|
105
|
-
<div class="buttons"><button class="btn btn-hot">${icon('upload')}
|
|
106
|
-
<small class="help"
|
|
107
|
+
<div class="field"><label class="check"><input type="checkbox" name="replace" value="true"> ${tx('plg.replace')}</label></div>
|
|
108
|
+
<div class="buttons"><button class="btn btn-hot">${icon('upload')} ${tx('plg.import')}</button></div>
|
|
109
|
+
<small class="help">${tx.code('plg.import_help')}</small>
|
|
107
110
|
</form>`)}`;
|
|
108
111
|
}
|
|
109
112
|
|
|
@@ -132,7 +135,7 @@ export async function exportPlugin(appId: number, name: string) {
|
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
export async function pluginRoutes(app: FastifyInstance) {
|
|
135
|
-
const crumbs = (a: any) => [['
|
|
138
|
+
const crumbs = (a: any) => [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.shared'), `${BASE}/apps/${a.id}/shared`], [tx('plg.title')]] as [string, string?][];
|
|
136
139
|
const find = async (id: string) => (/^\d+$/.test(id) ? appOr404(id) : undefined);
|
|
137
140
|
|
|
138
141
|
app.get(`${BASE}/apps/:id/plugins`, async (req: Req, reply) => {
|
|
@@ -140,7 +143,7 @@ export async function pluginRoutes(app: FastifyInstance) {
|
|
|
140
143
|
if (!s) return;
|
|
141
144
|
const a = await find(req.params.id);
|
|
142
145
|
if (!a) return reply.code(404).send('Not found');
|
|
143
|
-
return send(reply, s, shell(s, '
|
|
146
|
+
return send(reply, s, shell(s, tx('plg.title'), crumbs(a), await page(s, a, req.query?.p)));
|
|
144
147
|
});
|
|
145
148
|
|
|
146
149
|
app.post(`${BASE}/apps/:id/plugins/import`, async (req: Req, reply) => {
|
|
@@ -161,16 +164,16 @@ export async function pluginRoutes(app: FastifyInstance) {
|
|
|
161
164
|
try {
|
|
162
165
|
doc = JSON.parse(text);
|
|
163
166
|
} catch {
|
|
164
|
-
flash(s, text ? '
|
|
167
|
+
flash(s, text ? tx('plg.not_json') : tx('plg.choose_file'), 'error');
|
|
165
168
|
return back(reply, s, base);
|
|
166
169
|
}
|
|
167
170
|
try {
|
|
168
171
|
const p = await installPlugin(a.id, doc, req.body?.replace === 'true');
|
|
169
172
|
await logActivity({ appId: a.id, username: s.username, event: 'plugin', ip: clientIp(req), detail: `builder: imported plug-in ${p.name}` });
|
|
170
|
-
flash(s,
|
|
173
|
+
flash(s, tx(p.install_sql ? 'plg.imported_install' : 'plg.imported', { label: p.label }));
|
|
171
174
|
return back(reply, s, `${base}?p=${p.name}`);
|
|
172
175
|
} catch (e) {
|
|
173
|
-
flash(s,
|
|
176
|
+
flash(s, tx('plg.import_failed', { message: (e as Error).message }), 'error');
|
|
174
177
|
return back(reply, s, base);
|
|
175
178
|
}
|
|
176
179
|
});
|
|
@@ -193,7 +196,7 @@ export async function pluginRoutes(app: FastifyInstance) {
|
|
|
193
196
|
const a = await find(req.params.id);
|
|
194
197
|
if (!a) return reply.code(404).send('Not found');
|
|
195
198
|
const r = await owner.query('delete from meta.plugin where app_id = $1 and name = $2', [a.id, String(req.body?.name ?? '')]);
|
|
196
|
-
flash(s, r.rowCount ? '
|
|
199
|
+
flash(s, r.rowCount ? tx('plg.removed') : tx('plg.no_such'), r.rowCount ? 'ok' : 'error');
|
|
197
200
|
return back(reply, s, `${BASE}/apps/${a.id}/plugins`);
|
|
198
201
|
});
|
|
199
202
|
|
|
@@ -207,6 +210,6 @@ export async function pluginRoutes(app: FastifyInstance) {
|
|
|
207
210
|
const run = await runScripts(a, [{ name: p.name, script: p.install_sql }], s.username!);
|
|
208
211
|
clearCompletions();
|
|
209
212
|
await logActivity({ appId: a.id, username: s.username, event: 'plugin', ip: clientIp(req), detail: `builder: install SQL of ${p.name} ${run.ok ? 'ran' : 'failed'} (${run.results.length} statement(s))` });
|
|
210
|
-
return send(reply, s, shell(s, '
|
|
213
|
+
return send(reply, s, shell(s, tx('plg.title'), crumbs(a), await page(s, a, p.name, { name: p.label, run })));
|
|
211
214
|
});
|
|
212
215
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { owner } from '../db.ts';
|
|
2
2
|
import { html } from '../html.ts';
|
|
3
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
3
4
|
|
|
4
5
|
// Page designer → a background chain process → "Jobs": the last runs of the
|
|
5
6
|
// chain for every user (meta.process_job; process-jobs.ts runs them).
|
|
@@ -14,17 +15,17 @@ export async function processJobsPanel(processId: number) {
|
|
|
14
15
|
[processId],
|
|
15
16
|
)
|
|
16
17
|
).rows;
|
|
17
|
-
const off = process.env.BACKGROUND_PROCESSES === 'off' ? html`<p><b
|
|
18
|
+
const off = process.env.BACKGROUND_PROCESSES === 'off' ? html`<p><b>${tx('pjb.off')}</b></p>` : '';
|
|
18
19
|
return html`${off}${jobs.length
|
|
19
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
20
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('pjb.job')}</th><th>${tx('pjb.state')}</th><th>${tx('pjb.by')}</th><th>${tx('pjb.queued')}</th><th>${tx('pjb.took')}</th><th>${tx('pjb.steps')}</th><th>${tx('pjb.message')}</th></tr></thead><tbody>
|
|
20
21
|
${jobs.map((j) => html`<tr>
|
|
21
|
-
<td data-label="
|
|
22
|
-
<td data-label="
|
|
23
|
-
<td data-label="
|
|
24
|
-
<td data-label="
|
|
25
|
-
<td data-label="
|
|
26
|
-
<td data-label="
|
|
27
|
-
<td data-label="
|
|
22
|
+
<td data-label="${tx('pjb.job')}">${j.id}</td>
|
|
23
|
+
<td data-label="${tx('pjb.state')}"><span class="tag${j.state === 'failed' ? ' tag-error' : ''}">${j.state}</span></td>
|
|
24
|
+
<td data-label="${tx('pjb.by')}">${j.app_user}</td>
|
|
25
|
+
<td data-label="${tx('pjb.queued')}">${when(j.queued_at)}</td>
|
|
26
|
+
<td data-label="${tx('pjb.took')}">${j.started_at && j.ended_at ? `${((new Date(j.ended_at).getTime() - new Date(j.started_at).getTime()) / 1000).toFixed(1)} s` : j.started_at ? '…' : ''}</td>
|
|
27
|
+
<td data-label="${tx('pjb.steps')}">${j.steps_done}/${j.steps_total}${j.current ? ` (${j.current})` : ''}</td>
|
|
28
|
+
<td data-label="${tx('pjb.message')}">${j.error ?? j.message ?? ''}</td></tr>`)}
|
|
28
29
|
</tbody></table></div>`
|
|
29
|
-
: html`<p class="muted"
|
|
30
|
+
: html`<p class="muted">${tx('pjb.no_runs')}</p>`}`;
|
|
30
31
|
}
|
package/src/builder/pwa.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { html, raw } from '../html.ts';
|
|
|
6
6
|
import { readMultipart } from '../runtime/files.ts';
|
|
7
7
|
import { back, BASE, csrf, developer, flash, input, type Body, type Req } from './ui.ts';
|
|
8
8
|
import type { Session } from '../session.ts';
|
|
9
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
9
10
|
|
|
10
11
|
// App → Settings → Progressive Web App: installable, offline pages, the
|
|
11
12
|
// offline form queue, the icon (src/runtime/pwa.ts serves the rest), and
|
|
@@ -36,29 +37,29 @@ export async function pwaSection(a: { id: number; alias: string; pwa: boolean; p
|
|
|
36
37
|
html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
|
|
37
38
|
const stats = a.pwa && a.pwa_push ? await pushStats(a.id) : null;
|
|
38
39
|
return html`<form method="post" action="${BASE}/apps/${a.id}/pwa" enctype="multipart/form-data">${csrf(s)}
|
|
39
|
-
${check('pwa', '
|
|
40
|
+
${check('pwa', tx('pwa.installable'), a.pwa, tx('pwa.installable_help'))}
|
|
40
41
|
<div class="form-grid">
|
|
41
|
-
${input('pwa_short_name', '
|
|
42
|
+
${input('pwa_short_name', tx('pwa.short_name'), a.pwa_short_name ?? '', { placeholder: tx('pwa.short_name_ph') })}
|
|
42
43
|
</div>
|
|
43
|
-
${check('pwa_offline_pages', '
|
|
44
|
-
${check('pwa_offline_submit', '
|
|
45
|
-
${check('pwa_push', '
|
|
46
|
-
<div class="field" data-wide><label class="label" for="f_pwa_icon"
|
|
47
|
-
<div class="u-row">${a.has_icon || a.pwa ? html`<img class="pwa-icon-preview" src="/a/${a.alias}/icon-192.png" alt="
|
|
44
|
+
${check('pwa_offline_pages', tx('pwa.offline_pages'), a.pwa_offline_pages, tx('pwa.offline_pages_help'))}
|
|
45
|
+
${check('pwa_offline_submit', tx('pwa.offline_submit'), a.pwa_offline_submit, tx('pwa.offline_submit_help'))}
|
|
46
|
+
${check('pwa_push', tx('pwa.push'), a.pwa_push, tx('pwa.push_help'))}
|
|
47
|
+
<div class="field" data-wide><label class="label" for="f_pwa_icon">${tx('pwa.icon')}</label>
|
|
48
|
+
<div class="u-row">${a.has_icon || a.pwa ? html`<img class="pwa-icon-preview" src="/a/${a.alias}/icon-192.png" alt="${tx('pwa.icon_alt')}" width="48" height="48">` : ''}
|
|
48
49
|
<input type="file" id="f_pwa_icon" name="icon" accept="image/png"></div>
|
|
49
|
-
<small class="help">${a.has_icon ? html
|
|
50
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
50
|
+
<small class="help">${a.has_icon ? html`${tx('pwa.icon_stored')} <label class="check u-inline"><input type="checkbox" name="remove_icon" value="true"> ${tx('pwa.remove_icon')}</label>` : tx('pwa.no_icon')}</small></div>
|
|
51
|
+
<div class="buttons"><button class="btn btn-hot">${tx('common.save')}</button></div>
|
|
51
52
|
</form>
|
|
52
53
|
${stats
|
|
53
|
-
? html`<h3
|
|
54
|
-
<p class="muted">${stats.devices
|
|
54
|
+
? html`<h3>${tx('pwa.push')}</h3>
|
|
55
|
+
<p class="muted">${tx('pwa.stats_devices', { devices: stats.devices, users: stats.users })} ${tx(stats.waiting ? 'pwa.stats_week_waiting' : 'pwa.stats_week', { sent: stats.sent, no_device: stats.no_device, failed: stats.failed, waiting: stats.waiting })}</p>
|
|
55
56
|
<form method="post" action="${BASE}/apps/${a.id}/pwa/push-test" class="u-row">${csrf(s)}
|
|
56
|
-
${input('push_user', '
|
|
57
|
-
<div class="buttons"><button class="btn"
|
|
57
|
+
${input('push_user', tx('pwa.test_to'), s.username ?? '', { placeholder: tx('pwa.user_name') })}
|
|
58
|
+
<div class="buttons"><button class="btn">${tx('pwa.send_test')}</button></div>
|
|
58
59
|
</form>
|
|
59
60
|
<form method="post" action="${BASE}/apps/${a.id}/pwa/push-keys">${csrf(s)}
|
|
60
|
-
<div class="buttons"><button class="btn btn-danger"
|
|
61
|
-
<small class="help"
|
|
61
|
+
<div class="buttons"><button class="btn btn-danger">${tx('pwa.new_keys')}</button></div>
|
|
62
|
+
<small class="help">${tx('pwa.new_keys_help')}</small>
|
|
62
63
|
</form>`
|
|
63
64
|
: ''}`;
|
|
64
65
|
}
|
|
@@ -83,18 +84,18 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
|
|
|
83
84
|
if (file && file.data.length) {
|
|
84
85
|
const size = file.truncated ? null : pngSize(file.data);
|
|
85
86
|
if (!size) {
|
|
86
|
-
flash(s, file.truncated ?
|
|
87
|
+
flash(s, file.truncated ? tx('pwa.icon_too_large', { mb: ICON_MAX_MB }) : tx('pwa.icon_not_png'), 'error');
|
|
87
88
|
return back(reply, s, target);
|
|
88
89
|
}
|
|
89
90
|
if (size.width !== size.height || size.width < 512) {
|
|
90
|
-
flash(s,
|
|
91
|
+
flash(s, tx('pwa.icon_size', { width: size.width, height: size.height }), 'error');
|
|
91
92
|
return back(reply, s, target);
|
|
92
93
|
}
|
|
93
94
|
icon = file.data;
|
|
94
95
|
}
|
|
95
96
|
const push = b.pwa_push === 'true';
|
|
96
97
|
if (push && !secretKeyConfigured()) {
|
|
97
|
-
flash(s, '
|
|
98
|
+
flash(s, tx('pwa.push_needs_key'), 'error');
|
|
98
99
|
return back(reply, s, target);
|
|
99
100
|
}
|
|
100
101
|
await owner.query(
|
|
@@ -103,7 +104,7 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
|
|
|
103
104
|
[id, b.pwa === 'true', (b.pwa_short_name ?? '').trim().slice(0, 30) || null, b.pwa_offline_pages === 'true', b.pwa_offline_submit === 'true', icon ?? null, icon !== undefined, push],
|
|
104
105
|
);
|
|
105
106
|
if (push) await appPublicKey(Number(id));
|
|
106
|
-
flash(s, push && b.pwa !== 'true' ? '
|
|
107
|
+
flash(s, push && b.pwa !== 'true' ? tx('pwa.saved_not_installable') : tx('pwa.saved'));
|
|
107
108
|
return back(reply, s, target);
|
|
108
109
|
});
|
|
109
110
|
|
|
@@ -126,7 +127,7 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
|
|
|
126
127
|
return back(reply, s, target);
|
|
127
128
|
}
|
|
128
129
|
const devices = (await owner.one<{ n: number }>('select count(*)::int as n from meta.push_subscription where app_id = $1 and lower(username) = lower($2)', [a.id, user]))!.n;
|
|
129
|
-
flash(s, devices ?
|
|
130
|
+
flash(s, devices ? tx('pwa.test_queued', { n: devices, user }) : tx('pwa.test_no_device', { user }), devices ? 'ok' : 'error');
|
|
130
131
|
return back(reply, s, target);
|
|
131
132
|
});
|
|
132
133
|
|
|
@@ -136,7 +137,7 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
|
|
|
136
137
|
const id = req.params.id;
|
|
137
138
|
if (!/^\d+$/.test(id) || !(await owner.one('select 1 from meta.app where id = $1', [id]))) return reply.code(404).send('Not found');
|
|
138
139
|
await newAppKeys(Number(id));
|
|
139
|
-
flash(s, '
|
|
140
|
+
flash(s, tx('pwa.keys_made'));
|
|
140
141
|
return back(reply, s, `${BASE}/apps/${id}/settings?tab=pwa`);
|
|
141
142
|
});
|
|
142
143
|
}
|