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.
- package/db/migrations/155_mail_available.sql +23 -0
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/04-pages-and-regions.md +1 -1
- package/docs/guide/06-processing.md +7 -3
- package/docs/guide/07-dynamic-actions.md +3 -3
- package/docs/guide/09-reference.md +1 -0
- package/docs/guide/12-development.md +10 -1
- package/examples/gallery/README.md +23 -6
- package/examples/gallery/gallery.json +4 -0
- package/examples/gallery/samples/sample-approvals.sql +102 -11
- package/examples/gallery/samples/sample-calendar.sql +40 -5
- package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
- package/examples/gallery/samples/sample-grids.sql +35 -3
- package/examples/gallery/samples/sample-maps.sql +65 -7
- package/examples/gallery/samples/sample-processes.sql +466 -0
- package/examples/gallery/samples/sample-validations.sql +407 -0
- package/examples/gallery/samples/ux-patterns.sql +154 -14
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_80_notifications.sql +125 -0
- package/examples/hr/hr_81_vector_search_type.sql +33 -0
- package/package.json +1 -1
- package/public/app.css +8 -1
- package/public/app.js +28 -9
- package/src/app.ts +9 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3453 -0
- package/src/builder/i18n/en.ts +3453 -0
- package/src/builder/i18n/es.ts +3453 -0
- package/src/builder/i18n/fr.ts +3453 -0
- package/src/builder/i18n/it.ts +3453 -0
- package/src/builder/i18n/ja.ts +3453 -0
- package/src/builder/i18n/ko.ts +3453 -0
- package/src/builder/i18n/nl.ts +3453 -0
- package/src/builder/i18n/pt.ts +3453 -0
- package/src/builder/i18n/zh-cn.ts +3453 -0
- package/src/builder/i18n/zh-tw.ts +3453 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +27 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/runtime/engine.ts +6 -3
- package/src/runtime/lists.ts +2 -2
- package/src/runtime/push.ts +7 -1
- package/src/runtime/regions.ts +9 -4
package/src/builder/i18n.ts
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
['
|
|
34
|
-
...(last ? [['
|
|
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"
|
|
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="
|
|
67
|
-
<td><input name="${prefix}.${i}.path" value="${pathText(c.path)}" class="code" aria-label="
|
|
68
|
-
<td><select name="${prefix}.${i}.type" aria-label="
|
|
69
|
-
<td class="te-c"><input type="checkbox" name="${prefix}.${i}.readonly" aria-label="
|
|
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
|
|
80
|
-
${input('name', '
|
|
81
|
-
${input('description', '
|
|
82
|
-
<div class="field"><label class="label" for="js-source"
|
|
83
|
-
<select id="js-source" name="source">${opts.length ? opts.map((o) => html`<option${o === cur ? raw(' selected') : ''}>${o}</option>`) : html`<option value=""
|
|
84
|
-
<small class="help"
|
|
85
|
-
${input('view', '
|
|
86
|
-
<div class="field"><label class="check"><input type="checkbox" name="rows"${x.rows ? raw(' checked') : ''}>
|
|
87
|
-
<small class="help"
|
|
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
|
|
90
|
-
<p class="muted u-mt0"
|
|
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`, '
|
|
94
|
-
${input(`a.${j}.path`, '
|
|
95
|
-
${input(`a.${j}.view`, '
|
|
96
|
-
<div class="field"><label class="check"><input type="checkbox" name="a.${j}.drop">
|
|
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
|
|
99
|
-
${input(`a.${x.arrays.length}.name`, '
|
|
100
|
-
${input(`a.${x.arrays.length}.path`, '
|
|
101
|
-
${input(`a.${x.arrays.length}.view`, '
|
|
102
|
-
</div><small class="help"
|
|
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 ? '
|
|
105
|
-
<button class="btn" name="mode" value="propose" formnovalidate>${icon('lightbulb')}
|
|
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('
|
|
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(
|
|
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
|
|
214
|
-
<p class="muted u-mt0"
|
|
215
|
-
${region('
|
|
216
|
-
<tbody>${list.length ? list.map((x, i) => html`<tr><td data-label="
|
|
217
|
-
<td data-label="
|
|
218
|
-
<td data-label="
|
|
219
|
-
: html`<tr><td colspan="3" class="empty"
|
|
220
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/json-sources/source">${icon('plus')}
|
|
221
|
-
return send(reply, s, shell(s, '
|
|
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('
|
|
242
|
-
<p>${made ? html`${icon('check')} The views exist. Make them again after changing the columns.` : '
|
|
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 ? '
|
|
246
|
-
<button class="btn" name="mode" value="worksheet">${icon('code')}
|
|
247
|
-
${made ? region('
|
|
248
|
-
<p class="muted u-mt0"
|
|
249
|
-
<div class="form-grid">${input('page_no', '
|
|
250
|
-
<div class="buttons"><button class="btn">${icon('plus')}
|
|
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="
|
|
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('
|
|
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('
|
|
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"
|
|
284
|
-
${region('
|
|
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(
|
|
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('
|
|
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, '
|
|
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(
|
|
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, '
|
|
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,
|
|
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,
|
|
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, '
|
|
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,
|
|
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
|
}
|
package/src/builder/layouts.ts
CHANGED
|
@@ -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
|
|
26
|
-
${row.logo ? html`<p><img src="${base}/logo" alt="
|
|
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="
|
|
29
|
-
<button class="btn">${icon('upload')}
|
|
30
|
-
${row.logo ? html`<button class="btn" name="remove" value="1"
|
|
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')}
|
|
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, '
|
|
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, '
|
|
64
|
-
else if (file.truncated) flash(s,
|
|
65
|
-
else if (!type) flash(s, '
|
|
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, '
|
|
69
|
+
flash(s, tx('lay.saved'));
|
|
69
70
|
}
|
|
70
71
|
return back(reply, s, target);
|
|
71
72
|
});
|
package/src/builder/locks.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
2
|
import { owner } from '../db.ts';
|
|
3
|
-
import { html,
|
|
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
|
-
? '
|
|
83
|
-
:
|
|
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 =
|
|
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
|
|
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 ?
|
|
111
|
-
: html`<p class="muted"
|
|
112
|
-
: html`<p class="muted"
|
|
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}"
|
|
115
|
-
<input id="lock-note-${pageNo}" name="note" maxlength="500" placeholder="
|
|
116
|
-
<button class="btn">${icon('key')}
|
|
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
|
|
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
|
|
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}"
|
|
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"
|
|
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="
|
|
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')}
|
|
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"
|
|
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"
|
|
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,
|
|
170
|
-
else flash(s, '
|
|
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, '
|
|
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('
|
|
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 ? '
|
|
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, '
|
|
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, '
|
|
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('
|
|
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, '
|
|
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
|
}
|