@svgrid/enterprise 3.1.7 → 3.1.9
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/README.md +1 -0
- package/dist/SvAlertRuleEditor.svelte +16 -5
- package/dist/SvGridAlerts.svelte +8 -0
- package/dist/SvGridAlerts.svelte.d.ts +4 -0
- package/dist/SvGridBoard.svelte +69 -10
- package/dist/SvGridBulkEditDrawer.svelte +13 -5
- package/dist/SvGridGallery.svelte +286 -0
- package/dist/SvGridGallery.svelte.d.ts +30 -0
- package/dist/SvGridScheduler.svelte +33 -11
- package/dist/SvGridSelectionBar.svelte +47 -0
- package/dist/advanced-filter/expr-columns-from-grid.d.ts +1 -0
- package/dist/advanced-filter/expr-columns-from-grid.js +11 -7
- package/dist/advanced-filter-enable.d.ts +6 -0
- package/dist/advanced-filter-enable.js +8 -0
- package/dist/alerts/alert-engine.d.ts +7 -0
- package/dist/alerts/alert-engine.js +5 -2
- package/dist/alerts/condition-editor.d.ts +26 -0
- package/dist/alerts/condition-editor.js +17 -0
- package/dist/base/index.d.ts +20 -0
- package/dist/base/index.js +20 -0
- package/dist/bulk-edit.d.ts +11 -0
- package/dist/bulk-edit.js +18 -1
- package/dist/cdn/svgrid-enterprise.svelte-external.js +11375 -10805
- package/dist/designer/assets/GridFooter-Cu8_yBlQ.js +2 -0
- package/dist/designer/assets/GridMenus-BUjkkAU2.js +6 -0
- package/dist/designer/assets/PinSplitter-BgTpgh1d.css +1 -0
- package/dist/designer/assets/PinSplitter-C5AwA1xV.js +1 -0
- package/dist/designer/assets/SvGrid.helpers-CIrmIo9U.js +1 -0
- package/dist/designer/assets/SvGridCellEditor-CJxThKL_.js +4 -0
- package/dist/designer/assets/SvListBox-CS7oUAiX.js +2 -0
- package/dist/designer/assets/conditional-formatting-MDWbF8_Y.js +1 -0
- package/dist/designer/assets/grid-extension.svelte-BZ7AqyJI.js +1 -0
- package/dist/designer/assets/{index-DtBIaQno.js → index-BPU2KNc5.js} +396 -395
- package/dist/designer/assets/index-BmvG99kR.css +1 -0
- package/dist/designer/assets/sparkline-C-NcnB2i.js +1 -0
- package/dist/designer/assets/src-C_U1NKOY.js +58 -0
- package/dist/designer/assets/sv-grid-scrollbar-B9N6Wuj0.js +109 -0
- package/dist/designer/assets/validate-H5u2HEQh.js +1 -0
- package/dist/designer/index.html +11 -7
- package/dist/expressions/compile.js +10 -0
- package/dist/expressions/evaluate.js +18 -0
- package/dist/expressions/expression-types.d.ts +10 -0
- package/dist/expressions/kinds.d.ts +28 -0
- package/dist/expressions/kinds.js +19 -0
- package/dist/expressions/parse.js +9 -1
- package/dist/fields/AttachmentCell.svelte +242 -0
- package/dist/fields/AttachmentCell.svelte.d.ts +5 -0
- package/dist/fields/AttachmentDialog.svelte +357 -0
- package/dist/fields/AttachmentDialog.svelte.d.ts +13 -0
- package/dist/fields/AttachmentEditor.svelte +41 -0
- package/dist/fields/AttachmentEditor.svelte.d.ts +11 -0
- package/dist/fields/AttachmentPreview.svelte +72 -0
- package/dist/fields/AttachmentPreview.svelte.d.ts +8 -0
- package/dist/fields/BarcodeCell.svelte +20 -0
- package/dist/fields/BarcodeCell.svelte.d.ts +5 -0
- package/dist/fields/BaseAutomations.svelte +558 -0
- package/dist/fields/BaseAutomations.svelte.d.ts +18 -0
- package/dist/fields/BaseChartView.svelte +47 -0
- package/dist/fields/BaseChartView.svelte.d.ts +8 -0
- package/dist/fields/BaseTableView.svelte +185 -0
- package/dist/fields/BaseTableView.svelte.d.ts +37 -0
- package/dist/fields/BaseTabs.svelte +308 -0
- package/dist/fields/BaseTabs.svelte.d.ts +21 -0
- package/dist/fields/ButtonCell.svelte +47 -0
- package/dist/fields/ButtonCell.svelte.d.ts +20 -0
- package/dist/fields/CheckboxCell.svelte +46 -0
- package/dist/fields/CheckboxCell.svelte.d.ts +14 -0
- package/dist/fields/DeleteFieldConfirm.svelte +80 -0
- package/dist/fields/DeleteFieldConfirm.svelte.d.ts +8 -0
- package/dist/fields/DeleteRecordConfirm.svelte +48 -0
- package/dist/fields/DeleteRecordConfirm.svelte.d.ts +9 -0
- package/dist/fields/FieldCell.svelte +80 -0
- package/dist/fields/FieldCell.svelte.d.ts +9 -0
- package/dist/fields/FieldEditor.svelte +321 -0
- package/dist/fields/FieldEditor.svelte.d.ts +10 -0
- package/dist/fields/FieldInput.svelte +188 -0
- package/dist/fields/FieldInput.svelte.d.ts +29 -0
- package/dist/fields/FieldsHeaderEnd.svelte +146 -0
- package/dist/fields/FieldsHeaderEnd.svelte.d.ts +6 -0
- package/dist/fields/FootRowAdder.svelte +8 -0
- package/dist/fields/FootRowAdder.svelte.d.ts +6 -0
- package/dist/fields/GroupHeader.svelte +127 -0
- package/dist/fields/GroupHeader.svelte.d.ts +24 -0
- package/dist/fields/LinkCell.svelte +46 -0
- package/dist/fields/LinkCell.svelte.d.ts +9 -0
- package/dist/fields/LinkPickerEditor.svelte +172 -0
- package/dist/fields/LinkPickerEditor.svelte.d.ts +9 -0
- package/dist/fields/LinkPreview.svelte +60 -0
- package/dist/fields/LinkPreview.svelte.d.ts +7 -0
- package/dist/fields/LinkedRecordView.svelte +94 -0
- package/dist/fields/LinkedRecordView.svelte.d.ts +9 -0
- package/dist/fields/LinkedRecordsCell.svelte +59 -0
- package/dist/fields/LinkedRecordsCell.svelte.d.ts +11 -0
- package/dist/fields/LongTextEditor.svelte +157 -0
- package/dist/fields/LongTextEditor.svelte.d.ts +11 -0
- package/dist/fields/NumberEditor.svelte +309 -0
- package/dist/fields/NumberEditor.svelte.d.ts +12 -0
- package/dist/fields/OptionsEditor.svelte +117 -0
- package/dist/fields/OptionsEditor.svelte.d.ts +11 -0
- package/dist/fields/PercentCell.svelte +21 -0
- package/dist/fields/PercentCell.svelte.d.ts +8 -0
- package/dist/fields/PropertyInput.svelte +204 -0
- package/dist/fields/PropertyInput.svelte.d.ts +11 -0
- package/dist/fields/RatingCell.svelte +75 -0
- package/dist/fields/RatingCell.svelte.d.ts +16 -0
- package/dist/fields/RatingEditor.svelte +82 -0
- package/dist/fields/RatingEditor.svelte.d.ts +9 -0
- package/dist/fields/RecordView.svelte +310 -0
- package/dist/fields/RecordView.svelte.d.ts +11 -0
- package/dist/fields/RichTextCell.svelte +30 -0
- package/dist/fields/RichTextCell.svelte.d.ts +5 -0
- package/dist/fields/RowAdder.svelte +93 -0
- package/dist/fields/RowAdder.svelte.d.ts +7 -0
- package/dist/fields/RowHeader.svelte +99 -0
- package/dist/fields/RowHeader.svelte.d.ts +11 -0
- package/dist/fields/SelectCell.svelte +35 -0
- package/dist/fields/SelectCell.svelte.d.ts +15 -0
- package/dist/fields/SelectEditor.svelte +350 -0
- package/dist/fields/SelectEditor.svelte.d.ts +17 -0
- package/dist/fields/SummaryCell.svelte +160 -0
- package/dist/fields/SummaryCell.svelte.d.ts +11 -0
- package/dist/fields/SvBase.svelte +545 -0
- package/dist/fields/SvBase.svelte.d.ts +30 -0
- package/dist/fields/SvBaseForm.svelte +295 -0
- package/dist/fields/SvBaseForm.svelte.d.ts +22 -0
- package/dist/fields/SvBaseViewBar.svelte +644 -0
- package/dist/fields/SvBaseViewBar.svelte.d.ts +26 -0
- package/dist/fields/UserCell.svelte +39 -0
- package/dist/fields/UserCell.svelte.d.ts +9 -0
- package/dist/fields/UserFace.svelte +30 -0
- package/dist/fields/UserFace.svelte.d.ts +12 -0
- package/dist/fields/activity.svelte.d.ts +50 -0
- package/dist/fields/activity.svelte.js +110 -0
- package/dist/fields/attachments.d.ts +95 -0
- package/dist/fields/attachments.js +145 -0
- package/dist/fields/automation-outbound.d.ts +46 -0
- package/dist/fields/automation-outbound.js +61 -0
- package/dist/fields/automation-server.d.ts +53 -0
- package/dist/fields/automation-server.js +333 -0
- package/dist/fields/automations.svelte.d.ts +208 -0
- package/dist/fields/automations.svelte.js +437 -0
- package/dist/fields/bar-request.svelte.d.ts +16 -0
- package/dist/fields/bar-request.svelte.js +15 -0
- package/dist/fields/base-alerts.d.ts +24 -0
- package/dist/fields/base-alerts.js +64 -0
- package/dist/fields/base-connect.d.ts +82 -0
- package/dist/fields/base-connect.js +271 -0
- package/dist/fields/base-schema.d.ts +104 -0
- package/dist/fields/base-schema.js +266 -0
- package/dist/fields/base-sql.d.ts +62 -0
- package/dist/fields/base-sql.js +251 -0
- package/dist/fields/base-sync.d.ts +142 -0
- package/dist/fields/base-sync.js +499 -0
- package/dist/fields/base-tables.d.ts +89 -0
- package/dist/fields/base-tables.js +349 -0
- package/dist/fields/builtin-types.d.ts +13 -0
- package/dist/fields/builtin-types.js +773 -0
- package/dist/fields/button.d.ts +2 -0
- package/dist/fields/button.js +17 -0
- package/dist/fields/chart-view.d.ts +35 -0
- package/dist/fields/chart-view.js +154 -0
- package/dist/fields/column-order.d.ts +6 -0
- package/dist/fields/column-order.js +12 -0
- package/dist/fields/convert.d.ts +16 -0
- package/dist/fields/convert.js +176 -0
- package/dist/fields/copy-text.d.ts +13 -0
- package/dist/fields/copy-text.js +45 -0
- package/dist/fields/editor-request.svelte.d.ts +71 -0
- package/dist/fields/editor-request.svelte.js +52 -0
- package/dist/fields/field-ops.d.ts +72 -0
- package/dist/fields/field-ops.js +334 -0
- package/dist/fields/filter-model.d.ts +84 -0
- package/dist/fields/filter-model.js +446 -0
- package/dist/fields/format.d.ts +109 -0
- package/dist/fields/format.js +288 -0
- package/dist/fields/formula.d.ts +50 -0
- package/dist/fields/formula.js +530 -0
- package/dist/fields/grid-access.d.ts +60 -0
- package/dist/fields/grid-access.js +120 -0
- package/dist/fields/index.d.ts +34 -0
- package/dist/fields/index.js +312 -0
- package/dist/fields/linked-fields.d.ts +8 -0
- package/dist/fields/linked-fields.js +40 -0
- package/dist/fields/links.d.ts +10 -0
- package/dist/fields/links.js +46 -0
- package/dist/fields/name-list.d.ts +16 -0
- package/dist/fields/name-list.js +49 -0
- package/dist/fields/palette.d.ts +47 -0
- package/dist/fields/palette.js +69 -0
- package/dist/fields/record-delete.svelte.d.ts +12 -0
- package/dist/fields/record-delete.svelte.js +5 -0
- package/dist/fields/record-menu.d.ts +9 -0
- package/dist/fields/record-menu.js +78 -0
- package/dist/fields/registry.svelte.d.ts +176 -0
- package/dist/fields/registry.svelte.js +51 -0
- package/dist/fields/rich-text.d.ts +23 -0
- package/dist/fields/rich-text.js +48 -0
- package/dist/fields/row-color-choice.svelte.d.ts +31 -0
- package/dist/fields/row-color-choice.svelte.js +25 -0
- package/dist/fields/row-colors.d.ts +36 -0
- package/dist/fields/row-colors.js +90 -0
- package/dist/fields/row-height.d.ts +28 -0
- package/dist/fields/row-height.js +50 -0
- package/dist/fields/sources.svelte.d.ts +80 -0
- package/dist/fields/sources.svelte.js +131 -0
- package/dist/fields/summaries.d.ts +18 -0
- package/dist/fields/summaries.js +138 -0
- package/dist/fields/view-model.d.ts +129 -0
- package/dist/fields/view-model.js +492 -0
- package/dist/fields/viewbar/ChartPanel.svelte +61 -0
- package/dist/fields/viewbar/ChartPanel.svelte.d.ts +9 -0
- package/dist/fields/viewbar/ColorPanel.svelte +112 -0
- package/dist/fields/viewbar/ColorPanel.svelte.d.ts +9 -0
- package/dist/fields/viewbar/ConditionValue.svelte +153 -0
- package/dist/fields/viewbar/ConditionValue.svelte.d.ts +15 -0
- package/dist/fields/viewbar/FieldIcon.svelte +18 -0
- package/dist/fields/viewbar/FieldIcon.svelte.d.ts +9 -0
- package/dist/fields/viewbar/FilterPanel.svelte +192 -0
- package/dist/fields/viewbar/FilterPanel.svelte.d.ts +13 -0
- package/dist/fields/viewbar/GanttPanel.svelte +60 -0
- package/dist/fields/viewbar/GanttPanel.svelte.d.ts +10 -0
- package/dist/fields/viewbar/HideFieldsPanel.svelte +104 -0
- package/dist/fields/viewbar/HideFieldsPanel.svelte.d.ts +7 -0
- package/dist/fields/viewbar/SortPanel.svelte +85 -0
- package/dist/fields/viewbar/SortPanel.svelte.d.ts +8 -0
- package/dist/fields/viewbar/ViewsMenu.svelte +250 -0
- package/dist/fields/viewbar/ViewsMenu.svelte.d.ts +15 -0
- package/dist/fields/viewbar/panel-focus.d.ts +3 -0
- package/dist/fields/viewbar/panel-focus.js +22 -0
- package/dist/fields/viewbar/sort-state.d.ts +20 -0
- package/dist/fields/viewbar/sort-state.js +24 -0
- package/dist/gallery.d.ts +7 -0
- package/dist/gallery.js +36 -0
- package/dist/gantt/SvGridGantt.svelte +88 -16
- package/dist/gantt/gantt-config.d.ts +28 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/install.js +3 -0
- package/dist/license-core.d.ts +3 -3
- package/dist/license.js +1 -0
- package/dist/node/studio.js +141 -20
- package/dist/scheduler-config.d.ts +11 -0
- package/dist/sources/introspect-supabase.js +4 -1
- package/dist/sources/rest.js +6 -2
- package/dist/studio/emit-project.js +25 -6
- package/dist/studio/sveltekit3.js +13 -7
- package/dist/studio/ui-components.generated.js +94 -2
- package/dist/upgrade-prompt.d.ts +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/wc/GridFooter-f5K0aLe_.js +145 -0
- package/dist/wc/GridMenus-FtSN_8t9.js +1369 -0
- package/dist/wc/PinSplitter-D-qtDhSM.js +132 -0
- package/dist/wc/{SvGrid.helpers-zBkDTryR.js → SvGrid.helpers-B2GEKrxS.js} +84 -36
- package/dist/wc/{SvGridCellEditor-CzUKEbkI.js → SvGridCellEditor-B_cLJrsp.js} +20 -9
- package/dist/wc/conditional-formatting-BXis_oP1.js +189 -0
- package/dist/wc/grid-extension.svelte-Bufq4B4C.js +20 -0
- package/dist/wc/sv-sheet-element.js +7561 -7618
- package/dist/wc/svelte-virtualizer.svelte-ehwOupl-.js +360 -0
- package/dist/wc/{validate-43C03DLP.js → validate-BaMO8UPE.js} +1 -1
- package/package.json +14 -3
- package/dist/designer/assets/GridMenus-CvcASlKv.js +0 -8
- package/dist/designer/assets/SvGrid.helpers-DE5mmORW.js +0 -1
- package/dist/designer/assets/SvGridCellEditor-C7NWcnNC.js +0 -4
- package/dist/designer/assets/SvListBox-BlmmzYcU.js +0 -110
- package/dist/designer/assets/index-KmSEY7Lb.css +0 -1
- package/dist/designer/assets/src-DqHCqori.js +0 -58
- package/dist/designer/assets/validate-B71dE1Cs.js +0 -1
- package/dist/wc/GridMenus-fWbBUiRH.js +0 -1088
- package/dist/wc/svelte-virtualizer.svelte-Dntdh8Ms.js +0 -581
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* User, created-by and last-modified-by cells: an avatar (the picture, or
|
|
4
|
+
* initials on a color from the name) and the name. Several users show as
|
|
5
|
+
* overlapping avatars with the first name.
|
|
6
|
+
*/
|
|
7
|
+
import type { FieldCellProps } from './registry.svelte'
|
|
8
|
+
import { findFieldUser, type FieldUser } from './sources.svelte'
|
|
9
|
+
import UserFace from './UserFace.svelte'
|
|
10
|
+
|
|
11
|
+
let { value }: FieldCellProps = $props()
|
|
12
|
+
|
|
13
|
+
const list = $derived(
|
|
14
|
+
(Array.isArray(value) ? value : value == null || value === '' ? [] : [value])
|
|
15
|
+
.map((v) => (v && typeof v === 'object' ? (v as FieldUser) : findFieldUser(v) ?? { id: String(v), name: String(v) }))
|
|
16
|
+
.filter((u) => !!u),
|
|
17
|
+
)
|
|
18
|
+
const label = $derived(list.map((u) => u.name).join(', '))
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<span class="sv-fus" title={label} aria-label={label || 'Nobody'}>
|
|
22
|
+
<span class="sv-fus-faces">
|
|
23
|
+
{#each list.slice(0, 4) as u (u.id)}
|
|
24
|
+
<UserFace user={u} />
|
|
25
|
+
{/each}
|
|
26
|
+
</span>
|
|
27
|
+
{#if list.length === 1}
|
|
28
|
+
<span class="sv-fus-name">{list[0]!.name}</span>
|
|
29
|
+
{:else if list.length > 1}
|
|
30
|
+
<span class="sv-fus-name">{list[0]!.name} +{list.length - 1}</span>
|
|
31
|
+
{/if}
|
|
32
|
+
</span>
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
.sv-fus { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
|
|
36
|
+
.sv-fus-faces { display: inline-flex; flex: none; }
|
|
37
|
+
.sv-fus-faces > :global(* + *) { margin-inline-start: -6px; }
|
|
38
|
+
.sv-fus-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User, created-by and last-modified-by cells: an avatar (the picture, or
|
|
3
|
+
* initials on a color from the name) and the name. Several users show as
|
|
4
|
+
* overlapping avatars with the first name.
|
|
5
|
+
*/
|
|
6
|
+
import type { FieldCellProps } from './registry.svelte.js';
|
|
7
|
+
declare const UserCell: import("svelte").Component<FieldCellProps, {}, "">;
|
|
8
|
+
type UserCell = ReturnType<typeof UserCell>;
|
|
9
|
+
export default UserCell;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A person's avatar as user cells and the user editor draw it: the picture,
|
|
4
|
+
* or initials on a color from the name.
|
|
5
|
+
*/
|
|
6
|
+
import type { FieldUser } from './sources.svelte'
|
|
7
|
+
import { safeUrl } from './attachments'
|
|
8
|
+
|
|
9
|
+
let { user, size = 20 }: { user: FieldUser; size?: number } = $props()
|
|
10
|
+
|
|
11
|
+
const initials = (name: string) =>
|
|
12
|
+
name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]!.toUpperCase()).join('') || '?'
|
|
13
|
+
const hue = (s: string) => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7)
|
|
14
|
+
const src = $derived(user.avatarUrl ? safeUrl(user.avatarUrl, true) : null)
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
{#if src}
|
|
18
|
+
<img class="sv-fuf" {src} alt="" width={size} height={size} loading="lazy" decoding="async" style:--s={`${size}px`} />
|
|
19
|
+
{:else}
|
|
20
|
+
<span class="sv-fuf" style:--s={`${size}px`} style:background={`hsl(${hue(user.name)} 55% 46%)`} aria-hidden="true">{initials(user.name)}</span>
|
|
21
|
+
{/if}
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
.sv-fuf {
|
|
25
|
+
display: inline-flex; flex: none; align-items: center; justify-content: center;
|
|
26
|
+
width: var(--s); height: var(--s); border-radius: 999px; object-fit: cover;
|
|
27
|
+
color: #fff; font-size: calc(var(--s) * 0.45); font-weight: 600;
|
|
28
|
+
box-shadow: 0 0 0 2px var(--sg-bg, #fff);
|
|
29
|
+
}
|
|
30
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's avatar as user cells and the user editor draw it: the picture,
|
|
3
|
+
* or initials on a color from the name.
|
|
4
|
+
*/
|
|
5
|
+
import type { FieldUser } from './sources.svelte.js';
|
|
6
|
+
type $$ComponentProps = {
|
|
7
|
+
user: FieldUser;
|
|
8
|
+
size?: number;
|
|
9
|
+
};
|
|
10
|
+
declare const UserFace: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type UserFace = ReturnType<typeof UserFace>;
|
|
12
|
+
export default UserFace;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type RecordComment = {
|
|
2
|
+
id: string;
|
|
3
|
+
rowId: string;
|
|
4
|
+
/** The table of `<SvBase tables>` the record is in. */
|
|
5
|
+
tableId?: string;
|
|
6
|
+
text: string;
|
|
7
|
+
at: number;
|
|
8
|
+
by?: string;
|
|
9
|
+
};
|
|
10
|
+
/** Which record a comment store is asked about, beyond its row id. */
|
|
11
|
+
export type RecordCommentContext = {
|
|
12
|
+
/** The table of `<SvBase tables>` the record is in; undefined for a single table or a plain grid. */
|
|
13
|
+
tableId?: string;
|
|
14
|
+
/** The grid's api. */
|
|
15
|
+
api: unknown;
|
|
16
|
+
};
|
|
17
|
+
/** Where comments are kept: the app's backend, or memory by default. */
|
|
18
|
+
export type RecordCommentStore = {
|
|
19
|
+
list: (rowId: string, api: unknown, context: RecordCommentContext) => ReadonlyArray<RecordComment>;
|
|
20
|
+
add: (rowId: string, comment: RecordComment, api: unknown, context: RecordCommentContext) => void;
|
|
21
|
+
};
|
|
22
|
+
export type RecordChange = {
|
|
23
|
+
at: number;
|
|
24
|
+
/** The signed-in user who made it; undefined when nobody was signed in. */
|
|
25
|
+
by?: string;
|
|
26
|
+
/** The automation that made it, by name. */
|
|
27
|
+
automation?: string;
|
|
28
|
+
columnId: string;
|
|
29
|
+
value: unknown;
|
|
30
|
+
};
|
|
31
|
+
/** A grid, or a table of a base named by id (an automation's change). */
|
|
32
|
+
export type ActivityScope = object | string | {
|
|
33
|
+
base?: object;
|
|
34
|
+
tableId: string;
|
|
35
|
+
};
|
|
36
|
+
/** The table id a grid of `<SvBase tables>` carries; undefined for any other grid. */
|
|
37
|
+
export declare function tableIdOf(ctrl: any): string | undefined;
|
|
38
|
+
/** Keep comments in the app's store (null: back to memory). */
|
|
39
|
+
export declare function setRecordComments(next: RecordCommentStore | null): void;
|
|
40
|
+
/**
|
|
41
|
+
* Note a change to a cell (called after every edit, paste, fill and clear).
|
|
42
|
+
* `scope` is the grid, or the table (its base and id) for a change made
|
|
43
|
+
* outside it (an automation). `automation` names the automation that made it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function noteChange(scope: ActivityScope, rowId: string, columnId: string, value: unknown, automation?: string): void;
|
|
46
|
+
export declare function changesOf(scope: ActivityScope, rowId: string): ReadonlyArray<RecordChange>;
|
|
47
|
+
export declare function commentsOf(ctrl: any, rowId: string): ReadonlyArray<RecordComment>;
|
|
48
|
+
export declare function addComment(ctrl: any, rowId: string, text: string): void;
|
|
49
|
+
/** "just now", "5 minutes ago", "3 hours ago", else the date. */
|
|
50
|
+
export declare function timeAgo(at: number, now?: number): string;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A record's activity, as the expanded record shows it: the changes made to
|
|
3
|
+
* it in this session (who changed which field to what, and when) and its
|
|
4
|
+
* comments. Comments live in memory unless the app hands over a store of its
|
|
5
|
+
* own (`setRecordComments`) that saves them.
|
|
6
|
+
*
|
|
7
|
+
* In a grid of `<SvBase tables>` both are kept by table id and row id, so
|
|
8
|
+
* they outlive the grid (switching tables draws a new one) and two tables
|
|
9
|
+
* with the same row ids keep apart. A plain grid keeps them per grid.
|
|
10
|
+
*/
|
|
11
|
+
import { getCurrentUser } from './sources.svelte.js';
|
|
12
|
+
// Bumped on every change so the panel redraws.
|
|
13
|
+
let version = $state(0);
|
|
14
|
+
const changes = { bases: new WeakMap(), grids: new WeakMap() };
|
|
15
|
+
const memory = { bases: new WeakMap(), grids: new WeakMap() };
|
|
16
|
+
// The base of a table named by id alone.
|
|
17
|
+
const ANY_BASE = {};
|
|
18
|
+
let store = null;
|
|
19
|
+
/** The table id a grid of `<SvBase tables>` carries; undefined for any other grid. */
|
|
20
|
+
export function tableIdOf(ctrl) {
|
|
21
|
+
const id = ctrl?.props?.baseTableId;
|
|
22
|
+
return typeof id === 'string' && id ? id : undefined;
|
|
23
|
+
}
|
|
24
|
+
/** The records' lists for a grid, or for a table of a base. */
|
|
25
|
+
function rowsOf(kept, scope, create) {
|
|
26
|
+
const named = typeof scope === 'string' ? { tableId: scope } : 'tableId' in scope && typeof scope.tableId === 'string' ? scope : null;
|
|
27
|
+
const tableId = named ? named.tableId : tableIdOf(scope);
|
|
28
|
+
if (!tableId) {
|
|
29
|
+
let byRow = kept.grids.get(scope);
|
|
30
|
+
if (!byRow && create)
|
|
31
|
+
kept.grids.set(scope, (byRow = new Map()));
|
|
32
|
+
return byRow;
|
|
33
|
+
}
|
|
34
|
+
const base = (named ? named.base : scope?.props?.baseScope) ?? ANY_BASE;
|
|
35
|
+
let byTable = kept.bases.get(base);
|
|
36
|
+
if (!byTable && create)
|
|
37
|
+
kept.bases.set(base, (byTable = new Map()));
|
|
38
|
+
let byRow = byTable?.get(tableId);
|
|
39
|
+
if (!byRow && byTable && create)
|
|
40
|
+
byTable.set(tableId, (byRow = new Map()));
|
|
41
|
+
return byRow;
|
|
42
|
+
}
|
|
43
|
+
/** Keep comments in the app's store (null: back to memory). */
|
|
44
|
+
export function setRecordComments(next) {
|
|
45
|
+
store = next;
|
|
46
|
+
version += 1;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Note a change to a cell (called after every edit, paste, fill and clear).
|
|
50
|
+
* `scope` is the grid, or the table (its base and id) for a change made
|
|
51
|
+
* outside it (an automation). `automation` names the automation that made it.
|
|
52
|
+
*/
|
|
53
|
+
export function noteChange(scope, rowId, columnId, value, automation) {
|
|
54
|
+
const byRow = rowsOf(changes, scope, true);
|
|
55
|
+
const list = byRow.get(rowId) ?? [];
|
|
56
|
+
// Kept short: the panel shows the latest.
|
|
57
|
+
list.push({ at: Date.now(), ...(automation ? { automation } : { by: getCurrentUser()?.id }), columnId, value: Array.isArray(value) ? [...value] : value });
|
|
58
|
+
if (list.length > 50)
|
|
59
|
+
list.splice(0, list.length - 50);
|
|
60
|
+
byRow.set(rowId, list);
|
|
61
|
+
version += 1;
|
|
62
|
+
}
|
|
63
|
+
export function changesOf(scope, rowId) {
|
|
64
|
+
void version;
|
|
65
|
+
return rowsOf(changes, scope, false)?.get(rowId) ?? [];
|
|
66
|
+
}
|
|
67
|
+
const contextOf = (ctrl) => ({ tableId: tableIdOf(ctrl), api: ctrl?.buildApi?.() });
|
|
68
|
+
export function commentsOf(ctrl, rowId) {
|
|
69
|
+
void version;
|
|
70
|
+
if (store) {
|
|
71
|
+
const context = contextOf(ctrl);
|
|
72
|
+
return store.list(rowId, context.api, context);
|
|
73
|
+
}
|
|
74
|
+
return rowsOf(memory, ctrl, false)?.get(rowId) ?? [];
|
|
75
|
+
}
|
|
76
|
+
let seq = 0;
|
|
77
|
+
export function addComment(ctrl, rowId, text) {
|
|
78
|
+
const body = text.trim();
|
|
79
|
+
if (!body)
|
|
80
|
+
return;
|
|
81
|
+
const context = contextOf(ctrl);
|
|
82
|
+
const comment = {
|
|
83
|
+
id: `c${Date.now().toString(36)}${(seq += 1)}`,
|
|
84
|
+
rowId,
|
|
85
|
+
...(context.tableId ? { tableId: context.tableId } : {}),
|
|
86
|
+
text: body,
|
|
87
|
+
at: Date.now(),
|
|
88
|
+
by: getCurrentUser()?.id,
|
|
89
|
+
};
|
|
90
|
+
if (store)
|
|
91
|
+
store.add(rowId, comment, context.api, context);
|
|
92
|
+
else {
|
|
93
|
+
const byRow = rowsOf(memory, ctrl, true);
|
|
94
|
+
byRow.set(rowId, [...(byRow.get(rowId) ?? []), comment]);
|
|
95
|
+
}
|
|
96
|
+
version += 1;
|
|
97
|
+
}
|
|
98
|
+
/** "just now", "5 minutes ago", "3 hours ago", else the date. */
|
|
99
|
+
export function timeAgo(at, now = Date.now()) {
|
|
100
|
+
const s = Math.max(0, Math.round((now - at) / 1000));
|
|
101
|
+
if (s < 45)
|
|
102
|
+
return 'just now';
|
|
103
|
+
const m = Math.round(s / 60);
|
|
104
|
+
if (m < 60)
|
|
105
|
+
return `${m} minute${m === 1 ? '' : 's'} ago`;
|
|
106
|
+
const h = Math.round(m / 60);
|
|
107
|
+
if (h < 24)
|
|
108
|
+
return `${h} hour${h === 1 ? '' : 's'} ago`;
|
|
109
|
+
return new Date(at).toLocaleDateString();
|
|
110
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attachment field's value and the work around it: uploading a file,
|
|
3
|
+
* turning pasted URLs into attachments, the text a copy or an export writes.
|
|
4
|
+
* A cell holds an array, so it can hold several files.
|
|
5
|
+
*/
|
|
6
|
+
export type Attachment = {
|
|
7
|
+
id: string;
|
|
8
|
+
url: string;
|
|
9
|
+
name: string;
|
|
10
|
+
/** MIME type, e.g. 'image/png'. */
|
|
11
|
+
type: string;
|
|
12
|
+
size?: number;
|
|
13
|
+
width?: number;
|
|
14
|
+
height?: number;
|
|
15
|
+
/** A smaller image for the cell; the cell falls back to `url`. */
|
|
16
|
+
thumbnailUrl?: string;
|
|
17
|
+
};
|
|
18
|
+
/** What an upload function is told about the file it uploads. */
|
|
19
|
+
export type AttachmentUploadContext = {
|
|
20
|
+
row: unknown;
|
|
21
|
+
columnId: string;
|
|
22
|
+
/** Aborted when the user removes the file or closes the dialog mid-upload. */
|
|
23
|
+
signal: AbortSignal;
|
|
24
|
+
/** Report progress, 0..1. */
|
|
25
|
+
onProgress: (fraction: number) => void;
|
|
26
|
+
};
|
|
27
|
+
export type AttachmentUploader = (file: File, ctx: AttachmentUploadContext) => Promise<Attachment>;
|
|
28
|
+
/** The attachment field's `fieldProps`. */
|
|
29
|
+
export type AttachmentFieldProps = {
|
|
30
|
+
/** Accepted types, as for `<input accept>`: 'image/*,.pdf'. Default: anything. */
|
|
31
|
+
accept?: string;
|
|
32
|
+
/** Most files a cell holds. Default 20. */
|
|
33
|
+
maxFiles?: number;
|
|
34
|
+
/** Largest file in bytes. Default 25 MB. */
|
|
35
|
+
maxSize?: number;
|
|
36
|
+
/** Uploads a file and returns its attachment. Default: the app-wide uploader. */
|
|
37
|
+
upload?: AttachmentUploader;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The uploader every attachment column uses unless its `fieldProps.upload`
|
|
41
|
+
* sets one. Without any, files stay in this page as `blob:` URLs: fine for a
|
|
42
|
+
* demo, gone on reload, so a real app sets one.
|
|
43
|
+
*/
|
|
44
|
+
export declare function setAttachmentUploader(upload: AttachmentUploader | null): void;
|
|
45
|
+
export declare const newAttachmentId: () => string;
|
|
46
|
+
/** Upload one file with the column's uploader, the app's, or as a local blob URL. */
|
|
47
|
+
export declare function uploadAttachment(file: File, props: AttachmentFieldProps, ctx: AttachmentUploadContext): Promise<Attachment>;
|
|
48
|
+
/** 'At most 1 file', 'At most 20 files'. */
|
|
49
|
+
export declare const tooManyFiles: (maxFiles: number) => string;
|
|
50
|
+
/**
|
|
51
|
+
* Why a file is refused, or null when it is fine. `count` is how many the
|
|
52
|
+
* cell holds already. Works for an attachment added by URL too: its type
|
|
53
|
+
* comes from the name's extension, and a size is checked only when known.
|
|
54
|
+
*/
|
|
55
|
+
export declare function rejectReason(file: {
|
|
56
|
+
name: string;
|
|
57
|
+
type: string;
|
|
58
|
+
size?: number;
|
|
59
|
+
}, props: AttachmentFieldProps, count: number): string | null;
|
|
60
|
+
/** The attachments the field takes, in order, and why each of the others was refused. */
|
|
61
|
+
export declare function limitAttachments(list: ReadonlyArray<Attachment>, props: AttachmentFieldProps, count?: number): {
|
|
62
|
+
kept: Attachment[];
|
|
63
|
+
rejected: string[];
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The attachment column's valueParser: a list (a copied cell) or text with
|
|
67
|
+
* URLs in it (a paste), kept to the field's accept, maxSize and maxFiles.
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseAttachments(value: unknown, props: AttachmentFieldProps): Attachment[];
|
|
70
|
+
/** `<input accept>` semantics: '.pdf', 'image/*', 'application/json'. */
|
|
71
|
+
export declare function accepts(accept: string, file: {
|
|
72
|
+
name: string;
|
|
73
|
+
type: string;
|
|
74
|
+
}): boolean;
|
|
75
|
+
export declare function guessType(name: string): string;
|
|
76
|
+
export declare const isImage: (a: {
|
|
77
|
+
type?: string;
|
|
78
|
+
name?: string;
|
|
79
|
+
}) => boolean;
|
|
80
|
+
export declare const isPdf: (a: {
|
|
81
|
+
type?: string;
|
|
82
|
+
name?: string;
|
|
83
|
+
}) => boolean;
|
|
84
|
+
export declare function formatBytes(n: number | undefined): string;
|
|
85
|
+
/** The cell value as a clean array: anything that is not an attachment is dropped. */
|
|
86
|
+
export declare function toAttachments(value: unknown): Attachment[];
|
|
87
|
+
/** What a copy and an export write: the file names. */
|
|
88
|
+
export declare function attachmentsText(value: unknown): string;
|
|
89
|
+
/** Pasted text -> attachments, one per http(s) URL in it. Anything else is ignored. */
|
|
90
|
+
export declare function attachmentsFromText(text: string): Attachment[];
|
|
91
|
+
/**
|
|
92
|
+
* A URL safe to use: http(s) and blob: only. `forImage` also allows data:
|
|
93
|
+
* images (raster and SVG), which are inert inside <img> but never become a link.
|
|
94
|
+
*/
|
|
95
|
+
export declare function safeUrl(url: unknown, forImage?: boolean): string | null;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attachment field's value and the work around it: uploading a file,
|
|
3
|
+
* turning pasted URLs into attachments, the text a copy or an export writes.
|
|
4
|
+
* A cell holds an array, so it can hold several files.
|
|
5
|
+
*/
|
|
6
|
+
let appUploader = null;
|
|
7
|
+
/**
|
|
8
|
+
* The uploader every attachment column uses unless its `fieldProps.upload`
|
|
9
|
+
* sets one. Without any, files stay in this page as `blob:` URLs: fine for a
|
|
10
|
+
* demo, gone on reload, so a real app sets one.
|
|
11
|
+
*/
|
|
12
|
+
export function setAttachmentUploader(upload) {
|
|
13
|
+
appUploader = upload;
|
|
14
|
+
}
|
|
15
|
+
let counter = 0;
|
|
16
|
+
export const newAttachmentId = () => `att-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
17
|
+
/** Upload one file with the column's uploader, the app's, or as a local blob URL. */
|
|
18
|
+
export async function uploadAttachment(file, props, ctx) {
|
|
19
|
+
const upload = props.upload ?? appUploader;
|
|
20
|
+
if (upload)
|
|
21
|
+
return upload(file, ctx);
|
|
22
|
+
ctx.onProgress(1);
|
|
23
|
+
return { id: newAttachmentId(), url: URL.createObjectURL(file), name: file.name, type: file.type || guessType(file.name), size: file.size };
|
|
24
|
+
}
|
|
25
|
+
/** 'At most 1 file', 'At most 20 files'. */
|
|
26
|
+
export const tooManyFiles = (maxFiles) => `At most ${maxFiles} ${maxFiles === 1 ? 'file' : 'files'}`;
|
|
27
|
+
/**
|
|
28
|
+
* Why a file is refused, or null when it is fine. `count` is how many the
|
|
29
|
+
* cell holds already. Works for an attachment added by URL too: its type
|
|
30
|
+
* comes from the name's extension, and a size is checked only when known.
|
|
31
|
+
*/
|
|
32
|
+
export function rejectReason(file, props, count) {
|
|
33
|
+
const maxFiles = props.maxFiles ?? 20;
|
|
34
|
+
const maxSize = props.maxSize ?? 25 * 1024 * 1024;
|
|
35
|
+
if (count >= maxFiles)
|
|
36
|
+
return tooManyFiles(maxFiles);
|
|
37
|
+
if (file.size != null && file.size > maxSize)
|
|
38
|
+
return `Larger than ${formatBytes(maxSize)}`;
|
|
39
|
+
if (props.accept && !accepts(props.accept, file))
|
|
40
|
+
return 'Not an accepted type';
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/** The attachments the field takes, in order, and why each of the others was refused. */
|
|
44
|
+
export function limitAttachments(list, props, count = 0) {
|
|
45
|
+
const kept = [];
|
|
46
|
+
const rejected = [];
|
|
47
|
+
for (const a of list) {
|
|
48
|
+
const why = rejectReason(a, props, count + kept.length);
|
|
49
|
+
if (why)
|
|
50
|
+
rejected.push(`${a.name}: ${why}`);
|
|
51
|
+
else
|
|
52
|
+
kept.push(a);
|
|
53
|
+
}
|
|
54
|
+
return { kept, rejected };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The attachment column's valueParser: a list (a copied cell) or text with
|
|
58
|
+
* URLs in it (a paste), kept to the field's accept, maxSize and maxFiles.
|
|
59
|
+
*/
|
|
60
|
+
export function parseAttachments(value, props) {
|
|
61
|
+
const list = Array.isArray(value) ? toAttachments(value) : attachmentsFromText(String(value ?? ''));
|
|
62
|
+
return limitAttachments(list, props).kept;
|
|
63
|
+
}
|
|
64
|
+
/** `<input accept>` semantics: '.pdf', 'image/*', 'application/json'. */
|
|
65
|
+
export function accepts(accept, file) {
|
|
66
|
+
const name = file.name.toLowerCase();
|
|
67
|
+
const type = (file.type || guessType(file.name)).toLowerCase();
|
|
68
|
+
return accept
|
|
69
|
+
.split(',')
|
|
70
|
+
.map((s) => s.trim().toLowerCase())
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.some((rule) => rule.startsWith('.') ? name.endsWith(rule) : rule.endsWith('/*') ? type.startsWith(rule.slice(0, -1)) : type === rule);
|
|
73
|
+
}
|
|
74
|
+
const EXT_TYPES = {
|
|
75
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif',
|
|
76
|
+
pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', json: 'application/json', zip: 'application/zip',
|
|
77
|
+
doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
78
|
+
xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
79
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav',
|
|
80
|
+
};
|
|
81
|
+
export function guessType(name) {
|
|
82
|
+
const ext = name.split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? '';
|
|
83
|
+
return EXT_TYPES[ext] ?? 'application/octet-stream';
|
|
84
|
+
}
|
|
85
|
+
export const isImage = (a) => (a.type || guessType(a.name ?? '')).startsWith('image/');
|
|
86
|
+
export const isPdf = (a) => (a.type || guessType(a.name ?? '')) === 'application/pdf';
|
|
87
|
+
export function formatBytes(n) {
|
|
88
|
+
if (n == null || !Number.isFinite(n))
|
|
89
|
+
return '';
|
|
90
|
+
if (n < 1024)
|
|
91
|
+
return `${n} B`;
|
|
92
|
+
// One decimal below 10, none above; a whole number drops its '.0' (10 MB, 1.5 MB).
|
|
93
|
+
const short = (x) => (x < 10 ? x.toFixed(1).replace(/\.0$/, '') : String(Math.round(x)));
|
|
94
|
+
if (n < 1024 * 1024)
|
|
95
|
+
return `${short(n / 1024)} KB`;
|
|
96
|
+
if (n < 1024 * 1024 * 1024)
|
|
97
|
+
return `${short(n / 1024 / 1024)} MB`;
|
|
98
|
+
return `${short(n / 1024 / 1024 / 1024)} GB`;
|
|
99
|
+
}
|
|
100
|
+
/** The cell value as a clean array: anything that is not an attachment is dropped. */
|
|
101
|
+
export function toAttachments(value) {
|
|
102
|
+
if (!Array.isArray(value))
|
|
103
|
+
return [];
|
|
104
|
+
return value.filter((a) => !!a && typeof a === 'object' && typeof a.url === 'string');
|
|
105
|
+
}
|
|
106
|
+
/** What a copy and an export write: the file names. */
|
|
107
|
+
export function attachmentsText(value) {
|
|
108
|
+
return toAttachments(value).map((a) => a.name).join(', ');
|
|
109
|
+
}
|
|
110
|
+
/** Pasted text -> attachments, one per http(s) URL in it. Anything else is ignored. */
|
|
111
|
+
export function attachmentsFromText(text) {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const token of text.split(/[\s,]+/)) {
|
|
114
|
+
let url;
|
|
115
|
+
try {
|
|
116
|
+
url = new URL(token);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
122
|
+
continue;
|
|
123
|
+
const name = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() ?? url.hostname);
|
|
124
|
+
out.push({ id: newAttachmentId(), url: url.href, name, type: guessType(name) });
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A URL safe to use: http(s) and blob: only. `forImage` also allows data:
|
|
130
|
+
* images (raster and SVG), which are inert inside <img> but never become a link.
|
|
131
|
+
*/
|
|
132
|
+
export function safeUrl(url, forImage = false) {
|
|
133
|
+
if (typeof url !== 'string')
|
|
134
|
+
return null;
|
|
135
|
+
// An SVG drawn by <img> runs no script, so a data: SVG is as inert as a PNG there.
|
|
136
|
+
if (forImage && /^data:image\/(png|jpeg|gif|webp|avif|svg\+xml)[;,]/i.test(url))
|
|
137
|
+
return url;
|
|
138
|
+
try {
|
|
139
|
+
const u = new URL(url, typeof location !== 'undefined' ? location.href : 'https://x.invalid/');
|
|
140
|
+
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'blob:' ? u.href : null;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automation actions that leave the base: call a webhook, send an email.
|
|
3
|
+
*
|
|
4
|
+
* Both fill `{Field name}` tokens from the record. A webhook may also name
|
|
5
|
+
* secrets, `{secret:SLACK_TOKEN}`, which only the server runner reads (from
|
|
6
|
+
* its `secrets` option): in the browser such a webhook fails and says to run
|
|
7
|
+
* the automation on the server, so a key never has to live in the page.
|
|
8
|
+
*/
|
|
9
|
+
type Row = Record<string, unknown>;
|
|
10
|
+
/** POST (or PUT, PATCH) to a URL; `body` is a template, JSON by default. */
|
|
11
|
+
export type WebhookAction = {
|
|
12
|
+
type: 'webhook';
|
|
13
|
+
url: string;
|
|
14
|
+
method?: 'POST' | 'PUT' | 'PATCH';
|
|
15
|
+
/** `Name: value` pairs; values may name fields and secrets. */
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
/** The request body. Empty: `{ automation, table, record }` with every field as text. */
|
|
18
|
+
body?: string;
|
|
19
|
+
};
|
|
20
|
+
/** An email, sent by the server runner's `sendEmail`. */
|
|
21
|
+
export type EmailAction = {
|
|
22
|
+
type: 'email';
|
|
23
|
+
to: string;
|
|
24
|
+
subject: string;
|
|
25
|
+
body: string;
|
|
26
|
+
};
|
|
27
|
+
export type OutboundContext = {
|
|
28
|
+
automation: string;
|
|
29
|
+
table: string;
|
|
30
|
+
record: Row | null;
|
|
31
|
+
/** A template with `{Field name}` filled in from the record. */
|
|
32
|
+
fill: (template: string) => string;
|
|
33
|
+
/** Every field of the record as its cell shows it, by field name. */
|
|
34
|
+
fields: () => Record<string, string>;
|
|
35
|
+
/** A secret by name; the browser runner has none. */
|
|
36
|
+
secret?: (name: string) => string | undefined;
|
|
37
|
+
fetch?: typeof fetch;
|
|
38
|
+
};
|
|
39
|
+
export declare function callWebhook(action: WebhookAction, ctx: OutboundContext): Promise<string>;
|
|
40
|
+
export type OutgoingEmail = {
|
|
41
|
+
to: string[];
|
|
42
|
+
subject: string;
|
|
43
|
+
text: string;
|
|
44
|
+
};
|
|
45
|
+
export declare function sendEmailAction(action: EmailAction, ctx: OutboundContext, send: ((email: OutgoingEmail) => Promise<unknown> | unknown) | undefined): Promise<string>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automation actions that leave the base: call a webhook, send an email.
|
|
3
|
+
*
|
|
4
|
+
* Both fill `{Field name}` tokens from the record. A webhook may also name
|
|
5
|
+
* secrets, `{secret:SLACK_TOKEN}`, which only the server runner reads (from
|
|
6
|
+
* its `secrets` option): in the browser such a webhook fails and says to run
|
|
7
|
+
* the automation on the server, so a key never has to live in the page.
|
|
8
|
+
*/
|
|
9
|
+
const SECRET = /\{secret:([A-Za-z0-9_.-]+)\}/g;
|
|
10
|
+
function withSecrets(text, ctx) {
|
|
11
|
+
return text.replace(SECRET, (_m, name) => {
|
|
12
|
+
if (!ctx.secret)
|
|
13
|
+
throw new Error(`{secret:${name}} is read on the server only; run this automation on the server`);
|
|
14
|
+
const value = ctx.secret(name);
|
|
15
|
+
if (value == null)
|
|
16
|
+
throw new Error(`No secret named ${name}`);
|
|
17
|
+
return value;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Fill a JSON body template: a field's text is escaped so the JSON stays valid. */
|
|
21
|
+
function fillJson(template, ctx) {
|
|
22
|
+
const escaped = (s) => JSON.stringify(s).slice(1, -1);
|
|
23
|
+
return template.replace(/\{([^{}\n]+)\}/g, (m, name) => (name.startsWith('secret:') ? m : escaped(ctx.fill(`{${name}}`))));
|
|
24
|
+
}
|
|
25
|
+
export async function callWebhook(action, ctx) {
|
|
26
|
+
const doFetch = ctx.fetch ?? globalThis.fetch;
|
|
27
|
+
if (!doFetch)
|
|
28
|
+
throw new Error('No fetch here to call the webhook with');
|
|
29
|
+
const url = withSecrets(action.url.replace(/\{([^{}\n]+)\}/g, (m, name) => (name.startsWith('secret:') ? m : encodeURIComponent(ctx.fill(`{${name}}`)))), ctx);
|
|
30
|
+
if (!/^https?:\/\//i.test(url))
|
|
31
|
+
throw new Error('A webhook URL starts with https:// or http://');
|
|
32
|
+
const headers = { 'content-type': 'application/json' };
|
|
33
|
+
for (const [k, v] of Object.entries(action.headers ?? {}))
|
|
34
|
+
headers[k.toLowerCase()] = withSecrets(ctx.fill(v), ctx);
|
|
35
|
+
const json = (headers['content-type'] ?? '').includes('json');
|
|
36
|
+
const body = action.body?.trim()
|
|
37
|
+
? withSecrets(json ? fillJson(action.body, ctx) : ctx.fill(action.body), ctx)
|
|
38
|
+
: JSON.stringify({ automation: ctx.automation, table: ctx.table, record: ctx.record ? { id: ctx.record.id ?? null, ...ctx.fields() } : null });
|
|
39
|
+
const method = action.method ?? 'POST';
|
|
40
|
+
const res = await doFetch(url, { method, headers, body });
|
|
41
|
+
const host = (() => {
|
|
42
|
+
try {
|
|
43
|
+
return new URL(url).host;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
throw new Error(`${method} ${host} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`);
|
|
51
|
+
return `${method} ${host}: ${res.status}`;
|
|
52
|
+
}
|
|
53
|
+
export async function sendEmailAction(action, ctx, send) {
|
|
54
|
+
if (!send)
|
|
55
|
+
throw new Error('Email is sent by the server runner; give createAutomationServer a sendEmail');
|
|
56
|
+
const to = ctx.fill(action.to).split(/\s*[,;]\s*/).filter(Boolean);
|
|
57
|
+
if (!to.length)
|
|
58
|
+
throw new Error('No address to send to');
|
|
59
|
+
await send({ to, subject: ctx.fill(action.subject), text: ctx.fill(action.body) });
|
|
60
|
+
return `To ${to.join(', ')}: ${ctx.fill(action.subject)}`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type BaseTable } from './base-tables.js';
|
|
2
|
+
import { type AutomationRun } from './automations.svelte.js';
|
|
3
|
+
import { type OutgoingEmail } from './automation-outbound.js';
|
|
4
|
+
type Row = Record<string, unknown>;
|
|
5
|
+
export type AutomationNotice = {
|
|
6
|
+
message: string;
|
|
7
|
+
severity: 'info' | 'success' | 'warning' | 'error';
|
|
8
|
+
automation: string;
|
|
9
|
+
table: string;
|
|
10
|
+
record: Row | null;
|
|
11
|
+
};
|
|
12
|
+
export type AutomationServerOptions = {
|
|
13
|
+
/** The tables, each with its `source` and `automations` (its `data` is read from the source). */
|
|
14
|
+
tables: BaseTable[];
|
|
15
|
+
/** Secrets a webhook names as `{secret:NAME}`: an object, or a lookup such as `(n) => env[n]`. */
|
|
16
|
+
secrets?: Record<string, string | undefined> | ((name: string) => string | undefined);
|
|
17
|
+
/** Sends the "Send an email" action: your mail provider. */
|
|
18
|
+
sendEmail?: (email: OutgoingEmail) => Promise<unknown> | unknown;
|
|
19
|
+
/** "Show a notice" on the server: log it, push it to the people concerned. */
|
|
20
|
+
notify?: (notice: AutomationNotice) => void;
|
|
21
|
+
/** Each run as it finishes, with what each action did. */
|
|
22
|
+
onRun?: (run: AutomationRun) => void;
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
now?: () => Date;
|
|
25
|
+
/** How many runs `runs` keeps (default 200). */
|
|
26
|
+
keepRuns?: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function createAutomationServer(options: AutomationServerOptions): {
|
|
29
|
+
prime: () => Promise<void>;
|
|
30
|
+
recordChanged: (tableId: string, change: {
|
|
31
|
+
before?: Row | null;
|
|
32
|
+
after?: Row | null;
|
|
33
|
+
}, opts?: {
|
|
34
|
+
db?: boolean;
|
|
35
|
+
}) => Promise<AutomationRun[]>;
|
|
36
|
+
tableChanged: (tableId: string) => Promise<AutomationRun[]>;
|
|
37
|
+
audit: (tableId: string) => (entry: {
|
|
38
|
+
action: "create" | "update" | "delete";
|
|
39
|
+
id: string | null;
|
|
40
|
+
result?: unknown;
|
|
41
|
+
}) => Promise<void>;
|
|
42
|
+
formSubmitted: (tableId: string, recordId: string, view?: string) => Promise<AutomationRun[]>;
|
|
43
|
+
buttonPressed: (tableId: string, recordId: string, field: string) => Promise<AutomationRun[]>;
|
|
44
|
+
tick: (at?: Date) => Promise<AutomationRun[]>;
|
|
45
|
+
/** The latest runs, newest first: hand them to `<SvBase serverRuns>` from an endpoint. */
|
|
46
|
+
readonly runs: ReadonlyArray<AutomationRun>;
|
|
47
|
+
/** Resolves when every queued run has finished. */
|
|
48
|
+
settled: () => Promise<undefined>;
|
|
49
|
+
/** The tables as it last read them. */
|
|
50
|
+
readonly tables: ReadonlyArray<BaseTable>;
|
|
51
|
+
};
|
|
52
|
+
export type AutomationServer = ReturnType<typeof createAutomationServer>;
|
|
53
|
+
export {};
|