Merge remote-tracking branch 'brentsimmons/master'

This commit is contained in:
Olof Hellman 2018-01-19 22:33:06 -08:00
commit fe5a11a2cc
23 changed files with 762 additions and 51 deletions

View File

@ -6,6 +6,39 @@
<description>Most recent Evergreen changes with links to updates.</description>
<language>en</language>
<item>
<title>Evergreen 1.0d33</title>
<description><![CDATA[
<h4>Send to MarsEdit</h4>
<p>If you have <a href="https://red-sweater.com/marsedit/">MarsEdit</a> on your Mac, then you can send an article from Evergreen to MarsEdit via the sharing menu in the toolbar.</p>
<p>In MarsEdit you can edit the post and add your commentary before posting to your blog.</p>
]]></description>
<pubDate>Mon, 15 Jan 2018 14:10:00 -0800</pubDate>
<enclosure url="https://ranchero.com/downloads/Evergreen1.0d33.zip" sparkle:version="813" sparkle:shortVersionString="1.0d33" length="7274707" type="application/zip" />
<sparkle:minimumSystemVersion>10.13</sparkle:minimumSystemVersion>
</item>
<item>
<title>Evergreen 1.0d32</title>
<description><![CDATA[
<h4>Send to Micro.blog</h4>
<p>See the Sharing command in the toolbar for a new feature: send to Micro.blog. This sends the current item to your copy of the Micro.blog Mac app (if you have it). It gets launched if necessary. You can edit the post in Micro.blog before actually posting it to your microblog.</p>
<p>This is hugely important, because feed reading isnt just about reading — its also about posting. While Evergreen doesnt itself include a way to post to the web, other apps do, and Evergreen should make it easy to connect to these other apps.</p>
<p>Note: send-to-MarsEdit is next, probably in the next release.</p>
<h4>Misc.</h4>
<p>Improve the promptness and reliability of feed icons and favicons appearing in the timeline (when a folder is selected).</p>
<p>Increase the indentation in the source list so that feeds inside folders line up better.</p>
]]></description>
<pubDate>Sun, 14 Jan 2018 12:00:00 -0800</pubDate>
<enclosure url="https://ranchero.com/downloads/Evergreen1.0d32.zip" sparkle:version="804" sparkle:shortVersionString="1.0d32" length="7265605" type="application/zip" />
<sparkle:minimumSystemVersion>10.13</sparkle:minimumSystemVersion>
</item>
<item>
<title>Version 1.0d31</title>
<description><![CDATA[

View File

@ -12,17 +12,10 @@ import Cocoa
protocol SendToCommand {
var title: String { get }
var image: NSImage? { get }
func canSendObject(_ object: Any?, selectedText: String?) -> Bool
func sendObject(_ object: Any?, selectedText: String?)
}
extension SendToCommand {
func appExistsOnDisk(_ bundleIdentifier: String) -> Bool {
if let _ = NSWorkspace.shared.absolutePathForApplication(withBundleIdentifier: bundleIdentifier) {
return true
}
return false
}
}

View File

@ -6,16 +6,77 @@
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import Foundation
import Cocoa
import RSCore
import Data
final class SendToMarsEditCommand: SendToCommand {
let title = "MarsEdit"
var image: NSImage? {
return appToUse()?.icon ?? nil
}
private let marsEditApps = [UserApp(bundleID: "com.red-sweater.marsedit4"), UserApp(bundleID: "com.red-sweater.marsedit")]
func canSendObject(_ object: Any?, selectedText: String?) -> Bool {
if let _ = appToUse() {
return true
}
return false
}
func sendObject(_ object: Any?, selectedText: String?) {
guard canSendObject(object, selectedText: selectedText) else {
return
}
guard let article = (object as? ArticlePasteboardWriter)?.article else {
return
}
guard let app = appToUse(), app.launchIfNeeded(), app.bringToFront() else {
return
}
send(article, to: app)
}
}
private extension SendToMarsEditCommand {
func send(_ article: Article, to app: UserApp) {
// App has already been launched.
guard let targetDescriptor = app.targetDescriptor() else {
return
}
let body = article.contentHTML ?? article.contentText ?? article.summary
let authorName = article.authors?.first?.name
let sender = SendToBlogEditorApp(targetDesciptor: targetDescriptor, title: article.title, body: body, summary: article.summary, link: article.externalURL, permalink: article.url, subject: nil, creator: authorName, commentsURL: nil, guid: article.uniqueID, sourceName: article.feed?.nameForDisplay, sourceHomeURL: article.feed?.homePageURL, sourceFeedURL: article.feed?.url)
let _ = sender.send()
}
func appToUse() -> UserApp? {
marsEditApps.forEach{ $0.updateStatus() }
for app in marsEditApps {
if app.isRunning {
return app
}
}
for app in marsEditApps {
if app.existsOnDisk {
return app
}
}
return nil
}
}

View File

@ -8,23 +8,24 @@
import Cocoa
import Data
import RSCore
// Not undoable.
final class SendToMicroBlogCommand: SendToCommand {
private let bundleID = "blog.micro.mac"
private var appExists = false
let title = "Micro.blog"
init() {
self.appExists = appExistsOnDisk(bundleID)
NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive(_:)), name: NSApplication.didBecomeActiveNotification, object: nil)
var image: NSImage? {
return microBlogApp.icon
}
private let microBlogApp = UserApp(bundleID: "blog.micro.mac")
func canSendObject(_ object: Any?, selectedText: String?) -> Bool {
guard appExists, let article = object as? Article, let _ = article.preferredLink else {
microBlogApp.updateStatus()
guard microBlogApp.existsOnDisk, let article = (object as? ArticlePasteboardWriter)?.article, let _ = article.preferredLink else {
return false
}
@ -36,42 +37,60 @@ final class SendToMicroBlogCommand: SendToCommand {
guard canSendObject(object, selectedText: selectedText) else {
return
}
guard let article = object as? Article else {
guard let article = (object as? ArticlePasteboardWriter)?.article else {
return
}
guard microBlogApp.launchIfNeeded(), microBlogApp.bringToFront() else {
return
}
// TODO: get text from contentHTML or contentText if no title and no selectedText.
var s = ""
if let selectedText = selectedText {
s += selectedText
if let link = article.preferredLink {
s += "\n\n\(link)"
}
}
else if let title = article.title {
s += title
if let link = article.preferredLink {
s = "[" + s + "](" + link + ")"
}
}
else if let link = article.preferredLink {
s = link
}
// TODO: consider selectedText.
guard let encodedString = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
let s = article.attributionString + article.linkString
let urlQueryDictionary = ["text": s]
guard let urlQueryString = urlQueryDictionary.urlQueryString() else {
return
}
guard let url = URL(string: "microblog://post?text=" + encodedString) else {
guard let url = URL(string: "microblog://post?" + urlQueryString) else {
return
}
let _ = try? NSWorkspace.shared.open(url, options: [], configuration: [:])
}
@objc func appDidBecomeActive(_ note: Notification) {
self.appExists = appExistsOnDisk(bundleID)
}
}
private extension Article {
var attributionString: String {
// Feed name, or feed name + author name (if author is specified per-article).
// Includes trailing space.
if let feedName = feed?.nameForDisplay, let authorName = authors?.first?.name {
return feedName + ", " + authorName + ": "
}
if let feedName = feed?.nameForDisplay {
return feedName + ": "
}
return ""
}
var linkString: String {
// Title + link or just title (if no link) or just link if no title
if let title = title, let link = preferredLink {
return "[" + title + "](" + link + ")"
}
if let preferredLink = preferredLink {
return preferredLink
}
if let title = title {
return title
}
return ""
}
}

View File

@ -483,6 +483,7 @@
846E77161F6EF5D000A165E2 /* Database.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Database.xcodeproj; path = Frameworks/Database/Database.xcodeproj; sourceTree = "<group>"; };
846E77301F6EF5D600A165E2 /* Account.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Account.xcodeproj; path = Frameworks/Account/Account.xcodeproj; sourceTree = "<group>"; };
84702AA31FA27AC0006B8943 /* MarkReadOrUnreadCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkReadOrUnreadCommand.swift; sourceTree = "<group>"; };
847752FE2008879500D93690 /* CoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreServices.framework; path = System/Library/Frameworks/CoreServices.framework; sourceTree = SDKROOT; };
848F6AE41FC29CFA002D422E /* FaviconDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FaviconDownloader.swift; sourceTree = "<group>"; };
849A97421ED9EAA9007D329B /* AddFolderWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddFolderWindowController.swift; sourceTree = "<group>"; };
849A97511ED9EAC0007D329B /* AddFeedController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AddFeedController.swift; path = AddFeed/AddFeedController.swift; sourceTree = "<group>"; };
@ -1100,6 +1101,7 @@
84FB9A2C1EDCD6A4003D53B9 /* Frameworks */ = {
isa = PBXGroup;
children = (
847752FE2008879500D93690 /* CoreServices.framework */,
84FB9A2D1EDCD6B8003D53B9 /* Sparkle.framework */,
);
name = Frameworks;

View File

@ -33,6 +33,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
return image
}()
lazy var sendToCommands: [SendToCommand] = {
return [SendToMicroBlogCommand(), SendToMarsEditCommand()]
}()
var unreadCount = 0 {
didSet {
if unreadCount != oldValue {

View File

@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0d31</string>
<string>1.0d33</string>
<key>CFBundleVersion</key>
<string>522</string>
<key>LSMinimumSystemVersion</key>

View File

@ -287,6 +287,7 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations {
let items = selectedArticles.map { ArticlePasteboardWriter(article: $0) }
let sharingServicePicker = NSSharingServicePicker(items: items)
sharingServicePicker.delegate = self
sharingServicePicker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
}
@ -299,6 +300,30 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations {
}
}
// MARK: - NSSharingServicePickerDelegate
extension MainWindowController: NSSharingServicePickerDelegate {
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, sharingServicesForItems items: [Any], proposedSharingServices proposedServices: [NSSharingService]) -> [NSSharingService] {
let sendToServices = appDelegate.sendToCommands.flatMap { (sendToCommand) -> NSSharingService? in
guard let object = items.first else {
return nil
}
guard sendToCommand.canSendObject(object, selectedText: nil) else {
return nil
}
let image = sendToCommand.image ?? appDelegate.genericFeedImage ?? NSImage()
return NSSharingService(title: sendToCommand.title, image: image, alternateImage: nil) {
sendToCommand.sendObject(object, selectedText: nil)
}
}
return proposedServices + sendToServices
}
}
// MARK: - NSToolbarDelegate
extension NSToolbarItem.Identifier {

View File

@ -11,7 +11,7 @@ import Data
@objc final class ArticlePasteboardWriter: NSObject, NSPasteboardWriting {
private let article: Article
let article: Article
static let articleUTI = "com.ranchero.article"
static let articleUTIType = NSPasteboard.PasteboardType(rawValue: articleUTI)
static let articleUTIInternal = "com.ranchero.evergreen.internal.article"

View File

@ -52,6 +52,7 @@ class TimelineViewController: NSViewController, UndoableCommandRunner {
private var didRegisterForNotifications = false
private let timelineFontSizeKVOKey = "values.{AppDefaults.Key.timelineFontSize}"
private var reloadAvailableCellsTimer: Timer?
private var fetchAndMergeArticlesTimer: Timer?
private var articles = ArticleArray() {
didSet {
@ -120,6 +121,7 @@ class TimelineViewController: NSViewController, UndoableCommandRunner {
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .ImageDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil)
NSUserDefaultsController.shared.addObserver(self, forKeyPath: timelineFontSizeKVOKey, options: NSKeyValueObservingOptions(rawValue: 0), context: nil)
@ -369,6 +371,18 @@ class TimelineViewController: NSViewController, UndoableCommandRunner {
}
}
@objc func accountDidDownloadArticles(_ note: Notification) {
guard let feeds = note.userInfo?[Account.UserInfoKey.feeds] as? Set<Feed> else {
return
}
let shouldFetchAndMergeArticles = representedObjectsContainsAnyFeed(feeds)
if shouldFetchAndMergeArticles {
queueFetchAndMergeArticles()
}
}
// MARK: - Reloading Data
private func cellForRowView(_ rowView: NSView) -> NSView? {
@ -627,6 +641,15 @@ private extension TimelineViewController {
return
}
let fetchedArticles = fetchUnsortedArticles(for: representedObjects)
let sortedArticles = Array(fetchedArticles).sortedByDate()
if articles != sortedArticles {
articles = sortedArticles
}
}
func fetchUnsortedArticles(for representedObjects: [Any]) -> Set<Article> {
var fetchedArticles = Set<Article>()
for object in representedObjects {
@ -639,9 +662,43 @@ private extension TimelineViewController {
}
}
let sortedArticles = Array(fetchedArticles).sortedByDate()
if articles != sortedArticles {
articles = sortedArticles
return fetchedArticles
}
func fetchAndMergeArticles() {
let selectedArticleIDs = selectedArticles.articleIDs()
selectArticles(selectedArticleIDs)
}
func selectArticles(_ articleIDs: [String]) {
// let indexesToSelect = indexesOf(articleIDs)
// if indexesToSelect.isEmpty {
// tableView.deselectAll(self)
// return
// }
// tableView.selectRowIndexes(indexesToSelect, byExtendingSelection: false)
}
func invalidateFetchAndMergeArticlesTimer() {
if let timer = fetchAndMergeArticlesTimer {
if timer.isValid {
timer.invalidate()
}
fetchAndMergeArticlesTimer = nil
}
}
func queueFetchAndMergeArticles() {
invalidateFetchAndMergeArticlesTimer()
fetchAndMergeArticlesTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { (timer) in
self.fetchAndMergeArticles()
self.invalidateFetchAndMergeArticlesTimer()
}
}
@ -666,5 +723,31 @@ private extension TimelineViewController {
}
return true
}
func representedObjectsContainsAnyFeed(_ feeds: Set<Feed>) -> Bool {
// Return true if theres a match or if a folder contains (recursively) one of feeds
guard let representedObjects = representedObjects else {
return false
}
for representedObject in representedObjects {
if let feed = representedObject as? Feed {
for oneFeed in feeds {
if feed.feedID == oneFeed.feedID || feed.url == oneFeed.url {
return true
}
}
}
else if let folder = representedObject as? Folder {
for oneFeed in feeds {
if folder.hasFeed(with: oneFeed.feedID) || folder.hasFeed(withURL: oneFeed.url) {
return true
}
}
}
}
return false
}
}

View File

@ -41,7 +41,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
public static let updatedArticles = "updatedArticles" // AccountDidDownloadArticles
public static let statuses = "statuses" // StatusesDidChange
public static let articles = "articles" // StatusesDidChange
public static let feeds = "feeds" // StatusesDidChange
public static let feeds = "feeds" // AccountDidDownloadArticles, StatusesDidChange
}
public let accountID: String
@ -167,10 +167,11 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
if let updatedArticles = updatedArticles, !updatedArticles.isEmpty {
userInfo[UserInfoKey.updatedArticles] = updatedArticles
}
userInfo[UserInfoKey.feeds] = Set([feed])
completion()
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo.isEmpty ? nil : userInfo)
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
}
}

View File

@ -71,6 +71,7 @@
842E45CC1ED623C7000A8B52 /* UniqueIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E45CB1ED623C7000A8B52 /* UniqueIdentifier.swift */; };
8432B1861DACA0E90057D6DF /* NSResponder-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8432B1851DACA0E90057D6DF /* NSResponder-Extensions.swift */; };
8432B1881DACA2060057D6DF /* NSWindow-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8432B1871DACA2060057D6DF /* NSWindow-Extensions.swift */; };
8434D15C200BD6F400D6281E /* UserApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8434D15B200BD6F400D6281E /* UserApp.swift */; };
84411E731FE5FFC3004B527F /* NSImage+RSCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84411E721FE5FFC3004B527F /* NSImage+RSCore.swift */; };
844B5B571FE9D36000C7C76A /* Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844B5B561FE9D36000C7C76A /* Keyboard.swift */; };
844C915B1B65753E0051FC1B /* RSPlist.h in Headers */ = {isa = PBXBuildFile; fileRef = 844C91591B65753E0051FC1B /* RSPlist.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -102,6 +103,10 @@
84B99C9A1FAE650100ECDEDB /* OPMLRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C991FAE650100ECDEDB /* OPMLRepresentable.swift */; };
84B99C9B1FAE650100ECDEDB /* OPMLRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C991FAE650100ECDEDB /* OPMLRepresentable.swift */; };
84BB45431D6909C700B48537 /* NSMutableDictionary-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BB45421D6909C700B48537 /* NSMutableDictionary-Extensions.swift */; };
84C632A0200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.h in Headers */ = {isa = PBXBuildFile; fileRef = 84C6329E200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.h */; settings = {ATTRIBUTES = (Public, ); }; };
84C632A1200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.m in Sources */ = {isa = PBXBuildFile; fileRef = 84C6329F200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.m */; };
84C632A4200D356E007BEEAA /* SendToBlogEditorApp.h in Headers */ = {isa = PBXBuildFile; fileRef = 84C632A2200D356E007BEEAA /* SendToBlogEditorApp.h */; settings = {ATTRIBUTES = (Public, ); }; };
84C632A5200D356E007BEEAA /* SendToBlogEditorApp.m in Sources */ = {isa = PBXBuildFile; fileRef = 84C632A3200D356E007BEEAA /* SendToBlogEditorApp.m */; };
84C687301FBAA30800345C9E /* LogWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 84C6872F1FBAA30800345C9E /* LogWindow.xib */; };
84C687321FBAA3DF00345C9E /* LogWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C687311FBAA3DF00345C9E /* LogWindowController.swift */; };
84C687351FBC025600345C9E /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C687341FBC025600345C9E /* Log.swift */; };
@ -191,6 +196,7 @@
842E45CB1ED623C7000A8B52 /* UniqueIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UniqueIdentifier.swift; sourceTree = "<group>"; };
8432B1851DACA0E90057D6DF /* NSResponder-Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSResponder-Extensions.swift"; sourceTree = "<group>"; };
8432B1871DACA2060057D6DF /* NSWindow-Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSWindow-Extensions.swift"; sourceTree = "<group>"; };
8434D15B200BD6F400D6281E /* UserApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserApp.swift; path = AppKit/UserApp.swift; sourceTree = "<group>"; };
84411E721FE5FFC3004B527F /* NSImage+RSCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "NSImage+RSCore.swift"; path = "Images/NSImage+RSCore.swift"; sourceTree = "<group>"; };
844B5B561FE9D36000C7C76A /* Keyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Keyboard.swift; path = RSCore/Keyboard.swift; sourceTree = "<group>"; };
844C91591B65753E0051FC1B /* RSPlist.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RSPlist.h; path = RSCore/RSPlist.h; sourceTree = "<group>"; };
@ -218,6 +224,10 @@
84B99C931FAE64D400ECDEDB /* DisplayNameProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DisplayNameProvider.swift; path = RSCore/DisplayNameProvider.swift; sourceTree = "<group>"; };
84B99C991FAE650100ECDEDB /* OPMLRepresentable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OPMLRepresentable.swift; path = RSCore/OPMLRepresentable.swift; sourceTree = "<group>"; };
84BB45421D6909C700B48537 /* NSMutableDictionary-Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSMutableDictionary-Extensions.swift"; sourceTree = "<group>"; };
84C6329E200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "NSAppleEventDescriptor+RSCore.h"; path = "AppKit/NSAppleEventDescriptor+RSCore.h"; sourceTree = "<group>"; };
84C6329F200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "NSAppleEventDescriptor+RSCore.m"; path = "AppKit/NSAppleEventDescriptor+RSCore.m"; sourceTree = "<group>"; };
84C632A2200D356E007BEEAA /* SendToBlogEditorApp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SendToBlogEditorApp.h; path = AppKit/SendToBlogEditorApp.h; sourceTree = "<group>"; };
84C632A3200D356E007BEEAA /* SendToBlogEditorApp.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = SendToBlogEditorApp.m; path = AppKit/SendToBlogEditorApp.m; sourceTree = "<group>"; };
84C6872F1FBAA30800345C9E /* LogWindow.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = LogWindow.xib; path = AppKit/LogWindow.xib; sourceTree = "<group>"; };
84C687311FBAA3DF00345C9E /* LogWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LogWindowController.swift; path = AppKit/LogWindowController.swift; sourceTree = "<group>"; };
84C687341FBC025600345C9E /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
@ -434,6 +444,10 @@
isa = PBXGroup;
children = (
84CFF5511AC3CF4700CEA6C8 /* NSColor+RSCore.h */,
84C6329E200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.h */,
84C6329F200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.m */,
84C632A2200D356E007BEEAA /* SendToBlogEditorApp.h */,
84C632A3200D356E007BEEAA /* SendToBlogEditorApp.m */,
84CFF5521AC3CF4700CEA6C8 /* NSColor+RSCore.m */,
8415CB881BF84D24007B1E98 /* NSEvent+RSCore.h */,
8415CB891BF84D24007B1E98 /* NSEvent+RSCore.m */,
@ -460,6 +474,7 @@
84C687311FBAA3DF00345C9E /* LogWindowController.swift */,
84C687341FBC025600345C9E /* Log.swift */,
84C687371FBC028900345C9E /* LogItem.swift */,
8434D15B200BD6F400D6281E /* UserApp.swift */,
842DD7F91E1499FA00E061EB /* Views */,
);
name = AppKit;
@ -544,6 +559,7 @@
84CFF4FA1AC3C69700CEA6C8 /* RSCore.h in Headers */,
844F91D51D90D86100820C48 /* RSTransparentContainerView.h in Headers */,
84CFF53F1AC3CD0100CEA6C8 /* NSMutableSet+RSCore.h in Headers */,
84C632A0200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.h in Headers */,
84CFF5121AC3C6D800CEA6C8 /* RSBlocks.h in Headers */,
84CFF56D1AC3D20A00CEA6C8 /* NSImage+RSCore.h in Headers */,
84CFF5471AC3CD8000CEA6C8 /* NSTimer+RSCore.h in Headers */,
@ -555,6 +571,7 @@
84CFF52F1AC3CB1900CEA6C8 /* NSDate+RSCore.h in Headers */,
8414CBAB1C95F8F700333C12 /* RSGeometry.h in Headers */,
845DE0F31B80477100D1571B /* NSSet+RSCore.h in Headers */,
84C632A4200D356E007BEEAA /* SendToBlogEditorApp.h in Headers */,
844C915B1B65753E0051FC1B /* RSPlist.h in Headers */,
8453F7DE1BDF337800B1C8ED /* RSMacroProcessor.h in Headers */,
8415CB8A1BF84D24007B1E98 /* NSEvent+RSCore.h in Headers */,
@ -748,6 +765,7 @@
84CFF5171AC3C73000CEA6C8 /* RSConstants.m in Sources */,
8432B1881DACA2060057D6DF /* NSWindow-Extensions.swift in Sources */,
8402047E1FBCE77900D94C1A /* BatchUpdate.swift in Sources */,
84C632A5200D356E007BEEAA /* SendToBlogEditorApp.m in Sources */,
84CFF5541AC3CF4700CEA6C8 /* NSColor+RSCore.m in Sources */,
84536F671BB856D4001E1639 /* NSFileManager+RSCore.m in Sources */,
8415CB8B1BF84D24007B1E98 /* NSEvent+RSCore.m in Sources */,
@ -769,6 +787,7 @@
84C687351FBC025600345C9E /* Log.swift in Sources */,
84CFF5301AC3CB1900CEA6C8 /* NSDate+RSCore.m in Sources */,
84CFF5281AC3C9A200CEA6C8 /* NSArray+RSCore.m in Sources */,
84C632A1200D30F1007BEEAA /* NSAppleEventDescriptor+RSCore.m in Sources */,
84E72E171FBD647500B873C1 /* InspectorView.swift in Sources */,
84CFF5591AC3CF9100CEA6C8 /* NSView+RSCore.m in Sources */,
84CFF56A1AC3D1B000CEA6C8 /* RSScaling.m in Sources */,
@ -794,6 +813,7 @@
842E45CC1ED623C7000A8B52 /* UniqueIdentifier.swift in Sources */,
84A8358A1D4EC7B80004C598 /* PlistProviderProtocol.swift in Sources */,
849A339E1AC90A0A0015BA09 /* NSTableView+RSCore.m in Sources */,
8434D15C200BD6F400D6281E /* UserApp.swift in Sources */,
84CFF51B1AC3C77500CEA6C8 /* RSPlatform.m in Sources */,
845A291F1FC8BC49007B49E3 /* BinaryDiskCache.swift in Sources */,
84CFF52C1AC3CA9700CEA6C8 /* NSData+RSCore.m in Sources */,
@ -931,7 +951,6 @@
GCC_WARN_ABOUT_MISSING_NEWLINE = YES;
GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_FOUR_CHARACTER_CONSTANTS = YES;
GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES;
GCC_WARN_SHADOW = YES;
GCC_WARN_SIGN_COMPARE = YES;
@ -1003,7 +1022,6 @@
GCC_WARN_ABOUT_MISSING_NEWLINE = YES;
GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_FOUR_CHARACTER_CONSTANTS = YES;
GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES;
GCC_WARN_SHADOW = YES;
GCC_WARN_SIGN_COMPARE = YES;

