pgkiln 0.84.0 → 0.86.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 (105) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +14 -5
  4. package/docs/guide/04-pages-and-regions.md +1 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/09-reference.md +1 -0
  8. package/docs/guide/12-development.md +10 -1
  9. package/examples/gallery/README.md +23 -6
  10. package/examples/gallery/gallery.json +4 -0
  11. package/examples/gallery/samples/sample-approvals.sql +102 -11
  12. package/examples/gallery/samples/sample-calendar.sql +40 -5
  13. package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
  14. package/examples/gallery/samples/sample-grids.sql +35 -3
  15. package/examples/gallery/samples/sample-maps.sql +65 -7
  16. package/examples/gallery/samples/sample-processes.sql +466 -0
  17. package/examples/gallery/samples/sample-validations.sql +407 -0
  18. package/examples/gallery/samples/ux-patterns.sql +154 -14
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_80_notifications.sql +125 -0
  21. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  22. package/package.json +1 -1
  23. package/public/app.css +8 -1
  24. package/public/app.js +28 -9
  25. package/src/app.ts +9 -0
  26. package/src/builder/ai-agents.ts +86 -85
  27. package/src/builder/ai-builder.ts +30 -30
  28. package/src/builder/ai.ts +93 -92
  29. package/src/builder/api.ts +47 -47
  30. package/src/builder/app-groups.ts +33 -32
  31. package/src/builder/appfromfile.ts +57 -58
  32. package/src/builder/appsheets.ts +33 -32
  33. package/src/builder/appwizard.ts +65 -65
  34. package/src/builder/arrange.ts +23 -22
  35. package/src/builder/assistant.ts +39 -38
  36. package/src/builder/automations.ts +32 -31
  37. package/src/builder/availability.ts +24 -24
  38. package/src/builder/backups.ts +30 -31
  39. package/src/builder/blueprints.ts +62 -61
  40. package/src/builder/create-page.ts +52 -50
  41. package/src/builder/cross-app.ts +37 -35
  42. package/src/builder/dataload.ts +2 -2
  43. package/src/builder/datasets.ts +107 -106
  44. package/src/builder/dependencies.ts +20 -19
  45. package/src/builder/designer.ts +36 -36
  46. package/src/builder/diagnostics.ts +58 -57
  47. package/src/builder/features.ts +39 -35
  48. package/src/builder/feedback.ts +23 -20
  49. package/src/builder/find-workspace.ts +28 -24
  50. package/src/builder/form-designer.ts +36 -35
  51. package/src/builder/gallery.ts +25 -29
  52. package/src/builder/globalization.ts +50 -49
  53. package/src/builder/i18n/de.ts +3453 -0
  54. package/src/builder/i18n/en.ts +3453 -0
  55. package/src/builder/i18n/es.ts +3453 -0
  56. package/src/builder/i18n/fr.ts +3453 -0
  57. package/src/builder/i18n/it.ts +3453 -0
  58. package/src/builder/i18n/ja.ts +3453 -0
  59. package/src/builder/i18n/ko.ts +3453 -0
  60. package/src/builder/i18n/nl.ts +3453 -0
  61. package/src/builder/i18n/pt.ts +3453 -0
  62. package/src/builder/i18n/zh-cn.ts +3453 -0
  63. package/src/builder/i18n/zh-tw.ts +3453 -0
  64. package/src/builder/i18n.ts +17 -3
  65. package/src/builder/json-sources.ts +63 -62
  66. package/src/builder/layouts.ts +12 -11
  67. package/src/builder/locks.ts +29 -28
  68. package/src/builder/login-page.ts +26 -25
  69. package/src/builder/mail.ts +148 -147
  70. package/src/builder/newapp.ts +15 -12
  71. package/src/builder/plugins.ts +47 -44
  72. package/src/builder/process-jobs.ts +11 -10
  73. package/src/builder/pwa.ts +22 -21
  74. package/src/builder/remote-databases.ts +55 -56
  75. package/src/builder/report-settings.ts +1 -1
  76. package/src/builder/reporter.ts +43 -42
  77. package/src/builder/rest-catalogs.ts +87 -86
  78. package/src/builder/rest.ts +9 -8
  79. package/src/builder/routes.ts +27 -27
  80. package/src/builder/search.ts +14 -13
  81. package/src/builder/static-files.ts +31 -32
  82. package/src/builder/subscriptions.ts +47 -45
  83. package/src/builder/supporting.ts +23 -18
  84. package/src/builder/team.ts +179 -179
  85. package/src/builder/telemetry.ts +59 -62
  86. package/src/builder/templates.ts +66 -65
  87. package/src/builder/themeroller.ts +60 -63
  88. package/src/builder/ui-defaults.ts +21 -20
  89. package/src/builder/ui.ts +2 -1
  90. package/src/builder/upgrade.ts +25 -24
  91. package/src/builder/utilities.ts +48 -47
  92. package/src/builder/vector-providers.ts +67 -66
  93. package/src/builder/websources.ts +43 -42
  94. package/src/builder/wizards.ts +95 -89
  95. package/src/builder/workflow-migrate.ts +30 -31
  96. package/src/builder/workflows.ts +40 -39
  97. package/src/builder/workingcopies.ts +41 -49
  98. package/src/builder/workspace-backups.ts +21 -20
  99. package/src/builder/workspace-mail.ts +17 -16
  100. package/src/builder/workspace-resources.ts +104 -112
  101. package/src/builder/workspace-transfer.ts +34 -26
  102. package/src/runtime/engine.ts +6 -3
  103. package/src/runtime/lists.ts +2 -2
  104. package/src/runtime/push.ts +7 -1
  105. package/src/runtime/regions.ts +9 -4
