pgkiln 0.83.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/db/migrations/154_notifications.sql +186 -0
  2. package/docs/apex-feature-parity.md +3 -2
  3. package/docs/guide/03-builder.md +16 -5
  4. package/docs/guide/04-pages-and-regions.md +42 -0
  5. package/docs/guide/06-processing.md +22 -1
  6. package/docs/guide/07-dynamic-actions.md +1 -0
  7. package/docs/guide/09-reference.md +15 -0
  8. package/docs/guide/11-from-apex.md +1 -0
  9. package/docs/guide/12-development.md +13 -1
  10. package/package.json +1 -1
  11. package/public/app.css +29 -0
  12. package/public/app.js +78 -0
  13. package/public/sw.js +5 -1
  14. package/src/app.ts +11 -0
  15. package/src/builder/ai-agents.ts +86 -85
  16. package/src/builder/ai-builder.ts +30 -30
  17. package/src/builder/ai.ts +93 -92
  18. package/src/builder/api.ts +47 -47
  19. package/src/builder/app-groups.ts +33 -32
  20. package/src/builder/appfromfile.ts +57 -58
  21. package/src/builder/appsheets.ts +33 -32
  22. package/src/builder/appwizard.ts +65 -65
  23. package/src/builder/arrange.ts +23 -22
  24. package/src/builder/assistant.ts +39 -38
  25. package/src/builder/automations.ts +32 -31
  26. package/src/builder/availability.ts +24 -24
  27. package/src/builder/backups.ts +30 -31
  28. package/src/builder/blueprints.ts +62 -61
  29. package/src/builder/code-editor.ts +1 -1
  30. package/src/builder/components.ts +10 -6
  31. package/src/builder/create-page.ts +52 -50
  32. package/src/builder/cross-app.ts +37 -35
  33. package/src/builder/dataload.ts +2 -2
  34. package/src/builder/datasets.ts +107 -106
  35. package/src/builder/dependencies.ts +20 -19
  36. package/src/builder/designer.ts +36 -36
  37. package/src/builder/diagnostics.ts +58 -57
  38. package/src/builder/features.ts +39 -35
  39. package/src/builder/feedback.ts +23 -20
  40. package/src/builder/find-workspace.ts +28 -24
  41. package/src/builder/form-designer.ts +36 -35
  42. package/src/builder/gallery.ts +25 -29
  43. package/src/builder/globalization.ts +50 -49
  44. package/src/builder/i18n/de.ts +3461 -0
  45. package/src/builder/i18n/en.ts +3461 -0
  46. package/src/builder/i18n/es.ts +3461 -0
  47. package/src/builder/i18n/fr.ts +3461 -0
  48. package/src/builder/i18n/it.ts +3461 -0
  49. package/src/builder/i18n/ja.ts +3461 -0
  50. package/src/builder/i18n/ko.ts +3461 -0
  51. package/src/builder/i18n/nl.ts +3461 -0
  52. package/src/builder/i18n/pt.ts +3461 -0
  53. package/src/builder/i18n/zh-cn.ts +3461 -0
  54. package/src/builder/i18n/zh-tw.ts +3461 -0
  55. package/src/builder/i18n.ts +17 -3
  56. package/src/builder/json-sources.ts +63 -62
  57. package/src/builder/layouts.ts +12 -11
  58. package/src/builder/locks.ts +29 -28
  59. package/src/builder/login-page.ts +26 -25
  60. package/src/builder/mail.ts +148 -147
  61. package/src/builder/newapp.ts +15 -12
  62. package/src/builder/notification-settings.ts +27 -0
  63. package/src/builder/plugins.ts +47 -44
  64. package/src/builder/process-jobs.ts +11 -10
  65. package/src/builder/pwa.ts +22 -21
  66. package/src/builder/remote-databases.ts +55 -56
  67. package/src/builder/report-settings.ts +1 -1
  68. package/src/builder/reporter.ts +43 -42
  69. package/src/builder/rest-catalogs.ts +87 -86
  70. package/src/builder/rest.ts +9 -8
  71. package/src/builder/routes.ts +30 -27
  72. package/src/builder/search.ts +14 -13
  73. package/src/builder/static-files.ts +31 -32
  74. package/src/builder/subscriptions.ts +47 -45
  75. package/src/builder/supporting.ts +23 -18
  76. package/src/builder/team.ts +179 -179
  77. package/src/builder/telemetry.ts +59 -62
  78. package/src/builder/templates.ts +66 -65
  79. package/src/builder/themeroller.ts +60 -63
  80. package/src/builder/ui-defaults.ts +21 -20
  81. package/src/builder/ui.ts +2 -1
  82. package/src/builder/upgrade.ts +25 -24
  83. package/src/builder/utilities.ts +48 -47
  84. package/src/builder/vector-providers.ts +67 -66
  85. package/src/builder/websources.ts +43 -42
  86. package/src/builder/wizards.ts +95 -89
  87. package/src/builder/workflow-migrate.ts +30 -31
  88. package/src/builder/workflows.ts +40 -39
  89. package/src/builder/workingcopies.ts +41 -49
  90. package/src/builder/workspace-backups.ts +21 -20
  91. package/src/builder/workspace-mail.ts +17 -16
  92. package/src/builder/workspace-resources.ts +104 -112
  93. package/src/builder/workspace-transfer.ts +34 -26
  94. package/src/cli/replace.ts +2 -0
  95. package/src/i18n/ar.ts +9 -0
  96. package/src/i18n/cs.ts +9 -0
  97. package/src/i18n/da.ts +9 -0
  98. package/src/i18n/de.ts +9 -0
  99. package/src/i18n/el.ts +9 -0
  100. package/src/i18n/es.ts +9 -0
  101. package/src/i18n/fi.ts +9 -0
  102. package/src/i18n/fr.ts +9 -0
  103. package/src/i18n/he.ts +9 -0
  104. package/src/i18n/it.ts +9 -0
  105. package/src/i18n/ja.ts +9 -0
  106. package/src/i18n/ko.ts +9 -0
  107. package/src/i18n/nb.ts +9 -0
  108. package/src/i18n/pl.ts +9 -0
  109. package/src/i18n/pt.ts +9 -0
  110. package/src/i18n/ru.ts +9 -0
  111. package/src/i18n/sv.ts +9 -0
  112. package/src/i18n/tr.ts +9 -0
  113. package/src/i18n/uk.ts +9 -0
  114. package/src/i18n/zh.ts +9 -0
  115. package/src/i18n.ts +18 -0
  116. package/src/metadata.ts +9 -3
  117. package/src/runtime/engine.ts +2 -1
  118. package/src/runtime/feedback.ts +1 -1
  119. package/src/runtime/lists.ts +43 -11
  120. package/src/runtime/notifications.ts +237 -0
  121. package/src/runtime/processes.ts +45 -1
  122. package/src/runtime/regions.ts +11 -15
  123. package/src/runtime/render.ts +12 -3
  124. package/src/runtime/routes.ts +12 -4
  125. package/src/runtime/signin.ts +2 -1
  126. package/src/session.ts +13 -4
