mirror of https://github.com/nextcloud/contacts
576 lines
13 KiB
JavaScript
576 lines
13 KiB
JavaScript
/**
|
|
* @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
|
|
*
|
|
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
|
*
|
|
* @license GNU AGPL version 3 or any later version
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*
|
|
*/
|
|
|
|
import { v4 as uuid } from 'uuid'
|
|
import ICAL from 'ical.js'
|
|
import b64toBlob from 'b64-to-blob'
|
|
|
|
import store from '../store'
|
|
import updateDesignSet from '../services/updateDesignSet'
|
|
import sanitizeSVG from '@mattkrick/sanitize-svg'
|
|
|
|
/**
|
|
* Check if the given value is an empty array or an empty string
|
|
*
|
|
* @param {string|Array} value the value to check
|
|
* @return {boolean}
|
|
*/
|
|
const isEmpty = value => {
|
|
return (Array.isArray(value) && value.join('') === '') || (!Array.isArray(value) && value === '')
|
|
}
|
|
|
|
export const ContactKindProperties = ['KIND', 'X-ADDRESSBOOKSERVER-KIND']
|
|
|
|
export const MinimalContactProperties = [
|
|
'EMAIL', 'UID', 'CATEGORIES', 'FN', 'ORG', 'N', 'X-PHONETIC-FIRST-NAME', 'X-PHONETIC-LAST-NAME',
|
|
].concat(ContactKindProperties)
|
|
|
|
export default class Contact {
|
|
|
|
/**
|
|
* Creates an instance of Contact
|
|
*
|
|
* @param {string} vcard the vcard data as string with proper new lines
|
|
* @param {object} addressbook the addressbook which the contat belongs to
|
|
* @memberof Contact
|
|
*/
|
|
constructor(vcard, addressbook) {
|
|
if (typeof vcard !== 'string' || vcard.length === 0) {
|
|
throw new Error('Invalid vCard')
|
|
}
|
|
|
|
let jCal = ICAL.parse(vcard)
|
|
if (jCal[0] !== 'vcard') {
|
|
throw new Error('Only one contact is allowed in the vcard data')
|
|
}
|
|
|
|
if (updateDesignSet(jCal)) {
|
|
jCal = ICAL.parse(vcard)
|
|
}
|
|
|
|
this.jCal = jCal
|
|
this.addressbook = addressbook
|
|
this.vCard = new ICAL.Component(this.jCal)
|
|
|
|
// used to state a contact is not up to date with
|
|
// the server and cannot be pushed (etag)
|
|
this.conflict = false
|
|
|
|
// if no uid set, create one
|
|
if (!this.vCard.hasProperty('uid')) {
|
|
console.info('This contact did not have a proper uid. Setting a new one for ', this)
|
|
this.vCard.addPropertyWithValue('uid', uuid())
|
|
}
|
|
|
|
// if no rev set, init one
|
|
if (!this.vCard.hasProperty('rev')) {
|
|
const rev = new ICAL.VCardTime(null, null, 'date-time')
|
|
rev.fromUnixTime(Date.now() / 1000)
|
|
this.vCard.addPropertyWithValue('rev', rev)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update internal data of this contact
|
|
*
|
|
* @param {jCal} jCal jCal object from ICAL.js
|
|
* @memberof Contact
|
|
*/
|
|
updateContact(jCal) {
|
|
this.jCal = jCal
|
|
this.vCard = new ICAL.Component(this.jCal)
|
|
}
|
|
|
|
/**
|
|
* Update linked addressbook of this contact
|
|
*
|
|
* @param {object} addressbook the addressbook
|
|
* @memberof Contact
|
|
*/
|
|
updateAddressbook(addressbook) {
|
|
this.addressbook = addressbook
|
|
}
|
|
|
|
/**
|
|
* Ensure we're normalizing the possible arrays
|
|
* into a string by taking the first element
|
|
* e.g. ORG:ABC\, Inc.; will output an array because of the semi-colon
|
|
*
|
|
* @param {Array|string} data the data to normalize
|
|
* @return {string}
|
|
* @memberof Contact
|
|
*/
|
|
firstIfArray(data) {
|
|
return Array.isArray(data) ? data[0] : data
|
|
}
|
|
|
|
/**
|
|
* Return the url
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get url() {
|
|
if (this.dav) {
|
|
return this.dav.url
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* Return the version
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get version() {
|
|
return this.vCard.getFirstPropertyValue('version')
|
|
}
|
|
|
|
/**
|
|
* Set the version
|
|
*
|
|
* @param {string} version the version to set
|
|
* @memberof Contact
|
|
*/
|
|
set version(version) {
|
|
this.vCard.updatePropertyWithValue('version', version)
|
|
}
|
|
|
|
/**
|
|
* Return the uid
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get uid() {
|
|
return this.vCard.getFirstPropertyValue('uid')
|
|
}
|
|
|
|
/**
|
|
* Set the uid
|
|
*
|
|
* @param {string} uid the uid to set
|
|
* @memberof Contact
|
|
*/
|
|
set uid(uid) {
|
|
this.vCard.updatePropertyWithValue('uid', uid)
|
|
}
|
|
|
|
/**
|
|
* Return the rev
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get rev() {
|
|
return this.vCard.getFirstPropertyValue('rev')
|
|
}
|
|
|
|
/**
|
|
* Set the rev
|
|
*
|
|
* @param {string} rev the rev to set
|
|
* @memberof Contact
|
|
*/
|
|
set rev(rev) {
|
|
this.vCard.updatePropertyWithValue('rev', rev)
|
|
}
|
|
|
|
/**
|
|
* Return the key
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get key() {
|
|
return this.uid + '~' + this.addressbook.id
|
|
}
|
|
|
|
/**
|
|
* Return the photo
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get photo() {
|
|
return this.vCard.getFirstPropertyValue('photo')
|
|
}
|
|
|
|
/**
|
|
* Set the photo
|
|
*
|
|
* @param {string} photo the photo to set
|
|
* @memberof Contact
|
|
*/
|
|
set photo(photo) {
|
|
this.vCard.updatePropertyWithValue('photo', photo)
|
|
}
|
|
|
|
/**
|
|
* Return the photo usable url
|
|
* We cannot fetch external url because of csp policies
|
|
*
|
|
* @memberof Contact
|
|
*/
|
|
async getPhotoUrl() {
|
|
const photo = this.vCard.getFirstProperty('photo')
|
|
if (!photo) {
|
|
return false
|
|
}
|
|
const encoding = photo.getFirstParameter('encoding')
|
|
let photoType = photo.getFirstParameter('type')
|
|
const photoB64 = this.photo
|
|
|
|
const isBinary = photo.type === 'binary' || encoding === 'b'
|
|
|
|
let photoB64Data = photoB64
|
|
if (photo && photoB64.startsWith('data') && !isBinary) {
|
|
// get the last part = base64
|
|
photoB64Data = photoB64.split(',').pop()
|
|
// 'data:image/png;base64' => 'png'
|
|
photoType = photoB64.split(';')[0].split('/').pop()
|
|
}
|
|
|
|
// Verify if SVG is valid
|
|
if (photoType.startsWith('svg')) {
|
|
const imageSvg = atob(photoB64Data)
|
|
const cleanSvg = await sanitizeSVG(imageSvg)
|
|
|
|
if (!cleanSvg) {
|
|
console.error('Invalid SVG for the following contact. Ignoring...', this.contact, { photoB64, photoType })
|
|
return false
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Create blob from url
|
|
const blob = b64toBlob(photoB64Data, `image/${photoType}`)
|
|
return URL.createObjectURL(blob)
|
|
} catch {
|
|
console.error('Invalid photo for the following contact. Ignoring...', this.contact, { photoB64, photoType })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the groups
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get groups() {
|
|
const groupsProp = this.vCard.getFirstProperty('categories')
|
|
if (groupsProp) {
|
|
return groupsProp.getValues()
|
|
.filter(group => typeof group === 'string')
|
|
.filter(group => group.trim() !== '')
|
|
}
|
|
return []
|
|
}
|
|
|
|
/**
|
|
* Set the groups
|
|
*
|
|
* @param {Array} groups the groups to set
|
|
* @memberof Contact
|
|
*/
|
|
set groups(groups) {
|
|
// delete the title if empty
|
|
if (isEmpty(groups)) {
|
|
this.vCard.removeProperty('categories')
|
|
return
|
|
}
|
|
|
|
if (Array.isArray(groups)) {
|
|
let property = this.vCard.getFirstProperty('categories')
|
|
if (!property) {
|
|
// Init with empty group since we set everything afterwise
|
|
property = this.vCard.addPropertyWithValue('categories', '')
|
|
}
|
|
property.setValues(groups)
|
|
} else {
|
|
throw new Error('groups data is not an Array')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the groups
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get kind() {
|
|
return this.firstIfArray(
|
|
ContactKindProperties
|
|
.map(s => s.toLowerCase())
|
|
.map(s => this.vCard.getFirstPropertyValue(s))
|
|
.flat()
|
|
.filter(k => k)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Return the first email
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get email() {
|
|
return this.firstIfArray(this.vCard.getFirstPropertyValue('email'))
|
|
}
|
|
|
|
/**
|
|
* Return the first org
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get org() {
|
|
return this.firstIfArray(this.vCard.getFirstPropertyValue('org'))
|
|
}
|
|
|
|
/**
|
|
* Set the org
|
|
*
|
|
* @param {string} org the org data
|
|
* @memberof Contact
|
|
*/
|
|
set org(org) {
|
|
// delete the org if empty
|
|
if (isEmpty(org)) {
|
|
this.vCard.removeProperty('org')
|
|
return
|
|
}
|
|
this.vCard.updatePropertyWithValue('org', org)
|
|
}
|
|
|
|
/**
|
|
* Return the first title
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get title() {
|
|
return this.firstIfArray(this.vCard.getFirstPropertyValue('title'))
|
|
}
|
|
|
|
/**
|
|
* Set the title
|
|
*
|
|
* @param {string} title the title
|
|
* @memberof Contact
|
|
*/
|
|
set title(title) {
|
|
// delete the title if empty
|
|
if (isEmpty(title)) {
|
|
this.vCard.removeProperty('title')
|
|
return
|
|
}
|
|
this.vCard.updatePropertyWithValue('title', title)
|
|
}
|
|
|
|
/**
|
|
* Return the full name
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get fullName() {
|
|
return this.vCard.getFirstPropertyValue('fn')
|
|
}
|
|
|
|
/**
|
|
* Set the full name
|
|
*
|
|
* @param {string} name the fn data
|
|
* @memberof Contact
|
|
*/
|
|
set fullName(name) {
|
|
this.vCard.updatePropertyWithValue('fn', name)
|
|
}
|
|
|
|
/**
|
|
* Formatted display name based on the order key
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
*/
|
|
get displayName() {
|
|
const orderKey = store.getters.getOrderKey
|
|
const n = this.vCard.getFirstPropertyValue('n')
|
|
const fn = this.vCard.getFirstPropertyValue('fn')
|
|
const org = this.vCard.getFirstPropertyValue('org')
|
|
|
|
// if ordered by last or first name we need the N property
|
|
// ! by checking the property we check for null AND empty string
|
|
// ! that means we can then check for empty array and be safe not to have
|
|
// ! 'xxxx'.join('') !== ''
|
|
if (orderKey && n && !isEmpty(n)) {
|
|
switch (orderKey) {
|
|
case 'firstName':
|
|
// Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P.
|
|
// -> John Stevenson
|
|
if (isEmpty(n[0])) {
|
|
return n[1]
|
|
}
|
|
return n.slice(0, 2).reverse().join(' ')
|
|
|
|
case 'lastName':
|
|
// Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P.
|
|
// -> Stevenson, John
|
|
if (isEmpty(n[0])) {
|
|
return n[1]
|
|
}
|
|
return n.slice(0, 2).join(', ')
|
|
}
|
|
}
|
|
// otherwise the FN is enough
|
|
if (fn) {
|
|
return fn
|
|
}
|
|
// BUT if no FN property use the N anyway
|
|
if (n && !isEmpty(n)) {
|
|
// Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P.
|
|
// -> John Stevenson
|
|
if (isEmpty(n[0])) {
|
|
return n[1]
|
|
}
|
|
return n.slice(0, 2).reverse().join(' ')
|
|
}
|
|
// LAST chance, use the org ir that's the only thing we have
|
|
if (org && !isEmpty(org)) {
|
|
// org is supposed to be an array but is also used as plain string
|
|
return Array.isArray(org) ? org[0] : org
|
|
}
|
|
return ''
|
|
|
|
}
|
|
|
|
/**
|
|
* Return the first name if exists
|
|
* Returns the displayName otherwise
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
* @return {string} firstName|displayName
|
|
*/
|
|
get firstName() {
|
|
if (this.vCard.hasProperty('n')) {
|
|
// reverse and join
|
|
return this.vCard.getFirstPropertyValue('n')[1]
|
|
}
|
|
return this.displayName
|
|
}
|
|
|
|
/**
|
|
* Return the last name if exists
|
|
* Returns the displayName otherwise
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
* @return {string} lastName|displayName
|
|
*/
|
|
get lastName() {
|
|
if (this.vCard.hasProperty('n')) {
|
|
// reverse and join
|
|
return this.vCard.getFirstPropertyValue('n')[0]
|
|
}
|
|
return this.displayName
|
|
}
|
|
|
|
/**
|
|
* Return the phonetic first name if exists
|
|
* Returns the first name or displayName otherwise
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
* @return {string} phoneticFirstName|firstName|displayName
|
|
*/
|
|
get phoneticFirstName() {
|
|
if (this.vCard.hasProperty('x-phonetic-first-name')) {
|
|
return this.vCard.getFirstPropertyValue('x-phonetic-first-name')
|
|
}
|
|
return this.firstName
|
|
}
|
|
|
|
/**
|
|
* Return the phonetic last name if exists
|
|
* Returns the displayName otherwise
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
* @return {string} lastName|displayName
|
|
*/
|
|
get phoneticLastName() {
|
|
if (this.vCard.hasProperty('x-phonetic-last-name')) {
|
|
return this.vCard.getFirstPropertyValue('x-phonetic-last-name')
|
|
}
|
|
return this.lastName
|
|
}
|
|
|
|
/**
|
|
* Return all the properties as Property objects
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
* @return {Property[]} http://mozilla-comm.github.io/ical.js/api/ICAL.Property.html
|
|
*/
|
|
get properties() {
|
|
return this.vCard.getAllProperties()
|
|
}
|
|
|
|
/**
|
|
* Return an array of formatted properties for the search
|
|
*
|
|
* @readonly
|
|
* @memberof Contact
|
|
* @return {string[]}
|
|
*/
|
|
get searchData() {
|
|
return this.jCal[1].map(x => x[0] + ':' + x[3])
|
|
}
|
|
|
|
/**
|
|
* Add the contact to the group
|
|
*
|
|
* @param {string} group the group to add the contact to
|
|
* @memberof Contact
|
|
*/
|
|
addToGroup(group) {
|
|
if (this.groups.indexOf(group) === -1) {
|
|
if (this.groups.length > 0) {
|
|
this.vCard.getFirstProperty('categories').setValues(this.groups.concat(group))
|
|
} else {
|
|
this.vCard.updatePropertyWithValue('categories', [group])
|
|
}
|
|
}
|
|
}
|
|
|
|
toStringStripQuotes() {
|
|
const regexp = /TYPE="([a-zA-Z-,]+)"/gmi
|
|
const card = this.vCard.toString()
|
|
return card.replace(regexp, 'TYPE=$1')
|
|
}
|
|
|
|
}
|