Merge branch 'master' into extension-point
This commit is contained in:
commit
a131c01e77
|
@ -716,7 +716,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
webFeedDictionariesNeedUpdate = true
|
||||
}
|
||||
|
||||
func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping DatabaseCompletionBlock) {
|
||||
func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping UpdateArticlesCompletionBlock) {
|
||||
// Used only by an On My Mac or iCloud account.
|
||||
precondition(Thread.isMainThread)
|
||||
precondition(type == .onMyMac || type == .cloudKit)
|
||||
|
@ -724,14 +724,14 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
webFeed.takeSettings(from: parsedFeed)
|
||||
let parsedItems = parsedFeed.items
|
||||
guard !parsedItems.isEmpty else {
|
||||
completion(nil)
|
||||
completion(.success(NewAndUpdatedArticles()))
|
||||
return
|
||||
}
|
||||
|
||||
update(webFeed.webFeedID, with: parsedItems, completion: completion)
|
||||
}
|
||||
|
||||
func update(_ webFeedID: String, with parsedItems: Set<ParsedItem>, completion: @escaping DatabaseCompletionBlock) {
|
||||
func update(_ webFeedID: String, with parsedItems: Set<ParsedItem>, completion: @escaping UpdateArticlesCompletionBlock) {
|
||||
// Used only by an On My Mac or iCloud account.
|
||||
precondition(Thread.isMainThread)
|
||||
precondition(type == .onMyMac || type == .cloudKit)
|
||||
|
@ -740,9 +740,9 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
switch updateArticlesResult {
|
||||
case .success(let newAndUpdatedArticles):
|
||||
self.sendNotificationAbout(newAndUpdatedArticles)
|
||||
completion(nil)
|
||||
completion(.success(newAndUpdatedArticles))
|
||||
case .failure(let databaseError):
|
||||
completion(databaseError)
|
||||
completion(.failure(databaseError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -801,39 +801,45 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
|
||||
/// Mark articleIDs statuses based on statusKey and flag.
|
||||
/// Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
||||
func mark(articleIDs: Set<String>, statusKey: ArticleStatus.Key, flag: Bool, completion: DatabaseCompletionBlock? = nil) {
|
||||
/// Returns a set of new article statuses.
|
||||
func markAndFetchNew(articleIDs: Set<String>, statusKey: ArticleStatus.Key, flag: Bool, completion: ArticleIDsCompletionBlock? = nil) {
|
||||
guard !articleIDs.isEmpty else {
|
||||
completion?(nil)
|
||||
completion?(.success(Set<String>()))
|
||||
return
|
||||
}
|
||||
database.mark(articleIDs: articleIDs, statusKey: statusKey, flag: flag) { error in
|
||||
if let error = error {
|
||||
completion?(error)
|
||||
return
|
||||
database.markAndFetchNew(articleIDs: articleIDs, statusKey: statusKey, flag: flag) { result in
|
||||
switch result {
|
||||
case .success(let newArticleStatusIDs):
|
||||
self.noteStatusesForArticleIDsDidChange(articleIDs)
|
||||
completion?(.success(newArticleStatusIDs))
|
||||
case .failure(let databaseError):
|
||||
completion?(.failure(databaseError))
|
||||
}
|
||||
self.noteStatusesForArticleIDsDidChange(articleIDs)
|
||||
completion?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark articleIDs as read. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
||||
func markAsRead(_ articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
||||
mark(articleIDs: articleIDs, statusKey: .read, flag: true, completion: completion)
|
||||
/// Returns a set of new article statuses.
|
||||
func markAsRead(_ articleIDs: Set<String>, completion: ArticleIDsCompletionBlock? = nil) {
|
||||
markAndFetchNew(articleIDs: articleIDs, statusKey: .read, flag: true, completion: completion)
|
||||
}
|
||||
|
||||
/// Mark articleIDs as unread. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
||||
func markAsUnread(_ articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
||||
mark(articleIDs: articleIDs, statusKey: .read, flag: false, completion: completion)
|
||||
/// Returns a set of new article statuses.
|
||||
func markAsUnread(_ articleIDs: Set<String>, completion: ArticleIDsCompletionBlock? = nil) {
|
||||
markAndFetchNew(articleIDs: articleIDs, statusKey: .read, flag: false, completion: completion)
|
||||
}
|
||||
|
||||
/// Mark articleIDs as starred. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
||||
func markAsStarred(_ articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
||||
mark(articleIDs: articleIDs, statusKey: .starred, flag: true, completion: completion)
|
||||
/// Returns a set of new article statuses.
|
||||
func markAsStarred(_ articleIDs: Set<String>, completion: ArticleIDsCompletionBlock? = nil) {
|
||||
markAndFetchNew(articleIDs: articleIDs, statusKey: .starred, flag: true, completion: completion)
|
||||
}
|
||||
|
||||
/// Mark articleIDs as unstarred. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
||||
func markAsUnstarred(_ articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
||||
mark(articleIDs: articleIDs, statusKey: .starred, flag: false, completion: completion)
|
||||
/// Returns a set of new article statuses.
|
||||
func markAsUnstarred(_ articleIDs: Set<String>, completion: ArticleIDsCompletionBlock? = nil) {
|
||||
markAndFetchNew(articleIDs: articleIDs, statusKey: .starred, flag: false, completion: completion)
|
||||
}
|
||||
|
||||
/// Empty caches that can reasonably be emptied. Call when the app goes in the background, for instance.
|
||||
|
@ -888,7 +894,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
|
||||
public func debugDropConditionalGetInfo() {
|
||||
#if DEBUG
|
||||
flattenedWebFeeds().forEach{ $0.debugDropConditionalGetInfo() }
|
||||
flattenedWebFeeds().forEach{ $0.dropConditionalGetInfo() }
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -8,11 +8,13 @@
|
|||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
import SystemConfiguration
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
import RSCore
|
||||
import RSParser
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
|
@ -35,7 +37,13 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
private let accountZone: CloudKitAccountZone
|
||||
private let articlesZone: CloudKitArticlesZone
|
||||
|
||||
private let refresher = LocalAccountRefresher()
|
||||
weak var account: Account?
|
||||
|
||||
private lazy var refresher: LocalAccountRefresher = {
|
||||
let refresher = LocalAccountRefresher()
|
||||
refresher.delegate = self
|
||||
return refresher
|
||||
}()
|
||||
|
||||
let behaviors: AccountBehaviors = []
|
||||
let isOPMLImportInProgress = false
|
||||
|
@ -45,7 +53,7 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
var accountMetadata: AccountMetadata?
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
|
||||
init(dataFolder: String) {
|
||||
accountZone = CloudKitAccountZone(container: container)
|
||||
articlesZone = CloudKitArticlesZone(container: container)
|
||||
|
@ -77,6 +85,14 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
let reachability = SCNetworkReachabilityCreateWithName(nil, "apple.com")
|
||||
var flags = SCNetworkReachabilityFlags()
|
||||
guard SCNetworkReachabilityGetFlags(reachability!, &flags), flags.contains(.reachable) else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
refreshAll(for: account, downloadFeeds: true, completion: completion)
|
||||
}
|
||||
|
||||
|
@ -91,12 +107,12 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
let starredArticleIDs = syncStatuses.filter({ $0.key == .starred && $0.flag == true }).map({ $0.articleID })
|
||||
account.fetchArticlesAsync(.articleIDs(Set(starredArticleIDs))) { result in
|
||||
let articleIDs = syncStatuses.map({ $0.articleID })
|
||||
account.fetchArticlesAsync(.articleIDs(Set(articleIDs))) { result in
|
||||
|
||||
func processWithArticles(_ starredArticles: Set<Article>) {
|
||||
func processWithArticles(_ articles: Set<Article>) {
|
||||
|
||||
self.articlesZone.sendArticleStatus(syncStatuses, starredArticles: starredArticles) { result in
|
||||
self.articlesZone.sendArticleStatus(syncStatuses, articles: articles) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.database.deleteSelectedForProcessing(syncStatuses.map({ $0.articleID }) )
|
||||
|
@ -112,8 +128,8 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
switch result {
|
||||
case .success(let starredArticles):
|
||||
processWithArticles(starredArticles)
|
||||
case .success(let articles):
|
||||
processWithArticles(articles)
|
||||
case .failure(let databaseError):
|
||||
completion(.failure(databaseError))
|
||||
}
|
||||
|
@ -147,6 +163,11 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard refreshProgress.isComplete else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
var fileData: Data?
|
||||
|
||||
do {
|
||||
|
@ -464,8 +485,13 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress)
|
||||
articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone)
|
||||
articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account,
|
||||
database: database,
|
||||
articlesZone: articlesZone,
|
||||
refreshProgress: refreshProgress)
|
||||
|
||||
// Check to see if this is a new account and initialize anything we need
|
||||
if account.externalID == nil {
|
||||
|
@ -473,11 +499,7 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
switch result {
|
||||
case .success(let externalID):
|
||||
account.externalID = externalID
|
||||
self.refreshAll(for: account, downloadFeeds: false) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "Error while doing intial refresh: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
self.refreshAll(for: account, downloadFeeds: false) { _ in }
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Error adding account container: %@", error.localizedDescription)
|
||||
}
|
||||
|
@ -522,6 +544,12 @@ private extension CloudKitAccountDelegate {
|
|||
let intialWebFeedsCount = downloadFeeds ? account.flattenedWebFeeds().count : 0
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(3 + intialWebFeedsCount)
|
||||
|
||||
func fail(_ error: Error) {
|
||||
self.processAccountError(account, error)
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
accountZone.fetchChangesInZone() { result in
|
||||
BatchUpdate.shared.end()
|
||||
|
@ -550,31 +578,34 @@ private extension CloudKitAccountDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
self.refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) {
|
||||
self.refresher.refreshFeeds(webFeeds) {
|
||||
|
||||
account.metadata.lastArticleFetchEndTime = Date()
|
||||
self.refreshProgress.clear()
|
||||
completion(.success(()))
|
||||
|
||||
self.sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(error))
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(error))
|
||||
fail(error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(error))
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -589,3 +620,24 @@ private extension CloudKitAccountDelegate {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension CloudKitAccountDelegate: LocalAccountRefresherDelegate {
|
||||
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, didProcess newAndUpdatedArticles: NewAndUpdatedArticles) {
|
||||
if let newArticles = newAndUpdatedArticles.newArticles {
|
||||
let syncStatuses = newArticles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: .read, flag: false)
|
||||
}
|
||||
database.insertStatuses(syncStatuses)
|
||||
}
|
||||
}
|
||||
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: WebFeed) {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
func localAccountRefresherDidFinish(_ refresher: LocalAccountRefresher) {
|
||||
refreshProgress.clear()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ final class CloudKitArticlesZone: CloudKitZone {
|
|||
struct CloudKitArticleStatus {
|
||||
static let recordType = "ArticleStatus"
|
||||
struct Fields {
|
||||
static let webFeedExternalID = "webFeedExternalID"
|
||||
static let read = "read"
|
||||
static let starred = "starred"
|
||||
static let userDeleted = "userDeleted"
|
||||
|
@ -81,8 +82,11 @@ final class CloudKitArticlesZone: CloudKitZone {
|
|||
}
|
||||
}
|
||||
|
||||
func sendArticleStatus(_ syncStatuses: [SyncStatus], starredArticles: Set<Article>, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
var records = makeStatusRecords(syncStatuses)
|
||||
func sendArticleStatus(_ syncStatuses: [SyncStatus], articles: Set<Article>, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
|
||||
var records = makeStatusRecords(syncStatuses, articles)
|
||||
|
||||
let starredArticles = articles.filter({ $0.status.starred == true })
|
||||
makeArticleRecordsIfNecessary(starredArticles) { result in
|
||||
switch result {
|
||||
case .success(let articleRecords):
|
||||
|
@ -92,11 +96,11 @@ final class CloudKitArticlesZone: CloudKitZone {
|
|||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion)
|
||||
self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: articles, completion: completion)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion)
|
||||
self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: articles, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -106,7 +110,7 @@ final class CloudKitArticlesZone: CloudKitZone {
|
|||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.sendArticleStatus(syncStatuses, starredArticles: starredArticles, completion: completion)
|
||||
self.sendArticleStatus(syncStatuses, articles: starredArticles, completion: completion)
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
@ -120,7 +124,13 @@ final class CloudKitArticlesZone: CloudKitZone {
|
|||
|
||||
private extension CloudKitArticlesZone {
|
||||
|
||||
func makeStatusRecords(_ syncStatuses: [SyncStatus]) -> [CKRecord] {
|
||||
func makeStatusRecords(_ syncStatuses: [SyncStatus], _ articles: Set<Article>) -> [CKRecord] {
|
||||
|
||||
var articleDict = [String: Article]()
|
||||
for article in articles {
|
||||
articleDict[article.articleID] = article
|
||||
}
|
||||
|
||||
var records = [String: CKRecord]()
|
||||
|
||||
for status in syncStatuses {
|
||||
|
@ -132,6 +142,10 @@ private extension CloudKitArticlesZone {
|
|||
records[status.articleID] = record
|
||||
}
|
||||
|
||||
if let webFeedExternalID = articleDict[status.articleID]?.webFeed?.externalID {
|
||||
record![CloudKitArticleStatus.Fields.webFeedExternalID] = webFeedExternalID
|
||||
}
|
||||
|
||||
switch status.key {
|
||||
case .read:
|
||||
record![CloudKitArticleStatus.Fields.read] = status.flag ? "1" : "0"
|
||||
|
|
|
@ -9,8 +9,11 @@
|
|||
import Foundation
|
||||
import os.log
|
||||
import RSParser
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
import SyncDatabase
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
|
||||
class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate {
|
||||
|
||||
|
@ -19,11 +22,19 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate {
|
|||
weak var account: Account?
|
||||
var database: SyncDatabase
|
||||
weak var articlesZone: CloudKitArticlesZone?
|
||||
weak var refreshProgress: DownloadProgress?
|
||||
|
||||
private lazy var refresher: LocalAccountRefresher = {
|
||||
let refresher = LocalAccountRefresher()
|
||||
refresher.delegate = self
|
||||
return refresher
|
||||
}()
|
||||
|
||||
init(account: Account, database: SyncDatabase, articlesZone: CloudKitArticlesZone) {
|
||||
init(account: Account, database: SyncDatabase, articlesZone: CloudKitArticlesZone, refreshProgress: DownloadProgress?) {
|
||||
self.account = account
|
||||
self.database = database
|
||||
self.articlesZone = articlesZone
|
||||
self.refreshProgress = refreshProgress
|
||||
}
|
||||
|
||||
func cloudKitDidChange(record: CKRecord) {
|
||||
|
@ -82,8 +93,40 @@ private extension CloudKitArticlesZoneDelegate {
|
|||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
account?.markAsUnread(updateableUnreadArticleIDs) { _ in
|
||||
group.leave()
|
||||
account?.markAsUnread(updateableUnreadArticleIDs) { result in
|
||||
switch result {
|
||||
case .success(let newArticleStatusIDs):
|
||||
|
||||
if newArticleStatusIDs.isEmpty {
|
||||
group.leave()
|
||||
} else {
|
||||
|
||||
var webFeedExternalIDDict = [String: String]()
|
||||
for record in records {
|
||||
if let webFeedExternalID = record[CloudKitArticlesZone.CloudKitArticleStatus.Fields.webFeedExternalID] as? String {
|
||||
webFeedExternalIDDict[record.externalID] = webFeedExternalID
|
||||
}
|
||||
}
|
||||
|
||||
var webFeeds = Set<WebFeed>()
|
||||
for newArticleStatusID in newArticleStatusIDs {
|
||||
if let webFeedExternalID = webFeedExternalIDDict[newArticleStatusID],
|
||||
let webFeed = self.account?.existingWebFeed(withExternalID: webFeedExternalID) {
|
||||
webFeeds.insert(webFeed)
|
||||
}
|
||||
}
|
||||
|
||||
webFeeds.forEach { $0.dropConditionalGetInfo() }
|
||||
self.refreshProgress?.addToNumberOfTasksAndRemaining(webFeeds.count)
|
||||
self.refresher.refreshFeeds(webFeeds) {
|
||||
group.leave()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
case .failure:
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.enter()
|
||||
|
@ -104,9 +147,9 @@ private extension CloudKitArticlesZoneDelegate {
|
|||
for receivedStarredArticle in receivedStarredArticles {
|
||||
if let parsedItem = makeParsedItem(receivedStarredArticle) {
|
||||
group.enter()
|
||||
self.account?.update(parsedItem.feedURL, with: Set([parsedItem])) { databaseError in
|
||||
self.account?.update(parsedItem.feedURL, with: Set([parsedItem])) { result in
|
||||
group.leave()
|
||||
if let databaseError = databaseError {
|
||||
if case .failure(let databaseError) = result {
|
||||
os_log(.error, log: self.log, "Error occurred while storing starred items: %@", databaseError.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
@ -119,7 +162,6 @@ private extension CloudKitArticlesZoneDelegate {
|
|||
|
||||
}
|
||||
|
||||
|
||||
func makeParsedItem(_ articleRecord: CKRecord) -> ParsedItem? {
|
||||
var parsedAuthors = Set<ParsedAuthor>()
|
||||
|
||||
|
@ -160,3 +202,17 @@ private extension CloudKitArticlesZoneDelegate {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension CloudKitArticlesZoneDelegate: LocalAccountRefresherDelegate {
|
||||
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, didProcess newAndUpdatedArticles: NewAndUpdatedArticles) {
|
||||
}
|
||||
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: WebFeed) {
|
||||
refreshProgress?.completeTask()
|
||||
}
|
||||
|
||||
func localAccountRefresherDidFinish(_ refresher: LocalAccountRefresher) {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import RSCore
|
|||
class FeedFinder {
|
||||
|
||||
static func find(url: URL, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
|
||||
downloadUsingCache(url) { (data, response, error) in
|
||||
downloadAddingToCache(url) { (data, response, error) in
|
||||
if response?.forcedStatusCode == 404 {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
|
|
|
@ -85,12 +85,16 @@ public enum FeedIdentifier: CustomStringConvertible, Hashable {
|
|||
public func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .smartFeed(let id):
|
||||
hasher.combine("smartFeed")
|
||||
hasher.combine(id)
|
||||
case .script(let id):
|
||||
hasher.combine("smartFeed")
|
||||
hasher.combine(id)
|
||||
case .webFeed(_, let webFeedID):
|
||||
hasher.combine("webFeed")
|
||||
hasher.combine(webFeedID)
|
||||
case .folder(_, let folderName):
|
||||
hasher.combine("folder")
|
||||
hasher.combine(folderName)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -125,15 +125,19 @@ final class FeedlyIngestStarredArticleIdsOperation: FeedlyOperation {
|
|||
let results = StarredStatusResults()
|
||||
|
||||
group.enter()
|
||||
account.markAsStarred(remoteStarredArticleIDs) { error in
|
||||
results.markAsStarredError = error
|
||||
account.markAsStarred(remoteStarredArticleIDs) { result in
|
||||
if case .failure(let error) = result {
|
||||
results.markAsStarredError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
let deltaUnstarredArticleIDs = localStarredArticleIDs.subtracting(remoteStarredArticleIDs)
|
||||
group.enter()
|
||||
account.markAsUnstarred(deltaUnstarredArticleIDs) { error in
|
||||
results.markAsUnstarredError = error
|
||||
account.markAsUnstarred(deltaUnstarredArticleIDs) { result in
|
||||
if case .failure(let error) = result {
|
||||
results.markAsUnstarredError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
|
|
|
@ -125,15 +125,19 @@ final class FeedlyIngestUnreadArticleIdsOperation: FeedlyOperation {
|
|||
let results = ReadStatusResults()
|
||||
|
||||
group.enter()
|
||||
account.markAsUnread(remoteUnreadArticleIDs) { error in
|
||||
results.markAsUnreadError = error
|
||||
account.markAsUnread(remoteUnreadArticleIDs) { result in
|
||||
if case .failure(let error) = result {
|
||||
results.markAsUnreadError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
let articleIDsToMarkRead = localUnreadArticleIDs.subtracting(remoteUnreadArticleIDs)
|
||||
group.enter()
|
||||
account.markAsRead(articleIDsToMarkRead) { error in
|
||||
results.markAsReadError = error
|
||||
account.markAsRead(articleIDsToMarkRead) { result in
|
||||
if case .failure(let error) = result {
|
||||
results.markAsReadError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import Foundation
|
|||
import RSCore
|
||||
import RSParser
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
|
@ -19,7 +20,13 @@ public enum LocalAccountDelegateError: String, Error {
|
|||
|
||||
final class LocalAccountDelegate: AccountDelegate {
|
||||
|
||||
private let refresher = LocalAccountRefresher()
|
||||
weak var account: Account?
|
||||
|
||||
private lazy var refresher: LocalAccountRefresher? = {
|
||||
let refresher = LocalAccountRefresher()
|
||||
refresher.delegate = self
|
||||
return refresher
|
||||
}()
|
||||
|
||||
let behaviors: AccountBehaviors = []
|
||||
let isOPMLImportInProgress = false
|
||||
|
@ -29,19 +36,23 @@ final class LocalAccountDelegate: AccountDelegate {
|
|||
var accountMetadata: AccountMetadata?
|
||||
|
||||
let refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
var refreshAllCompletion: ((Result<Void, Error>) -> Void)? = nil
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard refreshAllCompletion == nil else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
refreshAllCompletion = completion
|
||||
|
||||
let webFeeds = account.flattenedWebFeeds()
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count)
|
||||
refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) {
|
||||
self.refreshProgress.clear()
|
||||
account.metadata.lastArticleFetchEndTime = Date()
|
||||
completion(.success(()))
|
||||
}
|
||||
refresher?.refreshFeeds(webFeeds)
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
|
@ -197,6 +208,7 @@ final class LocalAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
self.account = account
|
||||
}
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
|
@ -209,7 +221,7 @@ final class LocalAccountDelegate: AccountDelegate {
|
|||
// MARK: Suspend and Resume (for iOS)
|
||||
|
||||
func suspendNetwork() {
|
||||
refresher.suspend()
|
||||
refresher?.suspend()
|
||||
}
|
||||
|
||||
func suspendDatabase() {
|
||||
|
@ -217,6 +229,24 @@ final class LocalAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
func resume() {
|
||||
refresher.resume()
|
||||
refresher?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalAccountDelegate: LocalAccountRefresherDelegate {
|
||||
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, didProcess newAndUpdatedArticles: NewAndUpdatedArticles) {
|
||||
}
|
||||
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: WebFeed) {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
func localAccountRefresherDidFinish(_ refresher: LocalAccountRefresher) {
|
||||
self.refreshProgress.clear()
|
||||
account?.metadata.lastArticleFetchEndTime = Date()
|
||||
refreshAllCompletion?(.success(()))
|
||||
refreshAllCompletion = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -11,20 +11,28 @@ import RSCore
|
|||
import RSParser
|
||||
import RSWeb
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
|
||||
protocol LocalAccountRefresherDelegate {
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, didProcess: NewAndUpdatedArticles)
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: WebFeed)
|
||||
func localAccountRefresherDidFinish(_ refresher: LocalAccountRefresher)
|
||||
}
|
||||
|
||||
final class LocalAccountRefresher {
|
||||
|
||||
private var feedCompletionBlock: ((WebFeed) -> Void)?
|
||||
private var completion: (() -> Void)?
|
||||
private var completions = [() -> Void]()
|
||||
private var isSuspended = false
|
||||
var delegate: LocalAccountRefresherDelegate?
|
||||
|
||||
private lazy var downloadSession: DownloadSession = {
|
||||
return DownloadSession(delegate: self)
|
||||
}()
|
||||
|
||||
public func refreshFeeds(_ feeds: Set<WebFeed>, feedCompletionBlock: @escaping (WebFeed) -> Void, completion: @escaping () -> Void) {
|
||||
self.feedCompletionBlock = feedCompletionBlock
|
||||
self.completion = completion
|
||||
public func refreshFeeds(_ feeds: Set<WebFeed>, completion: (() -> Void)? = nil) {
|
||||
if let completion = completion {
|
||||
completions.append(completion)
|
||||
}
|
||||
downloadSession.downloadObjects(feeds as NSSet)
|
||||
}
|
||||
|
||||
|
@ -64,21 +72,21 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
|
||||
guard !data.isEmpty, !isSuspended else {
|
||||
completion()
|
||||
feedCompletionBlock?(feed)
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
return
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
print("Error downloading \(feed.url) - \(error)")
|
||||
completion()
|
||||
feedCompletionBlock?(feed)
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
return
|
||||
}
|
||||
|
||||
let dataHash = data.md5String
|
||||
if dataHash == feed.contentHash {
|
||||
completion()
|
||||
feedCompletionBlock?(feed)
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -87,20 +95,20 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
|
||||
guard let account = feed.account, let parsedFeed = parsedFeed, error == nil else {
|
||||
completion()
|
||||
self.feedCompletionBlock?(feed)
|
||||
self.delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
return
|
||||
}
|
||||
|
||||
account.update(feed, with: parsedFeed) { error in
|
||||
if error == nil {
|
||||
account.update(feed, with: parsedFeed) { result in
|
||||
if case .success(let newAndUpdatedArticles) = result {
|
||||
self.delegate?.localAccountRefresher(self, didProcess: newAndUpdatedArticles)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
feed.conditionalGetInfo = HTTPConditionalGetInfo(urlResponse: httpResponse)
|
||||
}
|
||||
|
||||
feed.contentHash = dataHash
|
||||
}
|
||||
completion()
|
||||
self.feedCompletionBlock?(feed)
|
||||
self.delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -109,7 +117,7 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, representedObject: AnyObject) -> Bool {
|
||||
let feed = representedObject as! WebFeed
|
||||
guard !isSuspended else {
|
||||
feedCompletionBlock?(feed)
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -118,7 +126,7 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
}
|
||||
|
||||
if data.isDefinitelyNotFeed() {
|
||||
feedCompletionBlock?(feed)
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -127,7 +135,7 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
if FeedParser.mightBeAbleToParseBasedOnPartialData(parserData) {
|
||||
return true
|
||||
} else {
|
||||
feedCompletionBlock?(feed)
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -137,17 +145,23 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
|
||||
func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse response: URLResponse, representedObject: AnyObject) {
|
||||
let feed = representedObject as! WebFeed
|
||||
feedCompletionBlock?(feed)
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
}
|
||||
|
||||
func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) {
|
||||
let feed = representedObject as! WebFeed
|
||||
feedCompletionBlock?(feed)
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
}
|
||||
|
||||
func downloadSession(_ downloadSession: DownloadSession, didDiscardDuplicateRepresentedObject representedObject: AnyObject) {
|
||||
let feed = representedObject as! WebFeed
|
||||
delegate?.localAccountRefresher(self, requestCompletedFor: feed)
|
||||
}
|
||||
|
||||
func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) {
|
||||
completion?()
|
||||
completion = nil
|
||||
completions.forEach({ $0() })
|
||||
completions = [() -> Void]()
|
||||
delegate?.localAccountRefresherDidFinish(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -221,9 +221,9 @@ public final class WebFeed: Feed, Renamable, Hashable {
|
|||
self.metadata = metadata
|
||||
}
|
||||
|
||||
// MARK: - Debug
|
||||
|
||||
public func debugDropConditionalGetInfo() {
|
||||
// MARK: - API
|
||||
|
||||
public func dropConditionalGetInfo() {
|
||||
conditionalGetInfo = nil
|
||||
contentHash = nil
|
||||
}
|
||||
|
|
|
@ -27,6 +27,16 @@ public typealias SingleUnreadCountCompletionBlock = (SingleUnreadCountResult) ->
|
|||
public struct NewAndUpdatedArticles {
|
||||
public let newArticles: Set<Article>?
|
||||
public let updatedArticles: Set<Article>?
|
||||
|
||||
public init() {
|
||||
self.newArticles = Set<Article>()
|
||||
self.updatedArticles = Set<Article>()
|
||||
}
|
||||
|
||||
public init(newArticles: Set<Article>?, updatedArticles: Set<Article>?) {
|
||||
self.newArticles = newArticles
|
||||
self.updatedArticles = updatedArticles
|
||||
}
|
||||
}
|
||||
|
||||
public typealias UpdateArticlesResult = Result<NewAndUpdatedArticles, DatabaseError>
|
||||
|
@ -222,8 +232,8 @@ public final class ArticlesDatabase {
|
|||
return try articlesTable.mark(articles, statusKey, flag)
|
||||
}
|
||||
|
||||
public func mark(articleIDs: Set<String>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping DatabaseCompletionBlock) {
|
||||
articlesTable.mark(articleIDs, statusKey, flag, completion)
|
||||
public func markAndFetchNew(articleIDs: Set<String>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping ArticleIDsCompletionBlock) {
|
||||
articlesTable.markAndFetchNew(articleIDs, statusKey, flag, completion)
|
||||
}
|
||||
|
||||
/// Create statuses for specified articleIDs. For existing statuses, don’t do anything.
|
||||
|
|
|
@ -194,7 +194,7 @@ final class ArticlesTable: DatabaseTable {
|
|||
func makeDatabaseCalls(_ database: FMDatabase) {
|
||||
let articleIDs = parsedItems.articleIDs()
|
||||
|
||||
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1
|
||||
let (statusesDictionary, _) = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1
|
||||
assert(statusesDictionary.count == articleIDs.count)
|
||||
|
||||
let allIncomingArticles = Article.articlesWithParsedItems(parsedItems, webFeedID, self.accountID, statusesDictionary) //2
|
||||
|
@ -266,7 +266,7 @@ final class ArticlesTable: DatabaseTable {
|
|||
articleIDs.formUnion(parsedItems.articleIDs())
|
||||
}
|
||||
|
||||
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1
|
||||
let (statusesDictionary, _) = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1
|
||||
assert(statusesDictionary.count == articleIDs.count)
|
||||
|
||||
let allIncomingArticles = Article.articlesWithWebFeedIDsAndItems(webFeedIDsAndItems, self.accountID, statusesDictionary) //2
|
||||
|
@ -418,17 +418,17 @@ final class ArticlesTable: DatabaseTable {
|
|||
return statuses
|
||||
}
|
||||
|
||||
func mark(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ completion: @escaping DatabaseCompletionBlock) {
|
||||
func markAndFetchNew(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ completion: @escaping ArticleIDsCompletionBlock) {
|
||||
queue.runInTransaction { databaseResult in
|
||||
switch databaseResult {
|
||||
case .success(let database):
|
||||
self.statusesTable.mark(articleIDs, statusKey, flag, database)
|
||||
let newStatusIDs = self.statusesTable.markAndFetchNew(articleIDs, statusKey, flag, database)
|
||||
DispatchQueue.main.async {
|
||||
completion(nil)
|
||||
completion(.success(newStatusIDs))
|
||||
}
|
||||
case .failure(let databaseError):
|
||||
DispatchQueue.main.async {
|
||||
completion(databaseError)
|
||||
completion(.failure(databaseError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,11 +27,11 @@ final class StatusesTable: DatabaseTable {
|
|||
|
||||
// MARK: - Creating/Updating
|
||||
|
||||
func ensureStatusesForArticleIDs(_ articleIDs: Set<String>, _ read: Bool, _ database: FMDatabase) -> [String: ArticleStatus] {
|
||||
func ensureStatusesForArticleIDs(_ articleIDs: Set<String>, _ read: Bool, _ database: FMDatabase) -> ([String: ArticleStatus], Set<String>) {
|
||||
// Check cache.
|
||||
let articleIDsMissingCachedStatus = articleIDsWithNoCachedStatus(articleIDs)
|
||||
if articleIDsMissingCachedStatus.isEmpty {
|
||||
return statusesDictionary(articleIDs)
|
||||
return (statusesDictionary(articleIDs), Set<String>())
|
||||
}
|
||||
|
||||
// Check database.
|
||||
|
@ -43,7 +43,7 @@ final class StatusesTable: DatabaseTable {
|
|||
self.createAndSaveStatusesForArticleIDs(articleIDsNeedingStatus, read, database)
|
||||
}
|
||||
|
||||
return statusesDictionary(articleIDs)
|
||||
return (statusesDictionary(articleIDs), articleIDsNeedingStatus)
|
||||
}
|
||||
|
||||
func existingStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) -> [String: ArticleStatus] {
|
||||
|
@ -85,10 +85,11 @@ final class StatusesTable: DatabaseTable {
|
|||
return updatedStatuses
|
||||
}
|
||||
|
||||
func mark(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) {
|
||||
let statusesDictionary = ensureStatusesForArticleIDs(articleIDs, flag, database)
|
||||
func markAndFetchNew(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) -> Set<String> {
|
||||
let (statusesDictionary, newStatusIDs) = ensureStatusesForArticleIDs(articleIDs, flag, database)
|
||||
let statuses = Set(statusesDictionary.values)
|
||||
mark(statuses, statusKey, flag, database)
|
||||
return newStatusIDs
|
||||
}
|
||||
|
||||
// MARK: - Fetching
|
||||
|
|
|
@ -6,19 +6,19 @@
|
|||
<body>
|
||||
<outline text="inessential" title="inessential" type="rss" version="RSS" htmlUrl="https://inessential.com/" xmlUrl="https://inessential.com/feed.json"/>
|
||||
<outline text="Accidentally in Code" title="Accidentally in Code" type="rss" version="RSS" htmlUrl="https://cate.blog/" xmlUrl="https://cate.blog/feed/"/>
|
||||
<outline text="Becky Hansmeyer" title="Becky Hansmeyer" type="rss" version="RSS" htmlUrl="http://becky.coffee/" xmlUrl="https://beckyhansmeyer.com/feed/"/>
|
||||
<outline text="Becky Hansmeyer" title="Becky Hansmeyer" type="rss" version="RSS" htmlUrl="https://beckyhansmeyer.com" xmlUrl="https://beckyhansmeyer.com/feed/"/>
|
||||
<outline text="The Shape of Everything" title="The Shape of Everything" type="rss" version="RSS" htmlUrl="https://shapeof.com/" xmlUrl="https://shapeof.com/feed.json"/>
|
||||
<outline text="Daring Fireball" title="Daring Fireball" type="rss" version="RSS" htmlUrl="https://daringfireball.net/" xmlUrl="https://daringfireball.net/feeds/json"/>
|
||||
<outline text="Manton Reece" title="Manton Reece" type="rss" version="RSS" htmlUrl="https://manton.org/" xmlUrl="https://www.manton.org/feed/json"/>
|
||||
<outline text="Julia Evans" title="Julia Evans" type="rss" version="RSS" htmlUrl="https://jvns.ca/" xmlUrl="https://jvns.ca/atom.xml"/>
|
||||
<outline text="Jason Kottke" title="Jason Kottke" type="rss" version="RSS" htmlUrl="https://kottke.org/" xmlUrl="http://feeds.kottke.org/json"/>
|
||||
<outline text="Six Colors" title="Six Colors" type="rss" version="RSS" htmlUrl="https://sixcolors.com/" xmlUrl="https://sixcolors.com/feed.json"/>
|
||||
<outline text="Loop Insight" title="Loop Insight" type="rss" version="RSS" htmlUrl="http://www.loopinsight.com/" xmlUrl="http://www.loopinsight.com/feed/"/>
|
||||
<outline text="Loop Insight" title="Loop Insight" type="rss" version="RSS" htmlUrl="https://www.loopinsight.com/" xmlUrl="https://www.loopinsight.com/feed/"/>
|
||||
<outline text="NetNewsWire Blog" title="NetNewsWire Blog" type="rss" version="RSS" htmlUrl="https://nnw.ranchero.com/" xmlUrl="https://nnw.ranchero.com/feed.json"/>
|
||||
<outline text="Erica Sadun" title="Erica Sadun" type="rss" version="RSS" htmlUrl="https://ericasadun.com/" xmlUrl="https://ericasadun.com/feed/"/>
|
||||
<outline text="One Foot Tsunami" title="One Foot Tsunami" type="rss" version="RSS" htmlUrl="https://onefoottsunami.com/" xmlUrl="https://onefoottsunami.com/feed/json/"/>
|
||||
<outline text="The Omni Blog" title="The Omni Blog" type="rss" version="RSS" htmlUrl="https://www.omnigroup.com/blog/" xmlUrl="https://www.omnigroup.com/blog/rss/"/>
|
||||
<outline text="Rose Orchard" title="Rose Orchard" type="rss" version="RSS" htmlUrl="https://rosemaryorchard.com/" xmlUrl="https://rosemaryorchard.com/category/blog/feed"/>
|
||||
<outline text="Craig Hockenberry" title="Craig Hockenberry" type="rss" version="RSS" htmlUrl="https://furbo.org/" xmlUrl="https://furbo.org/feed/json"/>
|
||||
<outline text="Rose Orchard" title="Rose Orchard" type="rss" version="RSS" htmlUrl="https://rosemaryorchard.com/" xmlUrl="https://rosemaryorchard.com/blog/feed/"/>
|
||||
<outline text="Michael Tsai" title="Michael Tsai" type="rss" version="RSS" htmlUrl="https://mjtsai.com/blog/" xmlUrl="https://mjtsai.com/blog/feed/"/>
|
||||
</body>
|
||||
</opml>
|
||||
|
|
|
@ -191,6 +191,7 @@ open class ImageScrollView: UIScrollView {
|
|||
zoomView!.addGestureRecognizer(upSwipeGesture)
|
||||
|
||||
configureImageForSize(image.size)
|
||||
adjustFrameToCenter()
|
||||
}
|
||||
|
||||
private func configureImageForSize(_ size: CGSize) {
|
||||
|
|
|
@ -36,7 +36,7 @@ class ImageViewer {
|
|||
var canvas = document.createElement("canvas");
|
||||
canvas.width = this.img.naturalWidth;
|
||||
canvas.height = this.img.naturalHeight;
|
||||
canvas.getContext("2d").drawImage(this.img, 0, 0);
|
||||
canvas.getContext("2d").drawImage(this.img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const rect = this.img.getBoundingClientRect();
|
||||
const message = {
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 88d634f5fd42aab203b6e53c7b551a92b03ffc97
|
||||
Subproject commit c524ce9145dfe093500325b1c758ea83f82cc090
|
Loading…
Reference in New Issue