pgkiln 0.84.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/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/12-development.md +6 -1
- package/package.json +1 -1
- package/src/app.ts +9 -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/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 +3453 -0
- package/src/builder/i18n/en.ts +3453 -0
- package/src/builder/i18n/es.ts +3453 -0
- package/src/builder/i18n/fr.ts +3453 -0
- package/src/builder/i18n/it.ts +3453 -0
- package/src/builder/i18n/ja.ts +3453 -0
- package/src/builder/i18n/ko.ts +3453 -0
- package/src/builder/i18n/nl.ts +3453 -0
- package/src/builder/i18n/pt.ts +3453 -0
- package/src/builder/i18n/zh-cn.ts +3453 -0
- package/src/builder/i18n/zh-tw.ts +3453 -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/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 +27 -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/builder/appsheets.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { preview } from './dataload.ts';
|
|
|
22
22
|
import { finishGeneratedApp, createApp, createAppError, type CheckedApp } from './newapp.ts';
|
|
23
23
|
import { groupColumn } from './appfromfile.ts';
|
|
24
24
|
import { region, type Body } from './ui.ts';
|
|
25
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
25
26
|
|
|
26
27
|
// Create → From a file with several sheets or tables (APEX: Create App from a
|
|
27
28
|
// File, several sheets). An Excel workbook with several sheets, or a JSON
|
|
@@ -66,7 +67,7 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
|
|
|
66
67
|
try {
|
|
67
68
|
book = await readXlsxFile<string>(data, { parseNumber: (s) => s, trim: true });
|
|
68
69
|
} catch (e) {
|
|
69
|
-
throw new LoadError(
|
|
70
|
+
throw new LoadError(tx('ash.bad_xlsx', { error: (e as Error).message }));
|
|
70
71
|
}
|
|
71
72
|
const sheets: NamedSheet[] = [];
|
|
72
73
|
let total = 0;
|
|
@@ -77,16 +78,16 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
|
|
|
77
78
|
try {
|
|
78
79
|
sheet = tableSheet('xlsx', rows, headers);
|
|
79
80
|
} catch (e) {
|
|
80
|
-
if (e instanceof LoadError) throw new LoadError(
|
|
81
|
+
if (e instanceof LoadError) throw new LoadError(tx('ash.sheet_error', { sheet: s.sheet, error: e.message }));
|
|
81
82
|
throw e;
|
|
82
83
|
}
|
|
83
84
|
if (!sheet.rows.length) continue;
|
|
84
85
|
total += sheet.rows.length;
|
|
85
86
|
sheets.push({ ...sheet, name: s.sheet });
|
|
86
87
|
}
|
|
87
|
-
if (sheets.length > MAX_SHEETS) throw new LoadError(
|
|
88
|
-
if (total > MAX_ROWS) throw new LoadError(
|
|
89
|
-
if (!sheets.length) throw new LoadError('
|
|
88
|
+
if (sheets.length > MAX_SHEETS) throw new LoadError(tx('ash.too_many_sheets', { n: sheets.length, max: MAX_SHEETS }));
|
|
89
|
+
if (total > MAX_ROWS) throw new LoadError(tx('ash.too_many_rows_wb', { n: total, max: MAX_ROWS }));
|
|
90
|
+
if (!sheets.length) throw new LoadError(tx('ash.the_file_has_column_names'));
|
|
90
91
|
return sheets;
|
|
91
92
|
}
|
|
92
93
|
if (/\.json$/i.test(filename) || /^\s*\{/.test(data.subarray(0, 64).toString('utf8').replace(/^/, ''))) {
|
|
@@ -101,9 +102,9 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
|
|
|
101
102
|
([, v]) => Array.isArray(v) && v.length && v.every((r) => r && typeof r === 'object' && !Array.isArray(r)),
|
|
102
103
|
) as [string, unknown[]][];
|
|
103
104
|
if (arrays.length > 1) {
|
|
104
|
-
if (arrays.length > MAX_SHEETS) throw new LoadError(
|
|
105
|
+
if (arrays.length > MAX_SHEETS) throw new LoadError(tx('ash.too_many_arrays', { n: arrays.length, max: MAX_SHEETS }));
|
|
105
106
|
const total = arrays.reduce((n, [, v]) => n + v.length, 0);
|
|
106
|
-
if (total > MAX_ROWS) throw new LoadError(
|
|
107
|
+
if (total > MAX_ROWS) throw new LoadError(tx('ash.too_many_rows', { n: total, max: MAX_ROWS }));
|
|
107
108
|
return arrays.map(([name, v]) => ({ ...parseJson(JSON.stringify(v)), name }));
|
|
108
109
|
}
|
|
109
110
|
}
|
|
@@ -235,18 +236,18 @@ export function fkChecked(f: ForeignKey, b: Body | null) {
|
|
|
235
236
|
/** CREATE TABLE for a planned table: an integer key column becomes an identity primary key; another key is unique and not null next to a new identity id. */
|
|
236
237
|
export function plannedTableSql(schema: string, p: TablePlan) {
|
|
237
238
|
const valid = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
238
|
-
const where =
|
|
239
|
-
if (!valid.test(p.table)) throw new LoadError(
|
|
240
|
-
if (!p.cols.length) throw new LoadError(
|
|
239
|
+
const where = tx('ash.sheet', { name: p.sheet.name });
|
|
240
|
+
if (!valid.test(p.table)) throw new LoadError(tx('ash.bad_table_name', { where }));
|
|
241
|
+
if (!p.cols.length) throw new LoadError(tx('ash.no_columns', { where }));
|
|
241
242
|
for (const c of p.cols) {
|
|
242
|
-
if (!valid.test(c.name)) throw new LoadError(
|
|
243
|
-
if (!COLUMN_TYPES.includes(c.type)) throw new LoadError(
|
|
243
|
+
if (!valid.test(c.name)) throw new LoadError(tx('ash.bad_column', { where, col: c.name }));
|
|
244
|
+
if (!COLUMN_TYPES.includes(c.type)) throw new LoadError(tx('ash.bad_type', { where, type: c.type }));
|
|
244
245
|
}
|
|
245
|
-
if (new Set(p.cols.map((c) => c.name)).size !== p.cols.length) throw new LoadError(
|
|
246
|
+
if (new Set(p.cols.map((c) => c.name)).size !== p.cols.length) throw new LoadError(tx('ash.dup_columns', { where }));
|
|
246
247
|
const ident = pg.escapeIdentifier;
|
|
247
248
|
const defs: string[] = [];
|
|
248
249
|
if (!identityKey(p)) {
|
|
249
|
-
if (p.cols.some((c) => c.name === 'id')) throw new LoadError(
|
|
250
|
+
if (p.cols.some((c) => c.name === 'id')) throw new LoadError(tx('ash.id_taken', { where }));
|
|
250
251
|
defs.push('id bigint generated by default as identity primary key');
|
|
251
252
|
}
|
|
252
253
|
for (const c of p.cols) {
|
|
@@ -366,9 +367,9 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
|
|
|
366
367
|
export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet[], b: Body): Promise<BuiltBook> {
|
|
367
368
|
const v = (name: string) => String(b[name] ?? '');
|
|
368
369
|
const plans = planFrom(sheets, v).filter((p) => p.on);
|
|
369
|
-
if (!plans.length) throw new LoadError('
|
|
370
|
+
if (!plans.length) throw new LoadError(tx('ash.choose_at_least_one_sheet'));
|
|
370
371
|
const ddl = plans.map((p) => plannedTableSql(checked.schema, p));
|
|
371
|
-
if (new Set(plans.map((p) => p.table)).size !== plans.length) throw new LoadError('
|
|
372
|
+
if (new Set(plans.map((p) => p.table)).size !== plans.length) throw new LoadError(tx('ash.each_sheet_needs_a_table'));
|
|
372
373
|
// only proposed foreign keys can be chosen (recomputed from the posted names and types)
|
|
373
374
|
const fks = proposeForeignKeys(plans).filter((f) => v(fkName(f)) === String(f.to));
|
|
374
375
|
const headers = b.h !== '0';
|
|
@@ -381,7 +382,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
|
|
|
381
382
|
const tables: BuiltBook['tables'] = [];
|
|
382
383
|
for (const [n, p] of plans.entries()) {
|
|
383
384
|
await c.query(ddl[n]).catch((e) => {
|
|
384
|
-
throw new LoadError((e as { code?: string }).code === '42P07' ?
|
|
385
|
+
throw new LoadError((e as { code?: string }).code === '42P07' ? tx('ash.table_exists', { table: `${checked.schema}.${p.table}` }) : (e as Error).message);
|
|
385
386
|
});
|
|
386
387
|
const T = (await c.query('select $1::regclass::text as t', [`${pg.escapeIdentifier(checked.schema)}.${pg.escapeIdentifier(p.table)}`])).rows[0].t as string;
|
|
387
388
|
qualified.set(p.k, T);
|
|
@@ -413,7 +414,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
|
|
|
413
414
|
const ident = pg.escapeIdentifier;
|
|
414
415
|
await c.query(`create index on ${A} (${ident(col)})`);
|
|
415
416
|
await c.query(`alter table ${A} add foreign key (${ident(col)}) references ${B} (${ident(key)})`).catch((e) => {
|
|
416
|
-
throw new LoadError(
|
|
417
|
+
throw new LoadError(tx('ash.fk_failed', { fk: `${a.table}.${col} → ${to.table}.${key}`, error: (e as { detail?: string }).detail ?? (e as Error).message }));
|
|
417
418
|
});
|
|
418
419
|
foreignKeys.push(`${a.table}.${col} → ${to.table}.${key}`);
|
|
419
420
|
}
|
|
@@ -432,7 +433,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
|
|
|
432
433
|
if (b.chart === 'true') {
|
|
433
434
|
const dash = await addDashboard(c, checked.alias, [...qualified.values()], next);
|
|
434
435
|
if (dash) pages.push(dash);
|
|
435
|
-
else notes.push('
|
|
436
|
+
else notes.push(tx('ash.no_dashboard'));
|
|
436
437
|
}
|
|
437
438
|
await finishGeneratedApp(c, app.id, { description: checked.description });
|
|
438
439
|
return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, tables, foreignKeys, pages, notes };
|
|
@@ -462,22 +463,22 @@ export function sheetsForm(sheets: NamedSheet[], b: Body | null): Raw {
|
|
|
462
463
|
const sections = sheets.map((sheet, k) => {
|
|
463
464
|
const id = field(k, 'table');
|
|
464
465
|
return region(
|
|
465
|
-
|
|
466
|
-
html`<div class="field">${checkbox(field(k, 'on'), 'true', v(field(k, 'on')) === 'true',
|
|
466
|
+
tx('ash.sheet', { name: sheet.name }),
|
|
467
|
+
html`<div class="field">${checkbox(field(k, 'on'), 'true', v(field(k, 'on')) === 'true', tx('ash.create_from_sheet', { rows: sheet.rows.length, cols: sheet.headers.length }))}</div>
|
|
467
468
|
<div class="form-grid">
|
|
468
|
-
<div class="field"><label class="label" for="f_${id}"
|
|
469
|
-
<div class="field"><label class="label" for="f_${field(k, 'key')}"
|
|
470
|
-
<select id="f_${field(k, 'key')}" name="${field(k, 'key')}">${[['', '
|
|
469
|
+
<div class="field"><label class="label" for="f_${id}">${tx('ash.table_name')}</label><input id="f_${id}" name="${id}" value="${v(id)}"></div>
|
|
470
|
+
<div class="field"><label class="label" for="f_${field(k, 'key')}">${tx('ash.primary_key')}</label>
|
|
471
|
+
<select id="f_${field(k, 'key')}" name="${field(k, 'key')}">${[['', tx('ash.a_new_column_id_identity')], ...sheet.headers.map((h, i): [string, string] => [String(i), h])].map(
|
|
471
472
|
([value, label]) => html`<option value="${value}"${v(field(k, 'key')) === value ? raw(' selected') : ''}>${label}</option>`,
|
|
472
473
|
)}</select>
|
|
473
|
-
<small class="help"
|
|
474
|
+
<small class="help">${tx('ash.a_whole_number_key_column')}</small></div>
|
|
474
475
|
</div>
|
|
475
|
-
<div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col"
|
|
476
|
+
<div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">${tx('ash.file_column')}</th><th scope="col">${tx('ash.values')}</th><th scope="col">${tx('ash.col_name_head')}</th><th scope="col">${tx('ash.type')}</th></tr></thead><tbody>
|
|
476
477
|
${sheet.headers.map((h, i) => {
|
|
477
478
|
const type = v(field(k, `type_${i}`));
|
|
478
479
|
return html`<tr><td>${h}</td><td class="muted small ff-sample">${sample(sheet, i)}</td>
|
|
479
|
-
<td><input name="${field(k, `name_${i}`)}" value="${v(field(k, `name_${i}`))}" aria-label="
|
|
480
|
-
<td><select name="${field(k, `type_${i}`)}" aria-label="
|
|
480
|
+
<td><input name="${field(k, `name_${i}`)}" value="${v(field(k, `name_${i}`))}" aria-label="${tx('ash.col_name_for', { col: h, sheet: sheet.name })}"></td>
|
|
481
|
+
<td><select name="${field(k, `type_${i}`)}" aria-label="${tx('ash.type_of', { col: h, sheet: sheet.name })}">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
|
|
481
482
|
})}
|
|
482
483
|
</tbody></table></div>
|
|
483
484
|
<details class="ff-preview"><summary>Preview${sheet.rows.length > 10 ? ' (first 10 rows)' : ''}</summary>${preview(sheet)}</details>`,
|
|
@@ -491,11 +492,11 @@ export function sheetsForm(sheets: NamedSheet[], b: Body | null): Raw {
|
|
|
491
492
|
const fkList = fks.length
|
|
492
493
|
? html`<ul class="ff-fks">${fks.map(
|
|
493
494
|
(f) => html`<li><input type="hidden" name="fkp_${f.from}_${f.col}" value="${String(f.to)}">${checkbox(fkName(f), String(f.to), fkChecked(f, b), html`${name(f.from, f.col)} → ${keyName(f.to)}`)}
|
|
494
|
-
${f.missing ? html`<small class="help">${
|
|
495
|
+
${f.missing ? html`<small class="help">${tx('ash.missing', { n: f.missing, key: keyName(f.to) })}</small>` : ''}</li>`,
|
|
495
496
|
)}</ul>`
|
|
496
|
-
: html`<p class="muted"
|
|
497
|
+
: html`<p class="muted">${tx('ash.no_column_matches_another_table')}</p>`;
|
|
497
498
|
return html`${sections.map((x) => html`${x}<div class="u-spacer"></div>`)}
|
|
498
|
-
${region('
|
|
499
|
+
${region(tx('ash.foreign_keys'), html`<p class="muted u-mt0">${tx('ash.fk_intro')}</p>
|
|
499
500
|
${fkList}
|
|
500
|
-
<div class="buttons"><button class="btn" name="action" value="preview"
|
|
501
|
+
<div class="buttons"><button class="btn" name="action" value="preview">${tx('ash.update_the_proposals')}</button></div>`)}`;
|
|
501
502
|
}
|
package/src/builder/appwizard.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { LoadError } from '../dataload.ts';
|
|
|
7
7
|
import type { Session } from '../session.ts';
|
|
8
8
|
import { aliasFor, check, ITEM, type Created } from './appfromfile.ts';
|
|
9
9
|
import { addDashboard, parseBook } from './appsheets.ts';
|
|
10
|
-
import { finishGeneratedApp, checkNewApp, createApp, createAppError, featuresFrom, FEATURES, reservedSchema, startFromBoilerplate, DEFAULT_APP_ICON, type CheckedApp, type Features } from './newapp.ts';
|
|
10
|
+
import { finishGeneratedApp, checkNewApp, createApp, createAppError, featuresFrom, featureText, FEATURES, reservedSchema, startFromBoilerplate, DEFAULT_APP_ICON, type CheckedApp, type Features } from './newapp.ts';
|
|
11
11
|
import { currentWorkspace } from './workspaces.ts';
|
|
12
12
|
import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Body, type Req } from './ui.ts';
|
|
13
13
|
import { iconPicker } from './forms.ts';
|
|
@@ -16,6 +16,7 @@ import { BASE_STYLES } from '../runtime/styles.ts';
|
|
|
16
16
|
import { LANGUAGE_NAMES } from '../i18n.ts';
|
|
17
17
|
import { secretKeyConfigured } from '../secrets.ts';
|
|
18
18
|
import { appPublicKey } from '../push.ts';
|
|
19
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
19
20
|
|
|
20
21
|
// Two more ways into the create application wizard:
|
|
21
22
|
// - From pasted data (APEX: Create App from a File → Copy and Paste): CSV or
|
|
@@ -110,7 +111,7 @@ export interface Plan {
|
|
|
110
111
|
/** Create the application on existing tables and views (each with its page type) and the features, in one transaction. */
|
|
111
112
|
export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], chart: boolean, features: Partial<Features> = {}): Promise<Created> {
|
|
112
113
|
plans = plans.filter((p) => p.kind);
|
|
113
|
-
if (plans.length > MAX_TABLES) throw new LoadError(
|
|
114
|
+
if (plans.length > MAX_TABLES) throw new LoadError(tx('aw.at_most_tables', { n: MAX_TABLES }));
|
|
114
115
|
for (const p of plans) if (!kindsFor(p.rel).includes(p.kind)) throw new LoadError(`${p.rel.relname} can't have the page type ${PAGE_KINDS[p.kind] ?? p.kind}.`);
|
|
115
116
|
return owner.tx(async (c) => {
|
|
116
117
|
const app = await createApp(c, checked).catch((e) => {
|
|
@@ -157,9 +158,9 @@ export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], cha
|
|
|
157
158
|
const skipped: string[] = [];
|
|
158
159
|
const dash = await addDashboard(c, checked.alias, tables, next, skipped);
|
|
159
160
|
if (dash) pages.push(dash);
|
|
160
|
-
else notes.push('
|
|
161
|
+
else notes.push(tx('aw.no_dashboard'));
|
|
161
162
|
if (skipped.length)
|
|
162
|
-
notes.push(
|
|
163
|
+
notes.push(tx('aw.not_on_dashboard', { tables: skipped.map((t) => t.replace(/^.*\./, '').replace(/"/g, '')).join(', ') }));
|
|
163
164
|
}
|
|
164
165
|
await finishGeneratedApp(c, app.id, { description: checked.description, features, notes });
|
|
165
166
|
return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, pages, notes };
|
|
@@ -168,28 +169,27 @@ export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], cha
|
|
|
168
169
|
|
|
169
170
|
export async function appWizardRoutes(app: FastifyInstance) {
|
|
170
171
|
const page = (s: Session, reply: FastifyReply, title: string, crumb: string, main: Raw) =>
|
|
171
|
-
send(reply, s, shell(s, title, [['
|
|
172
|
+
send(reply, s, shell(s, title, [[tx('aw.app_builder'), BASE], [tx('aw.create'), `${BASE}/create`], [crumb]], html`<div class="ab-narrow">${main}</div>`));
|
|
172
173
|
|
|
173
174
|
// ---------------------------------------------------------------- pasted data
|
|
174
175
|
const pasteForm = (s: Session, b: Body = {}, error?: string) =>
|
|
175
|
-
html`<h1
|
|
176
|
-
<p class="muted"
|
|
177
|
-
|
|
178
|
-
${region('Data', html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
176
|
+
html`<h1>${tx('aw.create_an_application_from_pasted')}</h1>
|
|
177
|
+
<p class="muted">${tx.rich('aw.paste_intro', { link: html`<a href="${BASE}/create/file">${tx('aw.upload_a_file')}</a>` })}</p>
|
|
178
|
+
${region(tx('aw.data'), html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
179
179
|
<form method="post" action="${BASE}/create/paste">${csrf(s)}
|
|
180
180
|
<div class="form-grid">
|
|
181
|
-
${input('title', '
|
|
182
|
-
<div class="field" data-wide><label class="label" for="f_data"
|
|
181
|
+
${input('title', tx('aw.name'), b.title ?? tx('aw.pasted_data'), { help: tx('aw.names_the_application_and_the') })}
|
|
182
|
+
<div class="field" data-wide><label class="label" for="f_data">${tx('aw.data')}</label>
|
|
183
183
|
<textarea id="f_data" name="data" class="code" rows="12" required spellcheck="false" placeholder="name,city,joined Ann,Utrecht,2026-01-05">${b.data ?? ''}</textarea></div>
|
|
184
|
-
<div class="field"><label class="check"><input type="checkbox" name="headers" value="true"${b.headers === undefined || b.headers === 'true' ? raw(' checked') : ''}>
|
|
184
|
+
<div class="field"><label class="check"><input type="checkbox" name="headers" value="true"${b.headers === undefined || b.headers === 'true' ? raw(' checked') : ''}> ${tx('aw.first_row_contains_column_names')}</label></div>
|
|
185
185
|
</div>
|
|
186
|
-
<div class="buttons"><a class="btn" href="${BASE}/create"
|
|
186
|
+
<div class="buttons"><a class="btn" href="${BASE}/create">${tx('aw.cancel')}</a><button class="btn btn-hot">${icon('upload')} ${tx('aw.next')}</button></div>
|
|
187
187
|
</form>`)}`;
|
|
188
188
|
|
|
189
189
|
app.get(`${BASE}/create/paste`, async (req: Req, reply) => {
|
|
190
190
|
const s = await developer(req, reply);
|
|
191
191
|
if (!s) return;
|
|
192
|
-
return page(s, reply, '
|
|
192
|
+
return page(s, reply, tx('aw.paste_title'), tx('aw.pasted_data'), pasteForm(s));
|
|
193
193
|
});
|
|
194
194
|
|
|
195
195
|
app.post(`${BASE}/create/paste`, async (req: Req, reply) => {
|
|
@@ -198,19 +198,19 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
198
198
|
const b = req.body ?? {};
|
|
199
199
|
const fail = (message: string) => {
|
|
200
200
|
reply.code(422);
|
|
201
|
-
return page(s, reply, '
|
|
201
|
+
return page(s, reply, tx('aw.paste_title'), tx('aw.pasted_data'), pasteForm(s, { ...b, headers: b.headers === 'true' ? 'true' : 'false' }, message));
|
|
202
202
|
};
|
|
203
203
|
const text = typeof b.data === 'string' ? b.data : '';
|
|
204
|
-
if (!text.trim()) return fail('
|
|
204
|
+
if (!text.trim()) return fail(tx('aw.paste_the_data'));
|
|
205
205
|
const data = Buffer.from(text, 'utf8');
|
|
206
|
-
if (data.length > MAX_PASTE) return fail(
|
|
206
|
+
if (data.length > MAX_PASTE) return fail(tx('aw.paste_max', { mb: MAX_PASTE / 1024 / 1024 }));
|
|
207
207
|
const filename = pastedName(String(b.title ?? ''));
|
|
208
208
|
const headers = b.headers === 'true';
|
|
209
209
|
try {
|
|
210
210
|
// read as delimited text whatever it looks like
|
|
211
211
|
const sheets = await parseBook(filename, data, { headers });
|
|
212
|
-
if (sheets[0].format !== 'csv') throw new LoadError('
|
|
213
|
-
if (!sheets[0].rows.length) throw new LoadError('
|
|
212
|
+
if (sheets[0].format !== 'csv') throw new LoadError(tx('aw.paste_delimited_text_csv_or'));
|
|
213
|
+
if (!sheets[0].rows.length) throw new LoadError(tx('aw.the_data_has_column_names'));
|
|
214
214
|
} catch (e) {
|
|
215
215
|
if (e instanceof LoadError) return fail(e.message);
|
|
216
216
|
throw e;
|
|
@@ -253,13 +253,13 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
253
253
|
let built: Created;
|
|
254
254
|
let pushKey = false;
|
|
255
255
|
try {
|
|
256
|
-
if (schema && (reservedSchema(schema) || !(await appSchemas()).includes(schema))) throw new LoadError('
|
|
256
|
+
if (schema && (reservedSchema(schema) || !(await appSchemas()).includes(schema))) throw new LoadError(tx('aw.choose_one_of_the_schemas'));
|
|
257
257
|
const checked = await checkNewApp({ ...b, schema }, currentWorkspace(s)).catch((e) => {
|
|
258
258
|
throw new LoadError((e as Error).message);
|
|
259
259
|
});
|
|
260
260
|
const features = featuresFrom(b, b.features === '1');
|
|
261
261
|
const boilerplate = /^\d{1,9}$/.test(b.boilerplate ?? '') ? Number(b.boilerplate) : null;
|
|
262
|
-
if (boilerplate && !(await boilerplates(s)).some((x) => x.id === boilerplate)) throw new LoadError('
|
|
262
|
+
if (boilerplate && !(await boilerplates(s)).some((x) => x.id === boilerplate)) throw new LoadError(tx('aw.choose_a_boilerplate_application_an'));
|
|
263
263
|
// the tables of the form: only names of this schema's tables and views count (looked up again, never used as SQL)
|
|
264
264
|
const relations = schema ? await schemaRelations(schema) : [];
|
|
265
265
|
const plans: Plan[] = [];
|
|
@@ -272,8 +272,8 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
272
272
|
if (kind) plans.push({ rel, kind });
|
|
273
273
|
}
|
|
274
274
|
const askedTables = fromTables || b.tables === '1' || Object.keys(b).some((k) => /^t_\d+$/.test(k));
|
|
275
|
-
if (askedTables && !plans.length) throw new LoadError('
|
|
276
|
-
if (plans.length && boilerplate) throw new LoadError('
|
|
275
|
+
if (askedTables && !plans.length) throw new LoadError(tx('aw.choose_at_least_one_table'));
|
|
276
|
+
if (plans.length && boilerplate) throw new LoadError(tx('aw.a_boilerplate_application_brings_its'));
|
|
277
277
|
if (plans.length) built = await buildAppFromTables(checked, plans, b.chart === 'true', features);
|
|
278
278
|
else {
|
|
279
279
|
built = await owner.tx(async (c) => {
|
|
@@ -294,9 +294,9 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
294
294
|
// the push key of the new application, after the commit
|
|
295
295
|
if (pushKey && secretKeyConfigured()) await appPublicKey(built.app.id).catch(() => undefined);
|
|
296
296
|
// (sprint 73, APEX: straight to the new application's home) the result as a message on its page
|
|
297
|
-
const
|
|
298
|
-
const account = built.app.existingAccount ? `
|
|
299
|
-
flash(s,
|
|
297
|
+
const created = built.pages.length ? tx('aw.created_pages', { alias: built.app.alias, n: built.pages.length }) : tx('aw.created', { alias: built.app.alias });
|
|
298
|
+
const account = built.app.existingAccount ? ` ${tx('aw.existing_account', { user: String(b.admin_user ?? '').trim() })}` : '';
|
|
299
|
+
flash(s, `${created}${account}${built.notes.length ? ` ${built.notes.join(' ')}` : ''}`);
|
|
300
300
|
return back(reply, s, `${BASE}/apps/${built.app.id}`);
|
|
301
301
|
};
|
|
302
302
|
app.post(`${BASE}/apps`, (req: Req, reply) => create(req, reply));
|
|
@@ -322,8 +322,8 @@ const SOURCES: [string, string, string, string][] = [
|
|
|
322
322
|
const rlsBadge = (r: Relation) =>
|
|
323
323
|
!r.rls ? ''
|
|
324
324
|
: r.policies > 0
|
|
325
|
-
? html` <span class="tag aw-rls" title="
|
|
326
|
-
: html` <span class="tag tag-error aw-rls" title="
|
|
325
|
+
? html` <span class="tag aw-rls" title="${r.policies === 1 ? tx('aw.rls_on_1') : tx('aw.rls_on', { n: r.policies })}">${icon('shield')} RLS · ${r.policies}</span>`
|
|
326
|
+
: html` <span class="tag tag-error aw-rls" title="${tx('aw.row_level_security_is_on')}">${icon('shield')} ${tx('aw.rls_no_policies')}</span>`;
|
|
327
327
|
|
|
328
328
|
async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
|
|
329
329
|
const schemas = await appSchemas();
|
|
@@ -335,62 +335,62 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
335
335
|
// (sprint 73) the name proposed from the schema is selected on first focus, so typing replaces it (app.js data-select-once)
|
|
336
336
|
const suggested = known ? schema.replace(/_/g, ' ').replace(/^./, (x) => x.toUpperCase()) : '';
|
|
337
337
|
const kindOf = (r: Relation, i: number) => (b ? (b[`t_${i}`] === r.relname ? (b[`k_${i}`] === undefined ? defaultKind(r) : String(b[`k_${i}`])) : '') : defaultKind(r));
|
|
338
|
-
const rows = (r: Relation) => (r.rows === null ? '
|
|
338
|
+
const rows = (r: Relation) => (r.rows === null ? tx('aw.not_analyzed') : tx('aw.about_rows', { n: Number(r.rows).toLocaleString('en') }));
|
|
339
339
|
const main = html`<div class="ab-narrow create-app">
|
|
340
|
-
<h1
|
|
341
|
-
<nav class="create-sources" aria-label="
|
|
342
|
-
([path, ic, label, help]) => html`<a class="create-source" href="${BASE}/${path}">${icon(ic)}<span><b>${label}</b><small class="muted">${help}</small></span></a>`,
|
|
340
|
+
<h1>${tx('aw.create_an_application')}</h1>
|
|
341
|
+
<nav class="create-sources" aria-label="${tx('aw.other_ways_to_start')}">${SOURCES.map(
|
|
342
|
+
([path, ic, label, help]) => html`<a class="create-source" href="${BASE}/${path}">${icon(ic)}<span><b>${tx.dyn(`aw.src.${path.replace(/^create\//, '')}`, label)}</b><small class="muted">${tx.dyn(`aw.srch.${path.replace(/^create\//, '')}`, help)}</small></span></a>`,
|
|
343
343
|
)}</nav>
|
|
344
344
|
${message}
|
|
345
|
-
${schema && !known && !message ? html`<div class="alert alert-error" role="alert"
|
|
346
|
-
${region('
|
|
347
|
-
<div class="form-grid">${select('schema', '
|
|
348
|
-
'
|
|
349
|
-
<div class="buttons"><button class="btn">${icon('search')}
|
|
345
|
+
${schema && !known && !message ? html`<div class="alert alert-error" role="alert">${tx('aw.choose_one_of_the_schemas')}</div>` : ''}
|
|
346
|
+
${region(tx('aw.data'), html`<form method="get" action="${BASE}/create" class="create-schema">
|
|
347
|
+
<div class="form-grid">${select('schema', tx('aw.pages_on_the_tables_of'), schema, [['', tx('aw.a_new_empty_schema_home')], ...schemas.map((x): [string, string] => [x, x])],
|
|
348
|
+
tx('aw.schema_help'))}</div>
|
|
349
|
+
<div class="buttons"><button class="btn">${icon('search')} ${tx('aw.show_its_tables')}</button></div></form>`)}
|
|
350
350
|
<form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
|
|
351
351
|
<input type="hidden" name="features" value="1">
|
|
352
352
|
<div id="aw-schema-fields"><input type="hidden" name="schema" value="${known ? schema : ''}">${known ? html`<input type="hidden" name="tables" value="1">` : ''}</div>
|
|
353
|
-
${region('
|
|
354
|
-
<div class="field"><label class="label" for="f_name"
|
|
353
|
+
${region(tx('aw.application'), html`<div class="form-grid">
|
|
354
|
+
<div class="field"><label class="label" for="f_name">${tx('aw.name')}</label>
|
|
355
355
|
<input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
|
|
356
|
-
${input('description', '
|
|
357
|
-
${iconPicker('f_icon', 'icon', '
|
|
356
|
+
${input('description', tx('aw.description'), v('description', ''), { placeholder: tx('aw.one_line_for_the_home') })}
|
|
357
|
+
${iconPicker('f_icon', 'icon', tx('aw.icon'), v('icon', DEFAULT_APP_ICON))}
|
|
358
358
|
</div>`)}
|
|
359
|
-
${region('
|
|
360
|
-
${select('base', '
|
|
361
|
-
${select('nav', '
|
|
362
|
-
${input('accent', '
|
|
359
|
+
${region(tx('aw.appearance'), html`<div class="form-grid">
|
|
360
|
+
${select('base', tx('aw.theme_style'), v('base', 'iris'), [['iris', tx('aw.iris_light_body_dark_header')], ['lavender', tx('aw.lavender_rounded_indigo')], ['standard', tx('aw.standard_blue_navy_header')]])}
|
|
361
|
+
${select('nav', tx('aw.navigation'), v('nav', 'side'), [['side', tx('aw.side_menu')], ['top', tx('aw.top_menu')]])}
|
|
362
|
+
${input('accent', tx('aw.accent_colour'), v('accent', BASE_STYLES.iris.accent), { type: 'color', help: tx('aw.of_the_theme_style_the') })}
|
|
363
363
|
</div>`)}
|
|
364
|
-
${region('
|
|
365
|
-
<li><span class="aw-page-kind">${icon('home')}
|
|
364
|
+
${region(tx('aw.pages'), html`<ul class="aw-pages">
|
|
365
|
+
<li><span class="aw-page-kind">${icon('home')} ${tx('aw.home')}</span><span class="muted">${tx('aw.a_hero_with_the_name')}</span></li>
|
|
366
366
|
${known
|
|
367
367
|
? relations.length
|
|
368
368
|
? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
|
|
369
369
|
<label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
|
|
370
|
-
<select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
|
|
371
|
-
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? '
|
|
372
|
-
: html`<li class="muted">${
|
|
373
|
-
: html`<li class="muted"
|
|
370
|
+
<select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${tx.dyn(`aw.k.${k || 'none'}`, PAGE_KINDS[k])}</option>`)}</select>
|
|
371
|
+
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? tx('aw.view') : rows(r)}${r.kind === 'table' && !r.pk ? tx('aw.no_key') : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? tx('aw.paged_grid') : ''}</small>${rlsBadge(r)}</li>`)
|
|
372
|
+
: html`<li class="muted">${tx('aw.no_relations', { schema })}</li>`
|
|
373
|
+
: html`<li class="muted">${tx('aw.choose_a_schema_under_data')}</li>`}
|
|
374
374
|
</ul>
|
|
375
|
-
${known ? check('chart', '
|
|
376
|
-
${region('
|
|
377
|
-
${region('
|
|
378
|
-
<div class="field"><label class="label" for="f_alias"
|
|
375
|
+
${known ? check('chart', tx('aw.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true) : ''}`, '', 'aw-pages')}
|
|
376
|
+
${region(tx('aw.features'), html`<div class="aw-features">${FEATURES.map(([k, label, help]) => { const [l, h] = featureText(k, label, help); return check(`f_${k}`, l, features[k], h); })}</div>`)}
|
|
377
|
+
${region(tx('aw.settings'), html`<div class="form-grid">
|
|
378
|
+
<div class="field"><label class="label" for="f_alias">${tx('aw.alias_url')}</label>
|
|
379
379
|
<input id="f_alias" name="alias" value="${v('alias', known ? await aliasFor(schema) : '')}" required pattern="[a-z][a-z0-9_\-]*" maxlength="50" data-derive-from="f_name"${b && b.alias ? raw(' data-edited') : ''}>
|
|
380
|
-
<small class="help"
|
|
381
|
-
${select('authentication', '
|
|
382
|
-
${input('admin_user', '
|
|
383
|
-
${input('admin_password', '
|
|
384
|
-
${select('language', '
|
|
380
|
+
<small class="help">${tx('aw.lowercase_e_g_inventory_a')}</small></div>
|
|
381
|
+
${select('authentication', tx('aw.authentication'), v('authentication', 'app_users'), [['app_users', tx('aw.app_users_login_page')], ['none', tx('aw.none_public')]])}
|
|
382
|
+
${input('admin_user', tx('aw.first_user'), v('admin_user', ''), { placeholder: tx('aw.e_g_your_name'), help: tx('aw.gets_the_admin_role_an') })}
|
|
383
|
+
${input('admin_password', tx('aw.password'), '', { type: 'password', auto: 'new-password', help: tx('aw.for_a_new_account_at') })}
|
|
384
|
+
${select('language', tx('aw.language'), v('language', 'en'), Object.entries(LANGUAGE_NAMES).map(([code, name]): [string, string] => [code, `${name} (${code})`]))}
|
|
385
385
|
</div>`)}
|
|
386
386
|
${!known && bp.length
|
|
387
|
-
? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2
|
|
388
|
-
${select('boilerplate', '
|
|
389
|
-
'
|
|
387
|
+
? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2>${tx('aw.advanced')}</h2></summary><div class="region-body"><div class="form-grid">
|
|
388
|
+
${select('boilerplate', tx('aw.start_from'), v('boilerplate', ''), [['', tx('aw.a_blank_application')], ...bp.map((x): [string, string] => [String(x.id), tx('aw.bp_option', { name: x.name })])],
|
|
389
|
+
tx('aw.bp_help'))}
|
|
390
390
|
</div></div></details>`
|
|
391
391
|
: ''}
|
|
392
|
-
<div class="buttons"><a class="btn" href="${BASE}"
|
|
392
|
+
<div class="buttons"><a class="btn" href="${BASE}">${tx('aw.cancel')}</a><button class="btn btn-hot">${tx('aw.create_application')}</button></div>
|
|
393
393
|
</form>
|
|
394
394
|
</div>`;
|
|
395
|
-
return send(reply, s, shell(s, '
|
|
395
|
+
return send(reply, s, shell(s, tx('aw.create_application'), [[tx('aw.app_builder'), BASE], [tx('aw.create')]], main));
|
|
396
396
|
}
|