Basic CardDav UI (Contacts)
This implements a basic CardDav UI, titled "Contacts". It allows creating new contacts with a first name + last name, and editing their first and last names, main email, main phone, and notes. You can also import and export VCF (VCARD) files. It also allows editing the VCARD directly, for power users. Additionally, you can choose, create, or delete address books, and if there's no address book created yet in your CardDav server (first-time setup), it'll automatically create one, titled "Contacts". Finally, there are some dependency updates and a fix for the config not allowing disabling the `cardDav` or the `calDav` server. Related to #56
This commit is contained in:
@@ -24,7 +24,7 @@ export class AppConfig {
|
||||
allowPublicSharing: false,
|
||||
},
|
||||
core: {
|
||||
enabledApps: ['news', 'notes', 'photos', 'expenses'],
|
||||
enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts'],
|
||||
},
|
||||
visuals: {
|
||||
title: '',
|
||||
@@ -83,6 +83,14 @@ export class AppConfig {
|
||||
...config.email,
|
||||
...configFromFile.email,
|
||||
},
|
||||
contacts: {
|
||||
...config.contacts,
|
||||
...configFromFile.contacts,
|
||||
},
|
||||
calendar: {
|
||||
...config.calendar,
|
||||
...configFromFile.calendar,
|
||||
},
|
||||
};
|
||||
|
||||
console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n');
|
||||
|
||||
203
lib/models/contacts.ts
Normal file
203
lib/models/contacts.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createDAVClient } from 'tsdav';
|
||||
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { parseVCard } from '/lib/utils/contacts.ts';
|
||||
|
||||
interface DAVObject extends Record<string, any> {
|
||||
data?: string;
|
||||
displayName?: string;
|
||||
ctag?: string;
|
||||
url: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface Contact extends DAVObject {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
middleNames?: string[];
|
||||
title?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AddressBook extends DAVObject {}
|
||||
|
||||
const contactsConfig = await AppConfig.getContactsConfig();
|
||||
|
||||
async function getClient(userId: string) {
|
||||
const client = await createDAVClient({
|
||||
serverUrl: contactsConfig.cardDavUrl,
|
||||
credentials: {},
|
||||
authMethod: 'Custom',
|
||||
// deno-lint-ignore require-await
|
||||
authFunction: async () => {
|
||||
return {
|
||||
'X-Remote-User': userId,
|
||||
};
|
||||
},
|
||||
fetchOptions: {
|
||||
timeout: 15_000,
|
||||
},
|
||||
defaultAccountType: 'carddav',
|
||||
rootUrl: `${contactsConfig.cardDavUrl}/`,
|
||||
principalUrl: `${contactsConfig.cardDavUrl}/${userId}/`,
|
||||
homeUrl: `${contactsConfig.cardDavUrl}/${userId}/`,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export class ContactModel {
|
||||
static async list(
|
||||
userId: string,
|
||||
addressBookId: string,
|
||||
): Promise<Contact[]> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
|
||||
|
||||
const davContacts: DAVObject[] = await client.fetchVCards({
|
||||
addressBook: {
|
||||
url: addressBookUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const contacts: Contact[] = davContacts.map((davContact) => {
|
||||
return {
|
||||
...davContact,
|
||||
...parseVCard(davContact.data || '')[0],
|
||||
};
|
||||
});
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
static async get(
|
||||
userId: string,
|
||||
addressBookId: string,
|
||||
contactId: string,
|
||||
): Promise<Contact | undefined> {
|
||||
const contacts = await this.list(userId, addressBookId);
|
||||
|
||||
return contacts.find((contact) => contact.uid === contactId);
|
||||
}
|
||||
|
||||
static async create(
|
||||
userId: string,
|
||||
addressBookId: string,
|
||||
contactId: string,
|
||||
vCard: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
|
||||
|
||||
await client.createVCard({
|
||||
addressBook: {
|
||||
url: addressBookUrl,
|
||||
},
|
||||
vCardString: vCard,
|
||||
filename: `${contactId}.vcf`,
|
||||
});
|
||||
}
|
||||
|
||||
static async update(
|
||||
userId: string,
|
||||
contactUrl: string,
|
||||
vCard: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
await client.updateVCard({
|
||||
vCard: {
|
||||
url: contactUrl,
|
||||
data: vCard,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async delete(
|
||||
userId: string,
|
||||
contactUrl: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
await client.deleteVCard({
|
||||
vCard: {
|
||||
url: contactUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async listAddressBooks(
|
||||
userId: string,
|
||||
): Promise<AddressBook[]> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const davAddressBooks: DAVObject[] = await client.fetchAddressBooks();
|
||||
|
||||
const addressBooks: AddressBook[] = davAddressBooks.map((davAddressBook) => {
|
||||
const uid = davAddressBook.url.split('/').filter(Boolean).pop()!;
|
||||
|
||||
return {
|
||||
...davAddressBook,
|
||||
uid,
|
||||
};
|
||||
});
|
||||
|
||||
return addressBooks;
|
||||
}
|
||||
|
||||
static async createAddressBook(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const addressBookId = crypto.randomUUID();
|
||||
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
|
||||
|
||||
// For some reason this sends invalid XML
|
||||
// await client.makeCollection({
|
||||
// url: addressBookUrl,
|
||||
// props: {
|
||||
// displayName: name,
|
||||
// },
|
||||
// });
|
||||
|
||||
// Make "manual" request (https://www.rfc-editor.org/rfc/rfc6352.html#page-14)
|
||||
const xmlBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:mkcol xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:displayname>${encodeURIComponent(name)}</d:displayname>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<card:addressbook/>
|
||||
</d:resourcetype>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:mkcol>`;
|
||||
|
||||
await fetch(addressBookUrl, {
|
||||
method: 'MKCOL',
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'X-Remote-User': userId,
|
||||
},
|
||||
body: xmlBody,
|
||||
});
|
||||
}
|
||||
|
||||
static async deleteAddressBook(
|
||||
userId: string,
|
||||
addressBookId: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
|
||||
|
||||
await client.deleteObject({
|
||||
url: addressBookUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export const currencyMap = new Map<SupportedCurrencySymbol, SupportedCurrency>([
|
||||
|
||||
export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T;
|
||||
|
||||
export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses';
|
||||
export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
|
||||
238
lib/utils/contacts.ts
Normal file
238
lib/utils/contacts.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Contact } from '/lib/models/contacts.ts';
|
||||
|
||||
export function getIdFromVCard(vCard: string): string {
|
||||
const lines = vCard.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
// Loop through every line and find the UID line
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('UID:')) {
|
||||
const uid = line.replace('UID:', '');
|
||||
return uid.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function splitTextIntoVCards(text: string): string[] {
|
||||
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
const vCards: string[] = [];
|
||||
const currentVCard: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
currentVCard.push(line);
|
||||
|
||||
if (line.startsWith('END:VCARD')) {
|
||||
vCards.push(currentVCard.join('\n'));
|
||||
currentVCard.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return vCards;
|
||||
}
|
||||
|
||||
export function generateVCard(contactId: string, firstName: string, lastName?: string): string {
|
||||
const vCardText = `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:${getSafelyEscapedTextForVCard(lastName || '')};${getSafelyEscapedTextForVCard(firstName)};
|
||||
FN:${getSafelyEscapedTextForVCard(firstName)} ${getSafelyEscapedTextForVCard(lastName || '')}
|
||||
UID:${getSafelyEscapedTextForVCard(contactId)}
|
||||
END:VCARD`;
|
||||
|
||||
return vCardText;
|
||||
}
|
||||
|
||||
export function updateVCard(
|
||||
vCard: string,
|
||||
{ firstName, lastName, email, phone, notes }: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
notes?: string;
|
||||
},
|
||||
): string {
|
||||
const lines = vCard.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
let replacedName = false;
|
||||
let replacedFormattedName = false;
|
||||
let replacedEmail = false;
|
||||
let replacedPhone = false;
|
||||
let replacedNotes = false;
|
||||
|
||||
const updatedVCardLines = lines.map((line) => {
|
||||
if (line.startsWith('N:') && firstName && !replacedName) {
|
||||
replacedName = true;
|
||||
return `N:${getSafelyEscapedTextForVCard(lastName || '')};${getSafelyEscapedTextForVCard(firstName)};`;
|
||||
}
|
||||
|
||||
if (line.startsWith('FN:') && firstName && !replacedFormattedName) {
|
||||
replacedFormattedName = true;
|
||||
return `FN:${getSafelyEscapedTextForVCard(firstName)} ${getSafelyEscapedTextForVCard(lastName || '')}`;
|
||||
}
|
||||
|
||||
if ((line.startsWith('EMAIL:') || line.startsWith('EMAIL;')) && email && !replacedEmail) {
|
||||
replacedEmail = true;
|
||||
return line.replace(line.split(':')[1], getSafelyEscapedTextForVCard(email));
|
||||
}
|
||||
|
||||
if ((line.startsWith('TEL:') || line.startsWith('TEL;')) && phone && !replacedPhone) {
|
||||
replacedPhone = true;
|
||||
return line.replace(line.split(':')[1], getSafelyEscapedTextForVCard(phone));
|
||||
}
|
||||
|
||||
if (line.startsWith('NOTE:') && notes && !replacedNotes) {
|
||||
replacedNotes = true;
|
||||
return line.replace(line.split(':')[1], getSafelyEscapedTextForVCard(notes.replaceAll('\r', '')));
|
||||
}
|
||||
|
||||
return line;
|
||||
});
|
||||
|
||||
// Remove last line with END:VCARD
|
||||
const endLineIndex = updatedVCardLines.findIndex((line) => line.startsWith('END:VCARD'));
|
||||
updatedVCardLines.splice(endLineIndex, 1);
|
||||
|
||||
if (!replacedEmail && email) {
|
||||
updatedVCardLines.push(`EMAIL;TYPE=HOME:${getSafelyEscapedTextForVCard(email)}`);
|
||||
}
|
||||
if (!replacedPhone && phone) {
|
||||
updatedVCardLines.push(`TEL;TYPE=HOME:${getSafelyEscapedTextForVCard(phone)}`);
|
||||
}
|
||||
if (!replacedNotes && notes) {
|
||||
updatedVCardLines.push(`NOTE:${getSafelyEscapedTextForVCard(notes.replaceAll('\r', ''))}`);
|
||||
}
|
||||
|
||||
updatedVCardLines.push('END:VCARD');
|
||||
|
||||
const updatedVCard = updatedVCardLines.map((line) => line.trim()).filter(Boolean).join('\n');
|
||||
|
||||
return updatedVCard;
|
||||
}
|
||||
|
||||
function getSafelyEscapedTextForVCard(text: string) {
|
||||
return text.replaceAll('\n', '\\n').replaceAll(',', '\\,');
|
||||
}
|
||||
|
||||
function getSafelyUnescapedTextFromVCard(text: string): string {
|
||||
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
||||
}
|
||||
|
||||
type VCardVersion = '2.1' | '3.0' | '4.0';
|
||||
|
||||
export function parseVCard(text: string): Partial<Contact>[] {
|
||||
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const partialContacts: Partial<Contact>[] = [];
|
||||
|
||||
let partialContact: Partial<Contact> = {};
|
||||
let vCardVersion: VCardVersion = '2.1';
|
||||
|
||||
// Loop through every line
|
||||
for (const line of lines) {
|
||||
// Start new contact and vCard version
|
||||
if (line.startsWith('BEGIN:VCARD')) {
|
||||
partialContact = {};
|
||||
vCardVersion = '2.1';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finish contact
|
||||
if (line.startsWith('END:VCARD')) {
|
||||
partialContacts.push(partialContact);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select proper vCard version
|
||||
if (line.startsWith('VERSION:')) {
|
||||
if (line.startsWith('VERSION:2.1')) {
|
||||
vCardVersion = '2.1';
|
||||
} else if (line.startsWith('VERSION:3.0')) {
|
||||
vCardVersion = '3.0';
|
||||
} else if (line.startsWith('VERSION:4.0')) {
|
||||
vCardVersion = '4.0';
|
||||
} else {
|
||||
// Default to 2.1, log warning
|
||||
vCardVersion = '2.1';
|
||||
console.warn(`Invalid vCard version found: "${line}". Defaulting to 2.1 parser.`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') {
|
||||
console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`);
|
||||
vCardVersion = '2.1';
|
||||
}
|
||||
|
||||
if (line.startsWith('UID:')) {
|
||||
const uid = line.replace('UID:', '');
|
||||
|
||||
if (!uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.uid = uid;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('N:')) {
|
||||
const names = line.split('N:')[1].split(';');
|
||||
|
||||
const lastName = names[0] || '';
|
||||
const firstName = names[1] || '';
|
||||
const middleNames = names.slice(2, -1).filter(Boolean);
|
||||
const title = names.slice(-1).join(' ') || '';
|
||||
|
||||
if (!firstName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.firstName = firstName;
|
||||
partialContact.lastName = lastName;
|
||||
partialContact.middleNames = middleNames;
|
||||
partialContact.title = title;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('NOTE:')) {
|
||||
const notes = getSafelyUnescapedTextFromVCard(line.split('NOTE:')[1] || '');
|
||||
|
||||
partialContact.notes = notes;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((line.includes('TEL;') || line.includes('TEL:')) && !partialContact.phone) {
|
||||
const phoneInfo = line.split('TEL;')[1] || line.split('TEL')[1] || '';
|
||||
const phoneNumber = phoneInfo.split(':')[1] || '';
|
||||
// const label = (phoneInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||
|
||||
if (!phoneNumber) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.phone = phoneNumber;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((line.includes('EMAIL;') || line.includes('EMAIL:')) && !partialContact.email) {
|
||||
const emailInfo = line.split('EMAIL;')[1] || line.split('EMAIL')[1] || '';
|
||||
const emailAddress = emailInfo.split(':')[1] || '';
|
||||
// const label = (emailInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||
|
||||
if (!emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.email = emailAddress;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return partialContacts;
|
||||
}
|
||||
503
lib/utils/contacts_test.ts
Normal file
503
lib/utils/contacts_test.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||
import { assertMatch } from 'std/assert/assert_match.ts';
|
||||
|
||||
import { generateVCard, getIdFromVCard, parseVCard, splitTextIntoVCards, updateVCard } from './contacts.ts';
|
||||
|
||||
Deno.test('that getIdFromVCard works', () => {
|
||||
const tests: { input: string; expected?: string; shouldBeUUID?: boolean }[] = [
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:12345-abcde-67890
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
expected: '12345-abcde-67890',
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Jane Smith
|
||||
UID:jane-smith-uuid
|
||||
EMAIL:jane@example.com
|
||||
END:VCARD`,
|
||||
expected: 'jane-smith-uuid',
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:No UID Contact
|
||||
EMAIL:nouid@example.com
|
||||
END:VCARD`,
|
||||
shouldBeUUID: true,
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID: spaced-uid
|
||||
FN:Spaced UID
|
||||
END:VCARD`,
|
||||
expected: 'spaced-uid',
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = getIdFromVCard(test.input);
|
||||
if (test.expected) {
|
||||
assertEquals(output, test.expected);
|
||||
} else if (test.shouldBeUUID) {
|
||||
// Check that it's a valid UUID format
|
||||
assertMatch(output, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that splitTextIntoVCards works', () => {
|
||||
const tests: { input: string; expected: string[] }[] = [
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:1
|
||||
FN:John Doe
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:2
|
||||
FN:Jane Smith
|
||||
END:VCARD`,
|
||||
expected: [
|
||||
`BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:1
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
`BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:2
|
||||
FN:Jane Smith
|
||||
END:VCARD`,
|
||||
],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Single Contact
|
||||
EMAIL:single@example.com
|
||||
END:VCARD`,
|
||||
expected: [
|
||||
`BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Single Contact
|
||||
EMAIL:single@example.com
|
||||
END:VCARD`,
|
||||
],
|
||||
},
|
||||
{
|
||||
input: '',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Incomplete Contact`,
|
||||
expected: [],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = splitTextIntoVCards(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that generateVCard works', () => {
|
||||
const tests: { input: { contactId: string; firstName: string; lastName?: string }; expected: string }[] = [
|
||||
{
|
||||
input: { contactId: 'test-123', firstName: 'John', lastName: 'Doe' },
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
UID:test-123
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: { contactId: 'single-name', firstName: 'Madonna' },
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:;Madonna;
|
||||
FN:Madonna
|
||||
UID:single-name
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: { contactId: 'special-chars', firstName: 'John,Test', lastName: 'Doe\nSmith' },
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:Doe\\nSmith;John\\,Test;
|
||||
FN:John\\,Test Doe\\nSmith
|
||||
UID:special-chars
|
||||
END:VCARD`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = generateVCard(test.input.contactId, test.input.firstName, test.input.lastName);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that updateVCard works', () => {
|
||||
const tests: {
|
||||
input: {
|
||||
vCard: string;
|
||||
updates: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
notes?: string;
|
||||
};
|
||||
};
|
||||
expected: string;
|
||||
}[] = [
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-123
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
updates: { firstName: 'Jane', lastName: 'Smith' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-123
|
||||
N:Smith;Jane;
|
||||
FN:Jane Smith
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-456
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
EMAIL:old@example.com
|
||||
TEL:+1234567890
|
||||
END:VCARD`,
|
||||
updates: { email: 'new@example.com', phone: '+9876543210' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-456
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
EMAIL:new@example.com
|
||||
TEL:+9876543210
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-789
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
updates: { email: 'added@example.com', phone: '+1111111111', notes: 'Test notes' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-789
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
EMAIL;TYPE=HOME:added@example.com
|
||||
TEL;TYPE=HOME:+1111111111
|
||||
NOTE:Test notes
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-special
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
NOTE:Old notes
|
||||
END:VCARD`,
|
||||
updates: { notes: 'New notes\nwith newlines, and commas' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-special
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
NOTE:New notes\\nwith newlines\\, and commas
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-carriage
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
updates: { notes: 'Notes with\r\ncarriage returns' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-carriage
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
NOTE:Notes with\\ncarriage returns
|
||||
END:VCARD`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = updateVCard(test.input.vCard, test.input.updates);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that parseVCard works', () => {
|
||||
const tests: {
|
||||
input: string;
|
||||
expected: Array<{
|
||||
uid?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
middleNames?: string[];
|
||||
title?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
notes?: string;
|
||||
}>;
|
||||
}[] = [
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-123
|
||||
N:Doe;John;Middle;Jr
|
||||
FN:John Middle Doe Jr
|
||||
EMAIL;TYPE=HOME:john@example.com
|
||||
TEL;TYPE=HOME:+1234567890
|
||||
NOTE:Test contact notes
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'test-123',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
middleNames: ['Middle'],
|
||||
title: 'Jr',
|
||||
email: 'john@example.com',
|
||||
phone: '+1234567890',
|
||||
notes: 'Test contact notes',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
UID:test-456
|
||||
N:Smith;Jane;;
|
||||
FN:Jane Smith
|
||||
EMAIL:jane@example.com
|
||||
TEL:+9876543210
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'test-456',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
email: 'jane@example.com',
|
||||
phone: '+9876543210',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:multi-1
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:multi-2
|
||||
N:Smith;Jane;
|
||||
FN:Jane Smith
|
||||
EMAIL;PREF=1:jane@example.com
|
||||
END:VCARD`,
|
||||
expected: [
|
||||
{
|
||||
uid: 'multi-1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
uid: 'multi-2',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:escaped-contact
|
||||
N:Test;Contact;
|
||||
FN:Contact Test
|
||||
NOTE:Notes with\\nescaped newlines\\, and commas
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'escaped-contact',
|
||||
firstName: 'Contact',
|
||||
lastName: 'Test',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
notes: 'Notes with\nescaped newlines, and commas',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
UID:version-21
|
||||
N:Old;Format;
|
||||
FN:Format Old
|
||||
EMAIL:old@example.com
|
||||
TEL:+1111111111
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'version-21',
|
||||
firstName: 'Format',
|
||||
lastName: 'Old',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
email: 'old@example.com',
|
||||
phone: '+1111111111',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:email-variations
|
||||
N:Test;Email;
|
||||
FN:Email Test
|
||||
EMAIL:direct@example.com
|
||||
EMAIL;TYPE=WORK:work@example.com
|
||||
TEL:+1234567890
|
||||
TEL;TYPE=WORK:+9876543210
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'email-variations',
|
||||
firstName: 'Email',
|
||||
lastName: 'Test',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
email: 'direct@example.com', // Only first email is captured
|
||||
phone: '+1234567890', // Only first phone is captured
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:5.0
|
||||
UID:invalid-version
|
||||
N:Invalid;Version;
|
||||
FN:Version Invalid
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'invalid-version',
|
||||
firstName: 'Version',
|
||||
lastName: 'Invalid',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:no-first-name
|
||||
N:LastOnly;;
|
||||
FN:LastOnly
|
||||
END:VCARD`,
|
||||
expected: [{ uid: 'no-first-name' }],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:empty-uid
|
||||
N:Test;Empty;
|
||||
FN:Empty Test
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
firstName: 'Empty',
|
||||
lastName: 'Test',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
uid: 'empty-uid',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = parseVCard(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that parseVCard handles edge cases', () => {
|
||||
const edgeCases: { input: string; description: string; expected: any[] }[] = [
|
||||
{
|
||||
input: '',
|
||||
description: 'empty string',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
input: 'Not a vCard at all',
|
||||
description: 'invalid format',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:incomplete`,
|
||||
description: 'incomplete vCard without END',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:missing-required
|
||||
FN:Missing Required Fields
|
||||
END:VCARD`,
|
||||
description: 'vCard without N field',
|
||||
expected: [{ uid: 'missing-required' }],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:empty-fields
|
||||
N:;;;
|
||||
FN:Empty Fields
|
||||
EMAIL:
|
||||
TEL:
|
||||
NOTE:
|
||||
END:VCARD`,
|
||||
description: 'vCard with empty field values',
|
||||
expected: [{ uid: 'empty-fields', notes: '' }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of edgeCases) {
|
||||
const output = parseVCard(test.input);
|
||||
assertEquals(output, test.expected, `Failed for: ${test.description}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user