@@ -143,9 +143,23 @@ export const builderMessages = (lang: BuilderLang): Readonly<Record<string, stri
143
143
  // (sprint 162, CD-8) Long form builders (region and report settings) take the translator of the request being rendered
144
144
  // from the async context instead of a parameter on every helper: withTranslator(tr(s), () => build()), and inside,
145
145
  // scopedTx('key') like tx. Outside withTranslator it is English.
146
- const SCOPE = new AsyncLocalStorage<BuilderTranslate>();
147
- const current = () => SCOPE.getStore() ?? translatorFor('en');
148
- export const withTranslator = <T>(t: BuilderTranslate, fn: () => T): T => SCOPE.run(t, fn);
146
+ // (sprint 164) Every builder route runs in a request scope (app.ts wraps the handlers of /builder routes): developer()
147
+ // sets the developer's translator in it once the session is loaded, so scopedTx works in any builder handler and the
148
+ // helpers it calls; before that (the sign-in pages) it is the device's language (the cookie).
149
+ interface Scope { t?: BuilderTranslate; req?: FastifyRequest }
150
+ const SCOPE = new AsyncLocalStorage<Scope>();
151
+ const current = () => {
152
+ const sc = SCOPE.getStore();
153
+ return sc?.t ?? (sc?.req ? translatorFor(cookieLanguage(sc.req)) : translatorFor('en'));
154
+ };
155
+ export const withTranslator = <T>(t: BuilderTranslate, fn: () => T): T => SCOPE.run({ t }, fn);
156
+ /** (sprint 164) Runs a builder request's handler in its own scope (app.ts). */
157
+ export const inRequestScope = <T>(req: FastifyRequest, fn: () => T): T => SCOPE.run({ req }, fn);
158
+ /** (sprint 164) The signed-in developer's translator for the rest of the request (developer() in ui.ts). */
159
+ export const setRequestTranslator = (t: BuilderTranslate) => {
160
+ const sc = SCOPE.getStore();
161
+ if (sc) sc.t = t;
162
+ };
149
163
  export const scopedTx = ((key: BuilderKey, params?: Params) => current()(key, params)) as BuilderTranslate;
150
164
  Object.defineProperty(scopedTx, 'lang', { get: () => current().lang });
151
165
  scopedTx.dyn = (key, fallback, params) => current().dyn(key, fallback, params);
@@ -13,6 +13,7 @@ import { clearCompletions } from './code-editor.ts';
13
13
  import { appOr404 } from './forms.ts';
14
14
  import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
15
15
  import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
16
+ import { scopedTx as tx } from './i18n.ts';
16
17
 
17
18
  // (sprint 142, AP-8; APEX: Shared Components → Data Sources → JSON Sources)
18
19
  //
@@ -30,8 +31,8 @@ const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v)
30
31
  const SYSTEM = ['pg_catalog', 'information_schema', 'meta'];
31
32
 