@@ -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
  }
@@ -2,10 +2,11 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
- import { featureStates } from './features.ts';
5
+ import { featHelp, featLabel, featureStates } from './features.ts';
6
6
  import { appOr404 } from './forms.ts';
7
7
  import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
8
- import { NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
8
+ import { kindHelp, kindLabel, NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
9
+ import { scopedTx as tx } from './i18n.ts';
9
10
 
10
11
  // (sprint 137, AP-3) Create Page like APEX's: a grid of page types with small previews (tab Component), the
11
12
  // features (tab Feature, features.ts) and Create page as copy (tab Copy), at /builder/apps/:id/create-page.
@@ -14,12 +15,12 @@ import { NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
14
15
  // forms: no JavaScript needed. The previews are inline SVG drawn with classes (public/builder.css), no styles.
15
16
 
16
17
  /** The grid's order (APEX's names), each with the step it starts with. */
17
- const GRID: { key: string; label: string; help: string; table: boolean }[] = [
18
- { key: 'blank', label: 'Blank page', help: 'An empty page to build in the Page Designer.', table: false },
18
+ const GRID = (): { key: string; label: string; help: string; table: boolean }[] => [
19
+ { key: 'blank', label: tx('cpg.blank_page'), help: tx('cpg.an_empty_page_to_build'), table: false },
19
20
  ...['calendar', 'cards', 'chart', 'comments', 'content_row', 'dashboard', 'data_load', 'facets', 'form', 'grid', 'report', 'map',
20
21
  'master_detail', 'media_list', 'report_form', 'search', 'smart_filters', 'timeline', 'tree', 'tasks', 'wizard', 'workflows'].map((k) => {
21
22
  const d = PAGE_KINDS.find((x) => x[0] === k)!;
22
- return { key: k, label: d[1], help: d[2], table: !NO_TABLE.has(k) };
23
+ return { key: k, label: kindLabel(k, d[1]), help: kindHelp(k, d[2]), table: !NO_TABLE.has(k) };
23
24
  }),
24
25
  ];
25
26
 
@@ -66,7 +67,7 @@ const PREVIEWS: Record<string, string> = {
66
67
  export const preview = (key: string): Raw =>
67
68
  raw(`<svg class="cp-svg" viewBox="0 0 80 50" aria-hidden="true" focusable="false">${PREVIEWS[key] ?? PREVIEWS.blank}</svg>`);
68
69
 
69
- const TABS: [string, string][] = [['component', 'Component'], ['feature', 'Feature'], ['copy', 'Copy']];
70
+ const TABS: [string, string][] = [['component', tx('cpg.component')], ['feature', tx('cpg.feature')], ['copy', tx('cpg.copy')]];
70
71
 
71
72
  export async function createPageRoutes(app: FastifyInstance) {
72
73
  app.get(`${BASE}/apps/:id/create-page`, async (req: Req, reply) => {
@@ -76,8 +77,8 @@ export async function createPageRoutes(app: FastifyInstance) {
76
77
  if (!a) return reply.code(404).send('Not found');
77
78
  const q = req.query as Record<string, string | undefined>;
78
79
  const tab = TABS.some(([t]) => t === q.tab) ? q.tab! : 'component';
79
- const type = GRID.find((g) => g.key === q.type);
80
- const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', type ? `${BASE}/apps/${a.id}/create-page` : undefined]];
80
+ const type = GRID().find((g) => g.key === q.type);
81
+ const crumbs: [string, string?][] = [[tx('cpg.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', type ? `${BASE}/apps/${a.id}/create-page` : undefined]];
81
82
  const nextPage = async () => {
82
83
  const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
83
84
  let n = Math.max(0, ...[...used].filter((x) => x < 9000)) + 1;
@@ -91,49 +92,49 @@ export async function createPageRoutes(app: FastifyInstance) {
91
92
  if (type.key === 'blank') {
92
93
  body = html`<form method="post" action="${BASE}/apps/${a.id}/pages">${csrf(s)}
93
94
  <div class="form-grid">
94
- ${input('page_no', 'Page number', await nextPage(), { type: 'number', required: true })}
95
- ${input('name', 'Name', '', { required: true })}
96
- ${select('mode', 'Page mode', 'normal', [['normal', 'Normal page'], ['modal', 'Modal dialog']])}
97
- ${input('parent_page', 'Breadcrumb parent', a.home_page ?? '', { type: 'number' })}
95
+ ${input('page_no', tx('cpg.page_number'), await nextPage(), { type: 'number', required: true })}
96
+ ${input('name', tx('cpg.name'), '', { required: true })}
97
+ ${select('mode', tx('cpg.page_mode'), 'normal', [['normal', tx('cpg.normal_page')], ['modal', tx('cpg.modal_dialog')]])}
98
+ ${input('parent_page', tx('cpg.breadcrumb_parent'), a.home_page ?? '', { type: 'number' })}
98
99
  </div>
99
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Create page</button></div>
100
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">${tx('cpg.back')}</a><button class="btn btn-hot">${tx('cpg.create_page')}</button></div>
100
101
  </form>`;
101
102
  } else {
102
103
  const tables = await wizardTables(a.db_role);
103
104
  body = html`<form method="get" action="${BASE}/apps/${a.id}/wizard">
104
105
  <input type="hidden" name="kind" value="${type.key}">
105
106
  <div class="form-grid">
106
- ${select('table', 'Table or view', '', tables.map((t): [string, string] => [t.t, t.access ? t.t : `${t.t} (no access for ${a.db_role})`]),
107
+ ${select('table', tx('cpg.table_or_view'), '', tables.map((t): [string, string] => [t.t, t.access ? t.t : `${t.t} (no access for ${a.db_role})`]),
107
108
  'The next step proposes the columns, keys and foreign keys from the database catalog.')}
108
109
  </div>
109
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Next</button></div>
110
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">${tx('cpg.back')}</a><button class="btn btn-hot">${tx('cpg.next')}</button></div>
110
111
  </form>`;
111
112
  }
112
113
  const main = html`${appHeader(a, 'pages', s)}
113
114
  ${region(type.label, html`<div class="cp-step"><span class="cp-preview cp-preview-lg">${preview(type.key)}</span><p class="muted">${type.help}</p></div>${body}`)}`;
114
- return send(reply, s, shell(s, `Create page · ${a.name}`, [...crumbs, [type.label]], main));
115
+ return send(reply, s, shell(s, tx('cpg.page_title', { app: a.name }), [...crumbs, [type.label]], main));
115
116
  }
116
117
 
117
- const tabs = html`<nav class="gal-tabs segmented cp-tabs" aria-label="Create page">${TABS.map(([t, l]) =>
118
+ const tabs = html`<nav class="gal-tabs segmented cp-tabs" aria-label="${tx('cpg.create_page')}">${TABS.map(([t, l]) =>
118
119
  html`<a href="${BASE}/apps/${a.id}/create-page?tab=${t}"${t === tab ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
119
120
  let body: Raw;
120
121
  if (tab === 'component') {
121
- const href = (g: (typeof GRID)[number]) =>
122
+ const href = (g: ReturnType<typeof GRID>[number]) =>
122
123
  g.key !== 'blank' && !g.table ? `${BASE}/apps/${a.id}/wizard?kind=${g.key}` : `${BASE}/apps/${a.id}/create-page?type=${g.key}`;
123
- body = html`<p class="muted u-mt0">Choose a page type. Types on a table ask for it next; every type proposes its settings and opens the new page in the Page Designer.</p>
124
- <ul class="cp-grid">${GRID.map((g) => html`<li><a class="cp-type" href="${href(g)}"><span class="cp-preview">${preview(g.key)}</span><b>${g.label}</b><small>${g.help}</small></a></li>`)}
125
- <li><a class="cp-type" href="${BASE}/apps/${a.id}/ai-pages"><span class="cp-preview">${preview('ai')}</span><b>Create pages with AI</b><small>Describe the pages in your own words; check the proposals before they are made.</small></a></li></ul>`;
124
+ body = html`<p class="muted u-mt0">${tx('cpg.choose_a_page_type_types')}</p>
125
+ <ul class="cp-grid">${GRID().map((g) => html`<li><a class="cp-type" href="${href(g)}"><span class="cp-preview">${preview(g.key)}</span><b>${g.label}</b><small>${g.help}</small></a></li>`)}
126
+ <li><a class="cp-type" href="${BASE}/apps/${a.id}/ai-pages"><span class="cp-preview">${preview('ai')}</span><b>${tx('cpg.create_pages_with_ai')}</b><small>${tx('cpg.describe_the_pages_in_your')}</small></a></li></ul>`;
126
127
  } else if (tab === 'feature') {
127
128
  const states = await featureStates(a.id);
128
- body = html`<p class="muted u-mt0">Features add pages and settings to the application. Each lists what it adds before it creates anything; an installed feature is greyed out.</p>
129
+ body = html`<p class="muted u-mt0">${tx('cpg.features_add_pages_and_settings')}</p>
129
130
  <ul class="cp-grid">${states.map(({ f, installed, blocked }) => html`<li><a class="cp-type${installed || blocked ? ' cp-done' : ''}" href="${BASE}/apps/${a.id}/features/${f.key}">
130
- <span class="cp-feature-icon">${icon(f.icon)}</span><b>${f.label}</b><small>${f.help}</small>
131
- ${installed ? html`<span class="tag">Installed</span>` : blocked ? html`<span class="tag">Not available</span>` : ''}</a></li>`)}</ul>`;
131
+ <span class="cp-feature-icon">${icon(f.icon)}</span><b>${featLabel(f)}</b><small>${featHelp(f)}</small>
132
+ ${installed ? html`<span class="tag">${tx('cpg.installed')}</span>` : blocked ? html`<span class="tag">${tx('cpg.not_available')}</span>` : ''}</a></li>`)}</ul>`;
132
133
  } else {
133
134
  body = await copyStep1(a, s.username!);
134
135
  }
135
- const main = html`${appHeader(a, 'pages', s)}${region('Create page', html`${tabs}${body}`)}`;
136
- return send(reply, s, shell(s, `Create page · ${a.name}`, crumbs, main));
136
+ const main = html`${appHeader(a, 'pages', s)}${region(tx('cpg.create_page'), html`${tabs}${body}`)}`;
137
+ return send(reply, s, shell(s, tx('cpg.page_title', { app: a.name }), crumbs, main));
137
138
  });
138
139
 
139
140
  // ---------------------------------------------------------------- Create page as copy
@@ -146,17 +147,17 @@ export async function createPageRoutes(app: FastifyInstance) {
146
147
  const q = req.query as Record<string, string | undefined>;
147
148
  const from = await sourceApp(a.id, q.from);
148
149
  if (!from) {
149
- flash(s, 'Choose an application of this workspace.', 'error');
150
+ flash(s, tx('cpg.choose_an_application_of_this'), 'error');
150
151
  return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
151
152
  }
152
153
  const pages = (await owner.query<{ id: number; page_no: number; name: string }>('select id, page_no, name from meta.page where app_id = $1 order by page_no', [from.id])).rows;
153
154
  const page = pages.find((p) => String(p.page_no) === q.page);
154
- const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page?tab=copy`], ['Copy']];
155
+ const crumbs: [string, string?][] = [[tx('cpg.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('cpg.create_page'), `${BASE}/apps/${a.id}/create-page?tab=copy`], [tx('cpg.copy')]];
155
156
  let body: Raw;
156
157
  if (!page) {
157
158
  body = html`<form method="get" action="${BASE}/apps/${a.id}/copy-page"><input type="hidden" name="from" value="${from.id}">
158
- <div class="form-grid">${select('page', `Page of ${from.name}`, '', pages.map((p): [string, string] => [String(p.page_no), `${p.page_no} ${p.name}`]))}</div>
159
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=copy">Back</a><button class="btn btn-hot">Next</button></div></form>`;
159
+ <div class="form-grid">${select('page', tx('cpg.page_of', { app: from.name }), '', pages.map((p): [string, string] => [String(p.page_no), `${p.page_no} ${p.name}`]))}</div>
160
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=copy">${tx('cpg.back')}</a><button class="btn btn-hot">${tx('cpg.next')}</button></div></form>`;
160
161
  } else {
161
162
  const refs = (await owner.query<{ kind: string; name: string; in_target: boolean }>('select * from meta.page_shared_refs($1, $2)', [page.id, a.id])).rows;
162
163
  const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
@@ -166,27 +167,27 @@ export async function createPageRoutes(app: FastifyInstance) {
166
167
  const options = async (kind: string) => (await owner.query<{ n: string }>(SHARED_NAMES[kind], [a.id])).rows.map((r) => r.n);
167
168
  const mapRows = await Promise.all(refs.map(async (r) => {
168
169
  const names = await options(r.kind);
169
- const opts: [string, string][] = [...(same || r.in_target ? [] : [['', 'Copy it into this application'] as [string, string]]), ...names.map((n): [string, string] => [n, `Use ${n}`])];
170
- return html`<tr><td>${KIND_LABELS[r.kind] ?? r.kind}</td><td><code>${r.name}</code></td><td>
171
- <select name="map:${r.kind}:${r.name}" aria-label="${KIND_LABELS[r.kind] ?? r.kind} ${r.name}">${opts.map(([v, l]) => html`<option value="${v}"${v === (r.in_target || same ? r.name : '') ? raw(' selected') : ''}>${l}</option>`)}</select></td></tr>`;
170
+ const opts: [string, string][] = [...(same || r.in_target ? [] : [['', tx('cpg.copy_it_into_this_application')] as [string, string]]), ...names.map((n): [string, string] => [n, tx('cpg.use_name', { name: n })])];
171
+ return html`<tr><td>${kindName(r.kind)}</td><td><code>${r.name}</code></td><td>
172
+ <select name="map:${r.kind}:${r.name}" aria-label="${kindName(r.kind)} ${r.name}">${opts.map(([v, l]) => html`<option value="${v}"${v === (r.in_target || same ? r.name : '') ? raw(' selected') : ''}>${l}</option>`)}</select></td></tr>`;
172
173
  }));
173
- body = html`<p class="muted u-mt0">Page ${page.page_no} <b>${page.name}</b> of ${from.name}: its regions, items, buttons, dynamic actions, validations, processes, computations and branches. Items named P${page.page_no}_… are renamed to the new number, also where the page's code refers to them.</p>
174
+ body = html`<p class="muted u-mt0">${tx.rich('cpg.copy_intro', { n: page.page_no, name: html`<b>${page.name}</b>`, app: from.name, prefix: `P${page.page_no}_` })}</p>
174
175
  <form method="post" action="${BASE}/apps/${a.id}/copy-page">${csrf(s)}
175
176
  <input type="hidden" name="from" value="${from.id}"><input type="hidden" name="page" value="${page.page_no}">
176
177
  <div class="form-grid">
177
- ${input('new_page', 'New page number', no, { type: 'number', required: true })}
178
- ${input('name', 'Name', page.name, { required: true })}
178
+ ${input('new_page', tx('cpg.new_page_number'), no, { type: 'number', required: true })}
179
+ ${input('name', tx('cpg.name'), page.name, { required: true })}
179
180
  </div>
180
181
  ${refs.length
181
- ? html`<h3>Shared components</h3><p class="muted small">The page refers to these by name. ${same ? 'In the same application they stay as they are, or point to another of the same kind.' : 'Use one of this application\'s, or copy it from the other application.'}</p>
182
- <div class="table-wrap"><table class="report"><thead><tr><th>Kind</th><th>Name</th><th>In this application</th></tr></thead><tbody>${mapRows}</tbody></table></div>`
183
- : html`<p class="muted">The page uses no shared components.</p>`}
184
- ${same ? '' : html`<p class="muted small">Its SQL runs as this application's database role <code>${a.db_role ?? '(none)'}</code>: grant it what ${from.name}'s role may read and change.</p>`}
185
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/copy-page?from=${from.id}">Back</a><button class="btn btn-hot">Copy page</button></div>
182
+ ? html`<h3>${tx('cpg.shared_components')}</h3><p class="muted small">${tx('cpg.refers_by_name')} ${same ? tx('cpg.in_the_same_application_they') : tx('cpg.use_one_of_this_application')}</p>
183
+ <div class="table-wrap"><table class="report"><thead><tr><th>${tx('cpg.kind')}</th><th>${tx('cpg.name')}</th><th>${tx('cpg.in_this_application')}</th></tr></thead><tbody>${mapRows}</tbody></table></div>`
184
+ : html`<p class="muted">${tx('cpg.the_page_uses_no_shared')}</p>`}
185
+ ${same ? '' : html`<p class="muted small">${tx.rich('cpg.sql_runs_as', { role: html`<code>${a.db_role ?? tx('cpg.none')}</code>`, app: from.name })}</p>`}
186
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/copy-page?from=${from.id}">${tx('cpg.back')}</a><button class="btn btn-hot">${tx('cpg.copy_page')}</button></div>
186
187
  </form>`;
187
188
  }
188
- const main = html`${appHeader(a, 'pages', s)}${region('Create page as copy', body)}`;
189
- return send(reply, s, shell(s, `Copy page · ${a.name}`, crumbs, main));
189
+ const main = html`${appHeader(a, 'pages', s)}${region(tx('cpg.create_page_as_copy'), body)}`;
190
+ return send(reply, s, shell(s, tx('cpg.copy_title', { app: a.name }), crumbs, main));
190
191
  });
191
192
 
192
193
  app.post(`${BASE}/apps/:id/copy-page`, async (req: Req, reply) => {
@@ -198,15 +199,15 @@ export async function createPageRoutes(app: FastifyInstance) {
198
199
  const from = await sourceApp(a.id, String(b.from ?? ''));
199
200
  const retry = `${BASE}/apps/${a.id}/copy-page?from=${encodeURIComponent(String(b.from ?? ''))}&page=${encodeURIComponent(String(b.page ?? ''))}`;
200
201
  if (!from) {
201
- flash(s, 'Choose an application of this workspace.', 'error');
202
+ flash(s, tx('cpg.choose_an_application_of_this'), 'error');
202
203
  return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
203
204
  }
204
205
  try {
205
206
  const page = /^\d{1,6}$/.test(String(b.page ?? '')) ? Number(b.page) : NaN;
206
207
  const no = /^\d{1,6}$/.test(String(b.new_page ?? '').trim()) ? Number(String(b.new_page).trim()) : NaN;
207
- if (!(no >= 1)) throw new Error('Enter a page number.');
208
+ if (!(no >= 1)) throw new Error(tx('cpg.enter_a_page_number'));
208
209
  const name = String(b.name ?? '').trim().slice(0, 200);
209
- if (!name) throw new Error('Enter a name.');
210
+ if (!name) throw new Error(tx('cpg.enter_a_name'));
210
211
  // map:<kind>:<NAME> → a name of the target's (empty: copy); only known kinds pass
211
212
  const map: Record<string, string> = {};
212
213
  for (const [k, v] of Object.entries(b)) {
@@ -214,7 +215,7 @@ export async function createPageRoutes(app: FastifyInstance) {
214
215
  if (m && Object.hasOwn(SHARED_NAMES, m[1]) && typeof v === 'string') map[`${m[1]}:${m[2]}`] = v.slice(0, 100);
215
216
  }
216
217
  const id = await owner.tx(async (c) => (await c.query('select meta.copy_page($1, $2, $3, $4, $5, $6::jsonb) as id', [from.id, page, a.id, no, name, JSON.stringify(map)])).rows[0].id as number);
217
- flash(s, `Page ${no} "${name}" created as a copy of page ${page}${from.id === a.id ? '' : ` of ${from.name}`}.`);
218
+ flash(s, from.id === a.id ? tx('cpg.copied', { n: no, name, page }) : tx('cpg.copied_from', { n: no, name, page, app: from.name }));
218
219
  return back(reply, s, `${BASE}/pages/${id}`);
219
220
  } catch (e) {
220
221
  flash(s, (e as Error).message, 'error');
@@ -227,6 +228,7 @@ const KIND_LABELS: Record<string, string> = {
227
228
  authz_scheme: 'Authorization scheme', build_option: 'Build option', lov: 'List of values', list: 'List',
228
229
  template_component: 'Template component', search_config: 'Search configuration', data_load_def: 'Data load definition',
229
230
  };
231
+ const kindName = (k: string) => (Object.hasOwn(KIND_LABELS, k) ? tx.dyn(`cpg.k.${k}`, KIND_LABELS[k]) : k);
230
232
  /** The target application's names of each kind (the choices of the mapping). */
231
233
  const SHARED_NAMES: Record<string, string> = {
232
234
  authz_scheme: 'select name as n from meta.authz_scheme where app_id = $1 order by 1',
@@ -251,9 +253,9 @@ async function copyStep1(a: { id: number; name: string }, _user: string): Promis
251
253
  const apps = (await owner.query<{ id: number; name: string; alias: string }>(
252
254
  'select id, name, alias from meta.app where meta.app_workspace(id) = meta.app_workspace($1) order by id <> $1, lower(name)', [a.id],
253
255
  )).rows;
254
- return html`<p class="muted u-mt0">Copy a page of this application or another application of the workspace, under a new number and name. The shared components it uses (lists of values, authorization schemes, lists…) are mapped to this application's or copied.</p>
256
+ return html`<p class="muted u-mt0">${tx('cpg.copy_a_page_of_this')}</p>
255
257
  <form method="get" action="${BASE}/apps/${a.id}/copy-page">
256
- <div class="form-grid">${select('from', 'Copy from application', String(a.id), apps.map((x): [string, string] => [String(x.id), x.id === a.id ? `${x.name} (this application)` : `${x.name} (${x.alias})`]))}</div>
257
- <div class="buttons"><button class="btn btn-hot">Next</button></div>
258
+ <div class="form-grid">${select('from', tx('cpg.copy_from_application'), String(a.id), apps.map((x): [string, string] => [String(x.id), x.id === a.id ? `${x.name} (this application)` : `${x.name} (${x.alias})`]))}</div>
259
+ <div class="buttons"><button class="btn btn-hot">${tx('cpg.next')}</button></div>
258
260
  </form>`;
259
261
  }