pgkiln 0.81.0 → 0.83.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 (109) hide show
  1. package/db/migrations/147_condition_types.sql +80 -0
  2. package/db/migrations/148_validation_types.sql +44 -0
  3. package/db/migrations/149_dynamic_action_events.sql +45 -0
  4. package/db/migrations/150_process_types.sql +23 -0
  5. package/db/migrations/151_item_types.sql +16 -0
  6. package/db/migrations/152_workflow_diagram_region.sql +10 -0
  7. package/db/migrations/153_component_events.sql +21 -0
  8. package/docs/apex-feature-parity.md +11 -9
  9. package/docs/guide/02-concepts.md +3 -3
  10. package/docs/guide/03-builder.md +8 -1
  11. package/docs/guide/04-pages-and-regions.md +19 -4
  12. package/docs/guide/05-items.md +5 -0
  13. package/docs/guide/06-processing.md +104 -17
  14. package/docs/guide/07-dynamic-actions.md +43 -1
  15. package/docs/guide/11-from-apex.md +8 -0
  16. package/docs/guide/12-development.md +9 -1
  17. package/examples/hr/hr_79_condition_types.sql +17 -0
  18. package/package.json +1 -1
  19. package/public/app.css +14 -0
  20. package/public/app.js +242 -13
  21. package/public/builder.css +25 -2
  22. package/public/data-modeler.js +8 -5
  23. package/public/sql-monitor.js +5 -2
  24. package/src/appfiles.ts +4 -4
  25. package/src/builder/advisor.ts +43 -7
  26. package/src/builder/ai-builder.ts +50 -46
  27. package/src/builder/api.ts +49 -49
  28. package/src/builder/autorest.ts +92 -87
  29. package/src/builder/changelog.ts +39 -37
  30. package/src/builder/code-editor.ts +14 -10
  31. package/src/builder/components.ts +54 -23
  32. package/src/builder/conditions.ts +64 -0
  33. package/src/builder/data-modeler.ts +89 -84
  34. package/src/builder/data-security.ts +39 -37
  35. package/src/builder/dataload.ts +69 -65
  36. package/src/builder/datapump.ts +108 -97
  37. package/src/builder/ddl-tools.ts +38 -34
  38. package/src/builder/designer.ts +9 -1
  39. package/src/builder/document-view.ts +11 -11
  40. package/src/builder/i18n/de.ts +1662 -0
  41. package/src/builder/i18n/en.ts +1662 -0
  42. package/src/builder/i18n/es.ts +1662 -0
  43. package/src/builder/i18n/fr.ts +1662 -0
  44. package/src/builder/i18n/it.ts +1662 -0
  45. package/src/builder/i18n/ja.ts +1662 -0
  46. package/src/builder/i18n/ko.ts +1662 -0
  47. package/src/builder/i18n/nl.ts +1662 -0
  48. package/src/builder/i18n/pt.ts +1662 -0
  49. package/src/builder/i18n/zh-cn.ts +1662 -0
  50. package/src/builder/i18n/zh-tw.ts +1662 -0
  51. package/src/builder/i18n.ts +18 -0
  52. package/src/builder/object-actions.ts +18 -16
  53. package/src/builder/object-browser.ts +2 -2
  54. package/src/builder/querybuilder.ts +42 -36
  55. package/src/builder/quicksql.ts +16 -18
  56. package/src/builder/region-help.ts +3 -1
  57. package/src/builder/region-settings.ts +225 -198
  58. package/src/builder/report-settings.ts +64 -60
  59. package/src/builder/rest-modules.ts +145 -143
  60. package/src/builder/sampledata.ts +59 -52
  61. package/src/builder/scheduling.ts +180 -159
  62. package/src/builder/scripts.ts +71 -65
  63. package/src/builder/sql-charts.ts +36 -30
  64. package/src/builder/sql-monitor.ts +95 -89
  65. package/src/builder/sql-utilities.ts +47 -40
  66. package/src/builder/top-sql.ts +21 -17
  67. package/src/builder/unload.ts +48 -44
  68. package/src/builder/worksheet.ts +3 -3
  69. package/src/cron-schedule.ts +47 -17
  70. package/src/i18n/ar.ts +5 -0
  71. package/src/i18n/cs.ts +5 -0
  72. package/src/i18n/da.ts +5 -0
  73. package/src/i18n/de.ts +5 -0
  74. package/src/i18n/el.ts +5 -0
  75. package/src/i18n/es.ts +5 -0
  76. package/src/i18n/fi.ts +5 -0
  77. package/src/i18n/fr.ts +5 -0
  78. package/src/i18n/he.ts +5 -0
  79. package/src/i18n/it.ts +5 -0
  80. package/src/i18n/ja.ts +5 -0
  81. package/src/i18n/ko.ts +5 -0
  82. package/src/i18n/nb.ts +5 -0
  83. package/src/i18n/pl.ts +5 -0
  84. package/src/i18n/pt.ts +5 -0
  85. package/src/i18n/ru.ts +5 -0
  86. package/src/i18n/sv.ts +5 -0
  87. package/src/i18n/tr.ts +5 -0
  88. package/src/i18n/uk.ts +5 -0
  89. package/src/i18n/zh.ts +5 -0
  90. package/src/i18n.ts +10 -0
  91. package/src/metadata.ts +74 -9
  92. package/src/runtime/authz.ts +9 -5
  93. package/src/runtime/calendar.ts +3 -2
  94. package/src/runtime/context.ts +6 -0
  95. package/src/runtime/da-styles.ts +37 -0
  96. package/src/runtime/documents.ts +3 -2
  97. package/src/runtime/engine.ts +95 -13
  98. package/src/runtime/grid.ts +1 -0
  99. package/src/runtime/items.ts +33 -0
  100. package/src/runtime/logic.ts +79 -8
  101. package/src/runtime/maps.ts +56 -7
  102. package/src/runtime/process-types.ts +139 -0
  103. package/src/runtime/processes.ts +2 -0
  104. package/src/runtime/regions.ts +4 -0
  105. package/src/runtime/render.ts +19 -3
  106. package/src/runtime/routes.ts +6 -2
  107. package/src/runtime/tree.ts +4 -3
  108. package/src/runtime/workflow-diagram.ts +45 -0
  109. package/src/workflow.ts +10 -2
