pgkiln 0.84.0 → 0.85.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 (81) hide show
  1. package/docs/apex-feature-parity.md +2 -2
  2. package/docs/guide/03-builder.md +14 -5
  3. package/docs/guide/12-development.md +6 -1
  4. package/package.json +1 -1
  5. package/src/app.ts +9 -0
  6. package/src/builder/ai-agents.ts +86 -85
  7. package/src/builder/ai-builder.ts +30 -30
  8. package/src/builder/ai.ts +93 -92
  9. package/src/builder/api.ts +47 -47
  10. package/src/builder/app-groups.ts +33 -32
  11. package/src/builder/appfromfile.ts +57 -58
  12. package/src/builder/appsheets.ts +33 -32
  13. package/src/builder/appwizard.ts +65 -65
  14. package/src/builder/arrange.ts +23 -22
  15. package/src/builder/assistant.ts +39 -38
  16. package/src/builder/automations.ts +32 -31
  17. package/src/builder/availability.ts +24 -24
  18. package/src/builder/backups.ts +30 -31
  19. package/src/builder/blueprints.ts +62 -61
  20. package/src/builder/create-page.ts +52 -50
  21. package/src/builder/cross-app.ts +37 -35
  22. package/src/builder/dataload.ts +2 -2
  23. package/src/builder/datasets.ts +107 -106
  24. package/src/builder/dependencies.ts +20 -19
  25. package/src/builder/designer.ts +36 -36
  26. package/src/builder/diagnostics.ts +58 -57
  27. package/src/builder/features.ts +39 -35
  28. package/src/builder/feedback.ts +23 -20
  29. package/src/builder/find-workspace.ts +28 -24
  30. package/src/builder/form-designer.ts +36 -35
  31. package/src/builder/gallery.ts +25 -29
  32. package/src/builder/globalization.ts +50 -49
  33. package/src/builder/i18n/de.ts +3453 -0
  34. package/src/builder/i18n/en.ts +3453 -0
  35. package/src/builder/i18n/es.ts +3453 -0
  36. package/src/builder/i18n/fr.ts +3453 -0
  37. package/src/builder/i18n/it.ts +3453 -0
  38. package/src/builder/i18n/ja.ts +3453 -0
  39. package/src/builder/i18n/ko.ts +3453 -0
  40. package/src/builder/i18n/nl.ts +3453 -0
  41. package/src/builder/i18n/pt.ts +3453 -0
  42. package/src/builder/i18n/zh-cn.ts +3453 -0
  43. package/src/builder/i18n/zh-tw.ts +3453 -0
  44. package/src/builder/i18n.ts +17 -3
  45. package/src/builder/json-sources.ts +63 -62
  46. package/src/builder/layouts.ts +12 -11
  47. package/src/builder/locks.ts +29 -28
  48. package/src/builder/login-page.ts +26 -25
  49. package/src/builder/mail.ts +148 -147
  50. package/src/builder/newapp.ts +15 -12
  51. package/src/builder/plugins.ts +47 -44
  52. package/src/builder/process-jobs.ts +11 -10
  53. package/src/builder/pwa.ts +22 -21
  54. package/src/builder/remote-databases.ts +55 -56
  55. package/src/builder/report-settings.ts +1 -1
  56. package/src/builder/reporter.ts +43 -42
  57. package/src/builder/rest-catalogs.ts +87 -86
  58. package/src/builder/rest.ts +9 -8
  59. package/src/builder/routes.ts +27 -27
  60. package/src/builder/search.ts +14 -13
  61. package/src/builder/static-files.ts +31 -32
  62. package/src/builder/subscriptions.ts +47 -45
  63. package/src/builder/supporting.ts +23 -18
  64. package/src/builder/team.ts +179 -179
  65. package/src/builder/telemetry.ts +59 -62
  66. package/src/builder/templates.ts +66 -65
  67. package/src/builder/themeroller.ts +60 -63
  68. package/src/builder/ui-defaults.ts +21 -20
  69. package/src/builder/ui.ts +2 -1
  70. package/src/builder/upgrade.ts +25 -24
  71. package/src/builder/utilities.ts +48 -47
  72. package/src/builder/vector-providers.ts +67 -66
  73. package/src/builder/websources.ts +43 -42
  74. package/src/builder/wizards.ts +95 -89
  75. package/src/builder/workflow-migrate.ts +30 -31
  76. package/src/builder/workflows.ts +40 -39
  77. package/src/builder/workingcopies.ts +41 -49
  78. package/src/builder/workspace-backups.ts +21 -20
  79. package/src/builder/workspace-mail.ts +17 -16
  80. package/src/builder/workspace-resources.ts +104 -112
  81. package/src/builder/workspace-transfer.ts +34 -26
@@ -8,7 +8,7 @@ import {
8
8
  } from '../team.ts';
9
9
  import { TEAM_LANGUAGES, TEAM_SAMPLES } from '../team-samples.ts';
10
10
  import { ago } from './home.ts';
11
- import { tr } from './i18n.ts';
11
+ import { scopedTx as tx, tr } from './i18n.ts';
12
12
  import { back, BASE, csrf, designerUrl, developer, flash, region, send, shell, type Req } from './ui.ts';
13
13
  import { appAllowed, appWorkspace, currentWorkspace, workspaceAdmin } from './workspaces.ts';
14
14
 
@@ -37,8 +37,8 @@ async function teamDev(req: Req, reply: FastifyReply): Promise<{ s: Session; ws:
37
37
  if (!s) return null;
38
38
  const ws = currentWorkspace(s);
39
39
  if (ws < 0) {
40
- await send(reply.code(403), s, shell(s, 'Team Development', [['Team Development']],
41
- html`<h1>Team Development</h1><div class="alert alert-info" role="status">You are not a developer of any workspace: Team Development belongs to a workspace.</div>`, 'team'));
40
+ await send(reply.code(403), s, shell(s, tx('tm.team_development'), [[tx('tm.team_development')]],
41
+ html`<h1>${tx('tm.team_development')}</h1><div class="alert alert-info" role="status">${tx('tm.you_are_not_a_developer')}</div>`, 'team'));
42
42
  return null;
43
43
  }
44
44
  return { s, ws };
@@ -68,16 +68,16 @@ const chip = (l: { name: string; color: string }) => html`<span class="team-labe
68
68
  function teamHeader(active: Tab, counts?: { open: number; mine: number; mentions: number }) {
69
69
  const tab = (key: Tab, href: string, ic: string, label: string) =>
70
70
  html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
71
- return html`<div class="team-head"><h1>Team Development</h1>
72
- ${counts ? html`<p class="muted small team-counts">${counts.open} open issue${counts.open === 1 ? '' : 's'} · <a href="${TEAM}?assignee=me">${counts.mine} assigned to you</a>${counts.mentions ? html` · <a href="${TEAM}#mentions">${counts.mentions} mention${counts.mentions === 1 ? '' : 's'} of you</a>` : ''}</p>` : ''}
73
- <a class="btn btn-hot btn-sm" href="${TEAM}/issues/new">${icon('plus')} New issue</a></div>
74
- <nav class="ide-tabs team-tabs u-mb1" aria-label="Team Development">
75
- ${tab('issues', TEAM, 'list', 'Issues')}${tab('board', `${TEAM}/board`, 'kanban', 'Board')}${tab('labels', `${TEAM}/labels`, 'tag', 'Labels')}
76
- ${tab('milestones', `${TEAM}/milestones`, 'flag', 'Milestones')}${tab('templates', `${TEAM}/templates`, 'note', 'Templates')}${tab('setup', `${TEAM}/setup`, 'settings', 'Set up')}
71
+ return html`<div class="team-head"><h1>${tx('tm.team_development')}</h1>
72
+ ${counts ? html`<p class="muted small team-counts">${counts.open === 1 ? tx('tm.counts_open_1') : tx('tm.counts_open', { n: counts.open })} · <a href="${TEAM}?assignee=me">${tx('tm.counts_mine', { n: counts.mine })}</a>${counts.mentions ? html` · <a href="${TEAM}#mentions">${counts.mentions === 1 ? tx('tm.counts_mentions_1') : tx('tm.counts_mentions', { n: counts.mentions })}</a>` : ''}</p>` : ''}
73
+ <a class="btn btn-hot btn-sm" href="${TEAM}/issues/new">${icon('plus')} ${tx('tm.new_issue')}</a></div>
74
+ <nav class="ide-tabs team-tabs u-mb1" aria-label="${tx('tm.team_development')}">
75
+ ${tab('issues', TEAM, 'list', tx('tm.tab.issues'))}${tab('board', `${TEAM}/board`, 'kanban', tx('tm.tab.board'))}${tab('labels', `${TEAM}/labels`, 'tag', tx('tm.tab.labels'))}
76
+ ${tab('milestones', `${TEAM}/milestones`, 'flag', tx('tm.tab.milestones'))}${tab('templates', `${TEAM}/templates`, 'note', tx('tm.tab.templates'))}${tab('setup', `${TEAM}/setup`, 'settings', tx('tm.tab.setup'))}
77
77
  </nav>`;
78
78
  }
79
79
 
80
- const page = (s: Session, title: string, crumbs: [string, string?][], main: Raw) => shell(s, title, [['Team Development', TEAM], ...crumbs], main, 'team');
80
+ const page = (s: Session, title: string, crumbs: [string, string?][], main: Raw) => shell(s, title, [[tx('tm.team_development'), TEAM], ...crumbs], main, 'team');
81
81
 
82
82
  // ------------------------------------------------------------------ filters
83
83
 
