pgkiln 0.82.0 → 0.84.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/153_component_events.sql +21 -0
- package/db/migrations/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +4 -3
- package/docs/guide/03-builder.md +10 -1
- package/docs/guide/04-pages-and-regions.md +44 -1
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +28 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +3 -1
- package/docs/guide/12-development.md +16 -1
- package/package.json +1 -1
- package/public/app.css +31 -0
- package/public/app.js +163 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/public/sw.js +5 -1
- package/src/app.ts +2 -0
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +13 -8
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1635 -0
- package/src/builder/i18n/en.ts +1635 -0
- package/src/builder/i18n/es.ts +1635 -0
- package/src/builder/i18n/fr.ts +1635 -0
- package/src/builder/i18n/it.ts +1635 -0
- package/src/builder/i18n/ja.ts +1635 -0
- package/src/builder/i18n/ko.ts +1635 -0
- package/src/builder/i18n/nl.ts +1635 -0
- package/src/builder/i18n/pt.ts +1635 -0
- package/src/builder/i18n/zh-cn.ts +1635 -0
- package/src/builder/i18n/zh-tw.ts +1635 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/routes.ts +3 -0
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cli/replace.ts +2 -0
- package/src/cron-schedule.ts +47 -17
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +11 -3
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +18 -4
- package/src/runtime/routes.ts +16 -4
- package/src/runtime/signin.ts +2 -1
- package/src/runtime/tree.ts +4 -3
- package/src/session.ts +13 -4
package/src/builder/top-sql.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
|
|
|
3
3
|
import { html, raw } from '../html.ts';
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
|
|
6
|
+
import { tr, type BuilderTranslate } from './i18n.ts';
|
|
6
7
|
|
|
7
8
|
// App Builder → Activity → Top SQL: the statements the application's
|
|
8
9
|
// database role ran, from pg_stat_statements (APEX: Top SQL in the
|
|
@@ -27,12 +28,14 @@ export async function statementsState(): Promise<{ ok: true } | { ok: false; rea
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/** Why the statistics can't be shown, and how to turn them on. */
|
|
30
|
-
export function statementsNote(reason: 'not_installed' | 'not_loaded' | 'error', message?: string) {
|
|
31
|
+
export function statementsNote(tx: BuilderTranslate, reason: 'not_installed' | 'not_loaded' | 'error', message?: string) {
|
|
32
|
+
const ext = html`<code>pg_stat_statements</code>`;
|
|
33
|
+
const setting = html`<code>shared_preload_libraries = 'pg_stat_statements'</code>`;
|
|
31
34
|
return html`<div class="alert alert-error" role="alert">${reason === 'not_installed'
|
|
32
|
-
?
|
|
35
|
+
? tx.rich('topsql.not_installed', { ext, setting, alter: html`<code>alter system set shared_preload_libraries = 'pg_stat_statements'</code>`, create: html`<code>create extension pg_stat_statements;</code>` })
|
|
33
36
|
: reason === 'not_loaded'
|
|
34
|
-
?
|
|
35
|
-
:
|
|
37
|
+
? tx.rich('topsql.not_loaded', { ext, setting, compose: html`<code>docker-compose.yml</code>` })
|
|
38
|
+
: tx('topsql.read_error', { message: message ?? '' })}</div>`;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** The role's statements, slowest in total first (or by mean time, calls or rows). */
|
|
@@ -66,28 +69,29 @@ export async function topSqlRoutes(app: FastifyInstance) {
|
|
|
66
69
|
if (!s) return;
|
|
67
70
|
const a = await appOf(req.params.id);
|
|
68
71
|
if (!a) return reply.code(404).send('Not found');
|
|
72
|
+
const tx = tr(s);
|
|
69
73
|
const sort = SORTS[req.query.sort ?? ''] ? req.query.sort! : 'total';
|
|
70
74
|
const top = await topSql(a.db_role, sort);
|
|
71
75
|
const sortLink = (key: string, label: string) =>
|
|
72
76
|
html`<a href="${BASE}/apps/${a.id}/top-sql?sort=${key}"${sort === key ? raw(' aria-current="true"') : ''}>${label}</a>`;
|
|
73
77
|
const body = top.ok
|
|
74
78
|
? top.rows.length
|
|
75
|
-
? html`<div class="seg u-mb1"
|
|
76
|
-
<div class="table-wrap"><table class="report report-reflow top-sql"><thead><tr><th class="num"
|
|
79
|
+
? html`<div class="seg u-mb1">${tx.rich('topsql.sort_by', { total: sortLink('total', tx('topsql.sort.total')), mean: sortLink('mean', tx('topsql.sort.mean')), calls: sortLink('calls', tx('topsql.sort.calls')), rows: sortLink('rows', tx('topsql.sort.rows')) })}</div>
|
|
80
|
+
<div class="table-wrap"><table class="report report-reflow top-sql"><thead><tr><th class="num">${tx('sm.col.calls')}</th><th class="num">${tx('sm.col.total_ms')}</th><th class="num">${tx('sm.col.mean_ms')}</th><th class="num">${tx('sm.col.rows')}</th><th class="num">${tx('topsql.share')}</th><th>${tx('sm.col.statement')}</th></tr></thead>
|
|
77
81
|
<tbody>${top.rows.map((r) => html`<tr>
|
|
78
|
-
<td class="num" data-label="
|
|
79
|
-
<td class="num" data-label="
|
|
80
|
-
<td data-label="
|
|
82
|
+
<td class="num" data-label="${tx('sm.col.calls')}">${r.calls}</td><td class="num" data-label="${tx('sm.col.total_ms')}">${r.total_ms}</td><td class="num" data-label="${tx('sm.col.mean_ms')}">${r.mean_ms}</td>
|
|
83
|
+
<td class="num" data-label="${tx('sm.col.rows')}">${r.rows}</td><td class="num" data-label="${tx('topsql.share')}">${r.share ?? 0}%</td>
|
|
84
|
+
<td data-label="${tx('sm.col.statement')}"><code class="sql-text">${r.query}</code></td></tr>`)}</tbody></table></div>
|
|
81
85
|
<form method="post" action="${BASE}/apps/${a.id}/top-sql/reset" class="u-mt1">${csrf(s)}
|
|
82
|
-
<button class="btn" data-confirm="
|
|
83
|
-
: html`<p class="muted"
|
|
86
|
+
<button class="btn" data-confirm="${tx('topsql.reset_confirm', { role: a.db_role })}">${icon('history')} ${tx('topsql.reset')}</button></form>`
|
|
87
|
+
: html`<p class="muted">${tx.rich('topsql.none_yet', { role: html`<code>${a.db_role}</code>` })}</p>`
|
|
84
88
|
: top.reason === 'no_role'
|
|
85
|
-
? html`<div class="alert alert-error" role="alert"
|
|
86
|
-
: statementsNote(top.reason, top.message);
|
|
89
|
+
? html`<div class="alert alert-error" role="alert">${tx('topsql.no_role')}</div>`
|
|
90
|
+
: statementsNote(tx, top.reason, top.message);
|
|
87
91
|
const main = html`${appHeader(a, 'activity', s)}
|
|
88
|
-
${region('
|
|
92
|
+
${region(tx('sql.tool.topsql'), html`<p class="muted u-mt0">${tx.rich('topsql.intro', { role: html`<code>${a.db_role ?? tx('topsql.owner')}</code>`, ext: html`<code>pg_stat_statements</code>`, p1: html`<code>$1</code>`, p2: html`<code>$2</code>` })} <a href="${BASE}/apps/${a.id}/activity">${tx('topsql.back_activity')}</a></p>
|
|
89
93
|
${body}`)}`;
|
|
90
|
-
return send(reply, s, shell(s,
|
|
94
|
+
return send(reply, s, shell(s, `${tx('sql.tool.topsql')} · ${a.name}`, [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('topsql.activity'), `${BASE}/apps/${a.id}/activity`], [tx('sql.tool.topsql')]], main));
|
|
91
95
|
});
|
|
92
96
|
|
|
93
97
|
app.post(`${BASE}/apps/:id/top-sql/reset`, async (req: Req, reply) => {
|
|
@@ -96,9 +100,9 @@ export async function topSqlRoutes(app: FastifyInstance) {
|
|
|
96
100
|
const a = await appOf(req.params.id);
|
|
97
101
|
if (!a) return reply.code(404).send('Not found');
|
|
98
102
|
try {
|
|
99
|
-
if (!a.db_role) throw new Error('
|
|
103
|
+
if (!a.db_role) throw new Error(tr(s)('topsql.no_db_role'));
|
|
100
104
|
await owner.query(`select pg_stat_statements_reset((select oid from pg_roles where rolname = $1), (select oid from pg_database where datname = current_database()), 0)`, [a.db_role]);
|
|
101
|
-
flash(s, '
|
|
105
|
+
flash(s, tr(s)('topsql.reset_done'));
|
|
102
106
|
} catch (e) {
|
|
103
107
|
flash(s, (e as Error).message, 'error');
|
|
104
108
|
}
|
package/src/builder/unload.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from '../unload.ts';
|
|
23
23
|
import { BASE, csrf, developer, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
24
24
|
import { sessionSchema, workshopDb } from './workspaces.ts';
|
|
25
|
+
import { tr, type BuilderTranslate } from './i18n.ts';
|
|
25
26
|
|
|
26
27
|
// SQL Workshop → Unload Data (APEX: Data Workshop → Unload Data): a table or
|
|
27
28
|
// view (chosen columns, an optional WHERE and ORDER BY) or a query, to CSV,
|
|
@@ -88,86 +89,89 @@ const list = (v: unknown) => (Array.isArray(v) ? v : v === undefined || v === nu
|
|
|
88
89
|
export async function unloadRoutes(app: FastifyInstance) {
|
|
89
90
|
const page = (s: Session, reply: FastifyReply, main: Raw, code = 200) => {
|
|
90
91
|
reply.code(code);
|
|
91
|
-
|
|
92
|
+
const tx = tr(s);
|
|
93
|
+
return send(reply, s, shell(s, tx('sql.tool.unload'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.unload')]], html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('unload', s)}${main}`, 'sql'));
|
|
92
94
|
};
|
|
93
95
|
|
|
94
|
-
const formatFields = (b: Body) => {
|
|
96
|
+
const formatFields = (tx: BuilderTranslate, b: Body) => {
|
|
95
97
|
const fmt = (UNLOAD_FORMATS as string[]).includes(b.format ?? '') ? b.format : 'csv';
|
|
96
98
|
const posted = b.format !== undefined; // unticked checkboxes post nothing
|
|
97
99
|
const on = (name: string) => (!posted || b[name] === '1' ? raw(' checked') : '');
|
|
98
100
|
const opt = (map: Record<string, string>, field: string, labels: Record<string, string>) =>
|
|
99
101
|
Object.keys(map).map((k) => html`<option value="${k}"${(b[field] ?? Object.keys(map)[0]) === k ? raw(' selected') : ''}>${labels[k]}</option>`);
|
|
100
102
|
return html`
|
|
101
|
-
<fieldset class="prop-group u-mt125"><legend
|
|
102
|
-
<div class="unload-formats" role="radiogroup" aria-label="
|
|
103
|
-
(f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: '
|
|
103
|
+
<fieldset class="prop-group u-mt125"><legend>${tx('cl.format')}</legend>
|
|
104
|
+
<div class="unload-formats" role="radiogroup" aria-label="${tx('cl.format')}">${UNLOAD_FORMATS.map(
|
|
105
|
+
(f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: tx('ul.fmt.xlsx'), xml: 'XML', sql: tx('ul.fmt.sql') }[f]}</label>`,
|
|
104
106
|
)}</div>
|
|
105
107
|
</fieldset>
|
|
106
108
|
<fieldset class="prop-group"><legend>CSV</legend><div class="form-grid">
|
|
107
|
-
<div class="field"><label class="label" for="f_delimiter"
|
|
108
|
-
<select id="f_delimiter" name="delimiter">${opt(CSV_DELIMITERS, 'delimiter', { comma: '
|
|
109
|
-
<div class="field"><label class="label" for="f_enclosure"
|
|
110
|
-
<select id="f_enclosure" name="enclosure">${opt(CSV_ENCLOSURES, 'enclosure', { double: '
|
|
111
|
-
<small class="help"
|
|
109
|
+
<div class="field"><label class="label" for="f_delimiter">${tx('ul.separator')}</label>
|
|
110
|
+
<select id="f_delimiter" name="delimiter">${opt(CSV_DELIMITERS, 'delimiter', { comma: tx('ul.sep.comma'), semicolon: tx('ul.sep.semicolon'), tab: tx('ul.sep.tab'), pipe: tx('ul.sep.pipe') })}</select></div>
|
|
111
|
+
<div class="field"><label class="label" for="f_enclosure">${tx('ul.enclosed')}</label>
|
|
112
|
+
<select id="f_enclosure" name="enclosure">${opt(CSV_ENCLOSURES, 'enclosure', { double: tx('ul.enc.double'), single: tx('ul.enc.single') })}</select>
|
|
113
|
+
<small class="help">${tx('ul.enclosed_help')}</small></div>
|
|
112
114
|
<div class="field"><span class="label" aria-hidden="true"></span>
|
|
113
|
-
<label class="check"><input type="checkbox" name="header" value="1"${on('header')}>
|
|
114
|
-
<label class="check"><input type="checkbox" name="bom" value="1"${on('bom')}>
|
|
115
|
+
<label class="check"><input type="checkbox" name="header" value="1"${on('header')}> ${tx('ul.header')}</label>
|
|
116
|
+
<label class="check"><input type="checkbox" name="bom" value="1"${on('bom')}> ${tx('ul.bom')}</label></div>
|
|
115
117
|
</div></fieldset>
|
|
116
118
|
<fieldset class="prop-group"><legend>XML</legend><div class="form-grid">
|
|
117
|
-
<div class="field"><label class="label" for="f_root_tag"
|
|
118
|
-
<div class="field"><label class="label" for="f_row_tag"
|
|
119
|
-
<small class="help"
|
|
119
|
+
<div class="field"><label class="label" for="f_root_tag">${tx('ul.root')}</label><input id="f_root_tag" name="root_tag" value="${b.root_tag ?? DEFAULT_UNLOAD.rootTag}" maxlength="64"></div>
|
|
120
|
+
<div class="field"><label class="label" for="f_row_tag">${tx('ul.row')}</label><input id="f_row_tag" name="row_tag" value="${b.row_tag ?? DEFAULT_UNLOAD.rowTag}" maxlength="64">
|
|
121
|
+
<small class="help">${tx('ul.row_help')}</small></div>
|
|
120
122
|
</div></fieldset>`;
|
|
121
123
|
};
|
|
122
124
|
|
|
123
|
-
const sourceTabs = (active: 'table' | 'query') =>
|
|
124
|
-
html`<nav class="ide-tabs u-mb1" aria-label="
|
|
125
|
-
<a class="ide-tab" href="${BASE}/sql/unload"${active === 'table' ? raw(' aria-current="page"') : ''}>${icon('table')}<span
|
|
126
|
-
<a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span
|
|
125
|
+
const sourceTabs = (tx: BuilderTranslate, active: 'table' | 'query') =>
|
|
126
|
+
html`<nav class="ide-tabs u-mb1" aria-label="${tx('ul.from')}">
|
|
127
|
+
<a class="ide-tab" href="${BASE}/sql/unload"${active === 'table' ? raw(' aria-current="page"') : ''}>${icon('table')}<span>${tx('ul.table_or_view')}</span></a>
|
|
128
|
+
<a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span>${tx('sm.col.query')}</span></a></nav>`;
|
|
127
129
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
+
const introOf = (tx: BuilderTranslate) => html`<p class="muted">${tx('ul.intro', { n: DOWNLOAD_MAX_ROWS.toLocaleString('en') })}
|
|
131
|
+
${tx.rich('ul.intro2', { load: html`<a href="${BASE}/sql/load">${tx('sql.tool.load')}</a>` })}</p>`;
|
|
130
132
|
|
|
131
133
|
const form = async (s: Session, b: Body, error?: string) => {
|
|
134
|
+
const tx = tr(s);
|
|
135
|
+
const intro = introOf(tx);
|
|
132
136
|
const alert = error ? html`<div class="alert alert-error" role="alert">${error}</div>` : '';
|
|
133
137
|
if (b.source === 'query')
|
|
134
|
-
return html`${intro}${sourceTabs('query')}${region(
|
|
135
|
-
'
|
|
138
|
+
return html`${intro}${sourceTabs(tx, 'query')}${region(
|
|
139
|
+
tx('ul.unload_query'),
|
|
136
140
|
html`${alert}<form method="post" action="${BASE}/sql/unload">${csrf(s)}<input type="hidden" name="source" value="query">
|
|
137
|
-
<div class="field"><label class="label" for="f_query"
|
|
141
|
+
<div class="field"><label class="label" for="f_query">${tx('sm.col.query')}</label>
|
|
138
142
|
<textarea id="f_query" name="query" class="code sql-editor" rows="10" spellcheck="false" data-code="plpgsql" required>${b.query ?? ''}</textarea>
|
|
139
|
-
<small class="help"
|
|
140
|
-
${formatFields(b)}
|
|
141
|
-
<div class="buttons"><button class="btn btn-hot">${icon('download')}
|
|
143
|
+
<small class="help">${tx('ul.one_select')}</small></div>
|
|
144
|
+
${formatFields(tx, b)}
|
|
145
|
+
<div class="buttons"><button class="btn btn-hot">${icon('download')} ${tx('scr.download')}</button></div></form>`,
|
|
142
146
|
)}`;
|
|
143
147
|
const rels = await relations();
|
|
144
148
|
const rel = rels.find((r) => r.qname === b.table);
|
|
145
149
|
let schema = '';
|
|
146
150
|
const chooser = html`<form method="get" action="${BASE}/sql/unload" class="unload-pick">
|
|
147
|
-
<label class="sr-only" for="f_table"
|
|
148
|
-
<select id="f_table" name="table" required><option value=""
|
|
151
|
+
<label class="sr-only" for="f_table">${tx('ul.table_or_view')}</label>
|
|
152
|
+
<select id="f_table" name="table" required><option value="">${tx('ul.choose_table')}</option>${rels.map((r) => {
|
|
149
153
|
const group = r.schema !== schema ? html`${schema ? raw('</optgroup>') : ''}<optgroup label="${(schema = r.schema)}">` : '';
|
|
150
|
-
return html`${group}<option value="${r.qname}"${r === rel ? raw(' selected') : ''}>${r.name}${r.kind === 'view' ?
|
|
154
|
+
return html`${group}<option value="${r.qname}"${r === rel ? raw(' selected') : ''}>${r.name}${r.kind === 'view' ? ` ${tx('ul.view')}` : ''}</option>`;
|
|
151
155
|
})}${schema ? raw('</optgroup>') : ''}</select>
|
|
152
|
-
<button class="btn"
|
|
153
|
-
if (!rel) return html`${intro}${sourceTabs('table')}${region('
|
|
156
|
+
<button class="btn">${tx('ul.choose')}</button></form>`;
|
|
157
|
+
if (!rel) return html`${intro}${sourceTabs(tx, 'table')}${region(tx('ul.unload_table'), html`${alert}${chooser}`)}`;
|
|
154
158
|
const cols = await columns(rel.qname);
|
|
155
159
|
const chosen = new Set(b.format !== undefined ? list(b.columns) : cols.map((c) => c.name));
|
|
156
|
-
return html`${intro}${sourceTabs('table')}${region(
|
|
157
|
-
|
|
160
|
+
return html`${intro}${sourceTabs(tx, 'table')}${region(
|
|
161
|
+
tx('ul.unload_of', { name: rel.qname }),
|
|
158
162
|
html`${alert}${chooser}
|
|
159
163
|
<form method="post" action="${BASE}/sql/unload" class="u-mt1">${csrf(s)}<input type="hidden" name="source" value="table"><input type="hidden" name="table" value="${rel.qname}">
|
|
160
|
-
<fieldset class="prop-group"><legend
|
|
164
|
+
<fieldset class="prop-group"><legend>${tx('ul.columns')}</legend><div class="qb-pick">${cols.map(
|
|
161
165
|
(c) => html`<label class="check"><input type="checkbox" name="columns" value="${c.name}"${chosen.has(c.name) ? raw(' checked') : ''}> ${c.name} <span class="muted small">${c.type}</span></label>`,
|
|
162
166
|
)}</div></fieldset>
|
|
163
167
|
<div class="form-grid">
|
|
164
|
-
<div class="field"><label class="label" for="f_where"
|
|
165
|
-
<small class="help"
|
|
166
|
-
<div class="field"><label class="label" for="f_order"
|
|
167
|
-
<small class="help"
|
|
168
|
+
<div class="field"><label class="label" for="f_where">${tx('ul.where')}</label><input id="f_where" name="where" value="${b.where ?? ''}" placeholder="${tx('ul.eg', { x: 'deptno = 10' })}" class="code" spellcheck="false">
|
|
169
|
+
<small class="help">${tx('ul.where_help')}</small></div>
|
|
170
|
+
<div class="field"><label class="label" for="f_order">${tx('ul.order')}</label><input id="f_order" name="order" value="${b.order ?? ''}" placeholder="${tx('ul.eg', { x: '1, created desc' })}" class="code" spellcheck="false">
|
|
171
|
+
<small class="help">${tx('ul.order_help')}</small></div>
|
|
168
172
|
</div>
|
|
169
|
-
${formatFields(b)}
|
|
170
|
-
<div class="buttons"><button class="btn btn-hot">${icon('download')}
|
|
173
|
+
${formatFields(tx, b)}
|
|
174
|
+
<div class="buttons"><button class="btn btn-hot">${icon('download')} ${tx('scr.download')}</button></div></form>`,
|
|
171
175
|
)}`;
|
|
172
176
|
};
|
|
173
177
|
|
|
@@ -192,10 +196,10 @@ export async function unloadRoutes(app: FastifyInstance) {
|
|
|
192
196
|
name = 'query';
|
|
193
197
|
} else {
|
|
194
198
|
const rel = (await relations()).find((r) => r.qname === b.table);
|
|
195
|
-
if (!rel) return refuse('
|
|
199
|
+
if (!rel) return refuse(tr(s)('ul.err.table'));
|
|
196
200
|
const known = new Set((await columns(rel.qname)).map((c) => c.name));
|
|
197
201
|
const cols = list(b.columns).filter((c) => known.has(c));
|
|
198
|
-
if (!cols.length) return refuse('
|
|
202
|
+
if (!cols.length) return refuse(tr(s)('ul.err.column'));
|
|
199
203
|
const where = (b.where ?? '').trim();
|
|
200
204
|
const order = (b.order ?? '').trim();
|
|
201
205
|
// the WHERE and ORDER BY text is the developer's SQL: the whole statement must still be one SELECT
|
|
@@ -226,7 +230,7 @@ export async function unloadRoutes(app: FastifyInstance) {
|
|
|
226
230
|
} catch (e) {
|
|
227
231
|
await c.query('rollback').catch(() => {});
|
|
228
232
|
c.release(true);
|
|
229
|
-
return refuse(
|
|
233
|
+
return refuse(tr(s)('ul.err.failed', { error: (e as Error).message }));
|
|
230
234
|
}
|
|
231
235
|
await logActivity({ username: s.username, event: 'sql_unload', ip: clientIp(req), detail: `${opts.format}: ${sql.length > 1900 ? `${sql.slice(0, 1900)}…` : sql}` });
|
|
232
236
|
const out = new PassThrough();
|
package/src/builder/worksheet.ts
CHANGED
|
@@ -121,9 +121,9 @@ async function navigatorTree(db: Awaited<ReturnType<typeof workshopDb>>, schema:
|
|
|
121
121
|
return html`<ul class="ws-tree" id="ws-tree">${objects.map((o) => {
|
|
122
122
|
const list = cols.get(o.ref);
|
|
123
123
|
const obLink = `${BASE}/sql/objects?${new URLSearchParams(o.type === 'policy' ? { schema, k: 'table', o: o.ref, tab: 'rls' } : { schema, k: o.type, o: o.ref })}`;
|
|
124
|
-
if (!list) return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}"><a href="${obLink}" title="${label(o.type)}">${icon(iconOf(o.type))}<span>${o.name}</span></a>${objectMenu(schema, o)}</li>`;
|
|
124
|
+
if (!list) return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}"><a href="${obLink}" title="${label(o.type)}">${icon(iconOf(o.type))}<span>${o.name}</span></a>${objectMenu(schema, o, tx)}</li>`;
|
|
125
125
|
const st = objectStatements(schema, o.name, list, o.type);
|
|
126
|
-
return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}">${objectMenu(schema, o)}<details>
|
|
126
|
+
return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}">${objectMenu(schema, o, tx)}<details>
|
|
127
127
|
<summary draggable="true" data-ws-sql="${st.select}" title="${tx('ws.nav.drag', { type: tx.lang === 'en' ? label(o.type).replace(/s$/, '') : label(o.type) })}">${icon(iconOf(o.type))}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}</summary>
|
|
128
128
|
<ul class="ws-cols">${list.map((c) => html`<li title="${c.type}${c.notnull ? ' not null' : ''}">${c.pk ? icon('key') : icon('minus')}<span>${c.name}</span><small class="muted">${c.type}</small></li>`)}</ul>
|
|
129
129
|
<div class="ws-obj-actions">
|
|
@@ -525,7 +525,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
|
|
|
525
525
|
else {
|
|
526
526
|
const d = drawChart('', lastQuery.columns, lastQuery.data ?? [], spec);
|
|
527
527
|
chartCss = d.css;
|
|
528
|
-
chartPane = html`${chartControls(lastQuery.columns, spec)}
|
|
528
|
+
chartPane = html`${chartControls(lastQuery.columns, spec, 'ch', tx)}
|
|
529
529
|
<div class="ch-actions"><button class="btn btn-sm" name="mode" value="chart">${icon('refresh')} ${tx('ws.chart.again')}</button>
|
|
530
530
|
<input type="hidden" name="chart_sql" value="${lastQuery.sql}">
|
|
531
531
|
<label class="sr-only" for="ws-chart-name">${tx('ws.chart.name')}</label><input id="ws-chart-name" name="chart_name" placeholder="${tx('ws.chart.name')}" maxlength="120">
|
package/src/cli/replace.ts
CHANGED
|
@@ -78,6 +78,8 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
|
|
|
78
78
|
'rest_object',
|
|
79
79
|
// (121) the log of AI tool calls (the agents and tools themselves travel in meta.app)
|
|
80
80
|
'ai_tool_call',
|
|
81
|
+
// (154) users' in-app notifications
|
|
82
|
+
'notification',
|
|
81
83
|
// (122) Team Development issues of the workspace, linked to the application (workspace data: the workspace export)
|
|
82
84
|
'team_issue',
|
|
83
85
|
// (123) the log of running workflow instances migrated to a newer version (runtime data, like the instances)
|
package/src/cron-schedule.ts
CHANGED
|
@@ -231,12 +231,12 @@ const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
231
231
|
const sorted = (s: Set<number>) => [...s].sort((a, b) => a - b);
|
|
232
232
|
const isAll = (s: Set<number>, min: number, max: number) => s.size === max - min + 1;
|
|
233
233
|
/** a list of numbers with runs as ranges: 1-5, 7 */
|
|
234
|
-
function compact(values: number[], name: (n: number) => string = String) {
|
|
234
|
+
function compact(values: number[], name: (n: number) => string = String, t: CronWords = EN_WORDS) {
|
|
235
235
|
const out: string[] = [];
|
|
236
236
|
for (let i = 0; i < values.length; i++) {
|
|
237
237
|
let j = i;
|
|
238
238
|
while (j + 1 < values.length && values[j + 1] === values[j] + 1) j++;
|
|
239
|
-
out.push(j - i >= 2 ?
|
|
239
|
+
out.push(j - i >= 2 ? fillWords(t('cron.range'), { from: name(values[i]), to: name(values[j]) }) : j > i ? `${name(values[i])}, ${name(values[j])}` : name(values[i]));
|
|
240
240
|
i = j;
|
|
241
241
|
}
|
|
242
242
|
return out.join(', ');
|
|
@@ -250,33 +250,63 @@ function stepOf(s: Set<number>, min: number, max: number) {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
/** The schedule in words ("At 02:00, every day"). */
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
/**
|
|
254
|
+
* (sprint 162, CD-8) The phrases of a schedule in words, by key: English here; the builder passes its translator
|
|
255
|
+
* (the same keys, cron.*, in src/builder/i18n/*.ts) and the language for the day and month names.
|
|
256
|
+
*/
|
|
257
|
+
export const EN_WORDS_TEXT: Record<string, string> = {
|
|
258
|
+
'cron.reboot': 'When the database server starts', 'cron.every_second': 'Every second', 'cron.every_seconds': 'Every {n} seconds',
|
|
259
|
+
'cron.every_minute': 'Every minute', 'cron.every_minutes': 'Every {n} minutes', 'cron.every_hour': 'Every hour',
|
|
260
|
+
'cron.minute_of_hour': 'At minute {m} of every hour', 'cron.minutes_of_hour': 'At minutes {list} of every hour', 'cron.at': 'At {times}',
|
|
261
|
+
'cron.at_minute': 'At minute {m}', 'cron.at_minutes': 'At minutes {list}', 'cron.of_every_hours': '{mins} of every {n} hours',
|
|
262
|
+
'cron.past_hours': '{mins} past hours {list}', 'cron.day_and_last': 'on day {list} and the last day of the month',
|
|
263
|
+
'cron.last_day': 'on the last day of the month', 'cron.day_of_month': 'on day {list} of the month', 'cron.weekdays': 'on weekdays',
|
|
264
|
+
'cron.on_days': 'on {list}', 'cron.a_days': 'a {list}', 'cron.either': '{dom} or {dow}', 'cron.if': '{dom} if it is {dow}',
|
|
265
|
+
'cron.every_day': 'every day', 'cron.in_months': 'in {list}', 'cron.range': '{from} to {to}',
|
|
266
|
+
};
|
|
267
|
+
export type CronWords = (key: string) => string;
|
|
268
|
+
const EN_WORDS: CronWords = (k) => EN_WORDS_TEXT[k] ?? k;
|
|
269
|
+
const fillWords = (text: string, p: Record<string, string | number>) => text.replace(/\{(\w+)\}/g, (m, k: string) => (k in p ? String(p[k]) : m));
|
|
270
|
+
|
|
271
|
+
/** Day (0 = Sunday) and month (1 = January) names in a language (English: as before). */
|
|
272
|
+
const dayName = (lang: string, d: number) => (lang === 'en' ? DAY_NAMES[d] : new Intl.DateTimeFormat(lang, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, 0, 1 + d))));
|
|
273
|
+
const monthName = (lang: string, m: number) => (lang === 'en' ? MONTH_NAMES[m - 1] : new Intl.DateTimeFormat(lang, { month: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, m - 1, 1))));
|
|
274
|
+
|
|
275
|
+
export function describeSchedule(s: Schedule, t: CronWords = EN_WORDS, lang = 'en'): string {
|
|
276
|
+
const w = (key: string, p: Record<string, string | number> = {}) => fillWords(t(key), p);
|
|
277
|
+
const list = (v: number[], name?: (n: number) => string) => compact(v, name, t);
|
|
278
|
+
if (s.kind === 'reboot') return w('cron.reboot');
|
|
279
|
+
if (s.kind === 'seconds') return s.seconds === 1 ? w('cron.every_second') : w('cron.every_seconds', { n: s.seconds });
|
|
256
280
|
const minutes = sorted(s.minutes);
|
|
257
281
|
const hours = sorted(s.hours);
|
|
258
282
|
let time: string;
|
|
259
|
-
|
|
260
|
-
|
|
283
|
+
// whether the time already says how often (then no "every day")
|
|
284
|
+
let often = false;
|
|
285
|
+
if (isAll(s.minutes, 0, 59) && isAll(s.hours, 0, 23)) {
|
|
286
|
+
time = w('cron.every_minute');
|
|
287
|
+
often = true;
|
|
288
|
+
} else if (isAll(s.hours, 0, 23)) {
|
|
261
289
|
const step = stepOf(s.minutes, 0, 59);
|
|
262
|
-
|
|
263
|
-
|
|
290
|
+
often = true;
|
|
291
|
+
time = step ? w('cron.every_minutes', { n: step }) : minutes.length === 1 ? (minutes[0] === 0 ? w('cron.every_hour') : w('cron.minute_of_hour', { m: minutes[0] })) : w('cron.minutes_of_hour', { list: list(minutes) });
|
|
292
|
+
} else if (hours.length * minutes.length <= 6) time = w('cron.at', { times: hours.flatMap((h) => minutes.map((m) => `${pad(h)}:${pad(m)}`)).join(', ') });
|
|
264
293
|
else {
|
|
265
294
|
const step = stepOf(s.hours, 0, 23);
|
|
266
|
-
const mins = isAll(s.minutes, 0, 59) ? '
|
|
267
|
-
time =
|
|
295
|
+
const mins = isAll(s.minutes, 0, 59) ? w('cron.every_minute') : minutes.length === 1 ? w('cron.at_minute', { m: minutes[0] }) : w('cron.at_minutes', { list: list(minutes) });
|
|
296
|
+
time = step ? w('cron.of_every_hours', { mins, n: step }) : w('cron.past_hours', { mins, list: list(hours) });
|
|
268
297
|
}
|
|
269
298
|
const parts = [time];
|
|
270
299
|
const domAll = isAll(s.days, 1, 31) && !s.lastDay;
|
|
271
300
|
const dowAll = isAll(s.weekdays, 0, 6);
|
|
272
|
-
const dom = s.lastDay ? (s.days.size ?
|
|
301
|
+
const dom = s.lastDay ? (s.days.size ? w('cron.day_and_last', { list: list(sorted(s.days)) }) : w('cron.last_day')) : w('cron.day_of_month', { list: list(sorted(s.days)) });
|
|
273
302
|
const weekdays = sorted(s.weekdays);
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
303
|
+
const days = list(weekdays, (d) => dayName(lang, d));
|
|
304
|
+
const dow = weekdays.join() === '1,2,3,4,5' ? w('cron.weekdays') : w('cron.on_days', { list: days });
|
|
305
|
+
if (s.either) parts.push(w('cron.either', { dom, dow }));
|
|
306
|
+
else if (!domAll && !dowAll) parts.push(w('cron.if', { dom, dow: weekdays.join() === '1,2,3,4,5' ? w('cron.weekdays') : w('cron.a_days', { list: days }) }));
|
|
277
307
|
else if (!domAll) parts.push(dom);
|
|
278
308
|
else if (!dowAll) parts.push(dow);
|
|
279
|
-
else if (
|
|
280
|
-
if (!isAll(s.months, 1, 12)) parts.push(
|
|
309
|
+
else if (!often) parts.push(w('cron.every_day'));
|
|
310
|
+
if (!isAll(s.months, 1, 12)) parts.push(w('cron.in_months', { list: list(sorted(s.months), (m) => monthName(lang, m)) }));
|
|
281
311
|
return parts.join(', ');
|
|
282
312
|
}
|
package/src/i18n/ar.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const ar: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "المتصفحات التي اخترت فيها «إبقائي مسجّلًا»: {count}.",
|
|
311
311
|
'account.forget_devices': "تسجيل الخروج من كل الأجهزة",
|
|
312
312
|
'account.devices_forgotten': "تم تسجيل الخروج من كل الأجهزة.",
|
|
313
|
+
'notify.title': "الإشعارات",
|
|
314
|
+
'notify.none': "لا توجد إشعارات.",
|
|
315
|
+
'notify.mark_all_read': "تعليم الكل كمقروء",
|
|
316
|
+
'notify.all': "كل الإشعارات",
|
|
317
|
+
'notify.marked': "تم تعليم الإشعارات كمقروءة.",
|
|
318
|
+
'notify.gone': "هذا الإشعار لم يعد موجودًا.",
|
|
319
|
+
'notify.bell_one': "الإشعارات: 1 غير مقروء",
|
|
320
|
+
'notify.bell_n': "الإشعارات: {n} غير مقروءة",
|
|
321
|
+
'notify.unread_one': "غير مقروء",
|
|
313
322
|
'push.title': "الإشعارات",
|
|
314
323
|
'push.help': "تلقَّ إشعارات هذا التطبيق على هذا الجهاز، حتى عندما يكون التطبيق مغلقًا.",
|
|
315
324
|
'push.devices': "الأجهزة التي تتلقى الإشعارات: {n}.",
|
package/src/i18n/cs.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const cs: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Prohlížeče, ve kterých jste zvolili „Zůstat přihlášen“: {count}.",
|
|
311
311
|
'account.forget_devices': "Odhlásit na všech zařízeních",
|
|
312
312
|
'account.devices_forgotten': "Odhlášeno na všech zařízeních.",
|
|
313
|
+
'notify.title': "Oznámení",
|
|
314
|
+
'notify.none': "Žádná oznámení.",
|
|
315
|
+
'notify.mark_all_read': "Označit vše jako přečtené",
|
|
316
|
+
'notify.all': "Všechna oznámení",
|
|
317
|
+
'notify.marked': "Oznámení označena jako přečtená.",
|
|
318
|
+
'notify.gone': "Toto oznámení už neexistuje.",
|
|
319
|
+
'notify.bell_one': "Oznámení: 1 nepřečtené",
|
|
320
|
+
'notify.bell_n': "Oznámení: nepřečtených {n}",
|
|
321
|
+
'notify.unread_one': "Nepřečtené",
|
|
313
322
|
'push.title': "Oznámení",
|
|
314
323
|
'push.help': "Dostávejte oznámení z této aplikace na tomto zařízení, i když je aplikace zavřená.",
|
|
315
324
|
'push.devices': "Zařízení s oznámeními: {n}.",
|
package/src/i18n/da.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const da: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Browsere, hvor du valgte “Hold mig logget ind”: {count}.",
|
|
311
311
|
'account.forget_devices': "Log ud på alle enheder",
|
|
312
312
|
'account.devices_forgotten': "Logget ud på alle enheder.",
|
|
313
|
+
'notify.title': "Notifikationer",
|
|
314
|
+
'notify.none': "Ingen notifikationer.",
|
|
315
|
+
'notify.mark_all_read': "Markér alle som læst",
|
|
316
|
+
'notify.all': "Alle notifikationer",
|
|
317
|
+
'notify.marked': "Notifikationerne er markeret som læst.",
|
|
318
|
+
'notify.gone': "Denne notifikation findes ikke længere.",
|
|
319
|
+
'notify.bell_one': "Notifikationer: 1 ulæst",
|
|
320
|
+
'notify.bell_n': "Notifikationer: {n} ulæste",
|
|
321
|
+
'notify.unread_one': "Ulæst",
|
|
313
322
|
'push.title': "Notifikationer",
|
|
314
323
|
'push.help': "Få notifikationer fra denne app på denne enhed, også når appen er lukket.",
|
|
315
324
|
'push.devices': "Enheder med notifikationer: {n}.",
|
package/src/i18n/de.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const de: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': 'Browser, in denen Sie „Angemeldet bleiben“ gewählt haben: {count}.',
|
|
311
311
|
'account.forget_devices': 'Auf allen Geräten abmelden',
|
|
312
312
|
'account.devices_forgotten': 'Auf allen Geräten abgemeldet.',
|
|
313
|
+
'notify.title': "Benachrichtigungen",
|
|
314
|
+
'notify.none': "Keine Benachrichtigungen.",
|
|
315
|
+
'notify.mark_all_read': "Alle als gelesen markieren",
|
|
316
|
+
'notify.all': "Alle Benachrichtigungen",
|
|
317
|
+
'notify.marked': "Benachrichtigungen als gelesen markiert.",
|
|
318
|
+
'notify.gone': "Diese Benachrichtigung gibt es nicht mehr.",
|
|
319
|
+
'notify.bell_one': "Benachrichtigungen: 1 ungelesen",
|
|
320
|
+
'notify.bell_n': "Benachrichtigungen: {n} ungelesen",
|
|
321
|
+
'notify.unread_one': "Ungelesen",
|
|
313
322
|
'push.title': "Benachrichtigungen",
|
|
314
323
|
'push.help': "Erhalten Sie Benachrichtigungen dieser App auf diesem Gerät, auch wenn die App geschlossen ist.",
|
|
315
324
|
'push.devices': "Geräte mit Benachrichtigungen: {n}.",
|
package/src/i18n/el.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const el: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Προγράμματα περιήγησης όπου επιλέξατε «Να παραμείνω συνδεδεμένος»: {count}.",
|
|
311
311
|
'account.forget_devices': "Αποσύνδεση από όλες τις συσκευές",
|
|
312
312
|
'account.devices_forgotten': "Έγινε αποσύνδεση από όλες τις συσκευές.",
|
|
313
|
+
'notify.title': "Ειδοποιήσεις",
|
|
314
|
+
'notify.none': "Δεν υπάρχουν ειδοποιήσεις.",
|
|
315
|
+
'notify.mark_all_read': "Σήμανση όλων ως αναγνωσμένων",
|
|
316
|
+
'notify.all': "Όλες οι ειδοποιήσεις",
|
|
317
|
+
'notify.marked': "Οι ειδοποιήσεις σημάνθηκαν ως αναγνωσμένες.",
|
|
318
|
+
'notify.gone': "Αυτή η ειδοποίηση δεν υπάρχει πια.",
|
|
319
|
+
'notify.bell_one': "Ειδοποιήσεις: 1 μη αναγνωσμένη",
|
|
320
|
+
'notify.bell_n': "Ειδοποιήσεις: {n} μη αναγνωσμένες",
|
|
321
|
+
'notify.unread_one': "Μη αναγνωσμένη",
|
|
313
322
|
'push.title': "Ειδοποιήσεις",
|
|
314
323
|
'push.help': "Λάβετε ειδοποιήσεις από αυτήν την εφαρμογή σε αυτήν τη συσκευή, ακόμη και όταν η εφαρμογή είναι κλειστή.",
|
|
315
324
|
'push.devices': "Συσκευές με ειδοποιήσεις: {n}.",
|
package/src/i18n/es.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const es: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': 'Navegadores en los que eligió "Mantener la sesión iniciada": {count}.',
|
|
311
311
|
'account.forget_devices': 'Cerrar sesión en todos los dispositivos',
|
|
312
312
|
'account.devices_forgotten': 'Sesión cerrada en todos los dispositivos.',
|
|
313
|
+
'notify.title': "Notificaciones",
|
|
314
|
+
'notify.none': "No hay notificaciones.",
|
|
315
|
+
'notify.mark_all_read': "Marcar todas como leídas",
|
|
316
|
+
'notify.all': "Todas las notificaciones",
|
|
317
|
+
'notify.marked': "Notificaciones marcadas como leídas.",
|
|
318
|
+
'notify.gone': "Esta notificación ya no existe.",
|
|
319
|
+
'notify.bell_one': "Notificaciones: 1 sin leer",
|
|
320
|
+
'notify.bell_n': "Notificaciones: {n} sin leer",
|
|
321
|
+
'notify.unread_one': "Sin leer",
|
|
313
322
|
'push.title': "Notificaciones",
|
|
314
323
|
'push.help': "Reciba notificaciones de esta aplicación en este dispositivo, también cuando la aplicación está cerrada.",
|
|
315
324
|
'push.devices': "Dispositivos con notificaciones: {n}.",
|
package/src/i18n/fi.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const fi: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Selaimet, joissa valitsit ”Pidä minut kirjautuneena”: {count}.",
|
|
311
311
|
'account.forget_devices': "Kirjaudu ulos kaikilla laitteilla",
|
|
312
312
|
'account.devices_forgotten': "Kirjauduttu ulos kaikilla laitteilla.",
|
|
313
|
+
'notify.title': "Ilmoitukset",
|
|
314
|
+
'notify.none': "Ei ilmoituksia.",
|
|
315
|
+
'notify.mark_all_read': "Merkitse kaikki luetuiksi",
|
|
316
|
+
'notify.all': "Kaikki ilmoitukset",
|
|
317
|
+
'notify.marked': "Ilmoitukset merkittiin luetuiksi.",
|
|
318
|
+
'notify.gone': "Tätä ilmoitusta ei enää ole.",
|
|
319
|
+
'notify.bell_one': "Ilmoitukset: 1 lukematon",
|
|
320
|
+
'notify.bell_n': "Ilmoitukset: {n} lukematonta",
|
|
321
|
+
'notify.unread_one': "Lukematon",
|
|
313
322
|
'push.title': "Ilmoitukset",
|
|
314
323
|
'push.help': "Saa tämän sovelluksen ilmoitukset tälle laitteelle, myös kun sovellus on suljettu.",
|
|
315
324
|
'push.devices': "Laitteet, joilla on ilmoitukset: {n}.",
|
package/src/i18n/fr.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const fr: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': 'Navigateurs où vous avez choisi « Rester connecté » : {count}.',
|
|
311
311
|
'account.forget_devices': 'Se déconnecter sur tous les appareils',
|
|
312
312
|
'account.devices_forgotten': 'Déconnecté sur tous les appareils.',
|
|
313
|
+
'notify.title': "Notifications",
|
|
314
|
+
'notify.none': "Aucune notification.",
|
|
315
|
+
'notify.mark_all_read': "Tout marquer comme lu",
|
|
316
|
+
'notify.all': "Toutes les notifications",
|
|
317
|
+
'notify.marked': "Notifications marquées comme lues.",
|
|
318
|
+
'notify.gone': "Cette notification n'existe plus.",
|
|
319
|
+
'notify.bell_one': "Notifications : 1 non lue",
|
|
320
|
+
'notify.bell_n': "Notifications : {n} non lues",
|
|
321
|
+
'notify.unread_one': "Non lue",
|
|
313
322
|
'push.title': "Notifications",
|
|
314
323
|
'push.help': "Recevez les notifications de cette application sur cet appareil, même lorsqu'elle est fermée.",
|
|
315
324
|
'push.devices': "Appareils avec notifications : {n}.",
|
package/src/i18n/he.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const he: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "דפדפנים שבהם בחרתם \"להשאיר אותי מחובר\": {count}.",
|
|
311
311
|
'account.forget_devices': "יציאה מכל המכשירים",
|
|
312
312
|
'account.devices_forgotten': "בוצעה יציאה מכל המכשירים.",
|
|
313
|
+
'notify.title': "התראות",
|
|
314
|
+
'notify.none': "אין התראות.",
|
|
315
|
+
'notify.mark_all_read': "סימון הכול כנקרא",
|
|
316
|
+
'notify.all': "כל ההתראות",
|
|
317
|
+
'notify.marked': "ההתראות סומנו כנקראו.",
|
|
318
|
+
'notify.gone': "ההתראה הזו כבר לא קיימת.",
|
|
319
|
+
'notify.bell_one': "התראות: 1 שלא נקראה",
|
|
320
|
+
'notify.bell_n': "התראות: {n} שלא נקראו",
|
|
321
|
+
'notify.unread_one': "לא נקראה",
|
|
313
322
|
'push.title': "התראות",
|
|
314
323
|
'push.help': "קבלו התראות מהאפליקציה הזו במכשיר הזה, גם כשהאפליקציה סגורה.",
|
|
315
324
|
'push.devices': "מכשירים עם התראות: {n}.",
|
package/src/i18n/it.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const it: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Browser in cui hai scelto “Mantieni l'accesso”: {count}.",
|
|
311
311
|
'account.forget_devices': "Esci da tutti i dispositivi",
|
|
312
312
|
'account.devices_forgotten': "Uscita effettuata da tutti i dispositivi.",
|
|
313
|
+
'notify.title': "Notifiche",
|
|
314
|
+
'notify.none': "Nessuna notifica.",
|
|
315
|
+
'notify.mark_all_read': "Segna tutte come lette",
|
|
316
|
+
'notify.all': "Tutte le notifiche",
|
|
317
|
+
'notify.marked': "Notifiche segnate come lette.",
|
|
318
|
+
'notify.gone': "Questa notifica non esiste più.",
|
|
319
|
+
'notify.bell_one': "Notifiche: 1 da leggere",
|
|
320
|
+
'notify.bell_n': "Notifiche: {n} da leggere",
|
|
321
|
+
'notify.unread_one': "Da leggere",
|
|
313
322
|
'push.title': "Notifiche",
|
|
314
323
|
'push.help': "Ricevi le notifiche di questa app su questo dispositivo, anche quando l'app è chiusa.",
|
|
315
324
|
'push.devices': "Dispositivi con notifiche: {n}.",
|
package/src/i18n/ja.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const ja: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "「サインインしたままにする」を選んだブラウザー: {count}。",
|
|
311
311
|
'account.forget_devices': "すべてのデバイスでサインアウト",
|
|
312
312
|
'account.devices_forgotten': "すべてのデバイスでサインアウトしました。",
|
|
313
|
+
'notify.title': "通知",
|
|
314
|
+
'notify.none': "通知はありません。",
|
|
315
|
+
'notify.mark_all_read': "すべて既読にする",
|
|
316
|
+
'notify.all': "すべての通知",
|
|
317
|
+
'notify.marked': "通知を既読にしました。",
|
|
318
|
+
'notify.gone': "この通知はもう存在しません。",
|
|
319
|
+
'notify.bell_one': "通知: 未読 1 件",
|
|
320
|
+
'notify.bell_n': "通知: 未読 {n} 件",
|
|
321
|
+
'notify.unread_one': "未読",
|
|
313
322
|
'push.title': "通知",
|
|
314
323
|
'push.help': "アプリを閉じているときも、このデバイスでこのアプリの通知を受け取ります。",
|
|
315
324
|
'push.devices': "通知を受け取るデバイス: {n}。",
|