From b8d2f007801bc3a9625e5adc859003a229b8ac88 Mon Sep 17 00:00:00 2001 From: pallavibakale Date: Mon, 8 Jun 2026 23:31:35 -0400 Subject: [PATCH 1/6] fix: handle ical.js group parameter form to prevent custom label corruption on save/re-fetch cycle Signed-off-by: pallavibakale --- src/components/ContactDetails.vue | 2642 +++++++++-------- .../ContactDetails/ContactDetailsProperty.vue | 833 +++--- src/mixins/PropertyMixin.js | 286 +- src/models/contact.js | 1283 ++++---- src/store/contacts.js | 953 +++--- 5 files changed, 3138 insertions(+), 2859 deletions(-) diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index f246c957e5..b8ae041a21 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -4,1331 +4,1463 @@ --> diff --git a/src/components/ContactDetails/ContactDetailsProperty.vue b/src/components/ContactDetails/ContactDetailsProperty.vue index 94109bc8f9..d08858413b 100644 --- a/src/components/ContactDetails/ContactDetailsProperty.vue +++ b/src/components/ContactDetails/ContactDetailsProperty.vue @@ -4,401 +4,450 @@ --> diff --git a/src/mixins/PropertyMixin.js b/src/mixins/PropertyMixin.js index aa116fb6d7..560f14954e 100644 --- a/src/mixins/PropertyMixin.js +++ b/src/mixins/PropertyMixin.js @@ -2,150 +2,168 @@ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import debounce from 'debounce' -import Contact from '../models/contact.js' -import { setPropertyAlias } from '../services/updateDesignSet.js' +import debounce from 'debounce'; +import Contact from '../models/contact.js'; +import { setPropertyAlias } from '../services/updateDesignSet.js'; export default { - props: { - // Default property type. e.g. "WORK,HOME" - selectType: { - type: [Object], - default: () => {}, - }, - // Coming from the rfcProps Model - propModel: { - type: Object, - default: () => {}, - required: true, - }, - propType: { - type: String, - default: 'text', - }, - // The current property passed as Object - property: { - type: Object, - default: () => {}, - required: true, - }, - // Allows us to know if we need to - // add the property header or not - isFirstProperty: { - type: Boolean, - default: true, - }, - // Allows us to know if we need to - // add an extra space at the end - isLastProperty: { - type: Boolean, - default: true, - }, - // Is it read-only? - isReadOnly: { - type: Boolean, - required: true, - }, - // The available TYPE options from the propModel - // not used on the PropertySelect - options: { - type: Array, - default: () => [], - }, - localContact: { - type: Contact, - default: null, - }, - isMultiple: { - type: Boolean, - default: false, - }, - bus: { - type: Object, - required: false, - }, - }, + props: { + // Default property type. e.g. "WORK,HOME" + selectType: { + type: [Object], + default: () => {}, + }, + // Coming from the rfcProps Model + propModel: { + type: Object, + default: () => {}, + required: true, + }, + propType: { + type: String, + default: 'text', + }, + // The current property passed as Object + property: { + type: Object, + default: () => {}, + required: true, + }, + // Allows us to know if we need to + // add the property header or not + isFirstProperty: { + type: Boolean, + default: true, + }, + // Allows us to know if we need to + // add an extra space at the end + isLastProperty: { + type: Boolean, + default: true, + }, + // Is it read-only? + isReadOnly: { + type: Boolean, + required: true, + }, + // The available TYPE options from the propModel + // not used on the PropertySelect + options: { + type: Array, + default: () => [], + }, + localContact: { + type: Contact, + default: null, + }, + isMultiple: { + type: Boolean, + default: false, + }, + bus: { + type: Object, + required: false, + }, + }, - data() { - return { - // INIT data when the contact change. - // This is a simple copy that we can update as - // many times as we can and debounce-fire the update - // later - localValue: this.value, - localType: this.selectType, - } - }, + data() { + return { + // INIT data when the contact change. + // This is a simple copy that we can update as + // many times as we can and debounce-fire the update + // later + localValue: this.value, + localType: this.selectType, + }; + }, - computed: { - actions() { - return this.propModel.actions ? this.propModel.actions : [] - }, - haveAction() { - return this.actions && this.actions.length > 0 - }, - }, + computed: { + actions() { + return this.propModel.actions ? this.propModel.actions : []; + }, + haveAction() { + return this.actions && this.actions.length > 0; + }, + }, - watch: { - /** - * Since we're updating a local data based on the value prop, - * we need to make sure to update the local data on contact change - * in case the v-Node is reused. - */ - value() { - this.localValue = this.value - }, - selectType() { - this.localType = this.selectType - }, - }, + watch: { + /** + * Since we're updating a local data based on the value prop, + * we need to make sure to update the local data on contact change + * in case the v-Node is reused. + */ + value() { + this.localValue = this.value; + }, + selectType() { + this.localType = this.selectType; + }, + }, - methods: { - /** - * Delete the property - */ - deleteProperty() { - this.$emit('delete') - }, + methods: { + /** + * Delete the property + */ + deleteProperty() { + this.$emit('delete'); + }, - /** - * Debounce and send update event to parent - */ - updateValue: debounce(function(e) { - // https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier - this.$emit('update:value', this.localValue) - }, 500), + /** + * Debounce and send update event to parent + */ + updateValue: debounce(function (e) { + // https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier + this.$emit('update:value', this.localValue); + }, 500), - updateType: debounce(function(e) { - // https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier - this.$emit('update:selectType', this.localType) - }, 500), + updateType: debounce(function (e) { + // https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier + this.$emit('update:selectType', this.localType); + }, 500), - createLabel(label) { - let propGroup = this.property.name - if (!this.property.name.startsWith('nextcloud')) { - propGroup = `nextcloud${this.getNcGroupCount() + 1}.${this.property.name}` - this.property.jCal[0] = propGroup - } - const group = propGroup.split('.')[0] - const name = propGroup.split('.')[1] + createLabel(label) { + let propGroup = this.property.name; + const existingGroup = this.property.getParameter('group'); - this.localContact.vCard.addPropertyWithValue(`${group}.x-ablabel`, label.name) + if (existingGroup) { + // Server-loaded form: embed group into name, remove group param to avoid double prefix + propGroup = `${existingGroup}.${this.property.name}`; + this.property.jCal[0] = propGroup; + delete this.property.jCal[1].group; // prevent NEXTCLOUD1.NEXTCLOUD1.TEL - // force update the main design sets - setPropertyAlias(name, propGroup) + // Remove old X-ABLABEL in server-loaded form (group-param form) + const oldLabel = this.localContact.vCard + .getAllProperties('x-ablabel') + .find((p) => p.getParameter('group') === existingGroup); + if (oldLabel) this.localContact.vCard.removeProperty(oldLabel); + } else if (!this.property.name.startsWith('nextcloud')) { + propGroup = `nextcloud${this.getNcGroupCount() + 1}.${this.property.name}`; + this.property.jCal[0] = propGroup; + } + // else: already has a valid nextcloud group prefix in name — reuse it - this.$emit('update') - }, + const group = propGroup.split('.')[0]; + const name = propGroup.split('.')[1]; - getNcGroupCount() { - const props = this.localContact.jCal[1] - .map((prop) => prop[0].split('.')[0]) // itemxxx.adr => itemxxx - .filter((name) => name.startsWith('nextcloud')) // filter nextcloudxxx.adr - .map((prop) => parseInt(prop.split('nextcloud')[1])) // nextcloudxxx => xxx - return props.length > 0 - ? Math.max.apply(null, props) // get max iteration of nextcloud grouped props - : 0 - }, - }, -} + this.localContact.vCard.addPropertyWithValue( + `${group}.x-ablabel`, + label.name, + ); + setPropertyAlias(name, propGroup); + this.$emit('update'); + }, + + getNcGroupCount() { + const props = this.localContact.jCal[1] + .map((prop) => { + const nameGroup = prop[0].split('.')[0]; + if (nameGroup.startsWith('nextcloud')) return nameGroup; + return (prop[1] && prop[1].group) || ''; + }) + .filter((name) => name.startsWith('nextcloud')) + .map((prop) => parseInt(prop.replace('nextcloud', ''))) + .filter((n) => !isNaN(n)); + return props.length > 0 ? Math.max.apply(null, props) : 0; + }, + }, +}; diff --git a/src/models/contact.js b/src/models/contact.js index 325b641c8f..4101b1acba 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -3,14 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import sanitizeSVG from '@mattkrick/sanitize-svg' -import b64toBlob from 'b64-to-blob' -import { Buffer } from 'buffer' -import ICAL from 'ical.js' -import { v4 as uuid } from 'uuid' -import { shallowRef, unref } from 'vue' -import updateDesignSet from '../services/updateDesignSet.js' -import store from '../store/index.js' +import sanitizeSVG from '@mattkrick/sanitize-svg'; +import b64toBlob from 'b64-to-blob'; +import { Buffer } from 'buffer'; +import ICAL from 'ical.js'; +import { v4 as uuid } from 'uuid'; +import { shallowRef, unref } from 'vue'; +import updateDesignSet from '../services/updateDesignSet.js'; +import store from '../store/index.js'; /** * Check if the given value is an empty array or an empty string @@ -19,621 +19,666 @@ import store from '../store/index.js' * @return {boolean} */ function isEmpty(value) { - return (Array.isArray(value) && value.join('') === '') || (!Array.isArray(value) && value === '') + return ( + (Array.isArray(value) && value.join('') === '') || + (!Array.isArray(value) && value === '') + ); } -export const ContactKindProperties = ['KIND', 'X-ADDRESSBOOKSERVER-KIND'] +export const ContactKindProperties = ['KIND', 'X-ADDRESSBOOKSERVER-KIND']; export const MinimalContactProperties = [ - 'EMAIL', 'UID', 'TEL', 'CATEGORIES', 'FN', 'ORG', 'N', 'X-PHONETIC-FIRST-NAME', 'X-PHONETIC-LAST-NAME', 'X-MANAGERSNAME', 'TITLE', 'NOTE', 'RELATED', -].concat(ContactKindProperties) + 'EMAIL', + 'UID', + 'TEL', + 'CATEGORIES', + 'FN', + 'ORG', + 'N', + 'X-PHONETIC-FIRST-NAME', + 'X-PHONETIC-LAST-NAME', + 'X-MANAGERSNAME', + 'TITLE', + 'NOTE', + 'RELATED', +].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 version = this.vCard.getFirstPropertyValue('version') - if (version === '4.0') { - this.vCard.addPropertyWithValue('rev', ICAL.Time.fromJSDate(new Date(), true)) - } - if (version === '3.0') { - this.vCard.addPropertyWithValue('rev', ICAL.VCardTime.fromDateAndOrTimeString(new Date().toISOString(), 'date-time')) - } - } - } - - get vCard() { - return unref(this._vCard) - } - - set vCard(value) { - this._vCard = shallowRef(value) - } - - get favorite() { - if (this.dav) { - return this.dav.favorite || false - } - return false - } - - set favorite(value) { - if (this.dav) { - this.dav.favorite = value - } - } - - /** - * 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 Buffer.from(this.uid + '~' + this.addressbook.id, 'utf8').toString('base64') - } - - /** - * 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 whether a photo is available - * - * @readonly - * @memberof Contact - */ - get hasPhoto() { - return this.dav && this.dav.hasphoto - } - - /** - * 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') - - // Always convert to a string as this might be a binary value (and not a string) - const photoB64 = this.photo.toString() - - 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.toLowerCase().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 x-managersname - * - * @readonly - * @memberof Contact - */ - get managersName() { - const prop = this.vCard.getFirstProperty('x-managersname') - if (!prop) { - return null - } - return prop.getFirstParameter('uid') ?? null - } - - /** - * 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 first matching link for provided type - * Returns empty string otherwise - * - * @param {string} type of social - * @readonly - * @memberof Contact - * @return {string} firstMatchingLink|'' - */ - socialLink(type) { - if (this.vCard.hasProperty('x-socialprofile')) { - const x = this.vCard.getAllProperties('x-socialprofile').filter((a) => a.jCal[1].type.toString() === type) - - if (x.length > 0) { - return x[0].jCal[3].toString() - } - } - return '' - } - - /** - * 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() { - const MinimalContactPropertiesLower = MinimalContactProperties.map((prop) => prop.toLowerCase()) - const filtered = this.jCal[1] - .filter((x) => MinimalContactPropertiesLower.includes(x[0].toLowerCase())) - .map((x) => { - if (x[0].toLowerCase() === 'tel') { - return this.normalizedTels(x[3]) - } - return x[3].toString() - }) - return filtered - } - - // support numbers in weird formats for searching e.g. +49 (0) 123 456-789 - normalizedTels(number) { - return number.replace(/[^0-9+#]/g, '') - } - - /** - * 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') - } + /** + * 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 version = this.vCard.getFirstPropertyValue('version'); + if (version === '4.0') { + this.vCard.addPropertyWithValue( + 'rev', + ICAL.Time.fromJSDate(new Date(), true), + ); + } + if (version === '3.0') { + this.vCard.addPropertyWithValue( + 'rev', + ICAL.VCardTime.fromDateAndOrTimeString( + new Date().toISOString(), + 'date-time', + ), + ); + } + } + } + + get vCard() { + return unref(this._vCard); + } + + set vCard(value) { + this._vCard = shallowRef(value); + } + + get favorite() { + if (this.dav) { + return this.dav.favorite || false; + } + return false; + } + + set favorite(value) { + if (this.dav) { + this.dav.favorite = value; + } + } + + /** + * 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 Buffer.from(this.uid + '~' + this.addressbook.id, 'utf8').toString( + 'base64', + ); + } + + /** + * 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 whether a photo is available + * + * @readonly + * @memberof Contact + */ + get hasPhoto() { + return this.dav && this.dav.hasphoto; + } + + /** + * 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'); + + // Always convert to a string as this might be a binary value (and not a string) + const photoB64 = this.photo.toString(); + + 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.toLowerCase().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 x-managersname + * + * @readonly + * @memberof Contact + */ + get managersName() { + const prop = this.vCard.getFirstProperty('x-managersname'); + if (!prop) { + return null; + } + return prop.getFirstParameter('uid') ?? null; + } + + /** + * 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 first matching link for provided type + * Returns empty string otherwise + * + * @param {string} type of social + * @readonly + * @memberof Contact + * @return {string} firstMatchingLink|'' + */ + socialLink(type) { + if (this.vCard.hasProperty('x-socialprofile')) { + const x = this.vCard + .getAllProperties('x-socialprofile') + .filter((a) => a.jCal[1].type.toString() === type); + + if (x.length > 0) { + return x[0].jCal[3].toString(); + } + } + return ''; + } + + /** + * 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() { + const MinimalContactPropertiesLower = MinimalContactProperties.map((prop) => + prop.toLowerCase(), + ); + const filtered = this.jCal[1] + .filter((x) => MinimalContactPropertiesLower.includes(x[0].toLowerCase())) + .map((x) => { + if (x[0].toLowerCase() === 'tel') { + return this.normalizedTels(x[3]); + } + return x[3].toString(); + }); + return filtered; + } + + // support numbers in weird formats for searching e.g. +49 (0) 123 456-789 + normalizedTels(number) { + return number.replace(/[^0-9+#]/g, ''); + } + + /** + * 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-,]+)"/gim; + const card = this.vCard.toString(); + return card.replace(regexp, 'TYPE=$1'); + } } diff --git a/src/store/contacts.js b/src/store/contacts.js index 26fc8597a2..c3445f34b6 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { showError } from '@nextcloud/dialogs' -import ICAL from 'ical.js' -import Contact from '../models/contact.js' -import validate from '../services/validate.js' +import { showError } from '@nextcloud/dialogs'; +import ICAL from 'ical.js'; +import Contact from '../models/contact.js'; +import validate from '../services/validate.js'; /* * Currently ical.js does not serialize parameters with multiple values correctly. This is @@ -22,472 +22,507 @@ import validate from '../services/validate.js' * to be merged for ical.js. Until this fix is merged and released the following configuration * changes apply the workaround described above. */ -ICAL.design.vcard3.param.type.multiValueSeparateDQuote = true -ICAL.design.vcard.param.type.multiValueSeparateDQuote = true +ICAL.design.vcard3.param.type.multiValueSeparateDQuote = true; +ICAL.design.vcard.param.type.multiValueSeparateDQuote = true; function sortData(a, b) { - const nameA = typeof a.value === 'string' - ? a.value.toUpperCase() // ignore upper and lowercase - : a.value.toUnixTime() // only other sorting we support is a vCardTime - const nameB = typeof b.value === 'string' - ? b.value.toUpperCase() // ignore upper and lowercase - : b.value.toUnixTime() // only other sorting we support is a vCardTime - - const score = nameA.localeCompare - ? nameA.localeCompare(nameB) - : nameB - nameA - // if equal, fallback to the key - return score !== 0 - ? score - : a.key.localeCompare(b.key) + const nameA = + typeof a.value === 'string' + ? a.value.toUpperCase() // ignore upper and lowercase + : a.value.toUnixTime(); // only other sorting we support is a vCardTime + const nameB = + typeof b.value === 'string' + ? b.value.toUpperCase() // ignore upper and lowercase + : b.value.toUnixTime(); // only other sorting we support is a vCardTime + + const score = nameA.localeCompare + ? nameA.localeCompare(nameB) + : nameB - nameA; + // if equal, fallback to the key + return score !== 0 ? score : a.key.localeCompare(b.key); } function sortByFavoriteAndName(a, b) { - // favorites always on top - if (a.favorite !== b.favorite) { - return a.favorite ? -1 : 1 - } - // alphabetical within each group - if (!a.value && !b.value) { - return 0 - } - if (!a.value) { - return 1 - } - if (!b.value) { - return -1 - } - return a.value.localeCompare(b.value) + // favorites always on top + if (a.favorite !== b.favorite) { + return a.favorite ? -1 : 1; + } + // alphabetical within each group + if (!a.value && !b.value) { + return 0; + } + if (!a.value) { + return 1; + } + if (!b.value) { + return -1; + } + return a.value.localeCompare(b.value); } const state = { - // Using objects for performance - // https://codepen.io/skjnldsv/pen/ZmKvQo - contacts: {}, - sortedContacts: [], - orderKey: 'displayName', -} + // Using objects for performance + // https://codepen.io/skjnldsv/pen/ZmKvQo + contacts: {}, + sortedContacts: [], + orderKey: 'displayName', +}; const mutations = { - /** - * Store raw contacts into state - * Used by the first contact fetch - * - * @param {object} state Default state - * @param {Array} contacts Contacts - */ - appendContacts(state, contacts = []) { - state.contacts = contacts.reduce(function(list, contact) { - if (contact instanceof Contact) { - list[contact.key] = contact - } else { - console.error('Invalid contact object', contact) - } - return list - }, state.contacts) - }, - - /** - * Store favorite state into store - * - * @param {object} state Default state - * @param {Contact} contact Contact - */ - updateContactFavorite(state, contact) { - if (!state.contacts[contact.key] || !(contact instanceof Contact)) { - console.error('Invalid contact update', contact) - return - } - - if (state.contacts[contact.key].dav) { - state.contacts[contact.key].dav.favorite = contact.dav.favorite - } - - const sortedContact = state.sortedContacts.find((c) => c.key === contact.key) - if (sortedContact) { - sortedContact.favorite = contact.favorite || false - } - - state.sortedContacts = Object.values(state.contacts) - .filter((c) => c.kind !== 'group') - .map((c) => ({ - key: c.key, - value: (c[state.orderKey] || '').toString().toLowerCase(), - favorite: c.favorite || false, - })) - .sort(sortByFavoriteAndName) - }, - /** - * Delete a contact from the global contacts list - * - * @param {object} state the store data - * @param {Contact} contact the contact to delete - */ - deleteContact(state, contact) { - if (state.contacts[contact.key] && contact instanceof Contact) { - const index = state.sortedContacts.findIndex((search) => search.key === contact.key) - state.sortedContacts.splice(index, 1) - delete state.contacts[contact.key] - } else { - console.error('Error while deleting the following contact', contact) - } - }, - - /** - * Insert new contact into sorted array - * - * @param {object} state the store data - * @param {Contact} contact the contact to add - */ - addContact(state, contact) { - // Checking contact validity 🔍🙈 - if (contact instanceof Contact) { - validate(contact) - - const sortedContact = { - key: contact.key, - value: (contact[state.orderKey] || '').toString().toLowerCase(), - favorite: contact.favorite, - } - - // Not using sort, splice has far better performances - // https://jsperf.com/sort-vs-splice-in-array - for (let i = 0, len = state.sortedContacts.length; i < len; i++) { - const other = state.sortedContacts[i] - - // favorite comes before non-favorite - const differentFavStatus = other.favorite !== sortedContact.favorite - const otherShouldComeFirst = differentFavStatus && other.favorite - const sameFavAndSortedFirst = !differentFavStatus && sortData(other, sortedContact) >= 0 - - if (otherShouldComeFirst || sameFavAndSortedFirst) { - continue - } - - if (i + 1 === len) { - state.sortedContacts.push(sortedContact) - } else { - state.sortedContacts.splice(i, 0, sortedContact) - } - break - } - - if (state.sortedContacts.length === 0) { - state.sortedContacts.push(sortedContact) - } - - state.contacts[contact.key] = contact - } else { - console.error('Error while adding the following contact', contact) - } - }, - - /** - * Update a contact - * - * @param {object} state the store data - * @param {Contact} contact the contact to update - */ - updateContact(state, contact) { - if (state.contacts[contact.key] && contact instanceof Contact) { - const existingFavorite = state.contacts[contact.key].dav?.favorite || false - state.contacts[contact.key].updateContact(contact.jCal) - - // restore favorite on dav if it was lost during the update - if (state.contacts[contact.key].dav && state.contacts[contact.key].dav.favorite === undefined) { - state.contacts[contact.key].dav.favorite = existingFavorite - } - - const sortedContact = state.sortedContacts.find((search) => search.key === contact.key) - - if (!sortedContact) { - console.warn('sortedContact not found for', contact.key) - return - } - - const hasValueChanged = sortedContact.value !== contact[state.orderKey] - const hasFavoriteChanged = sortedContact.favorite !== (state.contacts[contact.key].dav?.favorite || false) - - if (hasValueChanged || hasFavoriteChanged) { - sortedContact.value = contact[state.orderKey] - sortedContact.favorite = state.contacts[contact.key].dav?.favorite || false - - state.sortedContacts.sort(sortByFavoriteAndName) - } - } else { - console.error('Error while replacing the following contact', contact) - } - }, - - /** - * Update a contact addressbook - * - * @param {object} state the store data - * @param {object} data destructuring object - * @param data.contact - * @param {Contact} contact the contact to update - * @param {object} addressbook the addressbook to set - * @param data.addressbook - */ - updateContactAddressbook(state, { contact, addressbook }) { - if (state.contacts[contact.key] && contact instanceof Contact) { - // replace contact object data by creating a new contact - const oldKey = contact.key - - // hijack reference - const newContact = contact - - // delete old key, cut reference - delete state.contacts[oldKey] - - // replace addressbook - newContact.addressbook = addressbook - - // set new key, re-assign reference - state.contacts[newContact.key] = newContact - - // Update sorted contacts list, replace at exact same position - const index = state.sortedContacts.findIndex((search) => search.key === oldKey) - state.sortedContacts[index].key = newContact.key - state.sortedContacts[index].value = newContact[state.orderKey] - } else { - console.error('Error while replacing the addressbook of following contact', contact) - } - }, - - /** - * Update a contact etag - * - * @param {object} state the store data - * @param {object} data destructuring object - * @param data.contact - * @param {Contact} contact the contact to update - * @param {string} etag the contact etag - * @param data.etag - */ - updateContactEtag(state, { contact, etag }) { - if (state.contacts[contact.key] && contact instanceof Contact) { - // replace contact object data - state.contacts[contact.key].dav.etag = etag - } else { - console.error('Error while replacing the etag of following contact', contact) - } - }, - - /** - * Order the contacts list. Filters have terrible performances. - * We do not want to run the sorting function every time. - * Let's only run it on additions and create an index - * - * @param {object} state the store data - */ - sortContacts(state) { - state.sortedContacts = Object.values(state.contacts) - .filter((contact) => contact.kind !== 'group') - .map((contact) => ({ - key: contact.key, - value: contact[state.orderKey], - favorite: contact.favorite || false, - })) - .sort(sortByFavoriteAndName) - }, - - /** - * Set the order key - * - * @param {object} state the store data - * @param {string} [orderKey] the order key to sort by - */ - setOrder(state, orderKey = 'displayName') { - state.orderKey = orderKey - }, - - /** - * Set a contact as `in conflict` with the server data - * - * @param {object} state the store data - * @param {object} data destructuring object - * @param {Contact} data.contact the contact to update - * @param {string} data.etag the etag to set - */ - setContactAsConflict(state, { contact, etag }) { - if (state.contacts[contact.key] && contact instanceof Contact) { - state.contacts[contact.key].conflict = etag - } else { - console.error('Error while handling the following contact', contact) - } - }, - - /** - * Set a contact dav property - * - * @param {object} state the store data - * @param {object} data destructuring object - * @param {Contact} data.contact the contact to update - * @param {object} data.dav the dav object returned by the cdav library - */ - setContactDav(state, { contact, dav }) { - if (state.contacts[contact.key] && contact instanceof Contact) { - contact = state.contacts[contact.key] - contact.dav = dav - } else { - console.error('Error while handling the following contact', contact) - } - }, -} + /** + * Store raw contacts into state + * Used by the first contact fetch + * + * @param {object} state Default state + * @param {Array} contacts Contacts + */ + appendContacts(state, contacts = []) { + state.contacts = contacts.reduce(function (list, contact) { + if (contact instanceof Contact) { + list[contact.key] = contact; + } else { + console.error('Invalid contact object', contact); + } + return list; + }, state.contacts); + }, + + /** + * Store favorite state into store + * + * @param {object} state Default state + * @param {Contact} contact Contact + */ + updateContactFavorite(state, contact) { + if (!state.contacts[contact.key] || !(contact instanceof Contact)) { + console.error('Invalid contact update', contact); + return; + } + + if (state.contacts[contact.key].dav) { + state.contacts[contact.key].dav.favorite = contact.dav.favorite; + } + + const sortedContact = state.sortedContacts.find( + (c) => c.key === contact.key, + ); + if (sortedContact) { + sortedContact.favorite = contact.favorite || false; + } + + state.sortedContacts = Object.values(state.contacts) + .filter((c) => c.kind !== 'group') + .map((c) => ({ + key: c.key, + value: (c[state.orderKey] || '').toString().toLowerCase(), + favorite: c.favorite || false, + })) + .sort(sortByFavoriteAndName); + }, + /** + * Delete a contact from the global contacts list + * + * @param {object} state the store data + * @param {Contact} contact the contact to delete + */ + deleteContact(state, contact) { + if (state.contacts[contact.key] && contact instanceof Contact) { + const index = state.sortedContacts.findIndex( + (search) => search.key === contact.key, + ); + state.sortedContacts.splice(index, 1); + delete state.contacts[contact.key]; + } else { + console.error('Error while deleting the following contact', contact); + } + }, + + /** + * Insert new contact into sorted array + * + * @param {object} state the store data + * @param {Contact} contact the contact to add + */ + addContact(state, contact) { + // Checking contact validity 🔍🙈 + if (contact instanceof Contact) { + validate(contact); + + const sortedContact = { + key: contact.key, + value: (contact[state.orderKey] || '').toString().toLowerCase(), + favorite: contact.favorite, + }; + + // Not using sort, splice has far better performances + // https://jsperf.com/sort-vs-splice-in-array + for (let i = 0, len = state.sortedContacts.length; i < len; i++) { + const other = state.sortedContacts[i]; + + // favorite comes before non-favorite + const differentFavStatus = other.favorite !== sortedContact.favorite; + const otherShouldComeFirst = differentFavStatus && other.favorite; + const sameFavAndSortedFirst = + !differentFavStatus && sortData(other, sortedContact) >= 0; + + if (otherShouldComeFirst || sameFavAndSortedFirst) { + continue; + } + + if (i + 1 === len) { + state.sortedContacts.push(sortedContact); + } else { + state.sortedContacts.splice(i, 0, sortedContact); + } + break; + } + + if (state.sortedContacts.length === 0) { + state.sortedContacts.push(sortedContact); + } + + state.contacts[contact.key] = contact; + } else { + console.error('Error while adding the following contact', contact); + } + }, + + /** + * Update a contact + * + * @param {object} state the store data + * @param {Contact} contact the contact to update + */ + updateContact(state, contact) { + if (state.contacts[contact.key] && contact instanceof Contact) { + const existingFavorite = + state.contacts[contact.key].dav?.favorite || false; + state.contacts[contact.key].updateContact(contact.jCal); + + // restore favorite on dav if it was lost during the update + if ( + state.contacts[contact.key].dav && + state.contacts[contact.key].dav.favorite === undefined + ) { + state.contacts[contact.key].dav.favorite = existingFavorite; + } + + const sortedContact = state.sortedContacts.find( + (search) => search.key === contact.key, + ); + + if (!sortedContact) { + console.warn('sortedContact not found for', contact.key); + return; + } + + const hasValueChanged = sortedContact.value !== contact[state.orderKey]; + const hasFavoriteChanged = + sortedContact.favorite !== + (state.contacts[contact.key].dav?.favorite || false); + + if (hasValueChanged || hasFavoriteChanged) { + sortedContact.value = contact[state.orderKey]; + sortedContact.favorite = + state.contacts[contact.key].dav?.favorite || false; + + state.sortedContacts.sort(sortByFavoriteAndName); + } + } else { + console.error('Error while replacing the following contact', contact); + } + }, + + /** + * Update a contact addressbook + * + * @param {object} state the store data + * @param {object} data destructuring object + * @param data.contact + * @param {Contact} contact the contact to update + * @param {object} addressbook the addressbook to set + * @param data.addressbook + */ + updateContactAddressbook(state, { contact, addressbook }) { + if (state.contacts[contact.key] && contact instanceof Contact) { + // replace contact object data by creating a new contact + const oldKey = contact.key; + + // hijack reference + const newContact = contact; + + // delete old key, cut reference + delete state.contacts[oldKey]; + + // replace addressbook + newContact.addressbook = addressbook; + + // set new key, re-assign reference + state.contacts[newContact.key] = newContact; + + // Update sorted contacts list, replace at exact same position + const index = state.sortedContacts.findIndex( + (search) => search.key === oldKey, + ); + state.sortedContacts[index].key = newContact.key; + state.sortedContacts[index].value = newContact[state.orderKey]; + } else { + console.error( + 'Error while replacing the addressbook of following contact', + contact, + ); + } + }, + + /** + * Update a contact etag + * + * @param {object} state the store data + * @param {object} data destructuring object + * @param data.contact + * @param {Contact} contact the contact to update + * @param {string} etag the contact etag + * @param data.etag + */ + updateContactEtag(state, { contact, etag }) { + if (state.contacts[contact.key] && contact instanceof Contact) { + // replace contact object data + state.contacts[contact.key].dav.etag = etag; + } else { + console.error( + 'Error while replacing the etag of following contact', + contact, + ); + } + }, + + /** + * Order the contacts list. Filters have terrible performances. + * We do not want to run the sorting function every time. + * Let's only run it on additions and create an index + * + * @param {object} state the store data + */ + sortContacts(state) { + state.sortedContacts = Object.values(state.contacts) + .filter((contact) => contact.kind !== 'group') + .map((contact) => ({ + key: contact.key, + value: contact[state.orderKey], + favorite: contact.favorite || false, + })) + .sort(sortByFavoriteAndName); + }, + + /** + * Set the order key + * + * @param {object} state the store data + * @param {string} [orderKey] the order key to sort by + */ + setOrder(state, orderKey = 'displayName') { + state.orderKey = orderKey; + }, + + /** + * Set a contact as `in conflict` with the server data + * + * @param {object} state the store data + * @param {object} data destructuring object + * @param {Contact} data.contact the contact to update + * @param {string} data.etag the etag to set + */ + setContactAsConflict(state, { contact, etag }) { + if (state.contacts[contact.key] && contact instanceof Contact) { + state.contacts[contact.key].conflict = etag; + } else { + console.error('Error while handling the following contact', contact); + } + }, + + /** + * Set a contact dav property + * + * @param {object} state the store data + * @param {object} data destructuring object + * @param {Contact} data.contact the contact to update + * @param {object} data.dav the dav object returned by the cdav library + */ + setContactDav(state, { contact, dav }) { + if (state.contacts[contact.key] && contact instanceof Contact) { + contact = state.contacts[contact.key]; + contact.dav = dav; + } else { + console.error('Error while handling the following contact', contact); + } + }, +}; const getters = { - getContacts: (state) => state.contacts, - getSortedContacts: (state) => state.sortedContacts, - getContact: (state) => (key) => state.contacts[key], - getOrderKey: (state) => state.orderKey, -} + getContacts: (state) => state.contacts, + getSortedContacts: (state) => state.sortedContacts, + getContact: (state) => (key) => state.contacts[key], + getOrderKey: (state) => state.orderKey, +}; const actions = { - - /** - * Toggle the favorite state of a contact. - * Updates the store - * - * @param {object} context the store mutations - * @param {object} contact the contact key to toggle - */ - async toggleFavorite(context, contact) { - if (!contact.dav) { - throw new Error(`Missing DAV object for contact ${contact.key}`) - } - - const oldValue = contact.dav.favorite || false - const newValue = !oldValue - - try { - contact.dav.favorite = newValue - await contact.dav.updateProperties() - context.commit('updateContactFavorite', contact) - } catch (error) { - contact.dav.favorite = oldValue - context.commit('updateContactFavorite', contact) - showError(t('contacts', 'Could not update favorite state')) - console.error('Could not toggle favorite state', error) - } - }, - - /** - * Delete a contact from the list and from the associated addressbook - * - * @param {object} context the store mutations - * @param {object} data destructuring object - * @param {Contact} data.contact the contact to delete - * @param {boolean} [data.dav] trigger a dav deletion - */ - async deleteContact(context, { contact, dav = true }) { - // only local delete if the contact doesn't exists on the server - if (contact.dav && dav) { - await contact.dav.delete() - .catch((error) => { - console.error(error) - showError(t('contacts', 'Unable to delete contact')) - }) - } - context.commit('deleteContact', contact) - context.commit('deleteContactFromAddressbook', contact) - context.commit('removeContactFromGroups', contact) - }, - - /** - * Add a contact to the list, the associated addressbook and to the groups - * - * @param {object} context the store mutations - * @param {Contact} contact the contact to delete - */ - async addContact(context, contact) { - await context.commit('addContact', contact) - await context.commit('addContactToAddressbook', contact) - await context.commit('extractGroupsFromContacts', [contact]) - }, - - /** - * Replace a contact by this new object - * - * @param {object} context the store mutations - * @param {Contact} contact the contact to update - * @return {Promise} - */ - async updateContact(context, contact) { - // Checking contact validity 🙈 - validate(contact) - - // Update REV - if (contact.version === '4.0') { - contact.rev = ICAL.Time.fromJSDate(new Date(), true) - } - if (contact.version === '3.0') { - contact.rev = ICAL.VCardTime.fromDateAndOrTimeString(new Date().toISOString(), 'date-time') - } - - const vData = contact.toStringStripQuotes() - - // if no dav key, contact does not exists on server - if (!contact.dav) { - // create contact - const dav = await contact.addressbook.dav.createVCard(vData) - context.commit('setContactDav', { contact, dav }) - return - } - - // if contact already exists - if (!contact.conflict) { - contact.dav.data = vData - try { - await contact.dav.update() - // all clear, let's update the store - context.commit('updateContact', contact) - } catch (error) { - console.error(error) - - // wrong etag, we most likely have a conflict - if (error && error?.status === 412) { - // saving the new etag so that the user can manually - // trigger a fetchCompleteData without any further errors - context.commit('setContactAsConflict', { contact, etag: error.xhr.getResponseHeader('etag') }) - console.error('This contact is outdated, the server refused it', contact) - } - throw (error) - } - } else { - console.error('This contact is outdated, refusing to push', contact) - } - }, - - /** - * Fetch the full vCard from the dav server - * - * @param {object} context the store mutations - * @param {object} data destructuring object - * @param {Contact} data.contact the contact to fetch - * @param {string} data.etag the contact etag to override in case of conflict - * @param data.forceReFetch - * @return {Promise} - */ - async fetchFullContact(context, { contact, etag = '', forceReFetch = false }) { - if (etag.trim() !== '') { - await context.commit('updateContactEtag', { contact, etag }) - } - - const storeContact = context.getters.getContact(contact.key) - const davObject = storeContact?.dav || contact.dav - - const savedFavorite = davObject.favorite - - return davObject.fetchCompleteData(forceReFetch) - .then(() => { - const newContact = new Contact(davObject.data, contact.addressbook) - newContact.dav = davObject - newContact.dav.favorite = savedFavorite - context.commit('updateContact', newContact) - }) - .catch((error) => { throw error }) - }, -} - -export default { state, mutations, getters, actions } + /** + * Toggle the favorite state of a contact. + * Updates the store + * + * @param {object} context the store mutations + * @param {object} contact the contact key to toggle + */ + async toggleFavorite(context, contact) { + if (!contact.dav) { + throw new Error(`Missing DAV object for contact ${contact.key}`); + } + + const oldValue = contact.dav.favorite || false; + const newValue = !oldValue; + + try { + contact.dav.favorite = newValue; + await contact.dav.updateProperties(); + context.commit('updateContactFavorite', contact); + } catch (error) { + contact.dav.favorite = oldValue; + context.commit('updateContactFavorite', contact); + showError(t('contacts', 'Could not update favorite state')); + console.error('Could not toggle favorite state', error); + } + }, + + /** + * Delete a contact from the list and from the associated addressbook + * + * @param {object} context the store mutations + * @param {object} data destructuring object + * @param {Contact} data.contact the contact to delete + * @param {boolean} [data.dav] trigger a dav deletion + */ + async deleteContact(context, { contact, dav = true }) { + // only local delete if the contact doesn't exists on the server + if (contact.dav && dav) { + await contact.dav.delete().catch((error) => { + console.error(error); + showError(t('contacts', 'Unable to delete contact')); + }); + } + context.commit('deleteContact', contact); + context.commit('deleteContactFromAddressbook', contact); + context.commit('removeContactFromGroups', contact); + }, + + /** + * Add a contact to the list, the associated addressbook and to the groups + * + * @param {object} context the store mutations + * @param {Contact} contact the contact to delete + */ + async addContact(context, contact) { + await context.commit('addContact', contact); + await context.commit('addContactToAddressbook', contact); + await context.commit('extractGroupsFromContacts', [contact]); + }, + + /** + * Replace a contact by this new object + * + * @param {object} context the store mutations + * @param {Contact} contact the contact to update + * @return {Promise} + */ + async updateContact(context, contact) { + // Checking contact validity 🙈 + validate(contact); + + // Update REV + if (contact.version === '4.0') { + contact.rev = ICAL.Time.fromJSDate(new Date(), true); + } + if (contact.version === '3.0') { + contact.rev = ICAL.VCardTime.fromDateAndOrTimeString( + new Date().toISOString(), + 'date-time', + ); + } + + const vData = contact.toStringStripQuotes(); + + // if no dav key, contact does not exists on server + if (!contact.dav) { + // create contact + const dav = await contact.addressbook.dav.createVCard(vData); + context.commit('setContactDav', { contact, dav }); + return; + } + + // if contact already exists + if (!contact.conflict) { + contact.dav.data = vData; + try { + await contact.dav.update(); + // all clear, let's update the store + context.commit('updateContact', contact); + } catch (error) { + console.error(error); + + // wrong etag, we most likely have a conflict + if (error && error?.status === 412) { + // saving the new etag so that the user can manually + // trigger a fetchCompleteData without any further errors + context.commit('setContactAsConflict', { + contact, + etag: error.xhr.getResponseHeader('etag'), + }); + console.error( + 'This contact is outdated, the server refused it', + contact, + ); + } + throw error; + } + } else { + console.error('This contact is outdated, refusing to push', contact); + } + }, + + /** + * Fetch the full vCard from the dav server + * + * @param {object} context the store mutations + * @param {object} data destructuring object + * @param {Contact} data.contact the contact to fetch + * @param {string} data.etag the contact etag to override in case of conflict + * @param data.forceReFetch + * @return {Promise} + */ + async fetchFullContact( + context, + { contact, etag = '', forceReFetch = false }, + ) { + if (etag.trim() !== '') { + await context.commit('updateContactEtag', { contact, etag }); + } + + const storeContact = context.getters.getContact(contact.key); + const davObject = storeContact?.dav || contact.dav; + + const savedFavorite = davObject.favorite; + + return davObject + .fetchCompleteData(forceReFetch) + .then(() => { + const newContact = new Contact(davObject.data, contact.addressbook); + newContact.dav = davObject; + newContact.dav.favorite = savedFavorite; + context.commit('updateContact', newContact); + }) + .catch((error) => { + throw error; + }); + }, +}; + +export default { state, mutations, getters, actions }; From 0efc4d7c0884405a71a39ce94b46f7ecc14751a0 Mon Sep 17 00:00:00 2001 From: pallavibakale Date: Thu, 11 Jun 2026 10:01:52 -0400 Subject: [PATCH 2/6] fix: linting fixes Signed-off-by: pallavibakale --- src/components/ContactDetails.vue | 2573 ++++++++--------- .../ContactDetails/ContactDetailsProperty.vue | 873 +++--- src/mixins/PropertyMixin.js | 302 +- src/models/contact.js | 1322 +++++---- src/store/contacts.js | 980 ++++--- 5 files changed, 2985 insertions(+), 3065 deletions(-) diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index b8ae041a21..abcef05d33 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -4,1330 +4,1273 @@ --> diff --git a/src/components/ContactDetails/ContactDetailsProperty.vue b/src/components/ContactDetails/ContactDetailsProperty.vue index 3969c4af2d..27a89d1739 100644 --- a/src/components/ContactDetails/ContactDetailsProperty.vue +++ b/src/components/ContactDetails/ContactDetailsProperty.vue @@ -42,7 +42,9 @@ import { matchTypes } from '../../utils/matchTypes.ts' export default { name: 'ContactDetailsProperty', - mixins: [OrgChartsMixin], + mixins: [ + OrgChartsMixin, + ], props: { property: { @@ -98,10 +100,7 @@ export default { // dynamic matching if (this.property.isMultiValue && this.propType === 'text') { return PropertyMultipleText - } else if ( - this.propType - && ['date-and-or-time', 'date-time', 'time', 'date'].indexOf(this.propType) > -1 - ) { + } else if (this.propType && ['date-and-or-time', 'date-time', 'time', 'date'].indexOf(this.propType) > -1) { return PropertyDateTime } else if (this.propType && this.propType === 'select') { return PropertySelect @@ -184,15 +183,12 @@ export default { selectType: this.selectType, }) } else { - return this.propModel.options.reduce( - (list, option) => { - if (!list.find((search) => search.name === option.name)) { - list.push(option) - } - return list - }, - this.selectType ? [this.selectType] : [], - ) + return this.propModel.options.reduce((list, option) => { + if (!list.find((search) => search.name === option.name)) { + list.push(option) + } + return list + }, this.selectType ? [this.selectType] : []) } }, @@ -247,12 +243,15 @@ export default { } if (this.propModel && this.propModel.options && this.type) { const selectedType = this.type - // vcard 3.0 save pref alongside TYPE + // vcard 3.0 save pref alongside TYPE .filter((type) => type !== 'pref') - // we only use uppercase strings + // we only use uppercase strings .map((str) => str.toUpperCase()) - const matchingType = matchTypes(selectedType, this.propModel.options) + const matchingType = matchTypes( + selectedType, + this.propModel.options, + ) if (matchingType) { return matchingType.type @@ -328,12 +327,7 @@ export default { } if (this.propName === 'x-managersname') { if (this.property.getParameter('uid')) { - return Buffer.from( - this.property.getParameter('uid') - + '~' - + this.contact.addressbook.id, - 'utf-8', - ).toString('base64') + return Buffer.from(this.property.getParameter('uid') + '~' + this.contact.addressbook.id, 'utf-8').toString('base64') } // Try to find the matching contact by display name // TODO: this only *shows* the display name but doesn't assign the missing UID @@ -410,17 +404,9 @@ export default { if (id === this.propName && this.isLastProperty) { this.$nextTick(() => { const comp = this.$refs.component - const el - = comp?.$el instanceof HTMLElement - ? comp.$el - : comp instanceof HTMLElement - ? comp - : null + const el = comp?.$el instanceof HTMLElement ? comp.$el : (comp instanceof HTMLElement ? comp : null) if (!el || !el.querySelectorAll) { - console.warn( - 'No focusable element found for property', - this.propName, - ) + console.warn('No focusable element found for property', this.propName) return } const inputs = el.querySelectorAll('input, textarea') diff --git a/src/mixins/PropertyMixin.js b/src/mixins/PropertyMixin.js index 6173ec47d4..66d329450b 100644 --- a/src/mixins/PropertyMixin.js +++ b/src/mixins/PropertyMixin.js @@ -145,11 +145,9 @@ export default { const group = propGroup.split('.')[0] const name = propGroup.split('.')[1] - this.localContact.vCard.addPropertyWithValue( - `${group}.x-ablabel`, - label.name, - ) + this.localContact.vCard.addPropertyWithValue(`${group}.x-ablabel`, label.name) setPropertyAlias(name, propGroup) + this.$emit('update') }, diff --git a/src/models/contact.js b/src/models/contact.js index 172ed968fb..ac0e0eab86 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -19,28 +19,13 @@ import store from '../store/index.js' * @return {boolean} */ function isEmpty(value) { - return ( - (Array.isArray(value) && value.join('') === '') - || (!Array.isArray(value) && value === '') - ) + return (Array.isArray(value) && value.join('') === '') || (!Array.isArray(value) && value === '') } export const ContactKindProperties = ['KIND', 'X-ADDRESSBOOKSERVER-KIND'] export const MinimalContactProperties = [ - 'EMAIL', - 'UID', - 'TEL', - 'CATEGORIES', - 'FN', - 'ORG', - 'N', - 'X-PHONETIC-FIRST-NAME', - 'X-PHONETIC-LAST-NAME', - 'X-MANAGERSNAME', - 'TITLE', - 'NOTE', - 'RELATED', + 'EMAIL', 'UID', 'TEL', 'CATEGORIES', 'FN', 'ORG', 'N', 'X-PHONETIC-FIRST-NAME', 'X-PHONETIC-LAST-NAME', 'X-MANAGERSNAME', 'TITLE', 'NOTE', 'RELATED', ].concat(ContactKindProperties) export default class Contact { @@ -75,10 +60,7 @@ export default class Contact { // 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, - ) + console.info('This contact did not have a proper uid. Setting a new one for ', this) this.vCard.addPropertyWithValue('uid', uuid()) } @@ -86,19 +68,10 @@ export default class Contact { if (!this.vCard.hasProperty('rev')) { const version = this.vCard.getFirstPropertyValue('version') if (version === '4.0') { - this.vCard.addPropertyWithValue( - 'rev', - ICAL.Time.fromJSDate(new Date(), true), - ) + this.vCard.addPropertyWithValue('rev', ICAL.Time.fromJSDate(new Date(), true)) } if (version === '3.0') { - this.vCard.addPropertyWithValue( - 'rev', - ICAL.VCardTime.fromDateAndOrTimeString( - new Date().toISOString(), - 'date-time', - ), - ) + this.vCard.addPropertyWithValue('rev', ICAL.VCardTime.fromDateAndOrTimeString(new Date().toISOString(), 'date-time')) } } } @@ -304,11 +277,7 @@ export default class Contact { const cleanSvg = await sanitizeSVG(imageSvg) if (!cleanSvg) { - console.error( - 'Invalid SVG for the following contact. Ignoring...', - this.contact, - { photoB64, photoType }, - ) + console.error('Invalid SVG for the following contact. Ignoring...', this.contact, { photoB64, photoType }) return false } } @@ -318,11 +287,7 @@ export default class Contact { const blob = b64toBlob(photoB64Data, `image/${photoType}`) return URL.createObjectURL(blob) } catch { - console.error( - 'Invalid photo for the following contact. Ignoring...', - this.contact, - { photoB64, photoType }, - ) + console.error('Invalid photo for the following contact. Ignoring...', this.contact, { photoB64, photoType }) return false } } @@ -336,8 +301,7 @@ export default class Contact { get groups() { const groupsProp = this.vCard.getFirstProperty('categories') if (groupsProp) { - return groupsProp - .getValues() + return groupsProp.getValues() .filter((group) => typeof group === 'string') .filter((group) => group.trim() !== '') } @@ -376,7 +340,8 @@ export default class Contact { * @memberof Contact */ get kind() { - return this.firstIfArray(ContactKindProperties.map((s) => s.toLowerCase()) + return this.firstIfArray(ContactKindProperties + .map((s) => s.toLowerCase()) .map((s) => this.vCard.getFirstPropertyValue(s)) .flat() .filter((k) => k)) @@ -495,16 +460,16 @@ export default class Contact { if (orderKey && n && !isEmpty(n)) { switch (orderKey) { case 'firstName': - // Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P. - // -> John Stevenson + // 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 + // Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P. + // -> Stevenson, John if (isEmpty(n[0])) { return n[1] } @@ -590,10 +555,8 @@ export default class Contact { */ socialLink(type) { if (this.vCard.hasProperty('x-socialprofile')) { - const x = this.vCard - .getAllProperties('x-socialprofile') - .filter((a) => a.jCal[1].type.toString() === type) - + const x = this.vCard.getAllProperties('x-socialprofile').filter((a) => a.jCal[1].type.toString() === type) + if (x.length > 0) { return x[0].jCal[3].toString() } @@ -661,9 +624,7 @@ export default class Contact { addToGroup(group) { if (this.groups.indexOf(group) === -1) { if (this.groups.length > 0) { - this.vCard - .getFirstProperty('categories') - .setValues(this.groups.concat(group)) + this.vCard.getFirstProperty('categories').setValues(this.groups.concat(group)) } else { this.vCard.updatePropertyWithValue('categories', [group]) } @@ -671,7 +632,7 @@ export default class Contact { } toStringStripQuotes() { - const regexp = /TYPE="([a-zA-Z-,]+)"/gim + const regexp = /TYPE="([a-zA-Z-,]+)"/gmi const card = this.vCard.toString() return card.replace(regexp, 'TYPE=$1') } diff --git a/src/store/contacts.js b/src/store/contacts.js index 29eeff0684..116db7e16f 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -26,20 +26,20 @@ ICAL.design.vcard3.param.type.multiValueSeparateDQuote = true ICAL.design.vcard.param.type.multiValueSeparateDQuote = true function sortData(a, b) { - const nameA - = typeof a.value === 'string' - ? a.value.toUpperCase() // ignore upper and lowercase - : a.value.toUnixTime() // only other sorting we support is a vCardTime - const nameB - = typeof b.value === 'string' - ? b.value.toUpperCase() // ignore upper and lowercase - : b.value.toUnixTime() // only other sorting we support is a vCardTime + const nameA = typeof a.value === 'string' + ? a.value.toUpperCase() // ignore upper and lowercase + : a.value.toUnixTime() // only other sorting we support is a vCardTime + const nameB = typeof b.value === 'string' + ? b.value.toUpperCase() // ignore upper and lowercase + : b.value.toUnixTime() // only other sorting we support is a vCardTime const score = nameA.localeCompare ? nameA.localeCompare(nameB) : nameB - nameA // if equal, fallback to the key - return score !== 0 ? score : a.key.localeCompare(b.key) + return score !== 0 + ? score + : a.key.localeCompare(b.key) } function sortByFavoriteAndName(a, b) { @@ -158,8 +158,7 @@ const mutations = { // favorite comes before non-favorite const differentFavStatus = other.favorite !== sortedContact.favorite const otherShouldComeFirst = differentFavStatus && other.favorite - const sameFavAndSortedFirst - = !differentFavStatus && sortData(other, sortedContact) >= 0 + const sameFavAndSortedFirst = !differentFavStatus && sortData(other, sortedContact) >= 0 if (otherShouldComeFirst || sameFavAndSortedFirst) { continue @@ -191,15 +190,11 @@ const mutations = { */ updateContact(state, contact) { if (state.contacts[contact.key] && contact instanceof Contact) { - const existingFavorite - = state.contacts[contact.key].dav?.favorite || false + const existingFavorite = state.contacts[contact.key].dav?.favorite || false state.contacts[contact.key].updateContact(contact.jCal) // restore favorite on dav if it was lost during the update - if ( - state.contacts[contact.key].dav - && state.contacts[contact.key].dav.favorite === undefined - ) { + if (state.contacts[contact.key].dav && state.contacts[contact.key].dav.favorite === undefined) { state.contacts[contact.key].dav.favorite = existingFavorite } @@ -211,14 +206,11 @@ const mutations = { } const hasValueChanged = sortedContact.value !== contact[state.orderKey] - const hasFavoriteChanged - = sortedContact.favorite - !== (state.contacts[contact.key].dav?.favorite || false) + const hasFavoriteChanged = sortedContact.favorite !== (state.contacts[contact.key].dav?.favorite || false) if (hasValueChanged || hasFavoriteChanged) { sortedContact.value = contact[state.orderKey] - sortedContact.favorite - = state.contacts[contact.key].dav?.favorite || false + sortedContact.favorite = state.contacts[contact.key].dav?.favorite || false state.sortedContacts.sort(sortByFavoriteAndName) } @@ -259,10 +251,7 @@ const mutations = { state.sortedContacts[index].key = newContact.key state.sortedContacts[index].value = newContact[state.orderKey] } else { - console.error( - 'Error while replacing the addressbook of following contact', - contact, - ) + console.error('Error while replacing the addressbook of following contact', contact) } }, @@ -281,10 +270,7 @@ const mutations = { // replace contact object data state.contacts[contact.key].dav.etag = etag } else { - console.error( - 'Error while replacing the etag of following contact', - contact, - ) + console.error('Error while replacing the etag of following contact', contact) } }, @@ -358,6 +344,7 @@ const getters = { } const actions = { + /** * Toggle the favorite state of a contact. * Updates the store @@ -396,10 +383,11 @@ const actions = { async deleteContact(context, { contact, dav = true }) { // only local delete if the contact doesn't exists on the server if (contact.dav && dav) { - await contact.dav.delete().catch((error) => { - console.error(error) - showError(t('contacts', 'Unable to delete contact')) - }) + await contact.dav.delete() + .catch((error) => { + console.error(error) + showError(t('contacts', 'Unable to delete contact')) + }) } context.commit('deleteContact', contact) context.commit('deleteContactFromAddressbook', contact) @@ -434,10 +422,7 @@ const actions = { contact.rev = ICAL.Time.fromJSDate(new Date(), true) } if (contact.version === '3.0') { - contact.rev = ICAL.VCardTime.fromDateAndOrTimeString( - new Date().toISOString(), - 'date-time', - ) + contact.rev = ICAL.VCardTime.fromDateAndOrTimeString(new Date().toISOString(), 'date-time') } const vData = contact.toStringStripQuotes() @@ -464,16 +449,10 @@ const actions = { if (error && error?.status === 412) { // saving the new etag so that the user can manually // trigger a fetchCompleteData without any further errors - context.commit('setContactAsConflict', { - contact, - etag: error.xhr.getResponseHeader('etag'), - }) - console.error( - 'This contact is outdated, the server refused it', - contact, - ) + context.commit('setContactAsConflict', { contact, etag: error.xhr.getResponseHeader('etag') }) + console.error('This contact is outdated, the server refused it', contact) } - throw error + throw (error) } } else { console.error('This contact is outdated, refusing to push', contact) @@ -490,10 +469,7 @@ const actions = { * @param data.forceReFetch * @return {Promise} */ - async fetchFullContact( - context, - { contact, etag = '', forceReFetch = false }, - ) { + async fetchFullContact(context, { contact, etag = '', forceReFetch = false }) { if (etag.trim() !== '') { await context.commit('updateContactEtag', { contact, etag }) } @@ -503,17 +479,14 @@ const actions = { const savedFavorite = davObject.favorite - return davObject - .fetchCompleteData(forceReFetch) + return davObject.fetchCompleteData(forceReFetch) .then(() => { const newContact = new Contact(davObject.data, contact.addressbook) newContact.dav = davObject newContact.dav.favorite = savedFavorite context.commit('updateContact', newContact) }) - .catch((error) => { - throw error - }) + .catch((error) => { throw error }) }, } From ad58efea32f605955b02e83cf87f8d74ee97c090 Mon Sep 17 00:00:00 2001 From: pallavibakale Date: Thu, 11 Jun 2026 11:19:04 -0400 Subject: [PATCH 4/6] fix: fixed linting issues Signed-off-by: pallavibakale --- src/components/ContactDetails.vue | 60 +++++++++---------- .../ContactDetails/ContactDetailsProperty.vue | 4 +- src/mixins/PropertyMixin.js | 2 +- src/models/contact.js | 4 +- src/store/contacts.js | 14 ++--- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index 0518b18d8d..f246c957e5 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -93,8 +93,8 @@ -