@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.
package/README.md ADDED
@@ -0,0 +1,1079 @@
1
+ # @pipobscure/vcard
2
+
3
+ A fully featured, RFC 6350-compliant vCard v4 library for Node.js and TypeScript.
4
+
5
+ Designed for use with CardDAV servers and clients. The parser is deliberately tolerant — it handles vCard v2.1, v3.0, and v4.0 input, Apple Contacts exports, QUOTED-PRINTABLE encoding, and various real-world quirks without throwing. The generator is deliberately strict — it produces RFC 6350-compliant output with CRLF line endings, UTF-8 byte-accurate line folding, and full validation.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ npm install @pipobscure/vcard
13
+ ```
14
+
15
+ Requires Node.js 18 or later (uses `Buffer`, ES modules).
16
+
17
+ ---
18
+
19
+ ## Quick Start
20
+
21
+ ```ts
22
+ import { VCard, FNProperty, EmailProperty, TelProperty, NProperty } from '@pipobscure/vcard';
23
+
24
+ // --- Parse ---
25
+ const vcards = VCard.parse(rawText);
26
+ const vc = vcards[0];
27
+ console.log(vc.displayName); // 'Alice Example'
28
+ console.log(vc.primaryEmail); // 'alice@example.com'
29
+
30
+ // --- Build ---
31
+ const vc = new VCard();
32
+ vc.fn.push(new FNProperty('Alice Example'));
33
+ vc.n = new NProperty({
34
+ familyNames: ['Example'],
35
+ givenNames: ['Alice'],
36
+ additionalNames: [],
37
+ honorificPrefixes: [],
38
+ honorificSuffixes: [],
39
+ });
40
+ vc.email.push(new EmailProperty('alice@example.com'));
41
+
42
+ const text = vc.toString();
43
+ // BEGIN:VCARD\r\n
44
+ // VERSION:4.0\r\n
45
+ // FN:Alice Example\r\n
46
+ // ...
47
+ // END:VCARD\r\n
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Table of Contents
53
+
54
+ - [Parsing](#parsing)
55
+ - [Generating](#generating)
56
+ - [The VCard class](#the-vcard-class)
57
+ - [Property classes](#property-classes)
58
+ - [Common parameter accessors](#common-parameter-accessors)
59
+ - [Identification properties](#identification-properties)
60
+ - [Delivery addressing](#delivery-addressing)
61
+ - [Communications](#communications)
62
+ - [Geographic](#geographic)
63
+ - [Organizational](#organizational)
64
+ - [Explanatory](#explanatory)
65
+ - [Security](#security)
66
+ - [Calendar](#calendar)
67
+ - [General](#general)
68
+ - [Unknown and extended properties](#unknown-and-extended-properties)
69
+ - [Date and time values](#date-and-time-values)
70
+ - [Escaping utilities](#escaping-utilities)
71
+ - [Validation](#validation)
72
+ - [RFC compliance notes](#rfc-compliance-notes)
73
+
74
+ ---
75
+
76
+ ## Parsing
77
+
78
+ ### `VCard.parse(text: string): VCard[]`
79
+
80
+ Parse one or more vCards from a string. Returns an array (empty if no vCards are found). Never throws — malformed input is handled tolerantly and a list of `parseWarnings` is attached to each resulting `VCard`.
81
+
82
+ ```ts
83
+ import { VCard } from '@pipobscure/vcard';
84
+
85
+ const vcards = VCard.parse(text);
86
+ for (const vc of vcards) {
87
+ if (vc.parseWarnings.length > 0) {
88
+ console.warn('Parse warnings:', vc.parseWarnings);
89
+ }
90
+ console.log(vc.displayName);
91
+ }
92
+ ```
93
+
94
+ ### `VCard.parseOne(text: string): VCard`
95
+
96
+ Parse exactly one vCard. Throws `Error` if the input contains no vCards.
97
+
98
+ ```ts
99
+ const vc = VCard.parseOne(text);
100
+ ```
101
+
102
+ ### Convenience functions
103
+
104
+ ```ts
105
+ import { parse, parseOne } from '@pipobscure/vcard';
106
+
107
+ const vcards = parse(text); // same as VCard.parse()
108
+ const vc = parseOne(text); // same as VCard.parseOne()
109
+ ```
110
+
111
+ ### Parsing tolerance
112
+
113
+ The parser handles all of the following without throwing:
114
+
115
+ - vCard versions 2.1, 3.0, and 4.0
116
+ - LF-only line endings (in addition to RFC-required CRLF)
117
+ - Mixed line endings within a single file
118
+ - Folded content lines (CRLF + whitespace continuation)
119
+ - `ENCODING=QUOTED-PRINTABLE` with multi-byte UTF-8 sequences (v2.1/v3.0)
120
+ - `ENCODING=b` (base64) parameter flag (v3.0 syntax)
121
+ - Case-insensitive property names and parameter names
122
+ - Comma-separated `TYPE` values (`TYPE=WORK,VOICE`)
123
+ - Quoted parameter values with commas (`TYPE="work,voice"`)
124
+ - Item-grouped properties (`item1.EMAIL`, `item1.X-ABLabel`)
125
+ - Unknown / proprietary properties (stored verbatim as `UnknownProperty`)
126
+ - Multiple vCards in a single string
127
+ - Content before `BEGIN:VCARD` and between vCards
128
+ - Missing `END:VCARD` (parsed with a warning)
129
+ - Properties with empty values
130
+
131
+ ### `ParseWarning`
132
+
133
+ Each parsed `VCard` has a `parseWarnings: ParseWarning[]` field.
134
+
135
+ ```ts
136
+ interface ParseWarning {
137
+ line?: number; // 1-based line number in the input, if known
138
+ message: string;
139
+ }
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Generating
145
+
146
+ ### `vcard.toString(options?: GenerateOptions): string`
147
+
148
+ Serialize the vCard to RFC 6350-compliant text. Throws `VCardError` if the card fails validation.
149
+
150
+ ```ts
151
+ const text = vc.toString();
152
+ ```
153
+
154
+ ### `vcard.toStringLenient(): string`
155
+
156
+ Serialize without validation. Useful for inspecting or debugging partial/draft cards.
157
+
158
+ ```ts
159
+ const text = vc.toStringLenient();
160
+ ```
161
+
162
+ ### `stringify(vcard: VCard | VCard[]): string`
163
+
164
+ Serialize one or multiple vCards to a single string.
165
+
166
+ ```ts
167
+ import { stringify } from '@pipobscure/vcard';
168
+
169
+ const text = stringify([vc1, vc2]);
170
+ ```
171
+
172
+ ### `GenerateOptions`
173
+
174
+ ```ts
175
+ interface GenerateOptions {
176
+ validate?: boolean; // default: true — throw VCardError on invalid cards
177
+ }
178
+ ```
179
+
180
+ ### Output guarantees
181
+
182
+ - Line endings are always `\r\n` (CRLF) per RFC 6350 §3.2.
183
+ - Lines are folded at 75 octets (UTF-8 byte count), not 75 characters. Continuation lines begin with a single space.
184
+ - `VERSION:4.0` is always the first property after `BEGIN:VCARD`.
185
+ - All text values are escaped (`\`, `,`, `;`, newline) per RFC 6350 §3.4.
186
+ - Parameter values that contain `:`, `;`, `,`, or `"` are automatically quoted.
187
+ - Properties are emitted in a consistent, human-readable order.
188
+
189
+ ---
190
+
191
+ ## The VCard class
192
+
193
+ ```ts
194
+ class VCard {
195
+ // Accumulates warnings from parsing; empty for programmatically built cards
196
+ parseWarnings: ParseWarning[];
197
+
198
+ // Version string from input (always '4.0' in generated output)
199
+ parsedVersion: string;
200
+
201
+ // ── Required (RFC cardinality 1*) ──────────────────────────────────────
202
+ fn: FNProperty[]; // at least one required
203
+
204
+ // ── Optional singular (RFC cardinality *1) ─────────────────────────────
205
+ n?: NProperty;
206
+ bday?: BDayProperty;
207
+ anniversary?: AnniversaryProperty;
208
+ gender?: GenderProperty;
209
+ prodid?: ProdIDProperty;
210
+ rev?: RevProperty;
211
+ uid?: UIDProperty;
212
+ kind?: KindProperty;
213
+
214
+ // ── Optional multiple (RFC cardinality *) ──────────────────────────────
215
+ nickname: NicknameProperty[];
216
+ photo: PhotoProperty[];
217
+ adr: AdrProperty[];
218
+ tel: TelProperty[];
219
+ email: EmailProperty[];
220
+ impp: IMPPProperty[];
221
+ lang: LangProperty[];
222
+ tz: TZProperty[];
223
+ geo: GeoProperty[];
224
+ title: TitleProperty[];
225
+ role: RoleProperty[];
226
+ logo: LogoProperty[];
227
+ org: OrgProperty[];
228
+ member: MemberProperty[];
229
+ related: RelatedProperty[];
230
+ categories: CategoriesProperty[];
231
+ note: NoteProperty[];
232
+ sound: SoundProperty[];
233
+ clientpidmap: ClientPidMapProperty[];
234
+ url: URLProperty[];
235
+ key: KeyProperty[];
236
+ fburl: FBURLProperty[];
237
+ caladruri: CALADRURIProperty[];
238
+ caluri: CALURIProperty[];
239
+ source: SourceProperty[];
240
+ xml: XMLProperty[];
241
+
242
+ // Unknown / extended / vendor properties (X-, unrecognised IANA)
243
+ extended: UnknownProperty[];
244
+ }
245
+ ```
246
+
247
+ ### Convenience accessors
248
+
249
+ ```ts
250
+ vc.displayName // string — value of the most-preferred FN property
251
+ vc.primaryEmail // string | undefined — most-preferred email address
252
+ vc.primaryTel // string | undefined — most-preferred telephone
253
+ ```
254
+
255
+ "Most preferred" means the property with the lowest `PREF` parameter value (1 = highest preference). If no `PREF` is set, the first property in the list is used.
256
+
257
+ ### `VCard.create(fn: string): VCard`
258
+
259
+ Quick-create a valid vCard with a single formatted name.
260
+
261
+ ```ts
262
+ const vc = VCard.create('Bob Builder');
263
+ vc.email.push(new EmailProperty('bob@example.com'));
264
+ ```
265
+
266
+ ### `vcard.addProperty(prop: Property): void`
267
+
268
+ Add any property to the correct typed field on the VCard.
269
+
270
+ ```ts
271
+ vc.addProperty(new EmailProperty('alice@example.com'));
272
+ // equivalent to: vc.email.push(new EmailProperty('alice@example.com'))
273
+ ```
274
+
275
+ ### `vcard.allProperties(): Property[]`
276
+
277
+ Return all properties as a flat array in logical order. Used internally by `toString()`.
278
+
279
+ ### `vcard.validate(): ValidationResult`
280
+
281
+ Validate without throwing.
282
+
283
+ ```ts
284
+ const result = vc.validate();
285
+ if (!result.valid) {
286
+ for (const err of result.errors) {
287
+ console.error(`${err.property}: ${err.message}`);
288
+ }
289
+ }
290
+ ```
291
+
292
+ ### `vcard.clone(): VCard`
293
+
294
+ Deep-clone by round-tripping through serialization. Always produces a clean v4 vCard.
295
+
296
+ ### `vcard.toJSON(): Record<string, unknown>`
297
+
298
+ Simplified JSON representation (not full jCard / RFC 7095).
299
+
300
+ ---
301
+
302
+ ## Property classes
303
+
304
+ Every property class extends `Property` and exposes:
305
+
306
+ - `name: string` — uppercase property name (e.g. `'FN'`)
307
+ - `group?: string` — optional group label (e.g. `'item1'` in Apple exports)
308
+ - `params: ParameterMap` — raw parameter map (`Map<string, string | string[]>`)
309
+ - `toContentLine(): string` — serializes the value portion (used by the generator)
310
+
311
+ ### Common parameter accessors
312
+
313
+ All property classes inherit these convenience getters/setters:
314
+
315
+ ```ts
316
+ prop.type // string[] — TYPE parameter values, lowercased
317
+ prop.pref // number | undefined — PREF parameter (1–100, 1 = most preferred)
318
+ prop.language // string | undefined — LANGUAGE parameter (BCP 47 tag)
319
+ prop.altid // string | undefined — ALTID parameter
320
+ prop.pid // string | undefined — PID parameter
321
+ prop.valueType // string | undefined — VALUE parameter (e.g. 'uri', 'text')
322
+ ```
323
+
324
+ Setting a value to `undefined` removes the parameter:
325
+
326
+ ```ts
327
+ prop.type = ['work', 'voice'];
328
+ prop.pref = 1;
329
+ prop.language = 'en';
330
+ prop.language = undefined; // removes LANGUAGE parameter
331
+ ```
332
+
333
+ ---
334
+
335
+ ### Identification properties
336
+
337
+ #### `FNProperty` — Formatted Name (RFC 6350 §6.2.1)
338
+
339
+ Cardinality: `1*` (required, one or more)
340
+
341
+ ```ts
342
+ class FNProperty extends TextProperty {
343
+ value: string;
344
+ }
345
+
346
+ new FNProperty('Alice Example')
347
+ new FNProperty('Alice Example', params, group)
348
+ ```
349
+
350
+ A vCard must have at least one `FN`. Multiple `FN` properties may be given to provide alternate language versions using `ALTID` and `LANGUAGE` parameters:
351
+
352
+ ```ts
353
+ const fn1 = new FNProperty('山田太郎');
354
+ fn1.altid = '1';
355
+ fn1.language = 'ja';
356
+
357
+ const fn2 = new FNProperty('Yamada Taro');
358
+ fn2.altid = '1';
359
+ fn2.language = 'en';
360
+ ```
361
+
362
+ #### `NProperty` — Structured Name (RFC 6350 §6.2.2)
363
+
364
+ Cardinality: `*1` (optional, at most one)
365
+
366
+ ```ts
367
+ class NProperty extends Property {
368
+ value: StructuredName;
369
+ }
370
+
371
+ interface StructuredName {
372
+ familyNames: string[]; // e.g. ['Smith']
373
+ givenNames: string[]; // e.g. ['John']
374
+ additionalNames: string[]; // e.g. ['Q.']
375
+ honorificPrefixes: string[]; // e.g. ['Dr.']
376
+ honorificSuffixes: string[]; // e.g. ['Jr.', 'PhD']
377
+ }
378
+ ```
379
+
380
+ Each component is a list to support multiple values (e.g. compound surnames). The `SORT-AS` parameter provides a sort key:
381
+
382
+ ```ts
383
+ const n = new NProperty({
384
+ familyNames: ['van der Berg'],
385
+ givenNames: ['Jan'],
386
+ additionalNames: [],
387
+ honorificPrefixes: [],
388
+ honorificSuffixes: [],
389
+ });
390
+ n.params.set('SORT-AS', 'Berg,Jan');
391
+ ```
392
+
393
+ #### `NicknameProperty` — Nickname (RFC 6350 §6.2.3)
394
+
395
+ Cardinality: `*`
396
+
397
+ ```ts
398
+ class NicknameProperty extends TextListProperty {
399
+ values: string[];
400
+ }
401
+
402
+ new NicknameProperty(['Johnny', 'The Genius'])
403
+ ```
404
+
405
+ #### `PhotoProperty` — Photo (RFC 6350 §6.2.4)
406
+
407
+ Cardinality: `*`
408
+
409
+ The value is a URI. In v4, inline data is expressed as a `data:` URI.
410
+
411
+ ```ts
412
+ class PhotoProperty extends UriProperty {
413
+ value: string;
414
+ mediatype?: string; // MEDIATYPE parameter
415
+ }
416
+
417
+ new PhotoProperty('https://example.com/alice.jpg')
418
+ new PhotoProperty('data:image/jpeg;base64,/9j/4AA...')
419
+ ```
420
+
421
+ #### `BDayProperty` — Birthday (RFC 6350 §6.2.5)
422
+
423
+ Cardinality: `*1`
424
+
425
+ ```ts
426
+ class BDayProperty extends Property {
427
+ dateValue: DateAndOrTime | null; // parsed date, or null if VALUE=text
428
+ textValue?: string; // present when VALUE=text
429
+ }
430
+
431
+ // Parsed from a date string
432
+ BDayProperty.fromText('19900315') // full date
433
+ BDayProperty.fromText('--0315') // month+day, no year
434
+ BDayProperty.fromText('1990') // year only
435
+
436
+ // VALUE=text for approximate dates
437
+ BDayProperty.fromText('circa 1800', new Map([['VALUE', 'text']]))
438
+
439
+ // From a typed value
440
+ new BDayProperty({ year: 1990, month: 3, day: 15, hasTime: false })
441
+ new BDayProperty('circa 1800') // stores as textValue
442
+ ```
443
+
444
+ #### `AnniversaryProperty` — Anniversary (RFC 6350 §6.2.6)
445
+
446
+ Cardinality: `*1`. Identical structure to `BDayProperty`.
447
+
448
+ #### `GenderProperty` — Gender (RFC 6350 §6.2.7)
449
+
450
+ Cardinality: `*1`
451
+
452
+ ```ts
453
+ class GenderProperty extends Property {
454
+ value: Gender;
455
+ }
456
+
457
+ interface Gender {
458
+ sex: GenderSex; // 'M' | 'F' | 'O' | 'N' | 'U' | ''
459
+ identity?: string; // free-form identity text
460
+ }
461
+
462
+ new GenderProperty({ sex: 'M' })
463
+ new GenderProperty({ sex: 'O', identity: 'non-binary' })
464
+ new GenderProperty({ sex: '', identity: 'it/its' })
465
+ ```
466
+
467
+ Sex values per RFC 6350:
468
+
469
+ | Value | Meaning |
470
+ |-------|---------|
471
+ | `M` | Male |
472
+ | `F` | Female |
473
+ | `O` | Other |
474
+ | `N` | None or not applicable |
475
+ | `U` | Unknown |
476
+ | `''` | Not specified (use with identity text) |
477
+
478
+ ---
479
+
480
+ ### Delivery addressing
481
+
482
+ #### `AdrProperty` — Address (RFC 6350 §6.3.1)
483
+
484
+ Cardinality: `*`
485
+
486
+ ```ts
487
+ class AdrProperty extends Property {
488
+ value: Address;
489
+ label?: string; // LABEL parameter — delivery label text
490
+ cc?: string; // CC parameter — ISO 3166-1 country code
491
+ }
492
+
493
+ interface Address {
494
+ postOfficeBox: string;
495
+ extendedAddress: string;
496
+ streetAddress: string;
497
+ locality: string; // city
498
+ region: string; // state/province
499
+ postalCode: string;
500
+ countryName: string;
501
+ }
502
+
503
+ const adr = new AdrProperty({
504
+ postOfficeBox: '',
505
+ extendedAddress: 'Suite 100',
506
+ streetAddress: '1 Infinite Loop',
507
+ locality: 'Cupertino',
508
+ region: 'CA',
509
+ postalCode: '95014',
510
+ countryName: 'USA',
511
+ });
512
+ adr.type = ['work'];
513
+ adr.label = '1 Infinite Loop\nCupertino, CA 95014\nUSA';
514
+ ```
515
+
516
+ ---
517
+
518
+ ### Communications
519
+
520
+ #### `TelProperty` — Telephone (RFC 6350 §6.4.1)
521
+
522
+ Cardinality: `*`
523
+
524
+ In v4, telephone values should be URIs (using `tel:` or `sip:` schemes). Plain text values are also accepted for compatibility.
525
+
526
+ ```ts
527
+ class TelProperty extends Property {
528
+ value: string;
529
+ isUri: boolean; // true when value is a URI
530
+ }
531
+
532
+ new TelProperty('tel:+1-555-123-4567') // URI (recommended)
533
+ new TelProperty('+1 555 123 4567') // text (tolerated)
534
+ ```
535
+
536
+ Well-known TYPE values: `voice`, `fax`, `cell`, `video`, `pager`, `textphone`, `text`, `work`, `home`.
537
+
538
+ #### `EmailProperty` — Email (RFC 6350 §6.4.2)
539
+
540
+ Cardinality: `*`
541
+
542
+ ```ts
543
+ class EmailProperty extends TextProperty {
544
+ value: string;
545
+ }
546
+
547
+ const email = new EmailProperty('alice@example.com');
548
+ email.type = ['work'];
549
+ email.pref = 1;
550
+ ```
551
+
552
+ #### `IMPPProperty` — Instant Messaging (RFC 6350 §6.4.3)
553
+
554
+ Cardinality: `*`. Value is a URI (e.g. `xmpp:alice@example.com`, `sip:alice@example.com`).
555
+
556
+ #### `LangProperty` — Language (RFC 6350 §6.4.4)
557
+
558
+ Cardinality: `*`. Value is a BCP 47 language tag.
559
+
560
+ ```ts
561
+ const lang = new LangProperty('fr');
562
+ lang.pref = 1;
563
+ ```
564
+
565
+ ---
566
+
567
+ ### Geographic
568
+
569
+ #### `TZProperty` — Time Zone (RFC 6350 §6.5.1)
570
+
571
+ Cardinality: `*`
572
+
573
+ ```ts
574
+ class TZProperty extends Property {
575
+ value: string;
576
+ valueKind: 'utc-offset' | 'uri' | 'text';
577
+ }
578
+
579
+ TZProperty.fromText('-0500') // UTC offset → valueKind: 'utc-offset'
580
+ TZProperty.fromText('-05:00') // colon format UTC offset
581
+ TZProperty.fromText('America/New_York', // IANA name → valueKind: 'text'
582
+ new Map([['VALUE', 'text']]))
583
+ TZProperty.fromText('https://...', ...) // URI → valueKind: 'uri'
584
+ ```
585
+
586
+ #### `GeoProperty` — Geographic Position (RFC 6350 §6.5.2)
587
+
588
+ Cardinality: `*`. Value is a `geo:` URI.
589
+
590
+ ```ts
591
+ class GeoProperty extends Property {
592
+ uri: string;
593
+ readonly coordinates: { latitude: number; longitude: number } | undefined;
594
+ }
595
+
596
+ // From coordinates
597
+ const geo = GeoProperty.fromCoordinates(37.386013, -122.082932);
598
+ // geo.uri === 'geo:37.386013,-122.082932'
599
+
600
+ // From URI string
601
+ const geo = new GeoProperty('geo:51.5074,-0.1278');
602
+ console.log(geo.coordinates); // { latitude: 51.5074, longitude: -0.1278 }
603
+ ```
604
+
605
+ ---
606
+
607
+ ### Organizational
608
+
609
+ #### `TitleProperty` / `RoleProperty`
610
+
611
+ Cardinality: `*`. Both are text properties.
612
+
613
+ ```ts
614
+ new TitleProperty('Software Engineer')
615
+ new RoleProperty('Lead Developer')
616
+ ```
617
+
618
+ #### `LogoProperty`
619
+
620
+ Cardinality: `*`. URI value (same as `PhotoProperty`).
621
+
622
+ #### `OrgProperty` — Organization (RFC 6350 §6.6.4)
623
+
624
+ Cardinality: `*`
625
+
626
+ ```ts
627
+ class OrgProperty extends Property {
628
+ value: Organization;
629
+ }
630
+
631
+ interface Organization {
632
+ name: string;
633
+ units: string[]; // organizational units (zero or more)
634
+ }
635
+
636
+ new OrgProperty({ name: 'Acme Corp', units: [] })
637
+ new OrgProperty({ name: 'Acme Corp', units: ['Engineering', 'Platform'] })
638
+ ```
639
+
640
+ The `SORT-AS` parameter provides a sort key for the name and units.
641
+
642
+ #### `MemberProperty` — Group Member (RFC 6350 §6.6.5)
643
+
644
+ Cardinality: `*`. URI value. Used in `KIND:group` cards to list members.
645
+
646
+ ```ts
647
+ new MemberProperty('urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6')
648
+ new MemberProperty('mailto:bob@example.com')
649
+ ```
650
+
651
+ #### `RelatedProperty` — Related Entity (RFC 6350 §6.6.6)
652
+
653
+ Cardinality: `*`. May be a URI or text (`VALUE=text`).
654
+
655
+ ```ts
656
+ class RelatedProperty extends Property {
657
+ value: string;
658
+ isUri: boolean;
659
+ }
660
+
661
+ RelatedProperty.fromText('urn:uuid:...', params) // URI
662
+ RelatedProperty.fromText('Jane Doe', // text
663
+ new Map([['VALUE', 'text'], ['TYPE', 'spouse']]))
664
+ ```
665
+
666
+ Well-known TYPE values: `contact`, `acquaintance`, `friend`, `met`, `co-worker`, `colleague`, `co-resident`, `neighbor`, `child`, `parent`, `sibling`, `spouse`, `kin`, `muse`, `crush`, `date`, `sweetheart`, `me`, `agent`, `emergency`.
667
+
668
+ ---
669
+
670
+ ### Explanatory
671
+
672
+ #### `CategoriesProperty` — Categories (RFC 6350 §6.7.1)
673
+
674
+ Cardinality: `*`. Value is a comma-separated list of text tags.
675
+
676
+ ```ts
677
+ new CategoriesProperty(['friend', 'colleague', 'vip'])
678
+ ```
679
+
680
+ #### `NoteProperty` — Note (RFC 6350 §6.7.2)
681
+
682
+ Cardinality: `*`. Text value; newlines are encoded as `\n` in the vCard text.
683
+
684
+ #### `ProdIDProperty` — Product Identifier (RFC 6350 §6.7.3)
685
+
686
+ Cardinality: `*1`. Should identify the software that created the vCard.
687
+
688
+ ```ts
689
+ vc.prodid = new ProdIDProperty('-//My App//My App 1.0//EN');
690
+ ```
691
+
692
+ #### `RevProperty` — Revision Timestamp (RFC 6350 §6.7.4)
693
+
694
+ Cardinality: `*1`. Stored as a JavaScript `Date`, or a raw string if parsing failed.
695
+
696
+ ```ts
697
+ class RevProperty extends Property {
698
+ value: Date | string;
699
+ }
700
+
701
+ vc.rev = new RevProperty(new Date());
702
+ // Serialized as: REV:20240615T103000Z
703
+ ```
704
+
705
+ #### `SoundProperty` — Sound (RFC 6350 §6.7.5)
706
+
707
+ Cardinality: `*`. URI value.
708
+
709
+ #### `UIDProperty` — Unique Identifier (RFC 6350 §6.7.6)
710
+
711
+ Cardinality: `*1`. Typically a `urn:uuid:` URI, but may be any URI or text.
712
+
713
+ ```ts
714
+ vc.uid = new UIDProperty('urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6');
715
+ ```
716
+
717
+ When the value looks like a URI (has a scheme), it is serialized without text escaping.
718
+
719
+ #### `ClientPidMapProperty` — Client PID Map (RFC 6350 §6.7.7)
720
+
721
+ Cardinality: `*`. Used for synchronisation between CardDAV clients. The value is a semicolon-separated pair of a PID number and a URI.
722
+
723
+ ```ts
724
+ class ClientPidMapProperty extends Property {
725
+ value: ClientPidMap;
726
+ }
727
+
728
+ interface ClientPidMap {
729
+ pid: number;
730
+ uri: string;
731
+ }
732
+
733
+ new ClientPidMapProperty({ pid: 1, uri: 'urn:uuid:...' })
734
+ ```
735
+
736
+ #### `URLProperty` — URL (RFC 6350 §6.7.8)
737
+
738
+ Cardinality: `*`. URI value.
739
+
740
+ ---
741
+
742
+ ### Security
743
+
744
+ #### `KeyProperty` — Public Key (RFC 6350 §6.8.1)
745
+
746
+ Cardinality: `*`. May be a URI or inline base64-encoded data.
747
+
748
+ ```ts
749
+ class KeyProperty extends Property {
750
+ value: string;
751
+ isUri: boolean;
752
+ }
753
+
754
+ KeyProperty.fromText('http://example.com/key.pgp',
755
+ new Map([['VALUE', 'uri'], ['TYPE', 'work']]))
756
+ ```
757
+
758
+ ---
759
+
760
+ ### Calendar
761
+
762
+ #### `FBURLProperty` — Free/Busy URL (RFC 6350 §6.9.1)
763
+
764
+ Cardinality: `*`. URI value.
765
+
766
+ #### `CALADRURIProperty` — Calendar User Address URI (RFC 6350 §6.9.2)
767
+
768
+ Cardinality: `*`. URI value. Used to schedule meetings with the contact.
769
+
770
+ #### `CALURIProperty` — Calendar URI (RFC 6350 §6.9.3)
771
+
772
+ Cardinality: `*`. URI value.
773
+
774
+ ---
775
+
776
+ ### General
777
+
778
+ #### `KindProperty` — Kind (RFC 6350 §6.1.4)
779
+
780
+ Cardinality: `*1`. Classifies the vCard object.
781
+
782
+ ```ts
783
+ vc.kind = new KindProperty('individual'); // default
784
+ vc.kind = new KindProperty('group'); // distribution list
785
+ vc.kind = new KindProperty('org'); // organisation
786
+ vc.kind = new KindProperty('location'); // place
787
+ ```
788
+
789
+ #### `SourceProperty` — Source (RFC 6350 §6.1.3)
790
+
791
+ Cardinality: `*`. URI indicating where the vCard data can be fetched.
792
+
793
+ #### `XMLProperty` — XML (RFC 6350 §6.1.5)
794
+
795
+ Cardinality: `*`. Extends vCard with XML data. The value is escaped text containing XML.
796
+
797
+ ---
798
+
799
+ ### Unknown and extended properties
800
+
801
+ Any property not listed in RFC 6350 — including `X-` vendor extensions, proprietary Apple/Google/Outlook properties, and unknown IANA properties — is stored as an `UnknownProperty` in `vcard.extended`. This ensures round-trip fidelity.
802
+
803
+ ```ts
804
+ class UnknownProperty extends Property {
805
+ rawValue: string; // the raw, uninterpreted value string
806
+ }
807
+
808
+ // Example: Apple-specific X-ABLabel grouped with an email
809
+ // item1.EMAIL:john@example.com
810
+ // item1.X-ABLabel:Work
811
+ const label = vc.extended.find(p => p.name === 'X-ABLABEL' && p.group === 'item1');
812
+ console.log(label?.rawValue); // 'Work'
813
+ ```
814
+
815
+ Unknown properties are serialized back verbatim, preserving groups and parameters.
816
+
817
+ ---
818
+
819
+ ## Date and time values
820
+
821
+ RFC 6350 §4.3 defines several date/time formats. The library uses the `DateAndOrTime` interface for structured representation.
822
+
823
+ ```ts
824
+ interface DateAndOrTime {
825
+ year?: number;
826
+ month?: number;
827
+ day?: number;
828
+ hour?: number;
829
+ minute?: number;
830
+ second?: number;
831
+ utcOffset?: string; // 'Z', '+HH:MM', '-HH:MM', '+HHMM', etc.
832
+ hasTime: boolean;
833
+ }
834
+ ```
835
+
836
+ ### `parseDateAndOrTime(value: string): DateAndOrTime | null`
837
+
838
+ Parse any RFC 6350 date/time string.
839
+
840
+ ```ts
841
+ import { parseDateAndOrTime } from '@pipobscure/vcard';
842
+
843
+ parseDateAndOrTime('19900315')
844
+ // { year: 1990, month: 3, day: 15, hasTime: false }
845
+
846
+ parseDateAndOrTime('--0315')
847
+ // { year: undefined, month: 3, day: 15, hasTime: false }
848
+ // (birthday where year is not known)
849
+
850
+ parseDateAndOrTime('1990')
851
+ // { year: 1990, hasTime: false }
852
+
853
+ parseDateAndOrTime('20090808T1430-0500')
854
+ // { year: 2009, month: 8, day: 8, hour: 14, minute: 30, utcOffset: '-0500', hasTime: true }
855
+
856
+ parseDateAndOrTime('20240101T120000Z')
857
+ // { year: 2024, month: 1, day: 1, hour: 12, minute: 0, second: 0, utcOffset: 'Z', hasTime: true }
858
+ ```
859
+
860
+ Returns `null` if the string is empty or completely unparseable.
861
+
862
+ ### `formatDateAndOrTime(dt: DateAndOrTime): string`
863
+
864
+ Serialize a `DateAndOrTime` back to RFC 6350 text.
865
+
866
+ ---
867
+
868
+ ## Escaping utilities
869
+
870
+ These are exported for advanced use; the library handles them automatically during parsing and generation.
871
+
872
+ ```ts
873
+ import {
874
+ escapeText,
875
+ escapeStructuredComponent,
876
+ unescapeText,
877
+ parseStructured,
878
+ parseList,
879
+ parseStructuredList,
880
+ needsParamQuoting,
881
+ quoteParamValue,
882
+ unquoteParamValue,
883
+ splitStructured,
884
+ splitList,
885
+ } from '@pipobscure/vcard';
886
+ ```
887
+
888
+ ### Text escaping (RFC 6350 §3.4)
889
+
890
+ ```ts
891
+ escapeText('Smith, John; Jr.')
892
+ // 'Smith\\, John\\; Jr.'
893
+
894
+ unescapeText('Smith\\, John\\; Jr.')
895
+ // 'Smith, John; Jr.'
896
+
897
+ unescapeText('Line one\\nLine two')
898
+ // 'Line one\nLine two'
899
+ ```
900
+
901
+ ### Structured value splitting
902
+
903
+ Splitting respects backslash escapes, so an escaped delimiter is not treated as a component boundary.
904
+
905
+ ```ts
906
+ // Semicolon-separated (N, ADR, ORG, GENDER)
907
+ parseStructured('Smith;John;Q.;Dr.;')
908
+ // ['Smith', 'John', 'Q.', 'Dr.', '']
909
+
910
+ // Comma-separated (NICKNAME, CATEGORIES)
911
+ parseList('friend,colleague,vip')
912
+ // ['friend', 'colleague', 'vip']
913
+
914
+ // Structured-with-lists (N honorific-suffixes: "ing. jr,M.Sc.")
915
+ parseStructuredList('Smith;Simon;;;ing. jr,M.Sc.')
916
+ // [['Smith'], ['Simon'], [], [], ['ing. jr', 'M.Sc.']]
917
+ ```
918
+
919
+ ### Parameter quoting
920
+
921
+ ```ts
922
+ needsParamQuoting('work,voice') // true
923
+ needsParamQuoting('work') // false
924
+
925
+ quoteParamValue('work,voice') // '"work,voice"'
926
+ quoteParamValue('work') // 'work'
927
+
928
+ unquoteParamValue('"work,voice"') // 'work,voice'
929
+ ```
930
+
931
+ ---
932
+
933
+ ## Validation
934
+
935
+ ### `VCardError`
936
+
937
+ Thrown by `vcard.toString()` when the card fails strict validation. Has an optional `property` field naming the offending property.
938
+
939
+ ```ts
940
+ import { VCardError } from '@pipobscure/vcard';
941
+
942
+ try {
943
+ const text = vc.toString();
944
+ } catch (err) {
945
+ if (err instanceof VCardError) {
946
+ console.error(`Validation failed on ${err.property}: ${err.message}`);
947
+ }
948
+ }
949
+ ```
950
+
951
+ ### Validation rules (enforced on generation)
952
+
953
+ | Rule | Detail |
954
+ |------|--------|
955
+ | `FN` required | At least one `FN` property must be present (cardinality `1*`). |
956
+ | `PREF` range | PREF parameter must be an integer between 1 and 100 inclusive. |
957
+ | `GENDER` sex | Must be one of `M`, `F`, `O`, `N`, `U`, or empty string. |
958
+ | `REV` validity | If a `Date` object is stored, it must not be `NaN`. |
959
+
960
+ ### `vcard.validate(): ValidationResult`
961
+
962
+ Non-throwing alternative to `toString()` for checking validity.
963
+
964
+ ```ts
965
+ interface ValidationResult {
966
+ valid: boolean;
967
+ errors: ValidationError[];
968
+ }
969
+
970
+ interface ValidationError {
971
+ property: string;
972
+ message: string;
973
+ }
974
+ ```
975
+
976
+ ---
977
+
978
+ ## RFC compliance notes
979
+
980
+ ### vCard 4.0 (RFC 6350)
981
+
982
+ This library targets RFC 6350. All properties defined in §6 are implemented as typed classes with correct cardinality semantics, value type parsing, and serialization.
983
+
984
+ ### Line folding (RFC 6350 §3.2)
985
+
986
+ Lines are folded at **75 octets** (UTF-8 bytes), not 75 characters. This is significant for non-ASCII content: a line of 25 three-byte characters (e.g. CJK) reaches the limit even though it is only 25 characters long.
987
+
988
+ The fold indicator (a single space on the continuation line) is stripped during unfolding. To preserve a word boundary across a fold, include the space as the last character of the preceding segment:
989
+
990
+ ```
991
+ FN:A very long name that spans \r\n
992
+ multiple lines\r\n
993
+ ```
994
+
995
+ After unfolding: `A very long name that spans multiple lines`.
996
+
997
+ ### Text escaping (RFC 6350 §3.4)
998
+
999
+ In `TEXT` value types, the following characters are escaped on output and unescaped on input:
1000
+
1001
+ | Sequence | Meaning |
1002
+ |----------|---------|
1003
+ | `\\` | Literal backslash |
1004
+ | `\n` or `\N` | Newline (U+000A) |
1005
+ | `\,` | Literal comma |
1006
+ | `\;` | Literal semicolon |
1007
+
1008
+ Note that colons do **not** need escaping in property values (the parser finds the first colon to split name from value).
1009
+
1010
+ ### Multi-valued parameters
1011
+
1012
+ The `TYPE` parameter may be specified in two equivalent ways, both of which are handled:
1013
+
1014
+ ```
1015
+ TEL;TYPE=work;TYPE=voice:...
1016
+ TEL;TYPE="work,voice":...
1017
+ TEL;TYPE=WORK,VOICE:... (v3 style — tolerated)
1018
+ ```
1019
+
1020
+ All three produce `prop.type === ['work', 'voice']`.
1021
+
1022
+ ### vCard 3.0 and 2.1 compatibility
1023
+
1024
+ The parser accepts v3.0 and v2.1 input:
1025
+
1026
+ - `ENCODING=QUOTED-PRINTABLE` values are decoded (with correct multi-byte UTF-8 support).
1027
+ - `ENCODING=b` (base64) is stripped; the value is stored as-is.
1028
+ - `CHARSET` parameters are accepted and ignored (the library assumes UTF-8 throughout).
1029
+ - The `LABEL` property (removed in v4) is stored as an `UnknownProperty`.
1030
+ - Bare type tokens without `=` (`TEL;WORK;VOICE:...`) are interpreted as `TYPE` values.
1031
+
1032
+ ### Property groups (Apple Contacts)
1033
+
1034
+ Apple Contacts uses a grouping mechanism to associate related properties:
1035
+
1036
+ ```
1037
+ item1.EMAIL;type=INTERNET:john@example.com
1038
+ item1.X-ABLabel:Work
1039
+ ```
1040
+
1041
+ The `group` field on the property is set to `'item1'`. Grouped properties are preserved on round-trip.
1042
+
1043
+ ---
1044
+
1045
+ ## Types reference
1046
+
1047
+ ```ts
1048
+ // Value types
1049
+ type ValueType = 'text' | 'uri' | 'date' | 'time' | 'date-time' |
1050
+ 'date-and-or-time' | 'timestamp' | 'boolean' |
1051
+ 'integer' | 'float' | 'utc-offset' | 'language-tag';
1052
+
1053
+ type TypeValue = 'work' | 'home' | 'voice' | 'fax' | 'cell' | 'video' |
1054
+ 'pager' | 'textphone' | 'text' | 'contact' | 'friend' |
1055
+ 'spouse' | 'child' | 'parent' | /* ... */ | string;
1056
+
1057
+ type KindValue = 'individual' | 'group' | 'org' | 'location' | string;
1058
+ type GenderSex = 'M' | 'F' | 'O' | 'N' | 'U' | '';
1059
+
1060
+ // Structured values
1061
+ interface StructuredName { familyNames, givenNames, additionalNames,
1062
+ honorificPrefixes, honorificSuffixes }
1063
+ interface Address { postOfficeBox, extendedAddress, streetAddress,
1064
+ locality, region, postalCode, countryName }
1065
+ interface Organization { name, units }
1066
+ interface Gender { sex, identity? }
1067
+ interface ClientPidMap { pid, uri }
1068
+ interface DateAndOrTime { year?, month?, day?, hour?, minute?, second?,
1069
+ utcOffset?, hasTime }
1070
+
1071
+ // Parameter map
1072
+ type ParameterMap = Map<string, string | string[]>;
1073
+ ```
1074
+
1075
+ ---
1076
+
1077
+ ## License
1078
+
1079
+ MIT