Merge branch 'master' of https://github.com/brentsimmons/NetNewsWire
This commit is contained in:
commit
abad2ca8ab
|
@ -36,9 +36,25 @@ public enum AccountType: Int {
|
|||
// TODO: more
|
||||
}
|
||||
|
||||
public enum AccountError: Error {
|
||||
public enum AccountError: LocalizedError {
|
||||
|
||||
case createErrorNotFound
|
||||
case createErrorAlreadySubscribed
|
||||
case opmlImportInProgress
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .opmlImportInProgress:
|
||||
return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running")
|
||||
default:
|
||||
return NSLocalizedString("An unknown error occurred.", comment: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
public var recoverySuggestion: String? {
|
||||
return NSLocalizedString("Please try again later.", comment: "Try later")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable {
|
||||
|
@ -299,10 +315,18 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
}
|
||||
|
||||
public func importOPML(_ opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
guard !delegate.opmlImportInProgress else {
|
||||
completion(.failure(AccountError.opmlImportInProgress))
|
||||
return
|
||||
}
|
||||
|
||||
delegate.importOPML(for: self, opmlFile: opmlFile) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
guard let self = self else { return }
|
||||
// Reset the last fetch date to get the article history for the added feeds.
|
||||
self.metadata.lastArticleFetch = nil
|
||||
self.delegate.refreshAll(for: self) {
|
||||
completion(.success(()))
|
||||
}
|
||||
|
@ -310,6 +334,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
|
||||
|
@ -544,6 +569,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
return database.fetchStarredArticleIDs()
|
||||
}
|
||||
|
||||
public func fetchArticleIDsForStatusesWithoutArticles() -> Set<String> {
|
||||
return database.fetchArticleIDsForStatusesWithoutArticles()
|
||||
}
|
||||
|
||||
public func opmlDocument() -> String {
|
||||
let escapedTitle = nameForDisplay.rs_stringByEscapingSpecialXMLCharacters()
|
||||
let openingText =
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5133230F22810E5700C30F19 /* FeedbinIcon.swift */; };
|
||||
5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */; };
|
||||
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */; };
|
||||
5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; };
|
||||
5165D7122282080C00D9D53D /* AccountFolderContentsSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D7112282080C00D9D53D /* AccountFolderContentsSyncTest.swift */; };
|
||||
5165D71622821C2400D9D53D /* taggings_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 5165D71322821C2400D9D53D /* taggings_delete.json */; };
|
||||
5165D71722821C2400D9D53D /* taggings_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5165D71422821C2400D9D53D /* taggings_add.json */; };
|
||||
|
@ -116,6 +117,7 @@
|
|||
5133230F22810E5700C30F19 /* FeedbinIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinIcon.swift; sourceTree = "<group>"; };
|
||||
5144EA48227B497600D19003 /* FeedbinAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAPICaller.swift; sourceTree = "<group>"; };
|
||||
5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAccountDelegate.swift; sourceTree = "<group>"; };
|
||||
5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = "<group>"; };
|
||||
5165D7112282080C00D9D53D /* AccountFolderContentsSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFolderContentsSyncTest.swift; sourceTree = "<group>"; };
|
||||
5165D71322821C2400D9D53D /* taggings_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = taggings_delete.json; sourceTree = "<group>"; };
|
||||
5165D71422821C2400D9D53D /* taggings_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = taggings_add.json; sourceTree = "<group>"; };
|
||||
|
@ -255,6 +257,7 @@
|
|||
51E490352288C37100C791F0 /* FeedbinDate.swift */,
|
||||
84CAD7151FDF2E22000F0755 /* FeedbinEntry.swift */,
|
||||
5133230F22810E5700C30F19 /* FeedbinIcon.swift */,
|
||||
5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */,
|
||||
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */,
|
||||
84245C841FDDD8CB0074AFBB /* FeedbinSubscription.swift */,
|
||||
51D58754227F53BE00900287 /* FeedbinTag.swift */,
|
||||
|
@ -521,6 +524,7 @@
|
|||
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */,
|
||||
5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */,
|
||||
844B297D2106C7EC004020B3 /* Feed.swift in Sources */,
|
||||
5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */,
|
||||
84B2D4D02238CD8A00498ADA /* FeedMetadata.swift in Sources */,
|
||||
5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */,
|
||||
84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */,
|
||||
|
|
|
@ -14,6 +14,8 @@ protocol AccountDelegate {
|
|||
|
||||
// Local account does not; some synced accounts might.
|
||||
var supportsSubFolders: Bool { get }
|
||||
var opmlImportInProgress: Bool { get }
|
||||
|
||||
var server: String? { get }
|
||||
var credentials: Credentials? { get set }
|
||||
var accountMetadata: AccountMetadata? { get set }
|
||||
|
|
|
@ -67,6 +67,54 @@ final class FeedbinAPICaller: NSObject {
|
|||
|
||||
}
|
||||
|
||||
func importOPML(opmlData: Data, completion: @escaping (Result<FeedbinImportResult, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("imports.json")
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
|
||||
transport.send(request: request, method: HTTPMethod.post, payload: opmlData) { result in
|
||||
|
||||
switch result {
|
||||
case .success(let (_, data)):
|
||||
|
||||
guard let resultData = data else {
|
||||
completion(.failure(TransportError.noData))
|
||||
break
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try JSONDecoder().decode(FeedbinImportResult.self, from: resultData)
|
||||
completion(.success(result))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveOPMLImportResult(importID: Int, completion: @escaping (Result<FeedbinImportResult?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("imports/\(importID).json")
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: FeedbinImportResult.self) { result in
|
||||
|
||||
switch result {
|
||||
case .success(let (_, importResult)):
|
||||
completion(.success(importResult))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveTags(completion: @escaping (Result<[FeedbinTag]?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("tags.json")
|
||||
|
@ -299,6 +347,33 @@ final class FeedbinAPICaller: NSObject {
|
|||
|
||||
}
|
||||
|
||||
func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([FeedbinEntry]?), Error>) -> Void) {
|
||||
|
||||
guard !articleIDs.isEmpty else {
|
||||
completion(.success(([FeedbinEntry]())))
|
||||
return
|
||||
}
|
||||
|
||||
let concatIDs = articleIDs.reduce("") { param, articleID in return param + ",\(articleID)" }
|
||||
let paramIDs = String(concatIDs.dropFirst())
|
||||
|
||||
var callURL = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)!
|
||||
callURL.queryItems = [URLQueryItem(name: "ids", value: paramIDs)]
|
||||
let request = URLRequest(url: callURL.url!, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { [weak self] result in
|
||||
|
||||
switch result {
|
||||
case .success(let (_, entries)):
|
||||
completion(.success((entries)))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveEntries(feedID: String, completion: @escaping (Result<([FeedbinEntry]?, String?), Error>) -> Void) {
|
||||
|
||||
let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()
|
||||
|
@ -359,25 +434,6 @@ final class FeedbinAPICaller: NSObject {
|
|||
|
||||
}
|
||||
|
||||
func extractPageNumber(link: String?) -> Int? {
|
||||
|
||||
guard let link = link else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let lowerBound = link.range(of: "page=")?.upperBound {
|
||||
if let upperBound = link.range(of: "&")?.lowerBound {
|
||||
return Int(link[lowerBound..<upperBound])
|
||||
}
|
||||
if let upperBound = link.range(of: ">")?.lowerBound {
|
||||
return Int(link[lowerBound..<upperBound])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func retrieveEntries(page: String, completion: @escaping (Result<([FeedbinEntry]?, String?), Error>) -> Void) {
|
||||
|
||||
guard let callURL = URL(string: page) else {
|
||||
|
@ -484,4 +540,23 @@ extension FeedbinAPICaller {
|
|||
}
|
||||
}
|
||||
|
||||
func extractPageNumber(link: String?) -> Int? {
|
||||
|
||||
guard let link = link else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let lowerBound = link.range(of: "page=")?.upperBound {
|
||||
if let upperBound = link.range(of: "&")?.lowerBound {
|
||||
return Int(link[lowerBound..<upperBound])
|
||||
}
|
||||
if let upperBound = link.range(of: ">")?.lowerBound {
|
||||
return Int(link[lowerBound..<upperBound])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
|||
|
||||
let supportsSubFolders = false
|
||||
let server: String? = "api.feedbin.com"
|
||||
var opmlImportInProgress = false
|
||||
|
||||
var credentials: Credentials? {
|
||||
didSet {
|
||||
|
@ -79,7 +80,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
|||
|
||||
func refreshAll(for account: Account, completion: (() -> Void)? = nil) {
|
||||
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(5)
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(6)
|
||||
|
||||
refreshAccount(account) { [weak self] result in
|
||||
switch result {
|
||||
|
@ -87,8 +88,12 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
|||
|
||||
self?.refreshArticles(account) {
|
||||
self?.refreshArticleStatus(for: account) {
|
||||
self?.refreshProgress.clear()
|
||||
completion?()
|
||||
self?.refreshMissingArticles(account) {
|
||||
self?.refreshProgress.clear()
|
||||
DispatchQueue.main.async {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -200,25 +205,32 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
let parserData = ParserData(url: opmlFile.absoluteString, data: opmlData)
|
||||
var opmlDocument: RSOPMLDocument?
|
||||
os_log(.debug, log: log, "Begin importing OPML...")
|
||||
opmlImportInProgress = true
|
||||
|
||||
do {
|
||||
opmlDocument = try RSOPMLParser.parseOPML(with: parserData)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
caller.importOPML(opmlData: opmlData) { [weak self] result in
|
||||
switch result {
|
||||
case .success(let importResult):
|
||||
if importResult.complete {
|
||||
guard let self = self else { return }
|
||||
os_log(.debug, log: self.log, "Import OPML done.")
|
||||
self.opmlImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
} else {
|
||||
self?.checkImportResult(opmlImportResultID: importResult.importResultID, completion: completion)
|
||||
}
|
||||
case .failure(let error):
|
||||
guard let self = self else { return }
|
||||
os_log(.debug, log: self.log, "Import OPML failed.")
|
||||
self.opmlImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let loadDocument = opmlDocument, let children = loadDocument.children else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
importOPMLItems(account, items: children, parentFolder: nil)
|
||||
|
||||
completion(.success(()))
|
||||
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
@ -507,6 +519,43 @@ private extension FeedbinAccountDelegate {
|
|||
|
||||
}
|
||||
|
||||
func checkImportResult(opmlImportResultID: Int, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
||||
Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] timer in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
os_log(.debug, log: self.log, "Checking status of OPML import...")
|
||||
|
||||
self.caller.retrieveOPMLImportResult(importID: opmlImportResultID) { result in
|
||||
switch result {
|
||||
case .success(let importResult):
|
||||
if let result = importResult, result.complete {
|
||||
os_log(.debug, log: self.log, "Checking status of OPML import successfully completed.")
|
||||
timer.invalidate()
|
||||
self.opmlImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Import OPML check failed.")
|
||||
timer.invalidate()
|
||||
self.opmlImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func syncFolders(_ account: Account, _ tags: [FeedbinTag]?) {
|
||||
|
||||
guard let tags = tags else { return }
|
||||
|
@ -749,125 +798,30 @@ private extension FeedbinAccountDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
let articleIDs = statuses.compactMap { Int($0.articleID) }
|
||||
let articleIDGroups = articleIDs.chunked(into: 1000)
|
||||
for articleIDGroup in articleIDGroups {
|
||||
|
||||
group.enter()
|
||||
apiCall(articleIDGroup) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.database.deleteSelectedForProcessing(articleIDGroup.map { String($0) } )
|
||||
completion()
|
||||
group.leave()
|
||||
case .failure(let error):
|
||||
guard let self = self else { return }
|
||||
os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription)
|
||||
self.database.resetSelectedForProcessing(articleIDGroup.map { String($0) } )
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func importOPMLItems(_ account: Account, items: [RSOPMLItem], parentFolder: Folder?) {
|
||||
|
||||
items.forEach { (item) in
|
||||
|
||||
if let feedSpecifier = item.feedSpecifier {
|
||||
importFeedSpecifier(account, feedSpecifier: feedSpecifier, parentFolder: parentFolder)
|
||||
return
|
||||
}
|
||||
|
||||
guard let folderName = item.titleFromAttributes else {
|
||||
// Folder doesn’t have a name, so it won’t be created, and its items will go one level up.
|
||||
if let itemChildren = item.children {
|
||||
importOPMLItems(account, items: itemChildren, parentFolder: parentFolder)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let folder = account.ensureFolder(with: folderName) {
|
||||
if let itemChildren = item.children {
|
||||
importOPMLItems(account, items: itemChildren, parentFolder: folder)
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func importFeedSpecifier(_ account: Account, feedSpecifier: RSOPMLFeedSpecifier, parentFolder: Folder?) {
|
||||
|
||||
caller.createSubscription(url: feedSpecifier.feedURL) { [weak self] result in
|
||||
|
||||
switch result {
|
||||
case .success(let subResult):
|
||||
switch subResult {
|
||||
case .created(let sub):
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
||||
let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL)
|
||||
feed.subscriptionID = String(sub.subscriptionID)
|
||||
|
||||
self?.importFeedSpecifierPostProcess(account: account, sub: sub, feedSpecifier: feedSpecifier, feed: feed, parentFolder: parentFolder)
|
||||
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
guard let self = self else { return }
|
||||
os_log(.error, log: self.log, "Create feed on OPML import failed: %@.", error.localizedDescription)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func importFeedSpecifierPostProcess(account: Account, sub: FeedbinSubscription, feedSpecifier: RSOPMLFeedSpecifier, feed: Feed, parentFolder: Folder?) {
|
||||
|
||||
// Rename the feed if its name in the OPML file doesn't match the found name
|
||||
if sub.name != feedSpecifier.title, let newName = feedSpecifier.title {
|
||||
|
||||
self.caller.renameSubscription(subscriptionID: String(sub.subscriptionID), newName: newName) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
feed.editedName = newName
|
||||
}
|
||||
case .failure(let error):
|
||||
guard let self = self else { return }
|
||||
os_log(.error, log: self.log, "Rename feed on OPML import failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Move the new feed if it is in a folder
|
||||
if let folder = parentFolder, let feedID = Int(feed.feedID) {
|
||||
|
||||
self.caller.createTagging(feedID: feedID, name: folder.name ?? "") { [weak self] result in
|
||||
switch result {
|
||||
case .success(let taggingID):
|
||||
DispatchQueue.main.async {
|
||||
self?.saveFolderRelationship(for: feed, withFolderName: folder.name ?? "", id: String(taggingID))
|
||||
folder.addFeed(feed)
|
||||
}
|
||||
case .failure(let error):
|
||||
guard let self = self else { return }
|
||||
os_log(.error, log: self.log, "Move feed to folder on OPML import failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
account.addFeed(feed)
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
completion()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1049,6 +1003,46 @@ private extension FeedbinAccountDelegate {
|
|||
|
||||
}
|
||||
|
||||
func refreshMissingArticles(_ account: Account, completion: @escaping (() -> Void)) {
|
||||
|
||||
os_log(.debug, log: log, "Refreshing missing articles...")
|
||||
let articleIDs = Array(account.fetchArticleIDsForStatusesWithoutArticles())
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
let chunkedArticleIDs = articleIDs.chunked(into: 100)
|
||||
refreshProgress.addToNumberOfTasks(chunkedArticleIDs.count - 1)
|
||||
|
||||
for chunk in chunkedArticleIDs {
|
||||
|
||||
group.enter()
|
||||
caller.retrieveEntries(articleIDs: chunk) { [weak self] result in
|
||||
|
||||
switch result {
|
||||
case .success(let entries):
|
||||
|
||||
self?.processEntries(account: account, entries: entries) {
|
||||
self?.refreshProgress.completeTask()
|
||||
group.leave()
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
guard let self = self else { return }
|
||||
os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription)
|
||||
group.leave()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
os_log(.debug, log: self.log, "Done refreshing missing articles.")
|
||||
completion()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func refreshArticles(_ account: Account, page: String?, completion: @escaping (() -> Void)) {
|
||||
|
||||
guard let page = page else {
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// FeedbinImportResult.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/17/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedbinImportResult: Codable {
|
||||
|
||||
let importResultID: Int
|
||||
let complete: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case importResultID = "id"
|
||||
case complete
|
||||
}
|
||||
|
||||
}
|
|
@ -18,6 +18,8 @@ public enum LocalAccountDelegateError: String, Error {
|
|||
final class LocalAccountDelegate: AccountDelegate {
|
||||
|
||||
let supportsSubFolders = false
|
||||
let opmlImportInProgress = false
|
||||
|
||||
let server: String? = nil
|
||||
var credentials: Credentials?
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
|
|
@ -112,6 +112,10 @@ public final class ArticlesDatabase {
|
|||
return articlesTable.fetchStarredArticleIDs()
|
||||
}
|
||||
|
||||
public func fetchArticleIDsForStatusesWithoutArticles() -> Set<String> {
|
||||
return articlesTable.fetchArticleIDsForStatusesWithoutArticles()
|
||||
}
|
||||
|
||||
public func mark(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<ArticleStatus>? {
|
||||
return articlesTable.mark(articles, statusKey, flag)
|
||||
}
|
||||
|
|
|
@ -307,6 +307,10 @@ final class ArticlesTable: DatabaseTable {
|
|||
return statusesTable.fetchStarredArticleIDs()
|
||||
}
|
||||
|
||||
func fetchArticleIDsForStatusesWithoutArticles() -> Set<String> {
|
||||
return statusesTable.fetchArticleIDsForStatusesWithoutArticles()
|
||||
}
|
||||
|
||||
func mark(_ articles: Set<Article>, _ statusKey: ArticleStatus.Key, _ flag: Bool) -> Set<ArticleStatus>? {
|
||||
|
||||
return statusesTable.mark(articles.statuses(), statusKey, flag)
|
||||
|
|
|
@ -86,6 +86,10 @@ final class StatusesTable: DatabaseTable {
|
|||
return fetchArticleIDs("select articleID from statuses where starred=1 and userDeleted=0;")
|
||||
}
|
||||
|
||||
func fetchArticleIDsForStatusesWithoutArticles() -> Set<String> {
|
||||
return fetchArticleIDs("select articleID from statuses s where userDeleted=0 and not exists (select 1 from articles a where a.articleID = s.articleID);")
|
||||
}
|
||||
|
||||
func fetchArticleIDs(_ sql: String) -> Set<String> {
|
||||
|
||||
var statuses: Set<String>? = nil
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
5144EA43227A380F00D19003 /* ExportOPMLWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA42227A380F00D19003 /* ExportOPMLWindowController.swift */; };
|
||||
5144EA51227B8E4500D19003 /* AccountsFeedbinWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */; };
|
||||
5144EA52227B8E4500D19003 /* AccountsFeedbin.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5144EA50227B8E4500D19003 /* AccountsFeedbin.xib */; };
|
||||
51543685228F6753005E1CDF /* DetailAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51543684228F6753005E1CDF /* DetailAccountViewController.swift */; };
|
||||
51554C24228B71910055115A /* SyncDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51554C01228B6EB50055115A /* SyncDatabase.framework */; };
|
||||
51554C25228B71910055115A /* SyncDatabase.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 51554C01228B6EB50055115A /* SyncDatabase.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
51554C30228B71A10055115A /* SyncDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51554C01228B6EB50055115A /* SyncDatabase.framework */; };
|
||||
|
@ -673,6 +674,7 @@
|
|||
5144EA42227A380F00D19003 /* ExportOPMLWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportOPMLWindowController.swift; sourceTree = "<group>"; };
|
||||
5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsFeedbinWindowController.swift; sourceTree = "<group>"; };
|
||||
5144EA50227B8E4500D19003 /* AccountsFeedbin.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountsFeedbin.xib; sourceTree = "<group>"; };
|
||||
51543684228F6753005E1CDF /* DetailAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailAccountViewController.swift; sourceTree = "<group>"; };
|
||||
51554BFC228B6EB50055115A /* SyncDatabase.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = SyncDatabase.xcodeproj; path = Frameworks/SyncDatabase/SyncDatabase.xcodeproj; sourceTree = SOURCE_ROOT; };
|
||||
5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = "<group>"; };
|
||||
5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicImageView.swift; sourceTree = "<group>"; };
|
||||
|
@ -1037,10 +1039,11 @@
|
|||
5183CCEC22711DCE0010922C /* Settings.storyboard */,
|
||||
51E595AA228DF94C00FCC42B /* SettingsTableViewCell.xib */,
|
||||
5183CCEE227125970010922C /* SettingsViewController.swift */,
|
||||
51E595AC228E1C2100FCC42B /* AddAccountViewController.swift */,
|
||||
51F85BE6227245FC00C787DC /* AboutViewController.swift */,
|
||||
51543684228F6753005E1CDF /* DetailAccountViewController.swift */,
|
||||
51F85BDB2272162F00C787DC /* RefreshIntervalViewController.swift */,
|
||||
51EF0F7B2277919E0050506E /* TimelineNumberOfLinesViewController.swift */,
|
||||
51E595AC228E1C2100FCC42B /* AddAccountViewController.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2347,6 +2350,7 @@
|
|||
51C4526A226508F600C03939 /* MasterFeedTableViewCellLayout.swift in Sources */,
|
||||
51C452AE2265104D00C03939 /* TimelineStringFormatter.swift in Sources */,
|
||||
512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */,
|
||||
51543685228F6753005E1CDF /* DetailAccountViewController.swift in Sources */,
|
||||
51C4529922650A0000C03939 /* ArticleStylesManager.swift in Sources */,
|
||||
51EF0F802277A8330050506E /* MasterTimelineCellLayout.swift in Sources */,
|
||||
51F85BF722749FA100C787DC /* UIFont-Extensions.swift in Sources */,
|
||||
|
|
|
@ -177,7 +177,8 @@ class NavigationStateController {
|
|||
NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil)
|
||||
|
||||
|
@ -197,6 +198,10 @@ class NavigationStateController {
|
|||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func accountStateDidChange(_ note: Notification) {
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func userDefaultsDidChange(_ note: Notification) {
|
||||
self.sortDirection = AppDefaults.timelineSortDirection
|
||||
}
|
||||
|
@ -230,6 +235,8 @@ class NavigationStateController {
|
|||
|
||||
func rebuildShadowTable() {
|
||||
|
||||
shadowTable = [[Node]]()
|
||||
|
||||
for i in 0..<treeController.rootNode.numberOfChildNodes {
|
||||
|
||||
var result = [Node]()
|
||||
|
@ -245,7 +252,7 @@ class NavigationStateController {
|
|||
}
|
||||
}
|
||||
|
||||
shadowTable[i] = result
|
||||
shadowTable.append(result)
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// DetailAccountViewController.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 5/17/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Account
|
||||
|
||||
class DetailAccountViewController: UITableViewController {
|
||||
|
||||
@IBOutlet weak var nameTextField: UITextField!
|
||||
@IBOutlet weak var activeSwitch: UISwitch!
|
||||
|
||||
weak var account: Account?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
guard let account = account else { return }
|
||||
nameTextField.text = account.name
|
||||
activeSwitch.isOn = account.isActive
|
||||
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
account?.name = nameTextField.text
|
||||
account?.isActive = activeSwitch.isOn
|
||||
}
|
||||
|
||||
}
|
|
@ -309,6 +309,99 @@
|
|||
</objects>
|
||||
<point key="canvasLocation" x="465" y="152"/>
|
||||
</scene>
|
||||
<!--Detail Account View Controller-->
|
||||
<scene sceneID="TQp-8g-7td">
|
||||
<objects>
|
||||
<tableViewController storyboardIdentifier="DetailAccountViewController" id="SLc-SS-bhp" customClass="DetailAccountViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="DfF-oG-mSd">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
|
||||
<sections>
|
||||
<tableViewSection id="Zeb-b0-lsx">
|
||||
<cells>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" id="5DR-M4-NFv">
|
||||
<rect key="frame" x="0.0" y="35" width="414" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="5DR-M4-NFv" id="edh-bL-MIR">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="43.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="19" translatesAutoresizingMaskIntoConstraints="NO" id="XJG-0j-coE">
|
||||
<rect key="frame" x="20" y="11.5" width="374" height="21"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oEe-a4-fph">
|
||||
<rect key="frame" x="0.0" y="0.0" width="45" height="21"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="8Xs-17-Ubp">
|
||||
<rect key="frame" x="64" y="0.0" width="310" height="21"/>
|
||||
<nil key="textColor"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<textInputTraits key="textInputTraits"/>
|
||||
</textField>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="XJG-0j-coE" firstAttribute="leading" secondItem="edh-bL-MIR" secondAttribute="leading" constant="20" id="2lj-H8-Iux"/>
|
||||
<constraint firstItem="XJG-0j-coE" firstAttribute="centerY" secondItem="edh-bL-MIR" secondAttribute="centerY" id="ZDw-ch-uB6"/>
|
||||
<constraint firstAttribute="trailing" secondItem="XJG-0j-coE" secondAttribute="trailing" constant="20" id="jih-VV-fBp"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" id="4n9-sW-i8D">
|
||||
<rect key="frame" x="0.0" y="79" width="414" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="4n9-sW-i8D" id="h3v-g9-biw">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="43.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Active" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pvF-Ge-m4M">
|
||||
<rect key="frame" x="20" y="11.5" width="48" height="21"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" translatesAutoresizingMaskIntoConstraints="NO" id="J5R-kl-pYE">
|
||||
<rect key="frame" x="345" y="6.5" width="51" height="31"/>
|
||||
</switch>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="J5R-kl-pYE" secondAttribute="trailing" constant="20" id="0P4-Vq-dZp"/>
|
||||
<constraint firstItem="pvF-Ge-m4M" firstAttribute="leading" secondItem="h3v-g9-biw" secondAttribute="leadingMargin" id="CIb-cr-t6V"/>
|
||||
<constraint firstItem="J5R-kl-pYE" firstAttribute="centerY" secondItem="h3v-g9-biw" secondAttribute="centerY" id="Qrx-J1-99r"/>
|
||||
<constraint firstItem="pvF-Ge-m4M" firstAttribute="centerY" secondItem="h3v-g9-biw" secondAttribute="centerY" id="Rq1-zD-1X7"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" id="VzJ-7V-Jfa">
|
||||
<rect key="frame" x="0.0" y="123" width="414" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="VzJ-7V-Jfa" id="GyY-75-Dmv">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="43.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
</cells>
|
||||
</tableViewSection>
|
||||
</sections>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="SLc-SS-bhp" id="PMF-j4-uum"/>
|
||||
<outlet property="delegate" destination="SLc-SS-bhp" id="kha-OO-7FD"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<connections>
|
||||
<outlet property="activeSwitch" destination="J5R-kl-pYE" id="qEU-6p-G82"/>
|
||||
<outlet property="nameTextField" destination="8Xs-17-Ubp" id="lKK-bI-mPR"/>
|
||||
</connections>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="CQK-9Z-wvA" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1155" y="145"/>
|
||||
</scene>
|
||||
<!--Add Account View Controller-->
|
||||
<scene sceneID="HbE-f2-Dbd">
|
||||
<objects>
|
||||
|
@ -401,7 +494,7 @@
|
|||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="kmn-Q7-rga" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1365.217391304348" y="160.04464285714286"/>
|
||||
<point key="canvasLocation" x="1826" y="139"/>
|
||||
</scene>
|
||||
<!--Timeline Text-->
|
||||
<scene sceneID="07z-Vb-4Fm">
|
||||
|
|
|
@ -48,6 +48,8 @@ class SettingsViewController: UITableViewController {
|
|||
buildLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.tableFooterView = buildLabel
|
||||
|
||||
tableView.reloadData()
|
||||
|
||||
}
|
||||
|
||||
// MARK: UITableView
|
||||
|
@ -96,10 +98,12 @@ class SettingsViewController: UITableViewController {
|
|||
case 0:
|
||||
let sortedAccounts = AccountManager.shared.sortedAccounts
|
||||
if indexPath.row == sortedAccounts.count {
|
||||
let timeline = UIStoryboard.settings.instantiateController(ofType: AddAccountViewController.self)
|
||||
self.navigationController?.pushViewController(timeline, animated: true)
|
||||
let controller = UIStoryboard.settings.instantiateController(ofType: AddAccountViewController.self)
|
||||
self.navigationController?.pushViewController(controller, animated: true)
|
||||
} else {
|
||||
// TODO
|
||||
let controller = UIStoryboard.settings.instantiateController(ofType: DetailAccountViewController.self)
|
||||
controller.account = sortedAccounts[indexPath.row]
|
||||
self.navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
case 1:
|
||||
switch indexPath.row {
|
||||
|
|
Loading…
Reference in New Issue