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
@@ -225,7 +225,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
225
225
  summary: (d) => d.name,
226
226
  defaults: { event: 'change', action: 'show' },
227
227
  validate: (v) =>
228
- v.css_classes && !CLASS_LIST.test(String(v.css_classes))
228
+ v.event === 'timer' && !/^\s*\d{1,4}\s*$/.test(String(v.trigger_element ?? '')) || (v.event === 'timer' && (Number(v.trigger_element) < 5 || Number(v.trigger_element) > 3600))
229
+ ? 'Timer: the trigger is a number of seconds from 5 to 3600 (how often the actions run).'
230
+ : v.css_classes && !CLASS_LIST.test(String(v.css_classes))
229
231
  ? 'CSS classes: up to five names of lower case letters, digits, - and _, separated by spaces.'
230
232
  : v.action === 'execute_javascript' && !/^[A-Za-z_$][\w$.-]{0,99}$/.test(String(v.code ?? '').trim())
231
233
  ? 'Execute JavaScript: Code is the name of a function a static file registers with pgkiln.actions.register(name, fn), e.g. highlightLate.'
@@ -235,9 +237,10 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
235
237
  fields: [
236
238
  { name: 'name', label: 'Name', kind: 'text', group: 'When' },
237
239
  { name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom', 'menu_entry',
238
- 'double_click', 'key_down', 'key_press', 'mouse_enter', 'mouse_leave', 'select', 'resize', 'scroll', 'page_unload', 'before_refresh', 'facets_change'], group: 'When',
239
- help: '(sprint 156) double_click, mouse_enter, mouse_leave: items, buttons or regions. key_down, key_press (a key that types a character), select (text selected): items. resize, scroll: the window (no trigger). page_unload: the page is left (only quick, local actions finish). before_refresh: a region is about to be refreshed by a Refresh action. facets_change: a faceted search or smart filters region\'s facets were applied (trigger: that region). ' + 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). menu_entry (Menu entry chosen): an entry {"label": …, "action": "NAME"} of a menu button was chosen. The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
240
- { name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: item names (change, focus, blur, key_release), a button name (click), region ids or static ids (after_refresh, selection_change), dialog page numbers (dialog_closed; empty: any dialog), event names (custom), menu entry action names or menu button names (menu_entry: any of its entries; a client-side condition tests the entry\'s name). Empty for load and before_submit.' },
240
+ 'double_click', 'key_down', 'key_press', 'mouse_enter', 'mouse_leave', 'select', 'resize', 'scroll', 'page_unload', 'before_refresh', 'facets_change',
241
+ 'grid_row_init', 'grid_save', 'calendar_date_select', 'calendar_view_change', 'map_object_click', 'map_change', 'tree_selection_change', 'timer'], group: 'When',
242
+ help: '(sprint 165) timer: every so many seconds while the page is open and visible (the trigger: a number of seconds, 5 to 3600), e.g. refresh_region of a report. (sprint 161) Component events, trigger: the region. grid_row_init: a row was added to an editable grid (Add row, Duplicate). grid_save: the grid\'s changes were saved (runs on the page that comes back). calendar_date_select: an empty day or hour was chosen (the condition sees YYYY-MM-DD or YYYY-MM-DDTHH:MM; cancel_event keeps the Create link closed). calendar_view_change: another view or period (the condition sees month, week, day or list). map_object_click: a marker or shape was clicked (the condition sees its id column, else its title). map_change: the map was moved or zoomed (the condition sees the zoom level). tree_selection_change: a tree node was chosen (the condition sees its id; cancel_event keeps its link closed). (sprint 156) double_click, mouse_enter, mouse_leave: items, buttons or regions. key_down, key_press (a key that types a character), select (text selected): items. resize, scroll: the window (no trigger). page_unload: the page is left (only quick, local actions finish). before_refresh: a region is about to be refreshed by a Refresh action. facets_change: a faceted search or smart filters region\'s facets were applied (trigger: that region). ' + 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). menu_entry (Menu entry chosen): an entry {"label": …, "action": "NAME"} of a menu button was chosen. The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
243
+ { name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: item names (change, focus, blur, key_release), a button name (click), region ids or static ids (after_refresh, selection_change, before_refresh, facets_change and the component events grid_*, calendar_*, map_*, tree_selection_change), dialog page numbers (dialog_closed; empty: any dialog), event names (custom), menu entry action names or menu button names (menu_entry: any of its entries; a client-side condition tests the entry\'s name). Empty for load and before_submit.' },
241
244
  { name: 'condition_type', label: 'Client-side condition', kind: 'select', options: ['', 'equals', 'not_equals', 'greater', 'greater_equal', 'less', 'less_equal', 'in_list', 'not_in_list', 'is_null', 'is_not_null'], group: 'When',
242
245
  help: 'On the (first) trigger item\'s value. greater / less: numbers as numbers, other values (ISO dates) as text; an empty value fails them. in_list / not_in_list: comma-separated values.' },
243
246
  { name: 'condition_value', label: 'Condition value', kind: 'text', group: 'When' },
@@ -246,7 +249,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
246
249
  help: '(sprint 156) cancel_event: stops the actions after it and the event\'s own effect (a button\'s submit or link, a typed key). set_style: {"styles": {"color": "#b42318", "font-weight": "bold"}} in the attributes, on the affected items or region. collapse_tree / expand_tree: every node of the affected tree region. show_ai_assistant: opens the affected AI assistant region (or its inline dialog) and puts the cursor in it. trigger_geocoding: the affected geocoded address item looks its text up. open_report_dialog: Code names the dialog of the affected interactive report (filter, columns, sort, highlight, compute, aggregate, chart, group, pivot, break, rows, saved). ' + 'show/hide/enable/disable reverse automatically when the condition is false. set_focus: the first affected item (or the region). show_error: on the affected items, or at the top. clear_errors: of the affected items, or all. ai_generate: runs the "Generate text with AI" process named in Code through AJAX, without submitting the page (on a submit button, the button submits instead when JavaScript is off). execute_javascript: calls the function named in Code, registered by a static application file (Shared Components → Static Application Files). push_subscribe: turns on push notifications on the user\'s device (use it on click: browsers ask only after one; the app needs push notifications on in Settings → Progressive Web App); Message is shown when it worked. confirm: asks Message; No stops the actions after it. close_dialog / cancel_dialog: close the modal page this runs on (close_dialog refreshes the page below and runs its dialog_closed actions). clear: empties the affected items. open_region / close_region: a collapsible region opens or closes (another region shows or hides). download: the file of the download process named in Code (Items to submit are sent first). print: the browser\'s print dialog. current_position: the device\'s position as latitude,longitude into the first affected item. share: the device\'s share sheet for this page (Message as the text), or the link copied. wait_popup: a modal "please wait" overlay (Message as its text) until remove_wait_popup or the next page, e.g. on before_submit around a long process, or wait_popup, execute_sql, remove_wait_popup around an Ajax call.' },
