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.
Files changed (99) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/db/migrations/154_notifications.sql +186 -0
  3. package/docs/apex-feature-parity.md +4 -3
  4. package/docs/guide/03-builder.md +10 -1
  5. package/docs/guide/04-pages-and-regions.md +44 -1
  6. package/docs/guide/06-processing.md +22 -1
  7. package/docs/guide/07-dynamic-actions.md +28 -0
  8. package/docs/guide/09-reference.md +15 -0
  9. package/docs/guide/11-from-apex.md +3 -1
  10. package/docs/guide/12-development.md +16 -1
  11. package/package.json +1 -1
  12. package/public/app.css +31 -0
  13. package/public/app.js +163 -4
  14. package/public/builder.css +25 -2
  15. package/public/data-modeler.js +8 -5
  16. package/public/sql-monitor.js +5 -2
  17. package/public/sw.js +5 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/ai-builder.ts +50 -46
  20. package/src/builder/api.ts +49 -49
  21. package/src/builder/autorest.ts +92 -87
  22. package/src/builder/changelog.ts +39 -37
  23. package/src/builder/code-editor.ts +1 -1
  24. package/src/builder/components.ts +13 -8
  25. package/src/builder/data-modeler.ts +89 -84
  26. package/src/builder/data-security.ts +39 -37
  27. package/src/builder/dataload.ts +69 -65
  28. package/src/builder/datapump.ts +108 -97
  29. package/src/builder/ddl-tools.ts +38 -34
  30. package/src/builder/document-view.ts +11 -11
  31. package/src/builder/i18n/de.ts +1635 -0
  32. package/src/builder/i18n/en.ts +1635 -0
  33. package/src/builder/i18n/es.ts +1635 -0
  34. package/src/builder/i18n/fr.ts +1635 -0
  35. package/src/builder/i18n/it.ts +1635 -0
  36. package/src/builder/i18n/ja.ts +1635 -0
  37. package/src/builder/i18n/ko.ts +1635 -0
  38. package/src/builder/i18n/nl.ts +1635 -0
  39. package/src/builder/i18n/pt.ts +1635 -0
  40. package/src/builder/i18n/zh-cn.ts +1635 -0
  41. package/src/builder/i18n/zh-tw.ts +1635 -0
  42. package/src/builder/i18n.ts +18 -0
  43. package/src/builder/notification-settings.ts +27 -0
  44. package/src/builder/object-actions.ts +18 -16
  45. package/src/builder/object-browser.ts +2 -2
  46. package/src/builder/querybuilder.ts +42 -36
  47. package/src/builder/quicksql.ts +16 -18
  48. package/src/builder/region-settings.ts +201 -199
  49. package/src/builder/report-settings.ts +64 -60
  50. package/src/builder/rest-modules.ts +145 -143
  51. package/src/builder/routes.ts +3 -0
  52. package/src/builder/sampledata.ts +59 -52
  53. package/src/builder/scheduling.ts +180 -159
  54. package/src/builder/scripts.ts +71 -65
  55. package/src/builder/sql-charts.ts +36 -30
  56. package/src/builder/sql-monitor.ts +95 -89
  57. package/src/builder/sql-utilities.ts +47 -40
  58. package/src/builder/top-sql.ts +21 -17
  59. package/src/builder/unload.ts +48 -44
  60. package/src/builder/worksheet.ts +3 -3
  61. package/src/cli/replace.ts +2 -0
  62. package/src/cron-schedule.ts +47 -17
  63. package/src/i18n/ar.ts +9 -0
  64. package/src/i18n/cs.ts +9 -0
  65. package/src/i18n/da.ts +9 -0
  66. package/src/i18n/de.ts +9 -0
  67. package/src/i18n/el.ts +9 -0
  68. package/src/i18n/es.ts +9 -0
  69. package/src/i18n/fi.ts +9 -0
  70. package/src/i18n/fr.ts +9 -0
  71. package/src/i18n/he.ts +9 -0
  72. package/src/i18n/it.ts +9 -0
  73. package/src/i18n/ja.ts +9 -0
  74. package/src/i18n/ko.ts +9 -0
  75. package/src/i18n/nb.ts +9 -0
  76. package/src/i18n/pl.ts +9 -0
  77. package/src/i18n/pt.ts +9 -0
  78. package/src/i18n/ru.ts +9 -0
  79. package/src/i18n/sv.ts +9 -0
  80. package/src/i18n/tr.ts +9 -0
  81. package/src/i18n/uk.ts +9 -0
  82. package/src/i18n/zh.ts +9 -0
  83. package/src/i18n.ts +18 -0
  84. package/src/metadata.ts +11 -3
  85. package/src/runtime/calendar.ts +3 -2
  86. package/src/runtime/context.ts +2 -0
  87. package/src/runtime/engine.ts +2 -1
  88. package/src/runtime/feedback.ts +1 -1
  89. package/src/runtime/grid.ts +1 -0
  90. package/src/runtime/lists.ts +43 -11
  91. package/src/runtime/maps.ts +7 -4
  92. package/src/runtime/notifications.ts +237 -0
  93. package/src/runtime/processes.ts +45 -1
  94. package/src/runtime/regions.ts +11 -15
  95. package/src/runtime/render.ts +18 -4
  96. package/src/runtime/routes.ts +16 -4
  97. package/src/runtime/signin.ts +2 -1
  98. package/src/runtime/tree.ts +4 -3
  99. package/src/session.ts +13 -4