@@ -7,6 +7,7 @@ import { autoLayout, cleanObjects, diagramSvg, lines, markers, matchNames, model
7
7
  import type { Session } from '../session.ts';
8
8
  import { back, BASE, bicon, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
9
  import { currentWorkspace, sessionSchema, usableSchemas, workshopDb } from './workspaces.ts';
10
+ import { tr, type BuilderKey, type BuilderTranslate } from './i18n.ts';
10
11
 
11
12
  // (sprint 98, Database Actions DA-10: Data Modeler) SQL Workshop → Data Modeler: named relational diagrams of a
12
13
  // schema's tables and views, per workspace (meta.sql_diagram, migration 111). A diagram keeps only the objects'
@@ -69,28 +70,29 @@ async function listPage(req: Req, reply: FastifyReply) {
69
70
  from meta.sql_diagram where workspace_id = $1 order by lower(name)`, [currentWorkspace(s)]),
70
71
  ]);
71
72
  const pick = current ?? (schemas.includes('public') ? 'public' : schemas[0]);
73
+ const tx = tr(s);
72
74
  const list = rows.rows.length
73
- ? html`<div class="table-wrap"><table class="report report-reflow" aria-label="Diagrams"><thead><tr><th>Name</th><th>Schema</th><th class="num">Objects</th><th>Updated</th><th>By</th><th><span class="sr-only">Actions</span></th></tr></thead>
75
+ ? html`<div class="table-wrap"><table class="report report-reflow" aria-label="${tx('dm.diagrams')}"><thead><tr><th>${tx('scr.name')}</th><th>${tx('sm.schema')}</th><th class="num">${tx('ddl.objects')}</th><th>${tx('scr.col.updated')}</th><th>${tx('scr.col.by')}</th><th><span class="sr-only">${tx('sm.col.actions')}</span></th></tr></thead>
74
76
  <tbody>${rows.rows.map((d) => html`<tr>
75
- <td data-label="Name" class="u-break"><a href="${M}/${d.id}">${d.name}</a></td>
76
- <td data-label="Schema" class="u-break">${d.schema_name}</td>
77
- <td data-label="Objects" class="num">${d.n}</td>
78
- <td data-label="Updated">${when(d.updated_at)}</td>
79
- <td data-label="By" class="u-break">${d.updated_by ?? d.created_by}</td>
80
- <td data-label="Actions"><form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the diagram ${d.name}? The tables stay as they are.">${icon('trash')} Delete</button></form></td></tr>`)}</tbody></table></div>`
81
- : html`<p class="muted">No diagrams in this workspace yet.</p>`;
77
+ <td data-label="${tx('scr.name')}" class="u-break"><a href="${M}/${d.id}">${d.name}</a></td>
78
+ <td data-label="${tx('sm.schema')}" class="u-break">${d.schema_name}</td>
79
+ <td data-label="${tx('ddl.objects')}" class="num">${d.n}</td>
80
+ <td data-label="${tx('scr.col.updated')}">${when(d.updated_at)}</td>
81
+ <td data-label="${tx('scr.col.by')}" class="u-break">${d.updated_by ?? d.created_by}</td>
82
+ <td data-label="${tx('sm.col.actions')}"><form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('dm.delete_confirm', { name: d.name })}">${icon('trash')} ${tx('scr.delete')}</button></form></td></tr>`)}</tbody></table></div>`
83
+ : html`<p class="muted">${tx('dm.none_yet')}</p>`;
82
84
  const create = html`<form method="post" action="${M}" class="dm-create">${csrf(s)}
83
85
  <div class="form-grid">
84
- <div class="field"><label class="label" for="f_name">Name</label><input id="f_name" name="name" required maxlength="120" placeholder="core tables"></div>
85
- <div class="field"><label class="label" for="f_schema">Schema</label><select id="f_schema" name="schema">${schemas.map((n) => html`<option${n === pick ? raw(' selected') : ''}>${n}</option>`)}</select></div>
86
- <div class="field"><label class="label" for="f_objects">Objects</label><input id="f_objects" name="objects" maxlength="2000" placeholder="orders, customers or *">
87
- <small class="help">Names separated by commas or spaces; <code>*</code> for all, <code>ord*</code> for those starting with ord. You can add more later.</small></div>
86
+ <div class="field"><label class="label" for="f_name">${tx('scr.name')}</label><input id="f_name" name="name" required maxlength="120" placeholder="${tx('dm.name_placeholder')}"></div>
87
+ <div class="field"><label class="label" for="f_schema">${tx('sm.schema')}</label><select id="f_schema" name="schema">${schemas.map((n) => html`<option${n === pick ? raw(' selected') : ''}>${n}</option>`)}</select></div>
88
+ <div class="field"><label class="label" for="f_objects">${tx('ddl.objects')}</label><input id="f_objects" name="objects" maxlength="2000" placeholder="${tx('dm.objects_placeholder')}">
89
+ <small class="help">${tx.code('dm.objects_help')}</small></div>
88
90
  </div>
89
- <div class="buttons"><button class="btn btn-hot">${icon('plus')} Create diagram</button></div></form>`;
90
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler', s)}
91
- <p class="muted u-mt0">Relational diagrams of a schema's tables and views: their columns, keys and the foreign keys between them. From a diagram: its DDL and a data dictionary.</p>
92
- ${region('Diagrams', list)}<div class="u-spacer"></div>${region('Create diagram', create)}`;
93
- return send(reply, s, shell(s, 'Data Modeler', [['SQL Workshop', `${BASE}/sql`], ['Data Modeler']], main, 'sql'));
91
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} ${tx('dm.create')}</button></div></form>`;
92
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('modeler', s)}
93
+ <p class="muted u-mt0">${tx('dm.intro')}</p>
94
+ ${region(tx('dm.diagrams'), list)}<div class="u-spacer"></div>${region(tx('dm.create'), create)}`;
95
+ return send(reply, s, shell(s, tx('sql.tool.modeler'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.modeler')]], main, 'sql'));
94
96
  }
95
97
 
96
98
  /** One diagram: the toolbar, the SVG, its objects, Add objects. */
@@ -100,10 +102,11 @@ async function diagramPage(req: Req, reply: FastifyReply) {
100
102
  const d = await loadDiagram(s, req.params.id);
101
103
  if (!d) return notFound(reply);
102
104
  const m = await model(s, d);
103
- const crumbs: [string, string?][] = [['SQL Workshop', `${BASE}/sql`], ['Data Modeler', M], [d.name]];
105
+ const tx = tr(s);
106
+ const crumbs: [string, string?][] = [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.modeler'), M], [d.name]];
104
107
  if (!m) {
105
- const main = html`<h1 class="u-mb1">${d.name}</h1>${workshopTabs('modeler', s)}<div class="alert alert-error" role="alert">The schema ${d.schema_name} doesn't exist any more, or this workspace can't use it.</div>
106
- <form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the diagram ${d.name}?">${icon('trash')} Delete the diagram</button></form>`;
108
+ const main = html`<h1 class="u-mb1">${d.name}</h1>${workshopTabs('modeler', s)}<div class="alert alert-error" role="alert">${tx('dm.schema_gone', { schema: d.schema_name })}</div>
109
+ <form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('dm.delete_confirm_short', { name: d.name })}">${icon('trash')} ${tx('dm.delete_diagram')}</button></form>`;
107
110
  return send(reply, s, shell(s, d.name, crumbs, main, 'sql'));
108
111
  }
109
112
  const { tables, placed, missing } = m;
