@rebasepro/common 0.0.1-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -0
  3. package/dist/collections/CollectionRegistry.d.ts +48 -0
  4. package/dist/collections/index.d.ts +1 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.es.js +2380 -0
  7. package/dist/index.es.js.map +1 -0
  8. package/dist/index.umd.js +2379 -0
  9. package/dist/index.umd.js.map +1 -0
  10. package/dist/util/arrays.d.ts +1 -0
  11. package/dist/util/builders.d.ts +64 -0
  12. package/dist/util/callbacks.d.ts +6 -0
  13. package/dist/util/collections.d.ts +11 -0
  14. package/dist/util/common.d.ts +2 -0
  15. package/dist/util/conditions.d.ts +26 -0
  16. package/dist/util/dates.d.ts +1 -0
  17. package/dist/util/entities.d.ts +28 -0
  18. package/dist/util/entity_actions.d.ts +2 -0
  19. package/dist/util/enums.d.ts +3 -0
  20. package/dist/util/fields.d.ts +2 -0
  21. package/dist/util/flatten_object.d.ts +5 -0
  22. package/dist/util/hash.d.ts +1 -0
  23. package/dist/util/index.d.ts +26 -0
  24. package/dist/util/names.d.ts +22 -0
  25. package/dist/util/navigation_from_path.d.ts +29 -0
  26. package/dist/util/navigation_utils.d.ts +31 -0
  27. package/dist/util/objects.d.ts +26 -0
  28. package/dist/util/os.d.ts +2 -0
  29. package/dist/util/parent_references_from_path.d.ts +6 -0
  30. package/dist/util/paths.d.ts +14 -0
  31. package/dist/util/permissions.d.ts +5 -0
  32. package/dist/util/permissions.test.d.ts +1 -0
  33. package/dist/util/plurals.d.ts +16 -0
  34. package/dist/util/references.d.ts +2 -0
  35. package/dist/util/regexp.d.ts +7 -0
  36. package/dist/util/relations.d.ts +12 -0
  37. package/dist/util/resolutions.d.ts +74 -0
  38. package/dist/util/storage.d.ts +24 -0
  39. package/dist/util/strings.d.ts +7 -0
  40. package/package.json +118 -0
  41. package/src/collections/CollectionRegistry.ts +319 -0
  42. package/src/collections/index.ts +1 -0
  43. package/src/index.ts +2 -0
  44. package/src/util/arrays.ts +3 -0
  45. package/src/util/builders.ts +138 -0
  46. package/src/util/callbacks.ts +115 -0
  47. package/src/util/collections.ts +126 -0
  48. package/src/util/common.ts +2 -0
  49. package/src/util/conditions.ts +348 -0
  50. package/src/util/dates.ts +1 -0
  51. package/src/util/entities.ts +212 -0
  52. package/src/util/entity_actions.ts +28 -0
  53. package/src/util/enums.ts +26 -0
  54. package/src/util/fields.ts +28 -0
  55. package/src/util/flatten_object.ts +45 -0
  56. package/src/util/hash.ts +11 -0
  57. package/src/util/index.ts +26 -0
  58. package/src/util/names.ts +30 -0
  59. package/src/util/navigation_from_path.ts +121 -0
  60. package/src/util/navigation_utils.ts +222 -0
  61. package/src/util/objects.ts +376 -0
  62. package/src/util/os.ts +13 -0
  63. package/src/util/parent_references_from_path.ts +57 -0
  64. package/src/util/paths.ts +27 -0
  65. package/src/util/permissions.test.ts +716 -0
  66. package/src/util/permissions.ts +235 -0
  67. package/src/util/plurals.ts +188 -0
  68. package/src/util/references.ts +34 -0
  69. package/src/util/regexp.ts +32 -0
  70. package/src/util/relations.ts +211 -0
  71. package/src/util/resolutions.ts +383 -0
  72. package/src/util/storage.ts +144 -0
  73. package/src/util/strings.ts +84 -0
