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.
Files changed (81) hide show
  1. package/docs/apex-feature-parity.md +2 -2
  2. package/docs/guide/03-builder.md +14 -5
  3. package/docs/guide/12-development.md +6 -1
  4. package/package.json +1 -1
  5. package/src/app.ts +9 -0
  6. package/src/builder/ai-agents.ts +86 -85
  7. package/src/builder/ai-builder.ts +30 -30
  8. package/src/builder/ai.ts +93 -92
  9. package/src/builder/api.ts +47 -47
  10. package/src/builder/app-groups.ts +33 -32
  11. package/src/builder/appfromfile.ts +57 -58
  12. package/src/builder/appsheets.ts +33 -32
  13. package/src/builder/appwizard.ts +65 -65
  14. package/src/builder/arrange.ts +23 -22
  15. package/src/builder/assistant.ts +39 -38
  16. package/src/builder/automations.ts +32 -31
  17. package/src/builder/availability.ts +24 -24
  18. package/src/builder/backups.ts +30 -31
  19. package/src/builder/blueprints.ts +62 -61
  20. package/src/builder/create-page.ts +52 -50
  21. package/src/builder/cross-app.ts +37 -35
  22. package/src/builder/dataload.ts +2 -2
  23. package/src/builder/datasets.ts +107 -106
  24. package/src/builder/dependencies.ts +20 -19
  25. package/src/builder/designer.ts +36 -36
  26. package/src/builder/diagnostics.ts +58 -57
  27. package/src/builder/features.ts +39 -35
  28. package/src/builder/feedback.ts +23 -20
  29. package/src/builder/find-workspace.ts +28 -24
  30. package/src/builder/form-designer.ts +36 -35
  31. package/src/builder/gallery.ts +25 -29
  32. package/src/builder/globalization.ts +50 -49
  33. package/src/builder/i18n/de.ts +3453 -0
  34. package/src/builder/i18n/en.ts +3453 -0
  35. package/src/builder/i18n/es.ts +3453 -0
  36. package/src/builder/i18n/fr.ts +3453 -0
  37. package/src/builder/i18n/it.ts +3453 -0
  38. package/src/builder/i18n/ja.ts +3453 -0
  39. package/src/builder/i18n/ko.ts +3453 -0
  40. package/src/builder/i18n/nl.ts +3453 -0
  41. package/src/builder/i18n/pt.ts +3453 -0
  42. package/src/builder/i18n/zh-cn.ts +3453 -0
  43. package/src/builder/i18n/zh-tw.ts +3453 -0
  44. package/src/builder/i18n.ts +17 -3
  45. package/src/builder/json-sources.ts +63 -62
  46. package/src/builder/layouts.ts +12 -11
  47. package/src/builder/locks.ts +29 -28
  48. package/src/builder/login-page.ts +26 -25
  49. package/src/builder/mail.ts +148 -147
  50. package/src/builder/newapp.ts +15 -12
  51. package/src/builder/plugins.ts +47 -44
  52. package/src/builder/process-jobs.ts +11 -10
  53. package/src/builder/pwa.ts +22 -21
  54. package/src/builder/remote-databases.ts +55 -56
  55. package/src/builder/report-settings.ts +1 -1
  56. package/src/builder/reporter.ts +43 -42
  57. package/src/builder/rest-catalogs.ts +87 -86
  58. package/src/builder/rest.ts +9 -8
  59. package/src/builder/routes.ts +27 -27
  60. package/src/builder/search.ts +14 -13
  61. package/src/builder/static-files.ts +31 -32
  62. package/src/builder/subscriptions.ts +47 -45
  63. package/src/builder/supporting.ts +23 -18
  64. package/src/builder/team.ts +179 -179
  65. package/src/builder/telemetry.ts +59 -62
  66. package/src/builder/templates.ts +66 -65
  67. package/src/builder/themeroller.ts +60 -63
  68. package/src/builder/ui-defaults.ts +21 -20
  69. package/src/builder/ui.ts +2 -1
  70. package/src/builder/upgrade.ts +25 -24
  71. package/src/builder/utilities.ts +48 -47
  72. package/src/builder/vector-providers.ts +67 -66
  73. package/src/builder/websources.ts +43 -42
  74. package/src/builder/wizards.ts +95 -89
  75. package/src/builder/workflow-migrate.ts +30 -31
  76. package/src/builder/workflows.ts +40 -39
  77. package/src/builder/workingcopies.ts +41 -49
  78. package/src/builder/workspace-backups.ts +21 -20
  79. package/src/builder/workspace-mail.ts +17 -16
  80. package/src/builder/workspace-resources.ts +104 -112
  81. package/src/builder/workspace-transfer.ts +34 -26
@@ -6,6 +6,7 @@ import { icon } from '../icons.ts';
6
6
  import { clientIp, logActivity } from '../session.ts';
7
7
  import { appOr404 } from './forms.ts';
8
8
  import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
9
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
9
10
 
10
11
  // Application → Backups (APEX: Application Backups, migration 082): the
11
12
  // exports this installation keeps of an application (before every import