@@ -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
- ? html`The <code>pg_stat_statements</code> extension isn't installed in this database. To turn it on: set <code>shared_preload_libraries = 'pg_stat_statements'</code> (postgresql.conf, or <code>alter system set shared_preload_libraries = 'pg_stat_statements'</code>), restart PostgreSQL, then as a superuser: <code>create extension pg_stat_statements;</code>`
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
- ? html`The server doesn't load <code>pg_stat_statements</code> yet. Set <code>shared_preload_libraries = 'pg_stat_statements'</code> (the development <code>docker-compose.yml</code> does) and restart PostgreSQL.`
35
- : html`The statistics could not be read: ${message ?? ''}`}</div>`;
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">Sort by ${sortLink('total', 'total time')} ${sortLink('mean', 'time per call')} ${sortLink('calls', 'calls')} ${sortLink('rows', 'rows')}</div>
76
- <div class="table-wrap"><table class="report report-reflow top-sql"><thead><tr><th class="num">Calls</th><th class="num">Total ms</th><th class="num">Mean ms</th><th class="num">Rows</th><th class="num">Share</th><th>Statement</th></tr></thead>
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="Calls">${r.calls}</td><td class="num" data-label="Total ms">${r.total_ms}</td><td class="num" data-label="Mean ms">${r.mean_ms}</td>
79
- <td class="num" data-label="Rows">${r.rows}</td><td class="num" data-label="Share">${r.share ?? 0}%</td>
80
- <td data-label="Statement"><code class="sql-text">${r.query}</code></td></tr>`)}</tbody></table></div>
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="Clear the statistics of role ${a.db_role}?">${icon('history')} Reset the statistics</button></form>`
83
- : html`<p class="muted">No statements recorded for <code>${a.db_role}</code> yet. Use the application, then come back.</p>`
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">This application runs as the owner role, so its statements can't be told apart. Give it a database role in Settings.</div>`
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('Top SQL', html`<p class="muted u-mt0">Statements run as the role <code>${a.db_role ?? '(owner)'}</code> since the statistics were last reset, from <code>pg_stat_statements</code>. Literals and binds show as <code>$1</code>, <code>$2</code>…; applications that share a role share this list. <a href="${BASE}/apps/${a.id}/activity">Back to Activity</a></p>
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, `Top SQL · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['Top SQL']], main));
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('The application has no database role.');
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, 'Statistics reset.');
105
+ flash(s, tr(s)('topsql.reset_done'));
102
106
  } catch (e) {
103
107
  flash(s, (e as Error).message, 'error');
104
108
  }
