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
@@ -7,6 +7,7 @@ import { assistantProblems, type AssistantConfig } from '../runtime/assistant.ts
7
7
  import type { Session } from '../session.ts';
8
8
  import { agentOptions } from './ai-agents.ts';
9
9
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
10
+ import { scopedTx as tx } from './i18n.ts';
10
11
 
11
12
  // Page designer → an AI assistant region → Settings (service, prompts,
12
13
  // context queries, tools, limits), and → a report region → "Ask in your own
@@ -29,7 +30,7 @@ async function serviceOptions(appId: number, current: unknown) {
29
30
  };
30
31
  }
31
32
 
32
- const NO_SERVICE = html`<p class="muted">This application may not use any AI service yet: an administrator adds one under <a href="${BASE}/ai">Workspace utilities → AI services</a> and allows this application to use it.</p>`;
33
+ const NO_SERVICE = () => html`<p class="muted">${tx.rich('asst.no_service', { link: html`<a href="${BASE}/ai">${tx('asst.workspace_utilities_ai_services')}</a>` })}</p>`;
33
34
 
34
35
  const json = (v: unknown) => (v === undefined ? '' : JSON.stringify(v, null, 2));
35
36
 
@@ -40,41 +41,41 @@ export async function assistantSettingsForm(pageId: number, appId: number, r: {
40
41
  const problems = assistantProblems(cfg);
41
42
  const agents = await agentOptions(appId);
42
43
  const agent = typeof cfg.agent === 'string' ? cfg.agent.toUpperCase() : '';
43
- return html`<h3 class="u-mt15">AI assistant settings</h3>
44
- <p class="muted u-mt0">A chat with an AI service. What users type, the context rows and the tools' results go to the service's provider (Claude, OpenAI, Gemini, or the OpenAI-compatible endpoint in its base URL). Context queries and SQL tools run as the application's database role; SQL tools are rolled back unless they may change data.</p>
45
- ${allowed.length ? '' : NO_SERVICE}
44
+ return html`<h3 class="u-mt15">${tx('asst.ai_assistant_settings')}</h3>
45
+ <p class="muted u-mt0">${tx('asst.a_chat_with_an_ai')}</p>
46
+ ${allowed.length ? '' : NO_SERVICE()}
46
47
  ${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
47
48
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/assistant" class="component-form">${csrf(s)}
48
- <fieldset class="prop-group"><legend>Assistant</legend><div class="form-grid">
49
- <div class="field"><label class="label" for="${id('agent')}">AI agent</label>
50
- <select id="${id('agent')}" name="agent">${opt('', '- none: the service, prompt and tools below -', agent)}${agents.map((n) => opt(n, n, agent))}${agent && !agents.includes(agent) ? opt(agent, `${agent} (does not exist)`, agent) : ''}</select>
51
- <small class="help">An agent of <a href="${BASE}/apps/${appId}/ai-agents">Shared Components → AI Agents</a> sets the service, system prompt, reasoning effort, tools and steps; saving with an agent removes the region's own service, system prompt, tools and tool rounds.</small></div>
52
- <div class="field"><label class="label" for="${id('service')}">AI service</label>
53
- <select id="${id('service')}" name="service">${opt('', '- choose -', cfg.service ?? '')}${options}</select></div>
54
- <div class="field"><label class="label" for="${id('max_rounds')}">Tool rounds per question</label>
49
+ <fieldset class="prop-group"><legend>${tx('asst.assistant')}</legend><div class="form-grid">
50
+ <div class="field"><label class="label" for="${id('agent')}">${tx('asst.ai_agent')}</label>
51
+ <select id="${id('agent')}" name="agent">${opt('', tx('asst.none_the_service_prompt_and'), agent)}${agents.map((n) => opt(n, n, agent))}${agent && !agents.includes(agent) ? opt(agent, `${agent} (does not exist)`, agent) : ''}</select>
52
+ <small class="help">${tx.rich('asst.agent_help', { link: html`<a href="${BASE}/apps/${appId}/ai-agents">${tx('asst.shared_components_ai_agents')}</a>` })}</small></div>
53
+ <div class="field"><label class="label" for="${id('service')}">${tx('asst.ai_service')}</label>
54
+ <select id="${id('service')}" name="service">${opt('', tx('asst.choose'), cfg.service ?? '')}${options}</select></div>
55
+ <div class="field"><label class="label" for="${id('max_rounds')}">${tx('asst.tool_rounds_per_question')}</label>
55
56
  <input id="${id('max_rounds')}" name="max_rounds" type="number" min="1" max="10" value="${cfg.max_rounds ?? ''}" placeholder="5"></div>
56
- <div class="field"><label class="label" for="${id('max_turns')}">Questions per conversation</label>
57
+ <div class="field"><label class="label" for="${id('max_turns')}">${tx('asst.questions_per_conversation')}</label>
57
58
  <input id="${id('max_turns')}" name="max_turns" type="number" min="1" max="100" value="${cfg.max_turns ?? ''}" placeholder="20"></div>
58
- <div class="field" data-wide><label class="label" for="${id('system')}">System prompt</label>
59
+ <div class="field" data-wide><label class="label" for="${id('system')}">${tx('asst.system_prompt')}</label>
59
60
  <textarea id="${id('system')}" name="system" rows="4">${cfg.system ?? ''}</textarea>
60
- <small class="help">Who the assistant is and what it may answer. <code>&amp;ITEM.</code> is replaced by the item's value as delimited data (never the session id or passwords).</small></div>
61
- <div class="field" data-wide><label class="label" for="${id('welcome')}">Welcome text</label>
61
+ <small class="help">${tx.code('asst.who_the_assistant_is_and')}</small></div>
62
+ <div class="field" data-wide><label class="label" for="${id('welcome')}">${tx('asst.welcome_text')}</label>
62
63
  <textarea id="${id('welcome')}" name="welcome" rows="2">${cfg.welcome ?? ''}</textarea></div>
63
- <div class="field"><label class="label" for="${id('placeholder')}">Placeholder</label>
64
+ <div class="field"><label class="label" for="${id('placeholder')}">${tx('asst.placeholder')}</label>
64
65
  <input id="${id('placeholder')}" name="placeholder" maxlength="200" value="${cfg.placeholder ?? ''}"></div>
65
- <div class="field"><label class="label" for="${id('error_message')}">Error message</label>
66
- <input id="${id('error_message')}" name="error_message" maxlength="300" value="${cfg.error_message ?? ''}" placeholder="the service's message"></div>
66
+ <div class="field"><label class="label" for="${id('error_message')}">${tx('asst.error_message')}</label>
67
+ <input id="${id('error_message')}" name="error_message" maxlength="300" value="${cfg.error_message ?? ''}" placeholder="${tx('asst.the_service_s_message')}"></div>
67
68
  </div>
68
- <div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> Also for users who are not signed in (every question costs tokens)</label></div></fieldset>
69
- <fieldset class="prop-group"><legend>Context and tools</legend>
70
- <div class="field"><label class="label" for="${id('context')}">Context queries (JSON)</label>
69
+ <div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> ${tx('asst.also_for_users_who_are')}</label></div></fieldset>
70
+ <fieldset class="prop-group"><legend>${tx('asst.context_and_tools')}</legend>
71
+ <div class="field"><label class="label" for="${id('context')}">${tx('asst.context_queries_json')}</label>
71
72
  <textarea id="${id('context')}" name="context" rows="5" class="code" spellcheck="false">${json(cfg.context)}</textarea>
72
- <small class="help">Run before each question; their rows go to the model as data. <code>[{"name": "policy", "sql": "select title, body from hr.policy where body @@ plainto_tsquery(:AI_PROMPT)", "max_rows": 5}]</code> (<code>:AI_PROMPT</code> is the question, <code>:APP_USER</code> and items as usual).</small></div>
73
- <div class="field"><label class="label" for="${id('tools')}">Tools (JSON)</label>
73
+ <small class="help">${tx.code('asst.run_before_each_question_their')}</small></div>
74
+ <div class="field"><label class="label" for="${id('tools')}">${tx('asst.tools_json')}</label>
74
75
  <textarea id="${id('tools')}" name="tools" rows="10" class="code" spellcheck="false">${json(cfg.tools)}</textarea>
75
- <small class="help">The model calls them when it needs to. SQL: <code>{"name": "my_leave", "description": "…", "sql": "select … where status = :STATUS", "parameters": {"STATUS": {"type": "string", "enum": ["PENDING", "APPROVED"]}}}</code>; add <code>"writes": true</code> for a tool that changes data and <code>"authz": "SCHEME"</code> to offer it only to some users. REST: <code>{"name": "department", "type": "rest", "source": "DEPARTMENT", "description": "…", "parameters": {"dname": {"type": "string"}}}</code>. Parameter types: string, integer, number, boolean, date; <code>"optional": true</code> allows none.</small></div>
76
+ <small class="help">${tx.code('asst.the_model_calls_them_when')}</small></div>
76
77
  </fieldset>
77
- <div class="buttons"><button class="btn btn-hot">Save AI assistant settings</button></div>
78
+ <div class="buttons"><button class="btn btn-hot">${tx('asst.save_ai_assistant_settings')}</button></div>
78
79
  </form>`;
79
80
  }
80
81
 
@@ -84,9 +85,9 @@ async function appProblems(appId: number, cfg: AssistantConfig) {
84
85
  const sources = new Set((await owner.query<{ name: string }>('select name from meta.rest_source where app_id = $1', [appId])).rows.map((x) => x.name));
85
86
  const schemes = new Set((await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1', [appId])).rows.map((x) => x.name.toUpperCase()));
86
87
  for (const t of Array.isArray(cfg.tools) ? cfg.tools : []) {
87
- if (t?.type === 'rest' && typeof t.source === 'string' && !sources.has(t.source.toUpperCase())) out.push(`Tool "${t.name}": there is no REST data source ${t.source.toUpperCase()}.`);
88
+ if (t?.type === 'rest' && typeof t.source === 'string' && !sources.has(t.source.toUpperCase())) out.push(tx('asst.no_rest_source', { tool: t.name, source: t.source.toUpperCase() }));
88
89
  if (typeof t?.authz === 'string' && t.authz.trim() && !['MUST_NOT_BE_PUBLIC_USER'].includes(t.authz.replace(/^!/, '').trim().toUpperCase()) && !schemes.has(t.authz.replace(/^!/, '').trim().toUpperCase()))
89
- out.push(`Tool "${t.name}": there is no authorization scheme ${t.authz}.`);
90
+ out.push(tx('asst.no_authz', { tool: t.name, scheme: t.authz }));
90
91
  }
91
92
  return out;
92
93
  }
@@ -124,12 +125,12 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
124
125
  try {
125
126
  out[k] = JSON.parse(v);
126
127
  } catch {
127
- errors.push(`${k === 'tools' ? 'Tools' : 'Context queries'}: not valid JSON (kept as it was).`);
128
+ errors.push(`${k === 'tools' ? tx('asst.tools') : tx('asst.context_queries')}: not valid JSON (kept as it was).`);
128
129
  }
129
130
  }
130
131
  if (out.agent) {
131
132
  for (const k of ['service', 'system', 'tools', 'max_rounds']) delete out[k];
132
- if (!(await agentOptions(appId)).includes(out.agent)) errors.push(`There is no AI agent ${out.agent}.`);
133
+ if (!(await agentOptions(appId)).includes(out.agent)) errors.push(tx('asst.no_agent', { agent: out.agent }));
133
134
  }
134
135
  errors.push(...assistantProblems(out), ...(await appProblems(appId, out as AssistantConfig)));
135
136
  return { config: out, errors };
@@ -140,17 +141,17 @@ export async function aiFilterSettingsForm(pageId: number, appId: number, r: { i
140
141
  const cfg = (r.config?.ai_filter ?? {}) as Config;
141
142
  const id = (n: string) => `raf_${r.id}_${n}`;
142
143
  const { options } = await serviceOptions(appId, cfg.service);
143
- return html`<h3 class="u-mt15">Ask in your own words (AI)</h3>
144
- <p class="muted u-mt0">Users type a question; it becomes the report's own settings (filters, highlights, sort, control break, totals, group by, pivot, chart, computed columns; only columns the report shows, only its operators), shown as chips they can edit, remove or undo. With an AI service the question and the column names go to the service's provider; without one (<i>built-in phrases</i>) pgkiln reads common phrases itself, in English, Dutch and German.</p>
144
+ return html`<h3 class="u-mt15">${tx('asst.ask_in_your_own_words')}</h3>
145
+ <p class="muted u-mt0">${tx.rich('asst.filter_intro', { builtin: html`<i>${tx('asst.built_in_phrases')}</i>` })}</p>
145
146
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/ai-filter" class="component-form">${csrf(s)}
146
147
  <div class="form-grid">
147
- <div class="field"><label class="label" for="${id('service')}">AI service</label>
148
- <select id="${id('service')}" name="service">${opt('', '- off -', r.config?.ai_filter ? '-' : '')}${opt('-', 'Built-in phrases (no AI)', r.config?.ai_filter && !cfg.service ? '-' : '')}${options}</select></div>
149
- <div class="field" data-wide><label class="label" for="${id('placeholder')}">Placeholder</label>
148
+ <div class="field"><label class="label" for="${id('service')}">${tx('asst.ai_service')}</label>
149
+ <select id="${id('service')}" name="service">${opt('', tx('asst.off'), r.config?.ai_filter ? '-' : '')}${opt('-', tx('asst.built_in_phrases_no_ai'), r.config?.ai_filter && !cfg.service ? '-' : '')}${options}</select></div>
150
+ <div class="field" data-wide><label class="label" for="${id('placeholder')}">${tx('asst.placeholder')}</label>
150
151
  <input id="${id('placeholder')}" name="placeholder" maxlength="200" value="${cfg.placeholder ?? ''}"></div>
151
152
  </div>
152
- <div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> Also for users who are not signed in</label></div>
153
- <div class="buttons"><button class="btn">Save</button></div>
153
+ <div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> ${tx('asst.also_for_users_who_are_2')}</label></div>
154
+ <div class="buttons"><button class="btn">${tx('asst.save')}</button></div>
154
155
  </form>`;
155
156
  }
156
157
 
@@ -170,7 +171,7 @@ export async function assistantBuilderRoutes(app: FastifyInstance) {
170
171
  const { config, errors } = await mergeAssistantSettings(r.app_id, r.config ?? {}, (req.body ?? {}) as Body);
171
172
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
172
173
  if (errors.length) flash(s, `Saved, but: ${errors.join(' ')}`, 'error');
173
- else flash(s, 'Settings saved.');
174
+ else flash(s, tx('asst.settings_saved'));
174
175
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
175
176
  });
176
177
 
@@ -193,7 +194,7 @@ export async function assistantBuilderRoutes(app: FastifyInstance) {
193
194
  if (errors.length) flash(s, errors.join(' '), 'error');
194
195
  else {
195
196
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
196
- flash(s, 'Settings saved.');
197
+ flash(s, tx('asst.settings_saved'));
197
198
  }
198
199
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
199
200
  });