@@ -14,8 +15,8 @@ import { appHeader, back, BASE, csrf, developer, flash, input, region, send, she
14
15
  // backup, so the version it replaces is backed up as well. Locks of the
15
16
  // application are respected (developer() refuses a POST to a locked app).
16
17
 
17
- const REASON: Record<string, string> = {
18
- import: 'before an import', merge: 'before a working copy merge', restore: 'before a restore', daily: 'daily', manual: 'made by hand',
18
+ const REASON: Record<string, BuilderKey> = {
19
+ import: 'bkp.reason.import', merge: 'bkp.reason.merge', restore: 'bkp.reason.restore', daily: 'bkp.reason.daily', manual: 'bkp.reason.manual',
19
20
  };
20
21
  const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
21
22
  const kb = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
@@ -39,35 +40,33 @@ export async function backupRoutes(app: FastifyInstance) {
39
40
  const rows = (await owner.query(
40
41
  'select id, created_at, created_by, reason, note, size from meta.app_backup where app_id = $1 order by created_at desc, id desc', [a.id])).rows;
41
42
  const body = html`
42
- ${region('Backups', html`
43
- <p class="muted u-mt0">Exports of this application kept by this installation: one before every import that replaces it (also from the
44
- command line and the MCP server), before a working copy merge and a restore, one a day when it changed, and the ones made here.
45
- The newest ${a.backups_keep} are kept. Backups are not exported.</p>
46
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Made (UTC)</th><th>Why</th><th>By</th><th>Note</th><th class="num">Size</th><th>Actions</th></tr></thead><tbody>
43
+ ${region(tx('app.head.backups'), html`
44
+ <p class="muted u-mt0">${tx('bkp.intro', { n: a.backups_keep })}</p>
45
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('bkp.made_utc')}</th><th>${tx('bkp.why')}</th><th>${tx('bkp.by')}</th><th>${tx('bkp.note')}</th><th class="num">${tx('bkp.size')}</th><th>${tx('bkp.actions')}</th></tr></thead><tbody>
47
46
  ${rows.length ? rows.map((b) => html`<tr>
48
- <td data-label="Made">${when(b.created_at)}</td>
49
- <td data-label="Why">${REASON[b.reason] ?? b.reason}</td>
50
- <td data-label="By">${b.created_by ?? ''}</td>
51
- <td data-label="Note" class="u-break">${b.note ?? ''}</td>
52
- <td class="num" data-label="Size">${kb(b.size)}</td>
53
- <td data-label="Actions"><div class="buttons">
54
- <a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups/${b.id}/download">${icon('download')} Download</a>
47
+ <td data-label="${tx('bkp.made')}">${when(b.created_at)}</td>
48
+ <td data-label="${tx('bkp.why')}">${REASON[b.reason] ? tx(REASON[b.reason]) : b.reason}</td>
49
+ <td data-label="${tx('bkp.by')}">${b.created_by ?? ''}</td>
50
+ <td data-label="${tx('bkp.note')}" class="u-break">${b.note ?? ''}</td>
51
+ <td class="num" data-label="${tx('bkp.size')}">${kb(b.size)}</td>
52
+ <td data-label="${tx('bkp.actions')}"><div class="buttons">
53
+ <a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups/${b.id}/download">${icon('download')} ${tx('bkp.download')}</a>
55
54
  <form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/restore" class="u-inline">${csrf(s)}
56
- <button class="btn btn-sm" data-confirm="Restore the application as it was on ${when(b.created_at)}? Its pages, shared components and settings become those of the backup; the current version is backed up first.">${icon('history')} Restore</button></form>
55
+ <button class="btn btn-sm" data-confirm="${tx('bkp.restore_confirm', { when: when(b.created_at) })}">${icon('history')} ${tx('bkp.restore')}</button></form>
57
56
  <form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/delete" class="u-inline">${csrf(s)}
58
- <button class="btn btn-sm btn-danger" data-confirm="Delete this backup?">Delete</button></form>
57
+ <button class="btn btn-sm btn-danger" data-confirm="${tx('bkp.delete_confirm')}">${tx('bkp.delete')}</button></form>
59
58
  </div></td></tr>`)
60
- : html`<tr><td colspan="6" class="empty">No backups yet.</td></tr>`}</tbody></table></div>`)}
59
+ : html`<tr><td colspan="6" class="empty">${tx('bkp.none')}</td></tr>`}</tbody></table></div>`)}
61
60
  <div class="columns">
62
- ${region('Back up now', html`<form method="post" action="${BASE}/apps/${a.id}/backups">${csrf(s)}
63
- <div class="form-grid">${input('note', 'Note', '', { placeholder: 'e.g. before the new dashboard' })}</div>
64
- <div class="buttons"><button class="btn btn-hot">${icon('save')} Back up now</button></div></form>`)}
65
- ${region('Keep', html`<form method="post" action="${BASE}/apps/${a.id}/backups/settings">${csrf(s)}
66
- <div class="form-grid">${input('keep', 'Backups to keep', a.backups_keep, { type: 'number', help: '0 to 500; 0 makes no backups at all (also not before an import).' })}</div>
67
- <div class="buttons"><button class="btn">Save</button></div></form>`)}
61
+ ${region(tx('bkp.back_up_now'), html`<form method="post" action="${BASE}/apps/${a.id}/backups">${csrf(s)}
62
+ <div class="form-grid">${input('note', tx('bkp.note'), '', { placeholder: tx('bkp.note_placeholder') })}</div>
63
+ <div class="buttons"><button class="btn btn-hot">${icon('save')} ${tx('bkp.back_up_now')}</button></div></form>`)}
64
+ ${region(tx('bkp.keep'), html`<form method="post" action="${BASE}/apps/${a.id}/backups/settings">${csrf(s)}
65
+ <div class="form-grid">${input('keep', tx('bkp.to_keep'), a.backups_keep, { type: 'number', help: tx('bkp.to_keep_help') })}</div>
66
+ <div class="buttons"><button class="btn">${tx('common.save')}</button></div></form>`)}
68
67
  </div>`;
69
68
  const main = html`${appHeader(a, 'pages', s)}<div class="ide-body">${body}</div>`;
70
- return send(reply, s, shell(s, `${a.name} backups`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Backups']], main));
69
+ return send(reply, s, shell(s, tx('bkp.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.head.backups')]], main));
71
70
  });