@@ -141,16 +141,16 @@ function filterForm(f: Filters, lk: Lookups, action: string, hidden: Record<stri
141
141
  const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
142
142
  const sel = (name: string, label: string, cur: string, options: Raw[]) =>
143
143
  html`<div class="field"><label class="label" for="tf_${name}">${label}</label><select id="tf_${name}" name="${name}">${options}</select></div>`;
144
- return html`<form method="get" action="${action}" class="team-filters" role="search" aria-label="Filter the issues">
144
+ return html`<form method="get" action="${action}" class="team-filters" role="search" aria-label="${tx('tm.filter_the_issues')}">
145
145
  ${Object.entries(hidden).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}
146
146
  ${f.page ? html`<input type="hidden" name="page" value="${f.page}">` : ''}
147
- <div class="field team-q"><label class="label" for="tf_q">Search</label><input id="tf_q" name="q" type="search" value="${f.q}" maxlength="100" placeholder="Title, text or #number"></div>
148
- ${sel('status', 'Status', f.status, [opt('open', 'Open', f.status), opt('closed', 'Closed', f.status), opt('all', 'All', f.status)])}
149
- ${sel('label', 'Label', f.label, [opt('', 'Any', f.label), ...lk.labels.map((l) => opt(String(l.id), l.group_name ? `${l.group_name}: ${l.name}` : l.name, f.label))])}
150
- ${sel('milestone', 'Milestone', f.milestone, [opt('', 'Any', f.milestone), opt('none', 'None', f.milestone), ...lk.milestones.map((m) => opt(String(m.id), m.name, f.milestone))])}
151
- ${sel('assignee', 'Assignee', f.assignee, [opt('', 'Anyone', f.assignee), opt('me', 'Me', f.assignee), opt('none', 'Nobody', f.assignee), ...lk.devs.map((d) => opt(d, d, f.assignee))])}
152
- ${sel('app', 'Application', f.app, [opt('', 'Any', f.app), ...lk.apps.map((a) => opt(String(a.id), a.name, f.app))])}
153
- <div class="buttons"><button class="btn btn-sm">${icon('filter')} Show</button>${filterQuery(f) ? html`<a class="btn btn-sm" href="${action}${Object.keys(hidden).length ? `?${new URLSearchParams(hidden)}` : ''}">Clear</a>` : ''}</div>
147
+ <div class="field team-q"><label class="label" for="tf_q">${tx('tm.search')}</label><input id="tf_q" name="q" type="search" value="${f.q}" maxlength="100" placeholder="${tx('tm.title_text_or_number')}"></div>
148
+ ${sel('status', tx('tm.f.status'), f.status, [opt('open', tx('tm.f.open'), f.status), opt('closed', tx('tm.closed'), f.status), opt('all', tx('tm.f.all'), f.status)])}
149
+ ${sel('label', tx('tm.label'), f.label, [opt('', tx('tm.f.any'), f.label), ...lk.labels.map((l) => opt(String(l.id), l.group_name ? `${l.group_name}: ${l.name}` : l.name, f.label))])}
150
+ ${sel('milestone', tx('tm.milestone'), f.milestone, [opt('', tx('tm.f.any'), f.milestone), opt('none', tx('tm.none'), f.milestone), ...lk.milestones.map((m) => opt(String(m.id), m.name, f.milestone))])}
151
+ ${sel('assignee', tx('tm.f.assignee'), f.assignee, [opt('', tx('tm.f.anyone'), f.assignee), opt('me', tx('tm.f.me'), f.assignee), opt('none', tx('tm.nobody'), f.assignee), ...lk.devs.map((d) => opt(d, d, f.assignee))])}
152
+ ${sel('app', tx('tm.application'), f.app, [opt('', tx('tm.f.any'), f.app), ...lk.apps.map((a) => opt(String(a.id), a.name, f.app))])}
153
+ <div class="buttons"><button class="btn btn-sm">${icon('filter')} ${tx('tm.show')}</button>${filterQuery(f) ? html`<a class="btn btn-sm" href="${action}${Object.keys(hidden).length ? `?${new URLSearchParams(hidden)}` : ''}">${tx('tm.clear')}</a>` : ''}</div>
154
154
  </form>`;
155
155
  }
156
156
 
@@ -173,30 +173,30 @@ interface IssueInput { title: string; body: string; labels: string[]; milestone:
173
173
  async function readIssue(b: Record<string, unknown>, ws: number, lk: Lookups): Promise<{ v: IssueInput; errors: string[] }> {
174
174
  const errors: string[] = [];
175
175
  const title = text(b.title, 1000).replace(/\s+/g, ' ');
176
- if (!title) errors.push('An issue needs a title.');
177
- if (title.length > 200) errors.push('The title is at most 200 characters.');
176
+ if (!title) errors.push(tx('tm.an_issue_needs_a_title'));
177
+ if (title.length > 200) errors.push(tx('tm.the_title_is_at_most'));
178
178
  const body = String(b.body ?? '').replace(/\r\n?/g, '\n');
179
- if (body.length > 20000) errors.push('The text is at most 20000 characters.');
179
+ if (body.length > 20000) errors.push(tx('tm.the_text_is_at_most'));
180
180
  const labelIds = new Set(lk.labels.map((l) => String(l.id)));
181
181
  const labels = many(b.labels);
182
- if (labels.some((l) => !labelIds.has(l))) errors.push('Unknown label.');
182
+ if (labels.some((l) => !labelIds.has(l))) errors.push(tx('tm.unknown_label'));
183
183
  const milestone = String(b.milestone ?? '');
184
- if (milestone && !lk.milestones.some((m) => String(m.id) === milestone)) errors.push('Unknown milestone.');
184
+ if (milestone && !lk.milestones.some((m) => String(m.id) === milestone)) errors.push(tx('tm.unknown_milestone'));
185
185
  const assignees = many(b.assignees);
186
186
  const devs = new Set(lk.devs);
187
- if (assignees.some((a) => !devs.has(a))) errors.push('Assignees are developers of this workspace.');
187
+ if (assignees.some((a) => !devs.has(a))) errors.push(tx('tm.assignees_are_developers_of_this'));
188
188
  const appRaw = String(b.app ?? '');
189
189
  let app: number | null = null;
190
190
  if (appRaw) {
191
- if (!/^\d{1,9}$/.test(appRaw) || !lk.apps.some((a) => String(a.id) === appRaw) || (await appWorkspace(appRaw)) !== ws) errors.push('The application is not one of this workspace.');
191
+ if (!/^\d{1,9}$/.test(appRaw) || !lk.apps.some((a) => String(a.id) === appRaw) || (await appWorkspace(appRaw)) !== ws) errors.push(tx('tm.the_application_is_not_one'));
192
192
  else app = Number(appRaw);
193
193
  }
194
194
  const pageRaw = String(b.page ?? '').trim();
195
195
  let pageNo: number | null = null;
196
196
  if (pageRaw) {
197
- if (!/^\d{1,9}$/.test(pageRaw)) errors.push('The page is a page number.');
198
- else if (!app) errors.push('Choose the application of the page.');
199
- else if (!(await owner.one('select 1 as ok from meta.page where app_id = $1 and page_no = $2', [app, pageRaw]))) errors.push(`The application has no page ${pageRaw}.`);
197
+ if (!/^\d{1,9}$/.test(pageRaw)) errors.push(tx('tm.the_page_is_a_page'));
198
+ else if (!app) errors.push(tx('tm.choose_the_application_of_the'));
199
+ else if (!(await owner.one('select 1 as ok from meta.page where app_id = $1 and page_no = $2', [app, pageRaw]))) errors.push(tx('tm.no_page', { page: pageRaw }));
200
200
  else pageNo = Number(pageRaw);
201
201
  }
202
202
  return { v: { title: title.slice(0, 200), body: body.slice(0, 20000), labels: [...new Set(labels)], milestone: milestone || null, assignees: [...new Set(assignees)], app, page: pageNo }, errors };
@@ -205,25 +205,25 @@ async function readIssue(b: Record<string, unknown>, ws: number, lk: Lookups): P
205
205
  function issueFields(v: Partial<IssueInput>, lk: Lookups, idp = 'f') {
206
206
  const labelSet = new Set(v.labels ?? []);
207
207
  const groups = new Map<string, Label[]>();
208
- for (const l of lk.labels) groups.set(l.group_name ?? 'Other labels', [...(groups.get(l.group_name ?? 'Other labels') ?? []), l]);
208
+ for (const l of lk.labels) groups.set(l.group_name ?? tx('tm.other_labels'), [...(groups.get(l.group_name ?? tx('tm.other_labels')) ?? []), l]);
209
209
  const asg = new Set(v.assignees ?? []);
210
210
  return html`
211
- <div class="field"><label class="label" for="${idp}_title">Title</label><input id="${idp}_title" name="title" maxlength="200" required value="${v.title ?? ''}"></div>
212
- <div class="field"><label class="label" for="${idp}_body">Description</label>
211
+ <div class="field"><label class="label" for="${idp}_title">${tx('tm.title')}</label><input id="${idp}_title" name="title" maxlength="200" required value="${v.title ?? ''}"></div>
212
+ <div class="field"><label class="label" for="${idp}_body">${tx('tm.description')}</label>
213
213
  <textarea id="${idp}_body" name="body" rows="10" maxlength="20000">${v.body ?? ''}</textarea>
214
- <small class="help">Markdown: headings, lists, <code>code</code>, links. Mention a developer with @name: they see it under Team Development.</small></div>
214
+ <small class="help">${tx.code('tm.body_help')}</small></div>
215
215
  <div class="form-grid">
216
- <div class="field"><label class="label" for="${idp}_milestone">Milestone</label><select id="${idp}_milestone" name="milestone">
216
+ <div class="field"><label class="label" for="${idp}_milestone">${tx('tm.milestone')}</label><select id="${idp}_milestone" name="milestone">
217
217
  <option value="">None</option>${lk.milestones.map((m) => html`<option value="${m.id}"${String(m.id) === String(v.milestone ?? '') ? raw(' selected') : ''}>${m.name}${m.status === 'closed' ? ' (closed)' : ''}</option>`)}</select></div>
218
- <div class="field"><label class="label" for="${idp}_app">Application</label><select id="${idp}_app" name="app">
218
+ <div class="field"><label class="label" for="${idp}_app">${tx('tm.application')}</label><select id="${idp}_app" name="app">
219
219
  <option value="">None</option>${lk.apps.map((a) => html`<option value="${a.id}"${a.id === v.app ? raw(' selected') : ''}>${a.name}</option>`)}</select></div>
220
- <div class="field"><label class="label" for="${idp}_page">Page number</label><input id="${idp}_page" name="page" type="number" min="0" step="1" inputmode="numeric" value="${v.page ?? ''}">
221
- <small class="help">A page of the application.</small></div>
220
+ <div class="field"><label class="label" for="${idp}_page">${tx('tm.page_number')}</label><input id="${idp}_page" name="page" type="number" min="0" step="1" inputmode="numeric" value="${v.page ?? ''}">
221
+ <small class="help">${tx('tm.a_page_of_the_application')}</small></div>
222
222
  </div>
223
- ${lk.labels.length ? html`<fieldset class="team-fieldset"><legend>Labels</legend>${[...groups].map(([g, ls]) => html`<div class="team-check-group"><span class="small muted">${g}</span>
223
+ ${lk.labels.length ? html`<fieldset class="team-fieldset"><legend>${tx('tm.labels')}</legend>${[...groups].map(([g, ls]) => html`<div class="team-check-group"><span class="small muted">${g}</span>
224
224
  ${ls.map((l) => html`<label class="check"><input type="checkbox" name="labels" value="${l.id}"${labelSet.has(String(l.id)) ? raw(' checked') : ''}> ${chip(l)}</label>`)}</div>`)}</fieldset>`
225
- : html`<p class="muted small">No labels yet: <a href="${TEAM}/setup">set up Team Development</a> or <a href="${TEAM}/labels">add labels</a>.</p>`}
226
- <fieldset class="team-fieldset"><legend>Assignees</legend><div class="team-check-group">
225
+ : html`<p class="muted small">${tx.rich('tm.no_labels_or', { setup: html`<a href="${TEAM}/setup">${tx('tm.set_up_team_development')}</a>`, add: html`<a href="${TEAM}/labels">${tx('tm.add_labels')}</a>` })}</p>`}
226
+ <fieldset class="team-fieldset"><legend>${tx('tm.assignees')}</legend><div class="team-check-group">
227
227
  ${lk.devs.map((d) => html`<label class="check"><input type="checkbox" name="assignees" value="${d}"${asg.has(d) ? raw(' checked') : ''}> ${d}</label>`)}</div></fieldset>`;
228
228
  }
229
229
 
@@ -247,10 +247,10 @@ async function setLinks(c: (sql: string, params?: unknown[]) => Promise<unknown>
247
247
  const issueOf = async (ws: number, id: string) =>
248
248
  isId(id) ? owner.one(`select i.*, m.name as milestone from meta.team_issue i left join meta.team_milestone m on m.id = i.milestone_id where i.id = $1 and i.workspace_id = $2`, [id, ws]) : undefined;
249
249
 
250
- const EVENT_TEXT: Record<string, string> = {
251
- created: 'created the issue', closed: 'closed the issue', reopened: 'reopened the issue', title: 'changed the title', body: 'edited the description',
252
- labels: 'changed the labels', assignees: 'changed the assignees', milestone: 'set the milestone', link: 'linked', commented: 'commented',
253
- };
250
+ const EVENT_TEXT = (action: string): string | undefined => ({
251
+ created: tx('tm.ev.created'), closed: tx('tm.ev.closed'), reopened: tx('tm.ev.reopened'), title: tx('tm.changed_the_title'), body: tx('tm.ev.body'),
252
+ labels: tx('tm.ev.labels'), assignees: tx('tm.ev.assignees'), milestone: tx('tm.ev.milestone'), link: tx('tm.ev.link'), commented: tx('tm.ev.commented'),
253
+ } as Record<string, string>)[action];
254
254
 
255
255
  export async function teamRoutes(app: FastifyInstance) {
256
256
  // ------------------------------------------------------------------ the issue list
@@ -267,35 +267,35 @@ export async function teamRoutes(app: FastifyInstance) {
267
267
  ]);
268
268
  const current = filterQuery(f);
269
269
  const main = html`${teamHeader('issues', counts)}
270
- ${!counts.set_up ? html`<div class="alert alert-info" role="status">${icon('lightbulb')} <span>New here? <a href="${TEAM}/setup">Set up Team Development</a>: sample labels (Bug, Feature, Task; priorities), templates and milestones.</span></div>` : ''}
270
+ ${!counts.set_up ? html`<div class="alert alert-info" role="status">${icon('lightbulb')} <span>${tx.rich('tm.new_here', { setup: html`<a href="${TEAM}/setup">${tx('tm.set_up_team_development_2')}</a>` })}</span></div>` : ''}
271
271
  <div class="team-layout">
272
272
  <div class="team-main">
273
273
  ${filterForm(f, lk, TEAM)}
274
- ${f.app && f.page ? html`<p class="small muted">Issues of page ${f.page} of ${lk.apps.find((a) => String(a.id) === f.app)?.name ?? 'the application'}.</p>` : ''}
275
- <p class="small muted" role="status">${rows.length} issue${rows.length === 1 ? '' : 's'}${rows.length === 500 ? ' (the first 500)' : ''}.</p>
274
+ ${f.app && f.page ? html`<p class="small muted">${tx('tm.issues_of_page', { page: f.page, app: lk.apps.find((a) => String(a.id) === f.app)?.name ?? tx('tm.the_application') })}</p>` : ''}
275
+ <p class="small muted" role="status">${rows.length === 1 ? tx('tm.n_issues_1') : rows.length === 500 ? tx('tm.n_issues_500', { n: rows.length }) : tx('tm.n_issues', { n: rows.length })}</p>
276
276
  ${rows.length ? html`<ul class="team-issues">${rows.map((i) => issueCard(i))}</ul>` : html`<p class="muted">No issues${current ? ' with these filters' : ' yet'}.</p>`}
277
277
  </div>
278
278
  <aside class="team-side">
279
- <section id="mentions" aria-labelledby="team-mentions-h"><h2 id="team-mentions-h">Mentions of you</h2>
279
+ <section id="mentions" aria-labelledby="team-mentions-h"><h2 id="team-mentions-h">${tx('tm.mentions_of_you')}</h2>
280
280
  ${mentions.rows.length ? html`<ul class="team-mentions">${mentions.rows.map((m) => html`<li><a href="${TEAM}/issues/${m.issue_id}">${m.title}</a> <span class="small muted">${m.by_user ?? ''} · ${ago(m.created_at)}</span></li>`)}</ul>
281
- <form method="post" action="${TEAM}/mentions/seen">${csrf(s)}<button class="btn btn-sm">${icon('check')} Mark all as seen</button></form>`
282
- : html`<p class="muted small">Nobody mentioned you in an unseen comment.</p>`}</section>
283
- <section aria-labelledby="team-views-h"><h2 id="team-views-h">Saved views</h2>
281
+ <form method="post" action="${TEAM}/mentions/seen">${csrf(s)}<button class="btn btn-sm">${icon('check')} ${tx('tm.mark_all_as_seen')}</button></form>`
282
+ : html`<p class="muted small">${tx('tm.nobody_mentioned_you_in_an')}</p>`}</section>
283
+ <section aria-labelledby="team-views-h"><h2 id="team-views-h">${tx('tm.saved_views')}</h2>
284
284
  ${views.rows.length ? html`<ul class="team-views">${views.rows.map((v) => html`<li><a href="${TEAM}${v.query ? `?${v.query}` : ''}"${v.query === current ? raw(' aria-current="true"') : ''}>${v.name}</a>
285
- <form method="post" action="${TEAM}/views/${v.id}/delete" class="u-inline">${csrf(s)}<button class="link-button" title="Delete the view ${v.name}" data-confirm="Delete the view ${v.name}?">${icon('trash')}<span class="sr-only">Delete ${v.name}</span></button></form></li>`)}</ul>`
286
- : html`<p class="muted small">Save the filters you use often.</p>`}
285
+ <form method="post" action="${TEAM}/views/${v.id}/delete" class="u-inline">${csrf(s)}<button class="link-button" title="${tx('tm.delete_view_confirm', { name: v.name })}" data-confirm="${tx('tm.delete_view_confirm', { name: v.name })}">${icon('trash')}<span class="sr-only">${tx('tm.delete_named', { name: v.name })}</span></button></form></li>`)}</ul>`
286
+ : html`<p class="muted small">${tx('tm.save_the_filters_you_use')}</p>`}
287
287
  <form method="post" action="${TEAM}/views" class="team-save-view">${csrf(s)}<input type="hidden" name="query" value="${current}">
288
- <label class="sr-only" for="tv_name">Name of the view</label><input id="tv_name" name="name" maxlength="40" required placeholder="Name of this view">
289
- <button class="btn btn-sm">${icon('bookmark')} Save view</button></form></section>
290
- <section aria-labelledby="team-quick-h"><h2 id="team-quick-h">Quick filters</h2><ul class="ab-links ab-tasks">
291
- <li><a href="${TEAM}?assignee=me">Assigned to me</a></li>
292
- <li><a href="${TEAM}?assignee=none">Not assigned</a></li>
293
- <li><a href="${TEAM}?status=closed">Closed</a></li>
294
- <li><a href="${TEAM}/board">Board by label</a></li>
295
- <li><a href="${TEAM}/board?by=milestone">Board by milestone</a></li></ul></section>
288
+ <label class="sr-only" for="tv_name">${tx('tm.name_of_the_view')}</label><input id="tv_name" name="name" maxlength="40" required placeholder="${tx('tm.name_of_this_view')}">
289
+ <button class="btn btn-sm">${icon('bookmark')} ${tx('tm.save_view')}</button></form></section>
290
+ <section aria-labelledby="team-quick-h"><h2 id="team-quick-h">${tx('tm.quick_filters')}</h2><ul class="ab-links ab-tasks">
291
+ <li><a href="${TEAM}?assignee=me">${tx('tm.assigned_to_me')}</a></li>
292
+ <li><a href="${TEAM}?assignee=none">${tx('tm.not_assigned')}</a></li>
293
+ <li><a href="${TEAM}?status=closed">${tx('tm.closed')}</a></li>
294
+ <li><a href="${TEAM}/board">${tx('tm.board_by_label')}</a></li>
295
+ <li><a href="${TEAM}/board?by=milestone">${tx('tm.board_by_milestone')}</a></li></ul></section>
296
296
  </aside>
297
297
  </div>`;
