Its a crux!

feat: add support for reals and dates in plist-parser

Signed-off-by: Jonathan Basniak <740416+gm112@users.noreply.github.com>

+91 -7
+44
libraries/plist-parser/src/plist-parser.test.ts
··· 26 26 }) 27 27 }) 28 28 29 + it('properly handles dates', () => { 30 + expect(() => { 31 + const serialized = serialize_xml_to_plist_object({ CFBundleName: new Date() }) 32 + expect(serialized).toContain('<date>') 33 + }).not.toThrow() 34 + }) 35 + 36 + it('properly handles real numbers', () => { 37 + expect(() => { 38 + const serialized = serialize_xml_to_plist_object({ CFBundleName: 123.456 }) 39 + expect(serialized).toContain('<real>123.456</real>') 40 + }).not.toThrow() 41 + }) 42 + 29 43 it('properly throws an error on NaN numbers', () => { 30 44 expect(() => serialize_xml_to_plist_object({ CFBundleName: NaN })).toThrowError(/unsupported_value_type/) 31 45 }) ··· 56 70 const parsed = deserialize_plist_xml_to_plist_object(test_plist_with_commentsonicthehedgehogs_as_xml) 57 71 const serialized = serialize_xml_to_plist_object(parsed) 58 72 expect(serialized).not.toContain('sonicthehedgehog') 73 + }) 74 + 75 + it('properly handles dates', () => { 76 + const parsed = deserialize_plist_xml_to_plist_object(test_plist_with_date_as_xml) 77 + const serialized = serialize_xml_to_plist_object(parsed) 78 + expect(serialized).toEqual(test_plist_with_date_as_xml) 79 + }) 80 + 81 + it('properly handles real numbers', () => { 82 + const parsed = deserialize_plist_xml_to_plist_object(test_plist_with_real_as_xml) 83 + const serialized = serialize_xml_to_plist_object(parsed) 84 + expect(serialized).toEqual(test_plist_with_real_as_xml) 59 85 }) 60 86 }) 61 87 }) ··· 298 324 <key>CFBundleName</key> 299 325 <!-- sonicthehedgehog --> 300 326 <string>hello&amp;hello</string> 327 + </dict> 328 + </plist>` 329 + 330 + const test_plist_with_date_as_xml = `<?xml version="1.0" encoding="UTF-8"?> 331 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 332 + <plist version="1.0"> 333 + <dict> 334 + <key>CFBundleName</key> 335 + <date>2023-01-01T00:00:00.000Z</date> 336 + </dict> 337 + </plist>` 338 + 339 + const test_plist_with_real_as_xml = `<?xml version="1.0" encoding="UTF-8"?> 340 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 341 + <plist version="1.0"> 342 + <dict> 343 + <key>CFBundleName</key> 344 + <real>123.456</real> 301 345 </dict> 302 346 </plist>` 303 347 /* eslint-enable @stylistic/no-tabs */
+47 -7
libraries/plist-parser/src/plist-parser.ts
··· 6 6 * @see {deserialize_plist_xml_to_plist_object} 7 7 * @link https://code.google.com/archive/p/networkpx/wikis/PlistSpec.wiki 8 8 * @description A single-file zero dependency parser for serializing and deserializing Apple Info.plist files. Supprts only XML formatted plists. 9 - * Only supports plists with a single root element, that contain only string, number, boolean, array(also nested), and dictionary(also nested) values. 9 + * Only supports plists with a single root element, that contain only string, number, boolean, reals, dates, array(also nested), and dictionary(also nested) values. 10 10 * Attempts to conform to Apple's formatting and specifications for plists in the parts of the spec that are supported. 11 11 * 12 12 * Notes for things that are not supported: ··· 14 14 * - Large files probably won't work due to the use of using regex to extract the <plist> data. Changing deserialization to use a streaming parser would address this if it is an issue. 15 15 * - Comments are ignored and will no-op/be removed when deserializing. 16 16 * - Binary data is ignored, and is untested. 17 - * - Dates are ignored, and is untested. At the moment I believe it would resolve to a regular string. 18 17 * - All unknown key dictionaries are not supported, and may be treated as a regular JSON object, which may mess up the structure of the plist. 19 18 */ 20 19 ··· 22 21 * Error types that plist_parser can throw. 23 22 */ 24 23 export type plist_parser_error_types = 'unsupported_value_type' | 'unsupported_tag' | 'invalid_xml' | 'info_plist_not_found' | 'infinite_loop' 25 - export type plist_value = string | number | boolean | plist_value[] | { [key: string]: plist_value } 24 + export type plist_value = string | Date | number | boolean | plist_value[] | { [key: string]: plist_value } 26 25 27 26 /** 28 27 * Serialize JS object to plist XML ··· 70 69 case 'string': return serialize_string(value, indent) 71 70 case 'boolean': return serialize_boolean(value, indent) 72 71 case 'number': 73 - if (!Number.isSafeInteger(value)) break 72 + if (!Number.isSafeInteger(value)) 73 + if (Number.isFinite(value)) 74 + return serialize_real(value, indent) 75 + else 76 + throw new Error('unsupported_value_type' as plist_parser_error_types, { 77 + cause: { value, type: typeof value }, 78 + }) 74 79 return serialize_number(value, indent) 75 80 case 'object': 76 81 if (Array.isArray(value)) return serialize_array(value, indent, depth) 77 - else if (value !== null && value !== undefined) return serialize_object(value as Record<string, plist_value>, indent, depth) 82 + else if (value !== null && value !== undefined) 83 + if (value instanceof Date) 84 + return serialize_date(value as Date, indent) 85 + else 86 + return serialize_object(value as Record<string, plist_value>, indent, depth) 78 87 } 79 88 80 89 throw new Error('unsupported_value_type' as plist_parser_error_types, { ··· 82 91 }) 83 92 } 84 93 94 + function serialize_date(value: Date, indent: string) { 95 + return `${indent}<date>${value.toISOString()}</date>` 96 + } 97 + 85 98 function serialize_string(value: string, indent: string) { 86 99 return `${indent}<string>${escape_xml(value)}</string>` 87 100 } ··· 89 102 // Supporting real values is not supported by this parser, we only support numbers. 90 103 function serialize_number(value: number, indent: string) { 91 104 return `${indent}<integer>${value}</integer>` 105 + } 106 + 107 + function serialize_real(value: number, indent: string) { 108 + return `${indent}<real>${value}</real>` 92 109 } 93 110 94 111 function serialize_boolean(value: boolean, indent: string) { ··· 130 147 } 131 148 132 149 /* Deserialization */ 133 - const plist_parser_regex = /<(dict|array|string|integer)>([\s\S]*?)<\/\1>/ 150 + const plist_parser_regex = /<(dict|array|string|integer|date|real)>([\s\S]*?)<\/\1>/ 134 151 function naive_ends_with_closing_tag(xml_fragment: string, _element_name: string) { 135 152 const open = xml_fragment.lastIndexOf('<') 136 153 return open !== -1 && xml_fragment[open + 1] === '/' && xml_fragment[xml_fragment.length - 1] === '>' 137 154 } 138 155 156 + const plist_date_iso8601_regex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/ 157 + function deserialize_plist_date_to_date_object(xml_fragment: string) { 158 + const match = xml_fragment.match(plist_date_iso8601_regex) 159 + if (!match) throw new Error('invalid_xml' as plist_parser_error_types, { cause: { xml: xml_fragment } }) 160 + 161 + const [, year, month, day, hour, minute, second] = match 162 + return new Date(Date.UTC( 163 + Number(year), 164 + Number(month) - 1, 165 + Number(day), 166 + Number(hour), 167 + Number(minute), 168 + Number(second), 169 + )) 170 + } 171 + 139 172 function deserialize_xml_fragment_to_plist_value_object(xml_fragment: string): plist_value { 140 173 if (xml_fragment === '<true/>' || xml_fragment === '<false/>') return xml_fragment === '<true/>' 141 174 else if (xml_fragment === '<array/>') return [] 142 175 else if (xml_fragment === '<dict/>') return {} 176 + else if (xml_fragment === '<date/>') return new Date(0) 143 177 else if (!naive_ends_with_closing_tag(xml_fragment, '')) throw new Error('invalid_xml' as plist_parser_error_types, { cause: { xml: xml_fragment } }) 144 178 145 179 const match = xml_fragment.match(plist_parser_regex) ··· 152 186 if (tag === 'string') return unescape_xml(content!) 153 187 else if (tag === 'dict') return deserialize_plist_dict_to_object(content!) 154 188 else if (tag === 'array') return deserialize_plist_array_to_object(content!) 189 + else if (tag === 'date') return deserialize_plist_date_to_date_object(content!) 155 190 else if (tag === 'true' || tag === 'false') return tag === 'true' 156 191 else if (tag === 'integer') { 157 192 const number_value = Number(content!) 158 193 if (Number.isSafeInteger(number_value)) 159 - return parseInt(content!) // Reals probably could be supported if we added a check for if the value is a fixed int or not. 194 + return parseInt(content!) 195 + } 196 + else if (tag === 'real') { 197 + const number_value = Number(content!) 198 + if (Number.isFinite(number_value)) 199 + return number_value 160 200 } 161 201 162 202 throw new Error('invalid_xml' as plist_parser_error_types, {