From e3d46960fd54ae0be6c9e2589bbc0831495fee0d Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 18 Mar 2020 15:48:44 -0500 Subject: [PATCH] Add CloudKit syncing add account UI. --- Frameworks/Account/Account.swift | 5 + .../Account/Account.xcodeproj/project.pbxproj | 16 +- Frameworks/Account/AccountManager.swift | 2 +- .../CloudKit/CloudKitAppDelegate.swift | 216 ++++++++++++++++++ Mac/AppAssets.swift | 6 + .../AccountsAddCloudKitWindowController.swift | 42 ++++ .../Accounts/AccountsAddViewController.swift | 32 ++- .../accountCloudKit.imageset/Contents.json | 15 ++ .../accountCloudKit.imageset/icloud.pdf | Bin 0 -> 4251 bytes Mac/Scriptability/Account+Scriptability.swift | 2 + NetNewsWire.xcodeproj/project.pbxproj | 12 + 11 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CloudKitAppDelegate.swift create mode 100644 Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift create mode 100644 Mac/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json create mode 100644 Mac/Resources/Assets.xcassets/accountCloudKit.imageset/icloud.pdf diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 5bbc1f6b2..f8bbaf8a8 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -36,6 +36,7 @@ public extension Notification.Name { public enum AccountType: Int, Codable { // Raw values should not change since they’re stored on disk. case onMyMac = 1 + case cloudKit = 2 case feedly = 16 case feedbin = 17 case feedWrangler = 18 @@ -232,6 +233,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, switch type { case .onMyMac: self.delegate = LocalAccountDelegate() + case .cloudKit: + self.delegate = CloudKitAccountDelegate() case .feedbin: self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport) case .freshRSS: @@ -256,6 +259,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, switch type { case .onMyMac: defaultName = Account.defaultLocalAccountName + case .cloudKit: + defaultName = "iCloud" case .feedly: defaultName = "Feedly" case .feedbin: diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 62d8daf90..e96803641 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */; }; 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */; }; 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */; }; + 5103A9D92422546800410853 /* CloudKitAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5103A9D82422546800410853 /* CloudKitAppDelegate.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; }; @@ -230,6 +231,7 @@ 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = ""; }; 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionResult.swift; sourceTree = ""; }; + 5103A9D82422546800410853 /* CloudKitAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAppDelegate.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -450,6 +452,14 @@ path = FeedWrangler; sourceTree = ""; }; + 5103A9D7242253DC00410853 /* CloudKit */ = { + isa = PBXGroup; + children = ( + 5103A9D82422546800410853 /* CloudKitAppDelegate.swift */, + ); + path = CloudKit; + sourceTree = ""; + }; 5111D71C2357534700737D45 /* Feedbin */ = { isa = PBXGroup; children = ( @@ -606,6 +616,7 @@ 3B826D9D2385C81C00FC1ADB /* FeedWrangler */, 552032EA229D5D5A009559E0 /* ReaderAPI */, 9EA31339231E368100268BA0 /* Feedly */, + 5103A9D7242253DC00410853 /* CloudKit */, 848935031F62484F00CEBD24 /* AccountTests */, 848934F71F62484F00CEBD24 /* Products */, 8469F80F1F6DC3C10084783E /* Frameworks */, @@ -844,11 +855,11 @@ 848934F51F62484F00CEBD24 = { CreatedOnToolsVersion = 9.0; LastSwiftMigration = 0900; - ProvisioningStyle = Manual; + ProvisioningStyle = Automatic; }; 848934FE1F62484F00CEBD24 = { CreatedOnToolsVersion = 9.0; - ProvisioningStyle = Manual; + ProvisioningStyle = Automatic; }; }; }; @@ -1008,6 +1019,7 @@ 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */, + 5103A9D92422546800410853 /* CloudKitAppDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */, 9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */, diff --git a/Frameworks/Account/AccountManager.swift b/Frameworks/Account/AccountManager.swift index fd357b8cc..321e8fb5f 100644 --- a/Frameworks/Account/AccountManager.swift +++ b/Frameworks/Account/AccountManager.swift @@ -120,7 +120,7 @@ public final class AccountManager: UnreadCountProvider { // MARK: - API public func createAccount(type: AccountType) -> Account { - let accountID = UUID().uuidString + let accountID = type == .cloudKit ? "iCloud" : UUID().uuidString let accountFolder = (accountsFolder as NSString).appendingPathComponent("\(type.rawValue)_\(accountID)") do { diff --git a/Frameworks/Account/CloudKit/CloudKitAppDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAppDelegate.swift new file mode 100644 index 000000000..44e921850 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitAppDelegate.swift @@ -0,0 +1,216 @@ +// +// CloudKitAppDelegate.swift +// Account +// +// Created by Maurice Parker on 3/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser +import Articles +import RSWeb + +public enum CloudKitAccountDelegateError: String, Error { + case invalidParameter = "An invalid parameter was used." +} + +final class CloudKitAccountDelegate: AccountDelegate { + + let behaviors: AccountBehaviors = [] + let isOPMLImportInProgress = false + + let server: String? = nil + var credentials: Credentials? + var accountMetadata: AccountMetadata? + + private let refresher = LocalAccountRefresher() + + var refreshProgress: DownloadProgress { + return refresher.progress + } + + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + refresher.refreshFeeds(account.flattenedWebFeeds()) { + account.metadata.lastArticleFetchEndTime = Date() + completion(.success(())) + } + } + + func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + completion(.success(())) + } + + func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + completion(.success(())) + } + + func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { + var fileData: Data? + + do { + fileData = try Data(contentsOf: opmlFile) + } catch { + completion(.failure(error)) + return + } + + guard let opmlData = fileData else { + completion(.success(())) + return + } + + let parserData = ParserData(url: opmlFile.absoluteString, data: opmlData) + var opmlDocument: RSOPMLDocument? + + do { + opmlDocument = try RSOPMLParser.parseOPML(with: parserData) + } catch { + completion(.failure(error)) + return + } + + guard let loadDocument = opmlDocument else { + completion(.success(())) + return + } + + guard let children = loadDocument.children else { + return + } + + BatchUpdate.shared.perform { + account.loadOPMLItems(children, parentFolder: nil) + } + + completion(.success(())) + + } + + func createWebFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + guard let url = URL(string: urlString) else { + completion(.failure(LocalAccountDelegateError.invalidParameter)) + return + } + + refreshProgress.addToNumberOfTasksAndRemaining(1) + FeedFinder.find(url: url) { result in + + switch result { + case .success(let feedSpecifiers): + guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), + let url = URL(string: bestFeedSpecifier.urlString) else { + self.refreshProgress.completeTask() + completion(.failure(AccountError.createErrorNotFound)) + return + } + + if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) { + self.refreshProgress.completeTask() + completion(.failure(AccountError.createErrorAlreadySubscribed)) + return + } + + let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress.completeTask() + + if let parsedFeed = parsedFeed { + account.update(feed, with: parsedFeed, {_ in}) + } + + feed.editedName = name + + container.addWebFeed(feed) + completion(.success(feed)) + + } + + case .failure: + self.refreshProgress.completeTask() + completion(.failure(AccountError.createErrorNotFound)) + } + + } + + } + + func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { + feed.editedName = name + completion(.success(())) + } + + func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { + container.removeWebFeed(feed) + completion(.success(())) + } + + func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + from.removeWebFeed(feed) + to.addWebFeed(feed) + completion(.success(())) + } + + func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { + container.addWebFeed(feed) + completion(.success(())) + } + + func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> Void) { + container.addWebFeed(feed) + completion(.success(())) + } + + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + if let folder = account.ensureFolder(with: name) { + completion(.success(folder)) + } else { + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + } + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + folder.name = name + completion(.success(())) + } + + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + account.removeFolder(folder) + completion(.success(())) + } + + func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { + account.addFolder(folder) + completion(.success(())) + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + return try? account.update(articles, statusKey: statusKey, flag: flag) + } + + func accountDidInitialize(_ account: Account) { + } + + func accountWillBeDeleted(_ account: Account) { + } + + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result) -> Void) { + return completion(.success(nil)) + } + + // MARK: Suspend and Resume (for iOS) + + func suspendNetwork() { + refresher.suspend() + } + + func suspendDatabase() { + // Nothing to do + } + + func resume() { + refresher.resume() + } +} diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index 57f53b07c..038dcfa39 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -21,6 +21,10 @@ struct AppAssets { return RSImage(named: .timelineStar) }() + static var accountCloudKit: RSImage! = { + return RSImage(named: "accountCloudKit") + }() + static var accountLocal: RSImage! = { return RSImage(named: "accountLocal") }() @@ -129,6 +133,8 @@ struct AppAssets { switch accountType { case .onMyMac: return AppAssets.accountLocal + case .cloudKit: + return AppAssets.accountCloudKit case .feedbin: return AppAssets.accountFeedbin case .feedly: diff --git a/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift b/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift new file mode 100644 index 000000000..5bd0e8916 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift @@ -0,0 +1,42 @@ +// +// AccountsAddCloudKitWindowController.swift +// NetNewsWire +// +// Created by Maurice Parker on 3/18/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import Account + +class AccountsAddCloudKitWindowController: NSWindowController { + + private weak var hostWindow: NSWindow? + + convenience init() { + self.init(windowNibName: NSNib.Name("AccountsAddCloudKit")) + } + + override func windowDidLoad() { + super.windowDidLoad() + } + + // MARK: API + + func runSheetOnWindow(_ hostWindow: NSWindow, completion: ((NSApplication.ModalResponse) -> Void)? = nil) { + self.hostWindow = hostWindow + hostWindow.beginSheet(window!, completionHandler: completion) + } + + // MARK: Actions + + @IBAction func cancel(_ sender: Any) { + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel) + } + + @IBAction func create(_ sender: Any) { + _ = AccountManager.shared.createAccount(type: .cloudKit) + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.OK) + } + +} diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index d1a8a76d4..5ef19c9fe 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -16,7 +16,11 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? - private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS] + #if DEBUG + private var addableAccountTypes: [AccountType] = [.onMyMac, .cloudKit, .feedbin, .feedly, .feedWrangler, .freshRSS] + #else + private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly] + #endif init() { super.init(nibName: "AccountsAdd", bundle: nil) @@ -27,12 +31,10 @@ class AccountsAddViewController: NSViewController { } override func viewDidLoad() { - super.viewDidLoad() - tableView.dataSource = self tableView.delegate = self - + removeCloudKitIfNecessary() } } @@ -63,6 +65,9 @@ extension AccountsAddViewController: NSTableViewDelegate { case .onMyMac: cell.accountNameLabel?.stringValue = Account.defaultLocalAccountName cell.accountImageView?.image = AppAssets.accountLocal + case .cloudKit: + cell.accountNameLabel?.stringValue = NSLocalizedString("iCloud", comment: "iCloud") + cell.accountImageView?.image = AppAssets.accountCloudKit case .feedbin: cell.accountNameLabel?.stringValue = NSLocalizedString("Feedbin", comment: "Feedbin") cell.accountImageView?.image = AppAssets.accountFeedbin @@ -95,6 +100,15 @@ extension AccountsAddViewController: NSTableViewDelegate { let accountsAddLocalWindowController = AccountsAddLocalWindowController() accountsAddLocalWindowController.runSheetOnWindow(self.view.window!) accountsAddWindowController = accountsAddLocalWindowController + case .cloudKit: + let accountsAddCloudKitWindowController = AccountsAddCloudKitWindowController() + accountsAddCloudKitWindowController.runSheetOnWindow(self.view.window!) { response in + if response == NSApplication.ModalResponse.OK { + self.removeCloudKitIfNecessary() + self.tableView.reloadData() + } + } + accountsAddWindowController = accountsAddCloudKitWindowController case .feedbin: let accountsFeedbinWindowController = AccountsFeedbinWindowController() accountsFeedbinWindowController.runSheetOnWindow(self.view.window!) @@ -142,3 +156,13 @@ extension AccountsAddViewController: OAuthAccountAuthorizationOperationDelegate view.window?.presentError(error) } } + +// MARK: Private + +private extension AccountsAddViewController { + func removeCloudKitIfNecessary() { + if let index = AccountManager.shared.activeAccounts.firstIndex(where: { $0.type == .cloudKit }) { + addableAccountTypes.remove(at: index) + } + } +} diff --git a/Mac/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json b/Mac/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json new file mode 100644 index 000000000..f0e09dead --- /dev/null +++ b/Mac/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "icloud.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/Mac/Resources/Assets.xcassets/accountCloudKit.imageset/icloud.pdf b/Mac/Resources/Assets.xcassets/accountCloudKit.imageset/icloud.pdf new file mode 100644 index 0000000000000000000000000000000000000000..74406f4cbf5e9bdd80f425de4421e074993302ef GIT binary patch literal 4251 zcmai%2{e@L`^V7~hEUm4o@6Z9#>^PJ+4re5WXV3wn6Wb&I|(Ji$eyK;i0q0=H1MJ zg(9uO>9|aZIE_Hro*O(q5cEVM>#^#hxk`D(TExJ~NDXo>M7V_kajWJExnpgATweT> zz?1v$tpxn3*5hkvS0YB0;b<~#N}fIBZ;lqyG90+WS;tp`T$%9Fu-?7i0tU;(*uR~nsKmI z##g8E?PiNOZCo6y(E0<#%Qm&DraaJDwF2%u(n*v}d1&a25ffNMa+(|4LPNJsyK|zf zR0K&TY}hL{haL6sY)eH&Cxko04nv;sw6e@u9z#t8Fhx|0rp0o!{>d_^!t5w5C4Y!|gQjo=qhT5fxw(7sgIfakO>0mg36aOH14j0 z_lB@v{AU{ANdV-Gu#P{TB!Y)G0RPL27YJTNACf)63qbxA&?b6#)91Z_J^xtzvNK*CCAS_q5MN7 zcb;C5oaWxX@StxBKu(9?>ugWBps)4cw@c=2KU0$d-bgA%k@DuS2mo0Z3}gk~o;KhC z8AqW)jhG_B#e8-4>v12FG{>7ZFzQE&*z0n$UB8CwXI z?6g^q3`{@kA8=d_?yq4m`gRS}D~0i?gB z!_@nYqMDxm(4e()V@Wm?UppvWZzSgRZZ{-BL{VGx00UUJ52Lox|2gV<75gnHUuZe;wk{*Y(mmZNVX;(b zanbVKl+)Mxfsx}^O?FgY1=7q`#1^Pon&d3Y5x0dBISMbshN#t@W0iF&W(-yC2Q(%} zOVG;z!|Hy9s^Pn9$0OYA5?jXD#o1yO7TmlTvt4DSW)x}J*NCs{Ba}DOJCt{DS*6%mP<4UAdinFbu&rl#QxU%&&;54B996iF&99G6m0DbIF5W)xB^k zO(^$@g9Uz{-WF!FiS`&hrzv7GwZR^TCqeE@3*?T3yEQT$j%W712S$X+LYbs59TW%nHlT|Q2R+%? zL-jSl5shb|jKg8hEZ07B@!Z!eis#MaOxJqHb<~O_KwHyDiF@Bm?WlOJM2)X+#2)gW zx?Yr{ZKS$>(p#{G#rFF4J?1w6;=m4LaQLA#rXK9U_Y6-%2P#ervFJB-eA4mfxy#Jg zFgSXqnpchESrgyr(a#LhH!w{}EE=y^_I2x7upC{{(TcYaWju7kH9;hgsZci~o+Iz* z^H_s~%M<$!MCM@A_<36e#v?7sqd0TyJKPy)Uz&ZI`8mERqyrZ`?CZ4$xV}gE7UCu9 zzO2YYcVcB)%rC`VvaU#dwC_E3;j|D(TZ6!ukmuEEejAvFLK)~8XFs_%biVoEsMw1C zi&Mkg%hxy?y*}fvoTvkb2!$LUi4$(E7msJP0=Zt}JaHQkbrUt^5@eZX*$F??gfI?! zi1rqV;0kE5!AU;4^PE>DV%+F6Lb_T5=atVq*9g!E(0*{?;6smIi=woeOe-`+C-_XToPH8K zbuD$gy)>yf!8K(*H3))E?oahg#mHuhV@`ye`JVr+n#s6PqtPTRx57L}Z?vjO^#)2e z<9-V6CN2XPg}Xkc@X{FaA~7uS*tY&;-4-1-~HhfSg3GCC9BHrk+#Bse{OJXS>}O zk||$Ld7QeEB9h{e!k7|Qbk2;}$K97%$; z7CBc@6BSs!?ee90PIWFxFhj6TP(aX3Fcs0NAduma(Vj7xF^y=ea@bgShJ6|0uoKdiKH%spS(b;LGN)>={Xnawj3KP~iGISV;V zx$$z2nsIh%c3!=Hy>-azhBv^6OEH>v8Xx9F=hS1fu(_l5CyNq_yHDj><(_U;vVVFx zdPQyATC5@}6P1XHZ8tP|-#cu2sfJobWp|x&5j4G-=+G(R(1-L*mu5{W2b#v z+*^j;s}Af`)1)}hawh1~5x(Ure}I{w#45x9qZ9p!SDalv=FXkviYH z;$XQg><-h8@AlAI|L5uLei|C21S(;j0q24AK<3P9Y%tct`+Y#ojf{=8*M#NXVnl6v zd6~4nY5U$#VO3za<=OV)mz748h%E$EoB3b$_XEk`bak{_Y7OFEy?v!06U%#+*H5oq z|D|r9zM*cAZmRCtR10a#$_YKP6&c7^UAgdq z^klNIgIX4kX|%k2>iALNVg8GwcBWa1#ty0smE4EvNZwa8( z@W3*Dcv9T^JzeG8+RU> zTA#?6@aQXn){K6sn~!q`+zkdk zcj$G*5{`~|L=X2G-!rbLyW~{4Jk_f8T+4WrBGYBf;r7yv$8B-Qp|aFvFjd5^`wOmP zEP4zL`ItCAVi|n#>rm%L_#)-O#siw)Ctnu7k3+LHYZmD^OS^$LWuI(ICp%Z~uRg22 zxKL+~wZ43b(>V?wR|R|cm~y7vMZd3LE^jVOFfLSUWB~bG;%G~bbpEcI9k$7oxeX1))eQ{ce=M<@u6Ze zvMDyP_0jm{Hsa2wFWNyzif4`BxzD#iN+*<8#@e=om$TSa1_Gjki)O`lvNxW-v5L0J zdH3;M)YCkqbI`e=;FD1M>*T?>Z(@jjg^=v zzQ{i=daKn`O}x!4t0)`p2um%uTX{|@9Hh{`nWYvxmMPnw4$cgwEJp0azB^uGvO8tgvvTM5cW4|zdUmT1+l~tr(y3@DS?@<U%1zQiCTHnF)pH7TW&YyYdN}kKedo5 zrB|glr(dd1C4VIk1wLAT`MHCf+B1HmUh@0X&6Muq?5ln>iX*PQ<+E$|e7gFX;fHm~ z+wV5qr|~lrJ$}y1`32F##dYc%VLLfA`}MYj*#-0#Iwd<+-R}zPw^e^JcZaEw&G0qa z@BF++p+BG*DG&P_c=z~qFGh!4l(x1O){B4#_5k(*VEeaZ52F8O;(r+13y?D;;GMBr zM1Q~m~pyVp-} z|A))vfBAsU<#-=^`q@ czy^0n7;`FK40$y#QHx1z9*?BcbKv?1l&Am30)6Hj;p` z50>N|2+$e+2lo4WOVZJuehT%bf22d_aIl2VA228k3Wdun$-@yyd88E-Do(%YIzu9U zPXhe^l7DLSClMUL^mUL2L;vRjkO~TL1;7FL8G|eC{rlbpc>EoMB9ZhZ`gaVjph)k+ zzhh7+>^B`0iJ-UVf9Vtvzv#>Kmz@L z0l5oABE92#U8SG=Q63INdi(zx>(C9LLQq1;!;mnnGCdO#<^ZFQln^jH46lq(RDvnn dtAYQ!