diff --git a/.gitignore b/.gitignore index 8a203c6..968ab18 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* @@ -51,3 +52,5 @@ xcuserdata /contacts contacts.tar.gz + +/.vscode \ No newline at end of file diff --git a/Makefile b/Makefile index 4aba08e..286d7c4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ PREFIX ?= /usr/local/bin .PHONY: archive clean install uninstall $(EXECUTABLE): - swift build --configuration release --arch x86_64 --arch arm64 + swift build --configuration release --arch x86_64 --arch arm64 -Xswiftc -parse-as-library cp .build/apple/Products/Release/$(EXECUTABLE) . install: $(EXECUTABLE) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..59e4a01 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41", + "version" : "1.3.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 22f86c9..850c08b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,18 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.9 import PackageDescription let package = Package( name: "contacts", + platforms: [ + .macOS(.v13), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + ], + targets: [ - .executableTarget(name: "contacts"), + .executableTarget(name: "contacts", dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ]), ] ) diff --git a/README.md b/README.md index 103339b..8abfb7b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,38 @@ # contacts-cli -A simple script for querying contacts from the command line. +A simple tool for querying and exporting the macOS contacts database from the command line. ## Usage: +Here's an overview of the commands: + +```sh +OVERVIEW: Query and export contacts from the command line + +USAGE: contacts-command [--version] [--output-type ] [--search-field ] + +ARGUMENTS: + The query input. Use '-' to read from stdin. + +OPTIONS: + --version Print out the version of the application. + --output-type + (values: csv, json; default: csv) + --search-field + (values: fullName, firstName, lastName, email, all; default: all) + -h, --help Show help information. +``` + +And some specific examples: + ```sh $ contacts query -NAME EMAIL -query@example.com First Last +fullName,firstName,lastName,email +First Last,First,Last,query@example.com,First Last + +$ echo '{"rowid": 1, "Name": "First Last"}' | jq -r '.Name' | xargs -I{} contacts {} --search-field fullName --output-type json | jq -r '.[0].email' | tr -d '\n' +fullName,firstName,lastName,email +First Last,First,Last,query@example.com,First Last ``` ## Installation diff --git a/Sources/contacts/main.swift b/Sources/contacts/main.swift index a7d54ff..8879b97 100644 --- a/Sources/contacts/main.swift +++ b/Sources/contacts/main.swift @@ -1,43 +1,175 @@ import AddressBook +import ArgumentParser -let arguments = CommandLine.arguments.dropFirst() -if arguments.isEmpty { - fputs("No arguments given\n", stderr) - exit(EXIT_FAILURE) +enum OutputType: String, CaseIterable, ExpressibleByArgument { + case csv, json } -guard let addressBook = ABAddressBook.shared() else { - fputs("Failed to create address book (check your Contacts privacy settings)\n", stderr) - exit(EXIT_FAILURE) +enum SearchFieldType: String, CaseIterable, ExpressibleByArgument { + case fullName, firstName, lastName, email, all } -private func comparison(forProperty property: String, string: String) -> ABSearchElement { - let comparison: ABSearchComparison = CFIndex(kABContainsSubStringCaseInsensitive.rawValue) - return ABPerson.searchElement(forProperty: property, label: nil, key: nil, value: string, - comparison: comparison) +@main +struct ContactsCommand: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Query and export contacts from the command line" + ) + + @Flag(help: "Print out the version of the application.") + var version = false + + @Argument(help: "The query input. Use '-' to read from stdin.") + var searchString: String + + @Option + var outputType: OutputType = .csv + + @Option + var searchField: SearchFieldType = .all + + mutating func run() throws { + if version { + // we cannot get the latest tag version at compile time + // https://stackoverflow.com/questions/27804227/using-compiler-variables-in-swift + print("v1.1") + return + } + + guard let addressBook = ABAddressBook.shared() else { + fputs("Failed to create address book (check your Contacts privacy settings)\n", stderr) + ContactsCommand.exit(withError: ExitCode.failure) + } + + if searchString == "-" { + searchString = readLine() ?? "" + + if searchString.isEmpty { + fputs("No search input provided through stdin\n", stderr) + ContactsCommand.exit(withError: ExitCode.failure) + } + } + + var searchComparison: ABSearchElement + + switch searchField { + case .all: + // search fn, ln, and email fields for the search input + searchComparison = ABSearchElement( + forConjunction: CFIndex(kABSearchOr.rawValue), + children: [ + comparison(forProperty: kABFirstNameProperty, string: searchString), + comparison(forProperty: kABLastNameProperty, string: searchString), + comparison(forProperty: kABEmailProperty, string: searchString), + ] + ) + case .fullName: + let nameParts = searchString.split(separator: " ", maxSplits: 1) + let firstName = nameParts.count > 0 ? String(nameParts[0]) : "" + let lastName = nameParts.count > 1 ? String(nameParts[1]) : "" + searchComparison = ABSearchElement( + forConjunction: CFIndex(kABSearchAnd.rawValue), + children: [ + comparison(forProperty: kABFirstNameProperty, string: firstName), + comparison(forProperty: kABLastNameProperty, string: lastName), + ] + ) + case .firstName: + searchComparison = ABSearchElement( + forConjunction: CFIndex(kABSearchAnd.rawValue), + children: [ + comparison(forProperty: kABFirstNameProperty, string: searchString), + ] + ) + case .lastName: + searchComparison = ABSearchElement( + forConjunction: CFIndex(kABSearchAnd.rawValue), + children: [ + comparison(forProperty: kABLastNameProperty, string: searchString), + ] + ) + case .email: + searchComparison = ABSearchElement( + forConjunction: CFIndex(kABSearchAnd.rawValue), + children: [ + comparison(forProperty: kABEmailProperty, string: searchString), + ] + ) + } + + let found = addressBook.records(matching: searchComparison) as? [ABRecord] ?? [] + + if found.count == 0 { + fputs("No contacts found\n", stderr) + ContactsCommand.exit(withError: ExitCode.failure) + } + + var results: [[String: String]] = [] + + for person in found { + let firstName = person.value(forProperty: kABFirstNameProperty) as? String ?? "" + let lastName = person.value(forProperty: kABLastNameProperty) as? String ?? "" + + let emailsProperty = person.value(forProperty: kABEmailProperty) as? ABMultiValue + + // TODO: think of a better way to handle multiple phones + // although there can be more than a single phone number, we'll assume there can only be one for now + let phonesProperty = person.value(forProperty: kABPhoneProperty) as? ABMultiValue + let phone = phonesProperty?.value(at: 0) as? String ?? "" + + if let emails = emailsProperty { + for i in 0 ..< emails.count() { + let email = emails.value(at: i) as? String ?? "" + let result: [String: String] = [ + "firstName": firstName, + "lastName": lastName, + "fullName": "\(firstName) \(lastName)", + "email": email, + "phone": phone, + ] + results.append(result) + } + } else { + let result: [String: String] = [ + "firstName": firstName, + "lastName": lastName, + "fullName": "\(firstName) \(lastName)", + "email": "", + "phone": phone, + ] + results.append(result) + } + } + + renderOuput(rows: results, outputType: outputType) + } } -let searchString = arguments.joined(separator: " ") -let firstNameSearch = comparison(forProperty: kABFirstNameProperty, string: searchString) -let lastNameSearch = comparison(forProperty: kABLastNameProperty, string: searchString) -let emailSearch = comparison(forProperty: kABEmailProperty, string: searchString) -let orComparison = ABSearchElement(forConjunction: CFIndex(kABSearchOr.rawValue), - children: [firstNameSearch, lastNameSearch, emailSearch]) +func renderOuput(rows: [[String: String]], outputType: OutputType) { + if outputType == .csv { + print(renderCSV(rows)) + return + } -let found = addressBook.records(matching: orComparison) as? [ABRecord] ?? [] -if found.count == 0 { - exit(EXIT_SUCCESS) + let jsonData = try? JSONSerialization.data(withJSONObject: rows, options: .prettyPrinted) + let jsonString = String(data: jsonData!, encoding: .utf8) + print(jsonString!) } -print("NAME\tEMAIL") -for person in found { - let firstName = person.value(forProperty: kABFirstNameProperty) as? String ?? "" - let lastName = person.value(forProperty: kABLastNameProperty) as? String ?? "" - let emailsProperty = person.value(forProperty: kABEmailProperty) as? ABMultiValue - if let emails = emailsProperty { - for i in 0.. String { + guard let firstRow = rows.first else { + return "" } + + let header = firstRow.keys.joined(separator: ",") + let values = rows.map { row in + row.values.joined(separator: ",") + }.joined(separator: "\n") + + return header + "\n" + values +} + +private func comparison(forProperty property: String, string: String) -> ABSearchElement { + let comparison: ABSearchComparison = CFIndex(kABContainsSubStringCaseInsensitive.rawValue) + return ABPerson.searchElement(forProperty: property, label: nil, key: nil, value: string, + comparison: comparison) }