├── Cartfile.private ├── Cartfile.resolved ├── Misc ├── screenshot-1.png └── ReadingListCalendar.sketch ├── ReadingListCalendarApp ├── ModalAlert │ ├── NSAlert+ModalAlert.swift │ ├── ModalAlert.swift │ ├── ModalAlertCreating.swift │ ├── ModalAlertCreating+Error.swift │ ├── ModalAlert+Functions.swift │ ├── ModalAlertFactory.swift │ └── ModalAlertCreating+CalendarAuthorizationDenied.swift ├── Calendar │ ├── EKEventStore+EventSaving.swift │ ├── EKEventStore+CalendarProviding.swift │ ├── EKEventStore+CalendarAuthorizng.swift │ ├── EKEventStore+CalendarsProviding.swift │ ├── EventSaving.swift │ ├── CalendarProviding.swift │ ├── CalendarsProviding.swift │ ├── CalendarIdStoring.swift │ ├── CalendarAuthorizing.swift │ ├── CalendarsProviding+EventCalendars.swift │ ├── EKAuthorizationStatus+Text.swift │ ├── UserDefaults+CalendarIdStoring.swift │ └── CalendarAuthorizing+Combine.swift ├── FileReader │ ├── FileManager+FileReading.swift │ ├── FileReading.swift │ └── FileReading+URL.swift ├── FileReadablity │ ├── FileManager+FileReadablity.swift │ ├── FileReadablity.swift │ ├── FileReadablity+URL.swift │ └── FileReadability+Functions.swift ├── Bookmarks │ ├── URIDict.swift │ ├── BookmarksLoading.swift │ ├── ReadingListItem.swift │ ├── ReadingListInfo.swift │ ├── Bookmark.swift │ ├── BookmarksLoader.swift │ ├── BookmarksLoadingError.swift │ ├── ReadingListError.swift │ └── Bookmark+ReadingList.swift ├── Resources │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-128pt-1x.png │ │ │ ├── Icon-128pt-2x.png │ │ │ ├── Icon-16pt-1x.png │ │ │ ├── Icon-16pt-2x.png │ │ │ ├── Icon-256pt-1x.png │ │ │ ├── Icon-256pt-2x.png │ │ │ ├── Icon-32pt-1x.png │ │ │ ├── Icon-32pt-2x.png │ │ │ ├── Icon-512pt-1x.png │ │ │ ├── Icon-512pt-2x.png │ │ │ └── Contents.json │ ├── ReadingListCalendarApp.entitlements │ ├── Info.plist │ └── Base.lproj │ │ └── Main.storyboard ├── FileOpener │ ├── FileOpening.swift │ ├── FileOpenerCreating.swift │ ├── FileOpener+Functions.swift │ ├── NSOpenPanel+FileOpening.swift │ ├── FileOpenerCreating+BookmarksFileOpener.swift │ ├── FileOpening+Combine.swift │ └── FileOpenerFactory.swift ├── Application │ ├── AppTerminating.swift │ ├── main.swift │ └── AppDelegate.swift ├── Helpers │ ├── URL+Functions.swift │ ├── Optional+OrThrow.swift │ ├── Error+Title.swift │ ├── NSProgressIndicator+Combine.swift │ ├── Error+Message.swift │ ├── NSPopUpButton+Combine.swift │ ├── Publisher+ReceiveOptionally.swift │ └── CustomPublisher.swift ├── Sync │ ├── ReadingListItemEventProviding.swift │ ├── ReadingListItemEventCreating.swift │ ├── SyncControlling.swift │ ├── SyncError.swift │ ├── EKEventStore+ReadingListItemEventCreating.swift │ ├── EKEventStore+ReadingListItemEventProviding.swift │ └── SyncController.swift ├── MainWindow │ ├── MainWindowController.swift │ ├── MainWindowControllerCreating.swift │ ├── MainWindowControllerFactory.swift │ └── MainViewController.swift ├── FileBookmarks │ ├── FileBookmarking.swift │ ├── FileBookmarking+Bookmarks.swift │ └── UserDefaults+FileBookmarking.swift └── .swiftlint.yml ├── ReadingListCalendarAppTests ├── Resources │ ├── Bookmarks.plist │ └── Info.plist ├── Doubles │ ├── FileReadabilityDouble.swift │ ├── CalendarsProvidingDouble.swift │ ├── ReadingListItemEventProvidingDouble.swift │ ├── ModalAlertDouble.swift │ ├── NSWindowControllerDouble.swift │ ├── AppTerminatingDouble.swift │ ├── EventSavingDouble.swift │ ├── FileReadingDouble.swift │ ├── BookmarskLoadingDouble.swift │ ├── FileOpeningDouble.swift │ ├── CalendarProvidingDouble.swift │ ├── EKCalendarDouble.swift │ ├── FileOpenerCreatingDouble.swift │ ├── ModalAlertCreatingDouble.swift │ ├── NSOpenPanelDouble.swift │ ├── ReadingListItemEventCreatingDouble.swift │ ├── CalendarAuthorizingDouble.swift │ ├── CalendarIdStoringDouble.swift │ ├── FileBookmarkingDouble.swift │ ├── SyncControllingDouble.swift │ ├── EKEventStoreDouble.swift │ └── MainWindowControllerCreatingDouble.swift ├── Helpers │ ├── NSView+AccessibilityElement.swift │ ├── ErrorTitleSpec.swift │ ├── ErrorMessageSpec.swift │ └── OptionalOrThrowSpec.swift ├── Bookmarks │ ├── Bookmark+Fake.swift │ ├── BookmarksLoadingErrorSpec.swift │ ├── ReadingListErrorSpec.swift │ ├── BookmarksLoaderSpec.swift │ └── BookmarkReadingListSpec.swift ├── .swiftlint.yml ├── Sync │ ├── SyncErrorSpec.swift │ ├── EKEventStoreReadingListItemEventCreatingSpec.swift │ ├── EKEventStoreReadingListItemEventProvidingSpec.swift │ └── SyncControllerSpec.swift ├── TestHelpers │ └── Publisher+Materialize.swift ├── ModalAlert │ └── ModalAlertFactorySpec.swift ├── Calendar │ └── UserDefaultsCalendarIdStoringSpec.swift ├── FileOpener │ ├── FileOpenerFactorySpec.swift │ └── NSOpenPanelFileOpeningSpec.swift ├── FileBookmarks │ └── UserDefaultsFileBookmarkingSpec.swift ├── MainWindow │ ├── MainWindowControllerSpec.swift │ └── MainViewControllerSpec.swift └── Application │ └── AppDelegateSpec.swift ├── setup.sh ├── .gitignore ├── README.md └── ReadingListCalendar.xcodeproj └── xcshareddata └── xcschemes └── ReadingListCalendar.xcscheme /Cartfile.private: -------------------------------------------------------------------------------- 1 | github "Quick/Quick" ~> 2.0 2 | github "Quick/Nimble" ~> 8.0 3 | -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | github "Quick/Nimble" "v8.0.4" 2 | github "Quick/Quick" "v2.2.0" 3 | -------------------------------------------------------------------------------- /Misc/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/Misc/screenshot-1.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/ModalAlert/NSAlert+ModalAlert.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSAlert: ModalAlert {} 4 | -------------------------------------------------------------------------------- /Misc/ReadingListCalendar.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/Misc/ReadingListCalendar.sketch -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/EKEventStore+EventSaving.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKEventStore: EventSaving {} 4 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileReader/FileManager+FileReading.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FileManager: FileReading {} 4 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/EKEventStore+CalendarProviding.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKEventStore: CalendarProviding {} 4 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileReadablity/FileManager+FileReadablity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FileManager: FileReadablity {} 4 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/URIDict.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct URIDict: Equatable, Decodable { 4 | let title: String 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/EKEventStore+CalendarAuthorizng.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKEventStore: CalendarAuthorizing {} 4 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/EKEventStore+CalendarsProviding.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKEventStore: CalendarsProviding {} 4 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileReader/FileReading.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FileReading { 4 | func contents(atPath path: String) -> Data? 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileOpener/FileOpening.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FileOpening { 4 | func openFile(completion: @escaping (URL?) -> Void) 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/BookmarksLoading.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol BookmarksLoading { 4 | func load(fromURL url: URL) throws -> Bookmark 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/EventSaving.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | protocol EventSaving { 4 | func save(_ event: EKEvent, span: EKSpan, commit: Bool) throws 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileReadablity/FileReadablity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FileReadablity { 4 | func isReadableFile(atPath path: String) -> Bool 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Resources/Bookmarks.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarAppTests/Resources/Bookmarks.plist -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/CalendarProviding.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | protocol CalendarProviding { 4 | func calendar(withIdentifier: String) -> EKCalendar? 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/ModalAlert/ModalAlert.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | protocol ModalAlert { 4 | @discardableResult 5 | func runModal() -> NSApplication.ModalResponse 6 | } 7 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/CalendarsProviding.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | protocol CalendarsProviding { 4 | func calendars(for entityType: EKEntityType) -> [EKCalendar] 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileOpener/FileOpenerCreating.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FileOpenerCreating { 4 | func create(title: String, ext: String, url: URL?) -> FileOpening 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Application/AppTerminating.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | protocol AppTerminating { 4 | func terminate(_ sender: Any?) 5 | } 6 | 7 | extension NSApplication: AppTerminating {} 8 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/ModalAlert/ModalAlertCreating.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | protocol ModalAlertCreating { 4 | func create(style: NSAlert.Style, title: String, message: String) -> ModalAlert 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Helpers/URL+Functions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func filePath(_ filename: String) -> (URL?) -> String { 4 | return { $0?.absoluteString ?? "❌ \(filename) file is not set" } 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Sync/ReadingListItemEventProviding.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | protocol ReadingListItemEventProviding { 4 | func event(for item: ReadingListItem, in calendar: EKCalendar) -> EKEvent? 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Sync/ReadingListItemEventCreating.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | protocol ReadingListItemEventCreating { 4 | func createEvent(for item: ReadingListItem, in calendar: EKCalendar) -> EKEvent 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-128pt-1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-128pt-1x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-128pt-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-128pt-2x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-16pt-1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-16pt-1x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-16pt-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-16pt-2x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256pt-1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256pt-1x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256pt-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256pt-2x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32pt-1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32pt-1x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32pt-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32pt-2x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512pt-1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512pt-1x.png -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512pt-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elpassion/ReadingListCalendarApp/HEAD/ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512pt-2x.png -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/FileReadabilityDouble.swift: -------------------------------------------------------------------------------- 1 | @testable import ReadingListCalendarApp 2 | 3 | class FileReadabilityDouble: FileReadablity { 4 | func isReadableFile(atPath path: String) -> Bool { return false } 5 | } 6 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/CalendarIdStoring.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | protocol CalendarIdStoring { 4 | func calendarId() -> AnyPublisher 5 | func setCalendarId(_ id: String?) -> AnyPublisher 6 | } 7 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/MainWindow/MainWindowController.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | class MainWindowController: NSWindowController { 4 | var mainViewController: MainViewController! { 5 | contentViewController as? MainViewController 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/ReadingListItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ReadingListItem: Equatable { 4 | let uuid: String 5 | let title: String 6 | let url: String 7 | let dateAdded: Date 8 | let previewText: String? 9 | } 10 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Application/main.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | let app = NSApplication.shared 4 | let delegate = NSClassFromString("XCTestCase") == nil ? AppDelegate() : nil 5 | 6 | app.delegate = delegate 7 | _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) 8 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileBookmarks/FileBookmarking.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | protocol FileBookmarking { 5 | func fileURL(forKey key: String) -> AnyPublisher 6 | func setFileURL(_ url: URL?, forKey key: String) -> AnyPublisher 7 | } 8 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Helpers/Optional+OrThrow.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Optional { 4 | func or(_ error: Error) throws -> Wrapped { 5 | guard let wrapped = self else { 6 | throw error 7 | } 8 | return wrapped 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileOpener/FileOpener+Functions.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | func openBookmarksFile(_ openerFactory: FileOpenerCreating) -> () -> AnyPublisher { 5 | return { 6 | openerFactory.createBookmarksFileOpener().openFile() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if ! which carthage > /dev/null; then 4 | echo "error: Carthage is not installed. Visit https://github.com/Carthage/Carthage to learn more." 5 | exit 1 6 | fi 7 | 8 | carthage bootstrap \ 9 | --platform macOS \ 10 | --no-use-binaries \ 11 | --cache-builds 12 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Helpers/Error+Title.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Error { 4 | var title: String { 5 | if let error = self as? LocalizedError, let description = error.errorDescription { 6 | return description 7 | } 8 | return "Error occured" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Sync/SyncControlling.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | protocol SyncControlling { 5 | func isSynchronizing() -> AnyPublisher 6 | func syncProgress() -> AnyPublisher 7 | func sync(bookmarksUrl: URL, calendarId: String) -> AnyPublisher 8 | } 9 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/CalendarAuthorizing.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | protocol CalendarAuthorizing { 4 | static func authorizationStatus(for entityType: EKEntityType) -> EKAuthorizationStatus 5 | func requestAccess(to entityType: EKEntityType, completion: @escaping EKEventStoreRequestAccessCompletionHandler) 6 | } 7 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/CalendarsProvidingDouble.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class CalendarsProvidingDouble: CalendarsProviding { 5 | var mockedCalendars = [EKCalendarDouble]() 6 | 7 | func calendars(for entityType: EKEntityType) -> [EKCalendar] { return mockedCalendars } 8 | } 9 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/ReadingListItemEventProvidingDouble.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class ReadingListItemEventProvidingDouble: ReadingListItemEventProviding { 5 | func event(for item: ReadingListItem, in calendar: EKCalendar) -> EKEvent? { 6 | return nil 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/ReadingListInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ReadingListInfo: Equatable, Decodable { 4 | let dateAdded: Date 5 | let previewText: String? 6 | 7 | enum CodingKeys: String, CodingKey { 8 | case dateAdded = "DateAdded" 9 | case previewText = "PreviewText" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Helpers/NSProgressIndicator+Combine.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Combine 3 | 4 | extension NSProgressIndicator { 5 | func update(fractionCompleted: Double?) { 6 | doubleValue = fractionCompleted.map { 7 | self.minValue + (self.maxValue - self.minValue) * $0 8 | } ?? 0 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/ModalAlert/ModalAlertCreating+Error.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension ModalAlertCreating { 4 | func createError(_ error: Error) -> ModalAlert { 5 | return create( 6 | style: .critical, 7 | title: error.title, 8 | message: error.message 9 | ) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/ModalAlertDouble.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class ModalAlertDouble: ModalAlert { 5 | private(set) var didRunModal = false 6 | 7 | func runModal() -> NSApplication.ModalResponse { 8 | didRunModal = true 9 | return .OK 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileReader/FileReading+URL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FileReading { 4 | func contents(atURL url: URL) -> Data? { 5 | _ = url.startAccessingSecurityScopedResource() 6 | defer { url.stopAccessingSecurityScopedResource() } 7 | return self.contents(atPath: url.path) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/ModalAlert/ModalAlert+Functions.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | func presentAlertForCalendarAuth(_ alertFactory: ModalAlertCreating) -> (EKAuthorizationStatus) -> Void { 4 | return { status in 5 | guard status != .authorized else { return } 6 | alertFactory.createCalendarAccessDenied().runModal() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileReadablity/FileReadablity+URL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FileReadablity { 4 | func isReadableFile(atURL url: URL) -> Bool { 5 | _ = url.startAccessingSecurityScopedResource() 6 | defer { url.stopAccessingSecurityScopedResource() } 7 | return isReadableFile(atPath: url.path) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Helpers/Error+Message.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Error { 4 | var message: String { 5 | guard let error = self as? LocalizedError else { 6 | return localizedDescription 7 | } 8 | guard let reason = error.failureReason else { 9 | return "" 10 | } 11 | return reason 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/NSWindowControllerDouble.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | class NSWindowControllerDouble: NSWindowController { 4 | private(set) var didShowWindow = false 5 | private(set) var didShowWindowSender: Any? 6 | 7 | override func showWindow(_ sender: Any?) { 8 | didShowWindow = true 9 | didShowWindowSender = sender 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/AppTerminatingDouble.swift: -------------------------------------------------------------------------------- 1 | @testable import ReadingListCalendarApp 2 | 3 | class AppTerminatingDouble: AppTerminating { 4 | private(set) var didTerminate = false 5 | private(set) var didTerminateWithSender: Any? 6 | 7 | func terminate(_ sender: Any?) { 8 | didTerminate = true 9 | didTerminateWithSender = sender 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/EventSavingDouble.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class EventSavingDouble: EventSaving { 5 | private(set) var savedEvents = [(event: EKEvent, span: EKSpan, commit: Bool)]() 6 | 7 | func save(_ event: EKEvent, span: EKSpan, commit: Bool) throws { 8 | savedEvents.append((event, span, commit)) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/FileReadingDouble.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import ReadingListCalendarApp 3 | 4 | class FileReadingDouble: FileReading { 5 | var mockedData: Data? 6 | private(set) var didReadContentsAtPath: String? 7 | 8 | func contents(atPath path: String) -> Data? { 9 | didReadContentsAtPath = path 10 | return mockedData 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileOpener/NSOpenPanel+FileOpening.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSOpenPanel: FileOpening { 4 | func openFile(completion: @escaping (URL?) -> Void) { 5 | begin { response in 6 | if response == .OK { 7 | completion(self.url) 8 | } else { 9 | completion(nil) 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/ModalAlert/ModalAlertFactory.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | struct ModalAlertFactory: ModalAlertCreating { 4 | func create(style: NSAlert.Style, title: String, message: String) -> ModalAlert { 5 | let alert = NSAlert() 6 | alert.alertStyle = style 7 | alert.messageText = title 8 | alert.informativeText = message 9 | return alert 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Helpers/NSPopUpButton+Combine.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Combine 3 | 4 | extension NSPopUpButton { 5 | func updateItems(_ items: (titles: [String], selected: Int?)) { 6 | removeAllItems() 7 | let titles = items.titles.enumerated().map { "\($0.offset + 1). \($0.element)" } 8 | addItems(withTitles: titles) 9 | selectItem(at: items.selected ?? -1) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/BookmarskLoadingDouble.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import ReadingListCalendarApp 3 | 4 | class BookmarskLoadingDouble: BookmarksLoading { 5 | var mockedBookmarks = Bookmark.fake() 6 | private(set) var didLoadFromURL: URL? 7 | 8 | func load(fromURL url: URL) throws -> Bookmark { 9 | didLoadFromURL = url 10 | return mockedBookmarks 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileOpener/FileOpenerCreating+BookmarksFileOpener.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FileOpenerCreating { 4 | func createBookmarksFileOpener() -> FileOpening { 5 | return create( 6 | title: "Open Bookmarks.plist file", 7 | ext: "plist", 8 | url: URL(fileURLWithPath: "/Users/\(NSUserName())/Library/Safari/Bookmarks.plist") 9 | ) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileBookmarks/FileBookmarking+Bookmarks.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension FileBookmarking { 5 | 6 | func bookmarksFileURL() -> AnyPublisher { 7 | fileURL(forKey: "bookmarks_file_url") 8 | } 9 | 10 | func setBookmarksFileURL(_ url: URL?) -> AnyPublisher { 11 | setFileURL(url, forKey: "bookmarks_file_url") 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/ModalAlert/ModalAlertCreating+CalendarAuthorizationDenied.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension ModalAlertCreating { 4 | func createCalendarAccessDenied() -> ModalAlert { 5 | return create( 6 | style: .warning, 7 | title: "Calendar Access Denied", 8 | message: "Open System Preferences, Security & Privacy and allow the app to access Calendar." 9 | ) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Sync/SyncError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum SyncError: Error { 4 | case calendarNotFound 5 | } 6 | 7 | extension SyncError: LocalizedError { 8 | var errorDescription: String? { 9 | return "Sync Error" 10 | } 11 | 12 | var failureReason: String? { 13 | switch self { 14 | case .calendarNotFound: 15 | return "Calendar not found" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/CalendarsProviding+EventCalendars.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import EventKit 3 | 4 | extension CalendarsProviding { 5 | func eventCalendars() -> AnyPublisher<[EKCalendar], Never> { 6 | CustomPublisher(request: { subscriber, _ in 7 | _ = subscriber.receive(self.calendars(for: .event)) 8 | subscriber.receive(completion: .finished) 9 | }).eraseToAnyPublisher() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/FileOpeningDouble.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import ReadingListCalendarApp 3 | 4 | class FileOpeningDouble: FileOpening { 5 | private(set) var didBeginOpeningFile = false 6 | private(set) var openFileCompletion: ((URL?) -> Void)? 7 | 8 | func openFile(completion: @escaping (URL?) -> Void) { 9 | didBeginOpeningFile = true 10 | openFileCompletion = completion 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Helpers/Publisher+ReceiveOptionally.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Publisher { 4 | func receive( 5 | optionallyOn scheduler: S?, 6 | options: S.SchedulerOptions? = nil 7 | ) -> AnyPublisher where S: Scheduler { 8 | guard let scheduler = scheduler else { return eraseToAnyPublisher() } 9 | return receive(on: scheduler, options: options).eraseToAnyPublisher() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/ReadingListCalendarApp.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.personal-information.calendars 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Helpers/NSView+AccessibilityElement.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSView { 4 | func accessibilityElement(id: String) -> T? { 5 | if accessibilityIdentifier() == id, let element = self as? T { 6 | return element 7 | } 8 | for view in subviews { 9 | if let element: T = view.accessibilityElement(id: id) { 10 | return element 11 | } 12 | } 13 | return nil 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/CalendarProvidingDouble.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class CalendarProvidingDouble: CalendarProviding { 5 | var mockedCalendar: EKCalendar? = EKCalendar(for: .event, eventStore: EKEventStore()) 6 | private(set) var didLoadCalendarWithIdentifier: String? 7 | 8 | func calendar(withIdentifier: String) -> EKCalendar? { 9 | didLoadCalendarWithIdentifier = withIdentifier 10 | return mockedCalendar 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileOpener/FileOpening+Combine.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension FileOpening { 5 | func openFile() -> AnyPublisher { 6 | CustomPublisher(request: { subscriber, _ in 7 | self.openFile { url in 8 | if let url = url { 9 | _ = subscriber.receive(url) 10 | } 11 | subscriber.receive(completion: .finished) 12 | } 13 | }).eraseToAnyPublisher() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Bookmarks/Bookmark+Fake.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import ReadingListCalendarApp 3 | 4 | extension Bookmark { 5 | static func fakeData() -> Data { 6 | let bundle = Bundle(for: BookmarksLoaderSpec.self) 7 | let path = bundle.path(forResource: "Bookmarks", ofType: "plist")! 8 | return FileManager.default.contents(atPath: path)! 9 | } 10 | 11 | static func fake() -> Bookmark { 12 | return try! PropertyListDecoder().decode(Bookmark.self, from: fakeData()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/EKCalendarDouble.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | class EKCalendarDouble: EKCalendar { 4 | init(id: String, title: String) { 5 | fakeId = id 6 | fakeTitle = title 7 | super.init() 8 | } 9 | 10 | override var calendarIdentifier: String { 11 | return fakeId 12 | } 13 | 14 | override var title: String { 15 | get { return fakeTitle } 16 | set { fakeTitle = newValue } 17 | } 18 | 19 | private let fakeId: String 20 | private var fakeTitle: String 21 | } 22 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Sync/EKEventStore+ReadingListItemEventCreating.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKEventStore: ReadingListItemEventCreating { 4 | func createEvent(for item: ReadingListItem, in calendar: EKCalendar) -> EKEvent { 5 | let event = EKEvent(eventStore: self) 6 | event.startDate = item.dateAdded 7 | event.endDate = item.dateAdded 8 | event.calendar = calendar 9 | event.url = URL(string: item.url) 10 | event.title = item.title 11 | event.notes = item.previewText 12 | return event 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/Bookmark.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Bookmark: Equatable, Decodable { 4 | let uuid: String 5 | let title: String? 6 | let children: [Bookmark]? 7 | let uri: URIDict? 8 | let url: String? 9 | let readingList: ReadingListInfo? 10 | 11 | enum CodingKeys: String, CodingKey { 12 | case uuid = "WebBookmarkUUID" 13 | case children = "Children" 14 | case title = "Title" 15 | case uri = "URIDictionary" 16 | case url = "URLString" 17 | case readingList = "ReadingList" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/EKAuthorizationStatus+Text.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKAuthorizationStatus { 4 | var text: String { 5 | switch self { 6 | case .authorized: 7 | return "✓ Callendar access authorized" 8 | case .notDetermined: 9 | return "❌ Callendar access not determined" 10 | case .restricted: 11 | return "❌ Callendar access restricted" 12 | case .denied: 13 | fallthrough 14 | @unknown default: 15 | return "❌ Callendar access denied" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/FileOpenerCreatingDouble.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class FileOpenerCreatingDouble: FileOpenerCreating { 5 | var openerDouble = FileOpeningDouble() 6 | private(set) var didCreateWithTitle: String? 7 | private(set) var didCreateWithExt: String? 8 | private(set) var didCreateWithUrl: URL? 9 | 10 | func create(title: String, ext: String, url: URL?) -> FileOpening { 11 | didCreateWithTitle = title 12 | didCreateWithExt = ext 13 | didCreateWithUrl = url 14 | return openerDouble 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/MainWindow/MainWindowControllerCreating.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | protocol MainWindowControllerCreating { 4 | // swiftlint:disable:next function_parameter_count 5 | func create( 6 | fileOpenerFactory: FileOpenerCreating, 7 | fileBookmarks: FileBookmarking, 8 | fileReadability: FileReadablity, 9 | calendarAuthorizer: CalendarAuthorizing, 10 | alertFactory: ModalAlertCreating, 11 | calendarsProvider: CalendarsProviding, 12 | calendarIdStore: CalendarIdStoring, 13 | syncController: SyncControlling 14 | ) -> NSWindowController 15 | } 16 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - file_name 3 | - empty_count 4 | - vertical_whitespace 5 | - vertical_parameter_alignment_on_call 6 | - nimble_operator 7 | - single_test_class 8 | - quick_discouraged_focused_test 9 | 10 | disabled_rules: 11 | - force_try 12 | - force_cast 13 | - force_unwrapping 14 | - type_name 15 | - line_length 16 | - file_length 17 | - function_parameter_count 18 | - cyclomatic_complexity 19 | - function_body_length 20 | - type_body_length 21 | - identifier_name 22 | - large_tuple 23 | 24 | line_length: 25 | warning: 120 26 | error: 200 27 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Sync/SyncErrorSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | @testable import ReadingListCalendarApp 4 | 5 | class SyncErrorSpec: QuickSpec { 6 | override func spec() { 7 | describe("calendar not found") { 8 | var error: SyncError! 9 | 10 | beforeEach { 11 | error = .calendarNotFound 12 | } 13 | 14 | it("should be correctly localized") { 15 | expect(error.errorDescription) == "Sync Error" 16 | expect(error.failureReason) == "Calendar not found" 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileReadablity/FileReadability+Functions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func isReadableFile(_ fileReadability: FileReadablity) -> (URL?) -> Bool { 4 | return { url in url.map(fileReadability.isReadableFile(atURL:)) ?? false } 5 | } 6 | 7 | func fileReadabilityStatus(_ filename: String, _ fileReadability: FileReadablity) -> (URL?) -> String { 8 | return { url in 9 | guard let url = url else { return "" } 10 | guard fileReadability.isReadableFile(atURL: url) else { return "❌ \(filename) file is not readable" } 11 | return "✓ \(filename) file is set and readable" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Sync/EKEventStore+ReadingListItemEventProviding.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKEventStore: ReadingListItemEventProviding { 4 | func event(for item: ReadingListItem, in calendar: EKCalendar) -> EKEvent? { 5 | let predicate = predicateForEvents( 6 | withStart: Date(timeIntervalSince1970: item.dateAdded.timeIntervalSince1970 - 1), 7 | end: Date(timeIntervalSince1970: item.dateAdded.timeIntervalSince1970 + 1), 8 | calendars: [calendar] 9 | ) 10 | return events(matching: predicate).first { $0.url?.absoluteString == item.url } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/ModalAlertCreatingDouble.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class ModalAlertCreatingDouble: ModalAlertCreating { 5 | var alertDouble = ModalAlertDouble() 6 | private(set) var didCreateWithStyle: NSAlert.Style? 7 | private(set) var didCreateWithTitle: String? 8 | private(set) var didCreateWithMessage: String? 9 | 10 | func create(style: NSAlert.Style, title: String, message: String) -> ModalAlert { 11 | didCreateWithStyle = style 12 | didCreateWithTitle = title 13 | didCreateWithMessage = message 14 | return alertDouble 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/BookmarksLoader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct BookmarksLoader: BookmarksLoading { 4 | var fileReader: FileReading = FileManager.default 5 | 6 | func load(fromURL url: URL) throws -> Bookmark { 7 | guard let data = fileReader.contents(atURL: url) else { 8 | throw BookmarksLoadingError.unableToLoad(url) 9 | } 10 | let bookmarks: Bookmark 11 | do { 12 | bookmarks = try PropertyListDecoder().decode(Bookmark.self, from: data) 13 | } catch { 14 | throw BookmarksLoadingError.decodingError(error) 15 | } 16 | return bookmarks 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/BookmarksLoadingError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum BookmarksLoadingError: Error { 4 | case unableToLoad(URL) 5 | case decodingError(Error) 6 | } 7 | 8 | extension BookmarksLoadingError: LocalizedError { 9 | var errorDescription: String? { 10 | return "Bookmarks Loading Error" 11 | } 12 | 13 | var failureReason: String? { 14 | switch self { 15 | case .unableToLoad(let url): 16 | return "Could not load reading list from \(url.absoluteString)" 17 | case .decodingError(let error): 18 | return "Could not decode bookmarks: \(error.localizedDescription)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileOpener/FileOpenerFactory.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | struct FileOpenerFactory: FileOpenerCreating { 4 | var openPanelFactory: () -> NSOpenPanel = NSOpenPanel.init 5 | 6 | func create(title: String, ext: String, url: URL?) -> FileOpening { 7 | let opener = openPanelFactory() 8 | opener.title = title 9 | opener.canCreateDirectories = false 10 | opener.showsHiddenFiles = true 11 | opener.directoryURL = url 12 | opener.allowedFileTypes = [ext] 13 | opener.canChooseFiles = true 14 | opener.canChooseDirectories = false 15 | opener.allowsMultipleSelection = false 16 | return opener 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/NSOpenPanelDouble.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | class NSOpenPanelDouble: NSOpenPanel { 4 | private(set) var didBegin = false 5 | private(set) var beginCompletionHandler: ((NSApplication.ModalResponse) -> Void)? 6 | var urlFake: URL? 7 | var directoryUrlFake: URL? 8 | 9 | override var directoryURL: URL? { 10 | get { return directoryUrlFake } 11 | set { directoryUrlFake = newValue } 12 | } 13 | 14 | override var url: URL? { 15 | return urlFake 16 | } 17 | 18 | override func begin(completionHandler handler: @escaping (NSApplication.ModalResponse) -> Void) { 19 | didBegin = true 20 | beginCompletionHandler = handler 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/ReadingListItemEventCreatingDouble.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class ReadingListItemEventCreatingDouble: ReadingListItemEventCreating { 5 | private(set) var didCreateEventsForItems = [ReadingListItem]() 6 | private(set) var didCreateEventsInCalendars = [EKCalendar]() 7 | private(set) var createdEvents = [EKEvent]() 8 | 9 | func createEvent(for item: ReadingListItem, in calendar: EKCalendar) -> EKEvent { 10 | didCreateEventsForItems.append(item) 11 | didCreateEventsInCalendars.append(calendar) 12 | let event = EKEvent(eventStore: EKEventStore()) 13 | createdEvents.append(event) 14 | return event 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/UserDefaults+CalendarIdStoring.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension UserDefaults: CalendarIdStoring { 5 | func calendarId() -> AnyPublisher { 6 | CustomPublisher(request: { subscriber, _ in 7 | _ = subscriber.receive(self.string(forKey: "calendar_id")) 8 | subscriber.receive(completion: .finished) 9 | }).eraseToAnyPublisher() 10 | } 11 | 12 | func setCalendarId(_ id: String?) -> AnyPublisher { 13 | CustomPublisher(request: { subscriber, _ in 14 | self.set(id, forKey: "calendar_id") 15 | _ = subscriber.receive() 16 | subscriber.receive(completion: .finished) 17 | }).eraseToAnyPublisher() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/CalendarAuthorizingDouble.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class CalendarAuthorizingDouble: CalendarAuthorizing { 5 | static var authorizationStatusMock = EKAuthorizationStatus.notDetermined 6 | private(set) var didRequestAccessToEntityType: EKEntityType? 7 | private(set) var requestAccessCompletion: EKEventStoreRequestAccessCompletionHandler? 8 | 9 | static func authorizationStatus(for entityType: EKEntityType) -> EKAuthorizationStatus { 10 | return authorizationStatusMock 11 | } 12 | 13 | func requestAccess(to entityType: EKEntityType, completion: @escaping EKEventStoreRequestAccessCompletionHandler) { 14 | didRequestAccessToEntityType = entityType 15 | requestAccessCompletion = completion 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/CalendarIdStoringDouble.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | @testable import ReadingListCalendarApp 3 | 4 | class CalendarIdStoringDouble: CalendarIdStoring { 5 | var mockedCalendarId: String? 6 | 7 | func calendarId() -> AnyPublisher { 8 | CustomPublisher(request: { subscriber, _ in 9 | _ = subscriber.receive(self.mockedCalendarId) 10 | subscriber.receive(completion: .finished) 11 | }).eraseToAnyPublisher() 12 | } 13 | 14 | func setCalendarId(_ id: String?) -> AnyPublisher { 15 | CustomPublisher(request: { subscriber, _ in 16 | self.mockedCalendarId = id 17 | _ = subscriber.receive() 18 | subscriber.receive(completion: .finished) 19 | }).eraseToAnyPublisher() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | *.DS_Store 3 | *.swp 4 | .Trashes 5 | 6 | # Xcode 7 | *.pbxuser 8 | !default.pbxuser 9 | *.mode1v3 10 | !default.mode1v3 11 | *.mode2v3 12 | !default.mode2v3 13 | *.perspective 14 | !default.perspective 15 | *.perspectivev3 16 | !default.perspectivev3 17 | *.xcuserstate 18 | project.xcworkspace/ 19 | xcuserdata/ 20 | build/ 21 | dist/ 22 | DerivedData/ 23 | *.moved-aside 24 | *.xccheckout 25 | 26 | # AppCode 27 | .idea 28 | 29 | # Backup files 30 | *~ 31 | *~.nib 32 | *~.xib 33 | \#*# 34 | .#* 35 | 36 | # Fastlane 37 | Fastlane/report.xml 38 | Fastlane/test_output 39 | Fastlane/screenshots 40 | Fastlane/*.xccoverage.plist 41 | Fastlane/README.md 42 | 43 | # Visual Studio Code 44 | .vscode 45 | 46 | # xcov 47 | xcov_output/ 48 | 49 | # Slather 50 | coverage_report/ 51 | cobertura.xml 52 | 53 | # Carthage 54 | Carthage/ 55 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/FileBookmarkingDouble.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | @testable import ReadingListCalendarApp 4 | 5 | class FileBookmarkingDouble: FileBookmarking { 6 | var urls = [String: URL]() 7 | 8 | func fileURL(forKey key: String) -> AnyPublisher { 9 | CustomPublisher(request: { subscriber, _ in 10 | _ = subscriber.receive(self.urls[key]) 11 | subscriber.receive(completion: .finished) 12 | }).eraseToAnyPublisher() 13 | } 14 | 15 | func setFileURL(_ url: URL?, forKey key: String) -> AnyPublisher { 16 | CustomPublisher(request: { subscriber, _ in 17 | self.urls[key] = url 18 | _ = subscriber.receive() 19 | subscriber.receive(completion: .finished) 20 | }).eraseToAnyPublisher() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/TestHelpers/Publisher+Materialize.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension Publisher { 5 | func materialize() -> Result<[Output], Failure> { 6 | var values = [Output]() 7 | var result: Result<[Output], Failure>! 8 | let semaphore = DispatchSemaphore(value: 0) 9 | let subscription = sink(receiveCompletion: { completion in 10 | switch completion { 11 | case .finished: 12 | result = .success(values) 13 | case .failure(let error): 14 | result = .failure(error) 15 | } 16 | semaphore.signal() 17 | }, receiveValue: { value in 18 | values.append(value) 19 | }) 20 | semaphore.wait() 21 | subscription.cancel() 22 | 23 | return result 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/ReadingListError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ReadingListError: Error { 4 | case readingListNotFound 5 | case readingListItemsNotFound 6 | case emptyTitle 7 | case emptyURL 8 | case emptyDate 9 | } 10 | 11 | extension ReadingListError: LocalizedError { 12 | var errorDescription: String? { 13 | return "Reading List Error" 14 | } 15 | 16 | var failureReason: String? { 17 | switch self { 18 | case .readingListNotFound: 19 | return "List not found" 20 | case .readingListItemsNotFound: 21 | return "Items not found" 22 | case .emptyTitle: 23 | return "Item title not found" 24 | case .emptyURL: 25 | return "Item URL not found" 26 | case .emptyDate: 27 | return "Item date not found" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Calendar/CalendarAuthorizing+Combine.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import EventKit 3 | 4 | extension CalendarAuthorizing { 5 | func eventsAuthorizationStatus() -> AnyPublisher { 6 | CustomPublisher(request: { subscriber, _ in 7 | _ = subscriber.receive(type(of: self).authorizationStatus(for: .event)) 8 | subscriber.receive(completion: .finished) 9 | }).eraseToAnyPublisher() 10 | } 11 | 12 | func requestAccessToEvents() -> AnyPublisher { 13 | CustomPublisher(request: { subscriber, _ in 14 | self.requestAccess(to: .event) { _, error in 15 | if let error = error { 16 | subscriber.receive(completion: .failure(error)) 17 | } else { 18 | _ = subscriber.receive() 19 | subscriber.receive(completion: .finished) 20 | } 21 | } 22 | }).eraseToAnyPublisher() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Bookmarks/Bookmark+ReadingList.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Bookmark { 4 | func readingListItems() throws -> [ReadingListItem] { 5 | guard let list = children?.first(where: { $0.isReadingList }) else { 6 | throw ReadingListError.readingListNotFound 7 | } 8 | guard let items = try list.children?.map({ try $0.readingListItem() }) else { 9 | throw ReadingListError.readingListItemsNotFound 10 | } 11 | return items 12 | } 13 | 14 | private var isReadingList: Bool { 15 | return title == "com.apple.ReadingList" 16 | } 17 | 18 | private func readingListItem() throws -> ReadingListItem { 19 | return ReadingListItem( 20 | uuid: uuid, 21 | title: try (uri?.title).or(ReadingListError.emptyTitle), 22 | url: try (url).or(ReadingListError.emptyURL), 23 | dateAdded: try (readingList?.dateAdded).or(ReadingListError.emptyDate), 24 | previewText: readingList?.previewText 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/SyncControllingDouble.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | @testable import ReadingListCalendarApp 4 | 5 | class SyncControllingDouble: SyncControlling { 6 | let isSynchronizingMock = CurrentValueSubject(false) 7 | let syncProgressMock = CurrentValueSubject(nil) 8 | private(set) var didSyncBookmarksUrl: URL? 9 | private(set) var didSyncCalendarId: String? 10 | private(set) var syncSubject: PassthroughSubject? 11 | 12 | func isSynchronizing() -> AnyPublisher { 13 | isSynchronizingMock.eraseToAnyPublisher() 14 | } 15 | 16 | func syncProgress() -> AnyPublisher { 17 | syncProgressMock.eraseToAnyPublisher() 18 | } 19 | 20 | func sync(bookmarksUrl: URL, calendarId: String) -> AnyPublisher { 21 | didSyncBookmarksUrl = bookmarksUrl 22 | didSyncCalendarId = calendarId 23 | syncSubject = PassthroughSubject() 24 | return syncSubject!.eraseToAnyPublisher() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/EKEventStoreDouble.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | class EKEventStoreDouble: EKEventStore { 4 | private(set) var didCreatePredicateForEventsWithStartDate: Date? 5 | private(set) var didCreatePredicateForEventsWithEndDate: Date? 6 | private(set) var didCreatePredicateForEventsWithCalendars: [EKCalendar]? 7 | var createdPredicate = NSPredicate(value: true) 8 | 9 | override func predicateForEvents( 10 | withStart startDate: Date, 11 | end endDate: Date, 12 | calendars: [EKCalendar]? 13 | ) -> NSPredicate { 14 | didCreatePredicateForEventsWithStartDate = startDate 15 | didCreatePredicateForEventsWithEndDate = endDate 16 | didCreatePredicateForEventsWithCalendars = calendars 17 | return createdPredicate 18 | } 19 | 20 | private(set) var didFetchEventsMatchingPredicate: NSPredicate? 21 | var mockedEvents = [EKEvent]() 22 | 23 | override func events(matching predicate: NSPredicate) -> [EKEvent] { 24 | didFetchEventsMatchingPredicate = predicate 25 | return mockedEvents 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Helpers/ErrorTitleSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Foundation 4 | @testable import ReadingListCalendarApp 5 | 6 | class ErrorTitleSpec: QuickSpec { 7 | override func spec() { 8 | describe("localized error with description") { 9 | var error: Error! 10 | var errorDescription: String! 11 | 12 | beforeEach { 13 | errorDescription = "Error Description" 14 | struct ErrorDouble: LocalizedError { 15 | var errorDescription: String? 16 | } 17 | error = ErrorDouble(errorDescription: errorDescription) 18 | } 19 | 20 | it("should have correct title") { 21 | expect(error.title) == errorDescription 22 | } 23 | } 24 | 25 | describe("non localized error") { 26 | var error: Error! 27 | 28 | beforeEach { 29 | struct ErrorDouble: Error {} 30 | error = ErrorDouble() 31 | } 32 | 33 | it("should have correct title") { 34 | expect(error.title) == "Error occured" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSCalendarsUsageDescription 26 | The app adds your reading list items to the calendar. 27 | NSHumanReadableCopyright 28 | Copyright © 2019 EL Passion. All rights reserved. 29 | NSMainStoryboardFile 30 | Main 31 | NSPrincipalClass 32 | NSApplication 33 | 34 | 35 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/ModalAlert/ModalAlertFactorySpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Cocoa 4 | @testable import ReadingListCalendarApp 5 | 6 | class ModalAlertFactorySpec: QuickSpec { 7 | override func spec() { 8 | describe("factory") { 9 | var sut: ModalAlertFactory! 10 | 11 | beforeEach { 12 | sut = ModalAlertFactory() 13 | } 14 | 15 | context("create") { 16 | var style: NSAlert.Style! 17 | var title: String! 18 | var message: String! 19 | var alert: ModalAlert! 20 | 21 | beforeEach { 22 | style = .warning 23 | title = "Alert Title" 24 | message = "Alert Message" 25 | alert = sut.create(style: .warning, title: title, message: message) 26 | } 27 | 28 | it("should be NSAlert") { 29 | expect(alert).to(beAnInstanceOf(NSAlert.self)) 30 | } 31 | 32 | it("should have correctly configured") { 33 | expect((alert as? NSAlert)?.alertStyle) == style 34 | expect((alert as? NSAlert)?.messageText) == title 35 | expect((alert as? NSAlert)?.informativeText) == message 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Bookmarks/BookmarksLoadingErrorSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Foundation 4 | @testable import ReadingListCalendarApp 5 | 6 | class BookmarksLoadingErrorSpec: QuickSpec { 7 | override func spec() { 8 | describe("unable to load error") { 9 | var error: BookmarksLoadingError! 10 | var url: URL! 11 | 12 | beforeEach { 13 | url = URL(fileURLWithPath: "/test/path") 14 | error = .unableToLoad(url) 15 | } 16 | 17 | it("should be correctly localized") { 18 | expect(error.errorDescription) == "Bookmarks Loading Error" 19 | expect(error.failureReason) == "Could not load reading list from \(url.absoluteString)" 20 | } 21 | } 22 | 23 | describe("decoding error") { 24 | var error: BookmarksLoadingError! 25 | var internalError: Error! 26 | 27 | beforeEach { 28 | internalError = NSError(domain: "domain", code: 1, userInfo: nil) 29 | error = .decodingError(internalError) 30 | } 31 | 32 | it("should be correctly localized") { 33 | expect(error.errorDescription) == "Bookmarks Loading Error" 34 | expect(error.failureReason) == "Could not decode bookmarks: \(internalError.localizedDescription)" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/MainWindow/MainWindowControllerFactory.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import EventKit 3 | 4 | struct MainWindowControllerFactory: MainWindowControllerCreating { 5 | // swiftlint:disable:next function_parameter_count 6 | func create( 7 | fileOpenerFactory: FileOpenerCreating, 8 | fileBookmarks: FileBookmarking, 9 | fileReadability: FileReadablity, 10 | calendarAuthorizer: CalendarAuthorizing, 11 | alertFactory: ModalAlertCreating, 12 | calendarsProvider: CalendarsProviding, 13 | calendarIdStore: CalendarIdStoring, 14 | syncController: SyncControlling 15 | ) -> NSWindowController { 16 | let storyboard = NSStoryboard(name: "Main", bundle: nil) 17 | let identifier = "MainWindowController" 18 | // swiftlint:disable:next force_cast 19 | let controller = storyboard.instantiateController(withIdentifier: identifier) as! MainWindowController 20 | controller.mainViewController.setUp( 21 | fileOpenerFactory: fileOpenerFactory, 22 | fileBookmarks: fileBookmarks, 23 | fileReadability: fileReadability, 24 | calendarAuthorizer: calendarAuthorizer, 25 | alertFactory: alertFactory, 26 | calendarsProvider: calendarsProvider, 27 | calendarIdStore: calendarIdStore, 28 | syncController: syncController 29 | ) 30 | return controller 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Calendar/UserDefaultsCalendarIdStoringSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Combine 4 | import Foundation 5 | @testable import ReadingListCalendarApp 6 | 7 | class UserDefaultsCalendarIdStoringSpec: QuickSpec { 8 | override func spec() { 9 | describe("defaults") { 10 | var sut: UserDefaults! 11 | var key: String! 12 | 13 | beforeEach { 14 | sut = UserDefaults(suiteName: "tests") 15 | key = "calendar_id" 16 | sut.removeObject(forKey: key) 17 | } 18 | 19 | it("should have no calendar id") { 20 | let result = sut.calendarId().materialize() 21 | expect(try? result.get()) == [nil] 22 | } 23 | 24 | context("set calendar id") { 25 | var id: String! 26 | var result: Result<[Void], Never>! 27 | 28 | beforeEach { 29 | id = "calendar-id-1" 30 | result = sut.setCalendarId(id).materialize() 31 | } 32 | 33 | it("should complete") { 34 | expect(try? result.get()).to(haveCount(1)) 35 | } 36 | 37 | it("should have correct calendar id") { 38 | let result = sut.calendarId().materialize() 39 | expect(try? result.get()) == [id] 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Helpers/ErrorMessageSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Foundation 4 | @testable import ReadingListCalendarApp 5 | 6 | class ErrorMessageSpec: QuickSpec { 7 | override func spec() { 8 | describe("localized error with failure reason") { 9 | var error: Error! 10 | var failureReason: String! 11 | 12 | beforeEach { 13 | failureReason = "Failure Reason" 14 | struct ErrorDouble: LocalizedError { 15 | var failureReason: String? 16 | } 17 | error = ErrorDouble(failureReason: failureReason) 18 | } 19 | 20 | it("should have correct message") { 21 | expect(error.message) == failureReason 22 | } 23 | } 24 | 25 | describe("localized error without failure reason") { 26 | var error: Error! 27 | 28 | beforeEach { 29 | struct ErrorDouble: LocalizedError {} 30 | error = ErrorDouble() 31 | } 32 | 33 | it("should have correct message") { 34 | expect(error.message) == "" 35 | } 36 | } 37 | 38 | describe("non localized error") { 39 | var error: Error! 40 | 41 | beforeEach { 42 | struct ErrorDouble: Error {} 43 | error = ErrorDouble() 44 | } 45 | 46 | it("should have correct message") { 47 | expect(error.message) == error.localizedDescription 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "Icon-16pt-1x.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "Icon-16pt-2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "Icon-32pt-1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "Icon-32pt-2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "Icon-128pt-1x.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "Icon-128pt-2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "Icon-256pt-1x.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "Icon-256pt-2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "Icon-512pt-1x.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "Icon-512pt-2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Sync/EKEventStoreReadingListItemEventCreatingSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import EventKit 4 | @testable import ReadingListCalendarApp 5 | 6 | class EKEventStoreReadingListItemEventCreatingSpec: QuickSpec { 7 | override func spec() { 8 | describe("event store") { 9 | var sut: EKEventStore! 10 | 11 | beforeEach { 12 | sut = EKEventStore() 13 | } 14 | 15 | context("create event for item in calendar") { 16 | var item: ReadingListItem! 17 | var calendar: EKCalendar! 18 | var event: EKEvent? 19 | 20 | beforeEach { 21 | item = ReadingListItem( 22 | uuid: "1234", 23 | title: "Title", 24 | url: "https://elpassion.com", 25 | dateAdded: Date(), 26 | previewText: "Preview Text" 27 | ) 28 | calendar = EKCalendar(for: .event, eventStore: sut) 29 | event = sut.createEvent(for: item, in: calendar) 30 | } 31 | 32 | it("should be correct") { 33 | expect(event?.startDate).to(beCloseTo(item.dateAdded, within: 1)) 34 | expect(event?.endDate).to(beCloseTo(item.dateAdded, within: 1)) 35 | expect(event?.calendar) == calendar 36 | expect(event?.url) == URL(string: item.url) 37 | expect(event?.title) == item.title 38 | expect(event?.notes) == item.previewText 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - file_name 3 | - empty_count 4 | - sorted_imports 5 | - force_unwrapping 6 | - operator_usage_whitespace 7 | - number_separator 8 | - switch_case_on_newline 9 | - implicit_return 10 | - overridden_super_call 11 | - object_literal 12 | - nimble_operator 13 | - first_where 14 | - closure_spacing 15 | - closure_end_indentation 16 | - attributes 17 | - explicit_init 18 | - fatal_error_message 19 | - redundant_nil_coalescing 20 | - private_outlet 21 | - prohibited_super_call 22 | - vertical_parameter_alignment_on_call 23 | - unneeded_parentheses_in_closure_argument 24 | - single_test_class 25 | - quick_discouraged_call 26 | - pattern_matching_keywords 27 | - multiline_parameters 28 | - joined_default_parameter 29 | - strict_fileprivate 30 | - let_var_whitespace 31 | - contains_over_first_not_nil 32 | - switch_case_alignment 33 | - multiline_arguments 34 | - unneeded_break_in_switch 35 | - literal_expression_end_indentation 36 | - sorted_first_last 37 | - override_in_extension 38 | - yoda_condition 39 | - for_where 40 | - unused_closure_parameter 41 | - discouraged_optional_boolean 42 | - empty_string 43 | - legacy_constructor 44 | - untyped_error_in_catch 45 | 46 | line_length: 47 | warning: 120 48 | error: 200 49 | 50 | file_length: 51 | warning: 140 52 | error: 150 53 | 54 | type_name: 55 | min_length: 2 56 | max_length: 57 | warning: 50 58 | error: 55 59 | 60 | identifier_name: 61 | min_length: 3 62 | max_length: 63 | warning: 40 64 | error: 50 65 | excluded: 66 | - ui 67 | - cg 68 | - id 69 | - to 70 | - rx 71 | - x 72 | - y 73 | 74 | explicit_type_interface: 75 | excluded: 76 | - local 77 | 78 | excluded: 79 | - Generated 80 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/FileBookmarks/UserDefaults+FileBookmarking.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension UserDefaults: FileBookmarking { 5 | func fileURL(forKey key: String) -> AnyPublisher { 6 | CustomPublisher(request: { subscriber, _ in 7 | guard let data = self.object(forKey: key) as? Data else { 8 | _ = subscriber.receive(nil) 9 | subscriber.receive(completion: .finished) 10 | return 11 | } 12 | do { 13 | let url = try NSURL( 14 | resolvingBookmarkData: data, 15 | options: [.withoutUI, .withSecurityScope], 16 | relativeTo: nil, 17 | bookmarkDataIsStale: nil 18 | ) 19 | _ = subscriber.receive(url as URL) 20 | subscriber.receive(completion: .finished) 21 | } catch { 22 | subscriber.receive(completion: .failure(error)) 23 | } 24 | }).eraseToAnyPublisher() 25 | } 26 | 27 | func setFileURL(_ url: URL?, forKey key: String) -> AnyPublisher { 28 | CustomPublisher(request: { subscriber, _ in 29 | do { 30 | let data = try url?.bookmarkData( 31 | options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], 32 | includingResourceValuesForKeys: nil, 33 | relativeTo: nil 34 | ) 35 | self.set(data, forKey: key) 36 | _ = subscriber.receive() 37 | subscriber.receive(completion: .finished) 38 | } catch { 39 | subscriber.receive(completion: .failure(error)) 40 | } 41 | }).eraseToAnyPublisher() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Doubles/MainWindowControllerCreatingDouble.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | @testable import ReadingListCalendarApp 3 | 4 | class MainWindowControllerCreatingDouble: MainWindowControllerCreating { 5 | private(set) var didCreate = false 6 | private(set) var didCreateWithFileOpenerFactory: FileOpenerCreating? 7 | private(set) var didCreateWithFileBookmarks: FileBookmarking? 8 | private(set) var didCreateWithFileReadability: FileReadablity? 9 | private(set) var didCreateWithCalendarAuthorizer: CalendarAuthorizing? 10 | private(set) var didCreateWithAlertFactory: ModalAlertCreating? 11 | private(set) var didCreateWithCalendarsProvider: CalendarsProviding? 12 | private(set) var didCreateWithCalendarIdStore: CalendarIdStoring? 13 | private(set) var didCreateWithSyncController: SyncControlling? 14 | let mock = NSWindowControllerDouble() 15 | 16 | func create( 17 | fileOpenerFactory: FileOpenerCreating, 18 | fileBookmarks: FileBookmarking, 19 | fileReadability: FileReadablity, 20 | calendarAuthorizer: CalendarAuthorizing, 21 | alertFactory: ModalAlertCreating, 22 | calendarsProvider: CalendarsProviding, 23 | calendarIdStore: CalendarIdStoring, 24 | syncController: SyncControlling 25 | ) -> NSWindowController { 26 | didCreate = true 27 | didCreateWithFileOpenerFactory = fileOpenerFactory 28 | didCreateWithFileBookmarks = fileBookmarks 29 | didCreateWithFileReadability = fileReadability 30 | didCreateWithCalendarAuthorizer = calendarAuthorizer 31 | didCreateWithAlertFactory = alertFactory 32 | didCreateWithCalendarsProvider = calendarsProvider 33 | didCreateWithCalendarIdStore = calendarIdStore 34 | didCreateWithSyncController = syncController 35 | 36 | return mock 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/FileOpener/FileOpenerFactorySpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Foundation 4 | @testable import ReadingListCalendarApp 5 | 6 | class FileOpenerFactorySpec: QuickSpec { 7 | override func spec() { 8 | describe("facotry") { 9 | var sut: FileOpenerFactory! 10 | 11 | beforeEach { 12 | sut = FileOpenerFactory() 13 | sut.openPanelFactory = NSOpenPanelDouble.init 14 | } 15 | 16 | context("create") { 17 | var title: String! 18 | var ext: String! 19 | var url: URL! 20 | var opener: FileOpening! 21 | 22 | beforeEach { 23 | title = "Open File Title" 24 | ext = "FileExtension" 25 | url = URL(fileURLWithPath: "/tmp") 26 | opener = sut.create(title: title, ext: ext, url: url) 27 | } 28 | 29 | it("should create NSOpenPanal") { 30 | expect(opener).to(beAnInstanceOf(NSOpenPanelDouble.self)) 31 | } 32 | 33 | it("should correctly configure opener") { 34 | let openPanel = opener as? NSOpenPanelDouble 35 | expect(openPanel?.title) == title 36 | expect(openPanel?.canCreateDirectories) == false 37 | expect(openPanel?.showsHiddenFiles) == true 38 | expect(openPanel?.directoryURL) == url 39 | expect(openPanel?.allowedFileTypes) == [ext] 40 | expect(openPanel?.canChooseFiles) == true 41 | expect(openPanel?.canChooseDirectories) == false 42 | expect(openPanel?.allowsMultipleSelection) == false 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Helpers/OptionalOrThrowSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | @testable import ReadingListCalendarApp 4 | 5 | class OptionalOrThrowSpec: QuickSpec { 6 | override func spec() { 7 | describe("Optional with value") { 8 | var sut: String? 9 | 10 | beforeEach { 11 | sut = "test" 12 | } 13 | 14 | context("or throw") { 15 | var value: String? 16 | var thrownError: Error? 17 | 18 | beforeEach { 19 | do { 20 | value = try sut.or(ErrorDouble()) 21 | } catch { 22 | thrownError = error 23 | } 24 | } 25 | 26 | it("should retrun correct value") { 27 | expect(value) == sut 28 | } 29 | 30 | it("should not throw") { 31 | expect(thrownError).to(beNil()) 32 | } 33 | } 34 | } 35 | 36 | describe("Optional without value") { 37 | var sut: String? 38 | 39 | beforeEach { 40 | sut = nil 41 | } 42 | 43 | context("or throw") { 44 | var value: String? 45 | var thrownError: Error? 46 | 47 | beforeEach { 48 | do { 49 | value = try sut.or(ErrorDouble()) 50 | } catch { 51 | thrownError = error 52 | } 53 | } 54 | 55 | it("should retrun nil value") { 56 | expect(value).to(beNil()) 57 | } 58 | 59 | it("should throw correct error") { 60 | expect(thrownError).to(beAnInstanceOf(ErrorDouble.self)) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | private struct ErrorDouble: Error {} 68 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Bookmarks/ReadingListErrorSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | @testable import ReadingListCalendarApp 4 | 5 | class ReadingListErrorSpec: QuickSpec { 6 | override func spec() { 7 | describe("reading list not found") { 8 | var error: ReadingListError! 9 | 10 | beforeEach { 11 | error = .readingListNotFound 12 | } 13 | 14 | it("should be correctly localized") { 15 | expect(error.errorDescription) == "Reading List Error" 16 | expect(error.failureReason) == "List not found" 17 | } 18 | } 19 | 20 | describe("reading list items not found") { 21 | var error: ReadingListError! 22 | 23 | beforeEach { 24 | error = .readingListItemsNotFound 25 | } 26 | 27 | it("should be correctly localized") { 28 | expect(error.errorDescription) == "Reading List Error" 29 | expect(error.failureReason) == "Items not found" 30 | } 31 | } 32 | 33 | describe("empty title") { 34 | var error: ReadingListError! 35 | 36 | beforeEach { 37 | error = .emptyTitle 38 | } 39 | 40 | it("should be correctly localized") { 41 | expect(error.errorDescription) == "Reading List Error" 42 | expect(error.failureReason) == "Item title not found" 43 | } 44 | } 45 | 46 | describe("empty URL") { 47 | var error: ReadingListError! 48 | 49 | beforeEach { 50 | error = .emptyURL 51 | } 52 | 53 | it("should be correctly localized") { 54 | expect(error.errorDescription) == "Reading List Error" 55 | expect(error.failureReason) == "Item URL not found" 56 | } 57 | } 58 | 59 | describe("empty date") { 60 | var error: ReadingListError! 61 | 62 | beforeEach { 63 | error = .emptyDate 64 | } 65 | 66 | it("should be correctly localized") { 67 | expect(error.errorDescription) == "Reading List Error" 68 | expect(error.failureReason) == "Item date not found" 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Sync/SyncController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import EventKit 3 | import Foundation 4 | 5 | class SyncController: SyncControlling { 6 | 7 | init() { 8 | bookmarksLoader = BookmarksLoader() 9 | let eventStore = EKEventStore() 10 | calendarProvider = eventStore 11 | eventProvider = eventStore 12 | eventCreator = eventStore 13 | eventSaver = eventStore 14 | } 15 | 16 | var bookmarksLoader: BookmarksLoading 17 | var calendarProvider: CalendarProviding 18 | var eventProvider: ReadingListItemEventProviding 19 | var eventCreator: ReadingListItemEventCreating 20 | var eventSaver: EventSaving 21 | 22 | // MARK: - SyncControlling 23 | 24 | func isSynchronizing() -> AnyPublisher { 25 | progress.map { $0 != nil }.removeDuplicates().eraseToAnyPublisher() 26 | } 27 | 28 | func syncProgress() -> AnyPublisher { 29 | progress.eraseToAnyPublisher() 30 | } 31 | 32 | func sync(bookmarksUrl: URL, calendarId: String) -> AnyPublisher { 33 | CustomPublisher(request: { subscriber, _ in 34 | do { 35 | try self.performSync(bookmarksUrl: bookmarksUrl, calendarId: calendarId) 36 | _ = subscriber.receive() 37 | subscriber.receive(completion: .finished) 38 | } catch { 39 | subscriber.receive(completion: .failure(error)) 40 | } 41 | self.progress.send(nil) 42 | }).eraseToAnyPublisher() 43 | } 44 | 45 | // MARK: - Private 46 | 47 | private let progress = CurrentValueSubject(nil) 48 | 49 | private func performSync(bookmarksUrl: URL, calendarId: String) throws { 50 | progress.send(0) 51 | let bookmarks = try bookmarksLoader.load(fromURL: bookmarksUrl) 52 | let calendar = try (calendarProvider.calendar(withIdentifier: calendarId)).or(SyncError.calendarNotFound) 53 | let items = try bookmarks.readingListItems() 54 | try items.enumerated().forEach { 55 | let (offset, item) = $0 56 | if eventProvider.event(for: item, in: calendar) == nil { 57 | let event = eventCreator.createEvent(for: item, in: calendar) 58 | try eventSaver.save(event, span: .thisEvent, commit: true) 59 | } 60 | progress.send(Double(offset + 1) / Double(items.count)) 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/FileBookmarks/UserDefaultsFileBookmarkingSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Combine 4 | @testable import ReadingListCalendarApp 5 | 6 | class UserDefaultsFileBookmarkingSpec: QuickSpec { 7 | override func spec() { 8 | describe("defaults") { 9 | var sut: UserDefaults! 10 | var key: String! 11 | 12 | beforeEach { 13 | sut = UserDefaults(suiteName: "tests") 14 | key = "test_file_bookmark_key" 15 | sut.removeObject(forKey: key) 16 | } 17 | 18 | it("should have no file url") { 19 | let result = sut.fileURL(forKey: key).materialize() 20 | expect(try? result.get()) == [nil] 21 | } 22 | 23 | context("set file url") { 24 | var url: URL! 25 | var result: Result<[Void], Error>? 26 | 27 | beforeEach { 28 | url = try! NSURL( 29 | resolvingAliasFileAt: URL(fileURLWithPath: "/tmp"), 30 | options: [.withoutUI] 31 | ) as URL 32 | result = sut.setFileURL(url, forKey: key).materialize() 33 | } 34 | 35 | it("should complete") { 36 | expect(try? result!.get()).to(haveCount(1)) 37 | } 38 | 39 | it("should have correct file url") { 40 | let result = sut.fileURL(forKey: key).materialize() 41 | expect(try? result.get()) == [url] 42 | } 43 | } 44 | 45 | context("with corrupted data") { 46 | beforeEach { 47 | let data = "TEST".data(using: .utf8) 48 | sut.set(data, forKey: key) 49 | } 50 | 51 | it("should return error") { 52 | let result = sut.fileURL(forKey: key).materialize() 53 | expect { try result.get() }.to(throwError()) 54 | } 55 | } 56 | 57 | context("set invalid file url") { 58 | it("should return error") { 59 | let url = URL(string: "http://www.elpassion.com/")! 60 | let result = sut.setFileURL(url, forKey: key).materialize() 61 | expect { try result.get() }.to(throwError()) 62 | } 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Helpers/CustomPublisher.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public final class CustomPublisher: Publisher where Failure: Error { 4 | 5 | public init(subscribe subscribeClosure: @escaping (AnySubscriber) -> Subscription) { 6 | self.subscribeClosure = subscribeClosure 7 | } 8 | 9 | public func receive(subscriber: S) where S: Combine.Subscriber, S.Input == Output, S.Failure == Failure { 10 | let subscription = subscribeClosure(AnySubscriber(subscriber)) 11 | subscriber.receive(subscription: subscription) 12 | } 13 | 14 | private let subscribeClosure: (AnySubscriber) -> Subscription 15 | 16 | } 17 | 18 | public extension CustomPublisher { 19 | 20 | convenience init( 21 | request requestClosure: @escaping (AnySubscriber, Subscribers.Demand) -> Void, 22 | cancel cancelClosure: @escaping () -> Void = {}, 23 | deinit deinitClosure: @escaping () -> Void = {} 24 | ) { 25 | self.init { subscriber in 26 | CustomSubscription( 27 | subscriber, 28 | request: requestClosure, 29 | cancel: cancelClosure, 30 | deinit: deinitClosure 31 | ) 32 | } 33 | } 34 | 35 | } 36 | 37 | public final class CustomSubscription: Subscription where Failure: Error { 38 | 39 | public init( 40 | _ subscriber: AnySubscriber, 41 | request requestClosure: @escaping (AnySubscriber, Subscribers.Demand) -> Void, 42 | cancel cancelClosure: @escaping () -> Void = {}, 43 | deinit deinitClosure: @escaping () -> Void = {} 44 | ) { 45 | self.subscriber = subscriber 46 | self.requestClosure = requestClosure 47 | self.cancelClosure = cancelClosure 48 | self.deinitClosure = deinitClosure 49 | } 50 | 51 | deinit { 52 | deinitClosure() 53 | } 54 | 55 | public func request(_ demand: Subscribers.Demand) { 56 | if let subscriber = subscriber { 57 | requestClosure(subscriber, demand) 58 | } 59 | } 60 | 61 | public func cancel() { 62 | subscriber = nil 63 | cancelClosure() 64 | } 65 | 66 | private var subscriber: AnySubscriber? 67 | private let requestClosure: (AnySubscriber, Subscribers.Demand) -> Void 68 | private let cancelClosure: () -> Void 69 | private let deinitClosure: () -> Void 70 | 71 | } 72 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Sync/EKEventStoreReadingListItemEventProvidingSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import EventKit 4 | @testable import ReadingListCalendarApp 5 | 6 | class EKEventStoreReadingListItemEventProvidingSpec: QuickSpec { 7 | override func spec() { 8 | describe("event store") { 9 | var sut: EKEventStoreDouble! 10 | 11 | beforeEach { 12 | sut = EKEventStoreDouble() 13 | } 14 | 15 | context("event for item in calendar") { 16 | var item: ReadingListItem! 17 | var calendar: EKCalendar! 18 | var event: EKEvent? 19 | 20 | beforeEach { 21 | sut.mockedEvents = [ 22 | EKEvent(eventStore: sut), 23 | EKEvent(eventStore: sut), 24 | EKEvent(eventStore: sut) 25 | ] 26 | sut.mockedEvents[0].url = URL(string: "https://darrarski.pl")! 27 | sut.mockedEvents[1].url = URL(string: "https://elpassion.com")! 28 | sut.mockedEvents[2].url = URL(string: "https://elpassion.com")! 29 | item = ReadingListItem( 30 | uuid: "1234", 31 | title: "Title", 32 | url: "https://elpassion.com", 33 | dateAdded: Date(), 34 | previewText: "Preview Text" 35 | ) 36 | calendar = EKCalendar(for: .event, eventStore: sut) 37 | event = sut.event(for: item, in: calendar) 38 | } 39 | 40 | it("should create correct predicate") { 41 | expect(sut.didCreatePredicateForEventsWithStartDate) 42 | == Date(timeIntervalSince1970: item.dateAdded.timeIntervalSince1970 - 1) 43 | expect(sut.didCreatePredicateForEventsWithEndDate) 44 | == Date(timeIntervalSince1970: item.dateAdded.timeIntervalSince1970 + 1) 45 | expect(sut.didCreatePredicateForEventsWithCalendars) 46 | == [calendar] 47 | } 48 | 49 | it("should fetch events matching predicate") { 50 | expect(sut.didFetchEventsMatchingPredicate) == sut.createdPredicate 51 | } 52 | 53 | it("should return correct event") { 54 | expect(event) == sut.mockedEvents[1] 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReadingListCalendar 2 | 3 | ![Swift v5.1](https://img.shields.io/badge/swift-v5.1-orange.svg) 4 | ![platform macOS](https://img.shields.io/badge/platform-macOS-blue.svg) 5 | ![code coverage 100%](https://img.shields.io/badge/covergage-100%25-success.svg) 6 | 7 | **macOS** application that sync **Safari Reading List** items to your **Calendar**. 8 | 9 | Inspired by [Tweet by Marcin Krzyżanowski](https://twitter.com/krzyzanowskim/status/1099679842860257280). 10 | 11 | ![Reading List Calendar App](Misc/screenshot-1.png) 12 | 13 | ## Install 14 | 15 | - macOS 10.15 or newer is required (last version that supports macOS 10.14 is [v1.0.2](https://github.com/elpassion/ReadingListCalendarApp/releases/tag/v1.0.2_3)) 16 | - Download latest version from [releases page](https://github.com/elpassion/ReadingListCalendarApp/releases) 17 | - Unzip archive and copy `Reading List Calendar.app` to `/Applications` folder 18 | - Start the app and configure it for your needs 19 | - Optionally, to setup automatic sync in background, follow [wiki page](https://github.com/elpassion/ReadingListCalendarApp/wiki) 20 | 21 | ## Launch arguments 22 | 23 | Application accepts following (optional) launch arguments: 24 | 25 | |Argument|Description| 26 | |:--|:--| 27 | |`-sync`|Start synchronization on app launch| 28 | |`-headless`|Do not present UI and terminate when synchronization completes (to use with `-sync` argument)| 29 | 30 | You can start the app with above arguments using `open` command: 31 | 32 | ```sh 33 | open -a "Reading List Calendar" --args -sync -headless 34 | ``` 35 | 36 | ## Roadmap 37 | 38 | - [x] MVP - adding reading list items to choosen calendar 39 | - [x] Automatic synchronization in background (using launch arguments) 40 | - [x] Migrate from RxSwift to Combine 41 | - [ ] UI for configuring automatic synchronization in background 42 | 43 | ## Develop 44 | 45 | ### Requirements 46 | 47 | - Xcode 11.1 48 | - [SwiftLint](https://github.com/realm/SwiftLint) 49 | - [Carthage](https://github.com/Carthage/Carthage) 50 | 51 | ### Setup 52 | 53 | - Run `setup.sh` in Terminal 54 | - Open `ReadingListCalendar.xcodeproj` in Xcode 55 | 56 | ### Build targets 57 | 58 | |Target|Kind|Description| 59 | |:--|:--|:--| 60 | |`ReadingListCalendarApp`|Cocoa App|Main target of the app| 61 | |`ReadingListCalendarAppTests`|macOS Unit Testing Bundle|App main target's tests| 62 | 63 | ### Build schemes 64 | 65 | |Scheme|Purpose| 66 | |:--|:--| 67 | |`ReadingListCalendar`|Build, run, test and archive the app| 68 | 69 | ## License 70 | 71 | Copyright © 2019 [EL Passion](https://www.elpassion.com) 72 | 73 | License: [GNU GPLv3](LICENSE) 74 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/FileOpener/NSOpenPanelFileOpeningSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Foundation 4 | @testable import ReadingListCalendarApp 5 | 6 | class NSOpenPanelFileOpeningSpec: QuickSpec { 7 | override func spec() { 8 | describe("panel") { 9 | var sut: NSOpenPanelDouble! 10 | 11 | beforeEach { 12 | sut = NSOpenPanelDouble() 13 | } 14 | 15 | context("open file") { 16 | var didOpenUrl: URL? 17 | 18 | beforeEach { 19 | sut.openFile { didOpenUrl = $0 } 20 | } 21 | 22 | afterEach { 23 | didOpenUrl = nil 24 | } 25 | 26 | it("should begin") { 27 | expect(sut.didBegin) == true 28 | } 29 | 30 | context("when file is opened with url") { 31 | var url: URL! 32 | 33 | beforeEach { 34 | url = URL(fileURLWithPath: "file_url") 35 | sut.urlFake = url 36 | sut.beginCompletionHandler?(.OK) 37 | } 38 | 39 | it("should open url") { 40 | expect(didOpenUrl) == url 41 | } 42 | } 43 | 44 | context("when file is opened without url") { 45 | beforeEach { 46 | sut.urlFake = nil 47 | sut.beginCompletionHandler?(.OK) 48 | } 49 | 50 | it("should not open url") { 51 | expect(didOpenUrl).to(beNil()) 52 | } 53 | } 54 | 55 | context("when cancelled without url") { 56 | beforeEach { 57 | sut.urlFake = nil 58 | sut.beginCompletionHandler?(.cancel) 59 | } 60 | 61 | it("should not open url") { 62 | expect(didOpenUrl).to(beNil()) 63 | } 64 | } 65 | 66 | context("when cancelled with url") { 67 | beforeEach { 68 | sut.urlFake = URL(fileURLWithPath: "cancel_url") 69 | sut.beginCompletionHandler?(.cancel) 70 | } 71 | 72 | it("should not open url") { 73 | expect(didOpenUrl).to(beNil()) 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Combine 3 | import EventKit 4 | 5 | class AppDelegate: NSObject, NSApplicationDelegate { 6 | 7 | var launchArguments: [String] = CommandLine.arguments 8 | var appTerminator: AppTerminating = NSApplication.shared 9 | var mainWindowControllerFactory: MainWindowControllerCreating = MainWindowControllerFactory() 10 | var fileOpenerFactory: FileOpenerCreating = FileOpenerFactory() 11 | var fileBookmarks: FileBookmarking = UserDefaults.standard 12 | var fileReadability: FileReadablity = FileManager.default 13 | var calendarAuthorizer: CalendarAuthorizing = EKEventStore() 14 | var alertFactory: ModalAlertCreating = ModalAlertFactory() 15 | var calendarsProvider: CalendarsProviding = EKEventStore() 16 | var calendarIdStore: CalendarIdStoring = UserDefaults.standard 17 | var syncController: SyncControlling = SyncController() 18 | 19 | // MARK: NSApplicationDelegate 20 | 21 | func applicationDidFinishLaunching(_ notification: Notification) { 22 | if !launchArguments.contains("-headless") { 23 | runApp() 24 | } 25 | if launchArguments.contains("-sync") { 26 | runSync() 27 | } 28 | } 29 | 30 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 31 | return true 32 | } 33 | 34 | // MARK: Private 35 | 36 | private var syncSubscription: AnyCancellable? 37 | 38 | private func runApp() { 39 | let controller = mainWindowControllerFactory.create( 40 | fileOpenerFactory: fileOpenerFactory, 41 | fileBookmarks: fileBookmarks, 42 | fileReadability: fileReadability, 43 | calendarAuthorizer: calendarAuthorizer, 44 | alertFactory: alertFactory, 45 | calendarsProvider: calendarsProvider, 46 | calendarIdStore: calendarIdStore, 47 | syncController: syncController 48 | ) 49 | controller.showWindow(self) 50 | } 51 | 52 | private func runSync() { 53 | syncSubscription?.cancel() 54 | syncSubscription = nil 55 | 56 | let bookmarksURL = fileBookmarks.bookmarksFileURL() 57 | .compactMap { $0 } 58 | .eraseToAnyPublisher() 59 | 60 | let calendarId = calendarIdStore.calendarId() 61 | .compactMap { $0 } 62 | .mapError { $0 as Error } 63 | .eraseToAnyPublisher() 64 | 65 | syncSubscription = Publishers 66 | .CombineLatest(bookmarksURL, calendarId) 67 | .flatMap(syncController.sync(bookmarksUrl:calendarId:)) 68 | .sink(receiveCompletion: { [weak self] completion in 69 | if self?.launchArguments.contains("-headless") == true { 70 | self?.appTerminator.terminate(nil) 71 | } else if case .failure(let error) = completion { 72 | self?.alertFactory.createError(error).runModal() 73 | } 74 | }, receiveValue: { _ in }) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Bookmarks/BookmarksLoaderSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Foundation 4 | @testable import ReadingListCalendarApp 5 | 6 | class BookmarksLoaderSpec: QuickSpec { 7 | override func spec() { 8 | describe("BookmarksLoader") { 9 | var sut: BookmarksLoader! 10 | var fileReader: FileReadingDouble! 11 | 12 | beforeEach { 13 | fileReader = FileReadingDouble() 14 | sut = BookmarksLoader() 15 | sut.fileReader = fileReader 16 | } 17 | 18 | context("load") { 19 | var url: URL! 20 | var bookmarks: Bookmark! 21 | 22 | beforeEach { 23 | fileReader.mockedData = Bookmark.fakeData() 24 | url = URL(fileURLWithPath: "bookmarks-url") 25 | bookmarks = try! sut.load(fromURL: url) 26 | } 27 | 28 | it("should read data from url") { 29 | expect(fileReader.didReadContentsAtPath) == url.path 30 | } 31 | 32 | it("should return correct booomarks") { 33 | expect(bookmarks) == Bookmark.fake() 34 | } 35 | } 36 | 37 | context("load from invalid url") { 38 | var url: URL! 39 | var thrownError: Error? 40 | 41 | beforeEach { 42 | url = URL(fileURLWithPath: "invalid url") 43 | fileReader.mockedData = nil 44 | do { 45 | _ = try sut.load(fromURL: url) 46 | } catch { 47 | thrownError = error 48 | } 49 | } 50 | 51 | it("should throw error") { 52 | expect(thrownError).notTo(beNil()) 53 | if let thrownError = thrownError as? BookmarksLoadingError, 54 | case let .unableToLoad(fromURL) = thrownError { 55 | expect(fromURL) == url 56 | } else { 57 | fail("invalid error thrown") 58 | } 59 | } 60 | } 61 | 62 | context("load from invalid data") { 63 | var thrownError: Error? 64 | 65 | beforeEach { 66 | fileReader.mockedData = Data() 67 | do { 68 | _ = try sut.load(fromURL: URL(fileURLWithPath: "")) 69 | } catch { 70 | thrownError = error 71 | } 72 | } 73 | 74 | it("should throw error") { 75 | expect(thrownError).notTo(beNil()) 76 | if let thrownError = thrownError as? BookmarksLoadingError, 77 | case let .decodingError(error) = thrownError { 78 | expect(error.localizedDescription).notTo(beEmpty()) 79 | } else { 80 | fail("invalid error thrown") 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/MainWindow/MainWindowControllerSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import EventKit 4 | @testable import ReadingListCalendarApp 5 | 6 | class MainWindowControllerSpec: QuickSpec { 7 | override func spec() { 8 | describe("factory") { 9 | var factory: MainWindowControllerFactory! 10 | 11 | beforeEach { 12 | factory = MainWindowControllerFactory() 13 | } 14 | 15 | context("create") { 16 | var sut: MainWindowController? 17 | var fileOpenerFactory: FileOpenerCreatingDouble! 18 | var fileBookmarks: FileBookmarkingDouble! 19 | var fileReadability: FileReadabilityDouble! 20 | var calendarAuthorizer: CalendarAuthorizingDouble! 21 | var alertFactory: ModalAlertCreatingDouble! 22 | var calendarsProvider: CalendarsProvidingDouble! 23 | var calendarIdStore: CalendarIdStoringDouble! 24 | var syncController: SyncControllingDouble! 25 | 26 | beforeEach { 27 | fileOpenerFactory = FileOpenerCreatingDouble() 28 | fileBookmarks = FileBookmarkingDouble() 29 | fileReadability = FileReadabilityDouble() 30 | calendarAuthorizer = CalendarAuthorizingDouble() 31 | alertFactory = ModalAlertCreatingDouble() 32 | calendarsProvider = CalendarsProvidingDouble() 33 | calendarIdStore = CalendarIdStoringDouble() 34 | syncController = SyncControllingDouble() 35 | sut = factory.create( 36 | fileOpenerFactory: fileOpenerFactory, 37 | fileBookmarks: fileBookmarks, 38 | fileReadability: fileReadability, 39 | calendarAuthorizer: calendarAuthorizer, 40 | alertFactory: alertFactory, 41 | calendarsProvider: calendarsProvider, 42 | calendarIdStore: calendarIdStore, 43 | syncController: syncController 44 | ) as? MainWindowController 45 | } 46 | 47 | afterEach { 48 | sut = nil 49 | } 50 | 51 | it("should not be nil") { 52 | expect(sut).notTo(beNil()) 53 | } 54 | 55 | describe("main view controller") { 56 | var mainViewController: MainViewController? 57 | 58 | beforeEach { 59 | mainViewController = sut?.mainViewController 60 | } 61 | 62 | it("should not be nil") { 63 | expect(mainViewController).notTo(beNil()) 64 | } 65 | 66 | it("should have correct dependencies") { 67 | expect(mainViewController?.fileOpenerFactory) === fileOpenerFactory 68 | expect(mainViewController?.fileBookmarks) === fileBookmarks 69 | expect(mainViewController?.fileReadability) === fileReadability 70 | expect(mainViewController?.calendarAuthorizer) === calendarAuthorizer 71 | expect(mainViewController?.alertFactory) === alertFactory 72 | expect(mainViewController?.calendarsProvider) === calendarsProvider 73 | expect(mainViewController?.calendarIdStore) === calendarIdStore 74 | expect(mainViewController?.syncController) === syncController 75 | } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Bookmarks/BookmarkReadingListSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Foundation 4 | @testable import ReadingListCalendarApp 5 | 6 | class BookmarkReadingListSpec: QuickSpec { 7 | override func spec() { 8 | context("Bookmark with reading list items") { 9 | var sut: Bookmark! 10 | 11 | beforeEach { 12 | sut = Bookmark.fake() 13 | } 14 | 15 | it("should have correct reading list items") { 16 | let expected = [ 17 | ReadingListItem( 18 | uuid: "RL-2", 19 | title: "EL Passion | App Development & Design House", 20 | url: "https://www.elpassion.com/", 21 | dateAdded: Date(timeIntervalSince1970: 1553675065), 22 | previewText: "MVP and full digital product development for startups and innovative companies. Custom web app development, mobile app development, and digital product design. Get a Free estimate of your project!" 23 | ), 24 | ReadingListItem( 25 | uuid: "RL-1", 26 | title: "Dariusz Rybicki - Software Engineer, Web, iOS and OS X App Developer", 27 | url: "http://darrarski.pl/", 28 | dateAdded: Date(timeIntervalSince1970: 1553675119), 29 | previewText: nil 30 | ) 31 | ] 32 | expect(try? sut.readingListItems()) == expected 33 | } 34 | } 35 | 36 | context("Bookmark without reading list") { 37 | var sut: Bookmark! 38 | 39 | beforeEach { 40 | sut = Bookmark( 41 | uuid: "1", 42 | title: nil, 43 | children: [], 44 | uri: nil, 45 | url: nil, 46 | readingList: nil 47 | ) 48 | } 49 | 50 | it("should throw reading list not found error") { 51 | var thrownError: Error? 52 | do { 53 | _ = try sut.readingListItems() 54 | } catch { 55 | thrownError = error 56 | } 57 | expect(thrownError).notTo(beNil()) 58 | if let error = thrownError as? ReadingListError, 59 | case ReadingListError.readingListNotFound = error {} else { 60 | fail("invalid error thrown: \(String(describing: thrownError))") 61 | } 62 | } 63 | } 64 | 65 | context("Bookmark without reading list items") { 66 | var sut: Bookmark! 67 | 68 | beforeEach { 69 | sut = Bookmark( 70 | uuid: "1", 71 | title: nil, 72 | children: [ 73 | Bookmark( 74 | uuid: "2", 75 | title: "com.apple.ReadingList", 76 | children: nil, 77 | uri: nil, 78 | url: nil, 79 | readingList: nil 80 | ) 81 | ], 82 | uri: nil, 83 | url: nil, 84 | readingList: nil 85 | ) 86 | } 87 | 88 | it("should throw reading list not found error") { 89 | var thrownError: Error? 90 | do { 91 | _ = try sut.readingListItems() 92 | } catch { 93 | thrownError = error 94 | } 95 | expect(thrownError).notTo(beNil()) 96 | if let error = thrownError as? ReadingListError, 97 | case ReadingListError.readingListItemsNotFound = error {} else { 98 | fail("invalid error thrown: \(String(describing: thrownError))") 99 | } 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /ReadingListCalendar.xcodeproj/xcshareddata/xcschemes/ReadingListCalendar.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 48 | 49 | 50 | 51 | 53 | 59 | 60 | 61 | 62 | 63 | 73 | 75 | 81 | 82 | 83 | 84 | 87 | 88 | 91 | 92 | 93 | 94 | 100 | 102 | 108 | 109 | 110 | 111 | 113 | 114 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Sync/SyncControllerSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Combine 4 | import EventKit 5 | import Foundation 6 | @testable import ReadingListCalendarApp 7 | 8 | class SyncControllerSpec: QuickSpec { 9 | override func spec() { 10 | describe("instantiate") { 11 | var sut: SyncController! 12 | var bookmarksLoader: BookmarskLoadingDouble! 13 | var calendarProvider: CalendarProvidingDouble! 14 | var eventProvider: ReadingListItemEventProvidingDouble! 15 | var eventCreator: ReadingListItemEventCreatingDouble! 16 | var eventSaver: EventSavingDouble! 17 | 18 | beforeEach { 19 | sut = SyncController() 20 | bookmarksLoader = BookmarskLoadingDouble() 21 | sut.bookmarksLoader = bookmarksLoader 22 | calendarProvider = CalendarProvidingDouble() 23 | sut.calendarProvider = calendarProvider 24 | eventProvider = ReadingListItemEventProvidingDouble() 25 | sut.eventProvider = eventProvider 26 | eventCreator = ReadingListItemEventCreatingDouble() 27 | sut.eventCreator = eventCreator 28 | eventSaver = EventSavingDouble() 29 | sut.eventSaver = eventSaver 30 | } 31 | 32 | context("sync") { 33 | var bookmarksUrl: URL! 34 | var calendarId: String! 35 | var isSynchronizingEvents: [Bool]! 36 | var syncProgressEvents: [Double?]! 37 | var result: Result<[Void], Error>! 38 | var subscriptions: Set! 39 | 40 | beforeEach { 41 | bookmarksUrl = URL(fileURLWithPath: "") 42 | calendarId = "CALENDAR-1234" 43 | subscriptions = Set() 44 | 45 | isSynchronizingEvents = [] 46 | sut.isSynchronizing() 47 | .sink(receiveValue: { isSynchronizingEvents.append($0) }) 48 | .store(in: &subscriptions) 49 | 50 | syncProgressEvents = [] 51 | sut.syncProgress() 52 | .sink(receiveValue: { syncProgressEvents.append($0) }) 53 | .store(in: &subscriptions) 54 | 55 | result = sut.sync(bookmarksUrl: bookmarksUrl, calendarId: calendarId) 56 | .materialize() 57 | } 58 | 59 | afterEach { 60 | isSynchronizingEvents = nil 61 | syncProgressEvents = nil 62 | result = nil 63 | subscriptions = nil 64 | } 65 | 66 | it("should load bookmarks from url") { 67 | expect(bookmarksLoader.didLoadFromURL) == bookmarksUrl 68 | } 69 | 70 | it("should load calendar with identifier") { 71 | expect(calendarProvider.didLoadCalendarWithIdentifier) == calendarId 72 | } 73 | 74 | it("should create events") { 75 | expect(eventCreator.didCreateEventsForItems) 76 | == (try! Bookmark.fake().readingListItems()) 77 | expect(eventCreator.didCreateEventsInCalendars) 78 | == Array(repeating: calendarProvider.mockedCalendar, count: 2) 79 | } 80 | 81 | it("should save events") { 82 | expect(eventSaver.savedEvents.map { $0.event }) 83 | == eventCreator.createdEvents 84 | expect(eventSaver.savedEvents.map { $0.span }) 85 | == Array(repeating: .thisEvent, count: 2) 86 | expect(eventSaver.savedEvents.map { $0.commit }) 87 | == Array(repeating: true, count: 2) 88 | } 89 | 90 | it("should emit correct isSynchronizing events") { 91 | expect(isSynchronizingEvents) == [false, true, false] 92 | } 93 | 94 | it("should emit correct syncProgress events") { 95 | expect(syncProgressEvents) == [nil, 0, 0.5, 1, nil] 96 | } 97 | 98 | it("should complete") { 99 | expect(try? result.get()).to(haveCount(1)) 100 | } 101 | } 102 | 103 | context("sync to invalid calendar") { 104 | var result: Result<[Void], Error>! 105 | 106 | beforeEach { 107 | calendarProvider.mockedCalendar = nil 108 | result = sut.sync(bookmarksUrl: URL(fileURLWithPath: ""), calendarId: "") 109 | .materialize() 110 | } 111 | 112 | afterEach { 113 | result = nil 114 | } 115 | 116 | it("should emit error") { 117 | expect { try result.get() }.to(throwError(closure: { error in 118 | if let error = error as? SyncError, 119 | case SyncError.calendarNotFound = error {} else { 120 | fail("invalid error emitted \(String(describing: error))") 121 | } 122 | })) 123 | } 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/MainWindow/MainViewController.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable file_length 2 | import AppKit 3 | import Combine 4 | import EventKit 5 | 6 | class MainViewController: NSViewController { 7 | 8 | private(set) var fileOpenerFactory: FileOpenerCreating! 9 | private(set) var fileBookmarks: FileBookmarking! 10 | private(set) var fileReadability: FileReadablity! 11 | private(set) var calendarAuthorizer: CalendarAuthorizing! 12 | private(set) var alertFactory: ModalAlertCreating! 13 | private(set) var calendarsProvider: CalendarsProviding! 14 | private(set) var calendarIdStore: CalendarIdStoring! 15 | private(set) var syncController: SyncControlling! 16 | 17 | var uiScheduler: DispatchQueue? = .main 18 | 19 | // swiftlint:disable:next function_parameter_count 20 | func setUp(fileOpenerFactory: FileOpenerCreating, 21 | fileBookmarks: FileBookmarking, 22 | fileReadability: FileReadablity, 23 | calendarAuthorizer: CalendarAuthorizing, 24 | alertFactory: ModalAlertCreating, 25 | calendarsProvider: CalendarsProviding, 26 | calendarIdStore: CalendarIdStoring, 27 | syncController: SyncControlling) { 28 | self.fileOpenerFactory = fileOpenerFactory 29 | self.fileBookmarks = fileBookmarks 30 | self.fileReadability = fileReadability 31 | self.calendarAuthorizer = calendarAuthorizer 32 | self.alertFactory = alertFactory 33 | self.calendarsProvider = calendarsProvider 34 | self.calendarIdStore = calendarIdStore 35 | self.syncController = syncController 36 | setUpBindings() 37 | } 38 | 39 | // MARK: View 40 | 41 | @IBOutlet private weak var bookmarksPathField: NSTextField! 42 | @IBOutlet private weak var bookmarksPathButton: NSButton! 43 | @IBOutlet private weak var bookmarksStatusField: NSTextField! 44 | @IBOutlet private weak var calendarAuthField: NSTextField! 45 | @IBOutlet private weak var calendarAuthButton: NSButton! 46 | @IBOutlet private weak var calendarSelectionField: NSTextField! 47 | @IBOutlet private weak var calendarSelectionButton: NSPopUpButton! 48 | @IBOutlet private weak var statusField: NSTextField! 49 | @IBOutlet private weak var synchronizeButton: NSButton! 50 | @IBOutlet private weak var progressIndicator: NSProgressIndicator! 51 | 52 | // MARK: Priavte 53 | 54 | private let bookmarksUrl = CurrentValueSubject(nil) 55 | private let calendarAuth = CurrentValueSubject(nil) 56 | private let calendars = CurrentValueSubject<[(id: String, title: String)], Never>([]) 57 | private let calendarId = CurrentValueSubject(nil) 58 | private var subscriptions = Set() 59 | private var openBookmarksFileSubscription: AnyCancellable? 60 | private var calendarAuthSubscription: AnyCancellable? 61 | private var calendarSelectionSubscription: AnyCancellable? 62 | private var synchronizeSubscription: AnyCancellable? 63 | 64 | @IBAction private func bookmarksPathButtonAction(_ sender: Any) { 65 | openBookmarksFileSubscription?.cancel() 66 | openBookmarksFileSubscription = openBookmarksFile(fileOpenerFactory)() 67 | .map { $0 as URL? } 68 | .assign(to: \.value, on: bookmarksUrl) 69 | } 70 | 71 | @IBAction func calendarAuthButtonAction(_ sender: Any) { 72 | calendarAuthSubscription?.cancel() 73 | calendarAuthSubscription = calendarAuthorizer.requestAccessToEvents().first() 74 | .flatMap { [unowned self] _ in 75 | self.calendarAuthorizer.eventsAuthorizationStatus().first() 76 | .mapError { $0 as Error } 77 | } 78 | .receive(optionallyOn: uiScheduler) 79 | .handleEvents(receiveOutput: presentAlertForCalendarAuth(alertFactory)) 80 | .catch { _ in Empty() } 81 | .map { $0 as EKAuthorizationStatus? } 82 | .assign(to: \.value, on: calendarAuth) 83 | } 84 | 85 | @IBAction func calendarSelectionButtonAction(_ sender: Any) { 86 | calendarSelectionSubscription?.cancel() 87 | let selectedIndex = calendarSelectionButton.indexOfSelectedItem 88 | calendarSelectionSubscription = calendars.first() 89 | .map { $0[selectedIndex].id } 90 | .assign(to: \.value, on: calendarId) 91 | } 92 | 93 | @IBAction private func synchronizeButtonAction(_ sender: Any) { 94 | synchronizeSubscription?.cancel() 95 | synchronizeSubscription = Publishers 96 | .CombineLatest( 97 | bookmarksUrl.first().eraseToAnyPublisher(), 98 | calendarId.first().eraseToAnyPublisher() 99 | ).map { (bookmarksUrl: $0, calendarId: $1) } 100 | .compactMap { $0 as? (bookmarksUrl: URL, calendarId: String) } 101 | .mapError { $0 as Error } 102 | .flatMap { [unowned self] in 103 | self.syncController.sync( 104 | bookmarksUrl: $0.bookmarksUrl, 105 | calendarId: $0.calendarId 106 | ).subscribe(on: DispatchQueue.global(qos: .background)) 107 | } 108 | .receive(optionallyOn: uiScheduler) 109 | .sink(receiveCompletion: { [weak self] completion in 110 | if case .failure(let error) = completion { 111 | self?.alertFactory.createError(error).runModal() 112 | } 113 | }, receiveValue: { _ in }) 114 | } 115 | 116 | // swiftlint:disable:next function_body_length 117 | private func setUpBindings() { 118 | fileBookmarks.bookmarksFileURL() 119 | .replaceError(with: nil) 120 | .assign(to: \.value, on: bookmarksUrl) 121 | .store(in: &subscriptions) 122 | 123 | bookmarksUrl.dropFirst() 124 | .flatMap { [unowned self] in 125 | self.fileBookmarks.setBookmarksFileURL($0).replaceError(with: ()) 126 | }.sink { _ in } 127 | .store(in: &subscriptions) 128 | 129 | bookmarksUrl.map(filePath("Bookmarks.plist")) 130 | .receive(optionallyOn: uiScheduler) 131 | .assign(to: \.stringValue, on: bookmarksPathField) 132 | .store(in: &subscriptions) 133 | 134 | bookmarksUrl.map(fileReadabilityStatus("Bookmarks.plist", fileReadability)) 135 | .receive(optionallyOn: uiScheduler) 136 | .assign(to: \.stringValue, on: bookmarksStatusField) 137 | .store(in: &subscriptions) 138 | 139 | calendarAuthorizer.eventsAuthorizationStatus() 140 | .map { $0 as EKAuthorizationStatus? } 141 | .assign(to: \.value, on: calendarAuth) 142 | .store(in: &subscriptions) 143 | 144 | calendarAuth.compactMap { $0?.text } 145 | .receive(optionallyOn: uiScheduler) 146 | .assign(to: \.stringValue, on: calendarAuthField) 147 | .store(in: &subscriptions) 148 | 149 | calendarAuth.map { _ in () } 150 | .flatMap(calendarsProvider.eventCalendars) 151 | .map { calendars in calendars.map { (id: $0.calendarIdentifier, title: $0.title) } } 152 | .assign(to: \.value, on: calendars) 153 | .store(in: &subscriptions) 154 | 155 | calendarIdStore.calendarId() 156 | .replaceError(with: nil) 157 | .assign(to: \.value, on: calendarId) 158 | .store(in: &subscriptions) 159 | 160 | calendarId.dropFirst() 161 | .flatMap(calendarIdStore.setCalendarId(_:)) 162 | .sink { _ in } 163 | .store(in: &subscriptions) 164 | 165 | calendars.flatMap { [unowned self] calendars in 166 | self.calendarId.map { calendarId in 167 | (titles: calendars.map { $0.title }, selected: calendars.firstIndex(where: { $0.id == calendarId })) 168 | }.eraseToAnyPublisher() 169 | }.receive(optionallyOn: uiScheduler) 170 | .sink { [weak self] in self?.calendarSelectionButton.updateItems($0) } 171 | .store(in: &subscriptions) 172 | 173 | Publishers.CombineLatest4( 174 | bookmarksUrl.map(isReadableFile(fileReadability)).eraseToAnyPublisher(), 175 | calendarAuth.map { $0 == .authorized }.eraseToAnyPublisher(), 176 | calendarId.map { $0 != nil }.eraseToAnyPublisher(), 177 | syncController.isSynchronizing().map { !$0 }.eraseToAnyPublisher() 178 | ).map { $0 && $1 && $2 && $3 } 179 | .receive(optionallyOn: uiScheduler) 180 | .assign(to: \.isEnabled, on: synchronizeButton) 181 | .store(in: &subscriptions) 182 | 183 | syncController.isSynchronizing().map { !$0 } 184 | .receive(optionallyOn: uiScheduler) 185 | .sink { [weak self] in 186 | self?.bookmarksPathButton.isEnabled = $0 187 | self?.calendarAuthButton.isEnabled = $0 188 | self?.calendarSelectionButton.isEnabled = $0 189 | } 190 | .store(in: &subscriptions) 191 | 192 | syncController.syncProgress() 193 | .receive(optionallyOn: uiScheduler) 194 | .sink { [weak self] in self?.progressIndicator.update(fractionCompleted: $0) } 195 | .store(in: &subscriptions) 196 | } 197 | 198 | } 199 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/Application/AppDelegateSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Cocoa 4 | import EventKit 5 | @testable import ReadingListCalendarApp 6 | 7 | class AppDelegateSpec: QuickSpec { 8 | override func spec() { 9 | context("create") { 10 | var sut: AppDelegate! 11 | 12 | beforeEach { 13 | sut = AppDelegate() 14 | } 15 | 16 | it("should have correct launch arguments") { 17 | expect(sut.launchArguments) == CommandLine.arguments 18 | } 19 | 20 | it("should have correct dependencies") { 21 | expect(sut.appTerminator) === NSApplication.shared 22 | expect(sut.mainWindowControllerFactory) 23 | .to(beAnInstanceOf(MainWindowControllerFactory.self)) 24 | expect(sut.fileOpenerFactory).to(beAnInstanceOf(FileOpenerFactory.self)) 25 | expect(sut.fileBookmarks) === UserDefaults.standard 26 | expect(sut.fileReadability) === FileManager.default 27 | expect(sut.calendarAuthorizer).to(beAKindOf(EKEventStore.self)) 28 | expect(sut.alertFactory).to(beAnInstanceOf(ModalAlertFactory.self)) 29 | expect(sut.calendarsProvider).to(beAKindOf(EKEventStore.self)) 30 | expect(sut.calendarIdStore) === UserDefaults.standard 31 | expect(sut.syncController).to(beAnInstanceOf(SyncController.self)) 32 | } 33 | 34 | it("should terminate after last window closed") { 35 | expect(sut.applicationShouldTerminateAfterLastWindowClosed(NSApplication.shared)) == true 36 | } 37 | 38 | context("when app did finish launching without arguments") { 39 | var mainWindowControllerFactory: MainWindowControllerCreatingDouble! 40 | var fileOpenerFactory: FileOpenerCreatingDouble! 41 | var fileBookmarks: FileBookmarkingDouble! 42 | var fileReadability: FileReadabilityDouble! 43 | var calendarAuthorizer: CalendarAuthorizingDouble! 44 | var alertFactory: ModalAlertCreatingDouble! 45 | var calendarsProvider: CalendarsProvidingDouble! 46 | var calendarIdStore: CalendarIdStoringDouble! 47 | var syncController: SyncControllingDouble! 48 | 49 | beforeEach { 50 | mainWindowControllerFactory = MainWindowControllerCreatingDouble() 51 | fileOpenerFactory = FileOpenerCreatingDouble() 52 | fileBookmarks = FileBookmarkingDouble() 53 | fileReadability = FileReadabilityDouble() 54 | calendarAuthorizer = CalendarAuthorizingDouble() 55 | alertFactory = ModalAlertCreatingDouble() 56 | calendarsProvider = CalendarsProvidingDouble() 57 | calendarIdStore = CalendarIdStoringDouble() 58 | syncController = SyncControllingDouble() 59 | sut.launchArguments = [] 60 | sut.appTerminator = AppTerminatingDouble() 61 | sut.mainWindowControllerFactory = mainWindowControllerFactory 62 | sut.fileOpenerFactory = fileOpenerFactory 63 | sut.fileBookmarks = fileBookmarks 64 | sut.fileReadability = fileReadability 65 | sut.calendarAuthorizer = calendarAuthorizer 66 | sut.alertFactory = alertFactory 67 | sut.calendarsProvider = calendarsProvider 68 | sut.calendarIdStore = calendarIdStore 69 | sut.syncController = syncController 70 | 71 | sut.applicationDidFinishLaunching(Notification(name: Notification.Name(rawValue: ""))) 72 | } 73 | 74 | it("should create main window controller with correct dependencies") { 75 | expect(mainWindowControllerFactory.didCreate) == true 76 | expect(mainWindowControllerFactory.didCreateWithFileOpenerFactory) === fileOpenerFactory 77 | expect(mainWindowControllerFactory.didCreateWithFileBookmarks) === fileBookmarks 78 | expect(mainWindowControllerFactory.didCreateWithFileReadability) === fileReadability 79 | expect(mainWindowControllerFactory.didCreateWithCalendarAuthorizer) === calendarAuthorizer 80 | expect(mainWindowControllerFactory.didCreateWithAlertFactory) === alertFactory 81 | expect(mainWindowControllerFactory.didCreateWithCalendarsProvider) === calendarsProvider 82 | expect(mainWindowControllerFactory.didCreateWithCalendarIdStore) === calendarIdStore 83 | expect(mainWindowControllerFactory.didCreateWithSyncController) === syncController 84 | } 85 | 86 | it("should show main window") { 87 | let mainWindowController = mainWindowControllerFactory.mock 88 | expect(mainWindowController.didShowWindow) == true 89 | expect(mainWindowController.didShowWindowSender) === sut 90 | } 91 | } 92 | 93 | context("when app did finish launching with sync argument") { 94 | var appTerminator: AppTerminatingDouble! 95 | var mainWindowControllerFactory: MainWindowControllerCreatingDouble! 96 | var fileOpenerFactory: FileOpenerCreatingDouble! 97 | var fileBookmarks: FileBookmarkingDouble! 98 | var bookmarksFileURL: URL! 99 | var fileReadability: FileReadabilityDouble! 100 | var calendarAuthorizer: CalendarAuthorizingDouble! 101 | var alertFactory: ModalAlertCreatingDouble! 102 | var calendarsProvider: CalendarsProvidingDouble! 103 | var calendarIdStore: CalendarIdStoringDouble! 104 | var calendarId: String! 105 | var syncController: SyncControllingDouble! 106 | 107 | beforeEach { 108 | appTerminator = AppTerminatingDouble() 109 | mainWindowControllerFactory = MainWindowControllerCreatingDouble() 110 | fileOpenerFactory = FileOpenerCreatingDouble() 111 | fileBookmarks = FileBookmarkingDouble() 112 | bookmarksFileURL = URL(fileURLWithPath: "bookmarks-url") 113 | fileBookmarks.urls["bookmarks_file_url"] = bookmarksFileURL 114 | fileReadability = FileReadabilityDouble() 115 | calendarAuthorizer = CalendarAuthorizingDouble() 116 | alertFactory = ModalAlertCreatingDouble() 117 | calendarsProvider = CalendarsProvidingDouble() 118 | calendarIdStore = CalendarIdStoringDouble() 119 | calendarId = "calendar-id" 120 | calendarIdStore.mockedCalendarId = calendarId 121 | syncController = SyncControllingDouble() 122 | sut.launchArguments = ["-sync"] 123 | sut.appTerminator = appTerminator 124 | sut.mainWindowControllerFactory = mainWindowControllerFactory 125 | sut.fileOpenerFactory = fileOpenerFactory 126 | sut.fileBookmarks = fileBookmarks 127 | sut.fileReadability = fileReadability 128 | sut.calendarAuthorizer = calendarAuthorizer 129 | sut.alertFactory = alertFactory 130 | sut.calendarsProvider = calendarsProvider 131 | sut.calendarIdStore = calendarIdStore 132 | sut.syncController = syncController 133 | 134 | sut.applicationDidFinishLaunching(Notification(name: Notification.Name(rawValue: ""))) 135 | } 136 | 137 | it("should create main window controller with correct dependencies") { 138 | expect(mainWindowControllerFactory.didCreate) == true 139 | expect(mainWindowControllerFactory.didCreateWithFileOpenerFactory) === fileOpenerFactory 140 | expect(mainWindowControllerFactory.didCreateWithFileBookmarks) === fileBookmarks 141 | expect(mainWindowControllerFactory.didCreateWithFileReadability) === fileReadability 142 | expect(mainWindowControllerFactory.didCreateWithCalendarAuthorizer) === calendarAuthorizer 143 | expect(mainWindowControllerFactory.didCreateWithAlertFactory) === alertFactory 144 | expect(mainWindowControllerFactory.didCreateWithCalendarsProvider) === calendarsProvider 145 | expect(mainWindowControllerFactory.didCreateWithCalendarIdStore) === calendarIdStore 146 | expect(mainWindowControllerFactory.didCreateWithSyncController) === syncController 147 | } 148 | 149 | it("should show main window") { 150 | let mainWindowController = mainWindowControllerFactory.mock 151 | expect(mainWindowController.didShowWindow) == true 152 | expect(mainWindowController.didShowWindowSender) === sut 153 | } 154 | 155 | it("should sync with stored bookmarks file url and calendar id") { 156 | expect(syncController.didSyncBookmarksUrl) == bookmarksFileURL 157 | expect(syncController.didSyncCalendarId) == calendarId 158 | } 159 | 160 | context("when sync completes") { 161 | beforeEach { 162 | syncController.syncSubject?.send(()) 163 | syncController.syncSubject?.send(completion: .finished) 164 | } 165 | 166 | it("should not terminate app") { 167 | expect(appTerminator.didTerminate) == false 168 | } 169 | } 170 | 171 | context("when sync fails") { 172 | var error: NSError! 173 | 174 | beforeEach { 175 | error = NSError(domain: "test", code: 123, userInfo: nil) 176 | syncController.syncSubject?.send(completion: .failure(error)) 177 | } 178 | 179 | it("should present error alert") { 180 | expect(alertFactory.didCreateWithStyle) == .critical 181 | expect(alertFactory.didCreateWithTitle) == error.title 182 | expect(alertFactory.didCreateWithMessage) == error.message 183 | expect(alertFactory.alertDouble.didRunModal) == true 184 | } 185 | 186 | it("should not terminate app") { 187 | expect(appTerminator.didTerminate) == false 188 | } 189 | } 190 | } 191 | 192 | context("when app did finish launching with sync and headless arguments") { 193 | var appTerminator: AppTerminatingDouble! 194 | var mainWindowControllerFactory: MainWindowControllerCreatingDouble! 195 | var fileOpenerFactory: FileOpenerCreatingDouble! 196 | var fileBookmarks: FileBookmarkingDouble! 197 | var bookmarksFileURL: URL! 198 | var fileReadability: FileReadabilityDouble! 199 | var calendarAuthorizer: CalendarAuthorizingDouble! 200 | var alertFactory: ModalAlertCreatingDouble! 201 | var calendarsProvider: CalendarsProvidingDouble! 202 | var calendarIdStore: CalendarIdStoringDouble! 203 | var calendarId: String! 204 | var syncController: SyncControllingDouble! 205 | 206 | beforeEach { 207 | appTerminator = AppTerminatingDouble() 208 | mainWindowControllerFactory = MainWindowControllerCreatingDouble() 209 | fileOpenerFactory = FileOpenerCreatingDouble() 210 | fileBookmarks = FileBookmarkingDouble() 211 | bookmarksFileURL = URL(fileURLWithPath: "bookmarks-url") 212 | fileBookmarks.urls["bookmarks_file_url"] = bookmarksFileURL 213 | fileReadability = FileReadabilityDouble() 214 | calendarAuthorizer = CalendarAuthorizingDouble() 215 | alertFactory = ModalAlertCreatingDouble() 216 | calendarsProvider = CalendarsProvidingDouble() 217 | calendarIdStore = CalendarIdStoringDouble() 218 | calendarId = "calendar-id" 219 | calendarIdStore.mockedCalendarId = calendarId 220 | syncController = SyncControllingDouble() 221 | sut.launchArguments = ["-sync", "-headless"] 222 | sut.appTerminator = appTerminator 223 | sut.mainWindowControllerFactory = mainWindowControllerFactory 224 | sut.fileOpenerFactory = fileOpenerFactory 225 | sut.fileBookmarks = fileBookmarks 226 | sut.fileReadability = fileReadability 227 | sut.calendarAuthorizer = calendarAuthorizer 228 | sut.alertFactory = alertFactory 229 | sut.calendarsProvider = calendarsProvider 230 | sut.calendarIdStore = calendarIdStore 231 | sut.syncController = syncController 232 | 233 | sut.applicationDidFinishLaunching(Notification(name: Notification.Name(rawValue: ""))) 234 | } 235 | 236 | it("should not create main window controller") { 237 | expect(mainWindowControllerFactory.didCreate) == false 238 | } 239 | 240 | it("should not show main window") { 241 | let mainWindowController = mainWindowControllerFactory.mock 242 | expect(mainWindowController.didShowWindow) == false 243 | } 244 | 245 | it("should sync with stored bookmarks file url and calendar id") { 246 | expect(syncController.didSyncBookmarksUrl) == bookmarksFileURL 247 | expect(syncController.didSyncCalendarId) == calendarId 248 | } 249 | 250 | context("when sync completes") { 251 | beforeEach { 252 | syncController.syncSubject?.send(()) 253 | syncController.syncSubject?.send(completion: .finished) 254 | } 255 | 256 | it("should terminate app") { 257 | expect(appTerminator.didTerminate) == true 258 | expect(appTerminator.didTerminateWithSender).to(beNil()) 259 | } 260 | } 261 | 262 | context("when sync fails") { 263 | var error: NSError! 264 | 265 | beforeEach { 266 | error = NSError(domain: "test", code: 123, userInfo: nil) 267 | syncController.syncSubject?.send(completion: .failure(error)) 268 | } 269 | 270 | it("should not present error alert") { 271 | expect(alertFactory.didCreateWithStyle).to(beNil()) 272 | expect(alertFactory.alertDouble.didRunModal) == false 273 | } 274 | 275 | it("should terminate app") { 276 | expect(appTerminator.didTerminate) == true 277 | expect(appTerminator.didTerminateWithSender).to(beNil()) 278 | } 279 | } 280 | } 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /ReadingListCalendarAppTests/MainWindow/MainViewControllerSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import Cocoa 4 | import EventKit 5 | @testable import ReadingListCalendarApp 6 | 7 | class MainViewControllerSpec: QuickSpec { 8 | override func spec() { 9 | context("instantiate") { 10 | var sut: MainViewController? 11 | 12 | beforeEach { 13 | let bundle = Bundle(for: MainViewController.self) 14 | let storyboard = NSStoryboard(name: "Main", bundle: bundle) 15 | let identifier = "MainViewController" 16 | sut = storyboard.instantiateController(withIdentifier: identifier) as? MainViewController 17 | sut?.uiScheduler = nil 18 | _ = sut?.view 19 | } 20 | 21 | it("should not be nil") { 22 | expect(sut).notTo(beNil()) 23 | } 24 | 25 | context("set up with readable bookmarks path") { 26 | var fileBookmarks: FileBookmarkingDouble! 27 | 28 | beforeEach { 29 | fileBookmarks = FileBookmarkingDouble() 30 | fileBookmarks.urls["bookmarks_file_url"] = URL(fileURLWithPath: "/tmp") 31 | 32 | sut?.setUp( 33 | fileOpenerFactory: FileOpenerCreatingDouble(), 34 | fileBookmarks: fileBookmarks, 35 | fileReadability: FileManager.default, 36 | calendarAuthorizer: CalendarAuthorizingDouble(), 37 | alertFactory: ModalAlertCreatingDouble(), 38 | calendarsProvider: CalendarsProvidingDouble(), 39 | calendarIdStore: CalendarIdStoringDouble(), 40 | syncController: SyncControllingDouble() 41 | ) 42 | } 43 | 44 | it("should have correct bookmarks path") { 45 | expect(sut?.bookmarksPathField.stringValue) 46 | == fileBookmarks.urls["bookmarks_file_url"]!.absoluteString 47 | } 48 | 49 | it("should have correct bookmarks status") { 50 | expect(sut?.bookmarksStatusField.stringValue) == "✓ Bookmarks.plist file is set and readable" 51 | } 52 | } 53 | 54 | context("set up with not readable bookmarks path") { 55 | var fileBookmarks: FileBookmarkingDouble! 56 | 57 | beforeEach { 58 | fileBookmarks = FileBookmarkingDouble() 59 | fileBookmarks.urls["bookmarks_file_url"] = URL(fileURLWithPath: "bookmarks_file_url") 60 | 61 | sut?.setUp( 62 | fileOpenerFactory: FileOpenerCreatingDouble(), 63 | fileBookmarks: fileBookmarks, 64 | fileReadability: FileManager.default, 65 | calendarAuthorizer: CalendarAuthorizingDouble(), 66 | alertFactory: ModalAlertCreatingDouble(), 67 | calendarsProvider: CalendarsProvidingDouble(), 68 | calendarIdStore: CalendarIdStoringDouble(), 69 | syncController: SyncControllingDouble() 70 | ) 71 | } 72 | 73 | it("should have correct bookmarks path") { 74 | let expected = fileBookmarks.urls["bookmarks_file_url"]!.absoluteString 75 | expect(sut?.bookmarksPathField.stringValue) == expected 76 | } 77 | 78 | it("should have correct bookmarks status") { 79 | expect(sut?.bookmarksStatusField.stringValue) == "❌ Bookmarks.plist file is not readable" 80 | } 81 | 82 | it("should sync button be disabled") { 83 | expect(sut?.synchronizeButton.isEnabled) == false 84 | } 85 | } 86 | 87 | context("set up without bookmarks path") { 88 | beforeEach { 89 | sut?.setUp( 90 | fileOpenerFactory: FileOpenerCreatingDouble(), 91 | fileBookmarks: FileBookmarkingDouble(), 92 | fileReadability: FileManager.default, 93 | calendarAuthorizer: CalendarAuthorizingDouble(), 94 | alertFactory: ModalAlertCreatingDouble(), 95 | calendarsProvider: CalendarsProvidingDouble(), 96 | calendarIdStore: CalendarIdStoringDouble(), 97 | syncController: SyncControllingDouble() 98 | ) 99 | } 100 | 101 | it("should have correct bookmarks path message") { 102 | expect(sut?.bookmarksPathField.stringValue) == "❌ Bookmarks.plist file is not set" 103 | } 104 | 105 | it("should have empty bookmarks status") { 106 | expect(sut?.bookmarksStatusField.stringValue).to(beEmpty()) 107 | } 108 | 109 | it("should sync button be disabled") { 110 | expect(sut?.synchronizeButton.isEnabled) == false 111 | } 112 | } 113 | 114 | context("set up for opening bookmarks file") { 115 | var fileOpenerFactory: FileOpenerCreatingDouble! 116 | var fileBookmarks: FileBookmarkingDouble! 117 | 118 | beforeEach { 119 | fileOpenerFactory = FileOpenerCreatingDouble() 120 | fileBookmarks = FileBookmarkingDouble() 121 | 122 | sut?.setUp( 123 | fileOpenerFactory: fileOpenerFactory, 124 | fileBookmarks: fileBookmarks, 125 | fileReadability: FileManager.default, 126 | calendarAuthorizer: CalendarAuthorizingDouble(), 127 | alertFactory: ModalAlertCreatingDouble(), 128 | calendarsProvider: CalendarsProvidingDouble(), 129 | calendarIdStore: CalendarIdStoringDouble(), 130 | syncController: SyncControllingDouble() 131 | ) 132 | } 133 | 134 | context("click bookmarks path button") { 135 | beforeEach { 136 | sut?.bookmarksPathButton.performClick(nil) 137 | } 138 | 139 | it("should create bookmarks file opener") { 140 | expect(fileOpenerFactory.didCreateWithTitle) == "Open Bookmarks.plist file" 141 | expect(fileOpenerFactory.didCreateWithExt) == "plist" 142 | let expectedURL = URL(fileURLWithPath: "/Users/\(NSUserName())/Library/Safari/Bookmarks.plist") 143 | expect(fileOpenerFactory.didCreateWithUrl) == expectedURL 144 | } 145 | 146 | it("should begin opening file") { 147 | expect(fileOpenerFactory.openerDouble.didBeginOpeningFile) == true 148 | } 149 | 150 | context("when file is opened") { 151 | var url: URL! 152 | 153 | beforeEach { 154 | url = URL(fileURLWithPath: "/tmp") 155 | fileOpenerFactory.openerDouble.openFileCompletion?(url) 156 | } 157 | 158 | it("should have correct bookmarks path") { 159 | expect(sut?.bookmarksPathField.stringValue) == url.absoluteString 160 | } 161 | 162 | it("should have correct bookmarks status") { 163 | expect(sut?.bookmarksStatusField.stringValue) == "✓ Bookmarks.plist file is set and readable" 164 | } 165 | 166 | it("should save bookmarks file url") { 167 | expect(fileBookmarks.urls["bookmarks_file_url"]) == url 168 | } 169 | 170 | context("click bookmarks path button") { 171 | beforeEach { 172 | sut?.bookmarksPathButton.performClick(nil) 173 | } 174 | 175 | context("when file is not opened") { 176 | beforeEach { 177 | fileOpenerFactory.openerDouble.openFileCompletion?(nil) 178 | } 179 | 180 | it("should have correct bookmarks path") { 181 | expect(sut?.bookmarksPathField.stringValue) == url.absoluteString 182 | } 183 | } 184 | } 185 | } 186 | } 187 | } 188 | 189 | context("set up with calendar authorization not determined") { 190 | var calendarAuthorizer: CalendarAuthorizingDouble! 191 | var alertFactory: ModalAlertCreatingDouble! 192 | 193 | beforeEach { 194 | calendarAuthorizer = CalendarAuthorizingDouble() 195 | CalendarAuthorizingDouble.authorizationStatusMock = .notDetermined 196 | alertFactory = ModalAlertCreatingDouble() 197 | 198 | sut?.setUp( 199 | fileOpenerFactory: FileOpenerCreatingDouble(), 200 | fileBookmarks: FileBookmarkingDouble(), 201 | fileReadability: FileManager.default, 202 | calendarAuthorizer: calendarAuthorizer, 203 | alertFactory: alertFactory, 204 | calendarsProvider: CalendarsProvidingDouble(), 205 | calendarIdStore: CalendarIdStoringDouble(), 206 | syncController: SyncControllingDouble() 207 | ) 208 | } 209 | 210 | it("should have correct calendar auth message") { 211 | expect(sut?.calendarAuthField.stringValue) == "❌ Callendar access not determined" 212 | } 213 | 214 | it("should sync button be disabled") { 215 | expect(sut?.synchronizeButton.isEnabled) == false 216 | } 217 | 218 | context("click authorize button") { 219 | beforeEach { 220 | sut?.calendarAuthButton.performClick(nil) 221 | } 222 | 223 | it("should request access to calendar events") { 224 | expect(calendarAuthorizer.didRequestAccessToEntityType) == .event 225 | } 226 | 227 | context("when request is granted") { 228 | beforeEach { 229 | type(of: calendarAuthorizer).authorizationStatusMock = .authorized 230 | calendarAuthorizer.requestAccessCompletion?(true, nil) 231 | } 232 | 233 | it("should have correct calendar auth message") { 234 | expect(sut?.calendarAuthField.stringValue) == "✓ Callendar access authorized" 235 | } 236 | } 237 | 238 | context("when request is denied") { 239 | beforeEach { 240 | type(of: calendarAuthorizer).authorizationStatusMock = .denied 241 | calendarAuthorizer.requestAccessCompletion?(false, nil) 242 | } 243 | 244 | it("should have correct calendar auth message") { 245 | expect(sut?.calendarAuthField.stringValue) == "❌ Callendar access denied" 246 | } 247 | 248 | it("should present alert") { 249 | expect(alertFactory.didCreateWithStyle) == .warning 250 | expect(alertFactory.didCreateWithTitle) == "Calendar Access Denied" 251 | expect(alertFactory.didCreateWithMessage) == "Open System Preferences, Security & Privacy and allow the app to access Calendar." 252 | expect(alertFactory.alertDouble.didRunModal) == true 253 | } 254 | } 255 | 256 | context("when authorization is restricted") { 257 | beforeEach { 258 | type(of: calendarAuthorizer).authorizationStatusMock = .restricted 259 | calendarAuthorizer.requestAccessCompletion?(false, nil) 260 | } 261 | 262 | it("should have correct calendar auth message") { 263 | expect(sut?.calendarAuthField.stringValue) == "❌ Callendar access restricted" 264 | } 265 | 266 | it("should present alert") { 267 | expect(alertFactory.didCreateWithStyle) == .warning 268 | expect(alertFactory.didCreateWithTitle) == "Calendar Access Denied" 269 | expect(alertFactory.didCreateWithMessage) == "Open System Preferences, Security & Privacy and allow the app to access Calendar." 270 | expect(alertFactory.alertDouble.didRunModal) == true 271 | } 272 | } 273 | 274 | context("when request fails with error") { 275 | beforeEach { 276 | type(of: calendarAuthorizer).authorizationStatusMock = .restricted 277 | calendarAuthorizer.requestAccessCompletion?(false, NSError(domain: "", code: 1, userInfo: nil)) 278 | } 279 | 280 | it("should have correct calendar auth message") { 281 | expect(sut?.calendarAuthField.stringValue) == "❌ Callendar access not determined" 282 | } 283 | 284 | it("should not present alert") { 285 | expect(alertFactory.didCreateWithStyle).to(beNil()) 286 | } 287 | } 288 | } 289 | } 290 | 291 | context("set up without calendar id") { 292 | var calendarsProvider: CalendarsProvidingDouble! 293 | var calendarIdStore: CalendarIdStoringDouble! 294 | 295 | beforeEach { 296 | calendarsProvider = CalendarsProvidingDouble() 297 | calendarsProvider.mockedCalendars = [ 298 | EKCalendarDouble(id: "calendar-1", title: "First Calendar"), 299 | EKCalendarDouble(id: "calendar-2", title: "Second Calendar"), 300 | EKCalendarDouble(id: "calendar-3", title: "Third Calendar") 301 | ] 302 | calendarIdStore = CalendarIdStoringDouble() 303 | 304 | sut?.setUp( 305 | fileOpenerFactory: FileOpenerCreatingDouble(), 306 | fileBookmarks: FileBookmarkingDouble(), 307 | fileReadability: FileManager.default, 308 | calendarAuthorizer: CalendarAuthorizingDouble(), 309 | alertFactory: ModalAlertCreatingDouble(), 310 | calendarsProvider: calendarsProvider, 311 | calendarIdStore: calendarIdStore, 312 | syncController: SyncControllingDouble() 313 | ) 314 | } 315 | 316 | it("should have correct calendars list") { 317 | let expected = calendarsProvider.mockedCalendars 318 | .enumerated() 319 | .map { "\($0.offset + 1). \($0.element.title)" } 320 | expect(sut?.calendarSelectionButton.itemTitles) == expected 321 | } 322 | 323 | it("should have no selected calendar") { 324 | expect(sut?.calendarSelectionButton.selectedItem).to(beNil()) 325 | } 326 | 327 | it("should sync button be disabled") { 328 | expect(sut?.synchronizeButton.isEnabled) == false 329 | } 330 | 331 | context("select calendar") { 332 | beforeEach { 333 | sut?.calendarSelectionButton.menu?.performActionForItem(at: 1) 334 | } 335 | 336 | it("should have correct calendar selected") { 337 | expect(sut?.calendarSelectionButton.titleOfSelectedItem) == "2. Second Calendar" 338 | } 339 | 340 | it("should store selected calendar id") { 341 | expect(calendarIdStore.mockedCalendarId) == "calendar-2" 342 | } 343 | } 344 | } 345 | 346 | context("set up with calendar id") { 347 | var calendarsProvider: CalendarsProvidingDouble! 348 | var calendarIdStore: CalendarIdStoringDouble! 349 | 350 | beforeEach { 351 | calendarsProvider = CalendarsProvidingDouble() 352 | calendarsProvider.mockedCalendars = [ 353 | EKCalendarDouble(id: "calendar-1", title: "First Calendar"), 354 | EKCalendarDouble(id: "calendar-2", title: "Second Calendar"), 355 | EKCalendarDouble(id: "calendar-3", title: "Third Calendar") 356 | ] 357 | 358 | calendarIdStore = CalendarIdStoringDouble() 359 | calendarIdStore.mockedCalendarId = "calendar-3" 360 | 361 | sut?.setUp( 362 | fileOpenerFactory: FileOpenerCreatingDouble(), 363 | fileBookmarks: FileBookmarkingDouble(), 364 | fileReadability: FileManager.default, 365 | calendarAuthorizer: CalendarAuthorizingDouble(), 366 | alertFactory: ModalAlertCreatingDouble(), 367 | calendarsProvider: calendarsProvider, 368 | calendarIdStore: calendarIdStore, 369 | syncController: SyncControllingDouble() 370 | ) 371 | } 372 | 373 | it("should have correct calendars list") { 374 | let expected = calendarsProvider.mockedCalendars 375 | .enumerated() 376 | .map { "\($0.offset + 1). \($0.element.title)" } 377 | expect(sut?.calendarSelectionButton.itemTitles) == expected 378 | } 379 | 380 | it("should have correct calendar selected") { 381 | expect(sut?.calendarSelectionButton.titleOfSelectedItem) == "3. Third Calendar" 382 | } 383 | } 384 | 385 | context("set up with readable bookmarks path and calendar set and authorized") { 386 | var fileBookmarks: FileBookmarkingDouble! 387 | var alertFactory: ModalAlertCreatingDouble! 388 | var calendarIdStore: CalendarIdStoringDouble! 389 | var syncController: SyncControllingDouble! 390 | 391 | beforeEach { 392 | fileBookmarks = FileBookmarkingDouble() 393 | fileBookmarks.urls["bookmarks_file_url"] = URL(fileURLWithPath: "/tmp") 394 | 395 | let calendarAuthorizer = CalendarAuthorizingDouble() 396 | CalendarAuthorizingDouble.authorizationStatusMock = .authorized 397 | 398 | alertFactory = ModalAlertCreatingDouble() 399 | 400 | let calendarsProvider = CalendarsProvidingDouble() 401 | calendarsProvider.mockedCalendars = [ 402 | EKCalendarDouble(id: "calendar-1", title: "First Calendar") 403 | ] 404 | 405 | calendarIdStore = CalendarIdStoringDouble() 406 | calendarIdStore.mockedCalendarId = "calendar-1" 407 | 408 | syncController = SyncControllingDouble() 409 | 410 | sut?.setUp( 411 | fileOpenerFactory: FileOpenerCreatingDouble(), 412 | fileBookmarks: fileBookmarks, 413 | fileReadability: FileManager.default, 414 | calendarAuthorizer: calendarAuthorizer, 415 | alertFactory: alertFactory, 416 | calendarsProvider: calendarsProvider, 417 | calendarIdStore: calendarIdStore, 418 | syncController: syncController 419 | ) 420 | } 421 | 422 | it("should sync button be enabled") { 423 | expect(sut?.synchronizeButton.isEnabled) == true 424 | } 425 | 426 | context("click sync button") { 427 | beforeEach { 428 | sut?.synchronizeButton.performClick(nil) 429 | } 430 | 431 | it("should synchronize") { 432 | expect(syncController.didSyncBookmarksUrl) == fileBookmarks.urls["bookmarks_file_url"]! 433 | expect(syncController.didSyncCalendarId) == calendarIdStore.mockedCalendarId 434 | } 435 | 436 | context("when sync starts") { 437 | beforeEach { 438 | syncController.syncProgressMock.send(0) 439 | syncController.isSynchronizingMock.send(true) 440 | } 441 | 442 | it("should disable buttons") { 443 | expect(sut?.bookmarksPathButton.isEnabled) == false 444 | expect(sut?.calendarAuthButton.isEnabled) == false 445 | expect(sut?.calendarSelectionButton.isEnabled) == false 446 | expect(sut?.synchronizeButton.isEnabled) == false 447 | } 448 | 449 | it("should show empty progress") { 450 | expect(sut?.progressIndicator.doubleValue) == 0 451 | } 452 | 453 | context("when sync progresses") { 454 | beforeEach { 455 | syncController.syncProgressMock.send(0.75) 456 | } 457 | 458 | it("should show correct progress") { 459 | expect(sut?.progressIndicator.doubleValue) == 75 460 | } 461 | } 462 | 463 | context("when sync finishes") { 464 | beforeEach { 465 | syncController.syncSubject?.send(()) 466 | syncController.syncSubject?.send(completion: .finished) 467 | syncController.syncProgressMock.send(nil) 468 | syncController.isSynchronizingMock.send(false) 469 | } 470 | 471 | it("should enable buttons") { 472 | expect(sut?.bookmarksPathButton.isEnabled) == true 473 | expect(sut?.calendarAuthButton.isEnabled) == true 474 | expect(sut?.calendarSelectionButton.isEnabled) == true 475 | expect(sut?.synchronizeButton.isEnabled) == true 476 | } 477 | 478 | it("should show empty progress") { 479 | expect(sut?.progressIndicator.doubleValue) == 0 480 | } 481 | } 482 | 483 | context("when sync fails with error") { 484 | var error: NSError! 485 | 486 | beforeEach { 487 | error = NSError(domain: "test", code: 123, userInfo: nil) 488 | syncController.syncSubject?.send(completion: .failure(error)) 489 | } 490 | 491 | it("should present error alert") { 492 | expect(alertFactory.didCreateWithStyle) == .critical 493 | expect(alertFactory.didCreateWithTitle) == error.title 494 | expect(alertFactory.didCreateWithMessage) == error.message 495 | expect(alertFactory.alertDouble.didRunModal) == true 496 | } 497 | } 498 | } 499 | } 500 | } 501 | } 502 | } 503 | } 504 | 505 | private extension MainViewController { 506 | var bookmarksPathField: NSTextField! { return view.accessibilityElement(id: #function) } 507 | var bookmarksPathButton: NSButton! { return view.accessibilityElement(id: #function) } 508 | var bookmarksStatusField: NSTextField! { return view.accessibilityElement(id: #function) } 509 | var calendarAuthField: NSTextField! { return view.accessibilityElement(id: #function) } 510 | var calendarAuthButton: NSButton! { return view.accessibilityElement(id: #function) } 511 | var calendarSelectionButton: NSPopUpButton! { return view.accessibilityElement(id: #function) } 512 | var synchronizeButton: NSButton! { return view.accessibilityElement(id: #function) } 513 | var progressIndicator: NSProgressIndicator! { return view.accessibilityElement(id: #function) } 514 | } 515 | -------------------------------------------------------------------------------- /ReadingListCalendarApp/Resources/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | --------------------------------------------------------------------------------