diff --git a/Package.swift b/Package.swift index 1724027..84e76cd 100644 --- a/Package.swift +++ b/Package.swift @@ -8,12 +8,22 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), ], targets: [ - .executableTarget( - name: "bcli", + .target( + name: "BearCLICore", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), ], + path: "Sources/BearCLICore" + ), + .executableTarget( + name: "bcli", + dependencies: ["BearCLICore"], path: "Sources/bcli" ), + .testTarget( + name: "BearCLITests", + dependencies: ["BearCLICore"], + path: "Tests/BearCLITests" + ), ] ) diff --git a/Sources/bcli/AuthServer.swift b/Sources/BearCLICore/AuthServer.swift similarity index 98% rename from Sources/bcli/AuthServer.swift rename to Sources/BearCLICore/AuthServer.swift index 87018a8..9ff331e 100644 --- a/Sources/bcli/AuthServer.swift +++ b/Sources/BearCLICore/AuthServer.swift @@ -7,7 +7,7 @@ import Glibc /// A minimal HTTP server that serves an Apple Sign-In page /// and waits for the browser to POST back a ckWebAuthToken. -class AuthServer { +public class AuthServer { private let preferredPort: UInt16 = 19222 private let timeoutSeconds: Int = 120 private var serverSocket: Int32 = -1 @@ -457,7 +457,7 @@ class AuthServer { // MARK: - Public Interface - func startAndWaitForToken() -> String? { + public func startAndWaitForToken() -> String? { do { let (sock, port) = try createServerSocket() serverSocket = sock @@ -504,9 +504,9 @@ class AuthServer { } } - var port: UInt16 { actualPort } + public var port: UInt16 { actualPort } - func openInBrowser() { + public func openInBrowser() { let p = Process() p.executableURL = URL(fileURLWithPath: "/usr/bin/open") p.arguments = ["http://localhost:\(actualPort)/"] @@ -514,11 +514,11 @@ class AuthServer { } } -enum AuthServerError: Error, CustomStringConvertible { +public enum AuthServerError: Error, CustomStringConvertible { case socketCreationFailed(String) case bindFailed(String) - var description: String { + public var description: String { switch self { case .socketCreationFailed(let msg): return "Socket creation failed: \(msg)" case .bindFailed(let msg): return "Bind failed: \(msg)" diff --git a/Sources/bcli/CloudKitAPI.swift b/Sources/BearCLICore/CloudKitAPI.swift similarity index 95% rename from Sources/bcli/CloudKitAPI.swift rename to Sources/BearCLICore/CloudKitAPI.swift index 7733cb0..c88ad60 100644 --- a/Sources/bcli/CloudKitAPI.swift +++ b/Sources/BearCLICore/CloudKitAPI.swift @@ -1,8 +1,12 @@ import Foundation /// Client for CloudKit Web Services REST API targeting Bear's iCloud container. -struct CloudKitAPI { - let auth: AuthConfig +public struct CloudKitAPI { + public let auth: AuthConfig + + public init(auth: AuthConfig) { + self.auth = auth + } private let baseURL = "https://api.apple-cloudkit.com/database/1/iCloud.net.shinyfrog.bear/production/private" private let bearZone = CKZoneID(zoneName: "Notes", ownerRecordName: nil) @@ -50,7 +54,7 @@ struct CloudKitAPI { // MARK: - Zones - func listZones() async throws -> [CKZone] { + public func listZones() async throws -> [CKZone] { struct Empty: Encodable {} let response: CKZoneListResponse = try await post(path: "zones/list", body: Empty()) return response.zones @@ -58,7 +62,7 @@ struct CloudKitAPI { // MARK: - Query Notes - func queryNotes( + public func queryNotes( trashed: Bool = false, archived: Bool = false, limit: Int = 50, @@ -98,7 +102,7 @@ struct CloudKitAPI { } /// Query notes with pagination support, fetching all results - func queryAllNotes( + public func queryAllNotes( trashed: Bool = false, archived: Bool = false, desiredKeys: [String]? = nil @@ -151,7 +155,7 @@ struct CloudKitAPI { // MARK: - Query Tags - func queryTags(limit: Int = 200) async throws -> [CKRecord] { + public func queryTags(limit: Int = 200) async throws -> [CKRecord] { let query = CKQuery( recordType: "SFNoteTag", filterBy: [], @@ -170,7 +174,7 @@ struct CloudKitAPI { // MARK: - Lookup by ID - func lookupRecords(ids: [String], desiredKeys: [String]? = nil) async throws -> [CKRecord] { + public func lookupRecords(ids: [String], desiredKeys: [String]? = nil) async throws -> [CKRecord] { let request = CKRecordLookupRequest( records: ids.map { CKRecordRef(recordName: $0) }, zoneID: bearZone, @@ -183,7 +187,7 @@ struct CloudKitAPI { // MARK: - Download Asset (note text) - func downloadAsset(url: String) async throws -> String { + public func downloadAsset(url: String) async throws -> String { guard let assetURL = URL(string: url) else { throw BearCLIError.invalidURL(url) } @@ -203,7 +207,7 @@ struct CloudKitAPI { // MARK: - Modify Records (create/update) - func modifyRecords(operations: [[String: AnyCodableValue]]) async throws -> [CKRecord] { + public func modifyRecords(operations: [[String: AnyCodableValue]]) async throws -> [CKRecord] { let body: [String: AnyCodableValue] = [ "operations": .array(operations.map { .dictionary($0) }), "zoneID": .dictionary(["zoneName": .string("Notes")]), @@ -214,7 +218,7 @@ struct CloudKitAPI { } /// Create a new note. Returns the created CKRecord. - func createNote(title: String, text: String, tags: [String] = []) async throws -> CKRecord { + public func createNote(title: String, text: String, tags: [String] = []) async throws -> CKRecord { let noteID = UUID().uuidString let now = Int64(Date().timeIntervalSince1970 * 1000) @@ -280,7 +284,7 @@ struct CloudKitAPI { } /// Update an existing note's text content. - func updateNote(record: CKRecord, newText: String) async throws -> CKRecord { + public func updateNote(record: CKRecord, newText: String) async throws -> CKRecord { let now = Int64(Date().timeIntervalSince1970 * 1000) // Extract title from the first H1 line of the new text, or keep existing @@ -379,7 +383,7 @@ struct CloudKitAPI { } /// Trash a note (soft delete). - func trashNote(record: CKRecord) async throws -> CKRecord { + public func trashNote(record: CKRecord) async throws -> CKRecord { let now = Int64(Date().timeIntervalSince1970 * 1000) let newClock = incrementVectorClock( record.fields["vectorClock"]?.value.stringValue ?? "" @@ -585,7 +589,7 @@ struct CloudKitAPI { // MARK: - Zone Changes (incremental sync) - func fetchZoneChanges(syncToken: String?, resultsLimit: Int = 200) async throws -> CKZoneChangeResult { + public func fetchZoneChanges(syncToken: String?, resultsLimit: Int = 200) async throws -> CKZoneChangeResult { var zone: [String: AnyCodableValue] = [ "zoneID": .dictionary(["zoneName": .string("Notes")]), "resultsLimit": .int(Int64(resultsLimit)), @@ -607,7 +611,7 @@ struct CloudKitAPI { // MARK: - Search (client-side title match) - func searchNotes(query searchTerm: String, limit: Int = 50) async throws -> [CKRecord] { + public func searchNotes(query searchTerm: String, limit: Int = 50) async throws -> [CKRecord] { // CloudKit doesn't support full-text search natively. // Fetch the lightweight index and filter client-side by title. let allRecords = try await queryAllNotes( @@ -634,7 +638,7 @@ struct CloudKitAPI { // MARK: - Errors -enum BearCLIError: Error, CustomStringConvertible { +public enum BearCLIError: Error, CustomStringConvertible { case authExpired case authNotConfigured case apiError(Int, String) @@ -642,7 +646,7 @@ enum BearCLIError: Error, CustomStringConvertible { case invalidURL(String) case noteNotFound(String) - var description: String { + public var description: String { switch self { case .authExpired: return "Auth token expired. Run `bcli auth` to re-authenticate." diff --git a/Sources/bcli/Commands/AuthCommand.swift b/Sources/BearCLICore/Commands/AuthCommand.swift similarity index 95% rename from Sources/bcli/Commands/AuthCommand.swift rename to Sources/BearCLICore/Commands/AuthCommand.swift index 1809708..ec2da54 100644 --- a/Sources/bcli/Commands/AuthCommand.swift +++ b/Sources/BearCLICore/Commands/AuthCommand.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct AuthCommand: ParsableCommand { - static let configuration = CommandConfiguration( +public struct AuthCommand: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "auth", abstract: "Authenticate with iCloud for Bear access" ) @@ -13,7 +13,9 @@ struct AuthCommand: ParsableCommand { @Flag(name: .long, help: "Force browser-based authentication even if already authenticated") var browser: Bool = false - func run() throws { + public init() {} + + public func run() throws { let webAuthToken: String if let t = token { diff --git a/Sources/bcli/Commands/CreateNote.swift b/Sources/BearCLICore/Commands/CreateNote.swift similarity index 93% rename from Sources/bcli/Commands/CreateNote.swift rename to Sources/BearCLICore/Commands/CreateNote.swift index 216f099..349bc43 100644 --- a/Sources/bcli/Commands/CreateNote.swift +++ b/Sources/BearCLICore/Commands/CreateNote.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct CreateNote: ParsableCommand { - static let configuration = CommandConfiguration( +public struct CreateNote: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new Bear note" ) @@ -22,7 +22,9 @@ struct CreateNote: ParsableCommand { @Flag(name: .long, help: "Output created note ID only (for scripting)") var quiet: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let title = self.title diff --git a/Sources/bcli/Commands/EditNote.swift b/Sources/BearCLICore/Commands/EditNote.swift similarity index 96% rename from Sources/bcli/Commands/EditNote.swift rename to Sources/BearCLICore/Commands/EditNote.swift index 00cd169..144f1f9 100644 --- a/Sources/bcli/Commands/EditNote.swift +++ b/Sources/BearCLICore/Commands/EditNote.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct EditNote: ParsableCommand { - static let configuration = CommandConfiguration( +public struct EditNote: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "edit", abstract: "Edit a Bear note" ) @@ -19,7 +19,9 @@ struct EditNote: ParsableCommand { @Flag(name: .long, help: "Open in $EDITOR for interactive editing") var editor: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let noteID = self.noteID diff --git a/Sources/bcli/Commands/ExportNotes.swift b/Sources/BearCLICore/Commands/ExportNotes.swift similarity index 96% rename from Sources/bcli/Commands/ExportNotes.swift rename to Sources/BearCLICore/Commands/ExportNotes.swift index 9be9373..d387bdf 100644 --- a/Sources/bcli/Commands/ExportNotes.swift +++ b/Sources/BearCLICore/Commands/ExportNotes.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct ExportNotes: ParsableCommand { - static let configuration = CommandConfiguration( +public struct ExportNotes: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "export", abstract: "Export Bear notes as markdown files" ) @@ -19,7 +19,9 @@ struct ExportNotes: ParsableCommand { @Flag(name: .long, help: "Include YAML frontmatter with metadata") var frontmatter: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let outputDir = self.outputDir diff --git a/Sources/bcli/Commands/GetNote.swift b/Sources/BearCLICore/Commands/GetNote.swift similarity index 95% rename from Sources/bcli/Commands/GetNote.swift rename to Sources/BearCLICore/Commands/GetNote.swift index 0dc358b..df65053 100644 --- a/Sources/bcli/Commands/GetNote.swift +++ b/Sources/BearCLICore/Commands/GetNote.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct GetNote: ParsableCommand { - static let configuration = CommandConfiguration( +public struct GetNote: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "get", abstract: "Get a Bear note's content" ) @@ -16,7 +16,9 @@ struct GetNote: ParsableCommand { @Flag(name: .long, help: "Output as JSON") var json: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let noteID = self.noteID diff --git a/Sources/bcli/Commands/ListNotes.swift b/Sources/BearCLICore/Commands/ListNotes.swift similarity index 96% rename from Sources/bcli/Commands/ListNotes.swift rename to Sources/BearCLICore/Commands/ListNotes.swift index 468185e..28ef241 100644 --- a/Sources/bcli/Commands/ListNotes.swift +++ b/Sources/BearCLICore/Commands/ListNotes.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct ListNotes: ParsableCommand { - static let configuration = CommandConfiguration( +public struct ListNotes: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "ls", abstract: "List Bear notes" ) @@ -25,7 +25,9 @@ struct ListNotes: ParsableCommand { @Flag(name: .long, help: "Output as JSON") var json: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let all = self.all diff --git a/Sources/bcli/Commands/ListTags.swift b/Sources/BearCLICore/Commands/ListTags.swift similarity index 94% rename from Sources/bcli/Commands/ListTags.swift rename to Sources/BearCLICore/Commands/ListTags.swift index 7930bec..0ef130e 100644 --- a/Sources/bcli/Commands/ListTags.swift +++ b/Sources/BearCLICore/Commands/ListTags.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct ListTags: ParsableCommand { - static let configuration = CommandConfiguration( +public struct ListTags: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "tags", abstract: "List all Bear tags" ) @@ -13,7 +13,9 @@ struct ListTags: ParsableCommand { @Flag(name: .long, help: "Output as JSON") var json: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let flat = self.flat diff --git a/Sources/bcli/Commands/SearchNotes.swift b/Sources/BearCLICore/Commands/SearchNotes.swift similarity index 97% rename from Sources/bcli/Commands/SearchNotes.swift rename to Sources/BearCLICore/Commands/SearchNotes.swift index 2426058..05c16d0 100644 --- a/Sources/bcli/Commands/SearchNotes.swift +++ b/Sources/BearCLICore/Commands/SearchNotes.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct SearchNotes: ParsableCommand { - static let configuration = CommandConfiguration( +public struct SearchNotes: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "search", abstract: "Search Bear notes (full-text)" ) @@ -19,7 +19,9 @@ struct SearchNotes: ParsableCommand { @Flag(name: .long, help: "Skip auto-sync (use existing cache as-is)") var noSync: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let query = self.query diff --git a/Sources/bcli/Commands/SyncCommand.swift b/Sources/BearCLICore/Commands/SyncCommand.swift similarity index 87% rename from Sources/bcli/Commands/SyncCommand.swift rename to Sources/BearCLICore/Commands/SyncCommand.swift index 29df5da..c05cc0d 100644 --- a/Sources/bcli/Commands/SyncCommand.swift +++ b/Sources/BearCLICore/Commands/SyncCommand.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct SyncCommand: ParsableCommand { - static let configuration = CommandConfiguration( +public struct SyncCommand: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "sync", abstract: "Sync Bear notes to a local cache for fast search" ) @@ -13,7 +13,9 @@ struct SyncCommand: ParsableCommand { @Flag(name: .shortAndLong, help: "Show per-note progress") var verbose: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let force = self.full diff --git a/Sources/bcli/Commands/TodoCommand.swift b/Sources/BearCLICore/Commands/TodoCommand.swift similarity index 98% rename from Sources/bcli/Commands/TodoCommand.swift rename to Sources/BearCLICore/Commands/TodoCommand.swift index a261a6a..fd366d9 100644 --- a/Sources/bcli/Commands/TodoCommand.swift +++ b/Sources/BearCLICore/Commands/TodoCommand.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct TodoCommand: ParsableCommand { - static let configuration = CommandConfiguration( +public struct TodoCommand: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "todo", abstract: "List and toggle TODO items in Bear notes" ) @@ -22,7 +22,9 @@ struct TodoCommand: ParsableCommand { @Option(name: .shortAndLong, help: "Maximum notes to show in list mode") var limit: Int = 30 - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let noteID = self.noteID diff --git a/Sources/bcli/Commands/TrashNote.swift b/Sources/BearCLICore/Commands/TrashNote.swift similarity index 92% rename from Sources/bcli/Commands/TrashNote.swift rename to Sources/BearCLICore/Commands/TrashNote.swift index dbf7275..5998954 100644 --- a/Sources/bcli/Commands/TrashNote.swift +++ b/Sources/BearCLICore/Commands/TrashNote.swift @@ -1,8 +1,8 @@ import ArgumentParser import Foundation -struct TrashNote: ParsableCommand { - static let configuration = CommandConfiguration( +public struct TrashNote: ParsableCommand { + public static let configuration = CommandConfiguration( commandName: "trash", abstract: "Move a Bear note to trash" ) @@ -13,7 +13,9 @@ struct TrashNote: ParsableCommand { @Flag(name: .long, help: "Skip confirmation prompt") var force: Bool = false - func run() throws { + public init() {} + + public func run() throws { let auth = try loadAuth() let api = CloudKitAPI(auth: auth) let noteID = self.noteID diff --git a/Sources/BearCLICore/Exports.swift b/Sources/BearCLICore/Exports.swift new file mode 100644 index 0000000..f45836f --- /dev/null +++ b/Sources/BearCLICore/Exports.swift @@ -0,0 +1,54 @@ +import ArgumentParser +import Foundation + +public struct BearCLI: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "bcli", + abstract: "CLI for Bear notes via CloudKit", + version: "0.3.0", + subcommands: [ + AuthCommand.self, + ListNotes.self, + GetNote.self, + ListTags.self, + SearchNotes.self, + CreateNote.self, + EditNote.self, + TrashNote.self, + TodoCommand.self, + ExportNotes.self, + SyncCommand.self, + ] + ) + + public init() {} +} + +// Shared auth loader +public func loadAuth() throws -> AuthConfig { + do { + return try AuthConfig.load() + } catch { + throw BearCLIError.authNotConfigured + } +} + +/// Run an async block synchronously using a semaphore +public func runAsync(_ block: @escaping () async throws -> Void) throws { + let semaphore = DispatchSemaphore(value: 0) + var thrownError: Error? + + Task { + do { + try await block() + } catch { + thrownError = error + } + semaphore.signal() + } + + semaphore.wait() + if let error = thrownError { + throw error + } +} diff --git a/Sources/BearCLICore/Models.swift b/Sources/BearCLICore/Models.swift new file mode 100644 index 0000000..2025f76 --- /dev/null +++ b/Sources/BearCLICore/Models.swift @@ -0,0 +1,352 @@ +import Foundation + +// MARK: - CloudKit API Request/Response Types + +public struct CKZoneListResponse: Decodable { + public let zones: [CKZone] +} + +public struct CKZone: Decodable { + public let zoneID: CKZoneID + public let syncToken: String? +} + +public struct CKZoneID: Codable { + public let zoneName: String + public let ownerRecordName: String? + + public init(zoneName: String, ownerRecordName: String? = nil) { + self.zoneName = zoneName + self.ownerRecordName = ownerRecordName + } +} + +public struct CKRecordQueryRequest: Encodable { + public let zoneID: CKZoneID + public let query: CKQuery + public let resultsLimit: Int? + public let desiredKeys: [String]? + + public init(zoneID: CKZoneID, query: CKQuery, resultsLimit: Int? = nil, desiredKeys: [String]? = nil) { + self.zoneID = zoneID + self.query = query + self.resultsLimit = resultsLimit + self.desiredKeys = desiredKeys + } +} + +public struct CKQuery: Encodable { + public let recordType: String + public let filterBy: [CKFilter]? + public let sortBy: [CKSort]? + + public init(recordType: String, filterBy: [CKFilter]? = nil, sortBy: [CKSort]? = nil) { + self.recordType = recordType + self.filterBy = filterBy + self.sortBy = sortBy + } +} + +public struct CKFilter: Encodable { + public let fieldName: String + public let comparator: String + public let fieldValue: CKFieldValue + + public init(fieldName: String, comparator: String, fieldValue: CKFieldValue) { + self.fieldName = fieldName + self.comparator = comparator + self.fieldValue = fieldValue + } +} + +public struct CKSort: Encodable { + public let fieldName: String + public let ascending: Bool + + public init(fieldName: String, ascending: Bool) { + self.fieldName = fieldName + self.ascending = ascending + } +} + +public struct CKFieldValue: Codable { + public let value: AnyCodableValue + public let type: String? + + public init(value: AnyCodableValue, type: String? = nil) { + self.value = value + self.type = type + } +} + +public struct CKRecordLookupRequest: Encodable { + public let records: [CKRecordRef] + public let zoneID: CKZoneID + public let desiredKeys: [String]? +} + +public struct CKRecordRef: Encodable { + public let recordName: String + + public init(recordName: String) { + self.recordName = recordName + } +} + +public struct CKRecordQueryResponse: Decodable { + public let records: [CKRecord] + public let continuationMarker: String? +} + +public struct CKRecordLookupResponse: Decodable { + public let records: [CKRecord] +} + +public struct CKRecord: Decodable { + public let recordName: String + public let recordType: String? + public let fields: [String: CKRecordField] + public let recordChangeTag: String? + public let created: CKTimestamp? + public let modified: CKTimestamp? + public let deleted: Bool? + + public init( + recordName: String, + recordType: String? = nil, + fields: [String: CKRecordField] = [:], + recordChangeTag: String? = nil, + created: CKTimestamp? = nil, + modified: CKTimestamp? = nil, + deleted: Bool? = nil + ) { + self.recordName = recordName + self.recordType = recordType + self.fields = fields + self.recordChangeTag = recordChangeTag + self.created = created + self.modified = modified + self.deleted = deleted + } +} + +// MARK: - CloudKit Zone Changes (for incremental sync) + +public struct CKZoneChangesResponse: Decodable { + public let zones: [CKZoneChangeResult] +} + +public struct CKZoneChangeResult: Decodable { + public let zoneID: CKZoneID + public let moreComing: Bool + public let syncToken: String + public let records: [CKRecord] +} + +public struct CKTimestamp: Decodable { + public let timestamp: Int64? + public let userRecordName: String? + + public init(timestamp: Int64? = nil, userRecordName: String? = nil) { + self.timestamp = timestamp + self.userRecordName = userRecordName + } +} + +public struct CKRecordField: Decodable { + public let value: AnyCodableValue + public let type: String? + + public init(value: AnyCodableValue, type: String? = nil) { + self.value = value + self.type = type + } +} + +// MARK: - Flexible JSON Value Type + +public enum AnyCodableValue: Codable { + case string(String) + case int(Int64) + case double(Double) + case bool(Bool) + case array([AnyCodableValue]) + case dictionary([String: AnyCodableValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + return + } + if let v = try? container.decode(Bool.self) { self = .bool(v); return } + if let v = try? container.decode(Int64.self) { self = .int(v); return } + if let v = try? container.decode(Double.self) { self = .double(v); return } + if let v = try? container.decode(String.self) { self = .string(v); return } + if let v = try? container.decode([AnyCodableValue].self) { self = .array(v); return } + if let v = try? container.decode([String: AnyCodableValue].self) { self = .dictionary(v); return } + self = .null + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let v): try container.encode(v) + case .int(let v): try container.encode(v) + case .double(let v): try container.encode(v) + case .bool(let v): try container.encode(v) + case .array(let v): try container.encode(v) + case .dictionary(let v): try container.encode(v) + case .null: try container.encodeNil() + } + } + + public var stringValue: String? { + if case .string(let v) = self { return v } + return nil + } + + public var intValue: Int64? { + if case .int(let v) = self { return v } + return nil + } + + public var doubleValue: Double? { + if case .double(let v) = self { return v } + if case .int(let v) = self { return Double(v) } + return nil + } + + public var arrayValue: [AnyCodableValue]? { + if case .array(let v) = self { return v } + return nil + } + + public var dictValue: [String: AnyCodableValue]? { + if case .dictionary(let v) = self { return v } + return nil + } +} + +// MARK: - Bear Domain Models + +public struct BearNote { + public let id: String + public let uniqueIdentifier: String + public let title: String + public let tags: [String] + public let pinned: Bool + public let archived: Bool + public let trashed: Bool + public let locked: Bool + public let todoCompleted: Int + public let todoIncompleted: Int + public let creationDate: Date? + public let modificationDate: Date? + public let textAssetURL: String? + public let hasFiles: Bool + + public init(from record: CKRecord) { + self.id = record.recordName + self.uniqueIdentifier = record.fields["uniqueIdentifier"]?.value.stringValue ?? record.recordName + self.title = record.fields["title"]?.value.stringValue ?? "(untitled)" + + if let tagsArray = record.fields["tagsStrings"]?.value.arrayValue { + self.tags = tagsArray.compactMap { $0.stringValue } + } else { + self.tags = [] + } + + self.pinned = record.fields["pinned"]?.value.intValue == 1 + self.archived = record.fields["archived"]?.value.intValue == 1 + self.trashed = record.fields["trashed"]?.value.intValue == 1 + self.locked = record.fields["locked"]?.value.intValue == 1 + self.todoCompleted = Int(record.fields["todoCompleted"]?.value.intValue ?? 0) + self.todoIncompleted = Int(record.fields["todoIncompleted"]?.value.intValue ?? 0) + + if let ts = record.fields["sf_creationDate"]?.value.intValue { + self.creationDate = Date(timeIntervalSince1970: Double(ts) / 1000.0) + } else { + self.creationDate = nil + } + + if let ts = record.fields["sf_modificationDate"]?.value.intValue { + self.modificationDate = Date(timeIntervalSince1970: Double(ts) / 1000.0) + } else { + self.modificationDate = nil + } + + if let textDict = record.fields["text"]?.value.dictValue, + let url = textDict["downloadURL"]?.stringValue { + self.textAssetURL = url + } else { + self.textAssetURL = nil + } + + self.hasFiles = record.fields["hasFiles"]?.value.intValue == 1 + } +} + +public struct BearTag { + public let id: String + public let title: String + public let notesCount: Int + public let pinned: Bool + public let isRoot: Bool + + public init(from record: CKRecord) { + self.id = record.recordName + self.title = record.fields["title"]?.value.stringValue ?? "(unknown)" + self.notesCount = Int(record.fields["notesCount"]?.value.intValue ?? 0) + self.pinned = record.fields["pinned"]?.value.intValue == 1 + self.isRoot = record.fields["isRoot"]?.value.intValue == 1 + } +} + +// MARK: - Auth Config + +public struct AuthConfig: Codable { + public var ckWebAuthToken: String + public let ckAPIToken: String + public var savedAt: Date + + public static let apiToken = "ce59f955ec47e744f720aa1d2816a4e985e472d8b859b6c7a47b81fd36646307" + + public static var configDir: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config") + .appendingPathComponent("bear-cli") + } + + public static var configFile: URL { + configDir.appendingPathComponent("auth.json") + } + + public init(ckWebAuthToken: String, ckAPIToken: String, savedAt: Date) { + self.ckWebAuthToken = ckWebAuthToken + self.ckAPIToken = ckAPIToken + self.savedAt = savedAt + } + + public static func load() throws -> AuthConfig { + let data = try Data(contentsOf: configFile) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(AuthConfig.self, from: data) + } + + public func save() throws { + let dir = AuthConfig.configDir + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(self) + try data.write(to: AuthConfig.configFile) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: AuthConfig.configFile.path + ) + } +} diff --git a/Sources/bcli/NoteCache.swift b/Sources/BearCLICore/NoteCache.swift similarity index 75% rename from Sources/bcli/NoteCache.swift rename to Sources/BearCLICore/NoteCache.swift index 977fb08..81eb20e 100644 --- a/Sources/bcli/NoteCache.swift +++ b/Sources/BearCLICore/NoteCache.swift @@ -2,22 +2,22 @@ import Foundation // MARK: - Cached Note -struct CachedNote: Codable { - let recordName: String - let uniqueIdentifier: String - let title: String - let tags: [String] - let pinned: Bool - let archived: Bool - let trashed: Bool - let locked: Bool - let creationDate: Date? - let modificationDate: Date? - let recordChangeTag: String? - let text: String - let hasFiles: Bool - - init(from record: CKRecord, text: String) { +public struct CachedNote: Codable { + public let recordName: String + public let uniqueIdentifier: String + public let title: String + public let tags: [String] + public let pinned: Bool + public let archived: Bool + public let trashed: Bool + public let locked: Bool + public let creationDate: Date? + public let modificationDate: Date? + public let recordChangeTag: String? + public let text: String + public let hasFiles: Bool + + public init(from record: CKRecord, text: String) { self.recordName = record.recordName self.uniqueIdentifier = record.fields["uniqueIdentifier"]?.value.stringValue ?? record.recordName self.title = record.fields["title"]?.value.stringValue ?? "(untitled)" @@ -53,34 +53,40 @@ struct CachedNote: Codable { // MARK: - Note Cache -struct NoteCache: Codable { - var syncToken: String? - var lastSyncDate: Date? - var notes: [String: CachedNote] // keyed by recordName +public struct NoteCache: Codable { + public var syncToken: String? + public var lastSyncDate: Date? + public var notes: [String: CachedNote] // keyed by recordName - static let staleThresholdSeconds: TimeInterval = 300 // 5 minutes + public init(syncToken: String? = nil, lastSyncDate: Date? = nil, notes: [String: CachedNote] = [:]) { + self.syncToken = syncToken + self.lastSyncDate = lastSyncDate + self.notes = notes + } + + public static let staleThresholdSeconds: TimeInterval = 300 // 5 minutes - static var cacheFile: URL { + public static var cacheFile: URL { AuthConfig.configDir.appendingPathComponent("cache.json") } - static func exists() -> Bool { + public static func exists() -> Bool { FileManager.default.fileExists(atPath: cacheFile.path) } - var isStale: Bool { + public var isStale: Bool { guard let lastSync = lastSyncDate else { return true } return Date().timeIntervalSince(lastSync) > Self.staleThresholdSeconds } - static func load() throws -> NoteCache { + public static func load() throws -> NoteCache { let data = try Data(contentsOf: cacheFile) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(NoteCache.self, from: data) } - func save() throws { + public func save() throws { let dir = AuthConfig.configDir try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -94,22 +100,22 @@ struct NoteCache: Codable { _ = try FileManager.default.replaceItemAt(Self.cacheFile, withItemAt: tmpFile) } - mutating func upsert(_ note: CachedNote) { + public mutating func upsert(_ note: CachedNote) { notes[note.recordName] = note } - mutating func remove(recordName: String) { + public mutating func remove(recordName: String) { notes.removeValue(forKey: recordName) } /// Upsert from a CKRecord returned after a CLI write operation. - mutating func upsertFromRecord(_ record: CKRecord, text: String) { + public mutating func upsertFromRecord(_ record: CKRecord, text: String) { let cached = CachedNote(from: record, text: text) notes[record.recordName] = cached } /// Mark a note as trashed in the cache. - mutating func markTrashed(recordName: String) { + public mutating func markTrashed(recordName: String) { guard let existing = notes[recordName] else { return } // Re-create with trashed flag - CachedNote is a value type let trashed = CachedNote( @@ -134,7 +140,7 @@ struct NoteCache: Codable { // MARK: - CachedNote memberwise init (for markTrashed) extension CachedNote { - init( + public init( recordName: String, uniqueIdentifier: String, title: String, diff --git a/Sources/bcli/SyncEngine.swift b/Sources/BearCLICore/SyncEngine.swift similarity index 90% rename from Sources/bcli/SyncEngine.swift rename to Sources/BearCLICore/SyncEngine.swift index 7449bd9..a185ace 100644 --- a/Sources/bcli/SyncEngine.swift +++ b/Sources/BearCLICore/SyncEngine.swift @@ -1,14 +1,14 @@ import Foundation -struct SyncStats { - var added: Int = 0 - var updated: Int = 0 - var deleted: Int = 0 - var failed: Int = 0 +public struct SyncStats { + public var added: Int = 0 + public var updated: Int = 0 + public var deleted: Int = 0 + public var failed: Int = 0 - var total: Int { added + updated + deleted } + public var total: Int { added + updated + deleted } - var summary: String { + public var summary: String { var parts: [String] = [] if added > 0 { parts.append("\(added) new") } if updated > 0 { parts.append("\(updated) updated") } @@ -19,11 +19,15 @@ struct SyncStats { } } -struct SyncEngine { - let api: CloudKitAPI +public struct SyncEngine { + public let api: CloudKitAPI + + public init(api: CloudKitAPI) { + self.api = api + } /// Main sync entry point. - func sync(force: Bool = false, verbose: Bool = false) async throws -> (NoteCache, SyncStats) { + public func sync(force: Bool = false, verbose: Bool = false) async throws -> (NoteCache, SyncStats) { if force || !NoteCache.exists() { return try await performFullSync(verbose: verbose) } @@ -46,7 +50,7 @@ struct SyncEngine { } /// Ensure cache exists and is reasonably fresh. Used by search before querying. - func ensureCacheReady(verbose: Bool = false) async throws -> NoteCache { + public func ensureCacheReady(verbose: Bool = false) async throws -> NoteCache { if !NoteCache.exists() { if verbose { print("No local cache found. Syncing...") } let (cache, stats) = try await performFullSync(verbose: verbose) @@ -184,7 +188,7 @@ struct SyncEngine { // MARK: - Shared Text Fetching /// Get note text from a CKRecord - tries textADP (inline) first, then asset download. - func fetchNoteText(from record: CKRecord) async throws -> String { + public func fetchNoteText(from record: CKRecord) async throws -> String { if let textADP = record.fields["textADP"]?.value.stringValue { return textADP } diff --git a/Sources/bcli/Models.swift b/Sources/bcli/Models.swift deleted file mode 100644 index 5f40e1d..0000000 --- a/Sources/bcli/Models.swift +++ /dev/null @@ -1,294 +0,0 @@ -import Foundation - -// MARK: - CloudKit API Request/Response Types - -struct CKZoneListResponse: Decodable { - let zones: [CKZone] -} - -struct CKZone: Decodable { - let zoneID: CKZoneID - let syncToken: String? -} - -struct CKZoneID: Codable { - let zoneName: String - let ownerRecordName: String? -} - -struct CKRecordQueryRequest: Encodable { - let zoneID: CKZoneID - let query: CKQuery - let resultsLimit: Int? - let desiredKeys: [String]? - - init(zoneID: CKZoneID, query: CKQuery, resultsLimit: Int? = nil, desiredKeys: [String]? = nil) { - self.zoneID = zoneID - self.query = query - self.resultsLimit = resultsLimit - self.desiredKeys = desiredKeys - } -} - -struct CKQuery: Encodable { - let recordType: String - let filterBy: [CKFilter]? - let sortBy: [CKSort]? -} - -struct CKFilter: Encodable { - let fieldName: String - let comparator: String - let fieldValue: CKFieldValue -} - -struct CKSort: Encodable { - let fieldName: String - let ascending: Bool -} - -struct CKFieldValue: Codable { - let value: AnyCodableValue - let type: String? - - init(value: AnyCodableValue, type: String? = nil) { - self.value = value - self.type = type - } -} - -struct CKRecordLookupRequest: Encodable { - let records: [CKRecordRef] - let zoneID: CKZoneID - let desiredKeys: [String]? -} - -struct CKRecordRef: Encodable { - let recordName: String -} - -struct CKRecordQueryResponse: Decodable { - let records: [CKRecord] - let continuationMarker: String? -} - -struct CKRecordLookupResponse: Decodable { - let records: [CKRecord] -} - -struct CKRecord: Decodable { - let recordName: String - let recordType: String? - let fields: [String: CKRecordField] - let recordChangeTag: String? - let created: CKTimestamp? - let modified: CKTimestamp? - let deleted: Bool? -} - -// MARK: - CloudKit Zone Changes (for incremental sync) - -struct CKZoneChangesResponse: Decodable { - let zones: [CKZoneChangeResult] -} - -struct CKZoneChangeResult: Decodable { - let zoneID: CKZoneID - let moreComing: Bool - let syncToken: String - let records: [CKRecord] -} - -struct CKTimestamp: Decodable { - let timestamp: Int64? - let userRecordName: String? -} - -struct CKRecordField: Decodable { - let value: AnyCodableValue - let type: String? -} - -// MARK: - Flexible JSON Value Type - -enum AnyCodableValue: Codable { - case string(String) - case int(Int64) - case double(Double) - case bool(Bool) - case array([AnyCodableValue]) - case dictionary([String: AnyCodableValue]) - case null - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - return - } - if let v = try? container.decode(Bool.self) { self = .bool(v); return } - if let v = try? container.decode(Int64.self) { self = .int(v); return } - if let v = try? container.decode(Double.self) { self = .double(v); return } - if let v = try? container.decode(String.self) { self = .string(v); return } - if let v = try? container.decode([AnyCodableValue].self) { self = .array(v); return } - if let v = try? container.decode([String: AnyCodableValue].self) { self = .dictionary(v); return } - self = .null - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let v): try container.encode(v) - case .int(let v): try container.encode(v) - case .double(let v): try container.encode(v) - case .bool(let v): try container.encode(v) - case .array(let v): try container.encode(v) - case .dictionary(let v): try container.encode(v) - case .null: try container.encodeNil() - } - } - - var stringValue: String? { - if case .string(let v) = self { return v } - return nil - } - - var intValue: Int64? { - if case .int(let v) = self { return v } - return nil - } - - var doubleValue: Double? { - if case .double(let v) = self { return v } - if case .int(let v) = self { return Double(v) } - return nil - } - - var arrayValue: [AnyCodableValue]? { - if case .array(let v) = self { return v } - return nil - } - - var dictValue: [String: AnyCodableValue]? { - if case .dictionary(let v) = self { return v } - return nil - } -} - -// MARK: - Bear Domain Models - -struct BearNote { - let id: String - let uniqueIdentifier: String - let title: String - let tags: [String] - let pinned: Bool - let archived: Bool - let trashed: Bool - let locked: Bool - let todoCompleted: Int - let todoIncompleted: Int - let creationDate: Date? - let modificationDate: Date? - let textAssetURL: String? - let hasFiles: Bool - - init(from record: CKRecord) { - self.id = record.recordName - self.uniqueIdentifier = record.fields["uniqueIdentifier"]?.value.stringValue ?? record.recordName - self.title = record.fields["title"]?.value.stringValue ?? "(untitled)" - - if let tagsArray = record.fields["tagsStrings"]?.value.arrayValue { - self.tags = tagsArray.compactMap { $0.stringValue } - } else { - self.tags = [] - } - - self.pinned = record.fields["pinned"]?.value.intValue == 1 - self.archived = record.fields["archived"]?.value.intValue == 1 - self.trashed = record.fields["trashed"]?.value.intValue == 1 - self.locked = record.fields["locked"]?.value.intValue == 1 - self.todoCompleted = Int(record.fields["todoCompleted"]?.value.intValue ?? 0) - self.todoIncompleted = Int(record.fields["todoIncompleted"]?.value.intValue ?? 0) - - if let ts = record.fields["sf_creationDate"]?.value.intValue { - self.creationDate = Date(timeIntervalSince1970: Double(ts) / 1000.0) - } else { - self.creationDate = nil - } - - if let ts = record.fields["sf_modificationDate"]?.value.intValue { - self.modificationDate = Date(timeIntervalSince1970: Double(ts) / 1000.0) - } else { - self.modificationDate = nil - } - - // Extract asset download URL from the text field - if let textDict = record.fields["text"]?.value.dictValue, - let url = textDict["downloadURL"]?.stringValue { - self.textAssetURL = url - } else { - self.textAssetURL = nil - } - - self.hasFiles = record.fields["hasFiles"]?.value.intValue == 1 - } -} - -struct BearTag { - let id: String - let title: String - let notesCount: Int - let pinned: Bool - let isRoot: Bool - - init(from record: CKRecord) { - self.id = record.recordName - self.title = record.fields["title"]?.value.stringValue ?? "(unknown)" - self.notesCount = Int(record.fields["notesCount"]?.value.intValue ?? 0) - self.pinned = record.fields["pinned"]?.value.intValue == 1 - self.isRoot = record.fields["isRoot"]?.value.intValue == 1 - } -} - -// MARK: - Auth Config - -struct AuthConfig: Codable { - var ckWebAuthToken: String - let ckAPIToken: String - var savedAt: Date - - static let apiToken = "ce59f955ec47e744f720aa1d2816a4e985e472d8b859b6c7a47b81fd36646307" - - static var configDir: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config") - .appendingPathComponent("bear-cli") - } - - static var configFile: URL { - configDir.appendingPathComponent("auth.json") - } - - static func load() throws -> AuthConfig { - let data = try Data(contentsOf: configFile) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(AuthConfig.self, from: data) - } - - func save() throws { - let dir = AuthConfig.configDir - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(self) - try data.write(to: AuthConfig.configFile) - // Restrict permissions to owner only - try FileManager.default.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: AuthConfig.configFile.path - ) - } -} diff --git a/Sources/bcli/main.swift b/Sources/bcli/main.swift index 5e82ea4..067ce0e 100644 --- a/Sources/bcli/main.swift +++ b/Sources/bcli/main.swift @@ -1,54 +1,3 @@ -import ArgumentParser -import Foundation - -struct BearCLI: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "bcli", - abstract: "CLI for Bear notes via CloudKit", - version: "0.3.0", - subcommands: [ - AuthCommand.self, - ListNotes.self, - GetNote.self, - ListTags.self, - SearchNotes.self, - CreateNote.self, - EditNote.self, - TrashNote.self, - TodoCommand.self, - ExportNotes.self, - SyncCommand.self, - ] - ) -} - -// Shared auth loader -func loadAuth() throws -> AuthConfig { - do { - return try AuthConfig.load() - } catch { - throw BearCLIError.authNotConfigured - } -} - -/// Run an async block synchronously using a semaphore -func runAsync(_ block: @escaping () async throws -> Void) throws { - let semaphore = DispatchSemaphore(value: 0) - var thrownError: Error? - - Task { - do { - try await block() - } catch { - thrownError = error - } - semaphore.signal() - } - - semaphore.wait() - if let error = thrownError { - throw error - } -} +import BearCLICore BearCLI.main() diff --git a/Tests/BearCLITests/ModelsTests.swift b/Tests/BearCLITests/ModelsTests.swift new file mode 100644 index 0000000..f2b5147 --- /dev/null +++ b/Tests/BearCLITests/ModelsTests.swift @@ -0,0 +1,221 @@ +import XCTest +@testable import BearCLICore + +final class AnyCodableValueTests: XCTestCase { + + // MARK: - AnyCodableValue accessors + + func testStringValue() { + let val = AnyCodableValue.string("hello") + XCTAssertEqual(val.stringValue, "hello") + XCTAssertNil(val.intValue) + } + + func testIntValue() { + let val = AnyCodableValue.int(42) + XCTAssertEqual(val.intValue, 42) + XCTAssertNil(val.stringValue) + } + + func testDoubleFromInt() { + let val = AnyCodableValue.int(10) + XCTAssertEqual(val.doubleValue, 10.0) + } + + func testDoubleValue() { + let val = AnyCodableValue.double(3.14) + XCTAssertEqual(val.doubleValue, 3.14) + } + + func testArrayValue() { + let val = AnyCodableValue.array([.string("a"), .string("b")]) + XCTAssertEqual(val.arrayValue?.count, 2) + XCTAssertNil(val.stringValue) + } + + func testDictValue() { + let val = AnyCodableValue.dictionary(["key": .int(1)]) + XCTAssertEqual(val.dictValue?["key"]?.intValue, 1) + } + + func testNullReturnsNil() { + let val = AnyCodableValue.null + XCTAssertNil(val.stringValue) + XCTAssertNil(val.intValue) + XCTAssertNil(val.doubleValue) + XCTAssertNil(val.arrayValue) + XCTAssertNil(val.dictValue) + } + + // MARK: - Codable round-trip + + func testCodableRoundTrip() throws { + let original = AnyCodableValue.dictionary([ + "name": .string("test"), + "count": .int(5), + "active": .bool(true), + "tags": .array([.string("a"), .string("b")]), + "empty": .null, + ]) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AnyCodableValue.self, from: data) + + XCTAssertEqual(decoded.dictValue?["name"]?.stringValue, "test") + XCTAssertEqual(decoded.dictValue?["count"]?.intValue, 5) + XCTAssertEqual(decoded.dictValue?["tags"]?.arrayValue?.count, 2) + } +} + +final class BearNoteTests: XCTestCase { + + func testNoteFromRecord() { + let now = Int64(Date().timeIntervalSince1970 * 1000) + let record = CKRecord( + recordName: "rec-123", + recordType: "SFNote", + fields: [ + "uniqueIdentifier": CKRecordField(value: .string("uid-abc"), type: "STRING"), + "title": CKRecordField(value: .string("My Note"), type: "STRING"), + "tagsStrings": CKRecordField(value: .array([.string("tag1"), .string("tag2")]), type: "STRING_LIST"), + "pinned": CKRecordField(value: .int(1), type: "INT64"), + "archived": CKRecordField(value: .int(0), type: "INT64"), + "trashed": CKRecordField(value: .int(0), type: "INT64"), + "locked": CKRecordField(value: .int(0), type: "INT64"), + "todoCompleted": CKRecordField(value: .int(3), type: "INT64"), + "todoIncompleted": CKRecordField(value: .int(2), type: "INT64"), + "sf_creationDate": CKRecordField(value: .int(now), type: "TIMESTAMP"), + "sf_modificationDate": CKRecordField(value: .int(now), type: "TIMESTAMP"), + "hasFiles": CKRecordField(value: .int(0), type: "INT64"), + ] + ) + + let note = BearNote(from: record) + + XCTAssertEqual(note.id, "rec-123") + XCTAssertEqual(note.uniqueIdentifier, "uid-abc") + XCTAssertEqual(note.title, "My Note") + XCTAssertEqual(note.tags, ["tag1", "tag2"]) + XCTAssertTrue(note.pinned) + XCTAssertFalse(note.archived) + XCTAssertFalse(note.trashed) + XCTAssertEqual(note.todoCompleted, 3) + XCTAssertEqual(note.todoIncompleted, 2) + XCTAssertNotNil(note.creationDate) + XCTAssertNotNil(note.modificationDate) + XCTAssertFalse(note.hasFiles) + } + + func testNoteFromEmptyRecord() { + let record = CKRecord(recordName: "rec-empty") + let note = BearNote(from: record) + + XCTAssertEqual(note.uniqueIdentifier, "rec-empty") + XCTAssertEqual(note.title, "(untitled)") + XCTAssertEqual(note.tags, []) + XCTAssertFalse(note.pinned) + XCTAssertNil(note.creationDate) + } +} + +final class BearTagTests: XCTestCase { + + func testTagFromRecord() { + let record = CKRecord( + recordName: "tag-1", + recordType: "SFNoteTag", + fields: [ + "title": CKRecordField(value: .string("recipes"), type: "STRING"), + "notesCount": CKRecordField(value: .int(12), type: "INT64"), + "pinned": CKRecordField(value: .int(0), type: "INT64"), + "isRoot": CKRecordField(value: .int(1), type: "INT64"), + ] + ) + + let tag = BearTag(from: record) + + XCTAssertEqual(tag.id, "tag-1") + XCTAssertEqual(tag.title, "recipes") + XCTAssertEqual(tag.notesCount, 12) + XCTAssertFalse(tag.pinned) + XCTAssertTrue(tag.isRoot) + } +} + +final class NoteCacheTests: XCTestCase { + + func testIsStaleWhenNoLastSync() { + let cache = NoteCache() + XCTAssertTrue(cache.isStale) + } + + func testIsStaleWhenRecent() { + let cache = NoteCache(lastSyncDate: Date()) + XCTAssertFalse(cache.isStale) + } + + func testIsStaleWhenOld() { + let old = Date().addingTimeInterval(-600) // 10 minutes ago + let cache = NoteCache(lastSyncDate: old) + XCTAssertTrue(cache.isStale) + } + + func testUpsertAndRemove() { + var cache = NoteCache() + let record = CKRecord( + recordName: "note-1", + fields: [ + "uniqueIdentifier": CKRecordField(value: .string("uid-1"), type: nil), + "title": CKRecordField(value: .string("Test"), type: nil), + ] + ) + let cached = CachedNote(from: record, text: "Hello world") + cache.upsert(cached) + + XCTAssertEqual(cache.notes.count, 1) + XCTAssertEqual(cache.notes["note-1"]?.title, "Test") + XCTAssertEqual(cache.notes["note-1"]?.text, "Hello world") + + cache.remove(recordName: "note-1") + XCTAssertEqual(cache.notes.count, 0) + } + + func testMarkTrashed() { + var cache = NoteCache() + let record = CKRecord( + recordName: "note-2", + fields: [ + "uniqueIdentifier": CKRecordField(value: .string("uid-2"), type: nil), + "title": CKRecordField(value: .string("To trash"), type: nil), + "trashed": CKRecordField(value: .int(0), type: nil), + ] + ) + let cached = CachedNote(from: record, text: "content") + cache.upsert(cached) + + XCTAssertFalse(cache.notes["note-2"]!.trashed) + + cache.markTrashed(recordName: "note-2") + XCTAssertTrue(cache.notes["note-2"]!.trashed) + } +} + +final class SyncStatsTests: XCTestCase { + + func testSummaryEmpty() { + let stats = SyncStats() + XCTAssertEqual(stats.summary, "no changes") + XCTAssertEqual(stats.total, 0) + } + + func testSummaryWithChanges() { + var stats = SyncStats() + stats.added = 3 + stats.updated = 1 + stats.deleted = 2 + XCTAssertEqual(stats.total, 6) + XCTAssertTrue(stats.summary.contains("3 new")) + XCTAssertTrue(stats.summary.contains("1 updated")) + XCTAssertTrue(stats.summary.contains("2 deleted")) + } +}