@@ -116,51 +119,51 @@ async function diagramPage(req: Req, reply: FastifyReply) {
116
119
  const fks = lines(d.schema_name, tables);
117
120
  const jsBtn = (act: string, ic: Raw | '', label: string, extra = '') =>
118
121
  html`<button type="button" class="btn btn-sm" data-dm="${act}" hidden title="${label}${extra ? ` (${extra})` : ''}" aria-label="${label}">${ic}<span class="dm-btn-label">${label}</span></button>`;
119
- const toolbar = html`<div class="dm-toolbar" role="toolbar" aria-label="Diagram">
122
+ const toolbar = html`<div class="dm-toolbar" role="toolbar" aria-label="${tx('dm.diagram')}">
120
123
  <form method="post" action="${M}/${d.id}/layout" class="dm-save-form">${csrf(s)}<input type="hidden" name="positions" value="">
121
- ${jsBtn('save', icon('save'), 'Save', 'Ctrl+S')}<span class="dm-status muted small" role="status" aria-live="polite"></span></form>
122
- ${jsBtn('undo', icon('undo'), 'Undo', 'Ctrl+Z')}${jsBtn('redo', icon('redo'), 'Redo', 'Ctrl+Y')}
123
- ${jsBtn('zoom-out', bicon('zoom-out'), 'Zoom out')}${jsBtn('zoom-in', bicon('zoom-in'), 'Zoom in')}${jsBtn('fit', bicon('expand'), 'Fit')}
124
- <form method="post" action="${M}/${d.id}/layout">${csrf(s)}<input type="hidden" name="layout" value="auto"><button class="btn btn-sm" data-dm="auto" title="Auto layout" aria-label="Auto layout">${icon('grid')}<span class="dm-btn-label">Auto layout</span></button></form>
125
- ${jsBtn('minimap', icon('map'), 'Mini-map')}
126
- <a class="btn btn-sm" href="${M}/${d.id}/svg" data-dm="svg" title="Export SVG" aria-label="Export SVG">${icon('download')}<span class="dm-btn-label">SVG</span></a>
127
- ${jsBtn('png', icon('image'), 'PNG')}${jsBtn('print', icon('printer'), 'Print')}
128
- <a class="btn btn-sm" href="${M}/${d.id}/ddl">${icon('code')}<span class="dm-btn-label">Generate DDL</span></a>
129
- <a class="btn btn-sm" href="${M}/${d.id}/dictionary">${icon('book')}<span class="dm-btn-label">Data dictionary</span></a>
130
- <a class="btn btn-sm btn-hot" href="${M}/${d.id}#dm-add">${icon('plus')}<span class="dm-btn-label">Add objects</span></a>
124
+ ${jsBtn('save', icon('save'), tx('scr.save'), 'Ctrl+S')}<span class="dm-status muted small" role="status" aria-live="polite"></span></form>
125
+ ${jsBtn('undo', icon('undo'), tx('dm.undo'), 'Ctrl+Z')}${jsBtn('redo', icon('redo'), tx('dm.redo'), 'Ctrl+Y')}
126
+ ${jsBtn('zoom-out', bicon('zoom-out'), tx('dm.zoom_out'))}${jsBtn('zoom-in', bicon('zoom-in'), tx('dm.zoom_in'))}${jsBtn('fit', bicon('expand'), tx('dm.fit'))}
127
+ <form method="post" action="${M}/${d.id}/layout">${csrf(s)}<input type="hidden" name="layout" value="auto"><button class="btn btn-sm" data-dm="auto" title="${tx('dm.auto')}" aria-label="${tx('dm.auto')}">${icon('grid')}<span class="dm-btn-label">${tx('dm.auto')}</span></button></form>
128
+ ${jsBtn('minimap', icon('map'), tx('dm.minimap'))}
129
+ <a class="btn btn-sm" href="${M}/${d.id}/svg" data-dm="svg" title="${tx('dm.export_svg')}" aria-label="${tx('dm.export_svg')}">${icon('download')}<span class="dm-btn-label">SVG</span></a>
130
+ ${jsBtn('png', icon('image'), 'PNG')}${jsBtn('print', icon('printer'), tx('dm.print'))}
131
+ <a class="btn btn-sm" href="${M}/${d.id}/ddl">${icon('code')}<span class="dm-btn-label">${tx('sql.tool.ddl')}</span></a>
132
+ <a class="btn btn-sm" href="${M}/${d.id}/dictionary">${icon('book')}<span class="dm-btn-label">${tx('dm.dictionary')}</span></a>
133
+ <a class="btn btn-sm btn-hot" href="${M}/${d.id}#dm-add">${icon('plus')}<span class="dm-btn-label">${tx('dm.add_objects')}</span></a>
131
134
  </div>`;
132
135
  const objectList = tables.length
133
- ? html`<div class="table-wrap"><table class="report report-reflow" aria-label="Objects in this diagram"><thead><tr><th>Object</th><th>Kind</th><th class="num">Columns</th><th>References</th><th><span class="sr-only">Actions</span></th></tr></thead>
136
+ ? html`<div class="table-wrap"><table class="report report-reflow" aria-label="${tx('dm.objects_in')}"><thead><tr><th>${tx('sm.col.object')}</th><th>${tx('dm.kind')}</th><th class="num">${tx('ul.columns')}</th><th>${tx('dm.references')}</th><th><span class="sr-only">${tx('sm.col.actions')}</span></th></tr></thead>
134
137
  <tbody>${tables.map((t) => html`<tr>
135
- <td data-label="Object" class="u-break"><a href="${objectHref(d.schema_name, t)}" title="${t.kind === 'table' ? 'Table Properties' : 'Object Browser'}">${t.name}</a>${t.comment ? html`<br><span class="muted small">${t.comment}</span>` : ''}</td>
136
- <td data-label="Kind">${t.kind}</td>
137
- <td data-label="Columns" class="num">${t.columns.length}</td>
138
- <td data-label="References" class="u-break">${fks.filter((l) => l.from === t.name).map((l) => l.to).join(', ') || '-'}</td>
139
- <td data-label="Actions"><form method="post" action="${M}/${d.id}/objects">${csrf(s)}<input type="hidden" name="remove" value="${t.name}"><button class="btn btn-sm" aria-label="Remove ${t.name} from the diagram">${icon('minus')} Remove</button></form></td></tr>`)}</tbody></table></div>`
140
- : html`<p class="muted">No objects yet: add some below.</p>`;
138
+ <td data-label="${tx('sm.col.object')}" class="u-break"><a href="${objectHref(d.schema_name, t)}" title="${t.kind === 'table' ? tx('cl.from.table') : tx('sql.tool.objects')}">${t.name}</a>${t.comment ? html`<br><span class="muted small">${t.comment}</span>` : ''}</td>
139
+ <td data-label="${tx('dm.kind')}">${t.kind}</td>
140
+ <td data-label="${tx('ul.columns')}" class="num">${t.columns.length}</td>
141
+ <td data-label="${tx('dm.references')}" class="u-break">${fks.filter((l) => l.from === t.name).map((l) => l.to).join(', ') || '-'}</td>
142
+ <td data-label="${tx('sm.col.actions')}"><form method="post" action="${M}/${d.id}/objects">${csrf(s)}<input type="hidden" name="remove" value="${t.name}"><button class="btn btn-sm" aria-label="${tx('dm.remove_label', { name: t.name })}">${icon('minus')} ${tx('dm.remove')}</button></form></td></tr>`)}</tbody></table></div>`
143
+ : html`<p class="muted">${tx('dm.no_objects')}</p>`;
141
144
  const add = html`<form method="get" action="${M}/${d.id}#dm-add" class="dm-search" role="search">
142
- <label class="label" for="f_q">Search the tables and views of ${d.schema_name}</label>
143
- <div class="dm-search-row"><input id="f_q" name="q" type="search" value="${q}" placeholder="Part of a name, emp*, or * for all" maxlength="200"><button class="btn">${icon('search')} Search</button></div></form>
145
+ <label class="label" for="f_q">${tx('dm.search_label', { schema: d.schema_name })}</label>
146
+ <div class="dm-search-row"><input id="f_q" name="q" type="search" value="${q}" placeholder="${tx('dm.search_placeholder')}" maxlength="200"><button class="btn">${icon('search')} ${tx('common.search')}</button></div></form>
144
147
  ${q
145
148
  ? found.length
146
- ? html`<form method="post" action="${M}/${d.id}/objects">${csrf(s)}<fieldset class="field"><legend class="label">${found.length} found${found.length === 500 ? ' (the first 500)' : ''}</legend><div class="qb-pick">
149
+ ? html`<form method="post" action="${M}/${d.id}/objects">${csrf(s)}<fieldset class="field"><legend class="label">${found.length === 500 ? tx('dm.found_first') : tx('dm.found', { n: found.length })}</legend><div class="qb-pick">
147
150
  ${found.map((n) => html`<label class="check u-break"><input type="checkbox" name="t" value="${n}" checked> ${n}${kindOf.get(n) !== 'table' ? html` <span class="tag">${kindOf.get(n)}</span>` : ''}</label>`)}</div></fieldset>
148
- <div class="buttons"><button class="btn btn-hot">${icon('plus')} Add to diagram</button></div></form>`
149
- : html`<p class="muted">Nothing else matches ${q}.</p>`
151
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} ${tx('dm.add_to')}</button></div></form>`
152
+ : html`<p class="muted">${tx('dm.nothing_matches', { q })}</p>`
150
153
  : ''}`;
