Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/components/ContactsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -480,11 +480,18 @@ export default {
},

async finishContactMerging(mergedContact) {
// After merging, we need to update the contact in the store
await this.$store.dispatch('fetchFullContact', { contact: mergedContact, forceReFetch: true })

this.unselectAllMultiSelected()
this.isMerging = false
try {
// After merging, we need to update the contact in the store
await this.$store.dispatch('fetchFullContact', { contact: mergedContact, forceReFetch: true })
} catch (error) {
// The merge itself already succeeded on the server, so we must
// not leave the dialog stuck in a loading state if refreshing
// the merged contact fails.
console.error('Could not refresh the merged contact', error)
} finally {
this.unselectAllMultiSelected()
this.isMerging = false
}

await this.$router.push({
name: 'root',
Expand Down
238 changes: 118 additions & 120 deletions src/components/ContactsList/Merging.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
type="warning"
:text="t('contacts', 'The selected contacts have conflicting information. Choose which information to keep')" />

<NcNoteCard
v-else-if="chosenAddressBook === null"
type="info"
:text="t('contacts', 'Select the address book to merge the contacts into')" />

<NcNoteCard
v-else
type="success"
Expand Down Expand Up @@ -144,7 +149,7 @@
</div>

<div class="merging__actions">
<NcButton :disabled="conflictsToResolve !== 0" variant="secondary" @click="mergeContacts">
<NcButton :disabled="!canMerge || isLoading" variant="secondary" @click="mergeContacts">
{{ t('contacts', 'Merge contacts') }}
<template #icon>
<IconSetMerge v-if="!isLoading" :size="20" />
Expand All @@ -156,6 +161,7 @@
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcNoteCard, NcSelect } from '@nextcloud/vue'
import ICAL from 'ical.js'
import mitt from 'mitt'
Expand All @@ -167,6 +173,7 @@ import IconDomain from 'vue-material-design-icons/Domain.vue'
import IconSetMerge from 'vue-material-design-icons/SetMerge.vue'
import ContactDetailsProperty from '../ContactDetails/ContactDetailsProperty.vue'
import rfcProps from '../../models/rfcProps.js'
import { comparePropertyLists, getPropertyValue, isPropertyListEmpty } from '../../utils/mergeContacts.ts'

export default {
name: 'Merging',
Expand All @@ -193,6 +200,8 @@ export default {
},
},

emits: ['finished'],

data() {
return {
bus: mitt(),
Expand Down Expand Up @@ -246,65 +255,52 @@ export default {
usedProperties() {
const allKeys = this.dividedProperties.flatMap((map) => Object.keys(map))
return [...new Set(allKeys)].filter((key) => {
const valA = this.dividedProperties[0][key] ? this.dividedProperties[0][key].map((value) => this.getPropertyValue(value)) : []
const valB = this.dividedProperties[1][key] ? this.dividedProperties[1][key].map((value) => this.getPropertyValue(value)) : []
return (
(!valA.every((val) => val === null || val === undefined || val === ''))
|| (!valB.every((val) => val === null || val === undefined || val === ''))
)
// Only consider a property "used" if at least one of the contacts
// holds a non-empty value for it. This correctly handles structured
// and multi-value properties (e.g. an empty "ADR:;;;;;;" or an empty
// "CATEGORIES:") which would otherwise show up as phantom rows.
const isAEmpty = isPropertyListEmpty(this.dividedProperties[0][key])
const isBEmpty = isPropertyListEmpty(this.dividedProperties[1][key])
return !(isAEmpty && isBEmpty)
})
},

conflictInformation() {
const conflictInformation = {}

this.usedProperties.forEach((property) => {
if ((this.dividedProperties[0][property] ?? []).every((val) => this.checkIfPropertyEmpty(val))) {
const type = comparePropertyLists(
this.dividedProperties[0][property],
this.dividedProperties[1][property],
)

if (type === 'onlyInSecond') {
conflictInformation[property] = {
type: 'onlyInSecond',
value: this.dividedProperties[1][property].map((val) => this.getPropertyValue(val)),
type,
value: this.dividedProperties[1][property].map((val) => getPropertyValue(val)),
}

return
}

if ((this.dividedProperties[1][property] ?? []).every((val) => this.checkIfPropertyEmpty(val))) {
} else if (type === 'onlyInFirst') {
conflictInformation[property] = {
type: 'onlyInFirst',
value: this.dividedProperties[0][property].map((val) => this.getPropertyValue(val)),
type,
value: this.dividedProperties[0][property].map((val) => getPropertyValue(val)),
}

return
}

const equalEvery = (a, b) => a.length === b.length && a.every((v, i) => v === b[i])

if (
equalEvery(
(this.dividedProperties[0][property] ?? []).map((val) => this.getPropertyValue(val)),
(this.dividedProperties[1][property] ?? []).map((val) => this.getPropertyValue(val)),
)
) {
} else if (type === 'equal') {
conflictInformation[property] = {
type: 'equal',
value: this.dividedProperties[0][property].map((val) => this.getPropertyValue(val)),
type,
value: this.dividedProperties[0][property].map((val) => getPropertyValue(val)),
}

return
}

if (rfcProps.properties[property]?.multiple === true) {
} else if (rfcProps.properties[property]?.multiple === true) {
// A property that can hold several values (e.g. tel, email):
// let the user pick any combination of the two sides.
conflictInformation[property] = {
type: 'conflictWithMultipleValues',
value: null,
}

return
}

conflictInformation[property] = {
type: 'conflict',
value: null,
} else {
conflictInformation[property] = {
type: 'conflict',
value: null,
}
}
})

Expand All @@ -326,6 +322,16 @@ export default {
],
}
},

/**
* Whether the contacts can be merged: all field conflicts are resolved
* and a target address book has been chosen.
*
* @return {boolean}
*/
canMerge() {
return this.conflictsToResolve === 0 && this.chosenAddressBook !== null
},
},

mounted() {
Expand Down Expand Up @@ -451,44 +457,9 @@ export default {
}
})

if (this.chosenAddressBook === null) {
conflictsCount++
}

this.conflictsToResolve = conflictsCount
},

getPropertyValue(property) {
if (!property) {
return null
}
if (property.isMultiValue) {
// differences between values types :x;x;x;x;x and x,x,x,x,x
return property.isStructuredValue
? property.getValues()[0]
: property.getValues()
}
return property.getFirstValue()
},

checkIfPropertyEmpty(property) {
if (property === undefined) {
return true
}

const value = this.getPropertyValue(property)

if (value === '' || value === null || (Array.isArray(value) && value.length === 0)) {
return true
}

if (Array.isArray(value)) {
return value.every((v) => v === '' || v === undefined)
}

return false
},

sortUsedProperties() {
// the properties where this.conflictInformation[property].type === 'conflict' should have priority
return this.usedProperties.sort((a, b) => {
Expand All @@ -506,60 +477,87 @@ export default {
},

async mergeContacts() {
if (this.isLoading || !this.canMerge) {
return
}

this.isLoading = true
const contactToSave = this.contactsList[0]

const finalProperties = {}
try {
const contactToSave = this.contactsList[0]

this.usedProperties.forEach((property) => {
if (this.conflictInformation[property]?.type === 'conflict') {
const resolvedVersion = this.resolvedConflicts.get(property)
if (resolvedVersion !== undefined) {
finalProperties[property] = [this.dividedProperties[resolvedVersion][property]]
const finalProperties = {}

this.usedProperties.forEach((property) => {
if (this.conflictInformation[property]?.type === 'conflict') {
const resolvedVersion = this.resolvedConflicts.get(property)
if (resolvedVersion !== undefined) {
finalProperties[property] = [this.dividedProperties[resolvedVersion][property]]
}
} else if (this.conflictInformation[property]?.type === 'conflictWithMultipleValues') {
const resolvedVersions = this.resolvedConflicts.get(property)
if (resolvedVersions?.size) {
finalProperties[property] = Array.from(resolvedVersions).map((version) => this.dividedProperties[version][property])
}
} else if (this.conflictInformation[property]?.type === 'onlyInSecond') {
finalProperties[property] = [this.dividedProperties[1][property]]
} else {
finalProperties[property] = [this.dividedProperties[0][property]]
}
} else if (this.conflictInformation[property]?.type === 'conflictWithMultipleValues') {
const resolvedVersions = this.resolvedConflicts.get(property)
if (resolvedVersions?.size) {
finalProperties[property] = Array.from(resolvedVersions).map((version) => this.dividedProperties[version][property])
})

this.usedProperties.forEach((name) => {
if (finalProperties[name] !== undefined && finalProperties[name].length > 0) {
const properties = finalProperties[name].flat().filter((property) => property !== null && property !== undefined)

properties.forEach((property) => {
// Get the actual property name (for lifeEvents group, properties have their real names like 'bday')
const actualName = property.name

// Remove any existing property with this name from the target vCard
const existingProps = contactToSave.vCard.getAllProperties(actualName)
existingProps.forEach((prop) => contactToSave.vCard.removeProperty(prop))
})

// Now add all the selected properties
properties.forEach((property) => {
// Deep clone the jCal data to avoid reference issues with complex types like dates
const clonedJCal = JSON.parse(JSON.stringify(property.jCal))
const clonedProperty = new ICAL.Property(clonedJCal)
contactToSave.vCard.addProperty(clonedProperty)
})
}
} else if (this.conflictInformation[property]?.type === 'onlyInSecond') {
finalProperties[property] = [this.dividedProperties[1][property]]
} else {
finalProperties[property] = [this.dividedProperties[0][property]]
}
})
})

this.usedProperties.forEach((name) => {
if (finalProperties[name] !== undefined && finalProperties[name].length > 0) {
const properties = finalProperties[name].flat().filter((property) => property !== null && property !== undefined)
contactToSave.groups = this.selectedGroups

properties.forEach((property) => {
// Get the actual property name (for lifeEvents group, properties have their real names like 'bday')
const actualName = property.name
const targetAddressbook = this.contactsList[this.chosenAddressBook.id].addressbook

// Remove any existing property with this name from the target vCard
const existingProps = contactToSave.vCard.getAllProperties(actualName)
existingProps.forEach((prop) => contactToSave.vCard.removeProperty(prop))
})
// Persist the merged data on the surviving contact. It still lives
// in its original address book at this point.
await this.$store.dispatch('updateContact', contactToSave)

// Remove the contact that was merged into the surviving one.
await this.$store.dispatch('deleteContact', { contact: this.contactsList[1] })

// Now add all the selected properties
properties.forEach((property) => {
// Deep clone the jCal data to avoid reference issues with complex types like dates
const clonedJCal = JSON.parse(JSON.stringify(property.jCal))
const clonedProperty = new ICAL.Property(clonedJCal)
contactToSave.vCard.addProperty(clonedProperty)
// If the user chose a different address book, perform a proper
// move so the contact is actually relocated on the server and the
// store stays consistent (a plain addressbook reassignment would
// only change the key and silently desync the store).
if (contactToSave.addressbook.id !== targetAddressbook.id) {
await this.$store.dispatch('moveContactToAddressbook', {
contact: contactToSave,
addressbook: targetAddressbook,
})
}
})

contactToSave.groups = this.selectedGroups
contactToSave.addressbook = this.contactsList[this.chosenAddressBook.id].addressbook

await this.$store.dispatch('updateContact', contactToSave)

await this.$store.dispatch('deleteContact', { contact: this.contactsList[1] })

this.$emit('finished', contactToSave)
this.$emit('finished', contactToSave)
} catch (error) {
console.error('Could not merge the contacts', error)
showError(this.t('contacts', 'Could not merge the contacts'))
} finally {
this.isLoading = false
}
},
},
}
Expand Down
Loading
Loading