93 Commits
v1.2.0 ... main

Author SHA1 Message Date
relikd
b194a1427d feat: add svg artwork 2025-12-09 00:30:25 +01:00
relikd
ff34781fea ref: simplify regex icon 2025-12-09 00:28:29 +01:00
relikd
4edd4448ae ref: pixel-perfect rss icon alignment 2025-12-09 00:10:54 +01:00
relikd
33f907228b ref: simplify rss icon path 2025-12-08 23:31:09 +01:00
relikd
673e0d3d48 fix: quadratic curve 2025-12-08 23:30:50 +01:00
relikd
b3fdadb9f4 feat: feed group icon 2025-12-08 22:40:11 +01:00
relikd
9fc513254f fix: pixel-perfect group icon 2025-12-08 22:31:49 +01:00
relikd
881b9db02c ref: flip coordinate system 2025-12-08 21:43:24 +01:00
relikd
3a14c90f37 ref: split svgRect and svgRoundedRect 2025-12-08 21:36:49 +01:00
relikd
96884474ac ref: unread dot icon 2025-12-08 21:21:29 +01:00
relikd
82ae18c8a5 ref: pixel-perfect main menu icon (+feed icon) 2025-12-08 21:10:20 +01:00
relikd
6eddb57651 ref: svg rss icon 2025-12-08 21:09:38 +01:00
relikd
67d17599b5 ref: default rss icon 2025-12-08 19:05:27 +01:00
relikd
3507fd8e27 feat: appearance settings article icon 2025-12-08 19:04:53 +01:00
relikd
ca417f35b6 ref: rename drawing methods 2025-12-08 17:36:48 +01:00
relikd
6e5326f913 feat: new menubar icon for Appearance settings 2025-12-08 16:32:39 +01:00
relikd
1589b23aa9 fix: TinySVG rect scaling 2025-12-08 16:31:53 +01:00
relikd
e0cd04b882 feat: new group icon (svg) 2025-12-08 14:59:48 +01:00
relikd
6b4c38ec21 feat: TinySVG support for quadratic curves 2025-12-08 14:49:40 +01:00
relikd
e7208ae2ab fix: variable name 2025-12-08 14:13:09 +01:00
relikd
508377a823 fix: limit tooltip to 2000 characters 2025-12-05 22:24:29 +01:00
relikd
2185eb76fb fix: uniform menu titles 2025-12-05 14:11:48 +01:00
relikd
8de163859b chore: bump version 2025-12-03 15:34:26 +01:00
relikd
f739b64ceb feat: add setting to show "toggle hidden" button 2025-12-03 15:06:56 +01:00
relikd
c2fda881b1 feat: add menu option to toggle hidden articles 2025-12-03 14:48:39 +01:00
relikd
a0a5b5b82d ref: tooltips on options 2025-12-03 14:15:21 +01:00
relikd
43e32b2286 ref: remove tooltip on column icon 2025-12-03 13:43:42 +01:00
relikd
205b544acd fix: undo settings migration
for whatever reason but it breaks Sandbox flag on release build
2025-12-02 20:25:59 +01:00
relikd
56f6ec1356 chore: bump version 2025-12-02 18:52:18 +01:00
relikd
ab71c51380 feat: show hidden articles by holding down option key 2025-12-02 18:48:46 +01:00
relikd
7a805ccdc4 feat: show tooltip for all appearance settings 2025-12-02 18:42:39 +01:00
relikd
f4f4bc9271 fix: annoying negative frame warning 2025-12-01 20:10:44 +01:00
relikd
64637243b5 chore: update recommended settings + xcconfig 2025-12-01 19:41:25 +01:00
relikd
5894b12c1d fix: user provided feed title for notifications (fixes #22) 2025-10-29 18:27:32 +01:00
relikd
0700eebb13 chore: update min OS in readme 2025-10-29 15:19:50 +01:00
relikd
4c4a133fe2 chore: bump version 2025-10-29 15:12:43 +01:00
relikd
ccca329630 feat: notification open options 2025-10-29 15:10:06 +01:00
relikd
831159904c chore: update changelog + bump version 2025-10-27 17:41:37 +01:00
relikd
cf3e9e4b4a feat: simplify options for show-only-unread 2025-10-27 17:36:07 +01:00
relikd
184e5c0882 fix: update menu with show-only-unread 2025-10-27 17:16:31 +01:00
relikd
575d1eaec8 fix: flipped "show only unread" (closes #21) 2025-10-27 16:21:31 +01:00
relikd
0c481d18dd chore: downscale app icon 2025-10-27 01:38:28 +01:00
relikd
c281573044 fix: localize string 2025-10-26 23:44:07 +01:00
relikd
d164c6bcb0 feat: use format "feed title: article title" 2025-10-26 23:36:15 +01:00
relikd
9f4de8fc8d chore: undo new app icon 2025-10-26 23:26:09 +01:00
relikd
c099c32cca fix: issues running Analyze 2025-10-26 23:11:15 +01:00
relikd
bdf9d11853 chore: bump version 2025-10-26 22:50:30 +01:00
relikd
c14af92289 feat: new app icon 2025-10-26 22:41:26 +01:00
relikd
b6978662fc feat: notifications help string 2025-10-26 20:34:19 +01:00
relikd
89f90ddb11 fix: dismiss global notification even if disabled 2025-10-26 20:33:54 +01:00
relikd
0b6a338fa3 feat: no global notify if marking articles read 2025-10-25 12:36:57 +02:00
relikd
3235bffdca fix: lower bound on status bar count 2025-10-25 12:36:12 +02:00
relikd
0a23819428 feat: notifications 2025-10-25 11:32:38 +02:00
relikd
def174c65f ref: StoreCoordinator updateArticles 2025-10-24 02:19:12 +02:00
relikd
e63d6c5784 feat: minimum OS 10.14 (for notifications) 2025-10-24 00:16:51 +02:00
relikd
46fa898807 ref: order code by appearance in UI 2025-10-24 00:15:14 +02:00
relikd
63509faef6 ref: ignore db save if config value unchanged 2025-10-24 00:11:18 +02:00
relikd
7047d99205 chore: re-compile with new certificate 2025-07-29 16:06:12 +02:00
relikd
614e4abb50 chore: changelog 2025-07-23 12:37:30 +02:00
relikd
20835cd155 feat: QLOPML extension 2025-07-23 12:31:40 +02:00
relikd
ba76f6a206 ref: remove QLOPML 10.9 2025-07-23 12:14:53 +02:00
relikd
256fd55d32 fix: run on macOS 10.15 2025-07-23 10:37:04 +02:00
relikd
4eb2248142 chore: update changelog 2025-07-21 14:02:07 +02:00
relikd
6ef23ef599 fix: list item whitespace in html to plain text 2025-07-21 13:57:30 +02:00
relikd
f65c5b9546 chore: update changelog 2025-07-21 13:21:57 +02:00
relikd
9c3814b470 fix: enable global mark read on background update 2025-07-21 13:19:13 +02:00
relikd
131bfaa14d ref: use UnreadTotal instead of two bool 2025-07-21 13:14:01 +02:00
relikd
fc6c3a3df2 fix: main menu open position (macOS 10.15) 2025-07-21 09:12:22 +02:00
relikd
f2bdc5b555 chore: bump version 2025-07-21 00:57:08 +02:00
relikd
060f538240 ref: always recreate statusItem.menu 2025-07-21 00:35:21 +02:00
relikd
5eed090e9c fix: macOS 15 ignores alternate item, remove isMainMenu 2025-07-21 00:34:25 +02:00
relikd
f7872c4f80 fix: alternative menu item selection (fixes #15) 2025-07-21 00:30:12 +02:00
relikd
0fdb8d9ccc fix: welcome message position 2025-07-20 22:17:53 +02:00
relikd
5d7242cc73 chore: upgrade to new Xcode version 2025-07-20 20:27:42 +02:00
relikd
b846319335 chore: update changelog, readme and version 2025-06-24 16:18:14 +02:00
relikd
82e9365272 feat: skip icon download during regex edit 2025-06-24 15:36:45 +02:00
relikd
839eee7d39 feat: regex converter 2025-06-24 15:36:10 +02:00
relikd
f577ec1ec2 fix: keep aspect ratio for buttonIcon 2025-06-24 12:34:05 +02:00
relikd
df0b5b1c91 feat: TinySVG + regex icon 2025-06-18 15:44:48 +02:00
relikd
86f5abde0c chore: bump version 2025-06-09 13:59:13 +02:00
relikd
02759ba0be fix: prefer guid match over link match (fixes #18) 2025-06-09 13:59:13 +02:00
relikd
3189015ce1 fix: feed icon size inside button bounds 2025-06-09 13:50:23 +02:00
relikd
6cf86d3bf8 docs: note on auto start (#12) 2023-10-13 01:16:14 +02:00
relikd
fb8f5be289 fix: feed menu sporadically not opening (#9) 2023-06-18 12:40:49 +02:00
relikd
51e1f07531 Version 1.2.1 release 2023-06-17 17:07:00 +02:00
relikd
b21cc20746 chore: update about page 2023-06-17 16:53:10 +02:00
relikd
be600b6c5f fix: initial autoresize on about page 2023-06-17 16:31:34 +02:00
relikd
a9c3ccc1f7 fix: use actual FlexibleSpace toolbar item 2023-06-17 16:31:34 +02:00
relikd
c4c5559d2d fix: autoresize issues (the 2nd) 2023-06-17 16:31:26 +02:00
relikd
68b25d10dd fix: auto column width for Feeds 2023-06-16 21:46:01 +02:00
relikd
24c785662a ref: remove Carthage dependency 2023-06-16 20:46:42 +02:00
relikd
2a589f51a8 fix: autoresize on Ventura 2023-06-16 19:08:26 +02:00
relikd
30527d50e6 fix: add missing changelog link 2022-10-01 18:14:59 +02:00
79 changed files with 2739 additions and 554 deletions

View File

@@ -5,12 +5,99 @@ 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.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 +118,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 +127,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 +172,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 +184,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 +245,21 @@ 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.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

View File

@@ -1 +0,0 @@
github "relikd/RSXML2" "v2.0.1"

View File

@@ -1 +0,0 @@
github "relikd/RSXML2" "v2.0.1"

6
Config-debug.xcconfig Normal file
View 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
View 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.5.5
PRODUCT_NAME = baRSS
PRODUCT_BUNDLE_IDENTIFIER = de.relikd.baRSS
CURRENT_PROJECT_VERSION = 16970

View 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
View 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>

View File

@@ -0,0 +1,5 @@
#import <Cocoa/Cocoa.h>
@interface PreviewViewController : NSViewController
@end

View 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

View 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
View 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
View 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
View 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; }
}

View File

@@ -1,4 +1,4 @@
[![macOS 10.12+](https://img.shields.io/badge/macOS-10.12+-888)](#download--install)
[![macOS 10.14+](https://img.shields.io/badge/macOS-10.14+-888)](#download--install)
[![Current release](https://img.shields.io/github/release/relikd/baRSS)](https://github.com/relikd/baRSS/releases)
[![All downloads](https://img.shields.io/github/downloads/relikd/baRSS/total)](https://github.com/relikd/baRSS/releases)
[![GitHub license](https://img.shields.io/github/license/relikd/baRSS)](LICENSE)
@@ -35,25 +35,27 @@ 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, [RSXML2] \(required), and [QLOPML] \(optional).
```sh
git clone https://github.com/relikd/baRSS
git clone https://github.com/relikd/RSXML2
git clone https://github.com/relikd/QLOPML
```
Next, you need to clone [QLOPML](https://github.com/relikd/QLOPML) in the same folder where this project is.
Alternatively, you can simply delete the `QLOPML` project reference without much harm.
`QLOPML` is a Quick Look plugin for `.opml` files.
It will display the file contents whenever you hit spacebar.
That's it.
Open Xcode and build the project.
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 +64,71 @@ 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.
1. When holding down the option key, the menu will show an item to open only a few unread items at a time.
This number can be changed with the following Terminal command (default: 10):
```
defaults write de.relikd.baRSS openFewLinksLimit -int 10
```
3. In preferences you can choose to show 'Short article names'.
2. In preferences you can choose to show 'Short article names'.
This will limit the number of displayed characters to 60 (default).
With this Terminal command you can customize this limit:
```
defaults write de.relikd.baRSS shortArticleNamesLimit -int 50
```
4. Limit the number of displayed articles per feed menu.
3. Each article menu item shows a summary tooltip (if the server provides one).
By default, the tooltip is limited to 2000 characters.
You can change the limit with this command:
```
defaults write de.relikd.baRSS tooltipCharacterLimit -int 500
```
3. Limit the number of displayed articles per feed menu.
**Note:** displayed unread count may be different than the unread items inside. 'Open all unread' will open hidden items too.
```
defaults write de.relikd.baRSS articlesInMenuLimit -int 40
```
5. You can change the appearance of colors throughout the application.
4. You can change the appearance of colors throughout the application.
E.g., The tint color of the menu bar icon and the color of the blue unread articles dot.
```
defaults write de.relikd.baRSS colorStatusIconTint -string "#37F"
defaults write de.relikd.baRSS colorUnreadIndicator -string "#FBA33A"
```
6. To backup your list of subscribed feeds, here is a one-liner:
5. To backup your list of subscribed feeds, here is a one-liner:
```
open barss:backup && cp "$HOME/Library/Containers/de.relikd.baRSS/Data/Library/Application Support/baRSS/backup/feeds_latest.opml" "$HOME/Desktop/baRSS_backup_$(date "+%Y-%m-%d").opml"
```
@@ -105,15 +138,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
@@ -165,15 +197,17 @@ 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: __2053h+__
- First prototype used __feedparser python__ library
[QLOPML]: https://github.com/relikd/QLOPML
[RSXML2]: https://github.com/relikd/RSXML2
[RSXML]: https://github.com/brentsimmons/RSXML

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 50;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -12,12 +12,17 @@
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 */; };
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 +30,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 +55,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 +70,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 +100,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 +131,33 @@
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>"; };
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 +166,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 +180,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 +192,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 +214,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 +244,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 +274,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 +310,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 +334,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 +356,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 +383,7 @@
isa = PBXGroup;
children = (
54ACC27C21061B3B0020715F /* baRSS Beta.app */,
54AD90E72E30C48400160925 /* QLOPML.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -328,8 +398,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 +429,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 +513,8 @@
54209E932117325100F3B5EF /* DrawImage.m */,
54910065233A4D4000858AE2 /* URLScheme.h */,
54910066233A4D4000858AE2 /* URLScheme.m */,
54229F532E02491A0019ACB0 /* TinySVG.h */,
54229F542E02491A0019ACB0 /* TinySVG.m */,
);
path = Helper;
sourceTree = "<group>";
@@ -442,27 +539,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 +594,9 @@
};
};
};
54AD90E62E30C48400160925 = {
CreatedOnToolsVersion = 12.4;
};
};
};
buildConfigurationList = 54ACC27721061B3B0020715F /* Build configuration list for PBXProject "baRSS" */;
@@ -495,23 +612,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 +652,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 +674,7 @@
);
inputPaths = (
);
name = "dynamic app name in db migration";
outputFileListPaths = (
);
outputPaths = (
@@ -572,8 +691,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 +706,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 +721,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 +730,53 @@
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 */,
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 +810,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 +826,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 +834,7 @@
};
54ACC28F21061B3C0020715F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 546BD1882EDE156000943942 /* Config.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
@@ -712,7 +869,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 +882,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 +903,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 +931,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 +952,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 +980,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 +1045,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 */

View File

@@ -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">

View File

@@ -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 {

Binary file not shown.

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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 -

View File

@@ -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

View File

@@ -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,6 +27,21 @@
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;
@@ -46,7 +63,14 @@
item.state = (self.unread && UserPrefsBool(Pref_feedUnreadIndicator) ? NSControlStateValueOn : NSControlStateValueOff);
item.onStateImage = [NSImage imageNamed:RSSImageMenuItemUnread];
item.accessibilityLabel = (self.unread ? NSLocalizedString(@"article: unread", @"accessibility label, feed menu item") : NSLocalizedString(@"article: read", @"accessibility label, feed menu item"));
item.toolTip = (self.abstract ? self.abstract : self.body); // fall back to body (html)
// truncate tooltip
NSUInteger limit = UserPrefsUInt(Pref_tooltipCharacterLimit);
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 +91,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

View 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

View 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

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"
@@ -129,7 +131,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 +149,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 +160,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 +172,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 +180,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 +190,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) {

View File

@@ -1,6 +1,7 @@
#import "DrawImage.h"
#import "Constants.h"
#import "NSColor+Ext.h"
#import "TinySVG.h"
@implementation DrawSeparator
@@ -22,146 +23,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 +60,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;
CGContextSetFillColorWithColor(c, [NSColor controlTextColor].CGColor);
// menu bar
CGContextSetAlpha(c, .23);
const CGFloat barHeightInset = round(size*.06);
svgRect(c, 1, CGRectInset(r, 0, barHeightInset));
CGContextFillPath(c);
const CGFloat offset = round(size*.75);
const CGFloat iconInset = round(size*.2);
const CGFloat iconCorner = size*.12;
CGContextSetAlpha(c, .66);
// 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/TinySVG.h Normal file
View 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
View 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));
}

View File

@@ -13,13 +13,14 @@
/** 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 NO */ static NSString* const Pref_globalToggleHidden = @"globalToggleHidden";
/** 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";
@@ -38,6 +39,7 @@
// ------ 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 2k */ static NSString* const Pref_tooltipCharacterLimit = @"tooltipCharacterLimit";
/** 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 +51,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 +83,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

View File

@@ -20,7 +20,8 @@ void UserPrefsInit(void) {
Pref_feedUnreadIndicator
]);
defaultsAppend(defs, @NO, @[
Pref_globalUnreadOnly, Pref_groupUnreadOnly, Pref_feedUnreadOnly,
Pref_globalToggleHidden,
Pref_groupUnreadOnly, Pref_feedUnreadOnly,
Pref_groupUnreadIndicator,
Pref_feedTruncateTitle,
Pref_feedLimitArticles
@@ -28,6 +29,7 @@ void UserPrefsInit(void) {
// 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:2000] forKey:Pref_tooltipCharacterLimit];
[defs setObject:[NSNumber numberWithUnsignedInteger:40] forKey:Pref_articlesInMenuLimit];
[defs setObject:[NSNumber numberWithUnsignedInteger:1] forKey:Pref_prefSelectedTab]; // feed tab
[[NSUserDefaults standardUserDefaults] registerDefaults:defs];
@@ -44,3 +46,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";
}
}

View File

@@ -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>

View File

@@ -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];
}

View File

@@ -69,6 +69,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;
}

View 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

View 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

View File

@@ -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)'
@@ -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];

View File

@@ -16,31 +16,99 @@
@implementation SettingsAppearanceView
- (instancetype)init {
self = [super initWithFrame: NSZeroRect];
self = [super initWithFrame:NSMakeRect(0, 0, 320, 327)];
// Insert matrix header (icons above checkbox matrix)
ColumnIcon(self, X__, RSSImageSettingsGlobal, NSLocalizedString(@"Show in menu bar", nil));
ColumnIcon(self, _X_, RSSImageSettingsGroup, NSLocalizedString(@"Show in group menu", nil));
ColumnIcon(self, __X, RSSImageSettingsFeed, NSLocalizedString(@"Show in feed menu", nil));
ColumnIcon(self, X__, RSSImageSettingsGlobalMenu);
ColumnIcon(self, _X_, RSSImageSettingsGroup);
ColumnIcon(self, __X, RSSImageSettingsFeed);
// 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 entry:NSLocalizedString(@"Tint menu bar icon on unread", nil)
help:NSLocalizedString(@"If active, a color will indicate if there are unread articles.", nil)
tip:nil
c1:Pref_globalTintMenuIcon c1tt:NSLocalizedString(@"menu bar icon", nil)
c2:nil c2tt:nil
c3:nil c3tt:nil];
[self entry:NSLocalizedString(@"Update all feeds", nil)
help:NSLocalizedString(@"Show button in main menu to reload all feeds. This will force fetch new online content regardless of next-update timer.", nil)
tip:nil
c1:Pref_globalUpdateAll c1tt:NSLocalizedString(@"in main menu", nil)
c2:nil c2tt:nil
c3:nil c3tt:nil];
[self entry:NSLocalizedString(@"Toggle “Show hidden articles”", nil)
help:NSLocalizedString(@"Show button in main menu to quickly toggle whether hidden articles should be shown. See option “Show only unread”.", nil)
tip:nil
c1:Pref_globalToggleHidden c1tt:NSLocalizedString(@"in main menu", nil)
c2:nil c2tt:nil
c3:nil c3tt:nil];
[self entry:NSLocalizedString(@"Open all unread", nil)
help:NSLocalizedString(@"Show button to open unread articles.", nil)
tip:NSLocalizedString(@"If you hold down option-key, this will become an “open a few” unread articles button.", nil)
c1:Pref_globalOpenUnread c1tt: NSLocalizedString(@"in main menu", nil)
c2:Pref_groupOpenUnread c2tt: NSLocalizedString(@"in group menu", nil)
c3:Pref_feedOpenUnread c3tt: NSLocalizedString(@"in feed menu", nil)];
[self entry:NSLocalizedString(@"Mark all read", nil)
help:NSLocalizedString(@"Show button to mark articles read.", nil)
tip:nil
c1:Pref_globalMarkRead c1tt: NSLocalizedString(@"in main menu", nil)
c2:Pref_groupMarkRead c2tt: NSLocalizedString(@"in group menu", nil)
c3:Pref_feedMarkRead c3tt: NSLocalizedString(@"in feed menu", nil)];
[self entry:NSLocalizedString(@"Mark all unread", nil)
help:NSLocalizedString(@"Show button to mark articles unread.", nil)
tip:NSLocalizedString(@"You can hold down option-key and click on an article to toggle that item (un-)read.", nil)
c1:Pref_globalMarkUnread c1tt: NSLocalizedString(@"in main menu", nil)
c2:Pref_groupMarkUnread c2tt: NSLocalizedString(@"in group menu", nil)
c3:Pref_feedMarkUnread c3tt: NSLocalizedString(@"in feed menu", nil)];
[self entry:NSLocalizedString(@"Number of unread articles", nil)
help:NSLocalizedString(@"Show count of unread articles in parenthesis.", nil)
tip:nil
c1:Pref_globalUnreadCount c1tt:NSLocalizedString(@"on menu bar icon", nil)
c2:Pref_groupUnreadCount c2tt:NSLocalizedString(@"on group folder", nil)
c3:Pref_feedUnreadCount c3tt:NSLocalizedString(@"on feed folder", nil)];
[self entry:NSLocalizedString(@"Indicator for unread articles", nil)
help:NSLocalizedString(@"Show blue dot on menu items with unread articles.", nil)
tip:nil
c1:nil c1tt:nil
c2:Pref_groupUnreadIndicator c2tt:NSLocalizedString(@"on group & feed folder", nil)
c3:Pref_feedUnreadIndicator c3tt:NSLocalizedString(@"on article entry", nil)];
[self entry:NSLocalizedString(@"Show only unread", nil)
help:NSLocalizedString(@"Hide articles which have been read.", nil)
tip:NSLocalizedString(@"You can hold down option-key before opening the main menu to temporarily disable this setting.", nil)
c1:nil c1tt:nil
c2:Pref_groupUnreadOnly c2tt:NSLocalizedString(@"hide group & feed folders with 0 unread articles", nil)
c3:Pref_feedUnreadOnly c3tt:NSLocalizedString(@"hide articles inside of feed folder", nil)];
[self entry:NSLocalizedString(@"Truncate article title", nil)
help:NSLocalizedString(@"Truncate article title after 60 characters. If a title is longer than that, show an ellipsis character “…” instead.", nil)
tip:nil
c1:nil c1tt:nil
c2:nil c2tt:nil
c3:Pref_feedTruncateTitle c3tt:NSLocalizedString(@"article title", nil)];
[self entry:NSLocalizedString(@"Limit number of articles", nil)
help:NSLocalizedString(@"Display at most 40 articles in feed menu. Remaining articles will be hidden from view but are still there. Unread count may be confusing as it will also count unread and hidden articles.", nil)
tip:nil
c1:nil c1tt:nil
c2:nil c2tt:nil
c3:Pref_feedLimitArticles c3tt:NSLocalizedString(@"in feed menu", nil)];
[[[[[NSView label:@"Note: you can hover over all options to display explanatory tooltips."]
multiline:NSMakeSize(100, 2 * HEIGHT_LABEL)] gray]
placeIn:self x:PAD_WIN yTop:self.y + PAD_L] sizeToRight:PAD_WIN];
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];
static inline void ColumnIcon(id this, CGFloat x, const NSImageName img) {
[[NSView imageView:img size:IconSize] placeIn:this x:x yTop:PAD_WIN];
}
/// Helper method for generating a checkbox
@@ -51,14 +119,22 @@ static inline NSButton* Checkbox(id this, CGFloat x, CGFloat y, NSString *key) {
}
/// Create new entry with 1-3 checkboxes and a descriptive label
- (NSTextField*)entry:(NSString*)label c1:(NSString*)pref1 c2:(NSString*)pref2 c3:(NSString*)pref3 {
- (NSTextField*)entry:(NSString*)label help:(NSString*)ttip tip:(NSString*)extraTip
c1:(NSString*)pref1 c1tt:(NSString*)ttip1
c2:(NSString*)pref2 c2tt:(NSString*)ttip2
c3:(NSString*)pref3 c3tt:(NSString*)ttip3
{
CGFloat y = self.y;
self.y += (PAD_S + HEIGHT_LABEL);
// TODO: localize: global, group, feed
if (pref1) Checkbox(self, X__ + 2, y + 2, pref1).accessibilityLabel = [label stringByAppendingString:@" (global)"];
if (pref2) Checkbox(self, _X_ + 2, y + 2, pref2).accessibilityLabel = [label stringByAppendingString:@" (group)"];
if (pref3) Checkbox(self, __X + 2, y + 2, pref3).accessibilityLabel = [label stringByAppendingString:@" (feed)"];
return [[[NSView label:label] placeIn:self x:PAD_WIN + 3 * colWidth yTop:y] sizeToRight:PAD_WIN];
if (pref1) [Checkbox(self, X__ + 2, y + 2, pref1) tooltip:ttip1].accessibilityLabel = [label stringByAppendingString:@" (global)"];
if (pref2) [Checkbox(self, _X_ + 2, y + 2, pref2) tooltip:ttip2].accessibilityLabel = [label stringByAppendingString:@" (group)"];
if (pref3) [Checkbox(self, __X + 2, y + 2, pref3) tooltip:ttip3].accessibilityLabel = [label stringByAppendingString:@" (feed)"];
if (extraTip != nil) {
label = [label stringByAppendingString:@" *"];
ttip = [ttip stringByAppendingFormat:@"\n\n* Tip: %@", extraTip];
}
return [[[[NSView label:label] placeIn:self x:PAD_WIN + 3 * colWidth yTop:y] sizeToRight:PAD_WIN] tooltip:ttip];
}
@end

View File

@@ -13,6 +13,7 @@ NS_ASSUME_NONNULL_BEGIN
@interface ModalFeedEdit : ModalEditDialog <NSTextFieldDelegate>
- (void)didClickWarningButton:(NSButton*)sender;
- (void)openRegexConverter;
@end
@interface ModalGroupEdit : ModalEditDialog

View File

@@ -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];
}
@@ -102,7 +116,8 @@
[f.meta setRefreshIfChanged:intv];
if (self.memFeed) {
[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;
}
}
@@ -121,9 +136,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 +148,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 +201,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 +229,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 +322,7 @@
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url];
return NO;
}
[NSEvent removeMonitor:self.eventMonitor];
return YES;
}

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
#import "ModalFeedEditView.h"
#import "ModalFeedEdit.h"
#import "NSView+Ext.h"
#import "Constants.h"
@interface StrictUIntFormatter : NSFormatter
@end
@@ -14,7 +15,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,7 +26,8 @@
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
@@ -34,6 +36,10 @@
// 3. row
self.refreshNum = [[NSView inputField:@"30" width:85] placeIn:self x:x yTop:2*rowHeight];
self.refreshUnit = [[NSView popupButton:120] placeIn:self x:NSMaxX(self.refreshNum.frame) + PAD_M yTop:2*rowHeight];
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];
@@ -41,6 +47,7 @@
self.refreshNum.accessibilityLabel = NSLocalizedString(@"Refresh interval", nil);
self.url.delegate = controller;
self.warningButton.hidden = YES;
self.regexConverterButton.hidden = YES;
self.refreshNum.formatter = [StrictUIntFormatter new]; // see below ...
[self prepareWarningPopover];
return self;

View File

@@ -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];

View File

@@ -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

View File

@@ -13,7 +13,7 @@
@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
@@ -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'
@@ -224,7 +224,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;

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View 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

View 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

View 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

View 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

View 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

View 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 = [self wrapContent:tv inScrollView:NSMakeRect(-1, NSHeight(self.frame) - heightHowTo, NSWidth(self.frame) + 2, 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;
[self wrapContent:tv inScrollView:NSMakeRect(-1, 0, NSWidth(self.frame) + 2, heightOutput)];
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

View 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

View 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

View File

@@ -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

View File

@@ -64,10 +64,9 @@
- (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.
@@ -79,14 +78,13 @@
BOOL onlyUnread = UserPrefsBool(Pref_feedUnreadOnly);
for (FeedArticle *fa in sortedList) {
if (onlyUnread && !fa.unread)
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 +129,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];
}
}

View File

@@ -2,7 +2,7 @@
NS_ASSUME_NONNULL_BEGIN
@interface BarStatusItem : NSObject
@interface BarStatusItem : NSObject <NSMenuDelegate>
@property (weak, readonly) NSMenu *mainMenu;
- (void)setUnreadCountAbsolute:(NSUInteger)count;

View File

@@ -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 Articles"` 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).
@@ -144,27 +153,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 articles' item
if (UserPrefsBool(Pref_globalToggleHidden)) {
NSMenuItem *toggleHidden = [menu addItemWithTitle:NSLocalizedString(@"Show hidden articles", nil) action:@selector(toggleHiddenArticles) keyEquivalent:@"h"];
toggleHidden.target = self;
toggleHidden.enabled = !self.holdingOptKey && (UserPrefsBool(Pref_groupUnreadOnly) || UserPrefsBool(Pref_feedUnreadOnly));
[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,6 +211,12 @@
[self updateBarIcon];
}
/// Called when user clicks on 'Show Hidden Articles' (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

View File

@@ -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:@"."];

View File

@@ -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

View File

@@ -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 == FEED || fg.type == GROUP)
&& UserPrefsBool(Pref_groupUnreadOnly)) {
item.hidden = YES;
}
item.submenu = [[NSMenu alloc] initWithTitle:t];
@@ -95,7 +93,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 +138,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]);
@@ -182,27 +182,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 +199,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;
}