ContactRepository.swift
340 lignes · 12581 octets
import Foundation struct VcfImportResult: Equatable { var imported: Int var skippedDuplicates: Int var errors: Int } /// Image-file abstraction for the repository (Android used `ContactImageStore` /// with `Bitmap`s; the iOS implementation lives with the Scan module owner). /// Bitmap-saving paths were replaced by pre-saved file paths in the API below. protocol ContactImageStoring { /// Copies (re-encoding if needed) an existing image into the target contact's /// storage. Returns the new path, or nil when the source is missing/unreadable. func copyImage(fromPath: String?, toContactId: Int64, name: String) -> String? /// Deletes every stored image of the contact. func deleteAll(contactId: Int64) } /// Sync payload mapping (Android `ContactSyncMapper`; the iOS implementation /// lives in the Sync module and should conform to this protocol). protocol ContactSyncPayloadMapping { func toCreatePayload(_ entity: CrmContactEntity) -> String func toUpdatePayload(_ entity: CrmContactEntity) -> String } final class ContactRepository { private let dao: CrmContactDao private let images: ContactImageStoring? private let syncOpDao: SyncOpDao? private let syncMapper: ContactSyncPayloadMapping? private let isSyncEnabled: () -> Bool init( dao: CrmContactDao, images: ContactImageStoring? = nil, syncOpDao: SyncOpDao? = nil, syncMapper: ContactSyncPayloadMapping? = nil, isSyncEnabled: @escaping () -> Bool = { false } ) { self.dao = dao self.images = images self.syncOpDao = syncOpDao self.syncMapper = syncMapper self.isSyncEnabled = isSyncEnabled } static func currentMillis() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) } /// Kotlin `observeAll(): Flow` — manual refresh model on iOS. func fetchAll() async throws -> [CrmContactEntity] { try await dao.fetchAll() } func getById(_ id: Int64) async throws -> CrmContactEntity? { try await dao.getById(id) } func getExclusions() async throws -> [DuplicateExclusionEntity] { try await dao.getExclusions() } func search(_ query: String) async throws -> [CrmContactEntity] { let q = query.trimmingCharacters(in: .whitespacesAndNewlines) if q.isEmpty { return try await dao.getAll() } // Alphanumeric tokens only — avoids FTS MATCH crashes on `"` `*` `:` etc. let tokens = q.components(separatedBy: .whitespacesAndNewlines) .map { token in String(token.filter { $0.isLetter || $0.isNumber }) } .filter { !$0.isEmpty } if tokens.isEmpty { return [] } let token = tokens.map { "\($0)*" }.joined(separator: " ") let ids = try await dao.searchIds(token) var out: [CrmContactEntity] = [] for id in ids { if let entity = try await dao.getById(id) { out.append(entity) } } return out } /// Android saved `Bitmap`s through `ContactImageStore`; on iOS the caller /// saves the files first and passes the resulting paths. /// `notesOverride` mirrors Kotlin's `notesOverride: String? = card.note`: /// omit it to use `card.note`, pass `.some(nil)` to explicitly clear notes. @discardableResult func insertFromCard( _ card: ContactCard, cardImagePath: String? = nil, profileImagePath: String? = nil, notesOverride: String?? = nil, now: Int64 = ContactRepository.currentMillis() ) async throws -> Int64 { let notes = notesOverride ?? card.note let entity = card.toEntity(notes: notes, now: now) let id = try await dao.insert(entity) var inserted = entity inserted.id = id var updated = inserted if let cardImagePath { updated.cardImagePath = cardImagePath } if let profileImagePath { updated.profileImagePath = profileImagePath } if updated != inserted { try await dao.update(updated) } if let stored = try await dao.getById(id) { try await enqueueContactCreate(stored) } return id } func updateFromCard( id: Int64, card: ContactCard, cardImagePath: String? = nil, profileImagePath: String? = nil, preserveNotesIfBlankDraft: Bool = true, now: Int64 = ContactRepository.currentMillis() ) async throws { guard let existing = try await dao.getById(id) else { return } let notes: String? if preserveNotesIfBlankDraft && (card.note?.isBlank ?? true) { notes = existing.notes } else { notes = card.note } var updated = card.toEntity(notes: notes, now: now) updated.id = id updated.createdAt = existing.createdAt updated.cardImagePath = existing.cardImagePath updated.profileImagePath = existing.profileImagePath updated.serverId = existing.serverId updated.statut = existing.statut updated.etape = existing.etape updated.tags = existing.tags // When the company name changes, invalidate the link: the server re-resolves by name. let newCompany = card.company?.trimmingCharacters(in: .whitespacesAndNewlines) let oldCompany = existing.company?.trimmingCharacters(in: .whitespacesAndNewlines) updated.entrepriseServerId = newCompany == oldCompany ? existing.entrepriseServerId : nil if let cardImagePath { updated.cardImagePath = cardImagePath } if let profileImagePath { updated.profileImagePath = profileImagePath } try await dao.update(updated) if let stored = try await dao.getById(id) { try await enqueueContactUpdate(stored) } } func delete(_ id: Int64) async throws { let existing = try await dao.getById(id) try await dao.deleteExclusionsFor(id) try await dao.deleteById(id) images?.deleteAll(contactId: id) if let existing { try await enqueueContactDelete(existing) } } // Kotlin `rotateCardImage(id:degrees:)` is not ported: it re-encoded the // card JPEG with Android Bitmap/Matrix. On iOS the image rotation belongs // to the image-store/Scan module owner. func markDifferent(_ a: Int64, _ b: Int64) async throws { let lo = min(a, b) let hi = max(a, b) try await dao.insertExclusion(DuplicateExclusionEntity(idA: lo, idB: hi)) } func mergeContacts( sources: [CrmContactEntity], choices: MergeFieldChoices ) async throws { let map = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) }) var merged = ContactMerge.merge(sources: sources, choices: choices) let keepId = choices.keepId if let srcId = choices.cardImageFromId { let path = map[srcId]?.cardImagePath if let copied = images?.copyImage(fromPath: path, toContactId: keepId, name: "card.jpg") { merged.cardImagePath = copied } } if let srcId = choices.profileImageFromId { let path = map[srcId]?.profileImagePath if let copied = images?.copyImage(fromPath: path, toContactId: keepId, name: "profile.jpg") { merged.profileImagePath = copied } } try await dao.update(merged) for s in sources where s.id != keepId { try await delete(s.id) } } func findMatchingId(card: ContactCard, among: [CrmContactEntity]) -> Int64? { let emails = Set(card.emails.map(ContactNormalizer.email).filter { !$0.isEmpty }) let phones = Set(card.phones.map(ContactNormalizer.phone).filter { !$0.isEmpty }) for e in among { let eEmails = Set(e.emails.map(ContactNormalizer.email)) let ePhones = Set(e.phones.map(ContactNormalizer.phone)) if !emails.isDisjoint(with: eEmails) || !phones.isDisjoint(with: ePhones) { return e.id } } return nil } func importCards(_ cards: [ContactCard]) async throws -> VcfImportResult { var imported = 0 var skipped = 0 var errors = 0 var existing = try await dao.getAll() for card in cards { do { if !card.hasAnyField() { errors += 1 continue } if findMatchingId(card: card, among: existing) != nil { skipped += 1 continue } let id = try await insertFromCard(card) if let stored = try await dao.getById(id) { existing.append(stored) } imported += 1 } catch { errors += 1 } } return VcfImportResult(imported: imported, skippedDuplicates: skipped, errors: errors) } private func enqueueContactCreate(_ contact: CrmContactEntity) async throws { guard isSyncEnabled(), let syncOpDao, let syncMapper else { return } try await syncOpDao.insert( SyncOpEntity( entityType: "contact", op: "create", payloadJson: syncMapper.toCreatePayload(contact), localId: contact.id, createdAt: ContactRepository.currentMillis() ) ) } /// If not yet synchronized, patches the pending create (like kanban tasks). private func enqueueContactUpdate(_ contact: CrmContactEntity) async throws { guard isSyncEnabled(), let syncOpDao, let syncMapper else { return } if let serverId = contact.serverId { try await syncOpDao.insert( SyncOpEntity( entityType: "contact", op: "update", payloadJson: syncMapper.toUpdatePayload(contact), serverId: serverId, createdAt: ContactRepository.currentMillis() ) ) return } let pendingCreate = try await syncOpDao.listAll() .first { $0.entityType == "contact" && $0.op == "create" && $0.localId == contact.id } if var patched = pendingCreate { patched.payloadJson = syncMapper.toCreatePayload(contact) try await syncOpDao.insert(patched) } } private func enqueueContactDelete(_ contact: CrmContactEntity) async throws { // Kotlin only required the op queue here (deletes carry no payload). guard isSyncEnabled(), let syncOpDao else { return } if let serverId = contact.serverId { try await syncOpDao.insert( SyncOpEntity( entityType: "contact", op: "delete", serverId: serverId, createdAt: ContactRepository.currentMillis() ) ) } else { try await clearPendingContactOps(contact.id) } } private func clearPendingContactOps(_ localId: Int64) async throws { guard let syncOpDao else { return } let ops = try await syncOpDao.listAll() .filter { $0.entityType == "contact" && $0.localId == localId } for op in ops { try await syncOpDao.deleteById(op.id) } } } extension ContactCard { func toEntity(notes: String?, now: Int64) -> CrmContactEntity { CrmContactEntity( fullName: fullName, firstName: firstName, lastName: lastName, company: company, jobTitle: jobTitle, phones: phones, emails: emails, website: website, address: address, notes: notes, createdAt: now, updatedAt: now, phonesText: phones.joined(separator: " "), emailsText: emails.joined(separator: " ") ) } } extension CrmContactEntity { func toCard() -> ContactCard { ContactCard( fullName: fullName, firstName: firstName, lastName: lastName, company: company, jobTitle: jobTitle, phones: phones, emails: emails, website: website, address: address, note: notes ) } }
GitRust