bplist-lossless 0.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.
@@ -0,0 +1,489 @@
1
+ const APPLE_PLIST_EPOCH_MS = Date.UTC(2001, 0, 1);
2
+
3
+ export type PlistDateBinaryInput = Buffer | Uint8Array | ArrayBuffer;
4
+ export type PlistDateInput =
5
+ | PlistDate
6
+ | Date
7
+ | number
8
+ | PlistDateBinaryInput;
9
+
10
+ type PlistCanonicalState = {
11
+ raw: Buffer;
12
+ plistSeconds: number;
13
+ unixMilliseconds: number;
14
+ };
15
+
16
+ function isBinaryInput(value: unknown): value is PlistDateBinaryInput {
17
+ return (
18
+ Buffer.isBuffer(value) ||
19
+ value instanceof Uint8Array ||
20
+ value instanceof ArrayBuffer
21
+ );
22
+ }
23
+
24
+ function normalizeRaw8(input: PlistDateBinaryInput): Buffer {
25
+ const raw = Buffer.isBuffer(input)
26
+ ? Buffer.from(input)
27
+ : input instanceof Uint8Array
28
+ ? Buffer.from(input)
29
+ : Buffer.from(new Uint8Array(input));
30
+
31
+ if (raw.length !== 8) {
32
+ throw new RangeError(
33
+ `PlistDate raw value must be exactly 8 bytes, got ${raw.length}.`,
34
+ );
35
+ }
36
+
37
+ return raw;
38
+ }
39
+
40
+ function encodePlistSeconds(seconds: number): Buffer {
41
+ const raw = Buffer.allocUnsafe(8);
42
+ raw.writeDoubleBE(seconds, 0);
43
+ return raw;
44
+ }
45
+
46
+ function decodePlistSeconds(raw: Buffer): number {
47
+ return raw.readDoubleBE(0);
48
+ }
49
+
50
+ function plistSecondsToUnixMilliseconds(seconds: number): number {
51
+ return APPLE_PLIST_EPOCH_MS + seconds * 1000;
52
+ }
53
+
54
+ function unixMillisecondsToPlistSeconds(milliseconds: number): number {
55
+ return (milliseconds - APPLE_PLIST_EPOCH_MS) / 1000;
56
+ }
57
+
58
+ function numberToStableString(value: number): string {
59
+ if (Object.is(value, -0)) return "-0";
60
+ if (Number.isNaN(value)) return "NaN";
61
+ if (value === Infinity) return "Infinity";
62
+ if (value === -Infinity) return "-Infinity";
63
+ return value.toString();
64
+ }
65
+
66
+ /**
67
+ * Exact raw plist path.
68
+ */
69
+ function canonicalStateFromRaw(input: PlistDateBinaryInput): PlistCanonicalState {
70
+ const raw = normalizeRaw8(input);
71
+ const plistSeconds = decodePlistSeconds(raw);
72
+ const unixMilliseconds = plistSecondsToUnixMilliseconds(plistSeconds);
73
+
74
+ return { raw, plistSeconds, unixMilliseconds };
75
+ }
76
+
77
+ /**
78
+ * Canonicalize a plist-seconds number by round-tripping it through the
79
+ * actual 8-byte binary representation.
80
+ */
81
+ function canonicalStateFromPlistSeconds(
82
+ seconds: number,
83
+ ): PlistCanonicalState {
84
+ const raw = encodePlistSeconds(seconds);
85
+ const plistSeconds = decodePlistSeconds(raw);
86
+ const unixMilliseconds = plistSecondsToUnixMilliseconds(plistSeconds);
87
+
88
+ return { raw, plistSeconds, unixMilliseconds };
89
+ }
90
+
91
+ /**
92
+ * Canonicalize Unix milliseconds into the exact plist-representable value.
93
+ *
94
+ * This is the key fix for discrepancies like:
95
+ * 4410317596806472
96
+ * becoming
97
+ * 4410317596806471.5
98
+ *
99
+ * We normalize immediately instead of only discovering the change after
100
+ * encode -> decode.
101
+ */
102
+ function canonicalStateFromUnixMilliseconds(
103
+ milliseconds: number,
104
+ ): PlistCanonicalState {
105
+ return canonicalStateFromPlistSeconds(
106
+ unixMillisecondsToPlistSeconds(milliseconds),
107
+ );
108
+ }
109
+
110
+ function resolveInitialState(value?: PlistDateInput): PlistCanonicalState {
111
+ if (value instanceof PlistDate) {
112
+ return canonicalStateFromRaw(value.getRawBytes());
113
+ }
114
+
115
+ if (isBinaryInput(value)) {
116
+ return canonicalStateFromRaw(value);
117
+ }
118
+
119
+ if (value instanceof Date) {
120
+ return canonicalStateFromUnixMilliseconds(value.getTime());
121
+ }
122
+
123
+ if (typeof value === "number") {
124
+ return canonicalStateFromUnixMilliseconds(value);
125
+ }
126
+
127
+ return canonicalStateFromUnixMilliseconds(Date.now());
128
+ }
129
+
130
+ export class PlistDate extends Date {
131
+ static readonly APPLE_PLIST_EPOCH_MS = APPLE_PLIST_EPOCH_MS;
132
+
133
+ /**
134
+ * Raw 8-byte plist payload is the canonical source of truth.
135
+ */
136
+ #raw = encodePlistSeconds(0);
137
+
138
+ constructor();
139
+ constructor(value: PlistDateInput);
140
+ constructor(value?: PlistDateInput) {
141
+ const state = resolveInitialState(value);
142
+ super(state.unixMilliseconds);
143
+ this.#raw = state.raw;
144
+ }
145
+
146
+ static from(input: PlistDateInput): PlistDate {
147
+ return new PlistDate(input);
148
+ }
149
+
150
+ static fromBuffer(input: PlistDateBinaryInput): PlistDate {
151
+ return new PlistDate(input);
152
+ }
153
+
154
+ static fromPlistSeconds(seconds: number): PlistDate {
155
+ return new PlistDate(encodePlistSeconds(seconds));
156
+ }
157
+
158
+ static fromUnixMilliseconds(milliseconds: number): PlistDate {
159
+ return new PlistDate(milliseconds);
160
+ }
161
+
162
+ static isPlistDate(value: unknown): value is PlistDate {
163
+ return value instanceof PlistDate;
164
+ }
165
+
166
+ #applyCanonicalState(state: PlistCanonicalState): void {
167
+ this.#raw = Buffer.from(state.raw);
168
+
169
+ // Native Date only keeps whole-millisecond precision internally.
170
+ // We still keep the exact plist value in #raw and expose it through
171
+ // plistSeconds/getTime().
172
+ super.setTime(state.unixMilliseconds);
173
+ }
174
+
175
+ #replaceFromRaw(input: PlistDateBinaryInput): void {
176
+ this.#applyCanonicalState(canonicalStateFromRaw(input));
177
+ }
178
+
179
+ #replaceFromPlistSeconds(seconds: number): void {
180
+ this.#applyCanonicalState(canonicalStateFromPlistSeconds(seconds));
181
+ }
182
+
183
+ #replaceFromUnixMilliseconds(milliseconds: number): number {
184
+ this.#applyCanonicalState(
185
+ canonicalStateFromUnixMilliseconds(milliseconds),
186
+ );
187
+ return this.getTime();
188
+ }
189
+
190
+ #resyncFromDateState(): number {
191
+ return this.#replaceFromUnixMilliseconds(super.getTime());
192
+ }
193
+
194
+ /**
195
+ * Exact plist-native value:
196
+ * seconds since 2001-01-01T00:00:00Z stored as IEEE 754
197
+ * (Institute of Electrical and Electronics Engineers 754) double.
198
+ */
199
+ get plistSeconds(): number {
200
+ return decodePlistSeconds(this.#raw);
201
+ }
202
+
203
+ getPlistSeconds(): number {
204
+ return this.plistSeconds;
205
+ }
206
+
207
+ getPlistSecondsString(): string {
208
+ return numberToStableString(this.plistSeconds);
209
+ }
210
+
211
+ /**
212
+ * Exact raw 8-byte plist payload.
213
+ * If the object was constructed from raw bytes, those bytes are preserved
214
+ * until the date is mutated.
215
+ */
216
+ getRawBytes(): Buffer {
217
+ return Buffer.from(this.#raw);
218
+ }
219
+
220
+ toRawString(encoding: BufferEncoding = "hex"): string {
221
+ return this.#raw.toString(encoding);
222
+ }
223
+
224
+ getRawHex(): string {
225
+ return this.toRawString("hex");
226
+ }
227
+
228
+ getRawBase64(): string {
229
+ return this.toRawString("base64");
230
+ }
231
+
232
+ toPlistString(): string {
233
+ return this.getPlistSecondsString();
234
+ }
235
+
236
+ toBuffer(): Buffer {
237
+ return this.getRawBytes();
238
+ }
239
+
240
+ /**
241
+ * Set the exact plist-native seconds value.
242
+ * Returns the plist-seconds string.
243
+ */
244
+ setPlistSeconds(seconds: number): string {
245
+ if (typeof seconds !== "number") {
246
+ throw new TypeError("PlistDate seconds must be a number.");
247
+ }
248
+
249
+ this.#replaceFromPlistSeconds(seconds);
250
+ return this.getPlistSecondsString();
251
+ }
252
+
253
+ /**
254
+ * Replace the date from exact raw plist bytes.
255
+ * Returns the plist-seconds string.
256
+ */
257
+ setRawBytes(input: PlistDateBinaryInput): string {
258
+ this.#replaceFromRaw(input);
259
+ return this.getPlistSecondsString();
260
+ }
261
+
262
+ /**
263
+ * Parse raw hex into the exact 8-byte plist payload.
264
+ */
265
+ setRawHex(hex: string): string {
266
+ if (typeof hex !== "string" || !/^[0-9a-fA-F]{16}$/.test(hex)) {
267
+ throw new TypeError(
268
+ "PlistDate raw hex must be a 16-character hexadecimal string.",
269
+ );
270
+ }
271
+
272
+ return this.setRawBytes(Buffer.from(hex, "hex"));
273
+ }
274
+
275
+ /**
276
+ * Exact Unix milliseconds derived from the canonical plist bytes.
277
+ * This may include a fractional millisecond.
278
+ */
279
+ override getTime(): number {
280
+ return plistSecondsToUnixMilliseconds(this.plistSeconds);
281
+ }
282
+
283
+ override valueOf(): number {
284
+ return this.getTime();
285
+ }
286
+
287
+ override [Symbol.toPrimitive](hint: "default"): string;
288
+ override [Symbol.toPrimitive](hint: "string"): string;
289
+ override [Symbol.toPrimitive](hint: "number"): number;
290
+ override [Symbol.toPrimitive](hint: string): string | number {
291
+ if (hint === "number") {
292
+ return this.getTime();
293
+ }
294
+
295
+ return this.toString();
296
+ }
297
+
298
+ override toJSON(): string {
299
+ return new Date(this.getTime()).toISOString();
300
+ }
301
+
302
+ clone(): PlistDate {
303
+ return new PlistDate(this.#raw);
304
+ }
305
+
306
+ equalsExactly(other: PlistDateInput): boolean {
307
+ const rhs = PlistDate.from(other);
308
+ return this.#raw.equals(rhs.#raw);
309
+ }
310
+
311
+ equalsValue(other: PlistDateInput): boolean {
312
+ const rhs = PlistDate.from(other);
313
+ return Object.is(this.plistSeconds, rhs.plistSeconds);
314
+ }
315
+
316
+ /**
317
+ * Direct time-setting path: normalize immediately to the exact
318
+ * plist-representable value.
319
+ */
320
+ override setTime(time: number): number {
321
+ return this.#replaceFromUnixMilliseconds(time);
322
+ }
323
+
324
+ override setMilliseconds(ms: number): number {
325
+ super.setMilliseconds(ms);
326
+ return this.#resyncFromDateState();
327
+ }
328
+
329
+ override setUTCMilliseconds(ms: number): number {
330
+ super.setUTCMilliseconds(ms);
331
+ return this.#resyncFromDateState();
332
+ }
333
+
334
+ override setSeconds(sec: number, ms?: number): number {
335
+ if (ms === undefined) {
336
+ super.setSeconds(sec);
337
+ } else {
338
+ super.setSeconds(sec, ms);
339
+ }
340
+ return this.#resyncFromDateState();
341
+ }
342
+
343
+ override setUTCSeconds(sec: number, ms?: number): number {
344
+ if (ms === undefined) {
345
+ super.setUTCSeconds(sec);
346
+ } else {
347
+ super.setUTCSeconds(sec, ms);
348
+ }
349
+ return this.#resyncFromDateState();
350
+ }
351
+
352
+ override setMinutes(min: number, sec?: number, ms?: number): number {
353
+ if (sec === undefined) {
354
+ super.setMinutes(min);
355
+ } else if (ms === undefined) {
356
+ super.setMinutes(min, sec);
357
+ } else {
358
+ super.setMinutes(min, sec, ms);
359
+ }
360
+ return this.#resyncFromDateState();
361
+ }
362
+
363
+ override setUTCMinutes(min: number, sec?: number, ms?: number): number {
364
+ if (sec === undefined) {
365
+ super.setUTCMinutes(min);
366
+ } else if (ms === undefined) {
367
+ super.setUTCMinutes(min, sec);
368
+ } else {
369
+ super.setUTCMinutes(min, sec, ms);
370
+ }
371
+ return this.#resyncFromDateState();
372
+ }
373
+
374
+ override setHours(
375
+ hours: number,
376
+ min?: number,
377
+ sec?: number,
378
+ ms?: number,
379
+ ): number {
380
+ if (min === undefined) {
381
+ super.setHours(hours);
382
+ } else if (sec === undefined) {
383
+ super.setHours(hours, min);
384
+ } else if (ms === undefined) {
385
+ super.setHours(hours, min, sec);
386
+ } else {
387
+ super.setHours(hours, min, sec, ms);
388
+ }
389
+ return this.#resyncFromDateState();
390
+ }
391
+
392
+ override setUTCHours(
393
+ hours: number,
394
+ min?: number,
395
+ sec?: number,
396
+ ms?: number,
397
+ ): number {
398
+ if (min === undefined) {
399
+ super.setUTCHours(hours);
400
+ } else if (sec === undefined) {
401
+ super.setUTCHours(hours, min);
402
+ } else if (ms === undefined) {
403
+ super.setUTCHours(hours, min, sec);
404
+ } else {
405
+ super.setUTCHours(hours, min, sec, ms);
406
+ }
407
+ return this.#resyncFromDateState();
408
+ }
409
+
410
+ override setDate(date: number): number {
411
+ super.setDate(date);
412
+ return this.#resyncFromDateState();
413
+ }
414
+
415
+ override setUTCDate(date: number): number {
416
+ super.setUTCDate(date);
417
+ return this.#resyncFromDateState();
418
+ }
419
+
420
+ override setMonth(month: number, date?: number): number {
421
+ if (date === undefined) {
422
+ super.setMonth(month);
423
+ } else {
424
+ super.setMonth(month, date);
425
+ }
426
+ return this.#resyncFromDateState();
427
+ }
428
+
429
+ override setUTCMonth(month: number, date?: number): number {
430
+ if (date === undefined) {
431
+ super.setUTCMonth(month);
432
+ } else {
433
+ super.setUTCMonth(month, date);
434
+ }
435
+ return this.#resyncFromDateState();
436
+ }
437
+
438
+ override setFullYear(
439
+ year: number,
440
+ month?: number,
441
+ date?: number,
442
+ ): number {
443
+ if (month === undefined) {
444
+ super.setFullYear(year);
445
+ } else if (date === undefined) {
446
+ super.setFullYear(year, month);
447
+ } else {
448
+ super.setFullYear(year, month, date);
449
+ }
450
+ return this.#resyncFromDateState();
451
+ }
452
+
453
+ override setUTCFullYear(
454
+ year: number,
455
+ month?: number,
456
+ date?: number,
457
+ ): number {
458
+ if (month === undefined) {
459
+ super.setUTCFullYear(year);
460
+ } else if (date === undefined) {
461
+ super.setUTCFullYear(year, month);
462
+ } else {
463
+ super.setUTCFullYear(year, month, date);
464
+ }
465
+ return this.#resyncFromDateState();
466
+ }
467
+
468
+ /**
469
+ * @deprecated This method is deprecated and should not be used. Use setFullYear() instead.
470
+ */
471
+ // @ts-ignore
472
+ override setYear(year: number): number {
473
+ // @ts-ignore
474
+ super.setYear(year);
475
+ return this.#resyncFromDateState();
476
+ }
477
+
478
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
479
+ const iso = (() => {
480
+ try {
481
+ return this.toISOString();
482
+ } catch {
483
+ return "Invalid Date";
484
+ }
485
+ })();
486
+
487
+ return `PlistDate(${iso}, plistSeconds=${this.getPlistSecondsString()}, raw=${this.getRawHex()})`;
488
+ }
489
+ }
@@ -0,0 +1,38 @@
1
+ const UID_BRAND = Symbol.for('plist.UID');
2
+
3
+ export class UID extends Uint8Array {
4
+ static override from(bytes: Uint8Array) {
5
+ return new UID(
6
+ bytes.buffer as ArrayBuffer,
7
+ bytes.byteOffset,
8
+ bytes.byteLength
9
+ );
10
+ }
11
+
12
+ constructor(buffer: ArrayBuffer, byteOffset?: number, length?: number) {
13
+ super(buffer, byteOffset, length);
14
+
15
+ Object.defineProperty(this, UID_BRAND, {
16
+ value: true,
17
+ enumerable: false,
18
+ });
19
+ }
20
+
21
+ static isUID(value: unknown): value is UID {
22
+ return (
23
+ value instanceof Uint8Array &&
24
+ value != null &&
25
+ (value as any)[UID_BRAND] === true
26
+ );
27
+ }
28
+
29
+ override toHex() {
30
+ return Array.from(this)
31
+ .map(b => b.toString(16).padStart(2, '0'))
32
+ .join('');
33
+ }
34
+
35
+ [Symbol.for('nodejs.util.inspect.custom')]() {
36
+ return `UID(${this.toHex()})`;
37
+ }
38
+ }
@@ -0,0 +1,54 @@
1
+ const UTF16_STRING_BRAND = Symbol.for('plist.Utf16String');
2
+
3
+ export class Utf16String extends Uint8Array {
4
+ static override from(bytes: Uint8Array): Utf16String {
5
+ return new Utf16String(
6
+ bytes.buffer as ArrayBuffer,
7
+ bytes.byteOffset,
8
+ bytes.byteLength
9
+ );
10
+ }
11
+
12
+ constructor(buffer: ArrayBuffer, byteOffset?: number, length?: number) {
13
+ super(buffer, byteOffset, length);
14
+
15
+ Object.defineProperty(this, UTF16_STRING_BRAND, {
16
+ value: true,
17
+ enumerable: false,
18
+ });
19
+ }
20
+
21
+ static isUtf16String(value: unknown): value is Utf16String {
22
+ return (
23
+ value instanceof Uint8Array &&
24
+ value != null &&
25
+ (value as any)[UTF16_STRING_BRAND] === true
26
+ );
27
+ }
28
+
29
+ override toString(): string {
30
+ const copy = new Uint8Array(this);
31
+
32
+ if (this.length % 2 !== 0) {
33
+ throw new Error('Invalid UTF-16 byte length');
34
+ }
35
+
36
+ for (let i = 0; i < copy.length; i += 2) {
37
+ const a = copy[i];
38
+ copy[i] = copy[i + 1]!;
39
+ copy[i + 1] = a!;
40
+ }
41
+
42
+ return Buffer.from(copy).toString('ucs2');
43
+ }
44
+
45
+ override toHex(): string {
46
+ return Array.from(this)
47
+ .map(b => b.toString(16).padStart(2, '0'))
48
+ .join('');
49
+ }
50
+
51
+ [Symbol.for('nodejs.util.inspect.custom')]() {
52
+ return `Utf16String(${this.toString()})`;
53
+ }
54
+ }
@@ -0,0 +1,5 @@
1
+ export * from '../utils/parse.js';
2
+ export * from '../utils/serialize.js';
3
+ export * from '../classes/plist-date.js';
4
+ export * from '../classes/uid.js';
5
+ export * from '../classes/utf16-string.js';