@book.dev/sdk 3.10.0 → 3.13.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/dist/backup.d.ts +106 -10
- package/dist/backup.js +7 -6
- package/dist/backup.js.map +1 -1
- package/dist/blockCatalogue.d.ts +24 -0
- package/dist/blockCatalogue.js +2 -0
- package/dist/blockCatalogue.js.map +1 -1
- package/dist/client.d.ts +48 -23
- package/dist/client.js +227 -13
- package/dist/client.js.map +1 -1
- package/dist/database.d.ts +152 -7
- package/dist/database.js +479 -5
- package/dist/database.js.map +1 -1
- package/dist/formSchema.d.ts +131 -0
- package/dist/formSchema.js +493 -0
- package/dist/formSchema.js.map +1 -0
- package/dist/forms.d.ts +117 -0
- package/dist/forms.js +65 -0
- package/dist/forms.js.map +1 -0
- package/dist/forwarding/forwardingClient.d.ts +9 -1
- package/dist/forwarding/forwardingClient.js +34 -23
- package/dist/forwarding/forwardingClient.js.map +1 -1
- package/dist/forwarding/tunnelClient.d.ts +7 -1
- package/dist/forwarding/tunnelClient.js +41 -16
- package/dist/forwarding/tunnelClient.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/pageProperties.d.ts +7 -0
- package/dist/pageProperties.js +2 -0
- package/dist/pageProperties.js.map +1 -1
- package/dist/routes.d.ts +12 -0
- package/dist/routes.js +12 -0
- package/dist/routes.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/database.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* StoredPage}. A **database** is a collection of pages (its *rows*) managed by
|
|
4
4
|
* typed *properties* and presented through one or more configurable *views*
|
|
5
5
|
* (table, list, gallery, board, calendar, timeline, map, relationship graph,
|
|
6
|
-
* or a bar/pie chart).
|
|
6
|
+
* form, or a bar/pie chart).
|
|
7
7
|
*
|
|
8
8
|
* Three ideas make OpenBook databases different from a plain spreadsheet:
|
|
9
9
|
*
|
|
@@ -28,6 +28,41 @@
|
|
|
28
28
|
* record — properties, views, filters — lives in its own `databases` table.
|
|
29
29
|
*/
|
|
30
30
|
import { evaluateFormula, FormulaError } from './formula';
|
|
31
|
+
/**
|
|
32
|
+
* Whether a public form may write a property type in v1. This exhaustive map is
|
|
33
|
+
* the shared allowlist for builders and the submission validator. Reference,
|
|
34
|
+
* identity/attestation, computed, and server-managed properties fail closed.
|
|
35
|
+
*/
|
|
36
|
+
export const FORM_PROPERTY_TYPE_WRITABILITY = {
|
|
37
|
+
text: true,
|
|
38
|
+
number: true,
|
|
39
|
+
rating: true,
|
|
40
|
+
select: true,
|
|
41
|
+
multi_select: true,
|
|
42
|
+
status: true,
|
|
43
|
+
checkbox: true,
|
|
44
|
+
date: true,
|
|
45
|
+
url: true,
|
|
46
|
+
email: true,
|
|
47
|
+
phone: true,
|
|
48
|
+
location: true,
|
|
49
|
+
files: true,
|
|
50
|
+
relation: false,
|
|
51
|
+
dependency: false,
|
|
52
|
+
rollup: false,
|
|
53
|
+
created_time: false,
|
|
54
|
+
last_edited_time: false,
|
|
55
|
+
unique_id: false,
|
|
56
|
+
expr: false,
|
|
57
|
+
formula: false,
|
|
58
|
+
person: false,
|
|
59
|
+
verification: false,
|
|
60
|
+
backlinks: false,
|
|
61
|
+
};
|
|
62
|
+
/** True when `type` is safe for a public form fill in v1. */
|
|
63
|
+
export function isFormWritablePropertyType(type) {
|
|
64
|
+
return FORM_PROPERTY_TYPE_WRITABILITY[type] === true;
|
|
65
|
+
}
|
|
31
66
|
/** Whether each side of a relation cardinality holds a single row. `1:1` is
|
|
32
67
|
* single both ways; `1:n` links many but each target points back to one; `n:n`
|
|
33
68
|
* is many both ways. */
|
|
@@ -50,6 +85,24 @@ export function asLocation(value) {
|
|
|
50
85
|
...(typeof v.address === 'string' && v.address.trim() ? { address: v.address } : {}),
|
|
51
86
|
};
|
|
52
87
|
}
|
|
88
|
+
/** Runtime list for fail-closed decoding of persisted view types. */
|
|
89
|
+
export const KNOWN_DATABASE_VIEW_TYPES = [
|
|
90
|
+
'table',
|
|
91
|
+
'list',
|
|
92
|
+
'gallery',
|
|
93
|
+
'board',
|
|
94
|
+
'calendar',
|
|
95
|
+
'timeline',
|
|
96
|
+
'map',
|
|
97
|
+
'graph',
|
|
98
|
+
'form',
|
|
99
|
+
'bar',
|
|
100
|
+
'pie',
|
|
101
|
+
];
|
|
102
|
+
/** True only for view layouts this SDK knows how to interpret. */
|
|
103
|
+
export function isDatabaseViewType(value) {
|
|
104
|
+
return typeof value === 'string' && KNOWN_DATABASE_VIEW_TYPES.includes(value);
|
|
105
|
+
}
|
|
53
106
|
/** The value-less, relative-to-today date operators. */
|
|
54
107
|
export const RELATIVE_DATE_OPS = [
|
|
55
108
|
'is_today',
|
|
@@ -62,6 +115,25 @@ export const RELATIVE_DATE_OPS = [
|
|
|
62
115
|
export function isFilterGroup(node) {
|
|
63
116
|
return node.conjunction !== undefined;
|
|
64
117
|
}
|
|
118
|
+
/** Stable machine-readable failures returned by {@link validateRowAgainstForm}. */
|
|
119
|
+
export const FORM_ROW_VALIDATION_ERROR_CODES = [
|
|
120
|
+
'view_type',
|
|
121
|
+
'unknown_field',
|
|
122
|
+
'required',
|
|
123
|
+
'type',
|
|
124
|
+
'min',
|
|
125
|
+
'max',
|
|
126
|
+
'minLength',
|
|
127
|
+
'maxLength',
|
|
128
|
+
'pattern',
|
|
129
|
+
'option',
|
|
130
|
+
'range',
|
|
131
|
+
'too_large',
|
|
132
|
+
'date_format',
|
|
133
|
+
'email_format',
|
|
134
|
+
'url_format',
|
|
135
|
+
'phone_format',
|
|
136
|
+
];
|
|
65
137
|
/**
|
|
66
138
|
* Validate + normalise a database's {@link AutoExpiryConfig} against its schema,
|
|
67
139
|
* returning the rule to apply, or `null` when auto-expiry should do nothing:
|
|
@@ -699,6 +771,21 @@ export function defaultDatabaseSchema() {
|
|
|
699
771
|
/** A fresh view of a given type with sensible defaults for its layout. */
|
|
700
772
|
export function defaultView(type, name, properties) {
|
|
701
773
|
const view = { id: shortId('view'), name, type, filters: [], sorts: [] };
|
|
774
|
+
if (type === 'form') {
|
|
775
|
+
// A form's field list is always explicit and fail-closed. Form-only fields
|
|
776
|
+
// are added as ordinary pageHidden properties by the builder, then appended
|
|
777
|
+
// here just like any other form-writable database column.
|
|
778
|
+
view.visiblePropertyIds = [
|
|
779
|
+
TITLE_PROPERTY_ID,
|
|
780
|
+
...properties
|
|
781
|
+
.filter((property) => property.id !== TITLE_PROPERTY_ID
|
|
782
|
+
&& !property.id.startsWith('sys_')
|
|
783
|
+
&& isFormWritablePropertyType(property.type))
|
|
784
|
+
.map((property) => property.id),
|
|
785
|
+
];
|
|
786
|
+
view.formFields = {};
|
|
787
|
+
view.formConfig = { acceptingResponses: true };
|
|
788
|
+
}
|
|
702
789
|
if (type === 'board' || type === 'bar' || type === 'pie') {
|
|
703
790
|
// Default the grouping to the first categorical property (select/status/
|
|
704
791
|
// relation — kanban columns / chart categories read best off one). With no
|
|
@@ -747,10 +834,11 @@ export const STATUS_GROUPS = [
|
|
|
747
834
|
/**
|
|
748
835
|
* Remove a property from a schema and scrub **every** dangling reference to it:
|
|
749
836
|
* each view's filters (flat list *and* the nested {@link filterRoot} tree),
|
|
750
|
-
* sorts, visible columns, summaries, and the group-by /
|
|
751
|
-
* any `rollup` on another property that aggregated
|
|
752
|
-
* returns a fresh schema — so it can be unit-tested
|
|
753
|
-
* action. (Renders already tolerate stale refs; this
|
|
837
|
+
* sorts, visible columns, form-field metadata, summaries, and the group-by /
|
|
838
|
+
* date / cover config; plus any `rollup` on another property that aggregated
|
|
839
|
+
* through or over it. Pure — returns a fresh schema — so it can be unit-tested
|
|
840
|
+
* and shared by the delete action. (Renders already tolerate stale refs; this
|
|
841
|
+
* keeps the schema clean.)
|
|
754
842
|
*/
|
|
755
843
|
export function removeProperty(schema, propertyId) {
|
|
756
844
|
const pruneNode = (node) => {
|
|
@@ -777,12 +865,16 @@ export function removeProperty(schema, propertyId) {
|
|
|
777
865
|
const summaries = v.summaries
|
|
778
866
|
? Object.fromEntries(Object.entries(v.summaries).filter(([k]) => k !== propertyId))
|
|
779
867
|
: undefined;
|
|
868
|
+
const formFields = v.formFields
|
|
869
|
+
? Object.fromEntries(Object.entries(v.formFields).filter(([k]) => k !== propertyId))
|
|
870
|
+
: undefined;
|
|
780
871
|
return {
|
|
781
872
|
...v,
|
|
782
873
|
filters: (v.filters ?? []).filter((f) => f.propertyId !== propertyId),
|
|
783
874
|
filterRoot: v.filterRoot ? pruneNode(v.filterRoot) : undefined,
|
|
784
875
|
sorts: (v.sorts ?? []).filter((s) => s.propertyId !== propertyId),
|
|
785
876
|
visiblePropertyIds: v.visiblePropertyIds?.filter((id) => id !== propertyId),
|
|
877
|
+
formFields,
|
|
786
878
|
summaries,
|
|
787
879
|
groupByPropertyId: v.groupByPropertyId === propertyId ? undefined : v.groupByPropertyId,
|
|
788
880
|
subGroupByPropertyId: v.subGroupByPropertyId === propertyId ? undefined : v.subGroupByPropertyId,
|
|
@@ -800,6 +892,388 @@ export function removeProperty(schema, propertyId) {
|
|
|
800
892
|
: t),
|
|
801
893
|
};
|
|
802
894
|
}
|
|
895
|
+
const FORM_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
896
|
+
const FORM_PHONE_RE = /^\+?[0-9().\-\s]+$/;
|
|
897
|
+
const FORM_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
898
|
+
const FORM_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
899
|
+
function formHasOwn(value, key) {
|
|
900
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
901
|
+
}
|
|
902
|
+
function validFormCalendarDate(year, month, day) {
|
|
903
|
+
if (month < 1 || month > 12)
|
|
904
|
+
return false;
|
|
905
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
906
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
907
|
+
return day >= 1 && day <= days[month - 1];
|
|
908
|
+
}
|
|
909
|
+
function validFormDate(value, includeTime) {
|
|
910
|
+
const match = (includeTime ? FORM_DATE_TIME_RE : FORM_DATE_RE).exec(value);
|
|
911
|
+
if (!match)
|
|
912
|
+
return false;
|
|
913
|
+
if (!validFormCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])))
|
|
914
|
+
return false;
|
|
915
|
+
if (!includeTime)
|
|
916
|
+
return true;
|
|
917
|
+
return Number(match[4]) <= 23 && Number(match[5]) <= 59 && (match[6] === undefined || Number(match[6]) <= 59);
|
|
918
|
+
}
|
|
919
|
+
function formFieldIsEmpty(property, value) {
|
|
920
|
+
if (value === undefined || value === null)
|
|
921
|
+
return true;
|
|
922
|
+
// HTML controls can emit an empty string even when the backing property is
|
|
923
|
+
// numeric, boolean, or otherwise non-string. Treat it as absence before type
|
|
924
|
+
// validation so a required blank consistently reports `required`.
|
|
925
|
+
if (typeof value === 'string' && value.trim() === '')
|
|
926
|
+
return true;
|
|
927
|
+
switch (property.type) {
|
|
928
|
+
case 'multi_select':
|
|
929
|
+
case 'files':
|
|
930
|
+
return Array.isArray(value) && value.length === 0;
|
|
931
|
+
case 'date':
|
|
932
|
+
if (property.dateRange && typeof value === 'object' && !Array.isArray(value)) {
|
|
933
|
+
const range = value;
|
|
934
|
+
return !range.start && !range.end;
|
|
935
|
+
}
|
|
936
|
+
return false;
|
|
937
|
+
default:
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
function validateFormDateValue(property, value) {
|
|
942
|
+
const validPart = (part) => typeof part === 'string' && validFormDate(part, property.includeTime === true);
|
|
943
|
+
if (!property.dateRange)
|
|
944
|
+
return validPart(value) ? null : 'date_format';
|
|
945
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
946
|
+
return 'type';
|
|
947
|
+
const range = value;
|
|
948
|
+
if (Object.keys(range).some((key) => key !== 'start' && key !== 'end'))
|
|
949
|
+
return 'type';
|
|
950
|
+
if (range.start !== null && range.start !== undefined && !validPart(range.start))
|
|
951
|
+
return 'date_format';
|
|
952
|
+
if (range.end !== null && range.end !== undefined && !validPart(range.end))
|
|
953
|
+
return 'date_format';
|
|
954
|
+
if (!validPart(range.start))
|
|
955
|
+
return 'date_format';
|
|
956
|
+
if (validPart(range.end)) {
|
|
957
|
+
// Date-only values sort chronologically as ISO strings. Date-times do not:
|
|
958
|
+
// mixed offsets can make a lexically earlier value represent a later instant
|
|
959
|
+
// (and vice versa), so compare their parsed instants instead.
|
|
960
|
+
const endBeforeStart = property.includeTime === true
|
|
961
|
+
? Date.parse(range.end) < Date.parse(range.start)
|
|
962
|
+
: range.end < range.start;
|
|
963
|
+
if (endBeforeStart)
|
|
964
|
+
return 'range';
|
|
965
|
+
}
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
function validateFormLocation(value) {
|
|
969
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
970
|
+
return 'type';
|
|
971
|
+
const location = value;
|
|
972
|
+
if (Object.keys(location).some((key) => !['lat', 'lng', 'label', 'address'].includes(key)))
|
|
973
|
+
return 'type';
|
|
974
|
+
if (typeof location.lat !== 'number' || !Number.isFinite(location.lat))
|
|
975
|
+
return 'type';
|
|
976
|
+
if (typeof location.lng !== 'number' || !Number.isFinite(location.lng))
|
|
977
|
+
return 'type';
|
|
978
|
+
if (location.lat < -90 || location.lat > 90 || location.lng < -180 || location.lng > 180)
|
|
979
|
+
return 'range';
|
|
980
|
+
if (location.label !== undefined && typeof location.label !== 'string')
|
|
981
|
+
return 'type';
|
|
982
|
+
if (location.address !== undefined && typeof location.address !== 'string')
|
|
983
|
+
return 'type';
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
function validateFormPropertyValue(property, value) {
|
|
987
|
+
switch (property.type) {
|
|
988
|
+
case 'text':
|
|
989
|
+
return typeof value === 'string' ? null : 'type';
|
|
990
|
+
case 'number':
|
|
991
|
+
return typeof value === 'number' && Number.isFinite(value) ? null : 'type';
|
|
992
|
+
case 'rating': {
|
|
993
|
+
if (typeof value !== 'number' || !Number.isInteger(value))
|
|
994
|
+
return 'type';
|
|
995
|
+
const max = property.numberTarget && property.numberTarget > 0 ? Math.min(10, Math.round(property.numberTarget)) : 5;
|
|
996
|
+
return value >= 1 && value <= max ? null : 'range';
|
|
997
|
+
}
|
|
998
|
+
case 'select':
|
|
999
|
+
case 'status':
|
|
1000
|
+
if (typeof value !== 'string')
|
|
1001
|
+
return 'type';
|
|
1002
|
+
return property.options?.some((option) => option.id === value) ? null : 'option';
|
|
1003
|
+
case 'multi_select': {
|
|
1004
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string'))
|
|
1005
|
+
return 'type';
|
|
1006
|
+
if (new Set(value).size !== value.length)
|
|
1007
|
+
return 'option';
|
|
1008
|
+
const options = new Set((property.options ?? []).map((option) => option.id));
|
|
1009
|
+
return value.every((entry) => options.has(entry)) ? null : 'option';
|
|
1010
|
+
}
|
|
1011
|
+
case 'checkbox':
|
|
1012
|
+
return typeof value === 'boolean' ? null : 'type';
|
|
1013
|
+
case 'date':
|
|
1014
|
+
return validateFormDateValue(property, value);
|
|
1015
|
+
case 'url':
|
|
1016
|
+
if (typeof value !== 'string')
|
|
1017
|
+
return 'type';
|
|
1018
|
+
try {
|
|
1019
|
+
const url = new URL(value);
|
|
1020
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname ? null : 'url_format';
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
return 'url_format';
|
|
1024
|
+
}
|
|
1025
|
+
case 'email':
|
|
1026
|
+
return typeof value !== 'string' ? 'type' : FORM_EMAIL_RE.test(value) ? null : 'email_format';
|
|
1027
|
+
case 'phone': {
|
|
1028
|
+
if (typeof value !== 'string')
|
|
1029
|
+
return 'type';
|
|
1030
|
+
const digits = value.replace(/\D/g, '').length;
|
|
1031
|
+
return FORM_PHONE_RE.test(value) && digits >= 7 && digits <= 15 ? null : 'phone_format';
|
|
1032
|
+
}
|
|
1033
|
+
case 'location':
|
|
1034
|
+
return validateFormLocation(value);
|
|
1035
|
+
case 'files':
|
|
1036
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.length > 0) ? null : 'type';
|
|
1037
|
+
default:
|
|
1038
|
+
return 'unknown_field';
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function copyFormValue(value) {
|
|
1042
|
+
if (Array.isArray(value))
|
|
1043
|
+
return [...value];
|
|
1044
|
+
if (value && typeof value === 'object')
|
|
1045
|
+
return { ...value };
|
|
1046
|
+
return value;
|
|
1047
|
+
}
|
|
1048
|
+
const FORM_TITLE_PROPERTY = {
|
|
1049
|
+
id: TITLE_PROPERTY_ID,
|
|
1050
|
+
name: 'Title',
|
|
1051
|
+
type: 'text',
|
|
1052
|
+
};
|
|
1053
|
+
/** Resolve the ordered, current, writable field mapping, including row title. */
|
|
1054
|
+
function mappedFormProperties(schema, view) {
|
|
1055
|
+
const byId = new Map(schema.properties.map((property) => [property.id, property]));
|
|
1056
|
+
return (view.visiblePropertyIds ?? [])
|
|
1057
|
+
.map((propertyId) => propertyId === TITLE_PROPERTY_ID ? FORM_TITLE_PROPERTY : byId.get(propertyId))
|
|
1058
|
+
.filter((property) => property !== undefined && !property.id.startsWith('sys_') && isFormWritablePropertyType(property.type));
|
|
1059
|
+
}
|
|
1060
|
+
/** Conservative structural screen for common exponential-backtracking forms. */
|
|
1061
|
+
export function formPatternIsUnsafe(pattern) {
|
|
1062
|
+
if (/\\(?:[1-9]|k<)/.test(pattern))
|
|
1063
|
+
return true;
|
|
1064
|
+
const frames = [{ hasAlternation: false, hasQuantifier: false }];
|
|
1065
|
+
let inClass = false;
|
|
1066
|
+
let quantifiers = 0;
|
|
1067
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
1068
|
+
const char = pattern[i];
|
|
1069
|
+
if (char === '\\') {
|
|
1070
|
+
i += 1;
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (inClass) {
|
|
1074
|
+
if (char === ']')
|
|
1075
|
+
inClass = false;
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
if (char === '[') {
|
|
1079
|
+
inClass = true;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (char === '(') {
|
|
1083
|
+
if (pattern[i + 1] === '?' && pattern[i + 2] !== ':')
|
|
1084
|
+
return true;
|
|
1085
|
+
if (pattern[i + 1] === '?' && pattern[i + 2] === ':')
|
|
1086
|
+
i += 2;
|
|
1087
|
+
frames.push({ hasAlternation: false, hasQuantifier: false });
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (char === '|') {
|
|
1091
|
+
frames[frames.length - 1].hasAlternation = true;
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (char === ')') {
|
|
1095
|
+
if (frames.length === 1)
|
|
1096
|
+
continue;
|
|
1097
|
+
const frame = frames.pop();
|
|
1098
|
+
const next = pattern[i + 1];
|
|
1099
|
+
const quantified = next === '*' || next === '+' || next === '?' || next === '{';
|
|
1100
|
+
if (quantified && (frame.hasAlternation || frame.hasQuantifier))
|
|
1101
|
+
return true;
|
|
1102
|
+
const parent = frames[frames.length - 1];
|
|
1103
|
+
parent.hasAlternation || (parent.hasAlternation = frame.hasAlternation);
|
|
1104
|
+
parent.hasQuantifier || (parent.hasQuantifier = frame.hasQuantifier || quantified);
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
if (char === '*' || char === '+' || char === '?' || char === '{') {
|
|
1108
|
+
frames[frames.length - 1].hasQuantifier = true;
|
|
1109
|
+
quantifiers += 1;
|
|
1110
|
+
if (quantifiers > 8)
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return inClass;
|
|
1115
|
+
}
|
|
1116
|
+
function validateFormFieldConstraints(metadata, value) {
|
|
1117
|
+
const validation = metadata?.validation;
|
|
1118
|
+
if (!validation)
|
|
1119
|
+
return [];
|
|
1120
|
+
const errors = [];
|
|
1121
|
+
if (typeof value === 'number') {
|
|
1122
|
+
if (typeof validation.min === 'number' && value < validation.min)
|
|
1123
|
+
errors.push('min');
|
|
1124
|
+
if (typeof validation.max === 'number' && value > validation.max)
|
|
1125
|
+
errors.push('max');
|
|
1126
|
+
}
|
|
1127
|
+
const length = typeof value === 'string' || Array.isArray(value) ? value.length : undefined;
|
|
1128
|
+
if (length !== undefined) {
|
|
1129
|
+
if (typeof validation.minLength === 'number' && length < validation.minLength)
|
|
1130
|
+
errors.push('minLength');
|
|
1131
|
+
if (typeof validation.maxLength === 'number' && length > validation.maxLength)
|
|
1132
|
+
errors.push('maxLength');
|
|
1133
|
+
}
|
|
1134
|
+
if (validation.pattern !== undefined && typeof value === 'string') {
|
|
1135
|
+
const pattern = validation.pattern;
|
|
1136
|
+
if (typeof pattern !== 'string'
|
|
1137
|
+
|| pattern.length > 256
|
|
1138
|
+
|| value.length > 1024
|
|
1139
|
+
|| formPatternIsUnsafe(pattern)) {
|
|
1140
|
+
errors.push('pattern');
|
|
1141
|
+
}
|
|
1142
|
+
else {
|
|
1143
|
+
try {
|
|
1144
|
+
if (!new RegExp(pattern).test(value))
|
|
1145
|
+
errors.push('pattern');
|
|
1146
|
+
}
|
|
1147
|
+
catch {
|
|
1148
|
+
errors.push('pattern');
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return errors;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Project only the public rendering contract from a freshly loaded form view.
|
|
1156
|
+
* Returns `null` for a non-form view so callers cannot accidentally serialize a
|
|
1157
|
+
* normal database view through the public endpoint.
|
|
1158
|
+
*/
|
|
1159
|
+
export function projectDatabaseFormDescriptor(schema, view) {
|
|
1160
|
+
if (view.type !== 'form')
|
|
1161
|
+
return null;
|
|
1162
|
+
const config = view.formConfig;
|
|
1163
|
+
const acceptingResponses = config?.acceptingResponses === true;
|
|
1164
|
+
const fields = mappedFormProperties(schema, view).map((property) => {
|
|
1165
|
+
const metadata = view.formFields && formHasOwn(view.formFields, property.id)
|
|
1166
|
+
? view.formFields[property.id]
|
|
1167
|
+
: undefined;
|
|
1168
|
+
const field = {
|
|
1169
|
+
propertyId: property.id,
|
|
1170
|
+
type: property.type,
|
|
1171
|
+
label: metadata?.label ?? property.name,
|
|
1172
|
+
help: metadata?.help ?? '',
|
|
1173
|
+
required: metadata?.required === true,
|
|
1174
|
+
placeholder: metadata?.placeholder ?? '',
|
|
1175
|
+
};
|
|
1176
|
+
if (property.type === 'text' && metadata?.multiline !== undefined) {
|
|
1177
|
+
field.multiline = metadata.multiline;
|
|
1178
|
+
}
|
|
1179
|
+
const validation = metadata?.validation;
|
|
1180
|
+
if (validation) {
|
|
1181
|
+
const displayValidation = {
|
|
1182
|
+
...(validation.min !== undefined ? { min: validation.min } : {}),
|
|
1183
|
+
...(validation.max !== undefined ? { max: validation.max } : {}),
|
|
1184
|
+
...(validation.minLength !== undefined ? { minLength: validation.minLength } : {}),
|
|
1185
|
+
...(validation.maxLength !== undefined ? { maxLength: validation.maxLength } : {}),
|
|
1186
|
+
};
|
|
1187
|
+
if (Object.keys(displayValidation).length > 0)
|
|
1188
|
+
field.validation = displayValidation;
|
|
1189
|
+
}
|
|
1190
|
+
if ((property.type === 'select' || property.type === 'multi_select' || property.type === 'status')
|
|
1191
|
+
&& property.options !== undefined) {
|
|
1192
|
+
field.options = property.options.map((option) => ({ ...option }));
|
|
1193
|
+
}
|
|
1194
|
+
if (property.type === 'date') {
|
|
1195
|
+
if (property.includeTime !== undefined)
|
|
1196
|
+
field.includeTime = property.includeTime;
|
|
1197
|
+
if (property.dateRange !== undefined)
|
|
1198
|
+
field.dateRange = property.dateRange;
|
|
1199
|
+
}
|
|
1200
|
+
if ((property.type === 'number' || property.type === 'rating') && property.numberTarget !== undefined) {
|
|
1201
|
+
field.numberTarget = property.numberTarget;
|
|
1202
|
+
}
|
|
1203
|
+
return field;
|
|
1204
|
+
});
|
|
1205
|
+
return {
|
|
1206
|
+
title: config?.title ?? view.name,
|
|
1207
|
+
description: config?.description ?? '',
|
|
1208
|
+
submitLabel: config?.submitLabel ?? 'Submit',
|
|
1209
|
+
acceptingResponses,
|
|
1210
|
+
...(!acceptingResponses && config?.closedMessage !== undefined
|
|
1211
|
+
? { closedMessage: config.closedMessage }
|
|
1212
|
+
: {}),
|
|
1213
|
+
fields,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Validate an untrusted public fill against the database's current schema and
|
|
1218
|
+
* form mapping. The allowlist is the form's explicit `visiblePropertyIds`
|
|
1219
|
+
* intersected with current form-writable properties. Unknown, deleted,
|
|
1220
|
+
* non-visible, computed, and managed fields are rejected; successful output is
|
|
1221
|
+
* a fresh record containing only validated fields ready for row creation.
|
|
1222
|
+
*/
|
|
1223
|
+
export function validateRowAgainstForm(schema, view, fields) {
|
|
1224
|
+
if (view.type !== 'form')
|
|
1225
|
+
return { ok: false, errors: [{ propertyId: '', code: 'view_type' }] };
|
|
1226
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
|
|
1227
|
+
return { ok: false, errors: [{ propertyId: '', code: 'type' }] };
|
|
1228
|
+
}
|
|
1229
|
+
const mapped = mappedFormProperties(schema, view);
|
|
1230
|
+
const allowedIds = new Set(mapped.map((property) => property.id));
|
|
1231
|
+
const errors = [];
|
|
1232
|
+
for (const propertyId of Object.keys(fields)) {
|
|
1233
|
+
if (!allowedIds.has(propertyId))
|
|
1234
|
+
errors.push({ propertyId, code: 'unknown_field' });
|
|
1235
|
+
}
|
|
1236
|
+
const validated = {};
|
|
1237
|
+
let name;
|
|
1238
|
+
for (const property of mapped) {
|
|
1239
|
+
const present = formHasOwn(fields, property.id);
|
|
1240
|
+
const value = present ? fields[property.id] : undefined;
|
|
1241
|
+
const metadata = view.formFields && formHasOwn(view.formFields, property.id)
|
|
1242
|
+
? view.formFields[property.id]
|
|
1243
|
+
: undefined;
|
|
1244
|
+
if (!present || formFieldIsEmpty(property, value)) {
|
|
1245
|
+
if (metadata?.required === true)
|
|
1246
|
+
errors.push({ propertyId: property.id, code: 'required' });
|
|
1247
|
+
if (property.id === TITLE_PROPERTY_ID)
|
|
1248
|
+
name = '';
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const code = validateFormPropertyValue(property, value);
|
|
1252
|
+
if (code) {
|
|
1253
|
+
errors.push({ propertyId: property.id, code });
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const constraintCodes = validateFormFieldConstraints(metadata, value);
|
|
1257
|
+
if (constraintCodes.length > 0) {
|
|
1258
|
+
for (const constraintCode of constraintCodes)
|
|
1259
|
+
errors.push({ propertyId: property.id, code: constraintCode });
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
if (property.id === TITLE_PROPERTY_ID) {
|
|
1263
|
+
name = value;
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
Object.defineProperty(validated, property.id, {
|
|
1267
|
+
value: copyFormValue(value),
|
|
1268
|
+
enumerable: true,
|
|
1269
|
+
configurable: true,
|
|
1270
|
+
writable: true,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
if (errors.length > 0)
|
|
1274
|
+
return { ok: false, errors };
|
|
1275
|
+
return name === undefined ? { ok: true, fields: validated } : { ok: true, name, fields: validated };
|
|
1276
|
+
}
|
|
803
1277
|
// ── Number formatting ────────────────────────────────────────────────────────
|
|
804
1278
|
const FORMAT_PREFIX = { dollar: '$', euro: '€', pound: '£', rupee: '₹' };
|
|
805
1279
|
/** Format a numeric value for display per a {@link NumberFormat}. Non-numbers pass through as text. */
|