247
250
  { name: 'affected_items', label: 'Affected items', kind: 'upper', group: 'Action' },
248
251
  { name: 'affected_region_id', label: 'Affected region', kind: 'region', group: 'Action' },
249
- { name: 'code', label: 'SQL', kind: 'code', wide: true, group: 'Action', help: 'set_value: a SELECT whose columns set the affected items · execute_sql: any SQL; returned columns named like items set them · ai_generate: the name of an ai_generate process on this page (its output items are updated; items to submit default to the items its prompts use) · execute_javascript: the name of a function registered with pgkiln.actions.register(name, fn) in a static file the page loads.' },
252
+ { name: 'code', label: 'SQL', kind: 'code', wide: true, group: 'Action', help: 'set_value: a SELECT whose columns set the affected items (empty on a component event, grid_* / calendar_* / map_* / tree_selection_change: the event\'s value) · execute_sql: any SQL; returned columns named like items set them · ai_generate: the name of an ai_generate process on this page (its output items are updated; items to submit default to the items its prompts use) · execute_javascript: the name of a function registered with pgkiln.actions.register(name, fn) in a static file the page loads.' },
250
253
  { name: 'items_to_submit', label: 'Items to submit', kind: 'upper', group: 'Action' },
251
254
  { name: 'message', label: 'Message (alert, confirm, show_success, show_error, wait_popup)', kind: 'text', group: 'Action' },