151
- const settings = html`<details class="dm-settings"><summary>Rename or delete the diagram</summary>
152
- <form method="post" action="${M}/${d.id}/rename" class="dm-search-row">${csrf(s)}<label class="sr-only" for="f_rename">Name</label><input id="f_rename" name="name" value="${d.name}" maxlength="120" required><button class="btn">${icon('edit')} Rename</button></form>
153
- <form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the diagram ${d.name}? The tables stay as they are.">${icon('trash')} Delete diagram</button></form></details>`;
154
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler', s)}
155
- <div class="dm-head"><h2 class="dm-name">${d.name}</h2><span class="muted small">Schema ${d.schema_name} · ${tables.length} objects · updated ${when(d.updated_at)} by ${d.updated_by ?? d.created_by}</span></div>
156
- ${missing.length ? html`<div class="alert alert-error" role="alert">Not in ${d.schema_name} any more: ${missing.join(', ')}. Saving the diagram leaves them out.</div>` : ''}
154
+ const settings = html`<details class="dm-settings"><summary>${tx('dm.settings')}</summary>
155
+ <form method="post" action="${M}/${d.id}/rename" class="dm-search-row">${csrf(s)}<label class="sr-only" for="f_rename">${tx('scr.name')}</label><input id="f_rename" name="name" value="${d.name}" maxlength="120" required><button class="btn">${icon('edit')} ${tx('cl.rename')}</button></form>
156
+ <form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('dm.delete_confirm', { name: d.name })}">${icon('trash')} ${tx('dm.delete_diagram')}</button></form></details>`;
157
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('modeler', s)}
158
+ <div class="dm-head"><h2 class="dm-name">${d.name}</h2><span class="muted small">${tx('dm.head', { schema: d.schema_name, n: tables.length, time: when(d.updated_at), user: d.updated_by ?? d.created_by })}</span></div>
159
+ ${missing.length ? html`<div class="alert alert-error" role="alert">${tx('dm.missing', { schema: d.schema_name, names: missing.join(', ') })}</div>` : ''}
157
160
  ${toolbar}
158
161
  <div class="dm-stage" data-dm-stage data-name="${d.name}">
159
- <div class="dm-canvas" tabindex="0" aria-label="Diagram ${d.name}: drag a box to move it, or focus it and use the arrow keys">${svg}</div>
162
+ <div class="dm-canvas" tabindex="0" aria-label="${tx('dm.canvas_label', { name: d.name })}">${svg}</div>
160
163
  </div>
161
- <p class="muted small dm-legend">P primary key · F foreign key · U unique · <strong>*</strong> not null · a line runs from the referencing table (crow's foot: many) to the referenced one (bar: one); dashed when the foreign key may be empty. A box's name opens ${tables.some((t) => t.kind === 'table') ? 'Table Properties' : 'the object'}.</p>
162
- ${region('Objects', objectList, '', 'dm-objects')}<div class="u-spacer"></div>
163
- ${region('Add objects to diagram', add, '', 'dm-add')}<div class="u-spacer"></div>
164
+ <p class="muted small dm-legend">${tx.rich('dm.legend', { star: html`<strong>*</strong>` })} ${tables.some((t) => t.kind === 'table') ? tx('dm.legend_opens_table') : tx('dm.legend_opens_object')}</p>
165
+ ${region(tx('ddl.objects'), objectList, '', 'dm-objects')}<div class="u-spacer"></div>
166
+ ${region(tx('dm.add_title'), add, '', 'dm-add')}<div class="u-spacer"></div>
164
167
  ${settings}`;
165
168
  return send(reply, s, shell(s, d.name, crumbs, main, 'sql'));
166
169
  }
@@ -188,23 +191,23 @@ export function diagramDdl(schema: string, names: Set<string>, objects: DdlObjec
188
191
  return ddlScript(schema, keep).replace(/^-- DDL of schema .*$/m, `-- DDL of ${names.size} objects of schema ${schema} (a Data Modeler diagram)`);
189
192
  }
190
193
 
191
- const CON: Record<string, string> = { p: 'Primary key', u: 'Unique', f: 'Foreign key', c: 'Check' };
194
+ const CON: Record<string, BuilderKey> = { p: 'dm.con.p', u: 'dm.con.u', f: 'dm.con.f', c: 'dm.con.c' };
192
195
 
193
196
  /** The data dictionary: tables, columns, comments, constraints. */
194
- function dictionary(schema: string, d: Diagram, tables: ModelTable[]): Raw {
197
+ function dictionary(tx: BuilderTranslate, schema: string, d: Diagram, tables: ModelTable[]): Raw {
195
198
  const fks = lines(schema, tables);
196
- return html`<header class="dm-dict-head"><h1>Data dictionary: ${d.name}</h1>
197
- <p>Schema ${schema} · ${tables.length} objects · generated ${when(new Date().toISOString())} UTC</p></header>
198
- <nav aria-label="Objects"><ol class="dm-dict-toc">${tables.map((t, i) => html`<li><a href="#t${i}">${t.name}</a> <span class="muted">${t.kind}</span></li>`)}</ol></nav>
199
+ return html`<header class="dm-dict-head"><h1>${tx('dm.dict_title', { name: d.name })}</h1>
200
+ <p>${tx('dm.dict_head', { schema, n: tables.length, time: when(new Date().toISOString()) })}</p></header>
201
+ <nav aria-label="${tx('ddl.objects')}"><ol class="dm-dict-toc">${tables.map((t, i) => html`<li><a href="#t${i}">${t.name}</a> <span class="muted">${t.kind}</span></li>`)}</ol></nav>
199
202
  ${tables.map((t, i) => html`<section class="dm-dict-table" id="t${i}">
200
203
  <h2>${t.name} <span class="muted small">${t.kind}</span></h2>
201
204
  ${t.comment ? html`<p>${t.comment}</p>` : ''}
202
- <div class="table-wrap"><table class="report" aria-label="Columns of ${t.name}"><thead><tr><th>#</th><th>Column</th><th>Type</th><th>Null</th><th>Default</th><th>Key</th><th>Comment</th></tr></thead>
205
+ <div class="table-wrap"><table class="report" aria-label="${tx('dm.columns_of', { name: t.name })}"><thead><tr><th>#</th><th>${tx('dm.col.column')}</th><th>${tx('ch.type')}</th><th>${tx('dm.col.null')}</th><th>${tx('dm.col.default')}</th><th>${tx('dm.col.key')}</th><th>${tx('dm.col.comment')}</th></tr></thead>
203
206
  <tbody>${t.columns.map((c, k) => html`<tr><td class="num">${k + 1}</td><td class="u-break"><code>${c.name}</code></td><td class="u-break">${c.type}</td><td>${c.notNull ? 'not null' : 'null'}</td>
204
207
  <td class="u-break">${c.default ?? ''}</td><td>${markers(t, c.name)}</td><td class="u-break">${c.comment ?? ''}</td></tr>`)}</tbody></table></div>
205
- ${t.constraints.length ? html`<h3>Constraints</h3><div class="table-wrap"><table class="report" aria-label="Constraints of ${t.name}"><thead><tr><th>Name</th><th>Type</th><th>Definition</th></tr></thead>
206
- <tbody>${t.constraints.map((k) => html`<tr><td class="u-break"><code>${k.name}</code></td><td>${CON[k.type]}</td><td class="u-break"><code>${k.def}</code></td></tr>`)}</tbody></table></div>` : ''}
207
- ${fks.some((l) => l.to === t.name) ? html`<h3>Referenced by</h3><ul>${fks.filter((l) => l.to === t.name).map((l) => html`<li>${l.label}</li>`)}</ul>` : ''}
208
+ ${t.constraints.length ? html`<h3>${tx('dm.constraints')}</h3><div class="table-wrap"><table class="report" aria-label="${tx('dm.constraints_of', { name: t.name })}"><thead><tr><th>${tx('scr.name')}</th><th>${tx('ch.type')}</th><th>${tx('dm.definition')}</th></tr></thead>
209
+ <tbody>${t.constraints.map((k) => html`<tr><td class="u-break"><code>${k.name}</code></td><td>${CON[k.type] ? tx(CON[k.type]) : k.type}</td><td class="u-break"><code>${k.def}</code></td></tr>`)}</tbody></table></div>` : ''}
210
+ ${fks.some((l) => l.to === t.name) ? html`<h3>${tx('dm.referenced_by')}</h3><ul>${fks.filter((l) => l.to === t.name).map((l) => html`<li>${l.label}</li>`)}</ul>` : ''}
208
211
  </section>`)}`;
209
212
  }
