macsync/@packages/inotes/Sources/INoteSync/SyncManager.swift

162 lines
4.8 KiB
Swift
Raw Permalink Normal View History

import AppKit
import Foundation
import LilithLogging
import MacSyncShared
private let log = AppLogger.logger(for: "INote.Sync")
// MARK: - Stats
public struct INoteSyncStats: Equatable, Sendable {
public var folderCount: Int = 0
public var noteCount: Int = 0
public init() {}
}
// MARK: - Sync Error
public enum INoteSyncError: Equatable, Sendable {
case none
case noteAccessRequired
case backendUnreachable
case connectionFailed(String)
public var message: String {
switch self {
case .none: return ""
case .noteAccessRequired: return "Notes automation access required"
case .backendUnreachable: return "Cannot connect to backend server"
case .connectionFailed(let m): return m
}
}
public var isConnectionError: Bool {
switch self {
case .backendUnreachable, .connectionFailed: return true
default: return false
}
}
}
// MARK: - SyncManager
@MainActor
public final class SyncManager: BaseSyncManager<INoteSyncStats, INoteSyncError> {
public static let shared = SyncManager()
public let reader = NotesReader.shared
private let apiClient = APIClient.shared
private let noteBatchSize = 100
// Outbound send queue (server Mac Notes writes).
private lazy var sendQueueClient: SendQueueClient<NoteSendTransport> = {
let transport = NoteSendTransport(apiClient: apiClient)
let sender = NoteSender()
return SendQueueClient(
label: "inotes",
transport: transport,
interval: 60
) { item in
await sender.apply(item)
}
}()
public override func didStartSync() {
sendQueueClient.start()
}
public override func willStopSync() {
sendQueueClient.stop()
}
private init() {
super.init(
initialStats: INoteSyncStats(),
noError: .none,
persistenceKey: "inotes",
timerInterval: 600
)
}
// MARK: - Authorization hooks
public override func isAuthorized() async -> Bool { reader.isAuthorized }
public override func requestAuthorization() async -> Bool {
await reader.requestAuthorization()
}
public override func onAuthorizationDenied() {
log.warning("Notes automation access denied")
syncError = .noteAccessRequired
}
public func openNotesAccessSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") {
NSWorkspace.shared.open(url)
}
}
// MARK: - Sync cycle
public override func performSync() async {
log.info("performSync starting")
currentOperation = "Fetching notes…"
let notes = await reader.fetchAllNotes()
log.info("Fetched \(notes.count) notes")
let df = ISO8601DateFormatter()
if !notes.isEmpty {
let batches = stride(from: 0, to: notes.count, by: noteBatchSize).map {
Array(notes[$0..<min($0 + noteBatchSize, notes.count)])
}
for (idx, batch) in batches.enumerated() {
currentOperation = "Syncing notes… (\(idx + 1)/\(batches.count))"
let payloads = batch.map { note in
SyncNotePayload(
noteIdentifier: note.noteIdentifier,
folder: note.folder,
name: note.name,
body: note.body,
modifiedAt: note.modificationDate.map { df.string(from: $0) }
)
}
do {
let synced = try await apiClient.syncNotes(payloads)
log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)")
if syncError.isConnectionError { syncError = .none }
} catch {
log.warning("Note batch \(idx + 1) failed: \(error.localizedDescription)")
setConnectionError(error)
}
}
}
await updateStats()
currentOperation = "Sync complete"
log.info("performSync complete notes=\(notes.count)")
}
private func updateStats() async {
do {
let response = try await apiClient.getStats()
stats.folderCount = response.folderCount
stats.noteCount = response.noteCount
log.info("Stats: folders=\(response.folderCount) notes=\(response.noteCount)")
} catch {
log.warning("updateStats failed: \(error.localizedDescription)")
}
}
private func setConnectionError(_ error: Error) {
if SyncConnectionErrorHeuristic.isConnectionError(error) {
syncError = .backendUnreachable
}
}
}