298
- return send(reply, s, page(s, 'Team Development', [], main));
298
+ return send(reply, s, page(s, tx('tm.team_development'), [], main));
299
299
  });
300
300
 
301
301
  // ------------------------------------------------------------------ the board
@@ -314,37 +314,37 @@ export async function teamRoutes(app: FastifyInstance) {
314
314
  if (by === 'milestone') {
315
315
  const ms = lk.milestones.filter((m) => m.status === 'open' || rows.some((r) => String(r.milestone_id) === String(m.id)));
316
316
  cols = [...ms.map((m) => ({ key: String(m.id), title: m.name, items: rows.filter((r) => String(r.milestone_id) === String(m.id)) })),
317
- { key: '', title: 'No milestone', items: rows.filter((r) => r.milestone_id === null) }];
317
+ { key: '', title: tx('tm.no_milestone'), items: rows.filter((r) => r.milestone_id === null) }];
318
318
  } else {
319
319
  const ls = lk.labels.filter((l) => String(l.group_id ?? 0) === group);
320
320
  // (ids: bigint columns come as strings, the json_agg ones as numbers)
321
321
  const ids = new Set(ls.map((l) => String(l.id)));
322
322
  cols = [...ls.map((l) => ({ key: String(l.id), title: l.name, chip: chip(l), items: rows.filter((r) => r.labels.some((x) => String(x.id) === String(l.id))) })),
323
- { key: '', title: 'None of these', items: rows.filter((r) => !r.labels.some((x) => ids.has(String(x.id)))) }];
323
+ { key: '', title: tx('tm.none_of_these'), items: rows.filter((r) => !r.labels.some((x) => ids.has(String(x.id)))) }];
324
324
  }
325
325
  const back = `${TEAM}/board?${filterQuery(f, { by: by === 'milestone' ? 'milestone' : '', group: by === 'label' ? group : '' })}`;
