diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 9473b2e24..e85e58d88 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ 9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */; }; 9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */; }; 9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */; }; + 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ECC9A84234DC16E009B5144 /* FeedlyAccountDelegateError.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -287,6 +288,7 @@ 9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAPICaller.swift; sourceTree = ""; }; 9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedlyAccountDelegate+OAuth.swift"; sourceTree = ""; }; 9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAuthorizationCodeGranting.swift; sourceTree = ""; }; + 9ECC9A84234DC16E009B5144 /* FeedlyAccountDelegateError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAccountDelegateError.swift; sourceTree = ""; }; D511EEB5202422BB00712EC3 /* Account_project_debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_debug.xcconfig; sourceTree = ""; }; D511EEB6202422BB00712EC3 /* Account_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_target.xcconfig; sourceTree = ""; }; D511EEB7202422BB00712EC3 /* Account_project_release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_release.xcconfig; sourceTree = ""; }; @@ -540,6 +542,7 @@ isa = PBXGroup; children = ( 9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */, + 9ECC9A84234DC16E009B5144 /* FeedlyAccountDelegateError.swift */, 9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */, 9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */, 9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */, @@ -783,6 +786,7 @@ 8469F81C1F6DD15E0084783E /* Account.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, 9E1D155923343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift in Sources */, diff --git a/Frameworks/Account/Feedly/FeedlyAPICaller.swift b/Frameworks/Account/Feedly/FeedlyAPICaller.swift index 6b7c1dd06..4d93167af 100644 --- a/Frameworks/Account/Feedly/FeedlyAPICaller.swift +++ b/Frameworks/Account/Feedly/FeedlyAPICaller.swift @@ -90,6 +90,11 @@ final class FeedlyAPICaller { } func getStream(for collection: FeedlyCollection, newerThan: Date? = nil, unreadOnly: Bool? = nil, completionHandler: @escaping (Result) -> ()) { + let id = FeedlyCategoryResourceId(id: collection.id) + getStream(for: id, newerThan: newerThan, unreadOnly: unreadOnly, completionHandler: completionHandler) + } + + func getStream(for resource: FeedlyResourceId, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result) -> ()) { guard let accessToken = credentials?.secret else { return DispatchQueue.main.async { completionHandler(.failure(CredentialsError.incompleteCredentials)) @@ -115,7 +120,7 @@ final class FeedlyAPICaller { queryItems.append(contentsOf: [ URLQueryItem(name: "count", value: "1000"), - URLQueryItem(name: "streamId", value: collection.id), + URLQueryItem(name: "streamId", value: resource.id), ]) components.queryItems = queryItems @@ -129,12 +134,6 @@ final class FeedlyAPICaller { request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - // URLSession.shared.dataTask(with: request) { (data, response, error) in - // let obj = try! JSONSerialization.jsonObject(with: data!, options: .allowFragments) - // let data = try! JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) - // print(String(data: data, encoding: .utf8)!) - // }.resume() - transport.send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (_, collections)): @@ -387,6 +386,98 @@ final class FeedlyAPICaller { } } } + + func addFeed(with feedId: FeedlyFeedResourceId, title: String? = nil, toCollectionWith collectionId: String, completionHandler: @escaping (Result<[FeedlyFeed], Error>) -> ()) { + guard let accessToken = credentials?.secret else { + return DispatchQueue.main.async { + completionHandler(.failure(CredentialsError.incompleteCredentials)) + } + } + + guard let encodedId = encodeForURLPath(collectionId) else { + return DispatchQueue.main.async { + completionHandler(.failure(FeedbinAccountDelegateError.invalidParameter)) + } + } + var components = baseUrlComponents + components.percentEncodedPath = "/v3/collections/\(encodedId)/feeds" + + guard let url = components.url else { + fatalError("\(components) does not produce a valid URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) + request.addValue("application/json", forHTTPHeaderField: "Accept-Type") + request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) + + do { + struct AddFeedBody: Encodable { + var id: String + var title: String? + } + let encoder = JSONEncoder() + let data = try encoder.encode(AddFeedBody(id: feedId.id, title: title)) + request.httpBody = data + } catch { + return DispatchQueue.main.async { + completionHandler(.failure(error)) + } + } + + transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + switch result { + case .success(_, let collectionFeeds): + if let feeds = collectionFeeds { + completionHandler(.success(feeds)) + } else { + completionHandler(.failure(URLError(.cannotDecodeContentData))) + } + case .failure(let error): + completionHandler(.failure(error)) + } + } + } + + func removeFeed(_ feedId: String, fromCollectionWith collectionId: String, completionHandler: @escaping (Result) -> ()) { + guard let accessToken = credentials?.secret else { + return DispatchQueue.main.async { + completionHandler(.failure(CredentialsError.incompleteCredentials)) + } + } + + guard let encodedCollectionId = encodeForURLPath(collectionId), let encodedFeedId = encodeForURLPath(feedId) else { + return DispatchQueue.main.async { + completionHandler(.failure(FeedbinAccountDelegateError.invalidParameter)) + } + } + var components = baseUrlComponents + components.percentEncodedPath = "/v3/collections/\(encodedCollectionId)/feeds/\(encodedFeedId)" + + guard let url = components.url else { + fatalError("\(components) does not produce a valid URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) + request.addValue("application/json", forHTTPHeaderField: "Accept-Type") + request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) + + transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + switch result { + case .success(let httpResponse, _): + if httpResponse.statusCode == 200 { + completionHandler(.success(())) + } else { + completionHandler(.failure(URLError(.cannotDecodeContentData))) + } + case .failure(let error): + completionHandler(.failure(error)) + } + } + } } extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting { diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift index 1c6761bd1..591155b60 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -148,14 +148,21 @@ final class FeedlyAccountDelegate: AccountDelegate { } func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + + let progress = refreshProgress + progress.addToNumberOfTasksAndRemaining(1) + caller.createCollection(named: name) { result in + progress.completeTask() + switch result { case .success(let collection): if let folder = account.ensureFolder(with: collection.label) { folder.externalID = collection.id completion(.success(folder)) } else { - completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + // Is the name empty? Or one of the global resource names? + completion(.failure(FeedlyAccountDelegateError.unableToAddFolder(name))) } case .failure(let error): completion(.failure(error)) @@ -165,26 +172,40 @@ final class FeedlyAccountDelegate: AccountDelegate { func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { guard let id = folder.externalID else { - completion(.failure(FeedbinAccountDelegateError.invalidParameter)) - return + return DispatchQueue.main.async { + completion(.failure(FeedlyAccountDelegateError.unableToRenameFolder(folder.nameForDisplay, name))) + } } + + let nameBefore = folder.name + caller.renameCollection(with: id, to: name) { result in switch result { case .success(let collection): folder.name = collection.label completion(.success(())) case .failure(let error): + folder.name = nameBefore completion(.failure(error)) } } + + folder.name = name } func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { guard let id = folder.externalID else { - completion(.failure(FeedbinAccountDelegateError.invalidParameter)) - return + return DispatchQueue.main.async { + completion(.failure(FeedlyAccountDelegateError.unableToRemoveFolder(folder.nameForDisplay))) + } } + + let progress = refreshProgress + progress.addToNumberOfTasksAndRemaining(1) + caller.deleteCollection(with: id) { result in + progress.completeTask() + switch result { case .success: account.removeFolder(folder) @@ -195,32 +216,267 @@ final class FeedlyAccountDelegate: AccountDelegate { } } + private func isValidContainer(for account: Account, container: Container) throws -> (Folder, String) { + guard let folder = container as? Folder else { + throw FeedlyAccountDelegateError.addFeedChooseFolder + } + + guard let collectionId = folder.externalID else { + throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder) + } + + guard let userId = credentials?.username else { + throw FeedlyAccountDelegateError.notLoggedIn + } + + let uncategorized = FeedlyCategoryResourceId.uncategorized(for: userId) + + guard collectionId != uncategorized.id else { + throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder) + } + + return (folder, collectionId) + } + func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { - fatalError() + let (folder, collectionId): (Folder, String) + do { + (folder, collectionId) = try isValidContainer(for: account, container: container) + } catch { + return DispatchQueue.main.async { + completion(.failure(error)) + } + } + + let resourceId = FeedlyFeedResourceId(url: url) + + let progress = refreshProgress + progress.addToNumberOfTasksAndRemaining(1) + + caller.addFeed(with: resourceId, title: name, toCollectionWith: collectionId) { [weak self] result in + defer { progress.completeTask() } + + switch result { + case .success(let feedlyFeeds): + let feedsBefore = folder.flattenedFeeds() + for feedlyFeed in feedlyFeeds where !account.hasFeed(with: feedlyFeed.feedId) { + let resourceId = FeedlyFeedResourceId(id: feedlyFeed.id) + let feed = account.createFeed(with: feedlyFeed.title, + url: resourceId.url, + feedID: feedlyFeed.id, + homePageURL: feedlyFeed.website) + folder.addFeed(feed) + } + + let feedsAfter = folder.flattenedFeeds() + let added = feedsAfter.subtracting(feedsBefore) + + guard let first = added.first else { + return completion(.failure(AccountError.createErrorNotFound)) + } + + let group = DispatchGroup() + progress.addToNumberOfTasksAndRemaining(1) + + if let self = self { + for feed in added { + group.enter() + let resourceId = FeedlyFeedResourceId(id: feed.feedID) + self.caller.getStream(for: resourceId, newerThan: nil, unreadOnly: nil) { result in + switch result { + case .success(let stream): + let items = Set(stream.items.map { FeedlyEntryParser(entry: $0).parsedItemRepresentation }) + + account.update(feed, parsedItems: items, defaultRead: false) { + group.leave() + } + + case .failure: + // Feed will remain empty until new articles appear. + group.leave() + } + } + } + } + + group.notify(queue: .main) { + progress.completeTask() + completion(.success(first)) + } + + case .failure(let error): + completion(.failure(error)) + } + } } func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { - fatalError() + let folderCollectionIds = account.folders?.filter { $0.has(feed) }.compactMap { $0.externalID } + guard let collectionIds = folderCollectionIds, let collectionId = collectionIds.first else { + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + return + } + + let feedId = FeedlyFeedResourceId(id: feed.feedID) + let editedNameBefore = feed.editedName + + // Adding an existing feed updates it. + // Updating feed name in one folder/collection updates it for all folders/collections. + caller.addFeed(with: feedId, title: name, toCollectionWith: collectionId) { result in + switch result { + case .success: + completion(.success(())) + + case .failure(let error): + feed.editedName = editedNameBefore + completion(.failure(error)) + } + } + + // optimistically set the name + feed.editedName = name } func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result) -> Void) { - fatalError() + let (folder, collectionId): (Folder, String) + do { + (folder, collectionId) = try isValidContainer(for: account, container: container) + } catch { + return DispatchQueue.main.async { + completion(.failure(error)) + } + } + + let feedId = FeedlyFeedResourceId(id: with.feedID) + + caller.addFeed(with: feedId, toCollectionWith: collectionId) { result in + switch result { + case .success(let feedlyFeeds): + for feedlyFeed in feedlyFeeds where !folder.hasFeed(with: feedlyFeed.feedId) { + let feed: Feed = { + if with.url == FeedlyFeedResourceId(id: feedlyFeed.id).url { + with.metadata.feedID = feedlyFeed.id + with.name = feedlyFeed.title + with.homePageURL = feedlyFeed.website + return with + } else { + let resourceId = FeedlyFeedResourceId(id: feedlyFeed.id) + return account.createFeed(with: feedlyFeed.title, + url: resourceId.url, + feedID: feedlyFeed.id, + homePageURL: feedlyFeed.website) + } + }() + folder.addFeed(feed) + } + + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + } } func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { - fatalError() + guard let folder = container as? Folder, let collectionId = folder.externalID else { + return DispatchQueue.main.async { + completion(.failure(FeedlyAccountDelegateError.unableToRemoveFeed(feed))) + } + } + + caller.removeFeed(feed.feedID, fromCollectionWith: collectionId) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + folder.addFeed(feed) + completion(.failure(error)) + } + } + + folder.removeFeed(feed) } func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { - fatalError() + guard let from = from as? Folder, let to = to as? Folder else { + return DispatchQueue.main.async { + completion(.failure(FeedlyAccountDelegateError.addFeedChooseFolder)) + } + } + + addFeed(for: account, with: feed, to: to) { [weak self] addResult in + switch addResult { + // now that we have added the feed, remove it from the other collection + case .success: + self?.removeFeed(for: account, with: feed, from: from) { removeResult in + switch removeResult { + case .success: + completion(.success(())) + case .failure: + from.addFeed(feed) + completion(.failure(FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed, from, to))) + } + } + case .failure(let error): + from.addFeed(feed) + to.removeFeed(feed) + completion(.failure(error)) + } + + } + + // optimistically move the feed, undoing as appropriate to the failure + from.removeFeed(feed) + to.addFeed(feed) } func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { - fatalError() + if let existingFeed = account.existingFeed(withURL: feed.url) { + account.addFeed(existingFeed, to: container) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + createFeed(for: account, url: feed.url, name: feed.editedName, container: container) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } } func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { - fatalError() + let group = DispatchGroup() + + for feed in folder.topLevelFeeds { + + folder.topLevelFeeds.remove(feed) + + group.enter() + restoreFeed(for: account, feed: feed, container: folder) { result in + group.leave() + switch result { + case .success: + break + case .failure(let error): + os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) + } + } + + } + + group.notify(queue: .main) { + account.addFolder(folder) + completion(.success(())) + } } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegateError.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegateError.swift new file mode 100644 index 000000000..d601f3122 --- /dev/null +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegateError.swift @@ -0,0 +1,83 @@ +// +// FeedlyAccountDelegateError.swift +// Account +// +// Created by Kiel Gillard on 9/10/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +enum FeedlyAccountDelegateError: LocalizedError { + case notLoggedIn + case unableToAddFolder(String) + case unableToRenameFolder(String, String) + case unableToRemoveFolder(String) + case unableToMoveFeedBetweenFolders(Feed, Folder, Folder) + case addFeedChooseFolder + case addFeedInvalidFolder(Folder) + case unableToRemoveFeed(Feed) + + var errorDescription: String? { + switch self { + case .notLoggedIn: + return NSLocalizedString("Please add the Feedly account again.", comment: "Feedly - Credentials not found.") + + case .unableToAddFolder(let name): + let template = NSLocalizedString("Could not create a folder named \"%@\".", comment: "Feedly - Could not create a folder/collection.") + return String(format: template, name) + + case .unableToRenameFolder(let from, let to): + let template = NSLocalizedString("Could not rename \"%@\" to \"%@\".", comment: "Feedly - Could not rename a folder/collection.") + return String(format: template, from, to) + + case .unableToRemoveFolder(let name): + let template = NSLocalizedString("Could not remove the folder named \"%@\".", comment: "Feedly - Could not remove a folder/collection.") + return String(format: template, name) + + case .unableToMoveFeedBetweenFolders(let feed, _, let to): + let template = NSLocalizedString("Could not move \"%@\" to \"%@\".", comment: "Feedly - Could not move a feed between folders/collections.") + return String(format: template, feed.nameForDisplay, to.nameForDisplay) + + case .addFeedChooseFolder: + return NSLocalizedString("Please choose a folder to contain the feed.", comment: "Feedly - Feed can only be added to folders.") + + case .addFeedInvalidFolder(let invalidFolder): + let template = NSLocalizedString("Feeds cannot be added to the \"%@\" folder.", comment: "Feedly - Feed can only be added to folders.") + return String(format: template, invalidFolder.nameForDisplay) + + case .unableToRemoveFeed(let feed): + let template = NSLocalizedString("Could not remove \"%@\".", comment: "Feedly - Could not remove a feed.") + return String(format: template, feed.nameForDisplay) + } + } + + var recoverySuggestion: String? { + switch self { + case .notLoggedIn: + return nil + + case .unableToAddFolder: + return nil + + case .unableToRenameFolder: + return nil + + case .unableToRemoveFolder: + return nil + + case .unableToMoveFeedBetweenFolders(let feed, let from, let to): + let template = NSLocalizedString("\"%@\" may be in both \"%@\" and \"%@\".", comment: "Feedly - Could not move a feed between folders/collections.") + return String(format: template, feed.nameForDisplay, from.nameForDisplay, to.nameForDisplay) + + case .addFeedChooseFolder: + return nil + + case .addFeedInvalidFolder: + return NSLocalizedString("Please choose a different folder to contain the feed.", comment: "Feedly - Feed can only be added to folders recovery suggestion.") + + case .unableToRemoveFeed: + return nil + } + } +} diff --git a/Frameworks/Account/Feedly/Models/FeedlyResourceId.swift b/Frameworks/Account/Feedly/Models/FeedlyResourceId.swift index 3095f2709..af31c55fd 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyResourceId.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyResourceId.swift @@ -13,17 +13,15 @@ protocol FeedlyResourceId { /// The resource Id from Feedly. var id: String { get } - - /// The location of the kind of resource a concrete type represents. - /// If the conrete type cannot strip the resource type from the Id, it should just return the Id - /// since the Id is a legitimate URL. - var url: String { get } } /// The Feed Resource is documented here: https://developer.feedly.com/cloud/ struct FeedlyFeedResourceId: FeedlyResourceId { var id: String + /// The location of the kind of resource a concrete type represents. + /// If the conrete type cannot strip the resource type from the Id, it should just return the Id + /// since the Id is a legitimate URL. var url: String { if let range = id.range(of: "feed/"), range.lowerBound == id.startIndex { var mutant = id @@ -35,3 +33,19 @@ struct FeedlyFeedResourceId: FeedlyResourceId { return id } } + +extension FeedlyFeedResourceId { + init(url: String) { + self.id = "feed/\(url)" + } +} + +struct FeedlyCategoryResourceId: FeedlyResourceId { + var id: String + + static func uncategorized(for userId: String) -> FeedlyCategoryResourceId { + // https://developer.feedly.com/cloud/#global-resource-ids + let id = "user/\(userId)/category/global.uncategorized" + return FeedlyCategoryResourceId(id: id) + } +} diff --git a/Mac/MainWindow/Detail/styleSheet.css b/Mac/MainWindow/Detail/styleSheet.css index ce5ef262e..a98d508e1 100644 --- a/Mac/MainWindow/Detail/styleSheet.css +++ b/Mac/MainWindow/Detail/styleSheet.css @@ -140,6 +140,18 @@ figcaption { line-height: 1.3em; } +sup { + vertical-align: top; + position: relative; + bottom: 0.2rem; +} + +sub { + vertical-align: bottom; + position: relative; + top: 0.2rem; +} + .iframeWrap { position: relative; display: block; diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 7792cc046..3e78283af 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -184,6 +184,9 @@ 51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84702AA31FA27AC0006B8943 /* MarkStatusCommand.swift */; }; 51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */; }; 51D87EE12311D34700E63F03 /* ActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D87EE02311D34700E63F03 /* ActivityType.swift */; }; + 51E149B3234D82E40004F7A5 /* PasswordField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E149B2234D82E40004F7A5 /* PasswordField.swift */; }; + 51E149C0234D839E0004F7A5 /* ShowHidePasswordView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51E149BF234D839E0004F7A5 /* ShowHidePasswordView.xib */; }; + 51E149C2234D852F0004F7A5 /* ShowHidePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E149C1234D852F0004F7A5 /* ShowHidePasswordView.swift */; }; 51E3EB33229AB02C00645299 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB32229AB02C00645299 /* ErrorHandler.swift */; }; 51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB3C229AB08300645299 /* ErrorHandler.swift */; }; 51E595A5228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */; }; @@ -802,6 +805,9 @@ 51CC9B3D231720B2000E842F /* MasterFeedDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedDataSource.swift; sourceTree = ""; }; 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterTimelineDataSource.swift; sourceTree = ""; }; 51D87EE02311D34700E63F03 /* ActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityType.swift; sourceTree = ""; }; + 51E149B2234D82E40004F7A5 /* PasswordField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordField.swift; sourceTree = ""; }; + 51E149BF234D839E0004F7A5 /* ShowHidePasswordView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowHidePasswordView.xib; sourceTree = ""; }; + 51E149C1234D852F0004F7A5 /* ShowHidePasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowHidePasswordView.swift; sourceTree = ""; }; 51E3EB32229AB02C00645299 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = ""; }; 51E3EB3C229AB08300645299 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = ""; }; 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleStatusSyncTimer.swift; sourceTree = ""; }; @@ -1244,6 +1250,9 @@ DF999FF622B5AEFA0064B687 /* SafariView.swift */, 51322858232FDDB80033D4ED /* VibrantButtonStyle.swift */, 51322854232EED360033D4ED /* VibrantSelectAction.swift */, + 51E149B2234D82E40004F7A5 /* PasswordField.swift */, + 51E149C1234D852F0004F7A5 /* ShowHidePasswordView.swift */, + 51E149BF234D839E0004F7A5 /* ShowHidePasswordView.xib */, ); path = "SwiftUI Extensions"; sourceTree = ""; @@ -2206,15 +2215,11 @@ TargetAttributes = { 513C5CE5232571C2003D4054 = { CreatedOnToolsVersion = 11.0; - DevelopmentTeam = M8L2WTLA8W; - ProvisioningStyle = Automatic; }; 6581C73220CED60000F4AD34 = { }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = M8L2WTLA8W; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { enabled = 1; @@ -2223,8 +2228,6 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = M8L2WTLA8W; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { enabled = 1; @@ -2233,8 +2236,6 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = 9C84TZ7Q6Z; - ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; }; @@ -2481,6 +2482,7 @@ 51F85BED227251DF00C787DC /* Acknowledgments.rtf in Resources */, 511D43D1231FA62800FB1562 /* SidebarKeyboardShortcuts.plist in Resources */, 51C452AB22650DC600C03939 /* template.html in Resources */, + 51E149C0234D839E0004F7A5 /* ShowHidePasswordView.xib in Resources */, 51F85BF12272524100C787DC /* Credits.rtf in Resources */, 84A3EE61223B667F00557320 /* DefaultFeeds.opml in Resources */, 511D43CF231FA62200FB1562 /* DetailKeyboardShortcuts.plist in Resources */, @@ -2709,6 +2711,7 @@ buildActionMask = 2147483647; files = ( 840D617F2029031C009BC708 /* AppDelegate.swift in Sources */, + 51E149B3234D82E40004F7A5 /* PasswordField.swift in Sources */, 512E08E72268801200BDCFDD /* FeedTreeControllerDelegate.swift in Sources */, 51C452A422650A2D00C03939 /* ArticleUtilities.swift in Sources */, 51EF0F79227716380050506E /* ColorHash.swift in Sources */, @@ -2777,6 +2780,7 @@ 51C4529A22650A0400C03939 /* ArticleStyle.swift in Sources */, 51C4527F2265092C00C03939 /* ArticleViewController.swift in Sources */, 51C4526A226508F600C03939 /* MasterFeedTableViewCellLayout.swift in Sources */, + 51E149C2234D852F0004F7A5 /* ShowHidePasswordView.swift in Sources */, 51C452AE2265104D00C03939 /* TimelineStringFormatter.swift in Sources */, 512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */, 51C4529922650A0000C03939 /* ArticleStylesManager.swift in Sources */, diff --git a/buildscripts/ci-build.sh b/buildscripts/ci-build.sh index 5f44bd72c..c33c17834 100755 --- a/buildscripts/ci-build.sh +++ b/buildscripts/ci-build.sh @@ -2,9 +2,12 @@ set -v set -e +# Unencrypt our provisioning profile, certificate, and private key +openssl aes-256-cbc -k "$ENCRYPTION_SECRET" -in buildscripts/profile/NetNewsWire.provisionprofile.enc -d -a -out buildscripts/profile/NetNewsWire.provisionprofile openssl aes-256-cbc -k "$ENCRYPTION_SECRET" -in buildscripts/certs/dev.cer.enc -d -a -out buildscripts/certs/dev.cer openssl aes-256-cbc -k "$ENCRYPTION_SECRET" -in buildscripts/certs/dev.p12.enc -d -a -out buildscripts/certs/dev.p12 +# Put the certificates and private key in the Keychain, set ACL permissions, and make default security create-keychain -p github-actions github-build.keychain security import buildscripts/certs/apple.cer -k ~/Library/Keychains/github-build.keychain -A security import buildscripts/certs/dev.cer -k ~/Library/Keychains/github-build.keychain -A @@ -12,9 +15,18 @@ security import buildscripts/certs/dev.p12 -k ~/Library/Keychains/github-build.k security set-key-partition-list -S apple-tool:,apple: -s -k github-actions github-build.keychain security default-keychain -s github-build.keychain -rm -f ./buildscripts/certs/dev.cer -rm -f ./buildscripts/certs/dev.p12 +# Copy the provisioning profile +mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles +cp buildscripts/profile/NetNewsWire.provisionprofile ~/Library/MobileDevice/Provisioning\ Profiles/ -xcodebuild -scheme 'NetNewsWire' -configuration Release -allowProvisioningUpdates -showBuildTimingSummary +# Delete the decrypted files +rm -f buildscripts/profile/NetNewsWire.provisionprofile +rm -f buildscripts/certs/dev.cer +rm -f buildscripts/certs/dev.p12 -security delete-keychain github-build.keychain \ No newline at end of file +# Do the build +xcodebuild -scheme 'NetNewsWire' -configuration Release -showBuildTimingSummary + +# Delete the keychain and the provisioningi profile +security delete-keychain github-build.keychain +rm -f ~/Library/MobileDevice/Provisioning\ Profiles/NetNewsWire.provisionprofile \ No newline at end of file diff --git a/iOS/Resources/styleSheet.css b/iOS/Resources/styleSheet.css index 6405a9347..df1c80612 100644 --- a/iOS/Resources/styleSheet.css +++ b/iOS/Resources/styleSheet.css @@ -141,6 +141,18 @@ figcaption { line-height: 1.3em; } +sup { + vertical-align: top; + position: relative; + bottom: 0.2rem; +} + +sub { + vertical-align: bottom; + position: relative; + top: 0.2rem; +} + .iframeWrap { position: relative; display: block; diff --git a/iOS/Settings/Account/SettingsFeedbinAccountView.swift b/iOS/Settings/Account/SettingsFeedbinAccountView.swift index 511318a81..a685a8ada 100644 --- a/iOS/Settings/Account/SettingsFeedbinAccountView.swift +++ b/iOS/Settings/Account/SettingsFeedbinAccountView.swift @@ -31,7 +31,7 @@ struct SettingsFeedbinAccountView : View { TextField("Email", text: $viewModel.email) .keyboardType(.emailAddress) .textContentType(.emailAddress) - SecureField("Password", text: $viewModel.password) + PasswordField(password: $viewModel.password) } Section(footer: HStack { diff --git a/iOS/SwiftUI Extensions/PasswordField.swift b/iOS/SwiftUI Extensions/PasswordField.swift new file mode 100644 index 000000000..8875837ae --- /dev/null +++ b/iOS/SwiftUI Extensions/PasswordField.swift @@ -0,0 +1,25 @@ +// +// PasswordField.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 10/8/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct PasswordField: UIViewRepresentable { + + let password: Binding + + func makeUIView(context: Context) -> ShowHidePasswordView { + let showHideView = Bundle.main.loadNibNamed("ShowHidePasswordView", owner: Self.self, options: nil)?[0] as! ShowHidePasswordView + showHideView.passwordTextField.bindingString = password + return showHideView + } + + func updateUIView(_ showHideView: ShowHidePasswordView, context: Context) { + showHideView.passwordTextField.bindingString = password + } + +} diff --git a/iOS/SwiftUI Extensions/ShowHidePasswordView.swift b/iOS/SwiftUI Extensions/ShowHidePasswordView.swift new file mode 100644 index 000000000..fe6b10d02 --- /dev/null +++ b/iOS/SwiftUI Extensions/ShowHidePasswordView.swift @@ -0,0 +1,51 @@ +// +// ShowHidePasswordView.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 10/8/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import UIKit +import SwiftUI + +class ShowHidePasswordView: UIView { + + @IBOutlet weak var passwordTextField: BindingTextField! + @IBOutlet weak var showHideButton: UIButton! + + @IBAction func toggleShowHideButton(_ sender: Any) { + if passwordTextField.isSecureTextEntry { + passwordTextField.isSecureTextEntry = false + showHideButton.setTitle(NSLocalizedString("Hide", comment: "Hide"), for: .normal) + } else { + passwordTextField.isSecureTextEntry = true + showHideButton.setTitle(NSLocalizedString("Show", comment: "Show"), for: .normal) + } + } + +} + +class BindingTextField: UITextField, UITextFieldDelegate { + + var bindingString: Binding? = nil + + override init(frame: CGRect) { + super.init(frame: frame) + delegate = self + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + delegate = self + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let currentValue = textField.text as NSString? { + let proposedValue = currentValue.replacingCharacters(in: range, with: string) + bindingString?.wrappedValue = proposedValue + } + return true + } + +} diff --git a/iOS/SwiftUI Extensions/ShowHidePasswordView.xib b/iOS/SwiftUI Extensions/ShowHidePasswordView.xib new file mode 100644 index 000000000..d68686200 --- /dev/null +++ b/iOS/SwiftUI Extensions/ShowHidePasswordView.xib @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xcconfig/NetNewsWire_macapp_target.xcconfig b/xcconfig/NetNewsWire_macapp_target.xcconfig index f24b25dbf..c3500d89c 100644 --- a/xcconfig/NetNewsWire_macapp_target.xcconfig +++ b/xcconfig/NetNewsWire_macapp_target.xcconfig @@ -1,6 +1,7 @@ -CODE_SIGN_IDENTITY = Mac Developer +CODE_SIGN_IDENTITY = Developer ID Application DEVELOPMENT_TEAM = M8L2WTLA8W -CODE_SIGN_STYLE = Automatic +CODE_SIGN_STYLE = Manual +ORGANIZATION_IDENTIFIER = com.ranchero PROVISIONING_PROFILE_SPECIFIER = // developers can locally override the Xcode settings for code signing @@ -37,6 +38,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon CODE_SIGN_ENTITLEMENTS = Mac/Resources/NetNewsWire.entitlements INFOPLIST_FILE = Mac/Resources/Info.plist LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks -PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.NetNewsWire-Evergreen +PRODUCT_BUNDLE_IDENTIFIER = $(ORGANIZATION_IDENTIFIER).NetNewsWire-Evergreen PRODUCT_NAME = NetNewsWire SWIFT_OBJC_BRIDGING_HEADER = Mac/NetNewsWire-Bridging-Header.h diff --git a/xcconfig/NetNewsWire_safariextension_target.xcconfig b/xcconfig/NetNewsWire_safariextension_target.xcconfig index d1cc16acd..8caead968 100644 --- a/xcconfig/NetNewsWire_safariextension_target.xcconfig +++ b/xcconfig/NetNewsWire_safariextension_target.xcconfig @@ -1,6 +1,7 @@ -CODE_SIGN_IDENTITY = Mac Developer +CODE_SIGN_IDENTITY = Developer ID Application DEVELOPMENT_TEAM = M8L2WTLA8W -CODE_SIGN_STYLE = Automatic +CODE_SIGN_STYLE = Manual +ORGANIZATION_IDENTIFIER = com.ranchero PROVISIONING_PROFILE_SPECIFIER = // developers can locally override the Xcode settings for code signing @@ -16,6 +17,7 @@ PROVISIONING_PROFILE_SPECIFIER = // CODE_SIGN_IDENTITY[sdk=iphoneos*] = iPhone Developer // CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = iPhone Developer // DEVELOPMENT_TEAM = +// ORGANIZATION_IDENTIFIER = // CODE_SIGN_STYLE = Automatic // PROVISIONING_PROFILE_SPECIFIER = // @@ -34,7 +36,7 @@ PROVISIONING_PROFILE_SPECIFIER = CODE_SIGN_ENTITLEMENTS = Mac/SafariExtension/Subscribe_to_Feed.entitlements INFOPLIST_FILE = Mac/SafariExtension/Info.plist LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks -PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.NetNewsWire-Evergreen.Subscribe-to-Feed +PRODUCT_BUNDLE_IDENTIFIER = $(ORGANIZATION_IDENTIFIER).NetNewsWire-Evergreen.Subscribe-to-Feed PRODUCT_NAME = $(TARGET_NAME) SDKROOT = macosx