252
255
  { name: 'config', label: 'Plug-in attributes (JSON)', kind: 'json', group: 'Action', help: 'plugin: {"attributes": {"NAME": "value or &ITEM."}} (Shared Components → Plug-ins lists each plug-in\'s attributes).' },
@@ -298,7 +301,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
298
301
  },
299
302
  fields: [
300
303
  { name: 'name', label: 'Name', kind: 'text' },
301
- { name: 'type', label: 'Type', kind: 'select', options: ['sql', 'form_dml', 'grid_dml', 'data_load', 'invoke_api', 'download', 'chain', 'workflow', 'ai_generate', 'plugin', 'send_push', 'send_email',
304
+ { name: 'type', label: 'Type', kind: 'select', options: ['sql', 'form_dml', 'grid_dml', 'data_load', 'invoke_api', 'download', 'chain', 'workflow', 'ai_generate', 'plugin', 'send_push', 'send_email', 'send_notification',
302
305
  'close_dialog', 'clear_session_state', 'reset_pagination', 'user_preferences', 'server_geocoding', 'print_report', 'human_task_create', 'human_task_manage', 'form_init'],
303
306
  help: '(sprint 157, settings in Attributes) close_dialog: {"items": ["P5_ID"]} closes the modal page, handing these items to the page below · clear_session_state: {"items": [...], "pages": [3], "all": false} · reset_pagination: reports and grids start on their first page · user_preferences: {"action": "set", "preference": "DEFAULT_DEPT", "item": "P1_DEPT"} or {"action": "reset", …} · server_geocoding: {"address_item": "P3_ADDRESS", "coordinates_item": "P3_LOCATION"} · print_report: {"document": "EMPLOYEE_SHEET"} (a document template or report query) · human_task_create: {"definition": "LEAVE_APPROVAL", "detail_pk": "&P7_ID.", "parameters": {"DAYS": "&P7_DAYS."}, "id_item": "P7_TASK_ID"} · human_task_manage: {"action": "approve", "task": "&P8_TASK_ID.", "comment": "&P8_COMMENT."} (approve, reject, complete with "outcome", claim, release, delegate with "to", cancel) · form_init: APEX\'s Form – Initialization (the form region fetches its row by itself) · download: a file from the query in Code · chain: runs the processes that name it as their chain, in sequence (optionally in the background) · workflow: start, terminate or retry a workflow · ai_generate: Generate text with AI (an AI service the application may use) · send_push: a push notification to a user\'s devices (Settings → Progressive Web App → push notifications)' },
304
307
  { name: 'point', label: 'Point', kind: 'select', options: ['submit', 'load', 'ajax'], help: 'A chain\'s processes run when the chain does (their own point is not used). ajax: an Ajax callback (APEX: On Demand) that JavaScript calls by name with pgkiln.server.process(\'NAME\', {items: [\'P1_X\'], x01: …}); SQL processes only; the last statement\'s rows come back as JSON, and the items it sets are set on the page.' },
@@ -306,8 +309,8 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
306
309
  { name: 'code', label: 'Code (SQL / PL/pgSQL call)', kind: 'code', wide: true,
307
310
  help: 'e.g. select sales.ship_order(:P3_ID::int) as p3_status — returned columns named like items set them. RAISE EXCEPTION messages are shown to the user; USING COLUMN = \'sal\' puts it on that field. · download: a query returning the file\'s content (bytea or text), file name and MIME type, e.g. select content, filename, mime_type from docs.file where id = :P5_ID::int — several rows are sent as one zip file.' },
308
311
  { name: 'region_id', label: 'Form / grid region (form_dml, grid_dml)', kind: 'region' },
309
- { name: 'config', label: 'Configuration (data_load, invoke_api, download, chain, workflow, ai_generate, plugin, send_push, send_email)', kind: 'json', wide: true,
310
- help: 'data_load: {"file_item":"P5_FILE","definition":"ORDERS_LOAD"} (a data load definition: table, format, mapping, transformations) or {"file_item":"P5_FILE","table":"sales.orders","mode":"append | merge | replace","skip_errors":false,"headers":true,"format":"auto | csv | xlsx | json | xml","row_tag":"order","columns":{"Heading in file":"column"}} — runs as the app\'s database role; columns match by name unless mapped. · invoke_api: {"source":"WEATHER","params":{"city":"&P5_CITY."},"items":{"P5_TEMP":"current.temp"},"status_item":"P5_STATUS"} or {"url":"https://api.example.com/orders/&P5_ID.","method":"POST","credential":"SHOP_API","body":"{\\"note\\": &P5_NOTE.}","items":{…}} — without "items", the first row\'s columns set the items named like them. · download: {"content_column":"content","filename_column":"filename","mime_column":"mime_type","zip_name":"files-&P5_ID..zip","disposition":"attachment | inline"} (all optional; default: the first three columns). Or {"export":"csv | json | xml | html | xlsx | pdf","file_name":"orders-&P5_ID.","title":"Orders","layout":"A4_LANDSCAPE","separator":";"}: the query\'s rows as one file (APEX_DATA_EXPORT; meta.data_export does csv, json, xml and html from SQL). · chain: {"background": true, "status_item": "P5_JOB_ID"} runs it after the submit, on the server (status in meta.process_jobs). · workflow: {"action":"start","definition":"APPROVAL","version":"2","detail_pk":"&P5_ID.","variables":{"AMOUNT":"&P5_AMOUNT."},"id_item":"P5_WORKFLOW_ID"} or {"action":"terminate | retry","instance":"&P5_WORKFLOW_ID.","comment":"…"} · send_push: {"to":"&P5_OWNER.","title":"Order &P5_ID. needs approval","body":"…","page":5,"items":{"P5_ID":"&P5_ID."},"tag":"order-&P5_ID.","urgency":"normal | high | low | very-low"} — to the user\'s devices that turned notifications on; the link is signed for that user · ai_generate: {"service":"CLAUDE","system":"You write short, friendly summaries.","prompt":"Summarise: &P5_NOTES.","output_item":"P5_SUMMARY"} (text), {"service":…,"prompt":"Extract the contact from: &P5_TEXT.","output_items":["P5_NAME","P5_EMAIL"]} (structured: a JSON schema built from the items, their labels describe the fields) or {"service":…,"prompt":…,"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"items":{"P5_NAME":"name"}} (your schema; items map to properties or paths); optional "max_tokens", "error_message". &ITEM. values are sent as delimited data, never as instructions. · plugin: {"plugin":"name","attributes":{"NAME":"value or &ITEM."}} (a process plug-in: its function returns the message) · send_email: {"to":"&P5_EMAIL.","cc":"…","bcc":"…","from":"…","reply_to":"…","subject":"Order &P5_ID.","body":"plain text","body_html":"<p>HTML (item values escaped)</p>"} or {"to":"&P5_EMAIL.","template":"ORDER_SHIPPED","placeholders":{"NAME":"&P5_NAME."}}; "attachments":"select content, filename, mime from sales.invoice where order_id = :P5_ID" — queued in the page\'s transaction, sent after the commit through the application\'s mail service' },
312
+ { name: 'config', label: 'Configuration (data_load, invoke_api, download, chain, workflow, ai_generate, plugin, send_push, send_email, send_notification)', kind: 'json', wide: true,
313
+ help: 'data_load: {"file_item":"P5_FILE","definition":"ORDERS_LOAD"} (a data load definition: table, format, mapping, transformations) or {"file_item":"P5_FILE","table":"sales.orders","mode":"append | merge | replace","skip_errors":false,"headers":true,"format":"auto | csv | xlsx | json | xml","row_tag":"order","columns":{"Heading in file":"column"}} — runs as the app\'s database role; columns match by name unless mapped. · invoke_api: {"source":"WEATHER","params":{"city":"&P5_CITY."},"items":{"P5_TEMP":"current.temp"},"status_item":"P5_STATUS"} or {"url":"https://api.example.com/orders/&P5_ID.","method":"POST","credential":"SHOP_API","body":"{\\"note\\": &P5_NOTE.}","items":{…}} — without "items", the first row\'s columns set the items named like them. · download: {"content_column":"content","filename_column":"filename","mime_column":"mime_type","zip_name":"files-&P5_ID..zip","disposition":"attachment | inline"} (all optional; default: the first three columns). Or {"export":"csv | json | xml | html | xlsx | pdf","file_name":"orders-&P5_ID.","title":"Orders","layout":"A4_LANDSCAPE","separator":";"}: the query\'s rows as one file (APEX_DATA_EXPORT; meta.data_export does csv, json, xml and html from SQL). · chain: {"background": true, "status_item": "P5_JOB_ID"} runs it after the submit, on the server (status in meta.process_jobs). · workflow: {"action":"start","definition":"APPROVAL","version":"2","detail_pk":"&P5_ID.","variables":{"AMOUNT":"&P5_AMOUNT."},"id_item":"P5_WORKFLOW_ID"} or {"action":"terminate | retry","instance":"&P5_WORKFLOW_ID.","comment":"…"} · send_push: {"to":"&P5_OWNER.","title":"Order &P5_ID. needs approval","body":"…","page":5,"items":{"P5_ID":"&P5_ID."},"tag":"order-&P5_ID.","urgency":"normal | high | low | very-low"} — to the user\'s devices that turned notifications on; the link is signed for that user · send_notification: {"to":"&P5_OWNER.","title":"Order &P5_ID. needs approval","body":"…","page":5,"items":{"P5_ID":"&P5_ID."},"icon":"bell","push":false} — an in-app notification (the bell; Settings → User Interface → Notifications); push: also to the user\'s devices · ai_generate: {"service":"CLAUDE","system":"You write short, friendly summaries.","prompt":"Summarise: &P5_NOTES.","output_item":"P5_SUMMARY"} (text), {"service":…,"prompt":"Extract the contact from: &P5_TEXT.","output_items":["P5_NAME","P5_EMAIL"]} (structured: a JSON schema built from the items, their labels describe the fields) or {"service":…,"prompt":…,"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"items":{"P5_NAME":"name"}} (your schema; items map to properties or paths); optional "max_tokens", "error_message". &ITEM. values are sent as delimited data, never as instructions. · plugin: {"plugin":"name","attributes":{"NAME":"value or &ITEM."}} (a process plug-in: its function returns the message) · send_email: {"to":"&P5_EMAIL.","cc":"…","bcc":"…","from":"…","reply_to":"…","subject":"Order &P5_ID.","body":"plain text","body_html":"<p>HTML (item values escaped)</p>"} or {"to":"&P5_EMAIL.","template":"ORDER_SHIPPED","placeholders":{"NAME":"&P5_NAME."}}; "attachments":"select content, filename, mime from sales.invoice where order_id = :P5_ID" — queued in the page\'s transaction, sent after the commit through the application\'s mail service' },
311
314
  { name: 'when_button', label: 'When button pressed', kind: 'upper' },
312
315
  ...conditionFields('Condition'),
313
316
  { name: 'success_message', label: 'Success message', kind: 'text' },
@@ -412,6 +415,8 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
412
415
  { name: 'parent_id', label: 'Parent entry', kind: 'list_parent', group: 'Entry', help: 'An entry of the same list: this entry is shown below it (a sub menu).' },
413
416
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Entry' },
414
417
  { name: 'badge', label: 'Badge', kind: 'text', group: 'Entry', help: 'A short text or number, &ITEM. allowed (e.g. &P1_OPEN_COUNT.).' },
418
+ { name: 'badge_query', label: 'Badge (SQL)', kind: 'code', group: 'Entry',
419
+ help: '(sprint 165) A SELECT whose first value is the badge, run as the application\'s role; wins over Badge. No row or an empty value: no badge. E.g. select nullif(count(*), 0) from sales.orders where status = \'NEW\' · select meta.unread_notifications(). Refreshed while the page is open with Settings → User Interface → Live badges.' },
415
420
  { name: 'description', label: 'Description', kind: 'textarea', group: 'Entry', help: 'Shown by the cards template.' },
416
421
  { name: 'target_page', label: 'Page', kind: 'page', group: 'Target', help: 'Empty with no URL: a heading for its child entries.' },
417
422
  { name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Target', help: '{"P3_ID": "&P1_ID."} — sent with a checksum.' },
@@ -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
  }