326
326
  const move = (i: IssueRow, from: string) => {
327
327
  const others = cols.filter((c) => c.key !== from);
328
328
  return html`<form method="post" action="${TEAM}/issues/${i.id}/move" class="team-move">${csrf(s)}
329
329
  <input type="hidden" name="by" value="${by}"><input type="hidden" name="group" value="${group}"><input type="hidden" name="from" value="${from}"><input type="hidden" name="back" value="${back}">
330
- <label class="sr-only" for="mv_${i.id}_${from}">Move #${i.id} to</label>
330
+ <label class="sr-only" for="mv_${i.id}_${from}">${tx('tm.move_to', { id: i.id })}</label>
331
331
  <select id="mv_${i.id}_${from}" name="to">${others.map((c) => html`<option value="${c.key}">${c.title}</option>`)}</select>
332
- <button class="btn btn-sm">Move</button></form>`;
332
+ <button class="btn btn-sm">${tx('tm.move')}</button></form>`;
333
333
  };
334
334
  const groupNav = by === 'label' && groupIds.length > 1
335
- ? html`<nav class="seg team-groups" aria-label="Label group">${groupIds.map((g) => html`<a href="${TEAM}/board?${filterQuery(f, { group: g })}"${g === group ? raw(' aria-current="true"') : ''}>${g === '0' ? 'Other labels' : lk.groups.find((x) => String(x.id) === g)?.name}</a>`)}</nav>` : '';
335
+ ? html`<nav class="seg team-groups" aria-label="${tx('tm.label_group')}">${groupIds.map((g) => html`<a href="${TEAM}/board?${filterQuery(f, { group: g })}"${g === group ? raw(' aria-current="true"') : ''}>${g === '0' ? tx('tm.other_labels') : lk.groups.find((x) => String(x.id) === g)?.name}</a>`)}</nav>` : '';
336
336
  const main = html`${teamHeader('board', counts)}
337
337
  <div class="team-board-bar">
338
- <nav class="seg" aria-label="Group the board by"><a href="${TEAM}/board?${filterQuery(f)}"${by === 'label' ? raw(' aria-current="true"') : ''}>${icon('tag')} Label</a><a href="${TEAM}/board?${filterQuery(f, { by: 'milestone' })}"${by === 'milestone' ? raw(' aria-current="true"') : ''}>${icon('flag')} Milestone</a></nav>
338
+ <nav class="seg" aria-label="${tx('tm.group_the_board_by')}"><a href="${TEAM}/board?${filterQuery(f)}"${by === 'label' ? raw(' aria-current="true"') : ''}>${icon('tag')} ${tx('tm.label')}</a><a href="${TEAM}/board?${filterQuery(f, { by: 'milestone' })}"${by === 'milestone' ? raw(' aria-current="true"') : ''}>${icon('flag')} ${tx('tm.milestone')}</a></nav>
339
339
  ${groupNav}
340
340
  </div>
341
341
  <details class="team-board-filters"><summary>${icon('filter')} Filters${filterQuery(f) ? ' (on)' : ''}</summary>${filterForm(f, lk, `${TEAM}/board`, { ...(by === 'milestone' ? { by } : {}), ...(by === 'label' ? { group } : {}) })}</details>
342
- ${by === 'label' && !lk.labels.length ? html`<p class="muted">No labels yet: <a href="${TEAM}/setup">set up Team Development</a>.</p>` : ''}
342
+ ${by === 'label' && !lk.labels.length ? html`<p class="muted">${tx.rich('tm.no_labels', { setup: html`<a href="${TEAM}/setup">${tx('tm.set_up_team_development')}</a>` })}</p>` : ''}
343
343
  <div class="team-board">${cols.map((c) => html`<section class="team-col" aria-label="${c.title}">
344
344
  <h2 class="team-col-head">${c.chip ?? c.title} <span class="muted small">${c.items.length}</span></h2>
345
- ${c.items.length ? html`<ul class="team-issues">${c.items.map((i) => issueCard(i, move(i, c.key)))}</ul>` : html`<p class="muted small">No issues.</p>`}
345
+ ${c.items.length ? html`<ul class="team-issues">${c.items.map((i) => issueCard(i, move(i, c.key)))}</ul>` : html`<p class="muted small">${tx('tm.no_issues')}</p>`}
346
346
  </section>`)}</div>`;
347
- return send(reply, s, page(s, 'Board', [['Board']], main));
347
+ return send(reply, s, page(s, tx('tm.board'), [[tx('tm.board')]], main));
348
348
  });
349
349
 
350
350
  // move a card: by milestone sets the milestone; by label replaces the issue's labels of that group with the target label
@@ -398,12 +398,12 @@ export async function teamRoutes(app: FastifyInstance) {
398
398
  };
399
399
  const main = html`${teamHeader('issues')}
400
400
  ${templates.length ? html`<form method="get" class="team-template-pick">${appQ ? html`<input type="hidden" name="app" value="${appQ.id}">` : ''}${v.page !== null && v.page !== undefined ? html`<input type="hidden" name="page" value="${v.page}">` : ''}
401
- <div class="field"><label class="label" for="f_template">Start from a template</label><select id="f_template" name="template" data-autosubmit>
402
- <option value="">Blank issue</option>${templates.map((x) => html`<option value="${x.id}"${x.id === tpl?.id ? raw(' selected') : ''}>${x.name}</option>`)}</select></div>
403
- <button class="btn btn-sm">Use template</button></form>` : ''}
404
- ${region('New issue', html`<form method="post" action="${TEAM}/issues">${csrf(s)}${issueFields(v, lk)}
405
- <div class="buttons"><button class="btn btn-hot">Create issue</button><a class="btn" href="${TEAM}">Cancel</a></div></form>`)}`;
406
- return send(reply, s, page(s, 'New issue', [['New issue']], main));
401
+ <div class="field"><label class="label" for="f_template">${tx('tm.start_from_a_template')}</label><select id="f_template" name="template" data-autosubmit>
402
+ <option value="">${tx('tm.blank_issue')}</option>${templates.map((x) => html`<option value="${x.id}"${x.id === tpl?.id ? raw(' selected') : ''}>${x.name}</option>`)}</select></div>
403
+ <button class="btn btn-sm">${tx('tm.use_template')}</button></form>` : ''}
404
+ ${region(tx('tm.new_issue'), html`<form method="post" action="${TEAM}/issues">${csrf(s)}${issueFields(v, lk)}
405
+ <div class="buttons"><button class="btn btn-hot">${tx('tm.create_issue')}</button><a class="btn" href="${TEAM}">${tx('tm.cancel')}</a></div></form>`)}`;
406
+ return send(reply, s, page(s, tx('tm.new_issue'), [[tx('tm.new_issue')]], main));
407
407
  });
408
408
 
409
409
  app.post(`${TEAM}/issues`, async (req: Req, reply) => {
@@ -424,7 +424,7 @@ export async function teamRoutes(app: FastifyInstance) {
424
424
  await recordMentions(c, ws, id, null, v.body, s.username);
425
425
  return id;
426
426
  });
427
- flash(s, `Issue #${id} created.`);
427
+ flash(s, tx('tm.created', { id }));
428
428
  return back(reply, s, `${TEAM}/issues/${id}`);
429
429
  });
430
430
 
@@ -458,7 +458,7 @@ export async function teamRoutes(app: FastifyInstance) {
458
458
  ...comments.rows.map((c) => ({ at: new Date(c.created_at).getTime(), r: html`<li class="team-comment" id="comment-${c.id}">
459
459
  <div class="team-comment-head"><b>${c.author}</b> <span class="muted small">${when(c.created_at)}</span></div>
460
460
  <div class="rich-text team-md">${raw(issueHtml(c.body, lk.devs))}</div></li>` })),
461
- ...events.rows.map((e) => ({ at: new Date(e.at).getTime(), r: html`<li class="team-event small muted">${icon('history')}<span><b>${e.username ?? 'someone'}</b> ${EVENT_TEXT[e.action] ?? e.action}${e.detail ? html`: ${e.detail}` : ''} · ${when(e.at)}</span></li>` })),
461
+ ...events.rows.map((e) => ({ at: new Date(e.at).getTime(), r: html`<li class="team-event small muted">${icon('history')}<span><b>${e.username ?? tx('tm.someone')}</b> ${EVENT_TEXT(e.action) ?? e.action}${e.detail ? html`: ${e.detail}` : ''} · ${when(e.at)}</span></li>` })),
462
462
  ].sort((a, b) => a.at - b.at);
463
463
  const v: IssueInput = { title: i.title, body: i.body, labels: labels.rows.map((l) => String(l.id)), milestone: i.milestone_id ? String(i.milestone_id) : null,
464
464
  assignees: assignees.rows.map((a) => a.username), app: appRow?.id ?? null, page: appRow ? i.page_no : null };
