diff --git a/lib/contacts.js b/lib/contacts.js index 07c22317..47006373 100644 --- a/lib/contacts.js +++ b/lib/contacts.js @@ -73,18 +73,31 @@ export function createCard(addressBook, options) { /** * Options: * + * (dav.Transport) xhr - request sender. * (dav.Sandbox) sandbox - optional request sandbox. + * (Array.) filters - optional carddav filters. + * (Array.) contents - optional contents of address object. */ export let listVCards = co.wrap(function *(addressBook, options) { debug(`Doing REPORT on address book ${addressBook.url} which belongs to ${addressBook.account.credentials.username}`); + /* According to http://stackoverflow.com/questions/23742568/google-carddav-api-addressbook-multiget-returns-400-bad-request, + * Google's CardDAV server requires a filter element. I don't think all addressbook-query calls need a filter in the spec though? + */ + let filters = options.filters || [{ + name: 'prop-filter', + attrs: { name: 'FN' }, + namespace: ns.CARDDAV + }]; + var req = request.addressBookQuery({ depth: 1, props: [ { name: 'getetag', namespace: ns.DAV }, - { name: 'address-data', namespace: ns.CARDDAV } - ] + { name: 'address-data', namespace: ns.CARDDAV, value: options.contents } + ], + filters: filters }); let responses = yield options.xhr.send(req, addressBook.url, { diff --git a/lib/request.js b/lib/request.js index 6c951c95..dbe84414 100644 --- a/lib/request.js +++ b/lib/request.js @@ -6,10 +6,14 @@ import * as template from './template/index'; * * (String) depth - optional value for Depth header. * (Array.) props - list of props to request. + * (Array.) filters - list of filters to send with request. */ export function addressBookQuery(options) { return collectionQuery( - template.addressBookQuery({ props: options.props || [] }), + template.addressBookQuery({ + props: options.props || [], + filters: options.filters || [] + }), { depth: options.depth } ); } diff --git a/lib/template/address_book_query.js b/lib/template/address_book_query.js index 9545ecec..38dac925 100644 --- a/lib/template/address_book_query.js +++ b/lib/template/address_book_query.js @@ -1,16 +1,12 @@ import prop from './prop'; export default function addressBookQuery(object) { - return ` + return ` - ${object.props.map(prop)} + ${object.props.map(prop).join('\n ')} - - - + ${object.filters.map(prop)} ` } diff --git a/lib/template/prop.js b/lib/template/prop.js index 4ba4830f..15ae6d13 100644 --- a/lib/template/prop.js +++ b/lib/template/prop.js @@ -1,42 +1,38 @@ import * as ns from '../namespace'; +let debug = require('debug')('dav:prop'); + /** - * @param {Object} filter looks like - * - * { - * type: 'comp-filter', - * attrs: { - * name: 'VCALENDAR' - * } - * } - * - * Or maybe - * - * { - * type: 'time-range', - * attrs: { - * start: '20060104T000000Z', - * end: '20060105T000000Z' - * } - * } - * - * You can nest them like so: - * - * { - * type: 'comp-filter', - * attrs: { name: 'VCALENDAR' }, - * children: [{ - * type: 'comp-filter', - * attrs: { name: 'VEVENT' }, - * children: [{ - * type: 'time-range', - * attrs: { start: '20060104T000000Z', end: '20060105T000000Z' } - * }] - * }] - * } + * (Array.) item - property to request. Has to look like: + * { + * name: '', + * namespace: , + * attrs: {: '', ...}, (optional) + * value: , (optional) + * } */ export default function prop(item) { - return `<${xmlnsPrefix(item.namespace)}:${item.name} />`; + debug('item %o', item); + + if (item.value === undefined) { + return `<${xmlnsPrefix(item.namespace)}:${item.name}${formatAttrs(item.attrs)} />`; + } + + if (typeof item.value !== 'object') { + return `<${xmlnsPrefix(item.namespace)}:${item.name}${formatAttrs(item.attrs)}>${item.value}`; + } + + return `<${xmlnsPrefix(item.namespace)}:${item.name}${formatAttrs(item.attrs)}>${item.value.map(prop).join('')}`; +} + +function formatAttrs(attrs) { + if (typeof attrs !== 'object') { + return ''; + } + + return ' ' + Object.keys(attrs) + .map(attr => `${attr}="${attrs[attr]}"`) + .join(' '); } function xmlnsPrefix(namespace) { diff --git a/lib/template/propfind.js b/lib/template/propfind.js index dd5cfef5..78c19e6a 100644 --- a/lib/template/propfind.js +++ b/lib/template/propfind.js @@ -7,7 +7,7 @@ export default function propfind(object) { xmlns:ca="http://apple.com/ns/ical/" xmlns:d="DAV:"> - ${object.props.map(prop)} + ${object.props.map(prop).join('\n ')} `; } diff --git a/test/integration/contacts_test.js b/test/integration/contacts_test.js index 685f7b4b..b2020113 100644 --- a/test/integration/contacts_test.js +++ b/test/integration/contacts_test.js @@ -7,7 +7,7 @@ import * as dav from '../../lib'; let debug = dav.debug('dav:contacts_test'); suite('contacts', function() { - let addressBooks, xhr; + let account, addressBooks, xhr; setup(co.wrap(function *() { debug('Create account.'); @@ -19,7 +19,7 @@ suite('contacts', function() { }) ); - let account = yield dav.createAccount({ + account = yield dav.createAccount({ server: 'http://127.0.0.1:8888/', xhr: xhr, accountType: 'carddav', @@ -181,6 +181,51 @@ suite('contacts', function() { assert.notStrictEqual(addressBook.syncToken, prevSyncToken, 'new token'); })); + test('#add 2nd vcard, #list specific Card', co.wrap(function *() { + let addressBook = addressBooks[0]; + yield dav.createCard(addressBook, { + filename: 'test2.vcf', + data: data.johnDoe, + xhr: xhr + }); + + let updated = yield dav.syncCarddavAccount(account, { + syncMethod: 'basic', + xhr: xhr + }); + + addressBooks = updated.addressBooks; + addressBook = account.addressBooks[0]; + let objects = addressBook.objects; + debug('%i addressBook(s) with %i vcard(s)', addressBooks.length, objects.length); + assert.isArray(objects); + assert.lengthOf(objects, 2, '2 vcards expected'); + + objects = yield dav.listVCards(addressBook, { + xhr: xhr, + /* AH20190113 - Content filter seem not to be supported < sabre-io 3.2 + contents: [{ name: 'prop', attrs: {name: 'FN'}, namespace: dav.ns.CARDDAV}, + { name: 'prop', attrs: {name: 'N'}, namespace: dav.ns.CARDDAV}],*/ + filters: [{ + name: 'prop-filter', + attrs: { name: 'FN' }, + namespace: dav.ns.CARDDAV, + value: [{ + name: 'text-match', + attrs: { collation: 'i;unicode-casemap', 'match-type': 'contains' }, + value: 'John', + namespace: dav.ns.CARDDAV + }] + }] + }); + + assert.isArray(objects); + assert.lengthOf(objects, 1); + let object = objects[0]; + assert.instanceOf(object, dav.VCard); + assert.include(object.addressData, 'john.doe@example.com', 'specific vcard look wrong one'); + })); + test('#deleteCard', co.wrap(function *() { let addressBook = addressBooks[0]; let objects = addressBook.objects; diff --git a/test/integration/data/index.js b/test/integration/data/index.js index 7d2ed03b..527c7114 100644 --- a/test/integration/data/index.js +++ b/test/integration/data/index.js @@ -7,7 +7,8 @@ export default docs; [ { name: 'bastille_day_party', fmt: 'ics' }, - { name: 'forrest_gump', fmt: 'vcf' } + { name: 'forrest_gump', fmt: 'vcf' }, + { name: 'john_doe', fmt: 'vcf' } ].forEach(function(file) { let camelCase = camelize(file.name); docs[camelCase] = fs.readFileSync( diff --git a/test/integration/data/john_doe.vcf b/test/integration/data/john_doe.vcf new file mode 100644 index 00000000..7b5538ea --- /dev/null +++ b/test/integration/data/john_doe.vcf @@ -0,0 +1,8 @@ +BEGIN:VCARD +VERSION:3.0 +N:Doe;John;Mr. +FN:John +TEL;TYPE=HOME,VOICE:(666) 123-4567 +EMAIL;TYPE=PREF,INTERNET:john.doe@example.com +REV:2019-01-13T19:12:32Z +END:VCARD diff --git a/test/unit/request/address_book_query_test.js b/test/unit/request/address_book_query_test.js index 31777efd..cf7a4eb6 100644 --- a/test/unit/request/address_book_query_test.js +++ b/test/unit/request/address_book_query_test.js @@ -45,6 +45,62 @@ suite('request.addressBookQuery', function() { yield mock.verify(send); })); + test('should add specific contents to report body', co.wrap(function *() { + let mock = nockWrapper('http://127.0.0.1:1337') + .matchRequestBody('/principals/admin/', 'REPORT', body => { + return body.match(//) !== null; + }); + + let req = addressBookQuery({ + props: [ { + name: 'address-data', + namespace: ns.CARDDAV, + value: [{ name: 'prop', attrs: {name: 'FN'}, namespace: ns.CARDDAV}] + }], + }); + + let send = xhr.send(req, 'http://127.0.0.1:1337/principals/admin/'); + yield mock.verify(send); + })); + + test('should add specified props to report body', co.wrap(function *() { + let mock = nockWrapper('http://127.0.0.1:1337') + .matchRequestBody('/principals/admin/', 'REPORT', body => { + return body.indexOf('') !== -1; + }); + + let req = addressBookQuery({ + props: [ { name: 'catdog', namespace: ns.DAV } ] + }); + + let send = xhr.send(req, 'http://127.0.0.1:1337/principals/admin/'); + yield mock.verify(send); + })); + + test('should add specified filters to report body', co.wrap(function *() { + let mock = nockWrapper('http://127.0.0.1:1337') + .matchRequestBody('/principals/admin/', 'REPORT', body => { + return body.match(/.*John Doe<\/card:text-match>/) !== null; + }); + + let req = addressBookQuery({ + filters: [{ + name: 'prop-filter', + attrs: { name: 'FN' }, + namespace: ns.CARDDAV, + value: [{ + name: 'text-match', + attrs: { collation: 'i;unicode-casemap', 'match-type': 'contains' }, + value: 'John Doe', + namespace: ns.CARDDAV + }] + }] + }); + + let send = xhr.send(req, 'http://127.0.0.1:1337/principals/admin/'); + yield mock.verify(send); + })); + test('should resolve with appropriate data structure', co.wrap(function *() { nockWrapper('http://127.0.0.1:1337') .intercept('/', 'REPORT')