PluginProbe ʕ •ᴥ•ʔ
GiveWP – Donation Plugin and Fundraising Platform / 4.12.0
GiveWP – Donation Plugin and Fundraising Platform v4.12.0
4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 2.3.1 2.3.2 2.30.0 2.31.0 2.31.1 2.32.0 2.33.0 2.33.1 2.33.2 2.33.3 2.33.4 2.33.5 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.4.7 2.5.0 2.5.1 2.5.10 2.5.11 2.5.12 2.5.13 2.5.2 2.5.3 2.5.4 2.5.5 2.5.6 2.5.7 2.5.8 2.5.9 2.6.0 2.6.1 2.6.2 2.6.3 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.8.0 2.8.1 2.9.0 2.9.1 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.10.0 3.11.0 3.12.0 3.12.1 3.12.2 3.12.3 3.13.0 3.14.0 3.14.1 3.14.2 3.15.0 3.15.1 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.16.5 3.17.0 3.17.1 3.17.2 3.18.0 3.19.0 3.19.1 3.19.2 3.19.3 3.19.4 3.2.0 3.2.1 3.2.2 3.20.0 3.21.0 3.21.1 3.22.0 3.22.1 3.22.2 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.6.0 3.6.1 3.6.2 3.7.0 3.8.0 3.9.0 4.0.0 4.1.0 4.1.1 4.10.0 4.10.1 4.11.0 4.12.0 4.13.0 4.13.1 4.13.2 4.14.0 4.14.1 4.14.2 4.14.3 4.14.4 4.14.5 4.14.6 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.1 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 trunk 1.9.0 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.10.0 2.10.1 2.10.2 2.10.3 2.10.4 2.11.0 2.11.1 2.11.2 2.11.3 2.12.0 2.12.1 2.12.2 2.12.3 2.13.0 2.13.1 2.13.2 2.13.3 2.13.4 2.14.0 2.15.0 2.16.0 2.16.1 2.17.0 2.17.1 2.17.3 2.18.0 2.18.1 2.19.1 2.19.2 2.19.3 2.19.4 2.19.5 2.19.6 2.19.7 2.19.8 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.20.0 2.20.1 2.20.2 2.21.0 2.21.1 2.21.2 2.21.3 2.21.4 2.22.0 2.22.1 2.22.2 2.22.3 2.23.0 2.23.1 2.23.2 2.24.0 2.24.1 2.24.2 2.25.0 2.25.1 2.25.2 2.25.3 2.26.0 2.27.0 2.27.1 2.27.2 2.27.3 2.28.0 2.29.0 2.29.1 2.29.2
give / src / Admin / ajv.ts
give / src / Admin Last commit date
common 9 months ago components 8 months ago fields 8 months ago hooks 8 months ago ajv.ts 9 months ago types.ts 8 months ago utils.ts 9 months ago
ajv.ts
542 lines
1 import {__, sprintf} from '@wordpress/i18n';
2 import {JSONSchemaType} from 'ajv';
3 import addErrors from 'ajv-errors';
4 import addFormats from 'ajv-formats';
5
6 /**
7 * Create an AJV resolver for react-hook-form with WordPress REST API schema
8 *
9 * This function creates a custom resolver that works with WordPress REST API schemas (Draft 03/04)
10 * and provides enhanced frontend validation using AJV (Draft 7/2019-09). It handles:
11 * - Transforming WordPress Draft 03/04 schema to Draft 7/2019-09 for AJV compatibility
12 * - Data transformation before validation (string numbers, enum handling, etc.)
13 * - Error handling and fallback behavior
14 *
15 * Key advantage: WordPress REST API supports most JSON Schema Draft 4 features but lacks
16 * some advanced features (if/then/else, allOf, not) that AJV can provide for enhanced frontend validation.
17 *
18 * @since 4.10.0 Refactor transformWordPressSchemaToDraft7 to handle readonly/readOnly fields and conditionally remove enum from nullable fields when value is null to prevent AJV conflicts
19 * @since 4.9.0
20 *
21 * @param schema - The JSON Schema from WordPress REST API
22 * @returns A resolver function compatible with react-hook-form
23 */
24 export function ajvResolver(schema: JSONSchemaType<any>) {
25 return (data: any) => {
26 try {
27 // Ensure we have valid data to validate
28 if (!data || typeof data !== 'object') {
29 return {values: data || {}, errors: {}};
30 }
31
32 const transformedData = transformFormDataForValidation(data, schema);
33 const ajv = configureAjvForWordPress();
34 const transformedSchema = transformWordPressSchemaToDraft7(schema, data);
35 const validate = ajv.compile(transformedSchema);
36 const valid = validate(transformedData);
37
38 if (valid) {
39 return {values: transformedData, errors: {}};
40 } else {
41 console.error('🔴 Validation failed, errors:', validate.errors);
42 const errors: any = {};
43 if (validate.errors) {
44 validate.errors.forEach((error) => {
45 const path = error.instancePath || error.schemaPath;
46 if (path) {
47 const fieldName = path.replace('/', '');
48 // Use the error message from ajv-errors
49 // ajv-errors should provide the custom message in error.message
50 const errorMessage = error.message || sprintf(__('%s is invalid.', 'give'), fieldName);
51
52 errors[fieldName] = {
53 type: 'validation',
54 message: errorMessage,
55 };
56 }
57 });
58 }
59 return {values: {}, errors};
60 }
61 } catch (error) {
62 console.error('AJV validation error:', error);
63 return {values: data, errors: {}};
64 }
65 };
66 }
67
68 /**
69 * Configure standard AJV (Draft 7/2019-09) for WordPress REST API compatibility
70 *
71 * WordPress REST API Schema Characteristics (Draft 03/04):
72 * - Uses 'required: true' on individual properties (Draft 03 syntax)
73 * - Uses 'readonly' property (lowercase, WordPress REST API standard)
74 * - Supports most JSON Schema Draft 4 features including:
75 * * anyOf, oneOf (since WordPress 5.6.0)
76 * * Basic validation: type, format, enum, pattern, constraints
77 * * Object/array validation: properties, items, additionalProperties
78 * - Does NOT support: allOf, not, if/then/else (conditional validation)
79 *
80 * This function configures AJV (Draft 7/2019-09) to work with WordPress schemas:
81 * - Transforms WordPress Draft 03/04 schemas to Draft 7/2019-09 syntax
82 * - Converts 'required: true' on individual properties to 'required' array at object level
83 * - Adds all standard JSON Schema formats using ajv-formats package
84 * - Adds custom error messages using ajv-errors package
85 * - Adds WordPress-specific custom formats (text-field, textarea-field)
86 * - Disables schema validation to avoid conflicts with WordPress schema extensions
87 * - Enables advanced validation features that WordPress ignores
88 *
89 * Validation Features Available:
90 *
91 * Backend (WordPress REST API) + Frontend (AJV):
92 * - Type validation (string, integer, boolean, number, array, object, null)
93 * - Required field validation
94 * - Format validation (email, date-time, uri, ip, hex-color, uuid, text-field, textarea-field, and all standard formats via ajv-formats)
95 * - Enum validation
96 * - Constraint validation (minLength, maxLength, minimum, maximum, etc.)
97 * - Pattern validation (regex)
98 * - Array validation (items, minItems, maxItems, uniqueItems)
99 * - Object validation (properties, additionalProperties, patternProperties)
100 * - Schema composition: anyOf, oneOf (WordPress 5.6.0+)
101 *
102 * Frontend Only (AJV Draft 7/2019-09):
103 * - Conditional validation (if/then/else)
104 * - Advanced schema composition (allOf, not)
105 * - Advanced references ($ref with complex paths)
106 * - Dependent required fields
107 * - Dynamic validation based on other field values
108 * - More comprehensive format validation
109 *
110 * References:
111 * - WordPress REST API Schema Documentation: https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/
112 * - It only supports a subset of the draft-04 and draft-03 meta-schemas: https://developer.wordpress.org/news/2024/07/json-schema-in-wordpress/#wordpress-rest-api
113 * - WordPress 5.6.0 anyOf/oneOf Support: https://core.trac.wordpress.org/ticket/51025
114 * - Implementation Changeset: https://core.trac.wordpress.org/changeset/49246
115 * - WordPress uses 'readonly' (lowercase) vs JSON Schema 'readOnly': https://core.trac.wordpress.org/ticket/56152
116 * - WordPress 5.5.0 UUID format support: https://core.trac.wordpress.org/ticket/50053
117 * - WordPress 5.9.0 text-field/textarea-field formats: https://core.trac.wordpress.org/changeset/49246
118 * - rest_validate_value_from_schema(): https://developer.wordpress.org/reference/functions/rest_validate_value_from_schema/
119 * - rest_get_allowed_schema_keywords(): https://developer.wordpress.org/reference/functions/rest_get_allowed_schema_keywords/
120 *
121 * @returns Configured AJV instance (Draft 7/2019-09) for WordPress compatibility
122 */
123 function configureAjvForWordPress() {
124 // Use standard AJV (Draft 7/2019-09) and transform WordPress schemas to be compatible
125 const AjvClass = require('ajv').default || require('ajv');
126
127 const ajv = new AjvClass({
128 // Disable schema validation to avoid conflicts with WordPress schemas
129 validateSchema: false,
130 // Disable strict mode to allow WordPress extensions like 'readonly'
131 strict: false,
132 // Enable all errors for better debugging
133 allErrors: true,
134 verbose: true,
135 });
136
137 // Add all standard JSON Schema formats using ajv-formats
138 addFormats(ajv);
139
140 // Add custom error messages support using ajv-errors
141 addErrors(ajv);
142
143 // Add WordPress-specific custom formats that are not in the standard
144 ajv.addFormat('text-field', true); // WordPress custom format - no validation, only sanitization
145 ajv.addFormat('textarea-field', true); // WordPress custom format - no validation, only sanitization
146 ajv.addFormat('integer', true); // WordPress custom format - no validation, only sanitization
147 ajv.addFormat('boolean', true); // WordPress custom format - no validation, only sanitization
148
149 // Transform WordPress schemas to be compatible with Draft 7/2019-09
150 // This converts Draft 03/04 syntax (required: true on properties) to Draft 7 syntax
151 const originalCompile = ajv.compile.bind(ajv);
152 ajv.compile = function (schema: JSONSchemaType<any>) {
153 try {
154 const transformedSchema = transformWordPressSchemaToDraft7(schema);
155 return originalCompile(transformedSchema);
156 } catch (error) {
157 console.error('Schema transformation error:', error);
158 return originalCompile(schema);
159 }
160 };
161
162 return ajv;
163 }
164
165 /**
166 * Transform WordPress schema from Draft 03/04 syntax to Draft 7/2019-09 syntax
167 *
168 * This function converts WordPress REST API schemas (Draft 03/04) to be compatible
169 * with AJV (Draft 7/2019-09). The transformation includes:
170 * - Converts 'required: true' on individual properties to 'required' array at object level
171 * - Updates $schema reference from Draft 04 to Draft 7/2019-09
172 * - Removes readonly/readOnly fields from validation (they shouldn't be validated by frontend)
173 * - Conditionally removes enum from nullable fields when value is null to prevent AJV conflicts
174 * - Preserves all advanced features (if/then/else, allOf, etc.) that WordPress ignores
175 * but AJV can use for enhanced frontend validation
176 *
177 * Key benefit: WordPress schemas can include advanced validation rules (if/then/else, allOf, not)
178 * that are ignored by the backend but utilized by the frontend for better UX.
179 */
180 function transformWordPressSchemaToDraft7(schema: JSONSchemaType<any>, data?: any): JSONSchemaType<any> {
181 if (!schema || typeof schema !== 'object') {
182 return schema;
183 }
184
185 const transformed = JSON.parse(JSON.stringify(schema));
186
187 // Update $schema reference to Draft 7/2019-09
188 if (transformed.$schema) {
189 transformed.$schema = 'https://json-schema.org/draft/2019-09/schema';
190 }
191
192 if (transformed.properties && typeof transformed.properties === 'object') {
193 const requiredFields: string[] = [];
194 const errorMessages: any = {};
195
196 Object.keys(transformed.properties).forEach((key) => {
197 const prop = transformed.properties[key];
198
199 // Early return if prop is not a valid object
200 if (!prop || typeof prop !== 'object') {
201 return;
202 }
203
204 // Converts 'required: true' on individual properties to 'required' array at object level
205 if (prop.required === true) {
206 requiredFields.push(key);
207 delete prop.required;
208 }
209
210 // Remove readonly/readOnly fields from validation (they shouldn't be validated by frontend)
211 if (prop.readonly === true || prop.readOnly === true) {
212 delete transformed.properties[key];
213 return;
214 }
215
216 // For WordPress Array type + enum (like honorific), conditionally remove enum based on current value
217 // This prevents AJV conflicts when nullable fields have null values
218 if (Array.isArray(prop.type) && prop.enum) {
219 const currentValue = data && data[key];
220 const allowsNull = prop.type.includes('null');
221 if (currentValue === null && allowsNull) {
222 delete prop.enum;
223 }
224 }
225
226 // Add custom error messages for each property
227 errorMessages[key] = getCustomErrorMessage(prop, key);
228 });
229
230 if (requiredFields.length > 0) {
231 transformed.required = requiredFields;
232 }
233
234 // Add error messages to the schema
235 if (Object.keys(errorMessages).length > 0) {
236 transformed.errorMessage = {
237 properties: errorMessages,
238 required: __('Required fields are missing.', 'give'),
239 _: __('Please check the form for errors.', 'give'),
240 };
241 }
242 }
243
244 return transformed;
245 }
246
247 /**
248 * Generate custom error messages for schema properties using ajv-errors
249 *
250 * This function creates specific error messages for different validation types
251 * based on the property schema, providing better user experience.
252 *
253 * @param prop - The property schema object
254 * @param fieldName - The name of the field
255 * @returns Custom error message string for the field
256 */
257 function getCustomErrorMessage(prop: any, fieldName: string): string {
258 // Priority order: format > type > constraints > generic
259
260 // Format validation messages (highest priority)
261 if (prop.format) {
262 switch (prop.format) {
263 case 'email':
264 return sprintf(__('%s must be a valid email address.', 'give'), fieldName);
265 case 'uri':
266 return sprintf(__('%s must be a valid URL.', 'give'), fieldName);
267 case 'date-time':
268 return sprintf(__('%s must be a valid date and time.', 'give'), fieldName);
269 case 'uuid':
270 return sprintf(__('%s must be a valid UUID.', 'give'), fieldName);
271 case 'hex-color':
272 return sprintf(__('%s must be a valid color code.', 'give'), fieldName);
273 default:
274 return sprintf(__('%s format is invalid.', 'give'), fieldName);
275 }
276 }
277
278 // Type validation messages
279 if (prop.type) {
280 if (prop.type === 'string') {
281 return sprintf(__('%s must be text.', 'give'), fieldName);
282 } else if (prop.type === 'number') {
283 return sprintf(__('%s must be a number.', 'give'), fieldName);
284 } else if (prop.type === 'integer') {
285 return sprintf(__('%s must be a whole number.', 'give'), fieldName);
286 } else if (prop.type === 'boolean') {
287 return sprintf(__('%s must be true or false.', 'give'), fieldName);
288 } else if (prop.type === 'array') {
289 return sprintf(__('%s must be a list.', 'give'), fieldName);
290 } else if (prop.type === 'object') {
291 return sprintf(__('%s must be an object.', 'give'), fieldName);
292 }
293 }
294
295 // Enum validation messages
296 if (prop.enum && Array.isArray(prop.enum)) {
297 return sprintf(__('%s must be one of: %s', 'give'), fieldName, prop.enum.join(', '));
298 }
299
300 // Constraint validation messages
301 if (prop.minLength !== undefined) {
302 return sprintf(__('%s must be at least %d characters long.', 'give'), fieldName, prop.minLength);
303 }
304 if (prop.maxLength !== undefined) {
305 return sprintf(__('%s must be no more than %d characters long.', 'give'), fieldName, prop.maxLength);
306 }
307 if (prop.minimum !== undefined) {
308 return sprintf(__('%s must be at least %s.', 'give'), fieldName, prop.minimum);
309 }
310 if (prop.maximum !== undefined) {
311 return sprintf(__('%s must be no more than %s.', 'give'), fieldName, prop.maximum);
312 }
313 if (prop.pattern) {
314 return sprintf(__('%s format is invalid.', 'give'), fieldName);
315 }
316
317 // Generic fallback
318 return sprintf(__('%s is invalid.', 'give'), fieldName);
319 }
320
321 /**
322 * Transform form data to be compatible with JSON Schema validation
323 *
324 * This function handles common form data issues that occur when HTML forms send
325 * data that doesn't match the expected schema types:
326 * - Converts string numbers to actual numbers based on schema type definitions
327 * - Handles enum fields with null/empty string values (converts to null if schema allows)
328 * - Removes non-required fields that are not present in form data
329 * - Recursively processes nested objects and arrays
330 *
331 * This ensures that form data matches the schema expectations for validation,
332 * working with both WordPress backend validation and AJV frontend validation.
333 */
334 function transformFormDataForValidation(data: any, schema: JSONSchemaType<any>): any {
335 if (!data || !schema || typeof data !== 'object') {
336 return data || {};
337 }
338 const transformed = JSON.parse(JSON.stringify(data)); // Deep clone
339
340 // Recursively transform nested objects
341 function transformObject(obj: any, schemaObj: any): any {
342 if (!obj || !schemaObj || typeof obj !== 'object') {
343 return obj;
344 }
345
346 const result = {...obj};
347
348 if (schemaObj.properties) {
349 Object.keys(schemaObj.properties).forEach((key) => {
350 const propSchema = schemaObj.properties[key];
351 const value = result[key];
352
353 // Skip validation for fields that are not present in form data and not required
354 const isRequired = Array.isArray(schemaObj.required) && schemaObj.required.includes(key);
355 const isFieldPresent = value !== undefined && value !== null;
356
357 // Remove fields that are not present and not required from the result
358 // Note: We allow empty strings ('') to be present so they can be saved to clear fields
359 if (!isFieldPresent && !isRequired) {
360 delete result[key];
361 return; // Skip processing this field
362 }
363
364 // Only process fields that are present in the form OR are required
365 if (propSchema && (isFieldPresent || isRequired)) {
366 // Handle number types
367 if (propSchema.type === 'number' && typeof value === 'string') {
368 // Convert string to number
369 const numValue = parseFloat(value);
370 if (!isNaN(numValue)) {
371 result[key] = numValue;
372 }
373 } else if (propSchema.type === 'integer' && typeof value === 'string') {
374 // Convert string to integer
375 const intValue = parseInt(value, 10);
376 if (!isNaN(intValue)) {
377 result[key] = intValue;
378 }
379 }
380 // Handle boolean types
381 else if (propSchema.type === 'boolean') {
382 if (typeof value === 'string') {
383 result[key] = value === 'true' || value === '1' || value === 'yes';
384 } else if (typeof value === 'number') {
385 result[key] = value === 1;
386 }
387 }
388 // Handle enum types - ensure value is valid
389 else if (propSchema.enum && Array.isArray(propSchema.enum)) {
390 // Check if null is allowed (when type includes 'null')
391 const allowsNull = Array.isArray(propSchema.type) && propSchema.type.includes('null');
392
393 // If value is null, undefined, or empty string and null is allowed, set to null
394 if ((value === null || value === undefined || value === '') && allowsNull) {
395 result[key] = null;
396 }
397 // If value is not in enum and not null/empty, try to find a close match
398 else if (!propSchema.enum.includes(value)) {
399 // If value is not in enum, try to find a close match or use first valid value
400 const stringValue = String(value).toLowerCase();
401 const validValue = propSchema.enum.find(
402 (enumValue) => String(enumValue).toLowerCase() === stringValue
403 );
404 if (validValue !== undefined) {
405 result[key] = validValue;
406 } else if (propSchema.enum.length > 0) {
407 // Use first enum value as fallback
408 result[key] = propSchema.enum[0];
409 }
410 }
411 }
412 // Handle oneOf schemas (like createdAt/updatedAt for donations)
413 else if (propSchema.oneOf && Array.isArray(propSchema.oneOf)) {
414 result[key] = transformOneOfValue(value, propSchema.oneOf);
415 }
416 // Handle array types with string/null (like createdAt/renewsAt for subscriptions)
417 else if (
418 Array.isArray(propSchema.type) &&
419 propSchema.type.includes('string') &&
420 propSchema.format === 'date-time'
421 ) {
422 // For subscription date fields that expect string or null
423 if (value === null || value === undefined) {
424 result[key] = null;
425 } else if (typeof value === 'string') {
426 // Handle ISO 8601 date strings (with or without timezone)
427 // Examples: '2025-07-15T16:34:57', '2025-07-15T16:34:57Z', '2025-07-15T16:34:57.000Z'
428 const date = new Date(value);
429 if (!isNaN(date.getTime())) {
430 // Ensure the string is in proper ISO format
431 const isoString = date.toISOString();
432 result[key] = isoString;
433 } else {
434 result[key] = null;
435 }
436 } else if (value instanceof Date) {
437 result[key] = value.toISOString();
438 } else {
439 result[key] = null;
440 }
441 }
442 // Handle nested objects
443 else if (
444 (propSchema.type === 'object' ||
445 (Array.isArray(propSchema.type) && propSchema.type.includes('object'))) &&
446 propSchema.properties
447 ) {
448 // Recursively transform nested objects
449 result[key] = transformObject(value, propSchema);
450 }
451 }
452 });
453 }
454
455 return result;
456 }
457
458 // Call transformObject to process the data
459 return transformObject(transformed, schema);
460 }
461
462 /**
463 * Transform values for oneOf schemas (like createdAt/updatedAt)
464 */
465 function transformOneOfValue(value: any, oneOfSchemas: any[]): any {
466 // If value is already null, return null
467 if (value === null || value === undefined) {
468 return null;
469 }
470
471 // Check each oneOf schema to see which one matches
472 for (const schema of oneOfSchemas) {
473 // If schema expects a string
474 if (schema.type === 'string') {
475 if (typeof value === 'string') {
476 // Check if it's a valid ISO date-time string
477 const date = new Date(value);
478 if (!isNaN(date.getTime())) {
479 return value; // Return as string if valid
480 }
481 }
482 }
483 // If schema expects an object with date property (like createdAt/updatedAt)
484 else if (schema.type === 'object' && schema.properties && schema.properties.date) {
485 // If value is already an object with date property, validate and return
486 if (typeof value === 'object' && value.date) {
487 const date = new Date(value.date);
488 if (!isNaN(date.getTime())) {
489 // Convert to ISO 8601 format if needed
490 const isoDate = date.toISOString();
491 return {
492 date: isoDate,
493 timezone: value.timezone || 'UTC',
494 timezone_type: value.timezone_type || 3,
495 };
496 }
497 }
498 // If value is a string, convert to object format
499 else if (typeof value === 'string') {
500 const date = new Date(value);
501 if (!isNaN(date.getTime())) {
502 return {
503 date: date.toISOString(),
504 timezone: 'UTC',
505 timezone_type: 3,
506 };
507 }
508 }
509 }
510 // If schema expects null
511 else if (schema.type === 'null') {
512 if (value === null) {
513 return null;
514 }
515 }
516 }
517
518 // If value is a Date object, convert to object format
519 if (value instanceof Date) {
520 return {
521 date: value.toISOString(),
522 timezone: 'UTC',
523 timezone_type: 3,
524 };
525 }
526
527 // If value is a string that looks like a date, convert to object format
528 if (typeof value === 'string') {
529 const date = new Date(value);
530 if (!isNaN(date.getTime())) {
531 return {
532 date: value,
533 timezone: 'UTC',
534 timezone_type: 3,
535 };
536 }
537 }
538
539 // Fallback: return null
540 return null;
541 }
542