pgkiln 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/db/migrations/108_sql_history.sql +25 -0
- package/docs/apex-feature-parity.md +33 -4
- package/docs/guide/03-builder.md +123 -28
- package/docs/guide/04-pages-and-regions.md +74 -2
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/08-security.md +1 -1
- package/docs/guide/09-reference.md +1 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +26 -3
- package/docs/guide/16-files.md +2 -2
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +62 -2
- package/public/app.js +244 -35
- package/public/builder.css +21 -1
- package/public/builder.js +67 -0
- package/public/sw.js +2 -1
- package/public/worksheet.css +244 -0
- package/public/worksheet.js +665 -0
- package/src/app.ts +9 -1
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/designer.ts +44 -17
- package/src/builder/errors.ts +65 -0
- package/src/builder/form-designer.ts +229 -0
- package/src/builder/locks.ts +6 -0
- package/src/builder/newapp.ts +2 -1
- package/src/builder/object-actions.ts +166 -0
- package/src/builder/object-browser.ts +111 -33
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/region-help.ts +2 -0
- package/src/builder/region-settings.ts +2 -0
- package/src/builder/reporter.ts +1 -1
- package/src/builder/routes.ts +7 -5
- package/src/builder/sampledata.ts +2 -2
- package/src/builder/scripts.ts +3 -3
- package/src/builder/search.ts +3 -3
- package/src/builder/shared.ts +2 -2
- package/src/builder/sql.ts +15 -73
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +5 -3
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +569 -0
- package/src/builder/workspaces.ts +1 -1
- package/src/cli/replace.ts +10 -0
- package/src/i18n/ar.ts +62 -0
- package/src/i18n/cs.ts +62 -0
- package/src/i18n/da.ts +62 -0
- package/src/i18n/de.ts +62 -0
- package/src/i18n/el.ts +62 -0
- package/src/i18n/es.ts +62 -0
- package/src/i18n/fi.ts +62 -0
- package/src/i18n/fr.ts +62 -0
- package/src/i18n/he.ts +62 -0
- package/src/i18n/it.ts +62 -0
- package/src/i18n/ja.ts +62 -0
- package/src/i18n/ko.ts +62 -0
- package/src/i18n/nb.ts +62 -0
- package/src/i18n/pl.ts +62 -0
- package/src/i18n/pt.ts +62 -0
- package/src/i18n/ru.ts +62 -0
- package/src/i18n/sv.ts +62 -0
- package/src/i18n/tr.ts +62 -0
- package/src/i18n/uk.ts +62 -0
- package/src/i18n/zh.ts +62 -0
- package/src/i18n.ts +124 -0
- package/src/metadata.ts +4 -2
- package/src/runtime/devbar.ts +1 -1
- package/src/runtime/facet-state.ts +10 -5
- package/src/runtime/facets.ts +29 -5
- package/src/runtime/form-designer.ts +676 -0
- package/src/runtime/grid.ts +15 -8
- package/src/runtime/items.ts +33 -21
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +7 -3
- package/src/runtime/report.ts +2 -2
- package/src/runtime/routes.ts +3 -1
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
- package/src/sqlformat.ts +208 -0
- package/src/sqlplan.ts +133 -0
- package/src/sqlscript.ts +6 -2
- package/src/sqlworksheet.ts +278 -0
- package/src/tabledef.ts +226 -0
- package/src/unload.ts +14 -3
package/src/runtime/grid.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { checksumValid, urlChecksum } from '../security.ts';
|
|
|
9
9
|
import { isAuthorized, pageAllowed } from './authz.ts';
|
|
10
10
|
import { publicError, toState, type PageContext } from './context.ts';
|
|
11
11
|
import { arrange, cleanLayout, currentLayout, defaultLayout, layoutJson, layoutParam, MAX_FROZEN, MAX_WIDTH, MIN_WIDTH, parseLayout, type GridLayout, type Placed } from './grid-layout.ts';
|
|
12
|
-
import { lovContains, lovOptions, type LovOption } from './items.ts';
|
|
12
|
+
import { appDateMask, dateField, lovContains, lovOptions, type LovOption } from './items.ts';
|
|
13
|
+
import { parseDateMask } from './format.ts';
|
|
13
14
|
import { resolveRestRegion, restAllows, restGridCall, restWritable } from './rest-sources.ts';
|
|
14
15
|
import { fillItems, linkAttrs } from './links.ts';
|
|
15
16
|
import { detailsOf, masterColumnOf, masterItemOf, selectHref, selectRowOf } from './master-detail.ts';
|
|
@@ -102,7 +103,7 @@ const rowToken = (ctx: PageContext, r: Region, pk: string) => urlChecksum(ctx.ap
|
|
|
102
103
|
|
|
103
104
|
// ---------------------------------------------------------------- render
|
|
104
105
|
|
|
105
|
-
function control(col: GridColumn, name: string, value: string, label: string) {
|
|
106
|
+
function control(ctx: PageContext, col: GridColumn, name: string, value: string, label: string) {
|
|
106
107
|
const aria = raw(` aria-label="${label.replace(/"/g, '"')}"`);
|
|
107
108
|
if (col.options)
|
|
108
109
|
return html`<select name="${name}"${aria}><option value=""></option>${col.options.map(
|
|
@@ -111,7 +112,8 @@ function control(col: GridColumn, name: string, value: string, label: string) {
|
|
|
111
112
|
if (col.typeOid === 16)
|
|
112
113
|
return html`<input type="checkbox" name="${name}" value="true"${value === 'true' ? raw(' checked') : ''}${aria}>`;
|
|
113
114
|
if (NUMERIC.has(col.typeOid)) return html`<input type="number" step="any" inputmode="decimal" name="${name}" value="${value}"${aria}>`;
|
|
114
|
-
|
|
115
|
+
// (sprint 84) a date in the application's date format, with pgkiln's date picker
|
|
116
|
+
if (col.typeOid === 1082) return dateField(ctx, { id: `f_${name}`, iso: value, mask: appDateMask(ctx), attrs: html` name="${name}"${aria}` });
|
|
115
117
|
if (col.typeOid === 1114 || col.typeOid === 1184)
|
|
116
118
|
return html`<input type="datetime-local" name="${name}" value="${value.slice(0, 16).replace(' ', 'T')}"${aria}>`;
|
|
117
119
|
return html`<input type="text" name="${name}" value="${value}"${aria}>`;
|
|
@@ -329,7 +331,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
329
331
|
if (!col.editable) return html`<td class="${colCls(p, pIdx, NUMERIC.has(col.typeOid) ? 'num' : null)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cell(row[col.idx], col.typeOid, fmtOf(col.name))}</td>`;
|
|
330
332
|
const name = `${g}_${i}_c${col.ci}`;
|
|
331
333
|
const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
|
|
332
|
-
return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cellText(col, value, value === dbValue ? cell(row[col.idx], col.typeOid, fmtOf(col.name)) : value)}${control(col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
|
|
334
|
+
return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cellText(col, value, value === dbValue ? cell(row[col.idx], col.typeOid, fmtOf(col.name)) : value)}${control(ctx, col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
|
|
333
335
|
});
|
|
334
336
|
const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
|
|
335
337
|
const selValue = selIdx >= 0 ? toState(row[selIdx]) ?? '' : null;
|
|
@@ -366,7 +368,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
366
368
|
const lbl = label(col.name);
|
|
367
369
|
if (masterCol && col.name === masterCol) return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${masterValue}</td>`;
|
|
368
370
|
return writable.has(col.name)
|
|
369
|
-
? html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control({ ...col, editable: true }, `${g}_n${j}_c${col.ci}`, values[col.ci] ?? '', `${lbl}, new row`)}</td>`
|
|
371
|
+
? html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control(ctx, { ...col, editable: true }, `${g}_n${j}_c${col.ci}`, values[col.ci] ?? '', `${lbl}, new row`)}</td>`
|
|
370
372
|
: html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}></td>`;
|
|
371
373
|
})}
|
|
372
374
|
</tr>`;
|
|
@@ -417,7 +419,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
417
419
|
orig[col.ci] = dbValue;
|
|
418
420
|
if (!col.editable) return field(p, cell(row[col.idx], col.typeOid, fmtOf(col.name)), false);
|
|
419
421
|
const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
|
|
420
|
-
return field(p, control(col, `${g}_0_c${col.ci}`, value, label(col.name)), true);
|
|
422
|
+
return field(p, control(ctx, col, `${g}_0_c${col.ci}`, value, label(col.name)), true);
|
|
421
423
|
});
|
|
422
424
|
const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
|
|
423
425
|
const links = [
|
|
@@ -438,7 +440,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
438
440
|
const col = p.col;
|
|
439
441
|
if (masterCol && col.name === masterCol) return [field(p, masterValue, false)];
|
|
440
442
|
if (!writable.has(col.name)) return [];
|
|
441
|
-
return [field(p, control({ ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
|
|
443
|
+
return [field(p, control(ctx, { ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
|
|
442
444
|
});
|
|
443
445
|
return html`<div class="grid-record grid-new" data-new-row="0"><div class="form-grid">${fieldsHtml}</div></div>
|
|
444
446
|
${recordNav()}`;
|
|
@@ -718,7 +720,12 @@ export async function gridDml(ctx: PageContext, p: Process): Promise<string | nu
|
|
|
718
720
|
const lov = r.config.columns?.[name]?.lov;
|
|
719
721
|
return !lov || v === '' || (await lovContains(ctx, lov, [v]));
|
|
720
722
|
};
|
|
721
|
-
|
|
723
|
+
// (sprint 84) a date typed in the date format (or YYYY-MM-DD) → YYYY-MM-DD; other text goes to the database as it is (which refuses it)
|
|
724
|
+
const dateMask = appDateMask(ctx);
|
|
725
|
+
const norm = (ci: number, v: string | undefined) =>
|
|
726
|
+
cols[ci].dataTypeID === 16 ? (v === 'true' ? 'true' : 'false')
|
|
727
|
+
: cols[ci].dataTypeID === 1082 && v?.trim() ? (parseDateMask(v, dateMask, ctx.locale.lang) ?? v)
|
|
728
|
+
: v ?? '';
|
|
722
729
|
|
|
723
730
|
const errors: string[] = [];
|
|
724
731
|
let inserted = 0, updated = 0, deleted = 0;
|
package/src/runtime/items.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { markdownHtml, sanitizeHtml } from '../richtext.ts';
|
|
|
14
14
|
import { qrSvg, type Ecc } from '../qrcode.ts';
|
|
15
15
|
import { formatNumber, isPlainNumber, maskError } from '../numformat.ts';
|
|
16
16
|
import { addressControl, imageControl } from './data-items.ts';
|
|
17
|
-
import { applyMask, formatMaskError, monthNames } from './format.ts';
|
|
17
|
+
import { applyMask, formatMaskError, monthNames, parseDateMask } from './format.ts';
|
|
18
18
|
|
|
19
19
|
const TRUTHY = new Set(['true', 't', 'on', '1', 'yes', 'y']);
|
|
20
20
|
export const isTruthy = (v: string | null | undefined) => !!v && TRUTHY.has(v.toLowerCase());
|
|
@@ -184,15 +184,36 @@ export const splitRange = (v: string | null | undefined): [string, string] => {
|
|
|
184
184
|
return [from, to];
|
|
185
185
|
};
|
|
186
186
|
|
|
187
|
+
/** (sprint 84) The application's date format, else ISO: every date field is text in it, with pgkiln's date picker. */
|
|
188
|
+
export const appDateMask = (ctx: PageContext) => ctx.locale.dateMask?.trim() || 'YYYY-MM-DD';
|
|
189
|
+
/** (sprint 84) A date typed in the application's date format (or YYYY-MM-DD) as YYYY-MM-DD; null when it isn't one. */
|
|
190
|
+
export const appDateReader = (ctx: PageContext) => (text: string) => parseDateMask(text, appDateMask(ctx), ctx.locale.lang);
|
|
191
|
+
|
|
187
192
|
/**
|
|
188
|
-
* (sprint 76) The date mask a date item shows its value in: its own format mask (a date mask),
|
|
189
|
-
* application's date format; null
|
|
193
|
+
* (sprint 76) The date mask a date or date range item shows its value in: its own format mask (a date mask),
|
|
194
|
+
* else the application's date format, else YYYY-MM-DD; null for other items.
|
|
190
195
|
*/
|
|
191
196
|
export function itemDateMask(ctx: PageContext, item: Item): string | null {
|
|
192
|
-
if (item.type !== 'date') return null;
|
|
197
|
+
if (item.type !== 'date' && item.type !== 'daterange') return null;
|
|
193
198
|
const own = typeof item.config?.format_mask === 'string' ? item.config.format_mask.trim() : '';
|
|
194
199
|
if (own && /(YYYY|YY|MM|DD|MON)/i.test(own) && !formatMaskError(own)) return own;
|
|
195
|
-
return ctx
|
|
200
|
+
return appDateMask(ctx);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* (sprint 84; APEX: the date picker) A date as text in a date mask, with a calendar button that opens pgkiln's
|
|
205
|
+
* date picker (app.js). Without JavaScript the text is typed (in the mask, or as YYYY-MM-DD) and read back on
|
|
206
|
+
* submit. `attrs`: extra attributes of the text field (name, form, min/max, aria).
|
|
207
|
+
*/
|
|
208
|
+
export function dateField(ctx: PageContext, o: { id: string; iso: string; mask: string; attrs?: Raw | string; editable?: boolean; typed?: string }) {
|
|
209
|
+
const iso = /^\d{4}-\d{2}-\d{2}/.test(o.iso) ? o.iso.slice(0, 10) : '';
|
|
210
|
+
const shown = iso ? applyMask(iso, o.mask, ctx.locale.lang) : (o.typed ?? o.iso);
|
|
211
|
+
const editable = o.editable !== false;
|
|
212
|
+
const t = ctx.locale.t;
|
|
213
|
+
// a number pad on phones, unless the mask has month or day names
|
|
214
|
+
const numeric = /MON|DAY|DY/i.test(o.mask.replace(/"[^"]*"/g, '')) ? '' : raw(' inputmode="numeric"');
|
|
215
|
+
return html`<span class="input-with-button date-field"><input type="text" id="${o.id}"${o.attrs ?? ''} value="${shown}" placeholder="${o.mask}" autocomplete="off"${numeric} data-date-mask="${o.mask}" data-months="${JSON.stringify(monthNames(ctx.locale.lang))}"${editable ? '' : raw(' readonly')}>${
|
|
216
|
+
editable ? html`<button type="button" class="btn" data-date-pick="${o.id}" aria-label="${t('item.choose_date')}" title="${t('item.choose_date')}" aria-haspopup="dialog">${icon('calendar')}</button>` : ''}</span>`;
|
|
196
217
|
}
|
|
197
218
|
|
|
198
219
|
/** A number or display item's number format mask ({"format_mask": "999G990D00"}), when it is a valid one. */
|
|
@@ -384,9 +405,11 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
384
405
|
useLegend = true;
|
|
385
406
|
const [from, to] = splitRange(value);
|
|
386
407
|
const t = ctx.locale.t;
|
|
408
|
+
// (sprint 84) each part in the date format with the date picker; the picker keeps the from on or before the to
|
|
409
|
+
const mask = itemDateMask(ctx, item)!;
|
|
387
410
|
control = html`<div class="date-range" data-range="${id}">
|
|
388
|
-
<span class="range-part"><label class="sub" for="${id}">${t('item.range_from')}</label
|
|
389
|
-
<span class="range-part"><label class="sub" for="${id}_TO">${t('item.range_to')}</label
|
|
411
|
+
<span class="range-part"><label class="sub" for="${id}">${t('item.range_from')}</label>${dateField(ctx, { id, iso: from, mask, attrs: html` name="${id}"${aria}` })}</span>
|
|
412
|
+
<span class="range-part"><label class="sub" for="${id}_TO">${t('item.range_to')}</label>${dateField(ctx, { id: `${id}_TO`, iso: to, mask, attrs: html` name="${id}"${aria}` })}</span>
|
|
390
413
|
</div>`;
|
|
391
414
|
break;
|
|
392
415
|
}
|
|
@@ -398,21 +421,10 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
398
421
|
control = html`<div class="input-with-button"><input type="text" id="${id}" name="${id}" value="${value}" inputmode="decimal" placeholder="52.01160,4.35710"${aria}>
|
|
399
422
|
<button type="button" class="btn" data-locate="${id}">${icon('map')} ${ctx.locale.t('item.locate')}</button></div>`;
|
|
400
423
|
break;
|
|
401
|
-
case 'date':
|
|
402
|
-
// (sprint 76) in the
|
|
403
|
-
|
|
404
|
-
if (!mask) {
|
|
405
|
-
control = html`<input type="date" id="${id}" name="${id}" value="${value}"${aria}${editable ? '' : raw(' readonly')}>`;
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
408
|
-
const iso = /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '';
|
|
409
|
-
const shown = iso ? applyMask(iso, mask, ctx.locale.lang) : value;
|
|
410
|
-
const months = monthNames(ctx.locale.lang);
|
|
411
|
-
control = html`<div class="input-with-button date-field"><input type="text" id="${id}" name="${id}" value="${shown}" placeholder="${mask}" autocomplete="off" data-date-mask="${mask}" data-months="${JSON.stringify(months)}"${aria}${editable ? '' : raw(' readonly')}>
|
|
412
|
-
${editable ? html`<input type="date" class="date-native" value="${iso}" tabindex="-1" aria-hidden="true" data-date-for="${id}">
|
|
413
|
-
<button type="button" class="btn" data-date-pick="${id}" aria-label="${ctx.locale.t('item.choose_date')}" title="${ctx.locale.t('item.choose_date')}">${icon('calendar')}</button>` : ''}</div>`;
|
|
424
|
+
case 'date':
|
|
425
|
+
// (sprint 76, 84) in the date format: a text field and pgkiln's date picker (app.js), read back on submit
|
|
426
|
+
control = dateField(ctx, { id, iso: value, mask: itemDateMask(ctx, item)!, attrs: html` name="${id}"${aria}`, editable });
|
|
414
427
|
break;
|
|
415
|
-
}
|
|
416
428
|
default: {
|
|
417
429
|
const typed: Record<string, string> = { number: 'number', date: 'date', password: 'password', email: 'email', tel: 'tel', url: 'url' };
|
|
418
430
|
// a number with a format mask is text in the language's notation (1.234,50), read back on submit
|
package/src/runtime/lists.ts
CHANGED
|
@@ -225,7 +225,7 @@ export async function renderListRegion(ctx: PageContext, r: Region): Promise<Raw
|
|
|
225
225
|
case 'badges':
|
|
226
226
|
return html`<ul class="list-badges">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'list-badge')}><span class="list-badge-value">${n.badge ?? icon(n.icon ?? 'chevron')}</span><span class="list-badge-label">${n.label}</span></a></li>`)}</ul>`;
|
|
227
227
|
case 'cards':
|
|
228
|
-
return html`<ul class="cards list-cards">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'card list-card')}><span class="list-card-head">${icon(n.icon ?? 'chevron')}
|
|
228
|
+
return html`<ul class="cards list-cards">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'card list-card')}><span class="list-card-head"><span class="list-card-icon">${icon(n.icon ?? 'chevron')}</span><h3>${n.label}</h3>${badge(n)}</span>${n.description ? html`<p class="muted">${n.description}</p>` : ''}</a></li>`)}</ul>`;
|
|
229
229
|
case 'tabs':
|
|
230
230
|
return html`<nav class="list-tabs" aria-label="${label}"><ul class="rds-list">${nodes.filter((n) => n.href).map((n) => html`<li><a ${linkAttrs(n, `rds-tab${n.inTrail ? ' is-current' : ''}`)}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</a></li>`)}</ul></nav>`;
|
|
231
231
|
case 'wizard_progress': {
|
package/src/runtime/pdf.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appDateReader } from './items.ts';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
3
|
import PDFDocument from 'pdfkit';
|
|
3
4
|
import type pg from 'pg';
|
|
@@ -192,7 +193,7 @@ export async function reportPdf(ctx: PageContext, r: Region): Promise<Buffer> {
|
|
|
192
193
|
...st.filters.map((f) => `${headingOf(r, f.column, ctx.locale.tr)} ${OPERATORS[f.op]?.label ?? f.op} ${OPERATORS[f.op]?.noValue ? '' : f.value}`.trim()),
|
|
193
194
|
...(() => {
|
|
194
195
|
const defs = reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions);
|
|
195
|
-
return facetFilters(ctx.params, r.id, defs).map((f) => describeFacetFilter(f, defs.get(f.column)?.label ?? headingOf(r, f.column, ctx.locale.tr), t, defs.get(f.column)));
|
|
196
|
+
return facetFilters(ctx.params, r.id, defs, undefined, appDateReader(ctx)).map((f) => describeFacetFilter(f, defs.get(f.column)?.label ?? headingOf(r, f.column, ctx.locale.tr), t, defs.get(f.column)));
|
|
196
197
|
})(),
|
|
197
198
|
...(st.search ? [`${t('report.search')}: "${st.search}"`] : []),
|
|
198
199
|
].map((s) => printable(oneLine(s)))
|
package/src/runtime/regions.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { renderGrid } from './grid.ts';
|
|
|
21
21
|
import { masterItemOf, mastersOf } from './master-detail.ts';
|
|
22
22
|
import { renderTasks } from './tasks.ts';
|
|
23
23
|
import { renderDataReporter } from './data-reporter.ts';
|
|
24
|
+
import { renderFormDesigner } from './form-designer.ts';
|
|
24
25
|
import { renderAssistant } from './assistant.ts';
|
|
25
26
|
import { renderAiFilter } from './ai-filter.ts';
|
|
26
27
|
import { renderWorkflows } from './workflows.ts';
|
|
@@ -378,6 +379,9 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
|
|
|
378
379
|
case 'data_reporter':
|
|
379
380
|
body = await renderDataReporter(ctx, r);
|
|
380
381
|
break;
|
|
382
|
+
case 'form_designer':
|
|
383
|
+
body = await renderFormDesigner(ctx, r);
|
|
384
|
+
break;
|
|
381
385
|
case 'ai_assistant':
|
|
382
386
|
body = await renderAssistant(ctx, r);
|
|
383
387
|
break;
|
package/src/runtime/render.ts
CHANGED
|
@@ -268,10 +268,11 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
268
268
|
const navbar = navbarNodes ? navbarMarkup(navbarNodes, t('list.navbar')) : '';
|
|
269
269
|
// (sprint 72) the account's display name in the user menu
|
|
270
270
|
const shown = signedIn ? ((await displayName(ctx.user)) ?? ctx.user) : ctx.user;
|
|
271
|
+
// (sprint 88, GEN-4; APEX: the page footer) under the content: the application's version and "Back to top" (to the header, #top)
|
|
271
272
|
return documentShell(
|
|
272
273
|
`${title} · ${ctx.app.name}`,
|
|
273
274
|
html`<a class="skip-link" href="#main">${t('common.skip')}</a>
|
|
274
|
-
<header class="t-header">
|
|
275
|
+
<header class="t-header" id="top">
|
|
275
276
|
<a href="#t-nav" class="t-nav-toggle icon-button" role="button" aria-label="${t('common.toggle_nav')}" aria-controls="t-nav">${icon('menu')}</a>
|
|
276
277
|
<a class="t-logo" href="${ctx.base}/${ctx.app.home_page}">${ctx.app.name}</a>
|
|
277
278
|
<span class="t-spacer"></span>
|
|
@@ -306,7 +307,8 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
306
307
|
<div class="t-body">
|
|
307
308
|
<nav id="t-nav" class="t-nav" aria-label="Main"><ul>${nav}</ul></nav>
|
|
308
309
|
<a href="#" class="t-nav-backdrop" tabindex="-1" aria-hidden="true"></a>
|
|
309
|
-
<main class="t-main" id="main">${main}
|
|
310
|
+
<main class="t-main" id="main">${main}
|
|
311
|
+
<footer class="t-footer">${ctx.app.version ? html`<span class="t-version">${ctx.locale.tr(ctx.app.version)}</span>` : ''}<a class="t-top" href="#top">${icon('arrow-up')} ${t('common.back_to_top')}</a></footer></main>
|
|
310
312
|
</div>
|
|
311
313
|
${devbarHtml(ctx)}`,
|
|
312
314
|
`t-app${topNav ? ' nav-top' : ''}`,
|
|
@@ -338,7 +340,9 @@ const CLIENT_TEXTS = ['pwa.offline_banner', 'pwa.queued', 'pwa.queue_waiting', '
|
|
|
338
340
|
'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied',
|
|
339
341
|
'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link',
|
|
340
342
|
// (sprint 76) a report column heading's menu
|
|
341
|
-
'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values'
|
|
343
|
+
'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values',
|
|
344
|
+
// (sprint 84) the date picker
|
|
345
|
+
'item.choose_date', 'calendar.previous', 'calendar.next', 'calendar.today', 'facets.clear_short'] as const;
|
|
342
346
|
export const clientTexts = (ctx: PageContext) => Object.fromEntries(CLIENT_TEXTS.map((k) => [k, ctx.locale.t(k)]));
|
|
343
347
|
|
|
344
348
|
/**
|
package/src/runtime/report.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { esc, html, raw, type Raw } from '../html.ts';
|
|
|
8
8
|
import type { Region } from '../metadata.ts';
|
|
9
9
|
import { isAuthorized, pageAllowed } from './authz.ts';
|
|
10
10
|
import { bindValues, publicError, stripSemicolon, type PageContext } from './context.ts';
|
|
11
|
-
import { heading, splitValues } from './items.ts';
|
|
11
|
+
import { appDateReader, heading, splitValues } from './items.ts';
|
|
12
12
|
import { linkAttrs } from './links.ts';
|
|
13
13
|
import type { Translate } from '../i18n.ts';
|
|
14
14
|
import type { Formatter } from './format.ts';
|
|
@@ -421,7 +421,7 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
|
|
|
421
421
|
// the search term, facet values and range bounds are query parameters
|
|
422
422
|
const p = new SqlParams();
|
|
423
423
|
const hides = hidesColumns(r);
|
|
424
|
-
const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions));
|
|
424
|
+
const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions), undefined, appDateReader(ctx));
|
|
425
425
|
const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || (st.search && st.searchCol);
|
|
426
426
|
// column name → type oid
|
|
427
427
|
const fields = needCols ? await fieldsOf(ctx, src) : [];
|
package/src/runtime/routes.ts
CHANGED
|
@@ -360,7 +360,9 @@ function applyPostedItems(ctx: PageContext, body: Body, only?: string[]) {
|
|
|
360
360
|
}
|
|
361
361
|
if (item.type === 'daterange') {
|
|
362
362
|
// two date inputs with the item's name (or "from:to" from a dynamic action); format checked in validate()
|
|
363
|
-
|
|
363
|
+
// (sprint 84) each part typed in the date format (or YYYY-MM-DD) → YYYY-MM-DD; other text is kept, and validate() reports it
|
|
364
|
+
const mask = itemDateMask(ctx, item)!;
|
|
365
|
+
const parts = (Array.isArray(raw) ? raw : raw ? String(raw).split(':') : []).map((v) => String(v).trim()).map((v) => (v ? (parseDateMask(v, mask, ctx.locale.lang) ?? v) : v));
|
|
364
366
|
ctx.session.state[item.name] = parts.some(Boolean) ? (parts.length === 1 ? `${parts[0]}:` : parts.join(':')) : null;
|
|
365
367
|
continue;
|
|
366
368
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appDateReader } from './items.ts';
|
|
1
2
|
import { html, type Raw } from '../html.ts';
|
|
2
3
|
import { icon } from '../icons.ts';
|
|
3
4
|
import type { Region } from '../metadata.ts';
|
|
@@ -34,7 +35,7 @@ export async function renderSmartFilters(ctx: PageContext, r: Region): Promise<R
|
|
|
34
35
|
const formId = `sf${r.id}`;
|
|
35
36
|
const defs = facetDefs(r.config).filter((f) => fs.cols.has(f.column));
|
|
36
37
|
const per = Number.isInteger(r.config.suggestions) ? Math.max(0, Math.min(MAX_SUGGESTIONS, r.config.suggestions)) : 3;
|
|
37
|
-
const filters = facetFilters(ctx.params, report.id, fs.all);
|
|
38
|
+
const filters = facetFilters(ctx.params, report.id, fs.all, undefined, appDateReader(ctx));
|
|
38
39
|
const own = new Map<string, FacetFilter>(filters.filter((f) => defs.some((d) => d.column === f.column)).map((f) => [f.column, f]));
|
|
39
40
|
|
|
40
41
|
// ---- chips: the search term and each filter of this region's facets, each with a remove link
|
package/src/runtime/tasks.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Region } from '../metadata.ts';
|
|
|
6
6
|
import { logActivity, saveState } from '../session.ts';
|
|
7
7
|
import { pageAllowed } from './authz.ts';
|
|
8
8
|
import { publicError, stripSemicolon, type PageContext } from './context.ts';
|
|
9
|
+
import { appDateMask, appDateReader, dateField } from './items.ts';
|
|
9
10
|
import { linkAttrs } from './links.ts';
|
|
10
11
|
import { cell } from './report.ts';
|
|
11
12
|
import { loadContext, safeNext, txContext, type Req } from './routes.ts';
|
|
@@ -158,8 +159,8 @@ async function vacationPanel(ctx: PageContext, r: Region): Promise<Raw | ''> {
|
|
|
158
159
|
: html`<p class="muted">${t('tasks.vacation_help')}</p>
|
|
159
160
|
<div class="vacation-fields">
|
|
160
161
|
<label>${t('tasks.vacation_to')} <input name="to" form="${f}" maxlength="100" required autocomplete="off"></label>
|
|
161
|
-
<label>${t('tasks.vacation_from')}
|
|
162
|
-
<label>${t('tasks.vacation_until')}
|
|
162
|
+
<label>${t('tasks.vacation_from')} ${dateField(ctx, { id: `${f}_from`, iso: '', mask: appDateMask(ctx), attrs: html` name="from" form="${f}" required` })}</label>
|
|
163
|
+
<label>${t('tasks.vacation_until')} ${dateField(ctx, { id: `${f}_until`, iso: '', mask: appDateMask(ctx), attrs: html` name="until" form="${f}" required` })}</label>
|
|
163
164
|
<button class="btn" form="${f}" name="action" value="set">${t('tasks.vacation_save')}</button></div>`}
|
|
164
165
|
</details>`;
|
|
165
166
|
}
|
|
@@ -173,7 +174,9 @@ export async function taskRoutes(app: FastifyInstance) {
|
|
|
173
174
|
const next = safeNext(ctx.app, b.next);
|
|
174
175
|
if (b.__csrf !== ctx.session.csrf_token || !['set', 'clear'].includes(String(b.action))) return reply.redirect(next, 303);
|
|
175
176
|
const t = ctx.locale.t;
|
|
176
|
-
|
|
177
|
+
// (sprint 84) typed in the application's date format (or YYYY-MM-DD)
|
|
178
|
+
const readDate = appDateReader(ctx);
|
|
179
|
+
const date = (v: unknown) => (typeof v === 'string' && v.length <= 100 ? readDate(v) : null);
|
|
177
180
|
try {
|
|
178
181
|
await appTx(txContext(ctx), async (c) => {
|
|
179
182
|
if (b.action === 'clear') await c.query('select meta.clear_vacation_rule()');
|
package/src/sqlformat.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { skipQuoted } from './binds.ts';
|
|
2
|
+
|
|
3
|
+
// (sprint 93, Database Actions DA-9) Format SQL for the worksheet (Ctrl+F7): keywords in one case, each clause
|
|
4
|
+
// of a query on its own line (SELECT, FROM, WHERE, GROUP BY, …), joins indented, AND / OR of a condition and the
|
|
5
|
+
// columns of a select list one per line, subqueries indented. Strings, quoted identifiers, dollar-quoted bodies
|
|
6
|
+
// and comments are kept as they are; everything else is re-spaced. It is a layout aid, not a parser: SQL it
|
|
7
|
+
// doesn't understand keeps its words and order.
|
|
8
|
+
|
|
9
|
+
const KEYWORDS = new Set(`
|
|
10
|
+
select from where and or not in is null like ilike between exists as on join inner left right full outer cross natural
|
|
11
|
+
using group by order having limit offset fetch first next rows row only union all intersect except distinct case when
|
|
12
|
+
then else end insert into values update set delete returning with recursive create table view materialized index
|
|
13
|
+
replace drop alter add column constraint primary key foreign references unique check default cascade restrict
|
|
14
|
+
truncate grant revoke to begin commit rollback do language function procedure returns return true false asc desc
|
|
15
|
+
nulls last over partition window filter lateral any some array cast interval if conflict nothing merge matched
|
|
16
|
+
explain analyze schema sequence trigger policy for each execute`.trim().split(/\s+/));
|
|
17
|
+
|
|
18
|
+
/** Clauses that start a line in a query; two-word ones are matched as their first word plus the next. */
|
|
19
|
+
const CLAUSES = new Set(['select', 'from', 'where', 'having', 'limit', 'offset', 'fetch', 'union', 'intersect', 'except', 'window', 'returning', 'values', 'set', 'with', 'insert', 'update', 'delete', 'on conflict']);
|
|
20
|
+
const TWO_WORD = new Set(['group by', 'order by', 'partition by', 'on conflict', 'union all']);
|
|
21
|
+
const JOIN_START = new Set(['join', 'inner', 'left', 'right', 'full', 'cross', 'natural']);
|
|
22
|
+
|
|
23
|
+
type Tok = { t: 'ws' | 'word' | 'quoted' | 'comment' | 'punct' | 'num'; v: string };
|
|
24
|
+
|
|
25
|
+
function tokens(sql: string): Tok[] {
|
|
26
|
+
const out: Tok[] = [];
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < sql.length) {
|
|
29
|
+
const c = sql[i];
|
|
30
|
+
if (/\s/.test(c)) {
|
|
31
|
+
let j = i + 1;
|
|
32
|
+
while (j < sql.length && /\s/.test(sql[j])) j++;
|
|
33
|
+
out.push({ t: 'ws', v: sql.slice(i, j) });
|
|
34
|
+
i = j;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const prev = i > 0 ? sql[i - 1] : '';
|
|
38
|
+
if (!(c === '$' && /[A-Za-z0-9_]/.test(prev))) {
|
|
39
|
+
const end = skipQuoted(sql, i);
|
|
40
|
+
if (end !== null && end > i) {
|
|
41
|
+
out.push({ t: c === '-' || c === '/' ? 'comment' : 'quoted', v: sql.slice(i, end) });
|
|
42
|
+
i = end;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (/[A-Za-z_\u0080-]/.test(c)) {
|
|
47
|
+
let j = i + 1;
|
|
48
|
+
while (j < sql.length && /[A-Za-z0-9_$\u0080-]/.test(sql[j])) j++;
|
|
49
|
+
// E'…' strings: the E belongs to the string
|
|
50
|
+
if ((sql[j] === "'") && j === i + 1 && /[eE]/.test(c)) {
|
|
51
|
+
const end = skipQuoted(sql, j)!;
|
|
52
|
+
out.push({ t: 'quoted', v: sql.slice(i, end) });
|
|
53
|
+
i = end;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out.push({ t: 'word', v: sql.slice(i, j) });
|
|
57
|
+
i = j;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (/[0-9]/.test(c)) {
|
|
61
|
+
let j = i + 1;
|
|
62
|
+
while (j < sql.length && /[0-9.eE_]/.test(sql[j])) j++;
|
|
63
|
+
out.push({ t: 'num', v: sql.slice(i, j) });
|
|
64
|
+
i = j;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// $1 parameters
|
|
68
|
+
const param = c === '$' ? /^\$\d+/.exec(sql.slice(i, i + 12)) : null;
|
|
69
|
+
if (param) {
|
|
70
|
+
out.push({ t: 'num', v: param[0] });
|
|
71
|
+
i += param[0].length;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// operators of several characters stay together (::, <=, <>, ->>, ||, :=)
|
|
75
|
+
const op = /^(::|->>|->|#>>|#>|<=|>=|<>|!=|\|\||:=|=>)/.exec(sql.slice(i, i + 3));
|
|
76
|
+
out.push({ t: 'punct', v: op ? op[0] : c });
|
|
77
|
+
i += op ? op[0].length : 1;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatSql(sql: string, keywordCase: 'upper' | 'lower' = 'lower'): string {
|
|
83
|
+
const toks = tokens(sql).filter((t) => t.t !== 'ws');
|
|
84
|
+
const kw = (w: string) => (keywordCase === 'upper' ? w.toUpperCase() : w.toLowerCase());
|
|
85
|
+
interface Frame { query: boolean; base: number; clause: string; between: boolean }
|
|
86
|
+
const frames: Frame[] = [{ query: true, base: 0, clause: '', between: false }];
|
|
87
|
+
let out = '';
|
|
88
|
+
let line = ''; // the current line
|
|
89
|
+
let glue = false; // the next token sticks to the last one (a unary minus)
|
|
90
|
+
const flush = () => {
|
|
91
|
+
out += `${line.replace(/\s+$/, '')}\n`;
|
|
92
|
+
line = '';
|
|
93
|
+
};
|
|
94
|
+
const newline = (indent: number) => {
|
|
95
|
+
if (line.trim()) flush();
|
|
96
|
+
line = ' '.repeat(Math.max(0, indent));
|
|
97
|
+
};
|
|
98
|
+
const put = (s: string, space = true) => {
|
|
99
|
+
if (glue) {
|
|
100
|
+
glue = false;
|
|
101
|
+
line += s;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (space && line.trim() && !/[\s(]$/.test(line) && !/^[,).;:[\]]/.test(s) && !line.endsWith('::') && !line.endsWith('.')) line += ' ';
|
|
105
|
+
else if (!line.trim() && line.length === 0) line = ' '.repeat(frames[frames.length - 1].base);
|
|
106
|
+
line += s;
|
|
107
|
+
};
|
|
108
|
+
for (let k = 0; k < toks.length; k++) {
|
|
109
|
+
const t = toks[k];
|
|
110
|
+
const f = frames[frames.length - 1];
|
|
111
|
+
const lower = t.v.toLowerCase();
|
|
112
|
+
if (t.t === 'comment') {
|
|
113
|
+
put(t.v);
|
|
114
|
+
if (t.v.startsWith('--')) newline(f.base);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (t.t === 'word' && KEYWORDS.has(lower)) {
|
|
118
|
+
const nextWord = toks.slice(k + 1).find((x) => x.t !== 'comment');
|
|
119
|
+
const two = nextWord && nextWord.t === 'word' ? `${lower} ${nextWord.v.toLowerCase()}` : '';
|
|
120
|
+
if (f.query && TWO_WORD.has(two) && two !== 'partition by') {
|
|
121
|
+
newline(f.base);
|
|
122
|
+
put(`${kw(lower)} ${kw(nextWord!.v.toLowerCase())}`);
|
|
123
|
+
f.clause = two;
|
|
124
|
+
k = toks.indexOf(nextWord!);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (f.query && CLAUSES.has(lower) && !(lower === 'set' && f.clause !== 'update') && !(lower === 'values' && f.clause === 'values')) {
|
|
128
|
+
if ((out || line.trim()) && !line.endsWith('(')) newline(f.base);
|
|
129
|
+
put(kw(lower));
|
|
130
|
+
f.clause = lower;
|
|
131
|
+
f.between = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (f.query && JOIN_START.has(lower)) {
|
|
135
|
+
const prevWord = [...toks.slice(0, k)].reverse().find((x) => x.t !== 'comment');
|
|
136
|
+
const afterJoinWord = prevWord?.t === 'word' && /^(inner|left|right|full|cross|natural|outer)$/i.test(prevWord.v);
|
|
137
|
+
// left( and right( are functions; JOIN right after INNER / LEFT … stays on its line
|
|
138
|
+
const starts = lower === 'join' ? !afterJoinWord : !!nextWord && /^(join|outer|inner)$/i.test(nextWord.v);
|
|
139
|
+
if (starts && !afterJoinWord) {
|
|
140
|
+
newline(f.base + 2);
|
|
141
|
+
put(kw(lower));
|
|
142
|
+
f.clause = 'from';
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (f.query && (lower === 'and' || lower === 'or') && ['where', 'having', 'from'].includes(f.clause)) {
|
|
147
|
+
if (lower === 'and' && f.between) {
|
|
148
|
+
f.between = false;
|
|
149
|
+
put(kw(lower));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
newline(f.base + (f.clause === 'from' ? 4 : 2) + (lower === 'and' ? 1 : 2));
|
|
153
|
+
put(kw(lower));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (lower === 'between') f.between = true;
|
|
157
|
+
put(kw(lower));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (t.t === 'punct') {
|
|
161
|
+
if (t.v === '(') {
|
|
162
|
+
const next = toks.slice(k + 1).find((x) => x.t !== 'comment');
|
|
163
|
+
const sub = !!next && next.t === 'word' && /^(select|with|values)$/i.test(next.v);
|
|
164
|
+
const prevTok = toks[k - 1];
|
|
165
|
+
const before = toks[k - 2];
|
|
166
|
+
const named = before?.t === 'word' && /^(into|table|view|index|on|using|references|function|procedure|type)$/i.test(before.v);
|
|
167
|
+
const fn = !!prevTok && (prevTok.t === 'word' || prevTok.t === 'quoted') && !named
|
|
168
|
+
&& (!KEYWORDS.has(prevTok.v.toLowerCase()) || /^(left|right|replace|cast|any|some)$/i.test(prevTok.v));
|
|
169
|
+
put('(', !fn);
|
|
170
|
+
frames.push({ query: sub, base: sub ? line.length : f.base, clause: '', between: false });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (t.v === ')') {
|
|
174
|
+
if (frames.length > 1) frames.pop();
|
|
175
|
+
put(')', false);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (t.v === ',') {
|
|
179
|
+
put(',', false);
|
|
180
|
+
if (f.query && f.clause === 'select') newline(f.base + 7);
|
|
181
|
+
else if (f.query && (f.clause === 'group by' || f.clause === 'order by')) newline(f.base + 9);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (t.v === ';') {
|
|
185
|
+
put(';', false);
|
|
186
|
+
flush();
|
|
187
|
+
out += '\n';
|
|
188
|
+
frames.splice(1);
|
|
189
|
+
frames[0].clause = '';
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (t.v === '.' || t.v === '::') {
|
|
193
|
+
line += t.v;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const prevTok = toks[k - 1];
|
|
197
|
+
const unary = (t.v === '-' || t.v === '+') && (!prevTok || prevTok.t === 'punct' && prevTok.v !== ')' || (prevTok.t === 'word' && KEYWORDS.has(prevTok.v.toLowerCase())));
|
|
198
|
+
put(t.v);
|
|
199
|
+
if (unary) glue = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// a word after "." or "::" sticks to it
|
|
203
|
+
if (line.endsWith('.') || line.endsWith('::')) line += t.v;
|
|
204
|
+
else put(t.v);
|
|
205
|
+
}
|
|
206
|
+
if (line.trim()) flush();
|
|
207
|
+
return out.replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + (sql.trimEnd().endsWith(';') ? '\n' : '');
|
|
208
|
+
}
|