Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26f95c2b13 | ||
|
|
a08898311c | ||
|
|
6a5ca09754 | ||
|
|
1de44071aa | ||
|
|
4864208754 | ||
|
|
76c7263548 | ||
|
|
7f40bb259c | ||
|
|
68aa4ef94b | ||
|
|
217a91b23c | ||
|
|
f0299d8246 | ||
|
|
ae0d5967c7 | ||
|
|
d45d4864b0 | ||
|
|
ef2c588f4c | ||
|
|
03aecdfa4a | ||
|
|
3b65bca88f | ||
|
|
bd03059247 | ||
|
|
d03840757a | ||
|
|
2e77f67102 | ||
|
|
5d339b8125 | ||
|
|
65cac6b19a | ||
|
|
2ec1743dd9 | ||
|
|
ca2b3cb887 | ||
|
|
b1ca30f914 | ||
|
|
5427cb58ee | ||
|
|
b94dd030b4 | ||
|
|
469d7bcdd4 | ||
|
|
0806003fc3 | ||
|
|
385bcf99f3 | ||
|
|
b194a1427d | ||
|
|
ff34781fea | ||
|
|
4edd4448ae | ||
|
|
33f907228b | ||
|
|
673e0d3d48 | ||
|
|
b3fdadb9f4 | ||
|
|
9fc513254f | ||
|
|
881b9db02c | ||
|
|
3a14c90f37 | ||
|
|
96884474ac | ||
|
|
82ae18c8a5 | ||
|
|
6eddb57651 | ||
|
|
67d17599b5 | ||
|
|
3507fd8e27 | ||
|
|
ca417f35b6 | ||
|
|
6e5326f913 | ||
|
|
1589b23aa9 | ||
|
|
e0cd04b882 | ||
|
|
6b4c38ec21 | ||
|
|
e7208ae2ab | ||
|
|
508377a823 | ||
|
|
2185eb76fb | ||
|
|
8de163859b | ||
|
|
f739b64ceb | ||
|
|
c2fda881b1 | ||
|
|
a0a5b5b82d | ||
|
|
43e32b2286 | ||
|
|
205b544acd | ||
|
|
56f6ec1356 | ||
|
|
ab71c51380 | ||
|
|
7a805ccdc4 | ||
|
|
f4f4bc9271 | ||
|
|
64637243b5 | ||
|
|
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 |
155
CHANGELOG.md
@@ -5,12 +5,121 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
## [1.6.1] – 2026-01-11
|
||||
### Fixed
|
||||
- Reschedule update timer after changing the refresh interval of a feed
|
||||
- Reschedule update timer after system sleep (fixes #26)
|
||||
|
||||
|
||||
## [1.6.0] – 2025-12-13
|
||||
### Added
|
||||
- *UI:* Limit content length for article tooltips. (fixes #25)
|
||||
- *Settings, Appearance:* Revamped appearance options v2. (thanks @Shnub)
|
||||
- *Settings, Appearance:* New GUI options for previously CLI-only options. Modify display limits directly in settings.
|
||||
|
||||
### Fixed
|
||||
- *Status Bar Menu:* Uniform capitalization for all menu items.
|
||||
- *Status Bar Menu:* Setting the "Open a few"-limit to zero, hides the button altogether.
|
||||
- *Settings, Appearance:* Pixel-perfect alignment of all drawable icons.
|
||||
- *UI:* Accessibility hints for appearance options generate better VoiceOver output.
|
||||
|
||||
### Changed
|
||||
- *UI:* "Show Hidden Article" renamed to "Show hidden feeds".
|
||||
|
||||
|
||||
## [1.5.5] – 2025-12-03
|
||||
### Added
|
||||
- *Settings, Appearance:* Improved tooltips on individual options
|
||||
- *Status Bar Menu:* Toggle button to show hidden articles without holding down option-key.
|
||||
|
||||
|
||||
## [1.5.4] – 2025-12-02
|
||||
### Added
|
||||
- *Settings, Appearance:* Tooltip explanation for all options
|
||||
- *Status Bar Menu:* Hold down option key before opening the menu bar icon to show hidden articles (if option "Show only unread" is active)
|
||||
|
||||
### Fixed
|
||||
- *UI:* Table cells were rendered slightly off bounds.
|
||||
|
||||
|
||||
## [1.5.3] – 2025-10-29
|
||||
### Fixed
|
||||
- *Notifications:* Use user-provided feed title instead of server provided title
|
||||
|
||||
|
||||
## [1.5.2] – 2025-10-29
|
||||
### Added
|
||||
- *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
|
||||
- *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)
|
||||
- *UI:* Add option to hide read articles (show only unread)
|
||||
|
||||
|
||||
## [1.1.3] – 2020-12-18
|
||||
@@ -31,8 +140,8 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2
|
||||
|
||||
## [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+)
|
||||
- *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
|
||||
@@ -40,9 +149,9 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2
|
||||
|
||||
## [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
|
||||
- *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
|
||||
@@ -85,7 +194,7 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2
|
||||
- *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
|
||||
- *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
|
||||
@@ -97,12 +206,12 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2
|
||||
- *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
|
||||
- *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
|
||||
|
||||
@@ -158,7 +267,23 @@ and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2
|
||||
Initial release
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/relikd/baRSS/compare/v1.1.3...HEAD
|
||||
[1.6.1]: https://github.com/relikd/baRSS/compare/v1.6.0...v1.6.1
|
||||
[1.6.0]: https://github.com/relikd/baRSS/compare/v1.5.5...v1.6.0
|
||||
[1.5.5]: https://github.com/relikd/baRSS/compare/v1.5.4...v1.5.5
|
||||
[1.5.4]: https://github.com/relikd/baRSS/compare/v1.5.3...v1.5.4
|
||||
[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 +0,0 @@
|
||||
github "relikd/RSXML2" "v2.0.1"
|
||||
6
Config-debug.xcconfig
Normal file
@@ -0,0 +1,6 @@
|
||||
// Configuration settings file format documentation can be found at:
|
||||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
#include "Config.xcconfig"
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.relikd.baRSS.beta
|
||||
12
Config.xcconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
// Configuration settings file format documentation can be found at:
|
||||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
CODE_SIGN_STYLE = Manual
|
||||
CODE_SIGN_IDENTITY = Apple Development
|
||||
ENABLE_HARDENED_RUNTIME = YES
|
||||
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14
|
||||
MARKETING_VERSION = 1.6.1
|
||||
PRODUCT_NAME = baRSS
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.relikd.baRSS
|
||||
CURRENT_PROJECT_VERSION = 17772
|
||||
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>
|
||||
44
QLOPML/Info.plist
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>QLOPML</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<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 © 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; }
|
||||
}
|
||||
83
README.md
@@ -1,4 +1,4 @@
|
||||
[](#download--install)
|
||||
[](#download--install)
|
||||
[](https://github.com/relikd/baRSS/releases)
|
||||
[](https://github.com/relikd/baRSS/releases)
|
||||
[](LICENSE)
|
||||
@@ -35,25 +35,22 @@ Further, tuning the update frequently will decrease the traffic even more.
|
||||
Download & Install
|
||||
------------------
|
||||
|
||||
Requires macOS Sierra (10.12) or higher.
|
||||
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 and [Carthage](https://github.com/Carthage/Carthage#installing-carthage).
|
||||
The latter is optional, you can build the [RSXML2] library from source instead.
|
||||
Carthage just makes it more convenient.
|
||||
Download and unzip this project, navigate to the root folder and run `carthage bootstrap --platform macOS`.
|
||||
You'll need Xcode and [RSXML2].
|
||||
|
||||
Next, you need to clone [QLOPML](https://github.com/relikd/QLOPML) in the same folder where this project is.
|
||||
Alternatively, you can simply delete the `QLOPML` project reference without much harm.
|
||||
`QLOPML` is a Quick Look plugin for `.opml` files.
|
||||
It will display the file contents whenever you hit spacebar.
|
||||
```sh
|
||||
git clone https://github.com/relikd/baRSS
|
||||
git clone https://github.com/relikd/RSXML2
|
||||
```
|
||||
|
||||
That's it.
|
||||
Open Xcode and build the project.
|
||||
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`.
|
||||
|
||||
@@ -62,40 +59,45 @@ If you prefer the optimized release version go to `Product > Archive`.
|
||||
Hidden options
|
||||
--------------
|
||||
|
||||
### 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.x-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.
|
||||
I am still searching for a way to keep the menu open after click (if you know of a way, let me know!).
|
||||
|
||||
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.
|
||||
|
||||
3. The option “Show only unread” will hide all items which have been read.
|
||||
You can hold down option key before opening the menu bar icon to show hidden articles regardless.
|
||||
This is a nice way to quickly lookup a hidden article without going into settings and twiddling with the checkbox.
|
||||
|
||||
|
||||
### 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. 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. 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
|
||||
```
|
||||
|
||||
3. In preferences you can choose to show 'Short article names'.
|
||||
This will limit the number of displayed characters to 60 (default).
|
||||
With this Terminal command you can customize this limit:
|
||||
```
|
||||
defaults write de.relikd.baRSS shortArticleNamesLimit -int 50
|
||||
```
|
||||
|
||||
4. 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
|
||||
```
|
||||
|
||||
5. You can change the appearance of colors throughout the application.
|
||||
1. 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"
|
||||
```
|
||||
|
||||
6. To backup your list of subscribed feeds, here is a one-liner:
|
||||
2. 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"
|
||||
```
|
||||
@@ -105,15 +107,14 @@ open barss:backup && cp "$HOME/Library/Containers/de.relikd.baRSS/Data/Library/A
|
||||
ToDo
|
||||
----
|
||||
|
||||
The following list is not exhaustive but rather a collection of nice things that will be added eventually.
|
||||
I will postpone the development until demand increases …
|
||||
The following list is a collection of ideas that may be added if people request it.
|
||||
|
||||
- [ ] Localizations
|
||||
- [ ] Feed generator for websites without feeds
|
||||
- [x] Feed generator for websites without feeds
|
||||
- [ ] Automatically choose best update interval (e.g., avg)
|
||||
- [ ] Sync with online services
|
||||
- [ ] Feeds with authentication
|
||||
- [ ] Notification Center
|
||||
- [x] Notification Center
|
||||
- [ ] Distraction Mode
|
||||
- [ ] Distract less: Sleep timer. (e.g., disable updates during working hours)
|
||||
- [ ] Distract more: Automatically open feed items
|
||||
@@ -163,17 +164,19 @@ Sadly, this was before Swift 5 and ABI stability.
|
||||
Had I only started the project a year later…
|
||||
But on the other hand, now it is macOS 10.12 compatible.
|
||||
|
||||
|
||||
### 3rd Party Libraries
|
||||
|
||||
This project uses a modified version of Brent Simmons' [RSXML](https://github.com/brentsimmons/RSXML) for feed parsing.
|
||||
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: __1970h+__
|
||||
- Estimated development time: __2121h+__
|
||||
- First prototype used __feedparser python__ library
|
||||
|
||||
|
||||
[RSXML2]: https://github.com/relikd/RSXML2
|
||||
[RSXML]: https://github.com/brentsimmons/RSXML
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 50;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -12,12 +12,18 @@
|
||||
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; };
|
||||
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */ = {isa = PBXBuildFile; fileRef = 541C67C22255470B004D2CE6 /* SettingsAppearance.m */; };
|
||||
54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; };
|
||||
54229F552E02491A0019ACB0 /* TinySVG.m in Sources */ = {isa = PBXBuildFile; fileRef = 54229F542E02491A0019ACB0 /* TinySVG.m */; };
|
||||
54253C7F2C47303A00742695 /* RegexConverter+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54253C7E2C47303A00742695 /* RegexConverter+Ext.m */; };
|
||||
54253C932C49BFCD00742695 /* RegexConverterModal.m in Sources */ = {isa = PBXBuildFile; fileRef = 54253C8A2C49A92400742695 /* RegexConverterModal.m */; };
|
||||
54253C942C49BFDC00742695 /* RegexConverterController.m in Sources */ = {isa = PBXBuildFile; fileRef = 54253C872C49A6A800742695 /* RegexConverterController.m */; };
|
||||
54253C952C49BFE400742695 /* RegexConverterView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54253C842C47369000742695 /* RegexConverterView.m */; };
|
||||
544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; };
|
||||
544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; };
|
||||
544DCCB9212A2B4D002DBC46 /* RSXML2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML2.framework */; };
|
||||
544DCCBA212A2B4D002DBC46 /* RSXML2.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML2.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
544DCCBE212A2B6F002DBC46 /* RSXML2.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = 544DCCBD212A2B6F002DBC46 /* RSXML2.framework.dSYM */; };
|
||||
544F5A752E30EFC700674F81 /* style.css in Resources */ = {isa = PBXBuildFile; fileRef = 544F5A722E30EFC700674F81 /* style.css */; };
|
||||
544F5A762E30EFC700674F81 /* opml-lib.m in Sources */ = {isa = PBXBuildFile; fileRef = 544F5A702E30EFC700674F81 /* opml-lib.m */; };
|
||||
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 5450100F230E9C8600F0B165 /* FeedDownload.m */; };
|
||||
545EB5DA2EE8622200FABBE0 /* StrictUIntFormatter.m in Sources */ = {isa = PBXBuildFile; fileRef = 545EB5D92EE8622200FABBE0 /* StrictUIntFormatter.m */; };
|
||||
5469E13C2EA90C6C00D46CE7 /* NotifyEndpoint.m in Sources */ = {isa = PBXBuildFile; fileRef = 5469E13B2EA90C6C00D46CE7 /* NotifyEndpoint.m */; };
|
||||
546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857D122802309001BA1C8 /* SettingsGeneralView.m */; };
|
||||
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */; };
|
||||
546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2D22C585580034E806 /* SettingsAboutView.m */; };
|
||||
@@ -25,18 +31,23 @@
|
||||
546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC44121189975007CC3A3 /* SettingsGeneral.m */; };
|
||||
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; };
|
||||
5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */; };
|
||||
5483296C2A3CDC38000688B9 /* RSXML2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 548329652A3CDB22000688B9 /* RSXML2.framework */; };
|
||||
5483296D2A3CDC38000688B9 /* RSXML2.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 548329652A3CDB22000688B9 /* RSXML2.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 548C6D09230C33DE003A1AAF /* NSURL+Ext.m */; };
|
||||
5491005D2331435E00858AE2 /* Download3rdParty.m in Sources */ = {isa = PBXBuildFile; fileRef = 5491005C2331435E00858AE2 /* Download3rdParty.m */; };
|
||||
54910067233A4D4000858AE2 /* URLScheme.m in Sources */ = {isa = PBXBuildFile; fileRef = 54910066233A4D4000858AE2 /* URLScheme.m */; };
|
||||
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; };
|
||||
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */; };
|
||||
54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A81220E723D00082C51 /* MapUnreadTotal.m */; };
|
||||
54A2D63922EF81A4007C61F3 /* QLOPML.qlgenerator in CopyFiles */ = {isa = PBXBuildFile; fileRef = 54A2D63822EF8193007C61F3 /* QLOPML.qlgenerator */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; };
|
||||
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* UpdateScheduler.m */; };
|
||||
54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.m */; };
|
||||
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54AD4E0B2301853D000AE386 /* NSString+Ext.m */; };
|
||||
54AD4EE72305B17D000AE386 /* container-migration.plist in Resources */ = {isa = PBXBuildFile; fileRef = 54AD4EE62305B17D000AE386 /* container-migration.plist */; };
|
||||
54AD90EA2E30C48400160925 /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54AD90E92E30C48400160925 /* Quartz.framework */; };
|
||||
54AD90EE2E30C48400160925 /* PreviewViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 54AD90ED2E30C48400160925 /* PreviewViewController.m */; };
|
||||
54AD90F12E30C48400160925 /* PreviewViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54AD90EF2E30C48400160925 /* PreviewViewController.xib */; };
|
||||
54AD90F72E30C48400160925 /* QLOPML.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 54AD90E72E30C48400160925 /* QLOPML.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B51703226DC339006C1B29 /* ModalFeedEditView.m */; };
|
||||
54B517072270E990006C1B29 /* NSView+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B517062270E92A006C1B29 /* NSView+Ext.m */; };
|
||||
54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B6F149231551B3002C94C9 /* FaviconDownload.m */; };
|
||||
@@ -45,6 +56,7 @@
|
||||
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */; };
|
||||
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; };
|
||||
54BF444A22D0F4F300660096 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54BF444922D0F4F300660096 /* AppIcon.icns */; };
|
||||
54D10DDB2C6E930F0008F621 /* RegexFeed.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D10DDA2C6E930F0008F621 /* RegexFeed.m */; };
|
||||
54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */; };
|
||||
54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */; };
|
||||
54DD9F1323D1D6B000B1EAA6 /* NSColor+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54DD9F1223D1D6B000B1EAA6 /* NSColor+Ext.m */; };
|
||||
@@ -59,11 +71,25 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
54A2D63722EF8193007C61F3 /* PBXContainerItemProxy */ = {
|
||||
548329642A3CDB22000688B9 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */;
|
||||
containerPortal = 5483295E2A3CDB22000688B9 /* RSXML2.xcodeproj */;
|
||||
proxyType = 2;
|
||||
remoteGlobalIDString = 540A649822EE78B200470937;
|
||||
remoteGlobalIDString = 84F22C0D1B52DDEA000060CE;
|
||||
remoteInfo = RSXML2;
|
||||
};
|
||||
548329662A3CDB22000688B9 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5483295E2A3CDB22000688B9 /* RSXML2.xcodeproj */;
|
||||
proxyType = 2;
|
||||
remoteGlobalIDString = 84F22C171B52DDEA000060CE;
|
||||
remoteInfo = RSXML2Tests;
|
||||
};
|
||||
54AD90F42E30C48400160925 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 54ACC27421061B3B0020715F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 54AD90E62E30C48400160925;
|
||||
remoteInfo = QLOPML;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
@@ -75,29 +101,20 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
544DCCBA212A2B4D002DBC46 /* RSXML2.framework in Embed Frameworks */,
|
||||
5483296D2A3CDC38000688B9 /* RSXML2.framework in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
544DCCBC212A2B5A002DBC46 /* CopyFiles */ = {
|
||||
54AD90F62E30C48400160925 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 16;
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
544DCCBE212A2B6F002DBC46 /* RSXML2.framework.dSYM in CopyFiles */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
54CE4D4522EF509400E89C16 /* CopyFiles */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = Contents/Library/QuickLook;
|
||||
dstSubfolderSpec = 1;
|
||||
files = (
|
||||
54A2D63922EF81A4007C61F3 /* QLOPML.qlgenerator in CopyFiles */,
|
||||
54AD90F72E30C48400160925 /* QLOPML.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
@@ -115,18 +132,35 @@
|
||||
541C67C22255470B004D2CE6 /* SettingsAppearance.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearance.m; sourceTree = "<group>"; };
|
||||
54209E922117325100F3B5EF /* DrawImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DrawImage.h; sourceTree = "<group>"; };
|
||||
54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = "<group>"; };
|
||||
54229F532E02491A0019ACB0 /* TinySVG.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TinySVG.h; sourceTree = "<group>"; };
|
||||
54229F542E02491A0019ACB0 /* TinySVG.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TinySVG.m; sourceTree = "<group>"; };
|
||||
54253C7A2C47303A00742695 /* RegexConverter+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RegexConverter+Ext.h"; sourceTree = "<group>"; };
|
||||
54253C7E2C47303A00742695 /* RegexConverter+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "RegexConverter+Ext.m"; sourceTree = "<group>"; };
|
||||
54253C832C47368F00742695 /* RegexConverterView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegexConverterView.h; sourceTree = "<group>"; };
|
||||
54253C842C47369000742695 /* RegexConverterView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegexConverterView.m; sourceTree = "<group>"; };
|
||||
54253C872C49A6A800742695 /* RegexConverterController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegexConverterController.m; sourceTree = "<group>"; };
|
||||
54253C882C49A6A800742695 /* RegexConverterController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegexConverterController.h; sourceTree = "<group>"; };
|
||||
54253C8A2C49A92400742695 /* RegexConverterModal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegexConverterModal.m; sourceTree = "<group>"; };
|
||||
54253C8B2C49A92400742695 /* RegexConverterModal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegexConverterModal.h; sourceTree = "<group>"; };
|
||||
544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = "<group>"; };
|
||||
544B01192114B41200386E5C /* ModalSheet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalSheet.m; sourceTree = "<group>"; };
|
||||
544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = "<group>"; };
|
||||
544B011C2114EE9100386E5C /* AppHook.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppHook.m; sourceTree = "<group>"; };
|
||||
544DCCB8212A2B4D002DBC46 /* RSXML2.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSXML2.framework; path = Carthage/Build/Mac/RSXML2.framework; sourceTree = "<group>"; };
|
||||
544DCCBD212A2B6F002DBC46 /* RSXML2.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = RSXML2.framework.dSYM; path = Carthage/Build/Mac/RSXML2.framework.dSYM; sourceTree = "<group>"; };
|
||||
544F5A6F2E30EFC700674F81 /* opml-lib.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "opml-lib.h"; sourceTree = "<group>"; };
|
||||
544F5A702E30EFC700674F81 /* opml-lib.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "opml-lib.m"; sourceTree = "<group>"; };
|
||||
544F5A722E30EFC700674F81 /* style.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = style.css; sourceTree = "<group>"; };
|
||||
5450100E230E9C8600F0B165 /* FeedDownload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedDownload.h; sourceTree = "<group>"; };
|
||||
5450100F230E9C8600F0B165 /* FeedDownload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = "<group>"; };
|
||||
545EB5D62EE8620300FABBE0 /* StrictUIntFormatter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StrictUIntFormatter.h; sourceTree = "<group>"; };
|
||||
545EB5D92EE8622200FABBE0 /* StrictUIntFormatter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StrictUIntFormatter.m; sourceTree = "<group>"; };
|
||||
5469E13A2EA90C6C00D46CE7 /* NotifyEndpoint.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NotifyEndpoint.h; sourceTree = "<group>"; };
|
||||
5469E13B2EA90C6C00D46CE7 /* NotifyEndpoint.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NotifyEndpoint.m; sourceTree = "<group>"; };
|
||||
546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearanceView.m; sourceTree = "<group>"; };
|
||||
546A6A2B22C584AF0034E806 /* SettingsAppearanceView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAppearanceView.h; sourceTree = "<group>"; };
|
||||
546A6A2D22C585580034E806 /* SettingsAboutView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAboutView.m; sourceTree = "<group>"; };
|
||||
546A6A2E22C585580034E806 /* SettingsAboutView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAboutView.h; sourceTree = "<group>"; };
|
||||
546BD1882EDE156000943942 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
|
||||
546BD1892EDE156000943942 /* Config-debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Config-debug.xcconfig"; sourceTree = "<group>"; };
|
||||
546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsFeeds.h; sourceTree = "<group>"; };
|
||||
546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsFeeds.m; sourceTree = "<group>"; };
|
||||
546FC44021189975007CC3A3 /* SettingsGeneral.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsGeneral.h; sourceTree = "<group>"; };
|
||||
@@ -135,6 +169,7 @@
|
||||
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = "<group>"; };
|
||||
5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsFeedsView.h; sourceTree = "<group>"; };
|
||||
5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsFeedsView.m; sourceTree = "<group>"; };
|
||||
5483295E2A3CDB22000688B9 /* RSXML2.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSXML2.xcodeproj; path = ../RSXML2/RSXML2.xcodeproj; sourceTree = "<group>"; };
|
||||
54892F1D2235285700271CBA /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
|
||||
548C6D08230C33DE003A1AAF /* NSURL+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURL+Ext.h"; sourceTree = "<group>"; };
|
||||
548C6D09230C33DE003A1AAF /* NSURL+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURL+Ext.m"; sourceTree = "<group>"; };
|
||||
@@ -148,7 +183,6 @@
|
||||
54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSFetchRequest+Ext.m"; sourceTree = "<group>"; };
|
||||
54A07A80220E723D00082C51 /* MapUnreadTotal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MapUnreadTotal.h; sourceTree = "<group>"; };
|
||||
54A07A81220E723D00082C51 /* MapUnreadTotal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MapUnreadTotal.m; sourceTree = "<group>"; };
|
||||
54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = QLOPML.xcodeproj; path = ../QLOPML/QLOPML.xcodeproj; sourceTree = "<group>"; };
|
||||
54ACC27C21061B3B0020715F /* baRSS Beta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "baRSS Beta.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
54ACC28321061B3B0020715F /* DBv1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DBv1.xcdatamodel; sourceTree = "<group>"; };
|
||||
54ACC28A21061B3C0020715F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -161,6 +195,13 @@
|
||||
54AD4E0B2301853D000AE386 /* NSString+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+Ext.m"; sourceTree = "<group>"; };
|
||||
54AD4EE42305AF60000AE386 /* baRSS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = baRSS.entitlements; sourceTree = "<group>"; };
|
||||
54AD4EE62305B17D000AE386 /* container-migration.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "container-migration.plist"; sourceTree = "<group>"; };
|
||||
54AD90E72E30C48400160925 /* QLOPML.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QLOPML.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
54AD90E92E30C48400160925 /* Quartz.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Quartz.framework; path = System/Library/Frameworks/Quartz.framework; sourceTree = SDKROOT; };
|
||||
54AD90EC2E30C48400160925 /* PreviewViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PreviewViewController.h; sourceTree = "<group>"; };
|
||||
54AD90ED2E30C48400160925 /* PreviewViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PreviewViewController.m; sourceTree = "<group>"; };
|
||||
54AD90F02E30C48400160925 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/PreviewViewController.xib; sourceTree = "<group>"; };
|
||||
54AD90F22E30C48400160925 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
54AD90F32E30C48400160925 /* QLOPML.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QLOPML.entitlements; sourceTree = "<group>"; };
|
||||
54B51702226DC339006C1B29 /* ModalFeedEditView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ModalFeedEditView.h; sourceTree = "<group>"; };
|
||||
54B51703226DC339006C1B29 /* ModalFeedEditView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEditView.m; sourceTree = "<group>"; };
|
||||
54B517052270E8C6006C1B29 /* NSView+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSView+Ext.h"; sourceTree = "<group>"; };
|
||||
@@ -176,6 +217,8 @@
|
||||
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = "<group>"; };
|
||||
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = "<group>"; };
|
||||
54BF444922D0F4F300660096 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = "<group>"; };
|
||||
54D10DD92C6E930F0008F621 /* RegexFeed.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegexFeed.h; sourceTree = "<group>"; };
|
||||
54D10DDA2C6E930F0008F621 /* RegexFeed.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegexFeed.m; sourceTree = "<group>"; };
|
||||
54D55D7122E624CD00057B98 /* SettingsFeeds+DragDrop.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SettingsFeeds+DragDrop.h"; sourceTree = "<group>"; };
|
||||
54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SettingsFeeds+DragDrop.m"; sourceTree = "<group>"; };
|
||||
54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RefreshStatisticsView.h; sourceTree = "<group>"; };
|
||||
@@ -204,7 +247,15 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
544DCCB9212A2B4D002DBC46 /* RSXML2.framework in Frameworks */,
|
||||
5483296C2A3CDC38000688B9 /* RSXML2.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
54AD90E42E30C48400160925 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
54AD90EA2E30C48400160925 /* Quartz.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -226,6 +277,21 @@
|
||||
path = "Status Bar Menu";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54253C862C49A5A900742695 /* Regex Editor */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54253C8B2C49A92400742695 /* RegexConverterModal.h */,
|
||||
54253C8A2C49A92400742695 /* RegexConverterModal.m */,
|
||||
54253C882C49A6A800742695 /* RegexConverterController.h */,
|
||||
54253C872C49A6A800742695 /* RegexConverterController.m */,
|
||||
54253C832C47368F00742695 /* RegexConverterView.h */,
|
||||
54253C842C47369000742695 /* RegexConverterView.m */,
|
||||
54D10DD92C6E930F0008F621 /* RegexFeed.h */,
|
||||
54D10DDA2C6E930F0008F621 /* RegexFeed.m */,
|
||||
);
|
||||
path = "Regex Editor";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
544936F721F1E51E00DEE9AA /* NSCategories */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -247,13 +313,13 @@
|
||||
path = NSCategories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
544FBD4321064AEB008A260C /* Frameworks */ = {
|
||||
5469E1372EA90C3500D46CE7 /* Notifications */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
544DCCB8212A2B4D002DBC46 /* RSXML2.framework */,
|
||||
544DCCBD212A2B6F002DBC46 /* RSXML2.framework.dSYM */,
|
||||
5469E13A2EA90C6C00D46CE7 /* NotifyEndpoint.h */,
|
||||
5469E13B2EA90C6C00D46CE7 /* NotifyEndpoint.m */,
|
||||
);
|
||||
name = Frameworks;
|
||||
path = Notifications;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
546FC44D2118B357007CC3A3 /* Preferences */ = {
|
||||
@@ -271,6 +337,15 @@
|
||||
path = Preferences;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5483295F2A3CDB22000688B9 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
548329652A3CDB22000688B9 /* RSXML2.framework */,
|
||||
548329672A3CDB22000688B9 /* RSXML2Tests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54A07A8322105E0800082C51 /* Core Data */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -284,29 +359,26 @@
|
||||
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */,
|
||||
540F704321B6C16C0022E69D /* FeedMeta+Ext.h */,
|
||||
540F704421B6C16C0022E69D /* FeedMeta+Ext.m */,
|
||||
54253C7A2C47303A00742695 /* RegexConverter+Ext.h */,
|
||||
54253C7E2C47303A00742695 /* RegexConverter+Ext.m */,
|
||||
54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */,
|
||||
54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */,
|
||||
);
|
||||
path = "Core Data";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54A2D63422EF8193007C61F3 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54A2D63822EF8193007C61F3 /* QLOPML.qlgenerator */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54ACC27321061B3B0020715F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
546BD1882EDE156000943942 /* Config.xcconfig */,
|
||||
546BD1892EDE156000943942 /* Config-debug.xcconfig */,
|
||||
540CD14821C094A2004AB594 /* README.md */,
|
||||
54892F1D2235285700271CBA /* CHANGELOG.md */,
|
||||
54ACC27E21061B3B0020715F /* baRSS */,
|
||||
54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */,
|
||||
5483295E2A3CDB22000688B9 /* RSXML2.xcodeproj */,
|
||||
54AD90EB2E30C48400160925 /* QLOPML */,
|
||||
54AD90E82E30C48400160925 /* Frameworks */,
|
||||
54ACC27D21061B3B0020715F /* Products */,
|
||||
544FBD4321064AEB008A260C /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -314,6 +386,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54ACC27C21061B3B0020715F /* baRSS Beta.app */,
|
||||
54AD90E72E30C48400160925 /* QLOPML.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -328,8 +401,10 @@
|
||||
54E9CF2F225913850023696F /* Helper */,
|
||||
544936F721F1E51E00DEE9AA /* NSCategories */,
|
||||
541A90EF21257D4F002680A6 /* Status Bar Menu */,
|
||||
5469E1372EA90C3500D46CE7 /* Notifications */,
|
||||
54A07A8322105E0800082C51 /* Core Data */,
|
||||
54AD4E04230084FD000AE386 /* Feed Import */,
|
||||
54253C862C49A5A900742695 /* Regex Editor */,
|
||||
546FC44D2118B357007CC3A3 /* Preferences */,
|
||||
54ACC28A21061B3C0020715F /* Info.plist */,
|
||||
54F7101322EE0DDA006985D1 /* Artwork */,
|
||||
@@ -357,6 +432,29 @@
|
||||
path = "Feed Import";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54AD90E82E30C48400160925 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54AD90E92E30C48400160925 /* Quartz.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54AD90EB2E30C48400160925 /* QLOPML */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
544F5A6F2E30EFC700674F81 /* opml-lib.h */,
|
||||
544F5A702E30EFC700674F81 /* opml-lib.m */,
|
||||
54AD90EC2E30C48400160925 /* PreviewViewController.h */,
|
||||
54AD90ED2E30C48400160925 /* PreviewViewController.m */,
|
||||
54AD90EF2E30C48400160925 /* PreviewViewController.xib */,
|
||||
54AD90F22E30C48400160925 /* Info.plist */,
|
||||
54AD90F32E30C48400160925 /* QLOPML.entitlements */,
|
||||
544F5A722E30EFC700674F81 /* style.css */,
|
||||
);
|
||||
path = QLOPML;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
54D857CF228022AB001BA1C8 /* General Tab */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -418,6 +516,10 @@
|
||||
54209E932117325100F3B5EF /* DrawImage.m */,
|
||||
54910065233A4D4000858AE2 /* URLScheme.h */,
|
||||
54910066233A4D4000858AE2 /* URLScheme.m */,
|
||||
54229F532E02491A0019ACB0 /* TinySVG.h */,
|
||||
54229F542E02491A0019ACB0 /* TinySVG.m */,
|
||||
545EB5D62EE8620300FABBE0 /* StrictUIntFormatter.h */,
|
||||
545EB5D92EE8622200FABBE0 /* StrictUIntFormatter.m */,
|
||||
);
|
||||
path = Helper;
|
||||
sourceTree = "<group>";
|
||||
@@ -442,27 +544,44 @@
|
||||
54ACC27921061B3B0020715F /* Frameworks */,
|
||||
54ACC27A21061B3B0020715F /* Resources */,
|
||||
544DCCBB212A2B4D002DBC46 /* Embed Frameworks */,
|
||||
54CE4D4522EF509400E89C16 /* CopyFiles */,
|
||||
544DCCBC212A2B5A002DBC46 /* CopyFiles */,
|
||||
543964EE2215C27B0016AAA3 /* ShellScript */,
|
||||
54FB05D12305BFAB00A088AD /* ShellScript */,
|
||||
54FB05D12305BFAB00A088AD /* dynamic app name in db migration */,
|
||||
54AD90F62E30C48400160925 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
54AD90F52E30C48400160925 /* PBXTargetDependency */,
|
||||
);
|
||||
name = baRSS;
|
||||
productName = baRRS;
|
||||
productReference = 54ACC27C21061B3B0020715F /* baRSS Beta.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
54AD90E62E30C48400160925 /* QLOPML */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 54AD90F82E30C48400160925 /* Build configuration list for PBXNativeTarget "QLOPML" */;
|
||||
buildPhases = (
|
||||
54AD90E32E30C48400160925 /* Sources */,
|
||||
54AD90E42E30C48400160925 /* Frameworks */,
|
||||
54AD90E52E30C48400160925 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = QLOPML;
|
||||
productName = QLOPML;
|
||||
productReference = 54AD90E72E30C48400160925 /* QLOPML.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
54ACC27421061B3B0020715F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1200;
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 2600;
|
||||
ORGANIZATIONNAME = relikd;
|
||||
TargetAttributes = {
|
||||
54ACC27B21061B3B0020715F = {
|
||||
@@ -480,6 +599,9 @@
|
||||
};
|
||||
};
|
||||
};
|
||||
54AD90E62E30C48400160925 = {
|
||||
CreatedOnToolsVersion = 12.4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 54ACC27721061B3B0020715F /* Build configuration list for PBXProject "baRSS" */;
|
||||
@@ -495,23 +617,31 @@
|
||||
projectDirPath = "";
|
||||
projectReferences = (
|
||||
{
|
||||
ProductGroup = 54A2D63422EF8193007C61F3 /* Products */;
|
||||
ProjectRef = 54A2D62E22EF8183007C61F3 /* QLOPML.xcodeproj */;
|
||||
ProductGroup = 5483295F2A3CDB22000688B9 /* Products */;
|
||||
ProjectRef = 5483295E2A3CDB22000688B9 /* RSXML2.xcodeproj */;
|
||||
},
|
||||
);
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
54ACC27B21061B3B0020715F /* baRSS */,
|
||||
54AD90E62E30C48400160925 /* QLOPML */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXReferenceProxy section */
|
||||
54A2D63822EF8193007C61F3 /* QLOPML.qlgenerator */ = {
|
||||
548329652A3CDB22000688B9 /* RSXML2.framework */ = {
|
||||
isa = PBXReferenceProxy;
|
||||
fileType = wrapper.framework;
|
||||
path = RSXML2.framework;
|
||||
remoteRef = 548329642A3CDB22000688B9 /* PBXContainerItemProxy */;
|
||||
sourceTree = BUILT_PRODUCTS_DIR;
|
||||
};
|
||||
548329672A3CDB22000688B9 /* RSXML2Tests.xctest */ = {
|
||||
isa = PBXReferenceProxy;
|
||||
fileType = wrapper.cfbundle;
|
||||
path = QLOPML.qlgenerator;
|
||||
remoteRef = 54A2D63722EF8193007C61F3 /* PBXContainerItemProxy */;
|
||||
path = RSXML2Tests.xctest;
|
||||
remoteRef = 548329662A3CDB22000688B9 /* PBXContainerItemProxy */;
|
||||
sourceTree = BUILT_PRODUCTS_DIR;
|
||||
};
|
||||
/* End PBXReferenceProxy section */
|
||||
@@ -527,28 +657,21 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
54AD90E52E30C48400160925 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
54AD90F12E30C48400160925 /* PreviewViewController.xib in Resources */,
|
||||
544F5A752E30EFC700674F81 /* style.css in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
543964EE2215C27B0016AAA3 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# https://crunchybagel.com/auto-incrementing-build-numbers-in-xcode/\nbuildNumber=$(/usr/libexec/PlistBuddy -c \"Print CFBundleVersion\" \"${PROJECT_DIR}/${INFOPLIST_FILE}\")\nbuildNumber=$(($buildNumber + 1))\n/usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $buildNumber\" \"${PROJECT_DIR}/${INFOPLIST_FILE}\"\n";
|
||||
};
|
||||
54FB05D12305BFAB00A088AD /* ShellScript */ = {
|
||||
54FB05D12305BFAB00A088AD /* dynamic app name in db migration */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
@@ -556,6 +679,7 @@
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "dynamic app name in db migration";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
@@ -572,8 +696,10 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
54253C932C49BFCD00742695 /* RegexConverterModal.m in Sources */,
|
||||
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */,
|
||||
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */,
|
||||
54D10DDB2C6E930F0008F621 /* RegexFeed.m in Sources */,
|
||||
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */,
|
||||
54E9CF32225914300023696F /* SettingsAbout.m in Sources */,
|
||||
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */,
|
||||
@@ -585,6 +711,7 @@
|
||||
54DD9F1323D1D6B000B1EAA6 /* NSColor+Ext.m in Sources */,
|
||||
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */,
|
||||
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */,
|
||||
54253C7F2C47303A00742695 /* RegexConverter+Ext.m in Sources */,
|
||||
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */,
|
||||
54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */,
|
||||
54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */,
|
||||
@@ -599,6 +726,7 @@
|
||||
54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */,
|
||||
54910067233A4D4000858AE2 /* URLScheme.m in Sources */,
|
||||
54F6025D21C1D4170006D338 /* OpmlFile.m in Sources */,
|
||||
54229F552E02491A0019ACB0 /* TinySVG.m in Sources */,
|
||||
5496B511214D6275003ED4ED /* UserPrefs.m in Sources */,
|
||||
546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */,
|
||||
54B517072270E990006C1B29 /* NSView+Ext.m in Sources */,
|
||||
@@ -607,21 +735,54 @@
|
||||
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */,
|
||||
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */,
|
||||
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
|
||||
54253C952C49BFE400742695 /* RegexConverterView.m in Sources */,
|
||||
548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */,
|
||||
545EB5DA2EE8622200FABBE0 /* StrictUIntFormatter.m in Sources */,
|
||||
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
|
||||
54195883218A061100581B79 /* Feed+Ext.m in Sources */,
|
||||
5469E13C2EA90C6C00D46CE7 /* NotifyEndpoint.m in Sources */,
|
||||
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */,
|
||||
54209E942117325100F3B5EF /* DrawImage.m in Sources */,
|
||||
54253C942C49BFDC00742695 /* RegexConverterController.m in Sources */,
|
||||
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */,
|
||||
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
54AD90E32E30C48400160925 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
544F5A762E30EFC700674F81 /* opml-lib.m in Sources */,
|
||||
54AD90EE2E30C48400160925 /* PreviewViewController.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
54AD90F52E30C48400160925 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 54AD90E62E30C48400160925 /* QLOPML */;
|
||||
targetProxy = 54AD90F42E30C48400160925 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
54AD90EF2E30C48400160925 /* PreviewViewController.xib */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
54AD90F02E30C48400160925 /* Base */,
|
||||
);
|
||||
name = PreviewViewController.xib;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
54ACC28E21061B3C0020715F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 546BD1892EDE156000943942 /* Config-debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
@@ -655,7 +816,9 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = UY657LKNHJ;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -669,7 +832,6 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.12;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -678,6 +840,7 @@
|
||||
};
|
||||
54ACC28F21061B3C0020715F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 546BD1882EDE156000943942 /* Config.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
@@ -712,7 +875,9 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = UY657LKNHJ;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -723,7 +888,6 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.12;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
};
|
||||
@@ -745,16 +909,12 @@
|
||||
CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES;
|
||||
CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = baRSS/baRSS.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Mac Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = UY657LKNHJ;
|
||||
EMBED_ASSET_PACKS_IN_PRODUCT_BUNDLE = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)",
|
||||
"$(PROJECT_DIR)/Carthage/Build/Mac",
|
||||
);
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"APP_NAME=\"\\@\\\"$(PRODUCT_NAME)\\\"\"",
|
||||
@@ -777,9 +937,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
"$(FRAMEWORK_SEARCH_PATHS)",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.relikd.baRSS.beta;
|
||||
PRODUCT_NAME = "$(TARGET_NAME) Beta";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
PRODUCT_NAME = "$(inherited) Beta";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -800,16 +958,12 @@
|
||||
CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES;
|
||||
CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = baRSS/baRSS.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Mac Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = UY657LKNHJ;
|
||||
EMBED_ASSET_PACKS_IN_PRODUCT_BUNDLE = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)",
|
||||
"$(PROJECT_DIR)/Carthage/Build/Mac",
|
||||
);
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"APP_NAME=\"\\@\\\"$(PRODUCT_NAME)\\\"\"",
|
||||
@@ -832,9 +986,47 @@
|
||||
"@executable_path/../Frameworks",
|
||||
"$(FRAMEWORK_SEARCH_PATHS)",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.relikd.baRSS;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
54AD90F92E30C48400160925 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = QLOPML/QLOPML.entitlements;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
INFOPLIST_FILE = QLOPML/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).QLOPML";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
54AD90FA2E30C48400160925 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = QLOPML/QLOPML.entitlements;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = QLOPML/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).QLOPML";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -859,6 +1051,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
54AD90F82E30C48400160925 /* Build configuration list for PBXNativeTarget "QLOPML" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
54AD90F92E30C48400160925 /* Debug */,
|
||||
54AD90FA2E30C48400160925 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCVersionGroup section */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1200"
|
||||
version = "1.3">
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.8">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#import "StoreCoordinator.h"
|
||||
#import "SettingsFeeds+DragDrop.h"
|
||||
#import "URLScheme.h"
|
||||
#import "NotifyEndpoint.h"
|
||||
#import "NSURL+Ext.h"
|
||||
#import "NSError+Ext.h"
|
||||
|
||||
@@ -37,12 +38,18 @@
|
||||
[_statusItem asyncReloadUnreadCount];
|
||||
[UpdateScheduler registerNetworkChangeNotification]; // will call update scheduler
|
||||
if ([StoreCoordinator isEmpty]) {
|
||||
[_statusItem showWelcomeMessage];
|
||||
// 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 {
|
||||
@@ -52,7 +59,44 @@
|
||||
/// 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()];
|
||||
// thank you, past-self! but it would have been nice to have easier "<=" comparison
|
||||
NSString *prevVersion = [StoreCoordinator optionForKey:@"app-version"];
|
||||
NSString *curVersion = UserPrefsAppVersion();
|
||||
// migrate if not run for the first time
|
||||
if (prevVersion != nil) {
|
||||
if ([prevVersion isEqualToString:curVersion]) {
|
||||
return; // migration already performed
|
||||
}
|
||||
// else: migrate
|
||||
NSInteger ver = 0;
|
||||
for (NSString *part in [prevVersion componentsSeparatedByString:@"."]) {
|
||||
ver = ver * 100 + [part integerValue];
|
||||
}
|
||||
if (ver <= 10505) { // v1.5.5
|
||||
[self migrate_v1_6_0];
|
||||
}
|
||||
}
|
||||
[StoreCoordinator setOption:@"app-version" value:curVersion];
|
||||
}
|
||||
|
||||
- (void)migrate_v1_6_0 {
|
||||
NSLog(@"Migrating to v1.6.0");
|
||||
// rename options
|
||||
BOOL shouldLimitCount = UserPrefsBool(@"feedLimitArticles"); // default: NO
|
||||
if (shouldLimitCount) {
|
||||
NSInteger prev = UserPrefsInt(@"articlesInMenuLimit"); // default: 40
|
||||
UserPrefsSetInt(Pref_articleCountLimit, prev == 0 ? 40 : prev);
|
||||
}
|
||||
BOOL shouldLimitTitle = UserPrefsBool(@"feedTruncateTitle"); // default: NO
|
||||
if (shouldLimitTitle) {
|
||||
NSInteger prev = UserPrefsInt(@"shortArticleNamesLimit"); // default: 60
|
||||
UserPrefsSetInt(Pref_articleTitleLimit, prev == 0 ? 60 : prev);
|
||||
}
|
||||
// delete old keys
|
||||
UserPrefsSet(@"feedLimitArticles", nil);
|
||||
UserPrefsSet(@"feedTruncateTitle", nil);
|
||||
UserPrefsSet(@"articlesInMenuLimit", nil);
|
||||
UserPrefsSet(@"shortArticleNamesLimit", nil);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +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"/>
|
||||
<stop offset="0" style="stop-color:#FF8B00"/>
|
||||
<stop offset="0.5" style="stop-color:#FFAB48"/>
|
||||
<stop offset="1" style="stop-color:#FF8B00"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#orange)" d="M0,25v50q0,25,25,25h50q25,0,25,-25v-50q0,-25,-25,-25h-50q-25,0,-25,25z"/>
|
||||
<g fill="#FFFFFF" transform="matrix(-0.75 0 0 0.75 87.5 12.5)">
|
||||
<circle cx="13" cy="13" r="13"/>
|
||||
<path d="M0,45v20Q65,65,65,0h-20Q45,45,0,45z"/>
|
||||
<path d="M0,80v20Q100,100,100,0h-20Q80,80,0,80z"/>
|
||||
<!-- 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 663 B After Width: | Height: | Size: 941 B |
8
baRSS/Artwork/icon-appearance-article.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<rect y="14" width="16" height="1"/>
|
||||
<rect y="10" width="16" height="1"/>
|
||||
<rect x="9" y="6" width="7" height="1"/>
|
||||
<rect x="9" y="2" width="7" height="1"/>
|
||||
<rect x="1" y="1" width="7" height="7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
7
baRSS/Artwork/icon-appearance-group.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<g fill="none" stroke="#000">
|
||||
<path d="M3,13.5c-1.5,0-2.5-1-2.5-2.5V3.5c0-1.5.5-2,2-2h1.5c1.5,0,1.5,1,3,1h6c1.5,0,2.5,1,2.5,2.5v6c0,1.5-1,2.5-2.5,2.5H3Z"/>
|
||||
<path d="M1.5,5h13Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
11
baRSS/Artwork/icon-appearance-main-menu.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<!-- menu -->
|
||||
<rect x="0" y="0" width="16" height="3"/>
|
||||
<rect x="5" y="4" width="9" height="12"/>
|
||||
<rect x="6" y="3" width="7" height="12" fill="#aaa"/>
|
||||
<!-- entries -->
|
||||
<rect x="6" y="12" width="6" height="1"/>
|
||||
<rect x="6" y="9" width="6" height="1"/>
|
||||
<rect x="6" y="6" width="6" height="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
7
baRSS/Artwork/icon-regex.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path d="M18,19c-14,21-13,43,0,62l-7,4C-4,63-4,35,12,14l6,5Z"/>
|
||||
<circle cx="31" cy="67" r="7"/>
|
||||
<path d="M65,28l11-4,2,6-11,4,7,9-5,4-7-9-7,9-5-4,7-9-11-4,2-6,11,4v-11h6v11Z"/>
|
||||
<path d="M82,81c14-21,13-43,0-62l7-5c16,22,15,50,0,71l-7-4Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
7
baRSS/Artwork/icon-rss-plain-paused.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="13" cy="87" r="13"/>
|
||||
<path d="M0,35q65,0,65,65h-20q0,-45,-45,-45z"/>
|
||||
<rect x="60" y="0" width="15" height="50"/>
|
||||
<rect x="85" y="0" width="15" height="50"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
6
baRSS/Artwork/icon-rss-plain.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="13" cy="87" r="13"/>
|
||||
<path d="M0,35q65,0,65,65h-20q0,-45,-45,-45z"/>
|
||||
<path d="M0,0q100,0,100,100h-20q0,-80,-80,-80z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
@@ -6,7 +6,6 @@
|
||||
// TODO: Add support for media player? image feed?
|
||||
// <enclosure url="https://url.mp3" length="63274022" type="audio/mpeg" />
|
||||
// TODO: Disable 'update all' menu item during update?
|
||||
// TODO: HTML to Feed Generator. https://github.com/RSS-Bridge/rss-bridge
|
||||
// TODO: SQlite instead of CoreData? https://www.objc.io/issues/4-core-data/SQLite-instead-of-core-data/
|
||||
|
||||
|
||||
@@ -22,19 +21,25 @@ static NSString* const auxiliaryAppURL = @"https://github.com/relikd/URL-Scheme-
|
||||
|
||||
|
||||
/// Default RSS icon (with border, with gradient, orange)
|
||||
static NSImageName const RSSImageDefaultRSSIcon = @"RSSImageDefaultRSSIcon";
|
||||
/// Settings, global icon (menu bar, black)
|
||||
static NSImageName const RSSImageSettingsGlobal = @"RSSImageSettingsGlobal";
|
||||
static NSImageName const RSSImageDefaultRSSIcon = @"RSSImageDefaultRSSIcon";
|
||||
/// Settings, global statusbar icon (rss icon with neighbor icons)
|
||||
static NSImageName const RSSImageSettingsGlobalIcon = @"RSSImageSettingsGlobalIcon";
|
||||
/// Settings, global menu icon (menu bar, black)
|
||||
static NSImageName const RSSImageSettingsGlobalMenu = @"RSSImageSettingsGlobalMenu";
|
||||
/// Settings, group icon (folder, black)
|
||||
static NSImageName const RSSImageSettingsGroup = @"RSSImageSettingsGroup";
|
||||
static NSImageName const RSSImageSettingsGroup = @"RSSImageSettingsGroup";
|
||||
/// Settings, feed icon (RSS, no border, no gradient, black)
|
||||
static NSImageName const RSSImageSettingsFeed = @"RSSImageSettingsFeed";
|
||||
static NSImageName const RSSImageSettingsFeed = @"RSSImageSettingsFeed";
|
||||
/// Settings, article icon (RSS surrounded by text lines)
|
||||
static NSImageName const RSSImageSettingsArticle = @"RSSImageSettingsArticle";
|
||||
/// Menu bar, bar icon (RSS, with border, no gradient, orange)
|
||||
static NSImageName const RSSImageMenuBarIconActive = @"RSSImageMenuBarIconActive";
|
||||
static NSImageName const RSSImageMenuBarIconActive = @"RSSImageMenuBarIconActive";
|
||||
/// Menu bar, bar icon (RSS, with border, no gradient, paused, orange)
|
||||
static NSImageName const RSSImageMenuBarIconPaused = @"RSSImageMenuBarIconPaused";
|
||||
static NSImageName const RSSImageMenuBarIconPaused = @"RSSImageMenuBarIconPaused";
|
||||
/// Menu item, unread state icon (blue dot)
|
||||
static NSImageName const RSSImageMenuItemUnread = @"RSSImageMenuItemUnread";
|
||||
static NSImageName const RSSImageMenuItemUnread = @"RSSImageMenuItemUnread";
|
||||
/// Feed edit, regex editor icon @c "(.*)"
|
||||
static NSImageName const RSSImageRegexIcon = @"RSSImageRegexIcon";
|
||||
|
||||
|
||||
#pragma mark - NSNotificationName constants
|
||||
|
||||
@@ -10,6 +10,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// Generator methods / Feed update
|
||||
+ (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context;
|
||||
- (NSString*)notificationID;
|
||||
- (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag;
|
||||
- (NSMenuItem*)newMenuItem;
|
||||
// Getter & Setter
|
||||
@@ -17,6 +18,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
- (void)setNewIcon:(NSURL*)location;
|
||||
// Article properties
|
||||
- (nullable NSArray<FeedArticle*>*)sortedArticles;
|
||||
- (NSUInteger)countUnread;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#import "FeedGroup+Ext.h"
|
||||
#import "FeedArticle+Ext.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "NotifyEndpoint.h"
|
||||
#import "NSURL+Ext.h"
|
||||
|
||||
@implementation Feed (Ext)
|
||||
@@ -17,6 +18,11 @@
|
||||
return 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.
|
||||
- (void)calculateAndSetIndexPathString {
|
||||
NSString *pthStr = [self.group indexPathString];
|
||||
@@ -28,7 +34,13 @@
|
||||
- (NSMenuItem*)newMenuItem {
|
||||
NSMenuItem *item = [NSMenuItem new];
|
||||
item.title = self.group.anyName;
|
||||
item.toolTip = self.subtitle;
|
||||
// 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;
|
||||
@@ -83,6 +95,8 @@
|
||||
[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 {
|
||||
FeedArticle *newArticle = [FeedArticle newArticle:article inContext:self.managedObjectContext];
|
||||
newArticle.sortIndex = currentIndex;
|
||||
@@ -102,10 +116,12 @@
|
||||
- (NSUInteger)deleteArticles:(NSMutableSet<FeedArticle*>*)localSet withRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
|
||||
NSUInteger c = 0;
|
||||
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];
|
||||
}
|
||||
@@ -113,6 +129,7 @@
|
||||
if (deletingSet.count > 0) {
|
||||
[localSet minusSet:deletingSet];
|
||||
[self removeArticles:deletingSet];
|
||||
[NotifyEndpoint dismiss:dismissed];
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -136,11 +153,13 @@
|
||||
- (FeedArticle*)findRemoteArticle:(RSParsedArticle*)remote inLocalSet:(NSSet<FeedArticle*>*)localSet {
|
||||
NSString *searchLink = remote.link;
|
||||
NSString *searchGuid = remote.guid;
|
||||
BOOL linkIsNil = (searchLink == nil);
|
||||
BOOL guidIsNil = (searchGuid == nil);
|
||||
for (FeedArticle *art in localSet) {
|
||||
if ((linkIsNil && art.link == nil) || (!linkIsNil && [art.link isEqualToString:searchLink])) {
|
||||
if ((guidIsNil && art.guid == nil) || (!guidIsNil && [art.guid isEqualToString:searchGuid]))
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -153,17 +172,29 @@
|
||||
- (RSParsedArticle*)findLocalArticle:(FeedArticle*)local inRemoteSet:(NSArray<RSParsedArticle*>*)remoteSet {
|
||||
NSString *searchLink = local.link;
|
||||
NSString *searchGuid = local.guid;
|
||||
BOOL linkIsNil = (searchLink == nil);
|
||||
BOOL guidIsNil = (searchGuid == nil);
|
||||
for (RSParsedArticle *art in remoteSet) {
|
||||
if ((linkIsNil && art.link == nil) || (!linkIsNil && [art.link isEqualToString:searchLink])) {
|
||||
if ((guidIsNil && art.guid == nil) || (!guidIsNil && [art.guid isEqualToString:searchGuid]))
|
||||
// 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 -
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface FeedArticle (Ext)
|
||||
+ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc;
|
||||
- (NSString*)notificationID;
|
||||
- (void)updateArticleIfChanged:(RSParsedArticle*)entry;
|
||||
- (NSMenuItem*)newMenuItem;
|
||||
@end
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@import RSXML2.RSParsedArticle;
|
||||
#import "FeedArticle+Ext.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "Constants.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "StoreCoordinator.h"
|
||||
#import "NotifyEndpoint.h"
|
||||
#import "NSString+Ext.h"
|
||||
|
||||
@implementation FeedArticle (Ext)
|
||||
@@ -25,15 +27,29 @@
|
||||
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 (UserPrefsBool(Pref_feedTruncateTitle)) {
|
||||
NSUInteger limit = UserPrefsUInt(Pref_shortArticleNamesLimit);
|
||||
if (title.length > limit)
|
||||
title = [[title substringToIndex:limit] stringByAppendingString:@"…"];
|
||||
NSUInteger limit = UserPrefsUInt(Pref_articleTitleLimit); // -1 will become MAX_INT
|
||||
if (limit > 0 && title.length > limit) {
|
||||
title = [[title substringToIndex:limit] stringByAppendingString:@"…"];
|
||||
}
|
||||
return title;
|
||||
}
|
||||
@@ -43,10 +59,17 @@
|
||||
NSMenuItem *item = [NSMenuItem new];
|
||||
item.title = [self shortArticleName];
|
||||
item.enabled = (self.link.length > 0);
|
||||
item.state = (self.unread && UserPrefsBool(Pref_feedUnreadIndicator) ? NSControlStateValueOn : NSControlStateValueOff);
|
||||
item.state = (self.unread && UserPrefsBool(Pref_articleUnreadIndicator) ? 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)
|
||||
// truncate tooltip
|
||||
NSUInteger limit = UserPrefsUInt(Pref_articleTooltipLimit); // -1 will become MAX_INT
|
||||
if (limit > 0) {
|
||||
NSString *tooltip = (self.abstract ? self.abstract : self.body); // fall back to body (html)
|
||||
if (tooltip.length > limit)
|
||||
tooltip = [[tooltip substringToIndex:limit] stringByAppendingString:@"…\n[…]"];
|
||||
item.toolTip = tooltip;
|
||||
}
|
||||
item.representedObject = self.objectID;
|
||||
item.target = [self class];
|
||||
item.action = @selector(didClickOnMenuItem:);
|
||||
@@ -67,8 +90,84 @@
|
||||
[StoreCoordinator saveContext:moc andParent:YES];
|
||||
NSNumber *num = (fa.unread ? @+1 : @-1);
|
||||
PostNotification(kNotificationTotalUnreadCountChanged, num);
|
||||
|
||||
[NotifyEndpoint dismiss:fa.feed.countUnread > 0 ? @[fa.notificationID] : @[fa.notificationID, fa.feed.notificationID]];
|
||||
}
|
||||
[moc reset];
|
||||
}
|
||||
|
||||
|
||||
#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
|
||||
|
||||
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
|
||||
@@ -30,6 +30,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// 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)cleanupAndShowAlert:(BOOL)flag;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
#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"
|
||||
@@ -57,7 +59,9 @@
|
||||
opt = [[Options alloc] initWithEntity:Options.entity insertIntoManagedObjectContext:moc];
|
||||
opt.key = key;
|
||||
}
|
||||
opt.value = value;
|
||||
if (opt.value != value) {
|
||||
opt.value = value;
|
||||
}
|
||||
[self saveContext:moc andParent:YES];
|
||||
[moc reset];
|
||||
}
|
||||
@@ -200,6 +204,50 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -1,52 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="14460.32" systemVersion="17G8030" minimumToolsVersion="Automatic" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="v1.0.0">
|
||||
<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="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"/>
|
||||
<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" syncable="YES"/>
|
||||
<attribute name="value" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<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="150"/>
|
||||
<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="FeedMeta" positionX="-348.02734375" positionY="136.89453125" width="128" height="150"/>
|
||||
<element name="Options" positionX="-279" positionY="36" width="128" height="75"/>
|
||||
<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>
|
||||
@@ -1,5 +1,5 @@
|
||||
@import Cocoa;
|
||||
@class RSParsedFeed, RSHTMLMetadataFeedLink, Feed, FaviconDownload;
|
||||
@class RSParsedFeed, RSHTMLMetadataFeedLink, Feed, FaviconDownload, RegexConverter;
|
||||
@protocol FeedDownloadDelegate;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
@@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@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);
|
||||
|
||||
@@ -21,6 +22,7 @@ typedef void (^FeedDownloadBlock)(FeedDownload *sender);
|
||||
+ (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;
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#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;
|
||||
@@ -20,6 +23,9 @@
|
||||
@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
|
||||
@@ -51,13 +57,20 @@
|
||||
FeedDownload *this = [FeedDownload new];
|
||||
this.assertIsFeedURL = YES;
|
||||
this.request = req;
|
||||
return this;
|
||||
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;
|
||||
@@ -134,10 +147,16 @@
|
||||
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
|
||||
@@ -146,6 +165,30 @@
|
||||
}];
|
||||
}
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#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"
|
||||
@@ -120,6 +121,24 @@ static NSInteger RadioGroupSelection(NSView *view) {
|
||||
|
||||
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];
|
||||
@@ -279,6 +298,21 @@ static NSInteger RadioGroupSelection(NSView *view) {
|
||||
[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;
|
||||
|
||||
@@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
// Scheduling
|
||||
+ (void)scheduleNextFeed;
|
||||
+ (void)forceUpdateAllFeeds;
|
||||
+ (void)downloadList:(NSArray<Feed*>*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block;
|
||||
+ (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;
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
#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"
|
||||
|
||||
@@ -108,13 +110,26 @@ static _Atomic(NSUInteger) _queueSize = 0;
|
||||
dispatch_once(&onceToken, ^{
|
||||
_timer = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:[self class] selector:@selector(updateTimerCallback) userInfo:nil repeats:YES];
|
||||
[[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
|
||||
// technically not the right place to register. But since it is run once, its easier than somewhere else.
|
||||
[NSWorkspace.sharedWorkspace.notificationCenter addObserver:[self class] selector:@selector(didWakeAfterSleep) name:NSWorkspaceDidWakeNotification object:nil];
|
||||
});
|
||||
if (!nextTime)
|
||||
nextTime = [NSDate distantFuture];
|
||||
NSTimeInterval tolerance = [nextTime timeIntervalSinceNow] * 0.15;
|
||||
_timer.tolerance = (tolerance < 1 ? 1 : tolerance); // at least 1 sec
|
||||
int tolerance = (int)([nextTime timeIntervalSinceNow] * 0.15);
|
||||
tolerance = (tolerance < 1 ? 1 : tolerance > 600 ? 600 : tolerance); // at least 1 sec, upto 10 min
|
||||
_timer.tolerance = tolerance;
|
||||
_timer.fireDate = nextTime;
|
||||
PostNotification(kNotificationScheduleTimerChanged, nil);
|
||||
#ifdef DEBUG
|
||||
NSLog(@"schedule timer: %@ (+/- %d sec)", nextTime, tolerance);
|
||||
#endif
|
||||
}
|
||||
|
||||
+ (void)didWakeAfterSleep {
|
||||
#ifdef DEBUG
|
||||
NSLog(@"did wake from sleep");
|
||||
#endif
|
||||
[UpdateScheduler scheduleNextFeed];
|
||||
}
|
||||
|
||||
/// Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user.
|
||||
@@ -129,7 +144,7 @@ static _Atomic(NSUInteger) _queueSize = 0;
|
||||
NSArray<Feed*> *list = [StoreCoordinator listOfFeedsThatNeedUpdate:updateAll inContext:moc];
|
||||
//NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early.");
|
||||
|
||||
[self downloadList:list userInitiated:updateAll finally:^{
|
||||
[self downloadList:list userInitiated:updateAll notifications:YES finally:^{
|
||||
[StoreCoordinator saveContext:moc andParent:YES]; // save parents too ...
|
||||
[moc reset];
|
||||
[self scheduleNextFeed]; // always reset the timer
|
||||
@@ -147,7 +162,7 @@ static _Atomic(NSUInteger) _queueSize = 0;
|
||||
}
|
||||
|
||||
/// Download list of feeds. Either silently in background or with alerts in foreground.
|
||||
+ (void)downloadList:(NSArray<Feed*>*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block {
|
||||
+ (void)downloadList:(NSArray<Feed*>*)list userInitiated:(BOOL)flag notifications:(BOOL)notify finally:(nullable os_block_t)block {
|
||||
if (![self allowNetworkConnection]) {
|
||||
if (block) block();
|
||||
return;
|
||||
@@ -158,7 +173,7 @@ static _Atomic(NSUInteger) _queueSize = 0;
|
||||
dispatch_group_t group = dispatch_group_create();
|
||||
for (Feed *f in list) {
|
||||
dispatch_group_enter(group);
|
||||
[self updateFeed:f alert:flag isForced:flag finally:^{
|
||||
[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);
|
||||
@@ -170,7 +185,7 @@ static _Atomic(NSUInteger) _queueSize = 0;
|
||||
/// Helper method to show modal error alert
|
||||
static inline void AlertDownloadError(NSError *err, NSString *url) {
|
||||
NSAlert *alertPopup = [NSAlert alertWithError:err];
|
||||
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", url];
|
||||
alertPopup.informativeText = [NSString stringWithFormat:NSLocalizedString(@"Error loading source: %@", nil), url];
|
||||
[alertPopup runModal];
|
||||
}
|
||||
|
||||
@@ -178,7 +193,7 @@ static inline void AlertDownloadError(NSError *err, NSString *url) {
|
||||
Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0).
|
||||
@note Will post a @c kNotificationArticlesUpdated notification if download was successful and status code is @b not 304.
|
||||
*/
|
||||
+ (void)updateFeed:(Feed*)feed alert:(BOOL)alert isForced:(BOOL)forced finally:(nullable os_block_t)block {
|
||||
+ (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) {
|
||||
@@ -188,7 +203,37 @@ static inline void AlertDownloadError(NSError *err, NSString *url) {
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
/// Draw separator line in @c NSOutlineView
|
||||
IB_DESIGNABLE
|
||||
@interface DrawSeparator : NSView
|
||||
@property (assign) BOOL invert;
|
||||
+ (instancetype)withSize:(NSSize)size;
|
||||
@end
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
#import "DrawImage.h"
|
||||
#import "Constants.h"
|
||||
#import "NSColor+Ext.h"
|
||||
#import "TinySVG.h"
|
||||
|
||||
|
||||
@implementation DrawSeparator
|
||||
+ (instancetype)withSize:(NSSize)size {
|
||||
return [[super alloc] initWithFrame:CGRectMake(0, 0, size.width, size.height)];
|
||||
}
|
||||
|
||||
- (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);
|
||||
NSColor *transparent = [color colorWithAlphaComponent:0.0];
|
||||
NSGradient *grdnt = [[NSGradient alloc] initWithStartingColor:self.invert ? transparent : color endingColor:self.invert ? color : transparent];
|
||||
NSRect separatorRect = NSMakeRect(1, NSMidY(self.bounds) - 1, NSWidth(self.bounds) - 2, 2);
|
||||
NSBezierPath *rounded = [NSBezierPath bezierPathWithRoundedRect:separatorRect xRadius:1 yRadius:1];
|
||||
[grdnt drawInBezierPath:rounded angle:0];
|
||||
}
|
||||
@@ -22,146 +28,24 @@ static inline const CGFloat ShorterSide(NSSize s) {
|
||||
return (s.width < s.height ? s.width : s.height);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// Flip coordinate system
|
||||
//static void FlipCoordinateSystem(CGContextRef c, CGFloat height) {
|
||||
// CGContextTranslateCTM(c, 0, height);
|
||||
// CGContextScaleCTM(c, 1, -1);
|
||||
//}
|
||||
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
/// 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));
|
||||
|
||||
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));
|
||||
}
|
||||
CGContextAddPath(c, menu);
|
||||
CGPathRelease(menu);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
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);
|
||||
/// 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);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
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
|
||||
PathAddCircle(bars, size * 0.125);
|
||||
PathAddRSSArc(bars, size * 0.45, size * 0.2);
|
||||
if (connection) {
|
||||
PathAddRSSArc(bars, size * 0.8, size * 0.2);
|
||||
} else {
|
||||
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);
|
||||
CGPathRelease(bars);
|
||||
}
|
||||
#pragma mark - Icon Background
|
||||
|
||||
|
||||
#pragma mark - Icon Background Generators
|
||||
|
||||
|
||||
/// Create @c CGPath with rounded corners (optional). @param roundness Value between @c 0.0 and @c 1.0
|
||||
static void AddRoundedBackgroundPath(CGContextRef c, CGRect r, CGFloat roundness) {
|
||||
const CGFloat corner = ShorterSide(r.size) * (roundness / 2.0);
|
||||
if (corner > 0) {
|
||||
CGMutablePathRef pth = CGPathCreateMutable();
|
||||
CGPathAddRoundedRect(pth, NULL, r, corner, corner);
|
||||
CGContextAddPath(c, pth);
|
||||
CGPathRelease(pth);
|
||||
} else {
|
||||
CGContextAddRect(c, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert and draw linear gradient with @c color saturation @c ±0.3
|
||||
static void DrawGradient(CGContextRef c, CGFloat size, NSColor *color) {
|
||||
CGFloat h = 0, s = 1, b = 1, a = 1;
|
||||
@@ -181,120 +65,242 @@ static void DrawGradient(CGContextRef c, CGFloat size, NSColor *color) {
|
||||
CFArrayRef colors = CFArrayCreate(NULL, cgColors, 3, NULL);
|
||||
CGGradientRef gradient = CGGradientCreateWithColors(NULL, colors, NULL);
|
||||
|
||||
CGContextDrawLinearGradient(c, gradient, CGPointMake(0, size), CGPointMake(size, 0), kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
|
||||
CGContextDrawLinearGradient(c, gradient, CGPointMake(0, 0), CGPointMake(size, size), kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
|
||||
CGGradientRelease(gradient);
|
||||
CFRelease(colors);
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - CGContext Drawing & Manipulation
|
||||
#pragma mark - RSS Icon (rounded corners)
|
||||
|
||||
|
||||
/// 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) {
|
||||
AddRoundedBackgroundPath(c, r, corner);
|
||||
if (scaling != 0.0)
|
||||
contentScale *= scaling;
|
||||
/**
|
||||
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) {
|
||||
svgCircle(c, size/100, 13, 87, 13, NO);
|
||||
svgPath(c, size/100, "M0,35q65,0,65,65h-20q0,-45,-45,-45z");
|
||||
if (connection) {
|
||||
svgPath(c, size/100, "M0,0q100,0,100,100h-20q0,-80,-80,-80z");
|
||||
} else {
|
||||
// pause icon
|
||||
svgRect(c, size/100, CGRectMake(60, 0, 15, 50));
|
||||
svgRect(c, size/100, CGRectMake(85, 0, 15, 50));
|
||||
}
|
||||
SetContentScale(c, r.size, contentScale);
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Easy Icon Drawing Methods
|
||||
|
||||
|
||||
/// Draw global icon (menu bar)
|
||||
static void DrawGlobalIcon(CGRect r, CGColorRef color, BOOL background) {
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
DrawRoundedFrame(c, r, color, background, 0.4, 1.0, 0.7);
|
||||
AddGlobalIconPath(c, ShorterSide(r.size));
|
||||
CGContextEOFillPath(c);
|
||||
}
|
||||
|
||||
/// Draw group icon (folder)
|
||||
static void DrawGroupIcon(CGRect r, CGColorRef color, BOOL background) {
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
const CGFloat s = ShorterSide(r.size);
|
||||
const CGFloat l = s * 0.08; // line width
|
||||
DrawRoundedFrame(c, r, color, background, 0.4, 1.0 - (l / s), 0.85);
|
||||
CGContextSetLineWidth(c, l * (background ? 0.5 : 1.0));
|
||||
AddGroupIconPath(c, s, background);
|
||||
CGContextStrokePath(c);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// Draw monochrome RSS icon with rounded corners
|
||||
static void RoundedRSS_Monochrome(CGRect r, BOOL connection) {
|
||||
const CGFloat size = ShorterSide(r.size);
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
DrawRoundedFrame(c, r, NSColor.whiteColor.CGColor, YES, 0.4, 1.0, 0.7);
|
||||
CGContextSetFillColorWithColor(c, [NSColor menuBarIconColor].CGColor);
|
||||
// background rounded rect
|
||||
svgRoundedRect(c, 1, r, size * 0.4/2);
|
||||
// RSS icon
|
||||
SetContentScale(c, r.size, 11/16.0);
|
||||
AddRSSIconPath(c, size, connection);
|
||||
CGContextEOFillPath(c);
|
||||
}
|
||||
|
||||
/// Draw RSS icon with orange gradient background
|
||||
static void RoundedRSS_Gradient(CGRect r, NSColor *color) {
|
||||
const CGFloat size = ShorterSide(r.size);
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
CGContextSetFillColorWithColor(c, NSColor.whiteColor.CGColor);
|
||||
// background rounded rect
|
||||
svgRoundedRect(c, 1, r, size * 0.4/2);
|
||||
// Gradient
|
||||
CGContextSaveGState(c);
|
||||
CGContextClip(c);
|
||||
DrawGradient(c, size, color);
|
||||
CGContextRestoreGState(c);
|
||||
// Bars
|
||||
// RSS icon
|
||||
SetContentScale(c, r.size, 11/16.0);
|
||||
AddRSSIconPath(c, size, YES);
|
||||
CGContextEOFillPath(c);
|
||||
}
|
||||
|
||||
|
||||
|
||||
#pragma mark - Appearance Settings
|
||||
|
||||
|
||||
/// Draw icon representing global `status bar icon` (rounded RSS icon with neighbor items)
|
||||
static void Appearance_MenuBarIcon(CGRect r) {
|
||||
const CGFloat size = ShorterSide(r.size);
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
|
||||
// menu bar
|
||||
CGContextSetFillColorWithColor(c, [NSColor controlTextColor].CGColor);
|
||||
svgRect(c, 1, CGRectInset(r, 0, size * 2/16));
|
||||
CGContextFillPath(c);
|
||||
|
||||
// neighbors
|
||||
const CGFloat offset = round(size*.75);
|
||||
const CGFloat iconInset = round(size*.2);
|
||||
const CGFloat iconCorner = size*.12;
|
||||
CGContextSetAlpha(c, .66);
|
||||
CGContextSetFillColorWithColor(c, [NSColor controlBackgroundColor].CGColor);
|
||||
|
||||
// left neighbor
|
||||
CGContextTranslateCTM(c, -offset, 0);
|
||||
svgRoundedRect(c, 1, CGRectInset(r, iconInset, iconInset), iconCorner);
|
||||
CGContextFillPath(c);
|
||||
|
||||
// right neighbor
|
||||
CGContextTranslateCTM(c, +2*offset, 0);
|
||||
svgRoundedRect(c, 1, CGRectInset(r, iconInset, iconInset), iconCorner);
|
||||
CGContextFillPath(c);
|
||||
|
||||
// main icon
|
||||
CGContextSetAlpha(c, 1);
|
||||
CGContextTranslateCTM(c, -offset, 0);
|
||||
svgRoundedRect(c, 1, CGRectInset(r, iconInset, iconInset), iconCorner);
|
||||
SetContentScale(c, r.size, 7/16.0);
|
||||
AddRSSIconPath(c, size, YES);
|
||||
CGContextEOFillPath(c);
|
||||
}
|
||||
|
||||
/// Draw icon representing `Main Menu` (menu bar)
|
||||
static void Appearance_MainMenu(CGRect r) {
|
||||
const CGFloat size = ShorterSide(r.size);
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
CGContextSetFillColorWithColor(c, [NSColor controlTextColor].CGColor);
|
||||
// menu
|
||||
svgRect(c, size/16, CGRectMake(0, 0, 16, 3));
|
||||
svgRect(c, size/16, CGRectMake(5, 4, 9, 12));
|
||||
svgRect(c, size/16, CGRectMake(6, 3, 7, 12));
|
||||
// entries
|
||||
svgRect(c, size/16, CGRectMake(6, 12, 6, 1));
|
||||
svgRect(c, size/16, CGRectMake(6, 9, 6, 1));
|
||||
svgRect(c, size/16, CGRectMake(6, 6, 6, 1));
|
||||
CGContextEOFillPath(c);
|
||||
}
|
||||
|
||||
/// Draw icon representing `FeedGroup` (folder)
|
||||
static void Appearance_Group(CGRect r, BOOL withLine) {
|
||||
const CGFloat size = ShorterSide(r.size);
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
// folder path
|
||||
svgPath(c, size/16, "M3,13.5c-1.5,0-2.5-1-2.5-2.5V3.5c0-1.5.5-2,2-2h1.5c1.5,0,1.5,1,3,1h6c1.5,0,2.5,1,2.5,2.5v6c0,1.5-1,2.5-2.5,2.5H3Z");
|
||||
// line
|
||||
if (withLine) {
|
||||
svgPath(c, size/16, "M1.5,5h13Z");
|
||||
}
|
||||
CGContextSetLineWidth(c, size * 1/16);
|
||||
CGContextSetStrokeColorWithColor(c, [NSColor controlTextColor].CGColor);
|
||||
CGContextStrokePath(c);
|
||||
}
|
||||
|
||||
/// Draw icon representing `Feed` (group + RSS)
|
||||
static void Appearance_Feed(CGRect r) {
|
||||
const CGFloat size = ShorterSide(r.size);
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
CGContextSetFillColorWithColor(c, [NSColor controlTextColor].CGColor);
|
||||
// folder
|
||||
Appearance_Group(r, NO);
|
||||
// rss icon
|
||||
SetContentScale(c, r.size, 7/16.0);
|
||||
AddRSSIconPath(c, size, YES);
|
||||
CGContextFillPath(c);
|
||||
}
|
||||
|
||||
/// Draw icon representing `Article` (RSS inside text document)
|
||||
static void Appearance_Article(CGRect r) {
|
||||
const CGFloat size = ShorterSide(r.size);
|
||||
CGContextRef c = NSGraphicsContext.currentContext.CGContext;
|
||||
CGContextSetFillColorWithColor(c, [NSColor controlTextColor].CGColor);
|
||||
// text lines
|
||||
svgRect(c, size/16, CGRectMake(0, 14, 16, 1));
|
||||
svgRect(c, size/16, CGRectMake(0, 10, 16, 1));
|
||||
svgRect(c, size/16, CGRectMake(9, 6, 7, 1));
|
||||
svgRect(c, size/16, CGRectMake(9, 2, 7, 1));
|
||||
// picture
|
||||
//svgRect(c, size/16, CGRectMake(1, 1, 7, 7));
|
||||
// RSS icon
|
||||
CGContextTranslateCTM(c, size/16 * 1, size/16 * 1); // same offset as picture
|
||||
CGContextScaleCTM(c, 7/16.0, 7/16.0); // same size as picture
|
||||
AddRSSIconPath(c, size, YES);
|
||||
CGContextEOFillPath(c);
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Other Icons
|
||||
|
||||
|
||||
/// Draw unread icon (blue dot for unread menu item)
|
||||
static void DrawUnreadIcon(CGRect r, NSColor *color) {
|
||||
CGFloat size = ShorterSide(r.size) / 2.0;
|
||||
const CGFloat radius = 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
|
||||
CGContextTranslateCTM(c, 0, radius * -0.15); // align with baseline of menu item text
|
||||
|
||||
// outer ring (opaque)
|
||||
CGContextSetFillColorWithColor(c, color.CGColor);
|
||||
PathAddRing(path, size, size * 0.7);
|
||||
CGPathAddArc(path, NULL, radius, radius, radius, 0, M_PI * 2, YES);
|
||||
CGPathAddArc(path, NULL, radius, radius, radius*.7, 0, M_PI * -2, YES);
|
||||
CGContextAddPath(c, path);
|
||||
CGContextEOFillPath(c);
|
||||
|
||||
// inner circle (translucent)
|
||||
CGContextSetFillColorWithColor(c, [color colorWithAlphaComponent:0.5].CGColor);
|
||||
PathAddCircle(path, size);
|
||||
CGPathAddArc(path, NULL, radius, radius, radius, 0, M_PI * 2, YES);
|
||||
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;
|
||||
|
||||
// background
|
||||
CGContextSetFillColorWithColor(c, NSColor.redColor.CGColor);
|
||||
svgRoundedRect(c, 1, r, size * 0.4/2);
|
||||
CGContextFillPath(c);
|
||||
|
||||
// foreground
|
||||
CGContextSetFillColorWithColor(c, NSColor.whiteColor.CGColor);
|
||||
SetContentScale(c, r.size, 25/32.0);
|
||||
// "("
|
||||
svgPath(c, size/100, "M18,19c-14,21-13,43,0,62l-7,4C-4,63-4,35,12,14l6,5Z");
|
||||
// "."
|
||||
svgCircle(c, size/100, 31, 67, 7, NO);
|
||||
// "*"
|
||||
svgPath(c, size/100, "M65,28l11-4,2,6-11,4,7,9-5,4-7-9-7,9-5-4,7-9-11-4,2-6,11,4v-11h6v11Z");
|
||||
// ")"
|
||||
svgPath(c, size/100, "M82,81c14-21,13-43,0-62l7-5c16,22,15,50,0,71l-7-4Z");
|
||||
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];
|
||||
NSImage *img = [NSImage imageWithSize: NSMakeSize(size, size) flipped:YES 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; });
|
||||
// Default feed icon (fallback icon if no favicon found)
|
||||
Register(16, RSSImageDefaultRSSIcon, NSLocalizedString(@"Default feed icon", nil), ^(NSRect r) { RoundedRSS_Gradient(r, [NSColor rssOrange]); return YES; });
|
||||
// Menu bar icon
|
||||
Register(16, RSSImageMenuBarIconActive, NSLocalizedString(@"Menu bar icon", nil), ^(NSRect r) { RoundedRSS_Monochrome(r, YES); return YES; });
|
||||
Register(16, RSSImageMenuBarIconPaused, NSLocalizedString(@"Menu bar icon, paused", nil), ^(NSRect r) { RoundedRSS_Monochrome(r, NO); return YES; });
|
||||
// Appearance settings
|
||||
Register(16, RSSImageSettingsGlobalIcon, NSLocalizedString(@"Global settings, menu bar icon", nil), ^(NSRect r) { Appearance_MenuBarIcon(r); return YES; });
|
||||
Register(16, RSSImageSettingsGlobalMenu, NSLocalizedString(@"Global settings, main menu", nil), ^(NSRect r) { Appearance_MainMenu(r); return YES; });
|
||||
Register(16, RSSImageSettingsGroup, NSLocalizedString(@"Group settings", nil), ^(NSRect r) { Appearance_Group(r, YES); return YES; });
|
||||
Register(16, RSSImageSettingsFeed, NSLocalizedString(@"Feed settings", nil), ^(NSRect r) { Appearance_Feed(r); return YES; });
|
||||
Register(16, RSSImageSettingsArticle, NSLocalizedString(@"Article settings", nil), ^(NSRect r) { Appearance_Article(r); return YES; });
|
||||
// Other settings
|
||||
Register(14, RSSImageMenuItemUnread, NSLocalizedString(@"Unread indicator", nil), ^(NSRect r) { DrawUnreadIcon(r, [NSColor unreadIndicatorColor]); return YES; });
|
||||
Register(32, RSSImageRegexIcon, NSLocalizedString(@"Regex icon", nil), ^(NSRect r) { DrawRegexIcon(r); return YES; });
|
||||
}
|
||||
|
||||
6
baRSS/Helper/StrictUIntFormatter.h
Normal file
@@ -0,0 +1,6 @@
|
||||
@import Cocoa;
|
||||
|
||||
@interface StrictUIntFormatter : NSFormatter
|
||||
/// Note: must contain `%ld` and is used as formatter string.
|
||||
@property (nullable, copy) NSString *unit;
|
||||
@end
|
||||
40
baRSS/Helper/StrictUIntFormatter.m
Normal file
@@ -0,0 +1,40 @@
|
||||
#import "StrictUIntFormatter.h"
|
||||
|
||||
@implementation StrictUIntFormatter
|
||||
/// Display object as integer formatted string.
|
||||
- (NSString *)stringForObjectValue:(id)obj {
|
||||
NSString *str = [NSString stringWithFormat:@"%@", obj];
|
||||
if (str.length == 0)
|
||||
return @"";
|
||||
if (self.unit)
|
||||
return [NSString stringWithFormat:self.unit, [str integerValue]];
|
||||
return [NSString stringWithFormat:@"%ld", [str integerValue]];
|
||||
}
|
||||
|
||||
- (NSString *)editingStringForObjectValue:(id)obj {
|
||||
NSString *str = [NSString stringWithFormat:@"%@", obj];
|
||||
if (str.length == 0)
|
||||
return @"";
|
||||
return [NSString stringWithFormat:@"%ld", [str integerValue]];
|
||||
}
|
||||
|
||||
/// Parse any pasted input as integer.
|
||||
- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error {
|
||||
if (string.length == 0) {
|
||||
*obj = @"";
|
||||
} else {
|
||||
*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
|
||||
|
||||
6
baRSS/Helper/TinySVG.h
Normal file
@@ -0,0 +1,6 @@
|
||||
@import Cocoa;
|
||||
|
||||
void svgPath(CGContextRef context, CGFloat scale, const char * path);
|
||||
void svgCircle(CGContextRef context, CGFloat scale, CGFloat x, CGFloat y, CGFloat radius, bool clockwise);
|
||||
void svgRoundedRect(CGContextRef context, CGFloat scale, CGRect rect, CGFloat cornerRadius);
|
||||
void svgRect(CGContextRef context, CGFloat scale, CGRect rect);
|
||||
187
baRSS/Helper/TinySVG.m
Normal file
@@ -0,0 +1,187 @@
|
||||
#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 == 'Q' && state->iNum == 4) {
|
||||
state->x = state->num[2];
|
||||
state->y = state->num[3];
|
||||
CGPathAddQuadCurveToPoint(path, NULL, state->num[0] * state->scale, state->num[1] * state->scale, 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 == 4 && strchr("Qq", 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper method to scale `rect` according to svg size.
|
||||
static inline CGRect scaledRect(CGRect rect, CGFloat scale) {
|
||||
if (scale == 1.0) { return rect; }
|
||||
return CGRectMake(rect.origin.x * scale, rect.origin.y * scale, rect.size.width * scale, rect.size.height * scale);
|
||||
}
|
||||
|
||||
|
||||
# pragma mark - External API
|
||||
|
||||
/// calls @c tinySVG_path and handles @c CGPath creation and release.
|
||||
void svgPath(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 svgCircle(CGContextRef context, CGFloat scale, CGFloat x, CGFloat y, CGFloat radius, bool clockwise) {
|
||||
// No `CGContextAddArc` because that doesnt work well with overlapping counter-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 CGPathAddRoundedRect
|
||||
/// @param cornerRadius Use half of @c min(w,h) for a full circle.
|
||||
void svgRoundedRect(CGContextRef context, CGFloat scale, CGRect rect, CGFloat cornerRadius) {
|
||||
CGMutablePathRef tmp = CGPathCreateMutable();
|
||||
CGPathAddRoundedRect(tmp, NULL, scaledRect(rect, scale), cornerRadius * scale, cornerRadius * scale);
|
||||
CGContextAddPath(context, tmp);
|
||||
CGPathRelease(tmp);
|
||||
}
|
||||
|
||||
/// Calls @c CGContextAddRect
|
||||
void svgRect(CGContextRef context, CGFloat scale, CGRect rect) {
|
||||
CGContextAddRect(context, scaledRect(rect, scale));
|
||||
}
|
||||
@@ -13,32 +13,37 @@
|
||||
/** 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 NO */ static NSString* const Pref_globalUnreadOnly = @"globalUnreadOnly";
|
||||
/** 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";
|
||||
// menu buttons
|
||||
/** default: @c NO */ static NSString* const Pref_globalToggleHidden = @"globalToggleHidden";
|
||||
/** 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_groupOpenUnread = @"groupOpenUnread";
|
||||
/** default: @c YES */ static NSString* const Pref_feedOpenUnread = @"feedOpenUnread";
|
||||
/** default: @c YES */ static NSString* const Pref_globalMarkRead = @"globalMarkRead";
|
||||
/** default: @c YES */ static NSString* const Pref_groupMarkRead = @"groupMarkRead";
|
||||
/** default: @c YES */ static NSString* const Pref_feedMarkRead = @"feedMarkRead";
|
||||
/** default: @c YES */ static NSString* const Pref_globalMarkUnread = @"globalMarkUnread";
|
||||
/** default: @c YES */ static NSString* const Pref_groupMarkUnread = @"groupMarkUnread";
|
||||
/** default: @c YES */ static NSString* const Pref_feedMarkUnread = @"feedMarkUnread";
|
||||
// display options
|
||||
/** default: @c YES */ static NSString* const Pref_globalUnreadCount = @"globalUnreadCount";
|
||||
/** default: @c YES */ static NSString* const Pref_groupUnreadCount = @"groupUnreadCount";
|
||||
/** default: @c YES */ static NSString* const Pref_feedUnreadCount = @"feedUnreadCount";
|
||||
/** default: @c YES */ static NSString* const Pref_globalTintMenuIcon = @"globalTintMenuBarIcon";
|
||||
/** default: @c NO */ static NSString* const Pref_groupUnreadIndicator = @"groupUnreadIndicator";
|
||||
/** default: @c NO */ static NSString* const Pref_feedUnreadIndicator = @"feedUnreadIndicator";
|
||||
/** default: @c YES */ static NSString* const Pref_articleUnreadIndicator = @"articleUnreadIndicator";
|
||||
/** default: @c NO */ static NSString* const Pref_groupUnreadOnly = @"groupUnreadOnly";
|
||||
/** default: @c NO */ static NSString* const Pref_feedUnreadOnly = @"feedUnreadOnly";
|
||||
/** default: @c NO */ static NSString* const Pref_articleUnreadOnly = @"articleUnreadOnly";
|
||||
// article display
|
||||
/** default: @c -1 */ static NSString* const Pref_articleCountLimit = @"articleCountLimit";
|
||||
/** default: @c -1 */ static NSString* const Pref_articleTitleLimit = @"articleTitleLimit";
|
||||
/** default: @c 2k */ static NSString* const Pref_articleTooltipLimit = @"articleTooltipLimit";
|
||||
// ------ 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";
|
||||
|
||||
@@ -49,6 +54,16 @@
|
||||
|
||||
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]; }
|
||||
@@ -71,7 +86,7 @@ static inline void UserPrefsSetBool(NSString* const key, BOOL value) { [[NSUserD
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// Helper method calls @c (mainBundle)CFBundleShortVersionString
|
||||
static inline NSString* UserPrefsAppVersion() { return [[NSBundle mainBundle] infoDictionary][@"CFBundleShortVersionString"]; }
|
||||
static inline NSString* UserPrefsAppVersion(void) { return [[NSBundle mainBundle] infoDictionary][@"CFBundleShortVersionString"]; }
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// | MARK: - Open URLs
|
||||
|
||||
@@ -17,18 +17,18 @@ void UserPrefsInit(void) {
|
||||
Pref_globalMarkRead, Pref_groupMarkRead, Pref_feedMarkRead,
|
||||
Pref_globalMarkUnread, Pref_groupMarkUnread, Pref_feedMarkUnread,
|
||||
Pref_globalUnreadCount, Pref_groupUnreadCount, Pref_feedUnreadCount,
|
||||
Pref_feedUnreadIndicator
|
||||
Pref_articleUnreadIndicator
|
||||
]);
|
||||
defaultsAppend(defs, @NO, @[
|
||||
Pref_globalUnreadOnly, Pref_groupUnreadOnly, Pref_feedUnreadOnly,
|
||||
Pref_groupUnreadIndicator,
|
||||
Pref_feedTruncateTitle,
|
||||
Pref_feedLimitArticles
|
||||
Pref_globalToggleHidden,
|
||||
Pref_groupUnreadOnly, Pref_feedUnreadOnly, Pref_articleUnreadOnly,
|
||||
Pref_groupUnreadIndicator, Pref_feedUnreadIndicator,
|
||||
]);
|
||||
// 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 numberWithInteger:-1] forKey:Pref_articleCountLimit];
|
||||
[defs setObject:[NSNumber numberWithInteger:-1] forKey:Pref_articleTitleLimit];
|
||||
[defs setObject:[NSNumber numberWithInteger:2000] forKey:Pref_articleTooltipLimit];
|
||||
[defs setObject:[NSNumber numberWithUnsignedInteger:1] forKey:Pref_prefSelectedTab]; // feed tab
|
||||
[[NSUserDefaults standardUserDefaults] registerDefaults:defs];
|
||||
}
|
||||
@@ -44,3 +44,22 @@ NSColor* UserPrefsColor(NSString *key, NSColor *defaultColor) {
|
||||
}
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -70,7 +70,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>14698</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.news</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
@@ -83,7 +83,7 @@
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2020 relikd.</string>
|
||||
<string>Copyright © 2025 relikd.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>AppHook</string>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
if (last != '\n') {
|
||||
[result appendString:@"\n"];
|
||||
}
|
||||
if (order > 0) [result appendFormat:@" %d. ", order++];
|
||||
else [result appendString:@" • "];
|
||||
if (order > 0) [result appendFormat:@" %d. ", order++];
|
||||
else [result appendString:@" • "];
|
||||
}
|
||||
} else {
|
||||
// append text inbetween tags
|
||||
@@ -74,7 +74,10 @@
|
||||
// collapsing multiple horizontal whitespaces (\h) into one (the first one)
|
||||
[[NSRegularExpression regularExpressionWithPattern:@"(\\h)[\\h]+" options:0 error:nil]
|
||||
replaceMatchesInString:result options:0 range:NSMakeRange(0, result.length) withTemplate:@"$1"];
|
||||
return [result stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
|
||||
NSMutableCharacterSet *cs = NSMutableCharacterSet.whitespaceAndNewlineCharacterSet;
|
||||
[cs removeCharactersInString:@" "]; // used for "li"
|
||||
return [result stringByTrimmingCharactersInSet:cs];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ static inline CGFloat NSMaxWidth(NSView *a, NSView *b) { return Max(NSWidth(a.fr
|
||||
// UI: TextFields
|
||||
+ (NSTextField*)label:(NSString*)text;
|
||||
+ (NSTextField*)inputField:(NSString*)placeholder width:(CGFloat)w;
|
||||
+ (NSTextField*)integerField:(NSString*)placeholder unit:(nullable NSString*)unit width:(CGFloat)w;
|
||||
+ (NSView*)labelColumn:(NSArray<NSString*>*)labels rowHeight:(CGFloat)h padding:(CGFloat)pad;
|
||||
// UI: Buttons
|
||||
+ (NSButton*)button:(NSString*)text;
|
||||
@@ -52,7 +53,7 @@ static inline CGFloat NSMaxWidth(NSView *a, NSView *b) { return Max(NSWidth(a.fr
|
||||
+ (nullable NSView*)radioGroup:(NSArray<NSString*>*)entries;
|
||||
// UI: Enclosing Container
|
||||
+ (NSPopover*)popover:(NSSize)size;
|
||||
- (NSScrollView*)wrapContent:(NSView*)content inScrollView:(NSRect)rect;
|
||||
- (NSScrollView*)wrapInScrollView:(NSSize)size;
|
||||
+ (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;
|
||||
@@ -60,7 +61,10 @@ static inline CGFloat NSMaxWidth(NSView *a, NSView *b) { return Max(NSWidth(a.fr
|
||||
- (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)alignTop;
|
||||
- (instancetype)alignRight;
|
||||
- (instancetype)sizableWidthAndHeight;
|
||||
- (instancetype)sizableWidth;
|
||||
- (instancetype)sizeToRight:(CGFloat)rightPadding;
|
||||
- (instancetype)sizeWidthToFit;
|
||||
- (instancetype)tooltip:(NSString*)tt;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#import "NSView+Ext.h"
|
||||
#import "StrictUIntFormatter.h"
|
||||
|
||||
@implementation NSView (Ext)
|
||||
|
||||
@@ -27,6 +28,15 @@
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Create input text field which only accepts integer values. (calls `inputField`) `21px` height.
|
||||
/// `field.formatter` is of type `StrictUIntFormatter`.
|
||||
+ (NSTextField*)integerField:(NSString*)placeholder unit:(nullable NSString*)unit width:(CGFloat)w {
|
||||
NSTextField *input = [self inputField:placeholder width:w];
|
||||
input.formatter = [StrictUIntFormatter new];
|
||||
((StrictUIntFormatter*)input.formatter).unit = unit;
|
||||
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;
|
||||
@@ -69,6 +79,11 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -165,17 +180,19 @@
|
||||
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];
|
||||
/// Removes `self` from current view (if already added) and sets `documentView` content for the newly created scroll view.
|
||||
/// You are responsible for adding this scroll view to the view hierarchy.
|
||||
- (NSScrollView*)wrapInScrollView:(NSSize)size {
|
||||
NSScrollView *scroll = [[[NSScrollView alloc] initWithFrame:CGRectMake(0, 0, size.width, size.height)] 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;
|
||||
if (self.superview) [self removeFromSuperview]; // remove if added already (e.g., helper methods above)
|
||||
if (self.frame.size.width == 0 && self.frame.size.height == 0) {
|
||||
self.frame = NSMakeRect(0, 0, scroll.contentSize.width, scroll.contentSize.height);
|
||||
}
|
||||
scroll.documentView = self;
|
||||
return scroll;
|
||||
}
|
||||
|
||||
@@ -252,6 +269,9 @@
|
||||
/// Modify @c .autoresizingMask; Add @c NSViewWidthSizable @c | @c NSViewHeightSizable flags
|
||||
- (instancetype)sizableWidthAndHeight { self.autoresizingMask |= NSViewWidthSizable | NSViewHeightSizable; return self; }
|
||||
|
||||
/// Modify @c .autoresizingMask; Add @c NSViewWidthSizable flags
|
||||
- (instancetype)sizableWidth { self.autoresizingMask |= NSViewWidthSizable; 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);
|
||||
@@ -268,10 +288,12 @@
|
||||
/// 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
|
||||
if ([self isKindOfClass:[NSTextField class]] && ((NSTextField*)self).editable == NO) {
|
||||
// a label already shows text, so the tooltip will probably be extended information.
|
||||
self.accessibilityHelp = tt;
|
||||
} else {
|
||||
self.accessibilityValueDescription = tt;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
|
||||
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,7 +4,7 @@
|
||||
@implementation SettingsAboutView
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super initWithFrame: NSZeroRect];
|
||||
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)'
|
||||
@@ -24,7 +24,7 @@
|
||||
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)];
|
||||
[[tv wrapInScrollView:NSMakeSize(NSWidth(self.frame) + 2, NSMinY(lblV.frame) - PAD_M - 20)] placeIn:self x:-1 y:20];
|
||||
return self;
|
||||
}
|
||||
|
||||
@@ -33,16 +33,16 @@
|
||||
NSMutableAttributedString *mas = [NSMutableAttributedString new];
|
||||
[mas beginEditing];
|
||||
[self str:mas add:@"Programming\n" bold:YES];
|
||||
[self str:mas add:@"Oleg Geier\n\n" bold:NO];
|
||||
[self str:mas add:@"Source Code Available\n" bold:YES];
|
||||
[self str:mas add:@"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)\nor " bold:NO];
|
||||
[self str:mas add:@"gitlab.com" link:@"https://gitlab.com/relikd/baRSS"];
|
||||
[self str:mas add:@" (MIT License)\n\n" bold:NO];
|
||||
[self str:mas add:@"3rd-Party Libraries\n" bold:YES];
|
||||
[self str:mas add:@" (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)" bold:NO];
|
||||
[self str:mas add:@"\n\n\n\nOptions\n" bold:YES];
|
||||
[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];
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
- (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];
|
||||
NSScrollView *scroll = self.view.subviews[0];
|
||||
NSView *contentView = scroll.documentView.subviews[0];
|
||||
for (NSControl *control in contentView.subviews) {
|
||||
if ([control isKindOfClass:[NSButton class]]) { // for all checkboxes
|
||||
[control setAction:@selector(didSelectCheckbox:)];
|
||||
[control setTarget:self];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import Cocoa;
|
||||
@class SettingsAppearance;
|
||||
|
||||
@interface SettingsAppearanceView : NSView
|
||||
@interface SettingsAppearanceView : NSView <NSTextFieldDelegate>
|
||||
@end
|
||||
|
||||
|
||||
@@ -2,63 +2,264 @@
|
||||
#import "NSView+Ext.h"
|
||||
#import "Constants.h" // column icons
|
||||
#import "UserPrefs.h" // preference constants & UserPrefsBool()
|
||||
#import "DrawImage.h" // DrawSeparator
|
||||
|
||||
@interface FlippedView : NSView @end
|
||||
@implementation FlippedView
|
||||
- (BOOL)isFlipped { return YES; }
|
||||
@end
|
||||
|
||||
|
||||
@interface SettingsAppearanceView()
|
||||
@property (assign) CGFloat y;
|
||||
@property (assign) NSView *content;
|
||||
@property (strong) NSMutableArray<NSString*> *columns;
|
||||
@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;
|
||||
/***/ static CGFloat const X___ = PAD_WIN + 0 * colWidth;
|
||||
/***/ static CGFloat const _X__ = PAD_WIN + 1 * colWidth;
|
||||
/***/ static CGFloat const __X_ = PAD_WIN + 2 * colWidth;
|
||||
/***/ static CGFloat const ___X = PAD_WIN + 3 * colWidth;
|
||||
/***/ static CGFloat const lbl_start = PAD_WIN + 4 * colWidth;
|
||||
|
||||
@implementation SettingsAppearanceView
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super initWithFrame: NSZeroRect];
|
||||
// Insert matrix header (icons above checkbox matrix)
|
||||
ColumnIcon(self, X__, RSSImageSettingsGlobal, NSLocalizedString(@"Show in menu bar", nil));
|
||||
ColumnIcon(self, _X_, RSSImageSettingsGroup, NSLocalizedString(@"Show in group menu", nil));
|
||||
ColumnIcon(self, __X, RSSImageSettingsFeed, NSLocalizedString(@"Show in feed menu", nil));
|
||||
// Generate checkbox matrix
|
||||
self.y = PAD_WIN + IconSize + PAD_S;
|
||||
[self entry:NSLocalizedString(@"Tint menu bar icon on unread", nil) c1:Pref_globalTintMenuIcon c2:nil c3:nil];
|
||||
[self entry:NSLocalizedString(@"Update all feeds", nil) c1:Pref_globalUpdateAll c2:nil c3:nil];
|
||||
[self entry:NSLocalizedString(@"Open all unread", nil) c1:Pref_globalOpenUnread c2:Pref_groupOpenUnread c3:Pref_feedOpenUnread];
|
||||
[self entry:NSLocalizedString(@"Mark all read", nil) c1:Pref_globalMarkRead c2:Pref_groupMarkRead c3:Pref_feedMarkRead];
|
||||
[self entry:NSLocalizedString(@"Mark all unread", nil) c1:Pref_globalMarkUnread c2:Pref_groupMarkUnread c3:Pref_feedMarkUnread];
|
||||
[self entry:NSLocalizedString(@"Show only unread / hide read", nil) c1:Pref_globalUnreadOnly c2:Pref_groupUnreadOnly c3:Pref_feedUnreadOnly];
|
||||
[self entry:NSLocalizedString(@"Number of unread articles", nil) c1:Pref_globalUnreadCount c2:Pref_groupUnreadCount c3:Pref_feedUnreadCount];
|
||||
[self entry:NSLocalizedString(@"Indicator for unread articles", nil) c1:nil c2:Pref_groupUnreadIndicator c3:Pref_feedUnreadIndicator];
|
||||
[[self entry:NSLocalizedString(@"Truncate article title", nil) c1:nil c2:nil c3:Pref_feedTruncateTitle]
|
||||
tooltip:NSLocalizedString(@"Truncate article title after 60 characters", nil)];
|
||||
[[self entry:NSLocalizedString(@"Limit number of articles", nil) c1:nil c2:nil c3:Pref_feedLimitArticles]
|
||||
tooltip:NSLocalizedString(@"Display at most 40 articles in feed menu", nil)];
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 320, 327)];
|
||||
self.y = PAD_WIN;
|
||||
// stupidly complex, nested UI just because you cant top-align `.documentView`
|
||||
// View is 0.5px shorter than self.frame because it will otherwise add a transparency to the TabBar
|
||||
NSScrollView *scroll = [[[FlippedView new] wrapInScrollView:NSMakeSize(320, 326.5)] placeIn:self x:0 y:0];
|
||||
self.content = [[[NSView alloc] initWithFrame:scroll.documentView.frame] placeIn:scroll.documentView x:0 y:0];
|
||||
scroll.borderType = NSNoBorder;
|
||||
// fix default window background color instead of pure black/white
|
||||
scroll.drawsBackground = NO;
|
||||
|
||||
[self note:NSLocalizedString(@"Hover over the options for additional explanations and usage tips.", nil)];
|
||||
|
||||
|
||||
// Menu Buttons
|
||||
|
||||
[self section:NSLocalizedString(@"Menu buttons", nil)];
|
||||
[self columns:@[
|
||||
RSSImageSettingsGlobalMenu, NSLocalizedString(@"Main menu", nil),
|
||||
RSSImageSettingsGroup, NSLocalizedString(@"Group menu", nil),
|
||||
RSSImageSettingsFeed, NSLocalizedString(@"Feed menu", nil),
|
||||
]];
|
||||
|
||||
[self entry:NSLocalizedString(@"“Show hidden feeds”", nil)
|
||||
help:NSLocalizedString(@"Show button to quickly toggle whether hidden entries should be shown. See option “Show only unread”.", nil)
|
||||
tip:NSLocalizedString(@"You can hold down option-key before opening the main menu to temporarily show all hidden entries.", nil)
|
||||
c1:Pref_globalToggleHidden c2:nil c3:nil c4:nil];
|
||||
|
||||
[self entry:NSLocalizedString(@"“Update all feeds”", nil)
|
||||
help:NSLocalizedString(@"Show button to reload all feeds. This will force fetch new online content regardless of next-update timer.", nil)
|
||||
tip:nil
|
||||
c1:Pref_globalUpdateAll c2:nil c3:nil c4:nil];
|
||||
|
||||
[self entry:NSLocalizedString(@"“Open all unread”", nil)
|
||||
help:NSLocalizedString(@"Show button to open unread articles.", nil)
|
||||
tip:nil
|
||||
c1:Pref_globalOpenUnread c2:Pref_groupOpenUnread c3:Pref_feedOpenUnread c4:nil];
|
||||
|
||||
[self entry:NSLocalizedString(@"“Mark all read”", nil)
|
||||
help:NSLocalizedString(@"Show button to mark articles read.", nil)
|
||||
tip:nil
|
||||
c1:Pref_globalMarkRead c2:Pref_groupMarkRead c3:Pref_feedMarkRead c4:nil];
|
||||
|
||||
[self entry:NSLocalizedString(@"“Mark all unread”", nil)
|
||||
help:NSLocalizedString(@"Show button to mark articles unread.", nil)
|
||||
tip:NSLocalizedString(@"Alternatively, you can hold down option-key and click on an article to toggle that item (un-)read.", nil)
|
||||
c1:Pref_globalMarkUnread c2:Pref_groupMarkUnread c3:Pref_feedMarkUnread c4:nil];
|
||||
|
||||
// self.y += PAD_M;
|
||||
[self intInput:Pref_openFewLinksLimit
|
||||
unit:NSLocalizedString(@"%ld unread", nil)
|
||||
label:NSLocalizedString(@"“Open a few unread” ⌥", nil)
|
||||
help:NSLocalizedString(@"If you hold down option-key, the “Open all unread” button becomes an “Open a few unread” button.", nil)];
|
||||
|
||||
// self.y += PAD_M;
|
||||
// [self note:NSLocalizedString(@"Hold down option-key and click on an article to toggle that item (un-)read.", nil)];
|
||||
|
||||
|
||||
// Display options
|
||||
|
||||
[self section:NSLocalizedString(@"Display options", nil)];
|
||||
[self columns:@[
|
||||
RSSImageSettingsGlobalIcon, NSLocalizedString(@"Menu bar icon", nil),
|
||||
RSSImageSettingsGroup, NSLocalizedString(@"Group menu item", nil),
|
||||
RSSImageSettingsFeed, NSLocalizedString(@"Feed menu item", nil),
|
||||
RSSImageSettingsArticle, NSLocalizedString(@"Article menu item", nil),
|
||||
]];
|
||||
|
||||
[self entry:NSLocalizedString(@"Number of unread articles", nil)
|
||||
help:NSLocalizedString(@"Show count of unread articles in parenthesis.", nil)
|
||||
tip:nil
|
||||
c1:Pref_globalUnreadCount c2:Pref_groupUnreadCount c3:Pref_feedUnreadCount c4:nil];
|
||||
|
||||
[self entry:NSLocalizedString(@"Color for unread articles", nil)
|
||||
help:NSLocalizedString(@"Show color marker on menu items with unread articles.", nil)
|
||||
tip:nil
|
||||
c1:Pref_globalTintMenuIcon c2:Pref_groupUnreadIndicator c3:Pref_feedUnreadIndicator c4:Pref_articleUnreadIndicator];
|
||||
|
||||
[self entry:NSLocalizedString(@"Show only unread", nil)
|
||||
help:NSLocalizedString(@"Hide articles which have been read.", nil)
|
||||
tip:nil
|
||||
c1:nil c2:Pref_groupUnreadOnly c3:Pref_feedUnreadOnly c4:Pref_articleUnreadOnly];
|
||||
|
||||
// self.y += PAD_M;
|
||||
// [self note:NSLocalizedString(@"Hold down option-key before opening the main menu to temporarily show hidden feeds.", nil)];
|
||||
|
||||
|
||||
// Other UI elements
|
||||
|
||||
[self section:NSLocalizedString(@"Article display", nil)];
|
||||
|
||||
[self intInput:Pref_articleCountLimit
|
||||
unit:NSLocalizedString(@"%ld entries", nil)
|
||||
label:NSLocalizedString(@"Limit number of articles", nil)
|
||||
help:NSLocalizedString(@"Display at most X articles in feed menu. Remaining articles will be hidden from view but are still there. Unread count may be confusing because hidden articles are counted too.", nil)];
|
||||
|
||||
[self intInput:Pref_articleTitleLimit
|
||||
unit:NSLocalizedString(@"%ld chars", nil)
|
||||
label:NSLocalizedString(@"Truncate article title", nil)
|
||||
help:NSLocalizedString(@"Truncate article title after X characters. If a title is longer than that, show an ellipsis character “…”.", nil)];
|
||||
|
||||
[self intInput:Pref_articleTooltipLimit
|
||||
unit:NSLocalizedString(@"%ld chars", nil)
|
||||
label:NSLocalizedString(@"Truncate article tooltip", nil)
|
||||
help:NSLocalizedString(@"Truncate article tooltip after X characters. This tooltip shows the whole article content (if provided by the server).", nil)];
|
||||
|
||||
self.y += PAD_WIN;
|
||||
|
||||
// sest final view size
|
||||
[[self.content sizableWidth] setFrameSize:NSMakeSize(NSWidth(self.content.frame), self.y)];
|
||||
[[scroll.documentView sizableWidth] setFrame:self.content.frame];
|
||||
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];
|
||||
|
||||
// MARK: - Section Header
|
||||
|
||||
|
||||
- (void)section:(NSString*)title {
|
||||
self.y += PAD_L;
|
||||
NSTextField *label = [[[NSView label:title] placeIn:self.content x:PAD_WIN yTop:self.y] large];
|
||||
[[[DrawSeparator withSize:NSMakeSize(100, NSHeight(label.frame))] placeIn:self.content x:NSMaxX(label.frame) + PAD_S yTop:self.y] sizeToRight:0];
|
||||
self.y += NSHeight(label.frame) + PAD_M;
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Column Icons
|
||||
|
||||
|
||||
/// Helper method for matrix table header icons
|
||||
- (void)columns:(NSArray<NSString*>*)columns {
|
||||
self.columns = [NSMutableArray arrayWithCapacity:4];
|
||||
for (NSUInteger i = 0; i < columns.count / 2; i++) {
|
||||
NSString *img = columns[i*2];
|
||||
NSString *ttip = columns[i*2 + 1];
|
||||
[[[NSView imageView:img size:IconSize] tooltip:ttip]
|
||||
placeIn:self.content x:PAD_WIN + i * colWidth yTop:self.y]
|
||||
.accessibilityLabel = NSLocalizedString(@"Column header:", nil);
|
||||
[self.columns addObject:ttip ? ttip : @""];
|
||||
}
|
||||
self.y += HEIGHT_INPUTFIELD + PAD_S;
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Notes
|
||||
|
||||
|
||||
- (void)note:(NSString*)text {
|
||||
NSTextField *lbl = [[[NSView label:text] multiline:NSMakeSize(320 - 2*PAD_WIN, 7 * HEIGHT_LABEL)] gray];
|
||||
NSSize bestSize = [lbl sizeThatFits:lbl.frame.size];
|
||||
[lbl setFrameSize:bestSize];
|
||||
[[lbl placeIn:self.content x:PAD_WIN yTop:self.y] sizeToRight:PAD_WIN];
|
||||
self.y += NSHeight(lbl.frame);
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Checkboxes
|
||||
|
||||
/// 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];
|
||||
static inline NSButton* Checkbox(SettingsAppearanceView *self, CGFloat x, NSString *key) {
|
||||
NSButton *check = [[NSView checkbox:UserPrefsBool(key)] placeIn:self.content x:x+2 yTop:self.y+2];
|
||||
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;
|
||||
- (NSTextField*)entry:(NSString*)label help:(NSString*)ttip tip:(NSString*)extraTip
|
||||
c1:(NSString*)pref1 c2:(NSString*)pref2 c3:(NSString*)pref3 c4:(NSString*)pref4
|
||||
{
|
||||
if (pref1) Checkbox(self, X___, pref1).accessibilityLabel = [NSString stringWithFormat:@"%@: %@", _columns[0], label];
|
||||
if (pref2) Checkbox(self, _X__, pref2).accessibilityLabel = [NSString stringWithFormat:@"%@: %@", _columns[1], label];
|
||||
if (pref3) Checkbox(self, __X_, pref3).accessibilityLabel = [NSString stringWithFormat:@"%@: %@", _columns[2], label];
|
||||
if (pref4) Checkbox(self, ___X, pref4).accessibilityLabel = [NSString stringWithFormat:@"%@: %@", _columns[3], label];
|
||||
if (extraTip != nil) {
|
||||
label = [label stringByAppendingString:@" 💡"];
|
||||
ttip = [ttip stringByAppendingFormat:@"\n\n💡 Tip: %@", extraTip];
|
||||
}
|
||||
NSTextField *lbl = [[[[NSView label:label] tooltip:ttip] placeIn:self.content x:lbl_start yTop:self.y] sizeToRight:PAD_WIN];
|
||||
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];
|
||||
return lbl;
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Int Input Field
|
||||
|
||||
|
||||
/// Create input field for integer numbers
|
||||
- (NSTextField*)intInput:(NSString*)pref unit:(NSString*)unit label:(NSString*)label help:(NSString*)ttip {
|
||||
// input field
|
||||
NSTextField *rv = [[NSView integerField:@"" unit:unit width:3 * colWidth + IconSize] placeIn:self.content x:PAD_WIN yTop:self.y];
|
||||
rv.placeholderString = NSLocalizedString(@"no limit", nil);
|
||||
// sadly, setting `accessibilityLabel` will break VoiceOver on empty input.
|
||||
// keep disabled so VoceOver will read the placeholder string if empty.
|
||||
rv.accessibilityLabel = label;
|
||||
rv.identifier = pref;
|
||||
rv.delegate = self;
|
||||
NSInteger val = UserPrefsInt(pref);
|
||||
if (val >= 0) {
|
||||
rv.stringValue = [NSString stringWithFormat:@"%ld", val];
|
||||
} else {
|
||||
rv.accessibilityValueDescription = rv.placeholderString;
|
||||
}
|
||||
// label
|
||||
[[[[NSView label:label] tooltip:ttip] placeIn:self.content x:lbl_start yTop:self.y + (HEIGHT_INPUTFIELD - HEIGHT_LABEL) / 2] sizeToRight:PAD_WIN];
|
||||
self.y += HEIGHT_INPUTFIELD + PAD_S;
|
||||
return rv;
|
||||
}
|
||||
|
||||
- (void)controlTextDidEndEditing:(NSNotification *)obj {
|
||||
NSTextField *sender = obj.object;
|
||||
NSString *pref = sender.identifier;
|
||||
|
||||
NSInteger newVal = sender.integerValue;
|
||||
BOOL isEmpty = newVal == 0 && sender.stringValue.length == 0;
|
||||
sender.accessibilityValueDescription = isEmpty ? sender.placeholderString : nil;
|
||||
UserPrefsSetInt(pref, isEmpty ? -1 : newVal);
|
||||
|
||||
BOOL hitReturn = [[obj.userInfo valueForKey:NSTextMovementUserInfoKey] integerValue] == NSTextMovementReturn;
|
||||
if (hitReturn) {
|
||||
// Allow to deselect NSTextField (when pressing enter to confirm change)
|
||||
[self.window performSelector:@selector(makeFirstResponder:) withObject:nil afterDelay:0];
|
||||
}
|
||||
}
|
||||
|
||||
// Allow to deselect all NSTextFields (via tab focus cycling)
|
||||
// Also: opens view with no NSTextField selected.
|
||||
- (BOOL)acceptsFirstResponder {
|
||||
return YES;
|
||||
}
|
||||
|
||||
// Allow to deselect all NSTextFields (by clicking outside / somewhere on the window)
|
||||
- (void)mouseDown:(NSEvent *)event {
|
||||
[self.window performSelector:@selector(makeFirstResponder:) withObject:nil afterDelay:0];
|
||||
// perform selector because otherwise it will raise an issue of different QoS levels
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -13,6 +13,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ModalFeedEdit : ModalEditDialog <NSTextFieldDelegate>
|
||||
- (void)didClickWarningButton:(NSButton*)sender;
|
||||
- (void)openRegexConverter;
|
||||
@end
|
||||
|
||||
@interface ModalGroupEdit : ModalEditDialog
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
#import "NSView+Ext.h"
|
||||
#import "NSDate+Ext.h"
|
||||
#import "NSURL+Ext.h"
|
||||
#import "RegexConverterController.h"
|
||||
#import "RegexConverterModal.h"
|
||||
#import "RegexConverter+Ext.h"
|
||||
|
||||
// ################################################################
|
||||
// #
|
||||
@@ -59,6 +62,9 @@
|
||||
@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
|
||||
@@ -71,6 +77,13 @@
|
||||
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.
|
||||
@@ -81,6 +94,7 @@
|
||||
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];
|
||||
}
|
||||
@@ -100,10 +114,15 @@
|
||||
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) {
|
||||
if (self.memFeed) { // newly created
|
||||
[self.memFeed copyValuesTo:f ignoreError:YES];
|
||||
[f setNewIcon:self.faviconFile]; // only if downloaded anything (nil deletes icon!)
|
||||
if (self.faviconFile) // only if downloaded anything (nil deletes icon!)
|
||||
[f setNewIcon:self.faviconFile];
|
||||
self.faviconFile = nil;
|
||||
} else { // updating existing feed meta
|
||||
if (f.meta.scheduled == nil || f.meta.scheduled.timeIntervalSinceNow > f.meta.refresh) {
|
||||
[f.meta scheduleNow:f.meta.refresh];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +140,11 @@
|
||||
- (void)downloadRSS {
|
||||
[self cancelDownloads];
|
||||
[self.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download
|
||||
[self.view.spinnerURL startAnimation:nil];
|
||||
[self.view.spinnerName startAnimation:nil];
|
||||
self.view.favicon.image = nil;
|
||||
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]) {
|
||||
@@ -131,7 +152,9 @@
|
||||
self.view.name.placeholderString = NSLocalizedString(@"Loading …", nil);
|
||||
}
|
||||
self.previousURL = self.view.url.stringValue;
|
||||
self.memFeed = [[FeedDownload withURL:self.previousURL] startWithDelegate:self];
|
||||
self.memFeed = [[[FeedDownload withURL:self.previousURL]
|
||||
withRegex:self.feedGroup.feed.regex enforce:self.openRegexAfterDownload]
|
||||
startWithDelegate:self];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +205,7 @@
|
||||
self.view.favicon.hidden = hasError;
|
||||
self.view.warningButton.hidden = !hasError;
|
||||
// Start favicon download
|
||||
if (hasError)
|
||||
if (hasError || self.skipIconDownload)
|
||||
[self downloadComplete];
|
||||
else
|
||||
self.memIcon = [[sender faviconDownload] startWithDelegate:self];
|
||||
@@ -210,8 +233,47 @@
|
||||
- (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
|
||||
@@ -264,6 +326,7 @@
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url];
|
||||
return NO;
|
||||
}
|
||||
[NSEvent removeMonitor:self.eventMonitor];
|
||||
return YES;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@property NSPopover *warningPopover;
|
||||
@property (strong) IBOutlet NSTextField *warningText;
|
||||
@property (strong) IBOutlet NSButton *warningReload;
|
||||
@property (strong) IBOutlet NSButton *regexConverterButton;
|
||||
|
||||
- (instancetype)initWithController:(ModalFeedEdit*)controller NS_DESIGNATED_INITIALIZER;
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
#import "ModalFeedEditView.h"
|
||||
#import "ModalFeedEdit.h"
|
||||
#import "NSView+Ext.h"
|
||||
#import "Constants.h"
|
||||
|
||||
@interface StrictUIntFormatter : NSFormatter
|
||||
@end
|
||||
|
||||
@implementation ModalFeedEditView
|
||||
|
||||
@@ -14,7 +13,7 @@
|
||||
NSView *labels = [NSView labelColumn:lbls rowHeight:HEIGHT_INPUTFIELD padding:PAD_S];
|
||||
|
||||
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 0, NSHeight(labels.frame))];
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 320, NSHeight(labels.frame))];
|
||||
self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
|
||||
|
||||
CGFloat x = NSWidth(labels.frame) + PAD_S;
|
||||
@@ -25,23 +24,29 @@
|
||||
self.url = [[[NSView inputField:@"https://example.org/feed.rss" width:0] placeIn:self x:x yTop:0] sizeToRight:PAD_S + 18];
|
||||
self.spinnerURL = [[NSView activitySpinner] placeIn:self xRight:1 yTop:2.5];
|
||||
self.favicon = [[[NSView imageView:nil size:18] tooltip:NSLocalizedString(@"Favicon", nil)] placeIn:self xRight:0 yTop:1.5];
|
||||
self.warningButton = [[[[NSView buttonIcon:NSImageNameCaution size:18] action:@selector(didClickWarningButton:) target:nil] // up the responder chain
|
||||
self.warningButton = [[[[NSView buttonIcon:NSImageNameCaution size:18]
|
||||
action:@selector(didClickWarningButton:) target:nil] // up the responder chain
|
||||
tooltip:NSLocalizedString(@"Click here to show failure reason", nil)]
|
||||
placeIn:self xRight:0 yTop:1.5];
|
||||
// 2. row
|
||||
self.name = [[[NSView inputField:NSLocalizedString(@"Example Title", nil) width:0] placeIn:self x:x yTop:rowHeight] sizeToRight:PAD_S + 18];
|
||||
self.spinnerName = [[NSView activitySpinner] placeIn:self xRight:1 yTop:rowHeight + 2.5];
|
||||
// 3. row
|
||||
self.refreshNum = [[NSView inputField:@"30" width:85] placeIn:self x:x yTop:2*rowHeight];
|
||||
self.refreshNum = [[NSView integerField:@"∞" unit:nil width:85] placeIn:self x:x yTop:2*rowHeight];
|
||||
self.refreshUnit = [[NSView popupButton:120] placeIn:self x:NSMaxX(self.refreshNum.frame) + PAD_M yTop:2*rowHeight];
|
||||
self.regexConverterButton = [[[[NSView buttonIcon:RSSImageRegexIcon size:19]
|
||||
action:@selector(openRegexConverter) target:controller]
|
||||
tooltip:NSLocalizedString(@"Regex converter", nil)]
|
||||
placeIn:self xRight:0 yTop:2*rowHeight + 1];
|
||||
|
||||
// initial state
|
||||
self.url.accessibilityLabel = lbls[0];
|
||||
self.name.accessibilityLabel = lbls[1];
|
||||
self.favicon.accessibilityLabel = nil; // disable `accessibilityDescription` of `RSSImageDefaultRSSIcon`
|
||||
self.refreshNum.accessibilityLabel = NSLocalizedString(@"Refresh interval", nil);
|
||||
self.url.delegate = controller;
|
||||
self.warningButton.hidden = YES;
|
||||
self.refreshNum.formatter = [StrictUIntFormatter new]; // see below ...
|
||||
self.regexConverterButton.hidden = YES;
|
||||
[self prepareWarningPopover];
|
||||
return self;
|
||||
}
|
||||
@@ -60,29 +65,3 @@
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark - StrictUIntFormatter -
|
||||
|
||||
|
||||
@implementation StrictUIntFormatter
|
||||
/// Display object as integer formatted string.
|
||||
- (NSString *)stringForObjectValue:(id)obj {
|
||||
return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]];
|
||||
}
|
||||
/// Parse any pasted input as integer.
|
||||
- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error {
|
||||
*obj = [[NSNumber numberWithInt:[string intValue]] stringValue];
|
||||
return YES;
|
||||
}
|
||||
/// Only digits, no other character allowed
|
||||
- (BOOL)isPartialStringValid:(NSString *__autoreleasing _Nonnull *)partialStringPtr proposedSelectedRange:(NSRangePointer)proposedSelRangePtr originalString:(NSString *)origString originalSelectedRange:(NSRange)origSelRange errorDescription:(NSString *__autoreleasing _Nullable *)error {
|
||||
for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) {
|
||||
unichar c = [*partialStringPtr characterAtIndex:i];
|
||||
if (c < '0' || c > '9')
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
@end
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@return Centered view without autoresizing.
|
||||
*/
|
||||
- (instancetype)initWithRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
|
||||
self = [super initWithFrame:NSZeroRect];
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 320, 327)];
|
||||
self.autoresizesSubviews = NO;
|
||||
|
||||
NSTextField *dateView = [self viewForArticlesCount:count latest:info];
|
||||
|
||||
@@ -138,7 +138,7 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder";
|
||||
if (selection.count > 0)
|
||||
[self.dataStore setSelectionIndexPaths:[selection sortedArrayUsingSelector:@selector(compare:)]];
|
||||
|
||||
[UpdateScheduler downloadList:feedsList userInitiated:YES finally:^{
|
||||
[UpdateScheduler downloadList:feedsList userInitiated:YES notifications:NO finally:^{
|
||||
[self endCoreDataChangeUndoEmpty:NO forceUndo:NO];
|
||||
for (Feed *f in feedsList)
|
||||
[moc refreshObject:f.group mergeChanges:NO]; // fixes blank icon if imported with no inet conn
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
@implementation SettingsFeedsView
|
||||
|
||||
- (instancetype)initWithController:(SettingsFeeds*)delegate {
|
||||
self = [super initWithFrame:NSZeroRect];
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 201, 327)];
|
||||
if (self) {
|
||||
self.controller = delegate; // make sure its first
|
||||
self.outline = [self generateOutlineView]; // uses self.controller
|
||||
[self wrapContent:self.outline inScrollView:NSMakeRect(0, 20, NSWidth(self.frame), NSHeight(self.frame) - 20)];
|
||||
[[self.outline wrapInScrollView:NSMakeSize(NSWidth(self.frame), NSHeight(self.frame) - 20)] placeIn:self x:0 y:20];
|
||||
self.outline.menu = [self generateCommandsMenu];
|
||||
[self.outline.menu.itemArray makeObjectsPerformSelector:@selector(setTarget:) withObject:delegate];
|
||||
CGFloat x = [self generateButtons]; // uses self.controller and self.outline
|
||||
@@ -60,7 +60,7 @@
|
||||
- (void)setOutlineColumns:(NSOutlineView*)outline {
|
||||
NSTableColumn *colName = [[NSTableColumn alloc] initWithIdentifier:CustomCellName];
|
||||
colName.title = NSLocalizedString(@"Name", nil);
|
||||
colName.width = 10000;
|
||||
colName.width = 201;
|
||||
colName.maxWidth = 10000;
|
||||
colName.resizingMask = NSTableColumnAutoresizingMask;
|
||||
[outline addTableColumn:colName];
|
||||
@@ -170,7 +170,7 @@
|
||||
NSUserInterfaceItemIdentifier const CustomCellName = @"NameColumnCell";
|
||||
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect {
|
||||
self = [super initWithFrame:frameRect];
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 100, 0)];
|
||||
self.identifier = CustomCellName;
|
||||
self.imageView = [[NSView imageView:nil size:16] placeIn:self x:1 yTop:1];
|
||||
self.imageView.accessibilityLabel = NSLocalizedString(@"Feed icon", nil);
|
||||
@@ -195,7 +195,7 @@ NSUserInterfaceItemIdentifier const CustomCellName = @"NameColumnCell";
|
||||
NSUserInterfaceItemIdentifier const CustomCellRefresh = @"RefreshColumnCell";
|
||||
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect {
|
||||
self = [super initWithFrame:frameRect];
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 100, 0)];
|
||||
self.identifier = CustomCellRefresh;
|
||||
self.textField = [[[[NSView label:@""] textRight] placeIn:self x:0 yTop:0] sizeToRight:0];
|
||||
self.textField.accessibilityTitle = @" "; // otherwise groups and separators will say 'text'
|
||||
@@ -210,7 +210,8 @@ NSUserInterfaceItemIdentifier const CustomCellRefresh = @"RefreshColumnCell";
|
||||
}
|
||||
self.textField.objectValue = str;
|
||||
self.textField.textColor = (str.length > 1 ? [NSColor controlTextColor] : [NSColor disabledControlTextColor]);
|
||||
self.textField.accessibilityLabel = (str.length > 1 ? NSLocalizedString(@"Refresh interval", nil) : nil);
|
||||
self.textField.accessibilityLabel = (str.length > 0 ? NSLocalizedString(@"Refresh interval", nil) : nil);
|
||||
[self.textField tooltip:(str.length == 1 ? NSLocalizedString(@"manually", nil) : nil)];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -224,7 +225,7 @@ NSUserInterfaceItemIdentifier const CustomCellRefresh = @"RefreshColumnCell";
|
||||
NSUserInterfaceItemIdentifier const CustomCellSeparator = @"SeparatorColumnCell";
|
||||
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect {
|
||||
self = [super initWithFrame:frameRect];
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 100, 0)];
|
||||
self.identifier = CustomCellSeparator;
|
||||
[[[[DrawSeparator alloc] initWithFrame:self.frame] placeIn:self x:0 y:0] sizableWidthAndHeight];
|
||||
return self;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SettingsGeneral : NSViewController
|
||||
- (void)changeHttpApplication:(NSPopUpButton *)sender;
|
||||
- (void)clickHowToDefaults:(NSButton *)sender;
|
||||
- (void)changeHttpApplication:(NSPopUpButton *)sender;
|
||||
- (void)changeNotificationType:(NSPopUpButton *)sender;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#import "StoreCoordinator.h"
|
||||
#import "Constants.h"
|
||||
#import "SettingsGeneralView.h"
|
||||
#import "NotifyEndpoint.h"
|
||||
|
||||
@interface SettingsGeneral()
|
||||
@property (strong) IBOutlet SettingsGeneralView *view; // override
|
||||
@@ -13,19 +14,38 @@
|
||||
|
||||
- (void)loadView {
|
||||
self.view = [[SettingsGeneralView alloc] initWithController:self];
|
||||
// Default http application for opening the feed urls
|
||||
NSPopUpButton *pop = self.view.popupHttpApplication;
|
||||
[pop removeAllItems];
|
||||
[pop addItemWithTitle:NSLocalizedString(@"System Default", @"Default web browser application")];
|
||||
NSArray<NSString*> *browsers = CFBridgingRelease(LSCopyAllHandlersForURLScheme(CFSTR("https")));
|
||||
for (NSString *bundleID in browsers) {
|
||||
[pop addItemWithTitle: [self applicationNameForBundleId:bundleID]];
|
||||
pop.lastItem.representedObject = bundleID;
|
||||
}
|
||||
[pop selectItemAtIndex:[pop indexOfItemWithRepresentedObject:UserPrefsString(Pref_defaultHttpApplication)]];
|
||||
|
||||
// Default RSS Reader application
|
||||
NSString *feedBundleId = CFBridgingRelease(LSCopyDefaultHandlerForURLScheme(CFSTR("feed")));
|
||||
self.view.defaultReader.objectValue = [self applicationNameForBundleId:feedBundleId];
|
||||
|
||||
// Default http application for opening the feed urls
|
||||
NSPopUpButton *defaultApp = self.view.popupHttpApplication;
|
||||
[defaultApp removeAllItems];
|
||||
[defaultApp addItemWithTitle:NSLocalizedString(@"System Default", @"Default web browser application")];
|
||||
NSArray<NSString*> *browsers = CFBridgingRelease(LSCopyAllHandlersForURLScheme(CFSTR("https")));
|
||||
for (NSString *bundleID in browsers) {
|
||||
[defaultApp addItemWithTitle: [self applicationNameForBundleId:bundleID]];
|
||||
defaultApp.lastItem.representedObject = bundleID;
|
||||
}
|
||||
[defaultApp selectItemAtIndex:[defaultApp indexOfItemWithRepresentedObject:UserPrefsString(Pref_defaultHttpApplication)]];
|
||||
|
||||
// Notification settings (disabled, per article, per feed, total)
|
||||
NSPopUpButton *notify = self.view.popupNotificationType;
|
||||
[notify removeAllItems];
|
||||
[notify addItemsWithTitles:@[
|
||||
NSLocalizedString(@"Disabled", @"No notifications"),
|
||||
NSLocalizedString(@"Per Article", nil),
|
||||
NSLocalizedString(@"Per Feed", nil),
|
||||
NSLocalizedString(@"Global “X unread articles”", nil),
|
||||
]];
|
||||
notify.itemArray[0].representedObject = NotificationTypeToString(NotificationTypeDisabled);
|
||||
notify.itemArray[1].representedObject = NotificationTypeToString(NotificationTypePerArticle);
|
||||
notify.itemArray[2].representedObject = NotificationTypeToString(NotificationTypePerFeed);
|
||||
notify.itemArray[3].representedObject = NotificationTypeToString(NotificationTypeGlobal);
|
||||
NotificationType savedType = UserPrefsNotificationType();
|
||||
[notify selectItemAtIndex:[notify indexOfItemWithRepresentedObject:NotificationTypeToString(savedType)]];
|
||||
self.view.notificationHelp.stringValue = [self notificationHelpString:savedType];
|
||||
}
|
||||
|
||||
/// Get human readable application name such as 'Safari' or 'baRSS'
|
||||
@@ -41,11 +61,6 @@
|
||||
|
||||
#pragma mark - User interaction
|
||||
|
||||
// Callback method fired when user selects a different item from popup list
|
||||
- (void)changeHttpApplication:(NSPopUpButton *)sender {
|
||||
UserPrefsSet(Pref_defaultHttpApplication, sender.selectedItem.representedObject);
|
||||
}
|
||||
|
||||
// Callback method from round help button right of default feed reader text
|
||||
- (void)clickHowToDefaults:(NSButton *)sender {
|
||||
NSAlert *alert = [[NSAlert alloc] init];
|
||||
@@ -63,4 +78,29 @@
|
||||
|
||||
// x-apple.systempreferences:com.apple.preferences.users?startupItemsPref
|
||||
|
||||
// Callback method fired when user selects a different item from popup list
|
||||
- (void)changeHttpApplication:(NSPopUpButton *)sender {
|
||||
UserPrefsSet(Pref_defaultHttpApplication, sender.selectedItem.representedObject);
|
||||
}
|
||||
|
||||
- (void)changeNotificationType:(NSPopUpButton *)sender {
|
||||
UserPrefsSet(Pref_notificationType, sender.selectedItem.representedObject);
|
||||
self.view.notificationHelp.stringValue = [self notificationHelpString:UserPrefsNotificationType()];
|
||||
[NotifyEndpoint activate];
|
||||
}
|
||||
|
||||
/// Help string explaining the different notification settings (for the current configuration)
|
||||
- (NSString*)notificationHelpString:(NotificationType)typ {
|
||||
switch (typ) {
|
||||
case NotificationTypeDisabled:
|
||||
return NSLocalizedString(@"Notifications are disabled. You will not get any notifications even if you enable them in System Settings.", nil);
|
||||
case NotificationTypePerArticle:
|
||||
return NSLocalizedString(@"You will get a notification for each article (“Feed Title: Article Title”). A click on the notification banner opens the article link and marks the item as read.", nil);
|
||||
case NotificationTypePerFeed:
|
||||
return NSLocalizedString(@"You will get a notification for each feed whenever one or more new articles are published (“Feed Title: X unread articles”). A click on the notification banner will open all unread articles of that feed.", nil);
|
||||
case NotificationTypeGlobal:
|
||||
return NSLocalizedString(@"You will get a single notification for all feeds combined (“baRSS: X unread articles”). A click on the notification banner will open all unread articles of all feeds.", nil);
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SettingsGeneralView : NSView
|
||||
@property (strong) IBOutlet NSPopUpButton* popupHttpApplication;
|
||||
@property (strong) IBOutlet NSTextField *defaultReader;
|
||||
@property (strong) IBOutlet NSPopUpButton* popupHttpApplication;
|
||||
@property (strong) IBOutlet NSPopUpButton* popupNotificationType;
|
||||
@property (strong) IBOutlet NSTextField* notificationHelp;
|
||||
|
||||
- (instancetype)initWithController:(SettingsGeneral*)controller NS_DESIGNATED_INITIALIZER;
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
|
||||
|
||||
@@ -5,16 +5,30 @@
|
||||
@implementation SettingsGeneralView
|
||||
|
||||
- (instancetype)initWithController:(SettingsGeneral*)controller {
|
||||
self = [super initWithFrame:NSZeroRect];
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 320, 327)];
|
||||
|
||||
// Change default feed reader application
|
||||
NSTextField *l1 = [[NSView label:NSLocalizedString(@"Default feed reader:", nil)] placeIn:self x:PAD_WIN yTop:PAD_WIN + 3];
|
||||
NSButton *help = [[[NSView helpButton] action:@selector(clickHowToDefaults:) target:controller] placeIn:self xRight:PAD_WIN yTop:PAD_WIN];
|
||||
self.defaultReader = [[[[NSView label:@""] bold] placeIn:self x:NSMaxX(l1.frame) + PAD_S yTop:PAD_WIN + 3] sizeToRight:NSWidth(help.frame) + PAD_WIN];
|
||||
|
||||
// Popup button 'Open URLs with:'
|
||||
CGFloat y = YFromTop(help) + PAD_M;
|
||||
NSTextField *l2 = [[NSView label:NSLocalizedString(@"Open URLs with:", nil)] placeIn:self x:PAD_WIN yTop:y + 1];
|
||||
self.popupHttpApplication = [[[[NSView popupButton:0] placeIn:self x:NSMaxX(l2.frame) + PAD_S yTop:y] sizeToRight:PAD_WIN]
|
||||
action:@selector(changeHttpApplication:) target:controller];
|
||||
|
||||
// Notification type
|
||||
y = YFromTop(self.popupHttpApplication) + PAD_M;
|
||||
NSTextField *l3 = [[NSView label:NSLocalizedString(@"Notifications:", nil)] placeIn:self x:PAD_WIN yTop:y + 1];
|
||||
self.popupNotificationType = [[[[NSView popupButton:0] placeIn:self x:NSMaxX(l3.frame) + PAD_S yTop:y] sizeToRight:PAD_WIN]
|
||||
action:@selector(changeNotificationType:) target:controller];
|
||||
|
||||
// Notification help text
|
||||
y = YFromTop(self.popupNotificationType) + PAD_M;
|
||||
self.notificationHelp = [[[[[NSView label:@""] gray]
|
||||
multiline:NSMakeSize(320 - 2*PAD_WIN, HEIGHT_LABEL * 5)]
|
||||
placeIn:self x:PAD_WIN yTop:y] sizeToRight:PAD_WIN];
|
||||
return self;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,18 +16,13 @@
|
||||
if (self) {
|
||||
self.tabStyle = NSTabViewControllerTabStyleToolbar;
|
||||
self.transitionOptions = NSViewControllerTransitionNone;
|
||||
|
||||
NSTabViewItem *flexibleWidth = [[NSTabViewItem alloc] initWithIdentifier:NSToolbarFlexibleSpaceItemIdentifier];
|
||||
flexibleWidth.viewController = [NSViewController new];
|
||||
|
||||
self.tabViewItems = @[
|
||||
TabItem(NSImageNamePreferencesGeneral, NSLocalizedString(@"General", nil), [SettingsGeneral class]),
|
||||
TabItem(NSImageNameUserAccounts, NSLocalizedString(@"Feeds", nil), [SettingsFeeds class]),
|
||||
TabItem(NSImageNameFontPanel, NSLocalizedString(@"Appearance", nil), [SettingsAppearance class]),
|
||||
flexibleWidth,
|
||||
TabItem(NSImageNameInfo, NSLocalizedString(@"About", nil), [SettingsAbout class]),
|
||||
];
|
||||
[self switchToTab: UserPrefsUInt(Pref_prefSelectedTab)];
|
||||
self.selectedTabViewItemIndex = -1;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -43,19 +38,16 @@ static inline NSTabViewItem* TabItem(NSImageName imageName, NSString *text, Clas
|
||||
/// Safely set selected index without out of bounds exception
|
||||
- (__kindof NSViewController*)switchToTab:(NSUInteger)index {
|
||||
if (index < 0 || index >= self.tabViewItems.count)
|
||||
return nil;
|
||||
NSTabViewItem *tab = self.tabViewItems[index];
|
||||
if (tab.identifier == NSToolbarFlexibleSpaceItemIdentifier)
|
||||
return nil;
|
||||
index = 0;
|
||||
self.selectedTabViewItemIndex = (NSInteger)index;
|
||||
return [tab viewController];
|
||||
return [self.tabViewItems[index] viewController];
|
||||
}
|
||||
|
||||
/// Delegate method, store last selected tab to user preferences
|
||||
- (void)tabView:(NSTabView*)tabView didSelectTabViewItem:(nullable NSTabViewItem*)tabViewItem {
|
||||
[super tabView:tabView didSelectTabViewItem:tabViewItem];
|
||||
NSInteger newIndex = self.selectedTabViewItemIndex;
|
||||
if (UserPrefsInt(Pref_prefSelectedTab) != newIndex)
|
||||
if (newIndex != -1 && UserPrefsInt(Pref_prefSelectedTab) != newIndex)
|
||||
UserPrefsSetInt(Pref_prefSelectedTab, newIndex);
|
||||
}
|
||||
|
||||
@@ -70,7 +62,10 @@ static inline NSTabViewItem* TabItem(NSImageName imageName, NSString *text, Clas
|
||||
w.contentMinSize = NSMakeSize(320, 327);
|
||||
w.windowController.shouldCascadeWindows = YES;
|
||||
w.title = [NSString stringWithFormat:NSLocalizedString(@"%@ Preferences", nil), NSProcessInfo.processInfo.processName];
|
||||
w.contentViewController = [PrefTabs new];
|
||||
PrefTabs *tabController = [PrefTabs new];
|
||||
w.contentViewController = tabController;
|
||||
[w.toolbar insertItemWithItemIdentifier:NSToolbarSpaceItemIdentifier atIndex:3];
|
||||
[w.toolbar insertItemWithItemIdentifier:NSToolbarFlexibleSpaceItemIdentifier atIndex:4];
|
||||
w.delegate = w;
|
||||
NSWindowPersistableFrameDescriptor prevFrame = UserPrefsString(Pref_prefWindowFrame);
|
||||
if (!prevFrame) {
|
||||
@@ -79,6 +74,7 @@ static inline NSTabViewItem* TabItem(NSImageName imageName, NSString *text, Clas
|
||||
} else {
|
||||
[w setFrameFromString:prevFrame];
|
||||
}
|
||||
[tabController switchToTab:UserPrefsUInt(Pref_prefSelectedTab)];
|
||||
return w;
|
||||
}
|
||||
|
||||
|
||||
12
baRSS/Regex Editor/RegexConverterController.h
Normal file
@@ -0,0 +1,12 @@
|
||||
@import Cocoa;
|
||||
@class RegexConverter, RegexConverterModal, Feed;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface RegexConverterController : NSViewController <NSTextFieldDelegate>
|
||||
+ (instancetype)withData:(NSData *)data andConverter:(nullable RegexConverter*)converter;
|
||||
- (RegexConverterModal*)getModalSheet;
|
||||
- (void)applyChanges:(Feed *)feed;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
125
baRSS/Regex Editor/RegexConverterController.m
Normal file
@@ -0,0 +1,125 @@
|
||||
#import "RegexConverterController.h"
|
||||
#import "RegexConverterView.h"
|
||||
#import "RegexConverter+Ext.h"
|
||||
#import "RegexConverterModal.h"
|
||||
#import "RegexFeed.h"
|
||||
#import "Feed+Ext.h"
|
||||
#import "NSURLRequest+Ext.h"
|
||||
|
||||
|
||||
// ################################################################
|
||||
// #
|
||||
// # MARK: - RegexConverterController -
|
||||
// #
|
||||
// ################################################################
|
||||
|
||||
@interface RegexConverterController() <NSWindowDelegate>
|
||||
@property (strong) RegexConverter *converter;
|
||||
@property (strong) RegexConverterModal *modalSheet;
|
||||
@property (strong) IBOutlet RegexConverterView *view; // override
|
||||
|
||||
@property (strong) NSString *theData; // not "copy" because generated in initializer
|
||||
@end
|
||||
|
||||
@implementation RegexConverterController
|
||||
@dynamic view;
|
||||
|
||||
/// Dedicated initializer
|
||||
+ (instancetype)withData:(NSData *)data andConverter:(RegexConverter*)converter {
|
||||
RegexConverterController *diag = [self new];
|
||||
diag.theData = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]: @"";
|
||||
diag.converter = converter;
|
||||
return diag;
|
||||
}
|
||||
|
||||
- (RegexConverterModal *)getModalSheet {
|
||||
if (!self.modalSheet) {
|
||||
self.modalSheet = [[RegexConverterModal alloc] initWithView:self.view];
|
||||
self.modalSheet.delegate = self;
|
||||
}
|
||||
return self.modalSheet;
|
||||
}
|
||||
|
||||
- (void)loadView {
|
||||
self.view = [[RegexConverterView alloc] initWithController:self];
|
||||
[self populateTextFields:self.converter];
|
||||
[self updateOutput:self.theData];
|
||||
}
|
||||
|
||||
/// Pre-fill UI control field values with @c RegexConverter properties.
|
||||
- (void)populateTextFields:(RegexConverter*)converter {
|
||||
if (converter) {
|
||||
self.view.entry.objectValue = converter.entry;
|
||||
self.view.href.objectValue = converter.href;
|
||||
self.view.title.objectValue = converter.title;
|
||||
self.view.desc.objectValue = converter.desc;
|
||||
self.view.date.objectValue = converter.date;
|
||||
self.view.dateFormat.objectValue = converter.dateFormat;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Update CoreData
|
||||
|
||||
- (void)applyChanges:(Feed *)feed {
|
||||
BOOL shouldDelete = self.view.entry.stringValue.length == 0;
|
||||
|
||||
if (shouldDelete) {
|
||||
if (feed.regex) {
|
||||
[feed.managedObjectContext deleteObject:feed.regex];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!feed.regex) {
|
||||
feed.regex = [RegexConverter newInContext:feed.managedObjectContext];
|
||||
}
|
||||
|
||||
[feed.regex setEntryIfChanged:self.view.entry.stringValue];
|
||||
[feed.regex setHrefIfChanged:self.view.href.stringValue];
|
||||
[feed.regex setTitleIfChanged:self.view.title.stringValue];
|
||||
[feed.regex setDescIfChanged:self.view.desc.stringValue];
|
||||
[feed.regex setDateIfChanged:self.view.date.stringValue];
|
||||
[feed.regex setDateFormatIfChanged:self.view.dateFormat.stringValue];
|
||||
}
|
||||
|
||||
#pragma mark - NSTextField Delegate
|
||||
|
||||
- (RegexFeed*)regexParser {
|
||||
RegexFeed *tmp = [RegexFeed new];
|
||||
tmp.rxEntry = self.view.entry.stringValue;
|
||||
tmp.rxHref = self.view.href.stringValue;
|
||||
tmp.rxTitle = self.view.title.stringValue;
|
||||
tmp.rxDesc = self.view.desc.stringValue;
|
||||
tmp.rxDate = self.view.date.stringValue;
|
||||
tmp.dateFormat = self.view.dateFormat.stringValue;
|
||||
return tmp;
|
||||
}
|
||||
|
||||
- (void)controlTextDidEndEditing:(NSNotification*)obj {
|
||||
if (self.view.entry.stringValue.length == 0) {
|
||||
[self updateOutput:self.theData];
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *err = nil;
|
||||
NSArray<RegexFeedEntry*> *matches = [[self regexParser] process:self.theData error:&err];
|
||||
if (err) {
|
||||
[self updateOutput:[NSString stringWithFormat:@"%@\n––––\n%@",
|
||||
err.localizedDescription, err.localizedRecoverySuggestion]];
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableString *rv = [NSMutableString new];
|
||||
for (RegexFeedEntry *entry in matches) {
|
||||
[rv appendFormat:@"%@\n\n$_href: %@\n$_title: %@\n$_date: %@ -> %@\n$_description: %@\n\n----------\n\n",
|
||||
entry.rawMatch, entry.href, entry.title, entry.dateString, entry.date, entry.desc];
|
||||
}
|
||||
|
||||
[self updateOutput:rv];
|
||||
}
|
||||
|
||||
- (void)updateOutput:(NSString *)text {
|
||||
[self.view.output.textStorage setAttributedString:[[NSAttributedString alloc] initWithString:text]];
|
||||
}
|
||||
|
||||
@end
|
||||
12
baRSS/Regex Editor/RegexConverterModal.h
Normal file
@@ -0,0 +1,12 @@
|
||||
@import Cocoa;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface RegexConverterModal : NSPanel
|
||||
@property (readonly) BOOL didTapCancel;
|
||||
|
||||
- (instancetype)initWithContentRect:(NSRect)contentRect styleMask:(NSWindowStyleMask)style backing:(NSBackingStoreType)backingStoreType defer:(BOOL)flag NS_UNAVAILABLE;
|
||||
- (instancetype)initWithView:(NSView*)content NS_DESIGNATED_INITIALIZER;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
60
baRSS/Regex Editor/RegexConverterModal.m
Normal file
@@ -0,0 +1,60 @@
|
||||
#import "RegexConverterModal.h"
|
||||
#import "UserPrefs.h"
|
||||
#import "NSView+Ext.h"
|
||||
|
||||
@interface RegexConverterModal()
|
||||
@property (assign) BOOL respondToShouldClose;
|
||||
@end
|
||||
|
||||
@implementation RegexConverterModal
|
||||
|
||||
/// Designated initializer. 'Done' and 'Cancel' buttons will be added automatically.
|
||||
- (instancetype)initWithView:(NSView*)content {
|
||||
static CGFloat const contentOffsetY = PAD_WIN + HEIGHT_BUTTON + PAD_L;
|
||||
|
||||
CGSize sz = content.frame.size;
|
||||
sz.width += 2 * (NSInteger)PAD_WIN;
|
||||
sz.height += PAD_WIN + contentOffsetY; // the second PAD_WIN is already in contentOffsetY
|
||||
|
||||
NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView;
|
||||
self = [super initWithContentRect:NSMakeRect(0, 0, sz.width, sz.height) styleMask:style backing:NSBackingStoreBuffered defer:NO];
|
||||
[content placeIn:self.contentView x:PAD_WIN y:contentOffsetY];
|
||||
|
||||
self.minSize = sz;
|
||||
|
||||
// Add default interaction buttons
|
||||
NSButton *btnDone = [self createButton:NSLocalizedString(@"Done", nil) atX:PAD_WIN];
|
||||
NSButton *btnCancel = [self createButton:NSLocalizedString(@"Cancel", nil) atX:sz.width - NSMinX(btnDone.frame) + PAD_M];
|
||||
btnDone.tag = 42; // mark 'Done' button
|
||||
//btnDone.keyEquivalent = @"\r"; // Enter / Return
|
||||
btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Helper method to create bottom-right aligned button.
|
||||
- (NSButton*)createButton:(NSString*)text atX:(CGFloat)x {
|
||||
return [[[NSView button:text] action:@selector(didTapButton:) target:self] placeIn:self.contentView xRight:x y:PAD_WIN];
|
||||
}
|
||||
|
||||
/// Sets bool for future usage
|
||||
- (void)setDelegate:(id<NSWindowDelegate>)delegate {
|
||||
[super setDelegate:delegate];
|
||||
self.respondToShouldClose = [delegate respondsToSelector:@selector(windowShouldClose:)];
|
||||
}
|
||||
|
||||
/**
|
||||
Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button.
|
||||
In the later case set @c .didTapCancel @c = @c YES
|
||||
*/
|
||||
- (void)didTapButton:(NSButton*)sender {
|
||||
BOOL successful = (sender.tag == 42); // 'Done' button
|
||||
_didTapCancel = !successful;
|
||||
if (self.respondToShouldClose && ![self.delegate windowShouldClose:self]) {
|
||||
return;
|
||||
}
|
||||
// Remove subviews to avoid _NSKeyboardFocusClipView issues
|
||||
[self.contentView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
[self.sheetParent endSheet:self returnCode:(successful ? NSModalResponseOK : NSModalResponseCancel)];
|
||||
}
|
||||
|
||||
@end
|
||||
20
baRSS/Regex Editor/RegexConverterView.h
Normal file
@@ -0,0 +1,20 @@
|
||||
@import Cocoa;
|
||||
@class RegexConverter, RegexConverterController;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface RegexConverterView : NSView
|
||||
@property (strong) IBOutlet NSTextField *entry;
|
||||
@property (strong) IBOutlet NSTextField *href;
|
||||
@property (strong) IBOutlet NSTextField *title;
|
||||
@property (strong) IBOutlet NSTextField *date;
|
||||
@property (strong) IBOutlet NSTextField *dateFormat;
|
||||
@property (strong) IBOutlet NSTextField *desc;
|
||||
@property (strong) IBOutlet NSTextView *output;
|
||||
|
||||
- (instancetype)initWithController:(RegexConverterController*)controller NS_DESIGNATED_INITIALIZER;
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;
|
||||
- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
141
baRSS/Regex Editor/RegexConverterView.m
Normal file
@@ -0,0 +1,141 @@
|
||||
#import "RegexConverterView.h"
|
||||
#import "RegexConverterController.h"
|
||||
#import "RegexConverter+Ext.h"
|
||||
#import "NSDate+Ext.h"
|
||||
#import "NSView+Ext.h"
|
||||
|
||||
@interface RegexConverterView()
|
||||
@property NSPopover *infoPopover;
|
||||
@property (strong) IBOutlet NSTextField *popoverText;
|
||||
@property (strong) IBOutlet NSButton *infoButtonEntry;
|
||||
@end
|
||||
|
||||
|
||||
@implementation RegexConverterView
|
||||
|
||||
static CGFloat const heightHowTo = 2 * HEIGHT_LABEL_SMALL;
|
||||
static CGFloat const heightOutput = 150;
|
||||
static CGFloat const heightRow = PAD_S + HEIGHT_INPUTFIELD;
|
||||
|
||||
- (instancetype)initWithController:(RegexConverterController*)controller {
|
||||
NSArray *lbls = @[
|
||||
NSLocalizedString(@"Entries", nil),
|
||||
NSLocalizedString(@"Link", nil),
|
||||
NSLocalizedString(@"Title", nil),
|
||||
NSLocalizedString(@"Description", nil),
|
||||
NSLocalizedString(@"Date", nil),
|
||||
NSLocalizedString(@"Date Format", nil),
|
||||
];
|
||||
NSView *labels = [NSView labelColumn:lbls rowHeight:HEIGHT_INPUTFIELD padding:PAD_S];
|
||||
|
||||
self = [super initWithFrame:NSMakeRect(0, 0, 420, heightHowTo + PAD_L + NSHeight(labels.frame) + PAD_L + heightOutput)];
|
||||
self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
|
||||
|
||||
[self makeHowTo];
|
||||
|
||||
CGFloat x = NSWidth(labels.frame) + PAD_S;
|
||||
[labels placeIn:self x:0 yTop:heightHowTo + PAD_L];
|
||||
|
||||
self.entry = [self inputAndExamples:0 x:x delegate:controller];
|
||||
self.href = [self inputAndExamples:1 x:x delegate:controller];
|
||||
self.title = [self inputAndExamples:2 x:x delegate:controller];
|
||||
self.desc = [self inputAndExamples:3 x:x delegate:controller];
|
||||
self.date = [self inputAndExamples:4 x:x delegate:controller];
|
||||
self.dateFormat = [self inputAndExamples:5 x:x delegate:controller];
|
||||
|
||||
// output text field
|
||||
self.output = [self makeOutput];
|
||||
|
||||
// prepare info popover
|
||||
self.infoPopover = [NSView popover: NSMakeSize(400, 100)];
|
||||
NSView *content = self.infoPopover.contentViewController.view;
|
||||
self.popoverText = [[[[[NSView label:@""] selectable] sizableWidthAndHeight]
|
||||
multiline:NSMakeSize(384, 92)] placeIn:content x:8 y:4];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSTextView *)makeHowTo {
|
||||
NSTextView *tv = [[NSTextView new] sizableWidthAndHeight];
|
||||
tv.editable = NO; // but selectable
|
||||
tv.drawsBackground = NO;
|
||||
tv.textContainer.textView.string = NSLocalizedString(@"DIY regex converter. Press enter to confirm. For help, refer to online tools (e.g., regex101 with options: global + single-line)", nil);
|
||||
NSScrollView *scroll = [[tv wrapInScrollView:NSMakeSize(NSWidth(self.frame) + 2, heightHowTo)] placeIn:self x:-1 y:NSHeight(self.frame) - heightHowTo];
|
||||
scroll.drawsBackground = NO;
|
||||
scroll.borderType = NSNoBorder;
|
||||
scroll.verticalScrollElasticity = NSScrollElasticityNone;
|
||||
scroll.autoresizingMask = NSViewMinYMargin | NSViewWidthSizable;
|
||||
return tv;
|
||||
}
|
||||
|
||||
- (NSTextView *)makeOutput {
|
||||
NSTextView *tv = [[NSTextView new] sizableWidthAndHeight];
|
||||
tv.editable = NO; // but selectable
|
||||
tv.backgroundColor = NSColor.whiteColor;
|
||||
[[tv wrapInScrollView:NSMakeSize(NSWidth(self.frame) + 2, heightOutput)] placeIn:self x:-1 y:0];
|
||||
return tv;
|
||||
}
|
||||
|
||||
/// Helper method to create input field with help button showing regex examples
|
||||
- (NSTextField *)inputAndExamples:(NSInteger)row x:(CGFloat)x delegate:(id<NSTextFieldDelegate>)delegate {
|
||||
CGFloat yOffset = heightHowTo + PAD_L + row * heightRow;
|
||||
NSTextField *input = [[[NSView inputField:@"" width:0] placeIn:self x:x yTop:yOffset]
|
||||
sizeToRight:PAD_S + HEIGHT_BUTTON]; // width of the helpButton
|
||||
input.delegate = delegate;
|
||||
|
||||
NSInteger tag = 700 + row;
|
||||
NSArray<NSString *> *examples = [self examplesFor:tag];
|
||||
if (examples.count > 0) {
|
||||
[[[[NSView helpButton] action:@selector(didClickExamplesButton:) target:self]
|
||||
tooltip:NSLocalizedString(@"Click here to show examples", nil)]
|
||||
placeIn:self xRight:0 yTop:yOffset].tag = tag;
|
||||
|
||||
input.placeholderString = [examples firstObject];
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Example to be displayed in help button
|
||||
- (NSArray<NSString *> *)examplesFor:(NSInteger)tag {
|
||||
switch (tag) {
|
||||
case 700: return @[ // entries
|
||||
@"<dt[ >].*?<\\/dd>",
|
||||
];
|
||||
case 701: return @[ // link
|
||||
@"href=\"([^\"]*)\"",
|
||||
];
|
||||
case 702: return @[ // title
|
||||
@"title=\"([^\"]*)\"",
|
||||
@">([^\\s<]*?)<\\/span>"
|
||||
];
|
||||
case 703: return @[ // description
|
||||
@"<dd[^>]*>(.*?)<\\/dd>",
|
||||
];
|
||||
case 704: return @[ // date matcher
|
||||
@"(\\d{2}.\\d{2}.\\d{4})",
|
||||
];
|
||||
case 705: return @[ // date format
|
||||
@"dd.MM.yyyy",
|
||||
@"dd. MMM yyyy",
|
||||
@"yyyy-MM-dd'T'HH:mm:ssZZZZZ",
|
||||
];
|
||||
default: break;
|
||||
}
|
||||
return @[];
|
||||
}
|
||||
|
||||
- (void)didClickExamplesButton:(NSButton*)sender {
|
||||
NSString *examples = [[self examplesFor:sender.tag] componentsJoinedByString:@"\n"];
|
||||
|
||||
// TODO: clickable entries
|
||||
self.popoverText.stringValue = [NSString stringWithFormat:@"%@", examples];
|
||||
|
||||
NSSize newSize = self.popoverText.fittingSize; // width is limited by the textfield's preferred width
|
||||
newSize.width += 2 * self.popoverText.frame.origin.x; // the padding
|
||||
newSize.height += 2 * self.popoverText.frame.origin.y;
|
||||
|
||||
// apply fitting size and display
|
||||
self.infoPopover.contentSize = newSize;
|
||||
[self.infoPopover showRelativeToRect:NSZeroRect ofView:sender preferredEdge:NSRectEdgeMinY];
|
||||
}
|
||||
|
||||
@end
|
||||
30
baRSS/Regex Editor/RegexFeed.h
Normal file
@@ -0,0 +1,30 @@
|
||||
@import Cocoa;
|
||||
@class RegexConverter;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface RegexFeedEntry : NSObject
|
||||
@property (nullable, readonly) NSString *href;
|
||||
@property (nullable, readonly) NSString *title;
|
||||
@property (nullable, readonly) NSString *desc;
|
||||
@property (nullable, readonly) NSString *dateString;
|
||||
@property (nullable, readonly) NSDate *date;
|
||||
|
||||
@property (nullable, readonly) NSString *rawMatch;
|
||||
@end
|
||||
|
||||
|
||||
@interface RegexFeed : NSObject
|
||||
@property (nullable, copy) NSString *rxEntry;
|
||||
@property (nullable, copy) NSString *rxHref;
|
||||
@property (nullable, copy) NSString *rxTitle;
|
||||
@property (nullable, copy) NSString *rxDesc;
|
||||
@property (nullable, copy) NSString *rxDate;
|
||||
@property (nullable, copy) NSString *dateFormat;
|
||||
|
||||
+ (RegexFeed *)from:(RegexConverter*)regex;
|
||||
|
||||
- (NSArray<RegexFeedEntry*>*)process:(NSString*)rawData error:(NSError * __autoreleasing *)err;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
86
baRSS/Regex Editor/RegexFeed.m
Normal file
@@ -0,0 +1,86 @@
|
||||
#import "RegexFeed.h"
|
||||
#import "RegexConverter+Ext.h"
|
||||
|
||||
@interface RegexFeedEntry()
|
||||
@property (nullable, copy) NSString *href;
|
||||
@property (nullable, copy) NSString *title;
|
||||
@property (nullable, copy) NSString *desc;
|
||||
@property (nullable, copy) NSString *dateString;
|
||||
@property (nullable, retain) NSDate *date;
|
||||
|
||||
@property (nullable, copy) NSString *rawMatch;
|
||||
@end
|
||||
|
||||
@implementation RegexFeedEntry
|
||||
@end
|
||||
|
||||
|
||||
@implementation RegexFeed
|
||||
|
||||
+ (RegexFeed *)from:(RegexConverter*)regex {
|
||||
RegexFeed *x = [RegexFeed new];
|
||||
x.rxEntry = regex.entry;
|
||||
x.rxHref = regex.href;
|
||||
x.rxTitle = regex.title;
|
||||
x.rxDesc = regex.desc;
|
||||
x.rxDate = regex.date;
|
||||
x.dateFormat = regex.dateFormat;
|
||||
return x;
|
||||
}
|
||||
|
||||
- (NSArray<RegexFeedEntry*>*)process:(NSString*)rawData error:(NSError * __autoreleasing *)err {
|
||||
NSRegularExpression *re_entries = [self regex:_rxEntry error:err];
|
||||
if (!re_entries) {
|
||||
return @[];
|
||||
}
|
||||
NSDateFormatter *dateFormatter = [NSDateFormatter new];
|
||||
[dateFormatter setDateFormat:_dateFormat];
|
||||
[dateFormatter setTimeZone:[NSTimeZone timeZoneWithName:@"UTC"]];
|
||||
// TODO: we probably need to handle locale. Especially for "d. MMM" like "3. Dec"
|
||||
|
||||
NSMutableArray<RegexFeedEntry*> *rv = [NSMutableArray new];
|
||||
NSRegularExpression *re4 = [self regex:_rxDate error:err];
|
||||
NSRegularExpression *re3 = [self regex:_rxDesc error:err];
|
||||
NSRegularExpression *re2 = [self regex:_rxTitle error:err];
|
||||
NSRegularExpression *re1 = [self regex:_rxHref error:err];
|
||||
NSArray<NSTextCheckingResult*> *matches = [re_entries matchesInString:rawData options:0 range:NSMakeRange(0, rawData.length)];
|
||||
|
||||
for (NSTextCheckingResult *match in matches) {
|
||||
NSString *subdata = [rawData substringWithRange:match.range];
|
||||
RegexFeedEntry *entry = [[RegexFeedEntry alloc] init];
|
||||
entry.rawMatch = subdata;
|
||||
entry.href = [self firstMatch:subdata re:re1];
|
||||
entry.title = [self firstMatch:subdata re:re2];
|
||||
entry.desc = [self firstMatch:subdata re:re3];
|
||||
entry.dateString = [self firstMatch:subdata re:re4];
|
||||
entry.date = (_dateFormat.length && entry.dateString.length) ? [dateFormatter dateFromString:entry.dateString] : nil;
|
||||
[rv addObject:entry];
|
||||
};
|
||||
return rv;
|
||||
}
|
||||
|
||||
- (nullable NSRegularExpression*)regex:(NSString*)pattern error:(NSError * __autoreleasing *)err {
|
||||
if (pattern.length == 0) {
|
||||
return nil;
|
||||
}
|
||||
NSRegularExpression *re = [[NSRegularExpression alloc] initWithPattern:pattern options:NSRegularExpressionDotMatchesLineSeparators error:err];
|
||||
if (*err) {
|
||||
return nil;
|
||||
}
|
||||
return re;
|
||||
}
|
||||
|
||||
- (nonnull NSString*)firstMatch:(NSString*)str re:(NSRegularExpression*)re {
|
||||
NSTextCheckingResult *match = [[re matchesInString:str options:0 range:NSMakeRange(0, str.length)] firstObject];
|
||||
if (match) {
|
||||
if (match.numberOfRanges < 2) {
|
||||
return NSLocalizedString(@"Regex error: Missing match-group? ('outer(.*?)text')", nil);
|
||||
}else if (match.numberOfRanges > 2) {
|
||||
return NSLocalizedString(@"Regex error: Multiple match-groups found", nil);
|
||||
}
|
||||
return [str substringWithRange:[match rangeAtIndex:1]];
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -4,6 +4,7 @@
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface BarMenu : NSObject <NSMenuDelegate>
|
||||
@property (assign) BOOL showHidden;
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
- (instancetype)initWithStatusItem:(BarStatusItem*)statusItem NS_DESIGNATED_INITIALIZER;
|
||||
@end
|
||||
|
||||
@@ -64,29 +64,28 @@
|
||||
- (void)setFeedGroups:(NSArray<FeedGroup*>*)sortedList forMenu:(NSMenu*)menu {
|
||||
[menu insertDefaultHeader];
|
||||
for (FeedGroup *fg in sortedList) {
|
||||
[menu insertFeedGroupItem:fg withUnread:self.unreadMap].submenu.delegate = self;
|
||||
[menu insertFeedGroupItem:fg withUnread:self.unreadMap showHidden:_showHidden].submenu.delegate = self;
|
||||
}
|
||||
UnreadTotal *uct = self.unreadMap[menu.titleIndexPath];
|
||||
[menu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)];
|
||||
[menu setHeaderHasUnread:self.unreadMap[menu.titleIndexPath]];
|
||||
}
|
||||
|
||||
/// Generate items for @c FeedArticles menu.
|
||||
- (void)setArticles:(NSArray<FeedArticle*>*)sortedList forMenu:(NSMenu*)menu {
|
||||
[menu insertDefaultHeader];
|
||||
NSInteger mc = NSIntegerMax;
|
||||
if (UserPrefsBool(Pref_feedLimitArticles))
|
||||
mc = UserPrefsInt(Pref_articlesInMenuLimit);
|
||||
BOOL onlyUnread = UserPrefsBool(Pref_feedUnreadOnly);
|
||||
|
||||
for (FeedArticle *fa in sortedList) {
|
||||
if (onlyUnread && !fa.unread)
|
||||
continue;
|
||||
if (--mc < 0) // mc == 0 will first decrement to -1, then evaluate
|
||||
break;
|
||||
[menu addItem:[fa newMenuItem]];
|
||||
NSInteger mc = UserPrefsInt(Pref_articleCountLimit);
|
||||
if (mc < 0) mc = NSIntegerMax;
|
||||
if (mc > 0) {
|
||||
BOOL onlyUnread = UserPrefsBool(Pref_articleUnreadOnly);
|
||||
|
||||
for (FeedArticle *fa in sortedList) {
|
||||
if (onlyUnread && !fa.unread && !_showHidden)
|
||||
continue;
|
||||
if (--mc < 0) // mc == 0 will first decrement to -1, then evaluate
|
||||
break;
|
||||
[menu addItem:[fa newMenuItem]];
|
||||
}
|
||||
}
|
||||
UnreadTotal *uct = self.unreadMap[menu.titleIndexPath];
|
||||
[menu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)];
|
||||
[menu setHeaderHasUnread:self.unreadMap[menu.titleIndexPath]];
|
||||
}
|
||||
|
||||
|
||||
@@ -131,11 +130,15 @@
|
||||
// 3. set unread count & enabled header for all parents
|
||||
NSArray<UnreadTotal*> *itms = [self.unreadMap itemsForPath:item.submenu.titleIndexPath create:NO];
|
||||
for (UnreadTotal *uct in itms.reverseObjectEnumerator) {
|
||||
[item.submenu setHeaderHasUnread:(uct.unread > 0) hasRead:(uct.unread < uct.total)];
|
||||
[item setTitleCount:uct.unread];
|
||||
item = item.parentItem;
|
||||
if (item) { // nil on last loop (aka main menu, see below)
|
||||
[item.submenu setHeaderHasUnread:uct];
|
||||
[item setTitleCount:uct.unread];
|
||||
item.hidden = NO;
|
||||
item = item.parentItem;
|
||||
}
|
||||
}
|
||||
// TODO: need to re-create groups if user chose to hide already read articles
|
||||
// call on main menu
|
||||
[self.statusItem.mainMenu setHeaderHasUnread:itms.firstObject];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface BarStatusItem : NSObject
|
||||
@interface BarStatusItem : NSObject <NSMenuDelegate>
|
||||
@property (weak, readonly) NSMenu *mainMenu;
|
||||
|
||||
- (void)setUnreadCountAbsolute:(NSUInteger)count;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#import "UserPrefs.h"
|
||||
#import "BarMenu.h"
|
||||
#import "AppHook.h"
|
||||
#import "NotifyEndpoint.h"
|
||||
#import "NSView+Ext.h"
|
||||
#import "NSColor+Ext.h"
|
||||
|
||||
@@ -13,12 +14,14 @@
|
||||
@property (strong) NSStatusItem *statusItem;
|
||||
@property (assign) NSInteger unreadCountTotal;
|
||||
@property (weak) NSMenuItem *updateAllItem;
|
||||
/// Set to `true` if user toggled the `"Show hidden feeds"` menu option.
|
||||
@property (assign) BOOL optShowHidden;
|
||||
/// Set to `true` if menu bar was opened while holding down option-key.
|
||||
@property (assign) BOOL holdingOptKey;
|
||||
@end
|
||||
|
||||
@implementation BarStatusItem
|
||||
|
||||
- (NSMenu *)mainMenu { return _statusItem.menu; }
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
// Show icon & prefetch unread count
|
||||
@@ -28,8 +31,7 @@
|
||||
self.statusItem.button.image.template = YES;
|
||||
// Add empty menu (will be populated once opened)
|
||||
self.statusItem.menu = [[NSMenu alloc] initWithTitle:@"M"];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(mainMenuWillOpen) name:NSMenuDidBeginTrackingNotification object:self.statusItem.menu];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(mainMenuDidClose) name:NSMenuDidEndTrackingNotification object:self.statusItem.menu];
|
||||
self.statusItem.menu.delegate = self;
|
||||
// Some icon unread count notification callback methods
|
||||
RegisterNotification(kNotificationNetworkStatusChanged, @selector(networkChanged:), self);
|
||||
RegisterNotification(kNotificationTotalUnreadCountChanged, @selector(unreadCountChanged:), self);
|
||||
@@ -72,14 +74,21 @@
|
||||
|
||||
/// Assign total unread count value directly.
|
||||
- (void)setUnreadCountAbsolute:(NSUInteger)count {
|
||||
_unreadCountTotal = (NSInteger)count;
|
||||
NSInteger oldCount = _unreadCountTotal;
|
||||
_unreadCountTotal = count > 0 ? (NSInteger)count : 0;
|
||||
[self updateBarIcon];
|
||||
[NotifyEndpoint setGlobalCount:_unreadCountTotal previousCount:oldCount];
|
||||
}
|
||||
|
||||
/// Assign new value by adding @c count to total unread count (may be negative).
|
||||
- (void)setUnreadCountRelative:(NSInteger)count {
|
||||
NSInteger oldCount = _unreadCountTotal;
|
||||
_unreadCountTotal += count;
|
||||
if (_unreadCountTotal < 0) {
|
||||
_unreadCountTotal = 0;
|
||||
}
|
||||
[self updateBarIcon];
|
||||
[NotifyEndpoint setGlobalCount:_unreadCountTotal previousCount:oldCount];
|
||||
}
|
||||
|
||||
/// Fetch new total unread count from core data and assign it as new value (dispatch async on main thread).
|
||||
@@ -98,6 +107,9 @@
|
||||
BOOL hasNet = [UpdateScheduler allowNetworkConnection];
|
||||
BOOL tint = (self.unreadCountTotal > 0 && hasNet && UserPrefsBool(Pref_globalTintMenuIcon));
|
||||
self.statusItem.button.image = [NSImage imageNamed:(hasNet ? RSSImageMenuBarIconActive : RSSImageMenuBarIconPaused)];
|
||||
self.statusItem.button.accessibilityLabel = hasNet
|
||||
? NSLocalizedString(@"RSS menu bar", nil)
|
||||
: NSLocalizedString(@"RSS menu bar, paused", nil);
|
||||
|
||||
if (@available(macOS 11, *)) {
|
||||
self.statusItem.button.image.template = !tint;
|
||||
@@ -144,27 +156,47 @@
|
||||
|
||||
#pragma mark - Main Menu Handling
|
||||
|
||||
- (void)mainMenuWillOpen {
|
||||
-(void)menuWillOpen:(NSMenu *)menu {
|
||||
self.holdingOptKey = NSEvent.modifierFlags & NSEventModifierFlagOption;
|
||||
_mainMenu = menu; // autoreleased once closed
|
||||
self.barMenu = [[BarMenu alloc] initWithStatusItem:self];
|
||||
[self insertMainMenuHeader:self.statusItem.menu];
|
||||
[self.barMenu menuNeedsUpdate:self.statusItem.menu];
|
||||
self.barMenu.showHidden = self.optShowHidden || self.holdingOptKey;
|
||||
|
||||
[self insertMainMenuHeader:menu];
|
||||
[self.barMenu menuNeedsUpdate:menu];
|
||||
// Add main menu items 'Preferences' and 'Quit'.
|
||||
[self.statusItem.menu addItem:[NSMenuItem separatorItem]];
|
||||
[self.statusItem.menu addItemWithTitle:NSLocalizedString(@"Preferences", nil) action:@selector(openPreferences) keyEquivalent:@","];
|
||||
[self.statusItem.menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"];
|
||||
[menu addItem:[NSMenuItem separatorItem]];
|
||||
[menu addItemWithTitle:NSLocalizedString(@"Preferences", nil) action:@selector(openPreferences) keyEquivalent:@","];
|
||||
[menu addItemWithTitle:NSLocalizedString(@"Quit", nil) action:@selector(terminate:) keyEquivalent:@"q"];
|
||||
}
|
||||
|
||||
- (void)mainMenuDidClose {
|
||||
[self.statusItem.menu removeAllItems];
|
||||
-(void)menuDidClose:(NSMenu *)menu {
|
||||
self.barMenu = nil;
|
||||
self.statusItem.menu = [[NSMenu alloc] initWithTitle:@"M"];
|
||||
self.statusItem.menu.delegate = self;
|
||||
self.holdingOptKey = NO;
|
||||
}
|
||||
|
||||
- (void)insertMainMenuHeader:(NSMenu*)menu {
|
||||
// 'Pause Updates' item
|
||||
NSMenuItem *pause = [menu addItemWithTitle:NSLocalizedString(@"Pause Updates", nil) action:@selector(pauseUpdates) keyEquivalent:@""];
|
||||
NSMenuItem *pause = [menu addItemWithTitle:NSLocalizedString(@"Pause updates", nil) action:@selector(pauseUpdates) keyEquivalent:@""];
|
||||
pause.target = self;
|
||||
if ([UpdateScheduler isPaused])
|
||||
pause.title = NSLocalizedString(@"Resume Updates", nil);
|
||||
pause.title = NSLocalizedString(@"Resume updates", nil);
|
||||
|
||||
// 'show hidden feeds' item
|
||||
if (UserPrefsBool(Pref_globalToggleHidden)) {
|
||||
NSMenuItem *toggleHidden = [menu addItemWithTitle:NSLocalizedString(@"Show hidden feeds", nil) action:@selector(toggleHiddenArticles) keyEquivalent:@"h"];
|
||||
toggleHidden.target = self;
|
||||
toggleHidden.enabled = !self.holdingOptKey && (UserPrefsBool(Pref_groupUnreadOnly) || UserPrefsBool(Pref_feedUnreadOnly) || UserPrefsBool(Pref_articleUnreadOnly));
|
||||
[toggleHidden setState:self.barMenu.showHidden ? NSControlStateValueOn : NSControlStateValueOff];
|
||||
if (!toggleHidden.enabled) {
|
||||
toggleHidden.toolTip = self.holdingOptKey
|
||||
? NSLocalizedString(@"Option disabled because overwritten by holding down option-key.", nil)
|
||||
: NSLocalizedString(@"Option disabled because appearance setting for “Show only unread” is disabled.", nil);
|
||||
}
|
||||
}
|
||||
|
||||
// 'Update all feeds' item
|
||||
if (UserPrefsBool(Pref_globalUpdateAll)) {
|
||||
NSMenuItem *updateAll = [menu addItemWithTitle:NSLocalizedString(@"Update all feeds", nil) action:@selector(updateAllFeeds) keyEquivalent:@""];
|
||||
@@ -182,7 +214,13 @@
|
||||
[self updateBarIcon];
|
||||
}
|
||||
|
||||
/// Called when user clicks on 'Update all feeds' (main menu only).
|
||||
/// Called when user clicks on `Show hidden feeds` (main menu only).
|
||||
- (void)toggleHiddenArticles {
|
||||
self.optShowHidden = !self.optShowHidden;
|
||||
self.barMenu.showHidden = self.optShowHidden;
|
||||
}
|
||||
|
||||
/// Called when user clicks on `Update all feeds` (main menu only).
|
||||
- (void)updateAllFeeds {
|
||||
// [self asyncReloadUnreadCount]; // should not be necessary
|
||||
[UpdateScheduler forceUpdateAllFeeds];
|
||||
|
||||
@@ -14,15 +14,12 @@
|
||||
- (instancetype)initWithCoreData:(NSArray<NSDictionary*>*)data {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
UnreadTotal *sum = [UnreadTotal new];
|
||||
_map = [NSMutableDictionary dictionaryWithCapacity:data.count];
|
||||
_map[@""] = sum;
|
||||
_map = [NSMutableDictionary dictionaryWithCapacity:data.count + 1];
|
||||
_map[@""] = [UnreadTotal new];
|
||||
|
||||
for (NSDictionary *d in data) {
|
||||
NSUInteger u = [d[@"unread"] unsignedIntegerValue];
|
||||
NSUInteger t = [d[@"total"] unsignedIntegerValue];
|
||||
sum.unread += u;
|
||||
sum.total += t;
|
||||
|
||||
for (UnreadTotal *uct in [self itemsForPath:d[@"indexPath"] create:YES]) {
|
||||
uct.unread += u;
|
||||
@@ -37,6 +34,7 @@
|
||||
- (NSArray<UnreadTotal*>*)itemsForPath:(NSString*)path create:(BOOL)flag {
|
||||
NSMutableArray<UnreadTotal*> *arr = [NSMutableArray array];
|
||||
NSMutableString *key = [NSMutableString string];
|
||||
[arr addObject:_map[@""]];
|
||||
for (NSString *idx in [path componentsSeparatedByString:@"."]) {
|
||||
if (key.length > 0)
|
||||
[key appendString:@"."];
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
@import Cocoa;
|
||||
@class FeedGroup, MapUnreadTotal;
|
||||
@class FeedGroup, MapUnreadTotal, UnreadTotal;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface NSMenu (Ext)
|
||||
@property (nonnull, copy, readonly) NSString *titleIndexPath;
|
||||
@property (nullable, readonly) NSMenuItem* parentItem;
|
||||
@property (readonly) BOOL isMainMenu;
|
||||
@property (readonly) BOOL isFeedMenu;
|
||||
|
||||
// Generator
|
||||
- (nullable NSMenuItem*)insertFeedGroupItem:(FeedGroup*)fg withUnread:(MapUnreadTotal*)unreadMap;
|
||||
- (nullable NSMenuItem*)insertFeedGroupItem:(FeedGroup*)fg withUnread:(MapUnreadTotal*)unreadMap showHidden:(BOOL)showHidden;
|
||||
- (void)insertDefaultHeader;
|
||||
// Update menu
|
||||
- (void)setHeaderHasUnread:(BOOL)hasUnread hasRead:(BOOL)hasRead;
|
||||
- (void)setHeaderHasUnread:(UnreadTotal*)count;
|
||||
- (nullable NSMenuItem*)deepestItemWithPath:(nonnull NSString*)path;
|
||||
@end
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#import "FeedGroup+Ext.h"
|
||||
#import "Constants.h"
|
||||
#import "MapUnreadTotal.h"
|
||||
#import "NotifyEndpoint.h"
|
||||
|
||||
typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
/// Used in @c allowDisplayOfHeaderItem: to identify and enable items
|
||||
@@ -36,9 +37,6 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
return [self.supermenu itemAtIndex:[self.supermenu indexOfItemWithSubmenu:self]];
|
||||
}
|
||||
|
||||
/// @return @c YES if menu is status bar menu.
|
||||
- (BOOL)isMainMenu { return (self.supermenu == nil); }
|
||||
|
||||
/// @return @c YES if menu contains feed articles only.
|
||||
- (BOOL)isFeedMenu { return ([self.title characterAtIndex:0] == 'F'); }
|
||||
|
||||
@@ -46,7 +44,7 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
#pragma mark - Generator -
|
||||
|
||||
/// Create new @c NSMenuItem with empty submenu and append it to the menu. @return Inserted item.
|
||||
- (nullable NSMenuItem*)insertFeedGroupItem:(FeedGroup*)fg withUnread:(MapUnreadTotal*)unreadMap {
|
||||
- (nullable NSMenuItem*)insertFeedGroupItem:(FeedGroup*)fg withUnread:(MapUnreadTotal*)unreadMap showHidden:(BOOL)showHidden {
|
||||
unichar chr = '-';
|
||||
NSMenuItem *item = nil;
|
||||
switch (fg.type) {
|
||||
@@ -59,10 +57,10 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
NSUInteger unread = unreadMap[[t substringFromIndex:2]].unread;
|
||||
|
||||
// Check user preferences to show only unread entries
|
||||
if (unread == 0 &&
|
||||
((fg.type == FEED && UserPrefsBool(Pref_groupUnreadOnly)) ||
|
||||
(fg.type == GROUP && UserPrefsBool(Pref_globalUnreadOnly)))) {
|
||||
return nil;
|
||||
if (unread == 0 && !showHidden
|
||||
&& ((fg.type == GROUP && UserPrefsBool(Pref_groupUnreadOnly))
|
||||
|| (fg.type == FEED && UserPrefsBool(Pref_feedUnreadOnly)))) {
|
||||
item.hidden = YES;
|
||||
}
|
||||
|
||||
item.submenu = [[NSMenu alloc] initWithTitle:t];
|
||||
@@ -77,8 +75,11 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
self.autoenablesItems = NO;
|
||||
NSMenuItem *itm = [self addItemIfAllowed:TagOpenAllUnread title:NSLocalizedString(@"Open all unread", nil)];
|
||||
if (itm) {
|
||||
NSString *altTitle = [NSString stringWithFormat:NSLocalizedString(@"Open a few unread (%lu)", nil), UserPrefsUInt(Pref_openFewLinksLimit)];
|
||||
[self addItem:[itm alternateWithTitle:altTitle]];
|
||||
NSInteger limit = UserPrefsInt(Pref_openFewLinksLimit);
|
||||
if (limit > 0) {
|
||||
NSString *altTitle = [NSString stringWithFormat:NSLocalizedString(@"Open a few unread (%ld)", nil), limit];
|
||||
[self addItem:[itm alternateWithTitle:altTitle]];
|
||||
}
|
||||
}
|
||||
[self addItemIfAllowed:TagMarkAllRead title:NSLocalizedString(@"Mark all read", nil)];
|
||||
[self addItemIfAllowed:TagMarkAllUnread title:NSLocalizedString(@"Mark all unread", nil)];
|
||||
@@ -95,7 +96,9 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
|
||||
|
||||
/// Loop over default header and enable 'OpenAllUnread' and 'TagMarkAllRead' based on unread count.
|
||||
- (void)setHeaderHasUnread:(BOOL)hasUnread hasRead:(BOOL)hasRead {
|
||||
- (void)setHeaderHasUnread:(UnreadTotal*)count {
|
||||
BOOL hasUnread = count.unread > 0;
|
||||
BOOL hasRead = count.unread < count.total;
|
||||
NSInteger i = [self indexOfItemWithTag:TagHeaderDelimiter] - 1;
|
||||
for (; i >= 0; i--) {
|
||||
NSMenuItem *item = [self itemAtIndex:i];
|
||||
@@ -138,7 +141,7 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
static NSString* const mr[] = {Pref_globalMarkRead, Pref_groupMarkRead, Pref_feedMarkRead};
|
||||
static NSString* const mu[] = {Pref_globalMarkUnread, Pref_groupMarkUnread, Pref_feedMarkUnread};
|
||||
static NSString* const ou[] = {Pref_globalOpenUnread, Pref_groupOpenUnread, Pref_feedOpenUnread};
|
||||
int i = (self.isMainMenu ? 0 : (self.isFeedMenu ? 2 : 1));
|
||||
int i = (self.supermenu == nil ? 0 : (self.isFeedMenu ? 2 : 1));
|
||||
switch (tag) {
|
||||
case TagMarkAllRead: return UserPrefsBool(mr[i]);
|
||||
case TagMarkAllUnread: return UserPrefsBool(mu[i]);
|
||||
@@ -165,7 +168,7 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
BOOL openLinks = NO;
|
||||
NSUInteger limit = 0;
|
||||
if (sender.tag == TagOpenAllUnread) {
|
||||
if (sender.isAlternate)
|
||||
if (sender.isAlternate) // if reaches this far, limit is guaranteed to be >0
|
||||
limit = UserPrefsUInt(Pref_openFewLinksLimit);
|
||||
openLinks = YES;
|
||||
} else if (sender.tag != TagMarkAllRead && sender.tag != TagMarkAllUnread) {
|
||||
@@ -182,27 +185,8 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
}
|
||||
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
|
||||
NSArray<FeedArticle*> *list = [StoreCoordinator articlesAtPath:path isFeed:isFeedMenu sorted:openLinks unread:markRead inContext:moc limit:limit];
|
||||
|
||||
BOOL success = NO;
|
||||
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)
|
||||
success = UserPrefsOpenURLs(urls);
|
||||
}
|
||||
// if success == NO, do not modify unread state
|
||||
if (!openLinks || success) {
|
||||
for (FeedArticle *fa in list) {
|
||||
fa.unread = !markRead;
|
||||
}
|
||||
[StoreCoordinator saveContext:moc andParent:YES];
|
||||
[moc reset];
|
||||
NSNumber *num = [NSNumber numberWithInteger: (markRead ? -1 : +1) * (NSInteger)list.count ];
|
||||
PostNotification(kNotificationTotalUnreadCountChanged, num);
|
||||
}
|
||||
[NotifyEndpoint dismiss:
|
||||
[StoreCoordinator updateArticles:list markRead:markRead andOpen:openLinks inContext:moc]];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -218,10 +202,7 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
NSMenuItem *alt = [self copy];
|
||||
alt.title = title;
|
||||
alt.keyEquivalentModifierMask = NSEventModifierFlagOption;
|
||||
if (!alt.hidden) { // hidden will be ignored if alternate is YES
|
||||
alt.hidden = YES; // force hidden to hide if menu is already open (background update)
|
||||
alt.alternate = YES;
|
||||
}
|
||||
alt.alternate = YES;
|
||||
return alt;
|
||||
}
|
||||
|
||||
@@ -234,11 +215,12 @@ typedef NS_ENUM(NSInteger, MenuItemTag) {
|
||||
if (loc != NSNotFound)
|
||||
self.title = [self.title substringToIndex:loc];
|
||||
}
|
||||
if (count > 0 && UserPrefsBool(self.submenu.isFeedMenu ? Pref_feedUnreadCount : Pref_groupUnreadCount)) {
|
||||
BOOL isFeed = self.submenu.isFeedMenu;
|
||||
if (count > 0 && UserPrefsBool(isFeed ? Pref_feedUnreadCount : Pref_groupUnreadCount)) {
|
||||
self.tag = TagTitleCountVisible; // apply new mask
|
||||
self.title = [self.title stringByAppendingFormat:@" (%ld)", count];
|
||||
self.onStateImage = [NSImage imageNamed:RSSImageMenuItemUnread];
|
||||
if (UserPrefsBool(Pref_groupUnreadIndicator))
|
||||
if (UserPrefsBool(isFeed ? Pref_feedUnreadIndicator : Pref_groupUnreadIndicator))
|
||||
self.state = NSControlStateValueOn;
|
||||
}
|
||||
}
|
||||
|
||||