58
.circleci/config.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
# 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
|
||||
@@ -6,6 +6,58 @@
|
||||
<description>Most recent NetNewsWire changes with links to updates.</description>
|
||||
<language>en</language>
|
||||
|
||||
<item>
|
||||
<title>NetNewsWire 5.0a3</title>
|
||||
<description><![CDATA[
|
||||
<p>Fixed crash happening only on macOS 10.15 beta. We owe Apple a bug report for this one.</p>
|
||||
<p>Fixed a crash that could happen when finding a feed.</p>
|
||||
<p>Skip showing error dialogs on automatic refreshes.</p>
|
||||
<p>Immediately show the refresh progress bar when an OPML import to Feedbin starts.</p>
|
||||
<p>Add ellipsis to Import from OPML and Export to OPML buttons.</p>
|
||||
]]></description>
|
||||
<pubDate>Mon, 10 Jun 2019 21:45:00 -0700</pubDate>
|
||||
<enclosure url="https://ranchero.com/downloads/NetNewsWire5.0a3.zip" sparkle:version="2223" sparkle:shortVersionString="5.0a3" length="4689888" type="application/zip" />
|
||||
<sparkle:minimumSystemVersion>10.14.4</sparkle:minimumSystemVersion>
|
||||
</item>
|
||||
|
||||
|
||||
<item>
|
||||
<title>NetNewsWire 5.0a2</title>
|
||||
<description><![CDATA[
|
||||
<p>Escape HTML in the title in the article view — if there’s HTML in the title, the tags should actually be displayed.</p>
|
||||
<p>The Mark as Read command in the Article menu now turns into Mark as Unread at the appropriate times.</p>
|
||||
<p>Feedbin syncing: send locally changed statuses before downloading statuses from the server.</p>
|
||||
<p>Feedbin syncing: fix bug renaming a folder that has no feeds.</p>
|
||||
<p>Feedbin syncing: fixed a bunch of accuracy and reliability issues, and a crashing bug.</p>
|
||||
<p>Fixed issue where local account feed finder could lock UI in the case of an error.</p>
|
||||
]]></description>
|
||||
<pubDate>Sat, 08 Jun 2019 16:00:00 -0700</pubDate>
|
||||
<enclosure url="https://ranchero.com/downloads/NetNewsWire5.0a2.zip" sparkle:version="2209" sparkle:shortVersionString="5.0a2" length="4691481" type="application/zip" />
|
||||
<sparkle:minimumSystemVersion>10.14.4</sparkle:minimumSystemVersion>
|
||||
</item>
|
||||
|
||||
|
||||
<item>
|
||||
<title>NetNewsWire 5.0a1</title>
|
||||
<description><![CDATA[
|
||||
<p>NetNewsWire 5.0 has reached alpha stage! This means it has no known bugs. It surely <i>does</i> have bugs, though. Now it’s time for testing. (And writing the Help book. And making the website better.)</p>
|
||||
<p>Fixed a crashing bug with parsing a response from Feedbin. (Totally our fault, not Feedbin’s fault.)</p>
|
||||
<p>Show avatars from Micro.blog feeds with multiple authors (such as your personal timeline feed).</p>
|
||||
<p>Made OPML import to the On My Mac account way faster.</p>
|
||||
<p>The Today smart feed now updates when the day changes.</p>
|
||||
<p>You can now drag and drop in the sidebar between accounts.</p>
|
||||
<p>Made the default file name for OPML exports “Subscriptions-[accountName].opml”</p>
|
||||
<p>Add explanation text to Account preferences for the Name field. (It’s just a display name and doesn’t affect authentication.)</p>
|
||||
<p>Fixed several bugs with Feedbin syncing — it’s now more reliable. (We know of no remaining sync bugs, though of course there might be some.)</p>
|
||||
<p>Added a placeholder web page for the Help book.</p>
|
||||
<p>New app icon! But it might take a while for your Mac to notice and put in the Dock. (I wish we could speed that up, but it’s out of our control.)</p>
|
||||
]]></description>
|
||||
<pubDate>Fri, 31 May 2019 20:30:00 -0700</pubDate>
|
||||
<enclosure url="https://ranchero.com/downloads/NetNewsWire5.0a1.zip" sparkle:version="2185" sparkle:shortVersionString="5.0a1" length="4686634" type="application/zip" />
|
||||
<sparkle:minimumSystemVersion>10.14.4</sparkle:minimumSystemVersion>
|
||||
</item>
|
||||
|
||||
|
||||
<item>
|
||||
<title>NetNewsWire 5.0d17</title>
|
||||
<description>< first. If your code doesn’t follow the guidelines, we will likely suggest revising it.
|
||||
|
||||
Patience may be required at times. Brent has a day job, and sometimes everything happens at once. :)
|
||||
|
||||
Our code of conduct is below.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
### Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
### Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
### Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
### Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting Brent Simmons at brent@ranchero.com. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
### Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at [http://contributor-covenant.org/version/1/4][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/4/
|
||||
@@ -36,27 +36,6 @@ public enum AccountType: Int {
|
||||
// TODO: more
|
||||
}
|
||||
|
||||
public enum AccountError: LocalizedError {
|
||||
|
||||
case createErrorNotFound
|
||||
case createErrorAlreadySubscribed
|
||||
case opmlImportInProgress
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .opmlImportInProgress:
|
||||
return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running")
|
||||
default:
|
||||
return NSLocalizedString("An unknown error occurred.", comment: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
public var recoverySuggestion: String? {
|
||||
return NSLocalizedString("Please try again later.", comment: "Try later")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable {
|
||||
|
||||
public struct UserInfoKey {
|
||||
@@ -82,6 +61,11 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
return defaultName
|
||||
}()
|
||||
|
||||
public var isDeleted = false
|
||||
|
||||
public var account: Account? {
|
||||
return self
|
||||
}
|
||||
public let accountID: String
|
||||
public let type: AccountType
|
||||
public var nameForDisplay: String {
|
||||
@@ -188,6 +172,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
}
|
||||
}
|
||||
|
||||
public var usesTags: Bool {
|
||||
return delegate.usesTags
|
||||
}
|
||||
|
||||
var refreshInProgress = false {
|
||||
didSet {
|
||||
if refreshInProgress != oldValue {
|
||||
@@ -275,6 +263,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
switch credentials {
|
||||
case .basic(let username, _):
|
||||
self.username = username
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
try CredentialsManager.storeCredentials(credentials, server: server)
|
||||
@@ -309,7 +299,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshAll(completion: (() -> Void)? = nil) {
|
||||
public func refreshAll(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
self.delegate.refreshAll(for: self, completion: completion)
|
||||
}
|
||||
|
||||
@@ -334,9 +324,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
guard let self = self else { return }
|
||||
// Reset the last fetch date to get the article history for the added feeds.
|
||||
self.metadata.lastArticleFetch = nil
|
||||
self.delegate.refreshAll(for: self) {
|
||||
completion(.success(()))
|
||||
}
|
||||
self.delegate.refreshAll(for: self, completion: completion)
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
@@ -392,16 +380,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
return feed
|
||||
}
|
||||
|
||||
func addFeed(container: Container, feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.addFeed(for: self, to: container, with: feed, completion: completion)
|
||||
public func addFeed(_ feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.addFeed(for: self, with: feed, to: container, completion: completion)
|
||||
}
|
||||
|
||||
func removeFeed(_ feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.removeFeed(for: self, from: container, with: feed, completion: completion)
|
||||
}
|
||||
|
||||
public func createFeed(url: String, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
delegate.createFeed(for: self, url: url, completion: completion)
|
||||
public func createFeed(url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
delegate.createFeed(for: self, url: url, name: name, container: container, completion: completion)
|
||||
}
|
||||
|
||||
func createFeed(with name: String?, url: String, feedID: String, homePageURL: String?) -> Feed {
|
||||
@@ -411,27 +395,32 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
feed.name = name
|
||||
feed.homePageURL = homePageURL
|
||||
|
||||
addFeed(feed)
|
||||
|
||||
return feed
|
||||
|
||||
}
|
||||
|
||||
public func deleteFeed(_ feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
feedMetadata[feed.url] = nil
|
||||
delegate.deleteFeed(for: self, with: feed, completion: completion)
|
||||
public func removeFeed(_ feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.removeFeed(for: self, with: feed, from: container, completion: completion)
|
||||
}
|
||||
|
||||
public func moveFeed(_ feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.moveFeed(for: self, with: feed, from: from, to: to, completion: completion)
|
||||
}
|
||||
|
||||
public func renameFeed(_ feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.renameFeed(for: self, with: feed, to: name, completion: completion)
|
||||
}
|
||||
|
||||
public func restoreFeed(_ feed: Feed, folder: Folder?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.restoreFeed(for: self, feed: feed, folder: folder, completion: completion)
|
||||
public func restoreFeed(_ feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.restoreFeed(for: self, feed: feed, container: container, completion: completion)
|
||||
}
|
||||
|
||||
public func deleteFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.deleteFolder(for: self, with: folder, completion: completion)
|
||||
public func addFolder(_ name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
delegate.addFolder(for: self, name: name, completion: completion)
|
||||
}
|
||||
|
||||
public func removeFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.removeFolder(for: self, with: folder, completion: completion)
|
||||
}
|
||||
|
||||
public func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
@@ -442,6 +431,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
delegate.restoreFolder(for: self, folder: folder, completion: completion)
|
||||
}
|
||||
|
||||
func clearFeedMetadata(_ feed: Feed) {
|
||||
feedMetadata[feed.url] = nil
|
||||
}
|
||||
|
||||
func addFolder(_ folder: Folder) {
|
||||
folders!.insert(folder)
|
||||
postChildrenDidChangeNotification()
|
||||
@@ -457,8 +450,9 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
structureDidChange()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.refreshAll()
|
||||
self.refreshAll() { result in }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func updateUnreadCounts(for feeds: Set<Feed>) {
|
||||
@@ -679,27 +673,25 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
return _flattenedFeeds
|
||||
}
|
||||
|
||||
public func removeFeed(_ feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.removeFeed(for: self, from: self, with: feed, completion: completion)
|
||||
}
|
||||
|
||||
public func addFeed(_ feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delegate.addFeed(for: self, to: self, with: feed, completion: completion)
|
||||
}
|
||||
|
||||
func removeFeed(_ feed: Feed) {
|
||||
public func removeFeed(_ feed: Feed) {
|
||||
topLevelFeeds.remove(feed)
|
||||
structureDidChange()
|
||||
postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
func addFeed(_ feed: Feed) {
|
||||
public func addFeed(_ feed: Feed) {
|
||||
topLevelFeeds.insert(feed)
|
||||
structureDidChange()
|
||||
postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
func deleteFolder(_ folder: Folder) {
|
||||
func addFeedIfNotInAnyFolder(_ feed: Feed) {
|
||||
if !flattenedFeeds().contains(feed) {
|
||||
addFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
func removeFolder(_ folder: Folder) {
|
||||
folders?.remove(folder)
|
||||
structureDidChange()
|
||||
postChildrenDidChangeNotification()
|
||||
@@ -772,19 +764,19 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
|
||||
@objc func saveToDiskIfNeeded() {
|
||||
|
||||
if dirty {
|
||||
if dirty && !isDeleted {
|
||||
saveToDisk()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func saveFeedMetadataIfNeeded() {
|
||||
if feedMetadataDirty {
|
||||
if feedMetadataDirty && !isDeleted {
|
||||
saveFeedMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func saveAccountMetadataIfNeeded() {
|
||||
if metadataDirty {
|
||||
if metadataDirty && !isDeleted {
|
||||
saveAccountMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -131,6 +132,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -285,6 +287,7 @@
|
||||
children = (
|
||||
848935101F62486800CEBD24 /* Account.swift */,
|
||||
841974241F6DDCE4006346C4 /* AccountDelegate.swift */,
|
||||
51E3EB40229AF61B00645299 /* AccountError.swift */,
|
||||
846E77531F6F00E300A165E2 /* AccountManager.swift */,
|
||||
84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */,
|
||||
84F73CF0202788D80000BCEF /* ArticleFetcher.swift */,
|
||||
@@ -530,6 +533,7 @@
|
||||
84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */,
|
||||
5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */,
|
||||
846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */,
|
||||
51E3EB41229AF61B00645299 /* AccountError.swift in Sources */,
|
||||
51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */,
|
||||
5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */,
|
||||
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */,
|
||||
|
||||
@@ -14,6 +14,7 @@ protocol AccountDelegate {
|
||||
|
||||
// Local account does not; some synced accounts might.
|
||||
var supportsSubFolders: Bool { get }
|
||||
var usesTags: Bool { get }
|
||||
var opmlImportInProgress: Bool { get }
|
||||
|
||||
var server: String? { get }
|
||||
@@ -22,23 +23,23 @@ protocol AccountDelegate {
|
||||
|
||||
var refreshProgress: DownloadProgress { get }
|
||||
|
||||
func refreshAll(for account: Account, completion: (() -> Void)?)
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void))
|
||||
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void))
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void)
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func deleteFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func createFeed(for account: Account, url: String, completion: @escaping (Result<Feed, Error>) -> Void)
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void)
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func deleteFeed(for account: Account, with feed: Feed, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func addFeed(for account: Account, to container: Container, with: Feed, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func removeFeed(for account: Account, from container: Container, with: Feed, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, folder: Folder?, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>?
|
||||
|
||||
69
Frameworks/Account/AccountError.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// AccountError.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/26/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
|
||||
public enum AccountError: LocalizedError {
|
||||
|
||||
case createErrorNotFound
|
||||
case createErrorAlreadySubscribed
|
||||
case opmlImportInProgress
|
||||
case wrappedError(error: Error, account: Account)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .createErrorNotFound:
|
||||
return NSLocalizedString("The feed couldn't be found and can't be added.", comment: "Not found")
|
||||
case .createErrorAlreadySubscribed:
|
||||
return NSLocalizedString("You are already subscribed to this feed and can't add it again.", comment: "Already subscribed")
|
||||
case .opmlImportInProgress:
|
||||
return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running")
|
||||
case .wrappedError(let error, let account):
|
||||
switch error {
|
||||
case TransportError.httpError(let status):
|
||||
if status == 401 {
|
||||
let localizedText = NSLocalizedString("Your \"%@\" credentials are invalid or expired.", comment: "Invalid or expired")
|
||||
return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay) as String
|
||||
} else {
|
||||
return unknownError(error, account)
|
||||
}
|
||||
default:
|
||||
return unknownError(error, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .createErrorNotFound:
|
||||
return nil
|
||||
case .createErrorAlreadySubscribed:
|
||||
return nil
|
||||
case .wrappedError(let error, _):
|
||||
switch error {
|
||||
case TransportError.httpError(let status):
|
||||
if status == 401 {
|
||||
return NSLocalizedString("Please update your credentials for this account.", comment: "Try later")
|
||||
} else {
|
||||
return NSLocalizedString("Please try again later.", comment: "Try later")
|
||||
}
|
||||
default:
|
||||
return NSLocalizedString("Please try again later.", comment: "Try later")
|
||||
}
|
||||
default:
|
||||
return NSLocalizedString("Please try again later.", comment: "Try later")
|
||||
}
|
||||
}
|
||||
|
||||
private func unknownError(_ error: Error, _ account: Account) -> String {
|
||||
let localizedText = NSLocalizedString("An error occurred while processing the \"%@\" account: %@", comment: "Unknown error")
|
||||
return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay, error.localizedDescription) as String
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public final class AccountManager: UnreadCountProvider {
|
||||
|
||||
public static let shared = AccountManager()
|
||||
public let defaultAccount: Account
|
||||
|
||||
private let accountsFolder = RSDataSubfolder(nil, "Accounts")!
|
||||
private var accountsDictionary = [String: Account]()
|
||||
|
||||
@@ -126,6 +127,7 @@ public final class AccountManager: UnreadCountProvider {
|
||||
}
|
||||
|
||||
accountsDictionary.removeValue(forKey: account.accountID)
|
||||
account.isDeleted = true
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(atPath: account.dataFolder)
|
||||
@@ -145,9 +147,19 @@ public final class AccountManager: UnreadCountProvider {
|
||||
return accountsDictionary[accountID]
|
||||
}
|
||||
|
||||
public func refreshAll() {
|
||||
public func refreshAll(errorHandler: @escaping (Error) -> Void) {
|
||||
|
||||
activeAccounts.forEach { $0.refreshAll() }
|
||||
activeAccounts.forEach { account in
|
||||
account.refreshAll() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
errorHandler(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func syncArticleStatusAll(completion: (() -> Void)? = nil) {
|
||||
|
||||
@@ -18,6 +18,7 @@ extension Notification.Name {
|
||||
|
||||
public protocol Container: class {
|
||||
|
||||
var account: Account? { get }
|
||||
var topLevelFeeds: Set<Feed> { get set }
|
||||
var folders: Set<Folder>? { get set }
|
||||
|
||||
@@ -27,8 +28,8 @@ public protocol Container: class {
|
||||
func hasChildFolder(with: String) -> Bool
|
||||
func childFolder(with: String) -> Folder?
|
||||
|
||||
func removeFeed(_ feed: Feed, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func addFeed(_ feed: Feed, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func removeFeed(_ feed: Feed)
|
||||
func addFeed(_ feed: Feed)
|
||||
|
||||
//Recursive — checks subfolders
|
||||
func flattenedFeeds() -> Set<Feed>
|
||||
|
||||
@@ -64,7 +64,7 @@ public final class Feed: DisplayNameProvider, Renamable, UnreadCountProvider, Ha
|
||||
set {
|
||||
let oldNameForDisplay = nameForDisplay
|
||||
metadata.name = newValue
|
||||
if oldNameForDisplay != nameForDisplay {
|
||||
if oldNameForDisplay != newValue {
|
||||
postDisplayNameDidChangeNotification()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,38 +11,54 @@ import RSParser
|
||||
import RSWeb
|
||||
import RSCore
|
||||
|
||||
protocol FeedFinderDelegate: class {
|
||||
|
||||
func feedFinder(_: FeedFinder, didFindFeeds: Set<FeedSpecifier>)
|
||||
}
|
||||
|
||||
class FeedFinder {
|
||||
|
||||
static func find(url: URL, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
|
||||
|
||||
private weak var delegate: FeedFinderDelegate?
|
||||
private var feedSpecifiers = [String: FeedSpecifier]()
|
||||
private var didNotifyDelegate = false
|
||||
|
||||
var initialDownloadError: Error?
|
||||
var initialDownloadStatusCode = -1
|
||||
|
||||
init(url: URL, delegate: FeedFinderDelegate) {
|
||||
|
||||
self.delegate = delegate
|
||||
|
||||
DispatchQueue.main.async() { () -> Void in
|
||||
|
||||
self.findFeeds(url)
|
||||
downloadUsingCache(url) { (data, response, error) in
|
||||
|
||||
if response?.forcedStatusCode == 404 {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let response = response else {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
if !response.statusIsOK || data.isEmpty {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
if FeedFinder.isFeed(data, url.absoluteString) {
|
||||
let feedSpecifier = FeedSpecifier(title: nil, urlString: url.absoluteString, source: .UserEntered)
|
||||
completion(.success(Set([feedSpecifier])))
|
||||
return
|
||||
}
|
||||
|
||||
if !FeedFinder.isHTML(data) {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
FeedFinder.findFeedsInHTMLPage(htmlData: data, urlString: url.absoluteString, completion: completion)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
deinit {
|
||||
notifyDelegateIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeedFinder {
|
||||
|
||||
func addFeedSpecifier(_ feedSpecifier: FeedSpecifier) {
|
||||
static func addFeedSpecifier(_ feedSpecifier: FeedSpecifier, feedSpecifiers: inout [String: FeedSpecifier]) {
|
||||
|
||||
// If there’s an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesn’t, use that non-nil title. Use the better source.
|
||||
|
||||
@@ -55,7 +71,7 @@ private extension FeedFinder {
|
||||
}
|
||||
}
|
||||
|
||||
func findFeedsInHTMLPage(htmlData: Data, urlString: String) {
|
||||
static func findFeedsInHTMLPage(htmlData: Data, urlString: String, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
|
||||
|
||||
// Feeds in the <head> section we automatically assume are feeds.
|
||||
// If there are none from the <head> section,
|
||||
@@ -63,31 +79,35 @@ private extension FeedFinder {
|
||||
// and added once we determine they are feeds.
|
||||
|
||||
let possibleFeedSpecifiers = possibleFeedsInHTMLPage(htmlData: htmlData, urlString: urlString)
|
||||
var feedSpecifiers = [String: FeedSpecifier]()
|
||||
var feedSpecifiersToDownload = Set<FeedSpecifier>()
|
||||
|
||||
var didFindFeedInHTMLHead = false
|
||||
|
||||
for oneFeedSpecifier in possibleFeedSpecifiers {
|
||||
if oneFeedSpecifier.source == .HTMLHead {
|
||||
addFeedSpecifier(oneFeedSpecifier)
|
||||
addFeedSpecifier(oneFeedSpecifier, feedSpecifiers: &feedSpecifiers)
|
||||
didFindFeedInHTMLHead = true
|
||||
}
|
||||
else {
|
||||
if !feedSpecifiersContainsURLString(oneFeedSpecifier.urlString) {
|
||||
if feedSpecifiers[oneFeedSpecifier.urlString] == nil {
|
||||
feedSpecifiersToDownload.insert(oneFeedSpecifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if didFindFeedInHTMLHead || feedSpecifiersToDownload.isEmpty {
|
||||
stopFinding()
|
||||
}
|
||||
else {
|
||||
downloadFeedSpecifiers(feedSpecifiersToDownload)
|
||||
if didFindFeedInHTMLHead {
|
||||
completion(.success(Set(feedSpecifiers.values)))
|
||||
return
|
||||
} else if feedSpecifiersToDownload.isEmpty {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
} else {
|
||||
downloadFeedSpecifiers(feedSpecifiersToDownload, feedSpecifiers: feedSpecifiers, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
func possibleFeedsInHTMLPage(htmlData: Data, urlString: String) -> Set<FeedSpecifier> {
|
||||
static func possibleFeedsInHTMLPage(htmlData: Data, urlString: String) -> Set<FeedSpecifier> {
|
||||
|
||||
let parserData = ParserData(url: urlString, data: htmlData)
|
||||
var feedSpecifiers = HTMLFeedFinder(parserData: parserData).feedSpecifiers
|
||||
@@ -109,105 +129,42 @@ private extension FeedFinder {
|
||||
return feedSpecifiers
|
||||
}
|
||||
|
||||
func feedSpecifiersContainsURLString(_ urlString: String) -> Bool {
|
||||
|
||||
if let _ = feedSpecifiers[urlString] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHTML(_ data: Data) -> Bool {
|
||||
|
||||
static func isHTML(_ data: Data) -> Bool {
|
||||
return (data as NSData).rs_dataIsProbablyHTML()
|
||||
}
|
||||
|
||||
func findFeeds(_ initialURL: URL) {
|
||||
static func downloadFeedSpecifiers(_ downloadFeedSpecifiers: Set<FeedSpecifier>, feedSpecifiers: [String: FeedSpecifier], completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
|
||||
|
||||
downloadInitialFeed(initialURL)
|
||||
}
|
||||
var resultFeedSpecifiers = feedSpecifiers
|
||||
let group = DispatchGroup()
|
||||
|
||||
for downloadFeedSpecifier in downloadFeedSpecifiers {
|
||||
|
||||
func downloadInitialFeed(_ initialURL: URL) {
|
||||
|
||||
downloadUsingCache(initialURL) { (data, response, error) in
|
||||
|
||||
self.initialDownloadStatusCode = response?.forcedStatusCode ?? -1
|
||||
|
||||
if let error = error {
|
||||
self.initialDownloadError = error
|
||||
self.stopFinding()
|
||||
return
|
||||
}
|
||||
guard let data = data, let response = response else {
|
||||
self.stopFinding()
|
||||
return
|
||||
}
|
||||
|
||||
if !response.statusIsOK || data.isEmpty {
|
||||
self.stopFinding()
|
||||
return
|
||||
}
|
||||
|
||||
if self.isFeed(data, initialURL.absoluteString) {
|
||||
let feedSpecifier = FeedSpecifier(title: nil, urlString: initialURL.absoluteString, source: .UserEntered)
|
||||
self.addFeedSpecifier(feedSpecifier)
|
||||
self.stopFinding()
|
||||
return
|
||||
}
|
||||
|
||||
if !self.isHTML(data) {
|
||||
self.stopFinding()
|
||||
return
|
||||
}
|
||||
|
||||
self.findFeedsInHTMLPage(htmlData: data, urlString: initialURL.absoluteString)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFeedSpecifiers(_ feedSpecifiers: Set<FeedSpecifier>) {
|
||||
|
||||
var pendingDownloads = feedSpecifiers
|
||||
|
||||
for oneFeedSpecifier in feedSpecifiers {
|
||||
|
||||
guard let url = URL(string: oneFeedSpecifier.urlString) else {
|
||||
pendingDownloads.remove(oneFeedSpecifier)
|
||||
guard let url = URL(string: downloadFeedSpecifier.urlString) else {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
group.enter()
|
||||
downloadUsingCache(url) { (data, response, error) in
|
||||
|
||||
pendingDownloads.remove(oneFeedSpecifier)
|
||||
|
||||
if let data = data, let response = response, response.statusIsOK, error == nil {
|
||||
if self.isFeed(data, oneFeedSpecifier.urlString) {
|
||||
self.addFeedSpecifier(oneFeedSpecifier)
|
||||
if self.isFeed(data, downloadFeedSpecifier.urlString) {
|
||||
addFeedSpecifier(downloadFeedSpecifier, feedSpecifiers: &resultFeedSpecifiers)
|
||||
}
|
||||
}
|
||||
|
||||
if pendingDownloads.isEmpty {
|
||||
self.stopFinding()
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func stopFinding() {
|
||||
|
||||
notifyDelegateIfNeeded()
|
||||
}
|
||||
|
||||
func notifyDelegateIfNeeded() {
|
||||
|
||||
if !didNotifyDelegate {
|
||||
delegate?.feedFinder(self, didFindFeeds: Set(feedSpecifiers.values))
|
||||
didNotifyDelegate = true
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
completion(.success(Set(resultFeedSpecifiers.values)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func isFeed(_ data: Data, _ urlString: String) -> Bool {
|
||||
|
||||
static func isFeed(_ data: Data, _ urlString: String) -> Bool {
|
||||
let parserData = ParserData(url: urlString, data: data)
|
||||
return FeedParser.canParse(parserData)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ struct FeedSpecifier: Hashable {
|
||||
return feedSpecifiers.anyObject()
|
||||
}
|
||||
|
||||
var currentHighScore = 0
|
||||
var currentHighScore = Int.min
|
||||
var currentBestFeed: FeedSpecifier? = nil
|
||||
|
||||
for oneFeedSpecifier in feedSpecifiers {
|
||||
|
||||
@@ -143,25 +143,6 @@ final class FeedbinAPICaller: NSObject {
|
||||
transport.send(request: request, method: HTTPMethod.post, payload: payload, completion: completion)
|
||||
}
|
||||
|
||||
func deleteTag(name: String, completion: @escaping (Result<[FeedbinTagging]?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("tags.json")
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
let payload = FeedbinDeleteTag(name: name)
|
||||
|
||||
transport.send(request: request, method: HTTPMethod.delete, payload: payload, resultType: [FeedbinTagging].self) { result in
|
||||
|
||||
switch result {
|
||||
case .success(let (_, taggings)):
|
||||
completion(.success(taggings))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveSubscriptions(completion: @escaping (Result<[FeedbinSubscription]?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("subscriptions.json")
|
||||
@@ -358,9 +339,9 @@ final class FeedbinAPICaller: NSObject {
|
||||
let concatIDs = articleIDs.reduce("") { param, articleID in return param + ",\(articleID)" }
|
||||
let paramIDs = String(concatIDs.dropFirst())
|
||||
|
||||
var callURL = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)!
|
||||
callURL.queryItems = [URLQueryItem(name: "ids", value: paramIDs)]
|
||||
let request = URLRequest(url: callURL.url!, credentials: credentials)
|
||||
var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)!
|
||||
callComponents.queryItems = [URLQueryItem(name: "ids", value: paramIDs), URLQueryItem(name: "mode", value: "extended")]
|
||||
let request = URLRequest(url: callComponents.url!, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { result in
|
||||
|
||||
@@ -380,9 +361,9 @@ final class FeedbinAPICaller: NSObject {
|
||||
let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()
|
||||
let sinceString = FeedbinDate.formatter.string(from: since)
|
||||
|
||||
var callURL = URLComponents(url: feedbinBaseURL.appendingPathComponent("/feeds/\(feedID)/entries.json"), resolvingAgainstBaseURL: false)!
|
||||
callURL.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100")]
|
||||
let request = URLRequest(url: callURL.url!, credentials: credentials)
|
||||
var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("feeds/\(feedID)/entries.json"), resolvingAgainstBaseURL: false)!
|
||||
callComponents.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100"), URLQueryItem(name: "mode", value: "extended")]
|
||||
let request = URLRequest(url: callComponents.url!, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { result in
|
||||
|
||||
@@ -411,9 +392,9 @@ final class FeedbinAPICaller: NSObject {
|
||||
}()
|
||||
|
||||
let sinceString = FeedbinDate.formatter.string(from: since)
|
||||
var callURL = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)!
|
||||
callURL.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100")]
|
||||
let request = URLRequest(url: callURL.url!, credentials: credentials)
|
||||
var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)!
|
||||
callComponents.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100"), URLQueryItem(name: "mode", value: "extended")]
|
||||
let request = URLRequest(url: callComponents.url!, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { result in
|
||||
|
||||
@@ -438,12 +419,12 @@ final class FeedbinAPICaller: NSObject {
|
||||
|
||||
func retrieveEntries(page: String, completion: @escaping (Result<([FeedbinEntry]?, String?), Error>) -> Void) {
|
||||
|
||||
guard let callURL = URL(string: page) else {
|
||||
guard let url = URL(string: page) else {
|
||||
completion(.success((nil, nil)))
|
||||
return
|
||||
}
|
||||
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
let request = URLRequest(url: url, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { result in
|
||||
|
||||
@@ -550,11 +531,12 @@ extension FeedbinAPICaller {
|
||||
}
|
||||
|
||||
if let lowerBound = link.range(of: "page=")?.upperBound {
|
||||
if let upperBound = link.range(of: "&")?.lowerBound {
|
||||
return Int(link[lowerBound..<upperBound])
|
||||
let partialLink = link[lowerBound..<link.endIndex]
|
||||
if let upperBound = partialLink.firstIndex(of: "&") {
|
||||
return Int(partialLink[partialLink.startIndex..<upperBound])
|
||||
}
|
||||
if let upperBound = link.range(of: ">")?.lowerBound {
|
||||
return Int(link[lowerBound..<upperBound])
|
||||
if let upperBound = partialLink.firstIndex(of: ">") {
|
||||
return Int(partialLink[partialLink.startIndex..<upperBound])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedbin")
|
||||
|
||||
let supportsSubFolders = false
|
||||
let usesTags = true
|
||||
let server: String? = "api.feedbin.com"
|
||||
var opmlImportInProgress = false
|
||||
|
||||
@@ -78,7 +79,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
func refreshAll(for account: Account, completion: (() -> Void)? = nil) {
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(6)
|
||||
|
||||
@@ -87,11 +88,13 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
case .success():
|
||||
|
||||
self.refreshArticles(account) {
|
||||
self.refreshArticleStatus(for: account) {
|
||||
self.refreshMissingArticles(account) {
|
||||
self.refreshProgress.clear()
|
||||
DispatchQueue.main.async {
|
||||
completion?()
|
||||
self.sendArticleStatus(for: account) {
|
||||
self.refreshArticleStatus(for: account) {
|
||||
self.refreshMissingArticles(account) {
|
||||
self.refreshProgress.clear()
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,9 +102,9 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion?()
|
||||
self.refreshProgress.clear()
|
||||
self.handleError(error)
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,12 +208,14 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
|
||||
os_log(.debug, log: log, "Begin importing OPML...")
|
||||
opmlImportInProgress = true
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
caller.importOPML(opmlData: opmlData) { result in
|
||||
switch result {
|
||||
case .success(let importResult):
|
||||
if importResult.complete {
|
||||
os_log(.debug, log: self.log, "Import OPML done.")
|
||||
self.refreshProgress.completeTask()
|
||||
self.opmlImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
@@ -220,77 +225,118 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Import OPML failed.")
|
||||
self.refreshProgress.completeTask()
|
||||
self.opmlImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
completion(.success(folder))
|
||||
} else {
|
||||
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
|
||||
}
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
guard folder.hasAtLeastOneFeed() else {
|
||||
folder.name = name
|
||||
return
|
||||
}
|
||||
|
||||
caller.renameTag(oldName: folder.name ?? "", newName: name) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
self.renameFolderRelationship(for: account, fromName: folder.name ?? "", toName: name)
|
||||
folder.name = name
|
||||
completion(.success(()))
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func deleteFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
// Feedbin uses tags and if at least one feed isn't tagged, then the folder doesn't exist on their system
|
||||
guard folder.hasAtLeastOneFeed() else {
|
||||
account.deleteFolder(folder)
|
||||
account.removeFolder(folder)
|
||||
return
|
||||
}
|
||||
|
||||
// After we successfully delete at Feedbin, we add all the feeds to the account to save them. We then
|
||||
// delete the folder. We then sync the taggings we received on the delete to remove any feeds from
|
||||
// the account that might be in another folder.
|
||||
caller.deleteTag(name: folder.name ?? "") { result in
|
||||
switch result {
|
||||
case .success(let taggings):
|
||||
DispatchQueue.main.sync {
|
||||
BatchUpdate.shared.perform {
|
||||
for feed in folder.topLevelFeeds {
|
||||
account.addFeed(feed)
|
||||
self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
|
||||
let group = DispatchGroup()
|
||||
|
||||
for feed in folder.topLevelFeeds {
|
||||
|
||||
if feed.folderRelationship?.count ?? 0 > 1 {
|
||||
|
||||
if let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] {
|
||||
group.enter()
|
||||
caller.deleteTagging(taggingID: feedTaggingID) { result in
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
account.deleteFolder(folder)
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
self.syncTaggings(account, taggings)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
|
||||
} else {
|
||||
|
||||
if let subscriptionID = feed.subscriptionID {
|
||||
group.enter()
|
||||
caller.deleteSubscription(subscriptionID: subscriptionID) { result in
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
account.clearFeedMetadata(feed)
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
account.removeFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func createFeed(for account: Account, url: String, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
|
||||
caller.createSubscription(url: url) { result in
|
||||
switch result {
|
||||
case .success(let subResult):
|
||||
switch subResult {
|
||||
case .created(let subscription):
|
||||
self.createFeed(account: account, subscription: subscription, completion: completion)
|
||||
self.createFeed(account: account, subscription: subscription, name: name, container: container, completion: completion)
|
||||
case .multipleChoice(let choices):
|
||||
self.decideBestFeedChoice(account: account, url: url, choices: choices, completion: completion)
|
||||
self.decideBestFeedChoice(account: account, url: url, name: name, container: container, choices: choices, completion: completion)
|
||||
case .alreadySubscribed:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(AccountError.createErrorAlreadySubscribed))
|
||||
@@ -302,7 +348,8 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,43 +374,38 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func deleteFeed(for account: Account, with feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
// This error should never happen
|
||||
guard let subscriptionID = feed.subscriptionID else {
|
||||
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
|
||||
return
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if feed.folderRelationship?.count ?? 0 > 1 {
|
||||
deleteTagging(for: account, with: feed, from: container, completion: completion)
|
||||
} else {
|
||||
deleteSubscription(for: account, with: feed, from: container, completion: completion)
|
||||
}
|
||||
|
||||
caller.deleteSubscription(subscriptionID: subscriptionID) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
account.removeFeed(feed)
|
||||
if let folders = account.folders {
|
||||
for folder in folders {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
}
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if from is Account {
|
||||
addFeed(for: account, with: feed, to: to, completion: completion)
|
||||
} else {
|
||||
deleteTagging(for: account, with: feed, from: from) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.addFeed(for: account, with: feed, to: to, completion: completion)
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, to container: Container, with feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
if let folder = container as? Folder, let feedID = Int(feed.feedID) {
|
||||
caller.createTagging(feedID: feedID, name: folder.name ?? "") { result in
|
||||
@@ -377,56 +419,39 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let account = container as? Account {
|
||||
account.addFeed(feed)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if let account = container as? Account {
|
||||
account.addFeedIfNotInAnyFolder(feed)
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, from container: Container, with feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
if let folder = container as? Folder, let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] {
|
||||
caller.deleteTagging(taggingID: feedTaggingID) { result in
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
if let existingFeed = account.existingFeed(withURL: feed.url) {
|
||||
account.addFeed(existingFeed, to: container) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
folder.removeFeed(feed)
|
||||
completion(.success(()))
|
||||
}
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let account = container as? Account {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, folder: Folder?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
let editedName = feed.editedName
|
||||
|
||||
createFeed(for: account, url: feed.url) { result in
|
||||
switch result {
|
||||
case .success(let feed):
|
||||
self.processRestoredFeed(for: account, feed: feed, editedName: editedName, folder: folder, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -436,22 +461,27 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
account.addFolder(folder)
|
||||
let group = DispatchGroup()
|
||||
|
||||
for feed in folder.topLevelFeeds {
|
||||
|
||||
folder.topLevelFeeds.remove(feed)
|
||||
|
||||
group.enter()
|
||||
addFeed(for: account, to: folder, with: feed) { result in
|
||||
if account.topLevelFeeds.contains(feed) {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
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: DispatchQueue.main) {
|
||||
account.addFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
@@ -464,6 +494,10 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
}
|
||||
database.insertStatuses(syncStatuses)
|
||||
|
||||
if database.selectPendingCount() > 100 {
|
||||
sendArticleStatus(for: account) {}
|
||||
}
|
||||
|
||||
return account.update(articles, statusKey: statusKey, flag: flag)
|
||||
|
||||
}
|
||||
@@ -491,14 +525,6 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
||||
|
||||
private extension FeedbinAccountDelegate {
|
||||
|
||||
func handleError(_ error: Error) {
|
||||
#if os(macOS)
|
||||
NSApplication.shared.presentError(error)
|
||||
#else
|
||||
UIApplication.shared.presentError(error)
|
||||
#endif
|
||||
}
|
||||
|
||||
func refreshAccount(_ account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
caller.retrieveTags { result in
|
||||
@@ -530,6 +556,7 @@ private extension FeedbinAccountDelegate {
|
||||
if let result = importResult, result.complete {
|
||||
os_log(.debug, log: self.log, "Checking status of OPML import successfully completed.")
|
||||
timer.invalidate()
|
||||
self.refreshProgress.completeTask()
|
||||
self.opmlImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
@@ -538,6 +565,7 @@ private extension FeedbinAccountDelegate {
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Import OPML check failed.")
|
||||
timer.invalidate()
|
||||
self.refreshProgress.completeTask()
|
||||
self.opmlImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
@@ -568,7 +596,7 @@ private extension FeedbinAccountDelegate {
|
||||
account.addFeed(feed)
|
||||
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
|
||||
}
|
||||
account.deleteFolder(folder)
|
||||
account.removeFolder(folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,7 +703,10 @@ private extension FeedbinAccountDelegate {
|
||||
DispatchQueue.main.sync {
|
||||
if let feed = account.idToFeedDictionary[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)
|
||||
@@ -820,72 +851,15 @@ private extension FeedbinAccountDelegate {
|
||||
|
||||
}
|
||||
|
||||
func processRestoredFeed(for account: Account, feed: Feed, editedName: String?, folder: Folder?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
if let folder = folder {
|
||||
|
||||
addFeed(for: account, to: folder, with: feed) { result in
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
|
||||
if editedName != nil {
|
||||
DispatchQueue.main.async {
|
||||
account.removeFeed(feed)
|
||||
folder.addFeed(feed)
|
||||
}
|
||||
self.processRestoredFeedName(for: account, feed: feed, editedName: editedName!, completion: completion)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
account.removeFeed(feed)
|
||||
folder.addFeed(feed)
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func renameFolderRelationship(for account: Account, fromName: String, toName: String) {
|
||||
for feed in account.flattenedFeeds() {
|
||||
if var folderRelationship = feed.folderRelationship {
|
||||
let relationship = folderRelationship[fromName]
|
||||
folderRelationship[fromName] = nil
|
||||
folderRelationship[toName] = relationship
|
||||
feed.folderRelationship = folderRelationship
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
account.addFeed(feed)
|
||||
}
|
||||
|
||||
if editedName != nil {
|
||||
processRestoredFeedName(for: account, feed: feed, editedName: editedName!, completion: completion)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func processRestoredFeedName(for account: Account, feed: Feed, editedName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
renameFeed(for: account, with: feed, to: editedName) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
feed.editedName = editedName
|
||||
completion(.success(()))
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func clearFolderRelationship(for feed: Feed, withFolderName folderName: String) {
|
||||
@@ -904,7 +878,7 @@ private extension FeedbinAccountDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func decideBestFeedChoice(account: Account, url: String, choices: [FeedbinSubscriptionChoice], completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
func decideBestFeedChoice(account: Account, url: String, name: String?, container: Container, choices: [FeedbinSubscriptionChoice], completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
|
||||
let feedSpecifiers: [FeedSpecifier] = choices.map { choice in
|
||||
let source = url == choice.url ? FeedSpecifier.Source.UserEntered : FeedSpecifier.Source.HTMLLink
|
||||
@@ -914,7 +888,7 @@ private extension FeedbinAccountDelegate {
|
||||
|
||||
if let bestSpecifier = FeedSpecifier.bestFeed(in: Set(feedSpecifiers)) {
|
||||
if let bestSubscription = choices.filter({ bestSpecifier.urlString == $0.url }).first {
|
||||
createFeed(for: account, url: bestSubscription.url, completion: completion)
|
||||
createFeed(for: account, url: bestSubscription.url, name: name, container: container, completion: completion)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
|
||||
@@ -928,44 +902,66 @@ private extension FeedbinAccountDelegate {
|
||||
|
||||
}
|
||||
|
||||
func createFeed( account: Account, subscription sub: FeedbinSubscription, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
func createFeed( account: Account, subscription sub: FeedbinSubscription, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
||||
let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL)
|
||||
feed.subscriptionID = String(sub.subscriptionID)
|
||||
|
||||
// Download the initial articles
|
||||
self.caller.retrieveEntries(feedID: feed.feedID) { result in
|
||||
|
||||
account.addFeed(feed, to: container) { result in
|
||||
switch result {
|
||||
case .success(let (entries, page)):
|
||||
|
||||
self.processEntries(account: account, entries: entries) {
|
||||
self.refreshArticles(account, page: page) {
|
||||
self.refreshArticleStatus(for: account) {
|
||||
self.refreshMissingArticles(account) {
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(feed))
|
||||
}
|
||||
}
|
||||
case .success:
|
||||
if let name = name {
|
||||
account.renameFeed(feed, to: name) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.initialFeedDownload(account: account, feed: feed, completion: completion)
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.initialFeedDownload(account: account, feed: feed, completion: completion)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Initial articles download failed: %@.", error.localizedDescription)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(feed))
|
||||
}
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func initialFeedDownload( account: Account, feed: Feed, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
|
||||
// Download the initial articles
|
||||
self.caller.retrieveEntries(feedID: feed.feedID) { result in
|
||||
|
||||
switch result {
|
||||
case .success(let (entries, page)):
|
||||
|
||||
self.processEntries(account: account, entries: entries) {
|
||||
self.refreshArticles(account, page: page) {
|
||||
self.refreshArticleStatus(for: account) {
|
||||
self.refreshMissingArticles(account) {
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(feed))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func refreshArticles(_ account: Account, completion: @escaping (() -> Void)) {
|
||||
|
||||
os_log(.debug, log: log, "Refreshing articles...")
|
||||
@@ -1098,7 +1094,7 @@ private extension FeedbinAccountDelegate {
|
||||
}
|
||||
|
||||
let parsedItems: [ParsedItem] = entries.map { entry in
|
||||
let authors = Set([ParsedAuthor(name: entry.authorName, url: nil, avatarURL: nil, emailAddress: nil)])
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1122,13 +1118,6 @@ private extension FeedbinAccountDelegate {
|
||||
_ = account.update(markUnreadArticles, statusKey: .read, flag: false)
|
||||
}
|
||||
|
||||
// Mark articles as read
|
||||
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs)
|
||||
let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs)
|
||||
DispatchQueue.main.async {
|
||||
_ = account.update(markReadArticles, statusKey: .read, flag: true)
|
||||
}
|
||||
|
||||
// Save any unread statuses for articles we haven't yet received
|
||||
let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID })
|
||||
let missingUnreadArticleIDs = deltaUnreadArticleIDs.subtracting(markUnreadArticleIDs)
|
||||
@@ -1138,6 +1127,22 @@ private extension FeedbinAccountDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark articles as read
|
||||
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs)
|
||||
let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs)
|
||||
DispatchQueue.main.async {
|
||||
_ = account.update(markReadArticles, statusKey: .read, flag: true)
|
||||
}
|
||||
|
||||
// Save any read statuses for articles we haven't yet received
|
||||
let markReadArticleIDs = Set(markReadArticles.map { $0.articleID })
|
||||
let missingReadArticleIDs = deltaReadArticleIDs.subtracting(markReadArticleIDs)
|
||||
if !missingReadArticleIDs.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
account.ensureStatuses(missingReadArticleIDs, .read, true)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func syncArticleStarredState(account: Account, articleIDs: [Int]?) {
|
||||
@@ -1156,13 +1161,6 @@ private extension FeedbinAccountDelegate {
|
||||
_ = account.update(markStarredArticles, statusKey: .starred, flag: true)
|
||||
}
|
||||
|
||||
// Mark articles as unstarred
|
||||
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs)
|
||||
let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs)
|
||||
DispatchQueue.main.async {
|
||||
_ = account.update(markUnstarredArticles, statusKey: .starred, flag: false)
|
||||
}
|
||||
|
||||
// Save any starred statuses for articles we haven't yet received
|
||||
let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID })
|
||||
let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs)
|
||||
@@ -1172,6 +1170,81 @@ private extension FeedbinAccountDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark articles as unstarred
|
||||
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs)
|
||||
let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs)
|
||||
DispatchQueue.main.async {
|
||||
_ = 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)
|
||||
if !missingUnstarredArticleIDs.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
account.ensureStatuses(missingUnstarredArticleIDs, .starred, false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func deleteTagging(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
if let folder = container as? Folder, let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] {
|
||||
caller.deleteTagging(taggingID: feedTaggingID) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
|
||||
folder.removeFeed(feed)
|
||||
account.addFeedIfNotInAnyFolder(feed)
|
||||
completion(.success(()))
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let account = container as? Account {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func deleteSubscription(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
// This error should never happen
|
||||
guard let subscriptionID = feed.subscriptionID else {
|
||||
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
caller.deleteSubscription(subscriptionID: subscriptionID) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
account.clearFeedMetadata(feed)
|
||||
account.removeFeed(feed)
|
||||
if let folders = account.folders {
|
||||
for folder in folders {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct FeedbinEntry: Codable {
|
||||
let summary: String?
|
||||
let datePublished: String?
|
||||
let dateArrived: String?
|
||||
let jsonFeed: FeedbinEntryJSONFeed?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case articleID = "id"
|
||||
@@ -32,6 +33,7 @@ struct FeedbinEntry: Codable {
|
||||
case summary = "summary"
|
||||
case datePublished = "published"
|
||||
case dateArrived = "created_at"
|
||||
case jsonFeed = "json_feed"
|
||||
}
|
||||
|
||||
// Feedbin dates can't be decoded by the JSONDecoding 8601 decoding strategy. Feedbin
|
||||
@@ -47,3 +49,19 @@ struct FeedbinEntry: Codable {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FeedbinEntryJSONFeed: Codable {
|
||||
let jsonFeedAuthor: FeedbinEntryJSONFeedAuthor?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jsonFeedAuthor = "author"
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedbinEntryJSONFeedAuthor: Codable {
|
||||
let url: String?
|
||||
let avatarURL: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url = "url"
|
||||
case avatarURL = "avatar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,20 +95,12 @@ public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCoun
|
||||
return topLevelFeeds.contains(feed)
|
||||
}
|
||||
|
||||
public func addFeed(_ feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
account?.addFeed(container: self, feed: feed, completion: completion)
|
||||
}
|
||||
|
||||
public func removeFeed(_ feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
account?.removeFeed(feed, from: self, completion: completion)
|
||||
}
|
||||
|
||||
func addFeed(_ feed: Feed) {
|
||||
public func addFeed(_ feed: Feed) {
|
||||
topLevelFeeds.insert(feed)
|
||||
postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
func removeFeed(_ feed: Feed) {
|
||||
public func removeFeed(_ feed: Feed) {
|
||||
topLevelFeeds.remove(feed)
|
||||
postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSParser
|
||||
import Articles
|
||||
import RSWeb
|
||||
@@ -18,16 +19,13 @@ public enum LocalAccountDelegateError: String, Error {
|
||||
final class LocalAccountDelegate: AccountDelegate {
|
||||
|
||||
let supportsSubFolders = false
|
||||
let usesTags = false
|
||||
let opmlImportInProgress = false
|
||||
|
||||
let server: String? = nil
|
||||
var credentials: Credentials?
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
||||
private weak var account: Account?
|
||||
private var feedFinder: FeedFinder?
|
||||
private var createFeedCompletion: ((Result<Feed, Error>) -> Void)?
|
||||
|
||||
private let refresher = LocalAccountRefresher()
|
||||
|
||||
var refreshProgress: DownloadProgress {
|
||||
@@ -35,9 +33,9 @@ final class LocalAccountDelegate: AccountDelegate {
|
||||
}
|
||||
|
||||
// LocalAccountDelegate doesn't wait for completion before calling the completion block
|
||||
func refreshAll(for account: Account, completion: (() -> Void)? = nil) {
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refresher.refreshFeeds(account.flattenedFeeds())
|
||||
completion?()
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
|
||||
@@ -81,33 +79,57 @@ final class LocalAccountDelegate: AccountDelegate {
|
||||
|
||||
// We use the same mechanism to load local accounts as we do to load the subscription
|
||||
// OPML all accounts.
|
||||
account.loadOPML(loadDocument)
|
||||
BatchUpdate.shared.perform {
|
||||
account.loadOPML(loadDocument)
|
||||
}
|
||||
completion(.success(()))
|
||||
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
folder.name = name
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func deleteFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
account.deleteFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func createFeed(for account: Account, url urlString: String, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
func createFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
completion(.failure(LocalAccountDelegateError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
self.account = account
|
||||
createFeedCompletion = completion
|
||||
|
||||
feedFinder = FeedFinder(url: url, delegate: self)
|
||||
|
||||
FeedFinder.find(url: url) { result in
|
||||
|
||||
switch result {
|
||||
case .success(let feedSpecifiers):
|
||||
|
||||
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers),
|
||||
let url = URL(string: bestFeedSpecifier.urlString) else {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
if account.hasFeed(withURL: bestFeedSpecifier.urlString) {
|
||||
completion(.failure(AccountError.createErrorAlreadySubscribed))
|
||||
return
|
||||
}
|
||||
|
||||
let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil)
|
||||
|
||||
InitialFeedDownloader.download(url) { parsedFeed in
|
||||
|
||||
if let parsedFeed = parsedFeed {
|
||||
account.update(feed, with: parsedFeed, {})
|
||||
}
|
||||
|
||||
feed.editedName = name
|
||||
|
||||
container.addFeed(feed)
|
||||
completion(.success(feed))
|
||||
|
||||
}
|
||||
|
||||
case .failure:
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
@@ -115,54 +137,42 @@ final class LocalAccountDelegate: AccountDelegate {
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func deleteFeed(for account: Account, from container: Container, feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
if let account = container as? Account {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
if let folder = container as? Folder {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
completion(.success(()))
|
||||
|
||||
}
|
||||
|
||||
func deleteFeed(for account: Account, with feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
account.removeFeed(feed)
|
||||
if let folders = account.folders {
|
||||
for folder in folders {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
container?.removeFeed(feed)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, to container: Container, with feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if let account = container as? Account {
|
||||
account.addFeed(feed)
|
||||
}
|
||||
if let folder = container as? Folder {
|
||||
folder.addFeed(feed)
|
||||
}
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
from.removeFeed(feed)
|
||||
to.addFeed(feed)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, from container: Container, with feed: Feed, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if let account = container as? Account {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
if let folder = container as? Folder {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
container.addFeed(feed)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, folder: Folder?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if let folder = folder {
|
||||
folder.addFeed(feed)
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
container.addFeed(feed)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
completion(.success(folder))
|
||||
} else {
|
||||
account.addFeed(feed)
|
||||
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
|
||||
}
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
folder.name = name
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
account.removeFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
@@ -183,42 +193,3 @@ final class LocalAccountDelegate: AccountDelegate {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension LocalAccountDelegate: FeedFinderDelegate {
|
||||
|
||||
// MARK: FeedFinderDelegate
|
||||
|
||||
public func feedFinder(_ feedFinder: FeedFinder, didFindFeeds feedSpecifiers: Set<FeedSpecifier>) {
|
||||
|
||||
if let error = feedFinder.initialDownloadError {
|
||||
if feedFinder.initialDownloadStatusCode == 404 {
|
||||
createFeedCompletion!(.failure(AccountError.createErrorNotFound))
|
||||
} else {
|
||||
createFeedCompletion!(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers),
|
||||
let url = URL(string: bestFeedSpecifier.urlString),
|
||||
let account = account else {
|
||||
createFeedCompletion!(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
if account.hasFeed(withURL: bestFeedSpecifier.urlString) {
|
||||
createFeedCompletion!(.failure(AccountError.createErrorAlreadySubscribed))
|
||||
return
|
||||
}
|
||||
|
||||
let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil)
|
||||
InitialFeedDownloader.download(url) { [weak self] parsedFeed in
|
||||
if let parsedFeed = parsedFeed {
|
||||
account.update(feed, with: parsedFeed, {})
|
||||
}
|
||||
self?.createFeedCompletion!(.success(feed))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ public final class SyncDatabase {
|
||||
return syncStatusTable.selectForProcessing()
|
||||
}
|
||||
|
||||
public func selectPendingCount() -> Int {
|
||||
return syncStatusTable.selectPendingCount()
|
||||
}
|
||||
|
||||
public func resetSelectedForProcessing(_ articleIDs: [String]) {
|
||||
syncStatusTable.resetSelectedForProcessing(articleIDs)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,23 @@ final class SyncStatusTable: DatabaseTable {
|
||||
|
||||
}
|
||||
|
||||
func selectPendingCount() -> Int {
|
||||
|
||||
var count: Int = 0
|
||||
|
||||
self.queue.fetchSync { (database) in
|
||||
let sql = "select count(*) from syncStatus"
|
||||
if let resultSet = database.executeQuery(sql, withArgumentsIn: nil) {
|
||||
resultSet.next()
|
||||
count = Int(resultSet.int(forColumnIndex: 0))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return count
|
||||
|
||||
}
|
||||
|
||||
func resetSelectedForProcessing(_ articleIDs: [String]) {
|
||||
self.queue.update { database in
|
||||
let parameters = articleIDs.map { $0 as AnyObject }
|
||||
|
||||
@@ -334,7 +334,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
|
||||
|
||||
@IBAction func refreshAll(_ sender: Any?) {
|
||||
|
||||
AccountManager.shared.refreshAll()
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present)
|
||||
}
|
||||
|
||||
@IBAction func showAddFeedWindow(_ sender: Any?) {
|
||||
|
||||
@@ -85,20 +85,20 @@
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="aJh-i4-bef"/>
|
||||
<menuItem title="Import OPML…" id="rSl-F4-qo7">
|
||||
<menuItem title="Import Subscriptions…" id="rSl-F4-qo7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="importOPMLFromFile:" target="Ady-hI-5gd" id="eGY-fm-uvK"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Export OPML…" keyEquivalent="e" id="Xy2-v8-Lj8">
|
||||
<menuItem title="Export Subscriptions…" keyEquivalent="e" id="Xy2-v8-Lj8">
|
||||
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||
<connections>
|
||||
<action selector="exportOPML:" target="Ady-hI-5gd" id="5Zy-m4-cE9"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="FYN-zt-6dI"/>
|
||||
<menuItem title="Close" keyEquivalent="w" id="DVo-aG-piG">
|
||||
<menuItem title="Close Window" keyEquivalent="w" id="DVo-aG-piG">
|
||||
<connections>
|
||||
<action selector="performClose:" target="Ady-hI-5gd" id="HmO-Ls-i7Q"/>
|
||||
</connections>
|
||||
@@ -413,7 +413,7 @@
|
||||
<items>
|
||||
<menuItem title="Mark as Read" keyEquivalent="U" id="Fc9-c7-2AY">
|
||||
<connections>
|
||||
<action selector="markRead:" target="Ady-hI-5gd" id="RQv-jl-2Nv"/>
|
||||
<action selector="toggleRead:" target="Ady-hI-5gd" id="jLQ-ZF-xye"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Mark All as Read" keyEquivalent="k" id="HdN-Ks-cwh">
|
||||
|
||||
@@ -257,20 +257,20 @@
|
||||
<customView translatesAutoresizingMaskIntoConstraints="NO" id="7UM-iq-OLB" customClass="AccountsTableViewBackgroundView" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="44" width="160" height="236"/>
|
||||
<subviews>
|
||||
<scrollView borderType="none" autohidesScrollers="YES" horizontalLineScroll="19" horizontalPageScroll="10" verticalLineScroll="19" verticalPageScroll="10" hasHorizontalScroller="NO" usesPredominantAxisScrolling="NO" translatesAutoresizingMaskIntoConstraints="NO" id="PaF-du-r3c">
|
||||
<scrollView borderType="none" autohidesScrollers="YES" horizontalLineScroll="26" horizontalPageScroll="10" verticalLineScroll="26" verticalPageScroll="10" hasHorizontalScroller="NO" horizontalScrollElasticity="none" translatesAutoresizingMaskIntoConstraints="NO" id="PaF-du-r3c">
|
||||
<rect key="frame" x="1" y="0.0" width="158" height="235"/>
|
||||
<clipView key="contentView" id="cil-Gq-akO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="158" height="235"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<tableView verticalHuggingPriority="750" allowsExpansionToolTips="YES" columnAutoresizingStyle="lastColumnOnly" columnReordering="NO" columnSelection="YES" columnResizing="NO" multipleSelection="NO" autosaveColumns="NO" rowSizeStyle="automatic" viewBased="YES" id="aTp-KR-y6b">
|
||||
<rect key="frame" x="0.0" y="0.0" width="163" height="235"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableView verticalHuggingPriority="750" allowsExpansionToolTips="YES" columnAutoresizingStyle="lastColumnOnly" columnReordering="NO" columnSelection="YES" columnResizing="NO" multipleSelection="NO" autosaveColumns="NO" rowHeight="24" viewBased="YES" id="aTp-KR-y6b">
|
||||
<rect key="frame" x="0.0" y="0.0" width="159" height="235"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxX="YES" flexibleMinY="YES" flexibleMaxY="YES"/>
|
||||
<size key="intercellSpacing" width="3" height="2"/>
|
||||
<color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="gridColor" name="gridColor" catalog="System" colorSpace="catalog"/>
|
||||
<tableColumns>
|
||||
<tableColumn width="160" minWidth="40" maxWidth="1000" id="JSx-yi-vwt">
|
||||
<tableColumn width="156" minWidth="40" maxWidth="1000" id="JSx-yi-vwt">
|
||||
<tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border">
|
||||
<font key="font" metaFont="smallSystem"/>
|
||||
<color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
|
||||
@@ -284,17 +284,19 @@
|
||||
<tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
|
||||
<prototypeCellViews>
|
||||
<tableCellView identifier="Cell" id="h2e-5a-qNO">
|
||||
<rect key="frame" x="1" y="1" width="160" height="17"/>
|
||||
<rect key="frame" x="1" y="1" width="156" height="17"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="27f-p8-Wnt">
|
||||
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" misplaced="YES" translatesAutoresizingMaskIntoConstraints="NO" id="27f-p8-Wnt">
|
||||
<rect key="frame" x="3" y="0.0" width="17" height="17"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="16" id="ewi-ds-jXB"/>
|
||||
<constraint firstAttribute="width" constant="16" id="k2v-Dn-07l"/>
|
||||
</constraints>
|
||||
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" image="NSActionTemplate" id="lKA-xK-bHU"/>
|
||||
</imageView>
|
||||
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" fixedFrame="YES" allowsExpansionToolTips="YES" translatesAutoresizingMaskIntoConstraints="NO" id="hR2-bm-0wE">
|
||||
<rect key="frame" x="25" y="0.0" width="135" height="17"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
||||
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" allowsExpansionToolTips="YES" translatesAutoresizingMaskIntoConstraints="NO" id="hR2-bm-0wE">
|
||||
<rect key="frame" x="26" y="0.0" width="126" height="17"/>
|
||||
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="Table View Cell" id="CcS-BO-sdv">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
@@ -302,6 +304,13 @@
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="27f-p8-Wnt" firstAttribute="centerY" secondItem="h2e-5a-qNO" secondAttribute="centerY" id="3DV-qg-ZWX"/>
|
||||
<constraint firstAttribute="trailing" secondItem="hR2-bm-0wE" secondAttribute="trailing" constant="6" id="RFl-Wx-ivW"/>
|
||||
<constraint firstItem="hR2-bm-0wE" firstAttribute="leading" secondItem="27f-p8-Wnt" secondAttribute="trailing" constant="6" id="eAZ-AX-38Y"/>
|
||||
<constraint firstItem="27f-p8-Wnt" firstAttribute="leading" secondItem="h2e-5a-qNO" secondAttribute="leading" constant="6" id="gjH-g3-okS"/>
|
||||
<constraint firstItem="hR2-bm-0wE" firstAttribute="centerY" secondItem="h2e-5a-qNO" secondAttribute="centerY" id="qAk-mB-zdS"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="imageView" destination="27f-p8-Wnt" id="isa-a7-64p"/>
|
||||
<outlet property="textField" destination="hR2-bm-0wE" id="g2q-ln-SHx"/>
|
||||
|
||||
25
Mac/ErrorHandler.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// ErrorHandler.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 5/26/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Account
|
||||
import os.log
|
||||
|
||||
struct ErrorHandler {
|
||||
|
||||
private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Account")
|
||||
|
||||
public static func present(_ error: Error) {
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
|
||||
public static func log(_ error: Error) {
|
||||
os_log(.error, log: self.log, "%@", error.localizedDescription)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -52,7 +52,6 @@ class AddFeedController: AddFeedWindowControllerDelegate {
|
||||
return
|
||||
}
|
||||
let account = accountAndFolderSpecifier.account
|
||||
let folder = accountAndFolderSpecifier.folder
|
||||
|
||||
if account.hasFeed(withURL: url.absoluteString) {
|
||||
showAlreadySubscribedError(url.absoluteString)
|
||||
@@ -61,20 +60,23 @@ class AddFeedController: AddFeedWindowControllerDelegate {
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
|
||||
account.createFeed(url: url.absoluteString) { [weak self] result in
|
||||
account.createFeed(url: url.absoluteString, name: title, container: container) { result in
|
||||
|
||||
self?.endShowingProgress()
|
||||
DispatchQueue.main.async {
|
||||
self.endShowingProgress()
|
||||
}
|
||||
|
||||
BatchUpdate.shared.end()
|
||||
|
||||
switch result {
|
||||
case .success(let feed):
|
||||
self?.processFeed(feed, account: account, folder: folder, url: url, title: title)
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
|
||||
case .failure(let error):
|
||||
BatchUpdate.shared.end()
|
||||
switch error {
|
||||
case AccountError.createErrorAlreadySubscribed:
|
||||
self?.showAlreadySubscribedError(url.absoluteString)
|
||||
self.showAlreadySubscribedError(url.absoluteString)
|
||||
case AccountError.createErrorNotFound:
|
||||
self?.showNoFeedsErrorMessage()
|
||||
self.showNoFeedsErrorMessage()
|
||||
default:
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
@@ -125,45 +127,6 @@ private extension AddFeedController {
|
||||
}
|
||||
}
|
||||
|
||||
func processFeed(_ feed: Feed, account: Account, folder: Folder?, url: URL, title: String?) {
|
||||
|
||||
if let title = title {
|
||||
account.renameFeed(feed, to: title) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let folder = folder {
|
||||
folder.addFeed(feed) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
BatchUpdate.shared.end()
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
|
||||
case .failure(let error):
|
||||
BatchUpdate.shared.end()
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
account.addFeed(feed) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
BatchUpdate.shared.end()
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
|
||||
case .failure(let error):
|
||||
BatchUpdate.shared.end()
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Errors
|
||||
|
||||
func showAlreadySubscribedError(_ urlString: String) {
|
||||
|
||||
@@ -29,7 +29,7 @@ class AddFeedWindowController : NSWindowController {
|
||||
|
||||
private var urlString: String?
|
||||
private var initialName: String?
|
||||
private var initialAccount: Account?
|
||||
private weak var initialAccount: Account?
|
||||
private var initialFolder: Folder?
|
||||
private weak var delegate: AddFeedWindowControllerDelegate?
|
||||
private var folderTreeController: TreeController!
|
||||
|
||||
@@ -13,53 +13,49 @@
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<window title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g">
|
||||
<windowStyleMask key="styleMask" titled="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="196" y="240" width="350" height="98"/>
|
||||
<rect key="contentRect" x="196" y="240" width="355" height="180"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1417"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="350" height="98"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<view key="contentView" wantsLayer="YES" misplaced="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="391" height="171"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<stackView distribution="fill" orientation="horizontal" alignment="top" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="o6E-UX-65J">
|
||||
<rect key="frame" x="20" y="57" width="310" height="21"/>
|
||||
<subviews>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="xJz-HU-L4i">
|
||||
<rect key="frame" x="-2" y="4" width="63" height="17"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="Account:" id="Dao-jI-G6i">
|
||||
<font key="font" metaFont="systemBold"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<popUpButton verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="bbC-2g-e3k" userLabel="Account Popup">
|
||||
<rect key="frame" x="65" y="-3" width="248" height="25"/>
|
||||
<popUpButtonCell key="cell" type="push" title="Item 1" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="MJb-Bf-UJG" id="xZd-AP-nuM">
|
||||
<behavior key="behavior" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="menu"/>
|
||||
<menu key="menu" id="TP8-jd-M1D">
|
||||
<items>
|
||||
<menuItem title="Item 1" state="on" id="MJb-Bf-UJG"/>
|
||||
<menuItem title="Item 2" id="7U3-Y5-qeM"/>
|
||||
<menuItem title="Item 3" id="Uii-dd-siy"/>
|
||||
</items>
|
||||
</menu>
|
||||
</popUpButtonCell>
|
||||
</popUpButton>
|
||||
</subviews>
|
||||
<visibilityPriorities>
|
||||
<integer value="1000"/>
|
||||
<integer value="1000"/>
|
||||
</visibilityPriorities>
|
||||
<customSpacing>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
</customSpacing>
|
||||
</stackView>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="eZ4-Ej-Hks">
|
||||
<rect key="frame" x="215" y="13" width="121" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Export OPML" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="bRz-cx-bmm">
|
||||
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="mvx-54-DH0">
|
||||
<rect key="frame" x="18" y="100" width="355" height="51"/>
|
||||
<textFieldCell key="cell" selectable="YES" id="7Ap-KG-Lc7">
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="title">Choose the account with the subscriptions you’d like to export. Subscriptions are exported in the standard OPML format, which most RSS readers can import.</string>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="xJz-HU-L4i">
|
||||
<rect key="frame" x="18" y="63" width="63" height="17"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="Account:" id="Dao-jI-G6i">
|
||||
<font key="font" metaFont="systemBold"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<popUpButton verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="bbC-2g-e3k" userLabel="Account Popup">
|
||||
<rect key="frame" x="85" y="58" width="289" height="25"/>
|
||||
<popUpButtonCell key="cell" type="push" title="Item 1" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="MJb-Bf-UJG" id="xZd-AP-nuM">
|
||||
<behavior key="behavior" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="menu"/>
|
||||
<menu key="menu" id="TP8-jd-M1D">
|
||||
<items>
|
||||
<menuItem title="Item 1" state="on" id="MJb-Bf-UJG"/>
|
||||
<menuItem title="Item 2" id="7U3-Y5-qeM"/>
|
||||
<menuItem title="Item 3" id="Uii-dd-siy"/>
|
||||
</items>
|
||||
</menu>
|
||||
</popUpButtonCell>
|
||||
</popUpButton>
|
||||
<button horizontalHuggingPriority="750" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="eZ4-Ej-Hks">
|
||||
<rect key="frame" x="229" y="13" width="148" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Export as OPML…" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="bRz-cx-bmm">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
@@ -71,7 +67,7 @@ DQ
|
||||
</connections>
|
||||
</button>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="PPB-R8-A9a">
|
||||
<rect key="frame" x="133" y="13" width="82" height="32"/>
|
||||
<rect key="frame" x="81" y="13" width="148" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="6lK-bV-Vwd">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
@@ -85,17 +81,24 @@ Gw
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="mvx-54-DH0" firstAttribute="leading" secondItem="EiT-Mj-1SZ" secondAttribute="leading" constant="20" symbolic="YES" id="6SB-1X-jS4"/>
|
||||
<constraint firstItem="xJz-HU-L4i" firstAttribute="leading" secondItem="EiT-Mj-1SZ" secondAttribute="leading" constant="20" symbolic="YES" id="8TB-fT-THB"/>
|
||||
<constraint firstItem="PPB-R8-A9a" firstAttribute="leading" secondItem="bbC-2g-e3k" secondAttribute="leading" id="9zJ-1D-4cz"/>
|
||||
<constraint firstAttribute="bottom" secondItem="eZ4-Ej-Hks" secondAttribute="bottom" constant="20" symbolic="YES" id="ES8-CX-Cvn"/>
|
||||
<constraint firstItem="o6E-UX-65J" firstAttribute="leading" secondItem="EiT-Mj-1SZ" secondAttribute="leading" constant="20" symbolic="YES" id="Ffh-rT-vN7"/>
|
||||
<constraint firstItem="eZ4-Ej-Hks" firstAttribute="top" secondItem="o6E-UX-65J" secondAttribute="bottom" constant="16" id="Ir1-CQ-4jm"/>
|
||||
<constraint firstItem="bbC-2g-e3k" firstAttribute="leading" secondItem="xJz-HU-L4i" secondAttribute="trailing" constant="8" symbolic="YES" id="IAs-Eg-vxH"/>
|
||||
<constraint firstItem="eZ4-Ej-Hks" firstAttribute="top" secondItem="bbC-2g-e3k" secondAttribute="bottom" constant="20" symbolic="YES" id="KLL-J1-sDN"/>
|
||||
<constraint firstItem="eZ4-Ej-Hks" firstAttribute="leading" secondItem="PPB-R8-A9a" secondAttribute="trailing" constant="12" symbolic="YES" id="Lfs-g3-crb"/>
|
||||
<constraint firstItem="PPB-R8-A9a" firstAttribute="centerY" secondItem="eZ4-Ej-Hks" secondAttribute="centerY" id="OVP-4p-nXp"/>
|
||||
<constraint firstAttribute="trailing" secondItem="eZ4-Ej-Hks" secondAttribute="trailing" constant="20" symbolic="YES" id="RDU-0d-O0Q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="o6E-UX-65J" secondAttribute="trailing" constant="20" symbolic="YES" id="Xhe-ek-MZi"/>
|
||||
<constraint firstItem="o6E-UX-65J" firstAttribute="top" secondItem="EiT-Mj-1SZ" secondAttribute="top" constant="20" symbolic="YES" id="mvL-Y1-uSn"/>
|
||||
<constraint firstItem="xJz-HU-L4i" firstAttribute="top" secondItem="mvx-54-DH0" secondAttribute="bottom" constant="20" id="Rxj-lF-b8R"/>
|
||||
<constraint firstItem="bbC-2g-e3k" firstAttribute="firstBaseline" secondItem="xJz-HU-L4i" secondAttribute="firstBaseline" id="TjR-gN-xrt"/>
|
||||
<constraint firstAttribute="trailing" secondItem="mvx-54-DH0" secondAttribute="trailing" constant="20" symbolic="YES" id="VBk-HZ-2xv"/>
|
||||
<constraint firstItem="eZ4-Ej-Hks" firstAttribute="width" secondItem="PPB-R8-A9a" secondAttribute="width" id="WOT-NI-xD8"/>
|
||||
<constraint firstItem="mvx-54-DH0" firstAttribute="top" secondItem="EiT-Mj-1SZ" secondAttribute="top" constant="20" symbolic="YES" id="l2i-jP-HX1"/>
|
||||
<constraint firstAttribute="trailing" secondItem="bbC-2g-e3k" secondAttribute="trailing" constant="20" symbolic="YES" id="uZb-X0-fUh"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<point key="canvasLocation" x="79" y="61"/>
|
||||
<point key="canvasLocation" x="81.5" y="102"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
||||
|
||||
@@ -84,7 +84,7 @@ class ExportOPMLWindowController: NSWindowController {
|
||||
panel.isExtensionHidden = false
|
||||
|
||||
let accountName = account.nameForDisplay.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespaces)
|
||||
panel.nameFieldStringValue = "\(accountName).opml"
|
||||
panel.nameFieldStringValue = "Subscriptions-\(accountName).opml"
|
||||
|
||||
panel.beginSheetModal(for: hostWindow!) { result in
|
||||
if result == NSApplication.ModalResponse.OK, let url = panel.url {
|
||||
|
||||
@@ -13,65 +13,47 @@
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<window title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g">
|
||||
<windowStyleMask key="styleMask" titled="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="196" y="240" width="350" height="110"/>
|
||||
<rect key="contentRect" x="196" y="240" width="401" height="183"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1417"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="350" height="110"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<view key="contentView" wantsLayer="YES" misplaced="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="421" height="154"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<stackView distribution="fill" orientation="horizontal" alignment="top" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="brC-JT-Rvi">
|
||||
<rect key="frame" x="20" y="57" width="310" height="33"/>
|
||||
<subviews>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="kZ4-y9-lYy">
|
||||
<rect key="frame" x="-2" y="16" width="63" height="17"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="Account:" id="e9g-7H-VWa">
|
||||
<font key="font" metaFont="systemBold"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<popUpButton verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="sEU-ot-DE2" userLabel="Account Popup">
|
||||
<rect key="frame" x="65" y="9" width="248" height="25"/>
|
||||
<popUpButtonCell key="cell" type="push" title="Item 1" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="xsd-12-2yb" id="NuO-Hk-nk3">
|
||||
<behavior key="behavior" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="menu"/>
|
||||
<menu key="menu" id="8LY-np-ij1">
|
||||
<items>
|
||||
<menuItem title="Item 1" state="on" id="xsd-12-2yb"/>
|
||||
<menuItem title="Item 2" id="JGa-5R-SV5"/>
|
||||
<menuItem title="Item 3" id="92e-hX-kYj"/>
|
||||
</items>
|
||||
</menu>
|
||||
</popUpButtonCell>
|
||||
</popUpButton>
|
||||
</subviews>
|
||||
<visibilityPriorities>
|
||||
<integer value="1000"/>
|
||||
<integer value="1000"/>
|
||||
</visibilityPriorities>
|
||||
<customSpacing>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
</customSpacing>
|
||||
</stackView>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="et6-I1-6wB">
|
||||
<rect key="frame" x="215" y="13" width="121" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Import OPML" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="dhV-on-ayM">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="vE6-sv-BA0">
|
||||
<rect key="frame" x="18" y="100" width="385" height="34"/>
|
||||
<textFieldCell key="cell" selectable="YES" title="Choose the account to get the imported subscriptions. This requires an OPML file, which most RSS readers can create." id="1Vu-Te-PGl">
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
DQ
|
||||
</string>
|
||||
<connections>
|
||||
<action selector="importOPML:" target="-2" id="hwR-7i-sks"/>
|
||||
</connections>
|
||||
</buttonCell>
|
||||
</button>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="kZ4-y9-lYy">
|
||||
<rect key="frame" x="18" y="63" width="63" height="17"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="Account:" id="e9g-7H-VWa">
|
||||
<font key="font" metaFont="systemBold"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
<popUpButton verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="sEU-ot-DE2" userLabel="Account Popup">
|
||||
<rect key="frame" x="85" y="58" width="319" height="25"/>
|
||||
<popUpButtonCell key="cell" type="push" title="Item 1" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="xsd-12-2yb" id="NuO-Hk-nk3">
|
||||
<behavior key="behavior" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="menu"/>
|
||||
<menu key="menu" id="8LY-np-ij1">
|
||||
<items>
|
||||
<menuItem title="Item 1" state="on" id="xsd-12-2yb"/>
|
||||
<menuItem title="Item 2" id="JGa-5R-SV5"/>
|
||||
<menuItem title="Item 3" id="92e-hX-kYj"/>
|
||||
</items>
|
||||
</menu>
|
||||
</popUpButtonCell>
|
||||
</popUpButton>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="ceu-mM-EKm">
|
||||
<rect key="frame" x="133" y="13" width="82" height="32"/>
|
||||
<rect key="frame" x="81" y="13" width="163" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="9ab-cB-hex">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
@@ -83,19 +65,39 @@ Gw
|
||||
<action selector="cancel:" target="-2" id="3bA-Ja-mgX"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button horizontalHuggingPriority="750" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="et6-I1-6wB">
|
||||
<rect key="frame" x="244" y="13" width="163" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Import from OPML…" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="dhV-on-ayM">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
DQ
|
||||
</string>
|
||||
<connections>
|
||||
<action selector="importOPML:" target="-2" id="hwR-7i-sks"/>
|
||||
</connections>
|
||||
</buttonCell>
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="brC-JT-Rvi" secondAttribute="trailing" constant="20" symbolic="YES" id="4L3-oZ-QfO"/>
|
||||
<constraint firstItem="et6-I1-6wB" firstAttribute="top" secondItem="brC-JT-Rvi" secondAttribute="bottom" constant="16" id="Lgq-uC-Cbg"/>
|
||||
<constraint firstAttribute="trailing" secondItem="vE6-sv-BA0" secondAttribute="trailing" constant="20" symbolic="YES" id="2UM-dU-XgH"/>
|
||||
<constraint firstItem="kZ4-y9-lYy" firstAttribute="top" secondItem="vE6-sv-BA0" secondAttribute="bottom" constant="20" id="8Wf-Iv-teA"/>
|
||||
<constraint firstItem="sEU-ot-DE2" firstAttribute="firstBaseline" secondItem="kZ4-y9-lYy" secondAttribute="firstBaseline" id="EPd-vD-8pa"/>
|
||||
<constraint firstItem="et6-I1-6wB" firstAttribute="width" secondItem="ceu-mM-EKm" secondAttribute="width" id="Ldw-C8-F5X"/>
|
||||
<constraint firstItem="vE6-sv-BA0" firstAttribute="leading" secondItem="EiT-Mj-1SZ" secondAttribute="leading" constant="20" symbolic="YES" id="M8o-2f-uax"/>
|
||||
<constraint firstItem="et6-I1-6wB" firstAttribute="top" secondItem="sEU-ot-DE2" secondAttribute="bottom" constant="20" symbolic="YES" id="OXm-Ns-r9Z"/>
|
||||
<constraint firstItem="kZ4-y9-lYy" firstAttribute="leading" secondItem="EiT-Mj-1SZ" secondAttribute="leading" constant="20" symbolic="YES" id="SZo-1P-yYA"/>
|
||||
<constraint firstAttribute="bottom" secondItem="et6-I1-6wB" secondAttribute="bottom" constant="20" symbolic="YES" id="T9O-XC-wdY"/>
|
||||
<constraint firstItem="brC-JT-Rvi" firstAttribute="leading" secondItem="EiT-Mj-1SZ" secondAttribute="leading" constant="20" symbolic="YES" id="Xjj-ZG-QwO"/>
|
||||
<constraint firstItem="et6-I1-6wB" firstAttribute="leading" secondItem="ceu-mM-EKm" secondAttribute="trailing" constant="12" symbolic="YES" id="Y8m-P8-JQh"/>
|
||||
<constraint firstItem="sEU-ot-DE2" firstAttribute="leading" secondItem="kZ4-y9-lYy" secondAttribute="trailing" constant="8" symbolic="YES" id="a0f-Ak-Zdq"/>
|
||||
<constraint firstAttribute="trailing" secondItem="et6-I1-6wB" secondAttribute="trailing" constant="20" symbolic="YES" id="chg-8b-D51"/>
|
||||
<constraint firstItem="ceu-mM-EKm" firstAttribute="centerY" secondItem="et6-I1-6wB" secondAttribute="centerY" id="eKt-vM-3wn"/>
|
||||
<constraint firstItem="brC-JT-Rvi" firstAttribute="top" secondItem="EiT-Mj-1SZ" secondAttribute="top" constant="20" symbolic="YES" id="kWe-BT-2bD"/>
|
||||
<constraint firstItem="ceu-mM-EKm" firstAttribute="leading" secondItem="sEU-ot-DE2" secondAttribute="leading" id="gb5-bZ-0R6"/>
|
||||
<constraint firstItem="vE6-sv-BA0" firstAttribute="top" secondItem="EiT-Mj-1SZ" secondAttribute="top" constant="20" symbolic="YES" id="ggA-m1-5pj"/>
|
||||
<constraint firstAttribute="trailing" secondItem="sEU-ot-DE2" secondAttribute="trailing" constant="20" symbolic="YES" id="sFO-00-DR0"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<point key="canvasLocation" x="83.5" y="67"/>
|
||||
<point key="canvasLocation" x="83.5" y="103.5"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
//
|
||||
// FolderPasteboardWriter.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 2/11/18.
|
||||
// Copyright © 2018 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Account
|
||||
import RSCore
|
||||
|
||||
extension Folder: PasteboardWriterOwner {
|
||||
|
||||
public var pasteboardWriter: NSPasteboardWriting {
|
||||
return FolderPasteboardWriter(folder: self)
|
||||
}
|
||||
}
|
||||
|
||||
@objc final class FolderPasteboardWriter: NSObject, NSPasteboardWriting {
|
||||
|
||||
private let folder: Folder
|
||||
static let folderUTIInternal = "com.ranchero.NetNewsWire-Evergreen.internal.folder"
|
||||
static let folderUTIInternalType = NSPasteboard.PasteboardType(rawValue: folderUTIInternal)
|
||||
|
||||
init(folder: Folder) {
|
||||
|
||||
self.folder = folder
|
||||
}
|
||||
|
||||
// MARK: - NSPasteboardWriting
|
||||
|
||||
func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] {
|
||||
|
||||
return [.string, FolderPasteboardWriter.folderUTIInternalType]
|
||||
}
|
||||
|
||||
func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? {
|
||||
|
||||
let plist: Any?
|
||||
|
||||
switch type {
|
||||
case .string:
|
||||
plist = folder.nameForDisplay
|
||||
case FolderPasteboardWriter.folderUTIInternalType:
|
||||
plist = internalDictionary()
|
||||
default:
|
||||
plist = nil
|
||||
}
|
||||
|
||||
return plist
|
||||
}
|
||||
}
|
||||
|
||||
private extension FolderPasteboardWriter {
|
||||
|
||||
private struct Key {
|
||||
|
||||
static let name = "name"
|
||||
|
||||
// Internal
|
||||
static let accountID = "accountID"
|
||||
static let folderID = "folderID"
|
||||
}
|
||||
|
||||
func internalDictionary() -> [String: Any] {
|
||||
|
||||
var d = [String: Any]()
|
||||
|
||||
d[Key.folderID] = folder.folderID
|
||||
if let name = folder.name {
|
||||
d[Key.name] = name
|
||||
}
|
||||
if let accountID = folder.account?.accountID {
|
||||
d[Key.accountID] = accountID
|
||||
}
|
||||
|
||||
return d
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct PasteboardFeed: Hashable {
|
||||
|
||||
// Internal
|
||||
static let accountID = "accountID"
|
||||
static let accountType = "accountType"
|
||||
static let feedID = "feedID"
|
||||
static let editedName = "editedName"
|
||||
}
|
||||
@@ -32,15 +33,17 @@ struct PasteboardFeed: Hashable {
|
||||
let name: String?
|
||||
let editedName: String?
|
||||
let accountID: String?
|
||||
let accountType: AccountType?
|
||||
let isLocalFeed: Bool
|
||||
|
||||
init(url: String, feedID: String?, homePageURL: String?, name: String?, editedName: String?, accountID: String?) {
|
||||
init(url: String, feedID: String?, homePageURL: String?, name: String?, editedName: String?, accountID: String?, accountType: AccountType?) {
|
||||
self.url = url.rs_normalizedURL()
|
||||
self.feedID = feedID
|
||||
self.homePageURL = homePageURL?.rs_normalizedURL()
|
||||
self.name = name
|
||||
self.editedName = editedName
|
||||
self.accountID = accountID
|
||||
self.accountType = accountType
|
||||
self.isLocalFeed = accountID != nil
|
||||
}
|
||||
|
||||
@@ -57,7 +60,12 @@ struct PasteboardFeed: Hashable {
|
||||
let feedID = dictionary[Key.feedID]
|
||||
let editedName = dictionary[Key.editedName]
|
||||
|
||||
self.init(url: url, feedID: feedID, homePageURL: homePageURL, name: name, editedName: editedName, accountID: accountID)
|
||||
var accountType: AccountType? = nil
|
||||
if let accountTypeString = dictionary[Key.accountType], let accountTypeInt = Int(accountTypeString) {
|
||||
accountType = AccountType(rawValue: accountTypeInt)
|
||||
}
|
||||
|
||||
self.init(url: url, feedID: feedID, homePageURL: homePageURL, name: name, editedName: editedName, accountID: accountID, accountType: accountType)
|
||||
}
|
||||
|
||||
init?(pasteboardItem: NSPasteboardItem) {
|
||||
@@ -86,7 +94,7 @@ struct PasteboardFeed: Hashable {
|
||||
if let foundType = pasteboardType {
|
||||
if let possibleURLString = pasteboardItem.string(forType: foundType) {
|
||||
if possibleURLString.rs_stringMayBeURL() {
|
||||
self.init(url: possibleURLString, feedID: nil, homePageURL: nil, name: nil, editedName: nil, accountID: nil)
|
||||
self.init(url: possibleURLString, feedID: nil, homePageURL: nil, name: nil, editedName: nil, accountID: nil, accountType: nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -131,6 +139,9 @@ struct PasteboardFeed: Hashable {
|
||||
if let accountID = accountID {
|
||||
d[PasteboardFeed.Key.accountID] = accountID
|
||||
}
|
||||
if let accountType = accountType {
|
||||
d[PasteboardFeed.Key.accountType] = String(accountType.rawValue)
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
@@ -186,7 +197,7 @@ extension Feed: PasteboardWriterOwner {
|
||||
private extension FeedPasteboardWriter {
|
||||
|
||||
var pasteboardFeed: PasteboardFeed {
|
||||
return PasteboardFeed(url: feed.url, feedID: feed.feedID, homePageURL: feed.homePageURL, name: feed.name, editedName: feed.editedName, accountID: feed.account?.accountID)
|
||||
return PasteboardFeed(url: feed.url, feedID: feed.feedID, homePageURL: feed.homePageURL, name: feed.name, editedName: feed.editedName, accountID: feed.account?.accountID, accountType: feed.account?.type)
|
||||
}
|
||||
|
||||
var exportDictionary: PasteboardFeedDictionary {
|
||||
|
||||
137
Mac/MainWindow/Sidebar/PasteboardFolder.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
//
|
||||
// FolderPasteboardWriter.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 2/11/18.
|
||||
// Copyright © 2018 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Account
|
||||
import RSCore
|
||||
|
||||
typealias PasteboardFolderDictionary = [String: String]
|
||||
|
||||
struct PasteboardFolder: Hashable {
|
||||
|
||||
private struct Key {
|
||||
static let name = "name"
|
||||
// Internal
|
||||
static let folderID = "folderID"
|
||||
static let accountID = "accountID"
|
||||
}
|
||||
|
||||
|
||||
let name: String
|
||||
let folderID: String?
|
||||
let accountID: String?
|
||||
|
||||
init(name: String, folderID: String?, accountID: String?) {
|
||||
self.name = name
|
||||
self.folderID = folderID
|
||||
self.accountID = accountID
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
init?(dictionary: PasteboardFolderDictionary) {
|
||||
guard let name = dictionary[Key.name] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let folderID = dictionary[Key.folderID]
|
||||
let accountID = dictionary[Key.accountID]
|
||||
|
||||
self.init(name: name, folderID: folderID, accountID: accountID)
|
||||
}
|
||||
|
||||
init?(pasteboardItem: NSPasteboardItem) {
|
||||
var pasteboardType: NSPasteboard.PasteboardType?
|
||||
if pasteboardItem.types.contains(FolderPasteboardWriter.folderUTIInternalType) {
|
||||
pasteboardType = FolderPasteboardWriter.folderUTIInternalType
|
||||
}
|
||||
|
||||
if let foundType = pasteboardType {
|
||||
if let folderDictionary = pasteboardItem.propertyList(forType: foundType) as? PasteboardFeedDictionary {
|
||||
self.init(dictionary: folderDictionary)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func pasteboardFolders(with pasteboard: NSPasteboard) -> Set<PasteboardFolder>? {
|
||||
guard let items = pasteboard.pasteboardItems else {
|
||||
return nil
|
||||
}
|
||||
let folders = items.compactMap { PasteboardFolder(pasteboardItem: $0) }
|
||||
return folders.isEmpty ? nil : Set(folders)
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
func internalDictionary() -> PasteboardFolderDictionary {
|
||||
var d = PasteboardFeedDictionary()
|
||||
d[PasteboardFolder.Key.name] = name
|
||||
if let folderID = folderID {
|
||||
d[PasteboardFolder.Key.folderID] = folderID
|
||||
}
|
||||
if let accountID = accountID {
|
||||
d[PasteboardFolder.Key.accountID] = accountID
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
extension Folder: PasteboardWriterOwner {
|
||||
|
||||
public var pasteboardWriter: NSPasteboardWriting {
|
||||
return FolderPasteboardWriter(folder: self)
|
||||
}
|
||||
}
|
||||
|
||||
@objc final class FolderPasteboardWriter: NSObject, NSPasteboardWriting {
|
||||
|
||||
private let folder: Folder
|
||||
static let folderUTIInternal = "com.ranchero.NetNewsWire-Evergreen.internal.folder"
|
||||
static let folderUTIInternalType = NSPasteboard.PasteboardType(rawValue: folderUTIInternal)
|
||||
|
||||
init(folder: Folder) {
|
||||
|
||||
self.folder = folder
|
||||
}
|
||||
|
||||
// MARK: - NSPasteboardWriting
|
||||
|
||||
func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] {
|
||||
|
||||
return [.string, FolderPasteboardWriter.folderUTIInternalType]
|
||||
}
|
||||
|
||||
func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? {
|
||||
|
||||
let plist: Any?
|
||||
|
||||
switch type {
|
||||
case .string:
|
||||
plist = folder.nameForDisplay
|
||||
case FolderPasteboardWriter.folderUTIInternalType:
|
||||
plist = internalDictionary
|
||||
default:
|
||||
plist = nil
|
||||
}
|
||||
|
||||
return plist
|
||||
}
|
||||
}
|
||||
|
||||
private extension FolderPasteboardWriter {
|
||||
var pasteboardFolder: PasteboardFolder {
|
||||
return PasteboardFolder(name: folder.name ?? "", folderID: String(folder.folderID), accountID: folder.account?.accountID)
|
||||
}
|
||||
|
||||
var internalDictionary: PasteboardFeedDictionary {
|
||||
return pasteboardFolder.internalDictionary()
|
||||
}
|
||||
}
|
||||
@@ -54,46 +54,70 @@ import Account
|
||||
}
|
||||
|
||||
func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation {
|
||||
guard let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard), !draggedFeeds.isEmpty else {
|
||||
let draggedFolders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard)
|
||||
let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard)
|
||||
if (draggedFolders == nil && draggedFeeds == nil) || (draggedFolders != nil && draggedFeeds != nil) {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
|
||||
let parentNode = nodeForItem(item)
|
||||
let contentsType = draggedFeedContentsType(draggedFeeds)
|
||||
|
||||
switch contentsType {
|
||||
case .singleNonLocal:
|
||||
let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)!
|
||||
return validateSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index)
|
||||
case .singleLocal:
|
||||
let draggedFeed = draggedFeeds.first!
|
||||
return validateSingleLocalFeedDrop(outlineView, draggedFeed, parentNode, index)
|
||||
case .multipleLocal:
|
||||
return validateLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index)
|
||||
case .multipleNonLocal, .mixed, .empty:
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
if let draggedFolders = draggedFolders {
|
||||
if draggedFolders.count == 1 {
|
||||
return validateLocalFolderDrop(outlineView, draggedFolders.first!, parentNode, index)
|
||||
} else {
|
||||
return validateLocalFoldersDrop(outlineView, draggedFolders, parentNode, index)
|
||||
}
|
||||
}
|
||||
|
||||
if let draggedFeeds = draggedFeeds {
|
||||
let contentsType = draggedFeedContentsType(draggedFeeds)
|
||||
|
||||
switch contentsType {
|
||||
case .singleNonLocal:
|
||||
let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)!
|
||||
return validateSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index)
|
||||
case .singleLocal:
|
||||
let draggedFeed = draggedFeeds.first!
|
||||
return validateSingleLocalFeedDrop(outlineView, draggedFeed, parentNode, index)
|
||||
case .multipleLocal:
|
||||
return validateLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index)
|
||||
case .multipleNonLocal, .mixed, .empty:
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
}
|
||||
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
|
||||
func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool {
|
||||
guard let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard), !draggedFeeds.isEmpty else {
|
||||
let draggedFolders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard)
|
||||
let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard)
|
||||
if (draggedFolders == nil && draggedFeeds == nil) || (draggedFolders != nil && draggedFeeds != nil) {
|
||||
return false
|
||||
}
|
||||
|
||||
let parentNode = nodeForItem(item)
|
||||
let contentsType = draggedFeedContentsType(draggedFeeds)
|
||||
|
||||
switch contentsType {
|
||||
case .singleNonLocal:
|
||||
let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)!
|
||||
return acceptSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index)
|
||||
case .singleLocal:
|
||||
return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index)
|
||||
case .multipleLocal:
|
||||
return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index)
|
||||
case .multipleNonLocal, .mixed, .empty:
|
||||
return false
|
||||
if let draggedFolders = draggedFolders {
|
||||
return acceptLocalFoldersDrop(outlineView, draggedFolders, parentNode, index)
|
||||
}
|
||||
|
||||
if let draggedFeeds = draggedFeeds {
|
||||
let contentsType = draggedFeedContentsType(draggedFeeds)
|
||||
|
||||
switch contentsType {
|
||||
case .singleNonLocal:
|
||||
let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)!
|
||||
return acceptSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index)
|
||||
case .singleLocal:
|
||||
return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index)
|
||||
case .multipleLocal:
|
||||
return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index)
|
||||
case .multipleNonLocal, .mixed, .empty:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,11 +133,10 @@ private extension SidebarOutlineDataSource {
|
||||
}
|
||||
|
||||
func nodeRepresentsDraggableItem(_ node: Node) -> Bool {
|
||||
// Don’t allow PseudoFeed or Folder to be dragged.
|
||||
// Don’t allow PseudoFeed to be dragged.
|
||||
// This will have to be revisited later. For instance,
|
||||
// user-created smart feeds should be draggable, maybe.
|
||||
// And we might allow dragging folders between accounts.
|
||||
return node.representedObject is Feed
|
||||
return node.representedObject is Folder || node.representedObject is Feed
|
||||
}
|
||||
|
||||
// MARK: - Drag and Drop
|
||||
@@ -173,20 +196,20 @@ private extension SidebarOutlineDataSource {
|
||||
guard let dropTargetNode = ancestorThatCanAcceptLocalFeed(parentNode) else {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if nodeAndDraggedFeedsDoNotShareAccount(dropTargetNode, Set([draggedFeed])) {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if nodeHasChildRepresentingDraggedFeed(dropTargetNode, draggedFeed) {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if violatesTagSpecificBehavior(dropTargetNode, draggedFeed) {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if parentNode == dropTargetNode && index == NSOutlineViewDropOnItemIndex {
|
||||
return .move
|
||||
return localDragOperation()
|
||||
}
|
||||
let updatedIndex = indexWhereDraggedFeedWouldAppear(dropTargetNode, draggedFeed)
|
||||
if parentNode !== dropTargetNode || index != updatedIndex {
|
||||
outlineView.setDropItem(dropTargetNode, dropChildIndex: updatedIndex)
|
||||
}
|
||||
return .move
|
||||
return localDragOperation()
|
||||
}
|
||||
|
||||
func validateLocalFeedsDrop(_ outlineView: NSOutlineView, _ draggedFeeds: Set<PasteboardFeed>, _ parentNode: Node, _ index: Int) -> NSDragOperation {
|
||||
@@ -194,19 +217,27 @@ private extension SidebarOutlineDataSource {
|
||||
guard let dropTargetNode = ancestorThatCanAcceptLocalFeed(parentNode) else {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if nodeAndDraggedFeedsDoNotShareAccount(dropTargetNode, draggedFeeds) {
|
||||
if nodeHasChildRepresentingAnyDraggedFeed(dropTargetNode, draggedFeeds) {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if nodeHasChildRepresentingAnyDraggedFeed(dropTargetNode, draggedFeeds) {
|
||||
if violatesTagSpecificBehavior(dropTargetNode, draggedFeeds) {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if parentNode !== dropTargetNode || index != NSOutlineViewDropOnItemIndex {
|
||||
outlineView.setDropItem(dropTargetNode, dropChildIndex: NSOutlineViewDropOnItemIndex)
|
||||
}
|
||||
return .move
|
||||
return localDragOperation()
|
||||
}
|
||||
|
||||
func localDragOperation() -> NSDragOperation {
|
||||
if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false {
|
||||
return .copy
|
||||
} else {
|
||||
return .move
|
||||
}
|
||||
}
|
||||
|
||||
private func accountForNode(_ node: Node) -> Account? {
|
||||
func accountForNode(_ node: Node) -> Account? {
|
||||
if let account = node.representedObject as? Account {
|
||||
return account
|
||||
}
|
||||
@@ -219,65 +250,193 @@ private extension SidebarOutlineDataSource {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func commonAccountFor(_ nodes: Set<Node>) -> Account? {
|
||||
// Return the Account if every node has an Account and they’re all the same.
|
||||
var account: Account? = nil
|
||||
func commonAccountsFor(_ nodes: Set<Node>) -> Set<Account> {
|
||||
|
||||
var accounts = Set<Account>()
|
||||
for node in nodes {
|
||||
guard let oneAccount = accountForNode(node) else {
|
||||
return nil
|
||||
}
|
||||
if account == nil {
|
||||
account = oneAccount
|
||||
}
|
||||
else {
|
||||
if account != oneAccount {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
accounts.insert(oneAccount)
|
||||
}
|
||||
return account
|
||||
return accounts
|
||||
}
|
||||
|
||||
private func move(node: Node, to parentNode: Node, account: Account) {
|
||||
guard let feed = node.representedObject as? Feed else {
|
||||
func accountHasFolderRepresentingAnyDraggedFolders(_ account: Account, _ draggedFolders: Set<PasteboardFolder>) -> Bool {
|
||||
for draggedFolder in draggedFolders {
|
||||
if account.existingFolder(with: draggedFolder.name) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateLocalFolderDrop(_ outlineView: NSOutlineView, _ draggedFolder: PasteboardFolder, _ parentNode: Node, _ index: Int) -> NSDragOperation {
|
||||
guard let dropAccount = parentNode.representedObject as? Account, dropAccount.accountID != draggedFolder.accountID else {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if accountHasFolderRepresentingAnyDraggedFolders(dropAccount, Set([draggedFolder])) {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
let updatedIndex = indexWhereDraggedFolderWouldAppear(parentNode, draggedFolder)
|
||||
if index != updatedIndex {
|
||||
outlineView.setDropItem(parentNode, dropChildIndex: updatedIndex)
|
||||
}
|
||||
return localDragOperation()
|
||||
}
|
||||
|
||||
func validateLocalFoldersDrop(_ outlineView: NSOutlineView, _ draggedFolders: Set<PasteboardFolder>, _ parentNode: Node, _ index: Int) -> NSDragOperation {
|
||||
guard let dropAccount = parentNode.representedObject as? Account else {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
if accountHasFolderRepresentingAnyDraggedFolders(dropAccount, draggedFolders) {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
for draggedFolder in draggedFolders {
|
||||
if dropAccount.accountID == draggedFolder.accountID {
|
||||
return SidebarOutlineDataSource.dragOperationNone
|
||||
}
|
||||
}
|
||||
if index != NSOutlineViewDropOnItemIndex {
|
||||
outlineView.setDropItem(parentNode, dropChildIndex: NSOutlineViewDropOnItemIndex)
|
||||
}
|
||||
return localDragOperation()
|
||||
}
|
||||
|
||||
func copyFeedInAccount(node: Node, to parentNode: Node) {
|
||||
guard let feed = node.representedObject as? Feed, let destination = parentNode.representedObject as? Container else {
|
||||
return
|
||||
}
|
||||
let source = node.parent?.representedObject as? Container
|
||||
let destination = parentNode.representedObject as? Container
|
||||
BatchUpdate.shared.start()
|
||||
source?.removeFeed(feed) { result in
|
||||
|
||||
destination.account?.addFeed(feed, to: destination) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
destination?.addFeed(feed) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
BatchUpdate.shared.end()
|
||||
break
|
||||
case .failure(let error):
|
||||
// If the second part of the move failed, try to put the feed back
|
||||
source?.addFeed(feed) { result in}
|
||||
BatchUpdate.shared.end()
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveFeedInAccount(node: Node, to parentNode: Node) {
|
||||
guard let feed = node.representedObject as? Feed,
|
||||
let source = node.parent?.representedObject as? Container,
|
||||
let destination = parentNode.representedObject as? Container else {
|
||||
return
|
||||
}
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
source.account?.moveFeed(feed, from: source, to: destination) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
BatchUpdate.shared.end()
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyFeedBetweenAccounts(node: Node, to parentNode: Node) {
|
||||
guard let feed = node.representedObject as? Feed,
|
||||
let destinationAccount = nodeAccount(parentNode),
|
||||
let destinationContainer = parentNode.representedObject as? Container else {
|
||||
return
|
||||
}
|
||||
|
||||
if let existingFeed = destinationAccount.existingFeed(withURL: feed.url) {
|
||||
destinationAccount.addFeed(existingFeed, to: destinationContainer) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
destinationAccount.createFeed(url: feed.url, name: feed.editedName, container: destinationContainer) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveFeedBetweenAccounts(node: Node, to parentNode: Node) {
|
||||
guard let feed = node.representedObject as? Feed,
|
||||
let sourceAccount = nodeAccount(node),
|
||||
let sourceContainer = node.parent?.representedObject as? Container,
|
||||
let destinationAccount = nodeAccount(parentNode),
|
||||
let destinationContainer = parentNode.representedObject as? Container else {
|
||||
return
|
||||
}
|
||||
|
||||
if let existingFeed = destinationAccount.existingFeed(withURL: feed.url) {
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
destinationAccount.addFeed(existingFeed, to: destinationContainer) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
sourceAccount.removeFeed(feed, from: sourceContainer) { result in
|
||||
BatchUpdate.shared.end()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
destinationAccount.createFeed(url: feed.url, name: feed.editedName, container: destinationContainer) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
sourceAccount.removeFeed(feed, from: sourceContainer) { result in
|
||||
BatchUpdate.shared.end()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func acceptLocalFeedsDrop(_ outlineView: NSOutlineView, _ draggedFeeds: Set<PasteboardFeed>, _ parentNode: Node, _ index: Int) -> Bool {
|
||||
guard let draggedNodes = draggedNodes else {
|
||||
return false
|
||||
}
|
||||
let allReferencedNodes = draggedNodes.union(Set([parentNode]))
|
||||
guard let account = commonAccountFor(allReferencedNodes) else {
|
||||
return false
|
||||
|
||||
draggedNodes.forEach { node in
|
||||
if sameAccount(node, parentNode) {
|
||||
if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false {
|
||||
copyFeedInAccount(node: node, to: parentNode)
|
||||
} else {
|
||||
moveFeedInAccount(node: node, to: parentNode)
|
||||
}
|
||||
} else {
|
||||
if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false {
|
||||
copyFeedBetweenAccounts(node: node, to: parentNode)
|
||||
} else {
|
||||
moveFeedBetweenAccounts(node: node, to: parentNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
BatchUpdate.shared.perform {
|
||||
draggedNodes.forEach { move(node: $0, to: parentNode, account: account) }
|
||||
}
|
||||
account.structureDidChange()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -313,6 +472,94 @@ private extension SidebarOutlineDataSource {
|
||||
return ancestorThatCanAcceptNonLocalFeed(parentNode)
|
||||
}
|
||||
|
||||
func copyFolderBetweenAccounts(node: Node, to parentNode: Node) {
|
||||
guard let sourceFolder = node.representedObject as? Folder,
|
||||
let destinationAccount = nodeAccount(parentNode) else {
|
||||
return
|
||||
}
|
||||
replicateFolder(sourceFolder, destinationAccount: destinationAccount, completion: {})
|
||||
}
|
||||
|
||||
func moveFolderBetweenAccounts(node: Node, to parentNode: Node) {
|
||||
guard let sourceFolder = node.representedObject as? Folder,
|
||||
let sourceAccount = nodeAccount(node),
|
||||
let destinationAccount = nodeAccount(parentNode) else {
|
||||
return
|
||||
}
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
replicateFolder(sourceFolder, destinationAccount: destinationAccount) {
|
||||
sourceAccount.removeFolder(sourceFolder) { result in
|
||||
BatchUpdate.shared.end()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replicateFolder(_ folder: Folder, destinationAccount: Account, completion: @escaping () -> Void) {
|
||||
destinationAccount.addFolder(folder.name ?? "") { result in
|
||||
switch result {
|
||||
case .success(let destinationFolder):
|
||||
let group = DispatchGroup()
|
||||
for feed in folder.topLevelFeeds {
|
||||
if let existingFeed = destinationAccount.existingFeed(withURL: feed.url) {
|
||||
group.enter()
|
||||
destinationAccount.addFeed(existingFeed, to: destinationFolder) { result in
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
group.enter()
|
||||
destinationAccount.createFeed(url: feed.url, name: feed.editedName, container: destinationFolder) { result in
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
completion()
|
||||
}
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func acceptLocalFoldersDrop(_ outlineView: NSOutlineView, _ draggedFolders: Set<PasteboardFolder>, _ parentNode: Node, _ index: Int) -> Bool {
|
||||
guard let draggedNodes = draggedNodes else {
|
||||
return false
|
||||
}
|
||||
|
||||
draggedNodes.forEach { node in
|
||||
if !sameAccount(node, parentNode) {
|
||||
if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false {
|
||||
copyFolderBetweenAccounts(node: node, to: parentNode)
|
||||
} else {
|
||||
moveFolderBetweenAccounts(node: node, to: parentNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func acceptSingleNonLocalFeedDrop(_ outlineView: NSOutlineView, _ draggedFeed: PasteboardFeed, _ parentNode: Node, _ index: Int) -> Bool {
|
||||
guard nodeIsDropTarget(parentNode), index == NSOutlineViewDropOnItemIndex else {
|
||||
return false
|
||||
@@ -346,27 +593,32 @@ private extension SidebarOutlineDataSource {
|
||||
return false
|
||||
}
|
||||
|
||||
func nodeAndDraggedFeedsDoNotShareAccount(_ parentNode: Node, _ draggedFeeds: Set<PasteboardFeed>) -> Bool {
|
||||
|
||||
let parentAccountId: String?
|
||||
if let account = parentNode.representedObject as? Account {
|
||||
parentAccountId = account.accountID
|
||||
} else if let folder = parentNode.representedObject as? Folder {
|
||||
parentAccountId = folder.account?.accountID
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
for draggedFeed in draggedFeeds {
|
||||
if draggedFeed.accountID != parentAccountId {
|
||||
func sameAccount(_ node: Node, _ parentNode: Node) -> Bool {
|
||||
if let accountID = nodeAccountID(node), let parentAccountID = nodeAccountID(parentNode) {
|
||||
if accountID == parentAccountID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
func nodeAccount(_ node: Node) -> Account? {
|
||||
if let account = node.representedObject as? Account {
|
||||
return account
|
||||
} else if let folder = node.representedObject as? Folder {
|
||||
return folder.account
|
||||
} else if let feed = node.representedObject as? Feed {
|
||||
return feed.account
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func nodeAccountID(_ node: Node) -> String? {
|
||||
return nodeAccount(node)?.accountID
|
||||
}
|
||||
|
||||
func nodeHasChildRepresentingAnyDraggedFeed(_ parentNode: Node, _ draggedFeeds: Set<PasteboardFeed>) -> Bool {
|
||||
for node in parentNode.childNodes {
|
||||
if nodeRepresentsAnyDraggedFeed(node, draggedFeeds) {
|
||||
@@ -376,6 +628,29 @@ private extension SidebarOutlineDataSource {
|
||||
return false
|
||||
}
|
||||
|
||||
func violatesTagSpecificBehavior(_ parentNode: Node, _ draggedFeed: PasteboardFeed) -> Bool {
|
||||
return violatesTagSpecificBehavior(parentNode, Set([draggedFeed]))
|
||||
}
|
||||
|
||||
func violatesTagSpecificBehavior(_ parentNode: Node, _ draggedFeeds: Set<PasteboardFeed>) -> Bool {
|
||||
guard let parentAccount = nodeAccount(parentNode), parentAccount.usesTags else {
|
||||
return false
|
||||
}
|
||||
|
||||
for draggedFeed in draggedFeeds {
|
||||
if parentAccount.accountID != draggedFeed.accountID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Can't copy to the account when using tags
|
||||
if parentNode.representedObject is Account && (NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func indexWhereDraggedFeedWouldAppear(_ parentNode: Node, _ draggedFeed: PasteboardFeed) -> Int {
|
||||
let draggedFeedWrapper = PasteboardFeedObjectWrapper(pasteboardFeed: draggedFeed)
|
||||
let draggedFeedNode = Node(representedObject: draggedFeedWrapper, parent: nil)
|
||||
@@ -386,6 +661,18 @@ private extension SidebarOutlineDataSource {
|
||||
let index = sortedNodes.firstIndex(of: draggedFeedNode)!
|
||||
return index
|
||||
}
|
||||
|
||||
func indexWhereDraggedFolderWouldAppear(_ parentNode: Node, _ draggedFolder: PasteboardFolder) -> Int {
|
||||
let draggedFolderWrapper = PasteboardFolderObjectWrapper(pasteboardFolder: draggedFolder)
|
||||
let draggedFolderNode = Node(representedObject: draggedFolderWrapper, parent: nil)
|
||||
draggedFolderNode.canHaveChildNodes = true
|
||||
let nodes = parentNode.childNodes + [draggedFolderNode]
|
||||
|
||||
// Revisit if the tree controller can ever be sorted in some other way.
|
||||
let sortedNodes = nodes.sortedAlphabeticallyWithFoldersAtEnd()
|
||||
let index = sortedNodes.firstIndex(of: draggedFolderNode)!
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
final class PasteboardFeedObjectWrapper: DisplayNameProvider {
|
||||
@@ -399,3 +686,15 @@ final class PasteboardFeedObjectWrapper: DisplayNameProvider {
|
||||
self.pasteboardFeed = pasteboardFeed
|
||||
}
|
||||
}
|
||||
|
||||
final class PasteboardFolderObjectWrapper: DisplayNameProvider {
|
||||
|
||||
var nameForDisplay: String {
|
||||
return pasteboardFolder.name
|
||||
}
|
||||
let pasteboardFolder: PasteboardFolder
|
||||
|
||||
init(pasteboardFolder: PasteboardFolder) {
|
||||
self.pasteboardFolder = pasteboardFolder
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ protocol SidebarDelegate: class {
|
||||
sidebarCellAppearance = SidebarCellAppearance(fontSize: AppDefaults.sidebarFontSize)
|
||||
|
||||
outlineView.dataSource = dataSource
|
||||
outlineView.setDraggingSourceOperationMask(.move, forLocal: true)
|
||||
outlineView.setDraggingSourceOperationMask(.copy, forLocal: false)
|
||||
outlineView.setDraggingSourceOperationMask([.move, .copy], forLocal: true)
|
||||
outlineView.registerForDraggedTypes([FeedPasteboardWriter.feedUTIInternalType, FeedPasteboardWriter.feedUTIType, .URL, .string])
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
@@ -61,6 +60,7 @@ protocol SidebarDelegate: class {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .FeedSettingDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDidRequestSidebarSelection(_:)), name: .UserDidRequestSidebarSelection, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(calendarDayChanged(_:)), name: .NSCalendarDayChanged, object: nil)
|
||||
|
||||
outlineView.reloadData()
|
||||
|
||||
@@ -166,6 +166,12 @@ protocol SidebarDelegate: class {
|
||||
revealAndSelectRepresentedObject(feed as AnyObject)
|
||||
}
|
||||
|
||||
@objc func calendarDayChanged(_ note: Notification) {
|
||||
DispatchQueue.main.async {
|
||||
SmartFeedsController.shared.todayFeed.fetchUnreadCounts()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@IBAction func delete(_ sender: AnyObject?) {
|
||||
|
||||
@@ -18,15 +18,18 @@ final class SingleLineTextFieldSizer {
|
||||
private let textField: NSTextField
|
||||
private var cache = [String: NSSize]()
|
||||
|
||||
init(font: NSFont) {
|
||||
/// Get the NSTextField size for text, given a font.
|
||||
static func size(for text: String, font: NSFont) -> NSSize {
|
||||
return sizer(for: font).size(for: text)
|
||||
}
|
||||
|
||||
init(font: NSFont) {
|
||||
self.textField = NSTextField(labelWithString: "")
|
||||
self.textField.font = font
|
||||
self.font = font
|
||||
}
|
||||
|
||||
func size(for text: String) -> NSSize {
|
||||
|
||||
if let cachedSize = cache[text] {
|
||||
return cachedSize
|
||||
}
|
||||
@@ -40,29 +43,23 @@ final class SingleLineTextFieldSizer {
|
||||
return calculatedSize
|
||||
}
|
||||
|
||||
static private var sizers = [NSFont: SingleLineTextFieldSizer]()
|
||||
static private var sizers = [SingleLineTextFieldSizer]()
|
||||
|
||||
static func sizer(for font: NSFont) -> SingleLineTextFieldSizer {
|
||||
|
||||
if let cachedSizer = sizers[font] {
|
||||
static private func sizer(for font: NSFont) -> SingleLineTextFieldSizer {
|
||||
// We used to use an [NSFont: SingleLineTextFieldSizer] dictionary —
|
||||
// until, in 10.14.5, we started getting crashes with the message:
|
||||
// Fatal error: Duplicate keys of type 'NSFont' were found in a Dictionary.
|
||||
// This usually means either that the type violates Hashable's requirements, or
|
||||
// that members of such a dictionary were mutated after insertion.
|
||||
// We use just an array of sizers now — which is totally fine,
|
||||
// because there’s only going to be like three of them.
|
||||
if let cachedSizer = sizers.firstElementPassingTest({ $0.font == font }) {
|
||||
return cachedSizer
|
||||
}
|
||||
|
||||
let newSizer = SingleLineTextFieldSizer(font: font)
|
||||
sizers[font] = newSizer
|
||||
sizers.append(newSizer)
|
||||
|
||||
return newSizer
|
||||
}
|
||||
|
||||
// Use this call. It’s easiest.
|
||||
|
||||
static func size(for text: String, font: NSFont) -> NSSize {
|
||||
|
||||
return sizer(for: font).size(for: text)
|
||||
}
|
||||
|
||||
static func emptyCache() {
|
||||
|
||||
sizers = [NSFont: SingleLineTextFieldSizer]()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .AccountsDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(calendarDayChanged(_:)), name: .NSCalendarDayChanged, object: nil)
|
||||
|
||||
didRegisterForNotifications = true
|
||||
}
|
||||
@@ -511,6 +512,14 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
|
||||
self.fontSize = AppDefaults.timelineFontSize
|
||||
self.sortDirection = AppDefaults.timelineSortDirection
|
||||
}
|
||||
|
||||
@objc func calendarDayChanged(_ note: Notification) {
|
||||
if representedObjectsContainsTodayFeed() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.fetchArticles()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reloading Data
|
||||
|
||||
@@ -966,6 +975,10 @@ private extension TimelineViewController {
|
||||
return representedObjects?.contains(where: { $0 is PseudoFeed}) ?? false
|
||||
}
|
||||
|
||||
func representedObjectsContainsTodayFeed() -> Bool {
|
||||
return representedObjects?.contains(where: { $0 === SmartFeedsController.shared.todayFeed }) ?? false
|
||||
}
|
||||
|
||||
func representedObjectsContainsAnyFeed(_ feeds: Set<Feed>) -> Bool {
|
||||
|
||||
// Return true if there’s a match or if a folder contains (recursively) one of feeds
|
||||
|
||||
@@ -26,12 +26,13 @@
|
||||
<rect key="frame" x="10" y="33" width="326" height="254"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<gridView xPlacement="fill" yPlacement="center" rowAlignment="none" rowSpacing="9" columnSpacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="nVy-H3-bFO">
|
||||
<rect key="frame" x="20" y="163" width="286" height="71"/>
|
||||
<gridView xPlacement="fill" yPlacement="center" rowAlignment="none" rowSpacing="9" translatesAutoresizingMaskIntoConstraints="NO" id="nVy-H3-bFO">
|
||||
<rect key="frame" x="20" y="103" width="286" height="131"/>
|
||||
<rows>
|
||||
<gridRow id="yLs-SL-a1b"/>
|
||||
<gridRow yPlacement="top" id="etw-2m-nWZ"/>
|
||||
<gridRow id="3IT-3r-gEK"/>
|
||||
<gridRow id="Y4C-5M-ySp"/>
|
||||
</rows>
|
||||
<columns>
|
||||
<gridColumn id="sMM-Ds-SKX"/>
|
||||
@@ -40,7 +41,7 @@
|
||||
<gridCells>
|
||||
<gridCell row="yLs-SL-a1b" column="sMM-Ds-SKX" id="3ea-DE-T3i">
|
||||
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="jiQ-KJ-SS0">
|
||||
<rect key="frame" x="-2" y="54" width="44" height="17"/>
|
||||
<rect key="frame" x="-2" y="114" width="44" height="17"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" alignment="right" title="Type:" id="tC5-Vt-gBc">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
@@ -50,7 +51,7 @@
|
||||
</gridCell>
|
||||
<gridCell row="yLs-SL-a1b" column="Fhf-h9-g0O" id="baI-Kp-tKF">
|
||||
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="XYX-iz-hnq">
|
||||
<rect key="frame" x="53" y="54" width="73" height="17"/>
|
||||
<rect key="frame" x="44" y="114" width="73" height="17"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="On My Mac" id="6yI-bV-1Sh">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
@@ -61,7 +62,7 @@
|
||||
<gridCell row="etw-2m-nWZ" column="sMM-Ds-SKX" id="htf-Ca-Hpv"/>
|
||||
<gridCell row="etw-2m-nWZ" column="Fhf-h9-g0O" id="NrD-vV-1Y1">
|
||||
<button key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="mgt-uY-fuq">
|
||||
<rect key="frame" x="53" y="29" width="60" height="18"/>
|
||||
<rect key="frame" x="44" y="89" width="60" height="18"/>
|
||||
<buttonCell key="cell" type="check" title="Active" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="wxB-dX-nGt">
|
||||
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
@@ -73,7 +74,7 @@
|
||||
</gridCell>
|
||||
<gridCell row="3IT-3r-gEK" column="sMM-Ds-SKX" id="2yP-oZ-A6S">
|
||||
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ted-jN-oYR">
|
||||
<rect key="frame" x="-2" y="3" width="44" height="17"/>
|
||||
<rect key="frame" x="-2" y="63" width="44" height="17"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" alignment="right" title="Name:" id="uyQ-Zi-QCr">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
@@ -83,7 +84,7 @@
|
||||
</gridCell>
|
||||
<gridCell row="3IT-3r-gEK" column="Fhf-h9-g0O" id="nCq-02-YVv">
|
||||
<textField key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="TT0-Kf-YTC">
|
||||
<rect key="frame" x="55" y="0.0" width="100" height="22"/>
|
||||
<rect key="frame" x="46" y="60" width="100" height="22"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" drawsBackground="YES" id="7Vp-Hq-j6n">
|
||||
<font key="font" usesAppearanceFont="YES"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
@@ -91,10 +92,21 @@
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</gridCell>
|
||||
<gridCell row="Y4C-5M-ySp" column="sMM-Ds-SKX" id="dON-E7-yd2"/>
|
||||
<gridCell row="Y4C-5M-ySp" column="Fhf-h9-g0O" id="i7Y-4k-5TF">
|
||||
<textField key="contentView" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="xp5-wk-PKc">
|
||||
<rect key="frame" x="44" y="0.0" width="244" height="51"/>
|
||||
<textFieldCell key="cell" selectable="YES" title="The name appears in the sidebar. It can be anything you want. You can even use emoji. 🎸" id="MW0-mH-Gaa">
|
||||
<font key="font" usesAppearanceFont="YES"/>
|
||||
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</gridCell>
|
||||
</gridCells>
|
||||
</gridView>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="gLh-gl-ZGQ">
|
||||
<rect key="frame" x="109" y="115" width="109" height="32"/>
|
||||
<rect key="frame" x="109" y="55" width="109" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Credentials" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="vYg-ZC-o4W">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
|
||||
@@ -88,7 +88,14 @@ class AccountsFeedbinWindowController: NSWindowController {
|
||||
try self.account?.removeBasicCredentials()
|
||||
try self.account?.storeCredentials(credentials)
|
||||
if newAccount {
|
||||
self.account?.refreshAll()
|
||||
self.account?.refreshAll() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK)
|
||||
} catch {
|
||||
|
||||
@@ -29,6 +29,10 @@ final class AccountsPreferencesViewController: NSViewController {
|
||||
|
||||
showController(AccountsAddViewController())
|
||||
|
||||
// Fix tableView frame — for some reason IB wants it 1pt wider than the clip view. This leads to unwanted horizontal scrolling.
|
||||
var rTable = tableView.frame
|
||||
rTable.size.width = tableView.superview!.frame.size.width
|
||||
tableView.frame = rTable
|
||||
}
|
||||
|
||||
@IBAction func addAccount(_ sender: Any) {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_16x16.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_32x32.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_128x128.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_256x256.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_512x512.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 639 KiB |
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>5.0d17</string>
|
||||
<string>5.0a3</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -50,12 +50,16 @@ class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectConta
|
||||
func deleteElement(_ element:ScriptingObject) {
|
||||
if let scriptableFolder = element as? ScriptableFolder {
|
||||
BatchUpdate.shared.perform {
|
||||
account.deleteFolder(scriptableFolder.folder) { result in
|
||||
account.removeFolder(scriptableFolder.folder) { result in
|
||||
}
|
||||
}
|
||||
} else if let scriptableFeed = element as? ScriptableFeed {
|
||||
BatchUpdate.shared.perform {
|
||||
account.deleteFeed(scriptableFeed.feed) { result in
|
||||
var container: Container? = nil
|
||||
if let scriptableFolder = scriptableFeed.container as? ScriptableFolder {
|
||||
container = scriptableFolder.folder
|
||||
}
|
||||
account.removeFeed(scriptableFeed.feed, from: container) { result in
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ class ScriptableFeed: NSObject, UniqueIdScriptingObject, ScriptingObjectContaine
|
||||
if let existingFeed = account.existingFeed(withURL:url) {
|
||||
return self.scriptableFeed(existingFeed, account:account, folder:folder)
|
||||
}
|
||||
|
||||
|
||||
let container: Container = folder != nil ? folder! : account
|
||||
|
||||
// at this point, we need to download the feed and parse it.
|
||||
// RS Parser does the callback for the download on the main thread (which it probably shouldn't?)
|
||||
// because we can't wait here (on the main thread, maybe) for the callback, we have to return from this function
|
||||
@@ -100,27 +102,12 @@ class ScriptableFeed: NSObject, UniqueIdScriptingObject, ScriptingObjectContaine
|
||||
// suspendExecution(). When we get the callback, we can supply the event result and call resumeExecution()
|
||||
command.suspendExecution()
|
||||
|
||||
account.createFeed(url: url) { result in
|
||||
account.createFeed(url: url, name: titleFromArgs, container: container) { result in
|
||||
switch result {
|
||||
case .success(let feed):
|
||||
|
||||
if let editedName = titleFromArgs {
|
||||
account.renameFeed(feed, to: editedName) { result in
|
||||
}
|
||||
}
|
||||
|
||||
// add the feed, putting it in a folder if needed
|
||||
account.addFeed(feed) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
|
||||
let scriptableFeed = self.scriptableFeed(feed, account:account, folder:folder)
|
||||
command.resumeExecution(withResult:scriptableFeed.objectSpecifier)
|
||||
case .failure:
|
||||
command.resumeExecution(withResult:nil)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
|
||||
let scriptableFeed = self.scriptableFeed(feed, account:account, folder:folder)
|
||||
command.resumeExecution(withResult:scriptableFeed.objectSpecifier)
|
||||
case .failure:
|
||||
command.resumeExecution(withResult:nil)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class ScriptableFolder: NSObject, UniqueIdScriptingObject, ScriptingObjectContai
|
||||
func deleteElement(_ element:ScriptingObject) {
|
||||
if let scriptableFeed = element as? ScriptableFeed {
|
||||
BatchUpdate.shared.perform {
|
||||
folder.account?.deleteFeed(scriptableFeed.feed) { result in }
|
||||
folder.account?.removeFeed(scriptableFeed.feed, from: folder) { result in }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
510D707422B028E1004E8F65 /* SettingsAddAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510D707322B028E1004E8F65 /* SettingsAddAccountView.swift */; };
|
||||
510D707E22B02A4B004E8F65 /* SettingsLocalAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510D707D22B02A4B004E8F65 /* SettingsLocalAccountView.swift */; };
|
||||
510D708022B02A5F004E8F65 /* SettingsFeedbinAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510D707F22B02A5F004E8F65 /* SettingsFeedbinAccountView.swift */; };
|
||||
510D708222B041CC004E8F65 /* SettingsAccountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510D708122B041CC004E8F65 /* SettingsAccountLabelView.swift */; };
|
||||
51126DA4225FDE2F00722696 /* RSImage-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51126DA3225FDE2F00722696 /* RSImage-Extensions.swift */; };
|
||||
5115CAF42266301400B21BCE /* AddContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51121B5A22661FEF00BC0EC1 /* AddContainerViewController.swift */; };
|
||||
5126EE97226CB48A00C22AFC /* NavigationStateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5126EE96226CB48A00C22AFC /* NavigationStateController.swift */; };
|
||||
@@ -118,6 +122,8 @@
|
||||
51C452B42265141B00C03939 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51C452B32265141B00C03939 /* WebKit.framework */; };
|
||||
51C452B82265178500C03939 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 51C452B72265178500C03939 /* styleSheet.css */; };
|
||||
51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84702AA31FA27AC0006B8943 /* MarkStatusCommand.swift */; };
|
||||
51E3EB33229AB02C00645299 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB32229AB02C00645299 /* ErrorHandler.swift */; };
|
||||
51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB3C229AB08300645299 /* ErrorHandler.swift */; };
|
||||
51E595A5228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */; };
|
||||
51E595A6228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */; };
|
||||
51E595AB228DF94C00FCC42B /* SettingsTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51E595AA228DF94C00FCC42B /* SettingsTableViewCell.xib */; };
|
||||
@@ -132,6 +138,8 @@
|
||||
51EF0F8E2279C9260050506E /* AccountsAdd.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51EF0F8D2279C9260050506E /* AccountsAdd.xib */; };
|
||||
51EF0F902279C9500050506E /* AccountsAddViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51EF0F8F2279C9500050506E /* AccountsAddViewController.swift */; };
|
||||
51EF0F922279CA620050506E /* AccountsAddTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51EF0F912279CA620050506E /* AccountsAddTableCellView.swift */; };
|
||||
51F35D0922AFD4760003CE1B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F35D0822AFD4760003CE1B /* SettingsView.swift */; };
|
||||
51F772F622B279570087D9D1 /* SettingsDetailAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F772EC22B2789B0087D9D1 /* SettingsDetailAccountView.swift */; };
|
||||
51F85BE5227217D000C787DC /* RefreshIntervalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BDB2272162F00C787DC /* RefreshIntervalViewController.swift */; };
|
||||
51F85BE7227245FC00C787DC /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BE6227245FC00C787DC /* AboutViewController.swift */; };
|
||||
51F85BEB22724CB600C787DC /* About.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 51F85BEA22724CB600C787DC /* About.rtf */; };
|
||||
@@ -157,7 +165,6 @@
|
||||
840958632201629A002C1579 /* Subscribe to Feed.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6581C73320CED60000F4AD34 /* Subscribe to Feed.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
840BEE4121D70E64009BBAFA /* CrashReportWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840BEE4021D70E64009BBAFA /* CrashReportWindowController.swift */; };
|
||||
840D617F2029031C009BC708 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840D617E2029031C009BC708 /* AppDelegate.swift */; };
|
||||
840D61962029031D009BC708 /* NetNewsWire_iOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840D61952029031D009BC708 /* NetNewsWire_iOSTests.swift */; };
|
||||
84162A152038C12C00035290 /* MarkCommandValidationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84162A142038C12C00035290 /* MarkCommandValidationStatus.swift */; };
|
||||
841ABA4E20145E7300980E11 /* NothingInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841ABA4D20145E7300980E11 /* NothingInspectorViewController.swift */; };
|
||||
841ABA5E20145E9200980E11 /* FolderInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841ABA5D20145E9200980E11 /* FolderInspectorViewController.swift */; };
|
||||
@@ -231,7 +238,7 @@
|
||||
84A37CB5201ECD610087C5AF /* RenameWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A37CB4201ECD610087C5AF /* RenameWindowController.swift */; };
|
||||
84A3EE5F223B667F00557320 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; };
|
||||
84A3EE61223B667F00557320 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; };
|
||||
84AD1EAA2031617300BC20B7 /* FolderPasteboardWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AD1EA92031617300BC20B7 /* FolderPasteboardWriter.swift */; };
|
||||
84AD1EAA2031617300BC20B7 /* PasteboardFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AD1EA92031617300BC20B7 /* PasteboardFolder.swift */; };
|
||||
84AD1EBA2031649C00BC20B7 /* SmartFeedPasteboardWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AD1EB92031649C00BC20B7 /* SmartFeedPasteboardWriter.swift */; };
|
||||
84AD1EBC2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AD1EBB2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift */; };
|
||||
84B7178C201E66580091657D /* SidebarViewController+ContextualMenus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7178B201E66580091657D /* SidebarViewController+ContextualMenus.swift */; };
|
||||
@@ -454,13 +461,6 @@
|
||||
remoteGlobalIDString = 844BEE401F0AB3AB004AB7CD;
|
||||
remoteInfo = ArticlesDatabaseTests;
|
||||
};
|
||||
840D61922029031D009BC708 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 849C64581ED37A5D003D8FC0 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 840D617B2029031C009BC708;
|
||||
remoteInfo = "NetNewsWire-iOS";
|
||||
};
|
||||
849C64721ED37A5D003D8FC0 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 849C64581ED37A5D003D8FC0 /* Project object */;
|
||||
@@ -659,6 +659,10 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
510D707322B028E1004E8F65 /* SettingsAddAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAddAccountView.swift; sourceTree = "<group>"; };
|
||||
510D707D22B02A4B004E8F65 /* SettingsLocalAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLocalAccountView.swift; sourceTree = "<group>"; };
|
||||
510D707F22B02A5F004E8F65 /* SettingsFeedbinAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFeedbinAccountView.swift; sourceTree = "<group>"; };
|
||||
510D708122B041CC004E8F65 /* SettingsAccountLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAccountLabelView.swift; sourceTree = "<group>"; };
|
||||
51121AA12265430A00BC0EC1 /* NetNewsWire_iOS_target.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOS_target.xcconfig; sourceTree = "<group>"; };
|
||||
51121B5A22661FEF00BC0EC1 /* AddContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContainerViewController.swift; sourceTree = "<group>"; };
|
||||
51126DA3225FDE2F00722696 /* RSImage-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-Extensions.swift"; sourceTree = "<group>"; };
|
||||
@@ -711,6 +715,8 @@
|
||||
51C4528B2265095F00C03939 /* AddFolderViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddFolderViewController.swift; sourceTree = "<group>"; };
|
||||
51C452B32265141B00C03939 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.2.sdk/System/Library/Frameworks/WebKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
51C452B72265178500C03939 /* styleSheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = styleSheet.css; sourceTree = "<group>"; };
|
||||
51E3EB32229AB02C00645299 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = "<group>"; };
|
||||
51E3EB3C229AB08300645299 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = "<group>"; };
|
||||
51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleStatusSyncTimer.swift; sourceTree = "<group>"; };
|
||||
51E595AA228DF94C00FCC42B /* SettingsTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsTableViewCell.xib; sourceTree = "<group>"; };
|
||||
51E595AC228E1C2100FCC42B /* AddAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountViewController.swift; sourceTree = "<group>"; };
|
||||
@@ -723,6 +729,8 @@
|
||||
51EF0F8D2279C9260050506E /* AccountsAdd.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountsAdd.xib; sourceTree = "<group>"; };
|
||||
51EF0F8F2279C9500050506E /* AccountsAddViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsAddViewController.swift; sourceTree = "<group>"; };
|
||||
51EF0F912279CA620050506E /* AccountsAddTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsAddTableCellView.swift; sourceTree = "<group>"; };
|
||||
51F35D0822AFD4760003CE1B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
51F772EC22B2789B0087D9D1 /* SettingsDetailAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDetailAccountView.swift; sourceTree = "<group>"; };
|
||||
51F85BDB2272162F00C787DC /* RefreshIntervalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshIntervalViewController.swift; sourceTree = "<group>"; };
|
||||
51F85BE6227245FC00C787DC /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = "<group>"; };
|
||||
51F85BEA22724CB600C787DC /* About.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = About.rtf; sourceTree = "<group>"; };
|
||||
@@ -752,7 +760,6 @@
|
||||
840BEE4021D70E64009BBAFA /* CrashReportWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReportWindowController.swift; sourceTree = "<group>"; };
|
||||
840D617C2029031C009BC708 /* NetNewsWire.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NetNewsWire.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
840D617E2029031C009BC708 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
840D61912029031D009BC708 /* NetNewsWire-iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "NetNewsWire-iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
840D61952029031D009BC708 /* NetNewsWire_iOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetNewsWire_iOSTests.swift; sourceTree = "<group>"; };
|
||||
840D61972029031D009BC708 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
84162A142038C12C00035290 /* MarkCommandValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkCommandValidationStatus.swift; sourceTree = "<group>"; };
|
||||
@@ -835,7 +842,7 @@
|
||||
84A1500420048DDF0046AD9A /* SendToMarsEditCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToMarsEditCommand.swift; sourceTree = "<group>"; };
|
||||
84A37CB4201ECD610087C5AF /* RenameWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RenameWindowController.swift; sourceTree = "<group>"; };
|
||||
84A3EE52223B667F00557320 /* DefaultFeeds.opml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = DefaultFeeds.opml; sourceTree = "<group>"; };
|
||||
84AD1EA92031617300BC20B7 /* FolderPasteboardWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderPasteboardWriter.swift; sourceTree = "<group>"; };
|
||||
84AD1EA92031617300BC20B7 /* PasteboardFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardFolder.swift; sourceTree = "<group>"; };
|
||||
84AD1EB92031649C00BC20B7 /* SmartFeedPasteboardWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedPasteboardWriter.swift; sourceTree = "<group>"; };
|
||||
84AD1EBB2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOutlineDataSource.swift; sourceTree = "<group>"; };
|
||||
84B7178B201E66580091657D /* SidebarViewController+ContextualMenus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SidebarViewController+ContextualMenus.swift"; sourceTree = "<group>"; };
|
||||
@@ -869,6 +876,7 @@
|
||||
84C9FCA32262A1B800D921D6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
84CBDDAE1FD3674C005A61AA /* Technotes */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Technotes; sourceTree = "<group>"; };
|
||||
84CC88171FE59CBF00644329 /* SmartFeedsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedsController.swift; sourceTree = "<group>"; };
|
||||
84D2200922B0BC4B0019E085 /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = "<group>"; };
|
||||
84D52E941FE588BB00D14F5B /* DetailStatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailStatusBarView.swift; sourceTree = "<group>"; };
|
||||
84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleLineTextFieldSizer.swift; sourceTree = "<group>"; };
|
||||
84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextFieldSizer.swift; sourceTree = "<group>"; };
|
||||
@@ -945,13 +953,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
840D618E2029031D009BC708 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
849C645D1ED37A5D003D8FC0 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -1039,16 +1040,13 @@
|
||||
5183CCEB227117C70010922C /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5183CCEC22711DCE0010922C /* Settings.storyboard */,
|
||||
51E595AA228DF94C00FCC42B /* SettingsTableViewCell.xib */,
|
||||
5183CCEE227125970010922C /* SettingsViewController.swift */,
|
||||
51E595AC228E1C2100FCC42B /* AddAccountViewController.swift */,
|
||||
515436892291FED9005E1CDF /* FeedbinAccountViewController.swift */,
|
||||
515436872291D75D005E1CDF /* AddLocalAccountViewController.swift */,
|
||||
51F85BE6227245FC00C787DC /* AboutViewController.swift */,
|
||||
51543684228F6753005E1CDF /* DetailAccountViewController.swift */,
|
||||
51F85BDB2272162F00C787DC /* RefreshIntervalViewController.swift */,
|
||||
51EF0F7B2277919E0050506E /* TimelineNumberOfLinesViewController.swift */,
|
||||
510D708122B041CC004E8F65 /* SettingsAccountLabelView.swift */,
|
||||
510D707322B028E1004E8F65 /* SettingsAddAccountView.swift */,
|
||||
51F772EC22B2789B0087D9D1 /* SettingsDetailAccountView.swift */,
|
||||
510D707F22B02A5F004E8F65 /* SettingsFeedbinAccountView.swift */,
|
||||
510D707D22B02A4B004E8F65 /* SettingsLocalAccountView.swift */,
|
||||
51F35D0822AFD4760003CE1B /* SettingsView.swift */,
|
||||
51F35CFD22AFD0350003CE1B /* UIKit */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
@@ -1159,6 +1157,23 @@
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
51F35CFD22AFD0350003CE1B /* UIKit */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5183CCEC22711DCE0010922C /* Settings.storyboard */,
|
||||
51E595AA228DF94C00FCC42B /* SettingsTableViewCell.xib */,
|
||||
5183CCEE227125970010922C /* SettingsViewController.swift */,
|
||||
51E595AC228E1C2100FCC42B /* AddAccountViewController.swift */,
|
||||
515436892291FED9005E1CDF /* FeedbinAccountViewController.swift */,
|
||||
515436872291D75D005E1CDF /* AddLocalAccountViewController.swift */,
|
||||
51F85BE6227245FC00C787DC /* AboutViewController.swift */,
|
||||
51543684228F6753005E1CDF /* DetailAccountViewController.swift */,
|
||||
51F85BDB2272162F00C787DC /* RefreshIntervalViewController.swift */,
|
||||
51EF0F7B2277919E0050506E /* TimelineNumberOfLinesViewController.swift */,
|
||||
);
|
||||
path = UIKit;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6581C73620CED60100F4AD34 /* SafariExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -1365,7 +1380,7 @@
|
||||
849A97601ED9EB96007D329B /* SidebarOutlineView.swift */,
|
||||
849A97631ED9EB96007D329B /* UnreadCountView.swift */,
|
||||
848D578D21543519005FFAD5 /* PasteboardFeed.swift */,
|
||||
84AD1EA92031617300BC20B7 /* FolderPasteboardWriter.swift */,
|
||||
84AD1EA92031617300BC20B7 /* PasteboardFolder.swift */,
|
||||
849A97821ED9EC63007D329B /* SidebarStatusBarView.swift */,
|
||||
844B5B6A1FEA224000C7C76A /* Keyboard */,
|
||||
845A29251FC928C7007B49E3 /* Cell */,
|
||||
@@ -1443,6 +1458,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
845B14A51FC2299E0013CF92 /* README.md */,
|
||||
84D2200922B0BC4B0019E085 /* CONTRIBUTING.md */,
|
||||
84CBDDAE1FD3674C005A61AA /* Technotes */,
|
||||
84C9FC6522629B3900D921D6 /* Mac */,
|
||||
84C9FC922262A0E600D921D6 /* iOS */,
|
||||
@@ -1466,7 +1482,6 @@
|
||||
849C64601ED37A5D003D8FC0 /* NetNewsWire.app */,
|
||||
849C64711ED37A5D003D8FC0 /* NetNewsWireTests.xctest */,
|
||||
840D617C2029031C009BC708 /* NetNewsWire.app */,
|
||||
840D61912029031D009BC708 /* NetNewsWire-iOSTests.xctest */,
|
||||
6581C73320CED60000F4AD34 /* Subscribe to Feed.appex */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -1550,6 +1565,7 @@
|
||||
84E46C7C1F75EF7B005ECFB3 /* AppDefaults.swift */,
|
||||
849EE70E203919360082A1EA /* AppAssets.swift */,
|
||||
842E45DC1ED8C54B000A8B52 /* Browser.swift */,
|
||||
51E3EB32229AB02C00645299 /* ErrorHandler.swift */,
|
||||
842E45E11ED8C681000A8B52 /* MainWindow */,
|
||||
84BBB12A20142A4700F054F5 /* Inspector */,
|
||||
84C9FC6922629E1200D921D6 /* Preferences */,
|
||||
@@ -1665,6 +1681,7 @@
|
||||
840D617E2029031C009BC708 /* AppDelegate.swift */,
|
||||
51C45254226507D200C03939 /* AppAssets.swift */,
|
||||
51C45255226507D200C03939 /* AppDefaults.swift */,
|
||||
51E3EB3C229AB08300645299 /* ErrorHandler.swift */,
|
||||
5126EE96226CB48A00C22AFC /* NavigationStateController.swift */,
|
||||
51C4525D226508F600C03939 /* MasterFeed */,
|
||||
51C4526D2265091600C03939 /* MasterTimeline */,
|
||||
@@ -1858,24 +1875,6 @@
|
||||
productReference = 840D617C2029031C009BC708 /* NetNewsWire.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
840D61902029031D009BC708 /* NetNewsWire-iOSTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 840D61A62029031E009BC708 /* Build configuration list for PBXNativeTarget "NetNewsWire-iOSTests" */;
|
||||
buildPhases = (
|
||||
840D618D2029031D009BC708 /* Sources */,
|
||||
840D618E2029031D009BC708 /* Frameworks */,
|
||||
840D618F2029031D009BC708 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
840D61932029031D009BC708 /* PBXTargetDependency */,
|
||||
);
|
||||
name = "NetNewsWire-iOSTests";
|
||||
productName = "NetNewsWire-iOSTests";
|
||||
productReference = 840D61912029031D009BC708 /* NetNewsWire-iOSTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
849C645F1ED37A5D003D8FC0 /* NetNewsWire */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 849C647A1ED37A5D003D8FC0 /* Build configuration list for PBXNativeTarget "NetNewsWire" */;
|
||||
@@ -1949,12 +1948,6 @@
|
||||
};
|
||||
};
|
||||
};
|
||||
840D61902029031D009BC708 = {
|
||||
CreatedOnToolsVersion = 9.3;
|
||||
DevelopmentTeam = 9C84TZ7Q6Z;
|
||||
ProvisioningStyle = Automatic;
|
||||
TestTargetID = 840D617B2029031C009BC708;
|
||||
};
|
||||
849C645F1ED37A5D003D8FC0 = {
|
||||
CreatedOnToolsVersion = 8.2.1;
|
||||
DevelopmentTeam = SHJK2V3AJG;
|
||||
@@ -2028,7 +2021,6 @@
|
||||
849C645F1ED37A5D003D8FC0 /* NetNewsWire */,
|
||||
849C64701ED37A5D003D8FC0 /* NetNewsWireTests */,
|
||||
840D617B2029031C009BC708 /* NetNewsWire-iOS */,
|
||||
840D61902029031D009BC708 /* NetNewsWire-iOSTests */,
|
||||
6581C73220CED60000F4AD34 /* Subscribe to Feed */,
|
||||
);
|
||||
};
|
||||
@@ -2209,13 +2201,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
840D618F2029031D009BC708 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
849C645E1ED37A5D003D8FC0 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -2336,14 +2321,19 @@
|
||||
51F85BF52273625800C787DC /* Bundle-Extensions.swift in Sources */,
|
||||
51C452A622650A3500C03939 /* Node-Extensions.swift in Sources */,
|
||||
5183CCDF226F1FCC0010922C /* UINavigationController+Progress.swift in Sources */,
|
||||
510D707422B028E1004E8F65 /* SettingsAddAccountView.swift in Sources */,
|
||||
51C45294226509C800C03939 /* SearchFeedDelegate.swift in Sources */,
|
||||
512E09352268B25900BDCFDD /* UISplitViewController-Extensions.swift in Sources */,
|
||||
51F772F622B279570087D9D1 /* SettingsDetailAccountView.swift in Sources */,
|
||||
510D707E22B02A4B004E8F65 /* SettingsLocalAccountView.swift in Sources */,
|
||||
51C452A022650A1900C03939 /* FeedIconDownloader.swift in Sources */,
|
||||
51F85BE7227245FC00C787DC /* AboutViewController.swift in Sources */,
|
||||
5154368A2291FED9005E1CDF /* FeedbinAccountViewController.swift in Sources */,
|
||||
510D708222B041CC004E8F65 /* SettingsAccountLabelView.swift in Sources */,
|
||||
51C4529E22650A1900C03939 /* ImageDownloader.swift in Sources */,
|
||||
51C45292226509C800C03939 /* TodayFeedDelegate.swift in Sources */,
|
||||
51C452A222650A1900C03939 /* RSHTMLMetadata+Extension.swift in Sources */,
|
||||
51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */,
|
||||
5183CCE5226F4DFA0010922C /* RefreshInterval.swift in Sources */,
|
||||
51EF0F7C2277919E0050506E /* TimelineNumberOfLinesViewController.swift in Sources */,
|
||||
51C4529D22650A1000C03939 /* FaviconURLFinder.swift in Sources */,
|
||||
@@ -2354,6 +2344,7 @@
|
||||
51C4526A226508F600C03939 /* MasterFeedTableViewCellLayout.swift in Sources */,
|
||||
51C452AE2265104D00C03939 /* TimelineStringFormatter.swift in Sources */,
|
||||
512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */,
|
||||
51F35D0922AFD4760003CE1B /* SettingsView.swift in Sources */,
|
||||
51543685228F6753005E1CDF /* DetailAccountViewController.swift in Sources */,
|
||||
51C4529922650A0000C03939 /* ArticleStylesManager.swift in Sources */,
|
||||
51EF0F802277A8330050506E /* MasterTimelineCellLayout.swift in Sources */,
|
||||
@@ -2386,14 +2377,7 @@
|
||||
51C452782265091600C03939 /* MasterTimelineCellData.swift in Sources */,
|
||||
51C45259226508D300C03939 /* AppDefaults.swift in Sources */,
|
||||
51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
840D618D2029031D009BC708 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
840D61962029031D009BC708 /* NetNewsWire_iOSTests.swift in Sources */,
|
||||
510D708022B02A5F004E8F65 /* SettingsFeedbinAccountView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -2458,9 +2442,10 @@
|
||||
849A97791ED9EC04007D329B /* TimelineStringFormatter.swift in Sources */,
|
||||
84E185C3203BB12600F69BFA /* MultilineTextFieldSizer.swift in Sources */,
|
||||
8477ACBE22238E9500DF7F37 /* SearchFeedDelegate.swift in Sources */,
|
||||
51E3EB33229AB02C00645299 /* ErrorHandler.swift in Sources */,
|
||||
8472058120142E8900AD578B /* FeedInspectorViewController.swift in Sources */,
|
||||
5144EA382279FC6200D19003 /* AccountsAddLocalWindowController.swift in Sources */,
|
||||
84AD1EAA2031617300BC20B7 /* FolderPasteboardWriter.swift in Sources */,
|
||||
84AD1EAA2031617300BC20B7 /* PasteboardFolder.swift in Sources */,
|
||||
5144EA51227B8E4500D19003 /* AccountsFeedbinWindowController.swift in Sources */,
|
||||
84AD1EBC2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift in Sources */,
|
||||
845A29241FC9255E007B49E3 /* SidebarCellAppearance.swift in Sources */,
|
||||
@@ -2616,11 +2601,6 @@
|
||||
name = Account;
|
||||
targetProxy = 51C451FA2264C83E00C03939 /* PBXContainerItemProxy */;
|
||||
};
|
||||
840D61932029031D009BC708 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 840D617B2029031C009BC708 /* NetNewsWire-iOS */;
|
||||
targetProxy = 840D61922029031D009BC708 /* PBXContainerItemProxy */;
|
||||
};
|
||||
849C64731ED37A5D003D8FC0 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 849C645F1ED37A5D003D8FC0 /* NetNewsWire */;
|
||||
@@ -2815,7 +2795,7 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
INFOPLIST_FILE = iOS/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.2;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -2878,7 +2858,7 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
INFOPLIST_FILE = iOS/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.2;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.iOS";
|
||||
@@ -2890,137 +2870,6 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
840D61A72029031E009BC708 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
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 = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
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;
|
||||
INFOPLIST_FILE = "NetNewsWire-iOSTests/Info.plist";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.iOSTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NetNewsWire-iOS.app/NetNewsWire-iOS";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
840D61A82029031E009BC708 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
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 = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
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;
|
||||
INFOPLIST_FILE = "NetNewsWire-iOSTests/Info.plist";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.iOSTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NetNewsWire-iOS.app/NetNewsWire-iOS";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
849C64781ED37A5D003D8FC0 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = D5907CDD2002F0BE005947E5 /* NetNewsWire_project_debug.xcconfig */;
|
||||
@@ -3105,15 +2954,6 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
840D61A62029031E009BC708 /* Build configuration list for PBXNativeTarget "NetNewsWire-iOSTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
840D61A72029031E009BC708 /* Debug */,
|
||||
840D61A82029031E009BC708 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
849C645B1ED37A5D003D8FC0 /* Build configuration list for PBXProject "NetNewsWire" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "849C645F1ED37A5D003D8FC0"
|
||||
BuildableName = "NetNewsWire.app"
|
||||
BlueprintName = "NetNewsWire"
|
||||
ReferencedContainer = "container:NetNewsWire.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
@@ -39,17 +48,6 @@
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "849C645F1ED37A5D003D8FC0"
|
||||
BuildableName = "NetNewsWire.app"
|
||||
BlueprintName = "NetNewsWire"
|
||||
ReferencedContainer = "container:NetNewsWire.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<AdditionalOptions>
|
||||
</AdditionalOptions>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -63,6 +61,7 @@
|
||||
stopOnEveryThreadSanitizerIssue = "YES"
|
||||
stopOnEveryUBSanitizerIssue = "YES"
|
||||
stopOnEveryMainThreadCheckerIssue = "YES"
|
||||
migratedStopOnEveryIssue = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
@@ -75,8 +74,6 @@
|
||||
ReferencedContainer = "container:NetNewsWire.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<AdditionalOptions>
|
||||
</AdditionalOptions>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
49
README.md
@@ -1,8 +1,10 @@
|
||||
# NetNewsWire
|
||||
#  NetNewsWire
|
||||
|
||||
[](https://circleci.com/gh/brentsimmons/NetNewsWire)
|
||||
|
||||
It’s a free and open source feed reader for macOS.
|
||||
|
||||
It’s not in beta yet. Not even alpha! While NetNewsWire 5.0 is feature-complete as of May 25, 2019, it has known bugs — and, surely, plenty of unknown bugs.
|
||||
It’s not in beta just yet. Getting close! While NetNewsWire 5.0 is feature-complete as of May 25, 2019, it has known bugs — and, surely, plenty of unknown bugs.
|
||||
|
||||
It supports [RSS](http://cyber.harvard.edu/rss/rss.html), [Atom](https://tools.ietf.org/html/rfc4287), [JSON Feed](https://jsonfeed.org/), and [RSS-in-JSON](https://github.com/scripting/Scripting-News/blob/master/rss-in-json/README.md) formats.
|
||||
|
||||
@@ -12,16 +14,49 @@ Also see the [Technotes](Technotes/) and the [Roadmap](Technotes/Roadmap.md).
|
||||
|
||||
Note: NetNewsWire’s Help menu has a bunch of these links, so you don’t have to remember to come back to this page.
|
||||
|
||||
Here’s [How to Support NetNewsWire](Technotes/HowToSupportNetNewsWire.markdown). Spoiler: don’t send money. :)
|
||||
|
||||
#### Community
|
||||
|
||||
[Join the Slack group](https://join.slack.com/t/netnewswire/shared_invite/enQtNjM4MDA1MjQzMDkzLTNlNjBhOWVhYzdhYjA4ZWFhMzQ1MTUxYjU0NTE5ZGY0YzYwZWJhNjYwNTNmNTg2NjIwYWY4YzhlYzk5NmU3ZTc) to talk with other NetNewsWire users — and to help out, if you’d like to, by testing, coding, writing, providing feedback, or just helping us think things through. Everybody is welcome and encouraged to join.
|
||||
|
||||
#### On accepting pull requests
|
||||
Every community member is expected to abide by the code of conduct which is included in the [Contributing](Contributing.md) page.
|
||||
|
||||
It’s pretty early still, and we have strong opinions about how we want to do things, so we’re not seeking help just yet.
|
||||
#### Pull Requests
|
||||
|
||||
That said, we will seriously consider any pull requests we do get. Just note that we may not accept them, or we may accept them and do a bunch of revision.
|
||||
See the [Contributing](Contributing.md) page for our process. It’s pretty straightforward.
|
||||
|
||||
It’s probably a good idea to let us know first what you’d like to do. The best place for that is definitely the [Slack group](https://join.slack.com/t/netnewswire/shared_invite/enQtNjM4MDA1MjQzMDkzLTNlNjBhOWVhYzdhYjA4ZWFhMzQ1MTUxYjU0NTE5ZGY0YzYwZWJhNjYwNTNmNTg2NjIwYWY4YzhlYzk5NmU3ZTc).
|
||||
#### Building
|
||||
|
||||
We do plan to add more and more contributors over time. Totally. But we’re taking it slow as we learn how to manage an open source project.
|
||||
```bash
|
||||
git clone https://github.com/brentsimmons/NetNewsWire.git
|
||||
cd NetNewsWire
|
||||
git submodule update --init
|
||||
```
|
||||
|
||||
You can locally override the Xcode settings for code signing
|
||||
by creating a `DeveloperSettings.xcconfig` file locally at the appropriate path.
|
||||
This allows for a pristine project with code signing set up with the appropriate
|
||||
developer ID and certificates, and for dev to be able to have local settings
|
||||
without needing to check in anything into source control.
|
||||
|
||||
As an example, make a `../../SharedXcodeSettings/DeveloperSettings.xcconfig` file and
|
||||
give it the contents
|
||||
|
||||
```
|
||||
CODE_SIGN_IDENTITY = Mac Developer
|
||||
DEVELOPMENT_TEAM = <Your Team ID>
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
PROVISIONING_PROFILE_SPECIFIER =
|
||||
```
|
||||
|
||||
Now you should be able to build without code signing errors and without modifying
|
||||
the NetNewsWire Xcode project.
|
||||
|
||||
Example:
|
||||
|
||||
If your NetNewsWire Xcode project file is at:
|
||||
`/Users/Shared/git/NetNewsWire/NetNewsWire.xcodeproj`
|
||||
|
||||
Create your `DeveloperSettings.xcconfig` file at
|
||||
`/Users/Shared/git/SharedXcodeSettings/DeveloperSettings.xcconfig`
|
||||
|
||||
@@ -86,10 +86,11 @@ private extension ArticleRenderer {
|
||||
}
|
||||
|
||||
func titleOrTitleLink() -> String {
|
||||
let escapedTitle = title.escapeHTML()
|
||||
if let link = article?.preferredLink {
|
||||
return title.htmlByAddingLink(link)
|
||||
return escapedTitle.htmlByAddingLink(link)
|
||||
}
|
||||
return title
|
||||
return escapedTitle
|
||||
}
|
||||
|
||||
func substitutions() -> [String: String] {
|
||||
|
||||
@@ -46,12 +46,30 @@ final class DeleteCommand: UndoableCommand {
|
||||
func perform() {
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
itemSpecifiers.forEach { $0.delete() }
|
||||
itemSpecifiers.forEach { $0.delete() {} }
|
||||
treeController.rebuild()
|
||||
}
|
||||
registerUndo()
|
||||
}
|
||||
|
||||
func perform(completion: @escaping () -> Void) {
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter()
|
||||
itemSpecifiers.forEach {
|
||||
$0.delete() {
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
treeController.rebuild()
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
self.registerUndo()
|
||||
completion()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func undo() {
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
@@ -132,18 +150,20 @@ private struct SidebarItemSpecifier {
|
||||
self.path = ContainerPath(account: account!, folders: node.containingFolders())
|
||||
}
|
||||
|
||||
func delete() {
|
||||
func delete(completion: @escaping () -> Void) {
|
||||
|
||||
if let feed = feed {
|
||||
BatchUpdate.shared.start()
|
||||
account?.deleteFeed(feed) { result in
|
||||
account?.removeFeed(feed, from: path.resolveContainer()) { result in
|
||||
BatchUpdate.shared.end()
|
||||
completion()
|
||||
self.checkResult(result)
|
||||
}
|
||||
} else if let folder = folder {
|
||||
BatchUpdate.shared.start()
|
||||
account?.deleteFolder(folder) { result in
|
||||
account?.removeFolder(folder) { result in
|
||||
BatchUpdate.shared.end()
|
||||
completion()
|
||||
self.checkResult(result)
|
||||
}
|
||||
}
|
||||
@@ -161,12 +181,12 @@ private struct SidebarItemSpecifier {
|
||||
|
||||
private func restoreFeed() {
|
||||
|
||||
guard let account = account, let feed = feed else {
|
||||
guard let account = account, let feed = feed, let container = path.resolveContainer() else {
|
||||
return
|
||||
}
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
account.restoreFeed(feed, folder: resolvedFolder()) { result in
|
||||
account.restoreFeed(feed, container: container) { result in
|
||||
BatchUpdate.shared.end()
|
||||
self.checkResult(result)
|
||||
}
|
||||
@@ -187,10 +207,6 @@ private struct SidebarItemSpecifier {
|
||||
|
||||
}
|
||||
|
||||
private func resolvedFolder() -> Folder? {
|
||||
return path.resolveContainer() as? Folder
|
||||
}
|
||||
|
||||
private func checkResult(_ result: Result<Void, Error>) {
|
||||
|
||||
switch result {
|
||||
|
||||
@@ -73,7 +73,7 @@ class AccountRefreshTimer {
|
||||
lastTimedRefresh = Date()
|
||||
update()
|
||||
|
||||
AccountManager.shared.refreshAll()
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum RefreshInterval: Int {
|
||||
enum RefreshInterval: Int, CaseIterable {
|
||||
case manually = 1
|
||||
case every10Minutes = 2
|
||||
case every30Minutes = 3
|
||||
|
||||
32
Technotes/BranchingStrategy.md
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
# NetNewsWire Branching Strategy
|
||||
|
||||
The main repository for NetNewsWire utilizes a [Trunk Based Development](https://trunkbaseddevelopment.com) branching strategy. This branching strategy is a variant of [Three-Flow](https://www.nomachetejuggling.com/2017/04/09/a-different-branching-strategy/).
|
||||
|
||||
## Three-Flow
|
||||
|
||||
Three-Flow uses 3 branches to facilitate development, stabilize a release, and manage production hotfixes. Development happens on Master and moves to a branch called Candidate when it is ready to be stabilized. New feature development continues on Master and bug fixes to the release candidate happen on Candidate. When the product is released, it is pushed to the Release branch. Hotfixes can happen on the Release branch. Candidate is now free to be reused to stabilize the next release. All bugs found and fixed are back merged to Candidate and then Master respectively.
|
||||
|
||||

|
||||
|
||||
All arrows going up are promotions (pushes) to the next environment. All arrows going down are back ports of bugfixes.
|
||||
|
||||
That is Three-Flow applied to NetNewsWire. It would be that simple, but we have two products we are going to deliver from the same repository. The iOS and the macOS variants of NetNewsWire. To stabilize and manage both variants, each will need to be given their own Candidate and Release branches.
|
||||
|
||||

|
||||
|
||||
Today (6/12/2019) we have 2 branches, master and macOS Candidate, in the main repository which will eventually grow to be 5 branches.
|
||||
|
||||
There will also be a number of repository forks that NetNewWire developers will create to do bug fixes and implement new features (not shown here). Typically contributers will fork the Master branch to thier own repository. They would then create a feature/bugfix branch on their repository. Once work on thier forked branch is complete, they will submit a pull request to be merged back into the main repository master.
|
||||
|
||||
## Tagging
|
||||
|
||||
Each release should be tagged using [Semantic Versioning](https://semver.org/). Candidates will continue to be tagged using the current convention which denotes the difference between developer, alpha and beta releases. Additionally, we will need to use a convention to avoid tag name collisions between iOS and macOS products. macOS will use even minor release numbers and iOS will use odd minor release numbers. (See the above diagram for examples.)
|
||||
|
||||
## Submodules
|
||||
|
||||
NetNewsWire uses Git submodules to manage project dependencies. All the submodules are under the same project umbrella as NetNewWire and there are no third party dependencies to manage. These submodules are mostly stable at this point. For simplicity sake, all development on the submodules will continue on their repository Master branch. These submodules won’t be managed as separate projects with separate releases/tags at this time.
|
||||
|
||||
## Summary
|
||||
|
||||
There are 3 types of branches: Master, Candidate, and Release. All feature development happens on Master. Stabilization happens on Candidate. Hotfixes happen on Release. Each product gets its own Candidate and Release branches. All candidates and releases get tagged.
|
||||
30
Technotes/ContinuousIntegration.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# NetNewsWire Continuous Integration
|
||||
|
||||
CI for NetNewsWire is enabled through CircleCI, hosted at
|
||||
<https://circleci.com/gh/brentsimmons/NetNewsWire>. The CI configuration (hosted in
|
||||
[`.circleci/config.yml`](https://github.com/brentsimmons/NetNewsWire/blob/master/.circleci/config.yml)
|
||||
uses `xcodebuild` to build the project after syncing the repository and
|
||||
the various submodules.
|
||||
|
||||
As of June 2019, CircleCI offered Xcode 10.2.1, so IOS 13 and Catalina support are not available
|
||||
via CI as yet.
|
||||
|
||||
The build itself focuses on the scheme NetNewsWire and leverages the
|
||||
`NetNewsWire.xcworkspace` configuration.
|
||||
|
||||
Each submodule also has it's own CI configuration, which are set up and built from
|
||||
their own repositories. The submodule CI systems are entirely independent so that
|
||||
those libraries can grow and change, getting CI verification, indepdent of NetNewsWire.
|
||||
|
||||
The submodule CI are typically set to run a build and any available tests. Refer to the
|
||||
project repository for the current and complete list of submodules, but for quick reference:
|
||||
|
||||
- [RSCore](https://github.com/brentsimmons/RSCore) [](https://circleci.com/gh/brentsimmons/RSCore)
|
||||
|
||||
- [RSWeb](https://github.com/brentsimmons/RSWeb) [](https://circleci.com/gh/brentsimmons/RSWeb)
|
||||
|
||||
- [RSParser](https://github.com/brentsimmons/RSParser) [](https://circleci.com/gh/brentsimmons/RSParser)
|
||||
|
||||
- [RSTree](https://github.com/brentsimmons/RSTree) [](https://circleci.com/gh/brentsimmons/RSTree)
|
||||
|
||||
- [RSDatabase](https://github.com/brentsimmons/RSDatabase) [](https://circleci.com/gh/brentsimmons/RSDatabase)
|
||||
43
Technotes/HowToSupportNetNewsWire.markdown
Normal file
@@ -0,0 +1,43 @@
|
||||
# How to Support NetNewsWire
|
||||
|
||||
First thing: don’t send money. This app is [written for love](https://inessential.com/2015/06/30/love), not money. :)
|
||||
|
||||
NetNewsWire is all about three things:
|
||||
|
||||
* The open web
|
||||
* High-quality open source Mac and iOS apps
|
||||
* The community that loves both of the above
|
||||
|
||||
Supporting all these things takes *work*.
|
||||
|
||||
### Here are some things you can do
|
||||
|
||||
In no particular order…
|
||||
|
||||
Write a blog instead of posting to Twitter or Facebook. (You can always re-post to those places if you want to extend your reach.) [Micro.blog](https://micro.blog/) is one good place to get going, but it’s not the only one.
|
||||
|
||||
Use an RSS reader even if it’s not NetNewsWire. (There are a bunch of good ones!)
|
||||
|
||||
Teach other people to use RSS readers. Blog about RSS readers. And about other open web technologies and apps.
|
||||
|
||||
Suggest apps for [macopenweb.com](https://macopenweb.com/).
|
||||
|
||||
Write Mac and iOS apps that promote use of the open web.
|
||||
|
||||
Donate to charities that promote literacy.
|
||||
|
||||
Tell other people about cool blogs and feeds you’ve found.
|
||||
|
||||
Support indie podcast apps.
|
||||
|
||||
Vote for candidates whose policies are not cruel.
|
||||
|
||||
Support your local library.
|
||||
|
||||
Be bold and do your best work.
|
||||
|
||||
Support indie developers — pay for apps that cost money. Even though NetNewsWire is free, apps are most definitely *not* free to make, and it costs money to keep improving them. It’s worth it.
|
||||
|
||||
Finally: report bugs and make feature requests on our Issues tracker. You can also join the Slack group — it’s not just for coders. We also need testers, writers, and, especially, people who are willing to talk things over. Most of software development is just making decisions, and we appreciate all the help we can get!
|
||||
|
||||
Or: skip helping us, and, instead, help people who need help more than we do. Those people should not be hard to find.
|
||||
BIN
Technotes/Images/Branching-Full.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
Technotes/Images/Branching.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
Technotes/Images/icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
@@ -10,8 +10,8 @@ Note: you can delete multiple feeds, and you can delete folders. You can also un
|
||||
|
||||
Since NetNewsWire is a nights-and-weekends project, we don’t have enough time to run and test on older versions of macOS. Most of the time it will require the most recent macOS.
|
||||
|
||||
#### Why is FeedBin syncing planned for 1.0 but _____ isn’t planned until 2.0?
|
||||
#### Why is Feedbin syncing planned for 1.0 but _____ isn’t planned until 2.0?
|
||||
|
||||
This was a difficult decision. We didn’t want to ship with no syncing at all, but we also didn’t want to delay shipping until we’ve done a whole bunch of systems.
|
||||
|
||||
So we chose FeedBin, since that’s what we use, and since the folks at FeedBin have been friendly and helpful.
|
||||
So we chose Feedbin, since that’s what we use, and since the folks at Feedbin have been friendly and helpful.
|
||||
|
||||
@@ -16,4 +16,8 @@
|
||||
|
||||
## Contributing
|
||||
|
||||
[Contributing](../CONTRIBUTING.md)
|
||||
|
||||
[Coding Guidelines](CodingGuidelines.md)
|
||||
|
||||
[Branching Strategy](BranchingStrategy.md)
|
||||
|
||||
39
Technotes/Reruns.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Why Reruns Happen
|
||||
|
||||
Sometimes you might see a new article in a feed that you’d swear you’ve already read. And maybe you can even see, in NetNewsWire, what looks like another copy of that same exact article, with no changes.
|
||||
|
||||
Here’s the thing to know: if the article really was the exact same in every respect, NetNewsWire would see that. It’s super-easy for a computer to tell that some data is the exact same as some other data.
|
||||
|
||||
When it’s not really the exact same, that’s where the problem comes in.
|
||||
|
||||
Here are some reasons this situation can happen:
|
||||
|
||||
## A blog changes its blog engine
|
||||
|
||||
If someone switches from (for instance) Ghost to WordPress, then the code that creates that feeds will be different. And that code will make a different choice for the unique ID for each article in the feed.
|
||||
|
||||
Those unique IDs are critical: they’re how NetNewsWire identifies an article. If an article appears with a new unique ID, then NetNewsWire treats it like a new article.
|
||||
|
||||
In this situation, you’ll often see that you get a bunch of reruns for a given feed all at once. You’ll get 10 or 20 or whatever.
|
||||
|
||||
This is by far the most common cause of reruns.
|
||||
|
||||
## A feed that lacks unique IDs does something weird
|
||||
|
||||
This is quite a bit less common. There are some feeds that don’t have unique IDs, which means NetNewsWire has to use some combination of other article metadata to identify articles.
|
||||
|
||||
That metadata could change just enough to throw NetNewsWire off. This is rare, but it can happen.
|
||||
|
||||
## A feed just has terrible bugs
|
||||
|
||||
We’ve seen feeds that create a different unique ID for each article every time you fetch the feed, which results in reruns every single time. We’ve seen feeds that use the same unique ID for every article in the feed, even — which goes against the very idea of unique IDs!
|
||||
|
||||
Some feeds just have bugs, and weird, unpredictable things happen.
|
||||
|
||||
NetNewsWire is designed to be resistant to that, and it does a good job — but we haven’t anticipated every odd case.
|
||||
|
||||
However, this is the most rare cause of reruns. The most common cause is, by far, the first one: the feed is now being generated by different software.
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
If you have a feed that keeps showing reruns (as opposed to once, when a blog changes its blogging system), please do report a bug, either on our [Issues Tracker](https://github.com/brentsimmons/NetNewsWire/issues) or on the [Slack group](https://join.slack.com/t/netnewswire/shared_invite/enQtNjM4MDA1MjQzMDkzLTNlNjBhOWVhYzdhYjA4ZWFhMzQ1MTUxYjU0NTE5ZGY0YzYwZWJhNjYwNTNmNTg2NjIwYWY4YzhlYzk5NmU3ZTc).
|
||||
@@ -9,7 +9,7 @@ This roadmap reflects thinking at the time of the last update. Anything can chan
|
||||
Features:
|
||||
|
||||
* Standalone feed reading (unsynced)
|
||||
* Syncing via FeedBin
|
||||
* Syncing via Feedbin
|
||||
* Built-in smart feeds (today, starred, all unread)
|
||||
* Searching
|
||||
* Starring
|
||||
|
||||
@@ -80,13 +80,10 @@ class AddFeedViewController: UITableViewController, AddContainerViewControllerCh
|
||||
let container = pickerData.containers[folderPickerView.selectedRow(inComponent: 0)]
|
||||
|
||||
var account: Account?
|
||||
var folder: Folder?
|
||||
if let containerAccount = container as? Account {
|
||||
account = containerAccount
|
||||
}
|
||||
if let containerFolder = container as? Folder, let containerAccount = containerFolder.account {
|
||||
} else if let containerFolder = container as? Folder, let containerAccount = containerFolder.account {
|
||||
account = containerAccount
|
||||
folder = containerFolder
|
||||
}
|
||||
|
||||
if account!.hasFeed(withURL: url.absoluteString) {
|
||||
@@ -94,26 +91,28 @@ class AddFeedViewController: UITableViewController, AddContainerViewControllerCh
|
||||
return
|
||||
}
|
||||
|
||||
let title = nameTextField.text
|
||||
|
||||
delegate?.processingDidBegin()
|
||||
BatchUpdate.shared.start()
|
||||
|
||||
account!.createFeed(url: url.absoluteString, name: nameTextField.text, container: container) { result in
|
||||
|
||||
account!.createFeed(url: url.absoluteString) { [weak self] result in
|
||||
BatchUpdate.shared.end()
|
||||
|
||||
switch result {
|
||||
case .success(let feed):
|
||||
self?.processFeed(feed, account: account!, folder: folder, url: url, title: title)
|
||||
self.delegate?.processingDidEnd()
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
|
||||
case .failure(let error):
|
||||
switch error {
|
||||
case AccountError.createErrorAlreadySubscribed:
|
||||
self?.showAlreadySubscribedError()
|
||||
self?.delegate?.processingDidCancel()
|
||||
self.showAlreadySubscribedError()
|
||||
self.delegate?.processingDidCancel()
|
||||
case AccountError.createErrorNotFound:
|
||||
self?.showNoFeedsErrorMessage()
|
||||
self?.delegate?.processingDidCancel()
|
||||
self.showNoFeedsErrorMessage()
|
||||
self.delegate?.processingDidCancel()
|
||||
default:
|
||||
self?.presentError(error)
|
||||
self?.delegate?.processingDidCancel()
|
||||
self.presentError(error)
|
||||
self.delegate?.processingDidCancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,45 +177,6 @@ private extension AddFeedViewController {
|
||||
presentError(title: title, message: message as String)
|
||||
}
|
||||
|
||||
func processFeed(_ feed: Feed, account: Account, folder: Folder?, url: URL, title: String?) {
|
||||
|
||||
if let title = title {
|
||||
account.renameFeed(feed, to: title) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
self?.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let folder = folder {
|
||||
folder.addFeed(feed) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.delegate?.processingDidEnd()
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
|
||||
case .failure(let error):
|
||||
self?.delegate?.processingDidEnd()
|
||||
self?.presentError(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
account.addFeed(feed) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.delegate?.processingDidEnd()
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
|
||||
case .failure(let error):
|
||||
self?.delegate?.processingDidEnd()
|
||||
self?.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AddFeedViewController: UITextFieldDelegate {
|
||||
|
||||
@@ -64,7 +64,7 @@ struct AppDefaults {
|
||||
}
|
||||
|
||||
static func registerDefaults() {
|
||||
let defaults: [String : Any] = [Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, Key.refreshInterval: RefreshInterval.everyHour.rawValue, Key.timelineNumberOfLines: 2]
|
||||
let defaults: [String : Any] = [Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, Key.refreshInterval: RefreshInterval.everyHour.rawValue, Key.timelineNumberOfLines: 3]
|
||||
UserDefaults.standard.register(defaults: defaults)
|
||||
}
|
||||
|
||||
|
||||
@@ -175,10 +175,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDele
|
||||
// If we haven't refreshed the database for 15 minutes, run a refresh automatically
|
||||
if let lastRefresh = AppDefaults.lastRefresh {
|
||||
if Date() > lastRefresh.addingTimeInterval(15 * 60) {
|
||||
AccountManager.shared.refreshAll()
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present)
|
||||
}
|
||||
} else {
|
||||
AccountManager.shared.refreshAll()
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -222,7 +222,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDele
|
||||
startingUnreadCount = self.unreadCount
|
||||
|
||||
DispatchQueue.main.async {
|
||||
AccountManager.shared.refreshAll()
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
|
||||
}
|
||||
|
||||
os_log("Accounts requested to begin refresh.", log: self.log, type: .debug)
|
||||
|
||||
25
iOS/ErrorHandler.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// ErrorHandler.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 5/26/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import RSCore
|
||||
import os.log
|
||||
|
||||
struct ErrorHandler {
|
||||
|
||||
private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Account")
|
||||
|
||||
public static func present(_ error: Error) {
|
||||
UIApplication.shared.presentError(error)
|
||||
}
|
||||
|
||||
public static func log(_ error: Error) {
|
||||
os_log(.error, log: self.log, "%@", error.localizedDescription)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import Account
|
||||
import Articles
|
||||
import RSCore
|
||||
import RSTree
|
||||
import SwiftUI
|
||||
|
||||
class MasterFeedViewController: ProgressTableViewController, UndoableCommandRunner {
|
||||
|
||||
@@ -373,22 +374,17 @@ class MasterFeedViewController: ProgressTableViewController, UndoableCommandRunn
|
||||
}()
|
||||
|
||||
// Move the Feed
|
||||
let source = sourceNode.parent?.representedObject as? Container
|
||||
let destination = destParentNode?.representedObject as? Container
|
||||
source?.removeFeed(feed) { [weak self] result in
|
||||
guard let source = sourceNode.parent?.representedObject as? Container, let destination = destParentNode?.representedObject as? Container else {
|
||||
return
|
||||
}
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
source.account?.moveFeed(feed, from: source, to: destination) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
destination?.addFeed(feed) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
source?.addFeed(feed) { result in }
|
||||
self?.presentError(error)
|
||||
}
|
||||
}
|
||||
BatchUpdate.shared.end()
|
||||
case .failure(let error):
|
||||
self?.presentError(error)
|
||||
self.presentError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,13 +394,8 @@ class MasterFeedViewController: ProgressTableViewController, UndoableCommandRunn
|
||||
|
||||
@IBAction func settings(_ sender: UIBarButtonItem) {
|
||||
|
||||
let settingsNavViewController = UIStoryboard.settings.instantiateInitialViewController() as! UINavigationController
|
||||
settingsNavViewController.modalPresentationStyle = .formSheet
|
||||
|
||||
let settingsViewController = settingsNavViewController.topViewController as! SettingsViewController
|
||||
settingsViewController.presentingParentController = self
|
||||
|
||||
self.present(settingsNavViewController, animated: true)
|
||||
let settings = UIHostingController(rootView: SettingsView(viewModel: SettingsView.ViewModel()))
|
||||
self.present(settings, animated: true)
|
||||
|
||||
}
|
||||
|
||||
@@ -531,14 +522,23 @@ class MasterFeedViewController: ProgressTableViewController, UndoableCommandRunn
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
navState.beginUpdates()
|
||||
|
||||
runCommand(deleteCommand)
|
||||
navState.rebuildShadowTable()
|
||||
tableView.deleteRows(at: [indexPath], with: .automatic)
|
||||
var deleteIndexPaths = [indexPath]
|
||||
if navState.isExpanded(deleteNode) {
|
||||
for i in 0..<deleteNode.numberOfChildNodes {
|
||||
deleteIndexPaths.append(IndexPath(row: indexPath.row + 1 + i, section: indexPath.section))
|
||||
}
|
||||
}
|
||||
|
||||
navState.endUpdates()
|
||||
pushUndoableCommand(deleteCommand)
|
||||
|
||||
navState.beginUpdates()
|
||||
deleteCommand.perform {
|
||||
self.navState.treeController.rebuild()
|
||||
self.navState.rebuildShadowTable()
|
||||
self.tableView.deleteRows(at: deleteIndexPaths, with: .automatic)
|
||||
self.navState.endUpdates()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -603,7 +603,7 @@ extension MasterFeedViewController: MasterFeedTableViewCellDelegate {
|
||||
private extension MasterFeedViewController {
|
||||
|
||||
@objc private func refreshAccounts(_ sender: Any) {
|
||||
AccountManager.shared.refreshAll()
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present)
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ extension MasterTimelineCellLayout {
|
||||
var r = CGRect.zero
|
||||
r.size = CGSize(width: MasterTimelineDefaultCellLayout.unreadCircleDimension, height: MasterTimelineDefaultCellLayout.unreadCircleDimension)
|
||||
r.origin.x = point.x
|
||||
r.origin.y = point.y + 9
|
||||
r.origin.y = point.y + 4
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -38,14 +38,15 @@ extension MasterTimelineCellLayout {
|
||||
r.size.width = MasterTimelineDefaultCellLayout.starDimension
|
||||
r.size.height = MasterTimelineDefaultCellLayout.starDimension
|
||||
r.origin.x = floor(point.x - ((MasterTimelineDefaultCellLayout.starDimension - MasterTimelineDefaultCellLayout.unreadCircleDimension) / 2.0))
|
||||
r.origin.y = point.y + 5
|
||||
r.origin.y = point.y + 2
|
||||
return r
|
||||
}
|
||||
|
||||
static func rectForAvatar(_ point: CGPoint) -> CGRect {
|
||||
var r = CGRect.zero
|
||||
r.size = MasterTimelineDefaultCellLayout.avatarSize
|
||||
r.origin = point
|
||||
r.origin.x = point.x
|
||||
r.origin.y = point.y + 4
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ import RSCore
|
||||
|
||||
struct MasterTimelineDefaultCellLayout: MasterTimelineCellLayout {
|
||||
|
||||
static let cellPadding = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
|
||||
static let cellPadding = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 16)
|
||||
|
||||
static let unreadCircleMarginLeft = CGFloat(integerLiteral: 8)
|
||||
static let unreadCircleDimension = CGFloat(integerLiteral: 8)
|
||||
static let unreadCircleMarginLeft = CGFloat(integerLiteral: 0)
|
||||
static let unreadCircleDimension = CGFloat(integerLiteral: 12)
|
||||
static let unreadCircleMarginRight = CGFloat(integerLiteral: 8)
|
||||
|
||||
static let starDimension = CGFloat(integerLiteral: 13)
|
||||
static let starDimension = CGFloat(integerLiteral: 16)
|
||||
|
||||
static let avatarSize = CGSize(width: 48.0, height: 48.0)
|
||||
static let avatarMarginRight = CGFloat(integerLiteral: 8)
|
||||
|
||||
@@ -17,8 +17,6 @@ class MasterTimelineTableViewCell: UITableViewCell {
|
||||
private let dateView = MasterTimelineTableViewCell.singleLineUILabel()
|
||||
private let feedNameView = MasterTimelineTableViewCell.singleLineUILabel()
|
||||
|
||||
private var layout: MasterTimelineCellLayout?
|
||||
|
||||
private lazy var avatarImageView: UIImageView = {
|
||||
let imageView = NonIntrinsicImageView(image: AppAssets.feedImage)
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
@@ -47,29 +45,25 @@ class MasterTimelineTableViewCell: UITableViewCell {
|
||||
}
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
if layout == nil {
|
||||
layout = updatedLayout()
|
||||
}
|
||||
return CGSize(width: bounds.width, height: layout!.height)
|
||||
let layout = updatedLayout(width: size.width)
|
||||
return CGSize(width: size.width, height: layout.height)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
|
||||
super.layoutSubviews()
|
||||
|
||||
if layout == nil {
|
||||
layout = updatedLayout()
|
||||
}
|
||||
let layout = updatedLayout(width: bounds.width)
|
||||
|
||||
unreadIndicatorView.setFrameIfNotEqual(layout!.unreadIndicatorRect)
|
||||
starView.setFrameIfNotEqual(layout!.starRect)
|
||||
avatarImageView.setFrameIfNotEqual(layout!.avatarImageRect)
|
||||
setFrame(for: titleView, rect: layout!.titleRect)
|
||||
setFrame(for: summaryView, rect: layout!.summaryRect)
|
||||
feedNameView.setFrameIfNotEqual(layout!.feedNameRect)
|
||||
dateView.setFrameIfNotEqual(layout!.dateRect)
|
||||
unreadIndicatorView.setFrameIfNotEqual(layout.unreadIndicatorRect)
|
||||
starView.setFrameIfNotEqual(layout.starRect)
|
||||
avatarImageView.setFrameIfNotEqual(layout.avatarImageRect)
|
||||
setFrame(for: titleView, rect: layout.titleRect)
|
||||
setFrame(for: summaryView, rect: layout.summaryRect)
|
||||
feedNameView.setFrameIfNotEqual(layout.feedNameRect)
|
||||
dateView.setFrameIfNotEqual(layout.dateRect)
|
||||
|
||||
separatorInset = layout!.separatorInsets
|
||||
separatorInset = layout.separatorInsets
|
||||
|
||||
}
|
||||
|
||||
@@ -137,11 +131,11 @@ private extension MasterTimelineTableViewCell {
|
||||
accessoryView = UIImageView(image: AppAssets.chevronRightImage)
|
||||
}
|
||||
|
||||
func updatedLayout() -> MasterTimelineCellLayout {
|
||||
func updatedLayout(width: CGFloat) -> MasterTimelineCellLayout {
|
||||
if UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory {
|
||||
return MasterTimelineAccessibilityCellLayout(width: bounds.width, insets: safeAreaInsets, cellData: cellData)
|
||||
return MasterTimelineAccessibilityCellLayout(width: width, insets: safeAreaInsets, cellData: cellData)
|
||||
} else {
|
||||
return MasterTimelineDefaultCellLayout(width: bounds.width, insets: safeAreaInsets, cellData: cellData)
|
||||
return MasterTimelineDefaultCellLayout(width: width, insets: safeAreaInsets, cellData: cellData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +228,6 @@ private extension MasterTimelineTableViewCell {
|
||||
}
|
||||
|
||||
func updateSubviews() {
|
||||
layout = nil
|
||||
updateTitleView()
|
||||
updateSummaryView()
|
||||
updateDateView()
|
||||
|
||||
@@ -351,7 +351,7 @@ class MasterTimelineViewController: ProgressTableViewController, UndoableCommand
|
||||
private extension MasterTimelineViewController {
|
||||
|
||||
@objc private func refreshAccounts(_ sender: Any) {
|
||||
AccountManager.shared.refreshAll()
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present)
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
"template-rendering-intent" : "template",
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
"template-rendering-intent" : "template",
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
40
iOS/Settings/SettingsAccountLabelView.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// SettingsAccountLabelView.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 6/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsAccountLabelView : View {
|
||||
let accountImage: String
|
||||
let accountLabel: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Image(accountImage)
|
||||
.resizable()
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.frame(height: 32)
|
||||
Text(verbatim: accountLabel).font(.title)
|
||||
|
||||
}
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct SettingsAccountLabelView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsAccountLabelView(accountImage: "accountLocal", accountLabel: "On My Device")
|
||||
.previewLayout(.fixed(width: 300, height: 44))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
31
iOS/Settings/SettingsAddAccountView.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// SettingsAddAccountView.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 6/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
struct SettingsAddAccountView : View {
|
||||
var body: some View {
|
||||
List {
|
||||
PresentationButton(SettingsAccountLabelView(accountImage: "accountLocal", accountLabel: Account.defaultLocalAccountName),
|
||||
destination: SettingsLocalAccountView(name: "")).padding(.all, 4)
|
||||
PresentationButton(SettingsAccountLabelView(accountImage: "accountFeedbin", accountLabel: "Feedbin"),
|
||||
destination: SettingsFeedbinAccountView(viewModel: SettingsFeedbinAccountView.ViewModel())).padding(.all, 4)
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
.navigationBarTitle(Text("Add Account"), displayMode: .inline)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct AddAccountView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsAddAccountView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
114
iOS/Settings/SettingsDetailAccountView.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// SettingsDetailAccountView.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 6/13/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Account
|
||||
|
||||
struct SettingsDetailAccountView : View {
|
||||
@ObjectBinding var viewModel: ViewModel
|
||||
@State private var verifyDelete = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Name")
|
||||
Divider()
|
||||
TextField($viewModel.name, placeholder: Text("(Optional)"))
|
||||
}
|
||||
Toggle(isOn: $viewModel.isActive) {
|
||||
Text("Active")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
|
||||
}) {
|
||||
Text("Credentials")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
if viewModel.isDeletable {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
self.verifyDelete = true
|
||||
}) {
|
||||
Text("Delete Account")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.presentation($verifyDelete) {
|
||||
Alert(title: Text("Are you sure you want to delete \"\(viewModel.nameForDisplay)\"?"),
|
||||
primaryButton: Alert.Button.default(Text("Delete"), onTrigger: { self.viewModel.delete() }),
|
||||
secondaryButton: Alert.Button.cancel())
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
.navigationBarTitle(Text(verbatim: viewModel.nameForDisplay), displayMode: .inline)
|
||||
|
||||
}
|
||||
|
||||
class ViewModel: BindableObject {
|
||||
let didChange = PassthroughSubject<ViewModel, Never>()
|
||||
let account: Account
|
||||
|
||||
init(_ account: Account) {
|
||||
self.account = account
|
||||
}
|
||||
|
||||
var nameForDisplay: String {
|
||||
account.nameForDisplay
|
||||
}
|
||||
|
||||
var name: String {
|
||||
get {
|
||||
account.name ?? ""
|
||||
}
|
||||
set {
|
||||
account.name = newValue.isEmpty ? nil : newValue
|
||||
didChange.send(self)
|
||||
}
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
get {
|
||||
account.isActive
|
||||
}
|
||||
set {
|
||||
account.isActive = newValue
|
||||
didChange.send(self)
|
||||
}
|
||||
}
|
||||
|
||||
var isDeletable: Bool {
|
||||
return AccountManager.shared.defaultAccount != account
|
||||
}
|
||||
|
||||
func delete() {
|
||||
AccountManager.shared.deleteAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct SettingsDetailAccountView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
let viewModel = SettingsDetailAccountView.ViewModel(AccountManager.shared.defaultAccount)
|
||||
return SettingsDetailAccountView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
150
iOS/Settings/SettingsFeedbinAccountView.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// SettingsFeedbinAccountView.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 6/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Account
|
||||
import RSWeb
|
||||
|
||||
struct SettingsFeedbinAccountView : View {
|
||||
@Environment(\.isPresented) private var isPresented
|
||||
@ObjectBinding var viewModel: ViewModel
|
||||
@State var busy: Bool = false
|
||||
@State var error: Text = Text("")
|
||||
var account: Account? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section(header:
|
||||
SettingsAccountLabelView(accountImage: "accountFeedbin", accountLabel: "Feedbin").padding()
|
||||
) {
|
||||
HStack {
|
||||
Spacer()
|
||||
TextField($viewModel.email, placeholder: Text("Email"))
|
||||
.textContentType(.username)
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
SecureField($viewModel.password, placeholder: Text("Password"))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
Section(footer:
|
||||
HStack {
|
||||
Spacer()
|
||||
error.color(.red)
|
||||
Spacer()
|
||||
}
|
||||
) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: { self.addAccount() }) {
|
||||
Text("Add Account")
|
||||
}
|
||||
.disabled(!viewModel.isValid)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(busy)
|
||||
.listStyle(.grouped)
|
||||
.navigationBarTitle(Text(""), displayMode: .inline)
|
||||
.navigationBarItems(leading:
|
||||
Button(action: { self.dismiss() }) { Text("Cancel") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func addAccount() {
|
||||
|
||||
busy = true
|
||||
|
||||
let emailAddress = viewModel.email.trimmingCharacters(in: .whitespaces)
|
||||
let credentials = Credentials.basic(username: emailAddress, password: viewModel.password)
|
||||
|
||||
Account.validateCredentials(type: .feedbin, credentials: credentials) { result in
|
||||
|
||||
self.busy = false
|
||||
|
||||
switch result {
|
||||
case .success(let authenticated):
|
||||
|
||||
if authenticated {
|
||||
|
||||
var newAccount = false
|
||||
let workAccount: Account
|
||||
if self.account == nil {
|
||||
workAccount = AccountManager.shared.createAccount(type: .feedbin)
|
||||
newAccount = true
|
||||
} else {
|
||||
workAccount = self.account!
|
||||
}
|
||||
|
||||
do {
|
||||
|
||||
do {
|
||||
try workAccount.removeBasicCredentials()
|
||||
} catch {}
|
||||
try workAccount.storeCredentials(credentials)
|
||||
|
||||
if newAccount {
|
||||
workAccount.refreshAll() { result in }
|
||||
}
|
||||
|
||||
self.dismiss()
|
||||
|
||||
} catch {
|
||||
self.error = Text("Keychain error while storing credentials.")
|
||||
}
|
||||
|
||||
} else {
|
||||
self.error = Text("Invalid email/password combination.")
|
||||
}
|
||||
|
||||
case .failure:
|
||||
self.error = Text("Network error. Try again later.")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func dismiss() {
|
||||
isPresented?.value = false
|
||||
}
|
||||
|
||||
class ViewModel: BindableObject {
|
||||
let didChange = PassthroughSubject<ViewModel, Never>()
|
||||
|
||||
var email: String = "" {
|
||||
didSet {
|
||||
didChange.send(self)
|
||||
}
|
||||
}
|
||||
var password: String = "" {
|
||||
didSet {
|
||||
didChange.send(self)
|
||||
}
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
return !email.isEmpty && !password.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct SettingsFeedbinAccountView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsFeedbinAccountView(viewModel: SettingsFeedbinAccountView.ViewModel())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
62
iOS/Settings/SettingsLocalAccountView.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// SettingsLocalAccountView.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 6/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
struct SettingsLocalAccountView : View {
|
||||
@Environment(\.isPresented) private var isPresented
|
||||
@State var name: String
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section(header:
|
||||
SettingsAccountLabelView(accountImage: "accountLocal", accountLabel: Account.defaultLocalAccountName).padding()
|
||||
) {
|
||||
HStack {
|
||||
Spacer()
|
||||
TextField($name, placeholder: Text("Name (Optional)"))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: { self.addAccount() }) {
|
||||
Text("Add Account")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
.navigationBarTitle(Text(""), displayMode: .inline)
|
||||
.navigationBarItems(leading: Button(action: { self.dismiss() }) { Text("Cancel") } )
|
||||
}
|
||||
}
|
||||
|
||||
private func addAccount() {
|
||||
let account = AccountManager.shared.createAccount(type: .onMyMac)
|
||||
account.name = name
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func dismiss() {
|
||||
isPresented?.value = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct SettingsLocalAccountView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsLocalAccountView(name: "")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
159
iOS/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 6/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Account
|
||||
|
||||
struct SettingsView : View {
|
||||
@ObjectBinding var viewModel: ViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
|
||||
Section(header: Text("ACCOUNTS")) {
|
||||
ForEach(viewModel.accounts.identified(by: \.self)) { account in
|
||||
NavigationButton(destination: SettingsDetailAccountView(viewModel: SettingsDetailAccountView.ViewModel(account)), isDetail: false) {
|
||||
Text(verbatim: account.nameForDisplay)
|
||||
}
|
||||
}
|
||||
NavigationButton(destination: SettingsAddAccountView(), isDetail: false) {
|
||||
Text("Add Account")
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("ABOUT")) {
|
||||
|
||||
Text("About NetNewsWire")
|
||||
|
||||
Button(action: {
|
||||
UIApplication.shared.open(URL(string: "https://ranchero.com/netnewswire/")!, options: [:])
|
||||
}) {
|
||||
Text("Website")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIApplication.shared.open(URL(string: "https://github.com/brentsimmons/NetNewsWire")!, options: [:])
|
||||
}) {
|
||||
Text("Github Repository")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIApplication.shared.open(URL(string: "https://github.com/brentsimmons/NetNewsWire/issues")!, options: [:])
|
||||
}) {
|
||||
Text("Bug Tracker")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIApplication.shared.open(URL(string: "https://github.com/brentsimmons/NetNewsWire/tree/master/Technotes")!, options: [:])
|
||||
}) {
|
||||
Text("Technotes")
|
||||
}
|
||||
|
||||
Text("Add NetNewsWire News Feed")
|
||||
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Section(header: Text("TIMELINE")) {
|
||||
Toggle(isOn: $viewModel.sortOldestToNewest) {
|
||||
Text("Sort Oldest to Newest")
|
||||
}
|
||||
Stepper(value: $viewModel.timelineNumberOfLines, in: 2...6) {
|
||||
Text("Number of Text Lines: \(viewModel.timelineNumberOfLines)")
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("DATABASE")) {
|
||||
Picker(selection: $viewModel.refreshInterval, label: Text("Refresh Interval")) {
|
||||
ForEach(RefreshInterval.allCases.identified(by: \.self)) { interval in
|
||||
Text(interval.description()).tag(interval)
|
||||
}
|
||||
}
|
||||
Text("Import Subscriptions...")
|
||||
Text("Export Subscriptions...")
|
||||
}
|
||||
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
.navigationBarTitle(Text("Settings"), displayMode: .inline)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class ViewModel: BindableObject {
|
||||
|
||||
let didChange = PassthroughSubject<ViewModel, Never>()
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .AccountsDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
get {
|
||||
return AccountManager.shared.sortedAccounts
|
||||
}
|
||||
set {
|
||||
}
|
||||
}
|
||||
|
||||
var sortOldestToNewest: Bool {
|
||||
get {
|
||||
return AppDefaults.timelineSortDirection == .orderedDescending
|
||||
}
|
||||
set {
|
||||
if newValue == true {
|
||||
AppDefaults.timelineSortDirection = .orderedDescending
|
||||
} else {
|
||||
AppDefaults.timelineSortDirection = .orderedAscending
|
||||
}
|
||||
didChange.send(self)
|
||||
}
|
||||
}
|
||||
|
||||
var timelineNumberOfLines: Int {
|
||||
get {
|
||||
return AppDefaults.timelineNumberOfLines
|
||||
}
|
||||
set {
|
||||
AppDefaults.timelineNumberOfLines = newValue
|
||||
didChange.send(self)
|
||||
}
|
||||
}
|
||||
|
||||
var refreshInterval: RefreshInterval {
|
||||
get {
|
||||
return AppDefaults.refreshInterval
|
||||
}
|
||||
set {
|
||||
AppDefaults.refreshInterval = newValue
|
||||
didChange.send(self)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func accountsDidChange(_ notification: Notification) {
|
||||
didChange.send(self)
|
||||
}
|
||||
|
||||
@objc func displayNameDidChange(_ notification: Notification) {
|
||||
didChange.send(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct SettingsView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsView(viewModel: SettingsView.ViewModel())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -10,7 +10,7 @@ import Account
|
||||
import UIKit
|
||||
|
||||
protocol AddAccountDismissDelegate: UIViewController {
|
||||
func dismiss(_ viewController: UIViewController)
|
||||
func dismiss()
|
||||
}
|
||||
|
||||
class AddAccountViewController: UITableViewController, AddAccountDismissDelegate {
|
||||
@@ -27,11 +27,13 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
let navController = UIStoryboard.settings.instantiateViewController(withIdentifier: "AddLocalAccountNavigationViewController") as! UINavigationController
|
||||
navController.modalPresentationStyle = .currentContext
|
||||
let addViewController = navController.topViewController as! AddLocalAccountViewController
|
||||
addViewController.delegate = self
|
||||
present(navController, animated: true)
|
||||
case 1:
|
||||
let navController = UIStoryboard.settings.instantiateViewController(withIdentifier: "FeedbinAccountNavigationViewController") as! UINavigationController
|
||||
navController.modalPresentationStyle = .currentContext
|
||||
let addViewController = navController.topViewController as! FeedbinAccountViewController
|
||||
addViewController.delegate = self
|
||||
present(navController, animated: true)
|
||||
@@ -40,8 +42,8 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss(_ viewController: UIViewController) {
|
||||
viewController.dismiss(animated: true, completion: nil)
|
||||
func dismiss() {
|
||||
navigationController?.popViewController(animated: false)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,13 +25,15 @@ class AddLocalAccountViewController: UIViewController {
|
||||
}
|
||||
|
||||
@IBAction func cancel(_ sender: Any) {
|
||||
delegate?.dismiss(self)
|
||||
dismiss(animated: true, completion: nil)
|
||||
delegate?.dismiss()
|
||||
}
|
||||
|
||||
@IBAction func addAccountTapped(_ sender: Any) {
|
||||
let account = AccountManager.shared.createAccount(type: .onMyMac)
|
||||
account.name = nameTextField.text
|
||||
delegate?.dismiss(self)
|
||||
dismiss(animated: true, completion: nil)
|
||||
delegate?.dismiss()
|
||||
}
|
||||
|
||||
}
|
||||