210
213
 
@@ -226,7 +229,7 @@ export async function dataModelerRoutes(app: FastifyInstance) {
226
229
  const db = await workshopDb(s);
227
230
  if (!(await usableSchemas(db)).includes(schema)) return notFound(reply);
228
231
  if (!name) {
229
- flash(s, 'A diagram needs a name.', 'error');
232
+ flash(s, tr(s)('dm.err.name'), 'error');
230
233
  return back(reply, s, M);
231
234
  }
232
235
  const names = matchNames((await modelNames(db, schema)).map((o) => o.name), String(req.body?.objects ?? '').slice(0, 2000), { exact: true }).slice(0, 500);
@@ -235,11 +238,11 @@ export async function dataModelerRoutes(app: FastifyInstance) {
235
238
  const row = await owner.one<{ id: string }>(
236
239
  `insert into meta.sql_diagram (workspace_id, name, schema_name, objects, created_by) values ($1, $2, $3, $4, $5) returning id::text`,
237
240
  [currentWorkspace(s), name, schema, JSON.stringify(autoLayout(schema, tables)), s.username]);
238
- flash(s, `Diagram ${name} created${tables.length ? ` with ${tables.length} objects` : ': add objects to it'}.`);
241
+ flash(s, tables.length ? tr(s)('dm.created_n', { name, n: tables.length }) : tr(s)('dm.created_empty', { name }));
239
242
  return back(reply, s, `${M}/${row!.id}${tables.length ? '' : '#dm-add'}`);
240
243
  } catch (e: any) {
241
244
  if (e.code !== '23505') throw e;
242
- flash(s, `There is already a diagram ${name} in this workspace.`, 'error');
245
+ flash(s, tr(s)('dm.err.exists', { name }), 'error');
243
246
  return back(reply, s, M);
244
247
  }
245
248
  });
@@ -259,18 +262,18 @@ export async function dataModelerRoutes(app: FastifyInstance) {
259
262
  const remove = typeof req.body?.remove === 'string' ? req.body.remove : '';
260
263
  if (remove) {
261
264
  await saveObjects(s, d.id, m.placed.filter((p) => p.name !== remove));
262
- flash(s, `${remove} removed from the diagram (the table stays as it is).`);
265
+ flash(s, tr(s)('dm.removed', { name: remove }));
263
266
  return back(reply, s, `${M}/${d.id}`);
264
267
  }
265
268
  const existing = new Set((await modelNames(m.db, d.schema_name)).map((o) => o.name));
266
269
  const adding = [...new Set(wanted)].filter((n) => existing.has(n) && !m.placed.some((p) => p.name === n)).slice(0, Math.max(0, 500 - m.placed.length));
267
270
  if (!adding.length) {
268
- flash(s, 'Nothing to add: pick tables or views of the schema that aren\'t in the diagram.', 'error');
271
+ flash(s, tr(s)('dm.err.nothing'), 'error');
269
272
  return back(reply, s, `${M}/${d.id}#dm-add`);
270
273
  }
271
274
  const added = await readModel(m.db, d.schema_name, adding);
272
275
  await saveObjects(s, d.id, placeAll(d.schema_name, [...m.tables, ...added], m.placed));
273
- flash(s, `Added ${added.length} object${added.length === 1 ? '' : 's'}: ${added.map((t) => t.name).join(', ')}.`);
276
+ flash(s, tr(s)(added.length === 1 ? 'dm.added_one' : 'dm.added', { n: added.length, names: added.map((t) => t.name).join(', ') }));
274
277
  return back(reply, s, `${M}/${d.id}`);
275
278
  });
276
279
 
@@ -298,7 +301,7 @@ export async function dataModelerRoutes(app: FastifyInstance) {
298
301
  }
299
302
  await saveObjects(s, d.id, next);
300
303
  if (json) return reply.send({ ok: true, saved: next.length });
301
- flash(s, req.body?.layout === 'auto' ? 'The boxes were laid out again.' : 'Diagram saved.');
304
+ flash(s, req.body?.layout === 'auto' ? tr(s)('dm.laid_out') : tr(s)('dm.saved'));
302
305
  return back(reply, s, `${M}/${d.id}`);
303
306
  });
304
307
 
@@ -309,15 +312,15 @@ export async function dataModelerRoutes(app: FastifyInstance) {
309
312
  if (!d) return notFound(reply);
310
313
  const name = String(req.body?.name ?? '').trim().slice(0, 120);
311
314
  if (!name) {
312
- flash(s, 'A diagram needs a name.', 'error');
315
+ flash(s, tr(s)('dm.err.name'), 'error');
313
316
  return back(reply, s, `${M}/${d.id}`);
314
317
  }
315
318
  try {
316
319
  await owner.query(`update meta.sql_diagram set name = $3, updated_by = $4, updated_at = now() where id = $1 and workspace_id = $2`, [d.id, currentWorkspace(s), name, s.username]);
317
- flash(s, `Diagram renamed to ${name}.`);
320
+ flash(s, tr(s)('dm.renamed', { name }));
318
321
  } catch (e: any) {
319
322
  if (e.code !== '23505') throw e;
320
- flash(s, `There is already a diagram ${name} in this workspace.`, 'error');
323
+ flash(s, tr(s)('dm.err.exists', { name }), 'error');
321
324
  }
322
325
  return back(reply, s, `${M}/${d.id}`);
323
326
  });