View File

@ -0,0 +1,19 @@
//
// NSAppleEventDescriptor+RSCore.h
// RSCore
//
// Created by Brent Simmons on 1/15/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
@import Cocoa;
NS_ASSUME_NONNULL_BEGIN
@interface NSAppleEventDescriptor (RSCore)
+ (NSAppleEventDescriptor * _Nullable)descriptorWithRunningApplication:(NSRunningApplication *)runningApplication;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,23 @@
//
// NSAppleEventDescriptor+RSCore.m
// RSCore
//
// Created by Brent Simmons on 1/15/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#import "NSAppleEventDescriptor+RSCore.h"
@implementation NSAppleEventDescriptor (RSCore)
+ (NSAppleEventDescriptor * _Nullable)descriptorWithRunningApplication:(NSRunningApplication *)runningApplication {
pid_t processIdentifier = runningApplication.processIdentifier;
if (processIdentifier == -1) {
return nil;
}
return [NSAppleEventDescriptor descriptorWithProcessIdentifier:processIdentifier];
}
@end

View File

@ -0,0 +1,28 @@
//
// SendToBlogEditorApp.h
// RSCore
//
// Created by Brent Simmons on 1/15/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
@import Cocoa;
// This is for sending articles to MarsEdit and other apps that implement the send-to-blog-editor Apple events API:
// http://ranchero.com/netnewswire/developers/externalinterface
//
// The first parameter is a target descriptor. The easiest way to get this is probably UserApp.targetDescriptor or +[NSAppleEventDescriptor descriptorWithRunningApplication:].
// This does not care of launching the app in the first place. See UserApp.swift.
NS_ASSUME_NONNULL_BEGIN
@interface SendToBlogEditorApp : NSObject
- (instancetype)initWithTargetDesciptor:(NSAppleEventDescriptor *)targetDescriptor title:(NSString * _Nullable)title body:(NSString * _Nullable)body summary:(NSString * _Nullable)summary link:(NSString * _Nullable)link permalink:(NSString * _Nullable)permalink subject:(NSString * _Nullable)subject creator:(NSString * _Nullable)creator commentsURL:(NSString * _Nullable)commentsURL guid:(NSString * _Nullable)guid sourceName:(NSString * _Nullable)sourceName sourceHomeURL:(NSString * _Nullable)sourceHomeURL sourceFeedURL:(NSString * _Nullable)sourceFeedURL;
- (OSStatus)send; // Actually send the Apple event.
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,113 @@
//
// SendToBlogEditorApp.m
// RSCore
//
// Created by Brent Simmons on 1/15/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#import "SendToBlogEditorApp.h"
@interface SendToBlogEditorApp()
@property (nonatomic, readonly) NSAppleEventDescriptor *targetDescriptor;
@property (nonatomic, nullable, readonly) NSString *title;
@property (nonatomic, nullable, readonly) NSString *body;
@property (nonatomic, nullable, readonly) NSString *summary;
@property (nonatomic, nullable, readonly) NSString *link;
@property (nonatomic, nullable, readonly) NSString *permalink;
@property (nonatomic, nullable, readonly) NSString *subject;
@property (nonatomic, nullable, readonly) NSString *creator;
@property (nonatomic, nullable, readonly) NSString *commentsURL;
@property (nonatomic, nullable, readonly) NSString *guid;
@property (nonatomic, nullable, readonly) NSString *sourceName;
@property (nonatomic, nullable, readonly) NSString *sourceHomeURL;
@property (nonatomic, nullable, readonly) NSString *sourceFeedURL;
@end
@implementation SendToBlogEditorApp
- (instancetype)initWithTargetDesciptor:(NSAppleEventDescriptor *)targetDescriptor title:(NSString * _Nullable)title body:(NSString * _Nullable)body summary:(NSString * _Nullable)summary link:(NSString * _Nullable)link permalink:(NSString * _Nullable)permalink subject:(NSString * _Nullable)subject creator:(NSString * _Nullable)creator commentsURL:(NSString * _Nullable)commentsURL guid:(NSString * _Nullable)guid sourceName:(NSString * _Nullable)sourceName sourceHomeURL:(NSString * _Nullable)sourceHomeURL sourceFeedURL:(NSString * _Nullable)sourceFeedURL {
self = [super init];
if (!self) {
return nil;
}
_targetDescriptor = targetDescriptor;
_title = title;
_body = body;
_summary = summary;
_link = link;
_permalink = permalink;
_subject = subject;
_creator = creator;
_commentsURL = commentsURL;
_guid = guid;
_sourceName = sourceName;
_sourceHomeURL = sourceHomeURL;
_sourceFeedURL = sourceFeedURL;
return self;
}
const AEKeyword EditDataItemAppleEventClass = 'EBlg';
const AEKeyword EditDataItemAppleEventID = 'oitm';
const AEKeyword DataItemTitle = 'titl';
const AEKeyword DataItemDescription = 'desc';
const AEKeyword DataItemSummary = 'summ';
const AEKeyword DataItemLink = 'link';
const AEKeyword DataItemPermalink = 'plnk';
const AEKeyword DataItemSubject = 'subj';
const AEKeyword DataItemCreator = 'crtr';
const AEKeyword DataItemCommentsURL = 'curl';
const AEKeyword DataItemGUID = 'guid';
const AEKeyword DataItemSourceName = 'snam';
const AEKeyword DataItemSourceHomeURL = 'hurl';
const AEKeyword DataItemSourceFeedURL = 'furl';
- (OSStatus)send {
NSAppleEventDescriptor *appleEvent = [NSAppleEventDescriptor appleEventWithEventClass:EditDataItemAppleEventClass eventID:EditDataItemAppleEventID targetDescriptor:self.targetDescriptor returnID:kAutoGenerateReturnID transactionID:kAnyTransactionID];
[appleEvent setParamDescriptor:[self paramDescriptor] forKeyword:keyDirectObject];
return AESendMessage((const AppleEvent *)[appleEvent aeDesc], NULL, kAENoReply | kAECanSwitchLayer | kAEAlwaysInteract, kAEDefaultTimeout);
}
#pragma mark - Private
- (NSAppleEventDescriptor *)paramDescriptor {
NSAppleEventDescriptor *descriptor = [NSAppleEventDescriptor recordDescriptor];
[self addToDescriptor:descriptor value:self.title keyword:DataItemTitle];
[self addToDescriptor:descriptor value:self.body keyword:DataItemDescription];
[self addToDescriptor:descriptor value:self.summary keyword:DataItemSummary];
[self addToDescriptor:descriptor value:self.link keyword:DataItemLink];
[self addToDescriptor:descriptor value:self.permalink keyword:DataItemPermalink];
[self addToDescriptor:descriptor value:self.subject keyword:DataItemSubject];
[self addToDescriptor:descriptor value:self.creator keyword:DataItemCreator];
[self addToDescriptor:descriptor value:self.commentsURL keyword:DataItemCommentsURL];
[self addToDescriptor:descriptor value:self.guid keyword:DataItemGUID];
[self addToDescriptor:descriptor value:self.sourceName keyword:DataItemSourceName];
[self addToDescriptor:descriptor value:self.sourceHomeURL keyword:DataItemSourceHomeURL];
[self addToDescriptor:descriptor value:self.sourceFeedURL keyword:DataItemSourceFeedURL];
return descriptor;
}
- (void)addToDescriptor:(NSAppleEventDescriptor *)descriptor value:(NSString *)value keyword:(AEKeyword)keyword {
if (!value) {
return;
}
NSAppleEventDescriptor *stringDescriptor = [NSAppleEventDescriptor descriptorWithString:value];
[descriptor setDescriptor:stringDescriptor forKeyword:keyword];
}
@end