@@ -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
- return send(reply, s, shell(s, 'Unload Data', [['SQL Workshop', `${BASE}/sql`], ['Unload Data']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('unload', s)}${main}`, 'sql'));
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>Format</legend>
102
- <div class="unload-formats" role="radiogroup" aria-label="Format">${UNLOAD_FORMATS.map(
103
- (f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: 'Excel (.xlsx)', xml: 'XML', sql: 'SQL inserts' }[f]}</label>`,
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">Separator</label>
108
- <select id="f_delimiter" name="delimiter">${opt(CSV_DELIMITERS, 'delimiter', { comma: 'Comma ,', semicolon: 'Semicolon ;', tab: 'Tab', pipe: 'Pipe |' })}</select></div>
109
- <div class="field"><label class="label" for="f_enclosure">Enclosed by</label>
110
- <select id="f_enclosure" name="enclosure">${opt(CSV_ENCLOSURES, 'enclosure', { double: 'Double quote "', single: "Single quote '" })}</select>
111
- <small class="help">Only values that contain the separator, the enclosure or a line break are enclosed.</small></div>
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')}> First row: column names</label>
114
- <label class="check"><input type="checkbox" name="bom" value="1"${on('bom')}> UTF-8 byte order mark (for Excel)</label></div>
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">Root element</label><input id="f_root_tag" name="root_tag" value="${b.root_tag ?? DEFAULT_UNLOAD.rootTag}" maxlength="64"></div>
118
- <div class="field"><label class="label" for="f_row_tag">Row element</label><input id="f_row_tag" name="row_tag" value="${b.row_tag ?? DEFAULT_UNLOAD.rowTag}" maxlength="64">
119
- <small class="help">One child element per column; empty (null) values are left out.</small></div>
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="Unload from">
125
- <a class="ide-tab" href="${BASE}/sql/unload"${active === 'table' ? raw(' aria-current="page"') : ''}>${icon('table')}<span>Table or view</span></a>
126
- <a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span>Query</span></a></nav>`;
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 intro = html`<p class="muted">Download the rows of a table, a view or a query as CSV, JSON, Excel or XML (up to ${DOWNLOAD_MAX_ROWS.toLocaleString('en')} rows).
129
- Runs as the builder's owner connection (an isolated workspace's developer login for its developers) in a read-only transaction. To load a file into a table, use <a href="${BASE}/sql/load">Load Data</a>.</p>`;
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
- 'Unload a query',
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">Query</label>
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">One SELECT (or WITH … SELECT) statement.</small></div>
140
- ${formatFields(b)}
141
- <div class="buttons"><button class="btn btn-hot">${icon('download')} Download</button></div></form>`,
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">Table or view</label>
148
- <select id="f_table" name="table" required><option value="">- choose a table or view -</option>${rels.map((r) => {
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' ? ' (view)' : ''}</option>`;
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">Choose</button></form>`;
153
- if (!rel) return html`${intro}${sourceTabs('table')}${region('Unload a table or view', html`${alert}${chooser}`)}`;
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
- `Unload ${rel.qname}`,
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>Columns</legend><div class="qb-pick">${cols.map(
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">Where</label><input id="f_where" name="where" value="${b.where ?? ''}" placeholder="e.g. deptno = 10" class="code" spellcheck="false">
165
- <small class="help">Optional: a condition, without the word WHERE.</small></div>
166
- <div class="field"><label class="label" for="f_order">Order by</label><input id="f_order" name="order" value="${b.order ?? ''}" placeholder="e.g. 1, created desc" class="code" spellcheck="false">
167
- <small class="help">Optional: columns, without the words ORDER BY.</small></div>
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')} Download</button></div></form>`,
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('Choose a table or view.');
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('Choose at least one column.');
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(`The query failed: ${(e as Error).message}`);
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();
@@ -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">
@@ -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)
@@ -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 ? `${name(values[i])} to ${name(values[j])}` : j > i ? `${name(values[i])}, ${name(values[j])}` : name(values[i]));
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
- export function describeSchedule(s: Schedule): string {
254
- if (s.kind === 'reboot') return 'When the database server starts';
255
- if (s.kind === 'seconds') return s.seconds === 1 ? 'Every second' : `Every ${s.seconds} seconds`;
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
- if (isAll(s.minutes, 0, 59) && isAll(s.hours, 0, 23)) time = 'Every minute';
260
- else if (isAll(s.hours, 0, 23)) {
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
- time = step ? `Every ${step} minutes` : minutes.length === 1 ? (minutes[0] === 0 ? 'Every hour' : `At minute ${minutes[0]} of every hour`) : `At minutes ${compact(minutes)} of every hour`;
263
- } else if (hours.length * minutes.length <= 6) time = `At ${hours.flatMap((h) => minutes.map((m) => `${pad(h)}:${pad(m)}`)).join(', ')}`;
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) ? 'Every minute' : minutes.length === 1 ? `At minute ${minutes[0]}` : `At minutes ${compact(minutes)}`;
267
- time = `${mins} ${step ? `of every ${step} hours` : `past hours ${compact(hours)}`}`;
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 ? `on day ${compact(sorted(s.days))} and the last day of the month` : 'on the last day of the month') : `on day ${compact(sorted(s.days))} of the month`;
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 dow = weekdays.join() === '1,2,3,4,5' ? 'on weekdays' : `on ${compact(weekdays, (d) => DAY_NAMES[d])}`;
275
- if (s.either) parts.push(`${dom} or ${dow}`);
276
- else if (!domAll && !dowAll) parts.push(`${dom} if it is ${dow.replace(/^on /, 'a ')}`);
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 (time !== 'Every minute' && !/^Every \d+ minutes$|hour$/.test(time)) parts.push('every day');
280
- if (!isAll(s.months, 1, 12)) parts.push(`in ${compact(sorted(s.months), (m) => MONTH_NAMES[m - 1])}`);
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}。",