Merge branch 'master' into feature/feed-wrangler

# Conflicts:
#	Frameworks/Account/Account.xcodeproj/project.pbxproj
This commit is contained in:
Jonathan Bennett 2019-10-16 05:47:53 -04:00
commit 86caa869fc
236 changed files with 5955 additions and 1466 deletions

View File

@ -1,58 +0,0 @@
# iOS CircleCI 2.0 configuration file
#
version: 2
jobs:
build:
# Specify the Xcode version to use
macos:
xcode: "10.2.1"
# https://circleci.com/docs/2.0/configuration-reference/
# Mac/IOS specific examples and docs under the following links:
# https://circleci.com/docs/2.0/hello-world-macos/
steps:
- checkout
- run: git submodule sync
- run: git submodule update --init
# Commands will execute in macOS container
# with Xcode 10.2.1 installed
- run: xcodebuild -version
#- run:
# name: get xcodebuild build options
# command: xcodebuild -help
- run:
name: get xcodebuild build settings
command: xcodebuild -showBuildSettings
- run:
name: force wipe of any pre-existing derived data in CI
command: rm -rf /Users/distiller/Library/Developer/Xcode/DerivedData/NetNewsWire-*
# Build the app and run tests
- run:
name: Build Mac
command: xcodebuild -workspace NetNewsWire.xcworkspace -scheme NetNewsWire -configuration Debug -showBuildTimingSummary
# NOTE(heckj):
# the -configuration Release build invokes a shell script specifically
# codesigning the Sparkle pieces with the developer 'Brent Simmons',
# so we don't try and invoke that in CI
#
# the stuff below is from example that was using fastlane
# (and we're not using that...) so it's placeholder tidbits
# to clue me in to where I can get things for test log output
# for the CircleCI UI exposure...
# Collect XML test results data to show in the UI,
# and save the same XML files under test-results folder
# in the Artifacts tab
#- store_test_results:
# path: test_output/report.xml
#- store_artifacts:
# path: /tmp/test-results
# destination: scan-test-results
#- store_artifacts:
# path: ~/Library/Logs/scan
# destination: scan-logs

45
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: CI
on: [push]
jobs:
build:
runs-on: macOS-latest
strategy:
matrix:
run-config:
- { scheme: 'NetNewsWire', destination: 'platform=macOS'}
- { scheme: 'NetNewsWire-iOS', destination: 'platform=iOS Simulator,OS=13.0,name=iPhone 11' }
steps:
- name: Checkout Project
uses: actions/checkout@v1
with:
submodules: recursive
- name: Switch to Xcode 11
run: sudo xcode-select -s /Applications/Xcode_11.app
- name: Show Build Version
run: xcodebuild -version
- name: Show Build Settings
run: xcodebuild -showBuildSettings
- name: Show Build SDK
run: xcodebuild -showsdks
- name: Show Available Destinations
env:
scheme: ${{ matrix.run-config['scheme'] }}
run: xcodebuild -scheme ${scheme} -showdestinations
- name: Run Build
env:
ENCRYPTION_SECRET: ${{ secrets.ENCRYPTION_SECRET }}
KEY_SECRET: ${{ secrets.KEY_SECRET }}
SCHEME: ${{ matrix.run-config['scheme'] }}
DESTINATION: ${{ matrix.run-config['destination'] }}
run: buildscripts/ci-build.sh

View File