@@ -5,6 +5,7 @@ import { html, type Raw } from '../html.ts';
5
5
  import { icon } from '../icons.ts';
6
6
  import type { Session } from '../session.ts';
7
7
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // Shared Components → Automations: the actions (add, edit, reorder),
10
11
  // next run, Run now and the run history with the errors per row, under the
@@ -23,13 +24,13 @@ export function actionPrefill(automation: string, actions: any[]) {
23
24
  /** Under an action's form: back to its automation. */
24
25
  export function actionExtras(row: any, automations: any[]): Raw {
25
26
  const a = automations.find((x) => x.name === row.automation_name);
26
- return a ? html`<p class="u-mt1"><a class="btn" href="?c=automation-${a.id}">${icon('clock')} Back to ${a.name}</a></p>` : html``;
27
+ return a ? html`<p class="u-mt1"><a class="btn" href="?c=automation-${a.id}">${icon('clock')} ${tx('au.back_to', { name: a.name })}</a></p>` : html``;
27
28
  }
28
29
 
29
30
  function rowErrors(errors: RowError[] | null, failed: number | null): Raw {
30
31
  if (!errors?.length) return html``;
31
- return html`<details><summary>${failed ?? errors.length} failed row(s)${failed && failed > errors.length ? ` (first ${errors.length})` : ''}</summary>
32
- <ul class="small">${errors.map((e) => html`<li>Row ${e.row}, action <b>${e.action}</b>: ${e.message}${e.values ? html`<br><code>${e.values}</code>` : ''}</li>`)}</ul></details>`;
32
+ return html`<details><summary>${tx('au.failed_rows', { n: failed ?? errors.length })}${failed && failed > errors.length ? ` ${tx('au.first', { n: errors.length })}` : ''}</summary>
33
+ <ul class="small">${errors.map((e) => html`<li>${tx.rich('au.row_action', { row: e.row, action: html`<b>${e.action}</b>` })}: ${e.message}${e.values ? html`<br><code>${e.values}</code>` : ''}</li>`)}</ul></details>`;
33
34
  }
34
35
 
35
36
  /** Actions, next run, Run now and the last runs of an automation. */
@@ -43,44 +44,44 @@ export async function automationExtras(appId: number, row: { id: number; name: s
43
44
  ).rows;
44
45
  let next: string;
45
46
  try {
46
- next = row.enabled ? when(nextRun(parseCron(row.schedule), row.time_zone, new Date())) : 'not scheduled (disabled)';
47
+ next = row.enabled ? when(nextRun(parseCron(row.schedule), row.time_zone, new Date())) : tx('au.not_scheduled');
47
48
  } catch (e) {
48
- next = `never: ${(e as Error).message}`;
49
+ next = tx('au.never', { message: (e as Error).message });
49
50
  }
50
- const scheduler = process.env.AUTOMATIONS === 'off' ? html` <b>(the scheduler is off on this server: AUTOMATIONS=off)</b>` : '';
51
+ const scheduler = process.env.AUTOMATIONS === 'off' ? html` <b>${tx('au.scheduler_off')}</b>` : '';
51
52
  const move = (id: number, dir: 'up' | 'down', label: string, disabled: boolean) =>
52
53
  html`<form method="post" action="${BASE}/apps/${appId}/shared/automation_action/${id}/move" class="u-inline">${csrf(s)}<input type="hidden" name="dir" value="${dir}"><button class="link-button" aria-label="${label}"${disabled ? html` disabled` : ''}>${dir === 'up' ? '↑' : '↓'}</button></form>`;
53
- const actionsHtml = html`<fieldset class="prop-group u-mt125"><legend>Actions</legend>
54
- <p class="muted u-mt0">Run in this order${row.query?.trim() ? ', for each row of the query' : ', once'}, in one transaction${row.error_handling === 'skip' && row.query?.trim() ? ' per run; a failing row is rolled back and skipped' : ''}.</p>
54
+ const actionsHtml = html`<fieldset class="prop-group u-mt125"><legend>${tx('au.actions')}</legend>
55
+ <p class="muted u-mt0">${tx(!row.query?.trim() ? 'au.order_once' : row.error_handling === 'skip' ? 'au.order_rows_skip' : 'au.order_rows')}</p>
55
56
  ${actions.length
56
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">Seq</th><th>Action</th><th>Condition</th><th>Order</th></tr></thead><tbody>
57
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">${tx('au.seq')}</th><th>${tx('au.action')}</th><th>${tx('au.condition')}</th><th>${tx('au.order')}</th></tr></thead><tbody>
57
58
  ${actions.map((x, i) => html`<tr>
58
- <td class="num" data-label="Seq">${x.seq}</td>
59
- <td data-label="Action"><a href="?c=automation_action-${x.id}">${x.name}</a></td>
60
- <td data-label="Condition">${x.condition ? html`<code>${x.condition.length > 80 ? x.condition.slice(0, 80) + '…' : x.condition}</code>` : html`<span class="muted">always</span>`}</td>
61
- <td data-label="Order">${move(x.id, 'up', `Move ${x.name} up`, i === 0)} ${move(x.id, 'down', `Move ${x.name} down`, i === actions.length - 1)}</td></tr>`)}
59
+ <td class="num" data-label="${tx('au.seq')}">${x.seq}</td>
60
+ <td data-label="${tx('au.action')}"><a href="?c=automation_action-${x.id}">${x.name}</a></td>
61
+ <td data-label="${tx('au.condition')}">${x.condition ? html`<code>${x.condition.length > 80 ? x.condition.slice(0, 80) + '…' : x.condition}</code>` : html`<span class="muted">${tx('au.always')}</span>`}</td>
62
+ <td data-label="${tx('au.order')}">${move(x.id, 'up', tx('au.move_up', { name: x.name }), i === 0)} ${move(x.id, 'down', tx('au.move_down', { name: x.name }), i === actions.length - 1)}</td></tr>`)}
62
63
  </tbody></table></div>`
63
- : html`<p class="muted">No actions yet: a run does nothing.</p>`}
64
- <p><a class="btn" href="?new=automation_action&amp;automation=${encodeURIComponent(row.name)}">+ Add action</a></p>
64
+ : html`<p class="muted">${tx('au.no_actions')}</p>`}
65
+ <p><a class="btn" href="?new=automation_action&amp;automation=${encodeURIComponent(row.name)}">+ ${tx('au.add_action')}</a></p>
65
66
  </fieldset>`;
66
- return html`${actionsHtml}<fieldset class="prop-group u-mt125"><legend>Runs</legend>
67
- <p>Next run: <b>${next}</b>${scheduler}</p>
68
- <p class="muted">From application code: <code>select meta.run_automation('${row.name}')</code> runs it now, in the caller's transaction.</p>
67
+ return html`${actionsHtml}<fieldset class="prop-group u-mt125"><legend>${tx('au.runs')}</legend>
68
+ <p>${tx.rich('au.next_run', { when: html`<b>${next}</b>` })}${scheduler}</p>
69
+ <p class="muted">${tx.rich('au.from_code', { call: html`<code>select meta.run_automation('${row.name}')</code>` })}</p>
69
70
  <form method="post" action="${BASE}/apps/${appId}/shared/automation/${row.id}/run">${csrf(s)}
70
- <button class="btn">${icon('play')} Run now</button>
71
+ <button class="btn">${icon('play')} ${tx('au.run_now')}</button>
71
72
  </form>
72
73
  ${log.length
73
- ? html`<div class="table-wrap u-mt075"><table class="report report-reflow"><thead><tr><th>Started</th><th>Took</th><th>By</th><th>Status</th><th class="num">Rows</th><th class="num">Failed</th><th>Message</th></tr></thead><tbody>
74
+ ? html`<div class="table-wrap u-mt075"><table class="report report-reflow"><thead><tr><th>${tx('au.started')}</th><th>${tx('au.took')}</th><th>${tx('au.by')}</th><th>${tx('au.status')}</th><th class="num">${tx('au.rows')}</th><th class="num">${tx('au.failed')}</th><th>${tx('au.message')}</th></tr></thead><tbody>
74
75
  ${log.map((l) => html`<tr>
75
- <td data-label="Started">${when(l.started_at)}</td>
76
- <td data-label="Took">${l.finished_at ? `${((new Date(l.finished_at).getTime() - new Date(l.started_at).getTime()) / 1000).toFixed(1)} s` : '…'}</td>
77
- <td data-label="By">${l.trigger}${l.run_by ? html` <span class="muted">(${l.run_by})</span>` : ''}</td>
78
- <td data-label="Status"><span class="tag${STATUS_TAG[l.status] ?? ''}">${l.status}</span></td>
79
- <td class="num" data-label="Rows">${l.rows ?? ''}</td>
80
- <td class="num" data-label="Failed">${l.rows_failed || ''}</td>
81
- <td data-label="Message">${l.message ?? ''}${rowErrors(l.errors, l.rows_failed)}</td></tr>`)}
76
+ <td data-label="${tx('au.started')}">${when(l.started_at)}</td>
77
+ <td data-label="${tx('au.took')}">${l.finished_at ? `${((new Date(l.finished_at).getTime() - new Date(l.started_at).getTime()) / 1000).toFixed(1)} s` : '…'}</td>
78
+ <td data-label="${tx('au.by')}">${l.trigger}${l.run_by ? html` <span class="muted">(${l.run_by})</span>` : ''}</td>
79
+ <td data-label="${tx('au.status')}"><span class="tag${STATUS_TAG[l.status] ?? ''}">${l.status}</span></td>
80
+ <td class="num" data-label="${tx('au.rows')}">${l.rows ?? ''}</td>
81
+ <td class="num" data-label="${tx('au.failed')}">${l.rows_failed || ''}</td>
82
+ <td data-label="${tx('au.message')}">${l.message ?? ''}${rowErrors(l.errors, l.rows_failed)}</td></tr>`)}
82
83
  </tbody></table></div>`
83
- : html`<p class="muted">No runs yet.</p>`}
84
+ : html`<p class="muted">${tx('au.no_runs')}</p>`}
84
85
  </fieldset>`;