@@ -328,7 +331,7 @@ export async function dataModelerRoutes(app: FastifyInstance) {
328
331
  const d = await loadDiagram(s, req.params.id);
329
332
  if (!d) return notFound(reply);
330
333
  await owner.query('delete from meta.sql_diagram where id = $1 and workspace_id = $2', [d.id, currentWorkspace(s)]);
331
- flash(s, `Diagram ${d.name} deleted.`);
334
+ flash(s, tr(s)('dm.deleted', { name: d.name }));
332
335
  return back(reply, s, M);
333
336
  });
334
337
 
@@ -353,13 +356,14 @@ export async function dataModelerRoutes(app: FastifyInstance) {
353
356
  const script = diagramDdl(d.schema_name, new Set(m.tables.map((t) => t.name)), await readSchema(m.db.pool, d.schema_name));
354
357
  if (req.query.download === '1')
355
358
  return reply.header('content-disposition', `attachment; filename="${fileName(d.name)}.sql"`).type('application/sql; charset=utf-8').send(script);
356
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler', s)}
357
- ${region(`DDL of ${d.name}`, html`<p class="muted u-mt0">The CREATE statements of the diagram's ${m.tables.length} objects in schema ${d.schema_name}: tables, their keys, indexes, triggers, row level security, policies, comments and grants, and views. Review before running.</p>
359
+ const tx = tr(s);
360
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('modeler', s)}
361
+ ${region(tx('ddl.of', { schema: d.name }), html`<p class="muted u-mt0">${tx('dm.ddl_intro', { n: m.tables.length, schema: d.schema_name })}</p>
358
362
  <pre class="source" data-code-view="sql" tabindex="0" aria-label="DDL">${script}</pre>
359
363
  <form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${script}">
360
- <div class="buttons"><a class="btn" href="${M}/${d.id}">${bicon('back')} Back to the diagram</a><a class="btn" href="${M}/${d.id}/ddl?download=1">${icon('download')} Download</a>
361
- <button class="btn btn-hot">${icon('play')} Open in the SQL Worksheet</button></div></form>`)}`;
362
- return send(reply, s, shell(s, `DDL of ${d.name}`, [['SQL Workshop', `${BASE}/sql`], ['Data Modeler', M], [d.name, `${M}/${d.id}`], ['DDL']], main, 'sql'));
364
+ <div class="buttons"><a class="btn" href="${M}/${d.id}">${bicon('back')} ${tx('dm.back')}</a><a class="btn" href="${M}/${d.id}/ddl?download=1">${icon('download')} ${tx('scr.download')}</a>
365
+ <button class="btn btn-hot">${icon('play')} ${tx('dm.open_worksheet')}</button></div></form>`)}`;
366
+ return send(reply, s, shell(s, tx('ddl.of', { schema: d.name }), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.modeler'), M], [d.name, `${M}/${d.id}`], ['DDL']], main, 'sql'));
363
367
  });
364
368
 
365
369
  // the data dictionary report (download=1: a standalone HTML file)
@@ -369,14 +373,15 @@ export async function dataModelerRoutes(app: FastifyInstance) {
369
373
  const d = await loadDiagram(s, req.params.id);
370
374
  const m = d && (await model(s, d));
371
375
  if (!d || !m) return notFound(reply);
372
- const body = dictionary(d.schema_name, d, m.tables);
376
+ const tx = tr(s);
377
+ const body = dictionary(tx, d.schema_name, d, m.tables);
373
378
  if (req.query.download === '1')
374
379
  return reply.header('content-disposition', `attachment; filename="${fileName(d.name)}-dictionary.html"`).type('text/html; charset=utf-8')
375
- .send(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Data dictionary: ${esc(d.name)}</title><style>${DICT_CSS}</style></head><body>${body}</body></html>`);
376
- const main = html`<div class="dm-dict-actions buttons"><a class="btn" href="${M}/${d.id}">${bicon('back')} Back to the diagram</a>
377
- <a class="btn" href="${M}/${d.id}/dictionary?download=1">${icon('download')} Download HTML</a>
378
- <button type="button" class="btn" data-dm="print" hidden>${icon('printer')} Print or save as PDF</button></div>
380
+ .send(`<!doctype html><html lang="${tx.lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(tx('dm.dict_title', { name: d.name }))}</title><style>${DICT_CSS}</style></head><body>${body}</body></html>`);
381
+ const main = html`<div class="dm-dict-actions buttons"><a class="btn" href="${M}/${d.id}">${bicon('back')} ${tx('dm.back')}</a>
382
+ <a class="btn" href="${M}/${d.id}/dictionary?download=1">${icon('download')} ${tx('dm.download_html')}</a>
383
+ <button type="button" class="btn" data-dm="print" hidden>${icon('printer')} ${tx('dm.print_pdf')}</button></div>
379
384
  <article class="dm-dict">${body}</article>`;
380
- return send(reply, s, shell(s, `Data dictionary: ${d.name}`, [['SQL Workshop', `${BASE}/sql`], ['Data Modeler', M], [d.name, `${M}/${d.id}`], ['Data dictionary']], main, 'sql'));
385
+ return send(reply, s, shell(s, tx('dm.dict_title', { name: d.name }), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.modeler'), M], [d.name, `${M}/${d.id}`], [tx('dm.dictionary')]], main, 'sql'));
381
386
  });
382
387
  }
@@ -1,5 +1,5 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
- import { html, raw, type Raw } from '../html.ts';
2
+ import { esc, html, raw, type Raw } from '../html.ts';
3
3
  import { icon } from '../icons.ts';
4
4
  import { owner } from '../db.ts';
5
5
  import { clientIp, logActivity, type Session } from '../session.ts';
@@ -9,6 +9,7 @@ import { clearCompletions } from './code-editor.ts';
9
9
  import { roleHints, splitRoles } from './users.ts';
10
10
  import { currentWorkspace, inWorkspaceSql, usableSchemas, workshopDb } from './workspaces.ts';
11
11
  import { joinStatements, recordChanges } from '../changelog.ts';
12
+ import { tr } from './i18n.ts';
12
13
  import {
13
14
  APP_ROLE_RE, checkSecurity, currentDef, MASK_LABELS, MASKS, maskedColumns, readSecurity, securitySql, sees,
14
15
  type ColumnMask, type SecurityDef, type TableSecurity,
@@ -55,64 +56,65 @@ function fromForm(b: Body, cur: TableSecurity): SecurityDef {
55
56
  }
56
57
 
57
58
  function form(s: Session, cur: TableSecurity, def: SecurityDef, opts: { ref: string; sql: string[]; warnings: string[]; errors: string[]; output: Raw | '' }): Raw {
59
+ const tx = tr(s);
58
60
  const chk = (x: boolean) => (x ? raw(' checked') : '');
59
61
  const all = cur.columns.map((c) => c.name);
60
62
  const v = def.view;
61
63
  const masked = new Set(maskedColumns(v));
62
64
  const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: opts.ref })}`;
63
65
  const policyUrl = `${BASE}/sql/objects/action?${new URLSearchParams({ schema: cur.schema, k: 'table', o: opts.ref, do: 'policy' })}`;
64
- const rows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">Database roles and columns</caption>
65
- <thead><tr><th scope="col">Database role</th>${all.map((c) => html`<th scope="col"><code>${c}</code>${masked.has(c) ? html` <span class="badge" title="Masked in ${v.name}">masked</span>` : ''}</th>`)}</tr></thead>
66
+ const rows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">${tx('ds.roles_and_columns')}</caption>
67
+ <thead><tr><th scope="col">${tx('ds.db_role')}</th>${all.map((c) => html`<th scope="col"><code>${c}</code>${masked.has(c) ? html` <span class="badge" title="${tx('ds.masked_in', { view: v.name })}">${tx('ds.masked')}</span>` : ''}</th>`)}</tr></thead>
66
68
  <tbody>${cur.grants.length ? def.grants.map((g, i) => {
67
69
  const info = cur.grants[i];
68
- return html`<tr><th scope="row"><code>${g.role === 'PUBLIC' ? 'PUBLIC' : g.role}</code>${info.apps.length ? html`<br><small class="muted">runs ${info.apps.join(', ')}</small>` : ''}</th>
69
- ${all.map((c, j) => html`<td class="ds-cell"><label class="check"><input type="checkbox" name="g.${i}.s.${j}"${chk(g.select.includes(c))}> read</label>
70
- <label class="check"><input type="checkbox" name="g.${i}.u.${j}"${chk(g.update.includes(c))}> update</label></td>`)}</tr>`;
71
- }) : html`<tr><td colspan="${all.length + 1}" class="muted">No database role has privileges on this table, and no application of the workspace runs with a database role.</td></tr>`}</tbody></table></div>
72
- ${cur.excluded.length ? html`<p class="muted small">Not in the matrix, because column privileges can't restrict them: ${cur.excluded.map((x) => html`<code>${x.role}</code> (${x.why}) `)}</p>` : ''}`;
73
- const maskRows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">Masks per column</caption>
74
- <thead><tr><th scope="col">Column</th><th scope="col">Mask</th>${v.roles.map((r) => html`<th scope="col">${r} sees it</th>`)}<th scope="col">Everyone else sees it</th></tr></thead>
70
+ return html`<tr><th scope="row"><code>${g.role === 'PUBLIC' ? 'PUBLIC' : g.role}</code>${info.apps.length ? html`<br><small class="muted">${tx('ds.runs', { apps: info.apps.join(', ') })}</small>` : ''}</th>
71
+ ${all.map((c, j) => html`<td class="ds-cell"><label class="check"><input type="checkbox" name="g.${i}.s.${j}"${chk(g.select.includes(c))}> ${tx('ds.read')}</label>
72
+ <label class="check"><input type="checkbox" name="g.${i}.u.${j}"${chk(g.update.includes(c))}> ${tx('ds.update')}</label></td>`)}</tr>`;
73
+ }) : html`<tr><td colspan="${all.length + 1}" class="muted">${tx('ds.no_grants')}</td></tr>`}</tbody></table></div>
74
+ ${cur.excluded.length ? html`<p class="muted small">${tx('ds.excluded')} ${cur.excluded.map((x) => html`<code>${x.role}</code> (${x.why}) `)}</p>` : ''}`;
75
+ const maskRows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">${tx('ds.masks_per_column')}</caption>
76
+ <thead><tr><th scope="col">${tx('ds.column')}</th><th scope="col">${tx('ds.mask')}</th>${v.roles.map((r) => html`<th scope="col">${tx('ds.role_sees', { role: r })}</th>`)}<th scope="col">${tx('ds.others_see')}</th></tr></thead>
75
77
  <tbody>${all.map((c, j) => {
76
78
  const m = v.cols[c];
77
79
  return html`<tr><th scope="row"><code>${c}</code> <small class="muted">${cur.columns[j].type}</small></th>