32
33
  const crumbs = (a: { id: number; name: string }, last?: string): [string, string?][] => [
33
- ['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`],
34
- ...(last ? [['JSON sources', `${BASE}/apps/${a.id}/json-sources`], [last]] as [string, string?][] : [['JSON sources']] as [string, string?][]),
34
+ [tx('jsrc.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('jsrc.shared_components'), `${BASE}/apps/${a.id}/shared`],
35
+ ...(last ? [[tx('jsrc.json_sources'), `${BASE}/apps/${a.id}/json-sources`], [last]] as [string, string?][] : [[tx('jsrc.json_sources')]] as [string, string?][]),
35
36
  ];
36
37
  const listOf = (a: any) => (Array.isArray(a.json_sources) ? a.json_sources : []) as JsonSource[];
37
38
  const srcUrl = (appId: number, name: string) => `${BASE}/apps/${appId}/json-sources/source?name=${encodeURIComponent(name)}`;
@@ -61,12 +62,12 @@ async function viewState(db: Db, x: JsonSource) {
61
62
 
62
63
  function columnsTable(prefix: string, cols: JsonColumn[], extra: number, emptyPath: boolean): Raw {
63
64
  const rows = [...cols, ...Array.from({ length: extra }, (): JsonColumn => ({ name: '', path: [], type: 'text' }))];
64
- return html`<div class="table-wrap"><table class="report js-cols"><thead><tr><th scope="col">Column</th><th scope="col">Path</th><th scope="col">Type</th><th scope="col">Read only</th></tr></thead><tbody>
65
+ return html`<div class="table-wrap"><table class="report js-cols"><thead><tr><th scope="col">${tx('jsrc.column')}</th><th scope="col">${tx('jsrc.path')}</th><th scope="col">${tx('jsrc.type')}</th><th scope="col">${tx('jsrc.read_only')}</th></tr></thead><tbody>
65
66
  ${rows.map((c, i) => html`<tr>
66
- <td><input name="${prefix}.${i}.name" value="${c.name}" class="code" aria-label="Column ${i + 1}: name" autocomplete="off" spellcheck="false"></td>
67
- <td><input name="${prefix}.${i}.path" value="${pathText(c.path)}" class="code" aria-label="Column ${i + 1}: path" autocomplete="off" spellcheck="false"${emptyPath ? raw(' placeholder="(the element itself)"') : ''}></td>
68
- <td><select name="${prefix}.${i}.type" aria-label="Column ${i + 1}: type">${JSON_TYPES.map((t) => html`<option${t === c.type ? raw(' selected') : ''}>${t}</option>`)}</select></td>
69
- <td class="te-c"><input type="checkbox" name="${prefix}.${i}.readonly" aria-label="Column ${i + 1}: read only"${c.readonly ? raw(' checked') : ''}></td></tr>`)}
67
+ <td><input name="${prefix}.${i}.name" value="${c.name}" class="code" aria-label="${tx('jsrc.aria_name', { n: i + 1 })}" autocomplete="off" spellcheck="false"></td>
68
+ <td><input name="${prefix}.${i}.path" value="${pathText(c.path)}" class="code" aria-label="${tx('jsrc.aria_path', { n: i + 1 })}" autocomplete="off" spellcheck="false"${emptyPath ? html` placeholder="${tx('jsrc.the_element_itself')}"` : ''}></td>
69
+ <td><select name="${prefix}.${i}.type" aria-label="${tx('jsrc.aria_type', { n: i + 1 })}">${JSON_TYPES.map((t) => html`<option${t === c.type ? raw(' selected') : ''}>${t}</option>`)}</select></td>
70
+ <td class="te-c"><input type="checkbox" name="${prefix}.${i}.readonly" aria-label="${tx('jsrc.aria_readonly', { n: i + 1 })}"${c.readonly ? raw(' checked') : ''}></td></tr>`)}
70
71
  </tbody></table></div>`;
71
72
  }
72
73
 
@@ -76,33 +77,33 @@ function sourceForm(a: any, s: Session, x: JsonSource, original: string, problem
76
77
  if (cur && !opts.includes(cur)) opts.unshift(cur);
77
78
  return html`${problems.length ? html`<div class="alert alert-error" role="alert"><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : ''}
78
79
  <form method="post" action="${BASE}/apps/${a.id}/json-sources/source">${csrf(s)}<input type="hidden" name="original" value="${original}">
79
- <fieldset class="prop-group"><legend>JSON source</legend><div class="form-grid">
80
- ${input('name', 'Name (static ID)', x.name, { required: true, placeholder: 'e.g. EMP_PROFILE', help: 'Upper case letters, digits and _.' })}
81
- ${input('description', 'Description', x.description ?? '')}
82
- <div class="field"><label class="label" for="js-source">Table and jsonb column</label>
83
- <select id="js-source" name="source">${opts.length ? opts.map((o) => html`<option${o === cur ? raw(' selected') : ''}>${o}</option>`) : html`<option value="">(no table with a json or jsonb column)</option>`}</select>
84
- <small class="help">A column of a table, or the document column of a table of documents. The table needs a primary key.</small></div>
85
- ${input('view', 'View', x.view, { required: true, placeholder: 'e.g. emp_profile', help: 'Made in the table\'s schema. Regions, REST and MCP use it like a table.' })}
86
- <div class="field"><label class="check"><input type="checkbox" name="rows"${x.rows ? raw(' checked') : ''}> The view also adds and deletes rows of the table</label>
87
- <small class="help">For a table of documents: an insert into the view adds a row, a delete removes it. Off: the view only changes the documents of existing rows.</small></div>
80
+ <fieldset class="prop-group"><legend>${tx('jsrc.json_source')}</legend><div class="form-grid">
81
+ ${input('name', tx('jsrc.name_static_id'), x.name, { required: true, placeholder: tx('jsrc.e_g_emp_profile'), help: tx('jsrc.upper_case_letters_digits_and') })}
82
+ ${input('description', tx('jsrc.description'), x.description ?? '')}
83
+ <div class="field"><label class="label" for="js-source">${tx('jsrc.table_and_jsonb_column')}</label>
84
+ <select id="js-source" name="source">${opts.length ? opts.map((o) => html`<option${o === cur ? raw(' selected') : ''}>${o}</option>`) : html`<option value="">${tx('jsrc.no_json_table')}</option>`}</select>
85
+ <small class="help">${tx('jsrc.a_column_of_a_table')}</small></div>
86
+ ${input('view', tx('jsrc.view'), x.view, { required: true, placeholder: tx('jsrc.e_g_emp_profile_2'), help: tx('jsrc.made_in_the_table_s') })}
87
+ <div class="field"><label class="check"><input type="checkbox" name="rows"${x.rows ? raw(' checked') : ''}> ${tx('jsrc.the_view_also_adds_and')}</label>
88
+ <small class="help">${tx('jsrc.for_a_table_of_documents')}</small></div>
88
89
  </div></fieldset>
89
- <fieldset class="prop-group"><legend>Columns</legend>
90
- <p class="muted u-mt0">A column per path in the document (keys separated by dots, e.g. <code>address.city</code>). A value that doesn't read as the type shows empty. Saving a row writes only the paths whose values changed: keys this list doesn't name stay in the document. Clear a name to remove a column.</p>
90
+ <fieldset class="prop-group"><legend>${tx('jsrc.columns')}</legend>
91
+ <p class="muted u-mt0">${tx.code('jsrc.a_column_per_path_in')}</p>
91
92
  ${columnsTable('c', x.columns, 3, false)}</fieldset>
92
93
  ${x.arrays.map((y, j) => html`<fieldset class="prop-group"><legend>Array ${y.name}</legend><div class="form-grid">
93
- ${input(`a.${j}.name`, 'Name', y.name, { required: true })}
94
- ${input(`a.${j}.path`, 'Path', pathText(y.path), { required: true, help: 'The array in the document, e.g. skills.' })}
95
- ${input(`a.${j}.view`, 'View', y.view, { required: true, help: `One row per element: the table's key, ${ROW_KEY} (the element's key) and ${POSITION} (1, 2 …), then these columns. Use it as a detail grid.` })}
96
- <div class="field"><label class="check"><input type="checkbox" name="a.${j}.drop"> Remove this array</label></div>
94
+ ${input(`a.${j}.name`, tx('jsrc.name'), y.name, { required: true })}
95
+ ${input(`a.${j}.path`, tx('jsrc.path'), pathText(y.path), { required: true, help: tx('jsrc.the_array_in_the_document') })}
96
+ ${input(`a.${j}.view`, tx('jsrc.view'), y.view, { required: true, help: tx('jsrc.array_view_help', { rowkey: ROW_KEY, position: POSITION }) })}
97
+ <div class="field"><label class="check"><input type="checkbox" name="a.${j}.drop"> ${tx('jsrc.remove_this_array')}</label></div>
97
98
  </div>${columnsTable(`a.${j}.c`, y.columns, 2, true)}</fieldset>`)}
98
- <fieldset class="prop-group"><legend>Add an array</legend><div class="form-grid">
99
- ${input(`a.${x.arrays.length}.name`, 'Name', '', { placeholder: 'e.g. skills' })}
100
- ${input(`a.${x.arrays.length}.path`, 'Path', '', { placeholder: 'e.g. skills' })}
101
- ${input(`a.${x.arrays.length}.view`, 'View', '', { placeholder: 'e.g. emp_profile_skills' })}
102
- </div><small class="help">Add the array, then Propose from the data fills its columns (or type them after saving).</small></fieldset>
99
+ <fieldset class="prop-group"><legend>${tx('jsrc.add_an_array')}</legend><div class="form-grid">
100
+ ${input(`a.${x.arrays.length}.name`, tx('jsrc.name'), '', { placeholder: tx('jsrc.e_g_skills') })}
101
+ ${input(`a.${x.arrays.length}.path`, tx('jsrc.path'), '', { placeholder: tx('jsrc.e_g_skills') })}
102
+ ${input(`a.${x.arrays.length}.view`, tx('jsrc.view'), '', { placeholder: tx('jsrc.e_g_emp_profile_skills') })}
103
+ </div><small class="help">${tx('jsrc.add_the_array_then_propose')}</small></fieldset>
103
104
  <div class="buttons">
104
- <button class="btn btn-hot" name="mode" value="save">${original ? 'Save' : 'Add JSON source'}</button>
105
- <button class="btn" name="mode" value="propose" formnovalidate>${icon('lightbulb')} Propose from the data</button>
105
+ <button class="btn btn-hot" name="mode" value="save">${original ? tx('jsrc.save') : tx('jsrc.add_json_source')}</button>
106
+ <button class="btn" name="mode" value="propose" formnovalidate>${icon('lightbulb')} ${tx('jsrc.propose_from_the_data')}</button>
106
107
  </div>
107
108
  </form>`;
108
109
  }
@@ -157,11 +158,11 @@ const label = (n: string) => n.replace(/_/g, ' ').replace(/^./, (c) => c.toUpper
157
158
 
158
159
  /** A form on the source's view with a detail grid per array; returns the page number. */
159
160
  export async function createSourcePage(appId: number, x: JsonSource, pageNo: number, title: string) {
160
- if (x.key.length !== 1) throw new Error('Create page needs a table with a one-column key; use the views in regions of your own.');
161
+ if (x.key.length !== 1) throw new Error(tx('jsrc.create_page_needs_a_table'));
161
162
  const key = x.key[0];
162
163
  const keyItem = `P${pageNo}_${key.toUpperCase()}`;
163
164
  return owner.tx(async (c) => {
164
- if ((await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, pageNo])).rows.length) throw new Error(`Page ${pageNo} exists already.`);
165
+ if ((await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, pageNo])).rows.length) throw new Error(tx('jsrc.page_exists', { n: pageNo }));
165
166
  const pageId = (await c.query<{ id: number }>('insert into meta.page (app_id, page_no, name, title) values ($1, $2, $3, $3) returning id', [appId, pageNo, title])).rows[0].id;
166
167
  const form = (await c.query<{ id: number }>(
167
168
  `insert into meta.region (page_id, seq, title, type, table_name, pk_column, pk_item) values ($1, 10, $2, 'form', $3, $4, $5) returning id`,
@@ -210,15 +211,15 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
210
211
  const db = await workshopDb(s);
211
212
  const states = await Promise.all(list.map((x) => viewState(db, x).catch(() => new Map<string, boolean>())));
212
213
  const main = html`${appHeader(a, 'shared', s)}
213
- <div class="title-row"><h1>JSON sources</h1></div>
214
- <p class="muted u-mt0">A JSON source describes a jsonb column: a column per path in its documents, and arrays of objects as rows of their own. pgkiln makes updatable views from it, so forms, reports, interactive grids, REST and MCP read and write the documents like tables. Saving a row changes only the paths it changed: keys the source doesn't know about stay. For a table with its child tables as one document, see SQL Workshop › Table Properties › Document view.</p>
215
- ${region('Sources', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Name</th><th scope="col">Column</th><th scope="col">Views</th></tr></thead>
216
- <tbody>${list.length ? list.map((x, i) => html`<tr><td data-label="Name"><a href="${srcUrl(a.id, x.name)}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
217
- <td data-label="Column"><code>${x.schema}.${x.table}.${x.column}</code></td>
218
- <td data-label="Views">${[x.view, ...x.arrays.map((y) => y.view)].map((v) => html`<code>${v}</code>${states[i].get(v) ? '' : html` <span class="tag">not made</span>`} `)}</td></tr>`)
219
- : html`<tr><td colspan="3" class="empty">No JSON sources yet.</td></tr>`}</tbody></table></div>
220
- <p><a class="btn" href="${BASE}/apps/${a.id}/json-sources/source">${icon('plus')} Add JSON source</a></p>`)}`;
221
- return send(reply, s, shell(s, 'JSON sources', crumbs(a), main));
214
+ <div class="title-row"><h1>${tx('jsrc.json_sources')}</h1></div>
215
+ <p class="muted u-mt0">${tx('jsrc.a_json_source_describes_a')}</p>
216
+ ${region(tx('jsrc.sources'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('jsrc.name')}</th><th scope="col">${tx('jsrc.column')}</th><th scope="col">${tx('jsrc.views')}</th></tr></thead>
217
+ <tbody>${list.length ? list.map((x, i) => html`<tr><td data-label="${tx('jsrc.name')}"><a href="${srcUrl(a.id, x.name)}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
218
+ <td data-label="${tx('jsrc.column')}"><code>${x.schema}.${x.table}.${x.column}</code></td>
219
+ <td data-label="${tx('jsrc.views')}">${[x.view, ...x.arrays.map((y) => y.view)].map((v) => html`<code>${v}</code>${states[i].get(v) ? '' : html` <span class="tag">${tx('jsrc.not_made')}</span>`} `)}</td></tr>`)
220
+ : html`<tr><td colspan="3" class="empty">${tx('jsrc.no_json_sources_yet')}</td></tr>`}</tbody></table></div>
221
+ <p><a class="btn" href="${BASE}/apps/${a.id}/json-sources/source">${icon('plus')} ${tx('jsrc.add_json_source')}</a></p>`)}`;
222
+ return send(reply, s, shell(s, tx('jsrc.json_sources'), crumbs(a), main));
222
223
  });
223
224
 
224
225
  app.get(`${BASE}/apps/:id(^\\d+$)/json-sources/source`, async (req: Req, reply) => {
@@ -238,20 +239,20 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
238
239
  const st = await viewState(db, x).catch(() => new Map<string, boolean>());
239
240
  const made = st.get(x.view) === true;
240
241
  const ddl = jsonSourceDdl(x, { app: a.alias, grants: await tableGrants(db, x.schema, x.table).catch(() => []), replace: st.size > 0 });
241
- extra = html`${region('Views', html`
242
- <p>${made ? html`${icon('check')} The views exist. Make them again after changing the columns.` : 'The views are not made yet.'}</p>
242
+ extra = html`${region(tx('jsrc.views'), html`
243
+ <p>${made ? html`${icon('check')} The views exist. Make them again after changing the columns.` : tx('jsrc.the_views_are_not_made')}</p>
243
244
  <details><summary>DDL</summary><pre class="source te-ddl" data-code-view="sql" tabindex="0" aria-label="DDL">${ddl.map((d) => `${d};`).join('\n\n')}</pre></details>
244
245
  <form method="post" action="${BASE}/apps/${a.id}/json-sources/views" class="buttons">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
245
- <button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${made ? 'Make the views again' : 'Create views'}</button>
246
- <button class="btn" name="mode" value="worksheet">${icon('code')} Open in the SQL Worksheet</button></form>`)}
247
- ${made ? region('Create page', html`<form method="post" action="${BASE}/apps/${a.id}/json-sources/page">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
248
- <p class="muted u-mt0">A form on <code>${x.view}</code>${x.arrays.length ? html` with ${x.arrays.length === 1 ? 'a detail grid' : 'detail grids'} for ${x.arrays.map((y) => y.name).join(', ')}` : ''}. Open it with the key in the URL (<code>P<i>n</i>_${(x.key[0] ?? '').toUpperCase()}</code>), e.g. from a report's link.</p>
249
- <div class="form-grid">${input('page_no', 'Page number', '', { type: 'number', required: true })}${input('title', 'Title', x.name.replace(/_/g, ' ').toLowerCase().replace(/^./, (ch) => ch.toUpperCase()), { required: true })}</div>
250
- <div class="buttons"><button class="btn">${icon('plus')} Create page</button></div></form>`) : ''}
246
+ <button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${made ? tx('jsrc.make_the_views_again') : tx('jsrc.create_views')}</button>
247
+ <button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('jsrc.open_in_the_sql_worksheet')}</button></form>`)}
248
+ ${made ? region(tx('jsrc.create_page'), html`<form method="post" action="${BASE}/apps/${a.id}/json-sources/page">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
249
+ <p class="muted u-mt0">${tx.rich(x.arrays.length === 0 ? 'jsrc.form_on' : x.arrays.length === 1 ? 'jsrc.form_on_grid' : 'jsrc.form_on_grids', { view: html`<code>${x.view}</code>`, arrays: x.arrays.map((y) => y.name).join(', '), item: html`<code>P<i>n</i>_${(x.key[0] ?? '').toUpperCase()}</code>` })}</p>
250
+ <div class="form-grid">${input('page_no', tx('jsrc.page_number'), '', { type: 'number', required: true })}${input('title', tx('jsrc.title'), x.name.replace(/_/g, ' ').toLowerCase().replace(/^./, (ch) => ch.toUpperCase()), { required: true })}</div>
251
+ <div class="buttons"><button class="btn">${icon('plus')} ${tx('jsrc.create_page')}</button></div></form>`) : ''}
251
252
  <form method="post" action="${BASE}/apps/${a.id}/json-sources/source/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
252
- <button class="btn btn-danger" data-confirm="Delete JSON source ${x.name}? Its views stay in the database (drop them in the SQL Workshop).">Delete JSON source</button></form>`;
253
+ <button class="btn btn-danger" data-confirm="${tx('jsrc.delete_confirm', { name: x.name })}">${tx('jsrc.delete_json_source')}</button></form>`;
253
254
  }
254
- const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1>${title}</h1></div>${region('JSON source', sourceForm(a, s, x, name, [], tables))}${extra}`;
255
+ const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1>${title}</h1></div>${region(tx('jsrc.json_source'), sourceForm(a, s, x, name, [], tables))}${extra}`;
255
256
  return send(reply, s, shell(s, title, crumbs(a, title), main));
256
257
  });
257
258
 
@@ -265,7 +266,7 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
265
266
  const tables = await jsonColumns(db);
266
267
  let x = sourceFrom(b, []);
267
268
  const errors: string[] = [];
268
- if (!tables.some((t) => t.schema === x.schema && t.table === x.table && t.column === x.column)) errors.push('Choose a table and its json or jsonb column.');
269
+ if (!tables.some((t) => t.schema === x.schema && t.table === x.table && t.column === x.column)) errors.push(tx('jsrc.choose_a_table_and_its'));
269
270
  else x.key = await primaryKey(db, x.schema, x.table);
270
271
  if (!x.view && x.table) x.view = `${x.table}_${x.column}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 63);
271
272
  if (!errors.length && one(b.mode) === 'propose') {
@@ -280,8 +281,8 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
280
281
  }
281
282
  const title = original || 'New JSON source';
282
283
  const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1>${title}</h1></div>
283
- <p class="alert alert-info" role="status">Proposed from up to 200 documents: check the names and types, then save.</p>
284
- ${region('JSON source', sourceForm(a, s, x, original, [], tables))}`;
284
+ <p class="alert alert-info" role="status">${tx('jsrc.proposed_from_up_to_200')}</p>
285
+ ${region(tx('jsrc.json_source'), sourceForm(a, s, x, original, [], tables))}`;
285
286
  return send(reply, s, shell(s, title, crumbs(a, title), main));
286
287
  }
287
288
  const problems = errors.length ? errors : await update(a.id, (list) => {
@@ -290,7 +291,7 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
290
291
  if (original && at < 0) out.push(`JSON source ${original} no longer exists.`);
291
292
  if (list.some((y, i) => y.name === x.name && i !== at)) out.push(`A JSON source ${x.name} exists already.`);
292
293
  const views = new Map(list.filter((_, i) => i !== at).flatMap((y) => [y.view, ...y.arrays.map((z) => z.view)].map((v) => [`${y.schema}.${v}`, y.name])));
293
- for (const v of [x.view, ...x.arrays.map((z) => z.view)]) if (views.has(`${x.schema}.${v}`)) out.push(`The view ${v} belongs to JSON source ${views.get(`${x.schema}.${v}`)}.`);
294
+ for (const v of [x.view, ...x.arrays.map((z) => z.view)]) if (views.has(`${x.schema}.${v}`)) out.push(tx('jsrc.view_taken', { view: v, source: views.get(`${x.schema}.${v}`) ?? '' }));
294
295
  if (out.length) return out;
295
296
  if (at >= 0) list[at] = x;
296
297
  else list.push(x);
@@ -298,10 +299,10 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
298
299
  });
299
300
  if (problems.length) {
300
301
  const title = original || 'New JSON source';
301
- const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1>${title}</h1></div>${region('JSON source', sourceForm(a, s, x, original, problems, tables))}`;
302
+ const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1>${title}</h1></div>${region(tx('jsrc.json_source'), sourceForm(a, s, x, original, problems, tables))}`;
302
303
  return send(reply.code(422), s, shell(s, title, crumbs(a, title), main));
303
304
  }
304
- flash(s, 'JSON source saved.');
305
+ flash(s, tx('jsrc.json_source_saved'));
305
306
  return back(reply, s, srcUrl(a.id, x.name));
306
307
  });
307
308
 
@@ -333,7 +334,7 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
333
334
  const tables = await jsonColumns(db);
334
335
  const problems = jsonSourceProblems(x);
335
336
  if (!tables.some((t) => t.schema === x.schema && t.table === x.table && t.column === x.column)) problems.push(`${x.schema}.${x.table}.${x.column} is not a json or jsonb column this connection may use.`);
336
- else if ((await primaryKey(db, x.schema, x.table)).join(',') !== x.key.join(',')) problems.push(`The primary key of ${x.schema}.${x.table} changed: save the source again.`);
337
+ else if ((await primaryKey(db, x.schema, x.table)).join(',') !== x.key.join(',')) problems.push(tx('jsrc.key_changed', { table: `${x.schema}.${x.table}` }));
337
338
  const st = problems.length ? new Map() : await viewState(db, x);
338
339
  for (const [v, ours] of st) if (!ours) problems.push(`${x.schema}.${v} exists and is not a view of a JSON source: choose another name.`);
339
340
  if (problems.length) {
@@ -343,7 +344,7 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
343
344
  const ddl = jsonSourceDdl(x, { app: a.alias, grants: await tableGrants(db, x.schema, x.table), replace: st.size > 0 });
344
345
  if (one(b.mode) === 'worksheet') {
345
346
  s.state.__SQLDRAFT = `${ddl.join(';\n\n')};`;
346
- flash(s, 'Check the statements, then run them as a script.');
347
+ flash(s, tx('jsrc.check_the_statements_then_run'));
347
348
  return back(reply, s, `${BASE}/sql/worksheet`);
348
349
  }
349
350
  const c = await db.pool.connect();
@@ -361,11 +362,11 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
361
362
  await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ddl.join(';\n').slice(0, 2000) });
362
363
  clearCompletions();
363
364
  if (failed) {
364
- flash(s, `Nothing was changed: ${failed}`, 'error');
365
+ flash(s, tx('jsrc.nothing_changed', { error: failed }), 'error');
365
366
  return back(reply, s, to);
366
367
  }
367
368
  await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', defaultSchema: x.schema, statements: [joinStatements(ddl)] });
368
- flash(s, `The views of ${x.name} are made: ${[x.view, ...x.arrays.map((y) => y.view)].join(', ')}.`);
369
+ flash(s, tx('jsrc.views_made', { name: x.name, views: [x.view, ...x.arrays.map((y) => y.view)].join(', ') }));
369
370
  return back(reply, s, to);
370
371
  });
371
372
 
@@ -379,7 +380,7 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
379
380
  const pageNo = Number(one(b.page_no));
380
381
  const title = one(b.title).trim().slice(0, 200) || x.name;
381
382
  if (!Number.isInteger(pageNo) || pageNo < 1 || pageNo > 99999) {
382
- flash(s, 'The page number is a whole number from 1.', 'error');
383
+ flash(s, tx('jsrc.the_page_number_is_a'), 'error');
383
384
  return back(reply, s, srcUrl(a.id, x.name));
384
385
  }
385
386
  try {
@@ -388,7 +389,7 @@ export async function jsonSourceRoutes(app: FastifyInstance) {
388
389
  flash(s, (e as Error).message, 'error');
389
390
  return back(reply, s, srcUrl(a.id, x.name));
390
391
  }
391
- flash(s, `Page ${pageNo} made: a form on ${x.view}${x.arrays.length ? ' with detail grids' : ''}.`);
392
+ flash(s, x.arrays.length ? tx('jsrc.page_made_grids', { n: pageNo, view: x.view }) : tx('jsrc.page_made', { n: pageNo, view: x.view }));
392
393
  return back(reply, s, `${BASE}/apps/${a.id}/pages/${pageNo}`);
393
394
  });
394
395
  }
@@ -5,6 +5,7 @@ import { icon } from '../icons.ts';
5
5
  import { readMultipart } from '../runtime/files.ts';
6
6
  import { layoutPreview, type PdfLayout } from '../runtime/pdf.ts';
7
7
  import type { Session } from '../session.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
  import { back, BASE, csrf, developer, flash, type Body, type Req } from './ui.ts';
9
10
 
10
11
  // Shared Components → Report layouts: the logo and the preview. The other
@@ -22,15 +23,15 @@ export function imageType(b: Buffer): 'image/png' | 'image/jpeg' | null {
22
23
  /** Logo and preview forms under a layout's settings. */
23
24
  export function layoutExtras(appId: number, row: { id: number; logo: Buffer | null }, s: Session) {
24
25
  const base = `${BASE}/apps/${appId}/shared/report_layout/${row.id}`;
25
- return html`<fieldset class="prop-group u-mt125"><legend>Logo image</legend>
26
- ${row.logo ? html`<p><img src="${base}/logo" alt="Logo of this layout" class="layout-logo"></p>` : html`<p class="muted">No logo. It prints at the top right of the first page.</p>`}
26
+ return html`<fieldset class="prop-group u-mt125"><legend>${tx('lay.logo_image')}</legend>
27
+ ${row.logo ? html`<p><img src="${base}/logo" alt="${tx('lay.logo_alt')}" class="layout-logo"></p>` : html`<p class="muted">${tx('lay.no_logo')}</p>`}
27
28
  <form method="post" action="${base}/logo" enctype="multipart/form-data" class="search u-mwnone">${csrf(s)}
28
- <input type="file" name="logo" accept="image/png,image/jpeg" aria-label="Logo (PNG or JPEG)">
29
- <button class="btn">${icon('upload')} Upload</button>
30
- ${row.logo ? html`<button class="btn" name="remove" value="1">Remove logo</button>` : ''}
29
+ <input type="file" name="logo" accept="image/png,image/jpeg" aria-label="${tx('lay.logo_file')}">
30
+ <button class="btn">${icon('upload')} ${tx('lay.upload')}</button>
31
+ ${row.logo ? html`<button class="btn" name="remove" value="1">${tx('lay.remove')}</button>` : ''}
31
32
  </form>
32
33
  </fieldset>
33
- <div class="buttons"><a class="btn" href="${base}/preview" target="_blank" rel="noopener">${icon('printer')} Preview PDF</a></div>`;
34
+ <div class="buttons"><a class="btn" href="${base}/preview" target="_blank" rel="noopener">${icon('printer')} ${tx('lay.preview_pdf')}</a></div>`;
34
35
  }
35
36
 
36
37
  export async function layoutRoutes(app: FastifyInstance) {
@@ -56,16 +57,16 @@ export async function layoutRoutes(app: FastifyInstance) {
56
57
  if (!(await find(id, cid))) return reply.code(404).send('Not found');
57
58
  if (req.body?.remove === '1') {
58
59
  await owner.query('update meta.report_layout set logo = null, logo_mime = null where id = $1 and app_id = $2', [cid, id]);
59
- flash(s, 'Logo removed.');
60
+ flash(s, tx('lay.removed'));
60
61
  return back(reply, s, target);
61
62
  }
62
63
  const type = file && !file.truncated ? imageType(file.data) : null;
63
- if (!file) flash(s, 'Choose a PNG or JPEG file.', 'error');
64
- else if (file.truncated) flash(s, `The logo is larger than ${LOGO_MAX_MB} MB.`, 'error');
65
- else if (!type) flash(s, 'The logo must be a PNG or JPEG image.', 'error');
64
+ if (!file) flash(s, tx('lay.choose'), 'error');
65
+ else if (file.truncated) flash(s, tx('lay.too_large', { mb: LOGO_MAX_MB }), 'error');
66
+ else if (!type) flash(s, tx('lay.not_image'), 'error');
66
67
  else {
67
68
  await owner.query('update meta.report_layout set logo = $3, logo_mime = $4 where id = $1 and app_id = $2', [cid, id, file.data, type]);
68
- flash(s, 'Logo saved.');
69
+ flash(s, tx('lay.saved'));
69
70
  }
70
71
  return back(reply, s, target);
71
72
  });
@@ -1,10 +1,11 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
- import { html, raw, type Raw } from '../html.ts';
3
+ import { html, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { clientIp, logActivity, type Session } from '../session.ts';
6
6
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
7
7
  import { issuesFrom } from '../team.ts';
8
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
8
9
 
9
10
  // Page locks, the application lock and developer comments (APEX: page locks,
10
11
  // application lock in 26.1, developer comments). A developer locks a page
@@ -79,12 +80,12 @@ export async function blockingLock(username: string, path: string): Promise<{ lo
79
80
 
80
81
  export const lockText = (l: Pick<Lock, 'page_no' | 'locked_by' | 'locked_at' | 'note' | 'run_only'>) =>
81
82
  l.run_only
82
- ? 'The application is Run only (Availability → Build status).'
83
- : `${l.page_no ? `Page ${l.page_no}` : 'The application'} is locked by ${l.locked_by} since ${l.locked_at.slice(0, 16)}${l.note ? ` (${l.note})` : ''}.`;
83
+ ? tx('lk.run_only')
84
+ : tx(l.page_no ? 'lk.page_locked_by' : 'lk.app_locked_by', { page: l.page_no, user: l.locked_by, since: l.locked_at.slice(0, 16), note: l.note ? ` (${l.note})` : '' });
84
85
 
85
86
  /** Refuse a change to a locked page or application: a JSON error or a message on the page it came from. */
86
87
  export async function refuseLocked(req: Req, reply: FastifyReply, s: Session, hit: NonNullable<Awaited<ReturnType<typeof blockingLock>>>) {
87
- const message = hit.lock.run_only ? `${lockText(hit.lock)} Set it back to Run and build to change it.` : `${lockText(hit.lock)} Changes are refused until it is unlocked.`;
88
+ const message = `${lockText(hit.lock)} ${tx(hit.lock.run_only ? 'lk.run_only_refused' : 'lk.refused')}`;
88
89
  if (String(req.headers.accept ?? '').includes('application/json')) return reply.code(423).send({ ok: false, error: message });
89
90
  flash(s, message, 'error');
90
91
  return back(reply, s, hit.pageId ? `${BASE}/pages/${hit.pageId}` : `${BASE}/apps/${hit.appId}`);
@@ -101,40 +102,40 @@ export async function lockPanel(s: Session, appId: number, pageNo: number, opts:
101
102
  const issues = await issuesFrom('comment', comments.rows.map((c) => Number(c.id)));
102
103
  const own = locks.find((l) => l.page_no === pageNo);
103
104
  const appLock = pageNo ? locks.find((l) => l.page_no === 0) : undefined;
104
- const what = pageNo ? `page ${pageNo}` : 'the application';
105
+ const pick = (page: BuilderKey, app: BuilderKey, params: Record<string, string | number> = {}) => tx(pageNo ? page : app, { page: pageNo, ...params });
105
106
  const hidden = html`<input type="hidden" name="page_no" value="${pageNo}">`;
106
107
  const lockState = own
107
108
  ? html`<p class="lock-state">${icon('key')} <span>${lockText(own)}</span></p>
108
109
  ${own.locked_by === s.username || admin
109
110
  ? html`<form method="post" action="${BASE}/apps/${appId}/unlock">${csrf(s)}${hidden}
110
- <button class="btn"${own.locked_by !== s.username ? raw(` data-confirm="Break ${own.locked_by}'s lock on ${what}?"`) : ''}>${own.locked_by === s.username ? 'Unlock' : 'Break lock (administrator)'}</button></form>`
111
- : html`<p class="muted">Only ${own.locked_by} or an administrator can unlock it.</p>`}`
112
- : html`<p class="muted">Not locked: every developer can change ${what}.</p>
111
+ <button class="btn"${own.locked_by !== s.username ? html` data-confirm="${pick('lk.break_confirm_page', 'lk.break_confirm_app', { user: own.locked_by })}"` : ''}>${own.locked_by === s.username ? tx('lk.unlock') : tx('lk.break')}</button></form>`
112
+ : html`<p class="muted">${tx('lk.only_owner', { user: own.locked_by })}</p>`}`
113
+ : html`<p class="muted">${pick('lk.not_locked_page', 'lk.not_locked_app')}</p>
113
114
  <form method="post" action="${BASE}/apps/${appId}/lock" class="search u-mwnone">${csrf(s)}${hidden}
114
- <label class="sr-only" for="lock-note-${pageNo}">Note</label>
115
- <input id="lock-note-${pageNo}" name="note" maxlength="500" placeholder="Note (optional), e.g. reworking the form">
116
- <button class="btn">${icon('key')} Lock ${what}</button></form>`;
115
+ <label class="sr-only" for="lock-note-${pageNo}">${tx('lk.note')}</label>
116
+ <input id="lock-note-${pageNo}" name="note" maxlength="500" placeholder="${tx('lk.note_ph')}">
117
+ <button class="btn">${icon('key')} ${pick('lk.lock_page', 'lk.lock_app')}</button></form>`;
117
118
  return html`<div class="lock-panel">
118
- ${opts.headings === false ? '' : html`<h3>Lock</h3>`}
119
+ ${opts.headings === false ? '' : html`<h3>${tx('lk.lock')}</h3>`}
119
120
  ${appLock && appLock.locked_by !== s.username ? html`<div class="alert alert-error" role="status">${lockText(appLock)}</div>` : ''}
120
121
  ${lockState}
121
- <h3>Comments</h3>
122
+ <h3>${tx('lk.comments')}</h3>
122
123
  <form method="post" action="${BASE}/apps/${appId}/comments">${csrf(s)}${hidden}
123
- <div class="field"><label class="label" for="comment-${pageNo}">Add a comment on ${what}</label>
124
+ <div class="field"><label class="label" for="comment-${pageNo}">${pick('lk.comment_on_page', 'lk.comment_on_app')}</label>
124
125
  <textarea id="comment-${pageNo}" name="body" rows="3" maxlength="4000" required></textarea></div>
125
- <div class="buttons"><button class="btn">Add comment</button></div>
126
+ <div class="buttons"><button class="btn">${tx('lk.add_comment')}</button></div>
126
127
  </form>
127
128
  ${comments.rows.length
128
129
  ? html`<ul class="dev-comments">${comments.rows.map((c) => html`<li>
129
130
  <div class="dev-comment-head"><b>${c.author}</b> <span class="muted">${String(c.created_at).slice(0, 16)}</span>
130
131
  ${c.author === s.username || admin
131
- ? html`<form method="post" action="${BASE}/apps/${appId}/comments/${c.id}/delete" class="u-inline">${csrf(s)}<button class="link-button" data-confirm="Delete this comment?">Delete</button></form>`
132
+ ? html`<form method="post" action="${BASE}/apps/${appId}/comments/${c.id}/delete" class="u-inline">${csrf(s)}<button class="link-button" data-confirm="${tx('lk.delete_comment_confirm')}">${tx('lk.delete')}</button></form>`
132
133
  : ''}
133
134
  ${issues.get(Number(c.id))
134
- ? html`<a class="small" href="/builder/team/issues/${issues.get(Number(c.id))}">${icon('kanban')} Issue #${issues.get(Number(c.id))}</a>`
135
- : html`<form method="post" action="/builder/team/from-comment" class="u-inline">${csrf(s)}<input type="hidden" name="comment" value="${c.id}"><button class="link-button">Make an issue</button></form>`}</div>
135
+ ? html`<a class="small" href="/builder/team/issues/${issues.get(Number(c.id))}">${icon('kanban')} ${tx('lk.issue', { n: issues.get(Number(c.id))! })}</a>`
136
+ : html`<form method="post" action="/builder/team/from-comment" class="u-inline">${csrf(s)}<input type="hidden" name="comment" value="${c.id}"><button class="link-button">${tx('lk.make_issue')}</button></form>`}</div>
136
137
  <p class="dev-comment-body">${c.body}</p></li>`)}</ul>`
137
- : html`<p class="muted">No comments yet.</p>`}
138
+ : html`<p class="muted">${tx('lk.no_comments')}</p>`}
138
139
  </div>`;
139
140
  }
140
141
 
@@ -166,8 +167,8 @@ export async function lockRoutes(app: FastifyInstance) {
166
167
  `insert into meta.builder_lock (app_id, page_no, locked_by, note) values ($1, $2, $3, $4) on conflict (app_id, page_no) do nothing`,
167
168
  [t.appId, t.pageNo, s.username, note],
168
169
  );
169
- if (r.rowCount) flash(s, `${t.pageNo ? `Page ${t.pageNo}` : 'The application'} is locked: other developers can't change it until you unlock it.`);
170
- else flash(s, 'It is already locked.', 'error');
170
+ if (r.rowCount) flash(s, tx(t.pageNo ? 'lk.locked_page' : 'lk.locked_app', { page: t.pageNo }));
171
+ else flash(s, tx('lk.already'), 'error');
171
172
  return back(reply, s, await backTo(t.appId, t.pageNo));
