Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f0ed0c4
fix(contacts): include REV in MinimalContactProperties for accurate l…
v3DJG6GL Mar 12, 2026
993fb84
fix(contacts): add extractSortValue helper to convert ICAL.Time to pr…
v3DJG6GL Mar 12, 2026
feff36f
fix(contacts): rewrite sortData to compare primitives with null-safety
v3DJG6GL Mar 12, 2026
8c42520
fix(contacts): use extractSortValue in addContact to store primitives
v3DJG6GL Mar 12, 2026
4a5c22e
fix(contacts): use extractSortValue in sortContacts to store primitives
v3DJG6GL Mar 12, 2026
8d5ba78
fix(contacts): use extractSortValue in updateContact with simplified …
v3DJG6GL Mar 12, 2026
06244a9
fix(contacts): use extractSortValue in updateContactAddressbook to st…
v3DJG6GL Mar 12, 2026
3f3a951
fix(contacts): inject existing REV into vCard string before parsing t…
v3DJG6GL Mar 12, 2026
457e7a8
fix(contacts): fix lint errors in contacts store (import order, eqeqe…
v3DJG6GL Mar 12, 2026
bec8020
fix(contacts): bypass ical.js REV parsing in extractSortValue to hand…
v3DJG6GL Mar 12, 2026
101c461
fix(contacts): add try-catch to rev getter for UI safety with malform…
v3DJG6GL Mar 12, 2026
33cb607
fix(contacts): skip re-sort after fetchFullContact and improve REV no…
v3DJG6GL Mar 12, 2026
8af0083
fix(contacts): remove fake REV=now from constructor
v3DJG6GL Mar 12, 2026
9662a66
fix(contacts): disable invalidREV check to prevent fake timestamps
v3DJG6GL Mar 12, 2026
286ff1a
fix(contacts): use virtua align:nearest to prevent scroll jumps
v3DJG6GL Mar 12, 2026
de9a85e
fix(contacts): fix lint error
v3DJG6GL Mar 12, 2026
6110d9c
refactor(contacts): move REV timestamp extraction onto Contact model
v3DJG6GL Apr 20, 2026
ad01118
refactor(contacts): normalize updateContact mutation payload to {cont…
v3DJG6GL Apr 20, 2026
d076903
fix(contacts): remove now-unused invalidREV check
v3DJG6GL Apr 20, 2026
1e5e15c
docs(contacts): explain skipSort in fetchFullContact
v3DJG6GL Apr 20, 2026
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
19 changes: 1 addition & 18 deletions src/components/ContactsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
},

/**
Expand Down
56 changes: 42 additions & 14 deletions src/models/contact.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 {
Expand Down Expand Up @@ -63,17 +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())
}

// 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() {
Expand Down Expand Up @@ -178,7 +167,11 @@ export default class Contact {
* @memberof Contact
*/
get rev() {
return this.vCard.getFirstPropertyValue('rev')
try {
return this.vCard.getFirstPropertyValue('rev')
} catch {
return null
}
}

/**
Expand All @@ -191,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
*
Expand Down
2 changes: 0 additions & 2 deletions src/services/checks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
43 changes: 0 additions & 43 deletions src/services/checks/invalidREV.js

This file was deleted.

92 changes: 62 additions & 30 deletions src/store/contacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

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'

Expand All @@ -26,20 +27,43 @@ 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) {
if (orderKey === 'rev') {
return contact.revTimestamp
}
const val = contact[orderKey]
if (val === null || val === undefined) {
return null
}
if (typeof val === 'string') {
return val
}
// ical.js methods don't work through Vue's reactive proxy; unwrap first.
try {
return toRaw(val).toUnixTime()
} catch {
return null
}
}

const state = {
Expand Down Expand Up @@ -99,7 +123,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
Expand Down Expand Up @@ -130,21 +154,25 @@ 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, contact) {
updateContact(state, { contact, 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 hasChanged = sortedContact.value !== contact[state.orderKey]
if (hasChanged) {
// then update the new data
sortedContact.value = contact[state.orderKey]
// 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)
Expand Down Expand Up @@ -181,7 +209,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)
}
Expand Down Expand Up @@ -217,7 +245,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)
},

Expand Down Expand Up @@ -343,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)

Expand Down Expand Up @@ -377,8 +405,12 @@ const actions = {
}
return contact.dav.fetchCompleteData(forceReFetch)
.then(() => {
const newContact = new Contact(contact.dav.data, contact.addressbook)
context.commit('updateContact', newContact)
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 })
},
Expand Down
Loading