85
86
  }
86
87
 
@@ -93,9 +94,9 @@ export async function automationRoutes(app: FastifyInstance) {
93
94
  const a = /^\d+$/.test(id) && /^\d+$/.test(cid) ? await owner.one('select id from meta.automation where id = $1 and app_id = $2', [cid, id]) : undefined;
94
95
  if (!a) return reply.code(404).send('Not found');
95
96
  const r = await runAutomation(a.id, 'manual');
96
- if (r.status === 'ok') flash(s, `Ran successfully${r.rows ? ` for ${r.rows} row(s)` : ''}.`);
97
- else if (r.status === 'warning') flash(s, `Ran with errors: ${r.message}. See the run history.`, 'error');
98
- else flash(s, r.message ?? 'The automation failed.', 'error');
97
+ if (r.status === 'ok') flash(s, r.rows ? tx('au.ran_rows', { n: r.rows }) : tx('au.ran'));
98
+ else if (r.status === 'warning') flash(s, tx('au.ran_errors', { message: r.message ?? '' }), 'error');
99
+ else flash(s, r.message ?? tx('au.failed_run'), 'error');
99
100
  return back(reply, s, target);
100
101
  });
101
102
 
@@ -4,18 +4,21 @@ import { html, raw } from '../html.ts';
4
4
  import { clientIp, logActivity } from '../session.ts';