172
173
  });
173
174
 
@@ -178,14 +179,14 @@ export async function lockRoutes(app: FastifyInstance) {
178
179
  if (!t) return reply.code(404).send('Not found');
179
180
  const lock = await owner.one<Lock>('select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 and page_no = $2', [t.appId, t.pageNo]);
180
181
  if (!lock) {
181
- flash(s, 'It is not locked.');
182
+ flash(s, tx('lk.not_locked'));
182
183
  } else if (lock.locked_by !== s.username && !(await isAdmin(s.username))) {
183
- return reply.code(403).send('Only the developer who locked it or an administrator can unlock it.');
184
+ return reply.code(403).send(tx('lk.only_unlocker'));
184
185
  } else {
185
186
  await owner.query('delete from meta.builder_lock where app_id = $1 and page_no = $2', [t.appId, t.pageNo]);
186
187
  if (lock.locked_by !== s.username)
187
188
  await logActivity({ appId: t.appId, pageNo: t.pageNo || null, username: s.username, event: 'lock_broken', ip: clientIp(req), detail: `builder: lock of ${lock.locked_by}` });
188
- flash(s, lock.locked_by === s.username ? 'Unlocked.' : `${lock.locked_by}'s lock was broken.`);
189
+ flash(s, lock.locked_by === s.username ? tx('lk.unlocked') : tx('lk.broken', { user: lock.locked_by }));
189
190
  }
190
191
  return back(reply, s, await backTo(t.appId, t.pageNo));
191
192
  });
