diff --git a/src/lib/__tests__/vcard.test.ts b/src/lib/__tests__/vcard.test.ts index ae2253d8..cff99bef 100644 --- a/src/lib/__tests__/vcard.test.ts +++ b/src/lib/__tests__/vcard.test.ts @@ -74,6 +74,32 @@ describe('parseVCard', () => { expect(note).toBe('line one\nline two, still going'); }); + it('decodes the escaped colons Google Contacts writes (#1438)', () => { + const vcf = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:Test Example', + 'NOTE:Reference\\: fictional value\\nSecond line\\, with comma\\; and semicolon', + 'END:VCARD', + ].join('\r\n'); + const [card] = parseVCard(vcf); + expect(Object.values(card.notes ?? {})[0]?.note).toBe( + 'Reference: fictional value\nSecond line, with comma; and semicolon', + ); + }); + + it('keeps an escaped backslash literal when an escapable character follows', () => { + const vcf = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:Paths', + 'NOTE:C:\\\\new\\\\:dir\\\\\\:x\\\\\\\\', + 'END:VCARD', + ].join('\r\n'); + const [card] = parseVCard(vcf); + expect(Object.values(card.notes ?? {})[0]?.note).toBe('C:\\new\\:dir\\:x\\\\'); + }); + it('parses CATEGORIES into keywords', () => { const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tagged\r\nEMAIL:t@x.com\r\nCATEGORIES:Friends,VIP\r\nEND:VCARD'; const [card] = parseVCard(vcf); diff --git a/src/lib/vcard.ts b/src/lib/vcard.ts index 0f58e4e4..31eb04ed 100644 --- a/src/lib/vcard.ts +++ b/src/lib/vcard.ts @@ -252,12 +252,12 @@ function decodeQuotedPrintable(input: string, charset?: string): string { return decodeBytes(bytes, charset); } +// One pass, so an escaped backslash can't pair up with the next character. +// \: isn't a vCard escape, but Google Contacts writes it. function decodeValue(raw: string): string { - return raw - .replace(/\\n/gi, '\n') - .replace(/\\,/g, ',') - .replace(/\\;/g, ';') - .replace(/\\\\/g, '\\'); + return raw.replace(/\\([\\,;:nN])/g, (_, c: string) => + c === 'n' || c === 'N' ? '\n' : c, + ); } function encodeValue(val: string): string {