Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c9362b42e | ||
|
|
9af191834e | ||
|
|
8d2e4e4383 | ||
|
|
473d4b6057 | ||
|
|
cee3780f71 | ||
|
|
58d7660b87 | ||
|
|
37fc1093ee | ||
|
|
b33791cae3 | ||
|
|
850351a966 | ||
|
|
ae18e93b6a | ||
|
|
b25565c74f | ||
|
|
23b5bba794 | ||
|
|
aa87d1be6a | ||
|
|
2c028e79e0 | ||
|
|
6da852f2c9 | ||
|
|
9dbd761fe0 | ||
|
|
32f999b248 | ||
|
|
37d3a461d6 | ||
|
|
1d9275e0df | ||
|
|
4075073d1b | ||
|
|
ad607bc22b | ||
|
|
1c174cc31e | ||
|
|
95115aa0a6 | ||
|
|
466e12ba5f | ||
|
|
eb32ca9617 | ||
|
|
e7dbfa5770 | ||
|
|
b961a3a56c | ||
|
|
a777b5672f | ||
|
|
571aac4533 | ||
|
|
e1bf7cac33 | ||
|
|
5392ac8ab2 | ||
|
|
9e7eda692b | ||
|
|
e6f4d05213 | ||
|
|
5ff1753858 | ||
|
|
202005eb0d | ||
|
|
cc218dfbcb | ||
|
|
48578ea211 | ||
|
|
a1f191789d | ||
|
|
a6c8198234 | ||
|
|
b081564eca | ||
|
|
c717487b0e | ||
|
|
dff1594926 | ||
|
|
9f2f1e67f5 | ||
|
|
4ae7b09944 | ||
|
|
314a3ea9cb | ||
|
|
cb117c0f01 | ||
|
|
bdc6d45a54 | ||
|
|
cd68febd88 | ||
|
|
613d1f60d5 | ||
|
|
21e2d6706f | ||
|
|
d56916be7a | ||
|
|
85cc12f34a | ||
|
|
666ecd154f | ||
|
|
1b96e79925 | ||
|
|
dda219b570 | ||
|
|
31e0821080 | ||
|
|
8dc95dda63 | ||
|
|
29a48384c7 | ||
|
|
8e712cae20 | ||
|
|
ba3310849c |
96
CHANGELOG.md
@@ -2,28 +2,97 @@
|
||||
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.2] - 2019-10-25
|
||||
### Fixed
|
||||
- *Status Bar Menu*: Preferences could not be opened on macOS 10.15
|
||||
- *Status Bar Menu*: Menu flickering resulting in a hang on macOS 10.15
|
||||
- *UI*: Text color in `About` tab
|
||||
|
||||
|
||||
## [1.0.1] - 2019-10-04
|
||||
### Fixed
|
||||
- Crash on macOS 10.14 due to a `CGColorRef` null pointer
|
||||
|
||||
|
||||
## [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
|
||||
- Removed 'Start on login'. Use Preferences > Users > Login Items instead.
|
||||
- *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)
|
||||
|
||||
|
||||
@@ -32,9 +101,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 (0x00–0x1F)
|
||||
- libxml2 will ignore lower ascii characters (`0x00`–`0x1F`)
|
||||
|
||||
|
||||
## [0.9.1] - 2019-02-14
|
||||
@@ -42,12 +111,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
|
||||
@@ -59,9 +128,12 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe
|
||||
Initial release
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/relikd/baRSS/compare/v0.9.4...HEAD
|
||||
[Unreleased]: https://github.com/relikd/baRSS/compare/v1.0.2...HEAD
|
||||
[1.0.2]: https://github.com/relikd/baRSS/compare/v1.0.1...v1.0.2
|
||||
[1.0.1]: https://github.com/relikd/baRSS/compare/v1.0.0...v1.0.1
|
||||
[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
|
||||
|
||||
@@ -1 +1 @@
|
||||
github "relikd/RSXML" "401f470ab00ab656843162e002e111331b001824"
|
||||
github "relikd/RSXML2" "v2.0.0"
|
||||
|
||||
205
README.md
@@ -1,90 +1,187 @@
|
||||
# baRSS – *Menu Bar RSS Reader*
|
||||
|
||||

|
||||
|
||||
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+) …
|
||||
[](#download--install)
|
||||
[](https://github.com/relikd/baRSS/releases)
|
||||
[](https://github.com/relikd/baRSS/releases)
|
||||
[](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.
|
||||

|
||||
|
||||
|
||||
### 3rd Party Libraries
|
||||
What is it?
|
||||
-----------
|
||||
|
||||
This project uses a modified version of Brent Simmons [RSXML](https://github.com/brentsimmons/RSXML) for feed parsing.
|
||||
RSXML is licensed under a MIT license (same as this project).
|
||||
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.
|
||||
|
||||
|
||||
Install
|
||||
-------
|
||||
### Features
|
||||
|
||||
Easy way: go to [releases](https://github.com/relikd/baRSS/releases) and downloaded the latest version.
|
||||
*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.
|
||||
|
||||
|
||||
|
||||
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.
|
||||
Searching for the App Store release? Read this [notice](#app-store-notice).
|
||||
|
||||
### 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] 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 spacebar.
|
||||
|
||||
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)~~
|
||||
|
||||
|
||||
|
||||
FAQ / Q&A
|
||||
---------
|
||||
|
||||
### App Store Notice
|
||||
|
||||
In the last couple of months I prepared baRSS to be released on the App Store.
|
||||
With sandboxing enabled and hardened runtime environment, etc.
|
||||
|
||||
But, for the time being, I decided to not publish this app for political reasons.
|
||||
I was not happy about some decisions made in the last weeks.
|
||||
Decisions that were evaluated on monetary aspects and not on ethical considerations.
|
||||
I won't support this conduct with my own money.
|
||||
|
||||
If you find this app somewhere on the App Store, you can be sure that it is a counterfeit.
|
||||
As long as you can read this very notice, I am not responsible for the publication.
|
||||
Further, I can't guarantee the App Store version wasn't modified by a malicious actor to spy on you.
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
This project uses a modified version of Brent Simmons' [RSXML](https://github.com/brentsimmons/RSXML) for feed parsing.
|
||||
[RSXML2] is licensed under a MIT license (same as this project).
|
||||
|
||||
|
||||
##### Trivia
|
||||
|
||||
- Start of project: __July 19, 2018__
|
||||
- Estimated development time: __1953h+__
|
||||
- First prototype used __feedparser python__ library
|
||||
|
||||
|
||||
[RSXML2]: https://github.com/relikd/RSXML2
|
||||
|
||||
@@ -10,37 +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 */; };
|
||||
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 */; };
|
||||
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;
|
||||
@@ -48,7 +74,7 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
544DCCBA212A2B4D002DBC46 /* RSXML.framework in Embed Frameworks */,
|
||||
544DCCBA212A2B4D002DBC46 /* RSXML2.framework in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -59,7 +85,17 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
544DCCBE212A2B6F002DBC46 /* RSXML.framework.dSYM in CopyFiles */,
|
||||
544DCCBE212A2B6F002DBC46 /* RSXML2.framework.dSYM in CopyFiles */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
54CE4D4522EF509400E89C16 /* CopyFiles */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = Contents/Library/QuickLook;
|
||||
dstSubfolderSpec = 1;
|
||||
files = (
|
||||
54A2D63922EF81A4007C61F3 /* QLOPML.qlgenerator in CopyFiles */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -74,52 +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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -131,7 +201,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */,
|
||||
544DCCB9212A2B4D002DBC46 /* RSXML2.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -153,52 +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 = (
|
||||
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>";
|
||||
@@ -222,12 +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 */,
|
||||
54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */,
|
||||
54ACC27D21061B3B0020715F /* Products */,
|
||||
544FBD4321064AEB008A260C /* Frameworks */,
|
||||
);
|
||||
@@ -244,36 +316,116 @@
|
||||
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>";
|
||||
};
|
||||
54AD4E04230084FD000AE386 /* Feed Import */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
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 = "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 */ = {
|
||||
isa = PBXGroup;
|
||||
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 */
|
||||
@@ -285,8 +437,10 @@
|
||||
54ACC27921061B3B0020715F /* Frameworks */,
|
||||
54ACC27A21061B3B0020715F /* Resources */,
|
||||
544DCCBB212A2B4D002DBC46 /* Embed Frameworks */,
|
||||
54CE4D4522EF509400E89C16 /* CopyFiles */,
|
||||
544DCCBC212A2B5A002DBC46 /* CopyFiles */,
|
||||
543964EE2215C27B0016AAA3 /* ShellScript */,
|
||||
54FB05D12305BFAB00A088AD /* ShellScript */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -313,8 +467,11 @@
|
||||
com.apple.ApplicationGroups.Mac = {
|
||||
enabled = 0;
|
||||
};
|
||||
com.apple.HardenedRuntime = {
|
||||
enabled = 1;
|
||||
};
|
||||
com.apple.Sandbox = {
|
||||
enabled = 0;
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -331,6 +488,12 @@
|
||||
mainGroup = 54ACC27321061B3B0020715F;
|
||||
productRefGroup = 54ACC27D21061B3B0020715F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectReferences = (
|
||||
{
|
||||
ProductGroup = 54A2D63422EF8193007C61F3 /* Products */;
|
||||
ProjectRef = 54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */;
|
||||
},
|
||||
);
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
54ACC27B21061B3B0020715F /* baRSS */,
|
||||
@@ -338,16 +501,24 @@
|
||||
};
|
||||
/* 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 */,
|
||||
54BF444A22D0F4F300660096 /* AppIcon.icns in Resources */,
|
||||
54AD4EE72305B17D000AE386 /* container-migration.plist in Resources */,
|
||||
54E3C02122EE076D006E2E24 /* opml-icon.icns in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -371,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 */
|
||||
@@ -378,27 +567,44 @@
|
||||
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 */,
|
||||
@@ -449,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;
|
||||
@@ -499,6 +702,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
@@ -520,7 +724,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;
|
||||
@@ -533,15 +736,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;
|
||||
@@ -559,7 +769,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 = "";
|
||||
@@ -570,7 +779,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;
|
||||
@@ -583,15 +791,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;
|
||||
@@ -609,7 +824,6 @@
|
||||
"@executable_path/../Frameworks",
|
||||
"$(FRAMEWORK_SEARCH_PATHS)",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.12;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.relikd.baRSS;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
113
baRSS.xcodeproj/xcshareddata/xcschemes/baRSS.xcscheme
Normal 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>
|
||||
@@ -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
|
||||
|
||||
118
baRSS/AppHook.m
@@ -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
13
baRSS/Artwork/application-icon.svg
Normal 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 |
7
baRSS/Artwork/document-icon-small.svg
Normal 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 |
21
baRSS/Artwork/document-icon.svg
Normal 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 |
BIN
baRSS/Artwork/opml-icon.icns
Normal 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 531 B |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +78,6 @@
|
||||
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*> *localSet = [self.articles mutableCopy];
|
||||
NSInteger diff = 0;
|
||||
@@ -98,56 +85,41 @@
|
||||
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 localSet Use result set @c localSet of method call @c deleteArticles:withRemoteSet:.
|
||||
@param remoteSet Readonly copy of @c RSParsedFeed.articles.
|
||||
@param localSet Use result set of @c deleteArticles:withRemoteSet:
|
||||
*/
|
||||
- (NSUInteger)insertArticles:(NSMutableSet<FeedArticle*>*)localSet withRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
|
||||
int32_t currentIndex = [[localSet valueForKeyPath:@"@min.sortIndex"] intValue];
|
||||
NSMutableArray<FeedArticle*>* newlyInserted = [NSMutableArray arrayWithCapacity:remoteSet.count];
|
||||
|
||||
NSUInteger c = 0;
|
||||
for (RSParsedArticle *article in [remoteSet reverseObjectEnumerator]) {
|
||||
// reverse enumeration ensures correct article order
|
||||
FeedArticle *storedArticle = [self findRemoteArticle:article inLocalSet:localSet];
|
||||
if (storedArticle) {
|
||||
[localSet removeObject:storedArticle];
|
||||
// If we encounter an already existing item, assume newly inserted are "ghost" items and mark read.
|
||||
if (newlyInserted.count > 0) {
|
||||
for (FeedArticle *ghostItem in newlyInserted) {
|
||||
ghostItem.unread = NO;
|
||||
}
|
||||
[newlyInserted removeAllObjects];
|
||||
}
|
||||
// Ensures consecutive block of incrementing numbers on sortIndex
|
||||
if (storedArticle.sortIndex != currentIndex) {
|
||||
storedArticle.sortIndex = currentIndex;
|
||||
}
|
||||
// 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 {
|
||||
FeedArticle *newArticle = [FeedArticle newArticle:article inContext:self.managedObjectContext];
|
||||
newArticle.sortIndex = currentIndex;
|
||||
[self addArticlesObject:newArticle];
|
||||
[newlyInserted addObject:newArticle];
|
||||
c += 1;
|
||||
}
|
||||
currentIndex += 1;
|
||||
}
|
||||
return newlyInserted.count; // all ghost items are removed already
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
Delete all articles from core data, that aren't present anymore.
|
||||
|
||||
@param localSet Input a copy of @c self.articles. Output the same set minus deleted articles.
|
||||
@param remoteSet Readonly copy of @c RSParsedFeed.articles.
|
||||
@param localSet Input a copy of @c self.articles . Output same set minus deleted articles.
|
||||
*/
|
||||
- (NSUInteger)deleteArticles:(NSMutableSet<FeedArticle*>*)localSet withRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
|
||||
NSUInteger c = 0;
|
||||
@@ -189,8 +161,8 @@
|
||||
BOOL linkIsNil = (searchLink == nil);
|
||||
BOOL guidIsNil = (searchGuid == nil);
|
||||
for (FeedArticle *art in localSet) {
|
||||
if ((linkIsNil && art.link == nil) || [art.link isEqualToString:searchLink]) {
|
||||
if ((guidIsNil && art.guid == nil) || [art.guid isEqualToString:searchGuid])
|
||||
if ((linkIsNil && art.link == nil) || (!linkIsNil && [art.link isEqualToString:searchLink])) {
|
||||
if ((guidIsNil && art.guid == nil) || (!guidIsNil && [art.guid isEqualToString:searchGuid]))
|
||||
return art;
|
||||
}
|
||||
}
|
||||
@@ -206,8 +178,8 @@
|
||||
BOOL linkIsNil = (searchLink == nil);
|
||||
BOOL guidIsNil = (searchGuid == nil);
|
||||
for (RSParsedArticle *art in remoteSet) {
|
||||
if ((linkIsNil && art.link == nil) || [art.link isEqualToString:searchLink]) {
|
||||
if ((guidIsNil && art.guid == nil) || [art.guid isEqualToString:searchGuid])
|
||||
if ((linkIsNil && art.link == nil) || (!linkIsNil && [art.link isEqualToString:searchLink])) {
|
||||
if ((guidIsNil && art.guid == nil) || (!guidIsNil && [art.guid isEqualToString:searchGuid]))
|
||||
return art;
|
||||
}
|
||||
}
|
||||
@@ -218,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
89
baRSS/Feed Import/Download3rdParty.m
Normal 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.
|
||||
|
||||
#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];
|
||||
}
|
||||
|
||||
/// @return @c http://i.ytimg.com/vi/<videoid>/maxresdefault.jpg
|
||||
+ (NSString*)videoImage4k:(NSString*)videoid {
|
||||
return [NSString stringWithFormat:@"http://i.ytimg.com/vi/%@/maxresdefault.jpg", videoid];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
47
baRSS/Feed Import/FaviconDownload.h
Normal 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
|
||||
225
baRSS/Feed Import/FaviconDownload.m
Normal 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
|
||||
63
baRSS/Feed Import/FeedDownload.h
Normal 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
|
||||
238
baRSS/Feed Import/FeedDownload.m
Normal 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
|
||||
63
baRSS/Feed Import/OpmlFile.h
Normal 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
|
||||
313
baRSS/Feed Import/OpmlFile.m
Normal 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
|
||||
@@ -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>
|
||||
*/
|
||||
312
baRSS/Feed Import/UpdateScheduler.m
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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.4</string>
|
||||
<string>1.0.2</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>1208</string>
|
||||
<string>14471</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>
|
||||
|
||||
@@ -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
|
||||
202
baRSS/NSCategories/NSDate+Ext.m
Normal 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
|
||||
37
baRSS/NSCategories/NSError+Ext.h
Normal 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
|
||||
152
baRSS/NSCategories/NSError+Ext.m
Normal 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
|
||||
32
baRSS/NSCategories/NSString+Ext.h
Normal 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
|
||||
162
baRSS/NSCategories/NSString+Ext.m
Normal 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
|
||||
40
baRSS/NSCategories/NSURL+Ext.h
Normal 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
|
||||
110
baRSS/NSCategories/NSURL+Ext.m
Normal 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
|
||||
31
baRSS/NSCategories/NSURLRequest+Ext.h
Normal 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
|
||||
97
baRSS/NSCategories/NSURLRequest+Ext.m
Normal 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
|
||||
108
baRSS/NSCategories/NSView+Ext.h
Normal 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
|
||||
381
baRSS/NSCategories/NSView+Ext.m
Normal 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
|
||||
26
baRSS/Preferences/About Tab/SettingsAbout.h
Normal 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
|
||||
@@ -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
|
||||
27
baRSS/Preferences/About Tab/SettingsAboutView.h
Normal 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
|
||||
|
||||
90
baRSS/Preferences/About Tab/SettingsAboutView.m
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// 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)];
|
||||
NSDictionary *style = @{ NSFontAttributeName: font, NSForegroundColorAttributeName: [NSColor controlTextColor] };
|
||||
[parent appendAttributedString:[[NSAttributedString alloc] initWithString:NonLocalized(text) attributes:style]];
|
||||
}
|
||||
|
||||
/// 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
|
||||
27
baRSS/Preferences/Appearance Tab/SettingsAppearance.h
Normal 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
|
||||
52
baRSS/Preferences/Appearance Tab/SettingsAppearance.m
Normal 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
|
||||
28
baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h
Normal 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
|
||||
|
||||
85
baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
45
baRSS/Preferences/Feeds Tab/ModalFeedEditView.h
Normal 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 (strong) IBOutlet NSTextField *url;
|
||||
@property (strong) IBOutlet NSProgressIndicator *spinnerURL;
|
||||
@property (strong) IBOutlet NSImageView *favicon;
|
||||
|
||||
@property (strong) IBOutlet NSTextField *name;
|
||||
@property (strong) IBOutlet NSProgressIndicator *spinnerName;
|
||||
|
||||
@property (strong) IBOutlet NSTextField *refreshNum;
|
||||
@property (strong) IBOutlet NSPopUpButton *refreshUnit;
|
||||
|
||||
@property (strong) IBOutlet NSButton *warningButton;
|
||||
@property NSPopover *warningPopover;
|
||||
@property (strong) IBOutlet NSTextField *warningText;
|
||||
@property (strong) IBOutlet NSButton *warningReload;
|
||||
|
||||
- (instancetype)initWithController:(ModalFeedEdit*)controller NS_DESIGNATED_INITIALIZER;
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
|
||||
- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE;
|
||||
@end
|
||||
110
baRSS/Preferences/Feeds Tab/ModalFeedEditView.m
Normal 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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
115
baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m
Normal 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
|
||||
29
baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.h
Normal 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
|
||||
253
baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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="<string>" 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>
|
||||
47
baRSS/Preferences/Feeds Tab/SettingsFeedsView.h
Normal 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 (strong) IBOutlet NSOutlineView *outline;
|
||||
@property (strong) IBOutlet NSTextField *status;
|
||||
@property (strong) 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
|
||||
257
baRSS/Preferences/Feeds Tab/SettingsFeedsView.m
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -21,159 +21,68 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import "SettingsGeneral.h"
|
||||
#import "AppHook.h"
|
||||
#import "BarStatusItem.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "Constants.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
|
||||
/// 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
|
||||
|
||||
@@ -1,487 +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="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>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2sG-NO-OJz">
|
||||
<rect key="frame" x="18" y="288" 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="283" 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="261" 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="256" 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>
|
||||
</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>
|
||||
34
baRSS/Preferences/General Tab/SettingsGeneralView.h
Normal 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 (strong) IBOutlet NSPopUpButton* popupHttpApplication;
|
||||
@property (strong) IBOutlet NSTextField *defaultReader;
|
||||
|
||||
- (instancetype)initWithController:(SettingsGeneral*)controller NS_DESIGNATED_INITIALIZER;
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
|
||||
- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE;
|
||||
@end
|
||||
|
||||
43
baRSS/Preferences/General Tab/SettingsGeneralView.m
Normal 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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -21,22 +21,60 @@
|
||||
// SOFTWARE.
|
||||
|
||||
#import "ModalSheet.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "NSView+Ext.h"
|
||||
|
||||
@interface ModalSheet()
|
||||
@property (weak) NSButton *btnDone;
|
||||
@property (assign) BOOL respondToShouldClose;
|
||||
@end
|
||||
|
||||
@implementation ModalSheet
|
||||
@synthesize didCloseAndSave = _didCloseAndSave, didCloseAndCancel = _didCloseAndCancel;
|
||||
|
||||
/// User did click the 'Done' button.
|
||||
- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; }
|
||||
/// User did click the 'Cancel' button.
|
||||
- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseCancel]; }
|
||||
/// Designated initializer. 'Done' and 'Cancel' buttons will be added automatically.
|
||||
- (instancetype)initWithView:(NSView*)content {
|
||||
static NSInteger const minWidth = 320;
|
||||
static NSInteger const maxWidth = 1200;
|
||||
static CGFloat const contentOffsetY = PAD_WIN + HEIGHT_BUTTON + PAD_L;
|
||||
|
||||
NSInteger w = UserPrefsInt(Pref_modalSheetWidth);
|
||||
if (w < minWidth) w = minWidth;
|
||||
else if (w > maxWidth) w = maxWidth;
|
||||
|
||||
CGFloat h = NSHeight(content.frame);
|
||||
[content setFrameSize: NSMakeSize(w, h)];
|
||||
|
||||
// after content size, increase to window size
|
||||
w += 2 * PAD_WIN;
|
||||
h += PAD_WIN + contentOffsetY; // the second PAD_WIN is already in contentOffsetY
|
||||
|
||||
NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView;
|
||||
self = [super initWithContentRect:NSMakeRect(0, 0, w, h) styleMask:style backing:NSBackingStoreBuffered defer:NO];
|
||||
[content placeIn:self.contentView x:PAD_WIN y:contentOffsetY];
|
||||
|
||||
// Restrict resizing to width only
|
||||
self.minSize = NSMakeSize(minWidth + 2 * PAD_WIN, h);
|
||||
self.maxSize = NSMakeSize(maxWidth + 2 * PAD_WIN, h);
|
||||
|
||||
// Add default interaction buttons
|
||||
NSButton *btnDone = [self createButton:NSLocalizedString(@"Done", nil) atX:PAD_WIN];
|
||||
NSButton *btnCancel = [self createButton:NSLocalizedString(@"Cancel", nil) atX:w - NSMinX(btnDone.frame) + PAD_M];
|
||||
btnDone.tag = 42; // mark 'Done' button
|
||||
btnDone.keyEquivalent = @"\r"; // Enter / Return
|
||||
btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Helper method to create bottom-right aligned button.
|
||||
- (NSButton*)createButton:(NSString*)text atX:(CGFloat)x {
|
||||
return [[[NSView button:text] action:@selector(didTapButton:) target:self] placeIn:self.contentView xRight:x y:PAD_WIN];
|
||||
}
|
||||
|
||||
/// Manually disable 'Done' button if a task is still running.
|
||||
- (void)setDoneEnabled:(BOOL)accept { self.btnDone.enabled = accept; }
|
||||
- (void)setDoneEnabled:(BOOL)accept {
|
||||
((NSButton*)[self.contentView viewWithTag:42]).enabled = accept;
|
||||
}
|
||||
|
||||
/// Sets bool for future usage
|
||||
- (void)setDelegate:(id<NSWindowDelegate>)delegate {
|
||||
[super setDelegate:delegate];
|
||||
self.respondToShouldClose = [delegate respondsToSelector:@selector(windowShouldClose:)];
|
||||
@@ -44,90 +82,24 @@
|
||||
|
||||
/**
|
||||
Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button.
|
||||
Flags controller as being closed @c .closeInitiated @c = @c YES.
|
||||
And removes all subviews (clean up).
|
||||
In the later case set @c .didTapCancel @c = @c YES
|
||||
*/
|
||||
- (void)closeWithResponse:(NSModalResponse)response {
|
||||
if (response == NSModalResponseOK && self.respondToShouldClose && ![self.delegate windowShouldClose:self]) {
|
||||
- (void)didTapButton:(NSButton*)sender {
|
||||
BOOL successful = (sender.tag == 42); // 'Done' button
|
||||
_didTapCancel = !successful;
|
||||
if (self.respondToShouldClose && ![self.delegate windowShouldClose:self]) {
|
||||
return;
|
||||
}
|
||||
_didCloseAndSave = (response == NSModalResponseOK);
|
||||
_didCloseAndCancel = (response != NSModalResponseOK);
|
||||
// store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues
|
||||
// first object is always the view of the modal dialog
|
||||
CGFloat w = self.contentView.subviews.firstObject.frame.size.width;
|
||||
[[NSUserDefaults standardUserDefaults] setInteger:(NSInteger)w forKey:@"modalSheetWidth"];
|
||||
// Save modal view width for next time
|
||||
NSInteger width = (NSInteger)(NSWidth(self.contentView.frame) - 2 * PAD_WIN);
|
||||
if (UserPrefsInt(Pref_modalSheetWidth) != width)
|
||||
UserPrefsSetInt(Pref_modalSheetWidth, width);
|
||||
// Remove subviews to avoid _NSKeyboardFocusClipView issues
|
||||
[self.contentView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
[self.sheetParent endSheet:self returnCode:response];
|
||||
[self.sheetParent endSheet:self returnCode:(successful ? NSModalResponseOK : NSModalResponseCancel)];
|
||||
}
|
||||
|
||||
/**
|
||||
Designated initializer for @c ModalSheet. 'Done' and 'Cancel' button will be added automatically.
|
||||
|
||||
@param content @c NSView will be displayed in dialog box.
|
||||
*/
|
||||
- (instancetype)initWithView:(NSView*)content {
|
||||
static const int padWindow = 20;
|
||||
static const int minWidth = 320;
|
||||
static const int maxWidth = 1200;
|
||||
|
||||
NSInteger prevWidth = [[NSUserDefaults standardUserDefaults] integerForKey:@"modalSheetWidth"];
|
||||
if (prevWidth < minWidth) prevWidth = minWidth;
|
||||
else if (prevWidth > maxWidth) prevWidth = maxWidth;
|
||||
|
||||
NSSize contentSize = NSMakeSize(prevWidth, content.frame.size.height);
|
||||
[content setFrameSize:contentSize];
|
||||
|
||||
NSSize wSize = NSMakeSize(contentSize.width + 2 * padWindow, contentSize.height + 2 * padWindow);
|
||||
|
||||
NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView;
|
||||
self = [super initWithContentRect:NSMakeRect(0, 0, wSize.width, wSize.height) styleMask:style backing:NSBackingStoreBuffered defer:NO];
|
||||
if (self) {
|
||||
NSButton *btnDone = [NSButton buttonWithTitle:NSLocalizedString(@"Done", nil) target:self action:@selector(didTapDoneButton:)];
|
||||
NSButton *btnCancel = [NSButton buttonWithTitle:NSLocalizedString(@"Cancel", nil) target:self action:@selector(didTapCancelButton:)];
|
||||
btnDone.keyEquivalent = @"\r"; // Enter / Return
|
||||
btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC
|
||||
|
||||
// Make room for buttons
|
||||
wSize.height += btnDone.frame.size.height;
|
||||
[self setContentSize:wSize];
|
||||
|
||||
// Restrict resizing to width only (after setContentSize:)
|
||||
self.minSize = NSMakeSize(minWidth + 2 * padWindow, wSize.height);
|
||||
self.maxSize = NSMakeSize(maxWidth + 2 * padWindow, wSize.height);
|
||||
|
||||
// Content view (set origin after setContentSize:)
|
||||
[content setFrameOrigin:NSMakePoint(padWindow, wSize.height - padWindow - contentSize.height)];
|
||||
[self.contentView addSubview:content];
|
||||
|
||||
// Respond buttons
|
||||
[self placeButtons:@[btnDone, btnCancel] inBottomRightCornerWithPadding:padWindow];
|
||||
[self.contentView addSubview:btnCancel];
|
||||
[self.contentView addSubview:btnDone];
|
||||
self.btnDone = btnDone;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
Buttons will stick to the right margin and bottom margin when resizing. Also sets autoresizingMask.
|
||||
|
||||
@param buttons First item is rightmost button. Next buttons will be appended left of that button and so on.
|
||||
@param padding Distance between button and right / bottom edge.
|
||||
*/
|
||||
- (void)placeButtons:(NSArray<NSButton*> *)buttons inBottomRightCornerWithPadding:(int)padding {
|
||||
NSEdgeInsets edge = buttons.firstObject.alignmentRectInsets;
|
||||
NSPoint p = NSMakePoint(self.contentView.frame.size.width - padding + edge.right, padding - edge.bottom);
|
||||
for (NSButton *btn in buttons) {
|
||||
p.x -= btn.frame.size.width;
|
||||
[btn setFrameOrigin:p];
|
||||
btn.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Resize modal window by @c dy. Makes room for additional content. Use negative values to shrink window.
|
||||
*/
|
||||
/// Resize modal window by @c dy. Makes room for additional content. Use negative values to shrink window.
|
||||
- (void)extendContentViewBy:(CGFloat)dy {
|
||||
self.minSize = NSMakeSize(self.minSize.width, self.minSize.height + dy);
|
||||
self.maxSize = NSMakeSize(self.maxSize.width, self.maxSize.height + dy);
|
||||
@@ -137,5 +109,3 @@
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +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;
|
||||
@class SettingsFeeds;
|
||||
|
||||
@interface Preferences : NSWindowController <NSWindowDelegate>
|
||||
@interface Preferences : NSWindow <NSWindowDelegate>
|
||||
+ (instancetype)window;
|
||||
- (__kindof NSViewController*)selectTab:(NSUInteger)index;
|
||||
@end
|
||||
|
||||