78
- <td><select name="m.${j}.mask" aria-label="Mask of ${c}"><option value="">Not masked</option>${MASKS.map((x) => html`<option value="${x}"${m?.mask === x ? raw(' selected') : ''}>${MASK_LABELS[x]}</option>`)}</select></td>
79
- ${v.roles.map((r, k) => html`<td class="ds-c"><input type="checkbox" aria-label="${r} sees ${c}" name="m.${j}.see.${k}"${chk(!!m && sees(v, c, r))}></td>`)}
80
- <td class="ds-c"><input type="checkbox" aria-label="Everyone else sees ${c}" name="m.${j}.others"${chk(!!m && m.others)}></td></tr>`;
80
+ <td><select name="m.${j}.mask" aria-label="${tx('ds.aria_mask', { column: c })}"><option value="">${tx('ds.not_masked')}</option>${MASKS.map((x) => html`<option value="${x}"${m?.mask === x ? raw(' selected') : ''}>${tx.dyn(`ds.mask.${x}`, MASK_LABELS[x])}</option>`)}</select></td>
81
+ ${v.roles.map((r, k) => html`<td class="ds-c"><input type="checkbox" aria-label="${tx('ds.aria_role_sees', { role: r, column: c })}" name="m.${j}.see.${k}"${chk(!!m && sees(v, c, r))}></td>`)}
82
+ <td class="ds-c"><input type="checkbox" aria-label="${tx('ds.aria_others_see', { column: c })}" name="m.${j}.others"${chk(!!m && m.others)}></td></tr>`;
81
83
  })}</tbody></table></div>`;
82
84
  return html`<form method="post" action="${BASE}/sql/objects/security" class="ds">${csrf(s)}
83
85
  <input type="hidden" name="ref" value="${opts.ref}"><input type="hidden" name="shown_roles" value="${v.roles.join(', ')}">