5
5
  import { appOr404 } from './forms.ts';
6
6
  import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
7
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
7
8
 
8
9
  // Application → Availability (APEX: Application Availability and Build Status,
9
10
  // migration 083): who may use the running application, the message they see
10
11
  // otherwise, and whether the builder may change it (Run only). Allowed on a
11
12
  // Run only application (src/builder/locks.ts), logged as "availability".
12
13
 
13
- const MODES: [string, string][] = [
14
- ['available', 'Available'],
15
- ['developers', 'Available to developers only'],
16
- ['restricted', 'Restricted to the users below (and developers)'],
17
- ['unavailable', 'Unavailable'],
14
+ const MODE_KEYS: [string, BuilderKey][] = [
15
+ ['available', 'avl.mode.available'],
16
+ ['developers', 'avl.mode.developers'],
17
+ ['restricted', 'avl.mode.restricted'],
18
+ ['unavailable', 'avl.mode.unavailable'],
18
19
  ];
20
+ /** The statuses with their labels in the developer's language. */
21
+ const modes = (): [string, string][] => MODE_KEYS.map(([v, k]) => [v, tx(k)]);
19
22
 
20
23
  export async function availabilityRoutes(app: FastifyInstance) {
21
24
  app.get(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
@@ -23,35 +26,32 @@ export async function availabilityRoutes(app: FastifyInstance) {
23
26
  if (!s) return;
24
27
  const a = await appOr404(req.params.id);
25
28
  if (!a) return reply.code(404).send('Not found');
26
- const body = region('Availability', html`<form method="post" action="${BASE}/apps/${a.id}/availability">${csrf(s)}
27
- <p class="muted u-mt0">Who may use the running application. Developers are those signed in to the builder in the same browser
28
- (they see the developer toolbar). Pages, file downloads, REST modules and the REST API follow this setting; automations,
29
- workflows, background jobs and queued mail keep running. It belongs to this installation: exports leave it out and an
30
- import over the application keeps it.</p>
29
+ const body = region(tx('app.head.availability'), html`<form method="post" action="${BASE}/apps/${a.id}/availability">${csrf(s)}
30
+ <p class="muted u-mt0">${tx('avl.intro')}</p>
31
31
  <div class="form-grid">
32
- ${select('availability', 'Status', a.availability, MODES)}
33
- <div class="field" data-wide><label class="label" for="f_message">Message</label>
32
+ ${select('availability', tx('avl.status'), a.availability, modes())}
33
+ <div class="field" data-wide><label class="label" for="f_message">${tx('avl.message')}</label>
34
34
  <textarea id="f_message" name="message" rows="3" maxlength="2000">${a.availability_message ?? ''}</textarea>
35
- <small class="help">Shown when the application is unavailable or for developers only, e.g. "We are upgrading the application; back at 14:00." Empty: a standard message in the user's language.</small></div>
36
- <div class="field" data-wide><label class="label" for="f_users">Users (restricted)</label>
35
+ <small class="help">${tx('avl.message_help')}</small></div>
36
+ <div class="field" data-wide><label class="label" for="f_users">${tx('avl.users')}</label>
37
37
  <textarea id="f_users" name="users" rows="2">${(a.availability_users ?? []).join(', ')}</textarea>
38
- <small class="help">User names separated by commas or spaces. They sign in as usual; others get "not available to your account".</small></div>
39
- ${select('build_status', 'Build status', a.build_status, [['run_and_build', 'Run and build'], ['run_only', 'Run only']],
40
- 'Run only: the builder refuses changes to the application (pages, shared components, settings, restores) until it is set back here. pgkiln import --replace still deploys it.')}
38
+ <small class="help">${tx('avl.users_help')}</small></div>
39
+ ${select('build_status', tx('avl.build_status'), a.build_status, [['run_and_build', tx('avl.run_and_build')], ['run_only', tx('avl.run_only')]],
40
+ tx('avl.build_status_help'))}
41
41
  </div>
42
- <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`);
42
+ <div class="buttons"><button class="btn btn-hot">${tx('common.save')}</button></div></form>`);
43
43
  const main = html`${appHeader(a, 'settings', s)}<div class="ide-body">${body}</div>`;
44
- return send(reply, s, shell(s, `${a.name} availability`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Availability']], main));
44
+ return send(reply, s, shell(s, tx('avl.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.head.availability')]], main));
45
45
  });
46
46
 
47
47
  app.post(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
48
48
  const s = await developer(req, reply);
49
49
  if (!s) return;
50
50
  const b = req.body ?? {};
51
- const mode = MODES.some(([v]) => v === b.availability) ? b.availability : 'available';
51
+ const mode = MODE_KEYS.some(([v]) => v === b.availability) ? b.availability : 'available';
52
52
  const users = [...new Set(String(b.users ?? '').split(/[\s,;]+/).map((u) => u.trim()).filter(Boolean))];
53
53
  if (users.some((u) => u.length > 100 || /[:\s]/.test(u))) {
54
- flash(s, 'User names are at most 100 characters, without spaces or colons.', 'error');
54
+ flash(s, tx('avl.users_invalid'), 'error');
55
55
  return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
56
56
  }
57
57
  const build = b.build_status === 'run_only' ? 'run_only' : 'run_and_build';
@@ -59,7 +59,7 @@ export async function availabilityRoutes(app: FastifyInstance) {
59
59
  `update meta.app set availability = $2, availability_message = $3, availability_users = $4, build_status = $5, updated_at = now() where id = $1`,
60
60
  [req.params.id, mode, String(b.message ?? '').trim().slice(0, 2000) || null, users, build]);
61
61
  if (r.rowCount) await logActivity({ appId: Number(req.params.id), username: s.username, event: 'availability', ip: clientIp(req), detail: `${mode}, ${build}` });
62
- flash(s, r.rowCount ? 'Availability saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
62
+ flash(s, r.rowCount ? tx('avl.saved') : tx('avl.not_found'), r.rowCount ? 'ok' : 'error');
63
63
  return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
64
64
  });
65
65
  }
@@ -68,7 +68,7 @@ export async function availabilityRoutes(app: FastifyInstance) {
68
68
  export const availabilityBadge = (a: { id: number; availability?: string; build_status?: string }) =>
69
69
  a.availability && a.availability !== 'available' || a.build_status === 'run_only'
70
70
  ? html`<a class="tag tag-warning availability-badge" href="${BASE}/apps/${a.id}/availability">${[
71
- a.availability && a.availability !== 'available' ? MODES.find(([v]) => v === a.availability)?.[1] : null,
72
- a.build_status === 'run_only' ? 'Run only' : null,
71
+ a.availability && a.availability !== 'available' ? modes().find(([v]) => v === a.availability)?.[1] : null,
72
+ a.build_status === 'run_only' ? tx('avl.run_only') : null,
73
73
  ].filter(Boolean).join(' · ')}</a>`
74
74
  : raw('');
@@ -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
  }