From f0ed0c46beb31a80e83b80a26822ffb91513414a Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:16:54 +0100 Subject: [PATCH 01/20] fix(contacts): include REV in MinimalContactProperties for accurate last-modified sorting Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/models/contact.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/contact.js b/src/models/contact.js index c2af2fe803..e1a28e73dc 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -25,7 +25,7 @@ function isEmpty(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', 'REV', ].concat(ContactKindProperties) export default class Contact { From 993fb8493c37f35c52e75e2cb5039aeeefcd77f2 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:17:57 +0100 Subject: [PATCH 02/20] fix(contacts): add extractSortValue helper to convert ICAL.Time to primitives at storage time Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/store/contacts.js b/src/store/contacts.js index 998ce71da0..bac974379b 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { toRaw } from 'vue' import { showError } from '@nextcloud/dialogs' import ICAL from 'ical.js' import Contact from '../models/contact.js' @@ -42,6 +43,19 @@ function sortData(a, b) { : a.key.localeCompare(b.key) } +function extractSortValue(contact, orderKey) { + const val = contact[orderKey] + if (val == null) return null + if (typeof val === 'string') return val + // ICAL.Time / VCardTime → unix timestamp (number) + // toRaw() unwraps Vue Proxy before calling ical.js methods + try { + return toRaw(val).toUnixTime() + } catch { + return null + } +} + const state = { // Using objects for performance // https://codepen.io/skjnldsv/pen/ZmKvQo From feff36f8d0b35bbb294a97e23b9c78312496a07a Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:18:15 +0100 Subject: [PATCH 03/20] fix(contacts): rewrite sortData to compare primitives with null-safety Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index bac974379b..f2bbe15174 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -27,20 +27,18 @@ 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 + const nameA = typeof a.value === 'string' ? a.value.toUpperCase() : a.value + const nameB = typeof b.value === 'string' ? b.value.toUpperCase() : b.value + + // Push null/undefined values to the end + if (nameA == null && nameB == null) return a.key.localeCompare(b.key) + if (nameA == null) return 1 + if (nameB == null) return -1 + + const score = typeof nameA === 'string' ? nameA.localeCompare(nameB) - : nameB - nameA - // if equal, fallback to the key - return score !== 0 - ? score - : a.key.localeCompare(b.key) + : nameB - nameA // descending: newest first + return score !== 0 ? score : a.key.localeCompare(b.key) } function extractSortValue(contact, orderKey) { From 8c42520556fa8b7f76e73f3d8971b6b9591c440a Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:18:37 +0100 Subject: [PATCH 04/20] fix(contacts): use extractSortValue in addContact to store primitives Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index f2bbe15174..1de39eaffa 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -111,7 +111,7 @@ const mutations = { const sortedContact = { key: contact.key, - value: contact[state.orderKey], + value: extractSortValue(contact, state.orderKey), } // Not using sort, splice has far better performances From 4a5c22ee21bb09c0217607971aaea2038945983a Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:18:59 +0100 Subject: [PATCH 05/20] fix(contacts): use extractSortValue in sortContacts to store primitives Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index 1de39eaffa..5781999a4d 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -229,7 +229,7 @@ const mutations = { state.sortedContacts = Object.values(state.contacts) // exclude groups .filter((contact) => contact.kind !== 'group') - .map((contact) => { return { key: contact.key, value: contact[state.orderKey] } }) + .map((contact) => { return { key: contact.key, value: extractSortValue(contact, state.orderKey) } }) .sort(sortData) }, From 8d5ba785857feb0283ef2da4e27c54a9ce94dee2 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:19 +0100 Subject: [PATCH 06/20] fix(contacts): use extractSortValue in updateContact with simplified change detection Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index 5781999a4d..07486f89ba 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -151,10 +151,10 @@ const mutations = { const sortedContact = state.sortedContacts.find((search) => search.key === contact.key) // has the sort key changed for this contact ? - const hasChanged = sortedContact.value !== contact[state.orderKey] - if (hasChanged) { + const newValue = extractSortValue(contact, state.orderKey) + if (sortedContact.value !== newValue) { // then update the new data - sortedContact.value = contact[state.orderKey] + sortedContact.value = newValue // and then we sort again state.sortedContacts.sort(sortData) } From 06244a998712933c4157457218846a3c1657cd8e Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:37 +0100 Subject: [PATCH 07/20] fix(contacts): use extractSortValue in updateContactAddressbook to store primitives Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index 07486f89ba..c31ddbd4a4 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -193,7 +193,7 @@ const mutations = { // 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] + state.sortedContacts[index].value = extractSortValue(newContact, state.orderKey) } else { console.error('Error while replacing the addressbook of following contact', contact) } From 3f3a9514f2af95af76f4f852f1426792fdcbe9a4 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:20:15 +0100 Subject: [PATCH 08/20] fix(contacts): inject existing REV into vCard string before parsing to prevent fake timestamps Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index c31ddbd4a4..2442afd6f1 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -389,7 +389,25 @@ const actions = { } return contact.dav.fetchCompleteData(forceReFetch) .then(() => { - const newContact = new Contact(contact.dav.data, contact.addressbook) + let vcardData = contact.dav.data + + // If server vCard has no REV, inject the existing contact's REV + // to prevent the constructor from generating a fake REV=NOW + if (!/^REV[;:]/im.test(vcardData)) { + const existing = context.state.contacts[contact.key] + if (existing) { + const revProp = toRaw(existing).vCard.getFirstProperty('rev') + if (revProp) { + const revLine = revProp.toICALString() + vcardData = vcardData.replace( + /(\r?\n)(END:VCARD)/i, + '$1' + revLine + '$1$2', + ) + } + } + } + + const newContact = new Contact(vcardData, contact.addressbook) context.commit('updateContact', newContact) }) .catch((error) => { throw error }) From 457e7a851be5b8e5f16e9f97c586d34740704e28 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:26:17 +0100 Subject: [PATCH 09/20] fix(contacts): fix lint errors in contacts store (import order, eqeqeq, curly braces) Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index 2442afd6f1..bb538547e6 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { toRaw } from 'vue' import { showError } from '@nextcloud/dialogs' import ICAL from 'ical.js' +import { toRaw } from 'vue' import Contact from '../models/contact.js' import validate from '../services/validate.js' @@ -31,9 +31,15 @@ function sortData(a, b) { const nameB = typeof b.value === 'string' ? b.value.toUpperCase() : b.value // Push null/undefined values to the end - if (nameA == null && nameB == null) return a.key.localeCompare(b.key) - if (nameA == null) return 1 - if (nameB == null) return -1 + if (nameA === null && nameB === null) { + return a.key.localeCompare(b.key) + } + if (nameA === null) { + return 1 + } + if (nameB === null) { + return -1 + } const score = typeof nameA === 'string' ? nameA.localeCompare(nameB) @@ -43,8 +49,12 @@ function sortData(a, b) { function extractSortValue(contact, orderKey) { const val = contact[orderKey] - if (val == null) return null - if (typeof val === 'string') return val + if (val === null || val === undefined) { + return null + } + if (typeof val === 'string') { + return val + } // ICAL.Time / VCardTime → unix timestamp (number) // toRaw() unwraps Vue Proxy before calling ical.js methods try { From bec802088c19be79d6364a4f46402366ba812db7 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:39:03 +0100 Subject: [PATCH 10/20] fix(contacts): bypass ical.js REV parsing in extractSortValue to handle malformed dates Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/store/contacts.js b/src/store/contacts.js index bb538547e6..f34b0d9ca6 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -47,7 +47,37 @@ function sortData(a, b) { return score !== 0 ? score : a.key.localeCompare(b.key) } +function extractRevTimestamp(contact) { + try { + const prop = toRaw(contact).vCard.getFirstProperty('rev') + if (!prop) { + return null + } + const raw = prop.jCal[3] + if (!raw || typeof raw !== 'string') { + return null + } + // Normalize basic ISO format: "20260312T192500Z" → "2026-03-12T19:25:00Z" + let s = raw + if (/^\d{8}T/.test(s)) { + s = s.replace(/^(\d{4})(\d{2})(\d{2})T/, '$1-$2-$3T') + } + if (/T\d{6}/.test(s)) { + s = s.replace(/T(\d{2})(\d{2})(\d{2})/, 'T$1:$2:$3') + } + // Fix missing seconds: "T19:25:Z" → "T19:25:00Z" + s = s.replace(/:Z$/, ':00Z') + const ts = Date.parse(s) + return isNaN(ts) ? null : Math.floor(ts / 1000) + } catch { + return null + } +} + function extractSortValue(contact, orderKey) { + if (orderKey === 'rev') { + return extractRevTimestamp(contact) + } const val = contact[orderKey] if (val === null || val === undefined) { return null From 101c46187468a851c3a3a5896d60bf55b1755781 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:39:13 +0100 Subject: [PATCH 11/20] fix(contacts): add try-catch to rev getter for UI safety with malformed dates Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/models/contact.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/models/contact.js b/src/models/contact.js index e1a28e73dc..9bc9ab37a2 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -178,7 +178,11 @@ export default class Contact { * @memberof Contact */ get rev() { - return this.vCard.getFirstPropertyValue('rev') + try { + return this.vCard.getFirstPropertyValue('rev') + } catch { + return null + } } /** From 33cb6075e43b5a4f967c21de83ee011ce9d4b6e9 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:30:29 +0100 Subject: [PATCH 12/20] fix(contacts): skip re-sort after fetchFullContact and improve REV normalization Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 52 +++++++++++++++++-------------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index f34b0d9ca6..a125c1bbc2 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -64,9 +64,12 @@ function extractRevTimestamp(contact) { } if (/T\d{6}/.test(s)) { s = s.replace(/T(\d{2})(\d{2})(\d{2})/, 'T$1:$2:$3') + } else if (/T\d{4}[Z+-]/.test(s)) { + // Basic time without seconds: "T1925Z" → "T19:25:00Z" + s = s.replace(/T(\d{2})(\d{2})([Z+-])/, 'T$1:$2:00$3') } - // Fix missing seconds: "T19:25:Z" → "T19:25:00Z" - s = s.replace(/:Z$/, ':00Z') + // Fix missing seconds: "T19:25:Z" or "T19:25Z" → "T19:25:00Z" + s = s.replace(/T(\d{2}:\d{2}):?Z$/, 'T$1:00Z') const ts = Date.parse(s) return isNaN(ts) ? null : Math.floor(ts / 1000) } catch { @@ -184,19 +187,23 @@ const mutations = { * @param {object} state the store data * @param {Contact} contact the contact to update */ - updateContact(state, contact) { + updateContact(state, payload) { + const contact = payload instanceof Contact ? payload : payload.contact + const skipSort = payload instanceof Contact ? false : (payload.skipSort ?? false) if (state.contacts[contact.key] && contact instanceof Contact) { // replace contact object data state.contacts[contact.key].updateContact(contact.jCal) - const sortedContact = state.sortedContacts.find((search) => search.key === contact.key) - - // has the sort key changed for this contact ? - const newValue = extractSortValue(contact, state.orderKey) - if (sortedContact.value !== newValue) { - // then update the new data - sortedContact.value = newValue - // and then we sort again - state.sortedContacts.sort(sortData) + if (!skipSort) { + const sortedContact = state.sortedContacts.find((search) => search.key === contact.key) + + // has the sort key changed for this contact ? + const newValue = extractSortValue(contact, state.orderKey) + if (sortedContact.value !== newValue) { + // then update the new data + sortedContact.value = newValue + // and then we sort again + state.sortedContacts.sort(sortData) + } } } else { console.error('Error while replacing the following contact', contact) @@ -429,26 +436,9 @@ const actions = { } return contact.dav.fetchCompleteData(forceReFetch) .then(() => { - let vcardData = contact.dav.data - - // If server vCard has no REV, inject the existing contact's REV - // to prevent the constructor from generating a fake REV=NOW - if (!/^REV[;:]/im.test(vcardData)) { - const existing = context.state.contacts[contact.key] - if (existing) { - const revProp = toRaw(existing).vCard.getFirstProperty('rev') - if (revProp) { - const revLine = revProp.toICALString() - vcardData = vcardData.replace( - /(\r?\n)(END:VCARD)/i, - '$1' + revLine + '$1$2', - ) - } - } - } - + const vcardData = contact.dav.data const newContact = new Contact(vcardData, contact.addressbook) - context.commit('updateContact', newContact) + context.commit('updateContact', { contact: newContact, skipSort: true }) }) .catch((error) => { throw error }) }, From 8af0083c25b5ed5ab32b0fe175680ea1826ec388 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:31:13 +0100 Subject: [PATCH 13/20] fix(contacts): remove fake REV=now from constructor Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/models/contact.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/models/contact.js b/src/models/contact.js index 9bc9ab37a2..d2dd8579a7 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -64,16 +64,6 @@ export default class Contact { 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() { From 9662a6696565318f78f5246f1dcff6d8126780ba Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:31:41 +0100 Subject: [PATCH 14/20] fix(contacts): disable invalidREV check to prevent fake timestamps Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/services/checks/invalidREV.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/services/checks/invalidREV.js b/src/services/checks/invalidREV.js index 27d1e8dc58..7ca0d58f0b 100644 --- a/src/services/checks/invalidREV.js +++ b/src/services/checks/invalidREV.js @@ -12,12 +12,7 @@ export default { silent: true, run: (contact) => { - try { - const hasRev = contact.vCard.hasProperty('rev') - return !hasRev - } catch (error) { - return true - } + return false }, fix: (contact) => { From 286ff1aa09bd2a6a0ca1849e96b5c395fecb6922 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:32:13 +0100 Subject: [PATCH 15/20] fix(contacts): use virtua align:nearest to prevent scroll jumps Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/components/ContactsList.vue | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/src/components/ContactsList.vue b/src/components/ContactsList.vue index bf43f2258a..b4b7922bb5 100644 --- a/src/components/ContactsList.vue +++ b/src/components/ContactsList.vue @@ -363,24 +363,7 @@ export default { if (index === -1) { return } - - const scroller = this.$refs.scroller - const scrollerBoundingRect = scroller.$el.getBoundingClientRect() - const item = this.$el.querySelector('#' + key.slice(0, -2)) - const itemBoundingRect = item?.getBoundingClientRect() - - // Try to scroll the item fully into view - if (!item || itemBoundingRect.y < scrollerBoundingRect.y) { - // Item is above the current scroll window (or partly overlapping) - scroller.scrollToIndex(index) - } else if (item) { - const itemHeight = scroller.getItemSize(index) - const pos = itemBoundingRect.y + itemHeight - (this.$el.offsetHeight + 50) - if (pos > 0) { - // Item is below the current scroll window (or partly overlapping) - scroller.scrollTo(scroller.scrollOffset + pos) - } - } + this.$refs.scroller.scrollToIndex(index, { align: 'nearest' }) }, /** From de9a85ebaab8120dddc376e059174d311ef4452b Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:41:00 +0100 Subject: [PATCH 16/20] fix(contacts): fix lint error Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/models/contact.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/models/contact.js b/src/models/contact.js index d2dd8579a7..0f6ff6ade7 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -63,7 +63,6 @@ export default class Contact { console.info('This contact did not have a proper uid. Setting a new one for ', this) this.vCard.addPropertyWithValue('uid', uuid()) } - } get vCard() { From 6110d9ca24e8afb14be6ebf6f78f24c2ba469f44 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:11:08 +0200 Subject: [PATCH 17/20] refactor(contacts): move REV timestamp extraction onto Contact model Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/models/contact.js | 37 ++++++++++++++++++++++++++++++++++++- src/store/contacts.js | 35 ++--------------------------------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/models/contact.js b/src/models/contact.js index 0f6ff6ade7..af9ea61632 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -8,7 +8,7 @@ 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 { shallowRef, toRaw, unref } from 'vue' import updateDesignSet from '../services/updateDesignSet.js' import store from '../store/index.js' @@ -184,6 +184,41 @@ export default class Contact { this.vCard.updatePropertyWithValue('rev', rev) } + /** + * REV as a unix timestamp (seconds), or null if missing/unparseable. + * Bypasses ICAL.Time parsing so malformed-but-salvageable basic-ISO + * strings ("20260312T192500Z", "T1925Z", trailing "T19:25Z") still sort. + * + * @readonly + * @memberof Contact + */ + get revTimestamp() { + try { + const prop = toRaw(this).vCard.getFirstProperty('rev') + if (!prop) { + return null + } + const raw = prop.jCal[3] + if (!raw || typeof raw !== 'string') { + return null + } + let s = raw + if (/^\d{8}T/.test(s)) { + s = s.replace(/^(\d{4})(\d{2})(\d{2})T/, '$1-$2-$3T') + } + if (/T\d{6}/.test(s)) { + s = s.replace(/T(\d{2})(\d{2})(\d{2})/, 'T$1:$2:$3') + } else if (/T\d{4}[Z+-]/.test(s)) { + s = s.replace(/T(\d{2})(\d{2})([Z+-])/, 'T$1:$2:00$3') + } + s = s.replace(/T(\d{2}:\d{2}):?Z$/, 'T$1:00Z') + const ts = Date.parse(s) + return isNaN(ts) ? null : Math.floor(ts / 1000) + } catch { + return null + } + } + /** * Return the key * diff --git a/src/store/contacts.js b/src/store/contacts.js index a125c1bbc2..431e524c0a 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -47,39 +47,9 @@ function sortData(a, b) { return score !== 0 ? score : a.key.localeCompare(b.key) } -function extractRevTimestamp(contact) { - try { - const prop = toRaw(contact).vCard.getFirstProperty('rev') - if (!prop) { - return null - } - const raw = prop.jCal[3] - if (!raw || typeof raw !== 'string') { - return null - } - // Normalize basic ISO format: "20260312T192500Z" → "2026-03-12T19:25:00Z" - let s = raw - if (/^\d{8}T/.test(s)) { - s = s.replace(/^(\d{4})(\d{2})(\d{2})T/, '$1-$2-$3T') - } - if (/T\d{6}/.test(s)) { - s = s.replace(/T(\d{2})(\d{2})(\d{2})/, 'T$1:$2:$3') - } else if (/T\d{4}[Z+-]/.test(s)) { - // Basic time without seconds: "T1925Z" → "T19:25:00Z" - s = s.replace(/T(\d{2})(\d{2})([Z+-])/, 'T$1:$2:00$3') - } - // Fix missing seconds: "T19:25:Z" or "T19:25Z" → "T19:25:00Z" - s = s.replace(/T(\d{2}:\d{2}):?Z$/, 'T$1:00Z') - const ts = Date.parse(s) - return isNaN(ts) ? null : Math.floor(ts / 1000) - } catch { - return null - } -} - function extractSortValue(contact, orderKey) { if (orderKey === 'rev') { - return extractRevTimestamp(contact) + return contact.revTimestamp } const val = contact[orderKey] if (val === null || val === undefined) { @@ -88,8 +58,7 @@ function extractSortValue(contact, orderKey) { if (typeof val === 'string') { return val } - // ICAL.Time / VCardTime → unix timestamp (number) - // toRaw() unwraps Vue Proxy before calling ical.js methods + // ical.js methods don't work through Vue's reactive proxy; unwrap first. try { return toRaw(val).toUnixTime() } catch { From ad0111816faef6508a0b6fa5c483590b3b458ab7 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:15:27 +0200 Subject: [PATCH 18/20] refactor(contacts): normalize updateContact mutation payload to {contact, skipSort?} Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/store/contacts.js b/src/store/contacts.js index 431e524c0a..a59543dc35 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -154,11 +154,11 @@ const mutations = { * Update a contact * * @param {object} state the store data - * @param {Contact} contact the contact to update + * @param {object} payload destructuring object + * @param {Contact} payload.contact the contact to update + * @param {boolean} [payload.skipSort] skip the re-sort after updating (default false) */ - updateContact(state, payload) { - const contact = payload instanceof Contact ? payload : payload.contact - const skipSort = payload instanceof Contact ? false : (payload.skipSort ?? false) + updateContact(state, { contact, skipSort = false }) { if (state.contacts[contact.key] && contact instanceof Contact) { // replace contact object data state.contacts[contact.key].updateContact(contact.jCal) @@ -371,7 +371,7 @@ const actions = { try { await contact.dav.update() // all clear, let's update the store - context.commit('updateContact', contact) + context.commit('updateContact', { contact }) } catch (error) { console.error(error) From d076903434b6985520386d4d7996782c1ea6ab21 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:17:33 +0200 Subject: [PATCH 19/20] fix(contacts): remove now-unused invalidREV check Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/services/checks/index.js | 2 -- src/services/checks/invalidREV.js | 38 ------------------------------- 2 files changed, 40 deletions(-) delete mode 100644 src/services/checks/invalidREV.js diff --git a/src/services/checks/index.js b/src/services/checks/index.js index f7a887d5dc..8f91e31e17 100644 --- a/src/services/checks/index.js +++ b/src/services/checks/index.js @@ -5,12 +5,10 @@ import badGenderType from './badGenderType.js' import duplicateTypes from './duplicateTypes.js' -import invalidREV from './invalidREV.js' import missingFN from './missingFN.js' export default [ badGenderType, duplicateTypes, - invalidREV, missingFN, ] diff --git a/src/services/checks/invalidREV.js b/src/services/checks/invalidREV.js deleted file mode 100644 index 7ca0d58f0b..0000000000 --- a/src/services/checks/invalidREV.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import ICAL from 'ical.js' - -// https://tools.ietf.org/html/rfc6350#section-6.7.4 - -export default { - name: 'invalid REV', - silent: true, - - run: (contact) => { - return false - }, - - fix: (contact) => { - try { - // removing old invalid data - contact.vCard.removeProperty('rev') - - // creating new value - const version = contact.version - if (version === '4.0') { - contact.vCard.addPropertyWithValue('rev', ICAL.Time.fromJSDate(new Date(), true)) - } - if (version === '3.0') { - contact.vCard.addPropertyWithValue('rev', ICAL.VCardTime.fromDateAndOrTimeString(new Date().toISOString(), 'date-time')) - } - - return true - } catch (error) { - console.error('Error fixing invalid REV:', error) - return false - } - }, -} From 1e5e15c62a3d1e70a0bb4807707550d2412a80b6 Mon Sep 17 00:00:00 2001 From: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:18:24 +0200 Subject: [PATCH 20/20] docs(contacts): explain skipSort in fetchFullContact Signed-off-by: v3DJG6GL <72495210+v3DJG6GL@users.noreply.github.com> --- src/store/contacts.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/store/contacts.js b/src/store/contacts.js index a59543dc35..d7a66c61a2 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -407,6 +407,9 @@ const actions = { .then(() => { const vcardData = contact.dav.data const newContact = new Contact(vcardData, contact.addressbook) + // skipSort: opening a contact must not visibly reorder the list. + // The server's REV rarely differs from the cached one here; if it + // does, the next mutation will re-sort. context.commit('updateContact', { contact: newContact, skipSort: true }) }) .catch((error) => { throw error })