72
71
 
73
72
  app.post(`${BASE}/apps/:id(^\\d+$)/backups`, async (req: Req, reply) => {
@@ -76,7 +75,7 @@ export async function backupRoutes(app: FastifyInstance) {
76
75
  const a = await appOr404(req.params.id);
77
76
  if (!a) return reply.code(404).send('Not found');
78
77
  const r = await owner.one(`select meta.backup_app($1, 'manual', $2, $3) as id`, [a.id, s.username, String(req.body?.note ?? '').trim().slice(0, 500) || null]);
79
- flash(s, r.id ? 'Backup made.' : 'No backup made: this application keeps none (set "Backups to keep").', r.id ? 'ok' : 'error');
78
+ flash(s, r.id ? tx('bkp.made_flash') : tx('bkp.none_kept'), r.id ? 'ok' : 'error');
80
79
  return back(reply, s, `${BASE}/apps/${a.id}/backups`);
81
80
  });
82
81
 
@@ -84,12 +83,12 @@ export async function backupRoutes(app: FastifyInstance) {
84
83
  const s = await developer(req, reply);
85
84
  if (!s) return;
86
85
  const keep = Number(req.body?.keep);
87
- if (!Number.isInteger(keep) || keep < 0 || keep > 500) flash(s, 'Backups to keep: a whole number from 0 to 500.', 'error');
86
+ if (!Number.isInteger(keep) || keep < 0 || keep > 500) flash(s, tx('bkp.keep_invalid'), 'error');
88
87
  else {
89
88
  await owner.query('update meta.app set backups_keep = $2, updated_at = now() where id = $1', [req.params.id, keep]);
90
89
  // fewer to keep: the oldest go now
91
90
  await owner.query(`delete from meta.app_backup where app_id = $1 and id not in (select id from meta.app_backup where app_id = $1 order by created_at desc, id desc limit $2)`, [req.params.id, keep]);
92
- flash(s, 'Saved.');
91
+ flash(s, tx('bkp.saved'));
93
92
  }
94
93
  return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
95
94
  });
@@ -112,14 +111,14 @@ export async function backupRoutes(app: FastifyInstance) {
112
111
  try {
113
112
  const at = await owner.tx(async (c) => {
114
113
  const b = (await c.query('select doc, created_at from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, a.id])).rows[0];
115
- if (!b) throw new Error('Backup not found.');
114
+ if (!b) throw new Error(tx('bkp.not_found'));
116
115
  await replaceApp(c, b.doc, a.alias, { backup: { reason: 'restore', user: s.username, note: `before restoring the backup of ${when(b.created_at)}` } });
117
116
  return when(b.created_at);
118
117
  });
119
118
  await logActivity({ appId: a.id, username: s.username, event: 'backup_restored', ip: clientIp(req), detail: `backup ${req.params.bid} of ${at}` });
120
- flash(s, `Restored the application as it was on ${at}. The version it replaced is the newest backup.`);
119
+ flash(s, tx('bkp.restored', { when: at }));
121
120
  } catch (e) {
122
- flash(s, `Restore failed: ${(e as Error).message}`, 'error');
121
+ flash(s, tx('bkp.restore_failed', { message: (e as Error).message }), 'error');
123
122
  }
124
123
  return back(reply, s, `${BASE}/apps/${a.id}/backups`);
125
124
  });
@@ -128,7 +127,7 @@ export async function backupRoutes(app: FastifyInstance) {
128
127
  const s = await developer(req, reply);
129
128
  if (!s) return;
130
129
  const r = await owner.query('delete from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, req.params.id]);
131
- flash(s, r.rowCount ? 'Backup deleted.' : 'Not found.', r.rowCount ? 'ok' : 'error');
130
+ flash(s, r.rowCount ? tx('bkp.deleted') : tx('bkp.not_found_short'), r.rowCount ? 'ok' : 'error');
132
131
  return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
133
132
  });
134
133
  }
@@ -16,6 +16,7 @@ import { devDbForApp } from '../workspace-db.ts';
16
16
  import { isAdmin } from './locks.ts';