@@ -196,10 +197,10 @@ export async function lockRoutes(app: FastifyInstance) {
196
197
  const t = await target(req);
197
198
  if (!t) return reply.code(404).send('Not found');
198
199
  const body = (req.body?.body ?? '').trim();
199
- if (!body || body.length > 4000) flash(s, 'A comment has 1 to 4000 characters.', 'error');
200
+ if (!body || body.length > 4000) flash(s, tx('lk.comment_length'), 'error');
200
201
  else {
201
202
  await owner.query('insert into meta.dev_comment (app_id, page_no, author, body) values ($1, $2, $3, $4)', [t.appId, t.pageNo, s.username, body]);
202
- flash(s, 'Comment added.');
203
+ flash(s, tx('lk.comment_added'));
203
204
  }
204
205
  return back(reply, s, await backTo(t.appId, t.pageNo));
205
206
  });
@@ -211,9 +212,9 @@ export async function lockRoutes(app: FastifyInstance) {
211
212
  ? await owner.one('select id, app_id, page_no, author from meta.dev_comment where id = $1 and app_id = $2', [req.params.cid, req.params.id])
212
213
  : undefined;
213
214
  if (!c) return reply.code(404).send('Not found');
214
- if (c.author !== s.username && !(await isAdmin(s.username))) return reply.code(403).send('Only the author or an administrator can delete a comment.');
215
+ if (c.author !== s.username && !(await isAdmin(s.username))) return reply.code(403).send(tx('lk.only_author'));
215
216
  await owner.query('delete from meta.dev_comment where id = $1', [c.id]);
216
- flash(s, 'Comment deleted.');
217
+ flash(s, tx('lk.comment_deleted'));
217
218
  return back(reply, s, await backTo(c.app_id, c.page_no));
218
219
  });
219
220
  }