@@ -466,42 +466,42 @@ export async function teamRoutes(app: FastifyInstance) {
466
466
  <article class="team-issue-page">
467
467
  <header class="team-issue-header">
468
468
  <h2 class="team-issue-h">${i.title} <span class="muted">#${i.id}</span></h2>
469
- <p><span class="team-status team-status-${i.status === 'closed' ? 'closed' : 'open'}">${i.status === 'closed' ? 'Closed' : 'Open'}</span>
469
+ <p><span class="team-status team-status-${i.status === 'closed' ? 'closed' : 'open'}">${i.status === 'closed' ? tx('tm.status_closed') : tx('tm.status_open')}</span>
470
470
  <span class="muted small">opened by ${i.created_by}, ${when(i.created_at)}${i.closed_at ? `; closed by ${i.closed_by ?? '?'}, ${when(i.closed_at)}` : ''}</span></p>
471
471
  </header>
472
472
  <div class="team-layout">
473
473
  <div class="team-main">
474
- <div class="rich-text team-md team-body">${i.body.trim() ? raw(issueHtml(i.body, lk.devs)) : html`<p class="muted">No description.</p>`}</div>
475
- <h3>Activity</h3>
474
+ <div class="rich-text team-md team-body">${i.body.trim() ? raw(issueHtml(i.body, lk.devs)) : html`<p class="muted">${tx('tm.no_description')}</p>`}</div>
475
+ <h3>${tx('tm.activity')}</h3>
476
476
  <ul class="team-timeline">${timeline.map((x) => x.r)}</ul>
477
477
  <form method="get" class="team-response-pick">${responses.rows.length ? html`
478
- <div class="field"><label class="label" for="f_response">Canned response</label><select id="f_response" name="response" data-autosubmit>
478
+ <div class="field"><label class="label" for="f_response">${tx('tm.canned_response')}</label><select id="f_response" name="response" data-autosubmit>
479
479
  <option value="">None</option>${responses.rows.map((r) => html`<option value="${r.id}"${r.id === response?.id ? raw(' selected') : ''}>${r.name}</option>`)}</select></div>
480
- <button class="btn btn-sm">Insert</button>` : ''}</form>
480
+ <button class="btn btn-sm">${tx('tm.insert')}</button>` : ''}</form>
481
481
  <form method="post" action="${TEAM}/issues/${i.id}/comments" id="add-comment">${csrf(s)}
482
- <div class="field"><label class="label" for="f_comment">Add a comment</label>
482
+ <div class="field"><label class="label" for="f_comment">${tx('tm.add_a_comment')}</label>
483
483
  <textarea id="f_comment" name="body" rows="4" maxlength="20000" required>${response?.body ?? ''}</textarea>
484
- <small class="help">Markdown; @name mentions a developer of this workspace.</small></div>
485
- <div class="buttons"><button class="btn btn-hot">Comment</button>
486
- <button class="btn" name="and" value="${i.status === 'closed' ? 'reopen' : 'close'}">${i.status === 'closed' ? 'Comment and reopen' : 'Comment and close'}</button></div></form>
484
+ <small class="help">${tx('tm.markdown_name_mentions_a_developer')}</small></div>
485
+ <div class="buttons"><button class="btn btn-hot">${tx('tm.comment')}</button>
486
+ <button class="btn" name="and" value="${i.status === 'closed' ? 'reopen' : 'close'}">${i.status === 'closed' ? tx('tm.comment_reopen') : tx('tm.comment_close')}</button></div></form>
487
487
  </div>
488
488
  <aside class="team-side">
489
- <section><h2>Status</h2><form method="post" action="${TEAM}/issues/${i.id}/status">${csrf(s)}
490
- <button class="btn btn-sm" name="status" value="${i.status === 'closed' ? 'open' : 'closed'}">${icon(i.status === 'closed' ? 'refresh' : 'check-circle')} ${i.status === 'closed' ? 'Reopen issue' : 'Close issue'}</button></form></section>
491
- <section><h2>Labels</h2>${labels.rows.length ? html`<div class="team-labels">${labels.rows.map(chip)}</div>` : html`<p class="muted small">None</p>`}</section>
492
- <section><h2>Milestone</h2><p>${i.milestone ? html`<a href="${TEAM}?milestone=${i.milestone_id}">${i.milestone}</a>` : html`<span class="muted small">None</span>`}</p></section>
493
- <section><h2>Assignees</h2><p>${assignees.rows.length ? assignees.rows.map((a) => a.username).join(', ') : html`<span class="muted small">Nobody</span>`}</p></section>
494
- <section><h2>Application</h2><p>${appRow
489
+ <section><h2>${tx('tm.status')}</h2><form method="post" action="${TEAM}/issues/${i.id}/status">${csrf(s)}
490
+ <button class="btn btn-sm" name="status" value="${i.status === 'closed' ? 'open' : 'closed'}">${icon(i.status === 'closed' ? 'refresh' : 'check-circle')} ${i.status === 'closed' ? tx('tm.reopen_issue') : tx('tm.close_issue')}</button></form></section>
491
+ <section><h2>${tx('tm.labels')}</h2>${labels.rows.length ? html`<div class="team-labels">${labels.rows.map(chip)}</div>` : html`<p class="muted small">${tx('tm.none')}</p>`}</section>
492
+ <section><h2>${tx('tm.milestone')}</h2><p>${i.milestone ? html`<a href="${TEAM}?milestone=${i.milestone_id}">${i.milestone}</a>` : html`<span class="muted small">${tx('tm.none')}</span>`}</p></section>
493
+ <section><h2>${tx('tm.assignees')}</h2><p>${assignees.rows.length ? assignees.rows.map((a) => a.username).join(', ') : html`<span class="muted small">${tx('tm.nobody')}</span>`}</p></section>
494
+ <section><h2>${tx('tm.application')}</h2><p>${appRow
495
495
  ? html`<a href="${BASE}/apps/${appRow.id}">${appRow.name}</a>${i.page_no !== null ? html` · <a href="${designerUrl(appRow.id, i.page_no)}">page ${i.page_no}</a>` : ''}`
496
- : html`<span class="muted small">None</span>`}</p></section>
497
- ${source ? html`<section><h2>From feedback</h2><p><a href="${BASE}/apps/${source.app_id}/feedback#feedback-${source.id}">Feedback of ${source.username}</a> <span class="small muted">(${source.name})</span></p></section>` : ''}
498
- ${comment ? html`<section><h2>From a developer comment</h2><p><a href="${comment.page_no ? designerUrl(comment.app_id, comment.page_no) : `${BASE}/apps/${comment.app_id}#app-lock`}">${comment.author}'s comment</a> <span class="small muted">(${comment.name}${comment.page_no ? `, page ${comment.page_no}` : ''})</span></p></section>` : ''}
499
- ${i.created_by === s.username || admin ? html`<section><h2>Delete</h2><form method="post" action="${TEAM}/issues/${i.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete issue #${i.id} with its comments and history?">${icon('trash')} Delete issue</button></form></section>` : ''}
496
+ : html`<span class="muted small">${tx('tm.none')}</span>`}</p></section>
497
+ ${source ? html`<section><h2>${tx('tm.from_feedback')}</h2><p><a href="${BASE}/apps/${source.app_id}/feedback#feedback-${source.id}">${tx('tm.feedback_of', { user: source.username })}</a> <span class="small muted">(${source.name})</span></p></section>` : ''}
498
+ ${comment ? html`<section><h2>${tx('tm.from_a_developer_comment')}</h2><p><a href="${comment.page_no ? designerUrl(comment.app_id, comment.page_no) : `${BASE}/apps/${comment.app_id}#app-lock`}">${tx('tm.comment_of', { author: comment.author })}</a> <span class="small muted">(${comment.name}${comment.page_no ? tx('tm.page_n', { n: comment.page_no }) : ''})</span></p></section>` : ''}
499
+ ${i.created_by === s.username || admin ? html`<section><h2>${tx('tm.delete')}</h2><form method="post" action="${TEAM}/issues/${i.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('tm.delete_issue_confirm', { id: i.id })}">${icon('trash')} ${tx('tm.delete_issue')}</button></form></section>` : ''}
500
500
  </aside>
501
501
  </div>
502
- <details class="team-edit" id="edit"><summary class="btn btn-sm">${icon('edit')} Edit the issue</summary>
502
+ <details class="team-edit" id="edit"><summary class="btn btn-sm">${icon('edit')} ${tx('tm.edit_the_issue')}</summary>
503
503
  <form method="post" action="${TEAM}/issues/${i.id}">${csrf(s)}${issueFields(v, lk, 'e')}
504
- <div class="buttons"><button class="btn btn-hot">Save</button></div></form></details>
504
+ <div class="buttons"><button class="btn btn-hot">${tx('tm.save')}</button></div></form></details>
505
505
  </article>`;
506
506
  return send(reply, s, page(s, `#${i.id} ${i.title}`, [[`#${i.id}`]], main));
507
507
  });
@@ -533,7 +533,7 @@ export async function teamRoutes(app: FastifyInstance) {
533
533
  for (const [action, detail] of await setLinks((sql, p) => c.query(sql, p), i.id, v, lk, before)) await logIssue(c, i.id, s.username, action, detail);
534
534
  await recordMentions(c, ws, i.id, null, v.body, s.username);
535
535
  });
536
- flash(s, 'Issue saved.');
536
+ flash(s, tx('tm.issue_saved'));
537
537
  return back(reply, s, `${TEAM}/issues/${i.id}`);
538
538
  });
539
539
 
@@ -551,7 +551,7 @@ export async function teamRoutes(app: FastifyInstance) {
551
551
  const status = req.body?.status;
552
552
  if (status !== 'open' && status !== 'closed') return reply.code(400).send('Unknown status');
553
553
  if (status !== i.status) await owner.tx((c) => setStatus(c, i.id, status, s.username));
554
- flash(s, status === 'closed' ? `Issue #${i.id} closed.` : `Issue #${i.id} reopened.`);
554
+ flash(s, status === 'closed' ? tx('tm.issue_closed', { id: i.id }) : tx('tm.reopened', { id: i.id }));
555
555
  return back(reply, s, `${TEAM}/issues/${i.id}`);
556
556
  });
557
557
 
@@ -563,7 +563,7 @@ export async function teamRoutes(app: FastifyInstance) {
563
563
  if (!i) return reply.code(404).send('Not found');
564
564
  const body = String(req.body?.body ?? '').replace(/\r\n?/g, '\n');
565
565
  if (!body.trim() || body.length > 20000) {
566
- flash(s, 'A comment has 1 to 20000 characters.', 'error');
566
+ flash(s, tx('tm.a_comment_has_1_to'), 'error');
567
567
  return back(reply, s, `${TEAM}/issues/${i.id}#add-comment`);
568
568
  }
569
569
  const and = req.body?.and;
@@ -576,7 +576,7 @@ export async function teamRoutes(app: FastifyInstance) {
576
576
  if (and === 'reopen' && i.status === 'closed') await setStatus(c, i.id, 'open', s.username);
577
577
  return cid;
578
578
  });
579
- flash(s, 'Comment added.');
579
+ flash(s, tx('tm.comment_added'));
580
580
  return back(reply, s, `${TEAM}/issues/${i.id}#comment-${cid}`);
581
581
  });
582
582
 
@@ -589,7 +589,7 @@ export async function teamRoutes(app: FastifyInstance) {
589
589
  if (i.created_by !== s.username && !(await workspaceAdmin(s.username, ws))) return reply.code(403).send('Only its author or a workspace administrator deletes an issue.');
590
590
  await owner.query('delete from meta.team_issue where id = $1', [i.id]);
591
591
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `team: issue ${i.id} deleted` });
592
- flash(s, `Issue #${i.id} deleted.`);
592
+ flash(s, tx('tm.deleted', { id: i.id }));
593
593
  return back(reply, s, TEAM);
594
594
  });
595
595
 
@@ -600,14 +600,14 @@ export async function teamRoutes(app: FastifyInstance) {
600
600
  const { s, ws } = t;
601
601
  const name = text(req.body?.name, 100);
602
602
  if (!NAME40.test(name)) {
603
- flash(s, 'A view has a name of 1 to 40 characters.', 'error');
603
+ flash(s, tx('tm.a_view_has_a_name'), 'error');
604
604
  return back(reply, s, TEAM);
605
605
  }
606
606
  // only the known filters, re-checked
607
607
  const query = filterQuery(parseFilters(Object.fromEntries(new URLSearchParams(String(req.body?.query ?? '').slice(0, 1000)))));
608
608
  await owner.query(`insert into meta.team_view (workspace_id, username, name, query) values ($1, $2, $3, $4)
609
609
  on conflict (workspace_id, username, lower(name)) do update set query = excluded.query`, [ws, s.username, name, query]);
610
- flash(s, `View ${name} saved.`);
610
+ flash(s, tx('tm.view_saved', { name }));
611
611
  return back(reply, s, `${TEAM}${query ? `?${query}` : ''}`);
612
612
  });
613
613
 
@@ -618,7 +618,7 @@ export async function teamRoutes(app: FastifyInstance) {
618
618
  if (!isId(req.params.id)) return reply.code(404).send('Not found');
619
619
  const r = await owner.query('delete from meta.team_view where id = $1 and workspace_id = $2 and username = $3', [req.params.id, ws, s.username]);
620
620
  if (!r.rowCount) return reply.code(404).send('Not found');
621
- flash(s, 'View deleted.');
621
+ flash(s, tx('tm.view_deleted'));
622
622
  return back(reply, s, TEAM);
623
623
  });
624
624
 
@@ -628,7 +628,7 @@ export async function teamRoutes(app: FastifyInstance) {
628
628
  const { s, ws } = t;
629
629
  await owner.query(`update meta.team_mention m set seen_at = now() from meta.team_issue i
630
630
  where i.id = m.issue_id and i.workspace_id = $1 and m.username = $2 and m.seen_at is null`, [ws, s.username]);
631
- flash(s, 'Mentions marked as seen.');
631
+ flash(s, tx('tm.mentions_marked_as_seen'));
632
632
  return back(reply, s, TEAM);
633
633
  });
634
634
 
