feat: regex converter

This commit is contained in:
relikd
2025-06-24 15:36:10 +02:00
parent f577ec1ec2
commit 839eee7d39
22 changed files with 887 additions and 40 deletions

View File

@@ -13,6 +13,10 @@
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */ = {isa = PBXBuildFile; fileRef = 541C67C22255470B004D2CE6 /* SettingsAppearance.m */; }; 541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */ = {isa = PBXBuildFile; fileRef = 541C67C22255470B004D2CE6 /* SettingsAppearance.m */; };
54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; }; 54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; };
54229F552E02491A0019ACB0 /* TinySVG.m in Sources */ = {isa = PBXBuildFile; fileRef = 54229F542E02491A0019ACB0 /* TinySVG.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 */; }; 544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; };
544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; }; 544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; };
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 5450100F230E9C8600F0B165 /* FeedDownload.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 */; }; 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 */; }; 54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; };
54BF444A22D0F4F300660096 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54BF444922D0F4F300660096 /* AppIcon.icns */; }; 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 */; }; 54D55D7322E624CD00057B98 /* SettingsFeeds+DragDrop.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D55D7222E624CD00057B98 /* SettingsFeeds+DragDrop.m */; };
54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.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 */; }; 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 = "<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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = "<group>"; };
@@ -190,6 +203,8 @@
54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = "<group>"; }; 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = "<group>"; };
54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RefreshStatisticsView.h; sourceTree = "<group>"; };
@@ -240,6 +255,21 @@
path = "Status Bar Menu"; path = "Status Bar Menu";
sourceTree = "<group>"; 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 */ = { 544936F721F1E51E00DEE9AA /* NSCategories */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -298,6 +328,8 @@
5477D34D21233C62002BA27F /* FeedGroup+Ext.m */, 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */,
540F704321B6C16C0022E69D /* FeedMeta+Ext.h */, 540F704321B6C16C0022E69D /* FeedMeta+Ext.h */,
540F704421B6C16C0022E69D /* FeedMeta+Ext.m */, 540F704421B6C16C0022E69D /* FeedMeta+Ext.m */,
54253C7A2C47303A00742695 /* RegexConverter+Ext.h */,
54253C7E2C47303A00742695 /* RegexConverter+Ext.m */,
54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */, 54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */,
54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */, 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */,
); );
@@ -344,6 +376,7 @@
541A90EF21257D4F002680A6 /* Status Bar Menu */, 541A90EF21257D4F002680A6 /* Status Bar Menu */,
54A07A8322105E0800082C51 /* Core Data */, 54A07A8322105E0800082C51 /* Core Data */,
54AD4E04230084FD000AE386 /* Feed Import */, 54AD4E04230084FD000AE386 /* Feed Import */,
54253C862C49A5A900742695 /* Regex Editor */,
546FC44D2118B357007CC3A3 /* Preferences */, 546FC44D2118B357007CC3A3 /* Preferences */,
54ACC28A21061B3C0020715F /* Info.plist */, 54ACC28A21061B3C0020715F /* Info.plist */,
54F7101322EE0DDA006985D1 /* Artwork */, 54F7101322EE0DDA006985D1 /* Artwork */,
@@ -606,8 +639,10 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
54253C932C49BFCD00742695 /* RegexConverterModal.m in Sources */,
54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */, 54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */,
54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */, 54AD4E0C2301853D000AE386 /* NSString+Ext.m in Sources */,
54D10DDB2C6E930F0008F621 /* RegexFeed.m in Sources */,
546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */, 546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */,
54E9CF32225914300023696F /* SettingsAbout.m in Sources */, 54E9CF32225914300023696F /* SettingsAbout.m in Sources */,
54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */, 54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */,
@@ -619,6 +654,7 @@
54DD9F1323D1D6B000B1EAA6 /* NSColor+Ext.m in Sources */, 54DD9F1323D1D6B000B1EAA6 /* NSColor+Ext.m in Sources */,
54ACC29521061E270020715F /* UpdateScheduler.m in Sources */, 54ACC29521061E270020715F /* UpdateScheduler.m in Sources */,
54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */, 54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */,
54253C7F2C47303A00742695 /* RegexConverter+Ext.m in Sources */,
5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */, 5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */,
54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */, 54B6F14A231551B3002C94C9 /* FaviconDownload.m in Sources */,
54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */, 54E4446C2329AE0600BBF481 /* NSError+Ext.m in Sources */,
@@ -642,11 +678,13 @@
541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */, 541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */,
54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */, 54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */,
546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */, 546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */,
54253C952C49BFE400742695 /* RegexConverterView.m in Sources */,
548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */, 548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */,
54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */, 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */,
54195883218A061100581B79 /* Feed+Ext.m in Sources */, 54195883218A061100581B79 /* Feed+Ext.m in Sources */,
54501010230E9C8600F0B165 /* FeedDownload.m in Sources */, 54501010230E9C8600F0B165 /* FeedDownload.m in Sources */,
54209E942117325100F3B5EF /* DrawImage.m in Sources */, 54209E942117325100F3B5EF /* DrawImage.m in Sources */,
54253C942C49BFDC00742695 /* RegexConverterController.m in Sources */,
54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */, 54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */,
54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */, 54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */,
); );

