@pipobscure/vcard 0.0.1

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,854 @@
1
+ /**
2
+ * vCard v4 property classes — RFC 6350
3
+ *
4
+ * Each property class corresponds to a vCard property defined in RFC 6350.
5
+ * Properties hold typed values for clean programmatic access.
6
+ *
7
+ * Cardinality (per RFC 6350 section 6):
8
+ * '1' = exactly one (required, singular)
9
+ * '*1' = zero or one (optional, singular)
10
+ * '1*' = one or more (required, multiple)
11
+ * '*' = zero or more (optional, multiple)
12
+ */
13
+ import { escapeText, escapeStructuredComponent, parseStructured, parseList, parseStructuredList, unescapeText, } from './escape.js';
14
+ // ── VCard Error ────────────────────────────────────────────────────────────
15
+ /** Thrown on strict validation failures during generation */
16
+ export class VCardError extends Error {
17
+ property;
18
+ constructor(message, property) {
19
+ super(message);
20
+ this.property = property;
21
+ this.name = 'VCardError';
22
+ }
23
+ }
24
+ // ── Base Property ──────────────────────────────────────────────────────────
25
+ /** Base class for all vCard properties */
26
+ export class Property {
27
+ /** Optional group name (e.g. 'item1') */
28
+ group;
29
+ /** Property name (always uppercase) */
30
+ name;
31
+ /** Raw parameters */
32
+ params;
33
+ constructor(name, params, group) {
34
+ this.name = name.toUpperCase();
35
+ this.params = params ?? new Map();
36
+ if (group !== undefined)
37
+ this.group = group;
38
+ }
39
+ // ── Convenience parameter accessors ──────────────────────────────────
40
+ /** TYPE parameter values (lowercased) */
41
+ get type() {
42
+ const v = this.params.get('TYPE');
43
+ if (!v)
44
+ return [];
45
+ const vals = Array.isArray(v) ? v : [v];
46
+ return vals.flatMap(s => s.split(',').map(x => x.toLowerCase().trim()));
47
+ }
48
+ set type(values) {
49
+ if (values.length === 0) {
50
+ this.params.delete('TYPE');
51
+ }
52
+ else {
53
+ this.params.set('TYPE', values.length === 1 ? (values[0] ?? '') : values);
54
+ }
55
+ }
56
+ /** PREF parameter (1–100, where 1 = most preferred) */
57
+ get pref() {
58
+ const v = this.params.get('PREF');
59
+ if (!v || Array.isArray(v))
60
+ return undefined;
61
+ const n = parseInt(v, 10);
62
+ return isNaN(n) ? undefined : n;
63
+ }
64
+ set pref(value) {
65
+ if (value === undefined) {
66
+ this.params.delete('PREF');
67
+ }
68
+ else {
69
+ this.params.set('PREF', String(value));
70
+ }
71
+ }
72
+ /** LANGUAGE parameter */
73
+ get language() {
74
+ const v = this.params.get('LANGUAGE');
75
+ return Array.isArray(v) ? v[0] : v;
76
+ }
77
+ set language(value) {
78
+ if (value === undefined) {
79
+ this.params.delete('LANGUAGE');
80
+ }
81
+ else {
82
+ this.params.set('LANGUAGE', value);
83
+ }
84
+ }
85
+ /** ALTID parameter */
86
+ get altid() {
87
+ const v = this.params.get('ALTID');
88
+ return Array.isArray(v) ? v[0] : v;
89
+ }
90
+ set altid(value) {
91
+ if (value === undefined) {
92
+ this.params.delete('ALTID');
93
+ }
94
+ else {
95
+ this.params.set('ALTID', value);
96
+ }
97
+ }
98
+ /** PID parameter (may be a list of pid-values like "1.1,2") */
99
+ get pid() {
100
+ const v = this.params.get('PID');
101
+ return Array.isArray(v) ? v[0] : v;
102
+ }
103
+ /** VALUE parameter */
104
+ get valueType() {
105
+ const v = this.params.get('VALUE');
106
+ return Array.isArray(v) ? v[0] : v;
107
+ }
108
+ set valueType(value) {
109
+ if (value === undefined) {
110
+ this.params.delete('VALUE');
111
+ }
112
+ else {
113
+ this.params.set('VALUE', value);
114
+ }
115
+ }
116
+ }
117
+ // ── Text Property ──────────────────────────────────────────────────────────
118
+ /** Property with a single TEXT value */
119
+ export class TextProperty extends Property {
120
+ value;
121
+ constructor(name, value, params, group) {
122
+ super(name, params, group);
123
+ this.value = value;
124
+ }
125
+ toContentLine() {
126
+ return escapeText(this.value);
127
+ }
128
+ }
129
+ /** Property with a list of TEXT values (comma-separated) */
130
+ export class TextListProperty extends Property {
131
+ values;
132
+ constructor(name, values, params, group) {
133
+ super(name, params, group);
134
+ this.values = values;
135
+ }
136
+ toContentLine() {
137
+ return this.values.map(v => escapeText(v)).join(',');
138
+ }
139
+ }
140
+ // ── URI Property ──────────────────────────────────────────────────────────
141
+ /** Property with a URI value (unescaped, no text escaping applied) */
142
+ export class UriProperty extends Property {
143
+ value;
144
+ constructor(name, value, params, group) {
145
+ super(name, params, group);
146
+ this.value = value;
147
+ }
148
+ toContentLine() {
149
+ return this.value;
150
+ }
151
+ }
152
+ // ── Specific Text Properties ──────────────────────────────────────────────
153
+ /** FN — Formatted Name (RFC 6350 §6.2.1) — cardinality: 1* */
154
+ export class FNProperty extends TextProperty {
155
+ constructor(value, params, group) {
156
+ super('FN', value, params, group);
157
+ }
158
+ }
159
+ /** NICKNAME — Nickname (RFC 6350 §6.2.3) — cardinality: * */
160
+ export class NicknameProperty extends TextListProperty {
161
+ constructor(values, params, group) {
162
+ super('NICKNAME', values, params, group);
163
+ }
164
+ }
165
+ /** NOTE — Note (RFC 6350 §6.7.2) — cardinality: * */
166
+ export class NoteProperty extends TextProperty {
167
+ constructor(value, params, group) {
168
+ super('NOTE', value, params, group);
169
+ }
170
+ }
171
+ /** TITLE — Job Title (RFC 6350 §6.6.1) — cardinality: * */
172
+ export class TitleProperty extends TextProperty {
173
+ constructor(value, params, group) {
174
+ super('TITLE', value, params, group);
175
+ }
176
+ }
177
+ /** ROLE — Role (RFC 6350 §6.6.2) — cardinality: * */
178
+ export class RoleProperty extends TextProperty {
179
+ constructor(value, params, group) {
180
+ super('ROLE', value, params, group);
181
+ }
182
+ }
183
+ /** PRODID — Product Identifier (RFC 6350 §6.7.3) — cardinality: *1 */
184
+ export class ProdIDProperty extends TextProperty {
185
+ constructor(value, params, group) {
186
+ super('PRODID', value, params, group);
187
+ }
188
+ }
189
+ /** UID — Unique Identifier (RFC 6350 §6.7.6) — cardinality: *1
190
+ * May be a URI or text (see valueType param). */
191
+ export class UIDProperty extends TextProperty {
192
+ constructor(value, params, group) {
193
+ super('UID', value, params, group);
194
+ // If value looks like a URI, set VALUE=uri implicitly on generation
195
+ }
196
+ toContentLine() {
197
+ // UID is often a URI — don't escape if value type is URI
198
+ if (this.valueType === 'uri' || looksLikeUri(this.value)) {
199
+ return this.value;
200
+ }
201
+ return escapeText(this.value);
202
+ }
203
+ }
204
+ /** KIND — Type of object (RFC 6350 §6.1.4) — cardinality: *1 */
205
+ export class KindProperty extends TextProperty {
206
+ constructor(value, params, group) {
207
+ super('KIND', value, params, group);
208
+ }
209
+ }
210
+ /** XML — XML data (RFC 6350 §6.1.5) — cardinality: * */
211
+ export class XMLProperty extends TextProperty {
212
+ constructor(value, params, group) {
213
+ super('XML', value, params, group);
214
+ }
215
+ }
216
+ /** SOURCE — Source URI (RFC 6350 §6.1.3) — cardinality: * */
217
+ export class SourceProperty extends UriProperty {
218
+ constructor(value, params, group) {
219
+ super('SOURCE', value, params, group);
220
+ }
221
+ }
222
+ /** CATEGORIES — Categories (RFC 6350 §6.7.1) — cardinality: * */
223
+ export class CategoriesProperty extends TextListProperty {
224
+ constructor(values, params, group) {
225
+ super('CATEGORIES', values, params, group);
226
+ }
227
+ }
228
+ /** URL — URI (RFC 6350 §6.7.8) — cardinality: * */
229
+ export class URLProperty extends UriProperty {
230
+ constructor(value, params, group) {
231
+ super('URL', value, params, group);
232
+ }
233
+ }
234
+ /** PHOTO — Photo URI (RFC 6350 §6.2.4) — cardinality: * */
235
+ export class PhotoProperty extends UriProperty {
236
+ constructor(value, params, group) {
237
+ super('PHOTO', value, params, group);
238
+ }
239
+ /** MEDIATYPE parameter */
240
+ get mediatype() {
241
+ const v = this.params.get('MEDIATYPE');
242
+ return Array.isArray(v) ? v[0] : v;
243
+ }
244
+ set mediatype(value) {
245
+ if (value === undefined) {
246
+ this.params.delete('MEDIATYPE');
247
+ }
248
+ else {
249
+ this.params.set('MEDIATYPE', value);
250
+ }
251
+ }
252
+ }
253
+ /** LOGO — Logo URI (RFC 6350 §6.6.3) — cardinality: * */
254
+ export class LogoProperty extends UriProperty {
255
+ constructor(value, params, group) {
256
+ super('LOGO', value, params, group);
257
+ }
258
+ }
259
+ /** SOUND — Sound URI (RFC 6350 §6.7.5) — cardinality: * */
260
+ export class SoundProperty extends UriProperty {
261
+ constructor(value, params, group) {
262
+ super('SOUND', value, params, group);
263
+ }
264
+ }
265
+ /** IMPP — Instant Messaging URI (RFC 6350 §6.4.3) — cardinality: * */
266
+ export class IMPPProperty extends UriProperty {
267
+ constructor(value, params, group) {
268
+ super('IMPP', value, params, group);
269
+ }
270
+ }
271
+ /** MEMBER — Group Member URI (RFC 6350 §6.6.5) — cardinality: * */
272
+ export class MemberProperty extends UriProperty {
273
+ constructor(value, params, group) {
274
+ super('MEMBER', value, params, group);
275
+ }
276
+ }
277
+ /** FBURL — Free/Busy URL (RFC 6350 §6.9.1) — cardinality: * */
278
+ export class FBURLProperty extends UriProperty {
279
+ constructor(value, params, group) {
280
+ super('FBURL', value, params, group);
281
+ }
282
+ }
283
+ /** CALADRURI — Calendar User Address URI (RFC 6350 §6.9.2) — cardinality: * */
284
+ export class CALADRURIProperty extends UriProperty {
285
+ constructor(value, params, group) {
286
+ super('CALADRURI', value, params, group);
287
+ }
288
+ }
289
+ /** CALURI — Calendar URI (RFC 6350 §6.9.3) — cardinality: * */
290
+ export class CALURIProperty extends UriProperty {
291
+ constructor(value, params, group) {
292
+ super('CALURI', value, params, group);
293
+ }
294
+ }
295
+ // ── Email Property ─────────────────────────────────────────────────────────
296
+ /** EMAIL — Email Address (RFC 6350 §6.4.2) — cardinality: * */
297
+ export class EmailProperty extends TextProperty {
298
+ constructor(value, params, group) {
299
+ super('EMAIL', value, params, group);
300
+ }
301
+ }
302
+ // ── Language Property ──────────────────────────────────────────────────────
303
+ /** LANG — Language (RFC 6350 §6.4.4) — cardinality: * */
304
+ export class LangProperty extends Property {
305
+ /** BCP 47 language tag (e.g. 'en', 'fr', 'zh-Hant') */
306
+ value;
307
+ constructor(value, params, group) {
308
+ super('LANG', params, group);
309
+ this.value = value;
310
+ }
311
+ toContentLine() {
312
+ return this.value;
313
+ }
314
+ }
315
+ // ── Telephone Property ────────────────────────────────────────────────────
316
+ /**
317
+ * TEL — Telephone Number (RFC 6350 §6.4.1) — cardinality: *
318
+ *
319
+ * In v4, TEL values SHOULD be URIs (tel: or sip:).
320
+ * Text values are also accepted for tolerance.
321
+ */
322
+ export class TelProperty extends Property {
323
+ value;
324
+ /** Whether this is a URI value (tel:, sip:, etc.) or plain text */
325
+ isUri;
326
+ constructor(value, params, group) {
327
+ super('TEL', params, group);
328
+ this.value = value;
329
+ this.isUri = looksLikeUri(value) || (params?.get('VALUE') === 'uri');
330
+ }
331
+ toContentLine() {
332
+ return this.isUri ? this.value : escapeText(this.value);
333
+ }
334
+ }
335
+ // ── Structured Name ────────────────────────────────────────────────────────
336
+ /**
337
+ * N — Structured Name (RFC 6350 §6.2.2) — cardinality: *1
338
+ *
339
+ * Components (each may be a comma-separated list):
340
+ * family-name ; given-name ; additional-names ; honorific-prefixes ; honorific-suffixes
341
+ */
342
+ export class NProperty extends Property {
343
+ value;
344
+ constructor(value, params, group) {
345
+ super('N', params, group);
346
+ this.value = value;
347
+ }
348
+ /** Create from a text value (tolerant parsing) */
349
+ static fromText(text, params, group) {
350
+ const components = parseStructuredList(text);
351
+ return new NProperty({
352
+ familyNames: components[0] ?? [],
353
+ givenNames: components[1] ?? [],
354
+ additionalNames: components[2] ?? [],
355
+ honorificPrefixes: components[3] ?? [],
356
+ honorificSuffixes: components[4] ?? [],
357
+ }, params, group);
358
+ }
359
+ toContentLine() {
360
+ const { familyNames, givenNames, additionalNames, honorificPrefixes, honorificSuffixes } = this.value;
361
+ return [
362
+ familyNames.map(escapeStructuredComponent).join(','),
363
+ givenNames.map(escapeStructuredComponent).join(','),
364
+ additionalNames.map(escapeStructuredComponent).join(','),
365
+ honorificPrefixes.map(escapeStructuredComponent).join(','),
366
+ honorificSuffixes.map(escapeStructuredComponent).join(','),
367
+ ].join(';');
368
+ }
369
+ /** SORT-AS parameter */
370
+ get sortAs() {
371
+ const v = this.params.get('SORT-AS');
372
+ if (!v)
373
+ return [];
374
+ const s = Array.isArray(v) ? v[0] ?? '' : v;
375
+ return parseList(s);
376
+ }
377
+ }
378
+ // ── Address ────────────────────────────────────────────────────────────────
379
+ /**
380
+ * ADR — Address (RFC 6350 §6.3.1) — cardinality: *
381
+ *
382
+ * Components:
383
+ * post-office-box ; extended-address ; street-address ;
384
+ * locality ; region ; postal-code ; country-name
385
+ */
386
+ export class AdrProperty extends Property {
387
+ value;
388
+ constructor(value, params, group) {
389
+ super('ADR', params, group);
390
+ this.value = value;
391
+ }
392
+ static fromText(text, params, group) {
393
+ const c = parseStructured(text);
394
+ return new AdrProperty({
395
+ postOfficeBox: c[0] ?? '',
396
+ extendedAddress: c[1] ?? '',
397
+ streetAddress: c[2] ?? '',
398
+ locality: c[3] ?? '',
399
+ region: c[4] ?? '',
400
+ postalCode: c[5] ?? '',
401
+ countryName: c[6] ?? '',
402
+ }, params, group);
403
+ }
404
+ toContentLine() {
405
+ const { postOfficeBox, extendedAddress, streetAddress, locality, region, postalCode, countryName } = this.value;
406
+ return [
407
+ escapeStructuredComponent(postOfficeBox),
408
+ escapeStructuredComponent(extendedAddress),
409
+ escapeStructuredComponent(streetAddress),
410
+ escapeStructuredComponent(locality),
411
+ escapeStructuredComponent(region),
412
+ escapeStructuredComponent(postalCode),
413
+ escapeStructuredComponent(countryName),
414
+ ].join(';');
415
+ }
416
+ /** LABEL parameter (delivery address label) */
417
+ get label() {
418
+ const v = this.params.get('LABEL');
419
+ return Array.isArray(v) ? v[0] : v;
420
+ }
421
+ set label(value) {
422
+ if (value === undefined) {
423
+ this.params.delete('LABEL');
424
+ }
425
+ else {
426
+ this.params.set('LABEL', value);
427
+ }
428
+ }
429
+ /** CC (country code) parameter */
430
+ get cc() {
431
+ const v = this.params.get('CC');
432
+ return Array.isArray(v) ? v[0] : v;
433
+ }
434
+ }
435
+ // ── Organization ──────────────────────────────────────────────────────────
436
+ /**
437
+ * ORG — Organization (RFC 6350 §6.6.4) — cardinality: *
438
+ *
439
+ * Semicolon-separated: org-name ; unit1 ; unit2 ; ...
440
+ */
441
+ export class OrgProperty extends Property {
442
+ value;
443
+ constructor(value, params, group) {
444
+ super('ORG', params, group);
445
+ this.value = value;
446
+ }
447
+ static fromText(text, params, group) {
448
+ const parts = parseStructured(text);
449
+ return new OrgProperty({
450
+ name: parts[0] ?? '',
451
+ units: parts.slice(1).filter(s => s !== ''),
452
+ }, params, group);
453
+ }
454
+ toContentLine() {
455
+ const parts = [this.value.name, ...this.value.units];
456
+ return parts.map(escapeStructuredComponent).join(';');
457
+ }
458
+ get sortAs() {
459
+ const v = this.params.get('SORT-AS');
460
+ if (!v)
461
+ return [];
462
+ const s = Array.isArray(v) ? v[0] ?? '' : v;
463
+ return parseList(s);
464
+ }
465
+ }
466
+ // ── Gender ─────────────────────────────────────────────────────────────────
467
+ /**
468
+ * GENDER — Gender (RFC 6350 §6.2.7) — cardinality: *1
469
+ *
470
+ * Value: sex ; identity-text
471
+ * Sex: M | F | O | N | U | (empty)
472
+ */
473
+ export class GenderProperty extends Property {
474
+ value;
475
+ constructor(value, params, group) {
476
+ super('GENDER', params, group);
477
+ this.value = value;
478
+ }
479
+ static fromText(text, params, group) {
480
+ const semi = text.indexOf(';');
481
+ if (semi === -1) {
482
+ return new GenderProperty({ sex: text.toUpperCase() }, params, group);
483
+ }
484
+ const sex = text.slice(0, semi).toUpperCase();
485
+ const identity = unescapeText(text.slice(semi + 1));
486
+ return new GenderProperty({ sex, identity: identity || undefined }, params, group);
487
+ }
488
+ toContentLine() {
489
+ const { sex, identity } = this.value;
490
+ if (!identity)
491
+ return sex;
492
+ return `${sex};${escapeStructuredComponent(identity)}`;
493
+ }
494
+ }
495
+ // ── Geographic ────────────────────────────────────────────────────────────
496
+ /**
497
+ * GEO — Geographic Position (RFC 6350 §6.5.2) — cardinality: *
498
+ * Value: URI (e.g. geo:37.386013,-122.082932)
499
+ */
500
+ export class GeoProperty extends Property {
501
+ /** The raw URI value */
502
+ uri;
503
+ constructor(uri, params, group) {
504
+ super('GEO', params, group);
505
+ this.uri = uri;
506
+ }
507
+ /** Parse latitude/longitude from a geo: URI */
508
+ get coordinates() {
509
+ const m = this.uri.match(/^geo:([+-]?\d+\.?\d*),([+-]?\d+\.?\d*)/i);
510
+ if (!m)
511
+ return undefined;
512
+ return { latitude: parseFloat(m[1]), longitude: parseFloat(m[2]) };
513
+ }
514
+ /** Build a GeoProperty from coordinates */
515
+ static fromCoordinates(lat, lon, params, group) {
516
+ return new GeoProperty(`geo:${lat},${lon}`, params, group);
517
+ }
518
+ toContentLine() {
519
+ return this.uri;
520
+ }
521
+ }
522
+ /**
523
+ * TZ — Time Zone (RFC 6350 §6.5.1) — cardinality: *
524
+ * May be: UTC offset (+HH:MM), URI, or text (IANA timezone name)
525
+ */
526
+ export class TZProperty extends Property {
527
+ value;
528
+ valueKind;
529
+ constructor(value, valueKind = 'text', params, group) {
530
+ super('TZ', params, group);
531
+ this.value = value;
532
+ this.valueKind = valueKind;
533
+ }
534
+ static fromText(text, params, group) {
535
+ const valType = params?.get('VALUE');
536
+ const vt = Array.isArray(valType) ? valType[0] : valType;
537
+ if (vt === 'uri' || looksLikeUri(text)) {
538
+ return new TZProperty(text, 'uri', params, group);
539
+ }
540
+ if (/^[+-]\d{2}:\d{2}$/.test(text) || /^[+-]\d{4}$/.test(text)) {
541
+ return new TZProperty(text, 'utc-offset', params, group);
542
+ }
543
+ return new TZProperty(unescapeText(text), 'text', params, group);
544
+ }
545
+ toContentLine() {
546
+ if (this.valueKind === 'uri' || this.valueKind === 'utc-offset') {
547
+ return this.value;
548
+ }
549
+ return escapeText(this.value);
550
+ }
551
+ }
552
+ // ── Date/Time Properties ──────────────────────────────────────────────────
553
+ /**
554
+ * Parse an RFC 6350 date-and-or-time value into a structured object.
555
+ *
556
+ * Supported formats (RFC 6350 §4.3):
557
+ * YYYY, YYYYMM, YYYYMMDD, --MMDD, --MM, ---DD
558
+ * THH, THHMM, THHMMSS, THHMMSSZ, THHMMSS+HHMM
559
+ * YYYYMMDDTHHMMSS, etc.
560
+ * Extended (with dashes/colons): YYYY-MM-DD, HH:MM:SS, etc.
561
+ */
562
+ export function parseDateAndOrTime(value) {
563
+ if (!value)
564
+ return null;
565
+ const v = value.replace(/\s/g, '');
566
+ let hasTime = false;
567
+ let year;
568
+ let month;
569
+ let day;
570
+ let hour;
571
+ let minute;
572
+ let second;
573
+ let utcOffset;
574
+ // Split date/time parts on T (case-insensitive)
575
+ const tIndex = v.toUpperCase().indexOf('T');
576
+ const datePart = tIndex === -1 ? v : v.slice(0, tIndex);
577
+ const timePart = tIndex === -1 ? '' : v.slice(tIndex + 1);
578
+ if (timePart)
579
+ hasTime = true;
580
+ // Parse date part
581
+ if (datePart) {
582
+ // --MMDD or --MM-DD
583
+ if (datePart.startsWith('--')) {
584
+ const d = datePart.slice(2).replace(/-/g, '');
585
+ if (d.length >= 2)
586
+ month = parseInt(d.slice(0, 2), 10);
587
+ if (d.length >= 4)
588
+ day = parseInt(d.slice(2, 4), 10);
589
+ }
590
+ // ---DD
591
+ else if (datePart.startsWith('---')) {
592
+ day = parseInt(datePart.slice(3), 10);
593
+ }
594
+ // YYYY, YYYY-MM, YYYYMM, YYYY-MM-DD, YYYYMMDD
595
+ else {
596
+ const clean = datePart.replace(/-/g, '');
597
+ if (clean.length >= 4)
598
+ year = parseInt(clean.slice(0, 4), 10);
599
+ if (clean.length >= 6)
600
+ month = parseInt(clean.slice(4, 6), 10);
601
+ if (clean.length >= 8)
602
+ day = parseInt(clean.slice(6, 8), 10);
603
+ }
604
+ }
605
+ // Parse time part
606
+ if (timePart) {
607
+ // Extract UTC offset suffix: Z, +HH:MM, -HH:MM, +HHMM, -HHMM
608
+ let tp = timePart;
609
+ const offsetMatch = tp.match(/([Zz]|[+-]\d{2}:?\d{2})$/);
610
+ if (offsetMatch) {
611
+ utcOffset = offsetMatch[1].toUpperCase();
612
+ tp = tp.slice(0, tp.length - offsetMatch[1].length);
613
+ }
614
+ const clean = tp.replace(/:/g, '');
615
+ if (clean.length >= 2)
616
+ hour = parseInt(clean.slice(0, 2), 10);
617
+ if (clean.length >= 4)
618
+ minute = parseInt(clean.slice(2, 4), 10);
619
+ if (clean.length >= 6)
620
+ second = parseInt(clean.slice(4, 6), 10);
621
+ }
622
+ return { year, month, day, hour, minute, second, utcOffset, hasTime };
623
+ }
624
+ /** Format a DateAndOrTime back to RFC 6350 text */
625
+ export function formatDateAndOrTime(dt) {
626
+ let datePart = '';
627
+ let timePart = '';
628
+ if (dt.year !== undefined || dt.month !== undefined || dt.day !== undefined) {
629
+ if (dt.year === undefined && dt.month !== undefined) {
630
+ // --MMDD or --MM
631
+ datePart = '--' + String(dt.month).padStart(2, '0') + (dt.day !== undefined ? String(dt.day).padStart(2, '0') : '');
632
+ }
633
+ else if (dt.year === undefined && dt.day !== undefined) {
634
+ datePart = '---' + String(dt.day).padStart(2, '0');
635
+ }
636
+ else {
637
+ datePart = dt.year !== undefined ? String(dt.year).padStart(4, '0') : '';
638
+ if (dt.month !== undefined)
639
+ datePart += String(dt.month).padStart(2, '0');
640
+ if (dt.day !== undefined)
641
+ datePart += String(dt.day).padStart(2, '0');
642
+ }
643
+ }
644
+ if (dt.hasTime && (dt.hour !== undefined || dt.minute !== undefined || dt.second !== undefined)) {
645
+ timePart = 'T';
646
+ timePart += dt.hour !== undefined ? String(dt.hour).padStart(2, '0') : '';
647
+ if (dt.minute !== undefined)
648
+ timePart += String(dt.minute).padStart(2, '0');
649
+ if (dt.second !== undefined)
650
+ timePart += String(dt.second).padStart(2, '0');
651
+ if (dt.utcOffset)
652
+ timePart += dt.utcOffset;
653
+ }
654
+ return datePart + timePart;
655
+ }
656
+ /**
657
+ * BDAY — Birthday (RFC 6350 §6.2.5) — cardinality: *1
658
+ * Value: date-and-or-time or text
659
+ */
660
+ export class BDayProperty extends Property {
661
+ /** Parsed date/time value, or null if text or unparseable */
662
+ dateValue;
663
+ /** Raw text value (for VALUE=text or unparseable) */
664
+ textValue;
665
+ constructor(value, params, group) {
666
+ super('BDAY', params, group);
667
+ if (typeof value === 'string') {
668
+ this.textValue = value;
669
+ this.dateValue = null;
670
+ }
671
+ else {
672
+ this.dateValue = value;
673
+ }
674
+ }
675
+ static fromText(text, params, group) {
676
+ const vt = params?.get('VALUE');
677
+ if (vt === 'text' || Array.isArray(vt) && vt[0] === 'text') {
678
+ return new BDayProperty(unescapeText(text), params, group);
679
+ }
680
+ const dt = parseDateAndOrTime(text);
681
+ if (dt)
682
+ return new BDayProperty(dt, params, group);
683
+ return new BDayProperty(text, params, group);
684
+ }
685
+ toContentLine() {
686
+ if (this.textValue !== undefined)
687
+ return escapeText(this.textValue);
688
+ if (this.dateValue)
689
+ return formatDateAndOrTime(this.dateValue);
690
+ return '';
691
+ }
692
+ }
693
+ /**
694
+ * ANNIVERSARY — Anniversary (RFC 6350 §6.2.6) — cardinality: *1
695
+ * Value: date-and-or-time or text
696
+ */
697
+ export class AnniversaryProperty extends Property {
698
+ dateValue;
699
+ textValue;
700
+ constructor(value, params, group) {
701
+ super('ANNIVERSARY', params, group);
702
+ if (typeof value === 'string') {
703
+ this.textValue = value;
704
+ this.dateValue = null;
705
+ }
706
+ else {
707
+ this.dateValue = value;
708
+ }
709
+ }
710
+ static fromText(text, params, group) {
711
+ const vt = params?.get('VALUE');
712
+ if (vt === 'text' || (Array.isArray(vt) && vt[0] === 'text')) {
713
+ return new AnniversaryProperty(unescapeText(text), params, group);
714
+ }
715
+ const dt = parseDateAndOrTime(text);
716
+ if (dt)
717
+ return new AnniversaryProperty(dt, params, group);
718
+ return new AnniversaryProperty(text, params, group);
719
+ }
720
+ toContentLine() {
721
+ if (this.textValue !== undefined)
722
+ return escapeText(this.textValue);
723
+ if (this.dateValue)
724
+ return formatDateAndOrTime(this.dateValue);
725
+ return '';
726
+ }
727
+ }
728
+ /**
729
+ * REV — Revision timestamp (RFC 6350 §6.7.4) — cardinality: *1
730
+ * Value: timestamp (YYYYMMDDTHHMMSSZ)
731
+ */
732
+ export class RevProperty extends Property {
733
+ /** Parsed as a Date object, or raw string if unparseable */
734
+ value;
735
+ constructor(value, params, group) {
736
+ super('REV', params, group);
737
+ this.value = value;
738
+ }
739
+ static fromText(text, params, group) {
740
+ // Try parsing as ISO timestamp
741
+ const dt = parseDateAndOrTime(text);
742
+ if (dt && dt.year !== undefined && dt.hasTime) {
743
+ // Construct a Date
744
+ const d = new Date(Date.UTC(dt.year, (dt.month ?? 1) - 1, dt.day ?? 1, dt.hour ?? 0, dt.minute ?? 0, dt.second ?? 0));
745
+ if (!isNaN(d.getTime()))
746
+ return new RevProperty(d, params, group);
747
+ }
748
+ return new RevProperty(text, params, group);
749
+ }
750
+ toContentLine() {
751
+ if (this.value instanceof Date) {
752
+ const d = this.value;
753
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
754
+ return (pad(d.getUTCFullYear(), 4) +
755
+ pad(d.getUTCMonth() + 1) +
756
+ pad(d.getUTCDate()) +
757
+ 'T' +
758
+ pad(d.getUTCHours()) +
759
+ pad(d.getUTCMinutes()) +
760
+ pad(d.getUTCSeconds()) +
761
+ 'Z');
762
+ }
763
+ return this.value;
764
+ }
765
+ }
766
+ // ── Key ───────────────────────────────────────────────────────────────────
767
+ /**
768
+ * KEY — Public Key (RFC 6350 §6.8.1) — cardinality: *
769
+ * Value: URI or inline text (base64-encoded)
770
+ */
771
+ export class KeyProperty extends Property {
772
+ value;
773
+ isUri;
774
+ constructor(value, isUri = false, params, group) {
775
+ super('KEY', params, group);
776
+ this.value = value;
777
+ this.isUri = isUri;
778
+ }
779
+ static fromText(text, params, group) {
780
+ const vt = params?.get('VALUE');
781
+ const isUri = vt === 'uri' || (Array.isArray(vt) && vt[0] === 'uri') || looksLikeUri(text);
782
+ return new KeyProperty(text, isUri, params, group);
783
+ }
784
+ toContentLine() {
785
+ return this.value; // URI or opaque inline data
786
+ }
787
+ }
788
+ // ── Related ────────────────────────────────────────────────────────────────
789
+ /**
790
+ * RELATED — Related Entity (RFC 6350 §6.6.6) — cardinality: *
791
+ * Value: URI or text
792
+ */
793
+ export class RelatedProperty extends Property {
794
+ value;
795
+ isUri;
796
+ constructor(value, isUri = true, params, group) {
797
+ super('RELATED', params, group);
798
+ this.value = value;
799
+ this.isUri = isUri;
800
+ }
801
+ static fromText(text, params, group) {
802
+ const vt = params?.get('VALUE');
803
+ const isUri = vt === 'uri' || (Array.isArray(vt) && vt[0] === 'uri') || looksLikeUri(text);
804
+ const v = isUri ? text : unescapeText(text);
805
+ return new RelatedProperty(v, isUri, params, group);
806
+ }
807
+ toContentLine() {
808
+ return this.isUri ? this.value : escapeText(this.value);
809
+ }
810
+ }
811
+ // ── CLIENTPIDMAP ──────────────────────────────────────────────────────────
812
+ /**
813
+ * CLIENTPIDMAP — Client PID Map (RFC 6350 §6.7.7) — cardinality: *
814
+ * Value: pid-number ; URI
815
+ */
816
+ export class ClientPidMapProperty extends Property {
817
+ value;
818
+ constructor(value, params, group) {
819
+ super('CLIENTPIDMAP', params, group);
820
+ this.value = value;
821
+ }
822
+ static fromText(text, params, group) {
823
+ const semi = text.indexOf(';');
824
+ if (semi === -1) {
825
+ return new ClientPidMapProperty({ pid: parseInt(text, 10) || 1, uri: '' }, params, group);
826
+ }
827
+ const pid = parseInt(text.slice(0, semi), 10) || 1;
828
+ const uri = text.slice(semi + 1);
829
+ return new ClientPidMapProperty({ pid, uri }, params, group);
830
+ }
831
+ toContentLine() {
832
+ return `${this.value.pid};${this.value.uri}`;
833
+ }
834
+ }
835
+ // ── Unknown / Extended Properties ─────────────────────────────────────────
836
+ /**
837
+ * An unrecognized IANA or X- property, stored verbatim for round-tripping.
838
+ */
839
+ export class UnknownProperty extends Property {
840
+ rawValue;
841
+ constructor(name, rawValue, params, group) {
842
+ super(name, params, group);
843
+ this.rawValue = rawValue;
844
+ }
845
+ toContentLine() {
846
+ return this.rawValue;
847
+ }
848
+ }
849
+ // ── Helpers ────────────────────────────────────────────────────────────────
850
+ /** Heuristic check: does this string look like a URI? */
851
+ function looksLikeUri(value) {
852
+ return /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(value);
853
+ }
854
+ //# sourceMappingURL=property.js.map