@@ -644,30 +644,30 @@ export async function teamRoutes(app: FastifyInstance) {
644
644
  const groupSel = (id: string, cur: number | null) => html`<select id="${id}" name="group"><option value="">No group</option>${lk.groups.map((g) => html`<option value="${g.id}"${g.id === cur ? raw(' selected') : ''}>${g.name}</option>`)}</select>`;
645
645
  const labelForm = (l: Partial<Label> & { id?: number }, p: string) => html`<form method="post" action="${TEAM}/labels${l.id ? `/${l.id}` : ''}">${csrf(s)}
646
646
  <div class="form-grid">
647
- <div class="field"><label class="label" for="${p}_name">Name</label><input id="${p}_name" name="name" maxlength="40" required value="${l.name ?? ''}"></div>
648
- <div class="field"><label class="label" for="${p}_color">Colour</label>${colorSel(`${p}_color`, l.color ?? 'gray')}</div>
649
- <div class="field"><label class="label" for="${p}_group">Group</label>${groupSel(`${p}_group`, l.group_id ?? null)}</div>
650
- <div class="field"><label class="label" for="${p}_desc">Description</label><input id="${p}_desc" name="description" maxlength="500" value="${l.description ?? ''}"></div>
651
- </div><div class="buttons"><button class="btn btn-sm btn-hot">${l.id ? 'Save' : 'Add label'}</button></div></form>`;
647
+ <div class="field"><label class="label" for="${p}_name">${tx('tm.name')}</label><input id="${p}_name" name="name" maxlength="40" required value="${l.name ?? ''}"></div>
648
+ <div class="field"><label class="label" for="${p}_color">${tx('tm.colour')}</label>${colorSel(`${p}_color`, l.color ?? 'gray')}</div>
649
+ <div class="field"><label class="label" for="${p}_group">${tx('tm.group')}</label>${groupSel(`${p}_group`, l.group_id ?? null)}</div>
650
+ <div class="field"><label class="label" for="${p}_desc">${tx('tm.description')}</label><input id="${p}_desc" name="description" maxlength="500" value="${l.description ?? ''}"></div>
651
+ </div><div class="buttons"><button class="btn btn-sm btn-hot">${l.id ? tx('tm.save') : tx('tm.add_label')}</button></div></form>`;
652
652
  const byGroup = [...lk.groups.map((g) => ({ g, ls: lk.labels.filter((l) => String(l.group_id) === String(g.id)) })), { g: null, ls: lk.labels.filter((l) => l.group_id === null) }];
653
653
  const main = html`${teamHeader('labels')}
654
- ${byGroup.filter((x) => x.g || x.ls.length).map(({ g, ls }) => region(g ? g.name : 'Labels without a group', html`
654
+ ${byGroup.filter((x) => x.g || x.ls.length).map(({ g, ls }) => region(g ? g.name : tx('tm.labels_no_group'), html`
655
655
  ${g?.description ? html`<p class="muted small u-mt0">${g.description}</p>` : ''}
656
656
  ${ls.length ? html`<ul class="team-label-list">${ls.map((l) => html`<li>${chip(l)} <span class="small muted">${l.description ?? ''}</span>
657
657
  <a class="small" href="${TEAM}?label=${l.id}">${n.get(Number(l.id)) ?? 0} open</a>
658
- <details class="team-inline-edit"><summary class="link-button">Edit</summary>${labelForm(l, `l${l.id}`)}
659
- ${admin ? html`<form method="post" action="${TEAM}/labels/${l.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the label ${l.name}? Issues lose it.">${icon('trash')} Delete label</button></form>` : ''}</details></li>`)}</ul>`
660
- : html`<p class="muted small">No labels in this group.</p>`}
661
- ${g && admin ? html`<form method="post" action="${TEAM}/groups/${g.id}/delete" class="u-mt1">${csrf(s)}<button class="btn btn-sm" data-confirm="Delete the group ${g.name}? Its labels stay, without a group.">Delete group</button></form>` : ''}`))}
658
+ <details class="team-inline-edit"><summary class="link-button">${tx('tm.edit')}</summary>${labelForm(l, `l${l.id}`)}
659
+ ${admin ? html`<form method="post" action="${TEAM}/labels/${l.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('tm.delete_label_confirm', { name: l.name })}">${icon('trash')} ${tx('tm.delete_label')}</button></form>` : ''}</details></li>`)}</ul>`
660
+ : html`<p class="muted small">${tx('tm.no_labels_in_this_group')}</p>`}
661
+ ${g && admin ? html`<form method="post" action="${TEAM}/groups/${g.id}/delete" class="u-mt1">${csrf(s)}<button class="btn btn-sm" data-confirm="${tx('tm.delete_group_confirm', { name: g.name })}">${tx('tm.delete_group')}</button></form>` : ''}`))}
662
662
  <div class="columns">
663
- ${region('Add a label', labelForm({}, 'nl'))}
664
- ${region('Add a label group', html`<form method="post" action="${TEAM}/groups">${csrf(s)}
665
- <div class="field"><label class="label" for="ng_name">Name</label><input id="ng_name" name="name" maxlength="40" required placeholder="e.g. Area"></div>
666
- <div class="field"><label class="label" for="ng_desc">Description</label><input id="ng_desc" name="description" maxlength="500"></div>
667
- <div class="buttons"><button class="btn btn-sm btn-hot">Add group</button></div></form>
668
- <p class="small muted">The board groups issues by the labels of one group. Only workspace administrators delete labels and groups.</p>`)}
663
+ ${region(tx('tm.add_a_label'), labelForm({}, 'nl'))}
664
+ ${region(tx('tm.add_a_label_group'), html`<form method="post" action="${TEAM}/groups">${csrf(s)}
665
+ <div class="field"><label class="label" for="ng_name">${tx('tm.name')}</label><input id="ng_name" name="name" maxlength="40" required placeholder="${tx('tm.e_g_area')}"></div>
666
+ <div class="field"><label class="label" for="ng_desc">${tx('tm.description')}</label><input id="ng_desc" name="description" maxlength="500"></div>
667
+ <div class="buttons"><button class="btn btn-sm btn-hot">${tx('tm.add_group')}</button></div></form>
668
+ <p class="small muted">${tx('tm.the_board_groups_issues_by')}</p>`)}
669
669
  </div>`;
670
- return send(reply, s, page(s, 'Labels', [['Labels']], main));
670
+ return send(reply, s, page(s, tx('tm.labels'), [[tx('tm.labels')]], main));
671
671
  });
672
672
 
673
673
  const saveLabel = async (req: Req, reply: FastifyReply, id: string | null) => {
@@ -682,7 +682,7 @@ export async function teamRoutes(app: FastifyInstance) {
682
682
  if (!isLabelColor(color)) return reply.code(400).send('Unknown colour');
683
683
  if (group && !(await owner.one('select 1 as ok from meta.team_label_group where id = $1 and workspace_id = $2', [isId(group) ? group : 0, ws]))) return reply.code(400).send('Unknown group');
684
684
  if (!NAME40.test(name)) {
685
- flash(s, 'A label has a name of 1 to 40 characters.', 'error');
685
+ flash(s, tx('tm.a_label_has_a_name'), 'error');
686
686
  return back(reply, s, `${TEAM}/labels`);
687
687
  }
688
688
  try {
@@ -691,7 +691,7 @@ export async function teamRoutes(app: FastifyInstance) {
691
691
  const r = await owner.query('update meta.team_label set name = $3, color = $4, group_id = $5, description = $6 where id = $1 and workspace_id = $2', [id, ws, name, color, group || null, desc]);
692
692
  if (!r.rowCount) return reply.code(404).send('Not found');
693
693
  } else await owner.query('insert into meta.team_label (workspace_id, name, color, group_id, description) values ($1, $2, $3, $4, $5)', [ws, name, color, group || null, desc]);
694
- flash(s, `Label ${name} saved.`);
694
+ flash(s, tx('tm.label_saved', { name }));
695
695
  } catch (e) {
696
696
  flash(s, /team_label_name/.test((e as Error).message) ? `A label ${name} exists already.` : (e as Error).message, 'error');
697
697
  }
@@ -706,13 +706,13 @@ export async function teamRoutes(app: FastifyInstance) {
706
706
  const { s, ws } = t;
707
707
  const name = text(req.body?.name, 100);
708
708
  if (!NAME40.test(name)) {
709
- flash(s, 'A group has a name of 1 to 40 characters.', 'error');
709
+ flash(s, tx('tm.a_group_has_a_name'), 'error');
710
710
  return back(reply, s, `${TEAM}/labels`);
711
711
  }
712
712
  const r = await owner.query(`insert into meta.team_label_group (workspace_id, name, description, seq)
713
713
  values ($1, $2, $3, (select coalesce(max(seq), 0) + 10 from meta.team_label_group where workspace_id = $1)) on conflict (workspace_id, lower(name)) do nothing`,
714
714
  [ws, name, text(req.body?.description, 500) || null]);
715
- flash(s, r.rowCount ? `Group ${name} added.` : `A group ${name} exists already.`, r.rowCount ? 'ok' : 'error');
715
+ flash(s, r.rowCount ? tx('tm.group_added', { name }) : tx('tm.group_exists', { name }), r.rowCount ? 'ok' : 'error');
716
716
  return back(reply, s, `${TEAM}/labels`);
717
717
  });
718
718
 
@@ -725,7 +725,7 @@ export async function teamRoutes(app: FastifyInstance) {
725
725
  if (!(await workspaceAdmin(s.username, ws))) return reply.code(403).send(`Only the workspace's administrators delete ${what}s.`);
726
726
  const r = await owner.query(`delete from meta.${table} where id = $1 and workspace_id = $2`, [req.params.id, ws]);
727
727
  if (!r.rowCount) return reply.code(404).send('Not found');
728
- flash(s, `The ${what} is deleted.`);
728
+ flash(s, tx(({ label: 'tm.label_deleted', 'label group': 'tm.group_deleted', milestone: 'tm.milestone_deleted', template: 'tm.template_deleted' } as const)[what as 'label'] ?? 'tm.label_deleted'));
729
729
  return back(reply, s, to);
730
730
  });
731
731
  adminDelete('labels', 'team_label', 'label', `${TEAM}/labels`);
