Merge pull request #1 from brentsimmons/master

Syncing with original
This commit is contained in:
Stuart Breckenridge
2019-06-15 06:55:39 +08:00
committed by GitHub
112 changed files with 2742 additions and 1467 deletions

58
.circleci/config.yml Normal file
View 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

View File

@@ -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 theres 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 its 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 Feedbins 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. (Its just a display name and doesnt affect authentication.)</p>
<p>Fixed several bugs with Feedbin syncing — its 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 its 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><![CDATA[

94
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,94 @@
# Contributing
We welcome contributions!
If youd like to contribute:
1. File a ticket describing the bug you want to fix or feature you want to add. Or find an existing ticket.
2. On the Slack group, bring it up on the #work channel for discussion (which may or may not include implementation discussion).
3. Once approved, then go for it. Write the code, then do a pull request. Well either have comments or well merge it. (We might revise it afterward, of course.)
## Notes
Its important that the pull request merge cleanly with master.
You should have read the [coding guidelines](Technotes/CodingGuidelines.md) first. If your code doesnt 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/

View File

@@ -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()
}
}

View File

@@ -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 */,

View File

@@ -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>?

View 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
}
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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 theres an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesnt, 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)
}
}

View File

@@ -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 {

View File

@@ -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])
}
}

View File

@@ -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))
}
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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()
}

View File

@@ -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))
}
}
}

View File

@@ -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)
}

View File

@@ -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 }

View File

@@ -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?) {

View File

@@ -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">

View File

@@ -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
View 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)
}
}

View File

@@ -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) {

View File

@@ -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!

View File

@@ -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 youd 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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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 {

View 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()
}
}

View File

@@ -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 {
// Dont allow PseudoFeed or Folder to be dragged.
// Dont 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 theyre 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
}
}

View File

@@ -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?) {

View File

@@ -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 theres 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. Its easiest.
static func size(for text: String, font: NSFont) -> NSSize {
return sizer(for: font).size(for: text)
}
static func emptyCache() {
sizers = [NSFont: SingleLineTextFieldSizer]()
}
}

View File

@@ -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 theres a match or if a folder contains (recursively) one of feeds

View File

@@ -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"/>

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 639 KiB

View File

@@ -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>

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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 }
}
}
}

View File

@@ -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 = (

View File

@@ -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"

View File

@@ -1,8 +1,10 @@
# NetNewsWire
# ![Icon](Technotes/Images/icon.png) NetNewsWire
[![CircleCI](https://circleci.com/gh/brentsimmons/NetNewsWire.svg?style=svg)](https://circleci.com/gh/brentsimmons/NetNewsWire)
Its a free and open source feed reader for macOS.
Its 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.
Its 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: NetNewsWires Help menu has a bunch of these links, so you dont have to remember to come back to this page.
Heres [How to Support NetNewsWire](Technotes/HowToSupportNetNewsWire.markdown). Spoiler: dont 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 youd 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.
Its pretty early still, and we have strong opinions about how we want to do things, so were 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. Its pretty straightforward.
Its probably a good idea to let us know first what youd 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 were 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`

View File

@@ -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] {

View File

@@ -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 {

View File

@@ -73,7 +73,7 @@ class AccountRefreshTimer {
lastTimedRefresh = Date()
update()
AccountManager.shared.refreshAll()
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
}

View File

@@ -8,7 +8,7 @@
import Foundation
enum RefreshInterval: Int {
enum RefreshInterval: Int, CaseIterable {
case manually = 1
case every10Minutes = 2
case every30Minutes = 3

View 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.
![Branching](Images/Branching.png)
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.
![Branching Full](Images/Branching-Full.png)
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 wont 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.

View 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) [![CircleCI](https://circleci.com/gh/brentsimmons/RSCore.svg?style=svg)](https://circleci.com/gh/brentsimmons/RSCore)
- [RSWeb](https://github.com/brentsimmons/RSWeb) [![CircleCI](https://circleci.com/gh/brentsimmons/RSWeb.svg?style=svg)](https://circleci.com/gh/brentsimmons/RSWeb)
- [RSParser](https://github.com/brentsimmons/RSParser) [![CircleCI](https://circleci.com/gh/brentsimmons/RSParser.svg?style=svg)](https://circleci.com/gh/brentsimmons/RSParser)
- [RSTree](https://github.com/brentsimmons/RSTree) [![CircleCI](https://circleci.com/gh/brentsimmons/RSTree.svg?style=svg)](https://circleci.com/gh/brentsimmons/RSTree)
- [RSDatabase](https://github.com/brentsimmons/RSDatabase) [![CircleCI](https://circleci.com/gh/brentsimmons/RSDatabase.svg?style=svg)](https://circleci.com/gh/brentsimmons/RSDatabase)

View File

@@ -0,0 +1,43 @@
# How to Support NetNewsWire
First thing: dont 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 its not the only one.
Use an RSS reader even if its 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 youve 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. Its worth it.
Finally: report bugs and make feature requests on our Issues tracker. You can also join the Slack group — its 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
Technotes/Images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -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 dont 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 _____ isnt planned until 2.0?
#### Why is Feedbin syncing planned for 1.0 but _____ isnt planned until 2.0?
This was a difficult decision. We didnt want to ship with no syncing at all, but we also didnt want to delay shipping until weve done a whole bunch of systems.
So we chose FeedBin, since thats what we use, and since the folks at FeedBin have been friendly and helpful.
So we chose Feedbin, since thats what we use, and since the folks at Feedbin have been friendly and helpful.

View File

@@ -16,4 +16,8 @@
## Contributing
[Contributing](../CONTRIBUTING.md)
[Coding Guidelines](CodingGuidelines.md)
[Branching Strategy](BranchingStrategy.md)

39
Technotes/Reruns.md Normal file
View File

@@ -0,0 +1,39 @@
# Why Reruns Happen
Sometimes you might see a new article in a feed that youd swear youve already read. And maybe you can even see, in NetNewsWire, what looks like another copy of that same exact article, with no changes.
Heres the thing to know: if the article really was the exact same in every respect, NetNewsWire would see that. Its super-easy for a computer to tell that some data is the exact same as some other data.
When its not really the exact same, thats 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: theyre 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, youll often see that you get a bunch of reruns for a given feed all at once. Youll 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 dont 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
Weve seen feeds that create a different unique ID for each article every time you fetch the feed, which results in reruns every single time. Weve 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 havent 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).

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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
View 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)
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -10,6 +10,7 @@
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
"template-rendering-intent" : "template",
"preserves-vector-representation" : true
}
}

View File

@@ -10,6 +10,7 @@
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
"template-rendering-intent" : "template",
"preserves-vector-representation" : true
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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)
}
}

View File

@@ -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()
}
}

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