From 839eee7d392da9ec9667665e54814456084da40e Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 24 Jun 2025 15:36:10 +0200 Subject: [PATCH] feat: regex converter --- baRSS.xcodeproj/project.pbxproj | 38 +++++ baRSS/Core Data/Feed+Ext.m | 2 + baRSS/Core Data/FeedArticle+Ext.h | 1 + baRSS/Core Data/FeedArticle+Ext.m | 84 +++++++++++ baRSS/Core Data/RegexConverter+Ext.h | 16 ++ baRSS/Core Data/RegexConverter+Ext.m | 70 +++++++++ .../DBv1.xcdatamodel/contents | 83 ++++++----- baRSS/Feed Import/FeedDownload.h | 4 +- baRSS/Feed Import/FeedDownload.m | 45 +++++- baRSS/Feed Import/OpmlFile.m | 34 +++++ baRSS/Preferences/Feeds Tab/ModalFeedEdit.h | 1 + baRSS/Preferences/Feeds Tab/ModalFeedEdit.m | 53 ++++++- .../Preferences/Feeds Tab/ModalFeedEditView.h | 1 + .../Preferences/Feeds Tab/ModalFeedEditView.m | 9 +- baRSS/Regex Editor/RegexConverterController.h | 12 ++ baRSS/Regex Editor/RegexConverterController.m | 125 ++++++++++++++++ baRSS/Regex Editor/RegexConverterModal.h | 12 ++ baRSS/Regex Editor/RegexConverterModal.m | 60 ++++++++ baRSS/Regex Editor/RegexConverterView.h | 20 +++ baRSS/Regex Editor/RegexConverterView.m | 141 ++++++++++++++++++ baRSS/Regex Editor/RegexFeed.h | 30 ++++ baRSS/Regex Editor/RegexFeed.m | 86 +++++++++++ 22 files changed, 887 insertions(+), 40 deletions(-) create mode 100644 baRSS/Core Data/RegexConverter+Ext.h create mode 100644 baRSS/Core Data/RegexConverter+Ext.m create mode 100644 baRSS/Regex Editor/RegexConverterController.h create mode 100644 baRSS/Regex Editor/RegexConverterController.m create mode 100644 baRSS/Regex Editor/RegexConverterModal.h create mode 100644 baRSS/Regex Editor/RegexConverterModal.m create mode 100644 baRSS/Regex Editor/RegexConverterView.h create mode 100644 baRSS/Regex Editor/RegexConverterView.m create mode 100644 baRSS/Regex Editor/RegexFeed.h create mode 100644 baRSS/Regex Editor/RegexFeed.m diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index ec57703..57af8c4 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -13,6 +13,10 @@ 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 */; }; 54501010230E9C8600F0B165 /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 5450100F230E9C8600F0B165 /* FeedDownload.m */; }; @@ -45,6 +49,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 */; }; @@ -130,6 +135,14 @@ 54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = ""; }; 54229F532E02491A0019ACB0 /* TinySVG.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TinySVG.h; sourceTree = ""; }; 54229F542E02491A0019ACB0 /* TinySVG.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TinySVG.m; sourceTree = ""; }; + 54253C7A2C47303A00742695 /* RegexConverter+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RegexConverter+Ext.h"; sourceTree = ""; }; + 54253C7E2C47303A00742695 /* RegexConverter+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "RegexConverter+Ext.m"; sourceTree = ""; }; + 54253C832C47368F00742695 /* RegexConverterView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegexConverterView.h; sourceTree = ""; }; + 54253C842C47369000742695 /* RegexConverterView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegexConverterView.m; sourceTree = ""; }; + 54253C872C49A6A800742695 /* RegexConverterController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegexConverterController.m; sourceTree = ""; }; + 54253C882C49A6A800742695 /* RegexConverterController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegexConverterController.h; sourceTree = ""; }; + 54253C8A2C49A92400742695 /* RegexConverterModal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegexConverterModal.m; sourceTree = ""; }; + 54253C8B2C49A92400742695 /* RegexConverterModal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegexConverterModal.h; sourceTree = ""; }; 544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = ""; }; 544B01192114B41200386E5C /* ModalSheet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalSheet.m; sourceTree = ""; }; 544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = ""; }; @@ -190,6 +203,8 @@ 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = ""; }; 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = ""; }; 54BF444922D0F4F300660096 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; + 54D10DD92C6E930F0008F621 /* RegexFeed.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RegexFeed.h; sourceTree = ""; }; + 54D10DDA2C6E930F0008F621 /* RegexFeed.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RegexFeed.m; sourceTree = ""; }; 54D55D7122E624CD00057B98 /* SettingsFeeds+DragDrop.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SettingsFeeds+DragDrop.h"; sourceTree = ""; }; 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SettingsFeeds+DragDrop.m"; sourceTree = ""; }; 54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RefreshStatisticsView.h; sourceTree = ""; }; @@ -240,6 +255,21 @@ path = "Status Bar Menu"; sourceTree = ""; }; + 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 = ""; + }; 544936F721F1E51E00DEE9AA /* NSCategories */ = { isa = PBXGroup; children = ( @@ -298,6 +328,8 @@ 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 */, ); @@ -344,6 +376,7 @@ 541A90EF21257D4F002680A6 /* Status Bar Menu */, 54A07A8322105E0800082C51 /* Core Data */, 54AD4E04230084FD000AE386 /* Feed Import */, + 54253C862C49A5A900742695 /* Regex Editor */, 546FC44D2118B357007CC3A3 /* Preferences */, 54ACC28A21061B3C0020715F /* Info.plist */, 54F7101322EE0DDA006985D1 /* Artwork */, @@ -606,8 +639,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 */, @@ -619,6 +654,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 */, @@ -642,11 +678,13 @@ 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 */, 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 */, ); diff --git a/baRSS/Core Data/Feed+Ext.m b/baRSS/Core Data/Feed+Ext.m index aa8c743..609c627 100644 --- a/baRSS/Core Data/Feed+Ext.m +++ b/baRSS/Core Data/Feed+Ext.m @@ -89,6 +89,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; diff --git a/baRSS/Core Data/FeedArticle+Ext.h b/baRSS/Core Data/FeedArticle+Ext.h index 69c331e..8adac94 100644 --- a/baRSS/Core Data/FeedArticle+Ext.h +++ b/baRSS/Core Data/FeedArticle+Ext.h @@ -6,6 +6,7 @@ NS_ASSUME_NONNULL_BEGIN @interface FeedArticle (Ext) + (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc; +- (void)updateArticleIfChanged:(RSParsedArticle*)entry; - (NSMenuItem*)newMenuItem; @end diff --git a/baRSS/Core Data/FeedArticle+Ext.m b/baRSS/Core Data/FeedArticle+Ext.m index ebd0453..2284955 100644 --- a/baRSS/Core Data/FeedArticle+Ext.m +++ b/baRSS/Core Data/FeedArticle+Ext.m @@ -25,6 +25,16 @@ return fa; } +- (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; @@ -71,4 +81,78 @@ [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 diff --git a/baRSS/Core Data/RegexConverter+Ext.h b/baRSS/Core Data/RegexConverter+Ext.h new file mode 100644 index 0000000..e2c942f --- /dev/null +++ b/baRSS/Core Data/RegexConverter+Ext.h @@ -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 diff --git a/baRSS/Core Data/RegexConverter+Ext.m b/baRSS/Core Data/RegexConverter+Ext.m new file mode 100644 index 0000000..d0eab87 --- /dev/null +++ b/baRSS/Core Data/RegexConverter+Ext.m @@ -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 diff --git a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents index bc9008f..2fe2e85 100644 --- a/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents +++ b/baRSS/DBv1.xcdatamodeld/DBv1.xcdatamodel/contents @@ -1,52 +1,63 @@ - + - - - - - - - + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - + + + + + + - - - - - - - + + + + + + + - - + + + + + + + + + + + - + - - + + + \ No newline at end of file diff --git a/baRSS/Feed Import/FeedDownload.h b/baRSS/Feed Import/FeedDownload.h index 12ff206..ae14a71 100644 --- a/baRSS/Feed Import/FeedDownload.h +++ b/baRSS/Feed Import/FeedDownload.h @@ -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)delegate; - (instancetype)startWithBlock:(nonnull FeedDownloadBlock)block; - (void)cancel; diff --git a/baRSS/Feed Import/FeedDownload.m b/baRSS/Feed Import/FeedDownload.m index 47866b3..8efd67d 100644 --- a/baRSS/Feed Import/FeedDownload.m +++ b/baRSS/Feed Import/FeedDownload.m @@ -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)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 *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]; diff --git a/baRSS/Feed Import/OpmlFile.m b/baRSS/Feed Import/OpmlFile.m index ef887da..2fae3b1 100644 --- a/baRSS/Feed Import/OpmlFile.m +++ b/baRSS/Feed Import/OpmlFile.m @@ -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; diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h index b75fdc2..c78a99f 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h @@ -13,6 +13,7 @@ NS_ASSUME_NONNULL_BEGIN @interface ModalFeedEdit : ModalEditDialog - (void)didClickWarningButton:(NSButton*)sender; +- (void)openRegexConverter; @end @interface ModalGroupEdit : ModalEditDialog diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index 0ba7389..1604be7 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -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,8 @@ @property (strong) FeedDownload *memFeed; @property (weak) FaviconDownload *memIcon; @property (strong) RefreshStatisticsView *statisticsView; +@property (nonatomic, assign) BOOL openRegexAfterDownload; +@property (weak) id eventMonitor; @end @implementation ModalFeedEdit @@ -71,6 +76,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 +93,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]; } @@ -131,7 +144,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]; } /** @@ -210,8 +225,43 @@ - (void)downloadComplete { [self.view.spinnerURL stopAnimation:nil]; [self.modalSheet setDoneEnabled:YES]; + + if (self.openRegexAfterDownload) { + [self openRegexConverter]; + } } + +#pragma mark - Regex Converter + +- (void)openRegexConverter { + if (!self.openRegexAfterDownload) { + self.openRegexAfterDownload = YES; + [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]; + + Feed *feed = self.feedGroup.feed; + RegexConverterController *c = [RegexConverterController withData:self.memFeed.rawData andConverter: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:feed]; + self.view.regexConverterButton.hidden = !feed.regex; + [self downloadRSS]; + } + }]; +} + + #pragma mark - Feed Statistics /// Perform statistics on newly downloaded feed item @@ -264,6 +314,7 @@ [[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url]; return NO; } + [NSEvent removeMonitor:self.eventMonitor]; return YES; } diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEditView.h b/baRSS/Preferences/Feeds Tab/ModalFeedEditView.h index 6ed444b..525220b 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEditView.h +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEditView.h @@ -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; diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEditView.m b/baRSS/Preferences/Feeds Tab/ModalFeedEditView.m index aecc6d8..4732fe2 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEditView.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEditView.m @@ -1,6 +1,7 @@ #import "ModalFeedEditView.h" #import "ModalFeedEdit.h" #import "NSView+Ext.h" +#import "Constants.h" @interface StrictUIntFormatter : NSFormatter @end @@ -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; diff --git a/baRSS/Regex Editor/RegexConverterController.h b/baRSS/Regex Editor/RegexConverterController.h new file mode 100644 index 0000000..156ebb6 --- /dev/null +++ b/baRSS/Regex Editor/RegexConverterController.h @@ -0,0 +1,12 @@ +@import Cocoa; +@class RegexConverter, RegexConverterModal, Feed; + +NS_ASSUME_NONNULL_BEGIN + +@interface RegexConverterController : NSViewController ++ (instancetype)withData:(NSData *)data andConverter:(nullable RegexConverter*)converter; +- (RegexConverterModal*)getModalSheet; +- (void)applyChanges:(Feed *)feed; +@end + +NS_ASSUME_NONNULL_END diff --git a/baRSS/Regex Editor/RegexConverterController.m b/baRSS/Regex Editor/RegexConverterController.m new file mode 100644 index 0000000..3a46804 --- /dev/null +++ b/baRSS/Regex Editor/RegexConverterController.m @@ -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() +@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 *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 diff --git a/baRSS/Regex Editor/RegexConverterModal.h b/baRSS/Regex Editor/RegexConverterModal.h new file mode 100644 index 0000000..6b83475 --- /dev/null +++ b/baRSS/Regex Editor/RegexConverterModal.h @@ -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 diff --git a/baRSS/Regex Editor/RegexConverterModal.m b/baRSS/Regex Editor/RegexConverterModal.m new file mode 100644 index 0000000..0296a42 --- /dev/null +++ b/baRSS/Regex Editor/RegexConverterModal.m @@ -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)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 diff --git a/baRSS/Regex Editor/RegexConverterView.h b/baRSS/Regex Editor/RegexConverterView.h new file mode 100644 index 0000000..513bd1c --- /dev/null +++ b/baRSS/Regex Editor/RegexConverterView.h @@ -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 diff --git a/baRSS/Regex Editor/RegexConverterView.m b/baRSS/Regex Editor/RegexConverterView.m new file mode 100644 index 0000000..a58497a --- /dev/null +++ b/baRSS/Regex Editor/RegexConverterView.m @@ -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)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 *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 *)examplesFor:(NSInteger)tag { + switch (tag) { + case 700: return @[ // entries + @"].*?<\\/dd>", + ]; + case 701: return @[ // link + @"href=\"([^\"]*)\"", + ]; + case 702: return @[ // title + @"title=\"([^\"]*)\"", + @">([^\\s<]*?)<\\/span>" + ]; + case 703: return @[ // description + @"]*>(.*?)<\\/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 diff --git a/baRSS/Regex Editor/RegexFeed.h b/baRSS/Regex Editor/RegexFeed.h new file mode 100644 index 0000000..616fe0a --- /dev/null +++ b/baRSS/Regex Editor/RegexFeed.h @@ -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*)process:(NSString*)rawData error:(NSError * __autoreleasing *)err; +@end + +NS_ASSUME_NONNULL_END diff --git a/baRSS/Regex Editor/RegexFeed.m b/baRSS/Regex Editor/RegexFeed.m new file mode 100644 index 0000000..7c4ab57 --- /dev/null +++ b/baRSS/Regex Editor/RegexFeed.m @@ -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*)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 *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 *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