@@ -740,14 +740,14 @@ export async function teamRoutes(app: FastifyInstance) {
740
740
  const [lk, admin] = await Promise.all([lookups(ws), workspaceAdmin(s.username, ws)]);
741
741
  const form = (m: Partial<Milestone>, p: string) => html`<form method="post" action="${TEAM}/milestones${m.id ? `/${m.id}` : ''}">${csrf(s)}
742
742
  <div class="form-grid">
743
- <div class="field"><label class="label" for="${p}_name">Name</label><input id="${p}_name" name="name" maxlength="40" required value="${m.name ?? ''}"></div>
744
- <div class="field"><label class="label" for="${p}_due">Due</label><input id="${p}_due" name="due_date" type="date" value="${m.due_date ?? ''}"></div>
745
- <div class="field"><label class="label" for="${p}_status">Status</label><select id="${p}_status" name="status">
743
+ <div class="field"><label class="label" for="${p}_name">${tx('tm.name')}</label><input id="${p}_name" name="name" maxlength="40" required value="${m.name ?? ''}"></div>
744
+ <div class="field"><label class="label" for="${p}_due">${tx('tm.due')}</label><input id="${p}_due" name="due_date" type="date" value="${m.due_date ?? ''}"></div>
745
+ <div class="field"><label class="label" for="${p}_status">${tx('tm.status')}</label><select id="${p}_status" name="status">
746
746
  <option value="open"${m.status !== 'closed' ? raw(' selected') : ''}>Open</option><option value="closed"${m.status === 'closed' ? raw(' selected') : ''}>Closed</option></select></div>
747
- <div class="field"><label class="label" for="${p}_desc">Description</label><input id="${p}_desc" name="description" maxlength="500" value="${m.description ?? ''}"></div>
748
- </div><div class="buttons"><button class="btn btn-sm btn-hot">${m.id ? 'Save' : 'Add milestone'}</button></div></form>`;
747
+ <div class="field"><label class="label" for="${p}_desc">${tx('tm.description')}</label><input id="${p}_desc" name="description" maxlength="500" value="${m.description ?? ''}"></div>
748
+ </div><div class="buttons"><button class="btn btn-sm btn-hot">${m.id ? tx('tm.save') : tx('tm.add_milestone')}</button></div></form>`;
749
749
  const main = html`${teamHeader('milestones')}
750
- ${region('Milestones', lk.milestones.length ? html`<ul class="team-milestones">${lk.milestones.map((m) => {
750
+ ${region(tx('tm.milestones'), lk.milestones.length ? html`<ul class="team-milestones">${lk.milestones.map((m) => {
751
751
  const total = m.open + m.closed;
752
752
  return html`<li class="team-milestone${m.status === 'closed' ? ' is-closed' : ''}">
753
753
  <div class="team-milestone-head"><b>${icon('flag')}${m.name}</b>${m.status === 'closed' ? html` <span class="tag">closed</span>` : ''}
@@ -755,12 +755,12 @@ export async function teamRoutes(app: FastifyInstance) {
755
755
  ${m.description ? html`<p class="small muted">${m.description}</p>` : ''}
756
756
  <progress class="team-progress" max="${total || 1}" value="${m.closed}" aria-label="${m.closed} of ${total} issues closed"></progress>
757
757
  <p class="small"><a href="${TEAM}?milestone=${m.id}">${m.open} open</a> · <a href="${TEAM}?milestone=${m.id}&amp;status=closed">${m.closed} closed</a></p>
758
- <details class="team-inline-edit"><summary class="link-button">Edit</summary>${form(m, `m${m.id}`)}
759
- ${admin ? html`<form method="post" action="${TEAM}/milestones/${m.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the milestone ${m.name}? Its issues keep no milestone.">${icon('trash')} Delete milestone</button></form>` : ''}</details>
758
+ <details class="team-inline-edit"><summary class="link-button">${tx('tm.edit')}</summary>${form(m, `m${m.id}`)}
759
+ ${admin ? html`<form method="post" action="${TEAM}/milestones/${m.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('tm.delete_milestone_confirm', { name: m.name })}">${icon('trash')} ${tx('tm.delete_milestone')}</button></form>` : ''}</details>
760
760
  </li>`;
761
- })}</ul>` : html`<p class="muted">No milestones yet.</p>`)}
762
- ${region('Add a milestone', form({}, 'nm'))}`;
763
- return send(reply, s, page(s, 'Milestones', [['Milestones']], main));
761
+ })}</ul>` : html`<p class="muted">${tx('tm.no_milestones_yet')}</p>`)}
762
+ ${region(tx('tm.add_a_milestone'), form({}, 'nm'))}`;
763
+ return send(reply, s, page(s, tx('tm.milestones'), [[tx('tm.milestones')]], main));
764
764
  });
765
765
 
766
766
  const saveMilestone = async (req: Req, reply: FastifyReply, id: string | null) => {
@@ -771,7 +771,7 @@ export async function teamRoutes(app: FastifyInstance) {
771
771
  const name = text(b.name, 100);
772
772
  const due = String(b.due_date ?? '');
773
773
  if (!NAME40.test(name) || (due && (!/^\d{4}-\d{2}-\d{2}$/.test(due) || Number.isNaN(Date.parse(due))))) {
774
- flash(s, 'A milestone has a name of 1 to 40 characters and an optional date.', 'error');
774
+ flash(s, tx('tm.a_milestone_has_a_name'), 'error');
775
775
  return back(reply, s, `${TEAM}/milestones`);
776
776
  }
777
777
  const vals = [name, text(b.description, 500) || null, due || null, b.status === 'closed' ? 'closed' : 'open'];
@@ -781,7 +781,7 @@ export async function teamRoutes(app: FastifyInstance) {
781
781
  const r = await owner.query('update meta.team_milestone set name = $3, description = $4, due_date = $5, status = $6 where id = $1 and workspace_id = $2', [id, ws, ...vals]);
782
782
  if (!r.rowCount) return reply.code(404).send('Not found');
783
783
  } else await owner.query('insert into meta.team_milestone (workspace_id, name, description, due_date, status) values ($1, $2, $3, $4, $5)', [ws, ...vals]);
784
- flash(s, `Milestone ${name} saved.`);
784
+ flash(s, tx('tm.milestone_saved', { name }));
785
785
  } catch (e) {
786
786
  flash(s, /team_milestone_name/.test((e as Error).message) ? `A milestone ${name} exists already.` : (e as Error).message, 'error');
787
787
  }
@@ -798,26 +798,26 @@ export async function teamRoutes(app: FastifyInstance) {
798
798
  const rows = (await owner.query('select id, kind, name, title, body, labels from meta.team_template where workspace_id = $1 order by kind, seq, lower(name)', [ws])).rows;
799
799
  const form = (x: { id?: number; kind?: string; name?: string; title?: string | null; body?: string; labels?: string[] }, p: string) => html`<form method="post" action="${TEAM}/templates${x.id ? `/${x.id}` : ''}">${csrf(s)}
800
800
  <div class="form-grid">
801
- <div class="field"><label class="label" for="${p}_name">Name</label><input id="${p}_name" name="name" maxlength="60" required value="${x.name ?? ''}"></div>
802
- <div class="field"><label class="label" for="${p}_kind">Kind</label><select id="${p}_kind" name="kind">
803
- <option value="issue"${x.kind !== 'response' ? raw(' selected') : ''}>Issue template</option><option value="response"${x.kind === 'response' ? raw(' selected') : ''}>Canned response</option></select></div>
804
- <div class="field"><label class="label" for="${p}_title">Issue title</label><input id="${p}_title" name="title" maxlength="200" value="${x.title ?? ''}"><small class="help">Issue templates only.</small></div>
805
- <div class="field"><label class="label" for="${p}_labels">Labels</label><input id="${p}_labels" name="labels" maxlength="500" value="${(x.labels ?? []).join(', ')}"><small class="help">Issue templates: label names, separated by commas.</small></div>
801
+ <div class="field"><label class="label" for="${p}_name">${tx('tm.name')}</label><input id="${p}_name" name="name" maxlength="60" required value="${x.name ?? ''}"></div>
802
+ <div class="field"><label class="label" for="${p}_kind">${tx('tm.kind')}</label><select id="${p}_kind" name="kind">
803
+ <option value="issue"${x.kind !== 'response' ? raw(' selected') : ''}>${tx('tm.kind_issue')}</option><option value="response"${x.kind === 'response' ? raw(' selected') : ''}>${tx('tm.kind_response')}</option></select></div>
804
+ <div class="field"><label class="label" for="${p}_title">${tx('tm.issue_title')}</label><input id="${p}_title" name="title" maxlength="200" value="${x.title ?? ''}"><small class="help">${tx('tm.issue_templates_only')}</small></div>
805
+ <div class="field"><label class="label" for="${p}_labels">${tx('tm.labels')}</label><input id="${p}_labels" name="labels" maxlength="500" value="${(x.labels ?? []).join(', ')}"><small class="help">${tx('tm.issue_templates_label_names_separated')}</small></div>
806
806
  </div>
807
- <div class="field"><label class="label" for="${p}_body">Text</label><textarea id="${p}_body" name="body" rows="6" maxlength="20000">${x.body ?? ''}</textarea><small class="help">Markdown.</small></div>
808
- <div class="buttons"><button class="btn btn-sm btn-hot">${x.id ? 'Save' : 'Add template'}</button></div></form>`;
807
+ <div class="field"><label class="label" for="${p}_body">${tx('tm.text')}</label><textarea id="${p}_body" name="body" rows="6" maxlength="20000">${x.body ?? ''}</textarea><small class="help">${tx('tm.markdown')}</small></div>
808
+ <div class="buttons"><button class="btn btn-sm btn-hot">${x.id ? tx('tm.save') : tx('tm.add_template')}</button></div></form>`;
809
809
  const list = (kind: string) => {
810
810
  const xs = rows.filter((r) => r.kind === kind);
811
811
  return xs.length ? html`<ul class="team-template-list">${xs.map((x) => html`<li><b>${x.name}</b>
812
- ${kind === 'issue' ? html` <a class="small" href="${TEAM}/issues/new?template=${x.id}">Use</a>` : ''}
813
- <details class="team-inline-edit"><summary class="link-button">Edit</summary>${form(x, `t${x.id}`)}
814
- <form method="post" action="${TEAM}/templates/${x.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the template ${x.name}?">${icon('trash')} Delete template</button></form></details></li>`)}</ul>`
815
- : html`<p class="muted small">None yet.</p>`;
812
+ ${kind === 'issue' ? html` <a class="small" href="${TEAM}/issues/new?template=${x.id}">${tx('tm.use')}</a>` : ''}
813
+ <details class="team-inline-edit"><summary class="link-button">${tx('tm.edit')}</summary>${form(x, `t${x.id}`)}
814
+ <form method="post" action="${TEAM}/templates/${x.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('tm.delete_template_confirm', { name: x.name })}">${icon('trash')} ${tx('tm.delete_template')}</button></form></details></li>`)}</ul>`
815
+ : html`<p class="muted small">${tx('tm.none_yet')}</p>`;
816
816
  };
817
817
  const main = html`${teamHeader('templates')}
818
- <div class="columns">${region('Issue templates', list('issue'))}${region('Canned responses', list('response'))}</div>
819
- ${region('Add a template', form({}, 'nt'))}`;
820
- return send(reply, s, page(s, 'Templates', [['Templates']], main));
818
+ <div class="columns">${region(tx('tm.issue_templates'), list('issue'))}${region(tx('tm.canned_responses'), list('response'))}</div>
819
+ ${region(tx('tm.add_a_template'), form({}, 'nt'))}`;
820
+ return send(reply, s, page(s, tx('tm.templates'), [[tx('tm.templates')]], main));
821
821
  });
822
822
 
823
823
  const saveTemplate = async (req: Req, reply: FastifyReply, id: string | null) => {
@@ -830,7 +830,7 @@ export async function teamRoutes(app: FastifyInstance) {
830
830
  const body = String(b.body ?? '').replace(/\r\n?/g, '\n');
831
831
  const labels = String(b.labels ?? '').split(',').map((x) => x.trim()).filter((x) => NAME40.test(x)).slice(0, 20);
832
832
  if (!/^\S(.{0,58}\S)?$/.test(name) || body.length > 20000) {
833
- flash(s, 'A template has a name of 1 to 60 characters and up to 20000 characters of text.', 'error');
833
+ flash(s, tx('tm.a_template_has_a_name'), 'error');
834
834
  return back(reply, s, `${TEAM}/templates`);
835
835
  }
836
836
  const vals = [kind, name, kind === 'issue' ? text(b.title, 200) || null : null, body, kind === 'issue' ? labels : []];
@@ -840,7 +840,7 @@ export async function teamRoutes(app: FastifyInstance) {
840
840
  const r = await owner.query('update meta.team_template set kind = $3, name = $4, title = $5, body = $6, labels = $7 where id = $1 and workspace_id = $2', [id, ws, ...vals]);
841
841
  if (!r.rowCount) return reply.code(404).send('Not found');
842
842
  } else await owner.query('insert into meta.team_template (workspace_id, kind, name, title, body, labels) values ($1, $2, $3, $4, $5, $6)', [ws, ...vals]);
843
- flash(s, `Template ${name} saved.`);
843
+ flash(s, tx('tm.template_saved', { name }));
844
844
  } catch (e) {
845
845
  flash(s, /team_template_name/.test((e as Error).message) ? `A template ${name} exists already.` : (e as Error).message, 'error');
846
846
  }
@@ -855,7 +855,7 @@ export async function teamRoutes(app: FastifyInstance) {
855
855
  if (!isId(req.params.id)) return reply.code(404).send('Not found');
856
856
  const r = await owner.query('delete from meta.team_template where id = $1 and workspace_id = $2', [req.params.id, ws]);
857
857
  if (!r.rowCount) return reply.code(404).send('Not found');
858
- flash(s, 'Template deleted.');
858
+ flash(s, tx('tm.template_deleted'));
859
859
  return back(reply, s, `${TEAM}/templates`);
860
860
  });