84
86
  ${opts.errors.length ? html`<div class="alert alert-error" role="alert"><ul>${opts.errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
85
- <section class="ds-section"><h2>${icon('shield')} Rows</h2>
86
- <p>Row level security is <strong>${cur.rls ? (cur.forceRls ? 'on, also for the owner' : 'on') : 'off'}</strong>${cur.rls ? '' : ': every role that may read the table reads every row'}.
87
- <a href="${BASE}/sql/objects/table?${new URLSearchParams({ o: opts.ref })}">Table Properties</a> switches it on; <a href="${policyUrl}">Add policy</a> writes one in the worksheet.</p>
88
- ${cur.policies.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Policy</th><th>Command</th><th>Roles</th><th>Using</th><th>With check</th></tr></thead><tbody>
89
- ${cur.policies.map((p) => html`<tr><td>${p.name}${p.permissive === 'RESTRICTIVE' ? html` <span class="badge">restrictive</span>` : ''}</td><td>${p.command}</td><td><code>${p.roles}</code></td><td><code>${p.using ?? ''}</code></td><td><code>${p.check ?? ''}</code></td></tr>`)}</tbody></table></div>`
90
- : html`<p class="muted">No policies${cur.rls ? ': with row level security on, the roles below read no rows at all' : ''}.</p>`}
87
+ <section class="ds-section"><h2>${icon('shield')} ${tx('ds.rows')}</h2>
88
+ <p>${tx.rich('ds.rls_is', { state: raw(`<strong>${esc(cur.rls ? (cur.forceRls ? tx('ds.rls_on_owner') : tx('ds.rls_on')) : tx('ds.rls_off'))}</strong>`) })}${cur.rls ? '' : `: ${tx('ds.rls_off_detail')}`}.
89
+ ${tx.rich('ds.rls_how', { props: raw(`<a href="${BASE}/sql/objects/table?${new URLSearchParams({ o: opts.ref })}">${esc(tx('ds.table_properties'))}</a>`), policy: raw(`<a href="${policyUrl}">${esc(tx('ds.add_policy'))}</a>`) })}</p>
90
+ ${cur.policies.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('ds.policy')}</th><th>${tx('ds.command')}</th><th>${tx('ds.roles')}</th><th>Using</th><th>With check</th></tr></thead><tbody>
91
+ ${cur.policies.map((p) => html`<tr><td>${p.name}${p.permissive === 'RESTRICTIVE' ? html` <span class="badge">${tx('ds.restrictive')}</span>` : ''}</td><td>${p.command}</td><td><code>${p.roles}</code></td><td><code>${p.using ?? ''}</code></td><td><code>${p.check ?? ''}</code></td></tr>`)}</tbody></table></div>`
92
+ : html`<p class="muted">${cur.rls ? tx('ds.no_policies_rls') : tx('ds.no_policies')}</p>`}
91
93
  </section>
92
- <section class="ds-section"><h2>${icon('table')} Columns</h2>
93
- <p class="muted">What each database role may read and update, per column. A column a role may not read is refused by PostgreSQL whoever sends the query: pages, REST handlers, AutoREST, AI tools, MCP or psql.</p>
94
+ <section class="ds-section"><h2>${icon('table')} ${tx('ds.columns')}</h2>
95
+ <p class="muted">${tx('ds.columns_help')}</p>
94
96
  ${rows}
95
97
  </section>
96
- <section class="ds-section"><h2>${icon('eye')} Values</h2>
97
- <p class="muted">Masked columns show their value only to the application roles you tick (<code>meta.has_role()</code> of the signed-in user); others see the mask. The masks live in a view next to the table that the roles above read instead of the table.</p>
98
- <label class="check"><input type="checkbox" name="view"${chk(v.enabled)}> Masking view</label>
98
+ <section class="ds-section"><h2>${icon('eye')} ${tx('ds.values')}</h2>
99
+ <p class="muted">${tx.code('ds.values_help')}</p>
100
+ <label class="check"><input type="checkbox" name="view"${chk(v.enabled)}> ${tx('ds.masking_view')}</label>
99
101
  <div class="form-grid">
100
- <div class="field"><label class="label" for="ds-view">View name</label><input id="ds-view" name="view_name" value="${v.name}" autocomplete="off" spellcheck="false"></div>
101
- <div class="field"><label class="label" for="ds-roles">Application roles</label><input id="ds-roles" name="roles" value="${v.roles.join(', ')}" autocomplete="off" spellcheck="false">
102
- <small class="help">Comma-separated. Changed roles get their column in the table below after Preview SQL.</small></div>
102
+ <div class="field"><label class="label" for="ds-view">${tx('ds.view_name')}</label><input id="ds-view" name="view_name" value="${v.name}" autocomplete="off" spellcheck="false"></div>
103
+ <div class="field"><label class="label" for="ds-roles">${tx('ds.app_roles')}</label><input id="ds-roles" name="roles" value="${v.roles.join(', ')}" autocomplete="off" spellcheck="false">
104
+ <small class="help">${tx('ds.app_roles_help')}</small></div>
103
105
  </div>
104
106
  ${maskRows}
105
107
  </section>
106
108
  <section class="ds-section"><h2>${icon('code')} SQL</h2>
107
109
  ${opts.warnings.length ? html`<div class="alert alert-warning" role="status"><ul>${opts.warnings.map((w) => html`<li>${w}</li>`)}</ul></div>` : ''}
108
- ${opts.sql.length ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="SQL">${opts.sql.join(';\n\n')};</pre>` : html`<p class="muted">No changes: the table's security is as the form shows it.</p>`}
110
+ ${opts.sql.length ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="SQL">${opts.sql.join(';\n\n')};</pre>` : html`<p class="muted">${tx('ds.no_changes')}</p>`}
109
111
  ${opts.output}
110
112
  </section>
111
113
  <div class="buttons">
112
- <button class="btn" name="mode" value="preview">${icon('eye')} Preview SQL</button>
113
- <button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>
114
- <button class="btn" name="mode" value="worksheet">${icon('code')} Open in the worksheet</button>
115
- <a class="btn" href="${objUrl}">Cancel</a>
114
+ <button class="btn" name="mode" value="preview">${icon('eye')} ${tx('ds.preview_sql')}</button>
115
+ <button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('te.apply')}</button>
116
+ <button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('te.open_ws')}</button>
117
+ <a class="btn" href="${objUrl}">${tx('common.cancel')}</a>
116
118
  </div></form>`;
117
119
  }
118
120
 
@@ -139,7 +141,7 @@ async function page(req: Req, reply: FastifyReply) {
139
141
  if (!errors.length) ({ statements: sql, warnings } = securitySql(def, cur));
140
142
  if (!errors.length && b.mode === 'worksheet') {
141
143
  s.state.__SQLDRAFT = sql.length ? `${sql.join(';\n\n')};` : '-- no changes';
142
- flash(s, 'Review the statements and run them: nothing has been changed yet.');
144
+ flash(s, tr(s)('te.review'));
143
145
  return back(reply, s, `${BASE}/sql/worksheet`);
144
146
  }
145
147
  if (!errors.length && b.mode === 'apply' && sql.length) {
@@ -163,16 +165,16 @@ async function page(req: Req, reply: FastifyReply) {
163
165
  clearCompletions();
164
166
  if (!failed) {
165
167
  await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', name: `security_${cur.table}`.slice(0, 63), defaultSchema: cur.schema, statements: [joinStatements(sql)] });
166
- flash(s, `Changed the security of ${cur.schema}.${cur.table}: ${sql.length} statement${sql.length === 1 ? '' : 's'}.`);
168
+ flash(s, sql.length === 1 ? tr(s)('ds.changed_1', { table: `${cur.schema}.${cur.table}` }) : tr(s)('ds.changed', { table: `${cur.schema}.${cur.table}`, n: sql.length }));
167
169
  return back(reply, s, `${BASE}/sql/objects/security?${new URLSearchParams({ o: ref })}`);
168
170
  }
169
- output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="Failed statement">${at}</pre></div>`;
171
+ output = html`<div class="alert alert-error" role="alert"><p>${tr(s)('ds.rolled_back')}</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="${tr(s)('ds.failed_statement')}">${at}</pre></div>`;
170
172
  }
171
173
  }
172
- const title = `Security of ${cur.schema}.${cur.table}`;
174
+ const title = tr(s)('ds.title', { table: `${cur.schema}.${cur.table}` });
173
175
  const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: ref })}`;
174
176
  const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects', s)}${form(s, cur, def, { ref, sql, warnings, errors, output })}`;
175
- return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [`${cur.schema}.${cur.table}`, objUrl], ['Security']], main, 'sql'));
177
+ return send(reply, s, shell(s, title, [[tr(s)('rail.sql_workshop'), `${BASE}/sql`], [tr(s)('sql.tool.objects'), `${BASE}/sql/objects`], [`${cur.schema}.${cur.table}`, objUrl], [tr(s)('ds.security')]], main, 'sql'));
176
178
  }
177
179
 
178
180
  export async function dataSecurityRoutes(app: FastifyInstance) {