17
17
  import { back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
18
18
  import { WIZARD_KINDS } from './wizards.ts';
19
+ import { scopedTx as tx } from './i18n.ts';
19
20
 
20
21
  // Create → From a blueprint (migration 063, src/blueprint.ts): saved
21
22
  // blueprints, an editor (JSON), an AI draft from a description (the App
@@ -68,38 +69,38 @@ const signed = (s: Session, spec: string, sig: unknown) => {
68
69
  };
69
70
 
70
71
  function parse(spec: string): { json: unknown; error: string | null } {
71
- if (spec.length > MAX_SPEC_CHARS) return { json: null, error: `The blueprint is too long (at most ${MAX_SPEC_CHARS} characters).` };
72
+ if (spec.length > MAX_SPEC_CHARS) return { json: null, error: tx('bp.too_long', { n: MAX_SPEC_CHARS }) };
72
73
  try {
73
74
  return { json: JSON.parse(spec), error: null };
74
75
  } catch (e) {
75
- return { json: null, error: `Not valid JSON: ${(e as Error).message}` };
76
+ return { json: null, error: tx('bp.bad_json', { error: (e as Error).message }) };
76
77
  }
77
78
  }
78
79
 
79
80
  const page = (s: Session, reply: FastifyReply, crumb: string, main: Raw) =>
80
- send(reply, s, shell(s, 'Blueprints', [['App Builder', BASE], ['Create', `${BASE}/create`], ['Blueprints', `${BASE}/blueprints`], ...(crumb ? [[crumb] as [string]] : [])], html`<div class="ab-narrow">${main}</div>`));
81
+ send(reply, s, shell(s, tx('bp.blueprints'), [[tx('bp.app_builder'), BASE], [tx('bp.create'), `${BASE}/create`], [tx('bp.blueprints'), `${BASE}/blueprints`], ...(crumb ? [[crumb] as [string]] : [])], html`<div class="ab-narrow">${main}</div>`));
81
82
 
82
83
  async function editor(s: Session, reply: FastifyReply, o: { id?: number | null; name: string; spec: string; description?: string; message?: Raw }) {
83
84
  const svc = await builderService();
84
- return page(s, reply, o.id ? o.name : 'New blueprint', html`<h1>${o.id ? `Blueprint: ${o.name}` : 'New blueprint'}</h1>
85
- <p class="muted">A blueprint describes an application: its tables (every table gets an <code>id</code>; <code>"references"</code> makes a foreign key), pages (${PAGE_TYPES.join(', ')}), menu and sample rows. <b>Review</b> shows what it will create; nothing is created before you confirm it there.</p>
85
+ return page(s, reply, o.id ? o.name : tx('bp.new_blueprint'), html`<h1>${o.id ? tx('bp.title', { name: o.name }) : tx('bp.new_blueprint')}</h1>
86
+ <p class="muted">${tx.rich('bp.intro', { id: html`<code>id</code>`, references: html`<code>"references"</code>`, pages: PAGE_TYPES.join(', '), review: html`<b>${tx('bp.review')}</b>` })}</p>
86
87
  ${o.message ?? ''}
87
- ${region('Draft with AI', svc
88
+ ${region(tx('bp.draft_with_ai'), svc
88
89
  ? html`<form method="post" action="${BASE}/blueprints/draft">${csrf(s)}
89
90
  <input type="hidden" name="id" value="${o.id ?? ''}"><input type="hidden" name="name" value="${o.name}">
90
- <div class="field"><label class="label" for="f_description">Describe the application</label>
91
- <textarea id="f_description" name="description" rows="3" maxlength="4000" required placeholder="e.g. Track projects with tasks and due dates, and the hours people spend on them">${o.description ?? ''}</textarea>
92
- <small class="help">The AI service ${svc.name} writes a blueprint into the editor below (replacing what is there). Only your description is sent.</small></div>
93
- <div class="buttons"><button class="btn" data-busy="Thinking…">Draft a blueprint</button></div>
91
+ <div class="field"><label class="label" for="f_description">${tx('bp.describe_the_application')}</label>
92
+ <textarea id="f_description" name="description" rows="3" maxlength="4000" required placeholder="${tx('bp.e_g_track_projects_with')}">${o.description ?? ''}</textarea>
93
+ <small class="help">${tx('bp.ai_help', { service: svc.name })}</small></div>
94
+ <div class="buttons"><button class="btn" data-busy="${tx('bp.thinking')}">${tx('bp.draft_a_blueprint')}</button></div>
94
95
  </form>`
95
- : html`<p class="muted">The App Builder has no AI service: an administrator chooses one under <a href="${BASE}/sql/ai">SQL Workshop → AI</a>. You can write the blueprint yourself.</p>`)}
96
- ${region('Blueprint', html`<form method="post" action="${BASE}/blueprints/review">${csrf(s)}
96
+ : html`<p class="muted">${tx.rich('bp.no_service', { link: html`<a href="${BASE}/sql/ai">${tx('bp.sql_workshop_ai')}</a>` })}</p>`)}
97
+ ${region(tx('bp.blueprint'), html`<form method="post" action="${BASE}/blueprints/review">${csrf(s)}
97
98
  <input type="hidden" name="id" value="${o.id ?? ''}">
98
- <div class="form-grid"><div class="field"><label class="label" for="f_name">Name</label><input id="f_name" name="name" maxlength="100" required value="${o.name}"></div></div>
99
+ <div class="form-grid"><div class="field"><label class="label" for="f_name">${tx('bp.name')}</label><input id="f_name" name="name" maxlength="100" required value="${o.name}"></div></div>
99
100
  <div class="field"><label class="label" for="f_spec">JSON</label>
100
101
  <textarea id="f_spec" name="spec" class="code" rows="24" spellcheck="false" data-code="json">${o.spec}</textarea>
101
- <small class="help">Column types: ${Object.keys(COLUMN_TYPES).join(', ')}; <code>"required"</code>, <code>"unique"</code>, <code>"values"</code> (allowed texts). Page types: ${WIZARD_KINDS.map(([k]) => k).join(', ')} and blank (<code>"text"</code>). Optional: <code>"dashboard": true</code>, <code>"navigation": [{"label", "page", "icon"}]</code>, <code>"sample_data": [{"table", "columns", "rows"}]</code>.</small></div>
102
- <div class="buttons"><button class="btn btn-hot">Review</button><button class="btn" formaction="${BASE}/blueprints/save">Save</button></div>
102
+ <small class="help">${tx.rich('bp.spec_help', { types: Object.keys(COLUMN_TYPES).join(', '), required: html`<code>"required"</code>`, unique: html`<code>"unique"</code>`, values: html`<code>"values"</code>`, kinds: WIZARD_KINDS.map(([k]) => k).join(', '), text: html`<code>"text"</code>`, dashboard: html`<code>"dashboard": true</code>`, navigation: html`<code>"navigation": [{"label", "page", "icon"}]</code>`, sample: html`<code>"sample_data": [{"table", "columns", "rows"}]</code>` })}</small></div>
103
+ <div class="buttons"><button class="btn btn-hot">${tx('bp.review')}</button><button class="btn" formaction="${BASE}/blueprints/save">${tx('bp.save')}</button></div>
103
104
  </form>`)}`);
104
105
  }
105
106
 
@@ -107,28 +108,28 @@ function review(s: Session, bp: Blueprint, spec: string, id: number | null, name
107
108
  const sql = blueprintSql(bp);
108
109
  const rows = (t: string) => bp.sample_data.filter((d) => d.table === t).reduce((n, d) => n + d.rows.length, 0);
109
110
  return html`<h1>Review: ${bp.name}</h1>
110
- <p class="muted">This is what the blueprint creates. Check it; go back to change it, or create the application.</p>
111
- ${region('Application', html`<dl class="bp-facts">
112
- <dt>Name</dt><dd>${bp.name}</dd><dt>Alias</dt><dd><code>/a/${bp.alias}</code></dd>
113
- <dt>Schema</dt><dd><code>${bp.schema}</code> (database role <code>app_${bp.alias.replace(/-/g, '_')}</code>)</dd>
114
- <dt>Sign-in</dt><dd>${bp.authentication === 'none' ? 'none: every page is public' : 'a login page (application users)'}</dd></dl>`)}
115
- ${region('Tables', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Table</th><th scope="col">Columns</th><th scope="col" class="num">Sample rows</th></tr></thead><tbody>
116
- ${bp.tables.map((t) => html`<tr><td data-label="Table"><code>${t.name}</code>${t.label ? html` <span class="muted">${t.label}</span>` : ''}</td>
117
- <td data-label="Columns">${t.columns.map((c, i) => html`${i ? ', ' : ''}<code>${c.name}</code> <span class="muted">${c.references ? `→ ${c.references}` : c.type}${c.required ? ', required' : ''}${c.unique ? ', unique' : ''}${c.values.length ? `, ${c.values.join(' / ')}` : ''}</span>`)}</td>
118
- <td data-label="Sample rows" class="num">${rows(t.name)}</td></tr>`)}
111
+ <p class="muted">${tx('bp.this_is_what_the_blueprint')}</p>
112
+ ${region(tx('bp.application'), html`<dl class="bp-facts">
113
+ <dt>${tx('bp.name')}</dt><dd>${bp.name}</dd><dt>${tx('bp.alias')}</dt><dd><code>/a/${bp.alias}</code></dd>
114
+ <dt>${tx('bp.schema')}</dt><dd><code>${bp.schema}</code> ${tx.rich('bp.db_role', { role: html`<code>app_${bp.alias.replace(/-/g, '_')}</code>` })}</dd>
115
+ <dt>${tx('bp.sign_in')}</dt><dd>${bp.authentication === 'none' ? 'none: every page is public' : 'a login page (application users)'}</dd></dl>`)}
116
+ ${region(tx('bp.tables'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('bp.table')}</th><th scope="col">${tx('bp.columns')}</th><th scope="col" class="num">${tx('bp.sample_rows')}</th></tr></thead><tbody>
117
+ ${bp.tables.map((t) => html`<tr><td data-label="${tx('bp.table')}"><code>${t.name}</code>${t.label ? html` <span class="muted">${t.label}</span>` : ''}</td>
118
+ <td data-label="${tx('bp.columns')}">${t.columns.map((c, i) => html`${i ? ', ' : ''}<code>${c.name}</code> <span class="muted">${c.references ? `→ ${c.references}` : c.type}${c.required ? ', required' : ''}${c.unique ? ', unique' : ''}${c.values.length ? `, ${c.values.join(' / ')}` : ''}</span>`)}</td>
119
+ <td data-label="${tx('bp.sample_rows')}" class="num">${rows(t.name)}</td></tr>`)}
119
120
  </tbody></table></div>
120
- <details class="u-mt1"><summary>The SQL</summary><pre class="bp-sql">${sql.join(';\n\n')};</pre></details>`)}
121
- ${region('Pages', html`<ul>${bp.pages.map((p) => html`<li>Page ${p.page}${p.form_page ? html` and ${p.form_page}` : ''}: ${p.label} <span class="muted">(${p.type}${p.table ? ` on ${p.table}` : ''})</span></li>`)}
122
- ${bp.dashboard ? html`<li>A dashboard with charts per table</li>` : ''}</ul>
123
- ${bp.navigation ? html`<p>Menu: ${bp.navigation.map((n) => n.label).join(' · ')}</p>` : html`<p class="muted">Menu: Home and the pages' own entries.</p>`}`)}
124
- ${region('Create the application', html`<form method="post" action="${BASE}/blueprints/create">${csrf(s)}
121
+ <details class="u-mt1"><summary>${tx('bp.the_sql')}</summary><pre class="bp-sql">${sql.join(';\n\n')};</pre></details>`)}
122
+ ${region(tx('bp.pages'), html`<ul>${bp.pages.map((p) => html`<li>Page ${p.page}${p.form_page ? html` and ${p.form_page}` : ''}: ${p.label} <span class="muted">(${p.type}${p.table ? ` on ${p.table}` : ''})</span></li>`)}
123
+ ${bp.dashboard ? html`<li>${tx('bp.a_dashboard_with_charts_per')}</li>` : ''}</ul>
124
+ ${bp.navigation ? html`<p>Menu: ${bp.navigation.map((n) => n.label).join(' · ')}</p>` : html`<p class="muted">${tx('bp.menu_home_and_the_pages')}</p>`}`)}
125
+ ${region(tx('bp.create_the_application'), html`<form method="post" action="${BASE}/blueprints/create">${csrf(s)}
125
126
  <input type="hidden" name="id" value="${id ?? ''}"><input type="hidden" name="name" value="${name}">
126
127
  <input type="hidden" name="spec" value="${spec}"><input type="hidden" name="sig" value="${sign(s, spec)}">
127
128
  ${bp.authentication === 'none' ? '' : html`<div class="form-grid">
128
- <div class="field"><label class="label" for="f_admin_user">First user (admin role)</label><input id="f_admin_user" name="admin_user" required value="${s.username}" autocomplete="off"></div>
129
- <div class="field"><label class="label" for="f_admin_password">Password (for a new account)</label><input id="f_admin_password" name="admin_password" type="password" autocomplete="new-password"></div>
129
+ <div class="field"><label class="label" for="f_admin_user">${tx('bp.first_user_admin_role')}</label><input id="f_admin_user" name="admin_user" required value="${s.username}" autocomplete="off"></div>
130
+ <div class="field"><label class="label" for="f_admin_password">${tx('bp.password_for_a_new_account')}</label><input id="f_admin_password" name="admin_password" type="password" autocomplete="new-password"></div>
130
131
  </div>`}
131
- <div class="buttons"><button class="btn" formaction="${BASE}/blueprints/edit">Back to the editor</button><button class="btn btn-hot">${icon('plus')} Create the application</button></div>
132
+ <div class="buttons"><button class="btn" formaction="${BASE}/blueprints/edit">${tx('bp.back_to_the_editor')}</button><button class="btn btn-hot">${icon('plus')} ${tx('bp.create_the_application')}</button></div>
132
133
  </form>`)}`;
133
134
  }
134
135
 
@@ -140,10 +141,10 @@ export async function openBlueprint(s: Session, reply: FastifyReply, name: strin
140
141
  const { json, error } = parse(spec);
141
142
  const { blueprint, problems } = error ? { blueprint: null, problems: [error] } : checkBlueprint(json);
142
143
  const taken = blueprint ? await owner.one('select 1 from meta.app where alias = $1', [blueprint.alias]) : null;
143
- if (taken) problems.push(`An application with the alias ${blueprint!.alias} already exists: change "alias" and "schema" in the blueprint.`);
144
+ if (taken) problems.push(tx('bp.alias_taken', { alias: blueprint!.alias }));
144
145
  if (!blueprint || problems.length)
145
- return editor(s, reply, { name, spec, message: html`<div class="alert alert-error" role="alert"><p>The blueprint can't be created yet:</p><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` });
146
- return page(s, reply, 'Review', review(s, blueprint, spec, null, name));
146
+ return editor(s, reply, { name, spec, message: html`<div class="alert alert-error" role="alert"><p>${tx('bp.the_blueprint_can_t_be')}</p><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` });
147
+ return page(s, reply, tx('bp.review_title'), review(s, blueprint, spec, null, name));
147
148
  }
148
149
 
149
150
  export async function blueprintRoutes(app: FastifyInstance) {
@@ -152,23 +153,23 @@ export async function blueprintRoutes(app: FastifyInstance) {
152
153
  if (!s) return;
153
154
  const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id where b.workspace_id = $1 order by lower(b.name), b.id`, [currentWorkspace(s)])).rows;
154
155
  const apps = (await owner.query(`select id, name, alias from meta.app where meta.app_workspace(id) = $1 order by lower(name), id`, [currentWorkspace(s)])).rows;
155
- return page(s, reply, '', html`<h1>Blueprints</h1>
156
- <p class="muted">Describe an application as a blueprint (tables, pages, menu, sample rows), review it and create it. <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} New blueprint</a></p>
156
+ return page(s, reply, '', html`<h1>${tx('bp.blueprints')}</h1>
157
+ <p class="muted">${tx('bp.list_intro')} <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} ${tx('bp.new_blueprint')}</a></p>
157
158
  ${list.length
158
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Blueprint</th><th scope="col">Application created</th><th scope="col">Changed</th><th scope="col"></th></tr></thead><tbody>
159
- ${list.map((x) => html`<tr><td data-label="Blueprint"><a href="${BASE}/blueprints/${x.id}">${x.name}</a> <span class="muted">${x.created_by ?? ''}</span></td>
160
- <td data-label="Application created">${x.app_id ? html`<a href="${BASE}/apps/${x.app_id}">${x.alias}</a>` : '—'}</td><td data-label="Changed">${String(x.updated).slice(0, 16)}</td>
161
- <td><form method="post" action="${BASE}/blueprints/${x.id}/delete">${csrf(s)}<button class="btn btn-small">Delete</button></form></td></tr>`)}
159
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('bp.blueprint')}</th><th scope="col">${tx('bp.application_created')}</th><th scope="col">${tx('bp.changed')}</th><th scope="col"></th></tr></thead><tbody>
160
+ ${list.map((x) => html`<tr><td data-label="${tx('bp.blueprint')}"><a href="${BASE}/blueprints/${x.id}">${x.name}</a> <span class="muted">${x.created_by ?? ''}</span></td>
161
+ <td data-label="${tx('bp.application_created')}">${x.app_id ? html`<a href="${BASE}/apps/${x.app_id}">${x.alias}</a>` : '—'}</td><td data-label="${tx('bp.changed')}">${String(x.updated).slice(0, 16)}</td>
162
+ <td><form method="post" action="${BASE}/blueprints/${x.id}/delete">${csrf(s)}<button class="btn btn-small">${tx('bp.delete')}</button></form></td></tr>`)}
162
163
  </tbody></table></div>`
163
- : html`<p class="muted">No saved blueprints yet.</p>`}
164
- ${apps.length ? region('From an application', html`<form method="post" action="${BASE}/blueprints/from-app">${csrf(s)}
165
- <p class="muted u-mt0">A blueprint of an application of this workspace: the tables its pages use, its pages and its menu, to review and change in the editor (for a copy, a new version or a test system). Nothing is created until you confirm the review.</p>
164
+ : html`<p class="muted">${tx('bp.no_saved_blueprints_yet')}</p>`}
165
+ ${apps.length ? region(tx('bp.from_an_application'), html`<form method="post" action="${BASE}/blueprints/from-app">${csrf(s)}
166
+ <p class="muted u-mt0">${tx('bp.a_blueprint_of_an_application')}</p>
166
167
  <div class="form-grid">
167
- <div class="field"><label class="label" for="f_bp_app">Application</label><select id="f_bp_app" name="app">${apps.map((a) => html`<option value="${a.id}">${a.name} (${a.alias})</option>`)}</select></div>
168
- <div class="field"><label class="check"><input type="checkbox" name="rows" value="true"> Sample rows: up to 20 per table</label>
169
- <small class="help">The rows are copied into the blueprint as they are: leave this off for personal or confidential data.</small></div>
168
+ <div class="field"><label class="label" for="f_bp_app">${tx('bp.application')}</label><select id="f_bp_app" name="app">${apps.map((a) => html`<option value="${a.id}">${a.name} (${a.alias})</option>`)}</select></div>
169
+ <div class="field"><label class="check"><input type="checkbox" name="rows" value="true"> ${tx('bp.sample_rows_up_to_20')}</label>
170
+ <small class="help">${tx('bp.the_rows_are_copied_into')}</small></div>
170
171
  </div>
171
- <div class="buttons"><button class="btn">${icon('layers')} Create a blueprint</button></div></form>`) : ''}`);
172
+ <div class="buttons"><button class="btn">${icon('layers')} ${tx('bp.create_a_blueprint')}</button></div></form>`) : ''}`);
172
173
  });
173
174
 
174
175
  // (sprint 66) a blueprint from an application of the current workspace, opened in the editor
@@ -184,15 +185,15 @@ export async function blueprintRoutes(app: FastifyInstance) {
184
185
  await logActivity({ appId: a.id, username: s.username, event: 'blueprint', ip: clientIp(req), detail: `blueprint from the application${rows ? ', with sample rows' : ''}` });
185
186
  return editor(s, reply, {
186
187
  name: `${a.name} blueprint`.slice(0, 100), spec: JSON.stringify(spec, null, 2),
187
- message: html`<div class="alert alert-info" role="status"><p>A blueprint of ${a.name}: review it, change the name, alias and schema, then <b>Review</b>.</p>
188
- ${notes.length ? html`<details><summary>${notes.length} note${notes.length === 1 ? '' : 's'} on what didn't carry over</summary><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></details>` : ''}</div>`,
188
+ message: html`<div class="alert alert-info" role="status"><p>${tx.rich('bp.of_app', { app: a.name, review: html`<b>${tx('bp.review')}</b>` })}</p>
189
+ ${notes.length ? html`<details><summary>${notes.length === 1 ? tx('bp.notes_1') : tx('bp.notes', { n: notes.length })}</summary><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></details>` : ''}</div>`,
189
190
  });
190
191
  });
191
192
 
192
193
  app.get(`${BASE}/blueprints/new`, async (req: Req, reply) => {
193
194
  const s = await developer(req, reply);
194
195
  if (!s) return;
195
- return editor(s, reply, { name: 'Projects', spec: JSON.stringify(EXAMPLE, null, 2) });
196
+ return editor(s, reply, { name: tx('bp.projects'), spec: JSON.stringify(EXAMPLE, null, 2) });
196
197
  });
197
198
 
198
199
  app.get(`${BASE}/blueprints/:id(^\\d+$)`, async (req: Req, reply) => {
@@ -225,7 +226,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
225
226
  ? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4 returning id', [o.id, o.name, JSON.stringify(json), currentWorkspace(s)]))?.id
226
227
  : (await owner.one('insert into meta.blueprint (name, spec, created_by, workspace_id) values ($1, $2, $3, $4) returning id', [o.name, JSON.stringify(json), s.username, currentWorkspace(s)])).id;
227
228
  if (!id) return reply.code(404).send('Not found');
228
- flash(s, `Blueprint ${o.name} saved.`);
229
+ flash(s, tx('bp.saved', { name: o.name }));
229
230
  return back(reply, s, `${BASE}/blueprints/${id}`);
230
231
  });
231
232
 
@@ -236,8 +237,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
236
237
  const description = String(req.body?.description ?? '').trim().slice(0, 4000);
237
238
  try {
238
239
  const svc = await builderService();
239
- if (!svc) throw new AiError('config', 'The App Builder has no AI service yet.');
240
- if (!description) throw new AiError('config', 'Describe the application.');
240
+ if (!svc) throw new AiError('config', tx('bp.the_app_builder_has_no'));
241
+ if (!description) throw new AiError('config', tx('bp.describe_the_application_2'));
241
242
  const res = await generate(svc.name, {
242
243
  system: 'You design small business web applications on PostgreSQL as blueprints. Use singular lower-case table names, a few meaningful columns per table, '
243
244
  + '"references" for relations (never an id column: every table has one), "values" for short fixed lists, and pages of the given types (report_form for the main tables; '
@@ -248,7 +249,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
248
249
  }, { appId: null, user: s.username, source: 'builder' });
249
250
  const spec = JSON.stringify(fromDraft(res.json), null, 2);
250
251
  return editor(s, reply, { ...o, name: o.id ? o.name : String((res.json as any)?.name ?? o.name).slice(0, 100), spec, description,
251
- message: html`<div class="alert alert-info">Drafted by AI: review it before you create anything.</div>` });
252
+ message: html`<div class="alert alert-info">${tx('bp.drafted_by_ai_review_it')}</div>` });
252
253
  } catch (e) {
253
254
  return editor(s, reply, { ...o, description, message: html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>` });
254
255
  }
@@ -261,10 +262,10 @@ export async function blueprintRoutes(app: FastifyInstance) {
261
262
  const { json, error } = parse(o.spec);
262
263
  const { blueprint, problems } = error ? { blueprint: null, problems: [error] } : checkBlueprint(json);
263
264
  const taken = blueprint ? await owner.one('select 1 from meta.app where alias = $1', [blueprint.alias]) : null;
264
- if (taken) problems.push(`An application with the alias ${blueprint!.alias} already exists.`);
265
+ if (taken) problems.push(tx('bp.alias_taken_short', { alias: blueprint!.alias }));
265
266
  if (!blueprint || problems.length)
266
- return editor(s, reply, { ...o, message: html`<div class="alert alert-error" role="alert"><p>The blueprint can't be created yet:</p><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` });
267
- return page(s, reply, 'Review', review(s, blueprint, o.spec, o.id, o.name));
267
+ return editor(s, reply, { ...o, message: html`<div class="alert alert-error" role="alert"><p>${tx('bp.the_blueprint_can_t_be')}</p><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` });
268
+ return page(s, reply, tx('bp.review_title'), review(s, blueprint, o.spec, o.id, o.name));
268
269
  });
269
270
 
270
271
  app.post(`${BASE}/blueprints/create`, async (req: Req, reply) => {
@@ -290,10 +291,10 @@ export async function blueprintRoutes(app: FastifyInstance) {
290
291
  return { ...r, existing: a.existingAccount };
291
292
  });
292
293
  await logActivity({ username: s.username, event: 'blueprint', ip: clientIp(req), detail: `created ${blueprint.alias} from blueprint ${o.name}` });
293
- flash(s, `Application ${blueprint.alias} created from the blueprint: ${blueprint.tables.length} table(s), ${built.rows} sample row(s).${built.existing ? ` The existing account ${String(b.admin_user)} got the admin role.` : ''}`);
294
+ flash(s, `${tx('bp.created', { alias: blueprint.alias, tables: blueprint.tables.length, rows: built.rows })}${built.existing ? ` ${tx('bp.admin_role', { user: String(b.admin_user) })}` : ''}`);
294
295
  return back(reply, s, `${BASE}/apps/${built.appId}`);
295
296
  } catch (e) {
296
- return page(s, reply, 'Review', html`<div class="alert alert-error" role="alert">No application was created: ${(e as Error).message}</div>${review(s, blueprint, o.spec, o.id, o.name)}`);
297
+ return page(s, reply, tx('bp.review_title'), html`<div class="alert alert-error" role="alert">${tx('bp.not_created', { error: (e as Error).message })}</div>${review(s, blueprint, o.spec, o.id, o.name)}`);
297
298
  }
298
299
  });
299
300
 
@@ -301,7 +302,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
301
302
  const s = await developer(req, reply);
302
303
  if (!s) return;
303
304
  const r = await owner.query('delete from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
304
- flash(s, r.rowCount ? 'Blueprint deleted (applications created from it stay).' : 'There is no such blueprint.');
305
+ flash(s, r.rowCount ? tx('bp.blueprint_deleted_applications_created_from') : tx('bp.there_is_no_such_blueprint'));
305
306
  return back(reply, s, `${BASE}/blueprints`);
306
307
  });
307
308
  }