60 Commits

Author SHA1 Message Date
relikd
cee3780f71 Fix crash on macOS 10.14 2019-10-04 01:50:56 +02:00
relikd
58d7660b87 Deprecation warnings in macOS 10.14 2019-10-04 01:20:39 +02:00
relikd
37fc1093ee Version 1.0.0 release 2019-10-03 21:36:13 +02:00
relikd
b33791cae3 Rename core data store during migration 2019-10-03 21:34:56 +02:00
relikd
850351a966 Unread indicator for groups 2019-10-03 18:02:38 +02:00
relikd
ae18e93b6a Refactoring UserPrefs 2019-10-03 12:13:38 +02:00
relikd
b25565c74f Custom colors via defaults plist 2019-09-26 16:24:51 +02:00
relikd
23b5bba794 Easy access macro to disable logging 2019-09-25 21:19:21 +02:00
relikd
aa87d1be6a URLScheme for handling app URLs 2019-09-25 13:06:04 +02:00
relikd
2c028e79e0 Backup URL scheme 2019-09-23 17:38:31 +02:00
relikd
6da852f2c9 The wicked ghost must vanish! Items emerge from the dead. 2019-09-19 15:45:15 +02:00
relikd
9dbd761fe0 NSError: inCaseLog + inCasePresent 2019-09-19 11:54:33 +02:00
relikd
32f999b248 Moving files around 2019-09-18 17:26:19 +02:00
relikd
37d3a461d6 Parser for YouTube URLs (channel, user, playlist) 2019-09-18 17:21:37 +02:00
relikd
1d9275e0df RSXML2 rename 2019-09-16 01:06:12 +02:00
relikd
4075073d1b Refactoring feed download + favicon cache 2019-09-15 23:27:01 +02:00
relikd
ad607bc22b rename NS_INLINE 2019-09-15 23:14:36 +02:00
relikd
1c174cc31e UTI type according to Wikipedia 3rd party UTIs list 2019-08-21 16:23:00 +02:00
relikd
95115aa0a6 Fix UTI type: org.opml.opml 2019-08-21 13:55:19 +02:00
relikd
466e12ba5f Etag still needs -gzip replacement 2019-08-21 11:40:14 +02:00
relikd
eb32ca9617 Cache handling according to RFC 7232 2019-08-21 10:46:18 +02:00
relikd
e7dbfa5770 Update readme 2019-08-20 02:39:33 +02:00
relikd
b961a3a56c Make me default RSS reader (sandbox compatible) 2019-08-19 23:30:56 +02:00
relikd
a777b5672f Update readme 2019-08-19 00:24:46 +02:00
relikd
571aac4533 barss: URL scheme + remove 'Fix cache' button 2019-08-16 18:36:19 +02:00
relikd
e1bf7cac33 Sandboxing & hardened runtime environment 2019-08-16 11:13:42 +02:00
relikd
5392ac8ab2 Fix status info message in feed settings 2019-08-15 01:38:57 +02:00
relikd
9e7eda692b Fix adding feeds when offline or paused 2019-08-14 16:47:34 +02:00
relikd
e6f4d05213 Fix selection must be sorted 2019-08-14 11:49:06 +02:00
relikd
5ff1753858 Improved HTML tag removal 2019-08-12 18:56:52 +02:00
relikd
202005eb0d Accurate status count when updating feeds 2019-08-12 01:52:42 +02:00
relikd
cc218dfbcb @import 2019-08-12 00:25:13 +02:00
relikd
48578ea211 Separate FeedDownload into UpdateScheduler & WebFeed 2019-08-11 20:23:32 +02:00
relikd
a1f191789d If database empty, add releases feed 2019-08-11 15:31:35 +02:00
relikd
a6c8198234 Fix const + PostNotification() + RegisterNotification() 2019-08-11 13:21:30 +02:00
relikd
b081564eca Welcome message 2019-08-11 12:45:06 +02:00
relikd
c717487b0e Database options for version migration 2019-08-09 21:07:54 +02:00
relikd
dff1594926 Blue dot for unread articles 2019-08-06 20:05:16 +02:00
relikd
9f2f1e67f5 Prefer 32px favicons 2019-08-06 13:28:05 +02:00
relikd
4ae7b09944 Quick Look preview for OPML files 2019-07-29 21:50:24 +02:00
relikd
314a3ea9cb Option to export selected items 2019-07-29 01:12:58 +02:00
relikd
cb117c0f01 Associate OPML files & file type icon 2019-07-29 00:30:58 +02:00
relikd
bdc6d45a54 Handle OPML file import without drag & drop 2019-07-28 22:43:18 +02:00
relikd
cd68febd88 Show document URI for any RSXML error 2019-07-28 22:38:52 +02:00
relikd
613d1f60d5 OPML file icon 2019-07-28 19:22:01 +02:00
relikd
21e2d6706f File rename 2019-07-25 18:56:08 +02:00
relikd
d56916be7a Drag & drop support for OPML files 2019-07-25 16:50:58 +02:00
relikd
85cc12f34a Fix: keep unread state if open unread articles failed 2019-07-23 15:49:45 +02:00
relikd
666ecd154f Filter out redundant sort index calculations 2019-07-08 23:48:38 +02:00
relikd
1b96e79925 Append new items at end 2019-07-08 22:42:41 +02:00
relikd
dda219b570 Refresh interval string localizations 2019-07-08 22:40:16 +02:00
relikd
31e0821080 replace assets with .icns icon & update readme 2019-07-06 21:05:15 +02:00
relikd
8dc95dda63 Propagate 5xx server error to user + reload button. Closes #5 2019-07-06 13:27:00 +02:00
relikd
29a48384c7 Refactoring code for image drawing 2019-07-03 14:00:02 +02:00
relikd
8e712cae20 Refactoring Interface Builder UI to code equivalent 2019-07-02 11:10:34 +02:00
relikd
ba3310849c Minimum required macOS version 2019-04-29 13:26:08 +02:00
relikd
4d49b3fb38 Increment version number for v0.9.4 2019-04-02 16:04:58 +02:00
relikd
a1b91e51f9 Fix: Mark blocks of reappearing ghost items as read 2019-04-02 15:52:03 +02:00
relikd
7004db25e5 Remove unreliable 'Start on login' 2019-04-01 15:07:33 +02:00
relikd
935325af04 First, remove old articles, then add new ones. Closes #4 2019-04-01 13:29:04 +02:00
116 changed files with 6466 additions and 5047 deletions

View File

@@ -2,19 +2,84 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project does NOT adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2019-10-03
### Added
- App Signing
- Sandboxing & hardened runtime environment
- Associate OPML files (double click and right click actions in Finder)
- Quick Look preview for OPML files
- *Adding feed:* 5xx server errors have a reload button which will initiate a new download with the same URL
- *Adding feed:* Empty feed title will automatically reuse title from xml file (even if xml title changes)
- *Adding feed:* Parser for YouTube channel, user, and playlist URLs
- *Adding feed:* `⌘R` will reload the same URL
- *Settings, Feeds:* `⌘R` will reload the data source
- *Settings, Feeds:* Refresh interval string localizations
- *Settings, Feeds:* Right click menu with edit actions
- *Settings, Feeds:* Drag & Drop feeds from / to OPML file
- *Settings, Feeds:* Drag & Drop feed titles and urls as text
- *Settings, Feeds:* OPML export with selected items only
- *DB*: New table for key-value options (app version, etc.)
- *UI:* Accessibility hints for most UI elements
- *UI*: Custom colors via user defaults plist (bar icon tint & unread indicator)
- *UI:* Unread indicator for groups
- *UI*: Show welcome message upon first usage (empty db)
- Welcome message also adds Github releases feed
- Config URL scheme `barss:` with `open/preferences`, `config/fixcache`, and `backup/show`
### Fixed
- *Adding feed:* Show proper HTTP status code error message (4xx and 5xx)
- *Adding feed:* Show (HTML) extracted failure reason for 5xx server errors
- *Adding feed:* If URLs can't be resolved in the first run (5xx error), try a second time. E.g., `Done` click (issue: #5)
- *Adding feed:* Prefer favicons with size `32x32`
- *Adding feed:* Inserting feeds when offline/paused will postpone download until network is reachable again
- *Adding feed:* `Cancel` will indeed cancel download, not just continue and ignore results
- *Settings, Feeds:* Actions `delete` and `edit` use clicked items instead of selected items
- *Settings, Feeds:* Status info with accurate download count (instead of `Updating feeds …`)
- *Settings, Feeds:* Status info shows `No network connection` and `Updates paused`
- *Settings, Feeds:* After feed edit, run update scheduler immediately
- *Status Bar Menu*: Feed title is updated properly
- *UI:* If an error occurs, show document URL (path to file or web url)
- Comparison of existing articles with nonexistent guid and link
- Don't mark articles read if opening URLs failed
- Don't mark articles read that appear in the middle of a feed (ghost items)
- HTML tag removal keeps structure intact
### Changed
- *Adding feed:* Display error reason if user cancels the creation of a new feed item
- *Adding feed:* Refresh interval hotkeys set to: `⌘1``⌘6`
- *Settings, Feeds:* Single add button for feeds, groups, and separators
- *Settings, Feeds:* Always append new items at the end
- *Settings, General*: Moved `Fix cache` button to `About` text section
- *Settings, General*: Changing default feed reader is prohibited within sandbox
- *Settings, General*: [Auxiliary application](https://github.com/relikd/URL-Scheme-Defaults) for changing default feed reader
- *Status Bar Menu*: Show `(no title)` instead of `(error)`
- *Status Bar Menu*: `Update all feeds` will show error alert for broken URLs
- *DB*: Dropping table `FeedIcon` in favor of image files cache
- *UI:* Interface builder files replaced with code equivalent
- *UI:* Mark unread articles with blue dot, instead of tick mark
## [0.9.4] - 2019-04-02
### Fixed
- Article order got mixed up for some feeds (issue: #4)
- If multiple consecutive items reappear in the middle of the feed mark them read
### Changed
- *UI:* Removed checkbox `Start on login`. Use Preferences > Users > Login Items instead.
## [0.9.3] - 2019-03-14
### Added
- Changelog
- UI: Show body tag in article tooltip if abstract tag is empty
- *UI:* Show body tag in article tooltip if abstract tag is empty
### Fixed
- 'Update all feeds' will shows unread items count properly during update
- `Update all feeds` will shows unread items count properly during update
- Fixed update for feeds where all article URLs point to the same resource (issue: #3)
@@ -23,9 +88,9 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe
- Limit number of articles that are displayed in feed menu (issue: #2)
### Fixed
- Cmd+Q in preferences will close the window instead of quitting the application
- `⌘Q` in preferences will close the window instead of quitting the application
- Crash when libxml2 encountered and set an error
- libxml2 will ignore lower ascii characters (0x000x1F)
- libxml2 will ignore lower ascii characters (`0x00``0x1F`)
## [0.9.1] - 2019-02-14
@@ -33,12 +98,12 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe
- Mark single article as un/read (hold down option key and click on article)
### Fixed
- Mouse click on 'Done' button, while entering a new feed URL, will start download properly
- Mouse click on `Done` button, while entering a new feed URL, will start download properly
- Use guid url if link is not set (issue: #1)
- Issue with feeds not being detected if XML tags start after 4kb
- Support uppercase schemes (e.g., 'FEED:')
- UI: Hide 'Next update in -25yrs'
- UI: Show alert after click on 'Fix Cache'
- Support uppercase schemes (e.g., `FEED:`)
- *UI:* Hide `Next update in -25yrs`
- *UI:* Show alert after click on `Fix Cache`
### Changed
- Auto increment build number
@@ -50,8 +115,10 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe
Initial release
[Unreleased]: https://github.com/relikd/baRSS/compare/v0.9.2...HEAD
[Unreleased]: https://github.com/relikd/baRSS/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/relikd/baRSS/compare/v0.9.4...v1.0.0
[0.9.4]: https://github.com/relikd/baRSS/compare/v0.9.3...v0.9.4
[0.9.3]: https://github.com/relikd/baRSS/compare/v0.9.2...v0.9.3
[0.9.2]: https://github.com/relikd/baRSS/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/relikd/baRSS/compare/v0.9...v0.9.1
[0.9]: https://github.com/relikd/baRSS/compare/e1f36514a8aa2d5fb9a575b6eb19adc2ce4a04d9...v0.9
[0.9]: https://github.com/relikd/baRSS/compare/2fecf33d3101b0e7888bafee9d3b0f8b9cee30c6...v0.9

View File

@@ -1 +1 @@
github "relikd/RSXML" "master"
github "relikd/RSXML2" "v2.0.0"

View File

@@ -1 +1 @@
github "relikd/RSXML" "401f470ab00ab656843162e002e111331b001824"
github "relikd/RSXML2" "v2.0.0"

178
README.md
View File

@@ -1,25 +1,61 @@
# baRSS *Menu Bar RSS Reader*
![screenshot](doc/screenshot.png)
For nearly a decade I've been using the then free version of [RSS Menu](https://itunes.apple.com/us/app/rss-menu/id423069534).
However, with the release of macOS Mojave, 32bit applications are no longer supported.
Furthermore, the currently available version in the Mac App Store was last updated in 2014 (as of writing).
*baRSS* was build from scratch with a minimal footprint in mind. It will be available on the AppStore eventually.
If you want a feature to be added, drop me an email or create an issue.
Look at the other issues, in case somebody else already filed one similar.
If you like this project and want to say thank you drop me a line (or other stuff like money).
Regardless, I'll continue development as long as I'm using it on my own.
Admittedly, I've invested way too much time in this project already (1400h+) …
[![macOS 10.12+](https://img.shields.io/badge/macOS-10.12+-888)](#download--install)
[![Current release](https://img.shields.io/github/release/relikd/baRSS)](https://github.com/relikd/baRSS/releases)
[![All downloads](https://img.shields.io/github/downloads/relikd/baRSS/total)](https://github.com/relikd/baRSS/releases)
[![GitHub license](https://img.shields.io/github/license/relikd/baRSS)](LICENSE)
### Why is this project not written in Swift?
baRSS *Menu Bar RSS Reader*
=============================
Actually, I started this project with Swift. Even without adding much functionality, the app was exceeding the 10 Mb file size.
Compared to the nearly finished Alpha version with 500 Kb written in Objective-C.
The reason for that, Swift frameworks are always packed into the final application.
I decided that this level of encapsulation is a waste of space for such a small application.
![screenshot](screenshot.png)
What is it?
-----------
A RSS & Atom feed reader that lives in the system status bar.
Very much inspired by [RSS Menu](https://itunes.apple.com/us/app/rss-menu/id423069534); go ahead and check that out.
*baRSS* will automatically update feeds for you, and inform you when new content is available.
The new articles are just a menu away.
### Features
*baRSS* is unobtrusive, fast, and built from scratch with minimal footprint in mind.
The application uses less than 30 Mb memory and has a ridiculous file size of 1 Mb.
Speaking of reducing web traffic.
In contrast to other applications, *baRSS* does not save any cached web sessions or cookies as a matter of fact.
But it will reuse `ETag` and `Last-Modified` headers to avoid unnecessary transmissions.
Further, tuning the update frequently will decrease the traffic even more.
### Why create something that already existed?
First, open source is awesome!
Secondly, RSS Menu made some design decisions I didn't like.
For example, the new integrated browser window.
One thing I liked most, was the fact that feeds were opened in the default browser.
Not like 99% of the other feed readers on the market that show a separate HTML viewer window.
No rendering issues, no broken links, no content that is different from the actual news article.
I know, the whole purpose of RSS is to deliver content without the need of opening a webpage.
But for me RSS is more about being informed whenever a blog or news feed has some updated content.
E.g, subscribing to video channels without having to have an account.
### Why is this project not written in Swift?!
Actually, I started this project with Swift.
Even without adding much functionality, the app was exceeding the 10 Mb file size.
The working alpha version, written in Objective-C, had only 500 Kb.
The reason being that Swift frameworks are always packed into the final application.
Sadly, this was before Swift 5 and ABI stability.
Had I only started the project a year later…
But on the other hand, now it is macOS 10.12 compatible.
### 3rd Party Libraries
@@ -28,63 +64,105 @@ This project uses a modified version of Brent Simmons [RSXML](https://github.com
RSXML is licensed under a MIT license (same as this project).
Install
-------
Easy way: go to [releases](https://github.com/relikd/baRSS/releases) and downloaded the latest version.
Download & Install
------------------
Requires macOS Sierra (10.12) or higher.
### Easy way
Go to [releases](https://github.com/relikd/baRSS/releases) and downloaded the latest version.
### Build from source
You'll need Xcode and [Carthage](https://github.com/Carthage/Carthage#installing-carthage). The latter is optional, you can build the [RSXML](https://github.com/relikd/RSXML) library from source instead. Carthage just makes it more convenient.
You'll need Xcode and [Carthage](https://github.com/Carthage/Carthage#installing-carthage).
The latter is optional, you can build the [RSXML2](https://github.com/relikd/RSXML2) library from source instead.
Carthage just makes it more convenient.
Download and unzip this project, navigate to the root folder and run `carthage bootstrap --platform macOS`.
That's it. Open Xcode and build the project. Note, there are some compiler flags that append 'beta' to the development release. If you prefer the optimized release version go to `Product > Archive`.
Next, you need to clone [QLOPML](https://github.com/relikd/QLOPML) in the same folder where this project is.
Alternatively, you can simply delete the `QLOPML` project reference without much harm.
`QLOPML` is a Quick Look plugin for `.opml` files.
It will display the file contents whenever you hit space.
That's it.
Open Xcode and build the project.
Note, there are some compiler flags that append 'beta' to the development release.
If you prefer the optimized release version go to `Product > Archive`.
Hidden options
--------------
1) When holding down the option key, the menu will show an item to open only a few unread items at a time.
This listing contains of options that have no UI that can be configured.
Most likely, you wouldn't ever stumble upon these if not reading this chapter.
**Note:** To reset an option run `defaults delete de.relikd.baRSS {KEY}`, where `{KEY}` is an option from below.
1. If you hold down the option key and click on an article item, you can mark a single item (un-)read.
2. When holding down the option key, the menu will show an item to open only a few unread items at a time.
This number can be changed with the following Terminal command (default: 10):
```
defaults write de.relikd.baRSS openFewLinksLimit -int 10
```
```defaults write de.relikd.baRSS openFewLinksLimit -int 10```
3. In preferences you can choose to show 'Short article names'.
This will limit the number of displayed characters to 60 (default).
With this Terminal command you can customize this limit:
```
defaults write de.relikd.baRSS shortArticleNamesLimit -int 50
```
2) In preferences you can choose to show 'Short article names'. This will limit the number of displayed characters to 60 (default).
With this Terminal command you can customize this number:
4. Limit the number of displayed articles per feed menu.
**Note:** displayed unread count may be different than the unread items inside ('Open unread' will open hidden items too).
```
defaults write de.relikd.baRSS articlesInMenuLimit -int 40
```
```defaults write de.relikd.baRSS shortArticleNamesLimit -int 50```
5. You can change the appearance of colors throughout the application.
E.g., The tint color of the menu bar icon and the color of the blue dot of unread articles.
```
defaults write de.relikd.baRSS colorStatusIconTint -string "#37F"
defaults write de.relikd.baRSS colorUnreadIndicator -string "#FBA33A"
```
3) If you hold down the option key and click on an article item, you can mark a single item (un-)read.
4) Limit number of displayed articles in feed menu.
**Note:** unread count for feed and group may be different than the unread items inside (if unread articles are omitted).
```defaults write de.relikd.baRSS articlesInMenuLimit -int 40```
6. To backup your list of subscribed feeds, here is a one-liner:
```
open barss:backup && cp "$HOME/Library/Containers/de.relikd.baRSS/Data/Library/Application Support/baRSS/backup/feeds_latest.opml" "$HOME/Desktop/baRSS_backup_$(date "+%Y-%m-%d").opml"
```
ToDo
----
- [ ] Missing
- [ ] App Icon & UI icons (a shout out to all designers out there!)
- [ ] Text / UI localization
- [ ] Feeds with authentication
- [ ] Sandbox (does work, except for:)
- [ ] Default RSS application checkbox (disable or other workaround)
The following list is not exhaustive but rather a collection of nice things that will be added eventually.
I may postpone some until demand increases …
- [ ] Nice to have (... on increased demand)
- [ ] Automatically choose best update interval (e.g., avg)
- [ ] Sync with online services
- [ ] Notification Center
- [ ] Distraction Mode
- [ ] Localizations
- [ ] Feed generator for websites without feeds
- [ ] Automatically choose best update interval (e.g., avg)
- [ ] Sync with online services
- [ ] Feeds with authentication
- [ ] Notification Center
- [ ] Distraction Mode
- [ ] Distract less: Sleep timer. (e.g., disable updates during working hours)
- [ ] Distract more: Automatically open feed items
- [ ] Add support for media types
- [ ] Add support for media types
- [ ] music / video? (open media player)
- [ ] Pure image feed? (show images directly in menu)
- [ ] Per feed / group settings
- [ ] Per feed / group settings
- [ ] select launch application (e.g., for podcasts)
- [ ] exclude unread count from menu bar (e.g., unimportant feeds)
- [ ] ~~Infinite storage. (load more button)~~
- [ ] ~~Infinite storage. (load more button)~~
##### Trivia
- Start of project: __July 19, 2018__
- Estimated development time: __1940h+__
- First prototype used __feedparser python__ library

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2018 relikd. Public Domain.</string>
<key>LSBackgroundOnly</key>
<true/>
</dict>
</plist>

View File

@@ -1,49 +0,0 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
// see: http://martiancraft.com/blog/2015/01/login-items/
NSURL *mainURL = [NSURL fileURLWithPath:@"../../../../" isDirectory:YES relativeToURL:NSBundle.mainBundle.bundleURL];
NSString *mainIdent = [[NSBundle bundleWithURL:mainURL] bundleIdentifier]; // de.relikd.baRSS
NSArray<NSRunningApplication*> *arr = [NSRunningApplication runningApplicationsWithBundleIdentifier:mainIdent];
if (arr.count == 0) { // if not already running
NSArray *pathComponents = [[[NSBundle mainBundle] bundlePath] pathComponents];
pathComponents = [pathComponents subarrayWithRange:NSMakeRange(0, [pathComponents count] - 4)];
NSString *path = [NSString pathWithComponents:pathComponents];
[[NSWorkspace sharedWorkspace] launchApplication:path];
}
/*
Important: If your daemon shuts down too quickly after being launched,
launchd may think it has crashed. Daemons that continue this behavior may
be suspended and not launched again when future requests arrive. To avoid
this behavior, do not shut down for at least 10 seconds after launch.
*/
// https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html
sleep(10); // Not sure if this is necessary. However, it doesnt hurt.
[NSApp terminate:nil];
}
return 0;
}

View File

@@ -10,40 +10,63 @@
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 540F704421B6C16C0022E69D /* FeedMeta+Ext.m */; };
54195883218A061100581B79 /* Feed+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195882218A061100581B79 /* Feed+Ext.m */; };
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; };
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */ = {isa = PBXBuildFile; fileRef = 541C67C22255470B004D2CE6 /* SettingsAppearance.m */; };
54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; };
544936FB21F1E66100DEE9AA /* Statistics.m in Sources */ = {isa = PBXBuildFile; fileRef = 544936FA21F1E66100DEE9AA /* Statistics.m */; };
544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; };
544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; };
544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; };
544DCCBA212A2B4D002DBC46 /* RSXML.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
544DCCBE212A2B6F002DBC46 /* RSXML.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = 544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */; };
544DCCB9212A2B4D002DBC46 /* RSXML2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML2.framework */; };
544DCCBA212A2B4D002DBC46 /* RSXML2.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML2.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
544DCCBE212A2B6F002DBC46 /* RSXML2.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = 544DCCBD212A2B6F002DBC46 /* RSXML2.framework.dSYM */; };
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 5450100F230E9C8600F0B165 /* FeedDownload.m */; };
546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857D122802309001BA1C8 /* SettingsGeneralView.m */; };
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */; };
546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2D22C585580034E806 /* SettingsAboutView.m */; };
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */; };
546FC43F21188C78007CC3A3 /* SettingsFeeds.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC43E21188C78007CC3A3 /* SettingsFeeds.xib */; };
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC44121189975007CC3A3 /* SettingsGeneral.m */; };
546FC44421189975007CC3A3 /* SettingsGeneral.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC44221189975007CC3A3 /* SettingsGeneral.xib */; };
546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC4462118A8E6007CC3A3 /* Preferences.xib */; };
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; };
5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */; };
548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 548C6D09230C33DE003A1AAF /* NSURL+Ext.m */; };
5491005D2331435E00858AE2 /* Download3rdParty.m in Sources */ = {isa = PBXBuildFile; fileRef = 5491005C2331435E00858AE2 /* Download3rdParty.m */; };
54910067233A4D4000858AE2 /* URLScheme.m in Sources */ = {isa = PBXBuildFile; fileRef = 54910066233A4D4000858AE2 /* URLScheme.m */; };
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; };
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */; };
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A81220E723D00082C51 /* MapUnreadTotal.m */; };
54ACC28621061B3C0020715F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28521061B3C0020715F /* Assets.xcassets */; };
54A2D63922EF81A4007C61F3 /* QLOPML.qlgenerator in CopyFiles */ = {isa = PBXBuildFile; fileRef = 54A2D63822EF8193007C61F3 /* QLOPML.qlgenerator */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; };
54ACC29521061E270020715F /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* FeedDownload.m */; };
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* UpdateScheduler.m */; };
54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.m */; };
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54AD4E0B2301853D000AE386 /* NSString+Ext.m */; };
54AD4EE72305B17D000AE386 /* container-migration.plist in Resources */ = {isa = PBXBuildFile; fileRef = 54AD4EE62305B17D000AE386 /* container-migration.plist */; };
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B51703226DC339006C1B29 /* ModalFeedEditView.m */; };
54B517072270E990006C1B29 /* NSView+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B517062270E92A006C1B29 /* NSView+Ext.m */; };
54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B6F149231551B3002C94C9 /* FaviconDownload.m */; };
54B6F14E23155E1A002C94C9 /* NSURLRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */; };
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749D92204A85C0022CC6D /* BarStatusItem.m */; };
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */; };
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; };
54CC04382162532A00A48795 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54CC04372162532A00A48795 /* main.m */; };
54CC043E2162566900A48795 /* baRSS-Helper.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 54CC042C2162532800A48795 /* baRSS-Helper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
54BF444A22D0F4F300660096 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54BF444922D0F4F300660096 /* AppIcon.icns */; };
54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */; };
54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */; };
54E3C02122EE076D006E2E24 /* opml-icon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54E3C02022EE076D006E2E24 /* opml-icon.icns */; };
54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E4446B2329AE0600BBF481 /* NSError+Ext.m */; };
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E8831E211B509D00064188 /* ModalFeedEdit.m */; };
54E88321211B509D00064188 /* ModalFeedEdit.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54E8831F211B509D00064188 /* ModalFeedEdit.xib */; };
54E9CF32225914300023696F /* SettingsAbout.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9CF31225914300023696F /* SettingsAbout.m */; };
54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28221061B3B0020715F /* DBv1.xcdatamodeld */; };
54F518782162CA4F00EE856C /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54F518772162CA4F00EE856C /* ServiceManagement.framework */; };
54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F6025C21C1D4170006D338 /* OpmlExport.m */; };
54F6025D21C1D4170006D338 /* OpmlFile.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F6025C21C1D4170006D338 /* OpmlFile.m */; };
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */; };
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FE73D2212316CD003EAC65 /* BarMenu.m */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
54A2D63722EF8193007C61F3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */;
proxyType = 2;
remoteGlobalIDString = 540A649822EE78B200470937;
remoteInfo = QLOPML;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
544DCCBB212A2B4D002DBC46 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
@@ -51,7 +74,7 @@
dstPath = "";
dstSubfolderSpec = 10;
files = (
544DCCBA212A2B4D002DBC46 /* RSXML.framework in Embed Frameworks */,
544DCCBA212A2B4D002DBC46 /* RSXML2.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
@@ -62,17 +85,17 @@
dstPath = "";
dstSubfolderSpec = 16;
files = (
544DCCBE212A2B6F002DBC46 /* RSXML.framework.dSYM in CopyFiles */,
544DCCBE212A2B6F002DBC46 /* RSXML2.framework.dSYM in CopyFiles */,
);
runOnlyForDeploymentPostprocessing = 0;
};
54CC043D2162565F00A48795 /* CopyFiles */ = {
54CE4D4522EF509400E89C16 /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = Contents/Library/LoginItems;
dstPath = Contents/Library/QuickLook;
dstSubfolderSpec = 1;
files = (
54CC043E2162566900A48795 /* baRSS-Helper.app in CopyFiles */,
54A2D63922EF81A4007C61F3 /* QLOPML.qlgenerator in CopyFiles */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -87,56 +110,86 @@
54195884218E1BDB00581B79 /* NSMenu+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenu+Ext.h"; sourceTree = "<group>"; };
54195885218E1BDB00581B79 /* NSMenu+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenu+Ext.m"; sourceTree = "<group>"; };
541958872190FF1200581B79 /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = "<group>"; };
541C67C12255470B004D2CE6 /* SettingsAppearance.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsAppearance.h; sourceTree = "<group>"; };
541C67C22255470B004D2CE6 /* SettingsAppearance.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearance.m; sourceTree = "<group>"; };
54209E922117325100F3B5EF /* DrawImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DrawImage.h; sourceTree = "<group>"; };
54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = "<group>"; };
544936F921F1E66100DEE9AA /* Statistics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Statistics.h; sourceTree = "<group>"; };
544936FA21F1E66100DEE9AA /* Statistics.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Statistics.m; sourceTree = "<group>"; };
544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = "<group>"; };
544B01192114B41200386E5C /* ModalSheet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalSheet.m; sourceTree = "<group>"; };
544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = "<group>"; };
544B011C2114EE9100386E5C /* AppHook.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppHook.m; sourceTree = "<group>"; };
544DCCB8212A2B4D002DBC46 /* RSXML.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSXML.framework; path = Carthage/Build/Mac/RSXML.framework; sourceTree = "<group>"; };
544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = RSXML.framework.dSYM; path = Carthage/Build/Mac/RSXML.framework.dSYM; sourceTree = "<group>"; };
544DCCB8212A2B4D002DBC46 /* RSXML2.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSXML2.framework; path = Carthage/Build/Mac/RSXML2.framework; sourceTree = "<group>"; };
544DCCBD212A2B6F002DBC46 /* RSXML2.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = RSXML2.framework.dSYM; path = Carthage/Build/Mac/RSXML2.framework.dSYM; sourceTree = "<group>"; };
5450100E230E9C8600F0B165 /* FeedDownload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedDownload.h; sourceTree = "<group>"; };
5450100F230E9C8600F0B165 /* FeedDownload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = "<group>"; };
546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearanceView.m; sourceTree = "<group>"; };
546A6A2B22C584AF0034E806 /* SettingsAppearanceView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAppearanceView.h; sourceTree = "<group>"; };
546A6A2D22C585580034E806 /* SettingsAboutView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAboutView.m; sourceTree = "<group>"; };
546A6A2E22C585580034E806 /* SettingsAboutView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAboutView.h; sourceTree = "<group>"; };
546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsFeeds.h; sourceTree = "<group>"; };
546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsFeeds.m; sourceTree = "<group>"; };
546FC43E21188C78007CC3A3 /* SettingsFeeds.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsFeeds.xib; sourceTree = "<group>"; };
546FC44021189975007CC3A3 /* SettingsGeneral.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsGeneral.h; sourceTree = "<group>"; };
546FC44121189975007CC3A3 /* SettingsGeneral.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneral.m; sourceTree = "<group>"; };
546FC44221189975007CC3A3 /* SettingsGeneral.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsGeneral.xib; sourceTree = "<group>"; };
546FC4462118A8E6007CC3A3 /* Preferences.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Preferences.xib; sourceTree = "<group>"; };
5477D34C21233C62002BA27F /* FeedGroup+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedGroup+Ext.h"; sourceTree = "<group>"; };
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = "<group>"; };
5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsFeedsView.h; sourceTree = "<group>"; };
5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsFeedsView.m; sourceTree = "<group>"; };
54892F1D2235285700271CBA /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
548C6D08230C33DE003A1AAF /* NSURL+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURL+Ext.h"; sourceTree = "<group>"; };
548C6D09230C33DE003A1AAF /* NSURL+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURL+Ext.m"; sourceTree = "<group>"; };
5491005B2331435E00858AE2 /* Download3rdParty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Download3rdParty.h; sourceTree = "<group>"; };
5491005C2331435E00858AE2 /* Download3rdParty.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Download3rdParty.m; sourceTree = "<group>"; };
54910065233A4D4000858AE2 /* URLScheme.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = URLScheme.h; sourceTree = "<group>"; };
54910066233A4D4000858AE2 /* URLScheme.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = URLScheme.m; sourceTree = "<group>"; };
5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = "<group>"; };
5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = "<group>"; };
54A07A7D220E04CF00082C51 /* NSFetchRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSFetchRequest+Ext.h"; sourceTree = "<group>"; };
54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSFetchRequest+Ext.m"; sourceTree = "<group>"; };
54A07A80220E723D00082C51 /* MapUnreadTotal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MapUnreadTotal.h; sourceTree = "<group>"; };
54A07A81220E723D00082C51 /* MapUnreadTotal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MapUnreadTotal.m; sourceTree = "<group>"; };
54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = QLOPML.xcodeproj; path = ../QLOPML/QLOPML.xcodeproj; sourceTree = "<group>"; };
54ACC27C21061B3B0020715F /* baRSS Beta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "baRSS Beta.app"; sourceTree = BUILT_PRODUCTS_DIR; };
54ACC28321061B3B0020715F /* DBv1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DBv1.xcdatamodel; sourceTree = "<group>"; };
54ACC28521061B3C0020715F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
54ACC28A21061B3C0020715F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
54ACC28B21061B3C0020715F /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
54ACC29321061E270020715F /* FeedDownload.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FeedDownload.h; sourceTree = "<group>"; };
54ACC29421061E270020715F /* FeedDownload.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = "<group>"; };
54ACC29321061E270020715F /* UpdateScheduler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UpdateScheduler.h; sourceTree = "<group>"; };
54ACC29421061E270020715F /* UpdateScheduler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UpdateScheduler.m; sourceTree = "<group>"; };
54ACC29621061FBA0020715F /* Preferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Preferences.h; sourceTree = "<group>"; };
54ACC29721061FBA0020715F /* Preferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Preferences.m; sourceTree = "<group>"; };
54AD4E0A2301853D000AE386 /* NSString+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSString+Ext.h"; sourceTree = "<group>"; };
54AD4E0B2301853D000AE386 /* NSString+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+Ext.m"; sourceTree = "<group>"; };
54AD4EE42305AF60000AE386 /* baRSS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = baRSS.entitlements; sourceTree = "<group>"; };
54AD4EE62305B17D000AE386 /* container-migration.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "container-migration.plist"; sourceTree = "<group>"; };
54B51702226DC339006C1B29 /* ModalFeedEditView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ModalFeedEditView.h; sourceTree = "<group>"; };
54B51703226DC339006C1B29 /* ModalFeedEditView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEditView.m; sourceTree = "<group>"; };
54B517052270E8C6006C1B29 /* NSView+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSView+Ext.h"; sourceTree = "<group>"; };
54B517062270E92A006C1B29 /* NSView+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSView+Ext.m"; sourceTree = "<group>"; };
54B6F148231551B3002C94C9 /* FaviconDownload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FaviconDownload.h; sourceTree = "<group>"; };
54B6F149231551B3002C94C9 /* FaviconDownload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FaviconDownload.m; sourceTree = "<group>"; };
54B6F14C23155E1A002C94C9 /* NSURLRequest+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURLRequest+Ext.h"; sourceTree = "<group>"; };
54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURLRequest+Ext.m"; sourceTree = "<group>"; };
54B749D82204A85C0022CC6D /* BarStatusItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarStatusItem.h; sourceTree = "<group>"; };
54B749D92204A85C0022CC6D /* BarStatusItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BarStatusItem.m; sourceTree = "<group>"; };
54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedArticle+Ext.h"; sourceTree = "<group>"; };
54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedArticle+Ext.m"; sourceTree = "<group>"; };
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = "<group>"; };
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = "<group>"; };
54CC042C2162532800A48795 /* baRSS-Helper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "baRSS-Helper.app"; sourceTree = BUILT_PRODUCTS_DIR; };
54CC04362162532A00A48795 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
54CC04372162532A00A48795 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
54BF444922D0F4F300660096 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = "<group>"; };
54D55D7122E624CD00057B98 /* SettingsFeeds+DragDrop.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SettingsFeeds+DragDrop.h"; sourceTree = "<group>"; };
54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SettingsFeeds+DragDrop.m"; sourceTree = "<group>"; };
54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RefreshStatisticsView.h; sourceTree = "<group>"; };
54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RefreshStatisticsView.m; sourceTree = "<group>"; };
54D857D022802309001BA1C8 /* SettingsGeneralView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsGeneralView.h; sourceTree = "<group>"; };
54D857D122802309001BA1C8 /* SettingsGeneralView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneralView.m; sourceTree = "<group>"; };
54E3C02022EE076D006E2E24 /* opml-icon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = "opml-icon.icns"; sourceTree = "<group>"; };
54E4446A2329AE0600BBF481 /* NSError+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSError+Ext.h"; sourceTree = "<group>"; };
54E4446B2329AE0600BBF481 /* NSError+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSError+Ext.m"; sourceTree = "<group>"; };
54E8831D211B509D00064188 /* ModalFeedEdit.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalFeedEdit.h; sourceTree = "<group>"; };
54E8831E211B509D00064188 /* ModalFeedEdit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEdit.m; sourceTree = "<group>"; };
54E8831F211B509D00064188 /* ModalFeedEdit.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ModalFeedEdit.xib; sourceTree = "<group>"; };
54F518772162CA4F00EE856C /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; };
54F6025B21C1D4170006D338 /* OpmlExport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OpmlExport.h; sourceTree = "<group>"; };
54F6025C21C1D4170006D338 /* OpmlExport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OpmlExport.m; sourceTree = "<group>"; };
54E9CF30225914300023696F /* SettingsAbout.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsAbout.h; sourceTree = "<group>"; };
54E9CF31225914300023696F /* SettingsAbout.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsAbout.m; sourceTree = "<group>"; };
54F6025B21C1D4170006D338 /* OpmlFile.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OpmlFile.h; sourceTree = "<group>"; };
54F6025C21C1D4170006D338 /* OpmlFile.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OpmlFile.m; sourceTree = "<group>"; };
54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StoreCoordinator.h; sourceTree = "<group>"; };
54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StoreCoordinator.m; sourceTree = "<group>"; };
54FE73D1212316CD003EAC65 /* BarMenu.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarMenu.h; sourceTree = "<group>"; };
@@ -148,15 +201,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
54F518782162CA4F00EE856C /* ServiceManagement.framework in Frameworks */,
544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
54CC04292162532800A48795 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
544DCCB9212A2B4D002DBC46 /* RSXML2.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -178,53 +223,45 @@
path = "Status Bar Menu";
sourceTree = "<group>";
};
544936F721F1E51E00DEE9AA /* Helper */ = {
544936F721F1E51E00DEE9AA /* NSCategories */ = {
isa = PBXGroup;
children = (
54209E922117325100F3B5EF /* DrawImage.h */,
54209E932117325100F3B5EF /* DrawImage.m */,
54ACC29321061E270020715F /* FeedDownload.h */,
54ACC29421061E270020715F /* FeedDownload.m */,
544936F921F1E66100DEE9AA /* Statistics.h */,
544936FA21F1E66100DEE9AA /* Statistics.m */,
54B517052270E8C6006C1B29 /* NSView+Ext.h */,
54B517062270E92A006C1B29 /* NSView+Ext.m */,
54AD4E0A2301853D000AE386 /* NSString+Ext.h */,
54AD4E0B2301853D000AE386 /* NSString+Ext.m */,
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */,
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */,
54E4446A2329AE0600BBF481 /* NSError+Ext.h */,
54E4446B2329AE0600BBF481 /* NSError+Ext.m */,
548C6D08230C33DE003A1AAF /* NSURL+Ext.h */,
548C6D09230C33DE003A1AAF /* NSURL+Ext.m */,
54B6F14C23155E1A002C94C9 /* NSURLRequest+Ext.h */,
54B6F14D23155E1A002C94C9 /* NSURLRequest+Ext.m */,
);
path = Helper;
path = NSCategories;
sourceTree = "<group>";
};
544FBD4321064AEB008A260C /* Frameworks */ = {
isa = PBXGroup;
children = (
54F518772162CA4F00EE856C /* ServiceManagement.framework */,
544DCCB8212A2B4D002DBC46 /* RSXML.framework */,
544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */,
544DCCB8212A2B4D002DBC46 /* RSXML2.framework */,
544DCCBD212A2B6F002DBC46 /* RSXML2.framework.dSYM */,
);
name = Frameworks;
sourceTree = "<group>";
};
546FC44521189ADC007CC3A3 /* General Tab */ = {
isa = PBXGroup;
children = (
5496B50F214D6275003ED4ED /* UserPrefs.h */,
5496B510214D6275003ED4ED /* UserPrefs.m */,
546FC44021189975007CC3A3 /* SettingsGeneral.h */,
546FC44121189975007CC3A3 /* SettingsGeneral.m */,
546FC44221189975007CC3A3 /* SettingsGeneral.xib */,
);
path = "General Tab";
sourceTree = "<group>";
};
546FC44D2118B357007CC3A3 /* Preferences */ = {
isa = PBXGroup;
children = (
54ACC29621061FBA0020715F /* Preferences.h */,
54ACC29721061FBA0020715F /* Preferences.m */,
546FC4462118A8E6007CC3A3 /* Preferences.xib */,
544B01182114B41200386E5C /* ModalSheet.h */,
544B01192114B41200386E5C /* ModalSheet.m */,
546FC44521189ADC007CC3A3 /* General Tab */,
54D857CF228022AB001BA1C8 /* General Tab */,
54E88323211B542E00064188 /* Feeds Tab */,
54D857D3228035D4001BA1C8 /* Appearance Tab */,
54D857D72280C367001BA1C8 /* About Tab */,
);
path = Preferences;
sourceTree = "<group>";
@@ -248,13 +285,21 @@
path = "Core Data";
sourceTree = "<group>";
};
54A2D63422EF8193007C61F3 /* Products */ = {
isa = PBXGroup;
children = (
54A2D63822EF8193007C61F3 /* QLOPML.qlgenerator */,
);
name = Products;
sourceTree = "<group>";
};
54ACC27321061B3B0020715F = {
isa = PBXGroup;
children = (
540CD14821C094A2004AB594 /* README.md */,
54892F1D2235285700271CBA /* CHANGELOG.md */,
54ACC27E21061B3B0020715F /* baRSS */,
54CC042D2162532800A48795 /* baRSS-Helper */,
54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */,
54ACC27D21061B3B0020715F /* Products */,
544FBD4321064AEB008A260C /* Frameworks */,
);
@@ -264,7 +309,6 @@
isa = PBXGroup;
children = (
54ACC27C21061B3B0020715F /* baRSS Beta.app */,
54CC042C2162532800A48795 /* baRSS-Helper.app */,
);
name = Products;
sourceTree = "<group>";
@@ -272,28 +316,73 @@
54ACC27E21061B3B0020715F /* baRSS */ = {
isa = PBXGroup;
children = (
54AD4EE42305AF60000AE386 /* baRSS.entitlements */,
541958872190FF1200581B79 /* Constants.h */,
544B011B2114EE9100386E5C /* AppHook.h */,
544B011C2114EE9100386E5C /* AppHook.m */,
541958872190FF1200581B79 /* Constants.h */,
544936F721F1E51E00DEE9AA /* Helper */,
54E9CF2F225913850023696F /* Helper */,
544936F721F1E51E00DEE9AA /* NSCategories */,
541A90EF21257D4F002680A6 /* Status Bar Menu */,
54A07A8322105E0800082C51 /* Core Data */,
54AD4E04230084FD000AE386 /* Feed Import */,
546FC44D2118B357007CC3A3 /* Preferences */,
54ACC28521061B3C0020715F /* Assets.xcassets */,
54ACC28A21061B3C0020715F /* Info.plist */,
54F7101322EE0DDA006985D1 /* Artwork */,
54ACC28B21061B3C0020715F /* main.m */,
54AD4EE62305B17D000AE386 /* container-migration.plist */,
54ACC28221061B3B0020715F /* DBv1.xcdatamodeld */,
);
path = baRSS;
sourceTree = "<group>";
};
54CC042D2162532800A48795 /* baRSS-Helper */ = {
54AD4E04230084FD000AE386 /* Feed Import */ = {
isa = PBXGroup;
children = (
54CC04362162532A00A48795 /* Info.plist */,
54CC04372162532A00A48795 /* main.m */,
54ACC29321061E270020715F /* UpdateScheduler.h */,
54ACC29421061E270020715F /* UpdateScheduler.m */,
5491005B2331435E00858AE2 /* Download3rdParty.h */,
5491005C2331435E00858AE2 /* Download3rdParty.m */,
5450100E230E9C8600F0B165 /* FeedDownload.h */,
5450100F230E9C8600F0B165 /* FeedDownload.m */,
54B6F148231551B3002C94C9 /* FaviconDownload.h */,
54B6F149231551B3002C94C9 /* FaviconDownload.m */,
54F6025B21C1D4170006D338 /* OpmlFile.h */,
54F6025C21C1D4170006D338 /* OpmlFile.m */,
);
path = "baRSS-Helper";
path = "Feed Import";
sourceTree = "<group>";
};
54D857CF228022AB001BA1C8 /* General Tab */ = {
isa = PBXGroup;
children = (
546FC44021189975007CC3A3 /* SettingsGeneral.h */,
546FC44121189975007CC3A3 /* SettingsGeneral.m */,
54D857D022802309001BA1C8 /* SettingsGeneralView.h */,
54D857D122802309001BA1C8 /* SettingsGeneralView.m */,
);
path = "General Tab";
sourceTree = "<group>";
};
54D857D3228035D4001BA1C8 /* Appearance Tab */ = {
isa = PBXGroup;
children = (
541C67C12255470B004D2CE6 /* SettingsAppearance.h */,
541C67C22255470B004D2CE6 /* SettingsAppearance.m */,
546A6A2B22C584AF0034E806 /* SettingsAppearanceView.h */,
546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */,
);
path = "Appearance Tab";
sourceTree = "<group>";
};
54D857D72280C367001BA1C8 /* About Tab */ = {
isa = PBXGroup;
children = (
54E9CF30225914300023696F /* SettingsAbout.h */,
54E9CF31225914300023696F /* SettingsAbout.m */,
546A6A2E22C585580034E806 /* SettingsAboutView.h */,
546A6A2D22C585580034E806 /* SettingsAboutView.m */,
);
path = "About Tab";
sourceTree = "<group>";
};
54E88323211B542E00064188 /* Feeds Tab */ = {
@@ -301,16 +390,42 @@
children = (
546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */,
546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */,
546FC43E21188C78007CC3A3 /* SettingsFeeds.xib */,
54D55D7122E624CD00057B98 /* SettingsFeeds+DragDrop.h */,
54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */,
54E8831D211B509D00064188 /* ModalFeedEdit.h */,
54E8831E211B509D00064188 /* ModalFeedEdit.m */,
54E8831F211B509D00064188 /* ModalFeedEdit.xib */,
54F6025B21C1D4170006D338 /* OpmlExport.h */,
54F6025C21C1D4170006D338 /* OpmlExport.m */,
5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */,
5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */,
54B51702226DC339006C1B29 /* ModalFeedEditView.h */,
54B51703226DC339006C1B29 /* ModalFeedEditView.m */,
54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */,
54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */,
);
path = "Feeds Tab";
sourceTree = "<group>";
};
54E9CF2F225913850023696F /* Helper */ = {
isa = PBXGroup;
children = (
5496B50F214D6275003ED4ED /* UserPrefs.h */,
5496B510214D6275003ED4ED /* UserPrefs.m */,
54209E922117325100F3B5EF /* DrawImage.h */,
54209E932117325100F3B5EF /* DrawImage.m */,
54910065233A4D4000858AE2 /* URLScheme.h */,
54910066233A4D4000858AE2 /* URLScheme.m */,
);
path = Helper;
sourceTree = "<group>";
};
54F7101322EE0DDA006985D1 /* Artwork */ = {
isa = PBXGroup;
children = (
54BF444922D0F4F300660096 /* AppIcon.icns */,
54E3C02022EE076D006E2E24 /* opml-icon.icns */,
);
path = Artwork;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -322,9 +437,10 @@
54ACC27921061B3B0020715F /* Frameworks */,
54ACC27A21061B3B0020715F /* Resources */,
544DCCBB212A2B4D002DBC46 /* Embed Frameworks */,
54CE4D4522EF509400E89C16 /* CopyFiles */,
544DCCBC212A2B5A002DBC46 /* CopyFiles */,
54CC043D2162565F00A48795 /* CopyFiles */,
543964EE2215C27B0016AAA3 /* ShellScript */,
54FB05D12305BFAB00A088AD /* ShellScript */,
);
buildRules = (
);
@@ -335,23 +451,6 @@
productReference = 54ACC27C21061B3B0020715F /* baRSS Beta.app */;
productType = "com.apple.product-type.application";
};
54CC042B2162532800A48795 /* baRSS-Helper */ = {
isa = PBXNativeTarget;
buildConfigurationList = 54CC043C2162532A00A48795 /* Build configuration list for PBXNativeTarget "baRSS-Helper" */;
buildPhases = (
54CC04282162532800A48795 /* Sources */,
54CC04292162532800A48795 /* Frameworks */,
54CC042A2162532800A48795 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "baRSS-Helper";
productName = "baRSS-Helper";
productReference = 54CC042C2162532800A48795 /* baRSS-Helper.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -368,16 +467,11 @@
com.apple.ApplicationGroups.Mac = {
enabled = 0;
};
com.apple.HardenedRuntime = {
enabled = 1;
};
com.apple.Sandbox = {
enabled = 0;
};
};
};
54CC042B2162532800A48795 = {
CreatedOnToolsVersion = 9.4.1;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 0;
enabled = 1;
};
};
};
@@ -394,31 +488,37 @@
mainGroup = 54ACC27321061B3B0020715F;
productRefGroup = 54ACC27D21061B3B0020715F /* Products */;
projectDirPath = "";
projectReferences = (
{
ProductGroup = 54A2D63422EF8193007C61F3 /* Products */;
ProjectRef = 54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */;
},
);
projectRoot = "";
targets = (
54ACC27B21061B3B0020715F /* baRSS */,
54CC042B2162532800A48795 /* baRSS-Helper */,
);
};
/* End PBXProject section */
/* Begin PBXReferenceProxy section */
54A2D63822EF8193007C61F3 /* QLOPML.qlgenerator */ = {
isa = PBXReferenceProxy;
fileType = wrapper.cfbundle;
path = QLOPML.qlgenerator;
remoteRef = 54A2D63722EF8193007C61F3 /* PBXContainerItemProxy */;
sourceTree = BUILT_PRODUCTS_DIR;
};
/* End PBXReferenceProxy section */
/* Begin PBXResourcesBuildPhase section */
54ACC27A21061B3B0020715F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */,
54E88321211B509D00064188 /* ModalFeedEdit.xib in Resources */,
54ACC28621061B3C0020715F /* Assets.xcassets in Resources */,
546FC44421189975007CC3A3 /* SettingsGeneral.xib in Resources */,
546FC43F21188C78007CC3A3 /* SettingsFeeds.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
54CC042A2162532800A48795 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
54BF444A22D0F4F300660096 /* AppIcon.icns in Resources */,
54AD4EE72305B17D000AE386 /* container-migration.plist in Resources */,
54E3C02122EE076D006E2E24 /* opml-icon.icns in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -442,6 +542,24 @@
shellPath = /bin/sh;
shellScript = "# https://crunchybagel.com/auto-incrementing-build-numbers-in-xcode/\nbuildNumber=$(/usr/libexec/PlistBuddy -c \"Print CFBundleVersion\" \"${PROJECT_DIR}/${INFOPLIST_FILE}\")\nbuildNumber=$(($buildNumber + 1))\n/usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $buildNumber\" \"${PROJECT_DIR}/${INFOPLIST_FILE}\"\n";
};
54FB05D12305BFAB00A088AD /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# replace '$(PRODUCT_NAME)' with actual value\nfile=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/container-migration.plist\"\nsed -i '' \"s/\\$(PRODUCT_NAME)/${PRODUCT_NAME}/\" \"$file\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -449,41 +567,50 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */,
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */,
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */,
54E9CF32225914300023696F /* SettingsAbout.m in Sources */,
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */,
54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */,
54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */,
544B011D2114EE9100386E5C /* AppHook.m in Sources */,
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */,
54ACC29521061E270020715F /* FeedDownload.m in Sources */,
546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */,
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */,
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */,
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */,
544936FB21F1E66100DEE9AA /* Statistics.m in Sources */,
54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */,
54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */,
5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */,
540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */,
54ACC28C21061B3C0020715F /* main.m in Sources */,
54B6F14E23155E1A002C94C9 /* NSURLRequest+Ext.m in Sources */,
54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */,
544B011A2114B41200386E5C /* ModalSheet.m in Sources */,
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */,
54ACC29821061FBA0020715F /* Preferences.m in Sources */,
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */,
54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */,
54910067233A4D4000858AE2 /* URLScheme.m in Sources */,
54F6025D21C1D4170006D338 /* OpmlFile.m in Sources */,
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */,
546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */,
54B517072270E990006C1B29 /* NSView+Ext.m in Sources */,
54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */,
5491005D2331435E00858AE2 /* Download3rdParty.m in Sources */,
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */,
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */,
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */,
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
54195883218A061100581B79 /* Feed+Ext.m in Sources */,
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */,
54209E942117325100F3B5EF /* DrawImage.m in Sources */,
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */,
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
54CC04282162532800A48795 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
54CC04382162532A00A48795 /* main.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
@@ -528,10 +655,7 @@
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_PREPROCESSOR_DEFINITIONS = "DEBUG=1";
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
@@ -599,7 +723,6 @@
54ACC29121061B3C0020715F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES;
CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES;
CLANG_ANALYZER_SECURITY_INSECUREAPI_STRCPY = YES;
@@ -612,15 +735,22 @@
CLANG_WARN_OBJC_EXPLICIT_OWNERSHIP_TYPE = YES;
CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES;
CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES;
CODE_SIGN_STYLE = Manual;
CODE_SIGN_ENTITLEMENTS = baRSS/baRSS.entitlements;
CODE_SIGN_IDENTITY = "Mac Developer";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = UY657LKNHJ;
EMBED_ASSET_PACKS_IN_PRODUCT_BUNDLE = NO;
ENABLE_HARDENED_RUNTIME = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/Carthage/Build/Mac",
);
GCC_PREPROCESSOR_DEFINITIONS = (
"APP_NAME=\"\\@\\\"$(PRODUCT_NAME)\\\"\"",
"$(inherited)",
);
GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS = YES;
GCC_WARN_ABOUT_MISSING_NEWLINE = YES;
GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES;
@@ -638,7 +768,6 @@
"@executable_path/../Frameworks",
"$(FRAMEWORK_SEARCH_PATHS)",
);
MACOSX_DEPLOYMENT_TARGET = 10.12;
PRODUCT_BUNDLE_IDENTIFIER = de.relikd.baRSS.beta;
PRODUCT_NAME = "$(TARGET_NAME) Beta";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -649,7 +778,6 @@
54ACC29221061B3C0020715F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES;
CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES;
CLANG_ANALYZER_SECURITY_INSECUREAPI_STRCPY = YES;
@@ -662,15 +790,22 @@
CLANG_WARN_OBJC_EXPLICIT_OWNERSHIP_TYPE = YES;
CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES;
CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES;
CODE_SIGN_STYLE = Manual;
CODE_SIGN_ENTITLEMENTS = baRSS/baRSS.entitlements;
CODE_SIGN_IDENTITY = "Mac Developer";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = UY657LKNHJ;
EMBED_ASSET_PACKS_IN_PRODUCT_BUNDLE = NO;
ENABLE_HARDENED_RUNTIME = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/Carthage/Build/Mac",
);
GCC_PREPROCESSOR_DEFINITIONS = (
"APP_NAME=\"\\@\\\"$(PRODUCT_NAME)\\\"\"",
"$(inherited)",
);
GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS = YES;
GCC_WARN_ABOUT_MISSING_NEWLINE = YES;
GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES;
@@ -688,51 +823,12 @@
"@executable_path/../Frameworks",
"$(FRAMEWORK_SEARCH_PATHS)",
);
MACOSX_DEPLOYMENT_TARGET = 10.12;
PRODUCT_BUNDLE_IDENTIFIER = de.relikd.baRSS;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
};
name = Release;
};
54CC043A2162532A00A48795 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = "baRSS-Helper/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.12;
PRODUCT_BUNDLE_IDENTIFIER = "de.relikd.baRSS-Helper";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
};
name = Debug;
};
54CC043B2162532A00A48795 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = "baRSS-Helper/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.12;
PRODUCT_BUNDLE_IDENTIFIER = "de.relikd.baRSS-Helper";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -754,15 +850,6 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
54CC043C2162532A00A48795 /* Build configuration list for PBXNativeTarget "baRSS-Helper" */ = {
isa = XCConfigurationList;
buildConfigurations = (
54CC043A2162532A00A48795 /* Debug */,
54CC043B2162532A00A48795 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCVersionGroup section */

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1000"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "54ACC27B21061B3B0020715F"
BuildableName = "baRSS.app"
BlueprintName = "baRSS"
ReferencedContainer = "container:baRSS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "54ACC27B21061B3B0020715F"
BuildableName = "baRSS.app"
BlueprintName = "baRSS"
ReferencedContainer = "container:baRSS.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
stopOnEveryMainThreadCheckerIssue = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "54ACC27B21061B3B0020715F"
BuildableName = "baRSS.app"
BlueprintName = "baRSS"
ReferencedContainer = "container:baRSS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.SQLDebug 4"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.MigrationDebug 1"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "CFNETWORK_DIAGNOSTICS"
value = "3"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "54ACC27B21061B3B0020715F"
BuildableName = "baRSS.app"
BlueprintName = "baRSS"
ReferencedContainer = "container:baRSS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -20,14 +20,12 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
#import <CoreData/CoreData.h>
@class BarStatusItem;
@import Cocoa;
@class BarStatusItem, Preferences;
@interface AppHook : NSApplication <NSApplicationDelegate>
@property (readonly, strong) BarStatusItem *statusItem;
@property (readonly, strong) NSPersistentContainer *persistentContainer;
- (void)openPreferences;
- (Preferences*)openPreferences;
@end

View File

@@ -21,12 +21,19 @@
// SOFTWARE.
#import "AppHook.h"
#import "BarStatusItem.h"
#import "FeedDownload.h"
#import "DrawImage.h"
#import "UserPrefs.h"
#import "Preferences.h"
#import "BarStatusItem.h"
#import "UpdateScheduler.h"
#import "StoreCoordinator.h"
#import "SettingsFeeds+DragDrop.h"
#import "URLScheme.h"
#import "NSURL+Ext.h"
#import "NSError+Ext.h"
@interface AppHook()
@property (strong) Preferences *prefWindow;
@property (strong) NSWindowController *prefWindow;
@end
@implementation AppHook
@@ -38,35 +45,36 @@
}
- (void)applicationWillFinishLaunching:(NSNotification *)notification {
UserPrefsInit();
RegisterImageViewNames();
_statusItem = [BarStatusItem new];
NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager];
[appleEventManager setEventHandler:self andSelector:@selector(handleGetURLEvent:withReplyEvent:)
[appleEventManager setEventHandler:self andSelector:@selector(handleAppleEvent:withReplyEvent:)
forEventClass:kInternetEventClass andEventID:kAEGetURL];
[self migrateVersionUpdate];
}
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
// feed://https://feeds.feedburner.com/simpledesktops
[FeedDownload registerNetworkChangeNotification]; // will call update scheduler
BOOL initial = [[NSURL faviconsCacheURL] mkdir];
[_statusItem asyncReloadUnreadCount];
[UpdateScheduler registerNetworkChangeNotification]; // will call update scheduler
if ([StoreCoordinator isEmpty]) {
[_statusItem showWelcomeMessage];
[UpdateScheduler autoDownloadAndParseUpdateURL];
} else {
// mostly for version migration 0.9.4 ~> 1.0 (favicon storage)
if (initial) [UpdateScheduler updateAllFavicons];
}
}
- (void)applicationWillTerminate:(NSNotification *)aNotification {
[FeedDownload unregisterNetworkChangeNotification];
[UpdateScheduler unregisterNetworkChangeNotification];
}
- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
NSString *url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
NSString *scheme = [[[NSURL URLWithString:url] scheme] lowercaseString];
url = [url substringFromIndex:scheme.length + 1]; // + ':'
if (url.length >= 2 && [[url substringToIndex:2] isEqualToString:@"//"]) {
url = [url substringFromIndex:2];
}
if ([scheme isEqualToString:@"feed"]) {
[FeedDownload autoDownloadAndParseURL:url successBlock:^{
[self reopenPreferencesIfOpen];
}];
}
// TODO: handle other app schemes like configuration export / import
// NSURLComponents *comp = [NSURLComponents componentsWithString:url];
/// Called during application start. Perform any version migration updates here.
- (void)migrateVersionUpdate {
// Currently unused, but you'll be thankful in the future for a previously saved version number
[StoreCoordinator setOption:@"app-version" value: UserPrefsAppVersion()];
}
@@ -74,32 +82,21 @@
/// Called whenever the user activates the preferences (either through menu click or hotkey).
- (void)openPreferences {
- (Preferences*)openPreferences {
if (!self.prefWindow) {
self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"];
self.prefWindow.window.title = [NSString stringWithFormat:@"%@ %@", NSProcessInfo.processInfo.processName,
NSLocalizedString(@"Preferences", nil)];
self.prefWindow = [[NSWindowController alloc] initWithWindow:[Preferences window]];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(preferencesClosed:) name:NSWindowWillCloseNotification object:self.prefWindow.window];
}
[NSApp activateIgnoringOtherApps:YES];
[self.prefWindow showWindow:nil];
return (Preferences*)self.prefWindow.window;
}
/// Callback method after user closes the preferences window.
- (void)preferencesClosed:(id)sender {
[[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:self.prefWindow.window];
self.prefWindow = nil;
[FeedDownload scheduleUpdateForUpcomingFeeds];
}
/// Close previous preferences window and re-open at the same position (will drop undo manager stack!)
- (void)reopenPreferencesIfOpen {
if (self.prefWindow) {
CGPoint screenPoint = self.prefWindow.window.frame.origin;
[self.prefWindow close];
[self openPreferences];
[self.prefWindow.window setFrameOrigin:screenPoint];
}
[UpdateScheduler scheduleNextFeed];
}
@@ -108,44 +105,38 @@
@synthesize persistentContainer = _persistentContainer;
/// The persistent container for the application. This implementation creates and returns a container, having loaded the store for the application to it.
- (NSPersistentContainer *)persistentContainer {
// The persistent container for the application. This implementation creates and returns a container, having loaded the store for the application to it.
@synchronized (self) {
if (_persistentContainer == nil) {
_persistentContainer = [[NSPersistentContainer alloc] initWithName:@"DBv1"];
NSManagedObjectModel *mom = [NSManagedObjectModel mergedModelFromBundles:nil];
_persistentContainer = [[NSPersistentContainer alloc] initWithName:@"Library" managedObjectModel:mom];
[_persistentContainer loadPersistentStoresWithCompletionHandler:^(NSPersistentStoreDescription *storeDescription, NSError *error) {
if (error != nil) {
NSLog(@"Couldn't read NSPersistentContainer: %@, %@", error, error.userInfo);
if ([error inCaseLog:"Couldn't read NSPersistentContainer"])
abort();
}
}];
}
}
return _persistentContainer;
}
/// Save changes in the application's managed object context before the application terminates.
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
// Save changes in the application's managed object context before the application terminates.
NSManagedObjectContext *context = self.persistentContainer.viewContext;
if (![context commitEditing]) {
NSLog(@"%@:%@ unable to commit editing to terminate", [self class], NSStringFromSelector(_cmd));
NSLogCaller(@"unable to commit editing to terminate");
return NSTerminateCancel;
}
if (!context.hasChanges) {
return NSTerminateNow;
}
NSError *error = nil;
if (![context save:&error]) {
// Customize this code block to include application-specific recovery steps.
BOOL result = [sender presentError:error];
if (result) {
return NSTerminateCancel;
}
NSString *question = NSLocalizedString(@"Could not save changes while quitting. Quit anyway?", @"Quit without saves error question message");
NSString *info = NSLocalizedString(@"Quitting now will lose any changes you have made since the last successful save", @"Quit without saves error question info");
NSString *quitButton = NSLocalizedString(@"Quit anyway", @"Quit anyway button title");
@@ -156,9 +147,7 @@
[alert addButtonWithTitle:quitButton];
[alert addButtonWithTitle:cancelButton];
NSInteger answer = [alert runModal];
if (answer == NSAlertSecondButtonReturn) {
if ([alert runModal] == NSAlertSecondButtonReturn) {
return NSTerminateCancel;
}
}
@@ -166,6 +155,27 @@
}
#pragma mark - Application Input (URLs and Files)
/// Callback method fired on opml file import
- (void)application:(NSApplication *)sender openFiles:(NSArray<NSString *> *)filenames {
NSMutableArray<NSURL*> *urls = [NSMutableArray arrayWithCapacity:filenames.count];
for (NSString *file in filenames) {
NSURL *u = [NSURL fileURLWithPath:file];
if (u) [urls addObject:u];
}
SettingsFeeds *sf = [[self openPreferences] selectTab:1];
[sf importOpmlFiles:urls];
[sender replyToOpenOrPrint:NSApplicationDelegateReplySuccess];
}
/// Callback method fired when opened with an URL (@c feed: and @c barss: scheme)
- (void)handleAppleEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
[URLScheme withURL:[[event paramDescriptorForKeyword:keyDirectObject] stringValue]];
}
#pragma mark - Event Handling, Forward Send Key Down Events
@@ -187,6 +197,7 @@ static NSEventModifierFlags fnKeyFlags = NSEventModifierFlagShift | NSEventModif
case 'a': if ([self sendAction:@selector(selectAll:) to:nil from:self]) return; break;
case 'q': if ([self sendAction:@selector(performClose:) to:nil from:self]) return; break;
case 'w': if ([self sendAction:@selector(performClose:) to:nil from:self]) return; break;
case 'r': if ([self sendAction:@selector(reloadData) to:nil from:self]) return; break;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
case 'z': if ([self sendAction:@selector(undo:) to:nil from:self]) return; break;
@@ -196,11 +207,6 @@ static NSEventModifierFlags fnKeyFlags = NSEventModifierFlagShift | NSEventModif
if ([self sendAction:@selector(redo:) to:nil from:self])
return;
}
} else {
if (key == NSEnterCharacter || key == NSCarriageReturnCharacter) {
if ([self sendAction:@selector(enterPressed:) to:nil from:self])
return;
}
}
#pragma clang diagnostic pop
}

BIN
baRSS/Artwork/AppIcon.icns Normal file

Binary file not shown.

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100">
<linearGradient id="orange" gradientUnits="userSpaceOnUse" x2="100" y2="100">
<stop offset="0" style="stop-color:#FF8B00"/>
<stop offset="0.5" style="stop-color:#FFAB48"/>
<stop offset="1" style="stop-color:#FF8B00"/>
</linearGradient>
<path fill="url(#orange)" d="M0,25v50q0,25,25,25h50q25,0,25,-25v-50q0,-25,-25,-25h-50q-25,0,-25,25z"/>
<g fill="#FFFFFF" transform="matrix(-0.75 0 0 0.75 87.5 12.5)">
<circle cx="13" cy="13" r="13"/>
<path d="M0,45v20Q65,65,65,0h-20Q45,45,0,45z"/>
<path d="M0,80v20Q100,100,100,0h-20Q80,80,0,80z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 70 100">
<style type="text/css"> .bw{fill:#000;} .fg{fill:#FBA43A;} </style>
<path class="fg" d="M0,0H45Q70,0,70,25V100H0V0z"/>
<path style="fill:#FFF;" d="M5,5H45Q65,5,65,25V95H5V5z"/>
<text class="bw" x="9" y="55" style="font:bold 36px sans-serif">OP</text>
<text class="bw" x="10" y="87" style="font:bold 34px sans-serif">ML</text>
</svg>

After

Width:  |  Height:  |  Size: 438 B

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 70 100">
<style type="text/css"> .bg{fill:#FFF;} .fg{fill:#FBA43A;} .bw{fill:#000;} </style>
<defs>
<symbol id="line">
<circle class="fg" cx="15" cy="5" r="5"/>
<circle class="bg" cx="15" cy="5" r="2.5"/>
<rect class="fg" x="23" y="3.5" width="37" height="3"/>
</symbol>
<symbol id="line2">
<circle class="fg" cx="25" cy="5" r="3.5"/>
<circle class="bg" cx="25" cy="5" r="0.5"/>
<rect class="fg" x="32" y="3.5" width="28" height="3"/>
</symbol>
</defs>
<path class="fg" d="M0,0H45Q70,0,70,25V100H0V0z"/>
<path class="bg" d="M5,5H45Q65,5,65,25V95H5V5z"/>
<text class="bw" x="10" y="90" style="font: bold 17.4px sans-serif">OPML</text>
<use xlink:href="#line" y="25"/>
<use xlink:href="#line" y="40"/>
<use xlink:href="#line2" y="53"/>
</svg>

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

View File

@@ -1,68 +0,0 @@
{
"images" : [
{
"idiom" : "mac",
"size" : "16x16",
"filename" : "barss-icon-16.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "16x16",
"filename" : "barss-icon-32.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "32x32",
"filename" : "barss-icon-32.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "32x32",
"filename" : "barss-icon-64.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "128x128",
"filename" : "barss-icon-128.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "128x128",
"filename" : "barss-icon-256.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "256x256",
"filename" : "barss-icon-256.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "256x256",
"filename" : "barss-icon-512.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "512x512",
"filename" : "barss-icon-512.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "512x512",
"filename" : "barss-icon-1024.png",
"scale" : "2x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,6 +0,0 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@@ -23,42 +23,93 @@
#ifndef Constants_h
#define Constants_h
@import Cocoa;
// TODO: Add support for media player? image feed?
// <enclosure url="https://url.mp3" length="63274022" type="audio/mpeg" />
// TODO: Disable 'update all' menu item during update?
// TODO: HTML to Feed Generator. https://github.com/RSS-Bridge/rss-bridge
// TODO: SQlite instead of CoreData? https://www.objc.io/issues/4-core-data/SQLite-instead-of-core-data/
/// UTI type used for opml files
static NSPasteboardType const UTI_OPML = @"org.opml.opml";
/// URL with newest baRSS releases. Automatically added when user starts baRSS for the first time.
static NSString* const versionUpdateURL = @"https://github.com/relikd/baRSS/releases.atom";
/// URL to help page of auxiliary application "URL Scheme Defaults"
static NSString* const auxiliaryAppURL = @"https://github.com/relikd/URL-Scheme-Defaults#url-scheme-defaults";
#pragma mark - NSImageName constants
/// Default RSS icon (with border, with gradient, orange)
static NSImageName const RSSImageDefaultRSSIcon = @"RSSImageDefaultRSSIcon";
/// Settings, global icon (menu bar, black)
static NSImageName const RSSImageSettingsGlobal = @"RSSImageSettingsGlobal";
/// Settings, group icon (folder, black)
static NSImageName const RSSImageSettingsGroup = @"RSSImageSettingsGroup";
/// Settings, feed icon (RSS, no border, no gradient, black)
static NSImageName const RSSImageSettingsFeed = @"RSSImageSettingsFeed";
/// Menu bar, bar icon (RSS, with border, no gradient, orange)
static NSImageName const RSSImageMenuBarIconActive = @"RSSImageMenuBarIconActive";
/// Menu bar, bar icon (RSS, with border, no gradient, paused, orange)
static NSImageName const RSSImageMenuBarIconPaused = @"RSSImageMenuBarIconPaused";
/// Menu item, unread state icon (blue dot)
static NSImageName const RSSImageMenuItemUnread = @"RSSImageMenuItemUnread";
#pragma mark - NSNotificationName constants
/// Helper method calls @c (defaultCenter)postNotification:
static inline void PostNotification(NSNotificationName name, id obj) { [[NSNotificationCenter defaultCenter] postNotificationName:name object:obj]; }
/// Helper method calls @c (defaultCenter)addObserver:
static inline void RegisterNotification(NSNotificationName name, SEL action, id observer) { [[NSNotificationCenter defaultCenter] addObserver:observer selector:action name:name object:nil]; }
/**
@c notification.object is @c NSNumber of type @c NSUInteger.
Represents number of feeds that are proccessed in background update. Sends @c 0 when all downloads are finished.
*/
static NSString *kNotificationBackgroundUpdateInProgress = @"baRSS-notification-background-update-in-progress";
static NSNotificationName const kNotificationBackgroundUpdateInProgress = @"baRSS-notification-background-update-in-progress";
/**
@c notification.object is @c nil.
Called whenever the update schedule timer is modified.
*/
static NSNotificationName const kNotificationScheduleTimerChanged = @"baRSS-notification-schedule-timer-changed";
/**
@c notification.object is @c NSManagedObjectID of type @c FeedGroup.
Called whenever a new feed group was created in @c autoDownloadAndParseURL:
*/
static NSNotificationName const kNotificationFeedGroupInserted = @"baRSS-notification-feed-inserted";
/**
@c notification.object is @c NSManagedObjectID of type @c Feed.
Called whenever download of a feed finished and object was modified (not if statusCode 304).
Called whenever download of a feed finished and articles were modified (not if statusCode 304).
*/
static NSString *kNotificationFeedUpdated = @"baRSS-notification-feed-updated";
static NSNotificationName const kNotificationArticlesUpdated = @"baRSS-notification-articles-updated";
/**
@c notification.object is @c NSManagedObjectID of type @c Feed.
Called whenever the icon attribute of an item was updated.
*/
static NSString *kNotificationFeedIconUpdated = @"baRSS-notification-feed-icon-updated";
static NSNotificationName const kNotificationFeedIconUpdated = @"baRSS-notification-feed-icon-updated";
/**
@c notification.object is @c NSNumber of type @c BOOL.
@c YES if network became reachable. @c NO on connection lost.
*/
static NSString *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed";
static NSNotificationName const kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed";
/**
@c notification.object is @c NSNumber of type @c NSInteger.
Represents a relative change (e.g., negative if items were marked read)
*/
static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";
static NSNotificationName const kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";
/**
@c notification.object is either @c nil or @c NSNumber of type @c NSInteger.
If new count is known an absoulte number is passed.
Else @c nil if count has to be fetched from core data.
*/
static NSString *kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset";
static NSNotificationName const kNotificationTotalUnreadCountReset = @"baRSS-notification-total-unread-count-reset";
#pragma mark - Internal
/**

View File

@@ -20,22 +20,21 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
#import "Feed+CoreDataClass.h"
#import <Cocoa/Cocoa.h>
@class RSParsedFeed;
@interface Feed (Ext)
@property (readonly) BOOL hasIcon;
@property (nonnull, readonly) NSImage* iconImage16;
// Generator methods / Feed update
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
- (void)calculateAndSetIndexPathString;
- (NSMenuItem*)newMenuItem;
// Getter & Setter
- (void)calculateAndSetIndexPathString;
- (void)setNewIcon:(NSURL*)location;
// Article properties
- (NSArray<FeedArticle*>*)sortedArticles;
// Icon
- (BOOL)setIconImage:(NSImage*)img;
@end

View File

@@ -20,35 +20,25 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import RSXML2;
#import "Feed+Ext.h"
#import "Constants.h"
#import "UserPrefs.h"
#import "DrawImage.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import "FeedArticle+Ext.h"
#import "StoreCoordinator.h"
#import <RSXML/RSXML.h>
#import "NSURL+Ext.h"
@implementation Feed (Ext)
/// Instantiates new @c Feed and @c FeedMeta entities in context.
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)moc {
Feed *feed = [[Feed alloc] initWithEntity:Feed.entity insertIntoManagedObjectContext:moc];
feed.meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:moc];
feed.meta = [FeedMeta newMetaInContext:moc];
return feed;
}
/// Instantiates new @c FeedGroup with @c FEED type, set the update interval to @c 30min and @c sortIndex to last root index.
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc {
NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc];
FeedGroup *fg = [FeedGroup newGroup:FEED inContext:moc];
[fg setParent:nil andSortIndex:(int32_t)lastIndex];
[fg.feed.meta setRefreshAndSchedule:kDefaultFeedRefreshInterval];
return fg.feed;
}
/// Call @c indexPathString on @c .group and update @c .indexPath if current value is different.
- (void)calculateAndSetIndexPathString {
NSString *pthStr = [self.group indexPathString];
@@ -59,7 +49,7 @@
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c image, and @c action.
- (NSMenuItem*)newMenuItem {
NSMenuItem *item = [NSMenuItem new];
item.title = self.group.nameOrError;
item.title = self.group.anyName;
item.toolTip = self.subtitle;
item.enabled = (self.articles.count > 0);
item.image = self.iconImage16;
@@ -73,7 +63,7 @@
+ (void)didClickOnMenuItem:(NSMenuItem*)sender {
NSString *url = [StoreCoordinator urlForFeedWithIndexPath:sender.representedObject];
if (url && url.length > 0)
[UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
UserPrefsOpenURL(url);
}
@@ -88,78 +78,64 @@
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
if (self.group.name.length == 0) // in case a blank group was initialized
self.group.name = obj.title;
// Add and remove articles
NSMutableSet<FeedArticle*> *oldSet = [self.articles mutableCopy];
NSInteger diff = [self addMissingArticles:obj withOldSet:oldSet]; // will remove items that should be kept
diff -= [self deleteArticlesWithOldSet:oldSet]; // remove old, outdated articles
NSMutableSet<FeedArticle*> *localSet = [self.articles mutableCopy];
NSInteger diff = 0;
diff -= [self deleteArticles:localSet withRemoteSet:obj.articles]; // remove old, outdated articles
diff += [self insertArticles:localSet withRemoteSet:obj.articles]; // insert new in correct order
// Get new total article count and post unread-count-change notification
if (flag && diff != 0) {
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(diff)];
PostNotification(kNotificationTotalUnreadCountChanged, @(diff));
}
}
/**
Append new articles and increment their sortIndex. Update unread counter on the way.
Append new articles and increment @c sortIndex and unread count.
New articles are in ascending order without any gaps in between.
@note
New articles should be in ascending order without any gaps in between.
If new article is disjunct from the article before, assume a deleted article re-appeared and mark it as read.
@param oldSet Input will be used to identify new articles.
Output contains articles that aren't present in the feed anymore and should be deleted.
@param localSet Use result set of @c deleteArticles:withRemoteSet:
*/
- (NSInteger)addMissingArticles:(RSParsedFeed*)obj withOldSet:(NSMutableSet<FeedArticle*>*)oldSet {
NSInteger newOnes = 0;
int32_t currentIndex = [[self.articles valueForKeyPath:@"@min.sortIndex"] intValue];
FeedArticle *lastInserted = nil;
BOOL hasGapBetweenNewArticles = NO;
for (RSParsedArticle *article in [obj.articles reverseObjectEnumerator]) {
// reverse enumeration ensures correct article order
FeedArticle *storedArticle = [self findArticle:article inSet:oldSet];
if (storedArticle) {
[oldSet removeObject:storedArticle];
if (storedArticle.sortIndex != currentIndex) {
storedArticle.sortIndex = currentIndex;
}
hasGapBetweenNewArticles = YES;
- (NSUInteger)insertArticles:(NSMutableSet<FeedArticle*>*)localSet withRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
int32_t currentIndex = [[localSet valueForKeyPath:@"@min.sortIndex"] intValue];
NSUInteger c = 0;
for (RSParsedArticle *article in [remoteSet reverseObjectEnumerator]) {
// Reverse enumeration ensures correct article order
FeedArticle *stored = [self findRemoteArticle:article inLocalSet:localSet];
if (stored) {
[localSet removeObject:stored];
if (stored.sortIndex != currentIndex)
stored.sortIndex = currentIndex; // Ensures block of ascending indices
} else {
newOnes += 1;
if (hasGapBetweenNewArticles && lastInserted) { // gap with at least one article inbetween
lastInserted.unread = NO;
newOnes -= 1;
}
hasGapBetweenNewArticles = NO;
lastInserted = [FeedArticle newArticle:article inContext:self.managedObjectContext];
lastInserted.sortIndex = currentIndex;
[self addArticlesObject:lastInserted];
FeedArticle *newArticle = [FeedArticle newArticle:article inContext:self.managedObjectContext];
newArticle.sortIndex = currentIndex;
[self addArticlesObject:newArticle];
c += 1;
}
currentIndex += 1;
}
if (hasGapBetweenNewArticles && lastInserted) {
lastInserted.unread = NO;
newOnes -= 1;
}
return newOnes;
return c;
}
/**
Delete all articles from core data, that are still in the oldSet.
Delete all articles from core data, that aren't present anymore.
@param localSet Input a copy of @c self.articles . Output same set minus deleted articles.
*/
- (NSUInteger)deleteArticlesWithOldSet:(NSMutableSet<FeedArticle*>*)oldSet {
if (!oldSet || oldSet.count == 0)
return 0;
- (NSUInteger)deleteArticles:(NSMutableSet<FeedArticle*>*)localSet withRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
NSUInteger c = 0;
for (FeedArticle *fa in oldSet) {
NSMutableSet<FeedArticle*> *deletingSet = [NSMutableSet setWithCapacity:localSet.count];
for (FeedArticle *fa in localSet) {
if (![self findLocalArticle:fa inRemoteSet:remoteSet]) {
if (fa.unread) ++c;
// TODO: keep unread articles?
[self.managedObjectContext deleteObject:fa];
[deletingSet addObject:fa];
}
}
if (deletingSet.count > 0) {
[localSet minusSet:deletingSet];
[self removeArticles:deletingSet];
}
if (oldSet.count > 0)
[self removeArticles:oldSet];
return c;
}
@@ -177,17 +153,34 @@
}
/**
Iterate over oldSet and return the one where @c link and @c guid matches. Or @c nil if no matching article found.
Iterate over localSet and return the one where @c link and @c guid matches. Or @c nil if no matching article found.
*/
- (FeedArticle*)findArticle:(RSParsedArticle*)article inSet:(NSSet<FeedArticle*>*)oldSet {
NSString *searchLink = article.link;
NSString *searchGuid = article.guid;
- (FeedArticle*)findRemoteArticle:(RSParsedArticle*)remote inLocalSet:(NSSet<FeedArticle*>*)localSet {
NSString *searchLink = remote.link;
NSString *searchGuid = remote.guid;
BOOL linkIsNil = (searchLink == nil);
BOOL guidIsNil = (searchGuid == nil);
for (FeedArticle *old in oldSet) {
if ((linkIsNil && old.link == nil) || [old.link isEqualToString:searchLink]) {
if ((guidIsNil && old.guid == nil) || [old.guid isEqualToString:searchGuid])
return old;
for (FeedArticle *art in localSet) {
if ((linkIsNil && art.link == nil) || (!linkIsNil && [art.link isEqualToString:searchLink])) {
if ((guidIsNil && art.guid == nil) || (!guidIsNil && [art.guid isEqualToString:searchGuid]))
return art;
}
}
return nil;
}
/**
Iterate over remoteSet and return the one where @c link and @c guid matches. Or @c nil if no matching article found.
*/
- (RSParsedArticle*)findLocalArticle:(FeedArticle*)local inRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
NSString *searchLink = local.link;
NSString *searchGuid = local.guid;
BOOL linkIsNil = (searchLink == nil);
BOOL guidIsNil = (searchGuid == nil);
for (RSParsedArticle *art in remoteSet) {
if ((linkIsNil && art.link == nil) || (!linkIsNil && [art.link isEqualToString:searchLink])) {
if ((guidIsNil && art.guid == nil) || (!guidIsNil && [art.guid isEqualToString:searchGuid]))
return art;
}
}
return nil;
@@ -197,39 +190,39 @@
#pragma mark - Icon -
/**
@return Return @c 16x16px image. Either from core data storage or generated default RSS icon.
*/
/// @return @c 16x16px image. Either from favicon cache or generated default RSS icon.
- (nonnull NSImage*)iconImage16 {
NSImage *img = nil;
if (self.articles.count == 0) {
img = [NSImage imageNamed:NSImageNameCaution];
} else if (self.icon.icon) {
img = [[NSImage alloc] initWithData:self.icon.icon];
} else if (self.hasIcon) {
img = [[NSImage alloc] initByReferencingURL:[self iconPath]];
} else {
return [RSSIcon iconWithSize:16]; // TODO: setup imageNamed: for default rss icon?
img = [NSImage imageNamed:RSSImageDefaultRSSIcon];
}
[img setSize:NSMakeSize(16, 16)];
return img;
}
/**
Set favicon icon or delete relationship if @c img is not a valid image.
/// Checks if file at @c iconPath is an actual file
- (BOOL)hasIcon { return [[self iconPath] existsAndIsDir:NO]; }
@return @c YES if icon was updated (core data did change).
*/
- (BOOL)setIconImage:(NSImage*)img {
if (img && [img isValid]) {
if (!self.icon)
self.icon = [[FeedIcon alloc] initWithEntity:FeedIcon.entity insertIntoManagedObjectContext:self.managedObjectContext];
self.icon.icon = [img TIFFRepresentation];
return YES;
} else if (self.icon) {
[self.managedObjectContext deleteObject:self.icon];
self.icon = nil;
return YES;
/// Image file path at e.g., "Application Support/baRSS/favicons/p42". @warning File may not exist!
- (NSURL*)iconPath {
return [[NSURL faviconsCacheURL] file:self.objectID.URIRepresentation.lastPathComponent ext:nil];
}
/// Move favicon from @c $TMPDIR to permanent destination in Application Support.
- (void)setNewIcon:(NSURL*)location {
if (!location) {
[[self iconPath] remove];
} else {
if (self.objectID.isTemporaryID) {
[self.managedObjectContext obtainPermanentIDsForObjects:@[self] error:nil];
}
[location moveTo:[self iconPath]];
PostNotification(kNotificationFeedIconUpdated, self.objectID);
}
return NO;
}
@end

View File

@@ -20,9 +20,8 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
#import "FeedArticle+CoreDataClass.h"
#import <Cocoa/Cocoa.h>
@class RSParsedArticle;
@interface FeedArticle (Ext)

View File

@@ -20,12 +20,12 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import RSXML2.RSParsedArticle;
#import "FeedArticle+Ext.h"
#import "Constants.h"
#import "UserPrefs.h"
#import "StoreCoordinator.h"
#import <RSXML/RSParsedArticle.h>
#import "NSString+Ext.h"
@implementation FeedArticle (Ext)
@@ -35,11 +35,10 @@
fa.unread = YES;
fa.guid = entry.guid;
fa.title = entry.title;
if (entry.abstract.length > 0) { // remove html tags and save plain text to db
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]*>" options:kNilOptions error:nil];
fa.abstract = [regex stringByReplacingMatchesInString:entry.abstract options:kNilOptions range:NSMakeRange(0, entry.abstract.length) withTemplate:@""];
}
fa.body = entry.body;
if (entry.abstract.length > 0)
fa.abstract = [entry.abstract htmlToPlainText];
if (entry.body.length > 0)
fa.body = [entry.body htmlToPlainText];
fa.author = entry.author;
fa.link = entry.link;
fa.published = entry.datePublished;
@@ -53,20 +52,22 @@
NSString *title = self.title;
if (!title) return @"";
// TODO: It should be enough to get user prefs once per menu build
if ([UserPrefs defaultNO:@"feedShortNames"]) {
NSUInteger limit = [UserPrefs shortArticleNamesLimit];
if (UserPrefsBool(Pref_feedTruncateTitle)) {
NSUInteger limit = UserPrefsUInt(Pref_shortArticleNamesLimit);
if (title.length > limit)
title = [NSString stringWithFormat:@"%@…", [title substringToIndex:limit-1]];
title = [[title substringToIndex:limit] stringByAppendingString:@"…"];
}
return title;
}
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c tickmark, and @c action.
/// @return Fully initialized @c NSMenuItem with @c title, @c tooltip, @c unread-indicator, and @c action.
- (NSMenuItem*)newMenuItem {
NSMenuItem *item = [NSMenuItem new];
item.title = [self shortArticleName];
item.enabled = (self.link.length > 0);
item.state = (self.unread && [UserPrefs defaultYES:@"feedTickMark"] ? NSControlStateValueOn : NSControlStateValueOff);
item.state = (self.unread && UserPrefsBool(Pref_feedUnreadIndicator) ? NSControlStateValueOn : NSControlStateValueOff);
item.onStateImage = [NSImage imageNamed:RSSImageMenuItemUnread];
item.accessibilityLabel = (self.unread ? NSLocalizedString(@"article: unread", @"accessibility label, feed menu item") : NSLocalizedString(@"article: read", @"accessibility label, feed menu item"));
item.toolTip = (self.abstract ? self.abstract : self.body); // fall back to body (html)
item.representedObject = self.objectID;
item.target = [self class];
@@ -80,15 +81,16 @@
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
FeedArticle *fa = [moc objectWithID:sender.representedObject];
NSString *url = fa.link;
if (flipUnread || fa.unread) {
BOOL success = NO;
if (url && url.length > 0 && !flipUnread) // flipUnread == change unread state
success = UserPrefsOpenURL(url);
if (flipUnread || (success && fa.unread)) {
fa.unread = !fa.unread;
NSNumber *num = (fa.unread ? @+1 : @-1);
[StoreCoordinator saveContext:moc andParent:YES];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:num];
NSNumber *num = (fa.unread ? @+1 : @-1);
PostNotification(kNotificationTotalUnreadCountChanged, num);
}
[moc reset];
if (url && url.length > 0 && !flipUnread) // flipUnread == change unread state
[UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
}
@end

View File

@@ -20,8 +20,8 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
#import "FeedGroup+CoreDataClass.h"
#import <Cocoa/Cocoa.h>
/// Enum type to distinguish different @c FeedGroup types: @c GROUP, @c FEED, @c SEPARATOR
typedef NS_ENUM(int16_t, FeedGroupType) {
@@ -33,20 +33,20 @@ typedef NS_ENUM(int16_t, FeedGroupType) {
@interface FeedGroup (Ext)
/// Overwrites @c type attribute with enum. Use one of: @c GROUP, @c FEED, @c SEPARATOR.
@property (nonatomic) FeedGroupType type;
@property (nonnull, readonly) NSString *nameOrError;
@property (nonnull, readonly) NSString *anyName;
@property (nonnull, readonly) NSImage* groupIconImage16;
@property (nonnull, readonly) NSImage* iconImage16;
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)context;
+ (instancetype)appendToRoot:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc;
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
- (void)setNameIfChanged:(NSString*)name;
- (void)setSortIndexIfChanged:(int32_t)sortIndex;
- (void)setNameIfChanged:(nullable NSString*)name;
- (NSMenuItem*)newMenuItem;
// Handle children and parents
- (NSString*)indexPathString;
- (NSArray<FeedGroup*>*)sortedChildren;
- (NSMutableArray<FeedGroup*>*)allParents;
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
// Printing
- (NSString*)readableDescription;
- (nonnull NSString*)refreshString;
@end

View File

@@ -21,17 +21,20 @@
// SOFTWARE.
#import "FeedGroup+Ext.h"
#import "FeedMeta+Ext.h"
#import "Feed+Ext.h"
#import "NSDate+Ext.h"
#import "StoreCoordinator.h"
@implementation FeedGroup (Ext)
#pragma mark - Properties
/// @return Returns "(error)" if @c self.name is @c nil.
- (nonnull NSString*)nameOrError {
return (self.name ? self.name : NSLocalizedString(@"(error)", nil));
/// Try return @c self.name or @c self.feed.title ; If both fail return "(no title)"
- (nonnull NSString*)anyName {
if (self.name.length > 0)
return self.name;
if (self.type == FEED && self.feed.title.length > 0)
return self.feed.title;
return NSLocalizedString(@"(no title)", nil);
}
/// @return Return @c 16x16px NSImageNameFolder image.
@@ -58,11 +61,23 @@
+ (instancetype)newGroup:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
FeedGroup *fg = [[FeedGroup alloc] initWithEntity: FeedGroup.entity insertIntoManagedObjectContext:moc];
fg.type = type;
if (type == FEED)
fg.feed = [Feed newFeedAndMetaInContext:moc];
switch (type) {
case GROUP: break;
case FEED: fg.feed = [Feed newFeedAndMetaInContext:moc]; break;
case SEPARATOR: fg.name = @"---"; break;
}
return fg;
}
/// Instantiates new @c FeedGroup at root level and append at end.
+ (instancetype)appendToRoot:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc {
NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc];
FeedGroup *fg = [FeedGroup newGroup:type inContext:moc];
[fg setParent:nil andSortIndex:(int32_t)lastIndex];
return fg;
}
/// Set @c parent and @c sortIndex. Also if type is @c FEED calculate and set @c indexPath string.
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
self.parent = parent;
self.sortIndex = sortIndex;
@@ -70,16 +85,30 @@
[self.feed calculateAndSetIndexPathString];
}
/// Set @c sortIndex of @c FeedGroup. Iterate over all @c Feed child items and update @c indexPath string.
- (void)setSortIndexIfChanged:(int32_t)sortIndex {
if (self.sortIndex != sortIndex) {
self.sortIndex = sortIndex;
[self iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
[feed calculateAndSetIndexPathString];
}];
}
}
/// Set @c name attribute but only if value differs.
- (void)setNameIfChanged:(NSString*)name {
if (![self.name isEqualToString: name])
- (void)setNameIfChanged:(nullable NSString*)name {
if (name.length == 0) {
if (self.name.length > 0)
self.name = nil; // nullify empty strings
} else if (![self.name isEqualToString: name]) {
self.name = name;
}
}
/// @return Fully initialized @c NSMenuItem with @c title and @c image.
- (NSMenuItem*)newMenuItem {
NSMenuItem *item = [NSMenuItem new];
item.title = self.nameOrError;
item.title = self.anyName;
item.enabled = (self.children.count > 0);
item.image = self.groupIconImage16;
item.representedObject = self.objectID;
@@ -141,22 +170,10 @@
/// @return Simplified description of the feed object.
- (NSString*)readableDescription {
switch (self.type) {
case GROUP: return [NSString stringWithFormat:@"%@:", self.anyName];
case FEED: return [NSString stringWithFormat:@"%@ (%@)", self.anyName, self.feed.meta.url];
case SEPARATOR: return @"-------------";
case GROUP: return [NSString stringWithFormat:@"%@", self.name];
case FEED:
return [NSString stringWithFormat:@"%@ (%@) - %@", self.name, self.feed.meta.url, [self refreshString]];
}
}
/// @return Formatted string for update interval ( e.g., @c 30m or @c 12h )
- (nonnull NSString*)refreshString {
if (self.type == FEED) {
int32_t refresh = self.feed.meta.refresh;
if (refresh <= 0)
return @"∞"; // ƒ Ø
return [NSDate stringForInterval:refresh rounded:NO];
}
return @"";
}
@end

View File

@@ -20,16 +20,18 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
#import "FeedMeta+CoreDataClass.h"
static const int32_t kDefaultFeedRefreshInterval = 30 * 60;
static int32_t const kDefaultFeedRefreshInterval = 30 * 60;
@interface FeedMeta (Ext)
+ (instancetype)newMetaInContext:(NSManagedObjectContext*)moc;
// HTTP response
- (void)setErrorAndPostponeSchedule;
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response;
// Setter
- (void)setUrlIfChanged:(NSString*)url;
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
- (BOOL)setRefreshAndSchedule:(int32_t)refresh;
- (void)setRefreshIfChanged:(int32_t)refresh;
- (void)scheduleNow:(NSTimeInterval)future;
@end

View File

@@ -26,6 +26,14 @@
@implementation FeedMeta (Ext)
/// Create new instance with default @c refresh interval and set @c scheduled to distant past.
+ (instancetype)newMetaInContext:(NSManagedObjectContext*)moc {
FeedMeta *meta = [[FeedMeta alloc] initWithEntity:FeedMeta.entity insertIntoManagedObjectContext:moc];
meta.refresh = kDefaultFeedRefreshInterval;
meta.scheduled = [NSDate distantPast]; // will cause update to refresh as soon as possible
return meta;
}
#pragma mark - HTTP response
/// Increment @c errorCount and set new @c scheduled date (2^N minutes, max. 5.7 days).
@@ -33,7 +41,6 @@
if (self.errorCount < 0)
self.errorCount = 0;
int16_t n = self.errorCount + 1; // always increment errorCount (can be used to indicate bad feeds)
// TODO: remove logging
#ifdef DEBUG
NSLog(@"ERROR: Feed download failed: %@ (errorCount: %d)", self.url, n);
#endif
@@ -44,10 +51,14 @@
[self scheduleNow:retryWaitTime];
}
/// Copy Etag & Last-Modified headers and update URL (if not 304). Then schedule new update date. Will reset errorCount to @c 0
- (void)setSucessfulWithResponse:(NSHTTPURLResponse*)response {
self.errorCount = 0; // reset counter
NSDictionary *header = [response allHeaderFields];
[self setEtag:header[@"Etag"] modified:header[@"Date"]]; // @"Expires", @"Last-Modified"
if (response.statusCode != 304) { // not all servers set etag / modified when returning 304
[self setEtag:header[@"Etag"] modified:header[@"Last-Modified"]];
[self setUrlIfChanged:response.URL.absoluteString];
}
[self scheduleNow:self.refresh];
}
@@ -58,26 +69,17 @@
if (![self.url isEqualToString:url]) self.url = url;
}
/// Set @c refresh attribute but only if value differs.
- (void)setRefreshIfChanged:(int32_t)refresh {
if (self.refresh != refresh) self.refresh = refresh;
}
/// Set @c etag and @c modified attributes. Only values that differ will be updated.
- (void)setEtag:(NSString*)etag modified:(NSString*)modified {
if (![self.etag isEqualToString:etag]) self.etag = etag;
if (![self.modified isEqualToString:modified]) self.modified = modified;
}
/**
Set @c refresh and calculate new @c scheduled date.
@return @c YES if refresh interval has changed
*/
- (BOOL)setRefreshAndSchedule:(int32_t)refresh {
if (self.refresh != refresh) {
self.refresh = refresh;
[self scheduleNow:self.refresh];
return YES;
}
return NO;
}
/// Set next scheduled feed update or @c nil if @c refresh @c <= @c 0.
- (void)scheduleNow:(NSTimeInterval)future {
if (self.refresh <= 0) { // update deactivated; manually update with force update all

View File

@@ -20,14 +20,15 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <CoreData/CoreData.h>
@import Cocoa;
@interface NSFetchRequest<ResultType> (Ext)
// Perform core data request and fetch data
- (NSArray<ResultType>*)fetchAllRows:(NSManagedObjectContext*)moc;
- (NSArray<NSManagedObjectID*>*)fetchIDs:(NSManagedObjectContext*)moc;
- (NSDictionary*)fetchFirstDict:(NSManagedObjectContext*)moc; // limit 1
- (ResultType)fetchFirst:(NSManagedObjectContext*)moc; // limit 1
- (NSUInteger)fetchCount:(NSManagedObjectContext*)moc;
- (id)fetchFirst:(NSManagedObjectContext*)moc; // limit 1
// Selecting, filtering, sorting results
- (instancetype)select:(NSArray<NSString*>*)cols; // sets .propertiesToFetch

View File

@@ -21,6 +21,7 @@
// SOFTWARE.
#import "NSFetchRequest+Ext.h"
#import "NSError+Ext.h"
@implementation NSFetchRequest (Ext)
@@ -28,7 +29,7 @@
- (NSArray*)fetchAllRows:(NSManagedObjectContext*)moc {
NSError *err;
NSArray *fetchResults = [moc executeFetchRequest:self error:&err];
if (err) NSLog(@"ERROR: Fetch request failed: %@", err);
[err inCaseLog:"Fetch request failed"];
//NSLog(@"%@ ==> %@", self, fetchResults); // debugging
return fetchResults;
}
@@ -40,7 +41,12 @@
return [self fetchAllRows:moc];
}
/// Set @c limit to @c 1 and fetch first objcect. May return object type or @c NSDictionary if @c resultType @c = @c NSManagedObjectIDResultType.
/// Same as @c fetchFirst: but with dictionary return type
- (NSDictionary*)fetchFirstDict:(NSManagedObjectContext*)moc {
return [self fetchFirst:moc];
}
/// Set @c limit to @c 1 and fetch first object. May return object type or @c NSDictionary if @c resultType @c = @c NSManagedObjectIDResultType.
- (id)fetchFirst:(NSManagedObjectContext*)moc {
self.fetchLimit = 1;
return [[self fetchAllRows:moc] firstObject];

View File

@@ -20,36 +20,38 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Foundation/Foundation.h>
@import Cocoa;
#import "DBv1+CoreDataModel.h"
@interface StoreCoordinator : NSObject
// Managing contexts
+ (NSManagedObjectContext*)getMainContext;
+ (NSManagedObjectContext*)createChildContext;
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag;
// Options
+ (nullable NSString*)optionForKey:(NSString*)key;
+ (void)setOption:(NSString*)key value:(NSString*)value;
// Feed update
+ (NSDate*)nextScheduledUpdate;
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(nullable NSManagedObjectContext*)moc;
// Count elements
+ (BOOL)isEmpty;
+ (NSUInteger)countTotalUnread;
+ (NSUInteger)countRootItemsInContext:(NSManagedObjectContext*)moc;
+ (NSArray<NSDictionary*>*)countAggregatedUnread;
// Get List Of Elements
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc;
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc;
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc;
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(nullable NSManagedObjectContext*)moc;
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(nullable NSManagedObjectContext*)moc;
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path;
// Unread articles list & mark articled read
+ (NSArray<FeedArticle*>*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit;
// Restore sound state
+ (void)restoreFeedIndexPaths;
+ (NSUInteger)deleteUnreferenced;
+ (NSUInteger)deleteAllGroups;
+ (void)cleanupAndShowAlert:(BOOL)flag;
+ (NSUInteger)cleanupFavicons;
@end

View File

@@ -21,9 +21,13 @@
// SOFTWARE.
#import "StoreCoordinator.h"
#import "NSFetchRequest+Ext.h"
#import "AppHook.h"
#import "Constants.h"
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "NSURL+Ext.h"
#import "NSError+Ext.h"
#import "NSFetchRequest+Ext.h"
@implementation StoreCoordinator
@@ -49,18 +53,35 @@
@param flag If @c YES save any parent context as well (recursive).
*/
+ (void)saveContext:(NSManagedObjectContext*)context andParent:(BOOL)flag {
// Performs the save action for the application, which is to send the save: message to the application's managed object context. Any encountered errors are presented to the user.
if (![context commitEditing]) {
NSLog(@"%@:%@ unable to commit editing before saving", [self class], NSStringFromSelector(_cmd));
}
if (![context commitEditing])
NSLogCaller(@"unable to commit editing before saving");
NSError *error = nil;
if (context.hasChanges && ![context save:&error]) {
// Customize this code block to include application-specific recovery steps.
[[NSApplication sharedApplication] presentError:error];
}
if (flag && context.parentContext) {
if (context.hasChanges && ![context save:&error])
[error inCasePresent:NSApp];
if (flag && context.parentContext)
[self saveContext:context.parentContext andParent:flag];
}
#pragma mark - Options
/// @return Value for option with @c key or @c nil.
+ (nullable NSString*)optionForKey:(NSString*)key {
return [[[Options fetchRequest] where:@"key = %@", key] fetchFirst:[self getMainContext]].value;
}
/// Init new option with given @c key
+ (void)setOption:(NSString*)key value:(NSString*)value {
NSManagedObjectContext *moc = [self getMainContext];
Options *opt = [[[Options fetchRequest] where:@"key = %@", key] fetchFirst:moc];
if (!opt) {
opt = [[Options alloc] initWithEntity:Options.entity insertIntoManagedObjectContext:moc];
opt.key = key;
}
opt.value = value;
[self saveContext:moc andParent:YES];
[moc reset];
}
@@ -70,26 +91,32 @@
+ (NSDate*)nextScheduledUpdate {
NSFetchRequest *fr = [FeedMeta fetchRequest];
[fr addFunctionExpression:@"min:" onKeyPath:@"scheduled" name:@"minDate" type:NSDateAttributeType];
return [fr fetchAllRows: [self getMainContext]].firstObject[@"minDate"];
return [fr fetchFirstDict: [self getMainContext]][@"minDate"];
}
/**
List of @c Feed items that need to be updated. Scheduled time is now (or in past).
@param forceAll If @c YES get a list of all @c Feed regardless of schedules time.
@param moc If @c nil perform requests on main context (ok for reading).
*/
+ (NSArray<Feed*>*)getListOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(NSManagedObjectContext*)moc {
+ (NSArray<Feed*>*)listOfFeedsThatNeedUpdate:(BOOL)forceAll inContext:(nullable NSManagedObjectContext*)moc {
NSFetchRequest *fr = [Feed fetchRequest];
if (!forceAll) {
// when fetching also get those feeds that would need update soon (now + 10s)
[fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+10]];
// when fetching also get those feeds that would need update soon (now + 2s)
[fr where:@"meta.scheduled <= %@", [NSDate dateWithTimeIntervalSinceNow:+2]];
}
return [fr fetchAllRows:moc];
return [fr fetchAllRows:moc ? moc : [self getMainContext]];
}
#pragma mark - Count Elements
/// @return @c YES if core data has no stored @c FeedGroup
+ (BOOL)isEmpty {
return [[FeedGroup fetchRequest] fetchFirst:[self getMainContext]] == nil;
}
/// @return Sum of all unread @c FeedArticle items.
+ (NSUInteger)countTotalUnread {
return [[[FeedArticle fetchRequest] where:@"unread = YES"] fetchCount: [self getMainContext]];
@@ -107,40 +134,41 @@
fr.propertiesToFetch = @[ @"indexPath" ];
[fr addFunctionExpression:@"sum:" onKeyPath:@"articles.unread" name:@"unread" type:NSInteger32AttributeType];
[fr addFunctionExpression:@"count:" onKeyPath:@"articles.unread" name:@"total" type:NSInteger32AttributeType];
return [fr fetchAllRows: [self getMainContext]];
return (NSArray<NSDictionary*>*)[fr fetchAllRows: [self getMainContext]];
}
#pragma mark - Get List Of Elements
/// @return Sorted list of @c FeedGroup items where @c FeedGroup.parent @c = @c parent.
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(NSManagedObjectContext*)moc {
return [[[[FeedGroup fetchRequest] where:@"parent = %@", parent] sortASC:@"sortIndex"] fetchAllRows:moc];
/**
@param moc If @c nil perform requests on main context (ok for reading).
@return Sorted list of @c FeedGroup items where @c FeedGroup.parent @c = @c parent.
*/
+ (NSArray<FeedGroup*>*)sortedFeedGroupsWithParent:(id)parent inContext:(nullable NSManagedObjectContext*)moc {
return [[[[FeedGroup fetchRequest] where:@"parent = %@", parent] sortASC:@"sortIndex"] fetchAllRows:moc ? moc : [self getMainContext]];
}
/// @return Sorted list of @c FeedArticle items where @c FeedArticle.feed @c = @c parent.
+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc {
return [[[[FeedArticle fetchRequest] where:@"feed = %@", parent] sortDESC:@"sortIndex"] fetchAllRows:moc];
}
//+ (NSArray<FeedArticle*>*)sortedArticlesWithParent:(id)parent inContext:(NSManagedObjectContext*)moc {
// return [[[[FeedArticle fetchRequest] where:@"feed = %@", parent] sortDESC:@"sortIndex"] fetchAllRows:moc];
//}
/// @return Unsorted list of @c Feed items where @c articles.count @c == @c 0.
+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc];
}
//+ (NSArray<Feed*>*)listOfFeedsMissingArticlesInContext:(NSManagedObjectContext*)moc {
// return [[[Feed fetchRequest] where:@"articles.@count == 0"] fetchAllRows:moc];
//}
/// @return Unsorted list of @c Feed items where @c icon is @c nil.
+ (NSArray<Feed*>*)listOfFeedsMissingIconsInContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"icon = NULL"] fetchAllRows:moc];
}
/// @return Single @c Feed item where @c Feed.indexPath @c = @c path.
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc];
/**
@param moc If @c nil perform requests on main context (ok for reading).
@return Single @c Feed item where @c Feed.indexPath @c = @c path.
*/
+ (Feed*)feedWithIndexPath:(nonnull NSString*)path inContext:(nullable NSManagedObjectContext*)moc {
return [[[Feed fetchRequest] where:@"indexPath = %@", path] fetchFirst:moc ? moc : [self getMainContext]];
}
/// @return URL of @c Feed item where @c Feed.indexPath @c = @c path.
+ (NSString*)urlForFeedWithIndexPath:(nonnull NSString*)path {
return [[[[Feed fetchRequest] where:@"indexPath = %@", path] select:@[@"link"]] fetchFirst: [self getMainContext]][@"link"];
return [[[[Feed fetchRequest] where:@"indexPath = %@", path] select:@[@"link"]] fetchFirstDict: [self getMainContext]][@"link"];
}
/// @return Unsorted list of object IDs where @c Feed.indexPath begins with @c path @c + @c "."
@@ -197,6 +225,20 @@
#pragma mark - Restore Sound State
/// Remove orphan core data entries with optional alert message of removed items count.
+ (void)cleanupAndShowAlert:(BOOL)flag {
NSUInteger deleted = [self deleteUnreferenced];
[self restoreFeedIndexPaths];
PostNotification(kNotificationTotalUnreadCountReset, nil);
if (flag) {
NSAlert *alert = [[NSAlert alloc] init];
alert.messageText = NSLocalizedString(@"Database cleanup successful", nil);
alert.informativeText = [NSString stringWithFormat:NSLocalizedString(@"Removed %lu unreferenced database entries.", nil), deleted];
alert.alertStyle = NSAlertStyleInformational;
[alert runModal];
}
}
/// Iterate over all @c Feed and re-calculate @c indexPath.
+ (void)restoreFeedIndexPaths {
NSManagedObjectContext *moc = [self getMainContext];
@@ -215,7 +257,6 @@
NSManagedObjectContext *moc = [self getMainContext];
deleted += [self batchDelete:Feed.entity nullAttribute:@"group" inContext:moc];
deleted += [self batchDelete:FeedMeta.entity nullAttribute:@"feed" inContext:moc];
deleted += [self batchDelete:FeedIcon.entity nullAttribute:@"feed" inContext:moc];
deleted += [self batchDelete:FeedArticle.entity nullAttribute:@"feed" inContext:moc];
if (deleted > 0) {
[self saveContext:moc andParent:YES];
@@ -225,13 +266,13 @@
}
/// Delete all @c FeedGroup items.
+ (NSUInteger)deleteAllGroups {
NSManagedObjectContext *moc = [self getMainContext];
NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc];
[self saveContext:moc andParent:YES];
[moc reset];
return deleted;
}
//+ (NSUInteger)deleteAllGroups {
// NSManagedObjectContext *moc = [self getMainContext];
// NSUInteger deleted = [self batchDelete:FeedGroup.entity nullAttribute:nil inContext:moc];
// [self saveContext:moc andParent:YES];
// [moc reset];
// return deleted;
//}
/**
Perform batch delete on entities of type @c entity where @c column @c IS @c NULL. If @c column is @c nil, delete all rows.
@@ -246,8 +287,29 @@
bdr.resultType = NSBatchDeleteResultTypeCount;
NSError *err;
NSBatchDeleteResult *res = [moc executeRequest:bdr error:&err];
if (err) NSLog(@"%@", err);
[err inCaseLog:"Couldn't delete batch"];
return [res.result unsignedIntegerValue];
}
/// Remove orphan favicons. @return Number of removed items.
+ (NSUInteger)cleanupFavicons {
NSURL *base = [[NSURL faviconsCacheURL] URLByResolvingSymlinksInPath];
if (![base existsAndIsDir:YES]) return 0;
NSFileManager *fm = [NSFileManager defaultManager];
NSDirectoryEnumerationOptions opt = NSDirectoryEnumerationSkipsSubdirectoryDescendants | NSDirectoryEnumerationSkipsPackageDescendants | NSDirectoryEnumerationSkipsHiddenFiles;
NSDirectoryEnumerator *enumerator = [fm enumeratorAtURL:base includingPropertiesForKeys:nil options:opt errorHandler:nil];
NSMutableArray<NSURL*> *toBeDeleted = [NSMutableArray array];
NSArray<NSManagedObjectID*> *feedIds = [[Feed fetchRequest] fetchIDs:[self getMainContext]];
NSArray<NSString*> *pks = [feedIds valueForKeyPath:@"URIRepresentation.lastPathComponent"];
for (NSURL *path in enumerator)
if (![pks containsObject:path.lastPathComponent])
[toBeDeleted addObject:path];
for (NSURL *path in toBeDeleted)
[fm removeItemAtURL:path error:nil];
return toBeDeleted.count;
}
@end

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14315.18" systemVersion="17G5019" minimumToolsVersion="Automatic" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="v1">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14460.32" systemVersion="17G8030" minimumToolsVersion="Automatic" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="v1.0.0">
<entity name="Feed" representedClassName="Feed" syncable="YES" codeGenerationType="class">
<attribute name="indexPath" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
@@ -7,7 +7,6 @@
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
<relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle" syncable="YES"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="feed" inverseEntity="FeedGroup" syncable="YES"/>
<relationship name="icon" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedIcon" inverseName="feed" inverseEntity="FeedIcon" syncable="YES"/>
<relationship name="meta" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedMeta" inverseName="feed" inverseEntity="FeedMeta" syncable="YES"/>
</entity>
<entity name="FeedArticle" representedClassName="FeedArticle" syncable="YES" codeGenerationType="class">
@@ -30,10 +29,6 @@
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Feed" inverseName="group" inverseEntity="Feed" syncable="YES"/>
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="children" inverseEntity="FeedGroup" syncable="YES"/>
</entity>
<entity name="FeedIcon" representedClassName="FeedIcon" syncable="YES" codeGenerationType="class">
<attribute name="icon" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES" customClassName="NSImage" syncable="YES"/>
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="icon" inverseEntity="Feed" syncable="YES"/>
</entity>
<entity name="FeedMeta" representedClassName="FeedMeta" syncable="YES" codeGenerationType="class">
<attribute name="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="etag" optional="YES" attributeType="String" syncable="YES"/>
@@ -43,11 +38,15 @@
<attribute name="url" optional="YES" attributeType="String" syncable="YES"/>
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed" syncable="YES"/>
</entity>
<entity name="Options" representedClassName="Options" syncable="YES" codeGenerationType="class">
<attribute name="key" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="value" optional="YES" attributeType="String" syncable="YES"/>
</entity>
<elements>
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="165"/>
<element name="Feed" positionX="-278.84765625" positionY="-112.953125" width="128" height="150"/>
<element name="FeedArticle" positionX="-96.77734375" positionY="-113.83984375" width="128" height="195"/>
<element name="FeedGroup" positionX="-460.37890625" positionY="-111.62890625" width="130.52734375" height="135"/>
<element name="FeedIcon" positionX="-202.79296875" positionY="137.71875" width="128" height="75"/>
<element name="FeedMeta" positionX="-348.02734375" positionY="136.89453125" width="128" height="150"/>
<element name="Options" positionX="-279" positionY="36" width="128" height="75"/>
</elements>
</model>

View File

@@ -1,6 +1,6 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
@@ -20,12 +20,13 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Foundation/Foundation.h>
#import <Cocoa/Cocoa.h>
@import Cocoa;
@class Feed;
#define ENV_LOG_YOUTUBE 1
@interface OpmlExport : NSObject
+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc;
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc;
// TODO: Make plugins extensible? community extensions.
@interface YouTubePlugin : NSObject
+ (NSString*)feedURL:(NSURL*)url;
+ (NSString*)videoImage:(NSString*)videoid;
+ (NSString*)videoImageHQ:(NSString*)videoid;
@end

View File

@@ -0,0 +1,84 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#include "Download3rdParty.h"
@implementation YouTubePlugin
/**
Transforms YouTube URL to XML feed URL. @c https://www.youtube.com/{channel|user|playlist]}/{id}
@note
Some YouTube HTML pages contain the 'alternate' tag, others don't.
This method will only be executed, if no other feed url was found.
@return @c nil if @c url is not properly formatted, YouTube feed URL otherwise.
*/
+ (NSString*)feedURL:(NSURL*)url {
if (![url.host hasSuffix:@"youtube.com"]) // 'youtu.be' & 'youtube-nocookie.com' will redirect
return nil;
// https://www.youtube.com/channel/[channel-id]
// https://www.youtube.com/user/[user-name]
// https://www.youtube.com/playlist?list=[playlist-id]
#if DEBUG && ENV_LOG_YOUTUBE
printf("resolving YouTube url:\n");
printf(" ↳ %s\n", url.absoluteString.UTF8String);
#endif
NSString *found = nil;
NSArray<NSString*> *parts = url.pathComponents;
if (parts.count > 1) { // first path component is always '/'
static NSString* const ytBase = @"https://www.youtube.com/feeds/videos.xml";
NSString *type = parts[1];
if ([type isEqualToString:@"channel"]) {
if (parts.count > 2)
found = [ytBase stringByAppendingFormat:@"?channel_id=%@", parts[2]];
} else if ([type isEqualToString:@"user"]) {
if (parts.count > 2)
found = [ytBase stringByAppendingFormat:@"?user=%@", parts[2]];
} else if ([type isEqualToString:@"playlist"]) {
NSURLComponents *uc = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
for (NSURLQueryItem *q in uc.queryItems) {
if ([q.name isEqualToString:@"list"]) {
found = [ytBase stringByAppendingFormat:@"?playlist_id=%@", q.value];
break;
}
}
}
}
#if DEBUG && ENV_LOG_YOUTUBE
printf(" ↳ %s\n", found ? found.UTF8String : "could not resolve!");
#endif
return found; // may be nil
}
/// @return @c http://i.ytimg.com/vi/<videoid>/default.jpg
+ (NSString*)videoImage:(NSString*)videoid {
return [NSString stringWithFormat:@"http://i.ytimg.com/vi/%@/default.jpg", videoid];
}
/// @return @c http://i.ytimg.com/vi/<videoid>/hqdefault.jpg
+ (NSString*)videoImageHQ:(NSString*)videoid {
return [NSString stringWithFormat:@"http://i.ytimg.com/vi/%@/hqdefault.jpg", videoid];
}
@end

View File

@@ -0,0 +1,47 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class Feed, RSHTMLMetadata, FeedDownload;
@protocol FaviconDownloadDelegate;
@interface FaviconDownload : NSObject
/// @c img and @c path are @c nil if image is not valid or couldn't be downloaded.
typedef void(^FaviconDownloadBlock)(NSImage * _Nullable img, NSURL * _Nullable path);
// Instantiation methods
+ (instancetype)withURL:(nonnull NSString*)urlStr isImageURL:(BOOL)flag;
+ (instancetype)updateFeed:(Feed*)feed finally:(nullable os_block_t)block;
// Actions
- (instancetype)startWithDelegate:(id<FaviconDownloadDelegate>)observer;
- (instancetype)startWithBlock:(nonnull FaviconDownloadBlock)block;
- (void)cancel;
// Extract from HTML metadata
+ (nullable NSString*)urlForMetadata:(RSHTMLMetadata*)meta;
@end
@protocol FaviconDownloadDelegate <NSObject>
@required
/// Called after image download. Called on error, but not if download is cancled.
- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path;
@end

View File

@@ -0,0 +1,225 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import RSXML2;
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "NSURL+Ext.h"
#import "NSURLRequest+Ext.h"
@interface FaviconDownload()
@property (nonatomic, weak) id<FaviconDownloadDelegate> delegate;
@property (nonatomic, strong) FaviconDownloadBlock block;
@property (nonatomic, weak) NSURLSessionTask *currentDownload;
@property (nonatomic, assign) BOOL canceled;
@property (nonatomic, assign) BOOL assertIsImageURL; // prohibit processing of HTML data
@property (nonatomic, strong) NSURL *remoteURL; // remote absolute path
@property (nonatomic, strong) NSURL *hostURL; // remote base domain
@property (nonatomic, strong) NSURL *fileURL; // local location
@end
@implementation FaviconDownload
// ---------------------------------------------------------------
// | MARK: - Class methods
// ---------------------------------------------------------------
/**
Start favicon download request on existing @c Feed object.
@note Will post a @c kNotificationFeedIconUpdated notification on success.
*/
+ (instancetype)updateFeed:(Feed*)feed finally:(nullable os_block_t)block {
NSString *url = feed.link;
if (!url) url = feed.meta.url;
NSManagedObjectContext *moc = feed.managedObjectContext;
NSManagedObjectID *oid = feed.objectID;
return [[self withURL:url isImageURL:NO] startWithBlock:^(NSImage * _Nullable img, NSURL * _Nullable path) {
if (path) [(Feed*)[moc objectWithID:oid] setNewIcon:path];
if (block) block();
}];
}
/**
Instantiate new loader from URL.
@param flag If @c YES skip parsing of html.
*/
+ (instancetype)withURL:(nonnull NSString*)urlStr isImageURL:(BOOL)flag {
FaviconDownload *this = [super new];
this.remoteURL = [NSURL URLWithString:urlStr];
this.assertIsImageURL = flag;
return this;
}
// ---------------------------------------------------------------
// | MARK: - Actions
// ---------------------------------------------------------------
/// Start download request and notify @c oberserver during the various steps.
- (instancetype)startWithDelegate:(id<FaviconDownloadDelegate>)observer {
self.delegate = observer;
[self performSelectorInBackground:@selector(start) withObject:nil];
return self;
}
/// Start download request and notify @c block once finished.
- (instancetype)startWithBlock:(nonnull FaviconDownloadBlock)block {
self.block = block;
[self performSelectorInBackground:@selector(start) withObject:nil];
return self;
}
/// Cancel running download task immediately. Will notify neither @c delegate nor @c block
- (void)cancel {
self.canceled = YES;
self.delegate = nil;
self.block = nil;
[self.currentDownload cancel];
}
/// Called for both; delegate and block observer.
- (void)start {
if (self.canceled)
return;
// Base URL part. E.g., https://stackoverflow.com/a/15897956/10616114 ==> https://stackoverflow.com/
self.hostURL = [[NSURL URLWithString:@"/" relativeToURL:self.remoteURL] absoluteURL];
self.assertIsImageURL ? [self continueWithImageDownload] : [self continueWithHTMLDownload];
}
/// Start request on HTML metadata and try parsing it. Will update @c remoteURL (@c nil on error)
- (void)continueWithHTMLDownload {
if (self.canceled)
return;
self.remoteURL = nil;
self.currentDownload = [[NSURLRequest requestWithURL:self.hostURL] dataTask:^(NSData * _Nullable htmlData, NSError * _Nullable error, NSHTTPURLResponse *response) {
if (self.canceled)
return;
if (htmlData) {
// TODO: use session delegate to stop download after <head>
RSXMLData *xml = [[RSXMLData alloc] initWithData:htmlData url:response.URL];
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
RSHTMLMetadata *meta = [parser parseSync:&error];
if (error) meta = nil;
NSString *u = [FaviconDownload urlForMetadata:meta];
if (u) self.remoteURL = [NSURL URLWithString:u];
}
[self continueWithImageDownload];
}];
}
/// Choose action based on whether @c .remoteURL is set.
- (void)continueWithImageDownload {
if (self.canceled)
return;
self.remoteURL ? [self loadImageFromRemoteURL] : [self loadImageFromDefaultLocation];
}
/// Download image from default location @c /favicon.ico
- (void)loadImageFromDefaultLocation {
self.remoteURL = [self.hostURL URLByAppendingPathComponent:@"favicon.ico"];
self.hostURL = nil; // prevent recursion in loadImageFromRemoteURL
[self loadImageFromRemoteURL];
}
/// Start download of favicon whether from already parsed favicon URL or default location.
- (void)loadImageFromRemoteURL {
if (self.canceled)
return;
self.currentDownload = [[NSURLRequest requestWithURL:self.remoteURL] downloadTask:^(NSURL * _Nullable path, NSError * _Nullable error) {
if (error) path = nil; // will also nullify img
NSImage *img = path ? [[NSImage alloc] initByReferencingURL:path] : nil;
if (img.valid) {
// move image to temporary destination, otherwise dataTask: will delete it.
NSString *tmpFile = NSProcessInfo.processInfo.globallyUniqueString;
self.fileURL = [[path URLByDeletingLastPathComponent] file:tmpFile ext:nil];
[path moveTo:self.fileURL];
} else if (self.hostURL) {
[self loadImageFromDefaultLocation]; // starts a new request
return;
}
[self finishAndNotify];
}];
}
/// Called after trying all favicon URLs. May be @c nil if none of the URLs were successful.
- (void)finishAndNotify {
if (self.canceled)
return;
NSURL *path = self.fileURL;
NSImage *img = [[NSImage alloc] initByReferencingURL:path];
if (!img.valid) { path = nil; img = nil; }
#if DEBUG && ENV_LOG_DOWNLOAD
printf("ICON %1.0fx%1.0f %s\n", img.size.width, img.size.height, self.remoteURL.absoluteString.UTF8String);
printf(" ↳ %s\n", path.absoluteString.UTF8String);
#endif
dispatch_async(dispatch_get_main_queue(), ^{
[self.delegate faviconDownload:self didFinish:path];
if (self.block) { self.block(img, path); self.block = nil; }
});
}
// ---------------------------------------------------------------
// | MARK: - Extract from HTML metadata
// ---------------------------------------------------------------
/// Extract favicon URL from parsed HTML metadata.
+ (nullable NSString*)urlForMetadata:(RSHTMLMetadata*)meta {
if (!meta) return nil;
double bestScore = DBL_MAX;
NSString *iconURL = nil;
if (meta.faviconLink.length > 0) {
bestScore = ScoreIcon(nil);
iconURL = meta.faviconLink; // Replaced below if size is between 18 and 56
}
if (meta.iconLinks.count > 0) {
for (RSHTMLMetadataIconLink *icon in meta.iconLinks) {
double currentScore = ScoreIcon(icon);
if (currentScore < bestScore) {
bestScore = currentScore;
iconURL = icon.link;
}
}
if (!iconURL) // return first, even if all items in list have size 0
return meta.iconLinks.firstObject.link;
}
return iconURL;
}
/// Find icon with closest matching size 32x32 (lower score means better match)
static double ScoreIcon(RSHTMLMetadataIconLink *icon) {
if ([icon.sizes isEqualToString:@"any"])
return DBL_MAX; // exclude svg
CGSize size = [icon getSize];
double area = size.width * size.height;
if (area <= 0) {
if ([icon.title hasPrefix:@"apple-touch-icon"])
area = 180 * 180; // https://webhint.io/docs/user-guide/hints/hint-apple-touch-icons/
else
area = 18 * 18; // Size could be 16, 32, or 48. Assuming its better than 16px.
}
double match = log10(area) - log10(32 * 32);
return fabs(match) + (match < 0 ? 1e-5 : 0); // slightly prefer larger icons (64px over 16px)
}
@end

View File

@@ -0,0 +1,63 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class RSParsedFeed, RSHTMLMetadataFeedLink, Feed, FaviconDownload;
@protocol FeedDownloadDelegate;
/**
All properties will be parsed and stored in local variables.
This will avoid unnecessary core data operations if user decides to cancel the edit.
*/
@interface FeedDownload : NSObject
@property (readonly, nonnull) NSURLRequest *request;
@property (readonly, nullable) NSHTTPURLResponse* response;
@property (readonly, nullable) RSParsedFeed *xmlfeed;
@property (readonly, nullable) NSError *error;
@property (readonly, nullable) NSString *faviconURL;
typedef void (^FeedDownloadBlock)(FeedDownload *sender);
// Instantiation methods
+ (instancetype)withURL:(NSString*)url;
+ (instancetype)withFeed:(Feed*)feed forced:(BOOL)flag;
// Actions
- (instancetype)startWithDelegate:(id<FeedDownloadDelegate>)delegate;
- (instancetype)startWithBlock:(nonnull FeedDownloadBlock)block;
- (void)cancel;
- (BOOL)copyValuesTo:(nonnull Feed*)feed ignoreError:(BOOL)flag;
// Getter
- (FaviconDownload*)faviconDownload;
@end
/// Protocol for handling an in memory download
@protocol FeedDownloadDelegate <NSObject>
@optional
/// Delegate must return chosen URL. If not implemented, the first URL will be used.
- (NSString*)feedDownload:(FeedDownload*)sender selectFeedFromList:(NSArray<RSHTMLMetadataFeedLink*>*)list;
/// Only called if an URL redirect occured.
- (void)feedDownload:(FeedDownload*)sender urlRedirected:(NSString*)newURL;
/// Called after xml data is loaded and parsed. Called on error, but not if download is cancled.
- (void)feedDownloadDidFinish:(FeedDownload*)sender;
@end

View File

@@ -0,0 +1,238 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import RSXML2;
#import "FeedDownload.h"
#import "FaviconDownload.h"
#import "Download3rdParty.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "NSError+Ext.h"
#import "NSURLRequest+Ext.h"
@interface FeedDownload()
@property (nonatomic, assign) BOOL respondToSelectFeed, respondToRedirect, respondToEnd;
@property (nonatomic, weak) id<FeedDownloadDelegate> delegate;
@property (nonatomic, strong) FeedDownloadBlock block;
@property (nonatomic, weak) NSURLSessionTask *currentDownload;
@property (nonatomic, assign) BOOL canceled;
@property (nonatomic, assign) BOOL assertIsFeedURL; // prohibit processing of HTML data
@property (nonatomic, strong) NSURLRequest *request;
@property (nonatomic, strong) NSHTTPURLResponse* response;
@property (nonatomic, strong) RSParsedFeed *xmlfeed;
@property (nonatomic, strong) NSError *error;
@property (nonatomic, strong) NSString *faviconURL;
@end
@implementation FeedDownload
// ---------------------------------------------------------------
// | MARK: - Class methods
// ---------------------------------------------------------------
/// @return New instance with plain @c url request.
+ (instancetype)withURL:(NSString*)url {
FeedDownload *this = [FeedDownload new];
this.request = [NSURLRequest withURL:url];
return this;
}
/// @return New instance using existing @c feed as template. Will reuse @c Etag and @c Last-modified headers.
+ (instancetype)withFeed:(Feed*)feed forced:(BOOL)flag {
FeedMeta *m = feed.meta;
NSMutableURLRequest *req = [NSMutableURLRequest withURL:m.url];
if (!flag) // any request that is not forced, is a background update
req.networkServiceType = NSURLNetworkServiceTypeBackground;
if (feed.articles.count > 0) { // dont use cache if feed is broken
// Both fields should be send (if server provides both) RFC: https://tools.ietf.org/html/rfc7232#section-2.4
if (m.etag.length > 0)
[req setValue:[m.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""] forHTTPHeaderField:@"If-None-Match"]; // ETag
if (m.modified.length > 0)
[req setValue:m.modified forHTTPHeaderField:@"If-Modified-Since"];
}
FeedDownload *this = [FeedDownload new];
this.assertIsFeedURL = YES;
this.request = req;
return this;
}
// ---------------------------------------------------------------
// | MARK: - Getter & Setter
// ---------------------------------------------------------------
/// Set delegate and check what methods are implemented.
- (void)setDelegate:(id<FeedDownloadDelegate>)observer {
_delegate = observer;
_respondToSelectFeed = [observer respondsToSelector:@selector(feedDownload:selectFeedFromList:)];
_respondToRedirect = [observer respondsToSelector:@selector(feedDownload:urlRedirected:)];
_respondToEnd = [observer respondsToSelector:@selector(feedDownloadDidFinish:)];
}
/// @return Initialize @c FaviconDownload instance. Will reuse favicon url from HTML parsing.
- (FaviconDownload*)faviconDownload {
if (self.faviconURL.length > 0) // favicon url already found, nice job
return [FaviconDownload withURL:self.faviconURL isImageURL:YES];
NSString *url = self.xmlfeed.link; // does only work for status != 304
if (!url) url = self.response.URL.absoluteString;
return [FaviconDownload withURL:url isImageURL:NO];
}
// ---------------------------------------------------------------
// | MARK: - Actions
// ---------------------------------------------------------------
/// Start download request and use @c delegate as callback notifier.
- (instancetype)startWithDelegate:(id<FeedDownloadDelegate>)delegate {
self.delegate = delegate;
[self downloadSource:self.request];
return self;
}
/// Start download request and use @c block as callback notifier.
- (instancetype)startWithBlock:(nonnull FeedDownloadBlock)block {
self.block = block;
[self downloadSource:self.request];
return self;
}
/// Cancel running download task without notice. Will notify neither @c delegate nor @c block
- (void)cancel {
self.canceled = YES;
self.delegate = nil;
self.block = nil;
[self.currentDownload cancel];
}
/**
Persist in memory object by copying all attributes to permanent core data storage.
@param flag If @c YES then @c FeedGroup won't increase the error count for the feed.
Feed will be scheduled as soon as the user reconnects to the internet.
@return @c YES if downloaded feed contains at least one article. ( @c 304 returns @c NO )
*/
- (BOOL)copyValuesTo:(nonnull Feed*)feed ignoreError:(BOOL)flag {
if (!flag && self.error) // Increase error count and schedule next update.
[feed.meta setErrorAndPostponeSchedule];
else if (self.response) // Update Etag & Last modified and schedule next update.
[feed.meta setSucessfulWithResponse:self.response];
else // Update URL but keep schedule (e.g., error while adding feed should auto-try once reconnected)
[feed.meta setUrlIfChanged:self.request.URL.absoluteString];
// If feed is broken indicate that feed will not be updated
if (!self.xmlfeed || self.xmlfeed.articles.count == 0)
return NO;
// Else: Update stored articles and indicate that feed was updated
[feed updateWithRSS:self.xmlfeed postUnreadCountChange:YES];
return YES;
}
// ---------------------------------------------------------------
// | MARK: - HTML Source Handling
// ---------------------------------------------------------------
/// Take the @c urlStr and run a download @c dataTask: on it. Auto-detect if data is HTML or feed.
- (void)downloadSource:(NSURLRequest*)request {
self.currentDownload = [request dataTask:^(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response) {
self.error = error;
self.response = response;
if (!data) { // data = nil if (error || 304)
[self performSelectorOnMainThread:@selector(finishAndNotify) withObject:nil waitUntilDone:NO];
return;
}
RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:response.URL];
if (!self.assertIsFeedURL && [xml.parserClass isHTMLParser])
[self processXMLDataHTML:xml]; // HTML source handling
else
[self processXMLDataFeed:xml]; // XML source handling
}];
}
/// The downloaded source seems to be HTML data, lets parse it with @c RSXML @c RSHTMLMetadataParser
- (void)processXMLDataHTML:(RSXMLData*)xml {
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
[parser parseAsync:^(RSHTMLMetadata * _Nullable meta, NSError * _Nullable error) {
NSString *feedURL = nil;
if (error) {
self.error = error;
}
else if (!meta || meta.feedLinks.count == 0) {
if ([xml.url.host hasSuffix:@"youtube.com"])
feedURL = [YouTubePlugin feedURL:xml.url];
if (feedURL.length == 0)
self.error = [NSError feedURLNotFound:xml.url];
}
else {
feedURL = meta.feedLinks.firstObject.link;
if (meta.feedLinks.count > 1 && self.respondToSelectFeed)
feedURL = [self.delegate feedDownload:self selectFeedFromList:meta.feedLinks];
if (!feedURL)
self.error = [NSError canceledByUser];
}
// finalize HTML parsing
if (self.error) {
[self finishAndNotify];
} else {
self.assertIsFeedURL = YES;
self.faviconURL = [FaviconDownload urlForMetadata:meta]; // re-use favicon url (if present)
// Feeds like https://news.ycombinator.com/ return 503 if URLs are requested too rapidly
//CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, false); // Non-blocking sleep (1s)
[self downloadSource:[NSURLRequest withURL:feedURL]];
}
}];
}
// ---------------------------------------------------------------
// | MARK: - XML Source Handling
// ---------------------------------------------------------------
/// The downloaded source seems to be proper feed data, lets parse it with @c RSXML @c RSFeedParser
- (void)processXMLDataFeed:(RSXMLData*)xml {
RSFeedParser *parser = [RSFeedParser parserWithXMLData:xml];
parser.dontStopOnLowerAsciiBytes = YES;
[parser parseAsync:^(RSParsedFeed * _Nullable parsedDocument, NSError * _Nullable error) {
self.error = error;
self.xmlfeed = parsedDocument;
[self finishAndNotify];
}];
}
/// Check if @c responseURL @c != @c requestURL
- (void)checkRedirectAndNotify {
NSString *responseURL = self.response.URL.absoluteString;
if (responseURL.length > 0 && ![responseURL isEqualToString:self.request.URL.absoluteString]) {
if (self.respondToRedirect) [self.delegate feedDownload:self urlRedirected:responseURL];
}
}
/// Called when feed download finished or failed, but not if canceled. Will notify @c delegate .
- (void)finishAndNotify {
if (self.canceled)
return;
[self checkRedirectAndNotify];
// notify observer
if (self.respondToEnd) [self.delegate feedDownloadDidFinish:self];
if (self.block) { self.block(self); self.block = nil; }
}
@end

View File

@@ -0,0 +1,63 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class FeedGroup;
typedef NS_OPTIONS(NSUInteger, OpmlFileExportOptions) {
OpmlFileExportOptionFlattened = 1 << 1,
OpmlFileExportOptionFullBackup = 1 << 2,
};
#pragma mark - Protocols
@protocol OpmlFileImportDelegate <NSObject>
@required
- (NSManagedObjectContext*)opmlFileImportContext; // currently called only once
@optional
- (void)opmlFileImportWillBegin:(NSManagedObjectContext*)moc;
- (void)opmlFileImportDidEnd:(NSManagedObjectContext*)moc;
@end
@protocol OpmlFileExportDelegate <NSObject>
@required
- (NSArray<FeedGroup*>*)opmlFileExportListOfFeedGroups:(OpmlFileExportOptions)options;
@end
#pragma mark - Classes
@interface OpmlFileImport : NSObject
@property (weak) id<OpmlFileImportDelegate> delegate;
+ (instancetype)withDelegate:(id<OpmlFileImportDelegate>)delegate;
- (void)showImportDialog:(NSWindow*)window;
- (void)importFiles:(NSArray<NSURL*>*)files;
@end
@interface OpmlFileExport : NSObject
@property (weak) id<OpmlFileExportDelegate> delegate;
+ (instancetype)withDelegate:(nullable id<OpmlFileExportDelegate>)delegate;
- (void)showExportDialog:(NSWindow*)window;
- (nullable NSError*)writeOPMLFile:(NSURL*)url withOptions:(OpmlFileExportOptions)opt;
@end

View File

@@ -0,0 +1,313 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import RSXML2;
#import "OpmlFile.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import "StoreCoordinator.h"
#import "Constants.h"
#import "NSDate+Ext.h"
#import "NSView+Ext.h"
#import "NSError+Ext.h"
#pragma mark - Helper
/// Loop over all subviews and find the @c NSButton that is selected.
static NSInteger RadioGroupSelection(NSView *view) {
for (NSButton *btn in view.subviews) {
if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) {
return btn.tag;
}
}
return -1;
}
// ################################################################
// #
// # OPML Import
// #
// ################################################################
#pragma mark - Import
@implementation OpmlFileImport
+ (instancetype)withDelegate:(id<OpmlFileImportDelegate>)delegate {
OpmlFileImport *opml = [[super alloc] init];
opml.delegate = delegate;
return opml;
}
/// Display Open File Panel to select @c .opml file. Perform web requests (feed data & icon) within a single undo group.
- (void)showImportDialog:(NSWindow*)window {
NSOpenPanel *op = [NSOpenPanel openPanel];
op.allowedFileTypes = @[UTI_OPML];
op.allowsMultipleSelection = YES;
[op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
if (result == NSModalResponseOK) {
[self importFiles:op.URLs];
}
}];
}
/// Perform core data import on all items of all @c files
- (void)importFiles:(NSArray<NSURL*>*)files {
id<OpmlFileImportDelegate> controller = self.delegate;
BOOL respondBegin = [controller respondsToSelector:@selector(opmlFileImportWillBegin:)];
BOOL respondEnd = [controller respondsToSelector:@selector(opmlFileImportDidEnd:)];
NSManagedObjectContext *moc = [controller opmlFileImportContext];
if (respondBegin)
[controller opmlFileImportWillBegin:moc];
NSUInteger lastIndex = [StoreCoordinator countRootItemsInContext:moc];
__block NSUInteger current = lastIndex;
[self enumerateFiles:files withBlock:^(RSOPMLItem *item) {
[self importFeed:item parent:nil index:(int32_t)current inContext:moc];
current += 1;
} finally:(!respondEnd ? nil : ^{ // ignore block if delegate doesn't respond
[controller opmlFileImportDidEnd:moc];
})];
}
/// Loop over all files and parse XML data. Calls @c block() for each root @c RSOPMLItem.
- (void)enumerateFiles:(NSArray<NSURL*>*)files withBlock:(void(^)(RSOPMLItem *item))block finally:(nullable dispatch_block_t)finally {
dispatch_group_t group = dispatch_group_create();
for (NSURL *url in files) {
dispatch_group_enter(group);
NSData *data = [NSData dataWithContentsOfURL:url];
RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:url];
RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml];
[parser parseAsync:^(RSOPMLItem * _Nullable doc, NSError * _Nullable error) {
if (![error inCasePresent:NSApp]) {
for (RSOPMLItem *itm in doc.children) {
block(itm);
}
}
dispatch_group_leave(group);
}];
}
if (finally) dispatch_group_notify(group, dispatch_get_main_queue(), finally);
}
/**
Import single item and recursively repeat import for each child.
@param item The item to be imported.
@param parent The already processed parent item.
@param idx @c sortIndex within the @c parent item.
@param moc Managed object context.
*/
- (void)importFeed:(RSOPMLItem*)item parent:(FeedGroup*)parent index:(int32_t)idx inContext:(NSManagedObjectContext*)moc {
FeedGroupType type = GROUP;
if ([item attributeForKey:OPMLXMLURLKey]) {
type = FEED;
} else if ([item attributeForKey:@"separator"]) { // baRSS specific
type = SEPARATOR;
}
FeedGroup *newFeed = [FeedGroup newGroup:type inContext:moc];
[newFeed setParent:parent andSortIndex:idx];
if (type == SEPARATOR)
return;
newFeed.name = item.displayName;
if (type == FEED) {
id refresh = [item attributeForKey:@"refreshInterval"]; // baRSS specific
int32_t interval = kDefaultFeedRefreshInterval; // TODO: set -1, then auto
if (refresh)
interval = (int32_t)[refresh integerValue];
newFeed.feed.meta.url = [item attributeForKey:OPMLXMLURLKey];
newFeed.feed.meta.refresh = interval;
} else { // GROUP
for (NSUInteger i = 0; i < item.children.count; i++) {
[self importFeed:item.children[i] parent:newFeed index:(int32_t)i inContext:moc];
}
}
}
/**
Ask user for permission to import new items (prior import). User can choose to append or replace existing items.
If user chooses to replace existing items, perform core data request to delete all feeds.
@param document Used to count feed items that will be imported
@return @c -1: User clicked 'Cancel' button. @c 0: Append items. @c 1: Overwrite items.
*/
//- (NSInteger)askToAppendOrOverwriteAlert:(RSOPMLItem*)document inContext:(NSManagedObjectContext*)moc {
// NSUInteger count = [self recursiveNumberOfFeeds:document];
// NSAlert *alert = [[NSAlert alloc] init];
// alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"Import of %lu feed items", nil), count];
// alert.informativeText = NSLocalizedString(@"Do you want to append or replace existing items?", nil);
// [alert addButtonWithTitle:NSLocalizedString(@"Import", nil)];
// [alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)];
// alert.accessoryView = [NSView radioGroup:@[NSLocalizedString(@"Append", nil),
// NSLocalizedString(@"Overwrite", nil)]];
//
// if ([alert runModal] == NSAlertFirstButtonReturn) {
// return RadioGroupSelection(alert.accessoryView);
// }
// return -1; // cancel button
//}
/// Count items where @c xmlURL key is set.
//- (NSUInteger)recursiveNumberOfFeeds:(RSOPMLItem*)document {
// if ([document attributeForKey:OPMLXMLURLKey]) {
// return 1;
// } else {
// NSUInteger sum = 0;
// for (RSOPMLItem *child in document.children) {
// sum += [self recursiveNumberOfFeeds:child];
// }
// return sum;
// }
//}
@end
// ################################################################
// #
// # OPML Export
// #
// ################################################################
#pragma mark - Export
@implementation OpmlFileExport
+ (instancetype)withDelegate:(nullable id<OpmlFileExportDelegate>)delegate {
OpmlFileExport *opml = [[super alloc] init];
opml.delegate = delegate;
return opml;
}
/// Display Save File Panel to select file destination.
- (void)showExportDialog:(NSWindow*)window {
NSSavePanel *sp = [NSSavePanel savePanel];
sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [NSDate dayStringLocalized]];
sp.allowedFileTypes = @[UTI_OPML];
sp.allowsOtherFileTypes = YES;
NSView *select = [NSView radioGroup:@[NSLocalizedString(@"Everything", nil),
NSLocalizedString(@"Selection", nil)]];
NSView *nested = [NSView radioGroup:@[NSLocalizedString(@"Hierarchical", nil),
NSLocalizedString(@"Flattened", nil)]];
NSView *v1 = [NSView wrapView:select withLabel:NSLocalizedString(@"Export:", nil) padding:PAD_M];
NSView *v2 = [NSView wrapView:nested withLabel:NSLocalizedString(@"Format:", nil) padding:PAD_M];
NSView *final = [[NSView alloc] init];
[v1 placeIn:final x:0 yTop:0];
[v2 placeIn:final x:NSWidth(v1.frame) + 100 yTop:0];
[final setFrameSize:NSMakeSize(NSMaxX(v2.frame), NSHeight(v2.frame))];
sp.accessoryView = final;
[sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
if (result == NSModalResponseOK) {
OpmlFileExportOptions opt = 0;
if (RadioGroupSelection(select) == 0)
opt |= OpmlFileExportOptionFullBackup;
if (RadioGroupSelection(nested) == 1)
opt |= OpmlFileExportOptionFlattened;
[self writeOPMLFile:sp.URL withOptions:opt];
}
}];
}
/**
Convert list of @c FeedGroup to @c NSXMLDocument and write to local file @c url.
On error: show application alert (which is also returned).
@note Calls @c opmlExportListOfFeedGroups: on @c delegate to obtain export list.
*/
- (nullable NSError*)writeOPMLFile:(NSURL*)url withOptions:(OpmlFileExportOptions)opt {
NSArray<FeedGroup*> *list = [self.delegate opmlFileExportListOfFeedGroups:opt];
if (!list) list = [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:nil]; // fetch all if delegate == nil
NSError *error;
// TODO: set error if nil or empty
if (list.count > 0) {
BOOL keepTree = !(opt & OpmlFileExportOptionFlattened);
NSXMLDocument *doc = [self xmlDocumentForFeeds:list hierarchical:keepTree];
NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint];
[xml writeToURL:url options:NSDataWritingAtomic error:&error];
}
[error inCasePresent:NSApp];
return error;
}
/**
Create NSXMLNode structure with application header nodes and body node containing feed items.
@param flag If @c YES keep parent-child structure intact. If @c NO ignore all parents and add @c Feed items only.
*/
- (NSXMLDocument*)xmlDocumentForFeeds:(NSArray<FeedGroup*>*)list hierarchical:(BOOL)flag {
NSXMLElement *head = [NSXMLElement elementWithName:@"head"];
head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"],
[NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"],
[NSXMLElement elementWithName:@"dateCreated" stringValue:[NSDate timeStringISO8601]] ];
NSXMLElement *body = [NSXMLElement elementWithName:@"body"];
for (FeedGroup *item in list) {
[self appendChild:item toNode:body hierarchical:flag];
}
NSXMLElement *opml = [NSXMLElement elementWithName:@"opml"];
opml.attributes = @[[NSXMLNode attributeWithName:@"version" stringValue:@"1.0"]];
opml.children = @[head, body];
NSXMLDocument *xml = [NSXMLDocument documentWithRootElement:opml];
xml.version = @"1.0";
xml.characterEncoding = @"UTF-8";
return xml;
}
/**
Build up @c NSXMLNode structure recursively. Essentially, re-create same structure as in core data storage.
@param flag If @c NO don't add groups to export file but continue evaluation of child items.
*/
- (void)appendChild:(FeedGroup*)item toNode:(NSXMLElement *)parent hierarchical:(BOOL)flag {
if (flag || item.type != GROUP) {
// dont add group node if hierarchical == NO
NSXMLElement *outline = [NSXMLElement elementWithName:@"outline"];
[parent addChild:outline];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTitleKey stringValue:item.anyName]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTextKey stringValue:item.anyName]];
if (item.type == SEPARATOR) {
[outline addAttribute:[NSXMLNode attributeWithName:@"separator" stringValue:@"true"]]; // baRSS specific
} else if (item.feed) {
[outline addAttribute:[NSXMLNode attributeWithName:OPMLHMTLURLKey stringValue:item.feed.link]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLXMLURLKey stringValue:item.feed.meta.url]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTypeKey stringValue:@"rss"]];
NSString *intervalStr = [NSString stringWithFormat:@"%d", item.feed.meta.refresh];
[outline addAttribute:[NSXMLNode attributeWithName:@"refreshInterval" stringValue:intervalStr]]; // baRSS specific
// TODO: option to export unread state?
}
parent = outline;
}
for (FeedGroup *subItem in [item sortedChildren]) {
[self appendChild:subItem toNode:parent hierarchical:flag];
}
}
@end

View File

@@ -1,6 +1,6 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
@@ -20,41 +20,29 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
#import <RSXML/RSXML.h>
@import Cocoa;
@class Feed;
@interface FeedDownload : NSObject
@interface UpdateScheduler : NSObject
@property (class, readonly) NSUInteger feedsInQueue;
@property (class, readonly) NSDate *dateScheduled;
@property (class, readonly) BOOL allowNetworkConnection;
@property (class, readonly) BOOL isUpdating;
@property (class, setter=setPaused:) BOOL isPaused;
// Getter
+ (NSString*)remainingTimeTillNextUpdate:(nullable double*)remaining;
+ (NSString*)updatingXFeeds;
// Scheduling
+ (void)scheduleNextFeed;
+ (void)forceUpdateAllFeeds;
+ (void)downloadList:(NSArray<Feed*>*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block;
+ (void)updateAllFavicons;
// Auto Download & Parse Feed URL
+ (void)autoDownloadAndParseURL:(NSString*)url;
+ (void)autoDownloadAndParseUpdateURL;
// Register for network change notifications
+ (void)registerNetworkChangeNotification;
+ (void)unregisterNetworkChangeNotification;
// Scheduling
+ (void)scheduleUpdateForUpcomingFeeds;
+ (void)forceUpdateAllFeeds;
// Downloading
+ (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(RSHTMLMetadata *meta))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block;
+ (void)autoDownloadAndParseURL:(NSString*)urlStr successBlock:(nullable os_block_t)block;
+ (void)batchDownloadFeeds:(NSArray<Feed*> *)list favicons:(BOOL)fav showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block;
// Favicon image download
+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block;
+ (void)downloadImage:(NSString*)url finished:(void(^)(NSImage * _Nullable img))block;
+ (nullable NSString*)faviconUrlForMetadata:(RSHTMLMetadata*)meta;
@end
/*
Developer Tip, error logs see:
Task <..> HTTP load failed (error code: -1003 [12:8])
Task <..> finished with error - code: -1003
==> NSURLErrorCannotFindHost in #import <Foundation/NSURLError.h>
TIC TCP Conn Failed [21:0x1d417fb00]: 1:65 Err(65)
==> EHOSTUNREACH in #import <sys/errno.h>
*/

View File

@@ -0,0 +1,312 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import SystemConfiguration;
#import "UpdateScheduler.h"
#import "Constants.h"
#import "StoreCoordinator.h"
#import "NSDate+Ext.h"
#import "FeedDownload.h"
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#include <stdatomic.h>
static NSTimer *_timer;
static SCNetworkReachabilityRef _reachability = NULL;
static BOOL _isReachable = YES;
static BOOL _updatePaused = NO;
static BOOL _nextUpdateIsForced = NO;
static _Atomic(NSUInteger) _queueSize = 0;
@implementation UpdateScheduler
// ################################################################
// # MARK: - Getter & Setter -
// ################################################################
/// @return Number of feeds being currently downloaded.
+ (NSUInteger)feedsInQueue { return _queueSize; }
/// @return Date when background update will fire. If updates are paused, date is @c distantFuture.
+ (NSDate *)dateScheduled { return _timer.fireDate; }
/// @return @c YES if current network state is reachable and updates are not paused by user.
+ (BOOL)allowNetworkConnection { return (_isReachable && !_updatePaused); }
/// @return @c YES if batch update is running
+ (BOOL)isUpdating { return _queueSize > 0; }
/// @return @c YES if update is paused by user.
+ (BOOL)isPaused { return _updatePaused; }
/// Set paused flag and cancel timer regardless of network connectivity.
+ (void)setPaused:(BOOL)flag {
// TODO: should pause persist between app launches?
_updatePaused = flag;
if (flag) [self scheduleTimer:nil];
else [self scheduleNextFeed];
}
/// Update status. 'Paused', 'No conection', or 'Next update in ...'
+ (NSString*)remainingTimeTillNextUpdate:(nullable double*)remaining {
double time = fabs(_timer.fireDate.timeIntervalSinceNow);
if (remaining)
*remaining = time;
if (!_isReachable)
return NSLocalizedString(@"No network connection", nil);
if (_updatePaused)
return NSLocalizedString(@"Updates paused", nil);
if (time > 1e9) // distance future, over 31 years
return @""; // aka. no feeds in list
return [NSString stringWithFormat:NSLocalizedString(@"Next update in %@", nil),
[NSDate stringForRemainingTime:_timer.fireDate]];
}
/// Update status. 'Updating X feeds ' or empty string if not updating.
+ (NSString*)updatingXFeeds {
NSUInteger c = _queueSize;
switch (c) {
case 0: return @"";
case 1: return NSLocalizedString(@"Updating 1 feed …", nil);
default: return [NSString stringWithFormat:NSLocalizedString(@"Updating %lu feeds …", nil), c];
}
}
// ################################################################
// # MARK: - Schedule Timer Actions -
// ################################################################
/// Get date of next up feed and start the timer.
+ (void)scheduleNextFeed {
if (![self allowNetworkConnection]) // timer will restart once connection exists
return;
if (_queueSize > 0) // assume every update ends with scheduleNextFeed
return; // skip until called again
NSDate *nextTime = [StoreCoordinator nextScheduledUpdate]; // if nextTime = nil, then no feeds to update
if (nextTime && [nextTime timeIntervalSinceNow] < 1) { // mostly, if app was closed for a long time
nextTime = [NSDate dateWithTimeIntervalSinceNow:1];
}
[self scheduleTimer:nextTime];
}
/// Start download of all feeds (immediatelly) regardless of @c .scheduled property.
+ (void)forceUpdateAllFeeds {
if (![self allowNetworkConnection]) // timer will restart once connection exists
return;
_nextUpdateIsForced = YES;
[self scheduleTimer:[NSDate dateWithTimeIntervalSinceNow:0.05]];
}
/**
Set new @c .fireDate and @c .tolerance for update timer.
@param nextTime If @c nil disable timer and set @c .fireDate to distant future.
*/
+ (void)scheduleTimer:(NSDate*)nextTime {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_timer = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:[self class] selector:@selector(updateTimerCallback) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
});
if (!nextTime)
nextTime = [NSDate distantFuture];
NSTimeInterval tolerance = [nextTime timeIntervalSinceNow] * 0.15;
_timer.tolerance = (tolerance < 1 ? 1 : tolerance); // at least 1 sec
_timer.fireDate = nextTime;
PostNotification(kNotificationScheduleTimerChanged, nil);
}
/// Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user.
+ (void)updateTimerCallback {
#ifdef DEBUG
NSLog(@"fired");
#endif
BOOL updateAll = _nextUpdateIsForced;
_nextUpdateIsForced = NO;
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
NSArray<Feed*> *list = [StoreCoordinator listOfFeedsThatNeedUpdate:updateAll inContext:moc];
//NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early.");
[self downloadList:list userInitiated:updateAll finally:^{
[StoreCoordinator saveContext:moc andParent:YES]; // save parents too ...
[moc reset];
[self scheduleNextFeed]; // always reset the timer
}];
}
// ################################################################
// # MARK: - Download Actions -
// ################################################################
/// Perform @c FaviconDownload on all core data @c Feed entries.
+ (void)updateAllFavicons {
for (Feed *f in [StoreCoordinator listOfFeedsThatNeedUpdate:YES inContext:nil])
[FaviconDownload updateFeed:f finally:nil];
}
/// Download list of feeds. Either silently in background or with alerts in foreground.
+ (void)downloadList:(NSArray<Feed*>*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block {
if (![self allowNetworkConnection]) {
if (block) block();
return;
}
// Else: batch download
atomic_fetch_add_explicit(&_queueSize, list.count, memory_order_relaxed);
PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize));
dispatch_group_t group = dispatch_group_create();
for (Feed *f in list) {
dispatch_group_enter(group);
[self updateFeed:f alert:flag isForced:flag finally:^{
atomic_fetch_sub_explicit(&_queueSize, 1, memory_order_relaxed);
PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize));
dispatch_group_leave(group);
}];
}
if (block) dispatch_group_notify(group, dispatch_get_main_queue(), block);
}
/// Helper method to show modal error alert
static inline void AlertDownloadError(NSError *err, NSString *url) {
NSAlert *alertPopup = [NSAlert alertWithError:err];
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", url];
[alertPopup runModal];
}
/**
Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0).
@note Will post a @c kNotificationArticlesUpdated notification if download was successful and status code is @b not 304.
*/
+ (void)updateFeed:(Feed*)feed alert:(BOOL)alert isForced:(BOOL)forced finally:(nullable os_block_t)block {
NSManagedObjectContext *moc = feed.managedObjectContext;
NSManagedObjectID *oid = feed.objectID;
[[FeedDownload withFeed:feed forced:forced] startWithBlock:^(FeedDownload *mem) {
if (alert && mem.error) // but still copy values for error count increment
AlertDownloadError(mem.error, mem.request.URL.absoluteString);
Feed *f = [moc objectWithID:oid];
BOOL recentlyAdded = (f.articles.count == 0); // before copy values
BOOL downloadIcon = (!f.hasIcon && (recentlyAdded || forced));
BOOL needsNotification = [mem copyValuesTo:f ignoreError:NO];
[StoreCoordinator saveContext:moc andParent:YES];
if (needsNotification)
PostNotification(kNotificationArticlesUpdated, oid);
if (downloadIcon && !mem.error) {
[FaviconDownload updateFeed:f finally:block];
} else if (block) block(); // always call block(); with or without favicon download
}];
}
/**
Download feed at url and append to persistent store in root folder. On error present user modal alert.
Creates new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and saves them to the persistent store.
*/
+ (void)autoDownloadAndParseURL:(NSString*)url addAnyway:(BOOL)flag name:(nullable NSString*)title refresh:(int32_t)interval {
[[FeedDownload withURL:url] startWithBlock:^(FeedDownload *mem) {
if (!flag && mem.error) {
AlertDownloadError(mem.error, url);
return;
}
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
FeedGroup *fg = [FeedGroup appendToRoot:FEED inContext:moc];
[fg setNameIfChanged:title];
[fg.feed.meta setRefreshIfChanged:interval];
[mem copyValuesTo:fg.feed ignoreError:YES];
[StoreCoordinator saveContext:moc andParent:YES];
PostNotification(kNotificationFeedGroupInserted, fg.objectID);
if (!mem.error) [FaviconDownload updateFeed:fg.feed finally:nil];
[moc reset];
[UpdateScheduler scheduleNextFeed];
}];
}
/// Download and process feed url. Auto update feed title with an update interval of 30 min.
+ (void)autoDownloadAndParseURL:(NSString*)url {
[self autoDownloadAndParseURL:url addAnyway:NO name:nil refresh:kDefaultFeedRefreshInterval];
}
/// Insert Github URL for version releases with update interval 2 days and rename @c FeedGroup item.
+ (void)autoDownloadAndParseUpdateURL {
[self autoDownloadAndParseURL:versionUpdateURL addAnyway:YES name:NSLocalizedString(@"baRSS releases", nil) refresh:2 * TimeUnitDays];
}
// ################################################################
// # MARK: - Network Connection & Reachability -
// ################################################################
/// Set callback on @c self to listen for network reachability changes.
+ (void)registerNetworkChangeNotification {
// https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x
if (_reachability != NULL) return;
_reachability = SCNetworkReachabilityCreateWithName(NULL, "1.1.1.1");
if (_reachability == NULL) return;
// If reachability information is available now, we don't get a callback later
SCNetworkConnectionFlags flags;
if (SCNetworkReachabilityGetFlags(_reachability, &flags))
networkReachabilityCallback(_reachability, flags, NULL);
if (!SCNetworkReachabilitySetCallback(_reachability, networkReachabilityCallback, NULL) ||
!SCNetworkReachabilityScheduleWithRunLoop(_reachability, [[NSRunLoop currentRunLoop] getCFRunLoop], kCFRunLoopCommonModes))
{
CFRelease(_reachability);
_reachability = NULL;
}
}
/// Remove @c self callback (network reachability changes).
+ (void)unregisterNetworkChangeNotification {
if (_reachability != NULL) {
SCNetworkReachabilitySetCallback(_reachability, nil, nil);
SCNetworkReachabilitySetDispatchQueue(_reachability, nil);
CFRelease(_reachability);
_reachability = NULL;
}
}
/// Called when network interface or reachability changes.
static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkConnectionFlags flags, void *object) {
if (_reachability == NULL) return;
_isReachable = [UpdateScheduler hasConnectivity:flags];
PostNotification(kNotificationNetworkStatusChanged, @(_isReachable));
if (_isReachable) {
[UpdateScheduler scheduleNextFeed];
} else {
[UpdateScheduler scheduleTimer:nil];
}
}
/// @return @c YES if network connection established.
+ (BOOL)hasConnectivity:(SCNetworkReachabilityFlags)flags {
if ((flags & kSCNetworkReachabilityFlagsReachable) == 0)
return NO;
if ((flags & kSCNetworkReachabilityFlagsConnectionRequired) == 0)
return YES;
if ((flags & kSCNetworkReachabilityFlagsInterventionRequired) == 0 &&
((flags & kSCNetworkReachabilityFlagsConnectionOnDemand) != 0 ||
(flags & kSCNetworkReachabilityFlagsConnectionOnTraffic) != 0))
return YES; // no-intervention AND ( on-demand OR on-traffic )
return NO;
}
@end

View File

@@ -20,76 +20,12 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
@interface NSColor (RandomColor)
/// just for testing purposes
+ (NSColor*)randomColor;
/// RGB color with (251, 163, 58)
+ (NSColor*)rssOrange;
@end
// ---------------------------------------------------------------
// |
// | DrawImage
// |
// ---------------------------------------------------------------
IB_DESIGNABLE
@interface DrawImage : NSView
@property (strong) IBInspectable NSColor *color;
@property (assign) IBInspectable BOOL showBackground;
/** percentage value between 0 - 100 */
@property (assign, nonatomic) IBInspectable CGFloat roundness;
@property (assign, nonatomic) IBInspectable CGFloat contentScale;
@property (strong, readonly) NSImageView *imageView;
- (NSImage*)drawnImage;
@end
// ---------------------------------------------------------------
// |
// | RSSIcon
// |
// ---------------------------------------------------------------
IB_DESIGNABLE
@interface RSSIcon : DrawImage
@property (strong) IBInspectable NSColor *barsColor;
@property (strong) IBInspectable NSColor *gradientColor;
@property (assign) IBInspectable BOOL noConnection;
+ (NSImage*)iconWithSize:(CGFloat)size;
+ (NSImage*)systemBarIcon:(CGFloat)size tint:(NSColor*)color noConnection:(BOOL)conn;
@end
// ---------------------------------------------------------------
// |
// | SettingsIconGlobal
// |
// ---------------------------------------------------------------
IB_DESIGNABLE
@interface SettingsIconGlobal : DrawImage
@end
// ---------------------------------------------------------------
// |
// | SettingsIconGroup
// |
// ---------------------------------------------------------------
IB_DESIGNABLE
@interface SettingsIconGroup : DrawImage
@end
// ---------------------------------------------------------------
// |
// | DrawSeparator
// |
// ---------------------------------------------------------------
@import Cocoa;
/// Draw separator line in @c NSOutlineView
IB_DESIGNABLE
@interface DrawSeparator : NSView
@end
void RegisterImageViewNames(void);

View File

@@ -21,224 +21,178 @@
// SOFTWARE.
#import "DrawImage.h"
#import "Constants.h"
#import "UserPrefs.h"
@implementation NSColor (RandomColor)
/// @return Color with random R, G, B values for testing purposes
+ (NSColor*)randomColor {
return [NSColor colorWithRed:(arc4random()%50+20)/100.0
green:(arc4random()%50+20)/100.0
blue:(arc4random()%50+20)/100.0
alpha:1];
}
/// @return Orange color that is typically used for RSS
+ (NSColor*)rssOrange {
return [NSColor colorWithCalibratedRed:0.984 green:0.639 blue:0.227 alpha:1.0];
@implementation DrawSeparator
- (void)drawRect:(NSRect)r {
NSColor *color = [NSColor darkGrayColor];
NSGradient *grdnt = [[NSGradient alloc] initWithStartingColor:color endingColor:[color colorWithAlphaComponent:0.0]];
NSRect separatorRect = NSMakeRect(1, NSMidY(self.frame) - 1, NSWidth(self.frame) - 2, 2);
NSBezierPath *rounded = [NSBezierPath bezierPathWithRoundedRect:separatorRect xRadius:1 yRadius:1];
[grdnt drawInBezierPath:rounded angle:0];
}
@end
// ################################################################
// #
// # DrawImage
// #
// ################################################################
#pragma mark - Helper Methods
@implementation DrawImage
@synthesize roundness = _roundness, contentScale = _contentScale;
-(id)init{self=[super init];if(self)[self initialize];return self;}
-(id)initWithFrame:(CGRect)f{self=[super initWithFrame:f];if(self)[self initialize];return self;}
-(id)initWithCoder:(NSCoder*)c{self=[super initWithCoder:c];if(self)[self initialize];return self;}
//#if !TARGET_INTERFACE_BUILDER #endif
/// Prepare view content to autoresize when rescaling
- (void)initialize {
_contentScale = 1.0;
_imageView = [NSImageView imageViewWithImage:[self drawnImage]];
[_imageView setFrameSize:self.frame.size];
_imageView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
[self addSubview:_imageView];
/// @return @c MIN(s.width,s.height)
static inline const CGFloat ShorterSide(NSSize s) {
return (s.width < s.height ? s.width : s.height);
}
/**
Designated initializer. Will add rounded corners and background color.
/// Perform @c CGAffineTransform with custom rotation point
// CGAffineTransform RotateAroundPoint(CGAffineTransform at, CGFloat angle, CGFloat x, CGFloat y) {
// at = CGAffineTransformTranslate(at, x, y);
// at = CGAffineTransformRotate(at, angle);
// return CGAffineTransformTranslate(at, -x, -y);
//}
@param w Square size of icon.
@param s Scaling factor of the content image.
*/
- (instancetype)initWithSize:(CGFloat)w scale:(CGFloat)s {
self = [super initWithFrame:NSMakeRect(0, 0, w, w)];
self.roundness = 40;
self.contentScale = s;
self.showBackground = YES;
return self;
#pragma mark - CGPath Component Generators
/// Add circle with @c radius
static inline void PathAddCircle(CGMutablePathRef path, CGFloat radius) {
CGPathAddArc(path, NULL, radius, radius, radius, 0, M_PI * 2, YES);
}
/**
@return New image with drawn content. Will call @c drawImageInRect:
*/
- (NSImage*)drawnImage {
return [NSImage imageWithSize:self.frame.size flipped:NO drawingHandler:^BOOL(NSRect rect) {
[self drawImageInRect:rect];
return YES;
}];
/// Add ring with @c radius and @c innerRadius
static inline void PathAddRing(CGMutablePathRef path, CGFloat radius, CGFloat innerRadius) {
CGPathAddArc(path, NULL, radius, radius, radius, 0, M_PI * 2, YES);
CGPathAddArc(path, NULL, radius, radius, innerRadius, 0, M_PI * -2, YES);
}
/// Set roundness factor for rounded corners (background). This setter ensures a percent value between 0 and 1.
- (void)setRoundness:(CGFloat)r {
_roundness = 0.5 * (r < 0 ? 0 : r > 100 ? 100 : r);
/// Add a single RSS icon radio wave
static inline void PathAddRSSArc(CGMutablePathRef path, CGFloat radius, CGFloat thickness) {
CGPathMoveToPoint(path, NULL, 0, radius + thickness);
CGPathAddArc(path, NULL, 0, 0, radius + thickness, M_PI_2, 0, YES);
CGPathAddLineToPoint(path, NULL, radius, 0);
CGPathAddArc(path, NULL, 0, 0, radius, 0, M_PI_2, NO);
CGPathCloseSubpath(path);
}
/// @return MIN( width, height )
- (CGFloat)shorterSide {
if (self.frame.size.width < self.frame.size.height)
return self.frame.size.width;
return self.frame.size.height;
/// Add two vertical bars representing a pause icon
static inline void PathAddPauseIcon(CGMutablePathRef path, CGAffineTransform at, CGFloat size, CGFloat thickness) {
const CGFloat off = (size - 2 * thickness) / 4;
CGPathAddRect(path, &at, CGRectMake(off, 0, thickness, size));
CGPathAddRect(path, &at, CGRectMake(size/2 + off, 0, thickness, size));
}
/**
Draw background image, rounded corners and scaled image content
*/
- (void)drawImageInRect:(NSRect)r {
const CGFloat s = [self shorterSide];
CGContextRef c = [[NSGraphicsContext currentContext] CGContext];
/// Add X icon by applying a rotational affine transform and drawing a plus sign
// void PathAddXIcon(CGMutablePathRef path, CGAffineTransform at, CGFloat size, CGFloat thickness) {
// at = RotateAroundPoint(at, M_PI_4, size/2, size/2);
// const CGFloat p = size * 0.5 - thickness / 2;
// CGPathAddRect(path, &at, CGRectMake(0, p, size, thickness));
// CGPathAddRect(path, &at, CGRectMake(p, 0, thickness, p));
// CGPathAddRect(path, &at, CGRectMake(p, p + thickness, thickness, p));
//}
if (_showBackground) {
CGMutablePathRef pth = CGPathCreateMutable();
const CGFloat corner = s * (_roundness / 100.0);
if (corner > 0) {
CGPathAddRoundedRect(pth, NULL, r, corner, corner);
} else {
CGPathAddRect(pth, NULL, r);
#pragma mark - Full Icon Path Generators
/// Create @c CGPath for global icon; a menu bar and an open menu below
static inline void AddGlobalIconPath(CGContextRef c, CGFloat size) {
CGMutablePathRef menu = CGPathCreateMutable();
CGPathAddRect(menu, NULL, CGRectMake(0, 0.8 * size, size, 0.2 * size));
CGPathAddRect(menu, NULL, CGRectMake(0.3 * size, 0, 0.55 * size, 0.75 * size));
CGPathAddRect(menu, NULL, CGRectMake(0.35 * size, 0.05 * size, 0.45 * size, 0.75 * size));
CGFloat entryHeight = 0.1 * size; // 0.075
for (int i = 0; i < 3; i++) { // 4
//CGPathAddRect(menu, NULL, CGRectMake(0.37 * size, (2 * i + 1) * entryHeight, 0.42 * size, entryHeight)); // uncomment path above
CGPathAddRect(menu, NULL, CGRectMake(0.35 * size, (2 * i + 1.5) * entryHeight, 0.4 * size, entryHeight * 0.8));
}
CGContextSetFillColorWithColor(c, [_color CGColor]);
CGContextAddPath(c, pth);
CGPathRelease(pth);
if ([self isMemberOfClass:[DrawImage class]])
CGContextFillPath(c); // fill only if not a subclass
}
if (_contentScale != 1.0) {
CGFloat offset = s * (1 - _contentScale) / 2;
CGContextTranslateCTM(c, offset, offset);
CGContextScaleCTM(c, _contentScale, _contentScale);
}
}
@end
// ################################################################
// #
// # RSSIcon
// #
// ################################################################
@implementation RSSIcon // content scale 0.75 works fine
/**
@return Default RSS icon for feeds that are missing an icon. (Not used in system bar).
*/
+ (NSImage*)iconWithSize:(CGFloat)s {
RSSIcon *icon = [[RSSIcon alloc] initWithSize:s scale:0.7];
icon.barsColor = [NSColor whiteColor];
icon.gradientColor = [NSColor rssOrange];
return [icon drawnImage];
CGContextAddPath(c, menu);
CGPathRelease(menu);
}
/**
Returns new @c NSImage with background (tinted or not) and connection error (if set).
/// Create @c CGPath for group icon; a folder symbol
static inline void AddGroupIconPath(CGContextRef c, CGFloat size, BOOL showBackground) {
const CGFloat r1 = size * 0.05; // corners
const CGFloat r2 = size * 0.08; // upper part, name tag
const CGFloat r3 = size * 0.15; // lower part, corners inside
const CGFloat posTop = 0.85 * size;
const CGFloat posMiddle = 0.6 * size - r3;
const CGFloat posBottom = 0.15 * size + r1;
const CGFloat posNameTag = 0.3 * size;
@param s Square image size
@param color Tint color of icon. Either untintend (white) or highlighted (rss orange).
@param conn If @c YES show small pause icon in right upper corner.
*/
+ (NSImage*)systemBarIcon:(CGFloat)s tint:(NSColor*)color noConnection:(BOOL)conn {
RSSIcon *icon = [[RSSIcon alloc] initWithSize:s scale:0.7];
icon.color = (color ? color : [NSColor blackColor]);
icon.noConnection = conn;
// icon.showBackground = !conn;
// icon.contentScale = (conn ? 0.9 : 0.7);
return [icon drawnImage];
CGMutablePathRef upper = CGPathCreateMutable();
CGPathMoveToPoint(upper, NULL, 0, 0.5 * size);
CGPathAddLineToPoint(upper, NULL, 0, posTop - r1);
CGPathAddArc(upper, NULL, r1, posTop - r1, r1, M_PI, M_PI_2, YES);
CGPathAddArc(upper, NULL, posNameTag, posTop - r2, r2, M_PI_2, M_PI_4, YES);
CGPathAddArc(upper, NULL, posNameTag + 1.85 * r2, posTop, r2, M_PI + M_PI_4, -M_PI_2, NO);
CGPathAddArc(upper, NULL, size - r1, posTop - r1 - r2, r1, M_PI_2, 0, YES);
CGPathAddArc(upper, NULL, size - r1, posBottom, r1, 0, -M_PI_2, YES);
CGPathAddArc(upper, NULL, r1, posBottom, r1, -M_PI_2, M_PI, YES);
CGPathCloseSubpath(upper);
CGMutablePathRef lower = CGPathCreateMutable();
CGPathAddArc(lower, NULL, r3, posMiddle, r3, M_PI, M_PI_2, YES);
CGPathAddArc(lower, NULL, size - r3, posMiddle, r3, M_PI_2, 0, YES);
CGPathAddArc(lower, NULL, size - r1, posBottom, r1, 0, -M_PI_2, YES);
CGPathAddArc(lower, NULL, r1, posBottom, r1, -M_PI_2, M_PI, YES);
CGPathCloseSubpath(lower);
CGContextAddPath(c, upper);
if (showBackground)
CGContextEOFillPath(c);
CGContextAddPath(c, lower);
CGPathRelease(upper);
CGPathRelease(lower);
}
/**
Draw two rss bars (or paused icon) and tint color or gradient color.
Create @c CGPath for RSS icon; a circle in the lower left bottom and two radio waves going outwards.
@param connection If @c NO, draw only one radio wave and a pause icon in the upper right
*/
- (void)drawImageInRect:(NSRect)r {
[super drawImageInRect:r];
const CGFloat s = [self shorterSide];
CGContextRef c = [[NSGraphicsContext currentContext] CGContext];
CGContextSetFillColorWithColor(c, [self.color CGColor]);
static inline void AddRSSIconPath(CGContextRef c, CGFloat size, BOOL connection) {
CGMutablePathRef bars = CGPathCreateMutable(); // the rss bars
// circle
const CGFloat r1 = s * 0.125; // circle radius
CGPathAddArc(bars, NULL, r1, r1, r1, 0, M_PI * 2, YES);
// 1st bar
CGPathMoveToPoint(bars, NULL, 0, s * 0.65);
CGPathAddArc(bars, NULL, 0, 0, s * 0.65, M_PI_2, 0, YES);
CGPathAddLineToPoint(bars, NULL, s * 0.45, 0);
CGPathAddArc(bars, NULL, 0, 0, s * 0.45, 0, M_PI_2, NO);
CGPathCloseSubpath(bars);
// 2nd bar
if (_noConnection) {
CGAffineTransform at = CGAffineTransformMake(0.5, 0, 0, 0.5, s * 0.5, s * 0.5);
// X icon
// CGPathMoveToPoint(bars, &at, 0, s * 0.2);
// CGPathAddLineToPoint(bars, &at, s * 0.3, s * 0.5);
// CGPathAddLineToPoint(bars, &at, 0, s * 0.8);
// CGPathAddLineToPoint(bars, &at, s * 0.2, s);
// CGPathAddLineToPoint(bars, &at, s * 0.5, s * 0.7);
// CGPathAddLineToPoint(bars, &at, s * 0.8, s);
// CGPathAddLineToPoint(bars, &at, s, s * 0.8);
// CGPathAddLineToPoint(bars, &at, s * 0.7, s * 0.5);
// CGPathAddLineToPoint(bars, &at, s, s * 0.2);
// CGPathAddLineToPoint(bars, &at, s * 0.8, 0);
// CGPathAddLineToPoint(bars, &at, s * 0.5, s * 0.3);
// CGPathAddLineToPoint(bars, &at, s * 0.2, 0);
// CGPathCloseSubpath(bars);
// Pause icon
// CGPathMoveToPoint(bars, &at, s * 0.2, s * 0.2);
CGPathAddRect(bars, &at, CGRectMake(s*0.1, 0, s*0.3, s));
CGPathAddRect(bars, &at, CGRectMake(s*0.6, 0, s*0.3, s));
PathAddCircle(bars, size * 0.125);
PathAddRSSArc(bars, size * 0.45, size * 0.2);
if (connection) {
PathAddRSSArc(bars, size * 0.8, size * 0.2);
} else {
CGPathMoveToPoint(bars, NULL, 0, s);
CGPathAddArc(bars, NULL, 0, 0, s, M_PI_2, 0, YES);
CGPathAddLineToPoint(bars, NULL, s * 0.8, 0);
CGPathAddArc(bars, NULL, 0, 0, s * 0.8, 0, M_PI_2, NO);
CGPathCloseSubpath(bars);
CGAffineTransform at = CGAffineTransformMake(0.5, 0, 0, 0.5, size/2, size/2);
PathAddPauseIcon(bars, at, size, size * 0.3);
//PathAddXIcon(bars, at, size, size * 0.3);
}
CGContextAddPath(c, bars);
if (_gradientColor) {
CGContextSaveGState(c);
CGContextClip(c);
[self drawGradient:c side:s / self.contentScale];
CGContextRestoreGState(c);
} else {
CGContextEOFillPath(c);
}
if (_barsColor) {
CGContextSetFillColorWithColor(c, [_barsColor CGColor]);
CGContextAddPath(c, bars);
CGContextEOFillPath(c);
}
CGPathRelease(bars);
}
/**
Apply gradient to current context clipping.
*/
- (void)drawGradient:(CGContextRef)c side:(CGFloat)w {
#pragma mark - Icon Background Generators
/// Create @c CGPath with rounded corners (optional). @param roundness Value between @c 0.0 and @c 1.0
static void AddRoundedBackgroundPath(CGContextRef c, CGRect r, CGFloat roundness) {
const CGFloat corner = ShorterSide(r.size) * (roundness / 2.0);
if (corner > 0) {
CGMutablePathRef pth = CGPathCreateMutable();
CGPathAddRoundedRect(pth, NULL, r, corner, corner);
CGContextAddPath(c, pth);
CGPathRelease(pth);
} else {
CGContextAddRect(c, r);
}
}
/// Insert and draw linear gradient with @c color saturation @c ±0.3
static void DrawGradient(CGContextRef c, CGFloat size, NSColor *color) {
CGFloat h = 0, s = 1, b = 1, a = 1;
@try {
NSColor *rgbColor = [_gradientColor colorUsingColorSpace:[NSColorSpace deviceRGBColorSpace]];
NSColor *rgbColor = [color colorUsingColorSpace:[NSColorSpace deviceRGBColorSpace]];
[rgbColor getHue:&h saturation:&s brightness:&b alpha:&a];
} @catch (NSException *e) {}
static const CGFloat impact = 0.3;
static CGFloat const impact = 0.3;
NSColor *darker = [NSColor colorWithHue:h saturation:(s + impact > 1 ? 1 : s + impact) brightness:b alpha:a];
NSColor *lighter = [NSColor colorWithHue:h saturation:(s - impact < 0 ? 0 : s - impact) brightness:b alpha:a];
const void* cgColors[] = {
@@ -249,128 +203,124 @@
CFArrayRef colors = CFArrayCreate(NULL, cgColors, 3, NULL);
CGGradientRef gradient = CGGradientCreateWithColors(NULL, colors, NULL);
CGContextDrawLinearGradient(c, gradient, CGPointMake(0, w), CGPointMake(w, 0), kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
CGContextDrawLinearGradient(c, gradient, CGPointMake(0, size), CGPointMake(size, 0), kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
CGGradientRelease(gradient);
CFRelease(colors);
}
@end
// ################################################################
// #
// # SettingsIconGlobal
// #
// ################################################################
#pragma mark - CGContext Drawing & Manipulation
@implementation SettingsIconGlobal // content scale 0.7 works fine
/**
Draw icon for preferences; showing the status bar and an open menu. (single colors contour)
*/
- (void)drawImageInRect:(NSRect)r {
[super drawImageInRect:r]; // add path of rounded rect
const CGFloat w = r.size.width;
const CGFloat h = r.size.height;
CGMutablePathRef menu = CGPathCreateMutable();
CGPathAddRect(menu, NULL, CGRectMake(0, 0.8 * h, w, 0.2 * h));
CGPathAddRect(menu, NULL, CGRectMake(0.3 * w, 0, 0.55 * w, 0.75 * h));
CGPathAddRect(menu, NULL, CGRectMake(0.35 * w, 0.05 * h, 0.45 * w, 0.75 * h));
CGFloat entryHeight = 0.1 * h; // 0.075
for (int i = 0; i < 3; i++) { // 4
//CGPathAddRect(menu, NULL, CGRectMake(0.37 * w, (2 * i + 1) * entryHeight, 0.42 * w, entryHeight)); // uncomment path above
CGPathAddRect(menu, NULL, CGRectMake(0.35 * w, (2 * i + 1.5) * entryHeight, 0.4 * w, entryHeight * 0.8));
}
CGContextRef c = [[NSGraphicsContext currentContext] CGContext];
CGContextSetFillColorWithColor(c, [self.color CGColor]);
CGContextAddPath(c, menu);
CGContextEOFillPath(c);
CGPathRelease(menu);
/// Scale and translate context to the center with respect to the new scale. If @c width @c != @c length align top left.
static void SetContentScale(CGContextRef c, CGSize size, CGFloat scale) {
const CGFloat s = ShorterSide(size);
CGFloat offset = s * (1 - scale) / 2;
CGContextTranslateCTM(c, offset, size.height - s + offset); // top left alignment
CGContextScaleCTM(c, scale, scale);
}
@end
// ################################################################
// #
// # SettingsIconGroup
// #
// ################################################################
@implementation SettingsIconGroup // content scale 0.8 works fine
/**
Draw icon for preferences; showing the mac typcial folder icon. (single colors contour)
*/
- (void)drawImageInRect:(NSRect)r {
[super drawImageInRect:r];
const CGFloat w = r.size.width;
const CGFloat h = r.size.height;
const CGFloat s = (w < h ? w : h); // shorter side
const CGFloat l = s * 0.04; // line width (half size)
const CGFloat r1 = s * 0.05; // corners
const CGFloat r2 = s * 0.08; // upper part, name tag
const CGFloat r3 = s * 0.15; // lower part, corners inside
const CGFloat posTop = 0.85 * h - l;
const CGFloat posMiddle = 0.6 * h - l - r3;
const CGFloat posBottom = 0.15 * h + l + r1;
const CGFloat posNameTag = 0.3 * w - l;
CGMutablePathRef upper = CGPathCreateMutable();
CGPathMoveToPoint(upper, NULL, l, 0.5 * h);
CGPathAddLineToPoint(upper, NULL, l, posTop - r1);
CGPathAddArc(upper, NULL, l + r1, posTop - r1, r1, M_PI, M_PI_2, YES);
CGPathAddArc(upper, NULL, posNameTag, posTop - r2, r2, M_PI_2, M_PI_4, YES);
CGPathAddArc(upper, NULL, posNameTag + 2 * r2, posTop, r2, M_PI + M_PI_4, -M_PI_2, NO);
CGPathAddArc(upper, NULL, w - l - r1, posTop - r1 - r2, r1, M_PI_2, 0, YES);
CGPathAddArc(upper, NULL, w - l - r1, posBottom, r1, 0, -M_PI_2, YES);
CGPathAddArc(upper, NULL, l + r1, posBottom, r1, -M_PI_2, M_PI, YES);
CGPathCloseSubpath(upper);
CGMutablePathRef lower = CGPathCreateMutable();
CGPathMoveToPoint(lower, NULL, l, 0.5 * h);
CGPathAddArc(lower, NULL, l + r3, posMiddle, r3, M_PI, M_PI_2, YES);
CGPathAddArc(lower, NULL, w - l - r3, posMiddle, r3, M_PI_2, 0, YES);
CGPathAddArc(lower, NULL, w - l - r1, posBottom, r1, 0, -M_PI_2, YES);
CGPathAddArc(lower, NULL, l + r1, posBottom, r1, -M_PI_2, M_PI, YES);
CGPathCloseSubpath(lower);
CGContextRef c = [[NSGraphicsContext currentContext] CGContext];
CGContextSetFillColorWithColor(c, [self.color CGColor]);
CGContextSetStrokeColorWithColor(c, [self.color CGColor]);
CGContextSetLineWidth(c, l * 2);
CGContextAddPath(c, upper);
CGContextAddPath(c, lower);
if (self.showBackground) {
CGContextAddPath(c, lower);
CGContextEOFillPath(c);
CGContextSetLineWidth(c, l); // thinner line
CGContextAddPath(c, lower);
/// Helper method; set drawing color, add rounded background and prepare content scale
static void DrawRoundedFrame(CGContextRef c, CGRect r, CGColorRef color, BOOL background, CGFloat corner, CGFloat defaultScale, CGFloat scaling) {
CGContextSetFillColorWithColor(c, color);
CGContextSetStrokeColorWithColor(c, color);
CGFloat contentScale = defaultScale;
if (background) {
AddRoundedBackgroundPath(c, r, corner);
if (scaling != 0.0)
contentScale *= scaling;
}
SetContentScale(c, r.size, contentScale);
}
#pragma mark - Easy Icon Drawing Methods
/// Draw global icon (menu bar)
static void DrawGlobalIcon(CGRect r, CGColorRef color, BOOL background) {
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
DrawRoundedFrame(c, r, color, background, 0.4, 1.0, 0.7);
AddGlobalIconPath(c, ShorterSide(r.size));
CGContextEOFillPath(c);
}
/// Draw group icon (folder)
static void DrawGroupIcon(CGRect r, CGColorRef color, BOOL background) {
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
const CGFloat s = ShorterSide(r.size);
const CGFloat l = s * 0.08; // line width
DrawRoundedFrame(c, r, color, background, 0.4, 1.0 - (l / s), 0.85);
CGContextSetLineWidth(c, l * (background ? 0.5 : 1.0));
AddGroupIconPath(c, s, background);
CGContextStrokePath(c);
CGPathRelease(upper);
CGPathRelease(lower);
}
@end
// ################################################################
// #
// # DrawSeparator
// #
// ################################################################
@implementation DrawSeparator
/**
Draw separator line in @c NSOutlineView
*/
- (void)drawRect:(NSRect)dirtyRect {
NSGradient *grdnt = [[NSGradient alloc] initWithStartingColor:[NSColor darkGrayColor] endingColor:[[NSColor darkGrayColor] colorWithAlphaComponent:0.0]];
NSRect separatorRect = NSMakeRect(1, self.frame.size.height / 2.0 - 1, self.frame.size.width - 2, 2);
NSBezierPath *rounded = [NSBezierPath bezierPathWithRoundedRect:separatorRect xRadius:1 yRadius:1];
[grdnt drawInBezierPath:rounded angle:0];
/// Draw RSS icon (flat without gradient)
static void DrawRSSIcon(CGRect r, CGColorRef color, BOOL background, BOOL connection) {
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
DrawRoundedFrame(c, r, color, background, 0.4, 1.0, 0.7);
AddRSSIconPath(c, ShorterSide(r.size), connection);
CGContextEOFillPath(c);
}
/// Draw RSS icon (with orange gradient, corner @c 0.4, white radio waves)
static void DrawRSSGradientIcon(CGRect r, NSColor *color) {
const CGFloat size = ShorterSide(r.size);
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
DrawRoundedFrame(c, r, NSColor.whiteColor.CGColor, YES, 0.4, 1.0, 0.7);
// Gradient
CGContextSaveGState(c);
CGContextClip(c);
DrawGradient(c, size, color);
CGContextRestoreGState(c);
// Bars
AddRSSIconPath(c, size, YES);
CGContextEOFillPath(c);
}
/// Draw unread icon (blue dot for unread menu item)
static void DrawUnreadIcon(CGRect r, NSColor *color) {
CGFloat size = ShorterSide(r.size) / 2.0;
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
CGMutablePathRef path = CGPathCreateMutable();
SetContentScale(c, r.size, 0.7);
CGContextTranslateCTM(c, 0, size * -0.15); // align with baseline of menu item text
CGContextSetFillColorWithColor(c, color.CGColor);
PathAddRing(path, size, size * 0.7);
CGContextAddPath(c, path);
CGContextEOFillPath(c);
CGContextSetFillColorWithColor(c, [color colorWithAlphaComponent:0.5].CGColor);
PathAddCircle(path, size);
CGContextAddPath(c, path);
CGContextFillPath(c);
CGPathRelease(path);
}
#pragma mark - NSImage Name Registration
/// Add single image to @c ImageNamed cache and set accessibility description
static void Register(CGFloat size, NSImageName name, NSString *description, BOOL (^draw)(NSRect r)) {
NSImage *img = [NSImage imageWithSize: NSMakeSize(size, size) flipped:NO drawingHandler:draw];
img.accessibilityDescription = description;
img.name = name;
}
/// Register all icons that require custom drawing in @c ImageNamed cache
void RegisterImageViewNames(void) {
NSColor *orange = [NSColor colorWithCalibratedRed:251/255.f green:163/255.f blue:58/255.f alpha:1.f]; // #FBA33A
NSColor *c1 = UserPrefsColor(Pref_colorStatusIconTint, orange);
NSColor *c2 = UserPrefsColor(Pref_colorUnreadIndicator, [NSColor systemBlueColor]);
Register(16, RSSImageDefaultRSSIcon, NSLocalizedString(@"RSS icon", nil), ^(NSRect r) { DrawRSSGradientIcon(r, orange); return YES; });
Register(16, RSSImageSettingsGlobal, NSLocalizedString(@"Global settings", nil), ^(NSRect r) { DrawGlobalIcon(r, [NSColor controlTextColor].CGColor, NO); return YES; });
Register(16, RSSImageSettingsGroup, NSLocalizedString(@"Group settings", nil), ^(NSRect r) { DrawGroupIcon(r, [NSColor controlTextColor].CGColor, NO); return YES; });
Register(16, RSSImageSettingsFeed, NSLocalizedString(@"Feed settings", nil), ^(NSRect r) { DrawRSSIcon(r, [NSColor controlTextColor].CGColor, NO, YES); return YES; });
Register(16, RSSImageMenuBarIconActive, NSLocalizedString(@"RSS menu bar icon", nil), ^(NSRect r) { DrawRSSIcon(r, c1.CGColor, YES, YES); return YES; });
Register(16, RSSImageMenuBarIconPaused, NSLocalizedString(@"RSS menu bar icon, paused", nil), ^(NSRect r) { DrawRSSIcon(r, c1.CGColor, YES, NO); return YES; });
Register(14, RSSImageMenuItemUnread, NSLocalizedString(@"Unread icon", nil), ^(NSRect r) { DrawUnreadIcon(r, c2); return YES; });
}
@end

View File

@@ -1,546 +0,0 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "FeedDownload.h"
#import "Constants.h"
#import "StoreCoordinator.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import <SystemConfiguration/SystemConfiguration.h>
static NSTimer *_timer;
static SCNetworkReachabilityRef _reachability = NULL;
static BOOL _isReachable = NO;
static BOOL _isUpdating = NO;
static BOOL _updatePaused = NO;
static BOOL _nextUpdateIsForced = NO;
@implementation FeedDownload
#pragma mark - User Interaction -
/// @return Date when background update will fire. If updates are paused, date is @c distantFuture.
+ (NSDate *)dateScheduled { return _timer.fireDate; }
/// @return @c YES if current network state is reachable and updates are not paused by user.
+ (BOOL)allowNetworkConnection { return (_isReachable && !_updatePaused); }
/// @return @c YES if batch update is running
+ (BOOL)isUpdating { return _isUpdating; }
/// @return @c YES if update is paused by user.
+ (BOOL)isPaused { return _updatePaused; }
/// Set paused flag and cancel timer regardless of network connectivity.
+ (void)setPaused:(BOOL)flag {
_updatePaused = flag;
if (_updatePaused)
[self pauseUpdates];
else
[self resumeUpdates];
}
/// Cancel current timer and stop any updates until enabled again.
+ (void)pauseUpdates {
[self scheduleTimer:nil];
}
/// Start normal (non forced) schedule if network is reachable.
+ (void)resumeUpdates {
if (_isReachable)
[self scheduleUpdateForUpcomingFeeds];
}
#pragma mark - Update Feed Timer -
/**
Get date of next up feed and start the timer.
*/
+ (void)scheduleUpdateForUpcomingFeeds {
if (![self allowNetworkConnection]) // timer will restart once connection exists
return;
NSDate *nextTime = [StoreCoordinator nextScheduledUpdate]; // if nextTime = nil, then no feeds to update
if (nextTime && [nextTime timeIntervalSinceNow] < 1) { // mostly, if app was closed for a long time
nextTime = [NSDate dateWithTimeIntervalSinceNow:1];
}
[self scheduleTimer:nextTime];
}
/**
Start download of all feeds (immediatelly) regardless of @c .scheduled property.
*/
+ (void)forceUpdateAllFeeds {
if (![self allowNetworkConnection]) // timer will restart once connection exists
return;
_nextUpdateIsForced = YES;
[self scheduleTimer:[NSDate dateWithTimeIntervalSinceNow:0.05]];
}
/**
Set new @c .fireDate and @c .tolerance for update timer.
@param nextTime If @c nil timer will be disabled with a @c .fireDate very far in the future.
*/
+ (void)scheduleTimer:(NSDate*)nextTime {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_timer = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:[self class] selector:@selector(updateTimerCallback) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
});
if (!nextTime)
nextTime = [NSDate distantFuture];
NSTimeInterval tolerance = [nextTime timeIntervalSinceNow] * 0.15;
_timer.tolerance = (tolerance < 1 ? 1 : tolerance); // at least 1 sec
_timer.fireDate = nextTime;
}
/**
Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user request.
*/
+ (void)updateTimerCallback {
#ifdef DEBUG
NSLog(@"fired");
#endif
BOOL updateAll = _nextUpdateIsForced;
_nextUpdateIsForced = NO;
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:updateAll inContext:moc];
//NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early.");
if (![self allowNetworkConnection]) {
[moc reset];
return;
}
[self batchDownloadFeeds:list favicons:updateAll showErrorAlert:NO finally:^{
[StoreCoordinator saveContext:moc andParent:YES]; // save parents too ...
[moc reset];
[self resumeUpdates]; // always reset the timer
}];
}
#pragma mark - Request Generator -
/// @return Base URL part. E.g., https://stackoverflow.com/a/15897956/10616114 ==> https://stackoverflow.com/
+ (NSURL*)hostURL:(NSString*)urlStr {
return [[NSURL URLWithString:@"/" relativeToURL:[self fixURL:urlStr]] absoluteURL];
}
/// Check if any scheme is set. If not, prepend 'http://'.
+ (NSURL*)fixURL:(NSString*)urlStr {
NSURL *url = [NSURL URLWithString:urlStr];
if (!url.scheme) {
url = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", urlStr]]; // usually will redirect to https if necessary
}
return url;
}
/// @return New request with no caching policy and timeout interval of 30 seconds.
+ (NSMutableURLRequest*)newRequestURL:(NSString*)urlStr {
return [NSMutableURLRequest requestWithURL:[self fixURL:urlStr]];
}
/// @return New request with etag and modified headers set (or not, if @c flag @c == @c YES ).
+ (NSURLRequest*)newRequest:(FeedMeta*)meta ignoreCache:(BOOL)flag {
NSMutableURLRequest *req = [self newRequestURL:meta.url];
if (!flag) {
if (meta.etag.length > 0)
[req setValue:meta.etag forHTTPHeaderField:@"If-None-Match"]; // ETag
else if (meta.modified.length > 0)
[req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"];
}
if (!_nextUpdateIsForced) // any request that is not forced, is a background update
req.networkServiceType = NSURLNetworkServiceTypeBackground;
return req;
}
+ (NSURLSession*)nonCachingSession {
static NSURLSession *session = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURLSessionConfiguration *conf = [NSURLSessionConfiguration defaultSessionConfiguration];
conf.HTTPCookieAcceptPolicy = NSHTTPCookieAcceptPolicyNever;
conf.HTTPShouldSetCookies = NO;
conf.HTTPCookieStorage = nil; // disables '~/Library/Cookies/'
conf.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
conf.URLCache = nil; // disables '~/Library/Caches/de.relikd.baRSS/'
conf.HTTPAdditionalHeaders = @{ @"User-Agent": @"baRSS (macOS)",
@"Accept-Encoding": @"gzip" };
session = [NSURLSession sessionWithConfiguration:conf];
});
return session; // [NSURLSession sharedSession];
}
/// Helper method to start new @c NSURLSession. If @c (http.statusCode==304) then set @c data @c = @c nil.
+ (void)asyncRequest:(NSURLRequest*)request block:(nonnull void(^)(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response))block {
[[[self nonCachingSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
if (error || [httpResponse statusCode] == 304)
data = nil;
block(data, error, httpResponse); // if status == 304, data & error nil
}] resume];
}
#pragma mark - Download RSS Feed -
/**
Start download session of RSS or Atom feed, parse feed and return result on the main thread.
@param xmlBlock Called immediately after @c RSXMLData is initialized. E.g., to use this data as HTML parser.
Return @c YES to to exit without calling @c feedBlock.
If @c NO and @c err @c != @c nil skip feed parsing and call @c feedBlock(nil,err,response).
@param feedBlock Called when parsing finished or an @c NSURL error occured.
If content did not change (status code 304) both, error and result will be @c nil.
Will be called on main thread.
*/
+ (void)parseFeedRequest:(NSURLRequest*)request xmlBlock:(nullable BOOL(^)(RSXMLData *xml, NSError **err))xmlBlock feedBlock:(nonnull void(^)(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response))feedBlock {
[self asyncRequest:request block:^(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response) {
RSParsedFeed *result = nil;
if (data) { // data = nil if (error || 304)
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:response.URL.absoluteString];
if (xmlBlock && xmlBlock(xml, &error)) {
return;
}
if (!error) { // metaBlock may set error
RSFeedParser *parser = [RSFeedParser parserWithXMLData:xml];
parser.dontStopOnLowerAsciiBytes = YES;
result = [parser parseSync:&error];
}
}
dispatch_async(dispatch_get_main_queue(), ^{
feedBlock(result, error, response);
});
}];
}
/**
Perform feed download request from URL alone. Not updating any @c Feed item.
@note @c askUser will not be called if url is XML already.
@param urlStr XML URL or HTTP URL that will be parsed to find feed URLs.
@param askUser Use @c list to present user a list of detected feed URLs.
@param block Called after webpage has been fully parsed (including html autodetect).
*/
+ (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(RSHTMLMetadata *meta))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block {
[self parseFeedRequest:[self newRequestURL:urlStr] xmlBlock:^BOOL(RSXMLData *xml, NSError **err) {
if (![xml.parserClass isHTMLParser])
return NO;
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
RSHTMLMetadata *parsedMeta = [parser parseSync:err];
if (*err)
return NO;
if (!parsedMeta || parsedMeta.feedLinks.count == 0) {
*err = RSXMLMakeErrorWrongParser(RSXMLErrorExpectingFeed, RSXMLErrorExpectingHTML);
return NO;
}
__block NSString *chosenURL = nil;
dispatch_sync(dispatch_get_main_queue(), ^{ // sync! (thread is already in background)
chosenURL = askUser(parsedMeta);
});
if (!chosenURL || chosenURL.length == 0)
return NO;
[self parseFeedRequest:[self newRequestURL:chosenURL] xmlBlock:nil feedBlock:block];
return YES;
} feedBlock:block];
}
/**
Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0).
@note Will post a @c kNotificationFeedUpdated notification if download was successful and @b not status code 304.
@param alert If @c YES display Error Popup to user.
@param block Parameter @c success is only @c YES if download was successful or if status code is 304 (not modified).
*/
+ (void)backgroundUpdateFeed:(Feed*)feed showErrorAlert:(BOOL)alert finally:(nullable void(^)(BOOL success))block {
NSManagedObjectID *oid = feed.objectID;
NSManagedObjectContext *moc = feed.managedObjectContext;
NSURLRequest *req = [self newRequest:feed.meta ignoreCache:(feed.articles.count == 0)];
NSString *reqURL = req.URL.absoluteString;
[self parseFeedRequest:req xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) {
Feed *f = [moc objectWithID:oid];
BOOL success = NO;
BOOL needsNotification = NO;
if (error) {
if (alert) {
NSAlert *alertPopup = [NSAlert alertWithError:error];
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", reqURL];
[alertPopup runModal];
}
[f.meta setErrorAndPostponeSchedule];
} else {
success = YES;
[f.meta setSucessfulWithResponse:response];
if (rss && rss.articles.count > 0) {
[f updateWithRSS:rss postUnreadCountChange:YES];
needsNotification = YES;
}
}
[StoreCoordinator saveContext:moc andParent:YES];
if (needsNotification)
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:oid];
if (block) block(success);
}];
}
/**
Download feed at url and append to persistent store in root folder.
On error present user modal alert.
Creates new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and saves them to the persistent store.
Update duration is set to the default of 30 minutes.
*/
+ (void)autoDownloadAndParseURL:(NSString*)url successBlock:(nullable os_block_t)block {
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
Feed *f = [Feed appendToRootWithDefaultIntervalInContext:moc];
f.meta.url = url;
[self backgroundUpdateBoth:f favicon:YES alert:YES finally:^(BOOL successful){
if (!successful) {
[moc deleteObject:f.group];
}
[StoreCoordinator saveContext:moc andParent:YES];
[moc reset];
if (successful) {
[self scheduleUpdateForUpcomingFeeds];
if (block) block();
}
}];
}
/**
Start download of feed xml, then continue with favicon download (optional).
@param fav If @c YES continue with favicon download after xml download finished.
@param alert If @c YES display Error Popup to user.
@param block Parameter @c success is @c YES if xml download succeeded (regardless of favicon result).
*/
+ (void)backgroundUpdateBoth:(Feed*)feed favicon:(BOOL)fav alert:(BOOL)alert finally:(nullable void(^)(BOOL success))block {
[self backgroundUpdateFeed:feed showErrorAlert:alert finally:^(BOOL success) {
if (fav && success) {
[self backgroundUpdateFavicon:feed replaceExisting:NO finally:^{
if (block) block(YES);
}];
} else {
if (block) block(success);
}
}];
}
/**
Start download of all feeds in list. Either with or without favicons.
@param list Download list using @c feed.meta.url as download url. (while reusing etag and modified headers)
@param fav If @c YES continue with favicon download after xml download finished.
@param alert If @c YES display Error Popup to user.
@param block Called after all downloads finished.
*/
+ (void)batchDownloadFeeds:(NSArray<Feed*> *)list favicons:(BOOL)fav showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block {
_isUpdating = YES;
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationBackgroundUpdateInProgress object:@(list.count)];
dispatch_group_t group = dispatch_group_create();
for (Feed *f in list) {
dispatch_group_enter(group);
[self backgroundUpdateBoth:f favicon:fav alert:alert finally:^(BOOL success){
dispatch_group_leave(group);
}];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
if (block) block();
_isUpdating = NO;
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationBackgroundUpdateInProgress object:@(0)];
});
}
#pragma mark - Download Favicon -
/**
Start favicon download request on existing @c Feed object.
@note Will post a @c kNotificationFeedIconUpdated notification if icon was updated.
@param overwrite If @c YES and icon is present already, @c block will return immediatelly.
*/
+ (void)backgroundUpdateFavicon:(Feed*)feed replaceExisting:(BOOL)overwrite finally:(nullable os_block_t)block {
if (!overwrite && feed.icon != nil) {
if (block) block();
return; // skip existing icons if replace == NO
}
NSManagedObjectID *oid = feed.objectID;
NSManagedObjectContext *moc = feed.managedObjectContext;
NSString *faviconURL = (feed.link.length > 0 ? feed.link : feed.meta.url);
[self downloadFavicon:faviconURL finished:^(NSImage *img) {
Feed *f = [moc objectWithID:oid];
if (f && [f setIconImage:img]) {
[StoreCoordinator saveContext:moc andParent:YES];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedIconUpdated object:oid];
}
if (block) block();
}];
}
/// Download favicon located at http://.../ @c favicon.ico. Callback @c block will be called on main thread.
+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block {
NSURL *host = [self hostURL:urlStr];
NSString *hostURL = host.absoluteString;
NSString *favURL = [host URLByAppendingPathComponent:@"favicon.ico"].absoluteString;
[self downloadImage:favURL finished:^(NSImage * _Nullable img) {
if (img) {
block(img); // is on main already (from downloadImage:)
} else {
[self downloadFaviconByParsingHTML:hostURL finished:block];
}
}];
}
/// Download html page and parse all icon urls. Starting a successive request on the url of the smallest icon.
+ (void)downloadFaviconByParsingHTML:(NSString*)hostURL finished:(void(^)(NSImage * _Nullable img))block {
[self asyncRequest:[self newRequestURL:hostURL] block:^(NSData * _Nullable htmlData, NSError * _Nullable error, NSHTTPURLResponse *response) {
if (htmlData) {
// TODO: use session delegate to stop downloading after <head>
RSXMLData *xml = [[RSXMLData alloc] initWithData:htmlData urlString:hostURL];
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
RSHTMLMetadata *meta = [parser parseSync:&error];
if (error) meta = nil;
NSString *iconURL = [self faviconUrlForMetadata:meta];
if (iconURL) {
// if everything went well we can finally start a request on the url we found.
[self downloadImage:iconURL finished:block];
return;
}
}
dispatch_async(dispatch_get_main_queue(), ^{ block(nil); }); // on failure
}];
}
/// Extract favicon URL from parsed HTML metadata.
+ (nullable NSString*)faviconUrlForMetadata:(RSHTMLMetadata*)meta {
if (meta) {
if (meta.faviconLink.length > 0) {
return meta.faviconLink;
}
else if (meta.iconLinks.count > 0) {
// at least any url (even if all items in list have size 0)
NSString *iconURL = meta.iconLinks.firstObject.link;
// we dont need much, lets find the smallest icon ...
int smallest = 9001;
for (RSHTMLMetadataIconLink *icon in meta.iconLinks) {
int size = (int)[icon getSize].width;
if (size > 0 && size < smallest) {
smallest = size;
iconURL = icon.link;
}
}
if (iconURL && iconURL.length > 0)
return iconURL;
}
}
return nil;
}
/// Download image in a background thread and notify once finished.
+ (void)downloadImage:(NSString*)url finished:(void(^)(NSImage * _Nullable img))block {
[self asyncRequest:[self newRequestURL:url] block:^(NSData * _Nullable data, NSError * _Nullable e, NSHTTPURLResponse *r) {
NSImage *img = [[NSImage alloc] initWithData:data];
if (!img || ![img isValid])
img = nil;
// if (img.size.width > 16 || img.size.height > 16) {
// NSImage *smallImage = [NSImage imageWithSize:NSMakeSize(16, 16) flipped:NO drawingHandler:^BOOL(NSRect dstRect) {
// [img drawInRect:dstRect];
// return YES;
// }];
// if (img.TIFFRepresentation.length > smallImage.TIFFRepresentation.length)
// img = smallImage;
// }
dispatch_async(dispatch_get_main_queue(), ^{ block(img); });
}];
}
#pragma mark - Network Connection & Reachability -
/// Set callback on @c self to listen for network reachability changes.
+ (void)registerNetworkChangeNotification {
// https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x
if (_reachability != NULL) return;
_reachability = SCNetworkReachabilityCreateWithName(NULL, "1.1.1.1");
if (_reachability == NULL) return;
// If reachability information is available now, we don't get a callback later
SCNetworkConnectionFlags flags;
if (SCNetworkReachabilityGetFlags(_reachability, &flags))
networkReachabilityCallback(_reachability, flags, NULL);
if (!SCNetworkReachabilitySetCallback(_reachability, networkReachabilityCallback, NULL) ||
!SCNetworkReachabilityScheduleWithRunLoop(_reachability, [[NSRunLoop currentRunLoop] getCFRunLoop], kCFRunLoopCommonModes))
{
CFRelease(_reachability);
_reachability = NULL;
}
}
/// Remove @c self callback (network reachability changes).
+ (void)unregisterNetworkChangeNotification {
if (_reachability != NULL) {
SCNetworkReachabilitySetCallback(_reachability, nil, nil);
SCNetworkReachabilitySetDispatchQueue(_reachability, nil);
CFRelease(_reachability);
_reachability = NULL;
}
}
/// Called when network interface or reachability changes.
static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkConnectionFlags flags, void *object) {
if (_reachability == NULL) return;
_isReachable = [FeedDownload hasConnectivity:flags];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationNetworkStatusChanged object:@(_isReachable)];
if (_isReachable) {
[FeedDownload resumeUpdates];
} else {
[FeedDownload pauseUpdates];
}
}
/// @return @c YES if network connection established.
+ (BOOL)hasConnectivity:(SCNetworkReachabilityFlags)flags {
if ((flags & kSCNetworkReachabilityFlagsReachable) == 0)
return NO;
if ((flags & kSCNetworkReachabilityFlagsConnectionRequired) == 0)
return YES;
if ((flags & kSCNetworkReachabilityFlagsInterventionRequired) == 0 &&
((flags & kSCNetworkReachabilityFlagsConnectionOnDemand) != 0 ||
(flags & kSCNetworkReachabilityFlagsConnectionOnTraffic) != 0))
return YES; // no-intervention AND ( on-demand OR on-traffic )
return NO;
}
@end

View File

@@ -1,141 +0,0 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "NSDate+Ext.h"
#import <QuartzCore/QuartzCore.h>
static const char _shortnames[] = {'y','w','d','h','m','s'};
static const char *_names[] = {"Years", "Weeks", "Days", "Hours", "Minutes", "Seconds"};
static const TimeUnitType _values[] = {
TimeUnitYears,
TimeUnitWeeks,
TimeUnitDays,
TimeUnitHours,
TimeUnitMinutes,
TimeUnitSeconds,
};
@implementation NSDate (Ext)
/// If @c flag @c = @c YES, print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h.
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag {
if (flag) {
unsigned short i = [self floatUnitIndexForInterval:abs(intv)];
return [NSString stringWithFormat:@"%1.1f%c", intv / (float)_values[i], _shortnames[i]];
}
unsigned short i = [self exactUnitIndexForInterval:abs(intv)];
return [NSString stringWithFormat:@"%d%c", intv / _values[i], _shortnames[i]];
}
/// @return Highest non-zero unit ( @c flag=YES ). Or highest integer-dividable unit ( @c flag=NO ).
+ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag {
if (flag) {
return _values[[self floatUnitIndexForInterval:abs(intv)]];
}
return _values[[self exactUnitIndexForInterval:abs(intv)]];
}
/// @return Highest unit type that allows integer division. E.g., '61 minutes'.
+ (unsigned short)exactUnitIndexForInterval:(Interval)intv {
for (unsigned short i = 0; i < 5; i++)
if (intv % _values[i] == 0) return i;
return 5; // seconds
}
/// @return Highest non-zero unit type. Can be used with fractions e.g., '1.1 hours'.
+ (unsigned short)floatUnitIndexForInterval:(Interval)intv {
for (unsigned short i = 0; i < 5; i++)
if (intv > _values[i]) return i;
return 5; // seconds
}
/* NOT USED
/// Convert any unit to the next smaller one. Unit does not have to be exact.
+ (TimeUnitType)smallerUnit:(TimeUnitType)unit {
if (unit <= TimeUnitHours) return TimeUnitSeconds;
if (unit <= TimeUnitDays) return TimeUnitMinutes; // > hours
if (unit <= TimeUnitWeeks) return TimeUnitHours; // > days
if (unit <= TimeUnitYears) return TimeUnitDays; // > weeks
return TimeUnitWeeks; // > years
}
/// @return Formatted string from @c timeIntervalSinceNow.
- (nonnull NSString*)intervalStringWithDecimal:(BOOL)flag {
return [NSDate stringForInterval:(Interval)[self timeIntervalSinceNow] rounded:flag];
}
/// @return Highest non-zero unit ( @c flag=YES ). Or highest integer-dividable unit ( @c flag=NO ).
- (TimeUnitType)unitWithDecimal:(BOOL)flag {
Interval absIntv = abs((Interval)[self timeIntervalSinceNow]);
if (flag) {
return _values[ [NSDate floatUnitIndexForInterval:absIntv] ];
}
return _values[ [NSDate exactUnitIndexForInterval:absIntv] ];
}
*/
@end
@implementation NSDate (RefreshControlsUI)
/// @return Interval by multiplying the text field value with the currently selected popup unit.
+ (Interval)intervalForPopup:(NSPopUpButton*)unit andField:(NSTextField*)value {
return value.intValue * (Interval)unit.selectedTag;
}
/// Configure both @c NSControl elements based on the provided interval @c intv.
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag {
TimeUnitType unit = [self unitForInterval:intv rounded:NO];
int num = (int)(intv / unit);
if (flag && popup.selectedTag != unit) [self animateControlSize:popup];
if (flag && field.intValue != num) [self animateControlSize:field];
[popup selectItemWithTag:unit];
field.intValue = num;
}
/// Insert all @c TimeUnitType items into popup button. Save unit value into @c tag attribute.
+ (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit {
[popup removeAllItems];
for (NSUInteger i = 0; i < 6; i++) {
[popup addItemWithTitle:[NSString stringWithUTF8String:_names[i]]];
NSMenuItem *item = popup.lastItem;
[item setKeyEquivalent:[[NSString stringWithFormat:@"%c", _shortnames[i]] uppercaseString]];
item.tag = _values[i];
}
[popup selectItemWithTag:unit];
}
/// Helper method to animate @c NSControl to draw user attention. View will be scalled up in a fraction of a second.
+ (void)animateControlSize:(NSView*)control {
CABasicAnimation *scale = [CABasicAnimation animationWithKeyPath:@"transform"];
CATransform3D tr = CATransform3DIdentity;
tr = CATransform3DTranslate(tr, NSMidX(control.bounds), NSMidY(control.bounds), 0);
tr = CATransform3DScale(tr, 1.1, 1.1, 1);
tr = CATransform3DTranslate(tr, -NSMidX(control.bounds), -NSMidY(control.bounds), 0);
scale.toValue = [NSValue valueWithCATransform3D:tr];
scale.duration = 0.15f;
scale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
[control.layer addAnimation:scale forKey:scale.keyPath];
}
@end

View File

@@ -1,188 +0,0 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "Statistics.h"
#import "NSDate+Ext.h"
@implementation Statistics
#pragma mark - Generate Refresh Interval Statistics
/**
@return @c nil if list contains less than 2 entries. Otherwise: @{min, max, avg, median, earliest, latest}
*/
+ (NSDictionary*)refreshInterval:(NSArray<NSDate*> *)list {
if (!list || list.count == 0)
return nil;
NSDate *earliest = [NSDate distantFuture];
NSDate *latest = [NSDate distantPast];
NSDate *prev = nil;
NSMutableArray<NSNumber*> *differences = [NSMutableArray array];
for (NSDate *d in list) {
if (![d isKindOfClass:[NSDate class]]) // because valueForKeyPath: can return NSNull
continue;
earliest = [d earlierDate:earliest];
latest = [d laterDate:latest];
if (prev) {
int dif = abs((int)[d timeIntervalSinceDate:prev]);
[differences addObject:[NSNumber numberWithInt:dif]];
}
prev = d;
}
if (differences.count == 0)
return nil;
[differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]];
NSUInteger i = (differences.count/2);
NSNumber *median = differences[i];
if ((differences.count % 2) == 0) { // even feed count, use median of two values
median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2];
}
return @{@"min" : differences.firstObject,
@"max" : differences.lastObject,
@"avg" : [differences valueForKeyPath:@"@avg.self"],
@"median" : median,
@"earliest" : earliest,
@"latest" : latest };
}
#pragma mark - Feed Statistics UI
/**
Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date.
@param info The dictionary generated with @c -refreshInterval:
@param count Article count.
@param callback If set, @c sender will be called with @c -refreshIntervalButtonClicked:.
If not disable button border and display as bold inline text.
@return Centered view without autoresizing.
*/
+ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
NSString *lbl = [NSString stringWithFormat:NSLocalizedString(@"%lu articles.", nil), count];
if (!info || info.count == 0)
return [self grayLabel:lbl];
// Subview with 4 button (min, max, avg, median)
NSView *buttonsView = [[NSView alloc] init];
NSPoint origin = NSZeroPoint;
for (NSString *str in @[@"min", @"max", @"avg", @"median"]) {
NSString *title = [str stringByAppendingString:@":"];
NSView *v = [self viewWithLabel:title andInterval:info[str] callback:callback];
[v setFrameOrigin:origin];
[buttonsView addSubview:v];
origin.x += NSWidth(v.frame);
}
[buttonsView setFrameSize:NSMakeSize(origin.x, NSHeight(buttonsView.subviews.firstObject.frame))];
// Subview with article count and latest article date
NSDate *lastUpdate = [info valueForKey:@"latest"];
NSString *mod = [NSDateFormatter localizedStringFromDate:lastUpdate dateStyle:NSDateFormatterMediumStyle timeStyle:NSDateFormatterShortStyle];
NSTextField *dateView = [self grayLabel:[lbl stringByAppendingFormat:@" (latest: %@)", mod]];
// Feed wasn't updated in a while ...
if ([lastUpdate timeIntervalSinceNow] < (-360 * 24 * 60 * 60)) {
NSMutableAttributedString *as = dateView.attributedStringValue.mutableCopy;
[as addAttribute:NSForegroundColorAttributeName value:[NSColor systemRedColor] range:NSMakeRange(lbl.length, as.length - lbl.length)];
[dateView setAttributedStringValue:as];
}
// Calculate offset and align both horizontally centered
CGFloat maxWidth = NSWidth(buttonsView.frame);
if (maxWidth < NSWidth(dateView.frame))
maxWidth = NSWidth(dateView.frame);
[buttonsView setFrameOrigin:NSMakePoint(0.5f*(maxWidth - NSWidth(buttonsView.frame)), 0)];
[dateView setFrameOrigin:NSMakePoint(0.5f*(maxWidth - NSWidth(dateView.frame)), NSHeight(buttonsView.frame))];
// Dump both into single parent view and make that view centered during resize
NSView *parent = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, maxWidth, NSMaxY(dateView.frame))];
parent.autoresizingMask = NSViewMinXMargin | NSViewMaxXMargin;// | NSViewMinYMargin | NSViewMaxYMargin;
parent.autoresizesSubviews = NO;
// parent.layer = [CALayer layer];
// parent.layer.backgroundColor = [NSColor systemYellowColor].CGColor;
[parent addSubview:dateView];
[parent addSubview:buttonsView];
return parent;
}
/**
Create view with duration button, e.g., '3.4h' and label infornt of it.
*/
+ (NSView*)viewWithLabel:(NSString*)title andInterval:(NSNumber*)value callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
static const int buttonPadding = 5;
NSButton *button = [self grayInlineButton:value];
if (callback) {
button.target = callback;
button.action = @selector(refreshIntervalButtonClicked:);
} else {
button.bordered = NO;
button.enabled = NO;
}
NSTextField *label;
if (title && title.length > 0) {
label = [self grayLabel:title];
[label setFrameOrigin:NSMakePoint(0, button.alignmentRectInsets.bottom + 0.5f*(NSHeight(button.frame) - NSHeight(label.frame)))];
}
[button setFrameOrigin:NSMakePoint(NSWidth(label.frame), 0)];
CGFloat maxHeight = NSHeight(button.frame);
if (maxHeight < NSHeight(label.frame))
maxHeight = NSHeight(label.frame);
NSView *parent = [[NSView alloc] initWithFrame: NSMakeRect(0, 0, NSMaxX(button.frame) + buttonPadding, maxHeight + buttonPadding)];
[parent addSubview:label];
[parent addSubview:button];
return parent;
}
/**
@return Rounded, gray inline button with tag equal to refresh interval.
*/
+ (NSButton*)grayInlineButton:(NSNumber*)num {
NSButton *button = [NSButton buttonWithTitle:[NSDate stringForInterval:num.intValue rounded:YES] target:nil action:nil];
button.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightBold];
button.bezelStyle = NSBezelStyleInline;
button.controlSize = NSControlSizeSmall;
TimeUnitType unit = [NSDate unitForInterval:num.intValue rounded:YES];
button.tag = (NSInteger)(roundf(num.floatValue / unit) * unit); // rounded inteval
[button sizeToFit];
return button;
}
/**
@return Simple Label with smaller gray text, non-editable.
*/
+ (NSTextField*)grayLabel:(NSString*)text {
NSTextField *label = [NSTextField textFieldWithString:text];
label.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightRegular];
label.textColor = [NSColor systemGrayColor];
label.drawsBackground = NO;
label.selectable = NO;
label.editable = NO;
label.bezeled = NO;
[label sizeToFit];
return label;
}
@end

27
baRSS/Helper/URLScheme.h Normal file
View File

@@ -0,0 +1,27 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@interface URLScheme : NSObject
+ (void)withURL:(NSString*)url;
@end

99
baRSS/Helper/URLScheme.m Normal file
View File

@@ -0,0 +1,99 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "URLScheme.h"
#import "AppHook.h" // barss:open/preferences
#import "Preferences.h" // barss:open/preferences
#import "UpdateScheduler.h" // feed:http://URL
#import "StoreCoordinator.h" // barss:config/fixcache
#import "OpmlFile.h" // barss:backup
#import "NSURL+Ext.h" // barss:backup
#import "NSDate+Ext.h" // barss:backup
@implementation URLScheme
/// Handles open URL requests. Scheme may start with @c feed: or @c barss:
+ (void)withURL:(NSString*)url {
NSString *scheme = [[[NSURL URLWithString:url] scheme] lowercaseString];
url = [url substringFromIndex:scheme.length + 1]; // + ':'
url = [url stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"/"]];
if ([scheme isEqualToString:@"feed"]) [[URLScheme new] handleSchemeFeed:url];
else if ([scheme isEqualToString:@"barss"]) [[URLScheme new] handleSchemeConfig:url];
}
/**
@c feed: URL scheme. Used for feed subscriptions.
@note E.g., @c feed://https://feeds.feedburner.com/simpledesktops
*/
- (void)handleSchemeFeed:(NSString*)url {
[UpdateScheduler autoDownloadAndParseURL:url];
}
/**
@c barss: URL scheme. Used for configuring the app.
@textblock
barss:open/preferences[/0-4]
barss:config/fixcache[/silent]
barss:backup[/show]
@/textblock
*/
- (void)handleSchemeConfig:(NSString*)url {
NSArray<NSString*> *comp = url.pathComponents;
NSString *action = comp.firstObject;
if (!action) return;
NSArray<NSString*> *params = [comp subarrayWithRange:NSMakeRange(1, comp.count - 1)];
if ([action isEqualToString:@"open"]) [self handleActionOpen:params];
else if ([action isEqualToString:@"config"]) [self handleActionConfig:params];
else if ([action isEqualToString:@"backup"]) [self handleActionBackup:params];
}
/// @c barss:open/preferences[/0-4]
- (void)handleActionOpen:(NSArray<NSString*>*)params {
if ([params.firstObject isEqualToString:@"preferences"]) {
NSDecimalNumber *num = [NSDecimalNumber decimalNumberWithString:params.lastObject];
[[(AppHook*)NSApp openPreferences] selectTab:num.unsignedIntegerValue];
}
}
/// @c barss:config/fixcache[/silent]
- (void)handleActionConfig:(NSArray<NSString*>*)params {
if ([params.firstObject isEqualToString:@"fixcache"]) {
[StoreCoordinator cleanupAndShowAlert:![params.lastObject isEqualToString:@"silent"]];
}
}
/// @c barss:backup[/show]
- (void)handleActionBackup:(NSArray<NSString*>*)params {
NSURL *baseURL = [NSURL backupPathURL];
[baseURL mkdir]; // non destructive make dir
NSURL *dest = [baseURL file:[@"feeds_" stringByAppendingString:[NSDate dayStringISO8601]] ext:@"opml"];
NSURL *sym = [baseURL file:@"feeds_latest" ext:@"opml"];
[sym remove]; // remove old sym link, otherwise won't be updated
[[NSFileManager defaultManager] createSymbolicLinkAtURL:sym withDestinationURL:[NSURL URLWithString:dest.lastPathComponent] error:nil];
[[OpmlFileExport withDelegate:nil] writeOPMLFile:dest withOptions:OpmlFileExportOptionFullBackup];
if ([params.firstObject isEqualToString:@"show"]) {
[[NSWorkspace sharedWorkspace] activateFileViewerSelectingURLs:@[dest]];
}
}
@end

111
baRSS/Helper/UserPrefs.h Normal file
View File

@@ -0,0 +1,111 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#ifndef UserPrefs_h
#define UserPrefs_h
@import Cocoa;
// ---------------------------------------------------------------
// | MARK: Constants
// ---------------------------------------------------------------
// ------ Preferences window ------
/** default: @c 1 */ static NSString* const Pref_prefSelectedTab = @"prefSelectedTab";
/** default: @c nil */ static NSString* const Pref_prefWindowFrame = @"prefWindowFrame";
/** default: @c nil */ static NSString* const Pref_modalSheetWidth = @"modalSheetWidth";
// ------ General settings ------ (Preferences > General Tab) ------
/** default: @c nil */ static NSString* const Pref_defaultHttpApplication = @"defaultHttpApplication";
// ------ Appearance matrix ------ (Preferences > Appearance Tab) ------
/** default: @c YES */ static NSString* const Pref_globalTintMenuIcon = @"globalTintMenuBarIcon";
/** default: @c YES */ static NSString* const Pref_globalUpdateAll = @"globalUpdateAll";
/** default: @c YES */ static NSString* const Pref_globalOpenUnread = @"globalOpenUnread";
/** default: @c YES */ static NSString* const Pref_globalMarkRead = @"globalMarkRead";
/** default: @c YES */ static NSString* const Pref_globalMarkUnread = @"globalMarkUnread";
/** default: @c YES */ static NSString* const Pref_globalUnreadCount = @"globalUnreadCount";
/** default: @c YES */ static NSString* const Pref_groupOpenUnread = @"groupOpenUnread";
/** default: @c YES */ static NSString* const Pref_groupMarkRead = @"groupMarkRead";
/** default: @c YES */ static NSString* const Pref_groupMarkUnread = @"groupMarkUnread";
/** default: @c YES */ static NSString* const Pref_groupUnreadCount = @"groupUnreadCount";
/** default: @c NO */ static NSString* const Pref_groupUnreadIndicator = @"groupUnreadIndicator";
/** default: @c YES */ static NSString* const Pref_feedOpenUnread = @"feedOpenUnread";
/** default: @c YES */ static NSString* const Pref_feedMarkRead = @"feedMarkRead";
/** default: @c YES */ static NSString* const Pref_feedMarkUnread = @"feedMarkUnread";
/** default: @c YES */ static NSString* const Pref_feedUnreadCount = @"feedUnreadCount";
/** default: @c YES */ static NSString* const Pref_feedUnreadIndicator = @"feedUnreadIndicator";
/** default: @c NO */ static NSString* const Pref_feedTruncateTitle = @"feedTruncateTitle";
/** default: @c NO */ static NSString* const Pref_feedLimitArticles = @"feedLimitArticles";
// ------ Hidden preferences ------ only modifiable via `defaults write de.relikd.baRSS {KEY}` ------
/** default: @c 10 */ static NSString* const Pref_openFewLinksLimit = @"openFewLinksLimit";
/** default: @c 60 */ static NSString* const Pref_shortArticleNamesLimit = @"shortArticleNamesLimit";
/** default: @c 40 */ static NSString* const Pref_articlesInMenuLimit = @"articlesInMenuLimit";
/** default: @c nil */ static NSString* const Pref_colorStatusIconTint = @"colorStatusIconTint";
/** default: @c nil */ static NSString* const Pref_colorUnreadIndicator = @"colorUnreadIndicator";
// ---------------------------------------------------------------
// | MARK: - NSUserDefaults
// ---------------------------------------------------------------
void UserPrefsInit(void);
NSColor* UserPrefsColor(NSString *key, NSColor *defaultColor); // Change with: defaults write de.relikd.baRSS {KEY} -string "#FBA33A"
// ------ Getter ------
/// Helper method calls @c (standardUserDefaults)boolForKey:
static inline BOOL UserPrefsBool(NSString* const key) { return [[NSUserDefaults standardUserDefaults] boolForKey:key]; }
/// Helper method calls @c (standardUserDefaults)integerForKey:
static inline NSInteger UserPrefsInt(NSString* const key) { return [[NSUserDefaults standardUserDefaults] integerForKey:key]; }
/// Helper method calls @c (standardUserDefaults)integerForKey: @return @c (NSUInteger)result
static inline NSUInteger UserPrefsUInt(NSString* const key) { return (NSUInteger)[[NSUserDefaults standardUserDefaults] integerForKey:key]; }
/// Helper method calls @c (standardUserDefaults)stringForKey:
static inline NSString* UserPrefsString(NSString* const key) { return [[NSUserDefaults standardUserDefaults] stringForKey:key]; }
// ------ Setter ------
/// Helper method calls @c (standardUserDefaults)setObject:forKey:
static inline void UserPrefsSet(NSString* const key, id value) { [[NSUserDefaults standardUserDefaults] setObject:value forKey:key]; }
/// Helper method calls @c (standardUserDefaults)setInteger:forKey:
static inline void UserPrefsSetInt(NSString* const key, NSInteger value) { [[NSUserDefaults standardUserDefaults] setInteger:value forKey:key]; }
/// Helper method calls @c (standardUserDefaults)setBool:forKey:
static inline void UserPrefsSetBool(NSString* const key, BOOL value) { [[NSUserDefaults standardUserDefaults] setBool:value forKey:key]; }
// ---------------------------------------------------------------
// | MARK: - NSBundle
// ---------------------------------------------------------------
/// Helper method calls @c (mainBundle)CFBundleShortVersionString
static inline NSString* UserPrefsAppVersion() { return [[NSBundle mainBundle] infoDictionary][@"CFBundleShortVersionString"]; }
// ---------------------------------------------------------------
// | MARK: - Open URLs
// ---------------------------------------------------------------
/**
Open web links in default browser or a browser the user selected in the preferences.
@param urls A list of @c NSURL objects that will be opened immediatelly in bulk.
@return @c YES if @c urls are opened successfully. @c NO on error.
*/
static inline BOOL UserPrefsOpenURLs(NSArray<NSURL*> *urls) {
return [[NSWorkspace sharedWorkspace] openURLs:urls withAppBundleIdentifier:UserPrefsString(Pref_defaultHttpApplication) options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil];
}
/// Call @c UserPrefsOpenURLs() with single item array and convert string to @c NSURL
static inline BOOL UserPrefsOpenURL(NSString *url) { return UserPrefsOpenURLs(@[[NSURL URLWithString:url]]); }
#endif /* UserPrefs_h */

63
baRSS/Helper/UserPrefs.m Normal file
View File

@@ -0,0 +1,63 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "UserPrefs.h"
#import "NSString+Ext.h" // hexColor
/// Helper method for @c UserPrefsInit()
static inline void defaultsAppend(NSMutableDictionary *defs, id value, NSArray<NSString*>* keys) {
for (NSString *key in keys)
[defs setObject:value forKey:key];
}
/// Helper method calls @c (standardUserDefaults)registerDefaults:
void UserPrefsInit(void) {
NSMutableDictionary *defs = [NSMutableDictionary dictionary];
defaultsAppend(defs, @YES, @[Pref_globalTintMenuIcon,
Pref_globalUpdateAll,
Pref_globalOpenUnread, Pref_groupOpenUnread, Pref_feedOpenUnread,
Pref_globalMarkRead, Pref_groupMarkRead, Pref_feedMarkRead,
Pref_globalMarkUnread, Pref_groupMarkUnread, Pref_feedMarkUnread,
Pref_globalUnreadCount, Pref_groupUnreadCount, Pref_feedUnreadCount,
Pref_feedUnreadIndicator]);
defaultsAppend(defs, @NO, @[Pref_groupUnreadIndicator,
Pref_feedTruncateTitle,
Pref_feedLimitArticles]);
// Display limits & truncation ( defaults write de.relikd.baRSS {KEY} -int 10 )
[defs setObject:[NSNumber numberWithUnsignedInteger:10] forKey:Pref_openFewLinksLimit];
[defs setObject:[NSNumber numberWithUnsignedInteger:60] forKey:Pref_shortArticleNamesLimit];
[defs setObject:[NSNumber numberWithUnsignedInteger:40] forKey:Pref_articlesInMenuLimit];
[defs setObject:[NSNumber numberWithUnsignedInteger:1] forKey:Pref_prefSelectedTab]; // feed tab
[[NSUserDefaults standardUserDefaults] registerDefaults:defs];
}
/// @return User set value. If it wasn't modified or couldn't be parsed return @c defaultColor
NSColor* UserPrefsColor(NSString *key, NSColor *defaultColor) {
NSString *colorStr = [[NSUserDefaults standardUserDefaults] stringForKey:key];
if (colorStr) {
NSColor *color = [colorStr hexColor];
if (color) return color;
NSLog(@"Error reading defaults '%@'. Hex color '%@' is invalid. It should be of the form #RBG or #RRGGBB.", key, colorStr);
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
}
return defaultColor;
}

View File

@@ -4,10 +4,38 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>org.opml.opml</string>
<key>LSHandlerRank</key>
<string>Default</string>
</dict>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>opml</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>opml-icon</string>
<key>CFBundleTypeName</key>
<string>OPML document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSItemContentTypes</key>
<array>
<string>org.opml.opml</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
@@ -17,7 +45,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.9.3</string>
<string>1.0.1</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -30,9 +58,21 @@
<string>feed</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>de.relikd.baRSS.url.config</string>
<key>CFBundleURLSchemes</key>
<array>
<string>barss</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1153</string>
<string>14405</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.news</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
@@ -46,5 +86,35 @@
<string>Copyright © 2019 relikd. Public Domain.</string>
<key>NSPrincipalClass</key>
<string>AppHook</string>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeDescription</key>
<string>OPML document</string>
<key>UTTypeIconFile</key>
<string>opml-icon</string>
<key>UTTypeIdentifier</key>
<string>org.opml.opml</string>
<key>UTTypeReferenceURL</key>
<string>http://dev.opml.org/spec2.html</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>opml</string>
</array>
<key>public.mime-type</key>
<array>
<string>text/xml</string>
<string>text/x-opml</string>
<string>application/xml</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@@ -20,7 +20,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
@import Cocoa;
typedef int32_t Interval;
typedef NS_ENUM(int32_t, TimeUnitType) {
@@ -33,8 +33,17 @@ typedef NS_ENUM(int32_t, TimeUnitType) {
};
@interface NSDate (Ext)
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag;
+ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag;
+ (NSString*)timeStringISO8601;
+ (NSString*)dayStringISO8601;
+ (NSString*)dayStringLocalized;
@end
@interface NSDate (Interval)
+ (nullable NSString*)intStringForInterval:(Interval)intv;
+ (nonnull NSString*)floatStringForInterval:(Interval)intv;
+ (nullable NSString*)stringForRemainingTime:(NSDate*)other;
+ (Interval)floatToIntInterval:(Interval)intv;
@end
@@ -43,3 +52,8 @@ typedef NS_ENUM(int32_t, TimeUnitType) {
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag;
+ (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit;
@end
@interface NSDate (Statistics)
+ (NSDictionary*)refreshIntervalStatistics:(NSArray<NSDate*> *)list;
@end

View File

@@ -0,0 +1,202 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import QuartzCore;
#import "NSDate+Ext.h"
static TimeUnitType const _values[] = {
TimeUnitYears,
TimeUnitWeeks,
TimeUnitDays,
TimeUnitHours,
TimeUnitMinutes,
TimeUnitSeconds,
};
@implementation NSDate (Ext)
/// @return Time as string in iso format: @c YYYY-MM-DD'T'hh:mm:ss'Z'
+ (NSString*)timeStringISO8601 {
return [[[NSISO8601DateFormatter alloc] init] stringFromDate:[NSDate date]];
}
/// @return Day as string in iso format: @c YYYY-MM-DD
+ (NSString*)dayStringISO8601 {
NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]];
return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day];
}
/// @return Day as string in localized short format, e.g., @c DD.MM.YY
+ (NSString*)dayStringLocalized {
return [NSDateFormatter localizedStringFromDate:[NSDate date] dateStyle:NSDateFormatterShortStyle timeStyle:NSDateFormatterNoStyle];
}
@end
@implementation NSDate (Interval)
/// Short interval formatter string (e.g., '30 min', '2 hrs')
+ (nullable NSString*)intStringForInterval:(Interval)intv {
TimeUnitType unit = [self unitForInterval:intv];
Interval num = intv / unit;
NSDateComponents *dc = [[NSDateComponents alloc] init];
switch (unit) {
case TimeUnitSeconds: dc.second = num; break;
case TimeUnitMinutes: dc.minute = num; break;
case TimeUnitHours: dc.hour = num; break;
case TimeUnitDays: dc.day = num; break;
case TimeUnitWeeks: dc.weekOfMonth = num; break;
case TimeUnitYears: dc.year = num; break;
}
return [NSDateComponentsFormatter localizedStringFromDateComponents:dc unitsStyle:NSDateComponentsFormatterUnitsStyleShort];
}
/// Print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h.
+ (nonnull NSString*)floatStringForInterval:(Interval)intv {
unsigned short i = [self floatUnitIndexForInterval:abs(intv)];
return [NSString stringWithFormat:@"%1.1f%c", intv / (float)_values[i], "ywdhms"[i]];
}
/// Short interval formatter string for remaining time until @c other date
+ (nullable NSString*)stringForRemainingTime:(NSDate*)other {
NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init];
formatter.unitsStyle = NSDateComponentsFormatterUnitsStyleShort; // e.g., '30 min'
formatter.maximumUnitCount = 1;
return [formatter stringFromTimeInterval: other.timeIntervalSinceNow];
}
/// Round uneven intervals to highest unit interval. E.g., @c 1:40>2:00 or @c 1:03>1:00
+ (Interval)floatToIntInterval:(Interval)intv {
TimeUnitType unit = _values[[self floatUnitIndexForInterval:abs(intv)]];
return (Interval)(roundf((float)intv / unit) * unit);
}
/// @return Highest integer-dividable unit. E.g., '61 minutes'
+ (TimeUnitType)unitForInterval:(Interval)intv {
if (intv == 0) return TimeUnitMinutes; // fallback to 0 minutes
for (unsigned short i = 0; i < 5; i++) // try: years -> minutes
if (intv % _values[i] == 0) return _values[i];
return TimeUnitSeconds;
}
/// @return Highest non-zero unit type. Can be used with fractions e.g., '1.1 hours'.
+ (unsigned short)floatUnitIndexForInterval:(Interval)intv {
if (intv == 0) return 4; // fallback to 0 minutes
for (unsigned short i = 0; i < 5; i++)
if (intv > _values[i]) return i;
return 5; // seconds
}
@end
@implementation NSDate (RefreshControlsUI)
/// @return Interval by multiplying the text field value with the currently selected popup unit.
+ (Interval)intervalForPopup:(NSPopUpButton*)unit andField:(NSTextField*)value {
return value.intValue * (Interval)unit.selectedTag;
}
/// Configure both @c NSControl elements based on the provided interval @c intv.
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag {
TimeUnitType unit = [self unitForInterval:intv];
int num = (int)(intv / unit);
if (flag && popup.selectedTag != unit) [self animateControlSize:popup];
if (flag && field.intValue != num) [self animateControlSize:field];
[popup selectItemWithTag:unit];
field.intValue = num;
}
/// Insert all @c TimeUnitType items into popup button. Save unit value into @c tag attribute.
+ (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit {
[popup removeAllItems];
[popup addItemsWithTitles:@[NSLocalizedString(@"Years", nil), NSLocalizedString(@"Weeks", nil),
NSLocalizedString(@"Days", nil), NSLocalizedString(@"Hours", nil),
NSLocalizedString(@"Minutes", nil), NSLocalizedString(@"Seconds", nil)]];
for (int i = 0; i < 6; i++) {
[popup itemAtIndex:i].tag = _values[i];
[popup itemAtIndex:i].keyEquivalent = [NSString stringWithFormat:@"%d", i+1]; // Cmd+1 .. Cmd+6
}
[popup selectItemWithTag:unit];
}
/// Helper method to animate @c NSControl to draw user attention. View will be scalled up in a fraction of a second.
+ (void)animateControlSize:(NSView*)control {
CABasicAnimation *scale = [CABasicAnimation animationWithKeyPath:@"transform"];
CATransform3D tr = CATransform3DIdentity;
tr = CATransform3DTranslate(tr, NSMidX(control.bounds), NSMidY(control.bounds), 0);
tr = CATransform3DScale(tr, 1.1, 1.1, 1);
tr = CATransform3DTranslate(tr, -NSMidX(control.bounds), -NSMidY(control.bounds), 0);
scale.toValue = [NSValue valueWithCATransform3D:tr];
scale.duration = 0.15f;
scale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
[control.layer addAnimation:scale forKey:scale.keyPath];
}
@end
@implementation NSDate (Statistics)
/**
@return @c nil if list contains less than 2 entries. Otherwise: @{min, max, avg, median, earliest, latest}
*/
+ (NSDictionary*)refreshIntervalStatistics:(NSArray<NSDate*> *)list {
if (!list || list.count == 0)
return nil;
NSDate *earliest = [NSDate distantFuture];
NSDate *latest = [NSDate distantPast];
NSDate *prev = nil;
NSMutableArray<NSNumber*> *differences = [NSMutableArray array];
for (NSDate *d in list) {
if (![d isKindOfClass:[NSDate class]]) // because valueForKeyPath: can return NSNull
continue;
earliest = [d earlierDate:earliest];
latest = [d laterDate:latest];
if (prev) {
int dif = abs((int)[d timeIntervalSinceDate:prev]);
[differences addObject:[NSNumber numberWithInt:dif]];
}
prev = d;
}
if (differences.count == 0)
return nil;
[differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]];
NSUInteger i = (differences.count/2);
NSNumber *median = differences[i];
if ((differences.count % 2) == 0) { // even feed count, use median of two values
median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2];
}
return @{@"min" : differences.firstObject,
@"max" : differences.lastObject,
@"avg" : [differences valueForKeyPath:@"@avg.self"],
@"median" : median,
@"earliest" : earliest,
@"latest" : latest };
}
@end

View File

@@ -0,0 +1,37 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
/// Log error message and prepend calling class and calling method.
#define NSLogCaller(desc) { NSLog(@"%@:%@ %@", [self class], NSStringFromSelector(_cmd), desc); }
@interface NSError (Ext)
// Generators
+ (instancetype)statusCode:(NSInteger)code reason:(nullable NSString*)reason;
+ (instancetype)feedURLNotFound:(NSURL*)url;
+ (instancetype)canceledByUser;
//+ (instancetype)formattingError:(NSString*)description;
// User notification
- (BOOL)inCaseLog:(nullable const char*)title;
- (BOOL)inCasePresent:(NSApplication*)app;
@end

View File

@@ -0,0 +1,152 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import RSXML2.RSXMLError;
#import "NSError+Ext.h"
@implementation NSError (Ext)
static const char* CodeDescription(NSInteger code) {
switch (code) {
/* --- Informational --- */
case 100: return "Continue";
case 101: return "Switching Protocols";
case 102: return "Processing";
case 103: return "Early Hints";
/* --- Success --- */
case 200: return "OK";
case 201: return "Created";
case 202: return "Accepted";
case 203: return "Non-Authoritative Information";
case 204: return "No Content";
case 205: return "Reset Content";
case 206: return "Partial Content";
case 207: return "Multi-Status";
case 208: return "Already Reported";
case 226: return "IM Used";
/* --- Redirection --- */
case 300: return "Multiple Choices";
case 301: return "Moved Permanently";
case 302: return "Found";
case 303: return "See Other";
case 304: return "Not Modified";
case 305: return "Use Proxy";
case 306: return "Switch Proxy";
case 307: return "Temporary Redirect";
case 308: return "Permanent Redirect";
/* --- Client error --- */
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 402: return "Payment Required";
case 403: return "Forbidden";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 406: return "Not Acceptable";
case 407: return "Proxy Authentication Required";
case 408: return "Request Timeout";
case 409: return "Conflict";
case 410: return "Gone";
case 411: return "Length Required";
case 412: return "Precondition Failed";
case 413: return "Payload Too Large";
case 414: return "URI Too Long";
case 415: return "Unsupported Media Type";
case 416: return "Range Not Satisfiable";
case 417: return "Expectation Failed";
case 418: return "I'm a teapot";
case 421: return "Misdirected Request";
case 422: return "Unprocessable Entity";
case 423: return "Locked";
case 424: return "Failed Dependency";
case 425: return "Too Early";
case 426: return "Upgrade Required";
case 428: return "Precondition Required";
case 429: return "Too Many Requests";
case 431: return "Request Header Fields Too Large";
case 451: return "Unavailable For Legal Reasons";
/* --- Server error --- */
case 500: return "Internal Server Error";
case 501: return "Not Implemented";
case 502: return "Bad Gateway";
case 503: return "Service Unavailable";
case 504: return "Gateway Timeout";
case 505: return "HTTP Version Not Supported";
case 506: return "Variant Also Negotiates";
case 507: return "Insufficient Storage";
case 508: return "Loop Detected";
case 510: return "Not Extended";
case 511: return "Network Authentication Required";
}
return "Unknown";
}
// ---------------------------------------------------------------
// | MARK: - Generators
// ---------------------------------------------------------------
/// Generate @c NSError from HTTP status code. E.g., @c code @c = @c 404 will return "404 Not Found".
+ (instancetype)statusCode:(NSInteger)code reason:(nullable NSString*)reason {
NSMutableDictionary *info = [NSMutableDictionary dictionaryWithCapacity:2];
info[NSLocalizedDescriptionKey] = [NSString stringWithFormat:@"%ld %s.", code, CodeDescription(code)];
if (reason) info[NSLocalizedRecoverySuggestionErrorKey] = reason;
NSInteger errCode = NSURLErrorUnknown;
if (code < 500) { if (code >= 400) errCode = NSURLErrorResourceUnavailable; }
else if (code < 600) errCode = NSURLErrorBadServerResponse;
return [self errorWithDomain:NSURLErrorDomain code:errCode userInfo:info];
}
/// Generate @c NSError for webpages that don't contain feed urls.
+ (instancetype)feedURLNotFound:(NSURL*)url {
return RSXMLMakeErrorWrongParser(RSXMLErrorExpectingFeed, RSXMLErrorExpectingHTML, url);
}
/// Generate @c NSError for user canceled operation. With title "Operation was canceled."
+ (instancetype)canceledByUser {
return [NSError errorWithDomain:NSCocoaErrorDomain code:NSUserCancelledError userInfo:nil];
}
/*// Generate @c NSError for invalid or malformed input. With title "The value is invalid."
+ (instancetype)formattingError:(NSString*)description {
NSDictionary *info = nil;
if (description) info = @{ NSLocalizedRecoverySuggestionErrorKey: description };
return [NSError errorWithDomain:NSCocoaErrorDomain code:NSFormattingError userInfo:info];
}*/
// ---------------------------------------------------------------
// | MARK: - User notification
// ---------------------------------------------------------------
/// Will only execute and return @c YES if @c error @c != @c nil . Log error message to console.
- (BOOL)inCaseLog:(nullable const char*)title {
if (title) printf("ERROR %s: %s, %s\n", title, self.description.UTF8String, self.userInfo.description.UTF8String);
else printf("%s, %s\n", self.description.UTF8String, self.userInfo.description.UTF8String);
return YES;
}
/// Will only execute and return @c YES if @c error @c != @c nil . Present application modal error message.
- (BOOL)inCasePresent:(NSApplication*)app {
[app presentError:self];
return YES;
}
@end

View File

@@ -0,0 +1,32 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@interface NSString (PlainHTML)
+ (NSString*)plainTextFromHTMLData:(NSData*)data;
- (nonnull NSString*)htmlToPlainText;
@end
@interface NSString (HexColor)
- (nullable NSColor*)hexColor;
@end

View File

@@ -0,0 +1,162 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "NSString+Ext.h"
@implementation NSString (PlainHTML)
/// Init string with @c NSUTF8StringEncoding and call @c htmlToPlainText
+ (NSString*)plainTextFromHTMLData:(NSData*)data {
if (!data) return nil;
return [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] htmlToPlainText];
}
/**
Simple HTML parser to extract TEXT elements and semi-structured elements like list items.
Ignores @c <head> , @c <style> and @c <script> tags.
*/
- (nonnull NSString*)htmlToPlainText {
NSScanner *scanner = [NSScanner scannerWithString:self];
scanner.charactersToBeSkipped = NSCharacterSet.newlineCharacterSet; // ! else, some spaces are dropped
NSCharacterSet *angleBrackets = [NSCharacterSet characterSetWithCharactersInString:@"<>"];
unichar prev = '>';
int order = 0; // ul & ol
NSString *skip = nil; // head, style, script
NSMutableString *result = [NSMutableString stringWithString:@" "];
while ([scanner isAtEnd] == NO) {
NSString *tag = nil;
if ([scanner scanUpToCharactersFromSet:angleBrackets intoString:&tag]) {
// parse html tag depending on type
if (prev == '<') {
if (skip) {
// skip everything between <head>, <style>, and <script> tags
if (CLOSE(tag, skip))
skip = nil;
continue;
}
if (OPEN(tag, @"a")) [result appendString:@" "];
else if (OPEN(tag, @"head")) skip = @"/head";
else if (OPEN(tag, @"style")) skip = @"/style";
else if (OPEN(tag, @"script")) skip = @"/script";
else if (CLOSE(tag, @"/p") || OPEN(tag, @"label") || OPEN(tag, @"br"))
[result appendString:@"\n"];
else if (OPEN(tag, @"h1") || OPEN(tag, @"h2") || OPEN(tag, @"h3") ||
OPEN(tag, @"h4") || OPEN(tag, @"h5") || OPEN(tag, @"h6") ||
CLOSE(tag, @"/h1") || CLOSE(tag, @"/h2") || CLOSE(tag, @"/h3") ||
CLOSE(tag, @"/h4") || CLOSE(tag, @"/h5") || CLOSE(tag, @"/h6"))
[result appendString:@"\n"];
else if (OPEN(tag, @"ol")) order = 1;
else if (OPEN(tag, @"ul")) order = 0;
else if (OPEN(tag, @"li")) {
// ordered and unordered list items
unichar last = [result characterAtIndex:result.length - 1];
if (last != '\n') {
[result appendString:@"\n"];
}
if (order > 0) [result appendFormat:@" %d. ", order++];
else [result appendString:@" • "];
}
} else {
// append text inbetween tags
if (!skip) {
[result appendString:tag];
}
}
}
if (![scanner isAtEnd]) {
unichar next = [self characterAtIndex:scanner.scanLocation];
if (prev == next) {
if (!skip)
[result appendFormat:@"%c", prev];
}
prev = next;
++scanner.scanLocation;
}
}
// collapsing multiple horizontal whitespaces (\h) into one (the first one)
[[NSRegularExpression regularExpressionWithPattern:@"(\\h)[\\h]+" options:0 error:nil]
replaceMatchesInString:result options:0 range:NSMakeRange(0, result.length) withTemplate:@"$1"];
return [result stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
}
#pragma mark - Helper methods
static inline BOOL OPEN(NSString *tag, NSString *match) {
return ([tag isEqualToString:match] || [tag hasPrefix:[match stringByAppendingString:@" "]]);
}
static inline BOOL CLOSE(NSString *tag, NSString *match) {
return [tag isEqualToString:match];
}
@end
@implementation NSString (HexColor)
/**
Color from hex string with format: @c #[0x|0X]([A]RGB|[AA]RRGGBB)
@return @c nil if string is not properly formatted.
*/
- (nullable NSColor*)hexColor {
if ([self characterAtIndex:0] != '#') // must start with '#'
return nil;
NSScanner *scanner = [NSScanner scannerWithString:self];
scanner.scanLocation = 1;
unsigned int value;
if (![scanner scanHexInt:&value])
return nil;
NSUInteger len = scanner.scanLocation - 1; // -'#'
if (len > 1 && ([self characterAtIndex:2] == 'x' || [self characterAtIndex:3] == 'X'))
len -= 2; // ignore '0x'RRGGBB
unsigned int r = 0, g = 0, b = 0, a = 255;
switch (len) {
case 4: // #ARGB
// ignore alpha for now
// a = (value >> 8) & 0xF0; a = a | (a >> 4);
case 3: // #RGB
r = (value >> 4) & 0xF0; r = r | (r >> 4);
g = (value) & 0xF0; g = g | (g >> 4);
b = (value) & 0x0F; b = b | (b << 4);
break;
case 8: // #AARRGGBB
// a = (value >> 24) & 0xFF;
case 6: // #RRGGBB
r = (value >> 16) & 0xFF;
g = (value >> 8) & 0xFF;
b = (value) & 0xFF;
break;
default:
return nil;
}
return [NSColor colorWithCalibratedRed:r/255.f green:g/255.f blue:b/255.f alpha:a/255.f];
}
@end

View File

@@ -0,0 +1,40 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
#define ENV_LOG_FILES 0
@interface NSURL (Ext)
// Generators
+ (NSURL*)applicationSupportURL;
+ (NSURL*)faviconsCacheURL;
+ (NSURL*)backupPathURL;
// File Traversal
- (BOOL)existsAndIsDir:(BOOL)dir;
- (NSURL*)subdir:(NSString*)dirname;
- (NSURL*)file:(NSString*)filename ext:(nullable NSString*)ext;
// File Manipulation
- (BOOL)mkdir;
- (void)remove;
- (void)moveTo:(NSURL*)destination;
@end

View File

@@ -0,0 +1,110 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "NSURL+Ext.h"
#import "NSError+Ext.h"
@implementation NSURL (Ext)
// ---------------------------------------------------------------
// | MARK: - Generators
// ---------------------------------------------------------------
/// @return Directory URL pointing to "Application Support/baRSS". Does @b not create directory!
+ (NSURL*)applicationSupportURL {
static NSURL *path = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
path = [[NSFileManager defaultManager] URLForDirectory:NSApplicationSupportDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:YES error:nil];
path = [path URLByAppendingPathComponent:APP_NAME isDirectory:YES];
});
return path;
}
/// @return Directory URL pointing to "Application Support/baRSS/favicons". Does @b not create directory!
+ (NSURL*)faviconsCacheURL {
return [[self applicationSupportURL] URLByAppendingPathComponent:@"favicons" isDirectory:YES];
}
/// @return Directory URL pointing to "Application Support/baRSS/backup". Does @b not create directory!
+ (NSURL*)backupPathURL {
return [[self applicationSupportURL] URLByAppendingPathComponent:@"backup" isDirectory:YES];
}
// ---------------------------------------------------------------
// | MARK: - File Traversal
// ---------------------------------------------------------------
/// @return @c YES if and only if item exists at URL and item matches @c dir flag
- (BOOL)existsAndIsDir:(BOOL)dir {
BOOL d;
return self.path && [[NSFileManager defaultManager] fileExistsAtPath:self.path isDirectory:&d] && d == dir;
}
/// @return @c NSURL copy with appended directory path
- (NSURL*)subdir:(NSString*)dirname {
return [self URLByAppendingPathComponent:dirname isDirectory:YES];
}
/// @return @c NSURL copy with appended file path and extension
- (NSURL*)file:(NSString*)filename ext:(nullable NSString*)ext {
NSURL *u = [self URLByAppendingPathComponent:filename isDirectory:NO];
return ext.length > 0 ? [u URLByAppendingPathExtension:ext] : u;
}
// ---------------------------------------------------------------
// | MARK: - File Manipulation
// ---------------------------------------------------------------
/**
Create directory at URL. If directory exists, this method does nothing.
@return @c YES if dir created successfully. @c NO if dir already exists or an error occured.
*/
- (BOOL)mkdir {
if ([self existsAndIsDir:YES]) return NO;
NSError *err;
[[NSFileManager defaultManager] createDirectoryAtURL:self withIntermediateDirectories:YES attributes:nil error:&err];
return ![err inCasePresent:NSApp];
}
/// Delete file or folder at URL. If item does not exist, this method does nothing.
- (void)remove {
#if DEBUG && ENV_LOG_FILES
BOOL success =
#endif
[[NSFileManager defaultManager] removeItemAtURL:self error:nil];
#if DEBUG && ENV_LOG_FILES
if (success) printf("DEL %s\n", self.absoluteString.UTF8String);
#endif
}
/// Move file to destination (by replacing any existing file)
- (void)moveTo:(NSURL*)destination {
[[NSFileManager defaultManager] removeItemAtURL:destination error:nil];
[[NSFileManager defaultManager] moveItemAtURL:self toURL:destination error:nil];
#if DEBUG && ENV_LOG_FILES
printf("MOVE %s\n", self.absoluteString.UTF8String);
printf(" ↳ %s\n", destination.absoluteString.UTF8String);
#endif
}
@end

View File

@@ -0,0 +1,31 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
#define ENV_LOG_DOWNLOAD 1
@interface NSURLRequest (Ext)
+ (instancetype)withURL:(NSString*)urlStr;
- (NSURLSessionDataTask*)dataTask:(nonnull void(^)(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response))block;
- (NSURLSessionDownloadTask*)downloadTask:(void(^)(NSURL * _Nullable path, NSError * _Nullable error))block;
@end

View File

@@ -0,0 +1,97 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "NSURLRequest+Ext.h"
#import "NSString+Ext.h"
#import "NSError+Ext.h"
/// @return Shared URL session with caches disabled, enabled gzip encoding and custom user agent.
static NSURLSession* NonCachingURLSession(void) {
static NSURLSession *session = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURLSessionConfiguration *conf = [NSURLSessionConfiguration defaultSessionConfiguration];
conf.HTTPCookieAcceptPolicy = NSHTTPCookieAcceptPolicyNever;
conf.HTTPShouldSetCookies = NO;
conf.HTTPCookieStorage = nil; // disables '~/Library/Cookies/'
conf.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
conf.URLCache = nil; // disables '~/Library/Caches/de.relikd.baRSS/'
conf.HTTPAdditionalHeaders = @{ @"User-Agent": @"baRSS (macOS)",
@"Accept-Encoding": @"gzip" };
session = [NSURLSession sessionWithConfiguration:conf];
});
return session;
}
@implementation NSURLRequest (Ext)
/// @return New request from URL. Ensures that at least @c http scheme is set.
+ (instancetype)withURL:(NSString*)urlStr {
NSURL *url = [NSURL URLWithString:urlStr];
if (!url.scheme)
url = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", urlStr]]; // will redirect to https
return [self requestWithURL:url];
}
/// Perform request with non caching @c NSURLSession . If HTTP status code is @c 304 then @c data @c = @c nil.
- (NSURLSessionDataTask*)dataTask:(nonnull void(^)(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response))block {
NSURLSessionDataTask *task = [NonCachingURLSession() dataTaskWithRequest:self completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
NSInteger status = [httpResponse statusCode];
#if DEBUG && ENV_LOG_DOWNLOAD
/*if (status != 304)*/ printf("GET %ld %s\n", status, self.URL.absoluteString.UTF8String);
#endif
if (error || status == 304) {
data = nil; // if status == 304, data & error nil
} else if (status >= 400 && status < 600) { // catch Client & Server errors
error = [NSError statusCode:status reason:(status >= 500 ? [NSString plainTextFromHTMLData:data] : nil)];
data = nil;
}
block(data, error, httpResponse);
}];
[task resume];
return task;
}
/// Prepare a download task and immediatelly perform request with non caching URL session.
- (NSURLSessionDownloadTask*)downloadTask:(void(^)(NSURL * _Nullable path, NSError * _Nullable error))block {
NSURLSessionDownloadTask *task = [NonCachingURLSession() downloadTaskWithRequest:self completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
block(location, error);
}];
[task resume];
return task;
}
/*
Developer Tip, error log:
Task <..> HTTP load failed (error code: -1003 [12:8])
Task <..> finished with error - code: -1003 --- NSURLErrorCannotFindHost
==> NSURLErrorCannotFindHost in #import <Foundation/NSURLError.h>
TIC TCP Conn Failed [21:0x1d417fb00]: 1:65 Err(65) --- EHOSTUNREACH, No route to host
TIC Read Status [9:0x0]: 1:57 --- ENOTCONN, Socket is not connected
==> EHOSTUNREACH in #import <sys/errno.h>
*/
@end

View File

@@ -0,0 +1,108 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
/***/ static CGFloat const PAD_WIN = 20; // window padding
/***/ static CGFloat const PAD_L = 16;
/***/ static CGFloat const PAD_M = 8;
/***/ static CGFloat const PAD_S = 4;
/***/ static CGFloat const PAD_XS = 2;
/***/ static CGFloat const HEIGHT_LABEL = 17;
/***/ static CGFloat const HEIGHT_LABEL_SMALL = 14;
/***/ static CGFloat const HEIGHT_INPUTFIELD = 21;
/***/ static CGFloat const HEIGHT_BUTTON = 21;
/***/ static CGFloat const HEIGHT_INLINEBUTTON = 16;
/***/ static CGFloat const HEIGHT_POPUP = 21;
/***/ static CGFloat const HEIGHT_SPINNER = 16;
/***/ static CGFloat const HEIGHT_CHECKBOX = 14;
/// Static variable to calculate origin center coordinate in its @c superview. The value of this var isn't used.
static CGFloat const CENTER = -0.015625;
/// Calculate @c origin.y going down from the top border of its @c superview
static inline CGFloat YFromTop(NSView *view) { return NSHeight(view.superview.frame) - NSMinY(view.frame) - view.alignmentRectInsets.bottom; }
/// @c MAX()
static inline CGFloat Max(CGFloat a, CGFloat b) { return a < b ? b : a; }
/// @c Max(NSWidth(a.frame),NSWidth(b.frame))
static inline CGFloat NSMaxWidth(NSView *a, NSView *b) { return Max(NSWidth(a.frame), NSWidth(b.frame)); }
/*
Allmost all methods return @c self to allow method chaining
*/
@interface NSView (Ext)
// UI: TextFields
+ (NSTextField*)label:(NSString*)text;
+ (NSTextField*)inputField:(NSString*)placeholder width:(CGFloat)w;
+ (NSView*)labelColumn:(NSArray<NSString*>*)labels rowHeight:(CGFloat)h padding:(CGFloat)pad;
// UI: Buttons
+ (NSButton*)button:(NSString*)text;
+ (NSButton*)buttonImageSquare:(nonnull NSImageName)name;
+ (NSButton*)buttonIcon:(nonnull NSImageName)name size:(CGFloat)size;
+ (NSButton*)helpButton;
+ (NSButton*)inlineButton:(NSString*)text;
+ (NSPopUpButton*)popupButton:(CGFloat)w;
// UI: Others
+ (NSImageView*)imageView:(NSImageName)name size:(CGFloat)size;
+ (NSButton*)checkbox:(BOOL)flag;
+ (NSProgressIndicator*)activitySpinner;
+ (NSView*)radioGroup:(NSArray<NSString*>*)entries target:(id)target action:(nonnull SEL)action;
+ (NSView*)radioGroup:(NSArray<NSString*>*)entries;
// UI: Enclosing Container
+ (NSPopover*)popover:(NSSize)size;
- (NSScrollView*)wrapContent:(NSView*)content inScrollView:(NSRect)rect;
+ (NSView*)wrapView:(NSView*)other withLabel:(NSString*)str padding:(CGFloat)pad;
// Insert UI elements in parent view
- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x y:(CGFloat)y;
- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x yTop:(CGFloat)y;
- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x y:(CGFloat)y;
- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x yTop:(CGFloat)y;
// Modify existing UI elements
- (instancetype)sizableWidthAndHeight;
- (instancetype)sizeToRight:(CGFloat)rightPadding;
- (instancetype)sizeWidthToFit;
- (instancetype)tooltip:(NSString*)tt;
// Debugging
- (instancetype)colorLayer:(NSColor*)color;
+ (NSView*)redCube:(CGFloat)size;
@end
@interface NSControl (Ext)
- (instancetype)action:(SEL)selector target:(id)target;
- (instancetype)large;
- (instancetype)small;
- (instancetype)tiny;
- (instancetype)bold;
- (instancetype)textRight;
- (instancetype)textCenter;
@end
@interface NSTextField (Ext)
- (instancetype)gray;
- (instancetype)selectable;
- (instancetype)multiline:(NSSize)size;
@end

View File

@@ -0,0 +1,381 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "NSView+Ext.h"
@implementation NSView (Ext)
#pragma mark - UI: TextFields -
/// Create label with non-editable text. Ensures uniform fontsize and text color. @c 17px height.
+ (NSTextField*)label:(NSString*)text {
NSTextField *label = [NSTextField labelWithString:text];
[label setFrameSize: NSMakeSize(0, HEIGHT_LABEL)];
label.font = [NSFont systemFontOfSize: NSFont.systemFontSize];
label.textColor = [NSColor controlTextColor];
label.lineBreakMode = NSLineBreakByTruncatingTail;
// label.backgroundColor = [NSColor yellowColor];
// label.drawsBackground = YES;
return [label sizeWidthToFit];
}
/// Create input text field with placeholder text. @c 21px height.
+ (NSTextField*)inputField:(NSString*)placeholder width:(CGFloat)w {
NSTextField *input = [NSTextField textFieldWithString:@""];
[input setFrameSize: NSMakeSize(w, HEIGHT_INPUTFIELD)];
input.alignment = NSTextAlignmentJustified;
input.placeholderString = placeholder;
input.font = [NSFont systemFontOfSize: NSFont.systemFontSize];
input.textColor = [NSColor controlTextColor];
return input;
}
/// Create view with @c NSTextField subviews with right-aligned and row-centered text from @c labels.
+ (NSView*)labelColumn:(NSArray<NSString*>*)labels rowHeight:(CGFloat)h padding:(CGFloat)pad {
CGFloat w = 0, y = 0;
CGFloat off = (h - HEIGHT_LABEL) / 2;
NSView *parent = [[NSView alloc] init];
for (NSUInteger i = 0; i < labels.count; i++) {
NSTextField *lbl = [[NSView label:labels[i]] placeIn:parent xRight:0 yTop:y + off];
w = Max(w, NSWidth(lbl.frame));
y += h + pad;
}
[parent setFrameSize: NSMakeSize(w, y - pad)];
return parent;
}
#pragma mark - UI: Buttons -
/// Create button. @c 21px height.
+ (NSButton*)button:(NSString*)text {
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 0, HEIGHT_BUTTON)];
btn.font = [NSFont systemFontOfSize:NSFont.systemFontSize];
btn.bezelStyle = NSBezelStyleRounded;
btn.title = text;
return [btn sizeWidthToFit];
}
/// Create @c NSBezelStyleSmallSquare image button. @c 25x21px
+ (NSButton*)buttonImageSquare:(nonnull NSImageName)name {
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 25, HEIGHT_BUTTON)];
btn.bezelStyle = NSBezelStyleSmallSquare;
btn.image = [NSImage imageNamed:name];
if (!btn.image) btn.title = name; // fallback to text
return btn;
}
/// Create pure image button with no border.
+ (NSButton*)buttonIcon:(nonnull NSImageName)name size:(CGFloat)size {
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, size, size)];
btn.bezelStyle = NSBezelStyleRounded;
btn.bordered = NO;
btn.image = [NSImage imageNamed:name];
return btn;
}
/// Create round button with question mark. @c 21x21px
+ (NSButton*)helpButton {
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 21, 21)];
btn.bezelStyle = NSBezelStyleHelpButton;
btn.title = @"";
return btn;
}
/// Create gray inline button with rounded corners. @c 16px height.
+ (NSButton*)inlineButton:(NSString*)text {
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 0, HEIGHT_INLINEBUTTON)];
btn.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight: NSFontWeightBold];
btn.bezelStyle = NSBezelStyleInline;
btn.controlSize = NSControlSizeSmall;
btn.title = text;
return [btn sizeWidthToFit];
}
/// Create empty drop down button. @c 21px height.
+ (NSPopUpButton*)popupButton:(CGFloat)w {
return [[NSPopUpButton alloc] initWithFrame: NSMakeRect(0, 0, w, HEIGHT_POPUP) pullsDown:NO];
}
#pragma mark - UI: Others -
/// Create @c ImageView with square @c size
+ (NSImageView*)imageView:(NSImageName)name size:(CGFloat)size {
NSImageView *imgView = [[NSImageView alloc] initWithFrame: NSMakeRect(0, 0, size, size)];
if (name) imgView.image = [NSImage imageNamed:name];
return imgView;
}
/// Create checkbox. @c 14px height.
+ (NSButton*)checkbox:(BOOL)flag {
NSButton *check = [NSButton checkboxWithTitle:@"" target:nil action:nil];
check.title = @""; // needed, otherwise will print "Button"
check.frame = NSMakeRect(0, 0, HEIGHT_CHECKBOX, HEIGHT_CHECKBOX);
check.state = (flag? NSControlStateValueOn : NSControlStateValueOff);
return check;
}
/// Create progress spinner. @c 16px size.
+ (NSProgressIndicator*)activitySpinner {
NSProgressIndicator *spin = [[NSProgressIndicator alloc] initWithFrame: NSMakeRect(0, 0, HEIGHT_SPINNER, HEIGHT_SPINNER)];
spin.indeterminate = YES;
spin.displayedWhenStopped = NO;
spin.style = NSProgressIndicatorStyleSpinning;
spin.controlSize = NSControlSizeSmall;
return spin;
}
/// Create grouping view with vertically, left-aligned radio buttons. Action is identical for all buttons (grouping).
+ (NSView*)radioGroup:(NSArray<NSString*>*)entries target:(id)target action:(nonnull SEL)action {
if (entries.count == 0)
return nil;
CGFloat w = 0, h = 0;
NSView *parent = [[NSView alloc] init];
for (NSUInteger i = entries.count; i > 0; i--) {
NSButton *btn = [NSButton radioButtonWithTitle:entries[i-1] target:target action:action];
btn.tag = (NSInteger)i-1;
if (btn.tag == 0)
btn.state = NSControlStateValueOn;
w = Max(w, NSWidth(btn.frame)); // find max width (before alignmentRect:)
[btn placeIn:parent x:0 y:h];
h += NSHeight([btn alignmentRectForFrame:btn.frame]) + PAD_XS;
}
[parent setFrameSize: NSMakeSize(w, h - PAD_XS)];
return parent;
}
/// Same as @c radioGroup:target:action: but using dummy action to ignore radio button click events.
+ (NSView*)radioGroup:(NSArray<NSString*>*)entries {
return [self radioGroup:entries target:self action:@selector(donothing)];
}
/// Solely used to group radio buttons
+ (void)donothing {}
#pragma mark - UI: Enclosing Container -
/// Create transient popover with initial view controller and view @c size
+ (NSPopover*)popover:(NSSize)size {
NSPopover *pop = [[NSPopover alloc] init];
pop.behavior = NSPopoverBehaviorTransient;
pop.contentViewController = [[NSViewController alloc] init];
pop.contentViewController.view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, size.width, size.height)];
return pop;
}
/// Insert @c scrollView, remove @c self from current view and set as @c documentView for the newly created scroll view.
- (NSScrollView*)wrapContent:(NSView*)content inScrollView:(NSRect)rect {
NSScrollView *scroll = [[[NSScrollView alloc] initWithFrame:rect] sizableWidthAndHeight];
scroll.borderType = NSBezelBorder;
scroll.hasVerticalScroller = YES;
scroll.horizontalScrollElasticity = NSScrollElasticityNone;
[self addSubview:scroll];
if (content.superview) [content removeFromSuperview]; // remove if added already (e.g., helper methods above)
content.frame = NSMakeRect(0, 0, scroll.contentSize.width, scroll.contentSize.height);
scroll.documentView = content;
return scroll;
}
/// Create view with @c NSTextField label in front of the view.
+ (NSView*)wrapView:(NSView*)other withLabel:(NSString*)str padding:(CGFloat)pad {
NSView *parent = [[NSView alloc] initWithFrame: NSZeroRect];
NSTextField *label = [NSView label:str];
[label placeIn:parent x:pad yTop:pad];
[other placeIn:parent x:pad + NSWidth(label.frame) yTop:pad];
[parent setFrameSize: NSMakeSize(NSMaxX(other.frame), NSHeight(other.frame) + 2 * pad)];
return parent;
}
#pragma mark - Insert UI elements in parent view -
/**
Set frame origin and insert @c self in @c parent view with @c frameForAlignmentRect:.
You may use @c CENTER to automatically calculate midpoint in parent view.
The @c autoresizingMask will be set accordingly.
*/
- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x y:(CGFloat)y {
if (x == CENTER) {
x = (NSWidth(parent.frame) - NSWidth(self.frame)) / 2;
self.autoresizingMask |= NSViewMinXMargin | NSViewMaxXMargin;
}
if (y == CENTER) {
y = (NSHeight(parent.frame) - NSHeight(self.frame)) / 2;
self.autoresizingMask |= NSViewMinYMargin | NSViewMaxYMargin;
}
[self setFrameOrigin: NSMakePoint(x, y)];
self.frame = [self frameForAlignmentRect:self.frame];
[parent addSubview:self];
return self;
}
/// Same as @c placeIn:x:y: but measure position from top instead of bottom. Also sets @c autoresizingMask.
- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x yTop:(CGFloat)y {
return [[self placeIn:parent x:x y:NSHeight(parent.frame) - NSHeight(self.frame) - y] alignTop];
}
/// Same as @c placeIn:x:y: but measure position from right instead of left. Also sets @c autoresizingMask.
- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x y:(CGFloat)y {
return [[self placeIn:parent x:NSWidth(parent.frame) - NSWidth(self.frame) - x y:y] alignRight];
}
/// Set origin by measuring from top right (@c CENTER is not allowed here). Also sets @c autoresizingMask.
- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x yTop:(CGFloat)y {
[self setFrameOrigin: NSMakePoint(NSWidth(parent.frame) - NSWidth(self.frame) - x,
NSHeight(parent.frame) - NSHeight(self.frame) - y)];
self.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin;
self.frame = [self frameForAlignmentRect:self.frame];
[parent addSubview:self];
return self;
}
#pragma mark - Modify existing UI elements -
// Aligned Frame Origins
// pad - view.alignmentRectInsets.left;
// pad - view.alignmentRectInsets.bottom;
// NSWidth(view.superview.frame) - NSWidth(view.frame) - pad + view.alignmentRectInsets.right;
// NSHeight(view.superview.frame) - NSHeight(view.frame) - pad + view.alignmentRectInsets.top;
/// Modify @c .autoresizingMask; Clear @c NSViewMaxYMargin flag and set @c NSViewMinYMargin
- (instancetype)alignTop { self.autoresizingMask = (self.autoresizingMask & ~NSViewMaxYMargin) | NSViewMinYMargin; return self; }
/// Modify @c .autoresizingMask; Clear @c NSViewMaxXMargin flag and set @c NSViewMinXMargin
- (instancetype)alignRight { self.autoresizingMask = (self.autoresizingMask & ~NSViewMaxXMargin) | NSViewMinXMargin; return self; }
/// Modify @c .autoresizingMask; Add @c NSViewWidthSizable @c | @c NSViewHeightSizable flags
- (instancetype)sizableWidthAndHeight { self.autoresizingMask |= NSViewWidthSizable | NSViewHeightSizable; return self; }
/// Extend frame in its @c superview and stick to right with padding. Adds @c NSViewWidthSizable to @c autoresizingMask
- (instancetype)sizeToRight:(CGFloat)rightPadding {
SetFrameWidth(self, NSWidth(self.superview.frame) - NSMinX(self.frame) - rightPadding + self.alignmentRectInsets.right);
self.autoresizingMask |= NSViewWidthSizable;
return self;
}
/// Set @c width to @c fittingSize.width but keep original height.
- (instancetype)sizeWidthToFit {
SetFrameWidth(self, self.fittingSize.width);
return self;
}
/// Set @c tooltip and @c accessibilityTitle of view and return self
- (instancetype)tooltip:(NSString*)tt {
self.toolTip = tt;
if (self.accessibilityLabel.length == 0)
self.accessibilityLabel = tt;
else
self.accessibilityValueDescription = tt;
return self;
}
/// Helper method to set frame width and keep same height
static inline void SetFrameWidth(NSView *view, CGFloat w) {
[view setFrameSize: NSMakeSize(w, NSHeight(view.frame))];
}
#pragma mark - Debugging -
/// Set background color on @c .layer
- (instancetype)colorLayer:(NSColor*)color {
self.layer = [CALayer layer];
self.layer.backgroundColor = color.CGColor;
return self;
}
+ (NSView*)redCube:(CGFloat)size {
return [[[NSView alloc] initWithFrame: NSMakeRect(0, 0, size, size)] colorLayer:NSColor.redColor];
}
@end
#pragma mark - NSControl specific -
@implementation NSControl (Ext)
/// Set @c target and @c action simultaneously
- (instancetype)action:(SEL)selector target:(id)target {
self.action = selector;
self.target = target;
return self;
}
/// Set system font with current @c pointSize @c + @c 2. A label will be @c 19px height.
- (instancetype)large { SetFontAndResize(self, [NSFont systemFontOfSize: self.font.pointSize + 2]); return self; }
/// Set system font with @c smallSystemFontSize and perform @c sizeToFit. A label will be @c 14px height.
- (instancetype)small { SetFontAndResize(self, [NSFont systemFontOfSize: NSFont.smallSystemFontSize]); return self; }
/// Set monospaced font with @c labelFontSize regular and perform @c sizeToFit. A label will be @c 13px height.
- (instancetype)tiny { SetFontAndResize(self, [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight: NSFontWeightRegular]); return self; }
/// Set system bold font with current @c pointSize
- (instancetype)bold { SetFontAndResize(self, [NSFont boldSystemFontOfSize: self.font.pointSize]); return self; }
/// Set @c .alignment to @c NSTextAlignmentRight
- (instancetype)textRight { self.alignment = NSTextAlignmentRight; return self; }
/// Set @c .alignment to @c NSTextAlignmentCenter
- (instancetype)textCenter { self.alignment = NSTextAlignmentCenter; return self; }
/// Helper method to set new font, subsequently run @c sizeToFit
static inline void SetFontAndResize(NSControl *control, NSFont *font) {
control.font = font; [control sizeToFit];
}
@end
@implementation NSTextField (Ext)
/// Set text color to @c systemGrayColor
- (instancetype)gray { self.textColor = [NSColor systemGrayColor]; return self; }
/// Set @c .selectable to @c YES
- (instancetype)selectable { self.selectable = YES; return self; }
/// Set @c .maximumNumberOfLines @c = @c 7 and @c preferredMaxLayoutWidth.
- (instancetype)multiline:(NSSize)size {
[self setFrameSize:size];
self.preferredMaxLayoutWidth = size.width;
self.lineBreakMode = NSLineBreakByWordWrapping;
self.usesSingleLineMode = NO;
self.maximumNumberOfLines = 7; // used in ModalFeedEditView
return self;
}
@end

View File

@@ -0,0 +1,26 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@interface SettingsAbout : NSViewController
@end

View File

@@ -1,6 +1,6 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
@@ -20,17 +20,13 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Foundation/Foundation.h>
#import "SettingsAbout.h"
#import "SettingsAboutView.h"
@interface UserPrefs : NSObject
+ (BOOL)defaultYES:(NSString*)key;
+ (BOOL)defaultNO:(NSString*)key;
@implementation SettingsAbout
+ (NSString*)getHttpApplication;
+ (void)setHttpApplication:(NSString*)bundleID;
+ (void)openURLsWithPreferredBrowser:(NSArray<NSURL*>*)urls;
- (void)loadView {
self.view = [SettingsAboutView new];
}
+ (NSUInteger)openFewLinksLimit; // Change with: 'defaults write de.relikd.baRSS openFewLinksLimit -int 10'
+ (NSUInteger)shortArticleNamesLimit; // Change with: 'defaults write de.relikd.baRSS shortArticleNamesLimit -int 50'
+ (NSUInteger)articlesInMenuLimit; // Change with: 'defaults write de.relikd.baRSS articlesInMenuLimit -int 40'
@end

View File

@@ -0,0 +1,27 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@interface SettingsAboutView : NSView
@end

View File

@@ -0,0 +1,89 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "SettingsAboutView.h"
#import "NSView+Ext.h"
@implementation SettingsAboutView
- (instancetype)init {
self = [super initWithFrame: NSZeroRect];
NSDictionary *info = [[NSBundle mainBundle] infoDictionary];
NSString *version = [NSString stringWithFormat:NSLocalizedString(@"Version %@", nil), info[@"CFBundleShortVersionString"]];
#if DEBUG // append build number, e.g., '0.9.4 (9906)'
version = [version stringByAppendingFormat:@" (%@)", info[@"CFBundleVersion"]];
#endif
// Application icon image (top-centered)
NSImageView *logo = [[NSView imageView:NSImageNameApplicationIcon size:64] placeIn:self x:CENTER yTop:PAD_M];
// Add app name
NSTextField *lblN = [[[[NSView label:APP_NAME] large] bold] placeIn:self x:CENTER yTop: YFromTop(logo) + PAD_M];
// Add version info
NSTextField *lblV = [[[[NSView label:version] small] selectable] placeIn:self x:CENTER yTop: YFromTop(lblN) + PAD_S];
// Add rtf document
NSTextView *tv = [[NSTextView new] sizableWidthAndHeight];
tv.textContainerInset = NSMakeSize(0, 15);
tv.alignment = NSTextAlignmentCenter;
tv.editable = NO; // but selectable
[tv.textStorage setAttributedString:[self rtfDocument]];
[self wrapContent:tv inScrollView:NSMakeRect(-1, 20, NSWidth(self.frame) + 2, NSMinY(lblV.frame) - PAD_M - 20)];
return self;
}
/// Construct attributed string by concatenating snippets of text.
- (NSMutableAttributedString*)rtfDocument {
NSMutableAttributedString *mas = [NSMutableAttributedString new];
[mas beginEditing];
[self str:mas add:@"Programming\n" bold:YES];
[self str:mas add:@"Oleg Geier\n\n" bold:NO];
[self str:mas add:@"Source Code Available\n" bold:YES];
[self str:mas add:@"github.com" link:@"https://github.com/relikd/baRSS"];
[self str:mas add:@" (MIT License)\nor " bold:NO];
[self str:mas add:@"gitlab.com" link:@"https://gitlab.com/relikd/baRSS"];
[self str:mas add:@" (MIT License)\n\n" bold:NO];
[self str:mas add:@"3rd-Party Libraries\n" bold:YES];
[self str:mas add:@"RSXML2" link:@"https://github.com/relikd/RSXML2"];
[self str:mas add:@" (MIT License)" bold:NO];
[self str:mas add:@"\n\n\n\nOptions\n" bold:YES];
[self str:mas add:@"Fix Cache\n" link:@"barss:config/fixcache"];
[self str:mas add:@"Backup now\n" link:@"barss:backup/show"];
[mas endEditing];
return mas;
}
/// Helper method to insert attributed (bold) text
- (void)str:(NSMutableAttributedString*)parent add:(NSString*)text bold:(BOOL)flag {
NSFont *font = [NSFont systemFontOfSize:NSFont.systemFontSize weight:(flag ? NSFontWeightMedium : NSFontWeightLight)];
[parent appendAttributedString:[[NSAttributedString alloc] initWithString:NonLocalized(text) attributes:@{ NSFontAttributeName : font }]];
}
/// Helper method to insert attributed hyperlink text
- (void)str:(NSMutableAttributedString*)parent add:(NSString*)text link:(NSString*)url {
[self str:parent add:text bold:NO];
[parent addAttribute:NSLinkAttributeName value:url range:NSMakeRange(parent.length - text.length, text.length)];
}
__attribute__((annotate("returns_localized_nsstring")))
static inline NSString *NonLocalized(NSString *s) { return s; }
@end

View File

@@ -0,0 +1,27 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@interface SettingsAppearance : NSViewController
- (void)didSelectCheckbox:(NSButton*)sender;
@end

View File

@@ -0,0 +1,52 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "SettingsAppearance.h"
#import "SettingsAppearanceView.h"
#import "AppHook.h"
#import "BarStatusItem.h"
#import "UserPrefs.h"
@implementation SettingsAppearance
- (void)loadView {
self.view = [SettingsAppearanceView new];
for (NSButton *button in self.view.subviews) {
if ([button isKindOfClass:[NSButton class]]) { // for all checkboxes
[button setAction:@selector(didSelectCheckbox:)];
[button setTarget:self];
}
}
}
#pragma mark - Checkbox Callback Method
/// Sync new value with UserDefaults and update status bar icon
- (void)didSelectCheckbox:(NSButton*)sender {
NSString *pref = sender.identifier;
UserPrefsSetBool(pref, (sender.state == NSControlStateValueOn));
if (pref == Pref_globalUnreadCount || pref == Pref_globalTintMenuIcon) { // == because static string
[[(AppHook*)NSApp statusItem] updateBarIcon];
}
}
@end

View File

@@ -0,0 +1,28 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class SettingsAppearance;
@interface SettingsAppearanceView : NSView
@end

View File

@@ -0,0 +1,85 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "SettingsAppearanceView.h"
#import "NSView+Ext.h"
#import "Constants.h" // column icons
#import "UserPrefs.h" // preference constants & UserPrefsBool()
@interface SettingsAppearanceView()
@property (assign) CGFloat y;
@end
/***/ static CGFloat const IconSize = 18;
/***/ static CGFloat const colWidth = (IconSize + PAD_M); // checkbox column width
/***/ static CGFloat const X__ = PAD_WIN + 0 * colWidth;
/***/ static CGFloat const _X_ = PAD_WIN + 1 * colWidth;
/***/ static CGFloat const __X = PAD_WIN + 2 * colWidth;
@implementation SettingsAppearanceView
- (instancetype)init {
self = [super initWithFrame: NSZeroRect];
// Insert matrix header (icons above checkbox matrix)
ColumnIcon(self, X__, RSSImageSettingsGlobal, NSLocalizedString(@"Show in menu bar", nil));
ColumnIcon(self, _X_, RSSImageSettingsGroup, NSLocalizedString(@"Show in group menu", nil));
ColumnIcon(self, __X, RSSImageSettingsFeed, NSLocalizedString(@"Show in feed menu", nil));
// Generate checkbox matrix
self.y = PAD_WIN + IconSize + PAD_S;
[self entry:NSLocalizedString(@"Tint menu bar icon on unread", nil) c1:Pref_globalTintMenuIcon c2:nil c3:nil];
[self entry:NSLocalizedString(@"Update all feeds", nil) c1:Pref_globalUpdateAll c2:nil c3:nil];
[self entry:NSLocalizedString(@"Open all unread", nil) c1:Pref_globalOpenUnread c2:Pref_groupOpenUnread c3:Pref_feedOpenUnread];
[self entry:NSLocalizedString(@"Mark all read", nil) c1:Pref_globalMarkRead c2:Pref_groupMarkRead c3:Pref_feedMarkRead];
[self entry:NSLocalizedString(@"Mark all unread", nil) c1:Pref_globalMarkUnread c2:Pref_groupMarkUnread c3:Pref_feedMarkUnread];
[self entry:NSLocalizedString(@"Number of unread articles", nil) c1:Pref_globalUnreadCount c2:Pref_groupUnreadCount c3:Pref_feedUnreadCount];
[self entry:NSLocalizedString(@"Indicator for unread articles", nil) c1:nil c2:Pref_groupUnreadIndicator c3:Pref_feedUnreadIndicator];
[[self entry:NSLocalizedString(@"Truncate article title", nil) c1:nil c2:nil c3:Pref_feedTruncateTitle]
tooltip:NSLocalizedString(@"Truncate article title after 60 characters", nil)];
[[self entry:NSLocalizedString(@"Limit number of articles", nil) c1:nil c2:nil c3:Pref_feedLimitArticles]
tooltip:NSLocalizedString(@"Display at most 40 articles in feed menu", nil)];
return self;
}
/// Helper method for matrix table header icons
static inline void ColumnIcon(id this, CGFloat x, const NSImageName img, NSString *ttip) {
[[[NSView imageView:img size:IconSize] placeIn:this x:x yTop:PAD_WIN] tooltip:ttip];
}
/// Helper method for generating a checkbox
static inline NSButton* Checkbox(id this, CGFloat x, CGFloat y, NSString *key) {
NSButton *check = [[NSView checkbox: UserPrefsBool(key)] placeIn:this x:x yTop:y];
check.identifier = key;
return check;
}
/// Create new entry with 1-3 checkboxes and a descriptive label
- (NSTextField*)entry:(NSString*)label c1:(NSString*)pref1 c2:(NSString*)pref2 c3:(NSString*)pref3 {
CGFloat y = self.y;
self.y += (PAD_S + HEIGHT_LABEL);
// TODO: localize: global, group, feed
if (pref1) Checkbox(self, X__ + 2, y + 2, pref1).accessibilityLabel = [label stringByAppendingString:@" (global)"];
if (pref2) Checkbox(self, _X_ + 2, y + 2, pref2).accessibilityLabel = [label stringByAppendingString:@" (group)"];
if (pref3) Checkbox(self, __X + 2, y + 2, pref3).accessibilityLabel = [label stringByAppendingString:@" (feed)"];
return [[[NSView label:label] placeIn:self x:PAD_WIN + 3 * colWidth yTop:y] sizeToRight:PAD_WIN];
}
@end

View File

@@ -20,10 +20,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
@import Cocoa;
#import "ModalSheet.h"
@class FeedGroup;
@class FeedGroup, ModalSheet;
@interface ModalEditDialog : NSViewController
+ (instancetype)modalWith:(FeedGroup*)group;
@@ -33,6 +32,7 @@
@interface ModalFeedEdit : ModalEditDialog <NSTextFieldDelegate>
- (void)didClickWarningButton:(NSButton*)sender;
@end
@interface ModalGroupEdit : ModalEditDialog

View File

@@ -20,18 +20,25 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import RSXML2;
#import "ModalFeedEdit.h"
#import "ModalFeedEditView.h"
#import "RefreshStatisticsView.h"
#import "Constants.h"
#import "FeedDownload.h"
#import "StoreCoordinator.h"
#import "FaviconDownload.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import "Statistics.h"
#import "NSView+Ext.h"
#import "NSDate+Ext.h"
#import "NSURL+Ext.h"
#pragma mark - ModalEditDialog -
// ################################################################
// #
// # MARK: - ModalEditDialog -
// #
// ################################################################
@interface ModalEditDialog() <NSWindowDelegate>
@property (strong) FeedGroup *feedGroup;
@@ -60,57 +67,50 @@
}
@end
// ################################################################
// #
// # MARK: - ModalFeedEdit -
// #
// ################################################################
#pragma mark - ModalFeedEdit -
@interface ModalFeedEdit() <RefreshIntervalButtonDelegate>
@property (weak) IBOutlet NSTextField *url;
@property (weak) IBOutlet NSTextField *name;
@property (weak) IBOutlet NSTextField *refreshNum;
@property (weak) IBOutlet NSPopUpButton *refreshUnit;
@property (weak) IBOutlet NSProgressIndicator *spinnerURL;
@property (weak) IBOutlet NSProgressIndicator *spinnerName;
@property (weak) IBOutlet NSButton *warningIndicator;
@property (weak) IBOutlet NSPopover *warningPopover;
@property (strong) NSView *statisticsView;
@interface ModalFeedEdit() <FeedDownloadDelegate, RefreshIntervalButtonDelegate, FaviconDownloadDelegate>
@property (strong) IBOutlet ModalFeedEditView *view; // override
@property (copy) NSString *previousURL; // check if changed and avoid multiple download
@property (copy) NSString *httpDate;
@property (copy) NSString *httpEtag;
@property (copy) NSString *faviconURL;
@property (strong) NSImage *favicon;
@property (strong) NSError *feedError; // download error or xml parser error
@property (strong) RSParsedFeed *feedResult; // parsed result
@property (assign) BOOL didDownloadFeed; // check if feed articles need update
@property (strong) NSURL *faviconFile;
@property (strong) FeedDownload *memFeed;
@property (weak) FaviconDownload *memIcon;
@property (strong) RefreshStatisticsView *statisticsView;
@end
@implementation ModalFeedEdit
@dynamic view;
/// Init feed edit dialog with default values.
- (void)viewDidLoad {
[super viewDidLoad];
- (void)loadView {
self.view = [[ModalFeedEditView alloc] initWithController:self];
self.previousURL = @"";
self.refreshNum.intValue = 30;
[NSDate populateUnitsMenu:self.refreshUnit selected:TimeUnitMinutes];
self.warningIndicator.image = nil;
[self.warningIndicator.cell setHighlightsBy:NSNoCellMask];
self.view.refreshNum.intValue = 30;
[NSDate populateUnitsMenu:self.view.refreshUnit selected:TimeUnitMinutes];
[self populateTextFields:self.feedGroup];
}
/**
Pre-fill UI control field values with @c FeedGroup properties.
*/
/// Pre-fill UI control field values with @c FeedGroup properties.
- (void)populateTextFields:(FeedGroup*)fg {
if (!fg || [fg hasChanges]) return; // hasChanges is true only if newly created
self.name.objectValue = fg.name;
self.url.objectValue = fg.feed.meta.url;
self.previousURL = self.url.stringValue;
self.warningIndicator.image = [fg.feed iconImage16];
[NSDate setInterval:fg.feed.meta.refresh forPopup:self.refreshUnit andField:self.refreshNum animate:NO];
self.view.name.objectValue = fg.name; // user given feed title
self.view.name.placeholderString = fg.feed.title; // actual feed title
self.view.url.objectValue = fg.feed.meta.url;
self.previousURL = self.view.url.stringValue;
self.view.favicon.image = [fg.feed iconImage16];
[NSDate setInterval:fg.feed.meta.refresh forPopup:self.view.refreshUnit andField:self.view.refreshNum animate:NO];
[self statsForCoreDataObject];
}
- (void)dealloc {
[self.faviconFile remove]; // Delete temporary favicon (if still exists)
}
#pragma mark - Edit Feed Data
/**
@@ -118,64 +118,42 @@
Set @c scheduled to a new date if refresh interval was changed.
*/
- (void)applyChangesToCoreDataObject {
Feed *feed = self.feedGroup.feed;
[self.feedGroup setNameIfChanged:self.name.stringValue];
FeedMeta *meta = feed.meta;
[meta setUrlIfChanged:self.previousURL];
[meta setRefreshAndSchedule:[NSDate intervalForPopup:self.refreshUnit andField:self.refreshNum]];
// updateTimer will be scheduled once preferences is closed
if (self.didDownloadFeed) {
[meta setEtag:self.httpEtag modified:self.httpDate];
[feed updateWithRSS:self.feedResult postUnreadCountChange:YES];
[feed setIconImage:self.favicon];
Feed *f = self.feedGroup.feed;
Interval intv = [NSDate intervalForPopup:self.view.refreshUnit andField:self.view.refreshNum];
[self.feedGroup setNameIfChanged:self.view.name.stringValue];
[f.meta setRefreshIfChanged:intv];
if (self.memFeed) {
[self.memFeed copyValuesTo:f ignoreError:YES];
[f setNewIcon:self.faviconFile]; // only if downloaded anything (nil deletes icon!)
self.faviconFile = nil;
}
}
/**
Prepare UI (nullify @c result, @c error and start @c ProgressIndicator).
Also disable 'Done' button during download and re-enable after all downloads are finished.
*/
- (void)preDownload {
[self.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download
[self.spinnerURL startAnimation:nil];
[self.spinnerName startAnimation:nil];
self.warningIndicator.image = nil;
self.didDownloadFeed = NO;
// Assuming the user has not changed title since the last fetch.
// Reset to "" because after download it will be pre-filled with new feed title
if ([self.name.stringValue isEqualToString:self.feedResult.title]) {
self.name.stringValue = @"";
}
self.feedResult = nil;
self.feedError = nil;
self.httpEtag = nil;
self.httpDate = nil;
self.favicon = nil;
self.faviconURL = nil;
/// Cancel any running download task and free volatile variables
- (void)cancelDownloads {
[self.memFeed cancel]; self.memFeed = nil;
[self.memIcon cancel]; self.memIcon = nil;
[self.faviconFile remove]; self.faviconFile = nil;
}
/**
All properties will be parsed and stored in class variables.
This should avoid unnecessary core data operations if user decides to cancel the edit.
The save operation will only be executed if user clicks on the 'OK' button.
Prepare UI (nullify results and start @c ProgressIndicator ).
Also disable 'Done' button during download and re-enable after download is finished.
*/
- (void)downloadRSS {
if (self.modalSheet.didCloseAndCancel)
return;
[self preDownload];
[FeedDownload newFeed:self.previousURL askUser:^NSString *(RSHTMLMetadata *meta) {
self.faviconURL = [FeedDownload faviconUrlForMetadata:meta]; // we can re-use favicon url if we find one
return [self letUserChooseXmlUrlFromList:meta.feedLinks];
} block:^(RSParsedFeed *result, NSError *error, NSHTTPURLResponse* response) {
if (self.modalSheet.didCloseAndCancel)
return;
self.didDownloadFeed = YES;
self.feedResult = result;
self.feedError = error;
self.httpEtag = [response allHeaderFields][@"Etag"];
self.httpDate = [response allHeaderFields][@"Date"]; // @"Expires", @"Last-Modified"
[self postDownload:response.URL.absoluteString];
}];
[self cancelDownloads];
[self.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download
[self.view.spinnerURL startAnimation:nil];
[self.view.spinnerName startAnimation:nil];
self.view.favicon.image = nil;
self.view.warningButton.hidden = YES;
// User didn't change title since last fetch. Will be pre-filled with new title after download
if ([self.view.name.stringValue isEqualToString:self.view.name.placeholderString]) {
self.view.name.stringValue = @"";
self.view.name.placeholderString = NSLocalizedString(@"Loading …", nil);
}
self.previousURL = self.view.url.stringValue;
self.memFeed = [[FeedDownload withURL:self.previousURL] startWithDelegate:self];
}
/**
@@ -184,16 +162,14 @@
@return Either URL string or @c nil if user canceled the selection.
*/
- (NSString*)letUserChooseXmlUrlFromList:(NSArray<RSHTMLMetadataFeedLink*> *)list {
if (list.count == 1) // nothing to choose
return list.firstObject.link;
- (NSString*)feedDownload:(FeedDownload*)sender selectFeedFromList:(NSArray<RSHTMLMetadataFeedLink*>*)list {
NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Choose feed menu", nil)];
menu.autoenablesItems = NO;
for (RSHTMLMetadataFeedLink *fl in list) {
[menu addItemWithTitle:fl.title action:nil keyEquivalent:@""];
}
NSPoint belowURL = NSMakePoint(0,self.url.frame.size.height);
if ([menu popUpMenuPositioningItem:nil atLocation:belowURL inView:self.url]) {
NSPoint belowURL = NSMakePoint(0, NSHeight(self.view.url.frame));
if ([menu popUpMenuPositioningItem:nil atLocation:belowURL inView:self.view.url]) {
NSInteger idx = [menu indexOfItem:menu.highlightedItem];
if (idx < 0) idx = 0; // User hit enter without selection. Assume first item, because PopUpMenu did return YES!
return [list objectAtIndex:(NSUInteger)idx].link;
@@ -201,70 +177,69 @@
return nil; // user selection canceled
}
/**
Update UI TextFields with downloaded values.
Title will be updated if TextField is empty. URL on redirect.
Finally begin favicon download and return control to user (enable 'Done' button).
*/
- (void)postDownload:(NSString*)responseURL {
if (self.modalSheet.didCloseAndCancel)
return;
// 1. Stop spinner animation for name field. (keep spinner for URL running until favicon downloaded)
[self.spinnerName stopAnimation:nil];
// 2. If URL was redirected, replace original text field value with new one. (e.g., https redirect)
if (responseURL.length > 0 && ![responseURL isEqualToString:self.previousURL]) {
self.previousURL = responseURL;
self.url.stringValue = responseURL;
/// If URL was redirected, replace original text field value with new one. (e.g., https redirect)
- (void)feedDownload:(FeedDownload*)sender urlRedirected:(NSString*)newURL {
if (!sender.error) {
// If the url has changed and there is an error:
// This probably means the feed URL was resolved, but the successive download returned 5xx error.
// Presumably to prevent site crawlers accessing many pages in quick succession. (delay of 1s does help)
// By not setting previousURL, a second hit on the 'Done' button will retry the resolved URL again.
self.previousURL = newURL;
}
// 3. Copy parsed feed title to text field. (only if user hasn't set anything else yet)
NSString *parsedTitle = self.feedResult.title;
if (parsedTitle.length > 0 && [self.name.stringValue isEqualToString:@""]) {
self.name.stringValue = parsedTitle; // no damage to replace an empty string
self.view.url.stringValue = newURL;
}
/// Update UI TextFields with downloaded values. Title updated if TextField is empty, URL if redirect.
- (void)feedDownloadDidFinish:(FeedDownload*)sender {
// Stop spinner for name field but keep running for URL until favicon downloaded
[self.view.spinnerName stopAnimation:nil];
NSString *newTitle = sender.xmlfeed.title;
self.view.name.placeholderString = newTitle;
if (newTitle.length > 0 && self.view.name.stringValue.length == 0) {
self.view.name.stringValue = newTitle; // only if default title wasn't changed
}
// TODO: user preference to automatically select refresh interval (selection: None,min,max,avg,median)
[self statsForDownloadObject];
// 4. Continue with favicon download (or finish with error)
if (self.feedError) {
[self finishDownloadWithFavicon:[NSImage imageNamed:NSImageNameCaution]];
} else {
if (!self.faviconURL)
self.faviconURL = self.feedResult.link;
if (self.faviconURL.length == 0)
self.faviconURL = responseURL;
[FeedDownload downloadFavicon:self.faviconURL finished:^(NSImage * _Nullable img) {
if (self.modalSheet.didCloseAndCancel)
return;
self.favicon = img;
[self finishDownloadWithFavicon:img];
}];
}
[self statsForDownloadObject:sender.xmlfeed.articles];
BOOL hasError = (sender.error != nil);
self.view.favicon.hidden = hasError;
self.view.warningButton.hidden = !hasError;
// Start favicon download
if (hasError)
[self downloadComplete];
else
self.memIcon = [[sender faviconDownload] startWithDelegate:self];
}
/**
The last step of the download process.
Stop spinning animation set favivon image preview (right of url bar) and re-enable 'Done' button.
Stop spinning animation, set favivon image (right of url bar), and re-enable 'Done' button.
*/
- (void)finishDownloadWithFavicon:(NSImage*)img {
if (self.modalSheet.didCloseAndCancel)
return;
[self.warningIndicator.cell setHighlightsBy: (self.feedError ? NSContentsCellMask : NSNoCellMask)];
self.warningIndicator.image = img;
[self.spinnerURL stopAnimation:nil];
- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path {
// Create image from favicon temporary file location or default icon if no favicon exists.
NSImage *img = path ? [[NSImage alloc] initByReferencingURL:path] : [NSImage imageNamed:RSSImageDefaultRSSIcon];
self.view.favicon.image = img;
self.faviconFile = path;
[self downloadComplete];
}
/// Called regardless of favicon download.
- (void)downloadComplete {
[self.view.spinnerURL stopAnimation:nil];
[self.modalSheet setDoneEnabled:YES];
}
#pragma mark - Feed Statistics
/// Perform statistics on newly downloaded feed item
- (void)statsForDownloadObject {
NSMutableArray<NSDate*> *arr = [NSMutableArray arrayWithCapacity:self.feedResult.articles.count];
for (RSParsedArticle *a in self.feedResult.articles) {
- (void)statsForDownloadObject:(NSArray<RSParsedArticle*>*)articles {
NSMutableArray<NSDate*> *arr = [NSMutableArray arrayWithCapacity:articles.count];
for (RSParsedArticle *a in articles) {
NSDate *d = a.datePublished;
if (!d) d = a.dateModified;
if (!d) continue;
[arr addObject:d];
}
[self appendViewWithFeedStatistics:arr count:self.feedResult.articles.count];
[self appendViewWithFeedStatistics:arr count:articles.count];
}
/// Perform statistics on stored core data object
@@ -275,24 +250,22 @@
/// Generate statistics UI with buttons to quickly select refresh unit and duration.
- (void)appendViewWithFeedStatistics:(NSArray*)dates count:(NSUInteger)count {
static const CGFloat statsPadding = 15.f;
CGFloat prevHeight = 0.f;
if (self.statisticsView != nil) {
prevHeight = self.statisticsView.frame.size.height + statsPadding;
prevHeight = NSHeight(self.statisticsView.frame) + PAD_L;
[self.statisticsView removeFromSuperview];
self.statisticsView = nil;
}
NSDictionary *stats = [Statistics refreshInterval:dates];
NSView *v = [Statistics viewForRefreshInterval:stats articleCount:count callback:self];
[[self getModalSheet] extendContentViewBy:v.frame.size.height + statsPadding - prevHeight];
[v setFrameOrigin:NSMakePoint(0.5f*(NSWidth(self.view.frame) - NSWidth(v.frame)), 0)];
[self.view addSubview:v];
self.statisticsView = v;
NSDictionary *stats = [NSDate refreshIntervalStatistics:dates];
RefreshStatisticsView *rsv = [[RefreshStatisticsView alloc] initWithRefreshInterval:stats articleCount:count callback:self];
[[self getModalSheet] extendContentViewBy:NSHeight(rsv.frame) + PAD_L - prevHeight];
self.statisticsView = [rsv placeIn:self.view x:CENTER y:0];
}
/// Callback method for @c Statistics @c +viewForRefreshInterval:articleCount:callback:
/// Callback method @c RefreshStatisticsView
- (void)refreshIntervalButtonClicked:(NSButton *)sender {
[NSDate setInterval:(Interval)sender.tag forPopup:self.refreshUnit andField:self.refreshNum animate:YES];
[NSDate setInterval:(Interval)sender.tag forPopup:self.view.refreshUnit andField:self.view.refreshNum animate:YES];
}
@@ -300,47 +273,60 @@
/// Window delegate will be only called on button 'Done'.
- (BOOL)windowShouldClose:(NSWindow *)sender {
if (![self.previousURL isEqualToString:self.url.stringValue]) {
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.url];
- (BOOL)windowShouldClose:(ModalSheet*)sender {
if (sender.didTapCancel) {
[self cancelDownloads];
} else if (![self.previousURL isEqualToString:self.view.url.stringValue]) { // 'Done' button
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url];
return NO;
}
return YES;
}
/// Whenever the user finished entering the url (return key or focus change) perform a download request.
- (void)controlTextDidEndEditing:(NSNotification *)obj {
if (obj.object == self.url) {
if (![self.previousURL isEqualToString:self.url.stringValue]) {
self.previousURL = self.url.stringValue;
- (void)controlTextDidEndEditing:(NSNotification*)obj {
if (obj.object == self.view.url && !self.modalSheet.didTapCancel) {
if (![self.previousURL isEqualToString:self.view.url.stringValue]) {
[self downloadRSS];
}
}
}
/// Warning button next to url text field. Will be visible if an error occurs during download.
- (IBAction)didClickWarningButton:(NSButton*)sender {
if (!self.feedError)
return;
- (void)didClickWarningButton:(NSButton*)sender {
NSError *err = self.memFeed.error;
if (!err) return;
NSString *str = self.feedError.localizedDescription;
NSTextField *tf = self.warningPopover.contentViewController.view.subviews.firstObject;
tf.maximumNumberOfLines = 7;
tf.objectValue = str;
// show reload button if server is temporarily offline (any 5xx server error)
BOOL serverError = (err.code == NSURLErrorBadServerResponse && err.domain == NSURLErrorDomain);
self.view.warningReload.hidden = !serverError;
NSSize newSize = tf.fittingSize; // width is limited by the textfield's preferred width
newSize.width += 2 * tf.frame.origin.x; // the padding
newSize.height += 2 * tf.frame.origin.y;
// set error description as text
if (serverError)
self.view.warningText.stringValue = [NSString stringWithFormat:@"%@\n\n%@", err.localizedDescription, err.localizedRecoverySuggestion];
else
self.view.warningText.objectValue = err.localizedDescription;
NSSize newSize = self.view.warningText.fittingSize; // width is limited by the textfield's preferred width
newSize.width += 2 * self.view.warningText.frame.origin.x; // the padding
newSize.height += 2 * self.view.warningText.frame.origin.y;
[self.warningPopover showRelativeToRect:sender.bounds ofView:sender preferredEdge:NSRectEdgeMinY];
[self.warningPopover setContentSize:newSize];
// apply fitting size and display
self.view.warningPopover.contentSize = newSize;
[self.view.warningPopover showRelativeToRect:NSZeroRect ofView:sender preferredEdge:NSRectEdgeMinY];
}
/// Either hit by Cmd+R or reload button inside warning popover error description
- (void)reloadData {
[self downloadRSS];
}
@end
#pragma mark - ModalGroupEdit -
// ################################################################
// #
// # MARK: - ModalGroupEdit -
// #
// ################################################################
@implementation ModalGroupEdit
/// Init view and set group name if edeting an already existing object.
@@ -351,41 +337,10 @@
}
/// Set one single @c NSTextField as entire view. Populate with default value and placeholder.
- (void)loadView {
NSTextField *tf = [NSTextField textFieldWithString:NSLocalizedString(@"New Group", nil)];
tf.placeholderString = NSLocalizedString(@"New Group", nil);
tf.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
self.view = tf;
self.view = [[NSView inputField:NSLocalizedString(@"New Group Name", nil) width:0] sizeToRight:0];
}
/// Edit of group finished. Save changes to core data object and perform save operation on delegate.
- (void)applyChangesToCoreDataObject {
[self.feedGroup setNameIfChanged:((NSTextField*)self.view).stringValue];
}
@end
#pragma mark - StrictUIntFormatter -
@interface StrictUIntFormatter : NSFormatter
@end
@implementation StrictUIntFormatter
/// Display object as integer formatted string.
- (NSString *)stringForObjectValue:(id)obj {
return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]];
}
/// Parse any pasted input as integer.
- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error {
*obj = [[NSNumber numberWithInt:[string intValue]] stringValue];
return YES;
}
/// Only digits, no other character allowed
- (BOOL)isPartialStringValid:(NSString *__autoreleasing _Nonnull *)partialStringPtr proposedSelectedRange:(NSRangePointer)proposedSelRangePtr originalString:(NSString *)origString originalSelectedRange:(NSRange)origSelRange errorDescription:(NSString *__autoreleasing _Nullable *)error {
for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) {
unichar c = [*partialStringPtr characterAtIndex:i];
if (c < '0' || c > '9')
return NO;
}
return YES;
}
@end

View File

@@ -1,158 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14313.18" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14313.18"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="ModalFeedEdit">
<connections>
<outlet property="name" destination="ab8-rr-HbK" id="J4T-Zl-KF3"/>
<outlet property="refreshNum" destination="cNl-ht-xws" id="3cA-TW-qi5"/>
<outlet property="refreshUnit" destination="TUi-VS-ge4" id="dr6-GW-gU0"/>
<outlet property="spinnerName" destination="Afo-pQ-8Qx" id="DVx-vd-Zer"/>
<outlet property="spinnerURL" destination="H0a-x4-o4X" id="MgB-RI-yP5"/>
<outlet property="url" destination="Asm-D9-ZfT" id="3gO-Xc-2KJ"/>
<outlet property="view" destination="i0K-k8-GMU" id="qcu-Oh-rOj"/>
<outlet property="warningIndicator" destination="LWE-Y8-ebl" id="j9x-OY-2th"/>
<outlet property="warningPopover" destination="stq-gJ-ra0" id="rJy-GV-PHk"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customView id="i0K-k8-GMU" userLabel="View">
<rect key="frame" x="0.0" y="0.0" width="320" height="79"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="MOX-a1-Yda" userLabel="URL Label">
<rect key="frame" x="-2" y="60" width="103" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="URL" id="6wE-lP-4xC">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField verticalHuggingPriority="750" fixedFrame="YES" textCompletion="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Asm-D9-ZfT">
<rect key="frame" x="107" y="58" width="191" height="21"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" state="on" borderStyle="bezel" placeholderString="https://example.org/feed.rss" drawsBackground="YES" usesSingleLineMode="YES" id="0Sk-H2-VAC">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
<connections>
<outlet property="delegate" destination="-2" id="R3c-aF-If2"/>
</connections>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kVL-HV-oxU" userLabel="Name Label">
<rect key="frame" x="-2" y="31" width="103" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="Name" id="2ls-F4-oUL">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ab8-rr-HbK">
<rect key="frame" x="107" y="29" width="191" height="21"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" state="on" borderStyle="bezel" placeholderString="Example Title" drawsBackground="YES" usesSingleLineMode="YES" id="1ku-vp-T5y">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5Tc-as-s1U" userLabel="Refresh Label">
<rect key="frame" x="-2" y="2" width="103" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="Refresh" id="2IV-ec-RfH">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="cNl-ht-xws">
<rect key="frame" x="107" y="0.0" width="85" height="21"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" state="on" borderStyle="bezel" placeholderString="30" drawsBackground="YES" usesSingleLineMode="YES" id="DqU-fT-cIf">
<customFormatter key="formatter" id="Lbd-r9-4bc" customClass="StrictUIntFormatter"/>
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<popUpButton verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="TUi-VS-ge4">
<rect key="frame" x="198" y="-3" width="125" height="26"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<popUpButtonCell key="cell" type="push" title="-- list --" bezelStyle="rounded" lineBreakMode="truncatingTail" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" autoenablesItems="NO" altersStateOfSelectedItem="NO" selectedItem="lQ1-ai-wYn" id="O0p-Tc-KQ1">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="menu"/>
<menu key="menu" showsStateColumn="NO" autoenablesItems="NO" id="7hX-7Y-rtT">
<items>
<menuItem title="-- list --" id="lQ1-ai-wYn">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
</items>
</menu>
</popUpButtonCell>
</popUpButton>
<progressIndicator wantsLayer="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" maxValue="100" displayedWhenStopped="NO" bezeled="NO" indeterminate="YES" controlSize="small" style="spinning" translatesAutoresizingMaskIntoConstraints="NO" id="H0a-x4-o4X">
<rect key="frame" x="304" y="60" width="16" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMinY="YES"/>
</progressIndicator>
<progressIndicator wantsLayer="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" maxValue="100" displayedWhenStopped="NO" bezeled="NO" indeterminate="YES" controlSize="small" style="spinning" translatesAutoresizingMaskIntoConstraints="NO" id="Afo-pQ-8Qx">
<rect key="frame" x="304" y="31" width="16" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMinY="YES"/>
</progressIndicator>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="LWE-Y8-ebl">
<rect key="frame" x="302" y="60" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="roundRect" bezelStyle="roundedRect" image="NSCaution" imagePosition="only" alignment="center" refusesFirstResponder="YES" state="on" imageScaling="proportionallyDown" inset="2" id="FAw-6c-Vij">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="cellTitle"/>
<string key="keyEquivalent">i</string>
<modifierMask key="keyEquivalentModifierMask" command="YES"/>
</buttonCell>
<connections>
<action selector="didClickWarningButton:" target="-2" id="wNa-Cc-jZb"/>
</connections>
</button>
</subviews>
<point key="canvasLocation" x="-137" y="586.5"/>
</customView>
<viewController id="xTH-2c-Ppt" userLabel="Popover View Controller">
<connections>
<outlet property="view" destination="bVj-RM-sjw" id="TP8-Eb-GVO"/>
</connections>
</viewController>
<popover behavior="t" id="stq-gJ-ra0">
<connections>
<outlet property="contentViewController" destination="xTH-2c-Ppt" id="ODh-uM-ARs"/>
</connections>
</popover>
<customView id="bVj-RM-sjw" userLabel="Popover View">
<rect key="frame" x="0.0" y="0.0" width="300" height="44"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<subviews>
<textField wantsLayer="YES" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" fixedFrame="YES" setsMaxLayoutWidthAtFirstLayout="YES" textCompletion="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nCT-Lc-wce">
<rect key="frame" x="2" y="2" width="296" height="40"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<textFieldCell key="cell" truncatesLastVisibleLine="YES" selectable="YES" allowsUndo="NO" sendsActionOnEndEditing="YES" id="YJs-n4-Lxb">
<font key="font" metaFont="system"/>
<string key="title">Couldn't load Feed
An additional line
and a third</string>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<point key="canvasLocation" x="14" y="477"/>
</customView>
</objects>
<resources>
<image name="NSCaution" width="32" height="32"/>
</resources>
</document>

View File

@@ -0,0 +1,45 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class ModalFeedEdit;
@interface ModalFeedEditView : NSView
@property (weak) IBOutlet NSTextField *url;
@property (weak) IBOutlet NSProgressIndicator *spinnerURL;
@property (weak) IBOutlet NSImageView *favicon;
@property (weak) IBOutlet NSTextField *name;
@property (weak) IBOutlet NSProgressIndicator *spinnerName;
@property (weak) IBOutlet NSTextField *refreshNum;
@property (weak) IBOutlet NSPopUpButton *refreshUnit;
@property (weak) IBOutlet NSButton *warningButton;
@property NSPopover *warningPopover;
@property (weak) IBOutlet NSTextField *warningText;
@property (weak) IBOutlet NSButton *warningReload;
- (instancetype)initWithController:(ModalFeedEdit*)controller NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE;
@end

View File

@@ -0,0 +1,110 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "ModalFeedEditView.h"
#import "ModalFeedEdit.h"
#import "NSView+Ext.h"
@interface StrictUIntFormatter : NSFormatter
@end
@implementation ModalFeedEditView
- (instancetype)initWithController:(ModalFeedEdit*)controller {
NSArray *lbls = @[NSLocalizedString(@"URL", nil),
NSLocalizedString(@"Name", nil),
NSLocalizedString(@"Refresh", nil)];
NSView *labels = [NSView labelColumn:lbls rowHeight:HEIGHT_INPUTFIELD padding:PAD_S];
self = [super initWithFrame:NSMakeRect(0, 0, 0, NSHeight(labels.frame))];
self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
CGFloat x = NSWidth(labels.frame) + PAD_S;
static CGFloat const rowHeight = PAD_S + HEIGHT_INPUTFIELD;
[labels placeIn:self x:0 yTop:0];
// 1. row
self.url = [[[NSView inputField:@"https://example.org/feed.rss" width:0] placeIn:self x:x yTop:0] sizeToRight:PAD_S + 18];
self.spinnerURL = [[NSView activitySpinner] placeIn:self xRight:1 yTop:2.5];
self.favicon = [[[NSView imageView:nil size:18] tooltip:NSLocalizedString(@"Favicon", nil)] placeIn:self xRight:0 yTop:1.5];
self.warningButton = [[[[NSView buttonIcon:NSImageNameCaution size:18] action:@selector(didClickWarningButton:) target:nil] // up the responder chain
tooltip:NSLocalizedString(@"Click here to show failure reason", nil)]
placeIn:self xRight:0 yTop:1.5];
// 2. row
self.name = [[[NSView inputField:NSLocalizedString(@"Example Title", nil) width:0] placeIn:self x:x yTop:rowHeight] sizeToRight:PAD_S + 18];
self.spinnerName = [[NSView activitySpinner] placeIn:self xRight:1 yTop:rowHeight + 2.5];
// 3. row
self.refreshNum = [[NSView inputField:@"30" width:85] placeIn:self x:x yTop:2*rowHeight];
self.refreshUnit = [[NSView popupButton:120] placeIn:self x:NSMaxX(self.refreshNum.frame) + PAD_M yTop:2*rowHeight];
// initial state
self.url.accessibilityLabel = lbls[0];
self.name.accessibilityLabel = lbls[1];
self.refreshNum.accessibilityLabel = NSLocalizedString(@"Refresh interval", nil);
self.url.delegate = controller;
self.warningButton.hidden = YES;
self.refreshNum.formatter = [StrictUIntFormatter new]; // see below ...
[self prepareWarningPopover];
return self;
}
/// Prepare popover controller to display errors during download
- (void)prepareWarningPopover {
self.warningPopover = [NSView popover: NSMakeSize(300, 100)];
NSView *content = self.warningPopover.contentViewController.view;
// User visible error description text (after click on warning button)
self.warningText = [[[[[NSView label:@""] selectable] sizableWidthAndHeight]
multiline:NSMakeSize(292, 96)] placeIn:content x:4 y:2];
// Reload button is only visible on 5xx server error (right of )
self.warningReload = [[[[NSView buttonIcon:NSImageNameRefreshTemplate size:16] placeIn:content x:35 yTop:21]
tooltip:NSLocalizedString(@"Retry download (⌘R)", nil)]
action:@selector(reloadData) target:nil]; // up the responder chain
}
@end
#pragma mark - StrictUIntFormatter -
@implementation StrictUIntFormatter
/// Display object as integer formatted string.
- (NSString *)stringForObjectValue:(id)obj {
return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]];
}
/// Parse any pasted input as integer.
- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error {
*obj = [[NSNumber numberWithInt:[string intValue]] stringValue];
return YES;
}
/// Only digits, no other character allowed
- (BOOL)isPartialStringValid:(NSString *__autoreleasing _Nonnull *)partialStringPtr proposedSelectedRange:(NSRangePointer)proposedSelRangePtr originalString:(NSString *)origString originalSelectedRange:(NSRange)origSelRange errorDescription:(NSString *__autoreleasing _Nullable *)error {
for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) {
unichar c = [*partialStringPtr characterAtIndex:i];
if (c < '0' || c > '9')
return NO;
}
return YES;
}
@end

View File

@@ -1,331 +0,0 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "OpmlExport.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import "StoreCoordinator.h"
#import "FeedDownload.h"
#import "Constants.h"
@implementation OpmlExport
#pragma mark - Open & Save Panel
/// Display Open File Panel to select @c .opml file. Perform web requests (feed data & icon) within a single undo group.
+ (void)showImportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc {
NSOpenPanel *op = [NSOpenPanel openPanel];
op.allowedFileTypes = @[@"opml"];
[op beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
if (result == NSModalResponseOK) {
NSData *data = [NSData dataWithContentsOfURL:op.URL];
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:@"opml-file-import"];
RSOPMLParser *parser = [RSOPMLParser parserWithXMLData:xml];
[parser parseAsync:^(RSOPMLItem * _Nullable doc, NSError * _Nullable error) {
if (error) {
[NSApp presentError:error];
} else {
[self importOPMLDocument:doc inContext:moc];
}
}];
}
}];
}
/// Display Save File Panel to select export destination. All feeds from core data will be exported.
+ (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc {
NSSavePanel *sp = [NSSavePanel savePanel];
sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [self currentDayAsStringISO8601:NO]];
sp.allowedFileTypes = @[@"opml"];
sp.allowsOtherFileTypes = YES;
NSView *radioView = [self radioGroupCreate:@[NSLocalizedString(@"Hierarchical", nil),
NSLocalizedString(@"Flattened", nil)]];
sp.accessoryView = [self viewByPrependingLabel:NSLocalizedString(@"Export format:", nil) toView:radioView];
[sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) {
if (result == NSModalResponseOK) {
BOOL flattened = ([self radioGroupSelection:radioView] == 1);
NSArray<FeedGroup*> *list = [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:moc];
NSXMLDocument *doc = [self xmlDocumentForFeeds:list hierarchical:!flattened];
NSData *xml = [doc XMLDataWithOptions:NSXMLNodePreserveAttributeOrder | NSXMLNodePrettyPrint];
NSError *error;
[xml writeToURL:sp.URL options:NSDataWritingAtomic error:&error];
if (error) {
[NSApp presentError:error];
}
}
}];
}
#pragma mark - Import
/**
Ask user for permission to import new items (prior import). User can choose to append or replace existing items.
If user chooses to replace existing items, perform core data request to delete all feeds.
@param document Used to count feed items that will be imported
@return @c -1: User clicked 'Cancel' button. @c 0: Append items. @c 1: Overwrite items.
*/
+ (NSInteger)askToAppendOrOverwriteAlert:(RSOPMLItem*)document inContext:(NSManagedObjectContext*)moc {
NSUInteger count = [self recursiveNumberOfFeeds:document];
NSAlert *alert = [[NSAlert alloc] init];
alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"Import of %lu feed items", nil), count];
alert.informativeText = NSLocalizedString(@"Do you want to append or replace existing items?", nil);
[alert addButtonWithTitle:NSLocalizedString(@"Import", nil)];
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)];
alert.accessoryView = [self radioGroupCreate:@[NSLocalizedString(@"Append", nil),
NSLocalizedString(@"Overwrite", nil)]];
if ([alert runModal] == NSAlertFirstButtonReturn) {
return [self radioGroupSelection:alert.accessoryView];
}
return -1; // cancel button
}
/**
Perform import of @c FeedGroup items.
*/
+ (void)importOPMLDocument:(RSOPMLItem*)doc inContext:(NSManagedObjectContext*)moc {
NSInteger select = [self askToAppendOrOverwriteAlert:doc inContext:moc];
if (select < 0 || select > 1) // not a valid selection (or cancel button)
return;
[moc.undoManager beginUndoGrouping];
int32_t idx = 0;
if (select == 1) { // overwrite selected
for (FeedGroup *fg in [StoreCoordinator sortedFeedGroupsWithParent:nil inContext:moc]) {
[moc deleteObject:fg]; // Not a batch delete request to support undo
}
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:@0];
} else {
idx = (int32_t)[StoreCoordinator countRootItemsInContext:moc];
}
NSMutableArray<Feed*> *list = [NSMutableArray array];
for (RSOPMLItem *item in doc.children) {
[self importFeed:item parent:nil index:idx inContext:moc appendToList:list];
idx += 1;
}
// Persist state, because on crash we have at least inserted items (without articles & icons)
[StoreCoordinator saveContext:moc andParent:YES];
[FeedDownload batchDownloadFeeds:list favicons:YES showErrorAlert:YES finally:^{
[StoreCoordinator saveContext:moc andParent:YES];
[moc.undoManager endUndoGrouping];
}];
}
/**
Import single item and recursively repeat import for each child.
@param item The item to be imported.
@param parent The already processed parent item.
@param idx @c sortIndex within the @c parent item.
@param moc Managed object context.
@param list Mutable list where newly inserted @c Feed items will be added.
*/
+ (void)importFeed:(RSOPMLItem*)item parent:(FeedGroup*)parent index:(int32_t)idx inContext:(NSManagedObjectContext*)moc appendToList:(NSMutableArray<Feed*> *)list {
FeedGroupType type = GROUP;
if ([item attributeForKey:OPMLXMLURLKey]) {
type = FEED;
} else if ([item attributeForKey:@"separator"]) { // baRSS specific
type = SEPARATOR;
}
FeedGroup *newFeed = [FeedGroup newGroup:type inContext:moc];
[newFeed setParent:parent andSortIndex:idx];
newFeed.name = (type == SEPARATOR ? @"---" : item.displayName);
switch (type) {
case GROUP:
for (NSUInteger i = 0; i < item.children.count; i++) {
[self importFeed:item.children[i] parent:newFeed index:(int32_t)i inContext:moc appendToList:list];
}
break;
case FEED:
@autoreleasepool {
FeedMeta *meta = newFeed.feed.meta;
meta.url = [item attributeForKey:OPMLXMLURLKey];
id refresh = [item attributeForKey:@"refreshInterval"]; // baRSS specific
if (refresh) {
[meta setRefreshAndSchedule:(int32_t)[refresh integerValue]];
} else {
[meta setRefreshAndSchedule:kDefaultFeedRefreshInterval]; // TODO: set -1, then auto
}
}
[list addObject:newFeed.feed];
break;
case SEPARATOR:
break;
}
}
#pragma mark - Export
/**
Create NSXMLNode structure with application header nodes and body node containing feed items.
@param flag If @c YES keep parent-child structure intact. If @c NO ignore all parents and add @c Feed items only.
*/
+ (NSXMLDocument*)xmlDocumentForFeeds:(NSArray<FeedGroup*>*)list hierarchical:(BOOL)flag {
NSXMLElement *head = [NSXMLElement elementWithName:@"head"];
head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"],
[NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"],
[NSXMLElement elementWithName:@"dateCreated" stringValue:[self currentDayAsStringISO8601:YES]] ];
NSXMLElement *body = [NSXMLElement elementWithName:@"body"];
for (FeedGroup *item in list) {
[self appendChild:item toNode:body hierarchical:flag];
}
NSXMLElement *opml = [NSXMLElement elementWithName:@"opml"];
opml.attributes = @[[NSXMLNode attributeWithName:@"version" stringValue:@"1.0"]];
opml.children = @[head, body];
NSXMLDocument *xml = [NSXMLDocument documentWithRootElement:opml];
xml.version = @"1.0";
xml.characterEncoding = @"UTF-8";
return xml;
}
/**
Build up @c NSXMLNode structure recursively. Essentially, re-create same structure as in core data storage.
@param flag If @c NO don't add groups to export file but continue evaluation of child items.
*/
+ (void)appendChild:(FeedGroup*)item toNode:(NSXMLElement *)parent hierarchical:(BOOL)flag {
if (flag || item.type != GROUP) {
// dont add group node if hierarchical == NO
NSXMLElement *outline = [NSXMLElement elementWithName:@"outline"];
[parent addChild:outline];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTitleKey stringValue:item.name]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTextKey stringValue:item.name]];
if (item.type == SEPARATOR) {
[outline addAttribute:[NSXMLNode attributeWithName:@"separator" stringValue:@"true"]]; // baRSS specific
} else if (item.feed) {
[outline addAttribute:[NSXMLNode attributeWithName:OPMLHMTLURLKey stringValue:item.feed.link]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLXMLURLKey stringValue:item.feed.meta.url]];
[outline addAttribute:[NSXMLNode attributeWithName:OPMLTypeKey stringValue:@"rss"]];
NSString *intervalStr = [NSString stringWithFormat:@"%d", item.feed.meta.refresh];
[outline addAttribute:[NSXMLNode attributeWithName:@"refreshInterval" stringValue:intervalStr]]; // baRSS specific
// TODO: option to export unread state?
}
parent = outline;
}
for (FeedGroup *subItem in [item sortedChildren]) {
[self appendChild:subItem toNode:parent hierarchical:flag];
}
}
#pragma mark - Helper
/// @param flag If @c YES use long internet format for opml file. If @c NO use short format as filename.
+ (NSString*)currentDayAsStringISO8601:(BOOL)flag {
if (flag)
return [[[NSISO8601DateFormatter alloc] init] stringFromDate:[NSDate date]];
// NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]];
// return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day];
return [NSDateFormatter localizedStringFromDate:[NSDate date] dateStyle:NSDateFormatterShortStyle timeStyle:NSDateFormatterNoStyle];
}
/// Count items where @c xmlURL key is set.
+ (NSUInteger)recursiveNumberOfFeeds:(RSOPMLItem*)document {
if ([document attributeForKey:OPMLXMLURLKey]) {
return 1;
} else {
NSUInteger sum = 0;
for (RSOPMLItem *child in document.children) {
sum += [self recursiveNumberOfFeeds:child];
}
return sum;
}
}
/// Solely used to group radio buttons
+ (void)donothing {}
/// Create a new view with as many @c NSRadioButton items as there are strings. Buttons @c tag is equal to the array index.
+ (NSView*)radioGroupCreate:(NSArray<NSString*>*)titles {
if (titles.count == 0)
return nil;
NSRect viewRect = NSMakeRect(0, 0, 0, 8);
NSInteger idx = (NSInteger)titles.count;
NSView *v = [[NSView alloc] init];
for (NSString *title in titles.reverseObjectEnumerator) {
idx -= 1;
NSButton *btn = [NSButton radioButtonWithTitle:title target:self action:@selector(donothing)];
btn.tag = idx;
btn.frame = NSOffsetRect(btn.frame, 0, viewRect.size.height);
viewRect.size.height += btn.frame.size.height + 2; // 2px padding
if (viewRect.size.width < btn.frame.size.width)
viewRect.size.width = btn.frame.size.width;
[v addSubview:btn];
if (idx == 0)
btn.state = NSControlStateValueOn;
}
viewRect.size.height += 6; // 8 - 2px padding
v.frame = viewRect;
return v;
}
/// Loop over all subviews and find the @c NSButton that is selected.
+ (NSInteger)radioGroupSelection:(NSView*)view {
for (NSButton *btn in view.subviews) {
if ([btn isKindOfClass:[NSButton class]] && btn.state == NSControlStateValueOn) {
return btn.tag;
}
}
return -1;
}
/// @return New view with @c NSTextField label in the top left corner and @c radioView on the right side.
+ (NSView*)viewByPrependingLabel:(NSString*)str toView:(NSView*)radioView {
NSTextField *label = [NSTextField textFieldWithString:str];
label.editable = NO;
label.selectable = NO;
label.bezeled = NO;
label.drawsBackground = NO;
NSRect fL = label.frame;
NSRect fR = radioView.frame;
fL.origin.y += fR.size.height - fL.size.height - 8;
fR.origin.x += fL.size.width;
label.frame = fL;
radioView.frame = fR;
NSView *view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, NSMaxX(fR), NSMaxY(fR))];
[view addSubview:label];
[view addSubview:radioView];
return view;
}
@end

View File

@@ -20,19 +20,17 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
@import Cocoa;
@protocol RefreshIntervalButtonDelegate <NSObject>
@required
/**
The interval-unit combination is stored as follows:
:: @c sender.tag @c >> @c 3 (Refresh Interval)
:: @c sender.tag @c & @c 0x7 (Refresh Unit, where 0: seconds and 4: weeks)
*/
/// @c sender.tag is refresh interval in seconds
- (void)refreshIntervalButtonClicked:(NSButton*)sender;
@end
@interface Statistics : NSObject
+ (NSDictionary*)refreshInterval:(NSArray<NSDate*> *)list;
+ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback;
@interface RefreshStatisticsView : NSView
- (instancetype)initWithRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE;
@end

View File

@@ -0,0 +1,115 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "RefreshStatisticsView.h"
#import "NSDate+Ext.h"
#import "NSView+Ext.h"
@implementation RefreshStatisticsView
/**
Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date.
@param info The dictionary generated with @c -refreshInterval:
@param count Article count.
@param callback If set, @c sender will be called with @c -refreshIntervalButtonClicked:.
If not disable button border and display as bold inline text.
@return Centered view without autoresizing.
*/
- (instancetype)initWithRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
self = [super initWithFrame:NSZeroRect];
self.autoresizesSubviews = NO;
NSTextField *dateView = [self viewForArticlesCount:count latest:info];
if (!info || info.count == 0) {
[self setFrameSize:dateView.frame.size];
[dateView placeIn:self x:0 y:0];
} else {
NSArray *arr = @[GrayLabel(NSLocalizedString(@"min:", nil)), [self createInlineButton:info[@"min"] callback:callback],
GrayLabel(NSLocalizedString(@"max:", nil)), [self createInlineButton:info[@"max"] callback:callback],
GrayLabel(NSLocalizedString(@"avg:", nil)), [self createInlineButton:info[@"avg"] callback:callback],
GrayLabel(NSLocalizedString(@"median:", nil)), [self createInlineButton:info[@"median"] callback:callback]];
NSView *buttonsView = [self placeViewsHorizontally:arr];
CGFloat w = NSMaxWidth(dateView, buttonsView);
[self setFrameSize:NSMakeSize(w, NSHeight(buttonsView.frame) + PAD_M + NSHeight(dateView.frame))];
[dateView placeIn:self x:CENTER yTop:0];
[buttonsView placeIn:self x:CENTER y:0];
}
return self;
}
/// TextField with article count and latest article date.
- (NSTextField*)viewForArticlesCount:(NSUInteger)count latest:(nullable NSDictionary*)info {
NSString *text = [NSString stringWithFormat:NSLocalizedString(@"%lu articles.", nil), count];
if (!info || info.count == 0) {
return GrayLabel(text);
}
NSDate *lastUpdate = [info valueForKey:@"latest"];
NSString *mod = [NSDateFormatter localizedStringFromDate:lastUpdate dateStyle:NSDateFormatterMediumStyle timeStyle:NSDateFormatterShortStyle];
NSTextField *label = GrayLabel([text stringByAppendingFormat:NSLocalizedString(@" (latest: %@)", nil), mod]);
// Feed wasn't updated in a while ...
if ([lastUpdate timeIntervalSinceNow] < (-360 * 24 * 60 * 60)) {
NSMutableAttributedString *as = label.attributedStringValue.mutableCopy;
[as addAttribute:NSForegroundColorAttributeName value:[NSColor systemRedColor] range:NSMakeRange(text.length, as.length - text.length)];
[label setAttributedStringValue:as]; // red colored date
}
return label;
}
/// Label with smaller gray text, non-editable. @c 13px height.
static inline NSTextField* GrayLabel(NSString *text) {
return [[[NSView label:text] tiny] gray];
}
/// Inline button with tag equal to refresh interval. @c 16px height.
- (NSButton*)createInlineButton:(NSNumber*)num callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
NSButton *button = [NSView inlineButton: [NSDate floatStringForInterval:num.intValue]];
Interval intv = [NSDate floatToIntInterval:num.intValue]; // rounded to highest unit
button.accessibilityTitle = [NSDate intStringForInterval:intv];
button.tag = (NSInteger)intv;
if (callback) {
[button action:@selector(refreshIntervalButtonClicked:) target:callback];
} else {
button.bordered = NO;
button.enabled = NO;
}
return button;
}
/// Helper method to arrange all views in a horizontal line (vertically centered).
- (NSView*)placeViewsHorizontally:(NSArray<NSView*>*)views {
CGFloat w = 0;
NSView *parent = [[NSView alloc] initWithFrame: NSZeroRect];
for (NSView *v in views) {
BOOL isButton = [v isKindOfClass:[NSButton class]];
[v setFrameOrigin:NSMakePoint(w, (isButton ? 0 : 2))];
[parent addSubview:v];
w += NSWidth(v.frame) + (isButton ? PAD_M : 0);
}
[parent setFrameSize:NSMakeSize(w - PAD_M, 16)];
return parent;
}
@end

View File

@@ -0,0 +1,29 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "SettingsFeeds.h"
#import "OpmlFile.h"
@interface SettingsFeeds (DragDrop) <NSOutlineViewDataSource, NSFilePromiseProviderDelegate, NSPasteboardTypeOwner, OpmlFileImportDelegate, OpmlFileExportDelegate>
- (void)prepareOutlineViewForDragDrop:(NSOutlineView*)outline;
- (void)importOpmlFiles:(NSArray<NSURL*>*)files;
@end

View File

@@ -0,0 +1,253 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "SettingsFeeds+DragDrop.h"
#import "StoreCoordinator.h"
#import "Constants.h"
#import "UpdateScheduler.h"
#import "FeedGroup+Ext.h"
// Pasteboard type used during internal row reordering
const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder";
@implementation SettingsFeeds (DragDrop)
/// Set self as @c dataSource and register drag types
- (void)prepareOutlineViewForDragDrop:(NSOutlineView*)outline {
outline.dataSource = self;
[outline registerForDraggedTypes:@[dragReorder, (NSPasteboardType)kUTTypeFileURL]];
[outline setDraggingSourceOperationMask:NSDragOperationMove forLocal:YES]; // reorder
[outline setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO]; // export
}
#pragma mark - Dragging Support, Data Source Delegate
/// Begin drag-n-drop operation by copying selected nodes to memory & prepare @c FilePromise
- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pasteboard {
NSFilePromiseProvider *opml = [[NSFilePromiseProvider alloc] initWithFileType:UTI_OPML delegate:self];
[pasteboard writeObjects:@[opml]]; // opml file export
[pasteboard setString:@"dragging" forType:dragReorder]; // internal row reordering
[pasteboard addTypes:@[NSPasteboardTypeString] owner:self]; // string export, same as Cmd-C
self.currentlyDraggedNodes = items;
return YES;
}
/// Clear previous memory after drag operation
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
self.currentlyDraggedNodes = nil;
}
/// Prohibit drag if destination is leaf or source has no opml
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id <NSDraggingInfo>)info proposedItem:(NSTreeNode*)parent proposedChildIndex:(NSInteger)index {
if (info.numberOfValidItemsForDrop == 0 // none of the files is opml
|| (index == -1 && [parent isLeaf])) { // drag on specific item (-1) that is not a group
return NSDragOperationNone;
}
if (info.draggingSource == outlineView) {
// Internal item reordering (dragReorder)
for (NSTreeNode *selection in self.currentlyDraggedNodes) {
if (IndexPathIsChildOfParent(parent.indexPath, selection.indexPath))
return NSDragOperationNone; // cannot move items into a child of its own
}
return NSDragOperationMove;
} else {
// Dropped file urls, set whole table as destination
[outlineView setDropItem:nil dropChildIndex:NSOutlineViewDropOnItemIndex];
return NSDragOperationGeneric;
}
}
/// Perform drag-n-drop operation, move nodes to new destination and update all indices
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(NSTreeNode*)newParent childIndex:(NSInteger)index {
if (info.numberOfValidItemsForDrop == 0)
return NO;
if (info.draggingSource == outlineView) {
// Calculate drop path
if (!newParent) newParent = [self.dataStore arrangedObjects]; // root
NSUInteger idx = (NSUInteger)index;
if (index == -1) // if folder, append to end
idx = newParent.childNodes.count;
// Internal item reordering (dragReorder)
[self beginCoreDataChange];
NSArray<NSTreeNode*> *previousParents = [self.currentlyDraggedNodes valueForKeyPath:@"parentNode"];
[self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:[newParent.indexPath indexPathByAddingIndex:idx]];
[self restoreOrderingAndIndexPathStr:[previousParents arrayByAddingObject:newParent]];
[self endCoreDataChangeUndoEmpty:YES forceUndo:NO];
} else {
// File import
NSArray<NSURL*> *files = [info.draggingPasteboard readObjectsForClasses:@[NSURL.class] options:@{ NSPasteboardURLReadingContentsConformToTypesKey: @[UTI_OPML] }];
[self importOpmlFiles:files];
}
return YES;
}
#pragma mark - OPML File Import
/// Helper method is also called from Application Delegate
- (void)importOpmlFiles:(NSArray<NSURL*>*)files {
[[OpmlFileImport withDelegate:self] importFiles:files];
}
/// Filter out file urls that are not opml files
- (void)outlineView:(NSOutlineView *)outlineView updateDraggingItemsForDrag:(id <NSDraggingInfo>)info {
if ([info.draggingPasteboard canReadItemWithDataConformingToTypes:@[(NSPasteboardType)kUTTypeFileURL]]) {
NSDraggingItemEnumerationOptions opt = NSDraggingItemEnumerationClearNonenumeratedImages;
NSArray<Class> *cls = @[ [NSURL class] ];
NSDictionary *dict = @{ NSPasteboardURLReadingContentsConformToTypesKey: @[UTI_OPML] };
__block NSInteger count = 0;
[info enumerateDraggingItemsWithOptions:opt forView:nil classes:cls searchOptions:dict usingBlock:^(NSDraggingItem * _Nonnull draggingItem, NSInteger idx, BOOL * _Nonnull stop) {
++count;
}];
info.numberOfValidItemsForDrop = count;
}
}
/// OPML import (context provider)
- (NSManagedObjectContext *)opmlFileImportContext {
return self.dataStore.managedObjectContext;
}
/// OPML import (will begin)
- (void)opmlFileImportWillBegin:(NSManagedObjectContext*)moc {
[self beginCoreDataChange];
}
/// OPML import (did end). Save changes, select newly inserted, and perform web request.
- (void)opmlFileImportDidEnd:(NSManagedObjectContext*)moc {
if (moc.undoManager.groupingLevel == 1 && !moc.hasChanges) { // exit early, dont need to create empty arrays
[self endCoreDataChangeUndoEmpty:YES forceUndo:YES];
return;
}
// Get list of feeds, and root level selection
NSMutableArray<NSIndexPath*> *selection = [NSMutableArray array];
NSMutableArray<Feed*> *feedsList = [NSMutableArray array];
for (__kindof NSManagedObject *obj in moc.insertedObjects) {
if ([obj isKindOfClass:[Feed class]]) {
[feedsList addObject:obj]; // list of feeds that need download
} else if ([obj isKindOfClass:[FeedGroup class]]) {
FeedGroup *fg = obj;
if (fg.parent == nil) // list of root level parents
[selection addObject:[NSIndexPath indexPathWithIndex:(NSUInteger)fg.sortIndex]];
}
}
// Persist state, because on crash we have at least inserted items (without articles & icons)
[StoreCoordinator saveContext:moc andParent:YES];
if (selection.count > 0)
[self.dataStore setSelectionIndexPaths:[selection sortedArrayUsingSelector:@selector(compare:)]];
[UpdateScheduler downloadList:feedsList userInitiated:YES finally:^{
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
for (Feed *f in feedsList)
[moc refreshObject:f.group mergeChanges:NO]; // fixes blank icon if imported with no inet conn
[UpdateScheduler scheduleNextFeed];
}];
}
#pragma mark - OPML File Export
/// OPML export with drag-n-drop (filename)
- (nonnull NSString *)filePromiseProvider:(nonnull NSFilePromiseProvider *)filePromiseProvider fileNameForType:(nonnull NSString *)fileType {
CFStringRef ext = UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)(fileType), kUTTagClassFilenameExtension);
return [@"baRSS export" stringByAppendingPathExtension: CFBridgingRelease(ext)];
}
/// OPML export with drag-n-drop (write)
- (void)filePromiseProvider:(nonnull NSFilePromiseProvider *)filePromiseProvider writePromiseToURL:(nonnull NSURL *)url completionHandler:(nonnull void (^)(NSError * _Nullable))completionHandler {
NSError *err = [[OpmlFileExport withDelegate:self] writeOPMLFile:url withOptions:0];
completionHandler(err);
}
/// OPML export: drag-n-drop & menu export (content provider)
- (NSArray<FeedGroup*>*)opmlFileExportListOfFeedGroups:(OpmlFileExportOptions)options {
if (options & OpmlFileExportOptionFullBackup) // through button or menu click
return [self.dataStore.arrangedObjects.childNodes valueForKeyPath:@"representedObject"];
// drag-n-drop with file promise provider
return [[self draggedTopLevelNodes] valueForKeyPath:@"representedObject"];
}
#pragma mark - String Export
/// Called during export for @c NSPasteboardTypeString (text drag and copy:)
- (void)pasteboard:(NSPasteboard *)sender provideDataForType:(NSPasteboardType)type {
if (type == NSPasteboardTypeString) {
NSMutableString *str = [[NSMutableString alloc] init];
for (NSTreeNode *node in [self draggedTopLevelNodes]) {
[self traverseChildren:node appendString:str prefix:@""];
}
[str deleteCharactersInRange: NSMakeRange(str.length - 1, 1)]; // delete trailing new-line
[sender setString:str forType:type];
}
}
/**
Go through all children recursively and prepend the string with spaces as nesting
@param obj Root Node or parent Node
@param str An initialized @c NSMutableString to append to
@param prefix Should be @c @@"" for the first call
*/
- (void)traverseChildren:(NSTreeNode*)obj appendString:(NSMutableString*)str prefix:(NSString*)prefix {
FeedGroup *fg = obj.representedObject;
[str appendFormat:@"%@%@\n", prefix, [fg readableDescription]];
prefix = [prefix stringByAppendingString:@" "];
for (NSTreeNode *child in obj.childNodes) {
[self traverseChildren:child appendString:str prefix:prefix];
}
}
#pragma mark - Helper Methods
/// Selection without redundant nodes that are already present in some selected parent node
- (NSArray<NSTreeNode*>*)draggedTopLevelNodes {
NSArray *nodes = self.currentlyDraggedNodes;
if (!nodes) nodes = self.dataStore.selectedNodes; // fallback to selection (e.g., Cmd-C)
NSMutableArray<NSTreeNode*> *result = [NSMutableArray arrayWithCapacity:nodes.count];
for (NSTreeNode *current in nodes) {
BOOL skip = NO;
for (NSTreeNode *stored in result) {
if (IndexPathIsChildOfParent(current.indexPath, stored.indexPath)) {
skip = YES; break;
}
}
if (skip == NO) [result addObject:current];
}
return result;
}
static inline BOOL IndexPathIsChildOfParent(NSIndexPath *child, NSIndexPath *parent) {
while (child.length > parent.length)
child = [child indexPathByRemovingLastIndex];
return [child isEqualTo:parent];
}
@end

View File

@@ -20,9 +20,23 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
@import Cocoa;
/** Manages the NSOutlineView and Feed creation and editing */
@interface SettingsFeeds : NSViewController <NSOutlineViewDataSource, NSOutlineViewDelegate>
@interface SettingsFeeds : NSViewController <NSOutlineViewDelegate>
@property (strong) NSTreeController *dataStore;
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
- (void)editSelectedItem;
- (void)doubleClickOutlineView:(NSOutlineView*)sender;
- (void)addFeed;
- (void)addGroup;
- (void)addSeparator;
- (void)remove:(id)sender;
- (void)openImportDialog;
- (void)openExportDialog;
- (void)beginCoreDataChange;
- (BOOL)endCoreDataChangeUndoEmpty:(BOOL)undoEmpty forceUndo:(BOOL)force;
- (void)restoreOrderingAndIndexPathStr:(NSArray<NSTreeNode*>*)parentsList;
@end

View File

@@ -20,69 +20,56 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "SettingsFeeds.h"
#import "SettingsFeeds+DragDrop.h"
#import "Constants.h"
#import "StoreCoordinator.h"
#import "ModalFeedEdit.h"
#import "Feed+Ext.h"
#import "FeedGroup+Ext.h"
#import "OpmlExport.h"
#import "FeedDownload.h"
#import "UpdateScheduler.h"
#import "SettingsFeedsView.h"
#import "NSError+Ext.h"
@interface SettingsFeeds ()
@property (weak) IBOutlet NSOutlineView *outlineView;
@property (weak) IBOutlet NSTreeController *dataStore;
@property (weak) IBOutlet NSProgressIndicator *spinner;
@property (weak) IBOutlet NSTextField *spinnerLabel;
@property (strong) NSArray<NSTreeNode*> *currentlyDraggedNodes;
@property (strong) SettingsFeedsView *view; // override super
@property (strong) NSUndoManager *undoManager;
@property (strong) NSTimer *timerStatusInfo;
@property (strong) NSDateComponentsFormatter *intervalFormatter;
@end
@implementation SettingsFeeds
@dynamic view;
// TODO: drag-n-drop feeds to opml file?
// Declare a string constant for the drag type - to be used when writing and retrieving pasteboard data...
static NSString *dragNodeType = @"baRSS-feed-drag";
- (void)loadView {
[self initCoreDataStore];
self.view = [[SettingsFeedsView alloc] initWithController:self];
self.view.outline.delegate = self; // viewForTableColumn
[self prepareOutlineViewForDragDrop:self.view.outline];
}
- (void)viewDidLoad {
[super viewDidLoad];
[self.outlineView registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]];
[self.dataStore setSortDescriptors:[NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]];
self.undoManager = [[NSUndoManager alloc] init];
self.undoManager.groupsByEvent = NO;
self.undoManager.levelsOfUndo = 30;
self.dataStore.managedObjectContext = [StoreCoordinator createChildContext];
self.dataStore.managedObjectContext.undoManager = self.undoManager;
// Register for notifications
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedIconUpdated object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateInProgress:) name:kNotificationBackgroundUpdateInProgress object:nil];
RegisterNotification(kNotificationArticlesUpdated, @selector(feedUpdated:), self);
RegisterNotification(kNotificationFeedIconUpdated, @selector(feedUpdated:), self);
RegisterNotification(kNotificationFeedGroupInserted, @selector(feedGroupInserted:), self);
// Status bar
RegisterNotification(kNotificationScheduleTimerChanged, @selector(updateStatusInfo), self);
RegisterNotification(kNotificationNetworkStatusChanged, @selector(updateStatusInfo), self);
RegisterNotification(kNotificationBackgroundUpdateInProgress, @selector(updateStatusInfo), self);
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
NSUInteger c = [StoreCoordinator cleanupFavicons];
if (c > 0) NSLog(@"Removed %lu unreferenced favicons", c);
}
#pragma mark - Activity Spinner & Status Info
/// Initialize status info timer
- (void)viewWillAppear {
self.intervalFormatter = [[NSDateComponentsFormatter alloc] init];
self.intervalFormatter.unitsStyle = NSDateComponentsFormatterUnitsStyleShort; // e.g., '30 min'
self.intervalFormatter.maximumUnitCount = 1;
self.timerStatusInfo = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:self selector:@selector(keepTimerRunning) userInfo:nil repeats:YES];
// needed to scroll outline view to top (if prefs open on another tab)
[self.dataStore setSelectionIndexPath:[NSIndexPath indexPathWithIndex:0]];
self.timerStatusInfo = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:self selector:@selector(updateStatusInfo) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timerStatusInfo forMode:NSRunLoopCommonModes];
// start spinner if update is in progress when preferences open
[self activateSpinner:([FeedDownload isUpdating] ? -1 : 0)];
[self updateStatusInfo];
}
/// Timer cleanup
@@ -90,76 +77,36 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
// in viewWillDisappear otherwise dealloc will not be called
[self.timerStatusInfo invalidate];
self.timerStatusInfo = nil;
self.intervalFormatter = nil;
}
/// Callback method to update status info. Will be called more often when interval is getting shorter.
- (void)keepTimerRunning {
NSDate *date = [FeedDownload dateScheduled];
if (date) {
double nextFire = fabs(date.timeIntervalSinceNow);
if (nextFire > 1e9) { // distance future, over 31 years
self.spinnerLabel.stringValue = @"";
return;
}
if (nextFire > 60) { // update 1/min
nextFire = fmod(nextFire, 60); // next update will align with minute
} else {
nextFire = 1; // update 1/sec
}
NSString *str = [self.intervalFormatter stringFromTimeInterval: date.timeIntervalSinceNow];
self.spinnerLabel.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Next update in %@", nil), str];
[self.timerStatusInfo setFireDate:[NSDate dateWithTimeIntervalSinceNow: nextFire]];
}
}
/// Start ( @c c @c > @c 0 ) or stop ( @c c @c = @c 0 ) activity spinner. Also, sets status info.
- (void)activateSpinner:(NSInteger)c {
if (c == 0) {
[self.spinner stopAnimation:nil];
self.spinnerLabel.stringValue = @"";
[self.timerStatusInfo fire];
} else {
[self.timerStatusInfo setFireDate:[NSDate distantFuture]];
[self.spinner startAnimation:nil];
if (c == 1) { // exactly one feed
self.spinnerLabel.stringValue = NSLocalizedString(@"Updating 1 feed …", nil);
} else if (c < 0) { // unknown number of feeds
self.spinnerLabel.stringValue = NSLocalizedString(@"Updating feeds …", nil);
} else {
self.spinnerLabel.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Updating %lu feeds …", nil), c];
}
}
}
#pragma mark - Notification callback methods
/// Callback method fired when feed (or icon) has been updated in the background.
- (void)feedUpdated:(NSNotification*)notify {
NSManagedObjectID *oid = notify.object;
NSManagedObjectContext *moc = self.dataStore.managedObjectContext;
Feed *feed = [moc objectRegisteredForID:oid];
if (feed) {
if (self.undoManager.groupingLevel == 0) // don't mess around if user is editing something
[moc refreshObject:feed mergeChanges:YES];
[self.dataStore rearrangeObjects];
}
}
/// Callback method fired when background feed update begins and ends.
- (void)updateInProgress:(NSNotification*)notify {
[self activateSpinner:[notify.object integerValue]];
}
#pragma mark - Persist state
/// Prepare undo manager and tree controller
- (void)initCoreDataStore {
self.undoManager = [[NSUndoManager alloc] init];
self.undoManager.groupsByEvent = NO;
self.undoManager.levelsOfUndo = 30;
self.dataStore = [[NSTreeController alloc] init];
self.dataStore.managedObjectContext = [StoreCoordinator createChildContext];
self.dataStore.managedObjectContext.undoManager = self.undoManager;
self.dataStore.childrenKeyPath = @"children";
self.dataStore.leafKeyPath = @"type";
self.dataStore.entityName = @"FeedGroup";
self.dataStore.objectClass = [FeedGroup class];
self.dataStore.fetchPredicate = [NSPredicate predicateWithFormat:@"parent == nil"];
self.dataStore.sortDescriptors = [NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]];
NSError *error;
[self.dataStore fetchWithRequest:nil merge:NO error:&error];
[error inCasePresent:NSApp];
}
/**
Refresh current context from parent context and start new undo grouping.
@note Should be balanced with @c endCoreDataChangeUndoChanges:
@note Should be balanced with @c endCoreDataChangeUndoEmpty:forceUndo:
*/
- (void)beginCoreDataChange {
// Does seem to create problems with undo stack if refreshing from parent context
@@ -171,26 +118,23 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
End undo grouping and save changes to persistent store. Or undo group if no changes occured.
@note Should be balanced with @c beginCoreDataChange
@param flag If @c YES force @c NSUndoManager to undo the changes immediatelly.
@param undoEmpty If @c YES undo the last operation if no changes were made (unnecessary undo).
@param force If @c YES force @c NSUndoManager to undo the changes immediatelly.
@return Returns @c YES if context was saved.
*/
- (BOOL)endCoreDataChangeShouldUndo:(BOOL)flag {
- (BOOL)endCoreDataChangeUndoEmpty:(BOOL)undoEmpty forceUndo:(BOOL)force {
[self.undoManager endUndoGrouping];
if (!flag && self.dataStore.managedObjectContext.hasChanges) {
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
[FeedDownload scheduleUpdateForUpcomingFeeds];
[self.timerStatusInfo fire];
return YES;
}
if (force || (undoEmpty && !self.dataStore.managedObjectContext.hasChanges)) {
[self.undoManager disableUndoRegistration];
[self.undoManager undoNestedGroup];
[self.undoManager enableUndoRegistration];
return NO;
}
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
return YES;
}
/**
After the user did undo or redo we can't ensure integrity without doing some additional work.
*/
/// After the user did undo or redo we can't ensure integrity without doing some additional work.
- (void)saveWithUnpredictableChange {
// dont use unless you merge changes from main
// NSManagedObjectContext *moc = self.dataStore.managedObjectContext;
@@ -199,68 +143,146 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
// NSInteger ins = [[[moc.insertedObjects filteredSetUsingPredicate:pred] valueForKeyPath:@"@sum.unread"] integerValue];
// NSLog(@"%ld, %ld", del, ins);
[StoreCoordinator saveContext:self.dataStore.managedObjectContext andParent:YES];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
PostNotification(kNotificationTotalUnreadCountReset, nil);
[self.dataStore rearrangeObjects]; // update ordering
[UpdateScheduler scheduleNextFeed];
}
/// Callback method fired when feed (or icon) has been updated in the background.
- (void)feedUpdated:(NSNotification*)notify {
NSManagedObjectID *oid = notify.object;
NSManagedObjectContext *moc = self.dataStore.managedObjectContext;
Feed *feed = [moc objectRegisteredForID:oid];
if (feed) {
if (self.undoManager.groupingLevel == 0) // don't mess around if user is editing something
[moc refreshObject:feed mergeChanges:YES];
[self.dataStore rearrangeObjects]; // update display, show new icon
}
}
/// Callback method fired when feed is inserted via a 'feed://' url
- (void)feedGroupInserted:(NSNotification*)notify {
[self.dataStore fetch:self];
}
#pragma mark - Activity Spinner & Status Info
/// Callback method to update status info. Called more often as the interval is getting shorter.
- (void)updateStatusInfo {
if ([UpdateScheduler feedsInQueue] > 0) {
[self.timerStatusInfo setFireDate:[NSDate distantFuture]];
self.view.status.stringValue = [UpdateScheduler updatingXFeeds];
[self.view.spinner startAnimation:nil];
} else {
[self.view.spinner stopAnimation:nil];
double remaining;
self.view.status.stringValue = [UpdateScheduler remainingTimeTillNextUpdate:&remaining];
if (remaining < 1e5) { // keep timer running if < 28 hours
// Next update is aligned with minute (fmod) else update 1/sec
NSDate *nextUpdate = [NSDate dateWithTimeIntervalSinceNow: (remaining > 60 ? fmod(remaining, 60) : 1)];
[self.timerStatusInfo setFireDate:nextUpdate];
}
}
}
#pragma mark - UI Button Interaction
/// Open clicked or selected item for editing.
- (void)editSelectedItem {
FeedGroup *chosen = [self userSelectionFirst].representedObject;
[self showModalForFeedGroup:chosen isGroupEdit:YES]; // yes will be overwritten anyway
}
/// Open clicked item for editing.
- (void)doubleClickOutlineView:(NSOutlineView*)sender {
if (sender.clickedRow != -1) // only if there is a clicked item
[self editSelectedItem];
}
/// Add feed button.
- (IBAction)addFeed:(id)sender {
- (void)addFeed {
[self showModalForFeedGroup:nil isGroupEdit:NO];
}
/// Add group button.
- (IBAction)addGroup:(id)sender {
- (void)addGroup {
[self showModalForFeedGroup:nil isGroupEdit:YES];
}
/// Add separator button.
- (IBAction)addSeparator:(id)sender {
- (void)addSeparator {
[self beginCoreDataChange];
[self insertFeedGroupAtSelection:SEPARATOR].name = @"---";
[self endCoreDataChangeShouldUndo:NO];
[self insertFeedGroupAtSelection:SEPARATOR];
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
}
/// Remove feed button. User has selected one or more item in outline view.
- (IBAction)remove:(id)sender {
- (void)remove:(id)sender {
NSArray<NSTreeNode*> *nodes = [self userSelectionAll];
NSArray<NSTreeNode*> *parentNodes = [nodes valueForKeyPath:@"parentNode"];
[self beginCoreDataChange];
NSArray<NSTreeNode*> *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"];
[self.dataStore remove:sender];
for (NSTreeNode *parent in parentNodes) {
[self restoreOrderingAndIndexPathStr:parent];
}
[self endCoreDataChangeShouldUndo:NO];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
[self.dataStore removeObjectsAtArrangedObjectIndexPaths:[nodes valueForKeyPath:@"indexPath"]];
[self restoreOrderingAndIndexPathStr:parentNodes];
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
[UpdateScheduler scheduleNextFeed];
PostNotification(kNotificationTotalUnreadCountReset, nil);
}
/// Open user selected item for editing.
- (IBAction)doubleClickOutlineView:(NSOutlineView*)sender {
if (sender.clickedRow == -1)
return; // ignore clicks on column headers and where no row was selected
FeedGroup *fg = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject];
[self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway
- (void)openImportDialog {
[[OpmlFileImport withDelegate:self] showImportDialog:self.view.window];
}
/// Share menu button. Currently only import & export feeds as OPML.
- (IBAction)shareMenu:(NSButton*)sender {
if (!sender.menu) {
sender.menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Import / Export menu", nil)];
sender.menu.autoenablesItems = NO;
[sender.menu addItemWithTitle:NSLocalizedString(@"Import Feeds …", nil) action:nil keyEquivalent:@""].tag = 101;
[sender.menu addItemWithTitle:NSLocalizedString(@"Export Feeds …", nil) action:nil keyEquivalent:@""].tag = 102;
// TODO: Add menus for online sync? email export? etc.
}
if ([sender.menu popUpMenuPositioningItem:nil atLocation:NSMakePoint(0,sender.frame.size.height) inView:sender]) {
NSInteger tag = sender.menu.highlightedItem.tag;
if (tag == 101) {
[OpmlExport showImportDialog:self.view.window withContext:self.dataStore.managedObjectContext];
} else if (tag == 102) {
[OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext];
- (void)openExportDialog {
[[OpmlFileExport withDelegate:self] showExportDialog:self.view.window];
}
#pragma mark - Keyboard Commands: undo, redo, copy, enter
/// Also look for commands right click menu of outline view
- (void)keyDown:(NSEvent *)event {
if (![self.view.outline.menu performKeyEquivalent:event]) {
[super keyDown:event];
}
}
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
- (BOOL)respondsToSelector:(SEL)aSelector {
if (aSelector == @selector(undo:))
return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0 && ![UpdateScheduler isUpdating];
if (aSelector == @selector(redo:))
return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0 && ![UpdateScheduler isUpdating];
if (aSelector == @selector(copy:) || aSelector == @selector(remove:))
return ([self userSelectionFirst] != nil);
if (aSelector == @selector(editSelectedItem)) {
FeedGroup *chosen = [self userSelectionFirst].representedObject;
if (chosen && chosen.type != SEPARATOR)
return YES; // can edit only if selection is not a separator
return NO;
}
return [super respondsToSelector:aSelector];
}
/// Perform undo operation and redraw UI & menu bar unread count
- (void)undo:(id)sender {
[self.undoManager undo];
[self saveWithUnpredictableChange];
}
/// Perform redo operation and redraw UI & menu bar unread count
- (void)redo:(id)sender {
[self.undoManager redo];
[self saveWithUnpredictableChange];
}
/// Copy human readable description of selected nodes to clipboard.
- (void)copy:(id)sender {
[[NSPasteboard generalPasteboard] declareTypes:@[NSPasteboardTypeString] owner:self]; // DragDrop handles callback
}
@@ -279,230 +301,93 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
[self beginCoreDataChange];
if (!fg || ![fg isKindOfClass:[FeedGroup class]]) {
fg = [self insertFeedGroupAtSelection:(flag ? GROUP : FEED)];
} else {
flag = (fg.type == GROUP);
}
ModalEditDialog *editDialog = (fg.type == GROUP ? [ModalGroupEdit modalWith:fg] : [ModalFeedEdit modalWith:fg]);
ModalEditDialog *editDialog = (flag ? [ModalGroupEdit modalWith:fg] : [ModalFeedEdit modalWith:fg]);
[self.view.window beginSheet:[editDialog getModalSheet] completionHandler:^(NSModalResponse returnCode) {
if (returnCode == NSModalResponseOK) {
[editDialog applyChangesToCoreDataObject];
}
if ([self endCoreDataChangeShouldUndo:(returnCode != NSModalResponseOK)]) {
[self.dataStore rearrangeObjects];
if ([self endCoreDataChangeUndoEmpty:YES forceUndo:(returnCode != NSModalResponseOK)]) {
if (!flag) [UpdateScheduler scheduleNextFeed]; // only for feed edit
[self.dataStore.managedObjectContext refreshObject:fg mergeChanges:NO]; // update title & icon
}
}];
}
/// Insert @c FeedGroup item either after current selection or inside selected folder (if expanded)
/// Insert @c FeedGroup item at the end of the current folder (or inside if expanded)
- (FeedGroup*)insertFeedGroupAtSelection:(FeedGroupType)type {
NSTreeNode *selNode = [self userSelectionFirst];
FeedGroup *selObj = selNode.representedObject;
// If group selected and expanded, insert into group. Else: append at end of current folder
if (![self.view.outline isItemExpanded:selNode]) {
selObj = selObj.parent; // nullable
selNode = selNode.parentNode;
}
// If no selection, append to root folder
if (!selNode) selNode = [self.dataStore arrangedObjects];
// Insert new node
NSUInteger index = selNode.childNodes.count;
FeedGroup *fg = [FeedGroup newGroup:type inContext:self.dataStore.managedObjectContext];
NSIndexPath *pth = [self indexPathForInsertAtNode:[[self.dataStore selectedNodes] firstObject]];
[self.dataStore insertObject:fg atArrangedObjectIndexPath:pth];
if (pth.length > 1) { // some subfolder and not root folder (has parent!)
NSTreeNode *parentNode = [[self.dataStore arrangedObjects] descendantNodeAtIndexPath:pth].parentNode;
fg.parent = parentNode.representedObject;
[self restoreOrderingAndIndexPathStr:parentNode];
} else {
[self restoreOrderingAndIndexPathStr:[self.dataStore arrangedObjects]]; // .parent = nil
}
[self.dataStore insertObject:fg atArrangedObjectIndexPath:[selNode.indexPath indexPathByAddingIndex:index]];
[fg setParent:selObj andSortIndex:(int32_t)index];
return fg;
}
/**
Index path will be selected as follow:
- @b root: append at end
- @b folder (expanded): append at front
- @b else: append after item.
@return indexPath where item will be inserted.
*/
- (NSIndexPath*)indexPathForInsertAtNode:(NSTreeNode*)node {
if (!node) { // append to root
return [NSIndexPath indexPathWithIndex:[self.dataStore arrangedObjects].childNodes.count]; // or 0 to append at front
} else if ([self.outlineView isItemExpanded:node]) { // append to group (if open)
return [node.indexPath indexPathByAddingIndex:0]; // or 'selection.childNodes.count' to append at end
} else { // append before / after selected item
NSIndexPath *pth = node.indexPath;
// remove the two lines below to insert infront of selection (instead of after selection)
NSUInteger lastIdx = [pth indexAtPosition:pth.length - 1];
return [[pth indexPathByRemovingLastIndex] indexPathByAddingIndex:lastIdx + 1];
}
}
/// Loop over all descendants and update @c sortIndex @c (FeedGroup) as well as all @c indexPath @c (Feed)
- (void)restoreOrderingAndIndexPathStr:(NSTreeNode*)parent {
NSArray<NSTreeNode*> *children = parent.childNodes;
for (NSUInteger i = 0; i < children.count; i++) {
FeedGroup *fg = [children objectAtIndex:i].representedObject;
if (fg.sortIndex != (int32_t)i)
fg.sortIndex = (int32_t)i;
[fg iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
[feed calculateAndSetIndexPathString];
}];
}
}
#pragma mark - Dragging Support, Data Source Delegate
/// Begin drag-n-drop operation by copying selected nodes to memory
- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard {
[self beginCoreDataChange];
[pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self];
[pboard setString:@"dragging" forType:dragNodeType];
self.currentlyDraggedNodes = items;
return YES;
}
/// Finish drag-n-drop operation by saving changes to persistent store
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation {
[self endCoreDataChangeShouldUndo:NO];
self.currentlyDraggedNodes = nil;
}
/// Perform drag-n-drop operation, move nodes to new destination and update all indices
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(id)item childIndex:(NSInteger)index {
NSTreeNode *destParent = (item != nil ? item : [self.dataStore arrangedObjects]);
NSUInteger idx = (NSUInteger)index;
if (index == -1) // drag items on folder or root drop
idx = destParent.childNodes.count;
NSIndexPath *dest = [destParent indexPath];
NSArray<NSTreeNode*> *previousParents = [self.currentlyDraggedNodes valueForKeyPath:@"parentNode"];
[self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:[dest indexPathByAddingIndex:idx]];
for (NSTreeNode *node in previousParents) {
[self restoreOrderingAndIndexPathStr:node];
}
[self restoreOrderingAndIndexPathStr:destParent];
return YES;
}
/// Validate method whether items can be dropped at destination
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id <NSDraggingInfo>)info proposedItem:(id)item proposedChildIndex:(NSInteger)index {
NSTreeNode *parent = item;
if (index == -1 && [parent isLeaf]) { // if drag is on specific item and that item isnt a group
return NSDragOperationNone;
}
while (parent != nil) {
for (NSTreeNode *node in self.currentlyDraggedNodes) {
if (parent == node)
return NSDragOperationNone; // cannot move items into a child of its own
}
parent = [parent parentNode];
}
return NSDragOperationGeneric;
}
#pragma mark - Data Source Delegate
/// Populate @c NSOutlineView data cells with core data object values.
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item {
FeedGroup *fg = [(NSTreeNode*)item representedObject];
BOOL isSeperator = (fg.type == SEPARATOR);
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
NSString *cellIdent = (isRefreshColumn ? @"cellRefresh" : (isSeperator ? @"cellSeparator" : @"cellFeed"));
// owner is nil to prohibit repeated awakeFromNib calls
NSTableCellView *cellView = [self.outlineView makeViewWithIdentifier:cellIdent owner:nil];
if (isRefreshColumn) {
NSString *str = [fg refreshString];
cellView.textField.stringValue = str;
cellView.textField.textColor = (str.length > 1 ? [NSColor controlTextColor] : [NSColor disabledControlTextColor]);
} else if (isSeperator) {
return cellView; // refresh cell already skipped with the above if condition
} else {
cellView.textField.objectValue = fg.name;
cellView.imageView.image = fg.iconImage16;
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(NSTreeNode*)item {
NSUserInterfaceItemIdentifier ident = tableColumn.identifier;
if (ident == CustomCellName) {
FeedGroup *fg = [item representedObject];
if (fg.type == SEPARATOR)
ident = CustomCellSeparator;
}
return cellView;
NSTableCellView *v = [outlineView makeViewWithIdentifier:ident owner:self];
if (v) return v;
if (ident == CustomCellName) return [NameColumnCell new];
if (ident == CustomCellRefresh) return [RefreshColumnCell new];
if (ident == CustomCellSeparator) return [SeparatorColumnCell new];
return nil;
}
#pragma mark - Keyboard Commands: undo, redo, copy, enter
#pragma mark - Helper Methods
/// Returning @c NO will result in a Action-Not-Available-Buzzer sound
- (BOOL)respondsToSelector:(SEL)aSelector {
if (aSelector == @selector(undo:))
return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating];
if (aSelector == @selector(redo:))
return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating];
if (aSelector == @selector(copy:) || aSelector == @selector(enterPressed:)) {
BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]];
BOOL hasSelection = (self.dataStore.selectedNodes.count > 0);
if (!outlineHasFocus || !hasSelection)
return NO;
if (aSelector == @selector(copy:))
return YES;
// can edit only if selection is not a separator
return (((FeedGroup*)self.dataStore.selectedNodes.firstObject.representedObject).type != SEPARATOR);
}
return [super respondsToSelector:aSelector];
}
/// Perform undo operation and redraw UI & menu bar unread count
- (void)undo:(id)sender {
[self.undoManager undo];
[self saveWithUnpredictableChange];
}
/// Perform redo operation and redraw UI & menu bar unread count
- (void)redo:(id)sender {
[self.undoManager redo];
[self saveWithUnpredictableChange];
}
/// User pressed enter; open edit dialog for selected item.
- (void)enterPressed:(id)sender {
[self showModalForFeedGroup:self.dataStore.selectedObjects.firstObject isGroupEdit:YES]; // yes will be overwritten anyway
}
/// Copy human readable description of selected nodes to clipboard.
- (void)copy:(id)sender {
NSMutableString *str = [[NSMutableString alloc] init];
NSUInteger count = self.dataStore.selectedNodes.count;
NSMutableArray<NSTreeNode*> *groups = [NSMutableArray arrayWithCapacity:count];
// filter out nodes that are already present in some selected parent node
for (NSTreeNode *node in self.dataStore.selectedNodes) {
BOOL skipItem = NO;
for (NSTreeNode *stored in groups) {
NSIndexPath *p = node.indexPath;
while (p.length > stored.indexPath.length)
p = [p indexPathByRemovingLastIndex];
if ([p isEqualTo:stored.indexPath]) {
skipItem = YES;
break;
}
}
if (!skipItem) {
[self traverseChildren:node appendString:str prefix:@""];
if (node.childNodes.count > 0)
[groups addObject:node];
}
}
[[NSPasteboard generalPasteboard] clearContents];
[[NSPasteboard generalPasteboard] setString:str forType:NSPasteboardTypeString];
}
/**
Go through all children recursively and prepend the string with spaces as nesting
@param obj Root Node or parent Node
@param str An initialized @c NSMutableString to append to
@param prefix Should be @c @@"" for the first call
Expected user selection as displayed in outline (border highlight).
Return clicked row only if it isn't included in the selection.
*/
- (void)traverseChildren:(NSTreeNode*)obj appendString:(NSMutableString*)str prefix:(NSString*)prefix {
[str appendFormat:@"%@%@\n", prefix, [obj.representedObject readableDescription]];
prefix = [prefix stringByAppendingString:@" "];
for (NSTreeNode *child in obj.childNodes) {
[self traverseChildren:child appendString:str prefix:prefix];
- (NSArray<NSTreeNode*>*)userSelectionAll {
NSOutlineView *ov = self.view.outline;
NSTreeNode *clicked = [ov itemAtRow: ov.clickedRow];
if (!clicked || [self.dataStore.selectedNodes containsObject:clicked]) {
return self.dataStore.selectedNodes;
}
return @[clicked];
}
/// Return clicked row (if present) or first selected node otherwise.
- (NSTreeNode*)userSelectionFirst {
NSTreeNode *clicked = [self.view.outline itemAtRow: self.view.outline.clickedRow];
if (clicked) return clicked;
return self.dataStore.selectedNodes.firstObject;
}
/// Loop over all descendants and update @c sortIndex @c (FeedGroup) as well as all @c indexPath @c (Feed)
- (void)restoreOrderingAndIndexPathStr:(NSArray<NSTreeNode*>*)parentsList {
for (NSTreeNode *parent in parentsList) {
for (NSUInteger i = 0; i < parent.childNodes.count; i++) {
FeedGroup *fg = parent.childNodes[i].representedObject;
[fg setSortIndexIfChanged:(int32_t)i];
}
}
}

View File

@@ -1,250 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14313.18" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14313.18"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="SettingsFeeds">
<connections>
<outlet property="dataStore" destination="JPf-gH-wxm" id="9qy-D6-L4R"/>
<outlet property="outlineView" destination="wP9-Vd-f79" id="nKf-fc-7Np"/>
<outlet property="spinner" destination="fos-vP-s2s" id="zZp-Op-ftK"/>
<outlet property="spinnerLabel" destination="44U-lx-hnq" id="GGB-H5-7LV"/>
<outlet property="view" destination="zfc-Ie-Sdx" id="65R-bK-FDI"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<treeController mode="entity" entityName="FeedGroup" fetchPredicateFormat="parent == nil" automaticallyPreparesContent="YES" childrenKeyPath="children" leafKeyPath="type" id="JPf-gH-wxm"/>
<customView id="zfc-Ie-Sdx" userLabel="View">
<rect key="frame" x="0.0" y="0.0" width="320" height="327"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<scrollView fixedFrame="YES" autohidesScrollers="YES" horizontalLineScroll="20" horizontalPageScroll="10" verticalLineScroll="20" verticalPageScroll="10" usesPredominantAxisScrolling="NO" translatesAutoresizingMaskIntoConstraints="NO" id="f1F-Mv-bod">
<rect key="frame" x="0.0" y="20" width="320" height="307"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<clipView key="contentView" ambiguous="YES" id="oIL-kH-Krb">
<rect key="frame" x="1" y="0.0" width="318" height="306"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<outlineView verticalHuggingPriority="750" allowsExpansionToolTips="YES" columnAutoresizingStyle="firstColumnOnly" alternatingRowBackgroundColors="YES" columnReordering="NO" columnResizing="NO" autosaveColumns="NO" typeSelect="NO" rowHeight="18" rowSizeStyle="automatic" headerView="uEa-oG-fr0" viewBased="YES" indentationPerLevel="15" outlineTableColumn="3Eq-bQ-AGJ" id="wP9-Vd-f79">
<rect key="frame" x="0.0" y="0.0" width="318" height="283"/>
<autoresizingMask key="autoresizingMask"/>
<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 identifier="NameColumn" editable="NO" width="262" minWidth="40" maxWidth="10000" id="3Eq-bQ-AGJ">
<tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border" title="Name">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="headerColor" catalog="System" colorSpace="catalog"/>
</tableHeaderCell>
<textFieldCell key="dataCell" lineBreakMode="truncatingTail" selectable="YES" editable="YES" title="Text Cell" id="gLU-zA-WTf">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
<tableColumnResizingMask key="resizingMask" resizeWithTable="YES"/>
<prototypeCellViews>
<tableCellView identifier="cellFeed" id="066-5N-dID" userLabel="Feed">
<rect key="frame" x="1" y="1" width="262" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="qHf-yW-Ks4" userLabel="img">
<rect key="frame" x="1" y="1" width="16" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" image="NSActionTemplate" id="NRq-gp-RJ5"/>
</imageView>
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" fixedFrame="YES" allowsExpansionToolTips="YES" translatesAutoresizingMaskIntoConstraints="NO" id="n7N-Pk-80l" userLabel="str">
<rect key="frame" x="23" y="0.0" width="241" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="Table View Cell" id="wHQ-uQ-pww">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<connections>
<outlet property="imageView" destination="qHf-yW-Ks4" id="LBQ-xL-3vr"/>
<outlet property="textField" destination="n7N-Pk-80l" id="ei3-ux-jga"/>
</connections>
</tableCellView>
<tableCellView identifier="cellSeparator" id="tjK-7n-uRz" userLabel="Separator">
<rect key="frame" x="1" y="21" width="262" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<customView fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="G7f-uh-abm" userLabel="img" customClass="DrawSeparator">
<rect key="frame" x="0.0" y="0.0" width="262" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</customView>
</subviews>
</tableCellView>
</prototypeCellViews>
<connections>
<binding destination="JPf-gH-wxm" name="value" keyPath="arrangedObjects" id="HfC-oh-cnN">
<dictionary key="options">
<bool key="NSConditionallySetsEditable" value="YES"/>
<bool key="NSCreatesSortDescriptor" value="NO"/>
</dictionary>
</binding>
</connections>
</tableColumn>
<tableColumn identifier="RefreshColumn" editable="NO" width="50" minWidth="40" maxWidth="100" id="N3k-JC-Czy">
<tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border" title="Refresh">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="headerColor" catalog="System" colorSpace="catalog"/>
</tableHeaderCell>
<textFieldCell key="dataCell" lineBreakMode="truncatingTail" selectable="YES" editable="YES" title="Text Cell" id="bQw-cL-PQs">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
<prototypeCellViews>
<tableCellView identifier="cellRefresh" id="Qyt-7v-t3G" userLabel="cellView">
<rect key="frame" x="266" y="1" width="50" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" fixedFrame="YES" allowsExpansionToolTips="YES" translatesAutoresizingMaskIntoConstraints="NO" id="17I-Oo-q9s" userLabel="str">
<rect key="frame" x="-1" y="0.0" width="52" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" alignment="right" title="21042s" id="ZlY-7o-ZTa">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<connections>
<outlet property="textField" destination="17I-Oo-q9s" id="i0p-KF-aE8"/>
</connections>
</tableCellView>
</prototypeCellViews>
<connections>
<binding destination="JPf-gH-wxm" name="value" keyPath="arrangedObjects" id="aq0-dy-F1G">
<dictionary key="options">
<bool key="NSConditionallySetsEditable" value="YES"/>
<bool key="NSCreatesSortDescriptor" value="NO"/>
</dictionary>
</binding>
</connections>
</tableColumn>
</tableColumns>
<connections>
<action trigger="doubleAction" selector="doubleClickOutlineView:" target="-2" id="nqp-9A-7ac"/>
<outlet property="dataSource" destination="-2" id="3Iv-Pa-dvh"/>
<outlet property="delegate" destination="-2" id="eCu-Hd-4Ct"/>
</connections>
</outlineView>
</subviews>
</clipView>
<scroller key="horizontalScroller" hidden="YES" verticalHuggingPriority="750" horizontal="YES" id="xsa-8D-Emz">
<rect key="frame" x="1" y="7" width="0.0" height="16"/>
<autoresizingMask key="autoresizingMask"/>
</scroller>
<scroller key="verticalScroller" hidden="YES" verticalHuggingPriority="750" horizontal="NO" id="p12-eT-ex6">
<rect key="frame" x="-15" y="23" width="16" height="0.0"/>
<autoresizingMask key="autoresizingMask"/>
</scroller>
<tableHeaderView key="headerView" id="uEa-oG-fr0">
<rect key="frame" x="0.0" y="0.0" width="318" height="23"/>
<autoresizingMask key="autoresizingMask"/>
</tableHeaderView>
</scrollView>
<button toolTip="Create new feed item" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="3dn-fo-MZT">
<rect key="frame" x="0.0" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" alternateTitle="Add feed" bezelStyle="smallSquare" image="NSAddTemplate" imagePosition="overlaps" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="mfH-K0-yNS">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent">n</string>
<modifierMask key="keyEquivalentModifierMask" command="YES"/>
</buttonCell>
<connections>
<action selector="addFeed:" target="-2" id="iWE-sh-KY1"/>
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="TJb-gv-6gO"/>
</connections>
</button>
<button toolTip="Delete item" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Xxm-75-8K8">
<rect key="frame" x="24" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" alternateTitle="Remove Feed" bezelStyle="smallSquare" image="NSRemoveTemplate" imagePosition="overlaps" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="6iS-E4-jzq">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
CA
</string>
<modifierMask key="keyEquivalentModifierMask" command="YES"/>
</buttonCell>
<connections>
<action selector="remove:" target="-2" id="JeR-iq-Gjb"/>
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canRemove" id="XYY-gx-tiN"/>
</connections>
</button>
<button toolTip="Add new grouping folder" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="jPg-sh-1Az">
<rect key="frame" x="64" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" alternateTitle="Add group" bezelStyle="smallSquare" image="NSPathTemplate" imagePosition="overlaps" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="rPk-c8-lMe">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent">g</string>
<modifierMask key="keyEquivalentModifierMask" command="YES"/>
</buttonCell>
<connections>
<action selector="addGroup:" target="-2" id="V3k-2H-4Kc"/>
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="JCF-ey-YUL"/>
</connections>
</button>
<button toolTip="Add new line separator" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kn9-pd-A47">
<rect key="frame" x="88" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" title="---" alternateTitle="Add separator" bezelStyle="smallSquare" image="NSPathTemplate" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="r9B-nl-XkX">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="addSeparator:" target="-2" id="dVQ-ge-moI"/>
<binding destination="JPf-gH-wxm" name="enabled" keyPath="canInsert" id="2aK-XU-RUD"/>
</connections>
</button>
<button toolTip="Import or Export data" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6ul-3K-fOy">
<rect key="frame" x="128" y="-1" width="25" height="23"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="smallSquare" alternateTitle="Export" bezelStyle="smallSquare" image="NSShareTemplate" imagePosition="overlaps" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="nrA-7c-1sL">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="shareMenu:" target="-2" id="JJq-7D-Bti"/>
</connections>
</button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="44U-lx-hnq">
<rect key="frame" x="166" y="4" width="141" height="14"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMaxY="YES"/>
<textFieldCell key="cell" lineBreakMode="clipping" alignment="center" title="&lt;string&gt;" id="yyA-K6-M3v">
<font key="font" metaFont="smallSystem"/>
<color key="textColor" name="systemGrayColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<progressIndicator wantsLayer="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" maxValue="100" displayedWhenStopped="NO" bezeled="NO" indeterminate="YES" controlSize="small" style="spinning" translatesAutoresizingMaskIntoConstraints="NO" id="fos-vP-s2s">
<rect key="frame" x="301" y="3" width="16" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
</progressIndicator>
</subviews>
<point key="canvasLocation" x="27" y="882.5"/>
</customView>
<viewController id="TaZ-4L-TdU" customClass="ModalFeedEdit"/>
</objects>
<resources>
<image name="NSActionTemplate" width="14" height="14"/>
<image name="NSAddTemplate" width="11" height="11"/>
<image name="NSPathTemplate" width="16" height="10"/>
<image name="NSRemoveTemplate" width="11" height="11"/>
<image name="NSShareTemplate" width="11" height="16"/>
</resources>
</document>

View File

@@ -0,0 +1,47 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class SettingsFeeds;
@interface SettingsFeedsView : NSView
@property (weak) IBOutlet NSOutlineView *outline;
@property (weak) IBOutlet NSTextField *status;
@property (weak) IBOutlet NSProgressIndicator *spinner;
- (instancetype)initWithController:(SettingsFeeds*)delegate NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE;
@end
@interface NameColumnCell : NSTableCellView
extern NSUserInterfaceItemIdentifier const CustomCellName;
@end
@interface RefreshColumnCell : NSTableCellView
extern NSUserInterfaceItemIdentifier const CustomCellRefresh;
@end
@interface SeparatorColumnCell : NSTableCellView
extern NSUserInterfaceItemIdentifier const CustomCellSeparator;
@end

View File

@@ -0,0 +1,257 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "SettingsFeedsView.h"
#import "StoreCoordinator.h"
#import "FeedGroup+Ext.h"
#import "DrawImage.h"
#import "SettingsFeeds.h"
#import "NSDate+Ext.h"
#import "NSView+Ext.h"
@interface SettingsFeedsView()
@property (weak) SettingsFeeds *controller;
@end
@implementation SettingsFeedsView
- (instancetype)initWithController:(SettingsFeeds*)delegate {
self = [super initWithFrame:NSZeroRect];
if (self) {
self.controller = delegate; // make sure its first
self.outline = [self generateOutlineView]; // uses self.controller
[self wrapContent:self.outline inScrollView:NSMakeRect(0, 20, NSWidth(self.frame), NSHeight(self.frame) - 20)];
self.outline.menu = [self generateCommandsMenu];
[self.outline.menu.itemArray makeObjectsPerformSelector:@selector(setTarget:) withObject:delegate];
CGFloat x = [self generateButtons]; // uses self.controller and self.outline
// Setup status text field ('Next update in X min.' or 'Updating X feeds ...')
self.status = [[[[[[NSView label:@""] small] gray] textCenter] placeIn:self x:x + PAD_L y:3.5] sizeToRight:PAD_L];
self.spinner = [[NSView activitySpinner] placeIn:self xRight:2 y:2];
}
return self;
}
/**
Setup @c self.outline
@note Requires @c self.controller
*/
- (NSOutlineView*)generateOutlineView {
// Generate outline view
NSOutlineView *o = [[NSOutlineView alloc] init];
o.columnAutoresizingStyle = NSTableViewFirstColumnOnlyAutoresizingStyle;
o.usesAlternatingRowBackgroundColors = YES;
o.allowsMultipleSelection = YES;
o.allowsColumnReordering = NO;
o.allowsColumnSelection = NO;
o.allowsEmptySelection = YES;
//o.intercellSpacing = NSMakeSize(3, 2);
o.rowHeight = 18;
[self setOutlineColumns:o];
// Setup action and bindings
SettingsFeeds *sf = self.controller;
o.target = sf;
o.doubleAction = @selector(doubleClickOutlineView:);
[o bind:NSContentBinding toObject:sf.dataStore withKeyPath:@"arrangedObjects" options:nil]; // @{NSAlwaysPresentsApplicationModalAlertsBindingOption:@YES}
[o bind:NSSelectionIndexPathsBinding toObject:sf.dataStore withKeyPath:@"selectionIndexPaths" options:nil];
return o;
}
/// Generate table columns 'Name' and 'Refresh'
- (void)setOutlineColumns:(NSOutlineView*)outline {
NSTableColumn *colName = [[NSTableColumn alloc] initWithIdentifier:CustomCellName];
colName.title = NSLocalizedString(@"Name", nil);
colName.width = 10000;
colName.maxWidth = 10000;
colName.resizingMask = NSTableColumnAutoresizingMask;
[outline addTableColumn:colName];
NSTableColumn *colRefresh = [[NSTableColumn alloc] initWithIdentifier:CustomCellRefresh];
colRefresh.title = NSLocalizedString(@"Refresh", nil);
colRefresh.width = 60;
colRefresh.resizingMask = NSTableColumnNoResizing;
[outline addTableColumn:colRefresh];
for (NSTableColumn *col in outline.tableColumns) {
col.headerCell.title = [NSString stringWithFormat:@" %@", col.title];
NSDictionary *attr = @{ NSFontAttributeName: [NSFont systemFontOfSize:NSFont.smallSystemFontSize weight:NSFontWeightMedium] };
col.headerCell.attributedStringValue = [[NSAttributedString alloc] initWithString:col.title attributes:attr];
}
outline.outlineTableColumn = colName;
}
/// Setup right click menu (also used for hotkeys).
- (NSMenu*)generateCommandsMenu {
NSMenu *m = [[NSMenu alloc] initWithTitle:@""];
[m addItemWithTitle:NSLocalizedString(@"Edit Item", nil) action:@selector(editSelectedItem) keyEquivalent:[NSString stringWithFormat:@"%c", NSCarriageReturnCharacter]].keyEquivalentModifierMask = 0;
[m addItemWithTitle:NSLocalizedString(@"Delete Item(s)", nil) action:@selector(remove:) keyEquivalent:[NSString stringWithFormat:@"%c", NSBackspaceCharacter]];
[m addItem:[NSMenuItem separatorItem]]; // index: 2
[m addItemWithTitle:NSLocalizedString(@"New Feed", nil) action:@selector(addFeed) keyEquivalent:@"n"];
[m addItemWithTitle:NSLocalizedString(@"New Group", nil) action:@selector(addGroup) keyEquivalent:@"g"];
[m addItemWithTitle:NSLocalizedString(@"New Separator", nil) action:@selector(addSeparator) keyEquivalent:@""];
[m addItem:[NSMenuItem separatorItem]]; // index: 6
[m addItemWithTitle:NSLocalizedString(@"Import Feeds …", nil) action:@selector(openImportDialog) keyEquivalent:@"o"];
[m addItemWithTitle:NSLocalizedString(@"Export Feeds …", nil) action:@selector(openExportDialog) keyEquivalent:@"s"];
[m addItem:[NSMenuItem separatorItem]]; // index: 9
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
[m addItemWithTitle:NSLocalizedString(@"Undo", nil) action:@selector(undo:) keyEquivalent:@"z"];
[m addItemWithTitle:NSLocalizedString(@"Redo", nil) action:@selector(redo:) keyEquivalent:@"Z"];
#pragma clang diagnostic pop
return m;
}
/**
Setup the bottom button bar. (e.g., add, remove, edit, export, import, etc.)
@note Requires @c self.controller and @c self.outline
@return Max x-value of last button frame
*/
- (CGFloat)generateButtons {
NSButton *add = [[NSView buttonImageSquare:NSImageNameAddTemplate] tooltip:NSLocalizedString(@"Add new item", nil)];
NSButton *del = [[NSView buttonImageSquare:NSImageNameRemoveTemplate] tooltip:NSLocalizedString(@"Delete selected items", nil)];
NSButton *share = [[NSView buttonImageSquare:NSImageNameShareTemplate] tooltip:NSLocalizedString(@"Import or export data", nil)];
[self button:add copyActions:3 to:5];
[self button:del copyActions:1 to:1];
[self button:share copyActions:7 to:8]; // TODO: Add menus for online sync? email export? etc.
[add placeIn:self x:0 y:0];
[del placeIn:self x:24 y:0];
[share placeIn:self x:2 * 24 + PAD_L y:0];
NSTreeController *tc = self.controller.dataStore;
[add bind:NSEnabledBinding toObject:tc withKeyPath:@"canInsert" options:nil];
[del bind:NSEnabledBinding toObject:tc withKeyPath:@"canRemove" options:nil];
return NSMaxX(share.frame);
}
/**
Duplicate right click menu actions to button
@note Requires @c self.outline
*/
- (void)button:(NSButton*)btn copyActions:(NSInteger)start to:(NSInteger)end {
if (start < 0 || start > end || end >= self.outline.menu.numberOfItems) {
NSAssert(NO, @"Invalid index, can't copy command menu items.");
return;
}
if (start == end) {
// copy menu item action to button action
NSMenuItem *source = [self.outline.menu itemAtIndex:start];
[btn action:source.action target:source.target];
btn.keyEquivalent = source.keyEquivalent;
btn.keyEquivalentModifierMask = source.keyEquivalentModifierMask;
} else {
// create drop down menu with all options
btn.menu = [[NSMenu alloc] initWithTitle:@""];
[btn action:@selector(openButtonMenu:) target:self];
for (NSInteger i = start; i <= end; i++) {
[btn.menu addItem:[[self.outline.menu itemAtIndex:i] copy]];
}
}
}
/// Show drop down menu even for left click.
- (void)openButtonMenu:(NSButton*)sender {
//[NSMenu popUpContextMenu:sender.menu withEvent:[NSApp currentEvent] forView:sender];
[sender.menu popUpMenuPositioningItem:nil atLocation:NSMakePoint(0, NSHeight(sender.frame)) inView:sender];
}
@end
#pragma mark - Custom Outline View Cells -
/**
First outline view column, with textfield and feed icon
*/
@implementation NameColumnCell
/// Identifier for cell with @c .imageView (feed icon) and @c .textField (feed title)
NSUserInterfaceItemIdentifier const CustomCellName = @"NameColumnCell";
- (instancetype)initWithFrame:(NSRect)frameRect {
self = [super initWithFrame:frameRect];
self.identifier = CustomCellName;
self.imageView = [[NSView imageView:nil size:16] placeIn:self x:1 yTop:1];
self.imageView.accessibilityLabel = NSLocalizedString(@"Feed icon", nil);
self.textField = [[[NSView label:@""] placeIn:self x:25 yTop:0] sizeToRight:0];
self.textField.accessibilityLabel = NSLocalizedString(@"Feed title", nil);
return self;
}
- (void)setObjectValue:(FeedGroup*)fg {
self.textField.objectValue = fg.anyName;
self.imageView.image = fg.iconImage16;
}
@end
/**
Second outline view column, either refresh string or empty
*/
@implementation RefreshColumnCell
/// Identifier for cell with @c .textField (refresh string or empty)
NSUserInterfaceItemIdentifier const CustomCellRefresh = @"RefreshColumnCell";
- (instancetype)initWithFrame:(NSRect)frameRect {
self = [super initWithFrame:frameRect];
self.identifier = CustomCellRefresh;
self.textField = [[[[NSView label:@""] textRight] placeIn:self x:0 yTop:0] sizeToRight:0];
self.textField.accessibilityTitle = @" "; // otherwise groups and separators will say 'text'
return self;
}
- (void)setObjectValue:(FeedGroup*)fg {
NSString *str = @"";
if (fg.type == FEED) {
int32_t refresh = fg.feed.meta.refresh;
str = (refresh <= 0 ? @"∞" : [NSDate intStringForInterval:refresh]); // ƒ Ø
}
self.textField.objectValue = str;
self.textField.textColor = (str.length > 1 ? [NSColor controlTextColor] : [NSColor disabledControlTextColor]);
self.textField.accessibilityLabel = (str.length > 1 ? NSLocalizedString(@"Refresh interval", nil) : nil);
}
@end
/**
First outline view column, separator line
*/
@implementation SeparatorColumnCell
/// Identifier for cell with line separator
NSUserInterfaceItemIdentifier const CustomCellSeparator = @"SeparatorColumnCell";
- (instancetype)initWithFrame:(NSRect)frameRect {
self = [super initWithFrame:frameRect];
self.identifier = CustomCellSeparator;
[[[[DrawSeparator alloc] initWithFrame:self.frame] placeIn:self x:0 y:0] sizableWidthAndHeight];
return self;
}
- (void)setObjectValue:(FeedGroup*)fg { /* do nothing */ }
@end

View File

@@ -20,8 +20,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
@import Cocoa;
@interface SettingsGeneral : NSViewController
@property (assign) IBOutlet NSView *appearanceView;
- (void)changeHttpApplication:(NSPopUpButton *)sender;
- (void)clickHowToDefaults:(NSButton *)sender;
@end

View File

@@ -21,172 +21,68 @@
// SOFTWARE.
#import "SettingsGeneral.h"
#import "AppHook.h"
#import "BarStatusItem.h"
#import "UserPrefs.h"
#import "StoreCoordinator.h"
#import "Constants.h"
#import <ServiceManagement/ServiceManagement.h>
#import "SettingsGeneralView.h"
@interface SettingsGeneral()
@property (weak) IBOutlet NSPopUpButton *popupHttpApplication;
@property (weak) IBOutlet NSPopUpButton *popupDefaultRSSReader;
@property (strong) IBOutlet SettingsGeneralView *view; // override
@end
@implementation SettingsGeneral
@dynamic view;
- (void)viewDidLoad {
[super viewDidLoad];
- (void)loadView {
self.view = [[SettingsGeneralView alloc] initWithController:self];
// Default http application for opening the feed urls
[self generateMenuForPopup:self.popupHttpApplication withScheme:@"https"];
[self.popupHttpApplication insertItemWithTitle:NSLocalizedString(@"System Default", @"Default web browser application") atIndex:0];
[self selectBundleID:[UserPrefs getHttpApplication] inPopup:self.popupHttpApplication];
NSPopUpButton *pop = self.view.popupHttpApplication;
[pop removeAllItems];
[pop addItemWithTitle:NSLocalizedString(@"System Default", @"Default web browser application")];
NSArray<NSString*> *browsers = CFBridgingRelease(LSCopyAllHandlersForURLScheme(CFSTR("https")));
for (NSString *bundleID in browsers) {
[pop addItemWithTitle: [self applicationNameForBundleId:bundleID]];
pop.lastItem.representedObject = bundleID;
}
[pop selectItemAtIndex:[pop indexOfItemWithRepresentedObject:UserPrefsString(Pref_defaultHttpApplication)]];
// Default RSS Reader application
[self generateMenuForPopup:self.popupDefaultRSSReader withScheme:@"feed"];
[self selectBundleID:[self defaultBundleIdForScheme:@"feed"] inPopup:self.popupDefaultRSSReader];
NSString *feedBundleId = CFBridgingRelease(LSCopyDefaultHandlerForURLScheme(CFSTR("feed")));
self.view.defaultReader.objectValue = [self applicationNameForBundleId:feedBundleId];
}
#pragma mark - UI interaction with IBAction
/// Run helper application to add thyself to startup items.
- (IBAction)changeStartOnLogin:(NSButton *)sender {
// launchctl list | grep de.relikd
CFStringRef helperIdentifier = CFBridgingRetain(@"de.relikd.baRSS-Helper");
Boolean setOnLogin = (sender.state == NSControlStateValueOn);
if (!helperIdentifier || !SMLoginItemSetEnabled(helperIdentifier, setOnLogin))
sender.state = (setOnLogin ? NSControlStateValueOff : NSControlStateValueOn); // restore prev state
if (helperIdentifier)
CFRelease(helperIdentifier);
/// Get human readable application name such as 'Safari' or 'baRSS'
- (nonnull NSString*)applicationNameForBundleId:(nonnull NSString*)bundleID {
NSString *name;
NSArray<NSURL*> *urls = CFBridgingRelease(LSCopyApplicationURLsForBundleIdentifier((__bridge CFStringRef)bundleID, NULL));
if (urls.count > 0) {
NSDictionary *info = CFBridgingRelease(CFBundleCopyInfoDictionaryForURL((CFURLRef)urls.firstObject));
name = info[(NSString*)kCFBundleExecutableKey];
}
return name ? name : bundleID;
}
- (IBAction)fixCache:(NSButton *)sender {
NSUInteger deleted = [StoreCoordinator deleteUnreferenced];
[StoreCoordinator restoreFeedIndexPaths];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil];
// show only if >0, but hey, this button will vanish anyway ...
#pragma mark - User interaction
// Callback method fired when user selects a different item from popup list
- (void)changeHttpApplication:(NSPopUpButton *)sender {
UserPrefsSet(Pref_defaultHttpApplication, sender.selectedItem.representedObject);
}
// Callback method from round help button right of default feed reader text
- (void)clickHowToDefaults:(NSButton *)sender {
NSAlert *alert = [[NSAlert alloc] init];
alert.messageText = [NSString stringWithFormat:@"Removed %lu unreferenced core data entries.", deleted];
alert.alertStyle = NSAlertStyleInformational;
[alert runModal];
}
- (IBAction)changeMenuBarIconSetting:(NSButton*)sender {
[[(AppHook*)NSApp statusItem] updateBarIcon];
}
- (IBAction)changeHttpApplication:(NSPopUpButton *)sender {
[UserPrefs setHttpApplication:sender.selectedItem.representedObject];
}
- (IBAction)changeDefaultRSSReader:(NSPopUpButton *)sender {
if ([self setDefaultRSSApplication:sender.selectedItem.representedObject] == NO) {
// in case anything went wrong, restore previous selection
[self selectBundleID:[self defaultBundleIdForScheme:@"feed"] inPopup:sender];
alert.messageText = NSLocalizedString(@"How to change default feed reader", nil);
alert.informativeText = NSLocalizedString(@"Unfortunately sandboxed applications are not allowed to change the default application. However, there is an auxiliary application.\n\nFollow the instructions to change the 'feed:' scheme.", nil);
[alert addButtonWithTitle:NSLocalizedString(@"Close", nil)];
[alert addButtonWithTitle:NSLocalizedString(@"Go to download page", nil)].toolTip = auxiliaryAppURL;
[alert beginSheetModalForWindow:self.view.window completionHandler:^(NSModalResponse returnCode) {
if (returnCode == NSAlertSecondButtonReturn) {
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:auxiliaryAppURL]];
}
}];
}
#pragma mark - Helper methods
/**
Populate @c NSPopUpButton menu with all available application for that scheme.
@param scheme URL scheme like @c 'feed' or @c 'https'
*/
- (void)generateMenuForPopup:(NSPopUpButton*)popup withScheme:(NSString*)scheme {
[popup removeAllItems];
NSArray<NSString*> *apps = [self listOfBundleIdsForScheme:scheme];
for (NSString *bundleID in apps) {
NSString *appName = [self applicationNameForBundleId:bundleID];
if (!appName)
appName = bundleID;
[popup addItemWithTitle:appName];
popup.lastItem.representedObject = bundleID;
}
}
/**
For a given @c NSPopUpButton select the item which represents the @c bundleID.
*/
- (void)selectBundleID:(NSString*)bundleID inPopup:(NSPopUpButton*)popup {
[popup selectItemAtIndex:[popup indexOfItemWithRepresentedObject:bundleID]];
}
/**
Get human readable, application name from @c bundleID.
@param bundleID as defined in @c Info.plist
@return Application name such as 'Safari' or 'baRSS'
*/
- (NSString*)applicationNameForBundleId:(NSString*)bundleID {
CFStringRef bundleIDRef = CFBridgingRetain(bundleID);
if (!bundleIDRef)
return nil;
CFArrayRef arr = LSCopyApplicationURLsForBundleIdentifier(bundleIDRef, NULL);
CFRelease(bundleIDRef);
if (!arr)
return nil;
CFDictionaryRef infoDict = NULL;
if (CFArrayGetCount(arr) > 0)
infoDict = CFBundleCopyInfoDictionaryForURL(CFArrayGetValueAtIndex(arr, 0));
CFRelease(arr);
if (!infoDict)
return nil;
NSString *name = CFDictionaryGetValue(infoDict, kCFBundleNameKey);
CFRelease(infoDict);
return name;
}
/**
Get a list of all installed applications supporting that URL scheme.
@param scheme URL scheme like @c 'feed' or @c 'https'
@return Array of @c bundleIDs of installed applications supporting that url scheme.
*/
- (NSArray<NSString*>*)listOfBundleIdsForScheme:(NSString*)scheme {
CFStringRef schemeRef = CFBridgingRetain(scheme);
if (!schemeRef)
return nil;
CFArrayRef allHandlers = LSCopyAllHandlersForURLScheme(schemeRef);
CFRelease(schemeRef);
return (NSArray*)CFBridgingRelease(allHandlers);
}
/**
Get current default application for provided URL scheme. (e.g., )
@param scheme URL scheme like @c 'feed' or @c 'https'
@return @c bundleID of default application
*/
- (NSString*)defaultBundleIdForScheme:(NSString*)scheme {
CFStringRef schemeRef = CFBridgingRetain(scheme);
if (!schemeRef)
return nil;
CFStringRef defaultHandler = LSCopyDefaultHandlerForURLScheme(schemeRef);
CFRelease(schemeRef);
return (NSString*)CFBridgingRelease(defaultHandler);
}
/**
Sets the default application for @c feed:// urls. (system wide)
@param bundleID as defined in @c Info.plist
@return Return @c YES if operation was successfull. @c NO otherwise.
*/
- (BOOL)setDefaultRSSApplication:(NSString*)bundleID {
// TODO: Does not work with sandboxing.
CFStringRef bundleIDRef = CFBridgingRetain(bundleID);
if (!bundleIDRef)
return NO;
CFStringRef schemeRef = CFBridgingRetain(@"feed");
if (!schemeRef) {
CFRelease(bundleIDRef);
return NO;
}
OSStatus s = LSSetDefaultHandlerForURLScheme(schemeRef, bundleIDRef);
CFRelease(schemeRef);
CFRelease(bundleIDRef);
return s == 0;
}
// x-apple.systempreferences:com.apple.preferences.users?startupItemsPref
@end

View File

@@ -1,504 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14313.18" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14313.18"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="SettingsGeneral">
<connections>
<outlet property="appearanceView" destination="Wwh-0p-tPi" id="51l-Wp-k0J"/>
<outlet property="popupDefaultRSSReader" destination="tJe-jL-nUu" id="DUq-ti-Drf"/>
<outlet property="popupHttpApplication" destination="BcN-gW-jBg" id="X2r-Nn-igN"/>
<outlet property="view" destination="mbb-wD-pDD" id="Syb-4w-ekh"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<userDefaultsController representsSharedInstance="YES" id="iU7-KA-nY5"/>
<customView id="mbb-wD-pDD" userLabel="View">
<rect key="frame" x="0.0" y="0.0" width="320" height="327"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="XF4-m8-sya">
<rect key="frame" x="18" y="291" width="284" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" title="Start on login" bezelStyle="regularSquare" imagePosition="left" inset="2" id="WwD-9B-kMx">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="changeStartOnLogin:" target="-2" id="e05-eb-6ni"/>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.startOnLogin" id="LnI-lN-nUf">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="0"/>
</dictionary>
</binding>
</connections>
</button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2sG-NO-OJz">
<rect key="frame" x="18" y="254" width="133" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="Open URLs with:" id="vNb-i3-dvE">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<popUpButton verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="BcN-gW-jBg">
<rect key="frame" x="155" y="249" width="148" height="26"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<popUpButtonCell key="cell" type="push" title="-- list --" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="qW6-vv-pdE" id="R91-En-pHg">
<behavior key="behavior" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="menu"/>
<menu key="menu" id="M0i-AE-1LS">
<items>
<menuItem title="-- list --" state="on" id="qW6-vv-pdE"/>
</items>
</menu>
</popUpButtonCell>
<connections>
<action selector="changeHttpApplication:" target="-2" id="Cyb-ab-VNu"/>
</connections>
</popUpButton>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="TC5-cu-zUi">
<rect key="frame" x="18" y="227" width="133" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="right" title="Default RSS Reader:" id="wvK-Oz-Kk3">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<popUpButton verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="tJe-jL-nUu">
<rect key="frame" x="155" y="222" width="148" height="26"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<popUpButtonCell key="cell" type="push" title="-- list --" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="4Gg-hZ-mh4" id="saR-9h-TWE">
<behavior key="behavior" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="menu"/>
<menu key="menu" id="8gY-aZ-fCb">
<items>
<menuItem title="-- list --" state="on" id="4Gg-hZ-mh4"/>
</items>
</menu>
</popUpButtonCell>
<connections>
<action selector="changeDefaultRSSReader:" target="-2" id="ul1-1K-oJb"/>
</connections>
</popUpButton>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="QwE-M7-q2R">
<rect key="frame" x="151" y="13" width="155" height="32"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<buttonCell key="cell" type="push" title="Fix Cache" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="ady-2s-Ggm">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="fixCache:" target="-2" id="gbM-hA-UVF"/>
</connections>
</button>
</subviews>
<point key="canvasLocation" x="33" y="-153.5"/>
</customView>
<customView id="Wwh-0p-tPi" userLabel="Appearance View">
<rect key="frame" x="0.0" y="0.0" width="320" height="327"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="c5z-lV-vas">
<rect key="frame" x="18" y="241" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="fhM-ZU-dqf">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.globalUpdateAll" id="ObW-85-BJh">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="qwe-HI-3qV">
<rect key="frame" x="18" y="219" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="PFz-Ow-r4F">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.globalOpenUnread" id="1gJ-DS-qv0">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ROH-bm-RYb">
<rect key="frame" x="44" y="219" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="z0G-PF-7X4">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.groupOpenUnread" id="IVo-sw-mcs">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="a64-GA-uqO">
<rect key="frame" x="70" y="219" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="5lC-Kd-cxG">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.feedOpenUnread" id="3NW-RY-kOa">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="IAr-hA-5en">
<rect key="frame" x="18" y="197" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="pfa-9f-faM">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.globalMarkRead" id="ZwQ-Dn-ocg">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="HVG-vG-GIU">
<rect key="frame" x="44" y="197" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="oje-pE-GW8">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.groupMarkRead" id="hya-HG-RtW">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Gtu-6h-y3W">
<rect key="frame" x="70" y="197" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="t0S-h2-fFL">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.feedMarkRead" id="ILe-xm-ITh">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="utE-1U-oPJ">
<rect key="frame" x="18" y="175" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="HwB-CY-h1x">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.globalMarkUnread" id="vc4-oK-5yY">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6wd-KD-Vq2">
<rect key="frame" x="44" y="175" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="9UH-v7-h2R">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.groupMarkUnread" id="bUj-qA-Wnt">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="upF-tg-Zfs">
<rect key="frame" x="70" y="175" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="8d6-wr-mdT">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.feedMarkUnread" id="0ES-Df-AI3">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="E0O-SU-lzt">
<rect key="frame" x="18" y="153" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="Vyz-7h-H3B">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="changeMenuBarIconSetting:" target="-2" id="0aa-UD-1gK"/>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.globalUnreadCount" id="2hk-H9-Oac">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vye-pf-bkq">
<rect key="frame" x="44" y="153" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="dRK-ge-IL7">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.groupUnreadCount" id="y2V-ws-n4p">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="fmJ-ac-dcb">
<rect key="frame" x="70" y="153" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="Nwc-Rx-Wbu">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.feedUnreadCount" id="OhX-uY-UA2">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ijX-fP-IQG">
<rect key="frame" x="70" y="131" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="CiI-wC-qa8">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.feedTickMark" id="Aia-Br-J5d">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Ibh-Ob-COI">
<rect key="frame" x="96" y="242" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Update all feeds" id="mqk-td-Ely">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="MAh-pk-fPm">
<rect key="frame" x="96" y="220" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Open all unread" id="3Wk-Ys-6Dg">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="fue-A5-JZt">
<rect key="frame" x="96" y="198" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Mark all read" id="qYo-AP-Ima">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1d6-T8-AME">
<rect key="frame" x="96" y="176" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Mark all unread" id="sp9-DH-f2e">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="hQY-zw-PVG">
<rect key="frame" x="96" y="154" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Number of unread items" id="fya-vs-MV6">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="QZ1-Mq-gky">
<rect key="frame" x="96" y="132" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Tick mark unread items" id="IYd-BL-Sc8">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<customView toolTip="Show in menu bar" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="0hm-pR-8ua" customClass="SettingsIconGlobal">
<rect key="frame" x="20" y="289" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="color">
<color key="value" name="labelColor" catalog="System" colorSpace="catalog"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="number" keyPath="roundness">
<real key="value" value="40"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</customView>
<customView toolTip="Show in group menu" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="lfC-et-W8m" customClass="SettingsIconGroup">
<rect key="frame" x="46" y="289" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="color">
<color key="value" name="labelColor" catalog="System" colorSpace="catalog"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="number" keyPath="roundness">
<real key="value" value="40"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</customView>
<customView toolTip="Show in feed menu" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="7Gn-Uq-6lG" customClass="RSSIcon">
<rect key="frame" x="72" y="289" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="roundness">
<real key="value" value="40"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="color" keyPath="color">
<color key="value" name="labelColor" catalog="System" colorSpace="catalog"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</customView>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Jug-kR-uf7">
<rect key="frame" x="18" y="263" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" state="on" inset="2" id="JGj-fV-11r">
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="changeMenuBarIconSetting:" target="-2" id="QXH-tb-Egy"/>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.tintMenuBarIcon" id="1N3-KQ-wbC">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="1"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="i0v-Fd-POW">
<rect key="frame" x="70" y="109" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" inset="2" id="Wsi-Zb-ug5">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.feedShortNames" id="dny-kJ-AZM">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="0"/>
</dictionary>
</binding>
</connections>
</button>
<button verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="saw-1G-eHz">
<rect key="frame" x="70" y="87" width="22" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="check" bezelStyle="regularSquare" imagePosition="left" inset="2" id="8LB-X9-2tl">
<behavior key="behavior" pushIn="YES" changeContents="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="iU7-KA-nY5" name="value" keyPath="values.feedLimitArticles" id="Hd2-Pr-n6T">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<integer key="NSNullPlaceholder" value="0"/>
</dictionary>
</binding>
</connections>
</button>
<textField toolTip="Truncate article title after 60 characters" horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="p7p-HI-ePS">
<rect key="frame" x="96" y="110" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Short article names" id="S8K-hH-Ssj">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField toolTip="Display at most 40 articles in feed menu" horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="b3d-WG-MiJ">
<rect key="frame" x="96" y="88" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Limit number of articles" id="vjz-OI-S9j">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="X7N-1T-bmw">
<rect key="frame" x="96" y="264" width="206" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Tint menu bar icon on unread" id="edV-Xi-cpf">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<point key="canvasLocation" x="404" y="-154"/>
</customView>
</objects>
</document>

View File

@@ -0,0 +1,34 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
@import Cocoa;
@class SettingsGeneral;
@interface SettingsGeneralView : NSView
@property (weak) IBOutlet NSPopUpButton* popupHttpApplication;
@property (weak) IBOutlet NSTextField *defaultReader;
- (instancetype)initWithController:(SettingsGeneral*)controller NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE;
@end

View File

@@ -0,0 +1,43 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "SettingsGeneralView.h"
#import "SettingsGeneral.h"
#import "NSView+Ext.h"
@implementation SettingsGeneralView
- (instancetype)initWithController:(SettingsGeneral*)controller {
self = [super initWithFrame:NSZeroRect];
// Change default feed reader application
NSTextField *l1 = [[NSView label:NSLocalizedString(@"Default feed reader:", nil)] placeIn:self x:PAD_WIN yTop:PAD_WIN + 3];
NSButton *help = [[[NSView helpButton] action:@selector(clickHowToDefaults:) target:controller] placeIn:self xRight:PAD_WIN yTop:PAD_WIN];
self.defaultReader = [[[[NSView label:@""] bold] placeIn:self x:NSMaxX(l1.frame) + PAD_S yTop:PAD_WIN + 3] sizeToRight:NSWidth(help.frame) + PAD_WIN];
// Popup button 'Open URLs with:'
CGFloat y = YFromTop(help) + PAD_M;
NSTextField *l2 = [[NSView label:NSLocalizedString(@"Open URLs with:", nil)] placeIn:self x:PAD_WIN yTop:y + 1];
self.popupHttpApplication = [[[[NSView popupButton:0] placeIn:self x:NSMaxX(l2.frame) + PAD_S yTop:y] sizeToRight:PAD_WIN]
action:@selector(changeHttpApplication:) target:controller];
return self;
}
@end

View File

@@ -1,88 +0,0 @@
//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "UserPrefs.h"
#import <Cocoa/Cocoa.h>
@implementation UserPrefs
/// @return @c YES if key is not set. Otherwise, return user defaults property from plist.
+ (BOOL)defaultYES:(NSString*)key {
if ([[NSUserDefaults standardUserDefaults] objectForKey:key] == NULL) {
return YES;
}
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
}
/// @return @c NO if key is not set. Otherwise, return user defaults property from plist.
+ (BOOL)defaultNO:(NSString*)key {
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
}
/// @return Return @c defaultInt if key is not set. Otherwise, return user defaults property from plist.
+ (NSInteger)defaultInt:(NSInteger)defaultInt forKey:(NSString*)key {
NSInteger ret = [[NSUserDefaults standardUserDefaults] integerForKey:key];
if (ret > 0) return ret;
return defaultInt;
}
/// @return User configured custom browser. Or @c nil if not set yet. (which will fallback to default browser)
+ (NSString*)getHttpApplication {
return [[NSUserDefaults standardUserDefaults] stringForKey:@"defaultHttpApplication"];
}
/// Store custom browser bundle id to user defaults.
+ (void)setHttpApplication:(NSString*)bundleID {
[[NSUserDefaults standardUserDefaults] setObject:bundleID forKey:@"defaultHttpApplication"];
}
/**
Open web links in default browser or a browser the user selected in the preferences.
@param urls A list of @c NSURL objects that will be opened immediatelly in bulk.
*/
+ (void)openURLsWithPreferredBrowser:(NSArray<NSURL*>*)urls {
if (urls.count == 0) return;
[[NSWorkspace sharedWorkspace] openURLs:urls withAppBundleIdentifier:[self getHttpApplication] options:NSWorkspaceLaunchDefault additionalEventParamDescriptor:nil launchIdentifiers:nil];
}
#pragma mark - Hidden Plist Properties -
/// @return The limit on how many links should be opened at the same time, if user holds the option key.
/// Default: @c 10
+ (NSUInteger)openFewLinksLimit {
return (NSUInteger)[self defaultInt:10 forKey:@"openFewLinksLimit"];
}
/// @return The limit on when to truncate article titles (Short names setting must be active).
/// Default: @c 60
+ (NSUInteger)shortArticleNamesLimit {
return (NSUInteger)[self defaultInt:60 forKey:@"shortArticleNamesLimit"];
}
/// @return The maximum number of articles displayed per feed (Limit articles setting must be active).
/// Default: @c 40
+ (NSUInteger)articlesInMenuLimit {
return (NSUInteger)[self defaultInt:40 forKey:@"articlesInMenuLimit"];
}
@end

View File

@@ -20,11 +20,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import <Cocoa/Cocoa.h>
@import Cocoa;
@interface ModalSheet : NSPanel
@property (readonly) BOOL didCloseAndSave;
@property (readonly) BOOL didCloseAndCancel;
@property (readonly) BOOL didTapCancel;
- (instancetype)initWithContentRect:(NSRect)contentRect styleMask:(NSWindowStyleMask)style backing:(NSBackingStoreType)backingStoreType defer:(BOOL)flag NS_UNAVAILABLE;
- (instancetype)initWithView:(NSView*)content NS_DESIGNATED_INITIALIZER;

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