861
861
 
@@ -866,17 +866,17 @@ export async function teamRoutes(app: FastifyInstance) {
866
866
  const { s } = t;
867
867
  const en = TEAM_SAMPLES.en;
868
868
  const main = html`${teamHeader('setup')}
869
- ${region('Set up Team Development', html`
870
- <p class="u-mt0">Installs sample data for ${s.workspace?.name ?? 'this workspace'}; what exists already (by name) is kept, so you can run it again.</p>
869
+ ${region(tx('tm.set_up_team_development_2'), html`
870
+ <p class="u-mt0">${tx('tm.setup_intro', { workspace: s.workspace?.name ?? tx('tm.this_workspace') })}</p>
871
871
  <form method="post" action="${TEAM}/setup">${csrf(s)}
872
- <div class="field"><label class="label" for="f_lang">Language</label><select id="f_lang" name="lang">${TEAM_LANGUAGES.map((l) => html`<option value="${l}">${TEAM_SAMPLES[l].language}</option>`)}</select></div>
873
- <fieldset class="team-fieldset"><legend>Install</legend>
874
- <label class="check"><input type="checkbox" name="labels" value="true" checked> Label groups and labels: ${en.groups.map((g) => `${g.name} (${g.labels.map((l) => l.name).join(', ')})`).join('; ')}</label>
875
- <label class="check"><input type="checkbox" name="templates" value="true" checked> Templates: ${en.issueTemplates.map((x) => x.name).join(', ')}; canned responses (${en.responses.length})</label>
876
- <label class="check"><input type="checkbox" name="milestones" value="true" checked> Milestones: ${en.milestones.map((m) => m.name).join(', ')}</label>
872
+ <div class="field"><label class="label" for="f_lang">${tx('tm.language')}</label><select id="f_lang" name="lang">${TEAM_LANGUAGES.map((l) => html`<option value="${l}">${TEAM_SAMPLES[l].language}</option>`)}</select></div>
873
+ <fieldset class="team-fieldset"><legend>${tx('tm.install')}</legend>
874
+ <label class="check"><input type="checkbox" name="labels" value="true" checked> ${tx('tm.setup_labels', { list: en.groups.map((g) => `${g.name} (${g.labels.map((l) => l.name).join(', ')})`).join('; ') })}</label>
875
+ <label class="check"><input type="checkbox" name="templates" value="true" checked> ${tx('tm.setup_templates', { list: en.issueTemplates.map((x) => x.name).join(', '), n: en.responses.length })}</label>
876
+ <label class="check"><input type="checkbox" name="milestones" value="true" checked> ${tx('tm.setup_milestones', { list: en.milestones.map((m) => m.name).join(', ') })}</label>
877
877
  </fieldset>
878
- <div class="buttons"><button class="btn btn-hot">Set up</button></div></form>`)}`;
879
- return send(reply, s, page(s, 'Set up', [['Set up']], main));
878
+ <div class="buttons"><button class="btn btn-hot">${tx('tm.set_up')}</button></div></form>`)}`;
879
+ return send(reply, s, page(s, tx('tm.set_up'), [[tx('tm.set_up')]], main));
880
880
  });
881
881
 
882
882
  app.post(`${TEAM}/setup`, async (req: Req, reply) => {
@@ -888,7 +888,7 @@ export async function teamRoutes(app: FastifyInstance) {
888
888
  const b = req.body ?? {};
889
889
  const n = await owner.tx((c) => teamSetup(c, ws, lang, { labels: b.labels === 'true', templates: b.templates === 'true', milestones: b.milestones === 'true' }));
890
890
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `team: set up (${lang})` });
891
- flash(s, `Set up: ${n.groups} label group(s), ${n.labels} label(s), ${n.templates} template(s) and ${n.milestones} milestone(s) added.`);
891
+ flash(s, tx('tm.setup_done', { groups: n.groups, labels: n.labels, templates: n.templates, milestones: n.milestones }));
892
892
  return back(reply, s, TEAM);
893
893
  });
894
894
 
@@ -904,7 +904,7 @@ export async function teamRoutes(app: FastifyInstance) {
904
904
  const existing = await owner.one('select id from meta.team_issue where feedback_id = $1 and workspace_id = $2', [f.id, ws]);
905
905
  if (existing) return back(reply, s, `${TEAM}/issues/${existing.id}`);
906
906
  const kindLabel = f.kind === 'problem' ? 'bug' : f.kind === 'idea' ? 'feature' : null;
907
- const title = `${f.kind === 'problem' ? 'Problem' : f.kind === 'idea' ? 'Idea' : 'Feedback'}: ${firstLine(f.body)}`;
907
+ const title = `${f.kind === 'problem' ? tx('tm.problem') : f.kind === 'idea' ? tx('tm.idea') : tx('tm.feedback')}: ${firstLine(f.body)}`;
908
908
  const body = `Feedback of **${mdEscape(f.username)}** (${f.kind}${f.rating ? `, ${f.rating} of 5` : ''}) on ${f.page_no ? `page ${f.page_no} of ` : ''}${mdEscape(f.app_name)}:\n\n${quote(f.body)}`;
909
909
  const id = await owner.tx(async (c) => {
910
910
  const id = Number((await c.query(`insert into meta.team_issue (workspace_id, title, body, app_id, page_no, feedback_id, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
@@ -914,7 +914,7 @@ export async function teamRoutes(app: FastifyInstance) {
914
914
  if (f.status === 'open') await c.query(`update meta.feedback set status = 'in_progress' where id = $1`, [f.id]);
915
915
  return id;
916
916
  });
917
- flash(s, `Issue #${id} created from the feedback.`);
917
+ flash(s, tx('tm.created_feedback', { id }));
918
918
  return back(reply, s, `${TEAM}/issues/${id}`);
919
919
  });
920
920
 
@@ -935,13 +935,13 @@ export async function teamRoutes(app: FastifyInstance) {
935
935
  await recordMentions(c, ws, id, null, c0.body, s.username);
936
936
  return id;
937
937
  });
938
- flash(s, `Issue #${id} created from the comment.`);
938
+ flash(s, tx('tm.created_comment', { id }));
939
939
  return back(reply, s, `${TEAM}/issues/${id}`);
940
940
  });
941
941
  }
942
942
 
943
943
  const firstLine = (t: string) => {
944
- const l = String(t).split('\n').map((x) => x.trim()).find(Boolean) ?? 'Untitled';
944
+ const l = String(t).split('\n').map((x) => x.trim()).find(Boolean) ?? tx('tm.untitled');
945
945
  return l.length > 80 ? `${l.slice(0, 79)}…` : l;
946
946
  };
947
947
  const mdEscape = (t: string) => String(t).replace(/[\\`*_[\]<>#]/g, (c) => `\\${c}`);
@@ -954,9 +954,9 @@ export async function pageIssuesNote(appId: number, pageNo: number): Promise<Raw
954
954
  const rows = await pageIssues(appId, pageNo);
955
955
  if (!rows.length) return '';
956
956
  const all = `${TEAM}?app=${appId}&page=${pageNo}`;
957
- return html`<div class="team-page-issues small" role="note">${icon('kanban')}<span>${rows.length} open issue${rows.length === 1 ? '' : 's'}:
958
- ${rows.slice(0, 3).map((r, i) => html`${i ? ', ' : ''}<a href="${TEAM}/issues/${r.id}">${r.title}</a>`)}${rows.length > 3 ? html`, <a href="${all}">all ${rows.length}</a>` : ''}
959
- · <a href="${TEAM}/issues/new?app=${appId}&amp;page=${pageNo}">New issue</a></span></div>`;
957
+ return html`<div class="team-page-issues small" role="note">${icon('kanban')}<span>${rows.length === 1 ? tx('tm.page_issues_1') : tx('tm.page_issues', { n: rows.length })}
958
+ ${rows.slice(0, 3).map((r, i) => html`${i ? ', ' : ''}<a href="${TEAM}/issues/${r.id}">${r.title}</a>`)}${rows.length > 3 ? html`, <a href="${all}">${tx('tm.all_n', { n: rows.length })}</a>` : ''}
959
+ · <a href="${TEAM}/issues/new?app=${appId}&amp;page=${pageNo}">${tx('tm.new_issue')}</a></span></div>`;
960
960
  }
961
961
 
962
962
  /** (App Home) open issues per page, and the Tasks entry. */
@@ -965,7 +965,7 @@ export async function appIssues(appId: number) {
965
965
  return {
966
966
  tag: (pageNo: number) => {
967
967
  const n = c.perPage.get(pageNo);
968
- return n ? html` <a class="tag tag-warning" href="${TEAM}?app=${appId}&amp;page=${pageNo}" title="Open issues of this page">${n} issue${n === 1 ? '' : 's'}</a>` : '';
968
+ return n ? html` <a class="tag tag-warning" href="${TEAM}?app=${appId}&amp;page=${pageNo}" title="${tx('tm.open_issues_of_this_page')}">${n} issue${n === 1 ? '' : 's'}</a>` : '';
969
969
  },
970
970
  task: html`<li><a href="${TEAM}?app=${appId}">${icon('kanban')} Open issues (${c.total})</a></li>`,
971
971
  };
@@ -989,5 +989,5 @@ export async function teamHomeBlock(s: Session) {
989
989
  /** (Feedback, developer comments) a button that turns the entry into an issue, or the link to its issue. */
990
990
  export function toIssueButton(s: Session, kind: 'feedback' | 'comment', id: number, issue: number | undefined) {
991
991
  if (issue) return html`<a class="small team-issue-link" href="${TEAM}/issues/${issue}">${icon('kanban')} Issue #${issue}</a>`;
992
- return html`<form method="post" action="${TEAM}/from-${kind}" class="u-inline">${csrf(s)}<input type="hidden" name="${kind}" value="${id}"><button class="link-button">Make an issue</button></form>`;
992
+ return html`<form method="post" action="${TEAM}/from-${kind}" class="u-inline">${csrf(s)}<input type="hidden" name="${kind}" value="${id}"><button class="link-button">${tx('tm.make_an_issue')}</button></form>`;
993
993
  }