View File

@ -0,0 +1,135 @@
//
// UserApp.swift
// RSCore
//
// Created by Brent Simmons on 1/14/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
import Cocoa
// Represents an app (the type of app mostly found in /Applications.)
// The app may or may not be running. It may or may not exist.
public final class UserApp {
public let bundleID: String
public var icon: NSImage? = nil
public var existsOnDisk = false
public var path: String? = nil
public var runningApplication: NSRunningApplication? = nil
public var isRunning: Bool {
updateStatus()
if let runningApplication = runningApplication {
return !runningApplication.isTerminated
}
return false
}
public init(bundleID: String) {
self.bundleID = bundleID
updateStatus()
}
public func updateStatus() {
if let runningApplication = runningApplication, runningApplication.isTerminated {
self.runningApplication = nil
}
let runningApplications = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
for app in runningApplications {
if let runningApplication = runningApplication {
if app == runningApplication {
break
}
}
else {
if !app.isTerminated {
runningApplication = app
break
}
}
}
if let runningApplication = runningApplication {
existsOnDisk = true
icon = runningApplication.icon
if let bundleURL = runningApplication.bundleURL {
path = bundleURL.path
}
else {
path = NSWorkspace.shared.absolutePathForApplication(withBundleIdentifier: bundleID)
}
if icon == nil, let path = path {
icon = NSWorkspace.shared.icon(forFile: path)
}
return
}
path = NSWorkspace.shared.absolutePathForApplication(withBundleIdentifier: bundleID)
if let path = path {
if icon == nil {
icon = NSWorkspace.shared.icon(forFile: path)
}
existsOnDisk = true
}
else {
existsOnDisk = false
icon = nil
}
}
public func launchIfNeeded() -> Bool {
// Return true if already running.
// Return true if not running and successfully gets launched.
updateStatus()
if isRunning {
return true
}
guard existsOnDisk, let path = path else {
return false
}
let url = URL(fileURLWithPath: path)
if let app = try? NSWorkspace.shared.launchApplication(at: url, options: [.withErrorPresentation], configuration: [:]) {
runningApplication = app
if app.isFinishedLaunching {
return true
}
Thread.sleep(forTimeInterval: 1.0) // Give the app time to launch. This is ugly.
return true
}
return false
}
public func bringToFront() -> Bool {
// Activates the app, ignoring other apps.
// Does not automatically launch the app first.
updateStatus()
return runningApplication?.activate(options: [.activateIgnoringOtherApps]) ?? false
}
public func targetDescriptor() -> NSAppleEventDescriptor? {
// Requires that the app has previously been launched.
updateStatus()
guard let runningApplication = runningApplication, !runningApplication.isTerminated else {
return nil
}
return NSAppleEventDescriptor(runningApplication: runningApplication)
}
}