@@ -0,0 +1,28 @@
1
+ import { EntityAction } from "@rebasepro/types";
2
+
3
+ const reservedKeys = ["edit", "copy", "delete"];
4
+
5
+ export function mergeEntityActions(currentActions: EntityAction[], newActions: EntityAction[]): EntityAction[] {
6
+ // given the current actions, replace the ones with the same key
7
+ // and append the new ones
8
+ const updatedActions: EntityAction[] = [];
9
+ currentActions.forEach(action => {
10
+ const newAction = newActions.find(a => a.key === action.key);
11
+ if (newAction) {
12
+ const mergedAction = {
13
+ ...action,
14
+ ...newAction
15
+ }
16
+ updatedActions.push(mergedAction);
17
+ } else {
18
+ updatedActions.push(action);
19
+ }
20
+ });
21
+ newActions.forEach(action => {
22
+ if (!currentActions.find(a => a.key === action.key) && (!action.key || !reservedKeys.includes(action.key))) {
23
+ updatedActions.push(action);
24
+ }
25
+ });
26
+ return updatedActions;
27
+
28
+ }
@@ -0,0 +1,26 @@
1
+ import { EnumValueConfig, EnumValues } from "@rebasepro/types";
2
+
3
+ export function enumToObjectEntries(enumValues: EnumValues): EnumValueConfig[] {
4
+ if (Array.isArray(enumValues)) {
5
+ return enumValues;
6
+ } else {
7
+ return Object.entries(enumValues).map(([id, value]) => {
8
+ if (typeof value === "string") {
9
+ return {
10
+ id,
11
+ label: value
12
+ }
13
+ } else {
14
+ return {
15
+ ...value,
16
+ id
17
+ }
18
+ }
19
+ });
20
+ }
21
+ }
22
+
23
+ export function getLabelOrConfigFrom(enumValues: EnumValueConfig[], key?: string | number): EnumValueConfig | undefined {
24
+ if (key === null || key === undefined) return undefined;
25
+ return enumValues.find((entry) => String(entry.id) === String(key));
26
+ }
@@ -0,0 +1,28 @@
1
+ import { DefaultFieldConfig } from "@rebasepro/types";
2
+
3
+ export function isDefaultFieldConfigId(id: string): id is DefaultFieldConfig {
4
+ return ["text_field",
5
+ "multiline",
6
+ "markdown",
7
+ "url",
8
+ "email",
9
+ "switch",
10
+ "select",
11
+ "multi_select",
12
+ "number_input",
13
+ "number_select",
14
+ "multi_number_select",
15
+ "file_upload",
16
+ "multi_file_upload",
17
+ "reference_as_string",
18
+ "reference",
19
+ "multi_references",
20
+ "relation",
21
+ "date_time",
22
+ "group",
23
+ "key_value",
24
+ "repeat",
25
+ "custom_array",
26
+ "block"
27
+ ].includes(id);
28
+ }
@@ -0,0 +1,45 @@
1
+ export function flattenObject(obj: Record<string, unknown>, parentKey = "") {
2
+ if (!obj) return obj;
3
+ return Object.keys(obj).reduce((flatObj, key) => {
4
+ const newKey = parentKey ? `${parentKey}.${key}` : key;
5
+
6
+ if (typeof obj[key] === "object" && obj[key] !== null) {
7
+ if (Array.isArray(obj[key])) {
8
+ obj[key].forEach((item: unknown, index: number) => {
9
+ Object.assign(flatObj, flattenObject(item as Record<string, unknown>, `${newKey}[${index}]`));
10
+ });
11
+ } else {
12
+ Object.assign(flatObj, flattenObject(obj[key] as Record<string, unknown>, newKey));
13
+ }
14
+ } else {
15
+ flatObj[newKey] = obj[key];
16
+ }
17
+
18
+ return flatObj;
19
+ }, {} as { [key: string]: unknown });
20
+ }
21
+
22
+
23
+ // map from nested property key like "a.b.c" to the maximum array count found in a list of objects for that array
24
+ export type ArrayValuesCount = Record<string, number>;
25
+
26
+ export function getArrayValuesCount(array: Record<string, unknown>[]): ArrayValuesCount {
27
+ return array.reduce((acc: ArrayValuesCount, obj: Record<string, unknown>) => {
28
+ Object.entries(obj).forEach(([key, value]) => {
29
+ // proceed only if value is an array
30
+ if (Array.isArray(value)) {
31
+ acc[key] = Math.max(acc[key] || 0, value.length);
32
+ }
33
+
34
+ // handle nested object
35
+ if (typeof value === "object" && value !== null) {
36
+ const nested = getArrayValuesCount([value as Record<string, unknown>]);
37
+ Object.entries(nested).forEach(([nestedKey, nestedCount]) => {
38
+ const compoundKey = `${key}.${nestedKey}`;
39
+ acc[compoundKey] = Math.max(acc[compoundKey] || 0, nestedCount);
40
+ });
41
+ }
42
+ });
43
+ return acc;
44
+ }, {});
45
+ }
@@ -0,0 +1,11 @@
1
+ export function hashString(str: string): number {
2
+ let hash = 0;
3
+ let i;
4
+ let chr;
5
+ for (i = 0; i < str.length; i++) {
6
+ chr = str.charCodeAt(i);
7
+ hash = ((hash << 5) - hash) + chr;
8
+ hash |= 0; // Convert to 32bit integer
9
+ }
10
+ return Math.abs(hash);
11
+ }
@@ -0,0 +1,26 @@
1
+ export * from "./collections";
2
+ export * from "./common";
3
+ export * from "./entities";
4
+ export * from "./strings";
5
+ export * from "./dates";
6
+ export * from "./enums";
7
+ export * from "./objects";
8
+ export * from "./paths";
9
+ export * from "./regexp";
10
+ export * from "./navigation_utils";
11
+ export * from "./entity_actions";
12
+ export * from "./fields";
13
+ export * from "./resolutions";
14
+ export * from "./permissions";
15
+ export * from "./plurals";
16
+ export * from "./references";
17
+ export * from "./flatten_object";
18
+ export * from "./navigation_from_path";
19
+ export * from "./parent_references_from_path";
20
+ export * from "./builders";
21
+ export * from "./storage";
22
+ export * from "./arrays";
23
+ export * from "./callbacks";
24
+ export * from "./hash";
25
+ export * from "./relations";
26
+ export * from "./conditions";
@@ -0,0 +1,30 @@
1
+ import { toSnakeCase } from "./strings";
2
+
3
+ /**
4
+ * Generates a foreign key column name from a given string, typically a collection slug or name.
5
+ * It converts the name to snake_case, attempts to singularize it by removing a trailing 's'
6
+ * (a common convention for collection names), and appends '_id'.
7
+ *
8
+ * @param name The base name to convert to a foreign key.
9
+ * @returns A foreign key name in the format 'singular_name_id'.
10
+ *
11
+ * @example
12
+ * // returns "user_id"
13
+ * generateForeignKeyName("users")
14
+ *
15
+ * @example
16
+ * // returns "post_id"
17
+ * generateForeignKeyName("posts")
18
+ *
19
+ * @example
20
+ * // returns "product_id"
21
+ * generateForeignKeyName("Product")
22
+ *
23
+ */
24
+ export function generateForeignKeyName(name: string): string {
25
+ const snakeCaseName = toSnakeCase(name);
26
+ // A simple heuristic to singularize a plural name, which is a common convention.
27
+ const singularName = snakeCaseName.endsWith("s") ? snakeCaseName.slice(0, -1) : snakeCaseName;
28
+ return `${singularName}_id`;
29
+ }
30
+
@@ -0,0 +1,121 @@
1
+ import { EntityCollection, EntityCustomView } from "@rebasepro/types";
2
+ import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
3
+ import { getSubcollections } from "./resolutions";
4
+
5
+ export type NavigationViewInternal<M extends Record<string, any> = any> =
6
+ | NavigationViewEntityInternal<M>
7
+ | NavigationViewCollectionInternal<M>
8
+ | NavigationViewEntityCustomInternal<M>;
9
+
10
+ export interface NavigationViewEntityInternal<M extends Record<string, any>> {
11
+ type: "entity";
12
+ entityId: string | number;
13
+ slug: string;
14
+ path: string;
15
+ parentCollection: EntityCollection<M>;
16
+ }
17
+
18
+ export interface NavigationViewCollectionInternal<M extends Record<string, any>> {
19
+ type: "collection";
20
+ id: string;
21
+ slug: string;
22
+ path: string;
23
+ collection: EntityCollection<M>;
24
+ }
25
+
26
+ export interface NavigationViewEntityCustomInternal<M extends Record<string, any>> {
27
+ type: "custom_view";
28
+ slug: string;
29
+ path: string;
30
+ entityId: string | number;
31
+ view: EntityCustomView<M>;
32
+ }
33
+
34
+ export function getNavigationEntriesFromPath(props: {
35
+ path: string,
36
+ collections: EntityCollection[] | undefined,
37
+ currentFullPath?: string,
38
+ contextEntityViews?: EntityCustomView[]
39
+ }): NavigationViewInternal[] {
40
+
41
+ const {
42
+ path,
43
+ collections = [],
44
+ currentFullPath,
45
+ } = props;
46
+
47
+ const subpaths = removeInitialAndTrailingSlashes(path).split("/");
48
+ const subpathCombinations = getCollectionPathsCombinations(subpaths);
49
+
50
+ const result: NavigationViewInternal[] = [];
51
+ for (let i = 0; i < subpathCombinations.length; i++) {
52
+ const subpathCombination = subpathCombinations[i];
53
+
54
+ const collection = collections && collections.find((entry) => entry.slug === subpathCombination);
55
+
56
+ if (collection) {
57
+ const collectionPath = currentFullPath && currentFullPath.length > 0
58
+ ? (currentFullPath + "/" + collection.slug)
59
+ : collection.slug;
60
+ result.push({
61
+ type: "collection",
62
+ id: collection.slug,
63
+ slug: collectionPath,
64
+ path: collectionPath,
65
+ collection
66
+ });
67
+ const restOfThePath = removeInitialAndTrailingSlashes(removeInitialAndTrailingSlashes(path).replace(subpathCombination, ""));
68
+ const nextSegments = restOfThePath.length > 0 ? restOfThePath.split("/") : [];
69
+ if (nextSegments.length > 0) {
70
+ const entityId = nextSegments[0];
71
+ const path = collectionPath + "/" + entityId;
72
+ result.push({
73
+ type: "entity",
74
+ entityId,
75
+ slug: collectionPath,
76
+ path,
77
+ parentCollection: collection
78
+ });
79
+ if (nextSegments.length > 1) {
80
+ const newPath = nextSegments.slice(1).join("/");
81
+ if (!collection) {
82
+ throw Error("collection not found resolving path: " + collection);
83
+ }
84
+ const entityViews = collection.entityViews;
85
+ const customView = entityViews && entityViews
86
+ .map((entry) => resolveEntityView(entry, props.contextEntityViews))
87
+ .filter(Boolean)
88
+ .find((entry) => entry!.key === newPath);
89
+ const subcollections = getSubcollections(collection);
90
+ if (customView) {
91
+ result.push({
92
+ type: "custom_view",
93
+ slug: collectionPath,
94
+ entityId: entityId,
95
+ path: path + "/" + customView.key,
96
+ view: customView
97
+ });
98
+ } else if (subcollections) {
99
+ result.push(...getNavigationEntriesFromPath({
100
+ path: newPath,
101
+ collections: subcollections,
102
+ currentFullPath: path,
103
+ contextEntityViews: props.contextEntityViews
104
+ }));
105
+ }
106
+ }
107
+ }
108
+ break;
109
+ }
110
+
111
+ }
112
+ return result;
113
+ }
114
+
115
+ function resolveEntityView(entityView: string | EntityCustomView, contextEntityViews?: EntityCustomView[]): EntityCustomView | undefined {
116
+ if (typeof entityView === "string") {
117
+ return contextEntityViews?.find((entry) => entry.key === entityView);
118
+ } else {
119
+ return entityView;
120
+ }
121
+ }
@@ -0,0 +1,222 @@
1
+ import { EntityCollection, CMSUrlController, SideEntityController } from "@rebasepro/types";
2
+ import { getSubcollections } from "./resolutions";
3
+
4
+ export function removeInitialAndTrailingSlashes(s: string): string {
5
+ return removeInitialSlash(removeTrailingSlash(s));
6
+ }
7
+
8
+ export function removeInitialSlash(s: string) {
9
+ if (s.startsWith("/"))
10
+ return s.slice(1);
11
+ else return s;
12
+ }
13
+
14
+ export function removeTrailingSlash(s: string) {
15
+ if (s.endsWith("/"))
16
+ return s.slice(0, -1);
17
+ else return s;
18
+ }
19
+
20
+ export function addInitialSlash(s: string) {
21
+ if (s.startsWith("/"))
22
+ return s;
23
+ else return `/${s}`;
24
+ }
25
+
26
+ export function getLastSegment(path: string) {
27
+ const cleanPath = removeInitialAndTrailingSlashes(path);
28
+ if (cleanPath.includes("/")) {
29
+ const segments = cleanPath.split("/");
30
+ return segments[segments.length - 1];
31
+ }
32
+ return cleanPath;
33
+ }
34
+
35
+ export function resolveCollectionPathIds(path: string, allCollections: EntityCollection[]): string {
36
+ let remainingPath = removeInitialAndTrailingSlashes(path);
37
+ if (!remainingPath) {
38
+ return "";
39
+ }
40
+
41
+ let currentCollections: EntityCollection[] | undefined = allCollections;
42
+ const resolvedPathParts: string[] = [];
43
+
44
+ while (remainingPath.length > 0) {
45
+ if (!currentCollections || currentCollections.length === 0) {
46
+ // We have remaining path segments but no more collections to match against
47
+ console.warn(`resolveCollectionPathIds: Path structure implies subcollections, but none found before segment starting with "${remainingPath}" in original path "${path}". Appending remaining original path.`);
48
+ resolvedPathParts.push(remainingPath);
49
+ remainingPath = ""; // Stop processing
50
+ break;
51
+ }
52
+
53
+ let foundMatch = false;
54
+ // Sort potential matches by length descending to prioritize longer matches (e.g., "a/b" over "a")
55
+ const potentialMatches: { col: EntityCollection; match: string; }[] = currentCollections
56
+ .flatMap(col => [{
57
+ col,
58
+ match: col.slug
59
+ }])
60
+ .filter(p => p.match && remainingPath.startsWith(p.match))
61
+ .sort((a, b) => b.match.length - a.match.length);
62
+
63
+ if (potentialMatches.length > 0) {
64
+ const {
65
+ col: foundCollection,
66
+ match: matchString
67
+ } = potentialMatches[0];
68
+
69
+ resolvedPathParts.push(foundCollection.dbPath); // Use the defined path
70
+ remainingPath = removeInitialSlash(remainingPath.substring(matchString.length));
71
+
72
+ // Check if we are at the end of the path
73
+ if (remainingPath.length === 0) {
74
+ foundMatch = true;
75
+ break; // Path ends with a collection segment
76
+ }
77
+
78
+ // The next segment must be an entity ID
79
+ const idSeparatorIndex = remainingPath.indexOf("/");
80
+ let entityId: string | number;
81
+ if (idSeparatorIndex > -1) {
82
+ entityId = remainingPath.substring(0, idSeparatorIndex);
83
+ remainingPath = remainingPath.substring(idSeparatorIndex + 1);
84
+ } else {
85
+ // This should not happen if the original path is valid (odd segments)
86
+ // but handle it defensively: assume the rest is the ID
87
+ entityId = remainingPath;
88
+ remainingPath = "";
89
+ console.warn(`resolveCollectionPathIds: Path seems to end with an entity ID "${entityId}" instead of a collection segment in original path "${path}". This might indicate an invalid input path.`);
90
+ // Even if it ends here, we still need to push the ID
91
+ }
92
+
93
+ resolvedPathParts.push(entityId); // Append entity ID
94
+ currentCollections = getSubcollections(foundCollection); // Move to subcollections
95
+ foundMatch = true;
96
+
97
+ if (!currentCollections && remainingPath.length > 0) {
98
+ // Warn if the path continues but no subcollections were defined
99
+ console.warn(`resolveCollectionPathIds: Path continues after entity ID "${entityId}", but no subcollections are defined for the preceding collection "${foundCollection.slug}" in path "${path}". Appending remaining original path.`);
100
+ resolvedPathParts.push(remainingPath); // Append the rest
101
+ remainingPath = ""; // Stop processing
102
+ break;
103
+ }
104
+
105
+ }
106
+
107
+ if (!foundMatch) {
108
+ // Collection definition not found for the start of the remaining path
109
+ console.warn(`resolveCollectionPathIds: Collection definition not found for segment starting with "${remainingPath}" in original path "${path}". Appending remaining original path.`);
110
+ resolvedPathParts.push(remainingPath); // Append the rest
111
+ remainingPath = ""; // Stop processing
112
+ break;
113
+ }
114
+ }
115
+
116
+ return resolvedPathParts.join("/");
117
+ }
118
+
119
+ /**
120
+ * Find the corresponding view at any depth for a given path.
121
+ * Note that path or segments of the paths can be collection aliases.
122
+ * @param slugOrPath
123
+ * @param collections
124
+ */
125
+ export function getCollectionBySlugWithin(slugOrPath: string, collections: EntityCollection[]): EntityCollection | undefined {
126
+
127
+ const subpaths = removeInitialAndTrailingSlashes(slugOrPath).split("/");
128
+ if (subpaths.length % 2 === 0) {
129
+ throw Error(`getCollectionBySlug: Collection paths must have an odd number of segments: ${slugOrPath}`);
130
+ }
131
+
132
+ const subpathCombinations = getCollectionPathsCombinations(subpaths);
133
+ let result: EntityCollection | undefined;
134
+ for (let i = 0; i < subpathCombinations.length; i++) {
135
+ const subpathCombination = subpathCombinations[i];
136
+ const navigationEntry = collections && collections
137
+ .sort((a, b) => (a.slug ?? "").localeCompare(b.slug ?? ""))
138
+ .find((entry) => entry.slug === subpathCombination);
139
+
140
+ if (navigationEntry) {
141
+
142
+ if (subpathCombination === slugOrPath) {
143
+ result = navigationEntry;
144
+ } else if (navigationEntry.subcollections) {
145
+ const newPath = slugOrPath.replace(subpathCombination, "").split("/").slice(2).join("/");
146
+ if (newPath.length > 0)
147
+ result = getCollectionBySlugWithin(newPath, getSubcollections(navigationEntry));
148
+ }
149
+ }
150
+ if (result) break;
151
+ }
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * Get the subcollection combinations from a path:
157
+ * "sites/es/locales" => ["sites/es/locales", "sites"]
158
+ * @param subpaths
159
+ */
160
+ export function getCollectionPathsCombinations(subpaths: string[]): string[] {
161
+ const entries = subpaths.length > 0 && subpaths.length % 2 === 0 ? subpaths.splice(0, subpaths.length - 1) : subpaths;
162
+
163
+ const length = entries.length;
164
+ const result: string[] = [];
165
+ for (let i = length; i > 0; i = i - 2) {
166
+ result.push(entries.slice(0, i).join("/"));
167
+ }
168
+ return result;
169
+
170
+ }
171
+
172
+ export function navigateToEntity({
173
+ openEntityMode,
174
+ collection,
175
+ entityId,
176
+ copy,
177
+ path,
178
+ selectedTab,
179
+ sideEntityController,
180
+ onClose,
181
+ navigation
182
+ }:
183
+
184
+ {
185
+ openEntityMode: "side_panel" | "full_screen";
186
+ collection?: EntityCollection;
187
+ entityId?: string | number;
188
+ selectedTab?: string;
189
+ copy?: boolean;
190
+ path: string;
191
+ sideEntityController: SideEntityController;
192
+ onClose?: () => void;
193
+ navigation: CMSUrlController
194
+ }) {
195
+
196
+ if (openEntityMode === "side_panel") {
197
+
198
+ sideEntityController.open({
199
+ entityId,
200
+ path: path,
201
+ copy,
202
+ selectedTab,
203
+ collection,
204
+ updateUrl: true,
205
+ onClose
206
+ });
207
+
208
+ } else {
209
+ let to = navigation.buildUrlCollectionPath(entityId ? `${path ?? path}/${entityId}` : path ?? path);
210
+ if (entityId && selectedTab) {
211
+ to += `/${selectedTab}`;
212
+ }
213
+ if (!entityId) {
214
+ to += "#new";
215
+ }
216
+ if (copy) {
217
+ to += "#copy";
218
+ }
219
+ navigation.navigate(to);
220
+ }
221
+
222
+ }