161 lines
4.8 KiB
Swift
161 lines
4.8 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|