@panneau/core 4.0.78 → 4.1.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/index.js CHANGED
@@ -1,13 +1,12 @@
1
+ import isObject from 'lodash-es/isObject';
1
2
  import { flattenComponents, getComponentFromName } from '@panneau/core/utils';
2
- import { isObject } from 'lodash';
3
3
  import isArray from 'lodash-es/isArray';
4
4
  import uniqBy from 'lodash-es/uniqBy';
5
5
  import EventEmitter from 'wolfy87-eventemitter';
6
- import isObject$1 from 'lodash-es/isObject';
7
- import uniq from 'lodash-es/uniq';
8
6
  import { Tracking as Tracking$1 } from '@folklore/tracking';
9
7
 
10
8
  class ComponentsManager {
9
+ // Nested components maps are flattened with their key as namespace
11
10
  constructor(components = {}) {
12
11
  this.components = flattenComponents(components);
13
12
  }
@@ -175,7 +174,7 @@ class FontsParser {
175
174
  theme = null,
176
175
  components = []
177
176
  } = story || {};
178
- const fonts = uniq(components.reduce((currentFonts, screen) => {
177
+ const fonts = uniqBy(components.reduce((currentFonts, screen) => {
179
178
  const {
180
179
  type
181
180
  } = screen;
@@ -196,7 +195,7 @@ class FontsParser {
196
195
  return fonts.length > 0 || themeFonts.length > 0 ? {
197
196
  ...story,
198
197
  theme: newTheme,
199
- fonts: uniq([...themeFonts, ...fonts], 'name')
198
+ fonts: uniqBy([...themeFonts, ...fonts], 'name')
200
199
  } : story;
201
200
  }
202
201
  return fonts.length > 0 ? {
@@ -244,9 +243,9 @@ class FontsParser {
244
243
  const value = data[key];
245
244
  let font = null;
246
245
  let subFonts = null;
247
- if (patternMatch && isObject$1(value) && FontsParser.valueIsFont(value)) {
246
+ if (patternMatch && isObject(value) && FontsParser.valueIsFont(value)) {
248
247
  font = value;
249
- } else if (isObject$1(value) || isArray(value)) {
248
+ } else if (isObject(value) || isArray(value)) {
250
249
  subFonts = FontsParser.extractFontsWithPaths(value, patterns, path);
251
250
  }
252
251
  return subFonts !== null || font !== null ? [...currentFonts, ...(subFonts || []), ...(font !== null ? [font] : [])] : currentFonts;
@@ -259,7 +258,7 @@ class FormsManager extends DefinitionsManager {}
259
258
  class MediasParser {
260
259
  constructor({
261
260
  fieldsManager,
262
- screensManager
261
+ screensManager = null
263
262
  }) {
264
263
  this.fieldsManager = fieldsManager;
265
264
  this.screensManager = screensManager;
@@ -286,7 +285,7 @@ class MediasParser {
286
285
  } = screen;
287
286
  const {
288
287
  fields = []
289
- } = this.screensManager.getDefinition(type) || {};
288
+ } = this.screensManager?.getDefinition(type) || {};
290
289
  const fieldsPattern = this.getMediaFieldsPattern(fields);
291
290
  const {
292
291
  data: newScreen,
@@ -346,7 +345,7 @@ class MediasParser {
346
345
  } = screen;
347
346
  const {
348
347
  fields = []
349
- } = this.screensManager.getDefinition(type) || {};
348
+ } = this.screensManager?.getDefinition(type) || {};
350
349
  const fieldsPattern = this.getMediaFieldsPattern(fields);
351
350
  return MediasParser.replacePathsWithMedias(screen, medias, fieldsPattern);
352
351
  }) : components;
@@ -405,9 +404,9 @@ class MediasParser {
405
404
  const value = data[key];
406
405
  let newValue;
407
406
  if (patternMatch) {
408
- newValue = isObject$1(value) ? value : medias[value] || null;
407
+ newValue = isObject(value) ? value : medias[value] || null;
409
408
  } else {
410
- newValue = isObject$1(value) || isArray(value) ? MediasParser.replacePathsWithMedias(value, medias, patterns, path) : value;
409
+ newValue = isObject(value) || isArray(value) ? MediasParser.replacePathsWithMedias(value, medias, patterns, path) : value;
411
410
  }
412
411
  return dataIsArray ? [...newData, newValue] : {
413
412
  ...newData,
@@ -433,11 +432,11 @@ class MediasParser {
433
432
  let newValue;
434
433
  let media = null;
435
434
  let subMedias = null;
436
- if (patternMatch && isObject$1(value)) {
435
+ if (patternMatch && isObject(value)) {
437
436
  const mediaPath = MediasParser.getMediaPath(value);
438
437
  newValue = mediaPath !== null ? mediaPath : value;
439
438
  media = mediaPath !== null ? value : null;
440
- } else if (isObject$1(value) || isArray(value)) {
439
+ } else if (isObject(value) || isArray(value)) {
441
440
  const subReturn = MediasParser.replaceMediasWithPaths(value, patterns, medias, path);
442
441
  newValue = subReturn.data;
443
442
  subMedias = subReturn.medias;
@@ -459,7 +458,7 @@ class MediasParser {
459
458
  }
460
459
  };
461
460
  }, {
462
- data: null,
461
+ data: dataIsArray ? [] : {},
463
462
  medias
464
463
  });
465
464
  }
@@ -467,16 +466,6 @@ class MediasParser {
467
466
 
468
467
  /* eslint-disable no-console */
469
468
  class Tracking extends Tracking$1 {
470
- constructor(opts = {}) {
471
- super(opts);
472
- const {
473
- variables = null
474
- } = this.options;
475
- this.variables = null;
476
- if (variables !== null) {
477
- this.setVariables(variables);
478
- }
479
- }
480
469
  setVariables(variables) {
481
470
  this.variables = variables;
482
471
  if (variables !== null) {
@@ -499,10 +488,15 @@ class Tracking extends Tracking$1 {
499
488
  };
500
489
  this.push(data);
501
490
  }
502
- trackEvent(category, action, label, {
503
- value = null,
504
- ...opts
505
- } = {}) {
491
+
492
+ // The options can also be a value, to stay compatible with the base trackEvent signature
493
+ trackEvent(category, action, label, options = {}) {
494
+ const {
495
+ value = null,
496
+ ...opts
497
+ } = isObject(options) ? options : {
498
+ value: options
499
+ };
506
500
  const data = {
507
501
  ...opts,
508
502
  event: 'eventInteraction',
package/dist/utils.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { kebabCase as dashCase, pascalCase, snakeCase } from 'change-case';
2
- import { ElementType, ReactNode, ReactElement, ForwardedRef } from 'react';
3
- import { MessageDescriptor } from 'react-intl';
2
+ import { ElementType, ComponentProps, ReactNode, ReactElement, ForwardedRef } from 'react';
3
+ import { FormattedMessage, MessageDescriptor } from 'react-intl';
4
4
 
5
5
  interface CheckClickableOptions {
6
6
  maxParentDistance?: number;
@@ -26,11 +26,6 @@ declare function createUseEvent(eventsManager: EventsManager | null): (event: st
26
26
  /*! clipboard-copy. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
27
27
  declare function copyToClipboard(text: string): Promise<void>;
28
28
 
29
- type ComponentMap = Record<string, ElementType | Record<string, unknown>>;
30
- declare function flattenComponents(components: ComponentMap | null, prefix?: string | null): Record<string, ElementType> | null;
31
-
32
- declare function formatDuration(timeInSeconds: number | string): string;
33
-
34
29
  /**
35
30
  * Core UI Types
36
31
  */
@@ -39,10 +34,22 @@ declare function formatDuration(timeInSeconds: number | string): string;
39
34
  * Internationalization message definition
40
35
  */
41
36
  type Message = MessageDescriptor;
37
+ /**
38
+ * Values that can be interpolated in an internationalization message
39
+ * (the `values` prop of FormattedMessage)
40
+ */
41
+ type MessageValues = NonNullable<ComponentProps<typeof FormattedMessage>['values']>;
42
+ type MessageValue = MessageValues[string];
42
43
  /**
43
44
  * Label can be either a message object or any React node
44
45
  */
45
46
  type Label = Message | ReactNode;
47
+ /**
48
+ * Map of components, can be nested by namespace (ex: { fields: { text: TextField } })
49
+ */
50
+ interface ComponentsMap {
51
+ [name: string]: ElementType | ComponentsMap;
52
+ }
46
53
  /**
47
54
  * Form control size
48
55
  */
@@ -106,8 +113,6 @@ interface Form {
106
113
  submitButtonLabel?: Label | null;
107
114
  [key: string]: unknown;
108
115
  }
109
- interface FormDefinition extends Form, Definition {
110
- }
111
116
 
112
117
  /**
113
118
  * Resource Types
@@ -120,6 +125,7 @@ interface FormDefinition extends Form, Definition {
120
125
  type TableColumn = string | Column;
121
126
  interface Column extends Record<string, unknown> {
122
127
  id?: string | null;
128
+ name?: string | null;
123
129
  label?: unknown;
124
130
  path?: string;
125
131
  valueKey?: string;
@@ -145,7 +151,7 @@ type ActionDefinition = string | Action;
145
151
  interface ResourceIntlValues {
146
152
  a_singular?: string;
147
153
  a_plural?: string;
148
- [key: string]: unknown;
154
+ [key: string]: MessageValue;
149
155
  }
150
156
  /**
151
157
  * Internationalization configuration
@@ -173,13 +179,32 @@ interface ResourcePages {
173
179
  duplicate?: PageDefinition;
174
180
  [key: string]: PageDefinition | undefined;
175
181
  }
182
+ /**
183
+ * Form definition of a resource (the id is optional, the form is identified by its key)
184
+ */
185
+ interface ResourceForm extends Form {
186
+ id?: string;
187
+ component?: string | ElementType;
188
+ }
176
189
  interface ResourceForms {
177
- default: FormDefinition | null;
178
- create: FormDefinition | null;
179
- edit: FormDefinition | null;
180
- delete: FormDefinition | null;
181
- modal: FormDefinition | null;
182
- [key: string]: FormDefinition | null;
190
+ default?: ResourceForm | null;
191
+ create?: ResourceForm | null;
192
+ edit?: ResourceForm | null;
193
+ delete?: ResourceForm | null;
194
+ modal?: ResourceForm | null;
195
+ [key: string]: ResourceForm | null | undefined;
196
+ }
197
+ /**
198
+ * Resource type (sub-type of a resource, ex: page types)
199
+ */
200
+ interface ResourceType {
201
+ id?: string;
202
+ name?: string;
203
+ fields?: Field[];
204
+ settings?: {
205
+ canCreate?: boolean;
206
+ [key: string]: unknown;
207
+ };
183
208
  }
184
209
  interface Resource {
185
210
  id: string;
@@ -191,6 +216,7 @@ interface Resource {
191
216
  settings?: Record<string, unknown>;
192
217
  extraRoutes?: RouteDefinition[];
193
218
  pages?: ResourcePages;
219
+ types?: ResourceType[];
194
220
  }
195
221
  /**
196
222
  * Filter definition
@@ -201,6 +227,10 @@ interface Filter {
201
227
  [key: string]: unknown;
202
228
  }
203
229
 
230
+ declare function flattenComponents(components: ComponentsMap | null, prefix?: string | null): Record<string, ElementType> | null;
231
+
232
+ declare function formatDuration(timeInSeconds: number | string): string;
233
+
204
234
  declare function getColumnsWithFields(resource: Resource, columns: TableColumn[] | null): Column[];
205
235
 
206
236
  interface ComponentResult {
@@ -239,6 +269,8 @@ interface FieldWithSubFields extends Field {
239
269
  type?: string | null;
240
270
  fields?: Field[] | null;
241
271
  field?: Field | null;
272
+ settings?: Field[] | null;
273
+ itemsField?: Field | null;
242
274
  listItems?: boolean;
243
275
  }
244
276
  declare function getFieldFromPath(path: string | string[], fields: Field[], fieldManager: FieldManager): FieldWithSubFields | null;
@@ -259,6 +291,16 @@ declare function loadPackage<T = unknown>(name: string, loader: () => Promise<T>
259
291
 
260
292
  declare function mergeRefs<T>(...refs: Array<ForwardedRef<T> | null | undefined>): (value: T | null) => void;
261
293
 
294
+ /**
295
+ * Parse a date-only value as a local date, ignoring its timezone.
296
+ *
297
+ * A date stored at midnight represents a calendar day, not an instant: converting it to the
298
+ * browser timezone would show the previous day west of the server timezone (ex: a date saved as
299
+ * 2026-10-01 00:00 UTC is 2026-09-30 20:00 in Montreal). Returns null when the value has a time,
300
+ * so the caller can parse it as an instant.
301
+ */
302
+ declare function parseDateOnly(value: string | null | undefined): Date | null;
303
+
262
304
  declare function toggleSelectedItem(items: Item[] | null, item: Item, { multiple: multipleSelection }?: {
263
305
  multiple?: boolean;
264
306
  }): Item[] | null;
@@ -277,4 +319,4 @@ interface FieldWithValidation extends Field {
277
319
  }
278
320
  declare function validateFields(fields: FieldWithValidation[], value: Record<string, unknown> | null): boolean;
279
321
 
280
- export { checkClickable, convertStyleToString, copyToClipboard, createNullableOnChange, createUseEvent, flattenComponents, formatDuration, getColumnsWithFields, getComponent, getComponentFromName, getDefinitionFromId, getDisplayName, getFieldByName, getFieldFromPath, getFileName, getPathValue, getSelectOptions, isMessage, loadPackage, mergeRefs, selectItems, setValue as setFieldValue, slug, toggleSelectedItem, unselectItems, validateFields };
322
+ export { checkClickable, convertStyleToString, copyToClipboard, createNullableOnChange, createUseEvent, flattenComponents, formatDuration, getColumnsWithFields, getComponent, getComponentFromName, getDefinitionFromId, getDisplayName, getFieldByName, getFieldFromPath, getFileName, getPathValue, getSelectOptions, isMessage, loadPackage, mergeRefs, parseDateOnly, selectItems, setValue as setFieldValue, slug, toggleSelectedItem, unselectItems, validateFields };
package/dist/utils.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import isNumber from 'lodash-es/isNumber';
2
- import { snakeCase } from 'snake-case';
3
2
  import isObject from 'lodash-es/isObject';
4
3
  import { useEffect, isValidElement } from 'react';
5
4
  import { isValidElementType } from 'react-is';
6
5
  import isString from 'lodash-es/isString';
7
- import { pascalCase, kebabCase, snakeCase as snakeCase$1 } from 'change-case';
6
+ import { pascalCase, kebabCase, snakeCase } from 'change-case';
8
7
  export { kebabCase as dashCase, pascalCase, snakeCase } from 'change-case';
9
8
  import isFunction from 'lodash-es/isFunction';
10
9
  import isArray from 'lodash-es/isArray';
@@ -44,8 +43,17 @@ function checkClickable(el, options = {}, parentDistance = 1) {
44
43
  return false;
45
44
  }
46
45
 
46
+ // Convert a camelCase style property (ex: backgroundColor) to its CSS kebab-case
47
+ // name (ex: background-color). Custom properties (--foo) are kept as is.
48
+ function toCssProperty(key) {
49
+ if (key.startsWith('--')) {
50
+ return key;
51
+ }
52
+ const property = key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
53
+ return property.startsWith('ms-') ? `-${property}` : property;
54
+ }
47
55
  function convertStyleToString(style) {
48
- return style !== null ? Object.keys(style).map(key => `${snakeCase(key)}:${isNumber(style[key]) ? `${style[key]}px` : style[key]};`).join('\n') : '';
56
+ return style !== null ? Object.keys(style).map(key => `${toCssProperty(key)}:${isNumber(style[key]) ? `${style[key]}px` : style[key]};`).join('\n') : '';
49
57
  }
50
58
 
51
59
  function createNullableOnChange(onChange = null) {
@@ -142,12 +150,15 @@ function flattenComponents(components, prefix = null) {
142
150
  if (components === null) {
143
151
  return null;
144
152
  }
145
- return Object.keys(components).reduce((newMap, key) => isValidElementType(components[key]) ? {
146
- ...newMap,
147
- [prefix !== null ? `${prefix}.${key}` : key]: components[key]
148
- } : {
149
- ...newMap,
150
- ...flattenComponents(components[key], key)
153
+ return Object.keys(components).reduce((newMap, key) => {
154
+ const path = prefix !== null ? `${prefix}.${key}` : key;
155
+ return isValidElementType(components[key]) ? {
156
+ ...newMap,
157
+ [path]: components[key]
158
+ } : {
159
+ ...newMap,
160
+ ...flattenComponents(components[key], path)
161
+ };
151
162
  }, {});
152
163
  }
153
164
 
@@ -275,6 +286,9 @@ function getFieldByName(fields, name) {
275
286
  }
276
287
 
277
288
  function getFieldFromPath(path, fields, fieldManager) {
289
+ const initialField = {
290
+ fields
291
+ };
278
292
  return (isArray(path) ? path : [path]).reduce((foundField, key) => {
279
293
  if (foundField === null) {
280
294
  return null;
@@ -298,9 +312,7 @@ function getFieldFromPath(path, fields, fieldManager) {
298
312
  };
299
313
  }
300
314
  return getFieldByName([...(fieldFields || []), ...(subFields || []), ...(settings || [])], key);
301
- }, {
302
- fields
303
- });
315
+ }, initialField);
304
316
  }
305
317
 
306
318
  function getFileName(url = null) {
@@ -359,24 +371,24 @@ function loadPackage(name, loader) {
359
371
  loaders[name].resource = response;
360
372
  events.emit(`loaded:${name}`, response);
361
373
  return response;
374
+ }, error => {
375
+ // Remove the failed loader so a later call can retry
376
+ delete loaders[name];
377
+ throw error;
362
378
  }),
363
379
  loaded: false,
364
380
  resource: null
365
381
  };
366
382
  }
367
- return new Promise(resolve => {
368
- const {
369
- loaded = false,
370
- resource
371
- } = loaders[name] || {};
372
- if (loaded === true) {
373
- resolve(resource);
374
- } else {
375
- events.once(`loaded:${name}`, response => {
376
- resolve(response);
377
- });
378
- }
379
- });
383
+ const {
384
+ loaded = false,
385
+ resource = null,
386
+ loader: currentLoader = null
387
+ } = loaders[name];
388
+ if (loaded === true) {
389
+ return Promise.resolve(resource);
390
+ }
391
+ return currentLoader;
380
392
  }
381
393
 
382
394
  function mergeRefs(...refs) {
@@ -391,6 +403,30 @@ function mergeRefs(...refs) {
391
403
  };
392
404
  }
393
405
 
406
+ // A date without time (2026-10-01), or a date at midnight with or without a timezone
407
+ // (2026-10-01T00:00:00-04:00, 2026-10-01T00:00:00.000000Z, 2026-10-01 00:00:00)
408
+ const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]00:00(?::00(?:\.0+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
409
+
410
+ /**
411
+ * Parse a date-only value as a local date, ignoring its timezone.
412
+ *
413
+ * A date stored at midnight represents a calendar day, not an instant: converting it to the
414
+ * browser timezone would show the previous day west of the server timezone (ex: a date saved as
415
+ * 2026-10-01 00:00 UTC is 2026-09-30 20:00 in Montreal). Returns null when the value has a time,
416
+ * so the caller can parse it as an instant.
417
+ */
418
+ function parseDateOnly(value) {
419
+ if (typeof value !== 'string') {
420
+ return null;
421
+ }
422
+ const match = DATE_ONLY_PATTERN.exec(value.trim());
423
+ if (match === null) {
424
+ return null;
425
+ }
426
+ const [, year, month, day] = match;
427
+ return new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
428
+ }
429
+
394
430
  function toggleSelectedItem(items, item, {
395
431
  multiple: multipleSelection = false
396
432
  } = {}) {
@@ -441,8 +477,18 @@ function setValue(value, keyParts, fieldValue) {
441
477
  if (value !== null || fieldValue !== null) {
442
478
  if (isArray) {
443
479
  const index = parseInt(key, 10);
444
- const arrayValue = value;
445
- const newArrayValue = fieldValue !== null ? [...arrayValue.slice(0, index), keyParts.length > 0 ? setValue(arrayValue !== null ? arrayValue[index] || null : null, keyParts, fieldValue) : fieldValue, ...arrayValue.slice(index + 1)] : [...arrayValue.slice(0, index), ...arrayValue.slice(index + 1)];
480
+ const arrayValue = Array.isArray(value) ? value : [];
481
+ let newArrayValue;
482
+ if (fieldValue !== null) {
483
+ newArrayValue = [...arrayValue];
484
+ // Pad with null so an out-of-range index lands at the right position
485
+ while (newArrayValue.length < index) {
486
+ newArrayValue.push(null);
487
+ }
488
+ newArrayValue[index] = keyParts.length > 0 ? setValue(arrayValue[index] || null, keyParts, fieldValue) : fieldValue;
489
+ } else {
490
+ newArrayValue = [...arrayValue.slice(0, index), ...arrayValue.slice(index + 1)];
491
+ }
446
492
  return newArrayValue.length > 0 ? newArrayValue : null;
447
493
  }
448
494
  const objectValue = value;
@@ -459,7 +505,7 @@ function slug(str, separator = null) {
459
505
  if (separator === '-') {
460
506
  toSlug = kebabCase(str);
461
507
  } else {
462
- toSlug = snakeCase$1(str);
508
+ toSlug = snakeCase(str);
463
509
  }
464
510
  return slugify(toSlug, {
465
511
  lower: true
@@ -472,11 +518,13 @@ function validateFields(fields, value) {
472
518
  if (field.type === 'fields' && field.fields) {
473
519
  return validateFields(field.fields, value);
474
520
  }
475
- const val = value && field.name && value[field.name] ? value[field.name] : false;
476
- return !(field.required && !val);
521
+ const val = value && field.name ? value[field.name] : null;
522
+ // Only null, undefined and empty strings are considered missing (0 and false are valid)
523
+ const isMissing = val === null || typeof val === 'undefined' || val === '';
524
+ return !(field.required && isMissing);
477
525
  }
478
526
  return acc;
479
527
  }, true);
480
528
  }
481
529
 
482
- export { checkClickable, convertStyleToString, copyToClipboard, createNullableOnChange, createUseEvent, flattenComponents, formatDuration, getColumnsWithFields, getComponent, getComponentFromName, getDefinitionFromId, getDisplayName, getFieldByName, getFieldFromPath, getFileName, getPathValue, getSelectOptions, isMessage, loadPackage, mergeRefs, selectItems, setValue as setFieldValue, slug, toggleSelectedItem, unselectItems, validateFields };
530
+ export { checkClickable, convertStyleToString, copyToClipboard, createNullableOnChange, createUseEvent, flattenComponents, formatDuration, getColumnsWithFields, getComponent, getComponentFromName, getDefinitionFromId, getDisplayName, getFieldByName, getFieldFromPath, getFileName, getPathValue, getSelectOptions, isMessage, loadPackage, mergeRefs, parseDateOnly, selectItems, setValue as setFieldValue, slug, toggleSelectedItem, unselectItems, validateFields };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panneau/core",
3
- "version": "4.0.78",
3
+ "version": "4.1.0",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "javascript"
@@ -75,7 +75,7 @@
75
75
  "debug": "^4.3.4",
76
76
  "flat": "^6.0.1",
77
77
  "hoist-non-react-statics": "^3.3.2",
78
- "lodash": "^4.17.21",
78
+ "lodash-es": "^4.17.21",
79
79
  "react-intl": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^10.0.0",
80
80
  "react-is": "^19.2.5",
81
81
  "slugify": "^1.5.0",
@@ -96,5 +96,5 @@
96
96
  "publishConfig": {
97
97
  "access": "public"
98
98
  },
99
- "gitHead": "669f20a39ca9545acbe0f2279c6d1af23e43ae6e"
99
+ "gitHead": "8a6accc1aab59368bf546c83b9433dfe94ccf767"
100
100
  }