@ -6,6 +6,32 @@
<description>Most recent NetNewsWire changes with links to updates.</description>
<language>en</language>
<item>
<title>NetNewsWire 5.0.3b1</title>
<description><![CDATA[
<p>Performance enhancement: fetching articles from the database is faster, and sometimes much faster.</p>
<p>Performance enhancement: syncing could block the main thread more than it should. We moved JSON decoding to a background thread, which fixes this. This is particularly noticeable during an initial sync.</p>
<p>Keyboard shortcuts: the 's' key toggles starred status. The 'r' and 'u' keys now both toggle read status (instead of setting read and unread status, respectively).</p>
<p>Articles view: articles where the feed icon is quite large would be slow to render — now they render as fast as other articles.</p>
<p>Articles view: a bug where keyboard shortcuts wouldnt work after giving the articles view focus has been fixed.</p>
<p>Articles view: YouTube videos could end up small. Fixed.</p>
<p>Articles view: fixed a bug scaling images to fit in the view.</p>
<p>Feedbin syncing: fixed a bug where renaming a tag on the Feedbin site would result in feeds in NNW ending up at the top level.</p>
<p>Help menu: fixed the expired Slack link.</p>
]]></description>
<pubDate>Mon, 30 Sep 2019 13:56:00 -0700</pubDate>
<enclosure url="https://github.com/brentsimmons/NetNewsWire/releases/download/mac-5.0.3b1/NetNewsWire5.0.3b1.zip" sparkle:version="2616" sparkle:shortVersionString="5.0.3b1" length="5106894" type="application/zip" />
<sparkle:minimumSystemVersion>10.14.4</sparkle:minimumSystemVersion>
</item>
<item>
<title>NetNewsWire 5.0.2</title>
<description><![CDATA[

View File

@ -372,10 +372,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
public func saveIfNecessary() {
metadataFile.saveIfNecessary()
feedMetadataFile.saveIfNecessary()
opmlFile.saveIfNecessary()
public func save() {
metadataFile.save()
feedMetadataFile.save()
opmlFile.save()
}
func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
@ -625,24 +625,34 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
func update(_ feed: Feed, with parsedFeed: ParsedFeed, _ completion: @escaping (() -> Void)) {
// Used only by an On My Mac account.
feed.takeSettings(from: parsedFeed)
update(feed, parsedItems: parsedFeed.items, completion)
let feedIDsAndItems = [feed.feedID: parsedFeed.items]
update(feedIDsAndItems: feedIDsAndItems, defaultRead: false, completion: completion)
}
func update(_ feed: Feed, parsedItems: Set<ParsedItem>, defaultRead: Bool = false, _ completion: @escaping (() -> Void)) {
database.update(feedID: feed.feedID, parsedItems: parsedItems, defaultRead: defaultRead) { (newArticles, updatedArticles) in
func update(feedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping (() -> Void)) {
assert(Thread.isMainThread)
guard !feedIDsAndItems.isEmpty else {
completion()
return
}
database.update(feedIDsAndItems: feedIDsAndItems, defaultRead: defaultRead) { (newArticles, updatedArticles) in
var userInfo = [String: Any]()
let feeds = Set(feedIDsAndItems.compactMap { (key, _) -> Feed? in
self.existingFeed(withFeedID: key)
})
if let newArticles = newArticles, !newArticles.isEmpty {
self.updateUnreadCounts(for: Set([feed]))
self.updateUnreadCounts(for: feeds)
userInfo[UserInfoKey.newArticles] = newArticles
}
if let updatedArticles = updatedArticles, !updatedArticles.isEmpty {
userInfo[UserInfoKey.updatedArticles] = updatedArticles
}
userInfo[UserInfoKey.feeds] = Set([feed])
userInfo[UserInfoKey.feeds] = feeds
completion()
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
}
}
@ -667,6 +677,11 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
}
/// Empty caches that can reasonably be emptied. Call when the app goes in the background, for instance.
func emptyCaches() {
database.emptyCaches()
}
// MARK: - Container
public func flattenedFeeds() -> Set<Feed> {
@ -683,6 +698,15 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
postChildrenDidChangeNotification()
}
public func removeFeeds(_ feeds: Set<Feed>) {
guard !feeds.isEmpty else {
return
}
topLevelFeeds.subtract(feeds)
structureDidChange()
postChildrenDidChangeNotification()
}
public func addFeed(_ feed: Feed) {
topLevelFeeds.insert(feed)
structureDidChange()

View File

@ -7,9 +7,9 @@
objects = {
/* Begin PBXBuildFile section */
3B29BC6E233EA83C002A346D /* FeedWranglerAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B29BC6C233EA83C002A346D /* FeedWranglerAPICaller.swift */; };
3B29BC6F233EA83C002A346D /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B29BC6D233EA83C002A346D /* FeedWranglerAccountDelegate.swift */; };
3B90F51F233F0EF800D481CC /* FeedWranglerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B90F51E233F0EF800D481CC /* FeedWranglerConfig.swift */; };
3BF610C723571CD4000EF978 /* FeedWranglerAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */; };
3BF610C823571CD4000EF978 /* FeedWranglerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */; };
3BF610C923571CD4000EF978 /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */; };
5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; };
5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; };
5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; };
@ -41,10 +41,13 @@
51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; };
51D5875C227F630B00900287 /* tags_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58759227F630B00900287 /* tags_initial.json */; };
51D5875E227F643C00900287 /* AccountFolderSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D5875D227F643C00900287 /* AccountFolderSyncTest.swift */; };
51E148EC234B8FFC0004F7A5 /* SyncDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */; };
51E148ED234B8FFC0004F7A5 /* SyncDatabase.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
51E3EB41229AF61B00645299 /* AccountError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB40229AF61B00645299 /* AccountError.swift */; };
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; };
51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; };
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; };
51FE1008234635A20056195D /* DeepLinkProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FE1007234635A20056195D /* DeepLinkProvider.swift */; };
552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */; };
552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032EE229D5D5A009559E0 /* ReaderAPISubscription.swift */; };
552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F0229D5D5A009559E0 /* ReaderAPITag.swift */; };
@ -78,16 +81,35 @@
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */; };
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */; };
9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */; };
9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773D22345700E0056A5A8 /* FeedlyLink.swift */; };
9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773D4234570E30056A5A8 /* FeedlyEntryParser.swift */; };
9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773D6234575AB0056A5A8 /* FeedlyTag.swift */; };
9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773D823458D590056A5A8 /* FeedlyResourceId.swift */; };
9E1773DB234593CF0056A5A8 /* FeedlyResourceIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773DA234593CF0056A5A8 /* FeedlyResourceIdTests.swift */; };
9E1AF38B2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1AF38A2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift */; };
9E1D154D233370D800F4944C /* FeedlySyncStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */; };
9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */; };
9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */; };
9E1D15532334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15522334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift */; };
9E1D15532334304B00F4944C /* FeedlyGetStreamOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15522334304B00F4944C /* FeedlyGetStreamOperation.swift */; };
9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D1554233431A600F4944C /* FeedlyOperation.swift */; };
9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */; };
9E1D155923343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D155823343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift */; };
9E1D155B2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */; };
9E1D155D233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */; };
9E510D6E234F16A8002E6F1A /* FeedlyAddFeedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E510D6D234F16A8002E6F1A /* FeedlyAddFeedRequest.swift */; };
9E713653233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift */; };
9E7299D723505E9600DAEFB7 /* FeedlyAddFeedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7299D623505E9600DAEFB7 /* FeedlyAddFeedOperation.swift */; };
9E7299D9235062A200DAEFB7 /* FeedlyResourceProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7299D8235062A200DAEFB7 /* FeedlyResourceProviding.swift */; };
9E7F15072341E96700F860D1 /* AccountFeedlySyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7F15062341E96700F860D1 /* AccountFeedlySyncTest.swift */; };
9E7F150A2341EF5A00F860D1 /* feedly_collections_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15092341EF5A00F860D1 /* feedly_collections_initial.json */; };
9E7F150D2341F32000F860D1 /* macintosh_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F150C2341F32000F860D1 /* macintosh_initial.json */; };
9E7F15112341F39A00F860D1 /* uncategorized_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15102341F39A00F860D1 /* uncategorized_initial.json */; };
9E7F15132341F3D900F860D1 /* programming_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15122341F3D900F860D1 /* programming_initial.json */; };
9E7F15152341F42000F860D1 /* weblogs_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15142341F42000F860D1 /* weblogs_initial.json */; };
9E7F15172341F48900F860D1 /* mustread_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15162341F48900F860D1 /* mustread_initial.json */; };
9E832B1E2343467900D83249 /* feedly_collections_addcollection.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E832B1D2343467900D83249 /* feedly_collections_addcollection.json */; };
9E832B202343476A00D83249 /* newcollection_addcollection.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E832B1F2343476A00D83249 /* newcollection_addcollection.json */; };
9E832B23234416B400D83249 /* feedly_collections_addfeed.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E832B22234416B400D83249 /* feedly_collections_addfeed.json */; };
9E832B25234416FF00D83249 /* mustread_addfeed.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E832B24234416FF00D83249 /* mustread_addfeed.json */; };
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */; };
9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */; };
9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */; };
@ -95,10 +117,15 @@
9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC625233318400085D7C9 /* FeedlyStream.swift */; };
9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */; };
9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */; };
9EBC31B7233987C1002A567B /* FeedlyArticleStatusCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBC31B6233987C1002A567B /* FeedlyArticleStatusCoordinator.swift */; };
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 */; };
9EE4CCFA234F106600FBAE4B /* FeedlyFeedContainerValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE4CCF9234F106600FBAE4B /* FeedlyFeedContainerValidator.swift */; };
9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEEF71E23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift */; };
9EEEF7212355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEEF7202355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift */; };
9EEEF75223567CA6009E9D80 /* saved_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EEEF75123567CA6009E9D80 /* saved_initial.json */; };
9EF35F7A234E830E003AE2AE /* FeedlyCompoundOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -146,10 +173,24 @@
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
51E148EE234B8FFC0004F7A5 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
51E148ED234B8FFC0004F7A5 /* SyncDatabase.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
3B29BC6C233EA83C002A346D /* FeedWranglerAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAPICaller.swift; sourceTree = "<group>"; };
3B29BC6D233EA83C002A346D /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = "<group>"; };
3B90F51E233F0EF800D481CC /* FeedWranglerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerConfig.swift; sourceTree = "<group>"; };
3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAPICaller.swift; sourceTree = "<group>"; };
3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerConfig.swift; sourceTree = "<group>"; };
3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = "<group>"; };
5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = "<group>"; };
5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = "<group>"; };
5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = "<group>"; };
@ -175,16 +216,19 @@
5165D71E22835E9800D9D53D /* HTMLFeedFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLFeedFinder.swift; sourceTree = "<group>"; };
5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InitialFeedDownloader.swift; sourceTree = "<group>"; };
5170743B232AEDB500A461A3 /* OPMLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLFile.swift; sourceTree = "<group>"; };
518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = "<group>"; };
51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = "<group>"; };
51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = "<group>"; };
51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = "<group>"; };
51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = "<group>"; };
51D58759227F630B00900287 /* tags_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_initial.json; sourceTree = "<group>"; };
51D5875D227F643C00900287 /* AccountFolderSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFolderSyncTest.swift; sourceTree = "<group>"; };
51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyncDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
51E3EB40229AF61B00645299 /* AccountError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountError.swift; sourceTree = "<group>"; };
51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = "<group>"; };
51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = "<group>"; };
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = "<group>"; };
51FE1007234635A20056195D /* DeepLinkProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkProvider.swift; sourceTree = "<group>"; };
552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIEntry.swift; sourceTree = "<group>"; };
552032EE229D5D5A009559E0 /* ReaderAPISubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPISubscription.swift; sourceTree = "<group>"; };
552032F0229D5D5A009559E0 /* ReaderAPITag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITag.swift; sourceTree = "<group>"; };
@ -221,16 +265,35 @@
84EAC4812148CC6300F154AB /* RSDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
84F73CF0202788D80000BCEF /* ArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleFetcher.swift; sourceTree = "<group>"; };
9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCreateFeedsForCollectionFoldersOperation.swift; sourceTree = "<group>"; };
9E1773D22345700E0056A5A8 /* FeedlyLink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedlyLink.swift; sourceTree = "<group>"; };
9E1773D4234570E30056A5A8 /* FeedlyEntryParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyEntryParser.swift; sourceTree = "<group>"; };
9E1773D6234575AB0056A5A8 /* FeedlyTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyTag.swift; sourceTree = "<group>"; };
9E1773D823458D590056A5A8 /* FeedlyResourceId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceId.swift; sourceTree = "<group>"; };
9E1773DA234593CF0056A5A8 /* FeedlyResourceIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceIdTests.swift; sourceTree = "<group>"; };
9E1AF38A2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetStarredArticlesOperation.swift; sourceTree = "<group>"; };
9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStrategy.swift; sourceTree = "<group>"; };
9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetCollectionsOperation.swift; sourceTree = "<group>"; };
9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyMirrorCollectionsAsFoldersOperation.swift; sourceTree = "<group>"; };
9E1D15522334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetCollectionStreamOperation.swift; sourceTree = "<group>"; };
9E1D15522334304B00F4944C /* FeedlyGetStreamOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamOperation.swift; sourceTree = "<group>"; };
9E1D1554233431A600F4944C /* FeedlyOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOperation.swift; sourceTree = "<group>"; };
9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRequestStreamsOperation.swift; sourceTree = "<group>"; };
9E1D155823343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamParsedItemsOperation.swift; sourceTree = "<group>"; };
9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrganiseParsedItemsByFeedOperation.swift; sourceTree = "<group>"; };
9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyUpdateAccountFeedsWithItemsOperation.swift; sourceTree = "<group>"; };
9E510D6D234F16A8002E6F1A /* FeedlyAddFeedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAddFeedRequest.swift; sourceTree = "<group>"; };
9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRefreshStreamEntriesStatusOperation.swift; sourceTree = "<group>"; };
9E7299D623505E9600DAEFB7 /* FeedlyAddFeedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAddFeedOperation.swift; sourceTree = "<group>"; };
9E7299D8235062A200DAEFB7 /* FeedlyResourceProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceProviding.swift; sourceTree = "<group>"; };
9E7F15062341E96700F860D1 /* AccountFeedlySyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedlySyncTest.swift; sourceTree = "<group>"; };
9E7F15092341EF5A00F860D1 /* feedly_collections_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_collections_initial.json; sourceTree = "<group>"; };
9E7F150C2341F32000F860D1 /* macintosh_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = macintosh_initial.json; sourceTree = "<group>"; };
9E7F15102341F39A00F860D1 /* uncategorized_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = uncategorized_initial.json; sourceTree = "<group>"; };
9E7F15122341F3D900F860D1 /* programming_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = programming_initial.json; sourceTree = "<group>"; };
9E7F15142341F42000F860D1 /* weblogs_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = weblogs_initial.json; sourceTree = "<group>"; };
9E7F15162341F48900F860D1 /* mustread_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = mustread_initial.json; sourceTree = "<group>"; };
9E832B1D2343467900D83249 /* feedly_collections_addcollection.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_collections_addcollection.json; sourceTree = "<group>"; };
9E832B1F2343476A00D83249 /* newcollection_addcollection.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = newcollection_addcollection.json; sourceTree = "<group>"; };
9E832B22234416B400D83249 /* feedly_collections_addfeed.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_collections_addfeed.json; sourceTree = "<group>"; };
9E832B24234416FF00D83249 /* mustread_addfeed.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = mustread_addfeed.json; sourceTree = "<group>"; };
9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedlyAccountDelegate.swift; sourceTree = "<group>"; };
9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCollection.swift; sourceTree = "<group>"; };
9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeed.swift; sourceTree = "<group>"; };
@ -238,10 +301,15 @@
9EAEC625233318400085D7C9 /* FeedlyStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyStream.swift; sourceTree = "<group>"; };
9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCategory.swift; sourceTree = "<group>"; };
9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrigin.swift; sourceTree = "<group>"; };
9EBC31B6233987C1002A567B /* FeedlyArticleStatusCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyArticleStatusCoordinator.swift; sourceTree = "<group>"; };
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAPICaller.swift; sourceTree = "<group>"; };
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedlyAccountDelegate+OAuth.swift"; sourceTree = "<group>"; };
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAuthorizationCodeGranting.swift; sourceTree = "<group>"; };
9ECC9A84234DC16E009B5144 /* FeedlyAccountDelegateError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAccountDelegateError.swift; sourceTree = "<group>"; };
9EE4CCF9234F106600FBAE4B /* FeedlyFeedContainerValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeedContainerValidator.swift; sourceTree = "<group>"; };
9EEEF71E23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySendArticleStatusesOperation.swift; sourceTree = "<group>"; };
9EEEF7202355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStarredArticlesOperation.swift; sourceTree = "<group>"; };
9EEEF75123567CA6009E9D80 /* saved_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = saved_initial.json; sourceTree = "<group>"; };
9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCompoundOperation.swift; sourceTree = "<group>"; };
D511EEB5202422BB00712EC3 /* Account_project_debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_debug.xcconfig; sourceTree = "<group>"; };
D511EEB6202422BB00712EC3 /* Account_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_target.xcconfig; sourceTree = "<group>"; };
D511EEB7202422BB00712EC3 /* Account_project_release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_release.xcconfig; sourceTree = "<group>"; };
@ -258,6 +326,7 @@
844B2981210CE3BF004020B3 /* RSWeb.framework in Frameworks */,
841D4D722106B40A00DD04E6 /* Articles.framework in Frameworks */,
841D4D702106B40400DD04E6 /* ArticlesDatabase.framework in Frameworks */,
51E148EC234B8FFC0004F7A5 /* SyncDatabase.framework in Frameworks */,
841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */,
841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */,
);
@ -274,12 +343,12 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
3B29BC6B233EA83C002A346D /* FeedWrangler */ = {
3BF610C323571CD4000EF978 /* FeedWrangler */ = {
isa = PBXGroup;
children = (
3B29BC6C233EA83C002A346D /* FeedWranglerAPICaller.swift */,
3B29BC6D233EA83C002A346D /* FeedWranglerAccountDelegate.swift */,
3B90F51E233F0EF800D481CC /* FeedWranglerConfig.swift */,
3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */,
3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */,
3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */,
);
path = FeedWrangler;
sourceTree = "<group>";
@ -384,6 +453,7 @@
8469F80F1F6DC3C10084783E /* Frameworks */ = {
isa = PBXGroup;
children = (
51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */,
84EAC4812148CC6300F154AB /* RSDatabase.framework */,
844B2980210CE3BF004020B3 /* RSWeb.framework */,
841D4D712106B40A00DD04E6 /* Articles.framework */,
@ -415,13 +485,14 @@
510BD112232C3E9D002692E4 /* FeedMetadataFile.swift */,
841974001F6DD1EC006346C4 /* Folder.swift */,
844B297E210CE37E004020B3 /* UnreadCountProvider.swift */,
51FE1007234635A20056195D /* DeepLinkProvider.swift */,
5165D71F22835E9800D9D53D /* FeedFinder */,
515E4EB12324FF7D0057B0E7 /* Credentials */,
8419742B1F6DDE84006346C4 /* LocalAccount */,
84245C7D1FDDD2580074AFBB /* Feedbin */,
552032EA229D5D5A009559E0 /* ReaderAPI */,
3BF610C323571CD4000EF978 /* FeedWrangler */,
9EA31339231E368100268BA0 /* Feedly */,
3B29BC6B233EA83C002A346D /* FeedWrangler */,
848935031F62484F00CEBD24 /* AccountTests */,
848934F71F62484F00CEBD24 /* Products */,
8469F80F1F6DC3C10084783E /* Frameworks */,
@ -449,21 +520,71 @@
5165D7112282080C00D9D53D /* AccountFolderContentsSyncTest.swift */,
5107A09C227DE77700C7C3C5 /* TestTransport.swift */,
5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */,
9E7F15082341E97100F860D1 /* Feedly */,
51D58756227F62E300900287 /* JSON */,
848935061F62485000CEBD24 /* Info.plist */,
);
path = AccountTests;
sourceTree = "<group>";
};
9E7F15082341E97100F860D1 /* Feedly */ = {
isa = PBXGroup;
children = (
9E7F15062341E96700F860D1 /* AccountFeedlySyncTest.swift */,
9E1773DA234593CF0056A5A8 /* FeedlyResourceIdTests.swift */,
9E7F150B2341F2A700F860D1 /* Initial */,
9E832B1A234344DA00D83249 /* AddCollection */,
9E832B21234416B400D83249 /* AddFeed */,
);
path = Feedly;
sourceTree = "<group>";
};
9E7F150B2341F2A700F860D1 /* Initial */ = {
isa = PBXGroup;
children = (
9EEEF75123567CA6009E9D80 /* saved_initial.json */,
9E7F15092341EF5A00F860D1 /* feedly_collections_initial.json */,
9E7F150C2341F32000F860D1 /* macintosh_initial.json */,
9E7F15162341F48900F860D1 /* mustread_initial.json */,
9E7F15122341F3D900F860D1 /* programming_initial.json */,
9E7F15102341F39A00F860D1 /* uncategorized_initial.json */,
9E7F15142341F42000F860D1 /* weblogs_initial.json */,
);
path = Initial;
sourceTree = "<group>";
};
9E832B1A234344DA00D83249 /* AddCollection */ = {
isa = PBXGroup;
children = (
9E832B1D2343467900D83249 /* feedly_collections_addcollection.json */,
9E832B1F2343476A00D83249 /* newcollection_addcollection.json */,
);
path = AddCollection;
sourceTree = "<group>";
};
9E832B21234416B400D83249 /* AddFeed */ = {
isa = PBXGroup;
children = (
9E832B22234416B400D83249 /* feedly_collections_addfeed.json */,
9E832B24234416FF00D83249 /* mustread_addfeed.json */,
);
path = AddFeed;
sourceTree = "<group>";
};
9EA31339231E368100268BA0 /* Feedly */ = {
isa = PBXGroup;
children = (
9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */,
9EE4CCF9234F106600FBAE4B /* FeedlyFeedContainerValidator.swift */,
9ECC9A84234DC16E009B5144 /* FeedlyAccountDelegateError.swift */,
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */,
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */,
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */,
9E510D6D234F16A8002E6F1A /* FeedlyAddFeedRequest.swift */,
9E1D1554233431A600F4944C /* FeedlyOperation.swift */,
9EBC31B6233987C1002A567B /* FeedlyArticleStatusCoordinator.swift */,
9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */,
9E7299D8235062A200DAEFB7 /* FeedlyResourceProviding.swift */,
9E7299D623505E9600DAEFB7 /* FeedlyAddFeedOperation.swift */,
9EBC31B32338AC2E002A567B /* Models */,
9EBC31B22338AC0F002A567B /* Refresh */,
);
@ -478,11 +599,13 @@
9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */,
9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */,
9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */,
9E1D15522334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift */,
9E1D155823343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift */,
9E1D15522334304B00F4944C /* FeedlyGetStreamOperation.swift */,
9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */,
9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */,
9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift */,
9EEEF7202355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift */,
9E1AF38A2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift */,
9EEEF71E23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift */,
);
path = Refresh;
sourceTree = "<group>";
@ -490,12 +613,16 @@
9EBC31B32338AC2E002A567B /* Models */ = {
isa = PBXGroup;
children = (
9E1773D823458D590056A5A8 /* FeedlyResourceId.swift */,
9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */,
9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */,
9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */,
9E1773D4234570E30056A5A8 /* FeedlyEntryParser.swift */,
9EAEC625233318400085D7C9 /* FeedlyStream.swift */,
9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */,
9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */,
9E1773D22345700E0056A5A8 /* FeedlyLink.swift */,
9E1773D6234575AB0056A5A8 /* FeedlyTag.swift */,
);
path = Models;
sourceTree = "<group>";
@ -504,6 +631,7 @@
isa = PBXGroup;
children = (
D511EEB8202422BB00712EC3 /* Account_project.xcconfig */,
518B2EA52351306200400001 /* Account_project_test.xcconfig */,
D511EEB5202422BB00712EC3 /* Account_project_debug.xcconfig */,
D511EEB7202422BB00712EC3 /* Account_project_release.xcconfig */,
D511EEB6202422BB00712EC3 /* Account_target.xcconfig */,
@ -529,12 +657,14 @@
isa = PBXNativeTarget;
buildConfigurationList = 8489350A1F62485000CEBD24 /* Build configuration list for PBXNativeTarget "Account" */;
buildPhases = (
3B90F52F233F266A00D481CC /* Run Script: Update FeedWranglerConfig.swift */,
3BF610B123571C31000EF978 /* Run Script: Update FeedWranglerConfig.swift */,
848934F11F62484F00CEBD24 /* Sources */,
3B90F530233F267400D481CC /* Run Script: Reset FeedWranglerConfig.swift */,
3BF610B223571C32000EF978 /* Run Script: Reset FeedWranglerConfig.swift */,
848934F21F62484F00CEBD24 /* Frameworks */,
848934F31F62484F00CEBD24 /* Headers */,
848934F41F62484F00CEBD24 /* Resources */,
51E148EE234B8FFC0004F7A5 /* Embed Frameworks */,
51C8F34C234FB14B0048ED95 /* Run Script: Verify No Build Settings */,
);
buildRules = (
);
@ -666,10 +796,21 @@
5133230E2281089500C30F19 /* icons.json in Resources */,
51D5875B227F630B00900287 /* tags_add.json in Resources */,
5133230C2281088A00C30F19 /* subscriptions_add.json in Resources */,
9E832B202343476A00D83249 /* newcollection_addcollection.json in Resources */,
9E832B1E2343467900D83249 /* feedly_collections_addcollection.json in Resources */,
9E7F15152341F42000F860D1 /* weblogs_initial.json in Resources */,
51D5875C227F630B00900287 /* tags_initial.json in Resources */,
51D5875A227F630B00900287 /* tags_delete.json in Resources */,
5165D71722821C2400D9D53D /* taggings_add.json in Resources */,
9EEEF75223567CA6009E9D80 /* saved_initial.json in Resources */,
9E832B23234416B400D83249 /* feedly_collections_addfeed.json in Resources */,
5165D71622821C2400D9D53D /* taggings_delete.json in Resources */,
9E7F15112341F39A00F860D1 /* uncategorized_initial.json in Resources */,
9E7F15132341F3D900F860D1 /* programming_initial.json in Resources */,
9E832B25234416FF00D83249 /* mustread_addfeed.json in Resources */,
9E7F150A2341EF5A00F860D1 /* feedly_collections_initial.json in Resources */,
9E7F15172341F48900F860D1 /* mustread_initial.json in Resources */,
9E7F150D2341F32000F860D1 /* macintosh_initial.json in Resources */,
5133230A2281082F00C30F19 /* subscriptions_initial.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -677,7 +818,7 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B90F52F233F266A00D481CC /* Run Script: Update FeedWranglerConfig.swift */ = {
3BF610B123571C31000EF978 /* Run Script: Update FeedWranglerConfig.swift */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -695,7 +836,7 @@
shellPath = /bin/sh;
shellScript = "FAILED=false\n\nif [ -z \"${FEED_WRANGLER_KEY}\" ]; then\nFAILED=true\nfi\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feed Wrangler Key. FeedWranglerConfig.swift not changed.\"\nexit 0\nfi\n\nsed -i .tmp \"s|{FEEDWRANGLERKEY}|${FEED_WRANGLER_KEY}|g; s|{FEEDWRANGLERKEY}|${FEED_WRANGLER_KEY}|g\" \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift\"\n\nrm -f \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift.tmp\"\n\necho \"All Feed Wrangler env values found!\"\n\n";
};
3B90F530233F267400D481CC /* Run Script: Reset FeedWranglerConfig.swift */ = {
3BF610B223571C32000EF978 /* Run Script: Reset FeedWranglerConfig.swift */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -713,6 +854,24 @@
shellPath = /bin/sh;
shellScript = "git checkout \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift\"\n";
};
51C8F34C234FB14B0048ED95 /* Run Script: Verify No Build Settings */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run Script: Verify No Build Settings";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -721,29 +880,30 @@
buildActionMask = 2147483647;
files = (
84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */,
9EF35F7A234E830E003AE2AE /* FeedlyCompoundOperation.swift in Sources */,
552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */,
84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */,
3B29BC6F233EA83C002A346D /* FeedWranglerAccountDelegate.swift in Sources */,
9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */,
9E7299D9235062A200DAEFB7 /* FeedlyResourceProviding.swift in Sources */,
9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */,
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 */,
3B29BC6E233EA83C002A346D /* FeedWranglerAPICaller.swift in Sources */,
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
9E1D155923343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift in Sources */,
3B90F51F233F0EF800D481CC /* FeedWranglerConfig.swift in Sources */,
846E77451F6EF9B900A165E2 /* Container.swift in Sources */,
9E1D15532334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift in Sources */,
9E1D15532334304B00F4944C /* FeedlyGetStreamOperation.swift in Sources */,
9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */,
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */,
9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */,
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */,
9E713653233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift in Sources */,
3BF610C923571CD4000EF978 /* FeedWranglerAccountDelegate.swift in Sources */,
841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */,
510BD113232C3E9D002692E4 /* FeedMetadataFile.swift in Sources */,
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */,
9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */,
846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */,
515E4EB72324FF8C0057B0E7 /* Credentials.swift in Sources */,
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */,
@ -752,12 +912,16 @@
844B297D2106C7EC004020B3 /* Feed.swift in Sources */,
9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */,
9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */,
9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */,
515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */,
9E7299D723505E9600DAEFB7 /* FeedlyAddFeedOperation.swift in Sources */,
5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */,
84B2D4D02238CD8A00498ADA /* FeedMetadata.swift in Sources */,
9EAEC624233315F60085D7C9 /* FeedlyEntry.swift in Sources */,
9EEEF7212355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift in Sources */,
5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */,
84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */,
9E510D6E234F16A8002E6F1A /* FeedlyAddFeedRequest.swift in Sources */,
5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */,
846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */,
55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */,
@ -773,10 +937,15 @@
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */,
9E1D155B2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift in Sources */,
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */,
3BF610C723571CD4000EF978 /* FeedWranglerAPICaller.swift in Sources */,
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */,
51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */,
9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */,
9EE4CCFA234F106600FBAE4B /* FeedlyFeedContainerValidator.swift in Sources */,
552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */,
51FE1008234635A20056195D /* DeepLinkProvider.swift in Sources */,
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */,
9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */,
9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */,
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,
@ -786,9 +955,11 @@
846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */,
515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */,
844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */,
9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */,
9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */,
3BF610C823571CD4000EF978 /* FeedWranglerConfig.swift in Sources */,
9E1AF38B2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift in Sources */,
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */,
9EBC31B7233987C1002A567B /* FeedlyArticleStatusCoordinator.swift in Sources */,
84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -797,12 +968,14 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9E7F15072341E96700F860D1 /* AccountFeedlySyncTest.swift in Sources */,
5165D7122282080C00D9D53D /* AccountFolderContentsSyncTest.swift in Sources */,
51D5875E227F643C00900287 /* AccountFolderSyncTest.swift in Sources */,
5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */,
513323082281070D00C30F19 /* AccountFeedSyncTest.swift in Sources */,
5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */,
5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */,
9E1773DB234593CF0056A5A8 /* FeedlyResourceIdTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -817,12 +990,31 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
51EC893023511FFE0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 518B2EA52351306200400001 /* Account_project_test.xcconfig */;
buildSettings = {
};
name = Test;
};
51EC893123511FFE0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEB6202422BB00712EC3 /* Account_target.xcconfig */;
buildSettings = {
};
name = Test;
};
51EC893223511FFE0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEB9202422BB00712EC3 /* Accounttests_target.xcconfig */;
buildSettings = {
};
name = Test;
};
848935081F62485000CEBD24 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEB5202422BB00712EC3 /* Account_project_debug.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 1;
SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos";
};
name = Debug;
};
@ -830,8 +1022,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEB7202422BB00712EC3 /* Account_project_release.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 1;
SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos";
};
name = Release;
};
@ -839,7 +1029,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEB6202422BB00712EC3 /* Account_target.xcconfig */;
buildSettings = {
CODE_SIGN_IDENTITY = "";
};
name = Debug;
};
@ -847,7 +1036,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEB6202422BB00712EC3 /* Account_target.xcconfig */;
buildSettings = {
CODE_SIGN_IDENTITY = "";
};
name = Release;
};
@ -872,6 +1060,7 @@
isa = XCConfigurationList;
buildConfigurations = (
848935081F62485000CEBD24 /* Debug */,
51EC893023511FFE0061B6F6 /* Test */,
848935091F62485000CEBD24 /* Release */,
);
defaultConfigurationIsVisible = 0;
@ -881,6 +1070,7 @@
isa = XCConfigurationList;
buildConfigurations = (
8489350B1F62485000CEBD24 /* Debug */,
51EC893123511FFE0061B6F6 /* Test */,
8489350C1F62485000CEBD24 /* Release */,
);
defaultConfigurationIsVisible = 0;
@ -890,6 +1080,7 @@
isa = XCConfigurationList;
buildConfigurations = (
8489350E1F62485000CEBD24 /* Debug */,
51EC893223511FFE0061B6F6 /* Test */,
8489350F1F62485000CEBD24 /* Release */,
);
defaultConfigurationIsVisible = 0;

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1100"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "848934F51F62484F00CEBD24"
BuildableName = "Account.framework"
BlueprintName = "Account"
ReferencedContainer = "container:Account.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Test"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "848934FE1F62484F00CEBD24"
BuildableName = "AccountTests.xctest"
BlueprintName = "AccountTests"
ReferencedContainer = "container:Account.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "848934F51F62484F00CEBD24"
BuildableName = "Account.framework"
BlueprintName = "Account"
ReferencedContainer = "container:Account.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -156,9 +156,13 @@ public final class AccountManager: UnreadCountProvider {
return accountsDictionary[accountID]
}
public func refreshAll(errorHandler: @escaping (Error) -> Void) {
public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() ->Void)? = nil) {
let group = DispatchGroup()
activeAccounts.forEach { account in
group.enter()
account.refreshAll() { result in
group.leave()
switch result {
case .success:
break
@ -167,6 +171,11 @@ public final class AccountManager: UnreadCountProvider {
}
}
}
group.notify(queue: DispatchQueue.main) {
completion?()
}
}
public func syncArticleStatusAll(completion: (() -> Void)? = nil) {
@ -184,6 +193,10 @@ public final class AccountManager: UnreadCountProvider {
}
}
public func saveAll() {
accounts.forEach { $0.save() }
}
public func anyAccountHasAtLeastOneFeed() -> Bool {
for account in activeAccounts {
if account.hasAtLeastOneFeed() {
@ -235,6 +248,15 @@ public final class AccountManager: UnreadCountProvider {
}
}
// MARK: - Caches
/// Empty caches that can reasonably be emptied  when the app moves to the background, for instance.
public func emptyCaches() {
for account in accounts {
account.emptyCaches()
}
}
// MARK: - Notifications
@objc dynamic func unreadCountDidChange(_ notification: Notification) {

View File

@ -31,7 +31,7 @@ final class AccountMetadataFile {
managedFile.load()
}
func saveIfNecessary() {
func save() {
managedFile.saveIfNecessary()
}

View File

@ -26,12 +26,12 @@ class AccountCredentialsTest: XCTestCase {
// Make sure any left over from failed tests are gone
do {
try account.removeBasicCredentials()
try account.removeCredentials(type: .basic)
} catch {
XCTFail(error.localizedDescription)
}
var credentials: Credentials? = Credentials.basic(username: "maurice", password: "hardpasswd")
var credentials: Credentials? = Credentials(type: .basic, username: "maurice", secret: "hardpasswd")
// Store the credentials
do {
@ -43,19 +43,21 @@ class AccountCredentialsTest: XCTestCase {
// Retrieve them
credentials = nil
do {
credentials = try account.retrieveBasicCredentials()
credentials = try account.retrieveCredentials(type: .basic)
} catch {
XCTFail(error.localizedDescription)
}
switch credentials! {
case .basic(let username, let password):
XCTAssertEqual("maurice", username)
XCTAssertEqual("hardpasswd", password)
switch credentials!.type {
case .basic:
XCTAssertEqual("maurice", credentials?.username)
XCTAssertEqual("hardpasswd", credentials?.secret)
default:
XCTFail("Expected \(CredentialsType.basic), received \(credentials!.type)")
}
// Update them
credentials = Credentials.basic(username: "maurice", password: "easypasswd")
credentials = Credentials(type: .basic, username: "maurice", secret: "easypasswd")
do {
try account.storeCredentials(credentials!)
} catch {
@ -65,27 +67,29 @@ class AccountCredentialsTest: XCTestCase {
// Retrieve them again
credentials = nil
do {
credentials = try account.retrieveBasicCredentials()
credentials = try account.retrieveCredentials(type: .basic)
} catch {
XCTFail(error.localizedDescription)
}
switch credentials! {
case .basic(let username, let password):
XCTAssertEqual("maurice", username)
XCTAssertEqual("easypasswd", password)
switch credentials!.type {
case .basic:
XCTAssertEqual("maurice", credentials?.username)
XCTAssertEqual("easypasswd", credentials?.secret)
default:
XCTFail("Expected \(CredentialsType.basic), received \(credentials!.type)")
}
// Delete them
do {
try account.removeBasicCredentials()
try account.removeCredentials(type: .basic)
} catch {
XCTFail(error.localizedDescription)
}
// Make sure they are gone
do {
try credentials = account.retrieveBasicCredentials()
try credentials = account.retrieveCredentials(type: .basic)
} catch {
XCTFail(error.localizedDescription)
}

View File

@ -20,14 +20,14 @@ class AccountFeedSyncTest: XCTestCase {
func testDownloadSync() {
let testTransport = TestTransport()
testTransport.testFiles["https://api.feedbin.com/v2/tags.json"] = "tags_add.json"
testTransport.testFiles["https://api.feedbin.com/v2/subscriptions.json"] = "subscriptions_initial.json"
testTransport.testFiles["https://api.feedbin.com/v2/icons.json"] = "icons.json"
testTransport.testFiles["tags.json"] = "tags_add.json"
testTransport.testFiles["subscriptions.json"] = "subscriptions_initial.json"
testTransport.testFiles["icons.json"] = "icons.json"
let account = TestAccountManager.shared.createAccount(type: .feedbin, transport: testTransport)
// Test initial folders
let initialExpection = self.expectation(description: "Initial feeds")
account.refreshAll() {
account.refreshAll() { _ in
initialExpection.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
@ -44,7 +44,7 @@ class AccountFeedSyncTest: XCTestCase {
testTransport.testFiles["https://api.feedbin.com/v2/subscriptions.json"] = "subscriptions_add.json"
let addExpection = self.expectation(description: "Add feeds")
account.refreshAll() {
account.refreshAll() { _ in
addExpection.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
@ -52,9 +52,9 @@ class AccountFeedSyncTest: XCTestCase {
XCTAssertEqual(225, account.flattenedFeeds().count)
let bPixels = account.idToFeedDictionary["1096623"]
XCTAssertEqual("Beautiful Pixels", bPixels!.name)
XCTAssertEqual("https://feedpress.me/beautifulpixels", bPixels!.url)
XCTAssertEqual("https://beautifulpixels.com/", bPixels!.homePageURL)
XCTAssertEqual("Beautiful Pixels", bPixels?.name)
XCTAssertEqual("https://feedpress.me/beautifulpixels", bPixels?.url)
XCTAssertEqual("https://beautifulpixels.com/", bPixels?.homePageURL)
XCTAssertEqual("https://favicons.feedbinusercontent.com/ea0/ea010c658d6e356e49ab239b793dc415af707b05.png", bPixels?.faviconURL)
TestAccountManager.shared.deleteAccount(account)

View File

@ -28,7 +28,7 @@ class AccountFolderContentsSyncTest: XCTestCase {
// Test initial folders
let initialExpection = self.expectation(description: "Initial contents")
account.refreshAll() {
account.refreshAll() { _ in
initialExpection.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
@ -41,7 +41,7 @@ class AccountFolderContentsSyncTest: XCTestCase {
testTransport.testFiles["https://api.feedbin.com/v2/taggings.json"] = "taggings_add.json"
let addExpection = self.expectation(description: "Add contents")
account.refreshAll() {
account.refreshAll() { _ in
addExpection.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
@ -53,7 +53,7 @@ class AccountFolderContentsSyncTest: XCTestCase {
testTransport.testFiles["https://api.feedbin.com/v2/taggings.json"] = "taggings_delete.json"
let deleteExpection = self.expectation(description: "Delete contents")
account.refreshAll() {
account.refreshAll() { _ in
deleteExpection.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)

View File

@ -25,7 +25,7 @@ class AccountFolderSyncTest: XCTestCase {
// Test initial folders
let initialExpection = self.expectation(description: "Initial tags")
account.refreshAll() {
account.refreshAll() { _ in
initialExpection.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
@ -43,7 +43,7 @@ class AccountFolderSyncTest: XCTestCase {
testTransport.testFiles["https://api.feedbin.com/v2/tags.json"] = "tags_delete.json"
let deleteExpection = self.expectation(description: "Delete tags")
account.refreshAll() {
account.refreshAll() { _ in
deleteExpection.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
@ -62,7 +62,7 @@ class AccountFolderSyncTest: XCTestCase {
testTransport.testFiles["https://api.feedbin.com/v2/tags.json"] = "tags_add.json"
let addExpection = self.expectation(description: "Add tags")
account.refreshAll() {
account.refreshAll() { _ in
addExpection.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)

View File

@ -0,0 +1,324 @@
//
// AccountFeedlySyncTest.swift
// AccountTests
//
// Created by Kiel Gillard on 30/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import Articles
class AccountFeedlySyncTest: XCTestCase {
private let testTransport = TestTransport()
private var account: Account!
override func setUp() {
super.setUp()
account = TestAccountManager.shared.createAccount(type: .feedly, transport: testTransport)
do {
let username = UUID().uuidString
let credentials = Credentials(type: .oauthAccessToken, username: username, secret: "test")
try account.storeCredentials(credentials)
} catch {
XCTFail("Unable to register mock credentials because \(error)")
}
}
override func tearDown() {
// Clean up
do {
try account.removeCredentials(type: .oauthAccessToken)
} catch {
XCTFail("Unable to clean up mock credentials because \(error)")
}
TestAccountManager.shared.deleteAccount(account)
super.tearDown()
}
// MARK: Initial Sync
func testInitialSync() {
XCTAssertTrue(account.idToFeedDictionary.isEmpty, "Expected to be testing a fresh account without any existing feeds.")
XCTAssertTrue((account.folders ?? Set()).isEmpty, "Expected to be testing a fresh account without any existing folders.")
set(testFiles: .initial, with: testTransport)
// Test initial folders for collections and feeds for collection feeds.
let initialExpection = self.expectation(description: "Initial feeds")
account.refreshAll() { _ in
initialExpection.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial")
checkArticles(againstItemsInStreamInJSONNamed: "macintosh_initial")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial")
checkArticles(againstItemsInStreamInJSONNamed: "programming_initial")
checkArticles(againstItemsInStreamInJSONNamed: "uncategorized_initial")
checkArticles(againstItemsInStreamInJSONNamed: "weblogs_initial")
}
// MARK: Add Collection
func testAddsFoldersForCollections() {
prepareBaseline(.initial)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial")
set(testFiles: .addCollection, with: testTransport)
let addCollectionExpectation = self.expectation(description: "Adds NewCollection")
account.refreshAll() { _ in
addCollectionExpectation.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection")
checkArticles(againstItemsInStreamInJSONNamed: "newcollection_addcollection")
}
// MARK: Add Feed
func testAddsFeeds() {
prepareBaseline(.addCollection)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial")
set(testFiles: .addFeed, with: testTransport)
let addFeedExpectation = self.expectation(description: "Add Feed To Must Read (hey, that rhymes!)")
account.refreshAll() { _ in
addFeedExpectation.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_addfeed")
}
// MARK: Remove Feed
func testRemovesFeeds() {
prepareBaseline(.addFeed)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_addfeed")
set(testFiles: .removeFeed, with: testTransport)
let removeFeedExpectation = self.expectation(description: "Remove Feed from Must Read")
account.refreshAll() { _ in
removeFeedExpectation.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial")
}
func testRemoveCollection() {
prepareBaseline(.addFeed)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed")
set(testFiles: .removeCollection, with: testTransport)
let removeCollectionExpectation = self.expectation(description: "Remove Collection")
account.refreshAll() { _ in
removeCollectionExpectation.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial")
}
// MARK: Utility
func prepareBaseline(_ testFiles: TestFiles) {
XCTAssertTrue(account.idToFeedDictionary.isEmpty, "Expected to be testing a fresh accout.")
set(testFiles: testFiles, with: testTransport)
// Test initial folders for collections and feeds for collection feeds.
let preparationExpectation = self.expectation(description: "Prepare Account")
account.refreshAll() { _ in
preparationExpectation.fulfill()
}
// If there's a failure here, then an operation hasn't completed.
// Check that test files have responses for all the requests this might make.
waitForExpectations(timeout: 5)
}
func checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed name: String) {
let collections = testJSON(named: name) as! [[String:Any]]
let collectionNames = Set(collections.map { $0["label"] as! String })
let collectionIds = Set(collections.map { $0["id"] as! String })
let folders = account.folders ?? Set()
let folderNames = Set(folders.compactMap { $0.name })
let folderIds = Set(folders.compactMap { $0.externalID })
let missingNames = collectionNames.subtracting(folderNames)
let missingIds = collectionIds.subtracting(folderIds)
XCTAssertEqual(folders.count, collections.count, "Mismatch between collections and folders.")
XCTAssertTrue(missingNames.isEmpty, "Collections with these names did not have a corresponding folder with the same name.")
XCTAssertTrue(missingIds.isEmpty, "Collections with these ids did not have a corresponding folder with the same id.")
for collection in collections {
checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONPayload: collection)
}
}
func checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONNamed name: String) {
let collection = testJSON(named: name) as! [String:Any]
checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONPayload: collection)
}
func checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONPayload collection: [String: Any]) {
let label = collection["label"] as! String
guard let folder = account.existingFolder(with: label) else {
// due to a previous test failure?
XCTFail("Could not find the \"\(label)\" folder.")
return
}
let collectionFeeds = collection["feeds"] as! [[String: Any]]
let folderFeeds = folder.topLevelFeeds
XCTAssertEqual(collectionFeeds.count, folderFeeds.count)
let collectionFeedIds = Set(collectionFeeds.map { $0["id"] as! String })
let folderFeedIds = Set(folderFeeds.map { $0.feedID })
let missingFeedIds = collectionFeedIds.subtracting(folderFeedIds)
XCTAssertTrue(missingFeedIds.isEmpty, "Feeds with these ids were not found in the \"\(label)\" folder.")
}
func checkArticles(againstItemsInStreamInJSONNamed name: String) {
let stream = testJSON(named: name) as! [String:Any]
checkArticles(againstItemsInStreamInJSONPayload: stream)
}
func checkArticles(againstItemsInStreamInJSONPayload stream: [String: Any]) {
struct ArticleItem {
var id: String
var feedId: String
var content: String
var JSON: [String: Any]
var unread: Bool
/// Convoluted external URL logic "documented" here:
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
var externalUrl: String? {
return ((JSON["canonical"] as? [[String: Any]]) ?? (JSON["alternate"] as? [[String: Any]]))?.compactMap { link -> String? in
let href = link["href"] as? String
if let type = link["type"] as? String {
if type == "text/html" {
return href
}
return nil
}
return href
}.first
}
init(item: [String: Any]) {
self.JSON = item
self.id = item["id"] as! String
let origin = item["origin"] as! [String: Any]
self.feedId = origin["streamId"] as! String
let content = item["content"] as? [String: Any]
let summary = item["summary"] as? [String: Any]
self.content = ((content ?? summary)?["content"] as? String) ?? ""
self.unread = item["unread"] as! Bool
}
}
let items = stream["items"] as! [[String: Any]]
let articleItems = items.map { ArticleItem(item: $0) }
let itemIds = Set(articleItems.map { $0.id })
let articles = account.fetchArticles(.articleIDs(itemIds))
let articleIds = Set(articles.map { $0.articleID })
let missing = itemIds.subtracting(articleIds)
XCTAssertEqual(items.count, articles.count)
XCTAssertTrue(missing.isEmpty, "Items with these ids did not have a corresponding article with the same id.")
for article in articles {
for item in articleItems where item.id == article.articleID {
XCTAssertEqual(article.uniqueID, item.id)
XCTAssertEqual(article.contentHTML, item.content)
XCTAssertEqual(article.feedID, item.feedId)
XCTAssertEqual(article.externalURL, item.externalUrl)
// XCTAssertEqual(article.status.boolStatus(forKey: .read), item.unread)
}
}
}
func testJSON(named: String) -> Any {
let bundle = Bundle(for: TestTransport.self)
let url = bundle.url(forResource: named, withExtension: "json")!
let data = try! Data(contentsOf: url)
let json = try! JSONSerialization.jsonObject(with: data)
return json
}
enum TestFiles {
case initial
case addCollection
case addFeed
case removeFeed
case removeCollection
}
func set(testFiles: TestFiles, with transport: TestTransport) {
// TestTransport blacklists certain query items to make mocking responses easier.
let collectionsEndpoint = "/v3/collections"
switch testFiles {
case .initial:
let dict = [
"/global.saved": "saved_initial.json",
collectionsEndpoint: "feedly_collections_initial.json",
"/5ca4d61d-e55d-4999-a8d1-c3b9d8789815": "macintosh_initial.json",
"/global.must": "mustread_initial.json",
"/885f2e01-d314-4e63-abac-17dcb063f5b5": "programming_initial.json",
"/66132046-6f14-488d-b590-8e93422723c8": "uncategorized_initial.json",
"/e31b3fcb-27f6-4f3e-b96c-53902586e366": "weblogs_initial.json",
]
transport.testFiles = dict
case .addCollection:
set(testFiles: .initial, with: transport)
var dict = transport.testFiles
dict[collectionsEndpoint] = "feedly_collections_addcollection.json"
dict["/fc09f383-5a9a-4daa-a575-3efc1733b173"] = "newcollection_addcollection.json"
transport.testFiles = dict
case .addFeed:
set(testFiles: .addCollection, with: transport)
var dict = transport.testFiles
dict[collectionsEndpoint] = "feedly_collections_addfeed.json"
dict["/global.must"] = "mustread_addfeed.json"
transport.testFiles = dict
case .removeFeed:
set(testFiles: .addCollection, with: transport)
case .removeCollection:
set(testFiles: .initial, with: transport)
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,27 @@
//
// FeedlyResourceIdTests.swift
// AccountTests
//
// Created by Kiel Gillard on 3/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlyResourceIdTests: XCTestCase {
func testFeedResourceId() {
let expectedUrl = "http://ranchero.com/blog/atom.xml"
let feedResource = FeedlyFeedResourceId(id: "feed/\(expectedUrl)")
let urlResource = FeedlyFeedResourceId(id: expectedUrl)
let otherResource = FeedlyFeedResourceId(id: "whiskey/\(expectedUrl)")
let invalidResource = FeedlyFeedResourceId(id: "")
XCTAssertEqual(feedResource.url, expectedUrl)
XCTAssertEqual(urlResource.url, expectedUrl)
XCTAssertEqual(otherResource.url, otherResource.id)
XCTAssertEqual(invalidResource.url, invalidResource.id)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"id":"user/f2f031bd-f3e3-4893-a447-467a291c6d1e/category/global.must","items":[]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -23,16 +23,16 @@ class TestAccountManager {
func createAccount(type: AccountType, username: String? = nil, password: String? = nil, transport: Transport) -> Account {
let accountID = UUID().uuidString
let accountFolder = accountsFolder.appendingPathComponent("\(type.rawValue)_\(accountID)").absoluteString
let accountFolder = accountsFolder.appendingPathComponent("\(type.rawValue)_\(accountID)")
do {
try FileManager.default.createDirectory(atPath: accountFolder, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: accountFolder, withIntermediateDirectories: true, attributes: nil)
} catch {
assertionFailure("Could not create folder for \(accountID) account.")
abort()
}
let account = Account(dataFolder: accountFolder, type: type, accountID: accountID, transport: transport)!
let account = Account(dataFolder: accountFolder.absoluteString, type: type, accountID: accountID, transport: transport)!
return account
@ -43,8 +43,11 @@ class TestAccountManager {
do {
try FileManager.default.removeItem(atPath: account.dataFolder)
}
catch let error as CocoaError where error.code == .fileNoSuchFile {
print("Unable to delete folder at: \(account.dataFolder) because \(error)")
}
catch {
assertionFailure("Could not create folder for OnMyMac account.")
assertionFailure("Could not delete folder at: \(account.dataFolder) because \(error)")
abort()
}

View File

@ -8,6 +8,7 @@
import Foundation
import RSWeb
import XCTest
final class TestTransport: Transport {
@ -16,30 +17,66 @@ final class TestTransport: Transport {
}
var testFiles = [String: String]()
var testStatusCodes = [String: Int]()
func send(request: URLRequest, completion: @escaping (Result<(HTTPHeaders, Data?), Error>) -> Void) {
/// Allows tests to filter time sensitive state out to make matching against test data easier.
var blacklistedQueryItemNames = Set([
"newerThan", // Feedly: Mock data has a fixed date.
"unreadOnly", // Feedly: Mock data is read/unread by test expectation.
"count", // Feedly: Mock data is limited by test expectation.
])
private func httpResponse(for request: URLRequest, statusCode: Int = 200) -> HTTPURLResponse {
guard let url = request.url else {
fatalError("Attempting to mock a http response for a request without a URL \(request).")
}
return HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: nil)!
}
func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
guard let urlString = request.url?.absoluteString else {
guard let url = request.url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
completion(.failure(TestTransportError.invalidState))
return
}
if let testFileName = testFiles[urlString] {
components.queryItems = components
.queryItems?
.filter { !blacklistedQueryItemNames.contains($0.name) }
guard let urlString = components.url?.absoluteString else {
completion(.failure(TestTransportError.invalidState))
return
}
let response = httpResponse(for: request, statusCode: testStatusCodes[urlString] ?? 200)
var mockResponseFound = false
for (key, testFileName) in testFiles where urlString.contains(key) {
let testFileURL = Bundle(for: TestTransport.self).resourceURL!.appendingPathComponent(testFileName)
let data = try! Data(contentsOf: testFileURL)
DispatchQueue.global(qos: .background).async {
completion(.success((HTTPHeaders(), data)))
completion(.success((response, data)))
}
} else {
mockResponseFound = true
break
}
if !mockResponseFound {
// XCTFail("Missing mock response for: \(urlString)")
print("***\nWARNING: \(self) missing mock response for:\n\(urlString)\n***")
DispatchQueue.global(qos: .background).async {
completion(.success((HTTPHeaders(), nil)))
completion(.success((response, nil)))
}
}
}
func send(request: URLRequest, payload: Data, completion: @escaping (Result<(HTTPHeaders, Data?), Error>) -> Void) {
func send(request: URLRequest, method: String, completion: @escaping (Result<Void, Error>) -> Void) {
fatalError("Unimplemented.")
}
func send(request: URLRequest, method: String, payload: Data, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
fatalError("Unimplemented.")
}
}

View File

@ -49,7 +49,11 @@ extension Feed {
public extension Article {
var account: Account? {
return AccountManager.shared.existingAccount(with: accountID)
// The force unwrapped shared instance was crashing Account.framework unit tests.
guard let manager = AccountManager.shared else {
return nil
}
return manager.existingAccount(with: accountID)
}
var feed: Feed? {

View File

@ -0,0 +1,21 @@
//
// DeepLinkProvider.swift
// Account
//
// Created by Maurice Parker on 10/3/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public enum DeepLinkKey: String {
case accountID = "accountID"
case accountName = "accountName"
case feedID = "feedID"
case articleID = "articleID"
case folderName = "folderName"
}
public protocol DeepLinkProvider {
var deepLinkUserInfo: [AnyHashable : Any] { get }
}

View File

@ -11,7 +11,7 @@ import RSCore
import RSWeb
import Articles
public final class Feed: DisplayNameProvider, Renamable, UnreadCountProvider, Hashable {
public final class Feed: DisplayNameProvider, Renamable, UnreadCountProvider, DeepLinkProvider, Hashable {
public weak var account: Account?
public let url: String
@ -123,6 +123,15 @@ public final class Feed: DisplayNameProvider, Renamable, UnreadCountProvider, Ha
}
}
public var isNotifyAboutNewArticles: Bool? {
get {
return metadata.isNotifyAboutNewArticles
}
set {
metadata.isNotifyAboutNewArticles = newValue
}
}
public var isArticleExtractorAlwaysOn: Bool? {
get {
return metadata.isArticleExtractorAlwaysOn
@ -170,6 +179,15 @@ public final class Feed: DisplayNameProvider, Renamable, UnreadCountProvider, Ha
account.renameFeed(self, to: newName, completion: completion)
}
// MARK: - PathIDUserInfoProvider
public var deepLinkUserInfo: [AnyHashable : Any] {
return [
DeepLinkKey.accountID.rawValue: account?.accountID ?? "",
DeepLinkKey.accountName.rawValue: account?.nameForDisplay ?? "",
DeepLinkKey.feedID.rawValue: feedID
]
}
// MARK: - UnreadCountProvider
public var unreadCount: Int {

View File

@ -24,6 +24,7 @@ final class FeedMetadata: Codable {
case editedName
case authors
case contentHash
case isNotifyAboutNewArticles
case isArticleExtractorAlwaysOn
case conditionalGetInfo
case subscriptionID
@ -78,10 +79,18 @@ final class FeedMetadata: Codable {
}
}
var isNotifyAboutNewArticles: Bool? {
didSet {
if isNotifyAboutNewArticles != oldValue {
valueDidChange(.isNotifyAboutNewArticles)
}
}
}
var isArticleExtractorAlwaysOn: Bool? {
didSet {
if isArticleExtractorAlwaysOn != oldValue {
valueDidChange(.contentHash)
valueDidChange(.isArticleExtractorAlwaysOn)
}
}
}

View File

@ -31,7 +31,7 @@ final class FeedMetadataFile {
managedFile.load()
}
func saveIfNecessary() {
func save() {
managedFile.saveIfNecessary()
}

View File

@ -728,21 +728,28 @@ private extension FeedbinAccountDelegate {
}
// Add any feeds we don't have and update any we do
var subscriptionsToAdd = Set<FeedbinSubscription>()
subscriptions.forEach { subscription in
let subFeedId = String(subscription.feedID)
if let feed = account.existingFeed(withFeedID: subFeedId) {
feed.name = subscription.name
// If the name has been changed on the server remove the locally edited name
feed.editedName = nil
feed.homePageURL = subscription.homePageURL
feed.subscriptionID = String(subscription.subscriptionID)
} else {
let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: subFeedId, homePageURL: subscription.homePageURL)
feed.subscriptionID = String(subscription.subscriptionID)
account.addFeed(feed)
}
else {
subscriptionsToAdd.insert(subscription)
}
}
// Actually add subscriptions all in one go, so we dont trigger various rebuilding things that Account does.
subscriptionsToAdd.forEach { subscription in
let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: String(subscription.feedID), homePageURL: subscription.homePageURL)
feed.subscriptionID = String(subscription.subscriptionID)
account.addFeed(feed)
}
}
@ -1053,7 +1060,6 @@ private extension FeedbinAccountDelegate {
}
func refreshArticles(_ account: Account, page: String?, completion: @escaping (() -> Void)) {
guard let page = page else {
completion()
return
@ -1073,49 +1079,23 @@ private extension FeedbinAccountDelegate {
os_log(.error, log: self.log, "Refresh articles for additional pages failed: %@.", error.localizedDescription)
completion()
}
}
}
func processEntries(account: Account, entries: [FeedbinEntry]?, completion: @escaping (() -> Void)) {
let parsedItems = mapEntriesToParsedItems(entries: entries)
let parsedMap = Dictionary(grouping: parsedItems, by: { item in item.feedURL } )
let group = DispatchGroup()
for (feedID, mapItems) in parsedMap {
group.enter()
if let feed = account.existingFeed(withFeedID: feedID) {
DispatchQueue.main.async {
account.update(feed, parsedItems: Set(mapItems), defaultRead: true) {
group.leave()
}
}
} else {
group.leave()
}
}
group.notify(queue: DispatchQueue.main) {
completion()
}
let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) }
account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true, completion: completion)
}
func mapEntriesToParsedItems(entries: [FeedbinEntry]?) -> Set<ParsedItem> {
guard let entries = entries else {
return Set<ParsedItem>()
}
let parsedItems: [ParsedItem] = entries.map { entry in
let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)])
return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: nil, externalURL: entry.url, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: authors, tags: nil, attachments: nil)
return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: nil, externalURL: entry.url, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil)
}
return Set(parsedItems)

View File

@ -10,7 +10,7 @@ import Foundation
import RSParser
import RSCore
struct FeedbinEntry: Codable {
final class FeedbinEntry: Codable {
let articleID: Int
let feedID: Int
@ -23,6 +23,19 @@ struct FeedbinEntry: Codable {
let dateArrived: String?
let jsonFeed: FeedbinEntryJSONFeed?
// Feedbin dates can't be decoded by the JSONDecoding 8601 decoding strategy. Feedbin
// requires a very specific date formatter to work and even then it fails occasionally.
// Rather than loose all the entries we only lose the one date by decoding as a string
// and letting the one date fail when parsed.
lazy var parsedDatePublished: Date? = {
if let datePublished = datePublished {
return RSDateWithString(datePublished)
}
else {
return nil
}
}()
enum CodingKeys: String, CodingKey {
case articleID = "id"
case feedID = "feed_id"
@ -35,19 +48,6 @@ struct FeedbinEntry: Codable {
case dateArrived = "created_at"
case jsonFeed = "json_feed"
}
// Feedbin dates can't be decoded by the JSONDecoding 8601 decoding strategy. Feedbin
// requires a very specific date formatter to work and even then it fails occasionally.
// Rather than loose all the entries we only lose the one date by decoding as a string
// and letting the one date fail when parsed.
func parseDatePublished() -> Date? {
if datePublished != nil {
return FeedbinDate.formatter.date(from: datePublished!)
} else {
return nil
}
}
}
struct FeedbinEntryJSONFeed: Codable {

View File

@ -10,7 +10,7 @@ import Foundation
import RSCore
import RSParser
struct FeedbinSubscription: Codable {
struct FeedbinSubscription: Hashable, Codable {
let subscriptionID: Int
let feedID: Int
@ -26,6 +26,9 @@ struct FeedbinSubscription: Codable {
case homePageURL = "site_url"
}
public func hash(into hasher: inout Hasher) {
hasher.combine(subscriptionID)
}
}
struct FeedbinCreateSubscription: Codable {

View File

@ -89,18 +89,41 @@ final class FeedlyAPICaller {
}
}
func getStream(for collection: FeedlyCollection, unreadOnly: Bool = false, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
func getStream(for collection: FeedlyCollection, newerThan: Date? = nil, unreadOnly: Bool? = nil, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
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<FeedlyStream, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/streams/contents"
components.queryItems = [
URLQueryItem(name: "streamId", value: collection.id),
URLQueryItem(name: "unreadOnly", value: unreadOnly ? "true" : "false")
]
// If you change these, check AccountFeedlySyncTest.set(testFiles:with:).
var queryItems = [URLQueryItem]()
if let date = newerThan {
let value = String(Int(date.timeIntervalSince1970 * 1000))
let queryItem = URLQueryItem(name: "newerThan", value: value)
queryItems.append(queryItem)
}
if let flag = unreadOnly {
let value = flag ? "true" : "false"
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
queryItems.append(queryItem)
}
queryItems.append(contentsOf: [
URLQueryItem(name: "count", value: "1000"),
URLQueryItem(name: "streamId", value: resource.id),
])
components.queryItems = queryItems
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
@ -111,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)):
@ -369,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<Void, Error>) -> ()) {
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 {

View File

@ -31,7 +31,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
didSet {
// https://developer.feedly.com/v3/developer/
if let devToken = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !devToken.isEmpty {
caller.credentials = Credentials(type: .oauthAccessToken, username: "", secret: devToken)
caller.credentials = Credentials(type: .oauthAccessToken, username: "Developer", secret: devToken)
} else {
caller.credentials = credentials
}
@ -44,7 +44,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
private let caller: FeedlyAPICaller
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
private let articleStatusCoodinator: FeedlyArticleStatusCoordinator
private let database: SyncDatabase
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API = .default) {
@ -70,9 +70,8 @@ final class FeedlyAccountDelegate: AccountDelegate {
caller = FeedlyAPICaller(transport: session, api: api)
}
articleStatusCoodinator = FeedlyArticleStatusCoordinator(dataFolderPath: dataFolder,
caller: caller,
log: log)
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
}
// MARK: Account API
@ -86,15 +85,20 @@ final class FeedlyAccountDelegate: AccountDelegate {
progress.addToNumberOfTasksAndRemaining(1)
syncStrategy?.startSync { result in
os_log(.debug, log: log, "Sync took %.3f seconds", -date.timeIntervalSinceNow)
DispatchQueue.main.async {
progress.completeTask()
}
progress.completeTask()
completion(result)
}
}
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
// Ensure remote articles have the same status as they do locally.
articleStatusCoodinator.sendArticleStatus(for: account, completion: completion)
let send = FeedlySendArticleStatusesOperation(database: database, caller: caller, log: log)
send.completionBlock = {
DispatchQueue.main.async {
completion()
}
}
OperationQueue.main.addOperation(send)
}
/// Attempts to ensure local articles have the same status as they do remotely.
@ -149,14 +153,21 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> 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))
@ -166,26 +177,40 @@ final class FeedlyAccountDelegate: AccountDelegate {
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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)
@ -196,42 +221,191 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
}
var createFeedRequest: FeedlyAddFeedRequest?
func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
fatalError()
let progress = refreshProgress
progress.addToNumberOfTasksAndRemaining(1)
let request = FeedlyAddFeedRequest(account: account, caller: caller, container: container, log: log)
self.createFeedRequest = request
request.addNewFeed(at: url, name: name) { [weak self] result in
progress.completeTask()
self?.createFeedRequest = nil
completion(result)
}
}
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
fatalError()
let folderCollectionIds = account.folders?.filter { $0.has(feed) }.compactMap { $0.externalID }
guard let collectionIds = folderCollectionIds, let collectionId = collectionIds.first else {
completion(.failure(FeedlyAccountDelegateError.unableToRenameFeed(feed.nameForDisplay, name)))
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, Error>) -> Void) {
fatalError()
var addFeedRequest: FeedlyAddFeedRequest?
func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
let progress = refreshProgress
progress.addToNumberOfTasksAndRemaining(1)
let request = FeedlyAddFeedRequest(account: account, caller: caller, container: container, log: log)
self.addFeedRequest = request
request.add(existing: feed) { [weak self] result in
progress.completeTask()
self?.addFeedRequest = nil
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
}
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
let acceptedStatuses = articleStatusCoodinator.articles(articles,
for: account,
didChangeStatus: statusKey,
flag: flag)
let syncStatuses = articles.map { article in
return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag)
}
return acceptedStatuses
database.insertStatuses(syncStatuses)
os_log(.debug, log: log, "Marking %@ as %@.", articles.map { $0.title }, syncStatuses)
if database.selectPendingCount() > 100 {
sendArticleStatus(for: account) { }
}
return account.update(articles, statusKey: statusKey, flag: flag)
}
func accountDidInitialize(_ account: Account) {
@ -239,13 +413,8 @@ final class FeedlyAccountDelegate: AccountDelegate {
syncStrategy = FeedlySyncStrategy(account: account,
caller: caller,
articleStatusCoordinator: articleStatusCoodinator,
database: database,
log: log)
//TODO: Figure out how other accounts get refreshed automatically.
refreshAll(for: account) { result in
print("sync after initialise did complete")
}
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void) {

View File

@ -0,0 +1,91 @@
//
// 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 unableToRenameFeed(String, String)
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 .unableToRenameFeed(let from, let to):
let template = NSLocalizedString("Could not rename \"%@\" to \"%@\".", comment: "Feedly - Could not rename a feed.")
return String(format: template, from, to)
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
case .unableToRenameFeed:
return nil
}
}
}

View File

@ -0,0 +1,63 @@
//
// FeedlyAddFeedOperation.swift
// Account
//
// Created by Kiel Gillard on 11/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
final class FeedlyAddFeedOperation: FeedlyOperation, FeedlyFeedsAndFoldersProviding, FeedlyResourceProviding {
let feedName: String?
let collectionId: String
let caller: FeedlyAPICaller
let account: Account
let folder: Folder
let feedResource: FeedlyFeedResourceId
init(account: Account, folder: Folder, feedResource: FeedlyFeedResourceId, feedName: String? = nil, collectionId: String, caller: FeedlyAPICaller) {
self.account = account
self.folder = folder
self.feedResource = feedResource
self.feedName = feedName
self.collectionId = collectionId
self.caller = caller
}
private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]()
var resource: FeedlyResourceId {
return feedResource
}
override func main() {
guard !isCancelled else {
return didFinish()
}
caller.addFeed(with: feedResource, title: feedName, toCollectionWith: collectionId) { [weak self] result in
guard let self = self else { return }
guard !self.isCancelled else { return self.didFinish() }
self.didCompleteRequest(result)
}
}
private func didCompleteRequest(_ result: Result<[FeedlyFeed], Error>) {
switch result {
case .success(let feedlyFeeds):
feedsAndFolders = [(feedlyFeeds, folder)]
let feedsWithCreatedFeedId = feedlyFeeds.filter { $0.feedId == resource.id }
if feedsWithCreatedFeedId.isEmpty {
didFinish(AccountError.createErrorNotFound)
} else {
didFinish()
}
case .failure(let error):
didFinish(error)
}
}
}

View File

@ -0,0 +1,130 @@
//
// FeedlyCreateFeedRequest.swift
// Account
//
// Created by Kiel Gillard on 10/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
final class FeedlyAddFeedRequest {
let account: Account
let caller: FeedlyAPICaller
let container: Container
let log: OSLog
init(account: Account, caller: FeedlyAPICaller, container: Container, log: OSLog) {
self.account = account
self.caller = caller
self.container = container
self.log = log
}
private class Delegate: FeedlyOperationDelegate {
let resourceProvider: FeedlyResourceProviding
init(resourceProvider: FeedlyResourceProviding) {
self.resourceProvider = resourceProvider
}
var completionHandler: ((Result<Feed, Error>) -> ())?
var error: Error?
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
self.error = error
}
}
func addNewFeed(at url: String, name: String? = nil, completion: @escaping (Result<Feed, Error>) -> Void) {
let resource = FeedlyFeedResourceId(url: url)
self.start(resource: resource, name: name, refreshes: true, completion: completion)
}
func add(existing feed: Feed, name: String? = nil, completion: @escaping (Result<Feed, Error>) -> Void) {
let resource = FeedlyFeedResourceId(id: feed.feedID)
self.start(resource: resource, name: name, refreshes: false, completion: completion)
}
private func start(resource: FeedlyFeedResourceId, name: String?, refreshes: Bool, completion: @escaping (Result<Feed, Error>) -> Void) {
let (folder, collectionId): (Folder, String)
do {
let validator = FeedlyFeedContainerValidator(container: container, userId: caller.credentials?.username)
(folder, collectionId) = try validator.getValidContainer()
} catch {
return DispatchQueue.main.async {
completion(.failure(error))
}
}
let delegate = Delegate(resourceProvider: resource)
delegate.completionHandler = completion
let createFeed = FeedlyCompoundOperation() {
let addRequest = FeedlyAddFeedOperation(account: account, folder: folder, feedResource: resource, feedName: name, collectionId: collectionId, caller: caller)
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addRequest, log: log)
createFeeds.addDependency(addRequest)
let getStream: FeedlyGetStreamOperation? = {
if refreshes {
let op = FeedlyGetStreamOperation(account: account, resourceProvider: addRequest, caller: caller, newerThan: nil)
op.addDependency(createFeeds)
return op
}
return nil
}()
let organiseByFeed: FeedlyOrganiseParsedItemsByFeedOperation? = {
if let getStream = getStream {
let op = FeedlyOrganiseParsedItemsByFeedOperation(account: account, entryProvider: getStream, log: log)
op.addDependency(getStream)
return op
}
return nil
}()
let updateAccount: FeedlyUpdateAccountFeedsWithItemsOperation? = {
if let organiseByFeed = organiseByFeed {
let op = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: organiseByFeed, log: log)
op.addDependency(organiseByFeed)
return op
}
return nil
}()
let operations = [addRequest, createFeeds, getStream, organiseByFeed, updateAccount].compactMap { $0 }
for operation in operations {
assert(operation.isReady == (operation === addRequest), "Only the add request operation should be ready.")
operation.delegate = delegate
}
return operations
}
let callback = BlockOperation() {
guard let handler = delegate.completionHandler else {
return
}
defer { delegate.completionHandler = nil }
if let error = delegate.error {
handler(.failure(error))
} else if let feed = folder.existingFeed(withFeedID: resource.id) {
handler(.success(feed))
} else {
handler(.failure(AccountError.createErrorNotFound))
}
}
callback.addDependency(createFeed)
OperationQueue.main.addOperations([createFeed, callback], waitUntilFinished: false)
}
}

View File

@ -1,125 +0,0 @@
//
// FeedlyArticleStatusCoordinator.swift
// Account
//
// Created by Kiel Gillard on 24/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import SyncDatabase
import Articles
import os.log
final class FeedlyArticleStatusCoordinator {
private let database: SyncDatabase
private let log: OSLog
private let caller: FeedlyAPICaller
init(dataFolderPath: String, caller: FeedlyAPICaller, log: OSLog) {
let databaseFilePath = (dataFolderPath as NSString).appendingPathComponent("Sync.sqlite3")
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
self.log = log
self.caller = caller
}
/// Stores a status for a particular article locally.
func articles(_ articles: Set<Article>, for account: Account, didChangeStatus statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
let syncStatuses = articles.map { article in
return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag)
}
database.insertStatuses(syncStatuses)
os_log(.debug, log: log, "Marking %@ as %@.", articles.map { $0.title }, syncStatuses)
if database.selectPendingCount() > 100 {
sendArticleStatus(for: account)
}
return account.update(articles, statusKey: statusKey, flag: flag)
}
/// Ensures local articles have the same status as they do remotely.
func refreshArticleStatus(for account: Account, stream: FeedlyStream, collection: FeedlyCollection, completion: @escaping (() -> Void)) {
guard let folder = account.existingFolder(with: collection.label) else {
completion()
return
}
let unreadArticleIds = Set(
stream.items
.filter { $0.unread }
.map { $0.id }
)
let localArticles = folder.fetchArticles()
let localArticleIds = localArticles.articleIDs()
let readArticleIds = localArticleIds.subtracting(unreadArticleIds)
account.update(localArticles.filter { readArticleIds.contains($0.articleID) }, statusKey: .read, flag: true)
// account.ensureStatuses(readArticleIds, true, .read, true)
account.update(localArticles.filter { unreadArticleIds.contains($0.articleID) }, statusKey: .read, flag: false)
// account.ensureStatuses(unreadArticleIds, false, .read, false)
os_log(.debug, log: log, "Ensured %i UNREAD and %i read article(s) in \"%@\".", unreadArticleIds.count, readArticleIds.count, collection.label)
completion()
// TODO: starred
// group.enter()
// caller.retrieveStarredEntries() { result in
// switch result {
// case .success(let articleIDs):
// self.syncArticleStarredState(account: account, articleIDs: articleIDs)
// group.leave()
// case .failure(let error):
// os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription)
// group.leave()
// }
//
// }
}
/// Ensures remote articles have the same status as they do locally.
func sendArticleStatus(for account: Account, completion: (() -> Void)? = nil) {
os_log(.debug, log: log, "Sending article statuses...")
let pending = database.selectForProcessing()
let statuses: [(status: ArticleStatus.Key, flag: Bool, action: FeedlyAPICaller.MarkAction)] = [
(.read, false, .unread),
(.read, true, .read),
(.starred, true, .saved),
(.starred, false, .unsaved),
]
let group = DispatchGroup()
for pairing in statuses {
let articleIds = pending.filter { $0.key == pairing.status && $0.flag == pairing.flag }
guard !articleIds.isEmpty else {
continue
}
let ids = Set(articleIds.map { $0.articleID })
let database = self.database
group.enter()
caller.mark(ids, as: pairing.action) { result in
switch result {
case .success:
database.deleteSelectedForProcessing(Array(ids))
case .failure:
database.resetSelectedForProcessing(Array(ids))
}
group.leave()
}
}
group.notify(queue: DispatchQueue.main) {
os_log(.debug, log: self.log, "Done sending article statuses.")
completion?()
}
}
}

View File

@ -0,0 +1,45 @@
//
// FeedlyCompoundOperation.swift
// Account
//
// Created by Kiel Gillard on 10/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
/// An operation with a queue of its own.
final class FeedlyCompoundOperation: FeedlyOperation {
private let operationQueue = OperationQueue()
private let operations: [Operation]
init(operations: [Operation]) {
assert(!operations.isEmpty)
self.operations = operations
}
convenience init(operationsBlock: () -> ([Operation])) {
let operations = operationsBlock()
self.init(operations: operations)
}
override func main() {
let finishOperation = BlockOperation { [weak self] in
self?.didFinish()
}
for operation in operations {
finishOperation.addDependency(operation)
}
var operationsWithFinish = operations
operationsWithFinish.append(finishOperation)
operationQueue.addOperations(operationsWithFinish, waitUntilFinished: false)
}
override func cancel() {
operationQueue.cancelAllOperations()
super.cancel()
}
}

View File

@ -0,0 +1,36 @@
//
// FeedlyFeedContainerValidator.swift
// Account
//
// Created by Kiel Gillard on 10/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyFeedContainerValidator {
var container: Container
var userId: String?
func getValidContainer() 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 = userId else {
throw FeedlyAccountDelegateError.notLoggedIn
}
let uncategorized = FeedlyCategoryResourceId.uncategorized(for: userId)
guard collectionId != uncategorized.id else {
throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder)
}
return (folder, collectionId)
}
}

View File

@ -24,7 +24,6 @@ class FeedlyOperation: Operation {
}
func didFinish(_ error: Error) {
assert(delegate != nil)
delegate?.feedlyOperation(self, didFailWith: error)
didFinish()
}

View File

@ -0,0 +1,20 @@
//
// FeedlyResourceProviding.swift
// Account
//
// Created by Kiel Gillard on 11/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyResourceProviding {
var resource: FeedlyResourceId { get }
}
extension FeedlyFeedResourceId: FeedlyResourceProviding {
var resource: FeedlyResourceId {
return self
}
}

View File

@ -48,9 +48,13 @@ struct FeedlyEntry: Decodable {
/// the feed from which this article was crawled. If present, streamId will contain the feed id, title will contain the feed title, and htmlUrl will contain the feeds website.
var origin: FeedlyOrigin?
//
// /// a list of alternate links for this article. Each link object contains a media type and a URL. Typically, a single object is present, with a link to the original web page.
// var alternate: [Link]?
/// Used to help find the URL to visit an article on a web site.
/// See https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
var canonical: [FeedlyLink]?
/// a list of alternate links for this article. Each link object contains a media type and a URL. Typically, a single object is present, with a link to the original web page.
var alternate: [FeedlyLink]?
//
// // var origin:
// // Optional origin object the feed from which this article was crawled. If present, streamId will contain the feed id, title will contain the feed title, and htmlUrl will contain the feeds website.
@ -62,8 +66,8 @@ struct FeedlyEntry: Decodable {
/// Was this entry read by the user? If an Authorization header is not provided, this will always return false. If an Authorization header is provided, it will reflect if the user has read this entry or not.
var unread: Bool
//
// /// a list of tag objects (id and label) that the user added to this entry. This value is only returned if an Authorization header is provided, and at least one tag has been added. If the entry has been explicitly marked as read (not the feed itself), the global.read tag will be present.
// var tags: [Tag]?
/// a list of tag objects (id and label) that the user added to this entry. This value is only returned if an Authorization header is provided, and at least one tag has been added. If the entry has been explicitly marked as read (not the feed itself), the global.read tag will be present.
var tags: [FeedlyTag]?
//
/// a list of category objects (id and label) that the user associated with the feed of this entry. This value is only returned if an Authorization header is provided.
var categories: [FeedlyCategory]?
@ -74,8 +78,8 @@ struct FeedlyEntry: Decodable {
// /// Timestamp for tagged articles, contains the timestamp when the article was tagged by the user. This will only be returned when the entry is returned through the streams API.
// var actionTimestamp: Date?
//
// /// A list of media links (videos, images, sound etc) provided by the feed. Some entries do not have a summary or content, only a collection of media links.
// var enclosure: [Link]?
/// A list of media links (videos, images, sound etc) provided by the feed. Some entries do not have a summary or content, only a collection of media links.
var enclosure: [FeedlyLink]?
//
// /// The article fingerprint. This value might change if the article is updated.
// var fingerprint: String

View File

@ -0,0 +1,103 @@
//
// FeedlyEntryParser.swift
// Account
//
// Created by Kiel Gillard on 3/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import Articles
import RSParser
struct FeedlyEntryParser {
var entry: FeedlyEntry
var id: String {
return entry.id
}
var feedUrl: String {
guard let id = entry.origin?.streamId else {
assertionFailure()
return ""
}
return id
}
/// Convoluted external URL logic "documented" here:
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
var externalUrl: String? {
let multidimensionalArrayOfLinks = [entry.canonical, entry.alternate]
let withExistingValues = multidimensionalArrayOfLinks.compactMap { $0 }
let flattened = withExistingValues.flatMap { $0 }
let webPageLinks = flattened.filter { $0.type == nil || $0.type == "text/html" }
return webPageLinks.first?.href
}
var title: String? {
return entry.title
}
var contentHMTL: String? {
return entry.content?.content ?? entry.summary?.content
}
var contentText: String? {
// We could strip HTML from contentHTML?
return nil
}
var summary: String? {
return entry.summary?.content
}
var datePublished: Date {
return entry.published
}
var dateModified: Date? {
return entry.updated
}
var authors: Set<ParsedAuthor>? {
guard let name = entry.author else {
return nil
}
return Set([ParsedAuthor(name: name, url: nil, avatarURL: nil, emailAddress: nil)])
}
var tags: Set<String>? {
guard let labels = entry.tags?.compactMap({ $0.label }), !labels.isEmpty else {
return nil
}
return Set(labels)
}
var attachments: Set<ParsedAttachment>? {
guard let enclosure = entry.enclosure, !enclosure.isEmpty else {
return nil
}
let attachments = enclosure.compactMap { ParsedAttachment(url: $0.href, mimeType: $0.type, title: nil, sizeInBytes: nil, durationInSeconds: nil) }
return attachments.isEmpty ? nil : Set(attachments)
}
var parsedItemRepresentation: ParsedItem {
return ParsedItem(syncServiceID: id,
uniqueID: id,
feedURL: feedUrl,
url: nil,
externalURL: externalUrl,
title: title,
contentHTML: contentHMTL,
contentText: contentText,
summary: summary,
imageURL: nil,
bannerImageURL: nil,
datePublished: datePublished,
dateModified: dateModified,
authors: authors,
tags: tags,
attachments: attachments)
}
}

View File

@ -13,4 +13,5 @@ struct FeedlyFeed: Codable {
var id: String
var title: String
var updated: Date?
var website: String?
}

View File

@ -0,0 +1,18 @@
//
// FeedlyLink.swift
// Account
//
// Created by Kiel Gillard on 3/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyLink: Decodable {
var href: String
/// The mime type of the resource located by `href`.
/// When `nil`, it's probably a web page?
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
var type: String?
}

View File

@ -0,0 +1,61 @@
//
// FeedlyResourceId.swift
// Account
//
// Created by Kiel Gillard on 3/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
/// The kinds of Resource Ids is documented here: https://developer.feedly.com/cloud/
protocol FeedlyResourceId {
/// The resource Id from Feedly.
var id: 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
mutant.removeSubrange(range)
return mutant
}
// It seems values like "something/https://my.blog/posts.xml" is a legit URL.
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)
}
}
struct FeedlyTagResourceId: FeedlyResourceId {
var id: String
static func saved(for userId: String) -> FeedlyTagResourceId {
// https://developer.feedly.com/cloud/#global-resource-ids
let id = "user/\(userId)/tag/global.saved"
return FeedlyTagResourceId(id: id)
}
}

View File

@ -0,0 +1,14 @@
//
// FeedlyTag.swift
// Account
//
// Created by Kiel Gillard on 3/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyTag: Decodable {
var id: String
var label: String?
}

View File

@ -13,11 +13,11 @@ import os.log
final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
let account: Account
let collectionsAndFoldersProvider: FeedlyCollectionsAndFoldersProviding
let feedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding
let log: OSLog
init(account: Account, collectionsAndFoldersProvider: FeedlyCollectionsAndFoldersProviding, log: OSLog) {
self.collectionsAndFoldersProvider = collectionsAndFoldersProvider
init(account: Account, feedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding, log: OSLog) {
self.feedsAndFoldersProvider = feedsAndFoldersProvider
self.account = account
self.log = log
}
@ -27,12 +27,28 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
guard !isCancelled else { return }
var localFeeds = account.flattenedFeeds()
let feedsBefore = localFeeds
let pairs = collectionsAndFoldersProvider.collectionsAndFolders
let pairs = feedsAndFoldersProvider.feedsAndFolders
let feedsBefore = Set(pairs
.map { $0.1 }
.flatMap { $0.topLevelFeeds })
// Remove feeds in a folder which are not in the corresponding collection.
for (collectionFeeds, folder) in pairs {
let feedsInFolder = folder.topLevelFeeds
let feedsInCollection = Set(collectionFeeds.map { $0.id })
let feedsToRemove = feedsInFolder.filter { !feedsInCollection.contains($0.feedID) }
if !feedsToRemove.isEmpty {
folder.removeFeeds(feedsToRemove)
// os_log(.debug, log: log, "\"%@\" - removed: %@", collection.label, feedsToRemove.map { $0.feedID }, feedsInCollection)
}
}
// Pair each Feed with its Folder.
var feedsAdded = Set<Feed>()
let feedsAndFolders = pairs
.compactMap { ($0.0.feeds, $0.1) }
.map({ (collectionFeeds, folder) -> [(FeedlyFeed, Folder)] in
return collectionFeeds.map { feed -> (FeedlyFeed, Folder) in
return (feed, folder) // pairs a folder for every feed in parallel
@ -41,23 +57,23 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
.flatMap { $0 }
.compactMap { (collectionFeed, folder) -> (Feed, Folder) in
// find an existing feed
for feed in localFeeds {
if feed.feedID == collectionFeed.feedId {
// find an existing feed previously added to the account
if let feed = account.existingFeed(withFeedID: collectionFeed.id) {
return (feed, folder)
} else {
// find an existing feed we created below in an earlier value
for feed in feedsAdded where feed.feedID == collectionFeed.id {
return (feed, folder)
}
}
// no exsiting feed, create a new one
let url = collectionFeed.id
let metadata = FeedMetadata(feedID: url)
// TODO: More metadata
let feed = Feed(account: account, url: url, metadata: metadata)
feed.name = collectionFeed.title
let id = collectionFeed.id
let url = FeedlyFeedResourceId(id: id).url
let feed = account.createFeed(with: collectionFeed.title, url: url, feedID: id, homePageURL: collectionFeed.website)
// So the same feed isn't created more than once.
localFeeds.insert(feed)
feedsAdded.insert(feed)
return (feed, folder)
}
@ -69,11 +85,10 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
}
}
// Remove feeds without folders/collections.
let feedsAfter = Set(feedsAndFolders.map { $0.0 })
let feedsWithoutCollections = feedsBefore.subtracting(feedsAfter)
for unmatched in feedsWithoutCollections {
account.removeFeed(unmatched)
}
account.removeFeeds(feedsWithoutCollections)
if !feedsWithoutCollections.isEmpty {
os_log(.debug, log: log, "Removed %i feeds", feedsWithoutCollections.count)

View File

@ -1,59 +0,0 @@
//
// FeedlyGetCollectionStreamOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyCollectionStreamProviding: class {
var collection: FeedlyCollection { get }
var stream: FeedlyStream { get }
}
/// Single responsibility is to get the stream content of a Collection from Feedly.
final class FeedlyGetCollectionStreamOperation: FeedlyOperation, FeedlyCollectionStreamProviding {
private(set) var collection: FeedlyCollection
var stream: FeedlyStream {
guard let stream = storedStream else {
// TODO: this is probably more error prone than it seems!
fatalError("\(type(of: self)) has been told to finish too early or a dependency is ignoring cancellation.")
}
return stream
}
private var storedStream: FeedlyStream?
let account: Account
let caller: FeedlyAPICaller
let unreadOnly: Bool
init(account: Account, collection: FeedlyCollection, caller: FeedlyAPICaller, unreadOnly: Bool = false) {
self.account = account
self.collection = collection
self.caller = caller
self.unreadOnly = unreadOnly
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
//TODO: Use account metadata to get articles newer than some date.
caller.getStream(for: collection, unreadOnly: unreadOnly) { result in
switch result {
case .success(let stream):
self.storedStream = stream
self.didFinish()
case .failure(let error):
self.didFinish(error)
}
}
}
}

View File

@ -0,0 +1,92 @@
//
// FeedlyGetStreamOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSParser
protocol FeedlyEntryProviding: class {
var resource: FeedlyResourceId { get }
var entries: [FeedlyEntry] { get }
var parsedEntries: Set<ParsedItem> { get }
}
/// Single responsibility is to get the stream content of a Collection from Feedly.
final class FeedlyGetStreamOperation: FeedlyOperation, FeedlyEntryProviding {
struct ResourceProvider: FeedlyResourceProviding {
var resource: FeedlyResourceId
}
let resourceProvider: FeedlyResourceProviding
var resource: FeedlyResourceId {
return resourceProvider.resource
}
var entries: [FeedlyEntry] {
guard let entries = storedStream?.items else {
assertionFailure("Has a prior operation finished too early? Is the operation included in \(self.dependencies)?")
return []
}
return entries
}
var parsedEntries: Set<ParsedItem> {
if let entries = storedParsedEntries {
return entries
}
let parsed = Set(entries.map { FeedlyEntryParser(entry: $0).parsedItemRepresentation })
storedParsedEntries = parsed
return parsed
}
private var storedStream: FeedlyStream? {
didSet {
storedParsedEntries = nil
}
}
private var storedParsedEntries: Set<ParsedItem>?
let account: Account
let caller: FeedlyAPICaller
let unreadOnly: Bool?
let newerThan: Date?
init(account: Account, resource: FeedlyResourceId, caller: FeedlyAPICaller, newerThan: Date?, unreadOnly: Bool? = nil) {
self.account = account
self.resourceProvider = ResourceProvider(resource: resource)
self.caller = caller
self.unreadOnly = unreadOnly
self.newerThan = newerThan
}
convenience init(account: Account, resourceProvider: FeedlyResourceProviding, caller: FeedlyAPICaller, newerThan: Date?, unreadOnly: Bool? = nil) {
self.init(account: account, resource: resourceProvider.resource, caller: caller, newerThan: newerThan, unreadOnly: unreadOnly)
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
caller.getStream(for: resourceProvider.resource, newerThan: newerThan, unreadOnly: unreadOnly) { result in
switch result {
case .success(let stream):
self.storedStream = stream
self.didFinish()
case .failure(let error):
self.didFinish(error)
}
}
}
}

View File

@ -1,77 +0,0 @@
//
// FeedlyGetStreamParsedItemsOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSParser
import os.log
protocol FeedlyStreamParsedItemsProviding: class {
var collection: FeedlyCollection { get }
var stream: FeedlyStream { get }
var parsedItems: [ParsedItem] { get }
}
/// Single responsibility is to model articles as ParsedItems for entries in a Collection's stream from Feedly.
final class FeedlyGetStreamParsedItemsOperation: FeedlyOperation, FeedlyStreamParsedItemsProviding {
private let account: Account
private let caller: FeedlyAPICaller
private let collectionStreamProvider: FeedlyCollectionStreamProviding
private let log: OSLog
var collection: FeedlyCollection {
return collectionStreamProvider.collection
}
var stream: FeedlyStream {
return collectionStreamProvider.stream
}
private(set) var parsedItems = [ParsedItem]()
init(account: Account, collectionStreamProvider: FeedlyCollectionStreamProviding, caller: FeedlyAPICaller, log: OSLog) {
self.account = account
self.caller = caller
self.collectionStreamProvider = collectionStreamProvider
self.log = log
}
override func main() {
defer { didFinish() }
guard !isCancelled else { return }
parsedItems = stream.items.compactMap { entry -> ParsedItem? in
guard let origin = entry.origin else {
// Assertion might be too heavy handed here as our understanding of the data quality from Feedly grows.
print("Entry has no origin and no way for us to figure out which feed it should belong to: \(entry)")
return nil
}
// TODO: Sensible values here.
let parsed = ParsedItem(syncServiceID: entry.id,
uniqueID: entry.id,
feedURL: origin.streamId,
url: nil,
externalURL: origin.htmlUrl,
title: entry.title,
contentHTML: entry.content?.content,
contentText: nil, // Seems there is no corresponding field in the JSON, so we might have to derive a value.
summary: nil,
imageURL: nil,
bannerImageURL: nil,
datePublished: entry.published,
dateModified: entry.updated,
authors: nil,
tags: nil,
attachments: nil)
return parsed
}
os_log(.debug, log: log, "Parsed %i items of %i entries for %@", parsedItems.count, stream.items.count, collection.label)
}
}

View File

@ -13,8 +13,12 @@ protocol FeedlyCollectionsAndFoldersProviding: class {
var collectionsAndFolders: [(FeedlyCollection, Folder)] { get }
}
protocol FeedlyFeedsAndFoldersProviding {
var feedsAndFolders: [([FeedlyFeed], Folder)] { get }
}
/// Single responsibility is accurately reflect Collections from Feedly as Folders.
final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyCollectionsAndFoldersProviding {
final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyCollectionsAndFoldersProviding, FeedlyFeedsAndFoldersProviding {
let caller: FeedlyAPICaller
let account: Account
@ -22,6 +26,7 @@ final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyCo
let log: OSLog
private(set) var collectionsAndFolders = [(FeedlyCollection, Folder)]()
private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]()
init(account: Account, collectionsProvider: FeedlyCollectionProviding, caller: FeedlyAPICaller, log: OSLog) {
self.collectionsProvider = collectionsProvider
@ -50,6 +55,10 @@ final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyCo
collectionsAndFolders = pairs
os_log(.debug, log: log, "Ensured %i folders for %i collections.", pairs.count, collections.count)
feedsAndFolders = pairs.map { (collection, folder) -> (([FeedlyFeed], Folder)) in
return (collection.feeds, folder)
}
// Remove folders without a corresponding collection
let collectionFolders = Set(pairs.map { $0.1 })
let foldersWithoutCollections = localFolders.subtracting(collectionFolders)

View File

@ -11,8 +11,7 @@ import RSParser
import os.log
protocol FeedlyParsedItemsByFeedProviding {
var collection: FeedlyCollection { get }
var stream: FeedlyStream { get }
var providerName: String { get }
var allFeeds: Set<Feed> { get }
func parsedItems(for feed: Feed) -> Set<ParsedItem>?
}
@ -20,31 +19,29 @@ protocol FeedlyParsedItemsByFeedProviding {
/// Single responsibility is to group articles by their feeds.
final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyParsedItemsByFeedProviding {
private let account: Account
private let parsedItemsProvider: FeedlyStreamParsedItemsProviding
private let entryProvider: FeedlyEntryProviding
private let log: OSLog
var allFeeds: Set<Feed> {
assert(Thread.isMainThread) // Needs to be on main thread because Feed is a main-thread-only model type.
let keys = Set(itemsKeyedByFeedId.keys)
return account.flattenedFeeds().filter { keys.contains($0.feedID) }
}
func parsedItems(for feed: Feed) -> Set<ParsedItem>? {
assert(Thread.isMainThread) // Needs to be on main thread because Feed is a main-thread-only model type.
return itemsKeyedByFeedId[feed.feedID]
}
var collection: FeedlyCollection {
return parsedItemsProvider.collection
}
var stream: FeedlyStream {
return parsedItemsProvider.stream
var providerName: String {
return entryProvider.resource.id
}
private var itemsKeyedByFeedId = [String: Set<ParsedItem>]()
init(account: Account, parsedItemsProvider: FeedlyStreamParsedItemsProviding, log: OSLog) {
init(account: Account, entryProvider: FeedlyEntryProviding, log: OSLog) {
self.account = account
self.parsedItemsProvider = parsedItemsProvider
self.entryProvider = entryProvider
self.log = log
}
@ -53,7 +50,7 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar
guard !isCancelled else { return }
let items = parsedItemsProvider.parsedItems
let items = entryProvider.parsedEntries
var dict = [String: Set<ParsedItem>](minimumCapacity: items.count)
for item in items {
@ -71,7 +68,7 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar
guard !isCancelled else { return }
}
os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, parsedItemsProvider.collection.label)
os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, entryProvider.resource.id)
itemsKeyedByFeedId = dict
}

View File

@ -12,14 +12,12 @@ import os.log
/// Single responsibility is to update the read status of articles stored locally with the unread status of the entries in a Collection's stream from Feedly.
final class FeedlyRefreshStreamEntriesStatusOperation: FeedlyOperation {
private let account: Account
private let collectionStreamProvider: FeedlyCollectionStreamProviding
private let entryProvider: FeedlyEntryProviding
private let log: OSLog
let articleStatusCoordinator: FeedlyArticleStatusCoordinator
init(account: Account, collectionStreamProvider: FeedlyCollectionStreamProviding, articleStatusCoordinator: FeedlyArticleStatusCoordinator, log: OSLog) {
init(account: Account, entryProvider: FeedlyEntryProviding, log: OSLog) {
self.account = account
self.articleStatusCoordinator = articleStatusCoordinator
self.collectionStreamProvider = collectionStreamProvider
self.entryProvider = entryProvider
self.log = log
}
@ -29,10 +27,23 @@ final class FeedlyRefreshStreamEntriesStatusOperation: FeedlyOperation {
return
}
let collection = collectionStreamProvider.collection
let stream = collectionStreamProvider.stream
articleStatusCoordinator.refreshArticleStatus(for: account, stream: stream, collection: collection) {
self.didFinish()
}
let entries = entryProvider.entries
let unreadArticleIds = Set(entries.filter { $0.unread }.map { $0.id })
// Mark articles as unread
let currentUnreadArticleIDs = account.fetchUnreadArticleIDs()
let deltaUnreadArticleIDs = unreadArticleIds.subtracting(currentUnreadArticleIDs)
let markUnreadArticles = account.fetchArticles(.articleIDs(deltaUnreadArticleIDs))
account.update(markUnreadArticles, statusKey: .read, flag: false)
let readAritcleIds = Set(entries.filter { !$0.unread }.map { $0.id })
let deltaReadArticleIDs = currentUnreadArticleIDs.intersection(readAritcleIds)
let markReadArticles = account.fetchArticles(.articleIDs(deltaReadArticleIDs))
account.update(markReadArticles, statusKey: .read, flag: true)
// os_log(.debug, log: log, "\"%@\" - updated %i UNREAD and %i read article(s).", collection.label, unreadArticleIds.count, markReadArticles.count)
didFinish()
}
}

View File

@ -10,7 +10,7 @@ import Foundation
import os.log
protocol FeedlyRequestStreamsOperationDelegate: class {
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetCollectionStreamOperation)
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetStreamOperation)
}
/// Single responsibility is to create one stream request operation for one Feedly collection.
@ -23,11 +23,15 @@ final class FeedlyRequestStreamsOperation: FeedlyOperation {
let caller: FeedlyAPICaller
let account: Account
let log: OSLog
let newerThan: Date?
let unreadOnly: Bool?
init(account: Account, collectionsProvider: FeedlyCollectionProviding, caller: FeedlyAPICaller, log: OSLog) {
init(account: Account, collectionsProvider: FeedlyCollectionProviding, newerThan: Date?, unreadOnly: Bool?, caller: FeedlyAPICaller, log: OSLog) {
self.account = account
self.caller = caller
self.collectionsProvider = collectionsProvider
self.newerThan = newerThan
self.unreadOnly = unreadOnly
self.log = log
}
@ -41,7 +45,12 @@ final class FeedlyRequestStreamsOperation: FeedlyOperation {
// TODO: Prioritise the must read collection/category before others so the most important content for the user loads first.
for collection in collectionsProvider.collections {
let operation = FeedlyGetCollectionStreamOperation(account: account, collection: collection, caller: caller)
let resource = FeedlyCategoryResourceId(id: collection.id)
let operation = FeedlyGetStreamOperation(account: account,
resource: resource,
caller: caller,
newerThan: newerThan,
unreadOnly: unreadOnly)
queueDelegate?.feedlyRequestStreamsOperation(self, enqueue: operation)
}

View File

@ -0,0 +1,71 @@
//
// FeedlySendArticleStatusesOperation.swift
// Account
//
// Created by Kiel Gillard on 14/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import Articles
import SyncDatabase
import os.log
/// Single responsibility is to update or ensure articles from the entry provider are the only starred articles.
final class FeedlySendArticleStatusesOperation: FeedlyOperation {
private let database: SyncDatabase
private let log: OSLog
private let caller: FeedlyAPICaller
init(database: SyncDatabase, caller: FeedlyAPICaller, log: OSLog) {
self.database = database
self.caller = caller
self.log = log
}
override func main() {
defer { didFinish() }
guard !isCancelled else {
return
}
os_log(.debug, log: log, "Sending article statuses...")
let pending = database.selectForProcessing()
let statuses: [(status: ArticleStatus.Key, flag: Bool, action: FeedlyAPICaller.MarkAction)] = [
(.read, false, .unread),
(.read, true, .read),
(.starred, true, .saved),
(.starred, false, .unsaved),
]
let group = DispatchGroup()
for pairing in statuses {
let articleIds = pending.filter { $0.key == pairing.status && $0.flag == pairing.flag }
guard !articleIds.isEmpty else {
continue
}
let ids = Set(articleIds.map { $0.articleID })
let database = self.database
group.enter()
caller.mark(ids, as: pairing.action) { result in
switch result {
case .success:
database.deleteSelectedForProcessing(Array(ids))
case .failure:
database.resetSelectedForProcessing(Array(ids))
}
group.leave()
}
}
group.notify(queue: DispatchQueue.main) {
os_log(.debug, log: self.log, "Done sending article statuses.")
self.didFinish()
}
}
}

View File

@ -0,0 +1,54 @@
//
// FeedlySetStarredArticlesOperation.swift
// Account
//
// Created by Kiel Gillard on 14/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
/// Single responsibility is to update or ensure articles from the entry provider are the only starred articles.
final class FeedlySetStarredArticlesOperation: FeedlyOperation {
private let account: Account
private let allStarredEntriesProvider: FeedlyEntryProviding
private let log: OSLog
init(account: Account, allStarredEntriesProvider: FeedlyEntryProviding, log: OSLog) {
self.account = account
self.allStarredEntriesProvider = allStarredEntriesProvider
self.log = log
}
override func main() {
defer { didFinish() }
guard !isCancelled else {
return
}
let remoteStarredArticleIds = Set(allStarredEntriesProvider.entries.map { $0.id })
let localStarredArticleIDs = account.fetchStarredArticleIDs()
// Mark articles as starred
let deltaStarredArticleIDs = remoteStarredArticleIds.subtracting(localStarredArticleIDs)
let markStarredArticles = account.fetchArticles(.articleIDs(deltaStarredArticleIDs))
account.update(markStarredArticles, statusKey: .starred, flag: true)
// Save any starred statuses for articles we haven't yet received
let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID })
let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs)
account.ensureStatuses(missingStarredArticleIDs, true, .starred, true)
// Mark articles as unstarred
let deltaUnstarredArticleIDs = localStarredArticleIDs.subtracting(remoteStarredArticleIds)
let markUnstarredArticles = account.fetchArticles(.articleIDs(deltaUnstarredArticleIDs))
account.update(markUnstarredArticles, statusKey: .starred, flag: false)
// Save any unstarred statuses for articles we haven't yet received
let markUnstarredArticleIDs = Set(markUnstarredArticles.map { $0.articleID })
let missingUnstarredArticleIDs = deltaUnstarredArticleIDs.subtracting(markUnstarredArticleIDs)
account.ensureStatuses(missingUnstarredArticleIDs, true, .starred, false)
}
}

View File

@ -0,0 +1,105 @@
//
// FeedlySyncStarredArticlesOperation.swift
// Account
//
// Created by Kiel Gillard on 15/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
final class FeedlySyncStarredArticlesOperation: FeedlyOperation {
private let account: Account
private let operationQueue: OperationQueue
private let caller: FeedlyAPICaller
private let log: OSLog
init(account: Account, caller: FeedlyAPICaller, log: OSLog) {
self.account = account
self.caller = caller
self.operationQueue = OperationQueue()
self.log = log
}
override func cancel() {
operationQueue.cancelAllOperations()
super.cancel()
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
guard let user = caller.credentials?.username else {
didFinish(FeedlyAccountDelegateError.notLoggedIn)
return
}
class Delegate: FeedlyOperationDelegate {
var error: Error?
weak var compoundOperation: FeedlyCompoundOperation?
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
compoundOperation?.cancel()
self.error = error
}
}
let delegate = Delegate()
let syncSaved = FeedlyCompoundOperation {
let saved = FeedlyTagResourceId.saved(for: user)
os_log(.debug, log: log, "Getting starred articles from \"%@\".", saved.id)
let getSavedStream = FeedlyGetStreamOperation(account: account,
resource: saved,
caller: caller,
newerThan: nil)
getSavedStream.delegate = delegate
// set statuses
let setStatuses = FeedlySetStarredArticlesOperation(account: account,
allStarredEntriesProvider: getSavedStream,
log: log)
setStatuses.delegate = delegate
setStatuses.addDependency(getSavedStream)
// ingest articles
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account,
entryProvider: getSavedStream,
log: log)
organiseByFeed.delegate = delegate
organiseByFeed.addDependency(setStatuses)
let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account,
organisedItemsProvider: organiseByFeed,
log: log)
updateAccount.delegate = delegate
updateAccount.addDependency(organiseByFeed)
return [getSavedStream, setStatuses, organiseByFeed, updateAccount]
}
delegate.compoundOperation = syncSaved
let finalOperation = BlockOperation { [weak self] in
guard let self = self else {
return
}
if let error = delegate.error {
self.didFinish(error)
} else {
self.didFinish()
}
os_log(.debug, log: self.log, "Done syncing starred articles.")
}
finalOperation.addDependency(syncSaved)
operationQueue.addOperations([syncSaved, finalOperation], waitUntilFinished: false)
}
}

View File

@ -8,21 +8,22 @@
import Foundation
import os.log
import SyncDatabase
final class FeedlySyncStrategy {
let account: Account
let caller: FeedlyAPICaller
let operationQueue: OperationQueue
let articleStatusCoordinator: FeedlyArticleStatusCoordinator
let database: SyncDatabase
let log: OSLog
init(account: Account, caller: FeedlyAPICaller, articleStatusCoordinator: FeedlyArticleStatusCoordinator, log: OSLog) {
init(account: Account, caller: FeedlyAPICaller, database: SyncDatabase, log: OSLog) {
self.account = account
self.caller = caller
self.operationQueue = OperationQueue()
self.log = log
self.articleStatusCoordinator = articleStatusCoordinator
self.database = database
}
func cancel() {
@ -32,6 +33,14 @@ final class FeedlySyncStrategy {
private var startSyncCompletionHandler: ((Result<Void, Error>) -> ())?
private var newerThan: Date? {
if let date = account.metadata.lastArticleFetch {
return date
} else {
return Calendar.current.date(byAdding: .day, value: -31, to: Date())
}
}
/// The truth is in the cloud.
func startSync(completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard operationQueue.operationCount == 0 else {
@ -40,9 +49,14 @@ final class FeedlySyncStrategy {
return
}
let sendArticleStatuses = FeedlySendArticleStatusesOperation(database: database, caller: caller, log: log)
sendArticleStatuses.delegate = self
// Since the truth is in the cloud, everything hinges of what Collections the user has.
let getCollections = FeedlyGetCollectionsOperation(caller: caller, log: log)
getCollections.delegate = self
getCollections.addDependency(sendArticleStatuses)
// Ensure a folder exists for each Collection, removing Folders without a corresponding Collection.
let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account,
@ -54,7 +68,7 @@ final class FeedlySyncStrategy {
// Ensure feeds are created and grouped by their folders.
let createFeedsOperation = FeedlyCreateFeedsForCollectionFoldersOperation(account: account,
collectionsAndFoldersProvider: mirrorCollectionsAsFolders,
feedsAndFoldersProvider: mirrorCollectionsAsFolders,
log: log)
createFeedsOperation.delegate = self
createFeedsOperation.addDependency(mirrorCollectionsAsFolders)
@ -63,34 +77,49 @@ final class FeedlySyncStrategy {
// Get the streams for each Collection. It will call back to enqueue more operations.
let getCollectionStreams = FeedlyRequestStreamsOperation(account: account,
collectionsProvider: getCollections,
newerThan: newerThan,
unreadOnly: false,
caller: caller,
log: log)
getCollectionStreams.delegate = self
getCollectionStreams.queueDelegate = self
getCollectionStreams.addDependency(getCollections)
let syncStarred = FeedlySyncStarredArticlesOperation(account: account, caller: caller, log: log)
syncStarred.addDependency(getCollections)
syncStarred.addDependency(mirrorCollectionsAsFolders)
syncStarred.addDependency(createFeedsOperation)
// Last operation to perform, which should be dependent on any other operation added to the queue.
let syncId = UUID().uuidString
let lastArticleFetchDate = Date()
let completionOperation = BlockOperation { [weak self] in
if let self = self {
os_log(.debug, log: self.log, "Sync completed: %@", syncId)
self.startSyncCompletionHandler = nil
DispatchQueue.main.async {
if let self = self {
self.account.metadata.lastArticleFetch = lastArticleFetchDate
os_log(.debug, log: self.log, "Sync completed: %@", syncId)
self.startSyncCompletionHandler = nil
}
completionHandler(.success(()))
}
completionHandler(.success(()))
}
completionOperation.addDependency(sendArticleStatuses)
completionOperation.addDependency(getCollections)
completionOperation.addDependency(mirrorCollectionsAsFolders)
completionOperation.addDependency(createFeedsOperation)
completionOperation.addDependency(getCollectionStreams)
completionOperation.addDependency(syncStarred)
finalOperation = completionOperation
startSyncCompletionHandler = completionHandler
let minimumOperations = [getCollections,
let minimumOperations = [sendArticleStatuses,
getCollections,
mirrorCollectionsAsFolders,
createFeedsOperation,
getCollectionStreams,
syncStarred,
completionOperation]
operationQueue.addOperations(minimumOperations, waitUntilFinished: false)
@ -103,26 +132,18 @@ final class FeedlySyncStrategy {
extension FeedlySyncStrategy: FeedlyRequestStreamsOperationDelegate {
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetCollectionStreamOperation) {
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue streamOperation: FeedlyGetStreamOperation) {
collectionStreamOperation.delegate = self
streamOperation.delegate = self
os_log(.debug, log: log, "Requesting stream for collection \"%@\"", collectionStreamOperation.collection.label)
// Parse the contents of this collection's stream.
let parseItemsOperation = FeedlyGetStreamParsedItemsOperation(account: account,
collectionStreamProvider: collectionStreamOperation,
caller: caller,
log: log)
parseItemsOperation.delegate = self
parseItemsOperation.addDependency(collectionStreamOperation)
// os_log(.debug, log: log, "Requesting stream for collection \"%@\"", streamOperation.collection.label)
// Group the stream's content by feed.
let groupItemsByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account,
parsedItemsProvider: parseItemsOperation,
entryProvider: streamOperation,
log: log)
groupItemsByFeed.delegate = self
groupItemsByFeed.addDependency(parseItemsOperation)
groupItemsByFeed.addDependency(streamOperation)
// Update the account with the articles for the feeds in the stream.
let updateOperation = FeedlyUpdateAccountFeedsWithItemsOperation(account: account,
@ -133,8 +154,7 @@ extension FeedlySyncStrategy: FeedlyRequestStreamsOperationDelegate {
// Once the articles are in the account, ensure they have the correct status
let ensureUnreadOperation = FeedlyRefreshStreamEntriesStatusOperation(account: account,
collectionStreamProvider: collectionStreamOperation,
articleStatusCoordinator: articleStatusCoordinator,
entryProvider: streamOperation,
log: log)
ensureUnreadOperation.delegate = self
@ -145,7 +165,7 @@ extension FeedlySyncStrategy: FeedlyRequestStreamsOperationDelegate {
operation.addDependency(ensureUnreadOperation)
}
let operations = [collectionStreamOperation, parseItemsOperation, groupItemsByFeed, updateOperation, ensureUnreadOperation]
let operations = [streamOperation, groupItemsByFeed, updateOperation, ensureUnreadOperation]
operationQueue.addOperations(operations, waitUntilFinished: false)
}

View File

@ -23,30 +23,25 @@ final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlyOperation {
}
override func main() {
assert(Thread.isMainThread) // Needs to be on main thread because Feed is a main-thread-only model type.
guard !isCancelled else {
didFinish()
return
}
let group = DispatchGroup()
let allFeeds = organisedItemsProvider.allFeeds
os_log(.debug, log: log, "Begin updating %i feeds in collection \"%@\"", allFeeds.count, organisedItemsProvider.collection.label)
os_log(.debug, log: log, "Begin updating %i feeds for \"%@\"", allFeeds.count, organisedItemsProvider.providerName)
var feedIDsAndItems = [String: Set<ParsedItem>]()
for feed in allFeeds {
guard let items = organisedItemsProvider.parsedItems(for: feed) else {
continue
}
group.enter()
os_log(.debug, log: log, "Updating %i items for feed \"%@\" in collection \"%@\"", items.count, feed.nameForDisplay, organisedItemsProvider.collection.label)
account.update(feed, parsedItems: items, defaultRead: true) {
group.leave()
}
feedIDsAndItems[feed.feedID] = items
}
group.notify(qos: .userInitiated, queue: .main) {
os_log(.debug, log: self.log, "Finished updating feeds in collection \"%@\"", self.organisedItemsProvider.collection.label)
account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true) {
os_log(.debug, log: self.log, "Finished updating feeds for \"%@\"", self.organisedItemsProvider.providerName)
self.didFinish()
}
}

View File

@ -10,7 +10,7 @@ import Foundation
import Articles
import RSCore
public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCountProvider, Hashable {
public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCountProvider, DeepLinkProvider, Hashable {
public weak var account: Account?
public var topLevelFeeds: Set<Feed> = Set<Feed>()
@ -32,6 +32,15 @@ public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCoun
public var nameForDisplay: String {
return name ?? Folder.untitledName
}
// MARK: - PathIDUserInfoProvider
public var deepLinkUserInfo: [AnyHashable : Any] {
return [
DeepLinkKey.accountID.rawValue: account?.accountID ?? "",
DeepLinkKey.accountName.rawValue: account?.nameForDisplay ?? "",
DeepLinkKey.folderName.rawValue: nameForDisplay
]
}
// MARK: - UnreadCountProvider
@ -98,10 +107,26 @@ public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCoun
postChildrenDidChangeNotification()
}
public func addFeeds(_ feeds: Set<Feed>) {
guard !feeds.isEmpty else {
return
}
topLevelFeeds.formUnion(feeds)
postChildrenDidChangeNotification()
}
public func removeFeed(_ feed: Feed) {
topLevelFeeds.remove(feed)
postChildrenDidChangeNotification()
}
public func removeFeeds(_ feeds: Set<Feed>) {
guard !feeds.isEmpty else {
return
}
topLevelFeeds.subtract(feeds)
postChildrenDidChangeNotification()
}
// MARK: - Hashable

View File

@ -31,10 +31,10 @@ final class LocalAccountDelegate: AccountDelegate {
return refresher.progress
}
// LocalAccountDelegate doesn't wait for completion before calling the completion block
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
refresher.refreshFeeds(account.flattenedFeeds())
completion(.success(()))
refresher.refreshFeeds(account.flattenedFeeds()) {
completion(.success(()))
}
}
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {

View File

@ -14,6 +14,8 @@ import Articles
final class LocalAccountRefresher {
private var completion: (() -> Void)?
private lazy var downloadSession: DownloadSession = {
return DownloadSession(delegate: self)
}()
@ -22,7 +24,8 @@ final class LocalAccountRefresher {
return downloadSession.progress
}
public func refreshFeeds(_ feeds: Set<Feed>) {
public func refreshFeeds(_ feeds: Set<Feed>, completion: @escaping () -> Void) {
self.completion = completion
downloadSession.downloadObjects(feeds as NSSet)
}
}
@ -102,6 +105,12 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) {
}
func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) {
completion?()
completion = nil
}
}
// MARK: - Utility

View File

@ -32,7 +32,7 @@ final class OPMLFile {
managedFile.load()
}
func saveIfNecessary() {
func save() {
managedFile.saveIfNecessary()
}

View File

@ -866,32 +866,9 @@ private extension ReaderAPIAccountDelegate {
}
func processEntries(account: Account, entries: [ReaderAPIEntry]?, completion: @escaping (() -> Void)) {
let parsedItems = mapEntriesToParsedItems(account: account, entries: entries)
let parsedMap = Dictionary(grouping: parsedItems, by: { item in item.feedURL } )
let group = DispatchGroup()
for (feedID, mapItems) in parsedMap {
group.enter()
if let feed = account.existingFeed(withFeedID: feedID) {
DispatchQueue.main.async {
account.update(feed, parsedItems: Set(mapItems), defaultRead: true) {
group.leave()
}
}
} else {
group.leave()
}
}
group.notify(queue: DispatchQueue.main) {
completion()
}
let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) }
account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true, completion: completion)
}
func mapEntriesToParsedItems(account: Account, entries: [ReaderAPIEntry]?) -> Set<ParsedItem> {

View File

@ -1,6 +1,7 @@
CODE_SIGN_IDENTITY = Mac Developer
CODE_SIGN_STYLE = Automatic
CODE_SIGN_IDENTITY = Developer ID Application
DEVELOPMENT_TEAM = M8L2WTLA8W
CODE_SIGN_STYLE = Manual
PROVISIONING_PROFILE_SPECIFIER =
// See the notes in NetNewsWire_target.xcconfig on why the
// DeveloperSettings.xcconfig is #included here
@ -10,6 +11,7 @@ DEVELOPMENT_TEAM = M8L2WTLA8W
SDKROOT = macosx
MACOSX_DEPLOYMENT_TARGET = 10.14
IPHONEOS_DEPLOYMENT_TARGET = 13.0
SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator
CLANG_ENABLE_OBJC_WEAK = YES
SWIFT_VERSION = 5.1

View File

@ -0,0 +1,3 @@
#include "./Account_project_debug.xcconfig"
OTHER_SWIFT_FLAGS = -DTEST $(inherited)

View File

@ -49,6 +49,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
518B2EA62351309100400001 /* Articles_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Articles_project_test.xcconfig; sourceTree = "<group>"; };
840405C91F1A8E4300DF0296 /* DatabaseID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseID.swift; sourceTree = "<group>"; };
844BEE5B1F0AB3C8004AB7CD /* Articles.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Articles.framework; sourceTree = BUILT_PRODUCTS_DIR; };
844BEE641F0AB3C9004AB7CD /* ArticlesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ArticlesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@ -149,6 +150,7 @@
children = (
D511EEE220242DFB00712EC3 /* Articles_project.xcconfig */,
D511EEE120242DFB00712EC3 /* Articles_project_debug.xcconfig */,
518B2EA62351309100400001 /* Articles_project_test.xcconfig */,
D511EEE420242DFB00712EC3 /* Articles_project_release.xcconfig */,
D511EEE320242DFB00712EC3 /* Articles_target.xcconfig */,
D511EEE020242DFB00712EC3 /* ArticlesDataTests_target.xcconfig */,
@ -177,6 +179,7 @@
844BEE571F0AB3C8004AB7CD /* Frameworks */,
844BEE581F0AB3C8004AB7CD /* Headers */,
844BEE591F0AB3C8004AB7CD /* Resources */,
51C8F34B234FB11A0048ED95 /* Run Script: Verify No Build Settings */,
);
buildRules = (
);
@ -217,11 +220,13 @@
TargetAttributes = {
844BEE5A1F0AB3C8004AB7CD = {
CreatedOnToolsVersion = 8.3.2;
DevelopmentTeam = SHJK2V3AJG;
LastSwiftMigration = 0830;
ProvisioningStyle = Automatic;
};
844BEE631F0AB3C9004AB7CD = {
CreatedOnToolsVersion = 8.3.2;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
};
};
@ -292,6 +297,27 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
51C8F34B234FB11A0048ED95 /* Run Script: Verify No Build Settings */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run Script: Verify No Build Settings";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
844BEE561F0AB3C8004AB7CD /* Sources */ = {
isa = PBXSourcesBuildPhase;
@ -324,13 +350,31 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
51EC89332351200F0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 518B2EA62351309100400001 /* Articles_project_test.xcconfig */;
buildSettings = {
};
name = Test;
};
51EC89342351200F0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE320242DFB00712EC3 /* Articles_target.xcconfig */;
buildSettings = {
};
name = Test;
};
51EC89352351200F0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE020242DFB00712EC3 /* ArticlesDataTests_target.xcconfig */;
buildSettings = {
};
name = Test;
};
844BEE6D1F0AB3C9004AB7CD /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE120242DFB00712EC3 /* Articles_project_debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos";
SWIFT_VERSION = 5.1;
};
name = Debug;
};
@ -338,9 +382,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE420242DFB00712EC3 /* Articles_project_release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos";
SWIFT_VERSION = 5.1;
};
name = Release;
};
@ -348,13 +389,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE320242DFB00712EC3 /* Articles_target.xcconfig */;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
DYLIB_INSTALL_NAME_BASE = "@rpath";
INFOPLIST_FILE = "$(SRCROOT)/Info.plist";
MACOSX_DEPLOYMENT_TARGET = 10.13;
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.Articles;
PRODUCT_NAME = Articles;
SKIP_INSTALL = YES;
};
name = Debug;
};
@ -362,13 +396,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE320242DFB00712EC3 /* Articles_target.xcconfig */;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
DYLIB_INSTALL_NAME_BASE = "@rpath";
INFOPLIST_FILE = "$(SRCROOT)/Info.plist";
MACOSX_DEPLOYMENT_TARGET = 10.13;
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.Articles;
PRODUCT_NAME = Articles;
SKIP_INSTALL = YES;
};
name = Release;
};
@ -376,7 +403,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE020242DFB00712EC3 /* ArticlesDataTests_target.xcconfig */;
buildSettings = {
PRODUCT_NAME = ArticlesTests;
};
name = Debug;
};
@ -384,7 +410,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE020242DFB00712EC3 /* ArticlesDataTests_target.xcconfig */;
buildSettings = {
PRODUCT_NAME = ArticlesTests;
};
name = Release;
};
@ -395,6 +420,7 @@
isa = XCConfigurationList;
buildConfigurations = (
844BEE6D1F0AB3C9004AB7CD /* Debug */,
51EC89332351200F0061B6F6 /* Test */,
844BEE6E1F0AB3C9004AB7CD /* Release */,
);
defaultConfigurationIsVisible = 0;
@ -404,6 +430,7 @@
isa = XCConfigurationList;
buildConfigurations = (
844BEE701F0AB3C9004AB7CD /* Debug */,
51EC89342351200F0061B6F6 /* Test */,
844BEE711F0AB3C9004AB7CD /* Release */,
);
defaultConfigurationIsVisible = 0;
@ -413,6 +440,7 @@
isa = XCConfigurationList;
buildConfigurations = (
844BEE731F0AB3C9004AB7CD /* Debug */,
51EC89352351200F0061B6F6 /* Test */,
844BEE741F0AB3C9004AB7CD /* Release */,
);
defaultConfigurationIsVisible = 0;

View File

@ -23,14 +23,12 @@
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
buildConfiguration = "Test"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@ -51,8 +49,6 @@
ReferencedContainer = "container:Articles.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@ -1,6 +1,7 @@
CODE_SIGN_IDENTITY = Mac Developer
CODE_SIGN_STYLE = Automatic
DEVELOPMENT_TEAM = 9C84TZ7Q6Z
CODE_SIGN_IDENTITY = Developer ID Application
DEVELOPMENT_TEAM = M8L2WTLA8W
CODE_SIGN_STYLE = Manual
PROVISIONING_PROFILE_SPECIFIER =
// See the notes in NetNewsWire_target.xcconfig on why the
// DeveloperSettings.xcconfig is #included here
@ -10,6 +11,8 @@ DEVELOPMENT_TEAM = 9C84TZ7Q6Z
SDKROOT = macosx
MACOSX_DEPLOYMENT_TARGET = 10.13
IPHONEOS_DEPLOYMENT_TARGET = 13.0
SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator
CLANG_ENABLE_OBJC_WEAK = YES
SWIFT_VERSION = 5.1
COMBINE_HIDPI_IMAGES = YES

View File

@ -0,0 +1,3 @@
#include "./Articles_project_debug.xcconfig"
OTHER_SWIFT_FLAGS = -DTEST $(inherited)

View File

@ -1,5 +1,3 @@
CODE_SIGN_IDENTITY =
INSTALL_PATH = $(LOCAL_LIBRARY_DIR)/Frameworks
SKIP_INSTALL = YES
DYLIB_COMPATIBILITY_VERSION = 1

View File

@ -18,7 +18,7 @@ import Articles
public typealias UnreadCountDictionary = [String: Int] // feedID: unreadCount
public typealias UnreadCountCompletionBlock = (UnreadCountDictionary) -> Void
public typealias UpdateArticlesWithFeedCompletionBlock = (Set<Article>?, Set<Article>?) -> Void //newArticles, updatedArticles
public typealias UpdateArticlesCompletionBlock = (Set<Article>?, Set<Article>?) -> Void //newArticles, updatedArticles
public final class ArticlesDatabase {
@ -126,10 +126,11 @@ public final class ArticlesDatabase {
// MARK: - Saving and Updating Articles
public func update(feedID: String, parsedItems: Set<ParsedItem>, defaultRead: Bool, completion: @escaping UpdateArticlesWithFeedCompletionBlock) {
return articlesTable.update(feedID, parsedItems, defaultRead, completion)
/// Update articles and save new ones. The key for feedIDsAndItems is feedID.
public func update(feedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping UpdateArticlesCompletionBlock) {
articlesTable.update(feedIDsAndItems, defaultRead, completion)
}
public func ensureStatuses(_ articleIDs: Set<String>, _ defaultRead: Bool, _ statusKey: ArticleStatus.Key, _ flag: Bool) {
articlesTable.ensureStatuses(articleIDs, defaultRead, statusKey, flag)
}
@ -151,6 +152,14 @@ public final class ArticlesDatabase {
public func mark(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<ArticleStatus>? {
return articlesTable.mark(articles, statusKey, flag)
}
// MARK: - Caches
/// Call to free up some memory. Should be done when the app is backgrounded, for instance.
/// This does not empty *all* caches  just the ones that are empty-able.
public func emptyCaches() {
articlesTable.emptyCaches()
}
}
// MARK: - Private

View File

@ -112,6 +112,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
518B2EA7235130CD00400001 /* ArticlesDatabase_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ArticlesDatabase_project_test.xcconfig; sourceTree = "<group>"; };
51C451FE2264CF2100C03939 /* RSParser.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSParser.framework; sourceTree = BUILT_PRODUCTS_DIR; };
840405CE1F1A963700DF0296 /* AttachmentsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsTable.swift; sourceTree = "<group>"; };
841D4D732106B59F00DD04E6 /* Articles.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Articles.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -287,6 +288,7 @@
D511EEE820242E0800712EC3 /* ArticlesDatabase_target.xcconfig */,
D511EEE920242E0800712EC3 /* ArticlesDatabase_project.xcconfig */,
D511EEEA20242E0800712EC3 /* ArticlesDatabase_project_debug.xcconfig */,
518B2EA7235130CD00400001 /* ArticlesDatabase_project_test.xcconfig */,
D511EEEB20242E0800712EC3 /* ArticlesDatabase_project_release.xcconfig */,
);
path = xcconfig;
@ -313,6 +315,7 @@
844BEE331F0AB3AA004AB7CD /* Frameworks */,
844BEE341F0AB3AA004AB7CD /* Headers */,
844BEE351F0AB3AA004AB7CD /* Resources */,
51C8F34A234FB0F50048ED95 /* Run Script: Verify No Build Settings */,
);
buildRules = (
);
@ -353,13 +356,13 @@
TargetAttributes = {
844BEE361F0AB3AA004AB7CD = {
CreatedOnToolsVersion = 8.3.2;
DevelopmentTeam = 9C84TZ7Q6Z;
DevelopmentTeam = SHJK2V3AJG;
LastSwiftMigration = 0830;
ProvisioningStyle = Automatic;
};
844BEE3F1F0AB3AB004AB7CD = {
CreatedOnToolsVersion = 8.3.2;
DevelopmentTeam = 9C84TZ7Q6Z;
DevelopmentTeam = SHJK2V3AJG;
ProvisioningStyle = Automatic;
};
};
@ -491,6 +494,27 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
51C8F34A234FB0F50048ED95 /* Run Script: Verify No Build Settings */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run Script: Verify No Build Settings";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
844BEE321F0AB3AA004AB7CD /* Sources */ = {
isa = PBXSourcesBuildPhase;
@ -534,13 +558,31 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
51EC89362351201E0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 518B2EA7235130CD00400001 /* ArticlesDatabase_project_test.xcconfig */;
buildSettings = {
};
name = Test;
};
51EC89372351201E0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE820242E0800712EC3 /* ArticlesDatabase_target.xcconfig */;
buildSettings = {
};
name = Test;
};
51EC89382351201E0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE720242E0800712EC3 /* ArticlesDatabaseTests_target.xcconfig */;
buildSettings = {
};
name = Test;
};
844BEE491F0AB3AB004AB7CD /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEEA20242E0800712EC3 /* ArticlesDatabase_project_debug.xcconfig */;
buildSettings = {
CODE_SIGN_IDENTITY = "-";
SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos";
SWIFT_SWIFT3_OBJC_INFERENCE = Off;
};
name = Debug;
};
@ -548,9 +590,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEEB20242E0800712EC3 /* ArticlesDatabase_project_release.xcconfig */;
buildSettings = {
CODE_SIGN_IDENTITY = "-";
SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos";
SWIFT_SWIFT3_OBJC_INFERENCE = Off;
};
name = Release;
};
@ -558,8 +597,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE820242E0800712EC3 /* ArticlesDatabase_target.xcconfig */;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.ArticlesDatabase;
PRODUCT_NAME = ArticlesDatabase;
};
name = Debug;
};
@ -567,8 +604,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE820242E0800712EC3 /* ArticlesDatabase_target.xcconfig */;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.ArticlesDatabase;
PRODUCT_NAME = ArticlesDatabase;
};
name = Release;
};
@ -576,7 +611,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE720242E0800712EC3 /* ArticlesDatabaseTests_target.xcconfig */;
buildSettings = {
PRODUCT_NAME = ArticlesDatabaseTests;
};
name = Debug;
};
@ -584,7 +618,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D511EEE720242E0800712EC3 /* ArticlesDatabaseTests_target.xcconfig */;
buildSettings = {
PRODUCT_NAME = ArticlesDatabaseTests;
};
name = Release;
};
@ -595,6 +628,7 @@
isa = XCConfigurationList;
buildConfigurations = (
844BEE491F0AB3AB004AB7CD /* Debug */,
51EC89362351201E0061B6F6 /* Test */,
844BEE4A1F0AB3AB004AB7CD /* Release */,
);
defaultConfigurationIsVisible = 0;
@ -604,6 +638,7 @@
isa = XCConfigurationList;
buildConfigurations = (
844BEE4C1F0AB3AB004AB7CD /* Debug */,
51EC89372351201E0061B6F6 /* Test */,
844BEE4D1F0AB3AB004AB7CD /* Release */,
);
defaultConfigurationIsVisible = 0;
@ -613,6 +648,7 @@
isa = XCConfigurationList;
buildConfigurations = (
844BEE4F1F0AB3AB004AB7CD /* Debug */,
51EC89382351201E0061B6F6 /* Test */,
844BEE501F0AB3AB004AB7CD /* Release */,
);
defaultConfigurationIsVisible = 0;

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1120"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "844BEE361F0AB3AA004AB7CD"
BuildableName = "ArticlesDatabase.framework"
BlueprintName = "ArticlesDatabase"
ReferencedContainer = "container:ArticlesDatabase.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Test"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "844BEE361F0AB3AA004AB7CD"
BuildableName = "ArticlesDatabase.framework"
BlueprintName = "ArticlesDatabase"
ReferencedContainer = "container:ArticlesDatabase.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -20,6 +20,7 @@ final class ArticlesTable: DatabaseTable {
private let statusesTable: StatusesTable
private let authorsLookupTable: DatabaseLookupTable
private let attachmentsLookupTable: DatabaseLookupTable
private var databaseArticlesCache = [String: DatabaseArticle]()
private lazy var searchTable: SearchTable = {
return SearchTable(queue: queue, articlesTable: self)
@ -214,9 +215,9 @@ final class ArticlesTable: DatabaseTable {
}
// MARK: - Updating
func update(_ feedID: String, _ parsedItems: Set<ParsedItem>, _ read: Bool, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) {
if parsedItems.isEmpty {
func update(_ feedIDsAndItems: [String: Set<ParsedItem>], _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) {
if feedIDsAndItems.isEmpty {
completion(nil, nil)
return
}
@ -230,30 +231,34 @@ final class ArticlesTable: DatabaseTable {
// 7. Call back with new and updated Articles.
// 8. Update search index.
let articleIDs = Set(parsedItems.map { $0.articleID })
var articleIDs = Set<String>()
for (_, parsedItems) in feedIDsAndItems {
articleIDs.formUnion(parsedItems.articleIDs())
}
self.queue.update { (database) in
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1
assert(statusesDictionary.count == articleIDs.count)
let allIncomingArticles = Article.articlesWithParsedItems(parsedItems, self.accountID, feedID, statusesDictionary) //2
let allIncomingArticles = Article.articlesWithFeedIDsAndItems(feedIDsAndItems, self.accountID, statusesDictionary) //2
if allIncomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
let incomingArticles = self.filterIncomingArticles(allIncomingArticles) //3
if incomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
let fetchedArticles = self.fetchArticlesForFeedID(feedID, withLimits: false, database) //4
let incomingArticleIDs = incomingArticles.articleIDs()
let fetchedArticles = self.fetchArticles(articleIDs: incomingArticleIDs, database) //4
let fetchedArticlesDictionary = fetchedArticles.dictionary()
let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
// 8. Update search index.
@ -264,16 +269,75 @@ final class ArticlesTable: DatabaseTable {
if let updatedArticles = updatedArticles {
articlesToIndex.formUnion(updatedArticles)
}
let articleIDs = articlesToIndex.articleIDs()
if articleIDs.isEmpty {
let articleIDsToIndex = articlesToIndex.articleIDs()
if articleIDsToIndex.isEmpty {
return
}
DispatchQueue.main.async {
self.searchTable.ensureIndexedArticles(for: articleIDs)
self.searchTable.ensureIndexedArticles(for: articleIDsToIndex)
}
}
}
// func update(_ feedID: String, _ parsedItems: Set<ParsedItem>, _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) {
// if parsedItems.isEmpty {
// completion(nil, nil)
// return
// }
//
// // 1. Ensure statuses for all the incoming articles.
// // 2. Create incoming articles with parsedItems.
// // 3. Ignore incoming articles that are userDeleted || (!starred and really old)
// // 4. Fetch all articles for the feed.
// // 5. Create array of Articles not in database and save them.
// // 6. Create array of updated Articles and save whats changed.
// // 7. Call back with new and updated Articles.
// // 8. Update search index.
//
// let articleIDs = Set(parsedItems.map { $0.articleID })
//
// self.queue.update { (database) in
// let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1
// assert(statusesDictionary.count == articleIDs.count)
//
// let allIncomingArticles = Article.articlesWithParsedItems(parsedItems, self.accountID, feedID, statusesDictionary) //2
// if allIncomingArticles.isEmpty {
// self.callUpdateArticlesCompletionBlock(nil, nil, completion)
// return
// }
//
// let incomingArticles = self.filterIncomingArticles(allIncomingArticles) //3
// if incomingArticles.isEmpty {
// self.callUpdateArticlesCompletionBlock(nil, nil, completion)
// return
// }
//
// let fetchedArticles = self.fetchArticlesForFeedID(feedID, withLimits: false, database) //4
// let fetchedArticlesDictionary = fetchedArticles.dictionary()
//
// let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
// let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
//
// self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
//
// // 8. Update search index.
// var articlesToIndex = Set<Article>()
// if let newArticles = newArticles {
// articlesToIndex.formUnion(newArticles)
// }
// if let updatedArticles = updatedArticles {
// articlesToIndex.formUnion(updatedArticles)
// }
// let articleIDs = articlesToIndex.articleIDs()
// if articleIDs.isEmpty {
// return
// }
// DispatchQueue.main.async {
// self.searchTable.ensureIndexedArticles(for: articleIDs)
// }
// }
// }
func ensureStatuses(_ articleIDs: Set<String>, _ defaultRead: Bool, _ statusKey: ArticleStatus.Key, _ flag: Bool) {
self.queue.update { (database) in
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, defaultRead, database)
@ -417,6 +481,14 @@ final class ArticlesTable: DatabaseTable {
}
}
}
// MARK: - Caches
func emptyCaches() {
queue.run { _ in
self.databaseArticlesCache = [String: DatabaseArticle]()
}
}
}
// MARK: - Private
@ -482,16 +554,21 @@ private extension ArticlesTable {
func makeDatabaseArticles(with resultSet: FMResultSet) -> Set<DatabaseArticle> {
let articles = resultSet.mapToSet { (row) -> DatabaseArticle? in
// The resultSet is a result of a JOIN query with the statuses table,
// so we can get the statuses at the same time and avoid additional database lookups.
guard let status = statusesTable.statusWithRow(resultSet) else {
assertionFailure("Expected status.")
guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
assertionFailure("Expected articleID.")
return nil
}
guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
assertionFailure("Expected articleID.")
// Articles are removed from the cache when theyre updated.
// See saveUpdatedArticles.
if let databaseArticle = databaseArticlesCache[articleID] {
return databaseArticle
}
// The resultSet is a result of a JOIN query with the statuses table,
// so we can get the statuses at the same time and avoid additional database lookups.
guard let status = statusesTable.statusWithRow(resultSet, articleID: articleID) else {
assertionFailure("Expected status.")
return nil
}
guard let feedID = row.string(forColumn: DatabaseKey.feedID) else {
@ -514,7 +591,9 @@ private extension ArticlesTable {
let datePublished = row.date(forColumn: DatabaseKey.datePublished)
let dateModified = row.date(forColumn: DatabaseKey.dateModified)
return DatabaseArticle(articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, status: status)
let databaseArticle = DatabaseArticle(articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, status: status)
databaseArticlesCache[articleID] = databaseArticle
return databaseArticle
}
return articles
@ -587,7 +666,7 @@ private extension ArticlesTable {
// MARK: - Saving Parsed Items
func callUpdateArticlesCompletionBlock(_ newArticles: Set<Article>?, _ updatedArticles: Set<Article>?, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) {
func callUpdateArticlesCompletionBlock(_ newArticles: Set<Article>?, _ updatedArticles: Set<Article>?, _ completion: @escaping UpdateArticlesCompletionBlock) {
DispatchQueue.main.async {
completion(newArticles, updatedArticles)
}
@ -670,6 +749,7 @@ private extension ArticlesTable {
func saveUpdatedArticles(_ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article], _ database: FMDatabase) {
removeArticlesFromDatabaseArticlesCache(updatedArticles)
saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database)
for updatedArticle in updatedArticles {
@ -690,10 +770,17 @@ private extension ArticlesTable {
// Not unexpected. There may be no changes.
return
}
updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, matches: updatedArticle.articleID, database: database)
}
func removeArticlesFromDatabaseArticlesCache(_ updatedArticles: Set<Article>) {
let articleIDs = updatedArticles.articleIDs()
for articleID in articleIDs {
databaseArticlesCache[articleID] = nil
}
}
func statusIndicatesArticleIsIgnorable(_ status: ArticleStatus) -> Bool {
// Ignorable articles: either userDeleted==1 or (not starred and arrival date > 4 months).
if status.userDeleted {
@ -711,3 +798,8 @@ private extension ArticlesTable {
}
}
private extension Set where Element == ParsedItem {
func articleIDs() -> Set<String> {
return Set<String>(map { $0.articleID })
}
}

View File

@ -79,11 +79,20 @@ extension Article {
return d.count < 1 ? nil : d
}
static func articlesWithParsedItems(_ parsedItems: Set<ParsedItem>, _ accountID: String, _ feedID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
// static func articlesWithParsedItems(_ parsedItems: Set<ParsedItem>, _ accountID: String, _ feedID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
// let maximumDateAllowed = Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now
// return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) })
// }
static func articlesWithFeedIDsAndItems(_ feedIDsAndItems: [String: Set<ParsedItem>], _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
let maximumDateAllowed = Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now
return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) })
var articles = Set<Article>()
for (feedID, parsedItems) in feedIDsAndItems {
let feedArticles = Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) })
articles.formUnion(feedArticles)
}
return articles
}
}
extension Article: DatabaseObject {

View File

@ -105,17 +105,21 @@ final class StatusesTable: DatabaseTable {
guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
return nil
}
return statusWithRow(row, articleID: articleID)
}
func statusWithRow(_ row: FMResultSet, articleID: String) ->ArticleStatus? {
if let cachedStatus = cache[articleID] {
return cachedStatus
}
guard let dateArrived = row.date(forColumn: DatabaseKey.dateArrived) else {
return nil
}
let articleStatus = ArticleStatus(articleID: articleID, dateArrived: dateArrived, row: row)
cache.addStatusIfNotCached(articleStatus)
return articleStatus
}

View File

@ -1,6 +1,7 @@
CODE_SIGN_IDENTITY = Mac Developer
CODE_SIGN_STYLE = Automatic
DEVELOPMENT_TEAM = 9C84TZ7Q6Z
CODE_SIGN_IDENTITY = Developer ID Application
DEVELOPMENT_TEAM = M8L2WTLA8W
CODE_SIGN_STYLE = Manual
PROVISIONING_PROFILE_SPECIFIER =
// See the notes in NetNewsWire_target.xcconfig on why the
// DeveloperSettings.xcconfig is #included here
@ -10,6 +11,8 @@ DEVELOPMENT_TEAM = 9C84TZ7Q6Z
SDKROOT = macosx
MACOSX_DEPLOYMENT_TARGET = 10.13
IPHONEOS_DEPLOYMENT_TARGET = 13.0
SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator
CLANG_ENABLE_OBJC_WEAK = YES
SWIFT_VERSION = 5.1
COMBINE_HIDPI_IMAGES = YES

View File

@ -0,0 +1,3 @@
#include "./ArticlesDatabase_project_debug.xcconfig"
OTHER_SWIFT_FLAGS = -DTEST $(inherited)

View File

@ -1,6 +1,3 @@
CODE_SIGN_IDENTITY =
// DEVELOPMENT_TEAM = 9C84TZ7Q6Z
INSTALL_PATH = $(LOCAL_LIBRARY_DIR)/Frameworks
SKIP_INSTALL = YES
DYLIB_COMPATIBILITY_VERSION = 1

View File

@ -32,6 +32,7 @@
51554C35228B72F40055115A /* RSDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
51554C37228B7DAC0055115A /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
51554C39228B83380055115A /* Articles.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Articles.framework; sourceTree = BUILT_PRODUCTS_DIR; };
518B2EA82351310D00400001 /* SyncDatabase_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SyncDatabase_project_test.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -73,6 +74,7 @@
isa = PBXGroup;
children = (
51554C1A228B701F0055115A /* SyncDatabase_project_debug.xcconfig */,
518B2EA82351310D00400001 /* SyncDatabase_project_test.xcconfig */,
51554C1C228B701F0055115A /* SyncDatabase_project_release.xcconfig */,
51554C19228B701F0055115A /* SyncDatabase_project.xcconfig */,
51554C1D228B701F0055115A /* SyncDatabase_target.xcconfig */,
@ -110,6 +112,7 @@
51554BE7228B6E8F0055115A /* Sources */,
51554BE8228B6E8F0055115A /* Frameworks */,
51554BE9228B6E8F0055115A /* Resources */,
51C8F349234FB0C40048ED95 /* Run Script: Verfiy No Build Settings */,
);
buildRules = (
);
@ -165,6 +168,27 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
51C8F349234FB0C40048ED95 /* Run Script: Verfiy No Build Settings */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run Script: Verfiy No Build Settings";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
51554BE7228B6E8F0055115A /* Sources */ = {
isa = PBXSourcesBuildPhase;
@ -184,65 +208,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 51554C1A228B701F0055115A /* SyncDatabase_project_debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "Mac Developer";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Debug;
};
@ -250,58 +215,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 51554C1C228B701F0055115A /* SyncDatabase_project_release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "Mac Developer";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SUPPORTED_PLATFORMS = "macosx iphonesimulator iphoneos";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Release;
};
@ -309,25 +222,6 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 51554C1D228B701F0055115A /* SyncDatabase_target.xcconfig */;
buildSettings = {
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SHJK2V3AJG;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
FRAMEWORK_VERSION = A;
INFOPLIST_FILE = Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.SyncDatabase;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
};
name = Debug;
};
@ -335,28 +229,23 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 51554C1D228B701F0055115A /* SyncDatabase_target.xcconfig */;
buildSettings = {
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SHJK2V3AJG;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
FRAMEWORK_VERSION = A;
INFOPLIST_FILE = Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.SyncDatabase;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
};
name = Release;
};
51EC89392351202A0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 51554C1A228B701F0055115A /* SyncDatabase_project_debug.xcconfig */;
buildSettings = {
};
name = Test;
};
51EC893A2351202A0061B6F6 /* Test */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 51554C1D228B701F0055115A /* SyncDatabase_target.xcconfig */;
buildSettings = {
};
name = Test;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -364,6 +253,7 @@
isa = XCConfigurationList;
buildConfigurations = (
51554BF1228B6E8F0055115A /* Debug */,
51EC89392351202A0061B6F6 /* Test */,
51554BF2228B6E8F0055115A /* Release */,
);
defaultConfigurationIsVisible = 0;
@ -373,6 +263,7 @@
isa = XCConfigurationList;
buildConfigurations = (
51554BF4228B6E8F0055115A /* Debug */,
51EC893A2351202A0061B6F6 /* Test */,
51554BF5228B6E8F0055115A /* Release */,
);
defaultConfigurationIsVisible = 0;

View File

@ -1,6 +1,7 @@
CODE_SIGN_IDENTITY = Mac Developer
CODE_SIGN_STYLE = Automatic
DEVELOPMENT_TEAM = 9C84TZ7Q6Z
CODE_SIGN_IDENTITY = Developer ID Application
DEVELOPMENT_TEAM = M8L2WTLA8W
CODE_SIGN_STYLE = Manual
PROVISIONING_PROFILE_SPECIFIER =
// See the notes in NetNewsWire_target.xcconfig on why the
// DeveloperSettings.xcconfig is #included here
@ -10,6 +11,8 @@ DEVELOPMENT_TEAM = 9C84TZ7Q6Z
SDKROOT = macosx
MACOSX_DEPLOYMENT_TARGET = 10.14
IPHONEOS_DEPLOYMENT_TARGET = 13.0
SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator
CLANG_ENABLE_OBJC_WEAK = YES
SWIFT_VERSION = 5.1
COMBINE_HIDPI_IMAGES = YES

View File

@ -0,0 +1,3 @@
#include "./SyncDatabase_project_debug.xcconfig"
OTHER_SWIFT_FLAGS = -DTEST $(inherited)

View File

@ -1,6 +1,3 @@
CODE_SIGN_IDENTITY =
// DEVELOPMENT_TEAM = 9C84TZ7Q6Z
INSTALL_PATH = $(LOCAL_LIBRARY_DIR)/Frameworks
SKIP_INSTALL = YES
DYLIB_COMPATIBILITY_VERSION = 1

View File

@ -5,7 +5,6 @@
// Created by Brent Simmons on 9/22/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import AppKit
enum FontSize: Int {
@ -228,7 +227,6 @@ struct AppDefaults {
// an issue, this could be changed to proactively look for whether the default has been
// set _by the user_ to false, and respect that default if it is so-set.
// UserDefaults.standard.set(true, forKey: "NSQuitAlwaysKeepsWindows")
// TODO: revisit the above when coming back to state restoration issues.
}
@ -326,7 +324,6 @@ private extension AppDefaults {
}
// MARK: -
extension UserDefaults {
/// This property exists so that it can conveniently be observed via KVO
@objc var CorreiaSeparators: Bool {
@ -338,4 +335,3 @@ extension UserDefaults {
}
}
}

View File

@ -5,19 +5,23 @@
// Created by Brent Simmons on 7/11/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import AppKit
import UserNotifications
import Articles
import RSTree
import RSWeb
import Account
import RSCore
#if TEST
import Sparkle
#endif
var appDelegate: AppDelegate!
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, UnreadCountProvider {
class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, UNUserNotificationCenterDelegate, UnreadCountProvider {
var userNotificationManager: UserNotificationManager!
var faviconDownloader: FaviconDownloader!
var imageDownloader: ImageDownloader!
var authorAvatarDownloader: AuthorAvatarDownloader!
@ -58,6 +62,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
private var addFeedController: AddFeedController?
private var addFolderWindowController: AddFolderWindowController?
private var importOPMLController: ImportOPMLWindowController?
private var importNNW3Controller: ImportNNW3WindowController?
private var exportOPMLController: ExportOPMLWindowController?
private var keyboardShortcutsWindowController: WebViewWindowController?
private var inspectorWindowController: InspectorWindowController?
@ -79,7 +84,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
// MARK: - API
func logMessage(_ message: String, type: LogItem.ItemType) {
#if DEBUG
@ -110,9 +114,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
// MARK: - NSApplicationDelegate
func applicationWillFinishLaunching(_ notification: Notification) {
installAppleEventHandlers()
#if TEST
// Don't prompt for updates while running automated tests
SUUpdater.shared()?.automaticallyChecksForUpdates = false
#endif
}
func applicationDidFinishLaunching(_ note: Notification) {
@ -129,8 +136,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
logDebugMessage("Is first run.")
}
let localAccount = AccountManager.shared.defaultAccount
NNW3FeedsImporter.importIfNeeded(isFirstRun, account: localAccount)
DefaultFeedsImporter.importIfNeeded(isFirstRun, account: localAccount)
let tempDirectory = NSTemporaryDirectory()
let bundleIdentifier = (Bundle.main.infoDictionary!["CFBundleIdentifier"]! as! String)
let cacheFolder = (tempDirectory as NSString).appendingPathComponent(bundleIdentifier)
@ -179,6 +187,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
refreshTimer = AccountRefreshTimer()
syncTimer = ArticleStatusSyncTimer()
UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in
if granted {
DispatchQueue.main.async {
NSApplication.shared.registerForRemoteNotifications()
}
}
}
UNUserNotificationCenter.current().delegate = self
userNotificationManager = UserNotificationManager()
#if RELEASE
debugMenuItem.menu?.removeItem(debugMenuItem)
DispatchQueue.main.async {
@ -198,6 +217,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
#endif
}
func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
guard let mainWindowController = mainWindowController else {
return false
}
mainWindowController.handle(userActivity)
return true
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
// https://github.com/brentsimmons/NetNewsWire/issues/522
@ -243,7 +270,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
// MARK: Notifications
@objc func unreadCountDidChange(_ note: Notification) {
if note.object is AccountManager {
@ -277,7 +303,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
// MARK: Main Window
func windowControllerWithName(_ storyboardName: String) -> NSWindowController {
let storyboard = NSStoryboard(name: NSStoryboard.Name(storyboardName), bundle: nil)
@ -294,7 +319,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
// MARK: NSUserInterfaceValidations
func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
if shuttingDown {
return false
@ -322,8 +346,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
return true
}
// MARK: UNUserNotificationCenterDelegate
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.alert, .badge, .sound])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
mainWindowController?.handle(response)
completionHandler()
}
// MARK: Add Feed
func addFeed(_ urlString: String?, name: String? = nil, account: Account? = nil, folder: Folder? = nil) {
createAndShowMainWindow()
@ -335,14 +369,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
// MARK: - Dock Badge
@objc func updateDockBadge() {
let label = unreadCount > 0 && !AppDefaults.hideDockUnreadCount ? "\(unreadCount)" : ""
NSApplication.shared.dockTile.badgeLabel = label
}
// MARK: - Actions
@IBAction func showPreferences(_ sender: Any?) {
if preferencesWindowController == nil {
@ -392,7 +424,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
@IBAction func toggleInspectorWindow(_ sender: Any?) {
if inspectorWindowController == nil {
inspectorWindowController = (windowControllerWithName("Inspector") as! InspectorWindowController)
}
@ -407,7 +438,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
@IBAction func importOPMLFromFile(_ sender: Any?) {
createAndShowMainWindow()
if mainWindowController!.isDisplayingSheet {
return
@ -415,11 +445,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
importOPMLController = ImportOPMLWindowController()
importOPMLController?.runSheetOnWindow(mainWindowController!.window!)
}
@IBAction func importNNW3FromFile(_ sender: Any?) {
createAndShowMainWindow()
if mainWindowController!.isDisplayingSheet {
return
}
importNNW3Controller = ImportNNW3WindowController()
importNNW3Controller?.runSheetOnWindow(mainWindowController!.window!)
}
@IBAction func exportOPML(_ sender: Any?) {
createAndShowMainWindow()
if mainWindowController!.isDisplayingSheet {
return
@ -427,11 +465,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
exportOPMLController = ExportOPMLWindowController()
exportOPMLController?.runSheetOnWindow(mainWindowController!.window!)
}
@IBAction func addAppNews(_ sender: Any?) {
if AccountManager.shared.anyAccountHasFeedWithURL(appNewsURLString) {
return
}
@ -459,7 +495,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
}
@IBAction func openSlackGroup(_ sender: Any?) {
Browser.open("https://join.slack.com/t/netnewswire/shared_invite/enQtNjM4MDA1MjQzMDkzLTNlNjBhOWVhYzdhYjA4ZWFhMzQ1MTUxYjU0NTE5ZGY0YzYwZWJhNjYwNTNmNTg2NjIwYWY4YzhlYzk5NmU3ZTc", inBackground: false)
Browser.open("https://ranchero.com/netnewswire/slack", inBackground: false)
}
@IBAction func openTechnotes(_ sender: Any?) {
@ -549,7 +585,6 @@ extension AppDelegate {
// An attached inspector can display incorrectly on certain setups (like mine); default to displaying in a separate window,
// and reset the default to a separate window when the preference is toggled off and on again in case the inspector is
// accidentally reattached.
AppDefaults.webInspectorStartsAttached = false
NotificationCenter.default.post(name: .WebInspectorEnabledDidChange, object: newValue)
#endif
@ -610,4 +645,4 @@ extension AppDelegate : ScriptingAppDelegate {
internal var scriptingSelectedArticles: [Article] {
return self.scriptingMainWindowController?.scriptingSelectedArticles ?? []
}
}
}

View File

@ -92,6 +92,12 @@
<action selector="importOPMLFromFile:" target="Ady-hI-5gd" id="eGY-fm-uvK"/>
</connections>
</menuItem>
<menuItem title="Import NNW 3 Subscriptions ..." id="ely-yi-STg">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="importNNW3FromFile:" target="Voe-Tx-rLC" id="mu3-gN-7uA"/>
</connections>
</menuItem>
<menuItem title="Export Subscriptions…" keyEquivalent="e" id="Xy2-v8-Lj8">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>

View File

@ -12,10 +12,11 @@ import Account
final class FeedInspectorViewController: NSViewController, Inspector {
@IBOutlet var imageView: NSImageView?
@IBOutlet var nameTextField: NSTextField?
@IBOutlet var homePageURLTextField: NSTextField?
@IBOutlet var urlTextField: NSTextField?
@IBOutlet weak var imageView: NSImageView?
@IBOutlet weak var nameTextField: NSTextField?
@IBOutlet weak var homePageURLTextField: NSTextField?
@IBOutlet weak var urlTextField: NSTextField?
@IBOutlet weak var isNotifyAboutNewArticlesCheckBox: NSButton!
@IBOutlet weak var isReaderViewAlwaysOnCheckBox: NSButton?
private var feed: Feed? {
@ -51,6 +52,10 @@ final class FeedInspectorViewController: NSViewController, Inspector {
}
// MARK: Actions
@IBAction func isNotifyAboutNewArticlesChanged(_ sender: Any) {
feed?.isNotifyAboutNewArticles = (isNotifyAboutNewArticlesCheckBox?.state ?? .off) == .on ? true : false
}
@IBAction func isReaderViewAlwaysOnChanged(_ sender: Any) {
feed?.isArticleExtractorAlwaysOn = (isReaderViewAlwaysOnCheckBox?.state ?? .off) == .on ? true : false
}
@ -89,6 +94,7 @@ private extension FeedInspectorViewController {
updateName()
updateHomePageURL()
updateFeedURL()
updateNotifyAboutNewArticles()
updateIsReaderViewAlwaysOn()
view.needsLayout = true
@ -135,6 +141,10 @@ private extension FeedInspectorViewController {
urlTextField?.stringValue = feed?.url ?? ""
}
func updateNotifyAboutNewArticles() {
isNotifyAboutNewArticlesCheckBox?.state = (feed?.isNotifyAboutNewArticles ?? false) ? .on : .off
}
func updateIsReaderViewAlwaysOn() {
isReaderViewAlwaysOnCheckBox?.state = (feed?.isArticleExtractorAlwaysOn ?? false) ? .on : .off
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="14868" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="cfG-Pn-VJS">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="15504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="cfG-Pn-VJS">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14868"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="15504"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
@ -34,11 +34,11 @@
<objects>
<viewController title="Feed" storyboardIdentifier="Feed" showSeguePresentationStyle="single" id="sfH-oR-GXm" customClass="FeedInspectorViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" id="ecA-UY-KEd">
<rect key="frame" x="0.0" y="0.0" width="256" height="298"/>
<rect key="frame" x="0.0" y="0.0" width="256" height="332"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="H9X-OG-K0p">
<rect key="frame" x="104" y="230" width="48" height="48"/>
<rect key="frame" x="104" y="264" width="48" height="48"/>
<constraints>
<constraint firstAttribute="width" constant="48" id="1Cy-0w-dBg"/>
<constraint firstAttribute="height" constant="48" id="edb-lw-Ict"/>
@ -46,7 +46,7 @@
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" image="NSNetwork" id="MZ2-89-Bje"/>
</imageView>
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="IWu-80-XC5">
<rect key="frame" x="20" y="166" width="216" height="56"/>
<rect key="frame" x="20" y="200" width="216" height="56"/>
<constraints>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="56" id="zV3-AX-gyC"/>
</constraints>
@ -63,7 +63,7 @@ Field</string>
</connections>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="2WO-Iu-p5e">
<rect key="frame" x="18" y="130" width="220" height="16"/>
<rect key="frame" x="18" y="96" width="220" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" allowsUndo="NO" sendsActionOnEndEditing="YES" title="Home Page" usesSingleLineMode="YES" id="Fg8-rA-G5J">
<font key="font" metaFont="system"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
@ -71,7 +71,7 @@ Field</string>
</textFieldCell>
</textField>
<textField verticalHuggingPriority="1000" horizontalCompressionResistancePriority="250" verticalCompressionResistancePriority="1000" textCompletion="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zm0-15-BFy">
<rect key="frame" x="18" y="110" width="220" height="16"/>
<rect key="frame" x="18" y="76" width="220" height="16"/>
<textFieldCell key="cell" selectable="YES" allowsUndo="NO" sendsActionOnEndEditing="YES" title="http://example.com/" id="L2p-ur-j7a">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
@ -79,7 +79,7 @@ Field</string>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ju6-Zo-8X4">
<rect key="frame" x="18" y="74" width="220" height="16"/>
<rect key="frame" x="18" y="40" width="220" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" allowsUndo="NO" sendsActionOnEndEditing="YES" title="Feed" usesSingleLineMode="YES" id="zzB-rX-1dK">
<font key="font" metaFont="system"/>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
@ -87,7 +87,7 @@ Field</string>
</textFieldCell>
</textField>
<textField verticalHuggingPriority="1000" horizontalCompressionResistancePriority="250" verticalCompressionResistancePriority="1000" translatesAutoresizingMaskIntoConstraints="NO" id="Vvk-KG-JlG">
<rect key="frame" x="18" y="54" width="220" height="16"/>
<rect key="frame" x="18" y="20" width="220" height="16"/>
<textFieldCell key="cell" selectable="YES" allowsUndo="NO" sendsActionOnEndEditing="YES" title="http://example.com/feed" id="HpC-rK-YGK">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
@ -95,8 +95,8 @@ Field</string>
</textFieldCell>
</textField>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="nH2-ab-KJ5">
<rect key="frame" x="18" y="18" width="174" height="18"/>
<buttonCell key="cell" type="check" title="Reader View is always on" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="aRe-yV-R0h">
<rect key="frame" x="18" y="130" width="180" height="18"/>
<buttonCell key="cell" type="check" title="Always Show Reader View" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="aRe-yV-R0h">
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
@ -104,14 +104,25 @@ Field</string>
<action selector="isReaderViewAlwaysOnChanged:" target="sfH-oR-GXm" id="rsD-0e-ksP"/>
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ZBX-E8-k9c">
<rect key="frame" x="18" y="164" width="179" height="18"/>
<buttonCell key="cell" type="check" title="Notify About New Articles" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="Bw5-c7-yDX">
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="isNotifyAboutNewArticlesChanged:" target="sfH-oR-GXm" id="Vx9-pQ-RnP"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="zm0-15-BFy" firstAttribute="top" secondItem="2WO-Iu-p5e" secondAttribute="bottom" constant="4" id="2fb-QO-XIm"/>
<constraint firstItem="IWu-80-XC5" firstAttribute="top" secondItem="H9X-OG-K0p" secondAttribute="bottom" constant="8" symbolic="YES" id="4WB-WJ-3Z4"/>
<constraint firstItem="ZBX-E8-k9c" firstAttribute="top" secondItem="IWu-80-XC5" secondAttribute="bottom" constant="20" id="5L7-aZ-vdg"/>
<constraint firstItem="nH2-ab-KJ5" firstAttribute="leading" secondItem="ecA-UY-KEd" secondAttribute="leading" constant="20" symbolic="YES" id="8pK-lW-xQk"/>
<constraint firstItem="H9X-OG-K0p" firstAttribute="centerX" secondItem="ecA-UY-KEd" secondAttribute="centerX" id="9CA-KA-HEg"/>
<constraint firstAttribute="bottom" secondItem="nH2-ab-KJ5" secondAttribute="bottom" constant="20" symbolic="YES" id="BlS-oO-dVy"/>
<constraint firstItem="nH2-ab-KJ5" firstAttribute="top" secondItem="Vvk-KG-JlG" secondAttribute="bottom" constant="20" id="Hh5-qo-dip"/>
<constraint firstItem="nH2-ab-KJ5" firstAttribute="top" secondItem="ZBX-E8-k9c" secondAttribute="bottom" constant="20" id="CpA-X9-EbP"/>
<constraint firstAttribute="bottom" secondItem="Vvk-KG-JlG" secondAttribute="bottom" constant="20" id="IxJ-5N-NhL"/>
<constraint firstAttribute="trailing" secondItem="ju6-Zo-8X4" secondAttribute="trailing" constant="20" symbolic="YES" id="Jzi-tP-TIw"/>
<constraint firstAttribute="trailing" secondItem="Vvk-KG-JlG" secondAttribute="trailing" constant="20" symbolic="YES" id="KAS-A7-TxB"/>
<constraint firstItem="ju6-Zo-8X4" firstAttribute="leading" secondItem="ecA-UY-KEd" secondAttribute="leading" constant="20" symbolic="YES" id="NwI-2x-dAr"/>
@ -120,10 +131,11 @@ Field</string>
<constraint firstAttribute="trailing" secondItem="IWu-80-XC5" secondAttribute="trailing" constant="20" symbolic="YES" id="WW6-xR-Zue"/>
<constraint firstItem="H9X-OG-K0p" firstAttribute="top" secondItem="ecA-UY-KEd" secondAttribute="top" constant="20" symbolic="YES" id="Z6q-PN-wOC"/>
<constraint firstItem="zm0-15-BFy" firstAttribute="leading" secondItem="ecA-UY-KEd" secondAttribute="leading" constant="20" symbolic="YES" id="aho-BJ-kmB"/>
<constraint firstItem="ZBX-E8-k9c" firstAttribute="leading" secondItem="ecA-UY-KEd" secondAttribute="leading" constant="20" symbolic="YES" id="cjR-0i-YNG"/>
<constraint firstAttribute="trailing" secondItem="2WO-Iu-p5e" secondAttribute="trailing" constant="20" symbolic="YES" id="dLU-a6-nfx"/>
<constraint firstAttribute="trailing" secondItem="zm0-15-BFy" secondAttribute="trailing" constant="20" symbolic="YES" id="js6-b2-FIR"/>
<constraint firstItem="2WO-Iu-p5e" firstAttribute="top" secondItem="IWu-80-XC5" secondAttribute="bottom" constant="20" id="mlo-9L-OMV"/>
<constraint firstItem="IWu-80-XC5" firstAttribute="leading" secondItem="ecA-UY-KEd" secondAttribute="leading" constant="20" symbolic="YES" id="r6h-Z0-g7b"/>
<constraint firstItem="2WO-Iu-p5e" firstAttribute="top" secondItem="nH2-ab-KJ5" secondAttribute="bottom" constant="20" id="rRv-qO-dPa"/>
<constraint firstItem="Vvk-KG-JlG" firstAttribute="top" secondItem="ju6-Zo-8X4" secondAttribute="bottom" constant="4" id="sAt-dN-Taz"/>
<constraint firstItem="Vvk-KG-JlG" firstAttribute="leading" secondItem="ecA-UY-KEd" secondAttribute="leading" constant="20" symbolic="YES" id="uS2-JS-PPg"/>
</constraints>
@ -131,6 +143,7 @@ Field</string>
<connections>
<outlet property="homePageURLTextField" destination="zm0-15-BFy" id="0Jh-yy-mnF"/>
<outlet property="imageView" destination="H9X-OG-K0p" id="Rm6-X6-csH"/>
<outlet property="isNotifyAboutNewArticlesCheckBox" destination="ZBX-E8-k9c" id="FWc-Ds-LUy"/>
<outlet property="isReaderViewAlwaysOnCheckBox" destination="nH2-ab-KJ5" id="xPg-P5-3cr"/>
<outlet property="nameTextField" destination="IWu-80-XC5" id="zg4-5h-hoP"/>
<outlet property="urlTextField" destination="Vvk-KG-JlG" id="bcl-fq-3nQ"/>
@ -138,7 +151,7 @@ Field</string>
</viewController>
<customObject id="1ho-ZO-Gkb" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="67" y="46"/>
<point key="canvasLocation" x="67" y="69.5"/>
</scene>
<!--Folder-->
<scene sceneID="8By-fa-WDQ">
@ -189,7 +202,7 @@ Field</string>
</viewController>
<customObject id="4SD-ni-Scy" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="67" y="329"/>
<point key="canvasLocation" x="67" y="389"/>
</scene>
<!--Builtin Smart Feed-->
<scene sceneID="dFq-3d-JKW">
@ -231,7 +244,7 @@ Field</string>
</viewController>
<customObject id="3Xn-vX-2s9" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="67" y="553"/>
<point key="canvasLocation" x="67" y="613"/>
</scene>
<!--Nothing to inspect-->
<scene sceneID="lUc-e1-dN7">

View File

@ -0,0 +1,17 @@
// Add the mouse listeners for the above functions
function linkHover() {
window.onmouseover = function(event) {
if (event.target.matches('a')) {
window.webkit.messageHandlers.mouseDidEnter.postMessage(event.target.href);
}
}
window.onmouseout = function(event) {
if (event.target.matches('a')) {
window.webkit.messageHandlers.mouseDidExit.postMessage(event.target.href);
}
}
}
function postRenderProcessing() {
linkHover()
}

View File

@ -3,6 +3,7 @@
<style>
</style>
<script src="main.js"></script>
<script src="main_mac.js"></script>
<script src="newsfoot.js" async="async"></script>
</head>
<body>

View File

@ -115,13 +115,20 @@ h1 {
line-height: 1.15em;
font-weight: bold;
}
pre {
max-width: 100%;
margin: 0;
overflow: auto;
overflow-y: hidden;
line-height: 20px;
border: 1px solid #777;
word-wrap: normal;
padding: 10px;
}
code, pre {
font-family: "SF Mono", Menlo, "Courier New", Courier, monospace;
font-size: 14px;
}
pre {
white-space: pre-wrap;
}
img, figure, video, iframe, div {
max-width: 100%;
height: auto !important;
@ -133,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;

View File

@ -7,6 +7,7 @@
//
import AppKit
import UserNotifications
import Articles
import Account
import RSCore
@ -17,6 +18,8 @@ enum TimelineSourceMode {
class MainWindowController : NSWindowController, NSUserInterfaceValidations {
private var activityManager = ActivityManager()
private var isShowingExtractedArticle = false
private var articleExtractor: ArticleExtractor? = nil
private var sharingServicePickerDelegate: NSSharingServicePickerDelegate?
@ -112,6 +115,18 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations {
return sidebarViewController?.selectedObjects
}
func handle(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
sidebarViewController?.deepLinkRevealAndSelect(for: userInfo)
currentTimelineViewController?.goToDeepLink(for: userInfo)
}
func handle(_ activity: NSUserActivity) {
guard let userInfo = activity.userInfo else { return }
sidebarViewController?.deepLinkRevealAndSelect(for: userInfo)
currentTimelineViewController?.goToDeepLink(for: userInfo)
}
// MARK: - Notifications
// func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) {
@ -308,6 +323,11 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations {
makeToolbarValidate()
}
if articleExtractor?.state == .failedToParse {
startArticleExtractorForCurrentLink()
return
}
guard articleExtractor?.state != .processing else {
articleExtractor?.cancel()
articleExtractor = nil
@ -449,6 +469,8 @@ extension MainWindowController: SidebarDelegate {
extension MainWindowController: TimelineContainerViewControllerDelegate {
func timelineSelectionDidChange(_: TimelineContainerViewController, articles: [Article]?, mode: TimelineSourceMode) {
activityManager.invalidateReading()
articleExtractor?.cancel()
articleExtractor = nil
isShowingExtractedArticle = false
@ -457,6 +479,7 @@ extension MainWindowController: TimelineContainerViewControllerDelegate {
let detailState: DetailState
if let articles = articles {
if articles.count == 1 {
activityManager.reading(articles.first!)
if articles.first?.feed?.isArticleExtractorAlwaysOn ?? false {
detailState = .loading
startArticleExtractorForCurrentLink()

Some files were not shown because too many files have changed in this diff Show More