View File

@@ -89,6 +89,8 @@
[localSet removeObject:stored]; [localSet removeObject:stored];
if (stored.sortIndex != currentIndex) if (stored.sortIndex != currentIndex)
stored.sortIndex = currentIndex; // Ensures block of ascending indices stored.sortIndex = currentIndex; // Ensures block of ascending indices
// replace local values with remote changes (if any)
[stored updateArticleIfChanged:article];
} else { } else {
FeedArticle *newArticle = [FeedArticle newArticle:article inContext:self.managedObjectContext]; FeedArticle *newArticle = [FeedArticle newArticle:article inContext:self.managedObjectContext];
newArticle.sortIndex = currentIndex; newArticle.sortIndex = currentIndex;

View File

@@ -6,6 +6,7 @@ NS_ASSUME_NONNULL_BEGIN
@interface FeedArticle (Ext) @interface FeedArticle (Ext)
+ (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc; + (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc;
- (void)updateArticleIfChanged:(RSParsedArticle*)entry;
- (NSMenuItem*)newMenuItem; - (NSMenuItem*)newMenuItem;
@end @end

View File

@@ -25,6 +25,16 @@
return fa; 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. /// @return Full or truncated article title, based on user preference in settings.
- (NSString*)shortArticleName { - (NSString*)shortArticleName {
NSString *title = self.title; NSString *title = self.title;
@@ -71,4 +81,78 @@
[moc reset]; [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 @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

@@ -1,52 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?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"> <entity name="Feed" representedClassName="Feed" syncable="YES" codeGenerationType="class">
<attribute name="indexPath" optional="YES" attributeType="String" syncable="YES"/> <attribute name="indexPath" optional="YES" attributeType="String"/>
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/> <attribute name="link" optional="YES" attributeType="String"/>
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/> <attribute name="subtitle" optional="YES" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/> <attribute name="title" optional="YES" attributeType="String"/>
<relationship name="articles" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="FeedArticle" inverseName="feed" inverseEntity="FeedArticle" syncable="YES"/> <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" syncable="YES"/> <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" syncable="YES"/> <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>
<entity name="FeedArticle" representedClassName="FeedArticle" syncable="YES" codeGenerationType="class"> <entity name="FeedArticle" representedClassName="FeedArticle" syncable="YES" codeGenerationType="class">
<attribute name="abstract" optional="YES" attributeType="String" syncable="YES"/> <attribute name="abstract" optional="YES" attributeType="String"/>
<attribute name="author" optional="YES" attributeType="String" syncable="YES"/> <attribute name="author" optional="YES" attributeType="String"/>
<attribute name="body" optional="YES" attributeType="String" syncable="YES"/> <attribute name="body" optional="YES" attributeType="String"/>
<attribute name="guid" optional="YES" attributeType="String" syncable="YES"/> <attribute name="guid" optional="YES" attributeType="String"/>
<attribute name="link" optional="YES" attributeType="String" syncable="YES"/> <attribute name="link" optional="YES" attributeType="String"/>
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO" customClassName="NSArray" syncable="YES"/> <attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO" customClassName="NSArray"/>
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/> <attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="title" optional="YES" attributeType="String" syncable="YES"/> <attribute name="title" optional="YES" attributeType="String"/>
<attribute name="unread" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/> <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" syncable="YES"/> <relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="articles" inverseEntity="Feed"/>
</entity> </entity>
<entity name="FeedGroup" representedClassName="FeedGroup" syncable="YES" codeGenerationType="class"> <entity name="FeedGroup" representedClassName="FeedGroup" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String" syncable="YES"/> <attribute name="name" optional="YES" attributeType="String"/>
<attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/> <attribute name="sortIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="type" optional="YES" attributeType="Integer 16" defaultValueString="-1" usesScalarValueType="YES" syncable="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" syncable="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" syncable="YES"/> <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" syncable="YES"/> <relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="FeedGroup" inverseName="children" inverseEntity="FeedGroup"/>
</entity> </entity>
<entity name="FeedMeta" representedClassName="FeedMeta" syncable="YES" codeGenerationType="class"> <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="errorCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="etag" optional="YES" attributeType="String" syncable="YES"/> <attribute name="etag" optional="YES" attributeType="String"/>
<attribute name="modified" optional="YES" attributeType="String" syncable="YES"/> <attribute name="modified" optional="YES" attributeType="String"/>
<attribute name="refresh" optional="YES" attributeType="Integer 32" defaultValueString="-1" usesScalarValueType="YES" syncable="YES"/> <attribute name="refresh" optional="YES" attributeType="Integer 32" defaultValueString="-1" usesScalarValueType="YES"/>
<attribute name="scheduled" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/> <attribute name="scheduled" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String" syncable="YES"/> <attribute name="url" optional="YES" attributeType="String"/>
<relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed" syncable="YES"/> <relationship name="feed" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="meta" inverseEntity="Feed"/>
</entity> </entity>
<entity name="Options" representedClassName="Options" syncable="YES" codeGenerationType="class"> <entity name="Options" representedClassName="Options" syncable="YES" codeGenerationType="class">
<attribute name="key" optional="YES" attributeType="String" syncable="YES"/> <attribute name="key" optional="YES" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="String" syncable="YES"/> <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> </entity>
<elements> <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="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="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="FeedMeta" positionX="-456.265625" positionY="62.41015625" width="128" height="150"/>
<element name="Options" positionX="-279" positionY="36" width="128" height="75"/> <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> </elements>
</model> </model>

View File

@@ -1,5 +1,5 @@
@import Cocoa; @import Cocoa;
@class RSParsedFeed, RSHTMLMetadataFeedLink, Feed, FaviconDownload; @class RSParsedFeed, RSHTMLMetadataFeedLink, Feed, FaviconDownload, RegexConverter;
@protocol FeedDownloadDelegate; @protocol FeedDownloadDelegate;
NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_BEGIN
@@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (readonly, nullable) RSParsedFeed *xmlfeed; @property (readonly, nullable) RSParsedFeed *xmlfeed;
@property (readonly, nullable) NSError *error; @property (readonly, nullable) NSError *error;
@property (readonly, nullable) NSString *faviconURL; @property (readonly, nullable) NSString *faviconURL;
@property (readonly, nullable) NSData *rawData;
typedef void (^FeedDownloadBlock)(FeedDownload *sender); typedef void (^FeedDownloadBlock)(FeedDownload *sender);
@@ -21,6 +22,7 @@ typedef void (^FeedDownloadBlock)(FeedDownload *sender);
+ (instancetype)withURL:(NSString*)url; + (instancetype)withURL:(NSString*)url;
+ (instancetype)withFeed:(Feed*)feed forced:(BOOL)flag; + (instancetype)withFeed:(Feed*)feed forced:(BOOL)flag;
// Actions // Actions
- (instancetype)withRegex:(nullable RegexConverter *)converter enforce:(BOOL)flag;
- (instancetype)startWithDelegate:(id<FeedDownloadDelegate>)delegate; - (instancetype)startWithDelegate:(id<FeedDownloadDelegate>)delegate;
- (instancetype)startWithBlock:(nonnull FeedDownloadBlock)block; - (instancetype)startWithBlock:(nonnull FeedDownloadBlock)block;
- (void)cancel; - (void)cancel;

View File

@@ -6,6 +6,9 @@
#import "FeedMeta+Ext.h" #import "FeedMeta+Ext.h"
#import "NSError+Ext.h" #import "NSError+Ext.h"
#import "NSURLRequest+Ext.h" #import "NSURLRequest+Ext.h"
#import "RegexFeed.h"
#import "RegexConverter+Ext.h"
@interface FeedDownload() @interface FeedDownload()
@property (nonatomic, assign) BOOL respondToSelectFeed, respondToRedirect, respondToEnd; @property (nonatomic, assign) BOOL respondToSelectFeed, respondToRedirect, respondToEnd;
@@ -20,6 +23,9 @@
@property (nonatomic, strong) RSParsedFeed *xmlfeed; @property (nonatomic, strong) RSParsedFeed *xmlfeed;
@property (nonatomic, strong) NSError *error; @property (nonatomic, strong) NSError *error;
@property (nonatomic, strong) NSString *faviconURL; @property (nonatomic, strong) NSString *faviconURL;
@property (nonatomic, strong) NSData *rawData;
@property (nonatomic, strong) RegexConverter *regexConverter;
@property (nonatomic, assign) BOOL regexEnforce;
@end @end
@implementation FeedDownload @implementation FeedDownload
@@ -51,13 +57,20 @@
FeedDownload *this = [FeedDownload new]; FeedDownload *this = [FeedDownload new];
this.assertIsFeedURL = YES; this.assertIsFeedURL = YES;
this.request = req; this.request = req;
return this; return [this withRegex:feed.regex enforce:false];
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------
// | MARK: - Getter & Setter // | 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. /// Set delegate and check what methods are implemented.
- (void)setDelegate:(id<FeedDownloadDelegate>)observer { - (void)setDelegate:(id<FeedDownloadDelegate>)observer {
_delegate = observer; _delegate = observer;
@@ -134,10 +147,16 @@
self.currentDownload = [request dataTask:^(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response) { self.currentDownload = [request dataTask:^(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response) {
self.error = error; self.error = error;
self.response = response; self.response = response;
self.rawData = data;
if (!data) { // data = nil if (error || 304) if (!data) { // data = nil if (error || 304)
[self performSelectorOnMainThread:@selector(finishAndNotify) withObject:nil waitUntilDone:NO]; [self performSelectorOnMainThread:@selector(finishAndNotify) withObject:nil waitUntilDone:NO];
return; 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]; RSXMLData *xml = [[RSXMLData alloc] initWithData:data url:response.URL];
if (!self.assertIsFeedURL && [xml.parserClass isHTMLParser]) if (!self.assertIsFeedURL && [xml.parserClass isHTMLParser])
[self processXMLDataHTML:xml]; // HTML source handling [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 /// The downloaded source seems to be HTML data, lets parse it with @c RSXML @c RSHTMLMetadataParser
- (void)processXMLDataHTML:(RSXMLData*)xml { - (void)processXMLDataHTML:(RSXMLData*)xml {
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml]; RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];

View File

@@ -2,6 +2,7 @@
#import "OpmlFile.h" #import "OpmlFile.h"
#import "FeedMeta+Ext.h" #import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h" #import "FeedGroup+Ext.h"
#import "RegexConverter+Ext.h"
#import "StoreCoordinator.h" #import "StoreCoordinator.h"
#import "Constants.h" #import "Constants.h"
#import "NSDate+Ext.h" #import "NSDate+Ext.h"
@@ -120,6 +121,24 @@ static NSInteger RadioGroupSelection(NSView *view) {
newFeed.feed.meta.url = [item attributeForKey:OPMLXMLURLKey]; newFeed.feed.meta.url = [item attributeForKey:OPMLXMLURLKey];
newFeed.feed.meta.refresh = interval; 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 } else { // GROUP
for (NSUInteger i = 0; i < item.children.count; i++) { for (NSUInteger i = 0; i < item.children.count; i++) {
[self importFeed:item.children[i] parent:newFeed index:(int32_t)i inContext:moc]; [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"]]; [outline addAttribute:[NSXMLNode attributeWithName:OPMLTypeKey stringValue:@"rss"]];
NSString *intervalStr = [NSString stringWithFormat:@"%d", item.feed.meta.refresh]; NSString *intervalStr = [NSString stringWithFormat:@"%d", item.feed.meta.refresh];
[outline addAttribute:[NSXMLNode attributeWithName:@"refreshInterval" stringValue:intervalStr]]; // baRSS specific [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? // TODO: option to export unread state?
} }
parent = outline; parent = outline;

View File

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

View File

@@ -11,6 +11,9 @@
#import "NSView+Ext.h" #import "NSView+Ext.h"
#import "NSDate+Ext.h" #import "NSDate+Ext.h"
#import "NSURL+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 (strong) FeedDownload *memFeed;
@property (weak) FaviconDownload *memIcon; @property (weak) FaviconDownload *memIcon;
@property (strong) RefreshStatisticsView *statisticsView; @property (strong) RefreshStatisticsView *statisticsView;
@property (nonatomic, assign) BOOL openRegexAfterDownload;
@property (weak) id eventMonitor;
@end @end
@implementation ModalFeedEdit @implementation ModalFeedEdit
@@ -71,6 +76,13 @@
self.view.refreshNum.intValue = 30; self.view.refreshNum.intValue = 30;
[NSDate populateUnitsMenu:self.view.refreshUnit selected:TimeUnitMinutes]; [NSDate populateUnitsMenu:self.view.refreshUnit selected:TimeUnitMinutes];
[self populateTextFields:self.feedGroup]; [self populateTextFields:self.feedGroup];
// removed in windowShouldClose:
self.eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskFlagsChanged handler:^(NSEvent *event) {
BOOL optionKeyActive = ((event.modifierFlags & NSEventModifierFlagOption) != 0);
self.view.regexConverterButton.hidden = !self.feedGroup.feed.regex && !optionKeyActive;
return event;
}];
} }
/// Pre-fill UI control field values with @c FeedGroup properties. /// Pre-fill UI control field values with @c FeedGroup properties.
@@ -81,6 +93,7 @@
self.view.url.objectValue = fg.feed.meta.url; self.view.url.objectValue = fg.feed.meta.url;
self.previousURL = self.view.url.stringValue; self.previousURL = self.view.url.stringValue;
self.view.favicon.image = [fg.feed iconImage16]; 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]; [NSDate setInterval:fg.feed.meta.refresh forPopup:self.view.refreshUnit andField:self.view.refreshNum animate:NO];
[self statsForCoreDataObject]; [self statsForCoreDataObject];
} }
@@ -131,7 +144,9 @@
self.view.name.placeholderString = NSLocalizedString(@"Loading …", nil); self.view.name.placeholderString = NSLocalizedString(@"Loading …", nil);
} }
self.previousURL = self.view.url.stringValue; 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,7 +225,42 @@
- (void)downloadComplete { - (void)downloadComplete {
[self.view.spinnerURL stopAnimation:nil]; [self.view.spinnerURL stopAnimation:nil];
[self.modalSheet setDoneEnabled:YES]; [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 #pragma mark - Feed Statistics
@@ -264,6 +314,7 @@
[[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url]; [[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url];
return NO; return NO;
} }
[NSEvent removeMonitor:self.eventMonitor];
return YES; return YES;
} }

View File

@@ -18,6 +18,7 @@ NS_ASSUME_NONNULL_BEGIN
@property NSPopover *warningPopover; @property NSPopover *warningPopover;
@property (strong) IBOutlet NSTextField *warningText; @property (strong) IBOutlet NSTextField *warningText;
@property (strong) IBOutlet NSButton *warningReload; @property (strong) IBOutlet NSButton *warningReload;
@property (strong) IBOutlet NSButton *regexConverterButton;
- (instancetype)initWithController:(ModalFeedEdit*)controller NS_DESIGNATED_INITIALIZER; - (instancetype)initWithController:(ModalFeedEdit*)controller NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE; - (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE;

View File

@@ -1,6 +1,7 @@
#import "ModalFeedEditView.h" #import "ModalFeedEditView.h"
#import "ModalFeedEdit.h" #import "ModalFeedEdit.h"
#import "NSView+Ext.h" #import "NSView+Ext.h"
#import "Constants.h"
@interface StrictUIntFormatter : NSFormatter @interface StrictUIntFormatter : NSFormatter
@end @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.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.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.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)] tooltip:NSLocalizedString(@"Click here to show failure reason", nil)]
placeIn:self xRight:0 yTop:1.5]; placeIn:self xRight:0 yTop:1.5];
// 2. row // 2. row
@@ -34,6 +36,10 @@
// 3. row // 3. row
self.refreshNum = [[NSView inputField:@"30" width:85] placeIn:self x:x yTop:2*rowHeight]; 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.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 // initial state
self.url.accessibilityLabel = lbls[0]; self.url.accessibilityLabel = lbls[0];
@@ -41,6 +47,7 @@
self.refreshNum.accessibilityLabel = NSLocalizedString(@"Refresh interval", nil); self.refreshNum.accessibilityLabel = NSLocalizedString(@"Refresh interval", nil);
self.url.delegate = controller; self.url.delegate = controller;
self.warningButton.hidden = YES; self.warningButton.hidden = YES;
self.regexConverterButton.hidden = YES;
self.refreshNum.formatter = [StrictUIntFormatter new]; // see below ... self.refreshNum.formatter = [StrictUIntFormatter new]; // see below ...
[self prepareWarningPopover]; [self prepareWarningPopover];
return self; return self;

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