View File

@ -52,6 +52,9 @@
#import <RSCore/RSGeometry.h>
#import <RSCore/NSAppleEventDescriptor+RSCore.h>
#import <RSCore/SendToBlogEditorApp.h>
#endif

View File

@ -7,6 +7,9 @@
objects = {
/* Begin PBXBuildFile section */
8409DB2C200AE4D700CE879E /* Dictionary+RSWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8409DB2B200AE4D700CE879E /* Dictionary+RSWeb.swift */; };
8409DB2E200AE74400CE879E /* DictionaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8409DB2D200AE74400CE879E /* DictionaryTests.swift */; };
8409DB30200AE81400CE879E /* String+RSWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8409DB2F200AE81400CE879E /* String+RSWeb.swift */; };
84245C5A1FDC690A0074AFBB /* WebServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84245C591FDC690A0074AFBB /* WebServiceProvider.swift */; };
84245C5B1FDC690A0074AFBB /* WebServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84245C591FDC690A0074AFBB /* WebServiceProvider.swift */; };
84245C5D1FDC697A0074AFBB /* Credentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84245C5C1FDC697A0074AFBB /* Credentials.swift */; };
@ -15,6 +18,7 @@
84245C611FDC69F20074AFBB /* APICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84245C5F1FDC69F20074AFBB /* APICall.swift */; };
84245C6F1FDDCD8C0074AFBB /* HTTPResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84245C6E1FDDCD8C0074AFBB /* HTTPResult.swift */; };
84245C701FDDCD8C0074AFBB /* HTTPResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84245C6E1FDDCD8C0074AFBB /* HTTPResult.swift */; };
84261183200AE918004D89DD /* StringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84261182200AE918004D89DD /* StringTests.swift */; };
842ED2E71E12FB8A000CF738 /* HTTPRequestHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842ED2E61E12FB8A000CF738 /* HTTPRequestHeader.swift */; };
842ED2E81E12FB8A000CF738 /* HTTPRequestHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842ED2E61E12FB8A000CF738 /* HTTPRequestHeader.swift */; };
842ED2EA1E12FB91000CF738 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842ED2E91E12FB91000CF738 /* HTTPMethod.swift */; };
@ -61,10 +65,14 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
8409DB2B200AE4D700CE879E /* Dictionary+RSWeb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Dictionary+RSWeb.swift"; path = "RSWeb/Dictionary+RSWeb.swift"; sourceTree = "<group>"; };
8409DB2D200AE74400CE879E /* DictionaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryTests.swift; sourceTree = "<group>"; };
8409DB2F200AE81400CE879E /* String+RSWeb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "String+RSWeb.swift"; path = "RSWeb/String+RSWeb.swift"; sourceTree = "<group>"; };
84245C591FDC690A0074AFBB /* WebServiceProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebServiceProvider.swift; sourceTree = "<group>"; };
84245C5C1FDC697A0074AFBB /* Credentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Credentials.swift; path = RSWeb/Credentials.swift; sourceTree = "<group>"; };
84245C5F1FDC69F20074AFBB /* APICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APICall.swift; sourceTree = "<group>"; };
84245C6E1FDDCD8C0074AFBB /* HTTPResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HTTPResult.swift; path = RSWeb/HTTPResult.swift; sourceTree = "<group>"; };
84261182200AE918004D89DD /* StringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringTests.swift; sourceTree = "<group>"; };
842ED2E61E12FB8A000CF738 /* HTTPRequestHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HTTPRequestHeader.swift; path = RSWeb/HTTPRequestHeader.swift; sourceTree = "<group>"; };
842ED2E91E12FB91000CF738 /* HTTPMethod.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HTTPMethod.swift; path = RSWeb/HTTPMethod.swift; sourceTree = "<group>"; };
842ED2EC1E12FB97000CF738 /* HTTPResponseCode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HTTPResponseCode.swift; path = RSWeb/HTTPResponseCode.swift; sourceTree = "<group>"; };
@ -143,6 +151,8 @@
842ED2D41E11FE8B000CF738 /* Constants */,
849C09231E0CAD67006B03FA /* Downloading */,
842ED30A1E12FBD8000CF738 /* URL+RSWeb.swift */,
8409DB2B200AE4D700CE879E /* Dictionary+RSWeb.swift */,
8409DB2F200AE81400CE879E /* String+RSWeb.swift */,
842ED3131E12FBE7000CF738 /* MimeType.swift */,
842ED3101E12FBE1000CF738 /* MacWebBrowser.swift */,
84245C5C1FDC697A0074AFBB /* Credentials.swift */,
@ -177,6 +187,8 @@
isa = PBXGroup;
children = (
849C08C41E0CAC86006B03FA /* RSWebTests.swift */,
8409DB2D200AE74400CE879E /* DictionaryTests.swift */,
84261182200AE918004D89DD /* StringTests.swift */,
849C08C61E0CAC86006B03FA /* Info.plist */,
);
path = RSWebTests;
@ -367,6 +379,7 @@
842ED3081E12FBD2000CF738 /* URLRequest+RSWeb.swift in Sources */,
842ED3051E12FBCC000CF738 /* NSMutableURLRequest+RSWeb.swift in Sources */,
842ED2E71E12FB8A000CF738 /* HTTPRequestHeader.swift in Sources */,
8409DB30200AE81400CE879E /* String+RSWeb.swift in Sources */,
842ED3111E12FBE1000CF738 /* MacWebBrowser.swift in Sources */,
842ED3141E12FBE7000CF738 /* MimeType.swift in Sources */,
84245C5D1FDC697A0074AFBB /* Credentials.swift in Sources */,
@ -376,6 +389,7 @@
842ED2F91E12FBB5000CF738 /* DownloadProgress.swift in Sources */,
842ED2EA1E12FB91000CF738 /* HTTPMethod.swift in Sources */,
842ED3021E12FBC7000CF738 /* HTTPConditionalGetInfo.swift in Sources */,
8409DB2C200AE4D700CE879E /* Dictionary+RSWeb.swift in Sources */,
842ED2ED1E12FB97000CF738 /* HTTPResponseCode.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -385,6 +399,8 @@
buildActionMask = 2147483647;
files = (
849C08C51E0CAC86006B03FA /* RSWebTests.swift in Sources */,
84261183200AE918004D89DD /* StringTests.swift in Sources */,
8409DB2E200AE74400CE879E /* DictionaryTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -0,0 +1,46 @@
//
// Dictionary+RSWeb.swift
// RSWeb
//
// Created by Brent Simmons on 1/13/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import Foundation
public extension Dictionary {
public func urlQueryString() -> String? {
// Turn a dictionary into string like foo=bar&param2=some%20thing
// Return nil if empty dictionary.
if isEmpty {
return nil
}
var s = ""
var numberAdded = 0
for (key, value) in self {
guard let key = key as? String, let value = value as? String else {
continue
}
guard let encodedKey = key.encodedForURLQuery(), let encodedValue = value.encodedForURLQuery() else {
continue
}
if numberAdded > 0 {
s += "&"
}
s += "\(encodedKey)=\(encodedValue)"
numberAdded += 1
}
if numberAdded < 1 {
return nil
}
return s
}
}

View File

@ -0,0 +1,20 @@
//
// String+RSWeb.swift
// RSWeb
//
// Created by Brent Simmons on 1/13/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import Foundation
public extension String {
public func encodedForURLQuery() -> String? {
guard let encodedString = addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return nil
}
return encodedString.replacingOccurrences(of: "&", with: "%38")
}
}

View File

@ -0,0 +1,45 @@
//
// DictionaryTests.swift
// RSWebTests
//
// Created by Brent Simmons on 1/13/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import XCTest
class DictionaryTests: XCTestCase {
func testSimpleQueryString() {
let d = ["foo": "bar", "param1": "This is a value."]
let s = d.urlQueryString()
XCTAssertTrue(s == "foo=bar&param1=This%20is%20a%20value." || s == "param1=This%20is%20a%20value.&foo=bar")
}
func testQueryStringWithAmpersand() {
let d = ["fo&o": "bar", "param1": "This is a&value."]
let s = d.urlQueryString()
XCTAssertTrue(s == "fo%38o=bar&param1=This%20is%20a%38value." || s == "param1=This%20is%20a%38value.&fo%38o=bar")
}
func testQueryStringWithAccentedCharacters() {
let d = ["fée": "bør"]
let s = d.urlQueryString()
XCTAssertTrue(s == "f%C3%A9e=b%C3%B8r")
}
func testQueryStringWithEmoji() {
let d = ["🌴e": "bar🎩🌴"]
let s = d.urlQueryString()
XCTAssertTrue(s == "%F0%9F%8C%B4e=bar%F0%9F%8E%A9%F0%9F%8C%B4")
}
}

View File

@ -0,0 +1,24 @@
//
// StringTests.swift
// RSWebTests
//
// Created by Brent Simmons on 1/13/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import XCTest
class StringTests: XCTestCase {
func testURLQueryEncoding() {
var s = "foo".encodedForURLQuery()
XCTAssertEqual(s, "foo")
s = "foo bar".encodedForURLQuery()
XCTAssertEqual(s, "foo%20bar")
s = "foo bar &well".encodedForURLQuery()
XCTAssertEqual(s, "foo%20bar%20%38well")
}
}