Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5894b12c1d | ||
|
|
0700eebb13 | ||
|
|
4c4a133fe2 | ||
|
|
ccca329630 | ||
|
|
831159904c | ||
|
|
cf3e9e4b4a | ||
|
|
184e5c0882 | ||
|
|
575d1eaec8 | ||
|
|
0c481d18dd | ||
|
|
c281573044 | ||
|
|
d164c6bcb0 | ||
|
|
9f4de8fc8d | ||
|
|
c099c32cca | ||
|
|
bdf9d11853 | ||
|
|
c14af92289 | ||
|
|
b6978662fc | ||
|
|
89f90ddb11 | ||
|
|
0b6a338fa3 | ||
|
|
3235bffdca | ||
|
|
0a23819428 | ||
|
|
def174c65f | ||
|
|
e63d6c5784 | ||
|
|
46fa898807 | ||
|
|
63509faef6 | ||
|
|
7047d99205 | ||
|
|
614e4abb50 | ||
|
|
20835cd155 | ||
|
|
ba76f6a206 | ||
|
|
256fd55d32 | ||
|
|
4eb2248142 | ||
|
|
6ef23ef599 | ||
|
|
f65c5b9546 | ||
|
|
9c3814b470 | ||
|
|
131bfaa14d | ||
|
|
fc6c3a3df2 | ||
|
|
f2bdc5b555 | ||
|
|
060f538240 | ||
|
|
5eed090e9c | ||
|
|
f7872c4f80 | ||
|
|
0fdb8d9ccc | ||
|
|
5d7242cc73 | ||
|
|
b846319335 | ||
|
|
82e9365272 | ||
|
|
839eee7d39 | ||
|
|
f577ec1ec2 | ||
|
|
df0b5b1c91 | ||
|
|
86f5abde0c | ||
|
|
02759ba0be | ||
|
|
3189015ce1 | ||
|
|
6cf86d3bf8 | ||
|
|
fb8f5be289 | ||
|
|
51e1f07531 | ||
|
|
b21cc20746 | ||
|
|
be600b6c5f | ||
|
|
a9c3ccc1f7 | ||
|
|
c4c5559d2d | ||
|
|
68b25d10dd | ||
|
|
24c785662a | ||
|
|
2a589f51a8 | ||
|
|
30527d50e6 | ||
|
|
eb06793b8f | ||
|
|
af89e58a9c | ||
|
|
364811642a | ||
|
|
a342104219 | ||
|
|
26151819c4 | ||
|
|
e0dec3adf9 | ||
|
|
f7eb63bed9 | ||
|
|
23f4f125db | ||
|
|
b3940f103a | ||
|
|
239527908f | ||
|
|
7df70a7936 | ||
|
|
e8c4c06d33 | ||
|
|
a9c0e64689 | ||
|
|
51dd688801 | ||
|
|
352428679d | ||
|
|
9b20262207 | ||
|
|
47a0e76cb3 | ||
|
|
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 | ||
|
|
4d49b3fb38 | ||
|
|
a1b91e51f9 | ||
|
|
7004db25e5 | ||
|
|
935325af04 |
234
CHANGELOG.md
@@ -2,43 +2,223 @@
|
||||
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.5.3] – 2025-10-29
|
||||
### Fixed
|
||||
- *Notifications:* Use user-provided feed title instead of server provided title
|
||||
|
||||
|
||||
## [0.9.3] - 2019-03-14
|
||||
## [1.5.2] – 2025-10-29
|
||||
### Added
|
||||
- Changelog
|
||||
- UI: Show body tag in article tooltip if abstract tag is empty
|
||||
- *Notifications:* Reply with "Open in background", "Mark read & dismiss", or "Open but keep unread"
|
||||
|
||||
|
||||
## [1.5.1] – 2025-10-27
|
||||
### Fixed
|
||||
- *Status Bar Menu:* Simplified options for "Show only unread"
|
||||
|
||||
|
||||
## [1.5.0] – 2025-10-27
|
||||
### Added
|
||||
- *UI:* Notifications
|
||||
|
||||
|
||||
## [1.4.1] – 2025-07-29
|
||||
### Fixed
|
||||
- Re-compiled because previous certificate was revoked (again!)
|
||||
|
||||
|
||||
## [1.4.0] – 2025-07-23
|
||||
### Added
|
||||
- *QuickLook:* Updated to new extension framework
|
||||
|
||||
|
||||
## [1.3.2] – 2025-07-23
|
||||
### Fixed
|
||||
- Previous version did not run on macOS 10.15
|
||||
|
||||
|
||||
## [1.3.1] – 2025-07-21
|
||||
### Fixed
|
||||
- *Status Bar Menu:* Always recreate main menu (hopefully fixes #13)
|
||||
- *Status Bar Menu:* Enable global mark read menu items on background update
|
||||
- *Status Bar Menu:* Keyboard navigation over alternate items ("Open a few") (fixes #15)
|
||||
- *Status Bar Menu:* Alternate item ("Open a few") was displayed as normal menu item in macOS 15
|
||||
- *UI:* Welcome message was displayed at the bottom left corner
|
||||
- *UI:* Tooltip will not remove preceding whitespace if html starts with a list
|
||||
- Update Xcode build flags
|
||||
|
||||
|
||||
## [1.3.0] – 2025-06-24
|
||||
### Added
|
||||
- *Adding feed:* Regex Converter for websites without RSS feed (hold down option key during edit)
|
||||
|
||||
### Fixed
|
||||
- 'Update all feeds' will shows unread items count properly during update
|
||||
- *Adding feed:* Keep aspect ratio of favicon inside button (related to fix in v1.2.3)
|
||||
|
||||
|
||||
## [1.2.3] – 2025-06-09
|
||||
### Fixed
|
||||
- *Adding feed:* Favicon size inside button
|
||||
- *DB:* Feeds with changing urls -> use guid for unique check
|
||||
|
||||
|
||||
## [1.2.2] – 2023-06-18
|
||||
### Fixed
|
||||
- Feed menu sporadically not opening
|
||||
|
||||
|
||||
## [1.2.1] – 2023-06-17
|
||||
### Added
|
||||
- Universal binary (Intel+AppleSilicon)
|
||||
|
||||
### Fixed
|
||||
- Autoresize issues of UI elements in macOS Ventura
|
||||
- Flexible width TabBarItem
|
||||
- Updated About page (removed dead link)
|
||||
|
||||
|
||||
## [1.2.0] – 2022-10-01
|
||||
### Added
|
||||
- *UI:* Add option to hide read articles (show only unread)
|
||||
|
||||
|
||||
## [1.1.3] – 2020-12-18
|
||||
### Fixed
|
||||
- Recognize YouTube channel URLs in the format `/c/channel-name`
|
||||
|
||||
|
||||
## [1.1.2] – 2020-11-27
|
||||
### Fixed
|
||||
- Fixes hidden color option for marking unread entries. Unread menu entries did use `colorStatusIconTint` instead of `colorUnreadIndicator` (thanks @tchek)
|
||||
- Workaround for not displaying status bar highlight color in macOS 11.0 (issue #7)
|
||||
|
||||
|
||||
## [1.1.1] – 2020-08-31
|
||||
### Fixed
|
||||
- Feed indices weren't updated properly which resulted in empty feed menus (issue: #6)
|
||||
|
||||
|
||||
## [1.1.0] – 2020-01-17
|
||||
### Added
|
||||
- *QuickLook:* Thumbnail previews for OPML files (QLOPML v1.3)
|
||||
- *Status Bar Menu:* Tint menu bar icon with Accent color (macOS 10.14+)
|
||||
|
||||
### Fixed
|
||||
- Resolved Xcode warnings in Xcode 11
|
||||
|
||||
|
||||
## [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
|
||||
- *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
|
||||
|
||||
### Fixed
|
||||
- `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)
|
||||
|
||||
|
||||
## [0.9.2] - 2019-03-07
|
||||
## [0.9.2] – 2019-03-07
|
||||
### Added
|
||||
- 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
|
||||
## [0.9.1] – 2019-02-14
|
||||
### Added
|
||||
- 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
|
||||
@@ -46,12 +226,32 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe
|
||||
- Remove html tags from abstract on save (not on display)
|
||||
|
||||
|
||||
## [0.9] - 2019-02-11
|
||||
## [0.9] – 2019-02-11
|
||||
Initial release
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/relikd/baRSS/compare/v0.9.2...HEAD
|
||||
[1.5.3]: https://github.com/relikd/baRSS/compare/v1.5.2...v1.5.3
|
||||
[1.5.2]: https://github.com/relikd/baRSS/compare/v1.5.1...v1.5.2
|
||||
[1.5.1]: https://github.com/relikd/baRSS/compare/v1.5.0...v1.5.1
|
||||
[1.5.0]: https://github.com/relikd/baRSS/compare/v1.4.1...v1.5.0
|
||||
[1.4.1]: https://github.com/relikd/baRSS/compare/v1.4.0...v1.4.1
|
||||
[1.4.0]: https://github.com/relikd/baRSS/compare/v1.3.2...v1.4.0
|
||||
[1.3.2]: https://github.com/relikd/baRSS/compare/v1.3.1...v1.3.2
|
||||
[1.3.1]: https://github.com/relikd/baRSS/compare/v1.3.0...v1.3.1
|
||||
[1.3.0]: https://github.com/relikd/baRSS/compare/v1.2.3...v1.3.0
|
||||
[1.2.3]: https://github.com/relikd/baRSS/compare/v1.2.2...v1.2.3
|
||||
[1.2.2]: https://github.com/relikd/baRSS/compare/v1.2.1...v1.2.2
|
||||
[1.2.1]: https://github.com/relikd/baRSS/compare/v1.2.0...v1.2.1
|
||||
[1.2.0]: https://github.com/relikd/baRSS/compare/v1.1.3...v1.2.0
|
||||
[1.1.3]: https://github.com/relikd/baRSS/compare/v1.1.2...v1.1.3
|
||||
[1.1.2]: https://github.com/relikd/baRSS/compare/v1.1.1...v1.1.2
|
||||
[1.1.1]: https://github.com/relikd/baRSS/compare/v1.1.0...v1.1.1
|
||||
[1.1.0]: https://github.com/relikd/baRSS/compare/v1.0.2...v1.1.0
|
||||
[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 +0,0 @@
|
||||
github "relikd/RSXML" "401f470ab00ab656843162e002e111331b001824"
|
||||
22
LICENSE
@@ -1,7 +1,21 @@
|
||||
Copyright 2018 Oleg Geier
|
||||
MIT License
|
||||
|
||||
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:
|
||||
Copyright (c) 2018 relikd
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
21
QLOPML/Base.lproj/PreviewViewController.xib
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="11762" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="11762"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="PreviewViewController" customModuleProvider="">
|
||||
<connections>
|
||||
<outlet property="view" destination="c22-O7-iKe" id="NRM-P4-wb6"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<customView id="c22-O7-iKe" userLabel="Preview View">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="272"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
|
||||
</customView>
|
||||
</objects>
|
||||
</document>
|
||||
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>QLOPML</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,16 +15,30 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>QLSupportedContentTypes</key>
|
||||
<array>
|
||||
<string>org.opml.opml</string>
|
||||
</array>
|
||||
<key>QLSupportsSearchableItems</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.quicklook.preview</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>PreviewViewController</string>
|
||||
</dict>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2018 relikd. Public Domain.</string>
|
||||
<key>LSBackgroundOnly</key>
|
||||
<true/>
|
||||
<string>Copyright © 2025 relikd.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
5
QLOPML/PreviewViewController.h
Normal file
@@ -0,0 +1,5 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@interface PreviewViewController : NSViewController
|
||||
|
||||
@end
|
||||
29
QLOPML/PreviewViewController.m
Normal file
@@ -0,0 +1,29 @@
|
||||
#import "PreviewViewController.h"
|
||||
#import <Quartz/Quartz.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#include "opml-lib.h"
|
||||
|
||||
@interface PreviewViewController () <QLPreviewingController>
|
||||
@end
|
||||
|
||||
@implementation PreviewViewController
|
||||
|
||||
- (NSString *)nibName {
|
||||
return @"PreviewViewController";
|
||||
}
|
||||
|
||||
- (void)preparePreviewOfFileAtURL:(NSURL *)url completionHandler:(void (^)(NSError * _Nullable))handler {
|
||||
NSData *data = generateHTMLData(url, [NSBundle mainBundle], NO);
|
||||
// sure, we could use `WKWebView`, but that requires the `com.apple.security.network.client` entitlement
|
||||
#pragma clang diagnostic ignored "-Wdeprecated"
|
||||
WebView *web = [[WebView alloc] initWithFrame:self.view.bounds];
|
||||
#pragma clang diagnostic pop
|
||||
web.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
|
||||
[self.view addSubview:web];
|
||||
// [web.mainFrame loadHTMLString:html baseURL:nil];
|
||||
[web.mainFrame loadData:data MIMEType:@"text/html" textEncodingName:@"UTF-8" baseURL:nil];
|
||||
handler(nil);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
10
QLOPML/QLOPML.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
7
QLOPML/opml-lib.h
Normal file
@@ -0,0 +1,7 @@
|
||||
#ifndef opml_lib_h
|
||||
#define opml_lib_h
|
||||
|
||||
NSData* generateHTMLData(NSURL *url, NSBundle *bundle, BOOL thumb);
|
||||
//void renderThumbnail(CFURLRef url, CFBundleRef bundle, CGContextRef context, CGSize maxSize);
|
||||
|
||||
#endif /* opml_lib_h */
|
||||
116
QLOPML/opml-lib.m
Normal file
@@ -0,0 +1,116 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
//#import <WebKit/WebKit.h>
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// |
|
||||
// | OPML renderer
|
||||
// |
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
NSXMLElement* make(NSString *tag, NSString *text, NSXMLElement *parent) {
|
||||
NSXMLElement *div = [NSXMLElement elementWithName:tag];
|
||||
if (text) div.stringValue = text;
|
||||
[parent addChild:div];
|
||||
return div;
|
||||
}
|
||||
|
||||
void attribute(NSXMLElement *parent, NSString *key, NSString *value) {
|
||||
[parent addAttribute:[NSXMLElement attributeWithName:key stringValue:value]];
|
||||
}
|
||||
|
||||
NSXMLElement* section(NSString *title, NSString *container, NSXMLElement *parent) {
|
||||
make(@"h3", title, parent);
|
||||
NSXMLElement *div = make(container, nil, parent);
|
||||
attribute(div, @"class", @"section");
|
||||
return div;
|
||||
}
|
||||
|
||||
void appendNode(NSXMLElement *child, NSXMLElement *parent, Boolean thumb) {
|
||||
|
||||
if ([child.name isEqualToString:@"head"]) {
|
||||
if (thumb)
|
||||
return;
|
||||
NSXMLElement *dl = section(@"Metadata:", @"dl", parent);
|
||||
for (NSXMLElement *head in child.children) {
|
||||
make(@"dt", head.name, dl);
|
||||
make(@"dd", head.stringValue, dl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ([child.name isEqualToString:@"body"]) {
|
||||
parent = thumb ? make(@"ul", nil, parent) : section(@"Content:", @"ul", parent);
|
||||
|
||||
} else if ([child.name isEqualToString:@"outline"]) {
|
||||
if ([child attributeForName:@"separator"].stringValue) {
|
||||
make(@"hr", nil, parent);
|
||||
} else {
|
||||
NSString *desc = [child attributeForName:@"title"].stringValue;
|
||||
if (!desc || desc.length == 0)
|
||||
desc = [child attributeForName:@"text"].stringValue;
|
||||
// refreshInterval
|
||||
NSXMLElement *li = make(@"li", desc, parent);
|
||||
if (!thumb) {
|
||||
NSString *xmlUrl = [child attributeForName:@"xmlUrl"].stringValue;
|
||||
if (xmlUrl && xmlUrl.length > 0) {
|
||||
[li addChild:[NSXMLNode textWithStringValue:@" — "]];
|
||||
attribute(make(@"a", xmlUrl, li), @"href", xmlUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child.childCount > 0) {
|
||||
parent = make(@"ul", nil, parent);
|
||||
}
|
||||
}
|
||||
for (NSXMLElement *c in child.children) {
|
||||
appendNode(c, parent, thumb);
|
||||
}
|
||||
}
|
||||
|
||||
NSData* generateHTMLData(NSURL *url, NSBundle *bundle, BOOL thumb) {
|
||||
NSError *err;
|
||||
NSXMLDocument *doc = [[NSXMLDocument alloc] initWithContentsOfURL:url options:0 error:&err];
|
||||
if (err || !doc) {
|
||||
printf("ERROR: %s\n", err.description.UTF8String);
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSXMLElement *html = [NSXMLElement elementWithName:@"html"];
|
||||
NSXMLElement *head = make(@"head", nil, html);
|
||||
make(@"title", @"OPML file", head);
|
||||
|
||||
NSString *cssPath = [bundle pathForResource:thumb ? @"style-thumb" : @"style" ofType:@"css"];
|
||||
NSString *data = [NSString stringWithContentsOfFile:cssPath encoding:NSUTF8StringEncoding error:nil];
|
||||
make(@"style", data, head);
|
||||
|
||||
NSXMLElement *body = make(@"body", nil, html);
|
||||
|
||||
for (NSXMLElement *child in doc.children) {
|
||||
appendNode(child, body, thumb);
|
||||
}
|
||||
NSXMLDocument *xml = [NSXMLDocument documentWithRootElement:html];
|
||||
return [xml XMLDataWithOptions:NSXMLNodePrettyPrint | NSXMLNodeCompactEmptyElement];
|
||||
}
|
||||
|
||||
|
||||
/*void renderThumbnail(CFURLRef url, CFBundleRef bundle, CGContextRef context, CGSize maxSize) {
|
||||
NSData *data = generateHTMLData((__bridge NSURL*)url, bundle, true);
|
||||
if (data) {
|
||||
CGRect rect = CGRectMake(0, 0, 600, 800);
|
||||
float scale = maxSize.height / rect.size.height;
|
||||
|
||||
WebView *webView = [[WebView alloc] initWithFrame:rect];
|
||||
[webView.mainFrame.frameView scaleUnitSquareToSize:CGSizeMake(scale, scale)];
|
||||
[webView.mainFrame.frameView setAllowsScrolling:NO];
|
||||
[webView.mainFrame loadData:data MIMEType:@"text/html" textEncodingName:@"utf-8" baseURL:nil];
|
||||
|
||||
while ([webView isLoading])
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0, true);
|
||||
[webView display];
|
||||
|
||||
NSGraphicsContext *gc = [NSGraphicsContext graphicsContextWithGraphicsPort:(void *)context
|
||||
flipped:webView.isFlipped];
|
||||
[webView displayRectIgnoringOpacity:webView.bounds inContext:gc];
|
||||
}
|
||||
}*/
|
||||
12
QLOPML/style.css
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
* { font-family: Courier; }
|
||||
body { padding: 30px; background-color: #AAA; color: black; }
|
||||
dd, li, hr { font-weight: bold; line-height: 1.5em; }
|
||||
ul { list-style-type: none; padding-bottom: 1em; }
|
||||
a { font-size: 0.75em; color: #FBA43A; }
|
||||
.section { padding: 1em 1.5em; border-radius: 7px; background-color: #EEE; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background-color: #555; color: white; }
|
||||
.section { background-color: #222; }
|
||||
}
|
||||
229
README.md
@@ -1,90 +1,201 @@
|
||||
# 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 Mojave (10.14) 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, [RSXML2] \(required), and [QLOPML] \(optional).
|
||||
|
||||
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.
|
||||
Download and unzip this project, navigate to the root folder and run `carthage bootstrap --platform macOS`.
|
||||
```sh
|
||||
git clone https://github.com/relikd/baRSS
|
||||
git clone https://github.com/relikd/RSXML2
|
||||
git clone https://github.com/relikd/QLOPML
|
||||
```
|
||||
|
||||
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 `baRSS/baRSS.xcodeproj` 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`.
|
||||
|
||||
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.
|
||||
### Launch on start / reboot
|
||||
|
||||
baRSS has no option to launch it on start.
|
||||
However, you can still add the application to auto boot by adding it to the system login items:
|
||||
|
||||
`System Preferences > User > Login Items` (macOS 10-12)
|
||||
`System Preferences > General > Login Items & Extensions` (macOS 13+)
|
||||
|
||||
|
||||
### UI options
|
||||
|
||||
1. If you hold down the option key and click on an article item, you can mark a single item (un-)read without opening it.
|
||||
|
||||
2. To add websites without RSS feed you can use the regex converter.
|
||||
Hold down the option key in the feed edit modal and click the red regex button.
|
||||
Though, admittedly, this is for experts only.
|
||||
I still have to find a nice user-friendly way to achieve this.
|
||||
|
||||
|
||||
### CLI options
|
||||
|
||||
The following options have no UI equivalent and must be configured in Terminal.
|
||||
Most likely, you will never 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. 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```
|
||||
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 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:
|
||||
3. Limit the number of displayed articles per feed menu.
|
||||
**Note:** displayed unread count may be different than the unread items inside. 'Open all unread' will open hidden items too.
|
||||
```
|
||||
defaults write de.relikd.baRSS articlesInMenuLimit -int 40
|
||||
```
|
||||
|
||||
```defaults write de.relikd.baRSS shortArticleNamesLimit -int 50```
|
||||
4. 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 unread articles dot.
|
||||
```
|
||||
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```
|
||||
5. 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 a collection of ideas that may be added if people request it.
|
||||
|
||||
- [ ] Localizations
|
||||
- [x] Feed generator for websites without feeds
|
||||
- [ ] Automatically choose best update interval (e.g., avg)
|
||||
- [ ] Sync with online services
|
||||
- [ ] Feeds with authentication
|
||||
- [x] 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
|
||||
- [ ] music / video? (open media player)
|
||||
- [ ] Pure image feed? (show images directly in menu)
|
||||
- [ ] 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)~~
|
||||
|
||||
|
||||
- [ ] Nice to have (... on increased demand)
|
||||
- [ ] Automatically choose best update interval (e.g., avg)
|
||||
- [ ] Sync with online services
|
||||
- [ ] 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
|
||||
- [ ] music / video? (open media player)
|
||||
- [ ] Pure image feed? (show images directly in menu)
|
||||
- [ ] 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)~~
|
||||
|
||||
FAQ / Q&A
|
||||
---------
|
||||
|
||||
### App Store Notice
|
||||
|
||||
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!
|
||||
Second, 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 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] for feed parsing.
|
||||
[RSXML2] is licensed under a MIT license (same as this project).
|
||||
|
||||
|
||||
##### Trivia
|
||||
|
||||
- Start of project: __July 19, 2018__
|
||||
- Estimated development time: __2053h+__
|
||||
- First prototype used __feedparser python__ library
|
||||
|
||||
|
||||
[QLOPML]: https://github.com/relikd/QLOPML
|
||||
[RSXML2]: https://github.com/relikd/RSXML2
|
||||
[RSXML]: https://github.com/brentsimmons/RSXML
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2018 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
int main(int argc, const char * argv[]) {
|
||||
@autoreleasepool {
|
||||
// see: http://martiancraft.com/blog/2015/01/login-items/
|
||||
NSURL *mainURL = [NSURL fileURLWithPath:@"../../../../" isDirectory:YES relativeToURL:NSBundle.mainBundle.bundleURL];
|
||||
NSString *mainIdent = [[NSBundle bundleWithURL:mainURL] bundleIdentifier]; // de.relikd.baRSS
|
||||
|
||||
NSArray<NSRunningApplication*> *arr = [NSRunningApplication runningApplicationsWithBundleIdentifier:mainIdent];
|
||||
if (arr.count == 0) { // if not already running
|
||||
NSArray *pathComponents = [[[NSBundle mainBundle] bundlePath] pathComponents];
|
||||
pathComponents = [pathComponents subarrayWithRange:NSMakeRange(0, [pathComponents count] - 4)];
|
||||
NSString *path = [NSString pathWithComponents:pathComponents];
|
||||
[[NSWorkspace sharedWorkspace] launchApplication:path];
|
||||
}
|
||||
/*
|
||||
Important: If your daemon shuts down too quickly after being launched,
|
||||
launchd may think it has crashed. Daemons that continue this behavior may
|
||||
be suspended and not launched again when future requests arrive. To avoid
|
||||
this behavior, do not shut down for at least 10 seconds after launch.
|
||||
*/
|
||||
// https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html
|
||||
sleep(10); // Not sure if this is necessary. However, it doesnt hurt.
|
||||
[NSApp terminate:nil];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
109
baRSS.xcodeproj/xcshareddata/xcschemes/baRSS.xcscheme
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.8">
|
||||
<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">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "54ACC27B21061B3B0020715F"
|
||||
BuildableName = "baRSS.app"
|
||||
BlueprintName = "baRSS"
|
||||
ReferencedContainer = "container:baRSS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
migratedStopOnEveryIssue = "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>
|
||||
</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>
|
||||
@@ -1,33 +1,9 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2018 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#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
|
||||
|
||||
147
baRSS/AppHook.m
@@ -1,32 +1,18 @@
|
||||
//
|
||||
// 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 "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 "NotifyEndpoint.h"
|
||||
#import "NSURL+Ext.h"
|
||||
#import "NSError+Ext.h"
|
||||
|
||||
@interface AppHook()
|
||||
@property (strong) Preferences *prefWindow;
|
||||
@property (strong) NSWindowController *prefWindow;
|
||||
@end
|
||||
|
||||
@implementation AppHook
|
||||
@@ -38,35 +24,42 @@
|
||||
}
|
||||
|
||||
- (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]) {
|
||||
// stupid macOS bugs ... status-bar-menu-item frame is zero without delay
|
||||
// [_statusItem showWelcomeMessage];
|
||||
[_statusItem performSelector:@selector(showWelcomeMessage) withObject:nil afterDelay:.2];
|
||||
[UpdateScheduler autoDownloadAndParseUpdateURL];
|
||||
} else {
|
||||
// mostly for version migration 0.9.4 ~> 1.0 (favicon storage)
|
||||
if (initial) [UpdateScheduler updateAllFavicons];
|
||||
}
|
||||
|
||||
// Notifications are disabled by default so this wont trigger for first app launch.
|
||||
// Also, this will register the notification delegate and respond to click & open feed.
|
||||
[NotifyEndpoint activate];
|
||||
}
|
||||
|
||||
- (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 +67,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 +90,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 +132,7 @@
|
||||
[alert addButtonWithTitle:quitButton];
|
||||
[alert addButtonWithTitle:cancelButton];
|
||||
|
||||
NSInteger answer = [alert runModal];
|
||||
|
||||
if (answer == NSAlertSecondButtonReturn) {
|
||||
if ([alert runModal] == NSAlertSecondButtonReturn) {
|
||||
return NSTerminateCancel;
|
||||
}
|
||||
}
|
||||
@@ -166,6 +140,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 +182,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 +192,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
17
baRSS/Artwork/application-icon.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
<!-- 3 = half stroke width, 28 = 25 + 3, 25 = radius, 44 = 100 - 2*r - 2*3 -->
|
||||
<!-- <path fill="url(#orange)" stroke="#FFF" stroke-width="6" d="M3,28v44q0,25,25,25h44q25,0,25,-25v-44q0,-25,-25,-25h-44q-25,0,-25,25z"/> -->
|
||||
<g transform="translate(10 10) scale(.8 .8)">
|
||||
<path fill="url(#orange)" d="M0,25v50q0,25,25,25h50q25,0,25,-25v-50q0,-25,-25,-25h-50q-25,0,-25,25z"/>
|
||||
<g fill="#FFF" transform="translate(12.5 12.5) scale(.75 .75)">
|
||||
<circle cx="87" cy="13" r="13"/>
|
||||
<path d="M35,0q0,65,65,65v-20q-45,0,-45,-45z"/>
|
||||
<path d="M0,0q0,100,100,100v-20q-80,0,-80,-80z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 941 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"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,94 @@
|
||||
//
|
||||
// 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 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: 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";
|
||||
/// Feed edit, regex editor icon @c "(.*)"
|
||||
static NSImageName const RSSImageRegexIcon = @"RSSImageRegexIcon";
|
||||
|
||||
|
||||
#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
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
//
|
||||
// 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;
|
||||
#import "Feed+CoreDataClass.h"
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@class RSParsedFeed;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface Feed (Ext)
|
||||
@property (readonly) BOOL hasIcon;
|
||||
@property (nonnull, readonly) NSImage* iconImage16;
|
||||
|
||||
// Generator methods / Feed update
|
||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||
+ (instancetype)appendToRootWithDefaultIntervalInContext:(NSManagedObjectContext*)moc;
|
||||
- (NSString*)notificationID;
|
||||
- (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;
|
||||
- (nullable NSArray<FeedArticle*>*)sortedArticles;
|
||||
- (NSUInteger)countUnread;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -1,52 +1,26 @@
|
||||
//
|
||||
// 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 "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 "NotifyEndpoint.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;
|
||||
/// unique ID used for notifications. returns @c objectID.URIRepresentation.absoluteString
|
||||
- (NSString*)notificationID {
|
||||
return self.objectID.URIRepresentation.absoluteString;
|
||||
}
|
||||
|
||||
/// Call @c indexPathString on @c .group and update @c .indexPath if current value is different.
|
||||
@@ -59,8 +33,14 @@
|
||||
/// @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.toolTip = self.subtitle;
|
||||
item.title = self.group.anyName;
|
||||
// Tooltip disabled (feed-group only) because it causes issues on macOS Ventura.
|
||||
// Menu opens invisibly (OrderNSWindow: unsupported window ordering op -1)
|
||||
// steps to reproduce:
|
||||
// 1. hover over a feed-group menu item until tooltip pops up
|
||||
// 2. hover over another feed with tooltip
|
||||
// 3. go back to previous feed.
|
||||
// item.toolTip = self.subtitle;
|
||||
item.enabled = (self.articles.count > 0);
|
||||
item.image = self.iconImage16;
|
||||
item.representedObject = self.indexPath;
|
||||
@@ -73,7 +53,7 @@
|
||||
+ (void)didClickOnMenuItem:(NSMenuItem*)sender {
|
||||
NSString *url = [StoreCoordinator urlForFeedWithIndexPath:sender.representedObject];
|
||||
if (url && url.length > 0)
|
||||
[UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
|
||||
UserPrefsOpenURL(url);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,78 +68,69 @@
|
||||
if (![self.subtitle isEqualToString:obj.subtitle]) self.subtitle = obj.subtitle;
|
||||
if (![self.link isEqualToString:obj.link]) self.link = obj.link;
|
||||
|
||||
if (self.group.name.length == 0) // in case a blank group was initialized
|
||||
self.group.name = obj.title;
|
||||
|
||||
// Add and remove articles
|
||||
NSMutableSet<FeedArticle*> *oldSet = [self.articles mutableCopy];
|
||||
NSInteger diff = [self addMissingArticles:obj withOldSet:oldSet]; // will remove items that should be kept
|
||||
diff -= [self deleteArticlesWithOldSet:oldSet]; // remove old, outdated articles
|
||||
NSMutableSet<FeedArticle*> *localSet = [self.articles mutableCopy];
|
||||
NSInteger diff = 0;
|
||||
diff -= [self deleteArticles:localSet withRemoteSet:obj.articles]; // remove old, outdated articles
|
||||
diff += [self insertArticles:localSet withRemoteSet:obj.articles]; // insert new in correct order
|
||||
// Get new total article count and post unread-count-change notification
|
||||
if (flag && diff != 0) {
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountChanged object:@(diff)];
|
||||
PostNotification(kNotificationTotalUnreadCountChanged, @(diff));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Append new articles and increment their sortIndex. Update unread counter on the way.
|
||||
Append new articles and increment @c sortIndex and unread count.
|
||||
New articles are in ascending order without any gaps in between.
|
||||
|
||||
@note
|
||||
New articles should be in ascending order without any gaps in between.
|
||||
If new article is disjunct from the article before, assume a deleted article re-appeared and mark it as read.
|
||||
|
||||
@param oldSet Input will be used to identify new articles.
|
||||
Output contains articles that aren't present in the feed anymore and should be deleted.
|
||||
@param localSet Use result set of @c deleteArticles:withRemoteSet:
|
||||
*/
|
||||
- (NSInteger)addMissingArticles:(RSParsedFeed*)obj withOldSet:(NSMutableSet<FeedArticle*>*)oldSet {
|
||||
NSInteger newOnes = 0;
|
||||
int32_t currentIndex = [[self.articles valueForKeyPath:@"@min.sortIndex"] intValue];
|
||||
FeedArticle *lastInserted = nil;
|
||||
BOOL hasGapBetweenNewArticles = NO;
|
||||
|
||||
for (RSParsedArticle *article in [obj.articles reverseObjectEnumerator]) {
|
||||
// reverse enumeration ensures correct article order
|
||||
FeedArticle *storedArticle = [self findArticle:article inSet:oldSet];
|
||||
if (storedArticle) {
|
||||
[oldSet removeObject:storedArticle];
|
||||
if (storedArticle.sortIndex != currentIndex) {
|
||||
storedArticle.sortIndex = currentIndex;
|
||||
}
|
||||
hasGapBetweenNewArticles = YES;
|
||||
- (NSUInteger)insertArticles:(NSMutableSet<FeedArticle*>*)localSet withRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
|
||||
int32_t currentIndex = [[localSet valueForKeyPath:@"@min.sortIndex"] intValue];
|
||||
NSUInteger c = 0;
|
||||
for (RSParsedArticle *article in [remoteSet reverseObjectEnumerator]) {
|
||||
// Reverse enumeration ensures correct article order
|
||||
FeedArticle *stored = [self findRemoteArticle:article inLocalSet:localSet];
|
||||
if (stored) {
|
||||
[localSet removeObject:stored];
|
||||
if (stored.sortIndex != currentIndex)
|
||||
stored.sortIndex = currentIndex; // Ensures block of ascending indices
|
||||
// replace local values with remote changes (if any)
|
||||
[stored updateArticleIfChanged:article];
|
||||
} else {
|
||||
newOnes += 1;
|
||||
if (hasGapBetweenNewArticles && lastInserted) { // gap with at least one article inbetween
|
||||
lastInserted.unread = NO;
|
||||
newOnes -= 1;
|
||||
}
|
||||
hasGapBetweenNewArticles = NO;
|
||||
lastInserted = [FeedArticle newArticle:article inContext:self.managedObjectContext];
|
||||
lastInserted.sortIndex = currentIndex;
|
||||
[self addArticlesObject:lastInserted];
|
||||
FeedArticle *newArticle = [FeedArticle newArticle:article inContext:self.managedObjectContext];
|
||||
newArticle.sortIndex = currentIndex;
|
||||
[self addArticlesObject:newArticle];
|
||||
c += 1;
|
||||
}
|
||||
currentIndex += 1;
|
||||
}
|
||||
if (hasGapBetweenNewArticles && lastInserted) {
|
||||
lastInserted.unread = NO;
|
||||
newOnes -= 1;
|
||||
}
|
||||
return newOnes;
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
Delete all articles from core data, that are still in the oldSet.
|
||||
Delete all articles from core data, that aren't present anymore.
|
||||
|
||||
@param localSet Input a copy of @c self.articles . Output same set minus deleted articles.
|
||||
*/
|
||||
- (NSUInteger)deleteArticlesWithOldSet:(NSMutableSet<FeedArticle*>*)oldSet {
|
||||
if (!oldSet || oldSet.count == 0)
|
||||
return 0;
|
||||
- (NSUInteger)deleteArticles:(NSMutableSet<FeedArticle*>*)localSet withRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
|
||||
NSUInteger c = 0;
|
||||
for (FeedArticle *fa in oldSet) {
|
||||
if (fa.unread) ++c;
|
||||
// TODO: keep unread articles?
|
||||
[self.managedObjectContext deleteObject:fa];
|
||||
NSMutableSet<FeedArticle*> *deletingSet = [NSMutableSet setWithCapacity:localSet.count];
|
||||
NSMutableArray *dismissed = [NSMutableArray array];
|
||||
for (FeedArticle *fa in localSet) {
|
||||
if (![self findLocalArticle:fa inRemoteSet:remoteSet]) {
|
||||
if (fa.unread) ++c;
|
||||
// TODO: keep unread articles?
|
||||
[dismissed addObject:fa.notificationID];
|
||||
[self.managedObjectContext deleteObject:fa];
|
||||
[deletingSet addObject:fa];
|
||||
}
|
||||
}
|
||||
if (deletingSet.count > 0) {
|
||||
[localSet minusSet:deletingSet];
|
||||
[self removeArticles:deletingSet];
|
||||
[NotifyEndpoint dismiss:dismissed];
|
||||
}
|
||||
if (oldSet.count > 0)
|
||||
[self removeArticles:oldSet];
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -170,66 +141,98 @@
|
||||
/**
|
||||
@return Articles sorted by attribute @c sortIndex with descending order (newest items first).
|
||||
*/
|
||||
- (NSArray<FeedArticle*>*)sortedArticles {
|
||||
- (nullable NSArray<FeedArticle*>*)sortedArticles {
|
||||
if (self.articles.count == 0)
|
||||
return nil;
|
||||
return [self.articles sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]];
|
||||
}
|
||||
|
||||
/**
|
||||
Iterate over oldSet and return the one where @c link and @c guid matches. Or @c nil if no matching article found.
|
||||
Iterate over localSet and return the one where @c link and @c guid matches. Or @c nil if no matching article found.
|
||||
*/
|
||||
- (FeedArticle*)findArticle:(RSParsedArticle*)article inSet:(NSSet<FeedArticle*>*)oldSet {
|
||||
NSString *searchLink = article.link;
|
||||
NSString *searchGuid = article.guid;
|
||||
BOOL linkIsNil = (searchLink == nil);
|
||||
BOOL guidIsNil = (searchGuid == nil);
|
||||
for (FeedArticle *old in oldSet) {
|
||||
if ((linkIsNil && old.link == nil) || [old.link isEqualToString:searchLink]) {
|
||||
if ((guidIsNil && old.guid == nil) || [old.guid isEqualToString:searchGuid])
|
||||
return old;
|
||||
- (FeedArticle*)findRemoteArticle:(RSParsedArticle*)remote inLocalSet:(NSSet<FeedArticle*>*)localSet {
|
||||
NSString *searchLink = remote.link;
|
||||
NSString *searchGuid = remote.guid;
|
||||
for (FeedArticle *art in localSet) {
|
||||
// assuming if a guid is set, it will always be unique
|
||||
if (searchGuid != nil) {
|
||||
if ([art.guid isEqualToString:searchGuid])
|
||||
return art;
|
||||
} else if (searchLink != nil) {
|
||||
if ([art.link isEqualToString:searchLink])
|
||||
return art;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
/**
|
||||
Iterate over remoteSet and return the one where @c link and @c guid matches. Or @c nil if no matching article found.
|
||||
*/
|
||||
- (RSParsedArticle*)findLocalArticle:(FeedArticle*)local inRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
|
||||
NSString *searchLink = local.link;
|
||||
NSString *searchGuid = local.guid;
|
||||
for (RSParsedArticle *art in remoteSet) {
|
||||
// assuming if a guid is set, it will always be unique
|
||||
if (searchGuid != nil) {
|
||||
if ([art.guid isEqualToString:searchGuid])
|
||||
return art;
|
||||
} else if (searchLink != nil) {
|
||||
if ([art.link isEqualToString:searchLink])
|
||||
return art;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
/// Number of unread articles
|
||||
- (NSUInteger)countUnread {
|
||||
NSUInteger count = 0;
|
||||
for (FeedArticle *article in self.articles) {
|
||||
if (article.unread)
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
#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) {
|
||||
NSData* data = [[NSData alloc] initWithContentsOfURL:[self iconPath]];
|
||||
img = [[NSImage alloc] initWithData:data];
|
||||
} 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.
|
||||
|
||||
@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;
|
||||
/// Checks if file at @c iconPath is an actual file
|
||||
- (BOOL)hasIcon { return [[self iconPath] existsAndIsDir:NO]; }
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,31 +1,14 @@
|
||||
//
|
||||
// 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;
|
||||
#import "FeedArticle+CoreDataClass.h"
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@class RSParsedArticle;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface FeedArticle (Ext)
|
||||
+ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc;
|
||||
- (NSString*)notificationID;
|
||||
- (void)updateArticleIfChanged:(RSParsedArticle*)entry;
|
||||
- (NSMenuItem*)newMenuItem;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -1,31 +1,11 @@
|
||||
//
|
||||
// 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.RSParsedArticle;
|
||||
#import "FeedArticle+Ext.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "Constants.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "StoreCoordinator.h"
|
||||
|
||||
#import <RSXML/RSParsedArticle.h>
|
||||
#import "NotifyEndpoint.h"
|
||||
#import "NSString+Ext.h"
|
||||
|
||||
@implementation FeedArticle (Ext)
|
||||
|
||||
@@ -35,11 +15,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;
|
||||
@@ -48,25 +27,42 @@
|
||||
return fa;
|
||||
}
|
||||
|
||||
/// unique ID used for notifications. returns @c objectID.URIRepresentation.absoluteString
|
||||
- (NSString*)notificationID {
|
||||
return self.objectID.URIRepresentation.absoluteString;
|
||||
}
|
||||
|
||||
- (void)updateArticleIfChanged:(RSParsedArticle*)entry {
|
||||
[self setGuidIfChanged:entry.guid];
|
||||
[self setTitleIfChanged:entry.title];
|
||||
[self setAuthorIfChanged:entry.author];
|
||||
[self setAbstractIfChanged:(entry.abstract.length > 0) ? [entry.abstract htmlToPlainText] : nil];
|
||||
[self setBodyIfChanged:(entry.body.length > 0) ? [entry.body htmlToPlainText] : nil];
|
||||
[self setLinkIfChanged:(entry.link.length > 0) ? entry.link : entry.guid];
|
||||
[self setPublishedIfChanged:entry.datePublished ? entry.datePublished : entry.dateModified];
|
||||
}
|
||||
|
||||
/// @return Full or truncated article title, based on user preference in settings.
|
||||
- (NSString*)shortArticleName {
|
||||
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 +76,92 @@
|
||||
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);
|
||||
|
||||
[NotifyEndpoint dismiss:fa.feed.countUnread > 0 ? @[fa.notificationID] : @[fa.notificationID, fa.feed.notificationID]];
|
||||
}
|
||||
[moc reset];
|
||||
if (url && url.length > 0 && !flipUnread) // flipUnread == change unread state
|
||||
[UserPrefs openURLsWithPreferredBrowser:@[[NSURL URLWithString:url]]];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Setter -
|
||||
|
||||
|
||||
/// Set @c guid attribute but only if value differs.
|
||||
- (void)setGuidIfChanged:(nullable NSString*)guid {
|
||||
if (guid.length == 0) {
|
||||
if (self.guid.length > 0)
|
||||
self.guid = nil; // nullify empty strings
|
||||
} else if (![self.guid isEqualToString: guid]) {
|
||||
self.guid = guid;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c link attribute but only if value differs.
|
||||
- (void)setLinkIfChanged:(nullable NSString*)link {
|
||||
if (link.length == 0) {
|
||||
if (self.link.length > 0)
|
||||
self.link = nil; // nullify empty strings
|
||||
} else if (![self.link isEqualToString: link]) {
|
||||
self.link = link;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c title attribute but only if value differs.
|
||||
- (void)setTitleIfChanged:(nullable NSString*)title {
|
||||
if (title.length == 0) {
|
||||
if (self.title.length > 0)
|
||||
self.title = nil; // nullify empty strings
|
||||
} else if (![self.title isEqualToString: title]) {
|
||||
self.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c abstract attribute but only if value differs.
|
||||
- (void)setAbstractIfChanged:(nullable NSString*)abstract {
|
||||
if (abstract.length == 0) {
|
||||
if (self.abstract.length > 0)
|
||||
self.abstract = nil; // nullify empty strings
|
||||
} else if (![self.abstract isEqualToString: abstract]) {
|
||||
self.abstract = abstract;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c body attribute but only if value differs.
|
||||
- (void)setBodyIfChanged:(nullable NSString*)body {
|
||||
if (body.length == 0) {
|
||||
if (self.body.length > 0)
|
||||
self.body = nil; // nullify empty strings
|
||||
} else if (![self.body isEqualToString: body]) {
|
||||
self.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c author attribute but only if value differs.
|
||||
- (void)setAuthorIfChanged:(nullable NSString*)author {
|
||||
if (author.length == 0) {
|
||||
if (self.author.length > 0)
|
||||
self.author = nil; // nullify empty strings
|
||||
} else if (![self.author isEqualToString: author]) {
|
||||
self.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c published attribute but only if value differs.
|
||||
- (void)setPublishedIfChanged:(nullable NSDate*)published {
|
||||
if (!published) {
|
||||
if (self.published)
|
||||
self.published = nil; // nullify empty date
|
||||
} else if (![self.published isEqualToDate: published]) {
|
||||
self.published = published;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
//
|
||||
// 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;
|
||||
#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) {
|
||||
@@ -29,24 +7,27 @@ typedef NS_ENUM(int16_t, FeedGroupType) {
|
||||
GROUP = 0, FEED = 1, SEPARATOR = 2
|
||||
};
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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;
|
||||
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
|
||||
- (void)setNameIfChanged:(NSString*)name;
|
||||
+ (instancetype)appendToRoot:(FeedGroupType)type inContext:(NSManagedObjectContext*)moc;
|
||||
- (void)setParent:(nullable FeedGroup *)parent andSortIndex:(int32_t)sortIndex;
|
||||
- (void)setSortIndexIfChanged:(int32_t)sortIndex;
|
||||
- (void)setNameIfChanged:(nullable NSString*)name;
|
||||
- (NSMenuItem*)newMenuItem;
|
||||
// Handle children and parents
|
||||
- (NSString*)indexPathString;
|
||||
- (NSArray<FeedGroup*>*)sortedChildren;
|
||||
- (nullable NSArray<FeedGroup*>*)sortedChildren;
|
||||
- (NSMutableArray<FeedGroup*>*)allParents;
|
||||
- (BOOL)iterateSorted:(BOOL)ordered overDescendantFeeds:(void(^)(Feed *feed, BOOL* cancel))block;
|
||||
// Printing
|
||||
- (NSString*)readableDescription;
|
||||
- (nonnull NSString*)refreshString;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
//
|
||||
// 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 "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,28 +39,55 @@
|
||||
+ (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;
|
||||
}
|
||||
|
||||
- (void)setParent:(FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
|
||||
/// 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:(nullable FeedGroup *)parent andSortIndex:(int32_t)sortIndex {
|
||||
self.parent = parent;
|
||||
self.sortIndex = sortIndex;
|
||||
if (self.type == FEED)
|
||||
[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;
|
||||
}
|
||||
// Otherwise move from 0.0 -> 0 will not trigger index path update
|
||||
[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;
|
||||
@@ -98,7 +106,7 @@
|
||||
}
|
||||
|
||||
/// @return Children sorted by attribute @c sortIndex (same order as in preferences).
|
||||
- (NSArray<FeedGroup*>*)sortedChildren {
|
||||
- (nullable NSArray<FeedGroup*>*)sortedChildren {
|
||||
if (self.children.count == 0)
|
||||
return nil;
|
||||
return [self.children sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]];
|
||||
@@ -141,22 +149,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
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
//
|
||||
// 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;
|
||||
#import "FeedMeta+CoreDataClass.h"
|
||||
|
||||
static const int32_t kDefaultFeedRefreshInterval = 30 * 60;
|
||||
static int32_t const kDefaultFeedRefreshInterval = 30 * 60;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
//
|
||||
// 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 "FeedMeta+Ext.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
|
||||
@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 +19,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 +29,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 +47,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
|
||||
|
||||
@@ -1,33 +1,14 @@
|
||||
//
|
||||
// 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;
|
||||
|
||||
#import <CoreData/CoreData.h>
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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
|
||||
@@ -36,3 +17,5 @@
|
||||
- (instancetype)sortDESC:(NSString*)key; // add .sortDescriptors -> ascending:NO
|
||||
- (instancetype)addFunctionExpression:(NSString*)fn onKeyPath:(NSString*)keyPath name:(NSString*)name type:(NSAttributeType)type; // add .propertiesToFetch -> (expressionForFunction:@[expressionForKeyPath:])
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
//
|
||||
// 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 "NSFetchRequest+Ext.h"
|
||||
#import "NSError+Ext.h"
|
||||
|
||||
@implementation NSFetchRequest (Ext)
|
||||
|
||||
@@ -28,7 +7,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 +19,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];
|
||||
|
||||
16
baRSS/Core Data/RegexConverter+Ext.h
Normal file
@@ -0,0 +1,16 @@
|
||||
@import Cocoa;
|
||||
#import "RegexConverter+CoreDataClass.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface RegexConverter (Ext)
|
||||
+ (instancetype)newInContext:(NSManagedObjectContext*)moc;
|
||||
- (void)setEntryIfChanged:(nullable NSString*)pattern;
|
||||
- (void)setHrefIfChanged:(nullable NSString*)pattern;
|
||||
- (void)setTitleIfChanged:(nullable NSString*)pattern;
|
||||
- (void)setDescIfChanged:(nullable NSString*)pattern;
|
||||
- (void)setDateIfChanged:(nullable NSString*)pattern;
|
||||
- (void)setDateFormatIfChanged:(nullable NSString*)pattern;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
70
baRSS/Core Data/RegexConverter+Ext.m
Normal file
@@ -0,0 +1,70 @@
|
||||
#import "RegexConverter+Ext.h"
|
||||
|
||||
@implementation RegexConverter (Ext)
|
||||
|
||||
/// Create new instance
|
||||
+ (instancetype)newInContext:(NSManagedObjectContext*)moc {
|
||||
return [[RegexConverter alloc] initWithEntity:[RegexConverter entity] insertIntoManagedObjectContext:moc];
|
||||
}
|
||||
|
||||
/// Set @c entry attribute but only if value differs.
|
||||
- (void)setEntryIfChanged:(nullable NSString*)pattern {
|
||||
if (pattern.length == 0) {
|
||||
if (self.entry.length > 0)
|
||||
self.entry = nil; // nullify empty strings
|
||||
} else if (![self.entry isEqualToString: pattern]) {
|
||||
self.entry = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c href attribute but only if value differs.
|
||||
- (void)setHrefIfChanged:(nullable NSString*)pattern {
|
||||
if (pattern.length == 0) {
|
||||
if (self.href.length > 0)
|
||||
self.href = nil; // nullify empty strings
|
||||
} else if (![self.href isEqualToString: pattern]) {
|
||||
self.href = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c title attribute but only if value differs.
|
||||
- (void)setTitleIfChanged:(nullable NSString*)pattern {
|
||||
if (pattern.length == 0) {
|
||||
if (self.title.length > 0)
|
||||
self.title = nil; // nullify empty strings
|
||||
} else if (![self.title isEqualToString: pattern]) {
|
||||
self.title = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c desc attribute but only if value differs.
|
||||
- (void)setDescIfChanged:(nullable NSString*)pattern {
|
||||
if (pattern.length == 0) {
|
||||
if (self.desc.length > 0)
|
||||
self.desc = nil; // nullify empty strings
|
||||
} else if (![self.desc isEqualToString: pattern]) {
|
||||
self.desc = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c date attribute but only if value differs.
|
||||
- (void)setDateIfChanged:(nullable NSString*)pattern {
|
||||
if (pattern.length == 0) {
|
||||
if (self.date.length > 0)
|
||||
self.date = nil; // nullify empty strings
|
||||
} else if (![self.date isEqualToString: pattern]) {
|
||||
self.date = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set @c dateFormat attribute but only if value differs.
|
||||
- (void)setDateFormatIfChanged:(nullable NSString*)pattern {
|
||||
if (pattern.length == 0) {
|
||||
if (self.dateFormat.length > 0)
|
||||
self.dateFormat = nil; // nullify empty strings
|
||||
} else if (![self.dateFormat isEqualToString: pattern]) {
|
||||
self.dateFormat = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,55 +1,40 @@
|
||||
//
|
||||
// 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 <Foundation/Foundation.h>
|
||||
@import Cocoa;
|
||||
#import "DBv1+CoreDataModel.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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:(nullable 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;
|
||||
+ (nullable NSArray<NSString*>*)updateArticles:(NSArray<FeedArticle*>*)list markRead:(BOOL)markRead andOpen:(BOOL)openLinks inContext:(NSManagedObjectContext*)moc;
|
||||
|
||||
// Restore sound state
|
||||
+ (void)restoreFeedIndexPaths;
|
||||
+ (NSUInteger)deleteUnreferenced;
|
||||
+ (NSUInteger)deleteAllGroups;
|
||||
+ (void)cleanupAndShowAlert:(BOOL)flag;
|
||||
+ (NSUInteger)cleanupFavicons;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
//
|
||||
// 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 "StoreCoordinator.h"
|
||||
#import "NSFetchRequest+Ext.h"
|
||||
#import "AppHook.h"
|
||||
#import "Constants.h"
|
||||
#import "FaviconDownload.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedArticle+Ext.h"
|
||||
#import "NSURL+Ext.h"
|
||||
#import "NSError+Ext.h"
|
||||
#import "NSFetchRequest+Ext.h"
|
||||
|
||||
@implementation StoreCoordinator
|
||||
|
||||
@@ -49,18 +33,37 @@
|
||||
@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;
|
||||
}
|
||||
if (opt.value != value) {
|
||||
opt.value = value;
|
||||
}
|
||||
[self saveContext:moc andParent:YES];
|
||||
[moc reset];
|
||||
}
|
||||
|
||||
|
||||
@@ -70,26 +73,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 +116,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:(nullable 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 "."
|
||||
@@ -194,9 +204,67 @@
|
||||
return [fr fetchAllRows:moc];
|
||||
}
|
||||
|
||||
/**
|
||||
For provided articles, pen link, mark read, and save changes.
|
||||
@warning Will invalidate context.
|
||||
|
||||
@param list Should only contain @c FeedArticle
|
||||
@param markRead Whether the articles should be marked read or unread.
|
||||
@param openLinks Whether to open the link or mark read without opening
|
||||
|
||||
@return @c notificationID for all articles that were opened (empty if @c openLinks=NO or open failed).
|
||||
*/
|
||||
+ (nullable NSArray<NSString*>*)updateArticles:(NSArray<FeedArticle*>*)list markRead:(BOOL)markRead andOpen:(BOOL)openLinks inContext:(NSManagedObjectContext*)moc {
|
||||
if (openLinks) {
|
||||
NSMutableArray<NSURL*> *urls = [NSMutableArray arrayWithCapacity:list.count];
|
||||
for (FeedArticle *fa in list) {
|
||||
if (fa.link.length > 0)
|
||||
[urls addObject:[NSURL URLWithString:fa.link]];
|
||||
}
|
||||
if (urls.count > 0 && !UserPrefsOpenURLs(urls))
|
||||
return nil; // if success == NO, do not modify unread state & exit
|
||||
}
|
||||
|
||||
NSInteger countChange = 0;
|
||||
for (FeedArticle *fa in list) {
|
||||
if (fa.unread == markRead) { // only if differs
|
||||
fa.unread = !markRead;
|
||||
countChange += markRead ? -1 : +1;
|
||||
}
|
||||
}
|
||||
[self saveContext:moc andParent:YES];
|
||||
|
||||
// gather uri-ids for notification dismiss
|
||||
NSMutableArray<NSString*> *dbRefs = [NSMutableArray array];
|
||||
if (markRead) {
|
||||
for (FeedArticle *fa in list) {
|
||||
[dbRefs addObject:fa.notificationID];
|
||||
[dbRefs addObject:fa.feed.notificationID];
|
||||
}
|
||||
}
|
||||
|
||||
[moc reset];
|
||||
PostNotification(kNotificationTotalUnreadCountChanged, @(countChange));
|
||||
return dbRefs;
|
||||
}
|
||||
|
||||
|
||||
#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 +283,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 +292,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 +313,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,53 +1,63 @@
|
||||
<?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="17709" systemVersion="19H2026" 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"/>
|
||||
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<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"/>
|
||||
<attribute name="indexPath" optional="YES" attributeType="String"/>
|
||||
<attribute name="link" optional="YES" attributeType="String"/>
|
||||
<attribute name="subtitle" optional="YES" attributeType="String"/>
|
||||
<attribute name="title" optional="YES" attributeType="String"/>
|
||||
<relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle"/>
|
||||
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="feed" inverseEntity="FeedGroup"/>
|
||||
<relationship name="meta" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="FeedMeta" inverseName="feed" inverseEntity="FeedMeta"/>
|
||||
<relationship name="regex" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="RegexConverter" inverseName="feed" inverseEntity="RegexConverter"/>
|
||||
</entity>
|
||||
<entity name="FeedArticle" representedClassName="FeedArticle" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="abstract" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="author" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="body" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="guid" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO" customClassName="NSArray" syncable="YES"/>
|
||||
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="unread" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
|
||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="articles" inverseEntity="Feed" syncable="YES"/>
|
||||
<attribute name="abstract" optional="YES" attributeType="String"/>
|
||||
<attribute name="author" optional="YES" attributeType="String"/>
|
||||
<attribute name="body" optional="YES" attributeType="String"/>
|
||||
<attribute name="guid" optional="YES" attributeType="String"/>
|
||||
<attribute name="link" optional="YES" attributeType="String"/>
|
||||
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO" customClassName="NSArray"/>
|
||||
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="title" optional="YES" attributeType="String"/>
|
||||
<attribute name="unread" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="articles" inverseEntity="Feed"/>
|
||||
</entity>
|
||||
<entity name="FeedGroup" representedClassName="FeedGroup" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="name" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="type" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
|
||||
<relationship name="children" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedGroup" inverseName="parent" inverseEntity="FeedGroup" syncable="YES"/>
|
||||
<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"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="type" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES"/>
|
||||
<relationship name="children" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedGroup" inverseName="parent" inverseEntity="FeedGroup"/>
|
||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Feed" inverseName="group" inverseEntity="Feed"/>
|
||||
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="children" inverseEntity="FeedGroup"/>
|
||||
</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"/>
|
||||
<attribute name="modified" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="refresh" optional="YES" attributeType="Integer 32" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="scheduled" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||
<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"/>
|
||||
<attribute name="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="etag" optional="YES" attributeType="String"/>
|
||||
<attribute name="modified" optional="YES" attributeType="String"/>
|
||||
<attribute name="refresh" optional="YES" attributeType="Integer 32" defaultValueString="-1" usesScalarValueType="YES"/>
|
||||
<attribute name="scheduled" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="url" optional="YES" attributeType="String"/>
|
||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed"/>
|
||||
</entity>
|
||||
<entity name="Options" representedClassName="Options" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="key" optional="YES" attributeType="String"/>
|
||||
<attribute name="value" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="RegexConverter" representedClassName="RegexConverter" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="date" optional="YES" attributeType="String"/>
|
||||
<attribute name="dateFormat" optional="YES" attributeType="String"/>
|
||||
<attribute name="desc" optional="YES" attributeType="String"/>
|
||||
<attribute name="entry" optional="YES" attributeType="String"/>
|
||||
<attribute name="href" optional="YES" attributeType="String"/>
|
||||
<attribute name="title" optional="YES" attributeType="String"/>
|
||||
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="regex" inverseEntity="Feed"/>
|
||||
</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="163"/>
|
||||
<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="FeedMeta" positionX="-456.265625" positionY="62.41015625" width="128" height="150"/>
|
||||
<element name="Options" positionX="-279.09375" positionY="91.4609375" width="128" height="75"/>
|
||||
<element name="RegexConverter" positionX="-115.984375" positionY="93.1796875" width="128" height="148"/>
|
||||
</elements>
|
||||
</model>
|
||||
14
baRSS/Feed Import/Download3rdParty.h
Normal file
@@ -0,0 +1,14 @@
|
||||
@import Cocoa;
|
||||
|
||||
#define ENV_LOG_YOUTUBE 1
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// TODO: Make plugins extensible? community extensions.
|
||||
@interface YouTubePlugin : NSObject
|
||||
+ (nullable NSString*)feedURL:(NSURL*)url data:(NSData*)html;
|
||||
+ (NSString*)videoImage:(NSString*)videoid;
|
||||
+ (NSString*)videoImageHQ:(NSString*)videoid;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
85
baRSS/Feed Import/Download3rdParty.m
Normal file
@@ -0,0 +1,85 @@
|
||||
#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.
|
||||
*/
|
||||
+ (nullable NSString*)feedURL:(NSURL*)url data:(NSData*)html {
|
||||
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]
|
||||
// https://www.youtube.com/c/[channel-name]
|
||||
#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;
|
||||
}
|
||||
}
|
||||
} else if ([type isEqualToString:@"c"]) {
|
||||
NSData *m_head = [@"<meta itemprop=\"channelId\" content=\"" dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSRange tmp = [html rangeOfData:m_head options:0 range:NSMakeRange(0, html.length)];
|
||||
if (tmp.location == NSNotFound) {
|
||||
NSData *m_json = [@"\"channelId\":\"" dataUsingEncoding:NSUTF8StringEncoding];
|
||||
tmp = [html rangeOfData:m_json options:0 range:NSMakeRange(0, html.length)];
|
||||
}
|
||||
NSUInteger start = tmp.location + tmp.length;
|
||||
NSUInteger end = html.length - start;
|
||||
if (end > 50) end = 50; // no need to search till the end
|
||||
NSString *substr = [[NSString alloc] initWithData:[html subdataWithRange:NSMakeRange(start, end)] encoding:NSUTF8StringEncoding];
|
||||
if (substr) {
|
||||
NSUInteger to = [substr rangeOfString:@"\""].location;
|
||||
if (to != NSNotFound) {
|
||||
found = [ytBase stringByAppendingFormat:@"?channel_id=%@", [substr substringToIndex:to]];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#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
|
||||
|
||||
29
baRSS/Feed Import/FaviconDownload.h
Normal file
@@ -0,0 +1,29 @@
|
||||
@import Cocoa;
|
||||
@class Feed, RSHTMLMetadata, FeedDownload;
|
||||
@protocol FaviconDownloadDelegate;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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:(nullable 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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
210
baRSS/Feed Import/FaviconDownload.m
Normal file
@@ -0,0 +1,210 @@
|
||||
@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;
|
||||
if (path) {
|
||||
NSData* data = [[NSData alloc] initWithContentsOfURL:path];
|
||||
img = [[NSImage alloc] initWithData:data];
|
||||
} else {
|
||||
img = 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;
|
||||
NSData* data = [[NSData alloc] initWithContentsOfURL:path];
|
||||
NSImage* img = [[NSImage alloc] initWithData:data];
|
||||
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:(nullable 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
|
||||
47
baRSS/Feed Import/FeedDownload.h
Normal file
@@ -0,0 +1,47 @@
|
||||
@import Cocoa;
|
||||
@class RSParsedFeed, RSHTMLMetadataFeedLink, Feed, FaviconDownload, RegexConverter;
|
||||
@protocol FeedDownloadDelegate;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/**
|
||||
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;
|
||||
@property (readonly, nullable) NSData *rawData;
|
||||
|
||||
typedef void (^FeedDownloadBlock)(FeedDownload *sender);
|
||||
|
||||
// Instantiation methods
|
||||
+ (instancetype)withURL:(NSString*)url;
|
||||
+ (instancetype)withFeed:(Feed*)feed forced:(BOOL)flag;
|
||||
// Actions
|
||||
- (instancetype)withRegex:(nullable RegexConverter *)converter enforce:(BOOL)flag;
|
||||
- (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.
|
||||
- (nullable 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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
259
baRSS/Feed Import/FeedDownload.m
Normal file
@@ -0,0 +1,259 @@
|
||||
@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"
|
||||
#import "RegexFeed.h"
|
||||
#import "RegexConverter+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;
|
||||
@property (nonatomic, strong) NSData *rawData;
|
||||
@property (nonatomic, strong) RegexConverter *regexConverter;
|
||||
@property (nonatomic, assign) BOOL regexEnforce;
|
||||
@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 withRegex:feed.regex enforce:false];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// | MARK: - Getter & Setter
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// Set @c .regexConverter for html-processed feeds.
|
||||
- (instancetype)withRegex:(RegexConverter *)converter enforce:(BOOL)flag {
|
||||
self.regexConverter = converter;
|
||||
self.regexEnforce = flag;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
self.rawData = data;
|
||||
if (!data) { // data = nil if (error || 304)
|
||||
[self performSelectorOnMainThread:@selector(finishAndNotify) withObject:nil waitUntilDone:NO];
|
||||
return;
|
||||
}
|
||||
// if regex is used, no further processing
|
||||
if (self.regexConverter || self.regexEnforce) {
|
||||
[self processWithRegexConverter:self.regexConverter data:data];
|
||||
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 is HTML data and will be parsed with @c RegexConverter
|
||||
- (void)processWithRegexConverter:(RegexConverter *)converter data:(NSData *)rawData {
|
||||
NSError *err = nil;
|
||||
if (converter) {
|
||||
NSString *theData = [[NSString alloc] initWithData:rawData encoding:NSUTF8StringEncoding];
|
||||
NSArray<RegexFeedEntry*> *matches = [[RegexFeed from:converter] process:theData error:&err];
|
||||
|
||||
RSParsedFeed *feed = [[RSParsedFeed alloc] initWithURL:self.request.URL];
|
||||
feed.link = self.request.URL.absoluteString; // needed for group-menu-item-open
|
||||
for (RegexFeedEntry *rxEntry in matches) {
|
||||
RSParsedArticle *article = [feed appendNewArticle];
|
||||
article.link = rxEntry.href;
|
||||
article.title = rxEntry.title;
|
||||
article.body = rxEntry.desc;
|
||||
article.datePublished = rxEntry.date;
|
||||
}
|
||||
self.xmlfeed = feed;
|
||||
} else {
|
||||
self.xmlfeed = nil;
|
||||
}
|
||||
self.error = err;
|
||||
[self performSelectorOnMainThread:@selector(finishAndNotify) withObject:nil waitUntilDone:NO];
|
||||
}
|
||||
|
||||
/// 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 data:xml.data];
|
||||
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 || !feedURL) {
|
||||
[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
|
||||
45
baRSS/Feed Import/OpmlFile.h
Normal file
@@ -0,0 +1,45 @@
|
||||
@import Cocoa;
|
||||
@class FeedGroup;
|
||||
|
||||
typedef NS_OPTIONS(NSUInteger, OpmlFileExportOptions) {
|
||||
OpmlFileExportOptionFlattened = 1 << 1,
|
||||
OpmlFileExportOptionFullBackup = 1 << 2,
|
||||
};
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
#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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
325
baRSS/Feed Import/OpmlFile.m
Normal file
@@ -0,0 +1,325 @@
|
||||
@import RSXML2;
|
||||
#import "OpmlFile.h"
|
||||
#import "FeedMeta+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
#import "RegexConverter+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;
|
||||
|
||||
// baRSS specific
|
||||
NSString *rxEntry = [item attributeForKey:@"rxEntry"];
|
||||
NSString *rxHref = [item attributeForKey:@"rxHref"];
|
||||
NSString *rxTitle = [item attributeForKey:@"rxTitle"];
|
||||
NSString *rxDesc = [item attributeForKey:@"rxDesc"];
|
||||
NSString *rxDate = [item attributeForKey:@"rxDate"];
|
||||
NSString *rxDateFormat = [item attributeForKey:@"rxDateFormat"];
|
||||
if (rxEntry || rxHref || rxTitle || rxDesc || rxDate || rxDateFormat) {
|
||||
RegexConverter *rx = [RegexConverter newInContext:moc];
|
||||
rx.entry = rxEntry;
|
||||
rx.href = rxHref;
|
||||
rx.title = rxTitle;
|
||||
rx.desc = rxDesc;
|
||||
rx.date = rxDate;
|
||||
rx.dateFormat = rxDateFormat;
|
||||
newFeed.feed.regex = rx;
|
||||
}
|
||||
} 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
|
||||
RegexConverter *rx = item.feed.regex;
|
||||
if (rx) { // baRSS specific
|
||||
if (rx.entry)
|
||||
[outline addAttribute:[NSXMLNode attributeWithName:@"rxEntry" stringValue:rx.entry]];
|
||||
if (rx.href)
|
||||
[outline addAttribute:[NSXMLNode attributeWithName:@"rxHref" stringValue:rx.href]];
|
||||
if (rx.title)
|
||||
[outline addAttribute:[NSXMLNode attributeWithName:@"rxTitle" stringValue:rx.title]];
|
||||
if (rx.desc)
|
||||
[outline addAttribute:[NSXMLNode attributeWithName:@"rxDesc" stringValue:rx.desc]];
|
||||
if (rx.date)
|
||||
[outline addAttribute:[NSXMLNode attributeWithName:@"rxDate" stringValue:rx.date]];
|
||||
if (rx.dateFormat)
|
||||
[outline addAttribute:[NSXMLNode attributeWithName:@"rxDateFormat" stringValue:rx.dateFormat]];
|
||||
}
|
||||
// TODO: option to export unread state?
|
||||
}
|
||||
parent = outline;
|
||||
}
|
||||
for (FeedGroup *subItem in [item sortedChildren]) {
|
||||
[self appendChild:subItem toNode:parent hierarchical:flag];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
30
baRSS/Feed Import/UpdateScheduler.h
Normal file
@@ -0,0 +1,30 @@
|
||||
@import Cocoa;
|
||||
|
||||
@class Feed;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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 notifications:(BOOL)notify 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;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
322
baRSS/Feed Import/UpdateScheduler.m
Normal file
@@ -0,0 +1,322 @@
|
||||
@import SystemConfiguration;
|
||||
#import "UpdateScheduler.h"
|
||||
#import "Constants.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "NotifyEndpoint.h"
|
||||
#import "NSDate+Ext.h"
|
||||
|
||||
#import "FeedDownload.h"
|
||||
#import "FaviconDownload.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedArticle+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 notifications:YES 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 notifications:(BOOL)notify 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 notifications:notify 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:NSLocalizedString(@"Error loading source: %@", nil), 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 notifications:(BOOL)notify 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];
|
||||
|
||||
// need to gather object before save, because afterwards list will be empty
|
||||
NSArray *inserted = notify ? moc.insertedObjects.allObjects : nil;
|
||||
NSArray *deleted = moc.deletedObjects.allObjects;
|
||||
|
||||
[StoreCoordinator saveContext:moc andParent:YES];
|
||||
|
||||
// after save, update notifications
|
||||
// dismiss previously delivered notifications
|
||||
if (deleted) {
|
||||
NSMutableArray *ids = [NSMutableArray array];
|
||||
for (FeedArticle *article in deleted) { // will contain non-articles too
|
||||
if ([article isKindOfClass:[FeedArticle class]] || [article isKindOfClass:[Feed class]]) {
|
||||
[ids addObject:article.notificationID];
|
||||
}
|
||||
}
|
||||
[NotifyEndpoint dismiss:ids]; // no-op if empty
|
||||
}
|
||||
// post new notification (if needed)
|
||||
if (notify && inserted) {
|
||||
BOOL didAddAny = NO;
|
||||
for (FeedArticle *article in inserted) { // will contain non-articles too
|
||||
if ([article isKindOfClass:[FeedArticle class]]) {
|
||||
[NotifyEndpoint postArticle:article];
|
||||
didAddAny = YES;
|
||||
}
|
||||
}
|
||||
if (didAddAny)
|
||||
[NotifyEndpoint postFeed:f];
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,95 +1,9 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2018 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@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);
|
||||
|
||||
@@ -1,244 +1,163 @@
|
||||
//
|
||||
// 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 "DrawImage.h"
|
||||
#import "Constants.h"
|
||||
#import "NSColor+Ext.h"
|
||||
#import "TinySVG.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));
|
||||
//}
|
||||
|
||||
|
||||
#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));
|
||||
|
||||
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);
|
||||
}
|
||||
CGContextSetFillColorWithColor(c, [_color CGColor]);
|
||||
CGContextAddPath(c, pth);
|
||||
CGPathRelease(pth);
|
||||
if ([self isMemberOfClass:[DrawImage class]])
|
||||
CGContextFillPath(c); // fill only if not a subclass
|
||||
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));
|
||||
}
|
||||
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).
|
||||
|
||||
@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];
|
||||
}
|
||||
|
||||
/**
|
||||
Draw two rss bars (or paused icon) and tint color or gradient color.
|
||||
*/
|
||||
- (void)drawImageInRect:(NSRect)r {
|
||||
[super drawImageInRect:r];
|
||||
/// 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;
|
||||
|
||||
const CGFloat s = [self shorterSide];
|
||||
CGContextRef c = [[NSGraphicsContext currentContext] CGContext];
|
||||
CGContextSetFillColorWithColor(c, [self.color CGColor]);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
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
|
||||
*/
|
||||
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
|
||||
|
||||
|
||||
/// 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 +168,152 @@
|
||||
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));
|
||||
|
||||
/// Flip coordinate system
|
||||
static void FlipCoordinateSystem(CGContextRef c, CGFloat height) {
|
||||
CGContextTranslateCTM(c, 0, height);
|
||||
CGContextScaleCTM(c, 1, -1);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
svgAddRect(c, 1, r, ShorterSide(r.size) * corner/2);
|
||||
if (scaling != 0.0)
|
||||
contentScale *= scaling;
|
||||
}
|
||||
|
||||
CGContextRef c = [[NSGraphicsContext currentContext] CGContext];
|
||||
CGContextSetFillColorWithColor(c, [self.color CGColor]);
|
||||
|
||||
CGContextAddPath(c, menu);
|
||||
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);
|
||||
CGPathRelease(menu);
|
||||
}
|
||||
@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);
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Draw "(.*)" as vector path
|
||||
static void DrawRegexIcon(CGRect r) {
|
||||
const CGFloat size = ShorterSide(r.size);
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
|
||||
svgAddRect(c, 1, r, .2 * size);
|
||||
CGContextSetFillColorWithColor(c, NSColor.redColor.CGColor);
|
||||
CGContextFillPath(c);
|
||||
|
||||
// SVG files use bottom-left corner coordinate system. Quartz uses top-left.
|
||||
FlipCoordinateSystem(c, r.size.height);
|
||||
SetContentScale(c, r.size, 0.8);
|
||||
// "("
|
||||
svgAddPath(c, size/1000, "m184 187c-140 205-134 432-1 622l-66 44c-159-221-151-499 0-708z");
|
||||
// "."
|
||||
svgAddCircle(c, size/1000, 315, 675, 70, NO);
|
||||
// "*"
|
||||
svgAddPath(c, size/1000, "m652 277 107-35 21 63-109 36 68 92-54 39-68-93-66 91-52-41 67-88-109-37 21-63 108 37v-113h66v112z");
|
||||
// ")"
|
||||
svgAddPath(c, size/1000, "m816 813c140-205 134-430 1-621l66-45c159 221 151 499 0 708z");
|
||||
|
||||
CGContextSetFillColorWithColor(c, NSColor.whiteColor.CGColor);
|
||||
CGContextFillPath(c);
|
||||
}
|
||||
|
||||
|
||||
#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) {
|
||||
Register(16, RSSImageDefaultRSSIcon, NSLocalizedString(@"RSS icon", nil), ^(NSRect r) { DrawRSSGradientIcon(r, [NSColor rssOrange]); 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, [NSColor menuBarIconColor].CGColor, YES, YES); return YES; });
|
||||
Register(16, RSSImageMenuBarIconPaused, NSLocalizedString(@"RSS menu bar icon, paused", nil), ^(NSRect r) { DrawRSSIcon(r, [NSColor menuBarIconColor].CGColor, YES, NO); return YES; });
|
||||
Register(14, RSSImageMenuItemUnread, NSLocalizedString(@"Unread icon", nil), ^(NSRect r) { DrawUnreadIcon(r, [NSColor unreadIndicatorColor]); return YES; });
|
||||
Register(32, RSSImageRegexIcon, NSLocalizedString(@"Regex icon", nil), ^(NSRect r) { DrawRegexIcon(r); return YES; });
|
||||
}
|
||||
@end
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2018 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <RSXML/RSXML.h>
|
||||
|
||||
@class Feed;
|
||||
|
||||
@interface FeedDownload : NSObject
|
||||
@property (class, readonly) NSDate *dateScheduled;
|
||||
@property (class, readonly) BOOL allowNetworkConnection;
|
||||
@property (class, readonly) BOOL isUpdating;
|
||||
@property (class, setter=setPaused:) BOOL isPaused;
|
||||
|
||||
// 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>
|
||||
*/
|
||||
@@ -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,45 +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 <Cocoa/Cocoa.h>
|
||||
|
||||
typedef int32_t Interval;
|
||||
typedef NS_ENUM(int32_t, TimeUnitType) {
|
||||
TimeUnitSeconds = 1,
|
||||
TimeUnitMinutes = 60,
|
||||
TimeUnitHours = 60 * 60,
|
||||
TimeUnitDays = 24 * 60 * 60,
|
||||
TimeUnitWeeks = 7 * 24 * 60 * 60,
|
||||
TimeUnitYears = 365 * 24 * 60 * 60
|
||||
};
|
||||
|
||||
@interface NSDate (Ext)
|
||||
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag;
|
||||
+ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag;
|
||||
@end
|
||||
|
||||
|
||||
@interface NSDate (RefreshControlsUI)
|
||||
+ (Interval)intervalForPopup:(NSPopUpButton*)unit andField:(NSTextField*)value;
|
||||
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag;
|
||||
+ (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit;
|
||||
@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,38 +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 <Cocoa/Cocoa.h>
|
||||
|
||||
@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)
|
||||
*/
|
||||
- (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;
|
||||
@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
|
||||
5
baRSS/Helper/TinySVG.h
Normal file
@@ -0,0 +1,5 @@
|
||||
@import Cocoa;
|
||||
|
||||
void svgAddPath(CGContextRef context, CGFloat scale, const char * path);
|
||||
void svgAddCircle(CGContextRef context, CGFloat scale, CGFloat x, CGFloat y, CGFloat radius, bool clockwise);
|
||||
void svgAddRect(CGContextRef context, CGFloat scale, CGRect rect, CGFloat cornerRadius);
|
||||
171
baRSS/Helper/TinySVG.m
Normal file
@@ -0,0 +1,171 @@
|
||||
#include "TinySVG.h"
|
||||
|
||||
|
||||
struct SVGState {
|
||||
CGFloat scale; // technically not part of parser but easier to pass along
|
||||
|
||||
char op;
|
||||
float x, y;
|
||||
bool prevDot;
|
||||
|
||||
float num[6];
|
||||
uint8 iNum;
|
||||
|
||||
char buf[15];
|
||||
uint8 iBuf;
|
||||
};
|
||||
|
||||
|
||||
# pragma mark - Helper
|
||||
|
||||
/// if number buffer contains anything, write it to num array and start new buffer
|
||||
static void finishNum(struct SVGState *state) {
|
||||
if (state->iBuf > 0) {
|
||||
state->buf[state->iBuf] = '\0';
|
||||
state->num[state->iNum++] = (float)atof(state->buf);
|
||||
state->iBuf = 0;
|
||||
state->prevDot = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// All numbers stored in num array, finalize SVG path operation and add path to @c CGContext
|
||||
static void finishOp(CGMutablePathRef path, struct SVGState *state) {
|
||||
char op = state->op;
|
||||
if (op >= 'a' && op <= 'z') {
|
||||
// convert relative to absolute coordinates
|
||||
for (uint8 t = 0; t < state->iNum; t++) {
|
||||
state->num[t] += (t % 2 || op == 'v') ? state->y : state->x;
|
||||
}
|
||||
// convert to upper-case
|
||||
op = op - 'a' + 'A';
|
||||
}
|
||||
|
||||
if (op == 'Z') {
|
||||
CGPathCloseSubpath(path);
|
||||
|
||||
} else if (op == 'V' && state->iNum == 1) {
|
||||
state->y = state->num[0];
|
||||
CGPathAddLineToPoint(path, NULL, state->x * state->scale, state->y * state->scale);
|
||||
|
||||
} else if (op == 'H' && state->iNum == 1) {
|
||||
state->x = state->num[0];
|
||||
CGPathAddLineToPoint(path, NULL, state->x * state->scale, state->y * state->scale);
|
||||
|
||||
} else if (op == 'M' && state->iNum == 2) {
|
||||
state->x = state->num[0];
|
||||
state->y = state->num[1];
|
||||
CGPathMoveToPoint(path, NULL, state->x * state->scale, state->y * state->scale);
|
||||
// Edge-case: "M 1 2 3 4 5 6" is valid SVG after move 1,2 all remaining points are lines (3,4 and 5,6)
|
||||
// For this case we overwrite op here. It will be overwritten again if a new op starts. Else, assume line-op.
|
||||
state->op = (state->op == 'm') ? 'l' : 'L';
|
||||
|
||||
} else if (op == 'L' && state->iNum == 2) {
|
||||
state->x = state->num[0];
|
||||
state->y = state->num[1];
|
||||
CGPathAddLineToPoint(path, NULL, state->x * state->scale, state->y * state->scale);
|
||||
|
||||
} else if (op == 'C' && state->iNum == 6) {
|
||||
state->x = state->num[4];
|
||||
state->y = state->num[5];
|
||||
CGPathAddCurveToPoint(path, NULL, state->num[0] * state->scale, state->num[1] * state->scale, state->num[2] * state->scale, state->num[3] * state->scale, state->x * state->scale, state->y * state->scale);
|
||||
} else {
|
||||
NSLog(@"Unsupported SVG operation %c %d", state->op, state->iNum);
|
||||
}
|
||||
state->iNum = 0;
|
||||
}
|
||||
|
||||
/// current number not finished yet. Append another char to internal buffer
|
||||
inline static void continueNum(char chr, struct SVGState *state) {
|
||||
state->buf[state->iBuf++] = chr;
|
||||
}
|
||||
|
||||
|
||||
# pragma mark - Parser
|
||||
|
||||
/// very basic svg path parser.
|
||||
static void tinySVG_parse(const char * code, CGFloat scale, CGMutablePathRef path) {
|
||||
struct SVGState state = {
|
||||
.scale = scale,
|
||||
.op = '_',
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
.prevDot = false,
|
||||
|
||||
//.num = {0, 0, 0, 0, 0, 0},
|
||||
.iNum = 0,
|
||||
//.buf = " ",
|
||||
.iBuf = 0,
|
||||
};
|
||||
|
||||
unsigned long len = strlen(code);
|
||||
for (unsigned long i = 0; i < len; i++) {
|
||||
char chr = code[i];
|
||||
if ((chr >= 'a' && chr <= 'z') || (chr >= 'A' && chr <= 'Z')) {
|
||||
if (state.op != '_') {
|
||||
finishNum(&state);
|
||||
finishOp(path, &state);
|
||||
}
|
||||
state.op = chr;
|
||||
} else if (chr >= '0' && chr <= '9') {
|
||||
continueNum(chr, &state);
|
||||
} else if (chr == '-' && state.iBuf == 0) {
|
||||
continueNum(chr, &state);
|
||||
} else if (chr == '.' && !state.prevDot) {
|
||||
continueNum(chr, &state);
|
||||
state.prevDot = true;
|
||||
} else { // any number-separating character
|
||||
finishNum(&state);
|
||||
|
||||
// Edge-Case: SVG can reuse the previous operation without declaration
|
||||
// e.g. you can draw four lines with "L1 2 3 4 5 6 7 8"
|
||||
// or two curves with "c1 2 3 4 5 6 -1 -2 -3 -4 -5 -6"
|
||||
// Therefore we need to complete the operation if the number of arguments is reached
|
||||
if (state.iNum == 1 && strchr("HhVv", state.op) != NULL) {
|
||||
finishOp(path, &state);
|
||||
} else if (state.iNum == 2 && strchr("MmLl", state.op) != NULL) {
|
||||
finishOp(path, &state);
|
||||
} else if (state.iNum == 6 && strchr("Cc", state.op) != NULL) {
|
||||
finishOp(path, &state);
|
||||
}
|
||||
|
||||
if (chr == '-') {
|
||||
continueNum(chr, &state);
|
||||
} else if (chr == '.') {
|
||||
continueNum(chr, &state);
|
||||
state.prevDot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# pragma mark - External API
|
||||
|
||||
/// calls @c tinySVG_path and handles @c CGPath creation and release.
|
||||
void svgAddPath(CGContextRef context, CGFloat scale, const char * code) {
|
||||
CGMutablePathRef path = CGPathCreateMutable();
|
||||
tinySVG_parse(code, scale, path);
|
||||
CGContextAddPath(context, path);
|
||||
CGPathRelease(path);
|
||||
}
|
||||
|
||||
/// calls @c CGPathAddArc with full circle
|
||||
void svgAddCircle(CGContextRef context, CGFloat scale, CGFloat x, CGFloat y, CGFloat radius, bool clockwise) {
|
||||
CGMutablePathRef tmp = CGPathCreateMutable();
|
||||
CGPathAddArc(tmp, NULL, x * scale, y * scale, radius * scale, 0, M_PI * 2, clockwise);
|
||||
CGContextAddPath(context, tmp);
|
||||
CGPathRelease(tmp);
|
||||
}
|
||||
|
||||
/// Calls @c CGContextAddRect or @c CGPathAddRoundedRect (optional).
|
||||
/// @param cornerRadius Use @c <=0 for no corners. Use half of @c min(w,h) for a full circle.
|
||||
void svgAddRect(CGContextRef context, CGFloat scale, CGRect rect, CGFloat cornerRadius) {
|
||||
if (cornerRadius > 0) {
|
||||
CGMutablePathRef tmp = CGPathCreateMutable();
|
||||
CGPathAddRoundedRect(tmp, NULL, rect, cornerRadius * scale, cornerRadius * scale);
|
||||
CGContextAddPath(context, tmp);
|
||||
CGPathRelease(tmp);
|
||||
} else {
|
||||
CGContextAddRect(context, rect);
|
||||
}
|
||||
}
|
||||
5
baRSS/Helper/URLScheme.h
Normal file
@@ -0,0 +1,5 @@
|
||||
@import Cocoa;
|
||||
|
||||
@interface URLScheme : NSObject
|
||||
+ (void)withURL:(NSString*)url;
|
||||
@end
|
||||
77
baRSS/Helper/URLScheme.m
Normal file
@@ -0,0 +1,77 @@
|
||||
#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
|
||||
102
baRSS/Helper/UserPrefs.h
Normal file
@@ -0,0 +1,102 @@
|
||||
#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";
|
||||
/** default: @c nil */ static NSString* const Pref_notificationType = @"notificationType";
|
||||
// ------ 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 NO */ static NSString* const Pref_groupUnreadOnly = @"groupUnreadOnly";
|
||||
/** 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 NO */ static NSString* const Pref_feedUnreadOnly = @"feedUnreadOnly";
|
||||
/** 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"
|
||||
|
||||
typedef NS_ENUM(NSInteger, NotificationType) {
|
||||
NotificationTypeDisabled,
|
||||
NotificationTypePerArticle,
|
||||
NotificationTypePerFeed,
|
||||
NotificationTypeGlobal,
|
||||
};
|
||||
NotificationType UserPrefsNotificationType(void);
|
||||
NSString* NotificationTypeToString(NotificationType typ);
|
||||
|
||||
// ------ 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(void) { 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 */
|
||||
65
baRSS/Helper/UserPrefs.m
Normal file
@@ -0,0 +1,65 @@
|
||||
#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_groupUnreadOnly, Pref_feedUnreadOnly,
|
||||
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;
|
||||
}
|
||||
|
||||
/// Convert stored notification type string into enum
|
||||
NotificationType UserPrefsNotificationType(void) {
|
||||
NSString *typ = UserPrefsString(Pref_notificationType);
|
||||
if ([typ isEqualToString:@"article"]) return NotificationTypePerArticle;
|
||||
if ([typ isEqualToString:@"feed"]) return NotificationTypePerFeed;
|
||||
if ([typ isEqualToString:@"global"]) return NotificationTypeGlobal;
|
||||
return NotificationTypeDisabled;
|
||||
}
|
||||
|
||||
/// Convert enum type to storable string
|
||||
NSString* NotificationTypeToString(NotificationType typ) {
|
||||
switch (typ) {
|
||||
case NotificationTypeDisabled: return nil;
|
||||
case NotificationTypePerArticle: return @"article";
|
||||
case NotificationTypePerFeed: return @"feed";
|
||||
case NotificationTypeGlobal: return @"global";
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,38 @@
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>org.opml.opml</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Default</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>opml</string>
|
||||
</array>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>opml-icon</string>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>OPML document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.opml.opml</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string></string>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
@@ -17,7 +45,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.9.3</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -30,9 +58,21 @@
|
||||
<string>feed</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>de.relikd.baRSS.url.config</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>barss</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1153</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.news</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>LSUIElement</key>
|
||||
@@ -43,8 +83,38 @@
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2019 relikd. Public Domain.</string>
|
||||
<string>Copyright © 2025 relikd.</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>
|
||||
|
||||
14
baRSS/NSCategories/NSColor+Ext.h
Normal file
@@ -0,0 +1,14 @@
|
||||
@import Cocoa;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface NSColor (Ext)
|
||||
/** @return @c RGB(251,163,58) @c (#FBA33A) */
|
||||
+ (instancetype)rssOrange;
|
||||
/** @return User preferred color; default: @c rssOrange */
|
||||
+ (instancetype)menuBarIconColor;
|
||||
/** @return User preferred color; default: @c systemBlueColor */
|
||||
+ (instancetype)unreadIndicatorColor;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
41
baRSS/NSCategories/NSColor+Ext.m
Normal file
@@ -0,0 +1,41 @@
|
||||
#import "NSColor+Ext.h"
|
||||
#import "UserPrefs.h"
|
||||
|
||||
@implementation NSColor (Ext)
|
||||
|
||||
+ (instancetype)rssOrange {
|
||||
static NSColor *color;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
color = [NSColor colorWithCalibratedRed:251/255.f green:163/255.f blue:58/255.f alpha:1.f]; // #FBA33A
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
+ (instancetype)menuBarIconColor {
|
||||
static NSColor *color;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
if (@available(macOS 10.14, *)) {
|
||||
color = UserPrefsColor(Pref_colorStatusIconTint, [NSColor controlAccentColor]);
|
||||
} else {
|
||||
color = UserPrefsColor(Pref_colorStatusIconTint, [self rssOrange]);
|
||||
}
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
+ (instancetype)unreadIndicatorColor {
|
||||
static NSColor *color;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
if (@available(macOS 10.14, *)) {
|
||||
color = UserPrefsColor(Pref_colorUnreadIndicator, [NSColor controlAccentColor]);
|
||||
} else {
|
||||
color = UserPrefsColor(Pref_colorUnreadIndicator, [NSColor systemBlueColor]);
|
||||
}
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
@end
|
||||
41
baRSS/NSCategories/NSDate+Ext.h
Normal file
@@ -0,0 +1,41 @@
|
||||
@import Cocoa;
|
||||
|
||||
typedef int32_t Interval;
|
||||
typedef NS_ENUM(int32_t, TimeUnitType) {
|
||||
TimeUnitSeconds = 1,
|
||||
TimeUnitMinutes = 60,
|
||||
TimeUnitHours = 60 * 60,
|
||||
TimeUnitDays = 24 * 60 * 60,
|
||||
TimeUnitWeeks = 7 * 24 * 60 * 60,
|
||||
TimeUnitYears = 365 * 24 * 60 * 60
|
||||
};
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface NSDate (Ext)
|
||||
+ (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
|
||||
|
||||
|
||||
@interface NSDate (RefreshControlsUI)
|
||||
+ (Interval)intervalForPopup:(NSPopUpButton*)unit andField:(NSTextField*)value;
|
||||
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag;
|
||||
+ (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit;
|
||||
@end
|
||||
|
||||
|
||||
@interface NSDate (Statistics)
|
||||
+ (nullable NSDictionary*)refreshIntervalStatistics:(NSArray<NSDate*> *)list;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
180
baRSS/NSCategories/NSDate+Ext.m
Normal file
@@ -0,0 +1,180 @@
|
||||
@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}
|
||||
*/
|
||||
+ (nullable 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
|
||||
19
baRSS/NSCategories/NSError+Ext.h
Normal file
@@ -0,0 +1,19 @@
|
||||
@import Cocoa;
|
||||
|
||||
/// Log error message and prepend calling class and calling method.
|
||||
#define NSLogCaller(desc) { NSLog(@"%@:%@ %@", [self class], NSStringFromSelector(_cmd), desc); }
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
130
baRSS/NSCategories/NSError+Ext.m
Normal file
@@ -0,0 +1,130 @@
|
||||
@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
|
||||
14
baRSS/NSCategories/NSString+Ext.h
Normal file
@@ -0,0 +1,14 @@
|
||||
@import Cocoa;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface NSString (PlainHTML)
|
||||
+ (NSString*)plainTextFromHTMLData:(NSData*)data;
|
||||
- (nonnull NSString*)htmlToPlainText;
|
||||
@end
|
||||
|
||||
@interface NSString (HexColor)
|
||||
- (nullable NSColor*)hexColor;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
143
baRSS/NSCategories/NSString+Ext.m
Normal file
@@ -0,0 +1,143 @@
|
||||
#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"];
|
||||
|
||||
NSMutableCharacterSet *cs = NSMutableCharacterSet.whitespaceAndNewlineCharacterSet;
|
||||
[cs removeCharactersInString:@" "]; // used for "li"
|
||||
return [result stringByTrimmingCharactersInSet:cs];
|
||||
}
|
||||
|
||||
|
||||
#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
|
||||
22
baRSS/NSCategories/NSURL+Ext.h
Normal file
@@ -0,0 +1,22 @@
|
||||
@import Cocoa;
|
||||
|
||||
#define ENV_LOG_FILES 0
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
88
baRSS/NSCategories/NSURL+Ext.m
Normal file
@@ -0,0 +1,88 @@
|
||||
#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
|
||||
13
baRSS/NSCategories/NSURLRequest+Ext.h
Normal file
@@ -0,0 +1,13 @@
|
||||
@import Cocoa;
|
||||
|
||||
#define ENV_LOG_DOWNLOAD 1
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
75
baRSS/NSCategories/NSURLRequest+Ext.m
Normal file
@@ -0,0 +1,75 @@
|
||||
#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
|
||||
90
baRSS/NSCategories/NSView+Ext.h
Normal file
@@ -0,0 +1,90 @@
|
||||
@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;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 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:(nullable NSImageName)name size:(CGFloat)size;
|
||||
+ (NSButton*)checkbox:(BOOL)flag;
|
||||
+ (NSProgressIndicator*)activitySpinner;
|
||||
+ (nullable NSView*)radioGroup:(NSArray<NSString*>*)entries target:(id)target action:(nonnull SEL)action;
|
||||
+ (nullable 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:(nullable 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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
364
baRSS/NSCategories/NSView+Ext.m
Normal file
@@ -0,0 +1,364 @@
|
||||
#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];
|
||||
NSSize s = btn.image.size;
|
||||
if (s.width > s.height)
|
||||
[btn.image setSize:NSMakeSize(size, size * (s.height / s.width))];
|
||||
else
|
||||
[btn.image setSize:NSMakeSize(size * (s.width / s.height), size)];
|
||||
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:(nullable 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).
|
||||
+ (nullable 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.
|
||||
+ (nullable 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:(nullable 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
|
||||
18
baRSS/Notifications/NotifyEndpoint.h
Normal file
@@ -0,0 +1,18 @@
|
||||
@import Cocoa;
|
||||
@import UserNotifications;
|
||||
|
||||
@class Feed, FeedArticle;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface NotifyEndpoint : NSObject <UNUserNotificationCenterDelegate>
|
||||
+ (void)activate;
|
||||
|
||||
+ (void)setGlobalCount:(NSInteger)count previousCount:(NSInteger)count;
|
||||
+ (void)postFeed:(Feed*)feed;
|
||||
+ (void)postArticle:(FeedArticle*)article;
|
||||
|
||||
+ (void)dismiss:(nullable NSArray<NSString*>*)list;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
192
baRSS/Notifications/NotifyEndpoint.m
Normal file
@@ -0,0 +1,192 @@
|
||||
#import "NotifyEndpoint.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "FeedGroup+Ext.h"
|
||||
#import "FeedArticle+Ext.h"
|
||||
|
||||
/**
|
||||
Sent for global unread count notification alert (Notification Center)
|
||||
*/
|
||||
static NSString* const kNotifyIdGlobal = @"global";
|
||||
|
||||
static NSString* const kCategoryDismissable = @"DISMISSIBLE";
|
||||
static NSString* const kActionOpenBackground = @"OPEN_IN_BACKGROUND";
|
||||
static NSString* const kActionMarkRead = @"MARK_READ_DONT_OPEN";
|
||||
static NSString* const kActionOpenOnly = @"OPEN_ONLY_DONT_MARK_READ";
|
||||
|
||||
|
||||
@implementation NotifyEndpoint
|
||||
|
||||
static NotifyEndpoint *singleton = nil;
|
||||
static NotificationType notifyType;
|
||||
|
||||
/// Ask user for permission to send notifications @b AND register delegate to respond to alert banner clicks.
|
||||
/// @note Called every time user changes notification settings
|
||||
+ (void)activate {
|
||||
UNUserNotificationCenter *center = UNUserNotificationCenter.currentNotificationCenter;
|
||||
notifyType = UserPrefsNotificationType();
|
||||
|
||||
// even if disabled, register delegate. This allows to open previously sent notifications
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
singleton = [NotifyEndpoint new];
|
||||
center.delegate = singleton;
|
||||
});
|
||||
|
||||
if (notifyType == NotificationTypeDisabled) {
|
||||
return;
|
||||
}
|
||||
// register action types (allow mark read without opening notification)
|
||||
UNNotificationAction *openBackgroundAction = [UNNotificationAction actionWithIdentifier:kActionOpenBackground title:NSLocalizedString(@"Open in background", nil) options:UNNotificationActionOptionNone];
|
||||
UNNotificationAction *dontOpenAction = [UNNotificationAction actionWithIdentifier:kActionMarkRead title:NSLocalizedString(@"Mark read & dismiss", nil) options:UNNotificationActionOptionNone];
|
||||
UNNotificationAction *dontReadAction = [UNNotificationAction actionWithIdentifier:kActionOpenOnly title:NSLocalizedString(@"Open but keep unread", nil) options:UNNotificationActionOptionNone];
|
||||
UNNotificationCategory *category = [UNNotificationCategory categoryWithIdentifier:kCategoryDismissable actions:@[openBackgroundAction, dontOpenAction, dontReadAction] intentIdentifiers:@[] options:UNNotificationCategoryOptionNone];
|
||||
[center setNotificationCategories:[NSSet setWithObject:category]];
|
||||
|
||||
[center requestAuthorizationWithOptions:UNAuthorizationOptionAlert | UNAuthorizationOptionSound completionHandler:^(BOOL granted, NSError * _Nullable error) {
|
||||
if (error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSAlert *alert = [[NSAlert alloc] init];
|
||||
alert.messageText = NSLocalizedString(@"Notifications Disabled", nil);
|
||||
alert.informativeText = NSLocalizedString(@"Either enable notifications in System Settings, or disable notifications in baRSS settings.", nil);
|
||||
alert.alertStyle = NSAlertStyleInformational;
|
||||
[alert runModal];
|
||||
});
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/// Set (or update) global "X unread articles"
|
||||
+ (void)setGlobalCount:(NSInteger)newCount previousCount:(NSInteger)oldCount {
|
||||
if (newCount > 0) {
|
||||
if (notifyType != NotificationTypeGlobal) {
|
||||
return;
|
||||
}
|
||||
// TODO: how to handle global count updates?
|
||||
// ignore and keep old count until 0?
|
||||
// or update count and show a new notification banner?
|
||||
if (newCount > oldCount) { // only notify if new feeds (quirk: will also trigger for option-click menu to mark unread)
|
||||
[self send:kNotifyIdGlobal
|
||||
title:APP_NAME
|
||||
body:[NSString stringWithFormat:NSLocalizedString(@"%ld unread articles", nil), newCount]];
|
||||
}
|
||||
} else {
|
||||
[self dismiss:@[kNotifyIdGlobal]];
|
||||
}
|
||||
}
|
||||
|
||||
/// Triggers feed notifications (if enabled)
|
||||
+ (void)postFeed:(Feed*)feed {
|
||||
if (notifyType != NotificationTypePerFeed) {
|
||||
return;
|
||||
}
|
||||
NSUInteger count = feed.countUnread;
|
||||
if (count > 0) {
|
||||
[feed.managedObjectContext obtainPermanentIDsForObjects:@[feed] error:nil];
|
||||
[self send:feed.notificationID
|
||||
title:feed.group.anyName
|
||||
body:[NSString stringWithFormat:NSLocalizedString(@"%ld unread articles", nil), count]];
|
||||
}
|
||||
}
|
||||
|
||||
/// Triggers article notifications (if enabled)
|
||||
+ (void)postArticle:(FeedArticle*)article {
|
||||
if (notifyType != NotificationTypePerArticle) {
|
||||
return;
|
||||
}
|
||||
[article.managedObjectContext obtainPermanentIDsForObjects:@[article] error:nil];
|
||||
[self send:article.notificationID
|
||||
title:article.feed.group.anyName
|
||||
body:article.title];
|
||||
}
|
||||
|
||||
/// Close already posted notifications because they were opened via menu
|
||||
+ (void)dismiss:(nullable NSArray<NSString*>*)list {
|
||||
if (list.count > 0) {
|
||||
[UNUserNotificationCenter.currentNotificationCenter removeDeliveredNotificationsWithIdentifiers:list];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Helper methods
|
||||
|
||||
/// Post notification (immediatelly).
|
||||
/// @param identifier Used to identify a specific instance (and dismiss a previously shown notification).
|
||||
+ (void)send:(NSString *)identifier title:(nullable NSString *)title body:(nullable NSString *)body {
|
||||
UNMutableNotificationContent *msg = [UNMutableNotificationContent new];
|
||||
if (title != nil) msg.title = title;
|
||||
if (body != nil) msg.body = body;
|
||||
// common settings:
|
||||
msg.categoryIdentifier = kCategoryDismissable;
|
||||
// TODO: make sound configurable?
|
||||
msg.sound = [UNNotificationSound defaultSound];
|
||||
[self send:identifier content: msg];
|
||||
}
|
||||
|
||||
/// Internal method for queueing a new notification.
|
||||
+ (void)send:(NSString *)identifier content:(UNMutableNotificationContent*)msg {
|
||||
UNUserNotificationCenter *center = UNUserNotificationCenter.currentNotificationCenter;
|
||||
|
||||
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
|
||||
if (settings.authorizationStatus != UNAuthorizationStatusAuthorized) {
|
||||
return;
|
||||
}
|
||||
|
||||
UNNotificationRequest *req = [UNNotificationRequest requestWithIdentifier:identifier content:msg trigger:nil];
|
||||
[center addNotificationRequest:req withCompletionHandler:^(NSError * _Nullable error) {
|
||||
if (error) {
|
||||
NSLog(@"Could not send notification: %@", error);
|
||||
}
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Delegate
|
||||
|
||||
/// Must be implemented to show notifications while the app is in foreground
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
|
||||
// all the options
|
||||
UNNotificationPresentationOptions common = UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge;
|
||||
if (@available(macOS 11.0, *)) {
|
||||
completionHandler(common | UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList);
|
||||
} else {
|
||||
completionHandler(common | UNNotificationPresentationOptionAlert);
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback method when user clicks on alert banner
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler {
|
||||
NSArray<FeedArticle*> *articles;
|
||||
|
||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||
NSString *theId = response.notification.request.identifier;
|
||||
if ([theId isEqualToString:kNotifyIdGlobal]) {
|
||||
// global notification
|
||||
articles = [StoreCoordinator articlesAtPath:nil isFeed:NO sorted:YES unread:YES inContext:moc limit:0];
|
||||
} else {
|
||||
NSURL *uri = [NSURL URLWithString:theId];
|
||||
NSManagedObjectID *oid = [moc.persistentStoreCoordinator managedObjectIDForURIRepresentation:uri];
|
||||
NSManagedObject *obj = [moc objectWithID:oid];
|
||||
if ([obj isKindOfClass:[FeedArticle class]]) {
|
||||
// per-article notification
|
||||
articles = @[(FeedArticle*)obj];
|
||||
} else if ([obj isKindOfClass:[Feed class]]) {
|
||||
// per-feed notification
|
||||
articles = [[[(Feed*)obj articles]
|
||||
filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"unread = 1"]]
|
||||
sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// open-in-background performs the same operation as a normal click
|
||||
// the "background" part is triggered by _NOT_ having the UNNotificationActionOptionForeground option
|
||||
BOOL dontOpen = [response.actionIdentifier isEqualToString:kActionMarkRead];
|
||||
BOOL dontMarkRead = [response.actionIdentifier isEqualToString:kActionOpenOnly];
|
||||
[StoreCoordinator updateArticles:articles markRead:!dontMarkRead andOpen:!dontOpen inContext:moc];
|
||||
}
|
||||
|
||||
@end
|
||||
4
baRSS/Preferences/About Tab/SettingsAbout.h
Normal file
@@ -0,0 +1,4 @@
|
||||
@import Cocoa;
|
||||
|
||||
@interface SettingsAbout : NSViewController
|
||||
@end
|
||||
10
baRSS/Preferences/About Tab/SettingsAbout.m
Normal file
@@ -0,0 +1,10 @@
|
||||
#import "SettingsAbout.h"
|
||||
#import "SettingsAboutView.h"
|
||||
|
||||
@implementation SettingsAbout
|
||||
|
||||
- (void)loadView {
|
||||
self.view = [SettingsAboutView new];
|
||||
}
|
||||
|
||||
@end
|
||||
5
baRSS/Preferences/About Tab/SettingsAboutView.h
Normal file
@@ -0,0 +1,5 @@
|
||||
@import Cocoa;
|
||||
|
||||
@interface SettingsAboutView : NSView
|
||||
@end
|
||||
|
||||
68
baRSS/Preferences/About Tab/SettingsAboutView.m
Normal file
@@ -0,0 +1,68 @@
|
||||
#import "SettingsAboutView.h"
|
||||
#import "NSView+Ext.h"
|
||||
|
||||
@implementation SettingsAboutView
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 320, 327)];
|
||||
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" bold:NO];
|
||||
[self str:mas add:@"\nSource Code Available\n" bold:YES];
|
||||
[self str:mas add:@"github.com" link:@"https://github.com/relikd/baRSS"];
|
||||
[self str:mas add:@" (MIT License)\n" bold:NO];
|
||||
[self str:mas add:@"\nLibraries\n" bold:YES];
|
||||
[self str:mas add:@"RSXML2" link:@"https://github.com/relikd/RSXML2"];
|
||||
[self str:mas add:@" (MIT License)\n" bold:NO];
|
||||
[self str:mas add:@"QLOPML" link:@"https://github.com/relikd/QLOPML"];
|
||||
[self str:mas add:@" (MIT License)\n" bold:NO];
|
||||
[self str:mas add:@"\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
|
||||
9
baRSS/Preferences/Appearance Tab/SettingsAppearance.h
Normal file
@@ -0,0 +1,9 @@
|
||||
@import Cocoa;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SettingsAppearance : NSViewController
|
||||
- (void)didSelectCheckbox:(NSButton*)sender;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
30
baRSS/Preferences/Appearance Tab/SettingsAppearance.m
Normal file
@@ -0,0 +1,30 @@
|
||||
#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
|
||||
@@ -0,0 +1,6 @@
|
||||
@import Cocoa;
|
||||
@class SettingsAppearance;
|
||||
|
||||
@interface SettingsAppearanceView : NSView
|
||||
@end
|
||||
|
||||
64
baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m
Normal file
@@ -0,0 +1,64 @@
|
||||
#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:NSMakeRect(0, 0, 320, 327)];
|
||||
// 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(@"Show only unread / hide read", nil) c1:nil c2:Pref_groupUnreadOnly c3:Pref_feedUnreadOnly];
|
||||
[[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
|
||||
@@ -1,29 +1,8 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2018 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
@import Cocoa;
|
||||
#import "ModalSheet.h"
|
||||
@class FeedGroup, ModalSheet;
|
||||
|
||||
@class FeedGroup;
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ModalEditDialog : NSViewController
|
||||
+ (instancetype)modalWith:(FeedGroup*)group;
|
||||
@@ -33,8 +12,11 @@
|
||||
|
||||
|
||||
@interface ModalFeedEdit : ModalEditDialog <NSTextFieldDelegate>
|
||||
- (void)didClickWarningButton:(NSButton*)sender;
|
||||
- (void)openRegexConverter;
|
||||
@end
|
||||
|
||||
@interface ModalGroupEdit : ModalEditDialog
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
//
|
||||
// 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 "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"
|
||||
#import "RegexConverterController.h"
|
||||
#import "RegexConverterModal.h"
|
||||
#import "RegexConverter+Ext.h"
|
||||
|
||||
|
||||
#pragma mark - ModalEditDialog -
|
||||
|
||||
// ################################################################
|
||||
// #
|
||||
// # MARK: - ModalEditDialog -
|
||||
// #
|
||||
// ################################################################
|
||||
|
||||
@interface ModalEditDialog() <NSWindowDelegate>
|
||||
@property (strong) FeedGroup *feedGroup;
|
||||
@@ -60,57 +48,61 @@
|
||||
}
|
||||
@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;
|
||||
@property (nonatomic, assign) BOOL skipIconDownload;
|
||||
@property (nonatomic, assign) BOOL openRegexAfterDownload;
|
||||
@property (weak) id eventMonitor;
|
||||
@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];
|
||||
|
||||
// removed in windowShouldClose:
|
||||
self.eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskFlagsChanged handler:^(NSEvent *event) {
|
||||
BOOL optionKeyActive = ((event.modifierFlags & NSEventModifierFlagOption) != 0);
|
||||
self.view.regexConverterButton.hidden = !self.feedGroup.feed.regex && !optionKeyActive;
|
||||
return event;
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
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];
|
||||
self.view.regexConverterButton.hidden = !fg.feed.regex;
|
||||
[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 +110,47 @@
|
||||
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];
|
||||
if (self.faviconFile) // only if downloaded anything (nil deletes icon!)
|
||||
[f setNewIcon:self.faviconFile];
|
||||
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.spinnerName startAnimation:nil];
|
||||
if (!self.skipIconDownload) {
|
||||
[self.view.spinnerURL 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]
|
||||
withRegex:self.feedGroup.feed.regex enforce:self.openRegexAfterDownload]
|
||||
startWithDelegate:self];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,16 +159,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;
|
||||
- (nullable 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 +174,114 @@
|
||||
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.skipIconDownload)
|
||||
[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];
|
||||
[self.modalSheet setDoneEnabled:YES];
|
||||
- (void)faviconDownload:(FaviconDownload*)sender didFinish:(nullable NSURL*)path {
|
||||
// Create image from favicon temporary file location or default icon if no favicon exists.
|
||||
NSImage *img;
|
||||
if (path) {
|
||||
NSData* data = [[NSData alloc] initWithContentsOfURL:path];
|
||||
img = [[NSImage alloc] initWithData:data];
|
||||
} else {
|
||||
img = [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];
|
||||
self.skipIconDownload = NO;
|
||||
|
||||
if (self.openRegexAfterDownload) {
|
||||
[self openRegexConverter];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Regex Converter
|
||||
|
||||
- (void)openRegexConverter {
|
||||
if (!self.openRegexAfterDownload) {
|
||||
self.openRegexAfterDownload = YES;
|
||||
self.skipIconDownload = self.feedGroup.feed.hasIcon;
|
||||
[self downloadRSS];
|
||||
return;
|
||||
}
|
||||
self.openRegexAfterDownload = NO;
|
||||
|
||||
// shrink FeedEdit modal size to effectively hide it behind new modal
|
||||
NSRect previous = self.modalSheet.frame;
|
||||
CGFloat minWidthDiff = previous.size.width - self.modalSheet.minSize.width;
|
||||
[self.modalSheet setFrame:NSInsetRect(previous, minWidthDiff / 2.0, 0) display:NO];
|
||||
|
||||
RegexConverterController *c = [RegexConverterController withData:self.memFeed.rawData andConverter:self.feedGroup.feed.regex];
|
||||
[self.modalSheet.sheetParent beginCriticalSheet:[c getModalSheet] completionHandler:^(NSModalResponse returnCode) {
|
||||
// reset previous size
|
||||
[self.modalSheet setFrame:previous display:NO];
|
||||
|
||||
if (returnCode == NSModalResponseOK) {
|
||||
[c applyChanges:self.feedGroup.feed];
|
||||
self.skipIconDownload = self.feedGroup.feed.hasIcon;
|
||||
self.view.regexConverterButton.hidden = !self.feedGroup.feed.regex;
|
||||
[self downloadRSS];
|
||||
} else {
|
||||
[self populateTextFields:self.feedGroup];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
#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 +292,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 +315,61 @@
|
||||
|
||||
|
||||
/// 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;
|
||||
}
|
||||
[NSEvent removeMonitor:self.eventMonitor];
|
||||
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 +380,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>
|
||||