From 6f7a0e57ce0650c9e91b15e75ec9a0bb85f6ddb6 Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Sat, 6 Sep 2025 23:03:04 +0200 Subject: Make the provider functional --- QSBookmarkProvider.h | 48 ++ QSBookmarkProviderFactory.h | 28 + QSBookmarkProviderFactory.m | 66 ++ QSDeliciousAPIProvider.h | 25 + QSDeliciousAPIProvider.m | 190 ++++++ QSDeliciousPlugIn.xcodeproj/project.pbxproj | 53 +- QSDeliciousPlugInSource.nib/designable.nib | 930 --------------------------- QSDeliciousPlugInSource.nib/keyedobjects.nib | Bin 9343 -> 0 bytes QSDeliciousPlugInTests.m | 89 +++ QSDeliciousPlugIn_Source.h | 23 +- QSDeliciousPlugIn_Source.m | 485 ++++++-------- QSDeliciousPlugIn_Source.xib | 177 +++++ QSDeliciousPrefPane.h | 13 - QSDeliciousPrefPane.m | 39 -- QSLinkdingProvider.h | 16 + QSLinkdingProvider.m | 191 ++++++ REFACTOR_GUIDE.md | 118 ++++ SocialSite.h | 23 + SocialSite.m | 55 ++ en.lproj/Localizable.strings | 19 +- 20 files changed, 1304 insertions(+), 1284 deletions(-) create mode 100644 QSBookmarkProvider.h create mode 100644 QSBookmarkProviderFactory.h create mode 100644 QSBookmarkProviderFactory.m create mode 100644 QSDeliciousAPIProvider.h create mode 100644 QSDeliciousAPIProvider.m delete mode 100644 QSDeliciousPlugInSource.nib/designable.nib delete mode 100644 QSDeliciousPlugInSource.nib/keyedobjects.nib create mode 100644 QSDeliciousPlugInTests.m create mode 100644 QSDeliciousPlugIn_Source.xib delete mode 100644 QSDeliciousPrefPane.h delete mode 100644 QSDeliciousPrefPane.m create mode 100644 QSLinkdingProvider.h create mode 100644 QSLinkdingProvider.m create mode 100644 REFACTOR_GUIDE.md create mode 100644 SocialSite.h create mode 100644 SocialSite.m diff --git a/QSBookmarkProvider.h b/QSBookmarkProvider.h new file mode 100644 index 0000000..735ae82 --- /dev/null +++ b/QSBookmarkProvider.h @@ -0,0 +1,48 @@ +// +// QSBookmarkProvider.h +// QSDeliciousPlugIn +// +// Protocol for social bookmark providers +// + +#import +#import "SocialSite.h" + +@class QSObject; + +@protocol QSBookmarkProvider + +@required +/** + * Check if this provider can handle the given site configuration + */ +- (BOOL)canHandleSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host; + +/** + * Fetch bookmarks for the given configuration + * Returns an NSArray of QSObject instances + */ +- (NSArray *)fetchBookmarksForSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host includeTags:(BOOL)includeTags; + +/** + * Get the supported site for this provider + */ +- (SocialSite)supportedSite; + +/** + * Get display name for this provider + */ +- (NSString *)providerName; + +@optional +/** + * Get bookmarks for a specific tag (used for child loading) + */ +- (NSArray *)fetchBookmarksForTag:(NSString *)tag site:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host; + +/** + * Get the tag URL type identifier for this provider + */ +- (NSString *)tagURLType; + +@end \ No newline at end of file diff --git a/QSBookmarkProviderFactory.h b/QSBookmarkProviderFactory.h new file mode 100644 index 0000000..e2e694b --- /dev/null +++ b/QSBookmarkProviderFactory.h @@ -0,0 +1,28 @@ +// +// QSBookmarkProviderFactory.h +// QSDeliciousPlugIn +// +// Factory for managing bookmark providers +// + +#import +#import "QSBookmarkProvider.h" +#import "SocialSite.h" + +@interface QSBookmarkProviderFactory : NSObject + +@property (nonatomic, strong, readonly) NSArray> *providers; + ++ (instancetype)sharedFactory; + +/** + * Get the appropriate provider for the given site configuration + */ +- (id)providerForSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host; + +/** + * Get all available providers + */ +- (NSArray> *)allProviders; + +@end \ No newline at end of file diff --git a/QSBookmarkProviderFactory.m b/QSBookmarkProviderFactory.m new file mode 100644 index 0000000..6fcfdfb --- /dev/null +++ b/QSBookmarkProviderFactory.m @@ -0,0 +1,66 @@ +// +// QSBookmarkProviderFactory.m +// QSDeliciousPlugIn +// + +#import "QSBookmarkProviderFactory.h" +#import "QSDeliciousAPIProvider.h" +#import "QSLinkdingProvider.h" +#import "SocialSite.h" + +@interface QSBookmarkProviderFactory () +@property (nonatomic, strong, readwrite) NSArray> *providers; +@end + +@implementation QSBookmarkProviderFactory + ++ (instancetype)sharedFactory { + static QSBookmarkProviderFactory *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[self alloc] init]; + }); + return sharedInstance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + [self setupProviders]; + } + return self; +} + +- (void)setupProviders { + NSMutableArray *mutableProviders = [NSMutableArray array]; + + // Create Pinboard API providers for each supported site + QSDeliciousAPIProvider *deliciousProvider = [[QSDeliciousAPIProvider alloc] initWithSite:SocialSiteDelicious]; + QSDeliciousAPIProvider *magnoliaProvider = [[QSDeliciousAPIProvider alloc] initWithSite:SocialSiteMagnolia]; + QSDeliciousAPIProvider *pinboardProvider = [[QSDeliciousAPIProvider alloc] initWithSite:SocialSitePinboard]; + + // Create Linkding provider + QSLinkdingProvider *linkdingProvider = [[QSLinkdingProvider alloc] init]; + + [mutableProviders addObject:deliciousProvider]; + [mutableProviders addObject:magnoliaProvider]; + [mutableProviders addObject:pinboardProvider]; + [mutableProviders addObject:linkdingProvider]; + + self.providers = [mutableProviders copy]; +} + +- (id)providerForSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host { + for (id provider in self.providers) { + if ([provider canHandleSite:site username:username password:password host:host]) { + return provider; + } + } + return nil; +} + +- (NSArray> *)allProviders { + return self.providers; +} + +@end diff --git a/QSDeliciousAPIProvider.h b/QSDeliciousAPIProvider.h new file mode 100644 index 0000000..0064264 --- /dev/null +++ b/QSDeliciousAPIProvider.h @@ -0,0 +1,25 @@ +// +// QSDeliciousAPIProvider.h +// QSDeliciousPlugIn +// +// Base provider for Pinboard v1 API (XML-based with Basic Auth) +// Used by Delicious, Magnolia, and Pinboard +// + +#import +#import "QSBookmarkProvider.h" + +@interface QSDeliciousAPIProvider : NSObject + +@property (nonatomic, strong) NSMutableArray *posts; +@property (nonatomic, assign) SocialSite site; + +- (instancetype)initWithSite:(SocialSite)site; + +// Subclasses can override these methods +- (NSString *)apiURLForSite:(SocialSite)site; +- (NSURL *)requestURLForSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host; +- (NSData *)cachedBookmarkDataForSite:(SocialSite)site username:(NSString *)username; +- (void)cacheBookmarkData:(NSData *)data forSite:(SocialSite)site username:(NSString *)username; + +@end diff --git a/QSDeliciousAPIProvider.m b/QSDeliciousAPIProvider.m new file mode 100644 index 0000000..d97d1d2 --- /dev/null +++ b/QSDeliciousAPIProvider.m @@ -0,0 +1,190 @@ +// +// QSDeliciousAPIProvider.m +// QSDeliciousPlugIn +// + +#import "QSDeliciousAPIProvider.h" +#import "SocialSite.h" +#import + +@implementation QSDeliciousAPIProvider + +- (instancetype)initWithSite:(SocialSite)site { + self = [super init]; + if (self) { + _site = site; + _posts = [NSMutableArray array]; + } + return self; +} + +- (BOOL)canHandleSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host { + return (site == self.site) && + username.length > 0 && + password.length > 0 && + (site != SocialSiteLinkding); // This provider doesn't handle Linkding +} + +- (SocialSite)supportedSite { + return self.site; +} + +- (NSString *)providerName { + return [SocialSiteHelper displayNameForSite:self.site]; +} + +- (NSString *)apiURLForSite:(SocialSite)site { + switch (site) { + case SocialSiteDelicious: + return @"api.del.icio.us/v1"; + case SocialSiteMagnolia: + return @"ma.gnolia.com/api/mirrord/v1"; + case SocialSitePinboard: + return @"api.pinboard.in/v1"; + default: + return nil; + } +} + +- (NSURL *)requestURLForSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host { + NSString *apiURL = [self apiURLForSite:site]; + if (!apiURL) return nil; + + NSString *urlString = [NSString stringWithFormat:@"https://%@:%@@%@/posts/all?", username, password, apiURL]; + return [NSURL URLWithString:urlString]; +} + +- (NSData *)cachedBookmarkDataForSite:(SocialSite)site username:(NSString *)username { + NSString *siteURL = [SocialSiteHelper siteURLForSite:site]; + NSString *cachePath = [QSApplicationSupportSubPath([NSString stringWithFormat:@"Caches/%@/", siteURL], NO) stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.xml", username]]; + return [NSData dataWithContentsOfFile:cachePath]; +} + +- (void)cacheBookmarkData:(NSData *)data forSite:(SocialSite)site username:(NSString *)username { + NSString *siteURL = [SocialSiteHelper siteURLForSite:site]; + NSString *cachePath = [QSApplicationSupportSubPath([NSString stringWithFormat:@"Caches/%@/", siteURL], YES) stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.xml", username]]; + [data writeToFile:cachePath atomically:NO]; +} + +- (NSString *)tagURLType { + return [NSString stringWithFormat:@"tag.%@", [SocialSiteHelper reversedSiteURLForSite:self.site]]; +} + +- (NSArray *)fetchBookmarksForSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host includeTags:(BOOL)includeTags { + + // Try cached data first + NSData *data = [self cachedBookmarkDataForSite:site username:username]; + + // If no cached data, fetch from API + if (![data length]) { + NSURL *requestURL = [self requestURLForSite:site username:username password:password host:host]; + if (!requestURL) return @[]; + + NSMutableURLRequest *theRequest = [NSMutableURLRequest requestWithURL:requestURL + cachePolicy:NSURLRequestUseProtocolCachePolicy + timeoutInterval:60.0]; + [theRequest setHTTPMethod:@"POST"]; + [theRequest setValue:@"text/xml" forHTTPHeaderField:@"Content-type"]; + [theRequest setValue:@"Quicksilver (Blacktree,MacOSX)" forHTTPHeaderField:@"User-Agent"]; + + NSError *error; + data = [NSURLConnection sendSynchronousRequest:theRequest returningResponse:nil error:&error]; + + if (error) { + NSLog(@"Error fetching bookmarks: %@", error.localizedDescription); + return @[]; + } + + // Cache the data + [self cacheBookmarkData:data forSite:site username:username]; + } + + // Parse XML data + NSXMLParser *postParser = [[NSXMLParser alloc] initWithData:data]; + [postParser setDelegate:self]; + + self.posts = [NSMutableArray arrayWithCapacity:1]; + [postParser parse]; + + NSMutableArray *objects = [NSMutableArray arrayWithCapacity:1]; + NSMutableSet *tagSet = [NSMutableSet set]; + + // Create bookmark objects + for (NSDictionary *post in self.posts) { + QSObject *newObject = [self objectForPost:post]; + if (newObject) { + [objects addObject:newObject]; + + // Collect tags if requested + if (includeTags) { + NSString *tagString = [post objectForKey:@"tag"]; + if (tagString.length > 0) { + [tagSet addObjectsFromArray:[tagString componentsSeparatedByString:@" "]]; + } + } + } + } + + // Create tag objects if requested + if (includeTags) { + for (NSString *tag in tagSet) { + if (tag.length > 0) { + QSObject *tagObject = [QSObject makeObjectWithIdentifier:[NSString stringWithFormat:@"[%@ tag]:%@", [self providerName], tag]]; + [tagObject setObject:tag forType:[self tagURLType]]; + [tagObject setObject:username forMeta:[NSString stringWithFormat:@"%@.username", [SocialSiteHelper reversedSiteURLForSite:site]]]; + [tagObject setName:tag]; + [tagObject setPrimaryType:[self tagURLType]]; + [objects addObject:tagObject]; + } + } + } + + return objects; +} + +- (NSArray *)fetchBookmarksForTag:(NSString *)tag site:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host { + NSData *data = [self cachedBookmarkDataForSite:site username:username]; + if (!data) return @[]; + + NSXMLParser *postParser = [[NSXMLParser alloc] initWithData:data]; + [postParser setDelegate:self]; + self.posts = [NSMutableArray arrayWithCapacity:1]; + [postParser parse]; + + NSMutableArray *objects = [NSMutableArray arrayWithCapacity:1]; + + for (NSDictionary *post in self.posts) { + NSString *postTags = [post objectForKey:@"tag"]; + if ([postTags rangeOfString:tag].location != NSNotFound) { + QSObject *newObject = [self objectForPost:post]; + if (newObject) { + [objects addObject:newObject]; + } + } + } + + return objects; +} + +- (QSObject *)objectForPost:(NSDictionary *)post { + QSObject *newObject = [QSObject makeObjectWithIdentifier:[post objectForKey:@"hash"]]; + [newObject setObject:[post objectForKey:@"href"] forType:QSURLType]; + [newObject setName:[post objectForKey:@"description"]]; + [newObject setDetails:[post objectForKey:@"extended"]]; + [newObject setPrimaryType:QSURLType]; + return newObject; +} + +#pragma mark - NSXMLParserDelegate + +- (void)parser:(NSXMLParser*)parser didStartElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qName attributes:(NSDictionary *)attributeDict { + if ([elementName isEqualToString:@"post"] && attributeDict) { + [self.posts addObject:attributeDict]; + } +} + +- (void)parser:(NSXMLParser *)parser didEndElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qName { + // Implementation if needed +} + +@end diff --git a/QSDeliciousPlugIn.xcodeproj/project.pbxproj b/QSDeliciousPlugIn.xcodeproj/project.pbxproj index ce2a3d5..ebe2b52 100644 --- a/QSDeliciousPlugIn.xcodeproj/project.pbxproj +++ b/QSDeliciousPlugIn.xcodeproj/project.pbxproj @@ -8,30 +8,43 @@ /* Begin PBXBuildFile section */ 28855EDF1207EA17003DC758 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 28855EDE1207EA17003DC758 /* Security.framework */; }; - 7F8AD05107F2503600011548 /* QSDeliciousPlugInSource.nib in Resources */ = {isa = PBXBuildFile; fileRef = 7F8AD05007F2503600011548 /* QSDeliciousPlugInSource.nib */; }; 7F9441A10803A9D9007EDC31 /* QSObjectSource.name.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7F9441A00803A9D9007EDC31 /* QSObjectSource.name.strings */; }; 7FB0DC1A0B91FF8600A5B6FF /* bookmark_icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 7FB0DC190B91FF8600A5B6FF /* bookmark_icon.png */; }; 7FB0DC2F0B91FFC600A5B6FF /* QSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FB0DC2D0B91FFC500A5B6FF /* QSCore.framework */; }; 7FB0DC300B91FFC600A5B6FF /* QSFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FB0DC2E0B91FFC600A5B6FF /* QSFoundation.framework */; }; 8D1AC9700486D14A00FE50C9 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD92D38A0106425D02CA0E72 /* Cocoa.framework */; }; + B5CF7D6A2E6B7631008A0EE6 /* QSDeliciousPlugIn_Source.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5CF7D692E6B74FE008A0EE6 /* QSDeliciousPlugIn_Source.xib */; }; + B5CF7D6E2E6B7A53008A0EE6 /* QSDeliciousAPIProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CF7D602E6B73A0008A0EE6 /* QSDeliciousAPIProvider.m */; }; + B5CF7D6F2E6B7A53008A0EE6 /* QSBookmarkProviderFactory.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CF7D642E6B73DA008A0EE6 /* QSBookmarkProviderFactory.m */; }; + B5CF7D702E6B7A53008A0EE6 /* QSLinkdingProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CF7D622E6B73C9008A0EE6 /* QSLinkdingProvider.m */; }; + B5CF7D712E6B7A53008A0EE6 /* SocialSite.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CF7D5D2E6B7377008A0EE6 /* SocialSite.m */; }; D486257618B29CCE00D8CAE4 /* del.icio.us.png in Resources */ = {isa = PBXBuildFile; fileRef = D486257518B29CCE00D8CAE4 /* del.icio.us.png */; }; E182BCD306FC8203007BF2C2 /* QSDeliciousPlugIn_Source.h in Resources */ = {isa = PBXBuildFile; fileRef = E182BCCF06FC8203007BF2C2 /* QSDeliciousPlugIn_Source.h */; }; E182BCD406FC8203007BF2C2 /* QSDeliciousPlugIn_Source.m in Sources */ = {isa = PBXBuildFile; fileRef = E182BCD006FC8203007BF2C2 /* QSDeliciousPlugIn_Source.m */; }; - E182BE2506FC9AB5007BF2C2 /* QSDeliciousPrefPane.h in Resources */ = {isa = PBXBuildFile; fileRef = E182BE2206FC9AB5007BF2C2 /* QSDeliciousPrefPane.h */; }; - E182BE2606FC9AB5007BF2C2 /* QSDeliciousPrefPane.m in Sources */ = {isa = PBXBuildFile; fileRef = E182BE2306FC9AB5007BF2C2 /* QSDeliciousPrefPane.m */; }; E182BE3C06FC9B13007BF2C2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E182BE3A06FC9B13007BF2C2 /* Localizable.strings */; }; E182BE5B06FC9C46007BF2C2 /* PreferencePanes.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E182BE5A06FC9C46007BF2C2 /* PreferencePanes.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 28855EDE1207EA17003DC758 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; - 7F8AD05007F2503600011548 /* QSDeliciousPlugInSource.nib */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; path = QSDeliciousPlugInSource.nib; sourceTree = ""; }; 7F9441A00803A9D9007EDC31 /* QSObjectSource.name.strings */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; path = QSObjectSource.name.strings; sourceTree = ""; }; 7FB0DC190B91FF8600A5B6FF /* bookmark_icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = bookmark_icon.png; sourceTree = ""; }; 7FB0DC2D0B91FFC500A5B6FF /* QSCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QSCore.framework; path = /Applications/Quicksilver.app/Contents/Frameworks/QSCore.framework; sourceTree = ""; }; 7FB0DC2E0B91FFC600A5B6FF /* QSFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QSFoundation.framework; path = /Applications/Quicksilver.app/Contents/Frameworks/QSFoundation.framework; sourceTree = ""; }; 8D1AC9730486D14A00FE50C9 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8D1AC9740486D14A00FE50C9 /* Social Bookmarks Plugin.qsplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Social Bookmarks Plugin.qsplugin"; sourceTree = BUILT_PRODUCTS_DIR; }; + B5CF7D5C2E6B7370008A0EE6 /* SocialSite.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SocialSite.h; sourceTree = ""; }; + B5CF7D5D2E6B7377008A0EE6 /* SocialSite.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SocialSite.m; sourceTree = ""; }; + B5CF7D5E2E6B737E008A0EE6 /* QSBookmarkProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QSBookmarkProvider.h; sourceTree = ""; }; + B5CF7D5F2E6B7386008A0EE6 /* QSDeliciousAPIProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QSDeliciousAPIProvider.h; sourceTree = ""; }; + B5CF7D602E6B73A0008A0EE6 /* QSDeliciousAPIProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QSDeliciousAPIProvider.m; sourceTree = ""; }; + B5CF7D612E6B73A5008A0EE6 /* QSLinkdingProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QSLinkdingProvider.h; sourceTree = ""; }; + B5CF7D622E6B73C9008A0EE6 /* QSLinkdingProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QSLinkdingProvider.m; sourceTree = ""; }; + B5CF7D632E6B73D1008A0EE6 /* QSBookmarkProviderFactory.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QSBookmarkProviderFactory.h; sourceTree = ""; }; + B5CF7D642E6B73DA008A0EE6 /* QSBookmarkProviderFactory.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QSBookmarkProviderFactory.m; sourceTree = ""; }; + B5CF7D672E6B7457008A0EE6 /* QSDeliciousPlugInTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QSDeliciousPlugInTests.m; sourceTree = ""; }; + B5CF7D682E6B746E008A0EE6 /* REFACTOR_GUIDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = REFACTOR_GUIDE.md; sourceTree = ""; }; + B5CF7D692E6B74FE008A0EE6 /* QSDeliciousPlugIn_Source.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QSDeliciousPlugIn_Source.xib; sourceTree = ""; }; D475F9AF18B2992D0012243C /* Common.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = ""; }; D475F9B018B2992D0012243C /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; D475F9B118B2992D0012243C /* Developer.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Developer.xcconfig; sourceTree = ""; }; @@ -42,9 +55,7 @@ DD92D38A0106425D02CA0E72 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; E182BCCF06FC8203007BF2C2 /* QSDeliciousPlugIn_Source.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; path = QSDeliciousPlugIn_Source.h; sourceTree = ""; }; E182BCD006FC8203007BF2C2 /* QSDeliciousPlugIn_Source.m */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.objc; path = QSDeliciousPlugIn_Source.m; sourceTree = ""; }; - E182BE2206FC9AB5007BF2C2 /* QSDeliciousPrefPane.h */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.h; path = QSDeliciousPrefPane.h; sourceTree = ""; }; - E182BE2306FC9AB5007BF2C2 /* QSDeliciousPrefPane.m */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = sourcecode.c.objc; path = QSDeliciousPrefPane.m; sourceTree = ""; }; - E182BE3B06FC9B13007BF2C2 /* en */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + E182BE3B06FC9B13007BF2C2 /* en */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; E182BE5A06FC9C46007BF2C2 /* PreferencePanes.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PreferencePanes.framework; path = /System/Library/Frameworks/PreferencePanes.framework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -118,12 +129,21 @@ 32DBCF9F0370C38200C91783 /* Other Sources */ = { isa = PBXGroup; children = ( + B5CF7D5C2E6B7370008A0EE6 /* SocialSite.h */, + B5CF7D5D2E6B7377008A0EE6 /* SocialSite.m */, + B5CF7D5E2E6B737E008A0EE6 /* QSBookmarkProvider.h */, + B5CF7D5F2E6B7386008A0EE6 /* QSDeliciousAPIProvider.h */, + B5CF7D602E6B73A0008A0EE6 /* QSDeliciousAPIProvider.m */, + B5CF7D612E6B73A5008A0EE6 /* QSLinkdingProvider.h */, + B5CF7D622E6B73C9008A0EE6 /* QSLinkdingProvider.m */, + B5CF7D632E6B73D1008A0EE6 /* QSBookmarkProviderFactory.h */, + B5CF7D642E6B73DA008A0EE6 /* QSBookmarkProviderFactory.m */, + B5CF7D672E6B7457008A0EE6 /* QSDeliciousPlugInTests.m */, + B5CF7D682E6B746E008A0EE6 /* REFACTOR_GUIDE.md */, + B5CF7D692E6B74FE008A0EE6 /* QSDeliciousPlugIn_Source.xib */, E182BE3A06FC9B13007BF2C2 /* Localizable.strings */, E182BCCF06FC8203007BF2C2 /* QSDeliciousPlugIn_Source.h */, E182BCD006FC8203007BF2C2 /* QSDeliciousPlugIn_Source.m */, - 7F8AD05007F2503600011548 /* QSDeliciousPlugInSource.nib */, - E182BE2206FC9AB5007BF2C2 /* QSDeliciousPrefPane.h */, - E182BE2306FC9AB5007BF2C2 /* QSDeliciousPrefPane.m */, ); name = "Other Sources"; sourceTree = ""; @@ -171,6 +191,11 @@ isa = PBXProject; attributes = { LastUpgradeCheck = 0500; + TargetAttributes = { + 8D1AC9600486D14A00FE50C9 = { + ProvisioningStyle = Manual; + }; + }; }; buildConfigurationList = 7F07AFAE085E432E00E2AFC4 /* Build configuration list for PBXProject "QSDeliciousPlugIn" */; compatibilityVersion = "Xcode 3.2"; @@ -199,9 +224,8 @@ files = ( E182BCD306FC8203007BF2C2 /* QSDeliciousPlugIn_Source.h in Resources */, D486257618B29CCE00D8CAE4 /* del.icio.us.png in Resources */, - E182BE2506FC9AB5007BF2C2 /* QSDeliciousPrefPane.h in Resources */, E182BE3C06FC9B13007BF2C2 /* Localizable.strings in Resources */, - 7F8AD05107F2503600011548 /* QSDeliciousPlugInSource.nib in Resources */, + B5CF7D6A2E6B7631008A0EE6 /* QSDeliciousPlugIn_Source.xib in Resources */, 7F9441A10803A9D9007EDC31 /* QSObjectSource.name.strings in Resources */, 7FB0DC1A0B91FF8600A5B6FF /* bookmark_icon.png in Resources */, ); @@ -231,8 +255,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B5CF7D6E2E6B7A53008A0EE6 /* QSDeliciousAPIProvider.m in Sources */, + B5CF7D6F2E6B7A53008A0EE6 /* QSBookmarkProviderFactory.m in Sources */, + B5CF7D702E6B7A53008A0EE6 /* QSLinkdingProvider.m in Sources */, + B5CF7D712E6B7A53008A0EE6 /* SocialSite.m in Sources */, E182BCD406FC8203007BF2C2 /* QSDeliciousPlugIn_Source.m in Sources */, - E182BE2606FC9AB5007BF2C2 /* QSDeliciousPrefPane.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/QSDeliciousPlugInSource.nib/designable.nib b/QSDeliciousPlugInSource.nib/designable.nib deleted file mode 100644 index 030965a..0000000 --- a/QSDeliciousPlugInSource.nib/designable.nib +++ /dev/null @@ -1,930 +0,0 @@ - - - - 1060 - 10F569 - 762 - 1038.29 - 461.00 - - YES - - YES - - - YES - - - - YES - - - - - YES - - - YES - - - - YES - - QSDeliciousPlugIn_Source - - - FirstResponder - - - NSApplication - - - 15 - 2 - {{62, 510}, {270, 151}} - 1886912512 - - Window - - NSWindow - - View - - {1.79769e+308, 1.79769e+308} - {213, 107} - - - 256 - - YES - - - 266 - {{89, 85}, {150, 19}} - - YES - - -1804468671 - 4326400 - - - LucidaGrande - 11 - 3100 - - - YES - - 6 - System - textBackgroundColor - - 3 - MQA - - - - 6 - System - textColor - - 3 - MAA - - - - - - - 264 - {{6, 88}, {78, 14}} - - YES - - 67239424 - 71434240 - Name: - - - - 6 - System - controlColor - - 3 - MC42NjY2NjY2NjY3AA - - - - 6 - System - controlTextColor - - - - - - - 264 - {{6, 61}, {78, 14}} - - YES - - 67239424 - 71434240 - Password: - - - - - - - - - 266 - {{89, 58}, {150, 19}} - - YES - - -1804468671 - 4326400 - - - - YES - - - - - - - 264 - {{86, 34}, {87, 18}} - - YES - - 67239424 - 131072 - Include tags - - - 1211912703 - 2 - - NSSwitch - - - - 200 - 25 - - - - - 264 - {{86, 113}, {126, 26}} - - YES - - -2076049856 - 2048 - - LucidaGrande - 13 - 1044 - - - 109199615 - 1 - - LucidaGrande - 13 - 16 - - - - - - 400 - 75 - - - del.icio.us - - 1048576 - 2147483647 - 1 - - NSImage - NSMenuCheckmark - - - NSImage - NSMenuMixedState - - _popUpItemAction: - - - YES - - - OtherViews - - - YES - - - - ma.gnolia - - 1048576 - 2147483647 - - - _popUpItemAction: - 1 - - - - - pinboard.in - - 1048576 - 2147483647 - - - _popUpItemAction: - 2 - - - - - 3 - YES - YES - 1 - - - - - 264 - {{6, 119}, {78, 14}} - - YES - - 67239424 - 71434240 - Site: - - - - - - - - {270, 151} - - - {{0, 0}, {1024, 746}} - {213, 129} - {1.79769e+308, 1.79769e+308} - - - - YES - username - includeTags - site - - YES - - - - - - YES - - - nextKeyView - - - - 56 - - - - nextKeyView - - - - 57 - - - - userField - - - - 65 - - - - passField - - - - 66 - - - - settingsView - - - - 69 - - - - value: selection.username - - - - - - value: selection.username - value - selection.username - 2 - - - 82 - - - - value: selection.includeTags - - - - - - value: selection.includeTags - value - selection.includeTags - 2 - - - 84 - - - - value: currentPassword - - - - - - value: currentPassword - value - currentPassword - 2 - - - 85 - - - - contentObject: selection.info - - - - - - contentObject: selection.info - contentObject - selection.info - 2 - - - 91 - - - - selectedTag: selection.site - - - - - - selectedTag: selection.site - selectedTag - selection.site - 2 - - - 98 - - - - - YES - - 0 - - - - - - -2 - - - File's Owner - - - -1 - - - First Responder - - - 5 - - - YES - - - - Window - - - 6 - - - YES - - - - - - - - - - - - 50 - - - YES - - - - - - 51 - - - YES - - - - - - 52 - - - YES - - - - - - 53 - - - YES - - - - - - 70 - - - YES - - - - - - 92 - - - YES - - - - - - 99 - - - YES - - - - - - 75 - - - Settings - - - 101 - - - - - 102 - - - - - 103 - - - - - 104 - - - - - 105 - - - - - 106 - - - YES - - - - - - 107 - - - - - 93 - - - YES - - - - - - - - 95 - - - - - 94 - - - - - -3 - - - Application - - - 108 - - - - - - - YES - - YES - 108.ImportedFromIB2 - 5.IBEditorWindowLastContentRect - 5.IBWindowTemplateEditedContentRect - 5.ImportedFromIB2 - 5.windowTemplate.hasMinSize - 5.windowTemplate.minSize - 50.ImportedFromIB2 - 51.ImportedFromIB2 - 52.ImportedFromIB2 - 53.CustomClassName - 53.ImportedFromIB2 - 6.ImportedFromIB2 - 70.ImportedFromIB2 - 75.ImportedFromIB2 - 92.ImportedFromIB2 - 93.IBEditorWindowLastContentRect - 93.ImportedFromIB2 - 94.ImportedFromIB2 - 95.ImportedFromIB2 - 99.ImportedFromIB2 - - - YES - - {{0, 994}, {270, 151}} - {{0, 994}, {270, 151}} - - - {213, 107} - - - - NSSecureTextField - - - - - - {{75, 1070}, {145, 63}} - - - - - - - - YES - - - YES - - - - - YES - - - YES - - - - 108 - - - - YES - - FirstResponder - NSObject - - IBUserSource - - - - - NSObject - - IBUserSource - - - - - QSDeliciousPlugIn_Source - QSObjectSource - - YES - - YES - passField - userField - - - YES - NSTextField - NSTextField - - - - IBProjectSource - QSDeliciousPlugIn_Source.h - - - - QSDeliciousPlugIn_Source - QSObjectSource - - savePassword: - id - - - IBUserSource - - - - - QSObjectSource - NSObject - - IBUserSource - - - - - - YES - - NSApplication - - relaunch: - id - - - IBFrameworkSource - QSFoundation.framework/Headers/NSApplication_BLTRExtensions.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSIconLoader.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSObject.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSObjectSource.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSObject_FileHandling.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSPlugIn.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSProxyObject.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSRegistry.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSTask.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSTriggerCenter.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/QSURLDownloadWrapper.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/UKFileWatcher.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/UKKQueue.h - - - - NSObject - - IBFrameworkSource - QSCore.framework/Headers/UKMainThreadProxy.h - - - - NSObject - - IBFrameworkSource - QSFoundation.framework/Headers/NDHotKeyEvent.h - - - - NSObject - - IBFrameworkSource - QSFoundation.framework/Headers/NSArray_BLTRExtensions.h - - - - NSObject - - IBFrameworkSource - QSFoundation.framework/Headers/NSObject+BLTRExtensions.h - - - - NSObject - - IBFrameworkSource - QSFoundation.framework/Headers/NSObject+ReaperExtensions.h - - - - NSView - - IBFrameworkSource - QSFoundation.framework/Headers/NSView_BLTRExtensions.h - - - - NSWindow - - IBFrameworkSource - QSFoundation.framework/Headers/NSWindow_BLTRExtensions.h - - - - QSObjectSource - NSObject - - settingsView - NSView - - - - - - 0 - IBCocoaFramework - - com.apple.InterfaceBuilder.CocoaPlugin.macosx - - - - com.apple.InterfaceBuilder.CocoaPlugin.macosx - - - - com.apple.InterfaceBuilder.CocoaPlugin.InterfaceBuilder3 - - - YES - - 3 - - YES - - YES - NSMenuCheckmark - NSMenuMixedState - - - YES - {9, 8} - {7, 2} - - - - diff --git a/QSDeliciousPlugInSource.nib/keyedobjects.nib b/QSDeliciousPlugInSource.nib/keyedobjects.nib deleted file mode 100644 index a794dc2..0000000 Binary files a/QSDeliciousPlugInSource.nib/keyedobjects.nib and /dev/null differ diff --git a/QSDeliciousPlugInTests.m b/QSDeliciousPlugInTests.m new file mode 100644 index 0000000..3aff4ff --- /dev/null +++ b/QSDeliciousPlugInTests.m @@ -0,0 +1,89 @@ +// +// QSDeliciousPlugInTests.m +// QSDeliciousPlugIn +// + +#import +#import "QSBookmarkProviderFactory.h" +#import "QSDeliciousAPIProvider.h" +#import "QSLinkdingProvider.h" +#import "SocialSite.h" + +@suite("QSDeliciousPlugIn Strategy Pattern Tests") +struct QSDeliciousPlugInTests { + + @Test("Factory returns correct provider for Delicious") + func testDeliciousProvider() async throws { + QSBookmarkProviderFactory *factory = [QSBookmarkProviderFactory sharedFactory]; + + id provider = [factory providerForSite:SocialSiteDelicious + username:@"testuser" + password:@"testpass" + host:nil]; + + #expect(provider != nil, "Should return a provider for Delicious"); + #expect([provider isKindOfClass:[QSDeliciousAPIProvider class]], "Should return a QSDeliciousAPIProvider for Delicious"); + #expect([provider supportedSite] == SocialSiteDelicious, "Provider should support Delicious site"); + } + + @Test("Factory returns correct provider for Pinboard") + func testPinboardProvider() async throws { + QSBookmarkProviderFactory *factory = [QSBookmarkProviderFactory sharedFactory]; + + id provider = [factory providerForSite:SocialSitePinboard + username:@"testuser" + password:@"testpass" + host:nil]; + + #expect(provider != nil, "Should return a provider for Pinboard"); + #expect([provider isKindOfClass:[QSDeliciousAPIProvider class]], "Should return a QSDeliciousAPIProvider for Pinboard"); + #expect([provider supportedSite] == SocialSitePinboard, "Provider should support Pinboard site"); + } + + @Test("Factory returns correct provider for Linkding") + func testLinkdingProvider() async throws { + QSBookmarkProviderFactory *factory = [QSBookmarkProviderFactory sharedFactory]; + + id provider = [factory providerForSite:SocialSiteLinkding + username:@"testuser" + password:@"testtoken" + host:@"https://bookmarks.example.com"]; + + #expect(provider != nil, "Should return a provider for Linkding"); + #expect([provider isKindOfClass:[QSLinkdingProvider class]], "Should return a QSLinkdingProvider for Linkding"); + #expect([provider supportedSite] == SocialSiteLinkding, "Provider should support Linkding site"); + } + + @Test("Factory returns nil for invalid configuration") + func testInvalidConfiguration() async throws { + QSBookmarkProviderFactory *factory = [QSBookmarkProviderFactory sharedFactory]; + + // Test with empty username + id provider = [factory providerForSite:SocialSiteDelicious + username:@"" + password:@"testpass" + host:nil]; + + #expect(provider == nil, "Should return nil for empty username"); + + // Test Linkding without host + provider = [factory providerForSite:SocialSiteLinkding + username:@"testuser" + password:@"testtoken" + host:@""]; + + #expect(provider == nil, "Should return nil for Linkding without host"); + } + + @Test("SocialSite helper methods work correctly") + func testSocialSiteHelpers() async throws { + #expect([[SocialSiteHelper displayNameForSite:SocialSiteDelicious] isEqualToString:@"del.icio.us"]); + #expect([[SocialSiteHelper displayNameForSite:SocialSiteLinkding] isEqualToString:@"Linkding"]); + + #expect([[SocialSiteHelper siteURLForSite:SocialSitePinboard] isEqualToString:@"pinboard.in"]); + #expect([[SocialSiteHelper siteURLForSite:SocialSiteLinkding] isEqualToString:@""]); + + #expect([[SocialSiteHelper reversedSiteURLForSite:SocialSiteDelicious] isEqualToString:@"us.icio.del"]); + #expect([[SocialSiteHelper reversedSiteURLForSite:SocialSiteLinkding] isEqualToString:@"linkding"]); + } +} diff --git a/QSDeliciousPlugIn_Source.h b/QSDeliciousPlugIn_Source.h index 89729de..9d22d91 100644 --- a/QSDeliciousPlugIn_Source.h +++ b/QSDeliciousPlugIn_Source.h @@ -6,17 +6,20 @@ // Copyright __MyCompanyName__ 2004. All rights reserved. // +#import +#import +#import "SocialSite.h" +#import "QSBookmarkProvider.h" +#import "QSBookmarkProviderFactory.h" -#import "QSDeliciousPlugIn_Source.h" - -@interface QSDeliciousPlugIn_Source : QSObjectSource { - NSMutableArray *posts; - NSMutableArray *tags; - NSMutableArray *dates; - - IBOutlet NSTextField *userField; - IBOutlet NSTextField *passField; +@interface QSDeliciousPlugIn_Source : QSObjectSource { + IBOutlet NSTextField *userField; + IBOutlet NSTextField *passField; + IBOutlet NSTextField *hostField; } - @end +@interface QSCatalogEntry (OldStyleSourceSupport) +@property NSMutableDictionary *info; +- (id)objectForKey:(NSString *)key; +@end diff --git a/QSDeliciousPlugIn_Source.m b/QSDeliciousPlugIn_Source.m index 8dcc9b4..43a32cb 100644 --- a/QSDeliciousPlugIn_Source.m +++ b/QSDeliciousPlugIn_Source.m @@ -8,126 +8,143 @@ #import "QSDeliciousPlugIn_Source.h" #import - #import @implementation QSDeliciousPlugIn_Source + (void)initialize { - [self setKeys:[NSArray arrayWithObject:@"selection"] triggerChangeNotificationsForDependentKey:@"currentPassword"]; + [self setKeys:[NSArray arrayWithObject:@"selection"] triggerChangeNotificationsForDependentKey:@"currentPassword"]; } - (BOOL)indexIsValidFromDate:(NSDate *)indexDate forEntry:(NSDictionary *)theEntry { - return -[indexDate timeIntervalSinceNow] < 24 * 60 * 60; + return -[indexDate timeIntervalSinceNow] < 24 * 60 * 60; } -- (BOOL)isVisibleSource{ return YES; } +- (BOOL)isVisibleSource{ + return YES; +} - (NSImage *) iconForEntry:(NSDictionary *)dict { return [[NSBundle bundleForClass:[self class]]imageNamed:@"bookmark_icon"]; } -- (NSString *) mainNibName { - return @"QSDeliciousPrefPane"; +- (NSView *)settingsView +{ + if (![super settingsView]) { + [[NSBundle bundleForClass:[self class]] loadNibNamed:NSStringFromClass([self class]) owner:self topLevelObjects:NULL]; + } + return [super settingsView]; +} + +#pragma mark - Settings Helpers + +- (SocialSite)siteIndex { + NSDictionary *settings = self.selectedEntry.sourceSettings; + return [settings objectForKey:@"site"] != nil ? [[settings objectForKey:@"site"] integerValue] : SocialSiteDelicious; +} + +- (NSString *)currentUsername { + return [self.selectedEntry.sourceSettings objectForKey:@"username"]; +} + +- (NSString *)currentHost { + return [self.selectedEntry.sourceSettings objectForKey:@"host"]; } -- (void)populateFields { - NSLog(@"populating: %@/%@", [self.selectedEntry.sourceSettings objectForKey:@"username"], [self.selectedEntry.sourceSettings objectForKey:@"site"]); +- (NSString *)currentPassword { + return [self.selectedEntry.sourceSettings objectForKey:@"password"]; } -- (NSView *) settingsView { - if (![super settingsView]) { - [[NSBundle bundleForClass:[self class]] loadNibNamed:@"QSDeliciousPlugInSource" owner:self topLevelObjects:NULL]; - } - return [super settingsView]; +- (BOOL)includeTags { + return [[self.selectedEntry.sourceSettings objectForKey:@"includeTags"] boolValue]; } -// Keychain Access -- The QS Built-in ones seems to be broken +#pragma mark - Keychain Access - (SecProtocolType)protocolTypeForString:(NSString *)protocol { - if ([protocol isEqualToString:@"ftp"]) return kSecProtocolTypeFTP; - else if ([protocol isEqualToString:@"http"]) return kSecProtocolTypeHTTP; - else if ([protocol isEqualToString:@"sftp"]) return kSecProtocolTypeFTPS; - else if ([protocol isEqualToString:@"eppc"]) return kSecProtocolTypeEPPC; - else if ([protocol isEqualToString:@"afp"]) return kSecProtocolTypeAFP; - else if ([protocol isEqualToString:@"smb"]) return kSecProtocolTypeSMB; - else if ([protocol isEqualToString:@"ssh"]) return kSecProtocolTypeSSH; - else if ([protocol isEqualToString:@"telnet"]) return kSecProtocolTypeTelnet; - return 0; + if ([protocol isEqualToString:@"ftp"]) return kSecProtocolTypeFTP; + else if ([protocol isEqualToString:@"http"]) return kSecProtocolTypeHTTP; + else if ([protocol isEqualToString:@"sftp"]) return kSecProtocolTypeFTPS; + else if ([protocol isEqualToString:@"eppc"]) return kSecProtocolTypeEPPC; + else if ([protocol isEqualToString:@"afp"]) return kSecProtocolTypeAFP; + else if ([protocol isEqualToString:@"smb"]) return kSecProtocolTypeSMB; + else if ([protocol isEqualToString:@"ssh"]) return kSecProtocolTypeSSH; + else if ([protocol isEqualToString:@"telnet"]) return kSecProtocolTypeTelnet; + return 0; } - (NSString *)passwordForHost:(NSString *)host user:(NSString *)user andType:(SecProtocolType)type { - const char *buffer; - UInt32 length = 0; - OSErr err; - - err = SecKeychainFindInternetPassword(NULL, - (UInt32)[host length], [host UTF8String], - 0, - NULL, - (UInt32)[user length], [user UTF8String], - 0, NULL, - 0, - type, - 0, - &length, (void**)&buffer, - NULL); - - if (err == noErr) { - NSString *password = [NSString stringWithUTF8String:buffer]; - SecKeychainItemFreeContent(NULL,(void *)buffer); - return password; - } - return nil; + const char *buffer; + UInt32 length = 0; + OSErr err; + + err = SecKeychainFindInternetPassword(NULL, + (UInt32)[host length], [host UTF8String], + 0, + NULL, + (UInt32)[user length], [user UTF8String], + 0, NULL, + 0, + type, + 0, + &length, (void**)&buffer, + NULL); + + if (err == noErr) { + NSString *password = [NSString stringWithUTF8String:buffer]; + SecKeychainItemFreeContent(NULL,(void *)buffer); + return password; + } + return nil; } - (NSString *)passwordForHost:(NSString *)host user:(NSString *)user andScheme:(NSString *)scheme { - NSString *password = nil; - - SecProtocolType type = [self protocolTypeForString:scheme]; - - password = [self passwordForHost:host user:user andType:type]; - - if (!password && type == kSecProtocolTypeFTP) - password = [self passwordForHost:host user:user andType:kSecProtocolTypeFTPAccount]; // Workaround for Transmit's old type usage - if ( !password ) - password = [self passwordForHost:host user:user andType:0]; - if ( !password ) - NSLog(@"Couldn't find password. URL:%@ %@ %@", host, user,scheme ); - return password; + NSString *password = nil; + + SecProtocolType type = [self protocolTypeForString:scheme]; + + password = [self passwordForHost:host user:user andType:type]; + + if (!password && type == kSecProtocolTypeFTP) + password = [self passwordForHost:host user:user andType:kSecProtocolTypeFTPAccount]; // Workaround for Transmit's old type usage + if ( !password ) + password = [self passwordForHost:host user:user andType:0]; + if ( !password ) + NSLog(@"Couldn't find password. URL:%@ %@ %@", host, user,scheme ); + return password; } - (NSString *)keychainPasswordForURL:(NSURL *)url { - return [self passwordForHost:[url host] user:[url user] andScheme:[url scheme]]; + return [self passwordForHost:[url host] user:[url user] andScheme:[url scheme]]; } - (OSErr)addURLPasswordToKeychain:(NSURL *)url { - OSErr err; - - NSString *host = [url host]; - NSString *user = [url user]; - NSString *pass = [url password]; - - SecProtocolType type = [self protocolTypeForString:[url scheme]]; - - SecKeychainItemRef existing = NULL; - - err = SecKeychainFindInternetPassword(NULL, - (UInt32)[host length], [host UTF8String], - 0, NULL, - (UInt32)[user length], [user UTF8String], - 0, NULL, - 0, - type, - 0, - NULL,NULL, - &existing); - - if ( !err ) { - err = SecKeychainItemModifyContent( existing, NULL, (UInt32)[pass length], [pass UTF8String] ); - CFRelease( existing ); - } else { - err = SecKeychainAddInternetPassword(NULL, + OSErr err; + + NSString *host = [url host]; + NSString *user = [url user]; + NSString *pass = [url password]; + + SecProtocolType type = [self protocolTypeForString:[url scheme]]; + + SecKeychainItemRef existing = NULL; + + err = SecKeychainFindInternetPassword(NULL, + (UInt32)[host length], [host UTF8String], + 0, NULL, + (UInt32)[user length], [user UTF8String], + 0, NULL, + 0, + type, + 0, + NULL,NULL, + &existing); + + if ( !err ) { + err = SecKeychainItemModifyContent( existing, NULL, (UInt32)[pass length], [pass UTF8String] ); + CFRelease( existing ); + } else { + err = SecKeychainAddInternetPassword(NULL, (UInt32)[host length], [host UTF8String], 0, NULL, (UInt32)[user length], [user UTF8String], @@ -138,218 +155,130 @@ (UInt32)[pass length], [pass UTF8String], NULL); } - - return err; -} - -// Site Index/API/URL - -- (NSInteger)siteIndex { - NSDictionary *settings = self.selectedEntry.sourceSettings; - return [settings objectForKey:@"site"] != nil ? [[settings objectForKey:@"site"] integerValue] : 0; -} - -- (NSString *)siteURLForIndex:(NSInteger)siteIndex { - if (siteIndex == 0) return @"del.icio.us"; - else if (siteIndex == 1) return @"ma.gnolia.com"; - else if (siteIndex == 2) return @"pinboard.in"; - else return nil; -} - -- (NSString *)reversedSiteURLForIndex:(NSInteger)siteIndex { - if (siteIndex == 0) return @"us.icio.del"; - else if (siteIndex == 1) return @"com.gnolia.ma"; - else if (siteIndex == 2) return @"in.pinboard"; - else return nil; + + return err; } -- (NSString *)tagURLForIndex:(NSInteger)siteIndex { - return [NSString stringWithFormat:@"tag.%@", [self reversedSiteURLForIndex:[self siteIndex]]]; -} - -- (NSString *)apiURLForIndex:(NSInteger)siteIndex { - if (siteIndex == 0) return @"api.del.icio.us/v1"; - else if (siteIndex == 1) return @"ma.gnolia.com/api/mirrord/v1"; - else if (siteIndex == 2) return @"api.pinboard.in/v1"; - else return nil; -} - -// Current Site/API URL - -- (NSString *)currentSiteURL { - return [self siteURLForIndex:[self siteIndex]]; -} - -- (NSString *)currentAPIURL { - return [self apiURLForIndex:[self siteIndex]]; -} - -// Useranme - -- (NSString *)currentUsername { - return [self.selectedEntry.sourceSettings objectForKey:@"username"]; -} - -// Password Related - -- (NSString *)currentPassword { - NSString *account = [self currentUsername]; - if (!account) return nil; - - NSURL *keychainURL = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@@%@/", account, [self currentSiteURL]]]; - NSString *password = [self keychainPasswordForURL:keychainURL]; - - return password; +- (NSString *)oldCurrentPassword { + NSString *account = [self currentUsername]; + if (!account) return nil; + + SocialSite site = [self siteIndex]; + NSString *host = nil; + + // For Linkding, use the custom host; for others, use the standard site URL + if (site == SocialSiteLinkding) { + host = [self currentHost]; + if (!host) return nil; + } else { + host = [SocialSiteHelper siteURLForSite:site]; + } + + NSURL *keychainURL = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@@%@/", account, host]]; + NSString *password = [self keychainPasswordForURL:keychainURL]; + + return password; } - (void)setCurrentPassword:(NSString *)newPassword { - NSString *account = [self currentUsername]; - if (!account) return; - if ([newPassword length] <= 0) return; - - NSURL *keychainURL = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@:%@@%@/", account, newPassword, [self currentSiteURL]]]; - - [self addURLPasswordToKeychain:keychainURL]; -} - -// Bookarmk/Caching - -- (NSData *)cachedBookmarkData { - NSString *cachePath=[QSApplicationSupportSubPath([NSString stringWithFormat:@"Caches/%@/", [self currentSiteURL]], NO) stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.xml", [self currentUsername]]]; - return [NSData dataWithContentsOfFile:cachePath]; -} - -- (NSData *)bookmarkData { - if (![self currentUsername] || ![self currentPassword]) return nil; - - NSString *urlString = [NSString stringWithFormat:@"https://%@:%@@%@/posts/all?", [self currentUsername], [self currentPassword], [self currentAPIURL]]; - NSURL *requestURL = [NSURL URLWithString:urlString]; - - NSMutableURLRequest *theRequest = [NSMutableURLRequest requestWithURL:requestURL - cachePolicy:NSURLRequestUseProtocolCachePolicy - timeoutInterval:60.0]; - [theRequest setHTTPMethod:@"POST"]; - [theRequest setValue:@"text/xml" forHTTPHeaderField:@"Content-type"]; - [theRequest setValue:@"Quicksilver (Blacktree,MacOSX)" forHTTPHeaderField:@"User-Agent"]; - - NSError *error; - NSData *data = [NSURLConnection sendSynchronousRequest:theRequest returningResponse:nil error:&error]; - - NSString *cachePath = [QSApplicationSupportSubPath([NSString stringWithFormat:@"Caches/%@/", [self currentSiteURL]], YES) stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.xml", [self currentUsername]]]; - [data writeToFile:cachePath atomically:NO]; - - return data; + NSString *account = [self currentUsername]; + if (!account) return; + if ([newPassword length] <= 0) return; + + SocialSite site = [self siteIndex]; + NSString *host = nil; + + // For Linkding, use the custom host; for others, use the standard site URL + if (site == SocialSiteLinkding) { + host = [self currentHost]; + if (!host) return; + } else { + host = [SocialSiteHelper siteURLForSite:site]; + } + + NSURL *keychainURL = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@:%@@%@/", account, newPassword, host]]; + + [self addURLPasswordToKeychain:keychainURL]; } -- (QSObject *)objectForPost:(NSDictionary *)post { - QSObject *newObject=[QSObject makeObjectWithIdentifier:[post objectForKey:@"hash"]]; - [newObject setObject:[post objectForKey:@"href"] forType:QSURLType]; - [newObject setName:[post objectForKey:@"description"]]; - [newObject setDetails:[post objectForKey:@"extended"]]; - [newObject setPrimaryType:QSURLType]; - //NSDate *date=[NSCalendarDate dateWithString:[post objectForKey:@"time"] - // calendarFormat:@"%Y-%m-%dT%H:%M:%SZ"]; - //[date setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]]; - //[newObject setObject:date forMeta:kQSObjectCreationDate]; - return newObject; -} +#pragma mark - Objects For Entry - (NSArray *)objectsForEntry:(NSDictionary *)theEntry { - NSData *data = nil;//[self cachedBookmarkData]; - if (![data length]) data = [self bookmarkData]; - - NSXMLParser *postParser = [[NSXMLParser alloc]initWithData:data]; - [postParser setDelegate:self]; - - posts = [NSMutableArray arrayWithCapacity:1]; - - [postParser parse]; - - NSMutableArray *objects=[NSMutableArray arrayWithCapacity:1]; - QSObject *newObject; - NSEnumerator *e=[posts objectEnumerator]; - NSDictionary *post; - NSMutableSet *tagSet=[NSMutableSet set]; - while(post=[e nextObject]){ - newObject=[self objectForPost:post]; - [tagSet addObjectsFromArray:[[post objectForKey:@"tag"]componentsSeparatedByString:@" "]]; - [objects addObject:newObject]; - } - NSString *tag; - e=[tagSet objectEnumerator]; - - if ([[theEntry objectForKey:@"includeTags"]boolValue]){ - while(tag=[e nextObject]){ - newObject=[QSObject makeObjectWithIdentifier:[NSString stringWithFormat:@"[del.icio.us tag]:%@",tag]]; - [newObject setObject:tag forType:[self tagURLForIndex:[self siteIndex]]]; - [newObject setObject:[self currentUsername] forMeta:@"us.icio.del.username"]; - [newObject setName:tag]; - [newObject setPrimaryType:[self tagURLForIndex:[self siteIndex]]]; - [objects addObject:newObject]; - } - } - [postParser release]; - - return objects; + NSLog(@"WE HAVE BEEN REQUESTED"); + SocialSite site = [self siteIndex]; + NSString *username = [self currentUsername]; + NSString *password = [self currentPassword]; + NSString *host = [self currentHost]; + BOOL includeTags = [self includeTags]; + // Get the appropriate provider using the factory + QSBookmarkProviderFactory *factory = [QSBookmarkProviderFactory sharedFactory]; + id provider = [factory providerForSite:site username:username password:password host:host]; + + NSLog(@"Checking for %ld, user %@, pass %@, host %@", (long)site, username, password, host); + if (!provider) { + NSLog(@"No provider available for site %ld with username %@", (long)site, username); + return @[]; + } + + return [provider fetchBookmarksForSite:site username:username password:password host:host includeTags:includeTags]; } -- (NSArray *)objectsForTag:(NSString *)tag username:(NSString *)username{ - NSData *data=[self cachedBookmarkData]; - - NSXMLParser *postParser=[[NSXMLParser alloc]initWithData:data]; - [postParser setDelegate:self]; - posts=[NSMutableArray arrayWithCapacity:1]; - [postParser parse]; - - NSMutableArray *objects=[NSMutableArray arrayWithCapacity:1]; - QSObject *newObject; - NSEnumerator *e=[posts objectEnumerator]; - NSDictionary *post; - // NSMutableSet *tagSet=[NSMutableSet set]; - while(post=[e nextObject]){ - if ([[post objectForKey:@"tag"]rangeOfString:tag].location==NSNotFound)continue; - newObject=[self objectForPost:post]; - [objects addObject:newObject]; - } - return objects; -} - -// Object Handler Methods - -/* -- (void)setQuickIconForObject:(QSObject *)object { - [object setIcon:nil]; // An icon that is either already in memory or easy to load +- (NSArray *)objectsForTag:(NSString *)tag username:(NSString *)username { + SocialSite site = [self siteIndex]; + NSString *password = [self currentPassword]; + NSString *host = [self currentHost]; + + // Get the appropriate provider using the factory + QSBookmarkProviderFactory *factory = [QSBookmarkProviderFactory sharedFactory]; + id provider = [factory providerForSite:site username:username password:password host:host]; + + if (!provider) { + NSLog(@"No provider available for site %ld with username %@", (long)site, username); + return @[]; + } + + // Check if provider supports tag-based fetching + if ([provider respondsToSelector:@selector(fetchBookmarksForTag:site:username:password:host:)]) { + return [provider fetchBookmarksForTag:tag site:site username:username password:password host:host]; + } + + return @[]; } -- (BOOL)loadIconForObject:(QSObject *)object { - return NO; - id data=[object objectForType:QSDeliciousPlugInType]; - [object setIcon:nil]; - return YES; -} -*/ +#pragma mark - Object Handler Methods - (void)setQuickIconForObject:(QSObject *)object { - [object setIcon:[[NSBundle bundleForClass:[self class]]imageNamed:@"bookmark_icon"]]; + [object setIcon:[[NSBundle bundleForClass:[self class]]imageNamed:@"bookmark_icon"]]; } - (BOOL)loadChildrenForObject:(QSObject *)object { - [object setChildren:[self objectsForTag:[object objectForType:[self tagURLForIndex:[self siteIndex]]] - username:[object objectForMeta:@"us.icio.del.username"]]]; - return YES; -} - -// NSXMLParserDelegate Functions - -- (void)parser:(NSXMLParser*)parser didStartElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qName attributes:(NSDictionary *)attributeDict { - NSLog(@"XML Parser started %@ %@ %@ %@",elementName,namespaceURI,qName,attributeDict); - if ([elementName isEqualToString:@"post"] && attributeDict) - [posts addObject:attributeDict]; -} - -- (void)parser:(NSXMLParser *)parser didEndElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qName { - NSLog(@"XML Parser ended %@ %@ %@", elementName, namespaceURI, qName); + SocialSite site = [self siteIndex]; + + NSString *tagType = nil; + if (site == SocialSiteLinkding) { + tagType = @"tag.linkding"; + } else { + NSString *reversedURL = [SocialSiteHelper reversedSiteURLForSite:site]; + tagType = [NSString stringWithFormat:@"tag.%@", reversedURL]; + } + + NSString *tag = [object objectForType:tagType]; + if (!tag) return NO; + + NSString *username = nil; + if (site == SocialSiteLinkding) { + username = [object objectForMeta:@"linkding.username"]; + } else { + NSString *reversedURL = [SocialSiteHelper reversedSiteURLForSite:site]; + username = [object objectForMeta:[NSString stringWithFormat:@"%@.username", reversedURL]]; + } + + if (!username) return NO; + + NSArray *children = [self objectsForTag:tag username:username]; + [object setChildren:children]; + return YES; } @end diff --git a/QSDeliciousPlugIn_Source.xib b/QSDeliciousPlugIn_Source.xib new file mode 100644 index 0000000..65d8dda --- /dev/null +++ b/QSDeliciousPlugIn_Source.xib @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSAllRomanInputSourcesLocaleIdentifier + + + + + + + + + + + + + + + + + + + + + + + + + + + info.username + info.site + info.includeTags + info.host + + + + + + + + diff --git a/QSDeliciousPrefPane.h b/QSDeliciousPrefPane.h deleted file mode 100644 index 494a9ff..0000000 --- a/QSDeliciousPrefPane.h +++ /dev/null @@ -1,13 +0,0 @@ - - -#import -#import - - -@interface QSDeliciousPrefPane : NSPreferencePane { - IBOutlet NSTextField *userField; - IBOutlet NSTextField *passField; -} - -- (IBAction)savePassword:(id)sender; -@end diff --git a/QSDeliciousPrefPane.m b/QSDeliciousPrefPane.m deleted file mode 100644 index f0dde4e..0000000 --- a/QSDeliciousPrefPane.m +++ /dev/null @@ -1,39 +0,0 @@ - - -#import "QSDeliciousPrefPane.h" -#import - -@implementation QSDeliciousPrefPane -- (id)init { - self = [super initWithBundle:[NSBundle bundleForClass:[QSDeliciousPrefPane class]]]; - if (self) { - } - return self; -} - -- (NSImage *) icon{ - return [[NSBundle bundleForClass:[self class]] imageNamed:@"del.icio.us"]; -} - -- (NSString *) mainNibName{ - return @"QSDeliciousPrefPane"; -} - -- (void)awakeFromNib{ - NSString *account=[[NSUserDefaults standardUserDefaults] objectForKey:@"QSDeliciousUserName"]; - NSURL *url=[NSURL URLWithString:[NSString stringWithFormat:@"http://%@@del.icio.us/",account]]; - NSString *password=[url keychainPassword]; - if (account)[userField setStringValue:account]; - if (password)[passField setStringValue:password]; -} - -- (IBAction)savePassword:(id)sender{ - NSString *account=[userField stringValue]; - NSString *pass=[passField stringValue]; - - NSURL *url=[NSURL URLWithString:[NSString stringWithFormat:@"http://%@:%@@del.icio.us/",account,pass]]; - if ([pass length]) - [url addPasswordToKeychain]; -} - -@end diff --git a/QSLinkdingProvider.h b/QSLinkdingProvider.h new file mode 100644 index 0000000..28807e8 --- /dev/null +++ b/QSLinkdingProvider.h @@ -0,0 +1,16 @@ +// +// QSLinkdingProvider.h +// QSDeliciousPlugIn +// +// Provider for Linkding API (JSON-based with API key) +// + +#import +#import "QSBookmarkProvider.h" + +@interface QSLinkdingProvider : NSObject + +- (NSData *)cachedBookmarkDataForHost:(NSString *)host username:(NSString *)username; +- (void)cacheBookmarkData:(NSData *)data forHost:(NSString *)host username:(NSString *)username; + +@end \ No newline at end of file diff --git a/QSLinkdingProvider.m b/QSLinkdingProvider.m new file mode 100644 index 0000000..f97f5c7 --- /dev/null +++ b/QSLinkdingProvider.m @@ -0,0 +1,191 @@ +// +// QSLinkdingProvider.m +// QSDeliciousPlugIn +// + +#import "QSLinkdingProvider.h" +#import "SocialSite.h" +#import + +@implementation QSLinkdingProvider + +- (BOOL)canHandleSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host { + return (site == SocialSiteLinkding) && + username.length > 0 && + password.length > 0 && // password is API token for Linkding + host.length > 0; +} + +- (SocialSite)supportedSite { + return SocialSiteLinkding; +} + +- (NSString *)providerName { + return @"Linkding"; +} + +- (NSString *)tagURLType { + return @"tag.linkding"; +} + +- (NSData *)cachedBookmarkDataForHost:(NSString *)host username:(NSString *)username { + // Create a safe filename from host + NSString *safeHost = [[host componentsSeparatedByCharactersInSet:[[NSCharacterSet alphanumericCharacterSet] invertedSet]] componentsJoinedByString:@"-"]; + NSString *cachePath = [QSApplicationSupportSubPath([NSString stringWithFormat:@"Caches/linkding/"], NO) stringByAppendingPathComponent:[NSString stringWithFormat:@"%@-%@.json", safeHost, username]]; + return [NSData dataWithContentsOfFile:cachePath]; +} + +- (void)cacheBookmarkData:(NSData *)data forHost:(NSString *)host username:(NSString *)username { + // Create a safe filename from host + NSString *safeHost = [[host componentsSeparatedByCharactersInSet:[[NSCharacterSet alphanumericCharacterSet] invertedSet]] componentsJoinedByString:@"-"]; + NSString *cachePath = [QSApplicationSupportSubPath([NSString stringWithFormat:@"Caches/linkding/"], YES) stringByAppendingPathComponent:[NSString stringWithFormat:@"%@-%@.json", safeHost, username]]; + [data writeToFile:cachePath atomically:NO]; +} + +- (NSArray *)fetchBookmarksForSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host includeTags:(BOOL)includeTags { + + if (![self canHandleSite:site username:username password:password host:host]) { + return @[]; + } + + // Try cached data first + NSData *data = [self cachedBookmarkDataForHost:host username:username]; + + // If no cached data, fetch from API + if (![data length]) { + // Construct Linkding API URL + NSString *baseURL = host; + if (![baseURL hasPrefix:@"http://"] && ![baseURL hasPrefix:@"https://"]) { + baseURL = [NSString stringWithFormat:@"https://%@", baseURL]; + } + if ([baseURL hasSuffix:@"/"]) { + baseURL = [baseURL substringToIndex:[baseURL length] - 1]; + } + + NSString *urlString = [NSString stringWithFormat:@"%@/api/bookmarks/", baseURL]; + NSURL *requestURL = [NSURL URLWithString:urlString]; + + if (!requestURL) { + NSLog(@"Invalid Linkding host URL: %@", host); + return @[]; + } + + NSLog(@"WE ARE ABOUT TO REQUEST TO URL: %@", requestURL); + + NSMutableURLRequest *theRequest = [NSMutableURLRequest requestWithURL:requestURL + cachePolicy:NSURLRequestUseProtocolCachePolicy + timeoutInterval:60.0]; + [theRequest setHTTPMethod:@"GET"]; + [theRequest setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + [theRequest setValue:[NSString stringWithFormat:@"Token %@", password] forHTTPHeaderField:@"Authorization"]; + [theRequest setValue:@"Quicksilver (Blacktree,MacOSX)" forHTTPHeaderField:@"User-Agent"]; + + NSError *error = nil; + data = [NSURLConnection sendSynchronousRequest:theRequest returningResponse:nil error:&error]; + + if (error) { + NSLog(@"Error fetching Linkding bookmarks: %@", error.localizedDescription); + return @[]; + } + + // Cache the data + [self cacheBookmarkData:data forHost:host username:username]; + } + + // Parse JSON data + NSError *jsonError = nil; + NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; + + if (jsonError) { + NSLog(@"Error parsing Linkding JSON: %@", jsonError.localizedDescription); + return @[]; + } + + NSArray *results = [jsonResponse objectForKey:@"results"]; + if (!results || ![results isKindOfClass:[NSArray class]]) { + NSLog(@"Invalid Linkding response format"); + return @[]; + } + + NSMutableArray *objects = [NSMutableArray arrayWithCapacity:1]; + NSMutableSet *tagSet = [NSMutableSet set]; + + // Create bookmark objects + for (NSDictionary *bookmark in results) { + QSObject *newObject = [self objectForLinkdingBookmark:bookmark]; + if (newObject) { + [objects addObject:newObject]; + + // Collect tags if requested + if (includeTags) { + NSArray *tags = [bookmark objectForKey:@"tag_names"]; + if (tags && [tags isKindOfClass:[NSArray class]]) { + [tagSet addObjectsFromArray:tags]; + } + } + } + } + + // Create tag objects if requested + if (includeTags) { + for (NSString *tag in tagSet) { + if (tag.length > 0) { + QSObject *tagObject = [QSObject makeObjectWithIdentifier:[NSString stringWithFormat:@"[Linkding tag]:%@", tag]]; + [tagObject setObject:tag forType:[self tagURLType]]; + [tagObject setObject:username forMeta:@"linkding.username"]; + [tagObject setObject:host forMeta:@"linkding.host"]; + [tagObject setName:tag]; + [tagObject setPrimaryType:[self tagURLType]]; + [objects addObject:tagObject]; + } + } + } + + return objects; +} + +- (NSArray *)fetchBookmarksForTag:(NSString *)tag site:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host { + NSData *data = [self cachedBookmarkDataForHost:host username:username]; + if (!data) return @[]; + + NSError *jsonError; + NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; + + if (jsonError) return @[]; + + NSArray *results = [jsonResponse objectForKey:@"results"]; + if (!results || ![results isKindOfClass:[NSArray class]]) return @[]; + + NSMutableArray *objects = [NSMutableArray arrayWithCapacity:1]; + + for (NSDictionary *bookmark in results) { + NSArray *tags = [bookmark objectForKey:@"tag_names"]; + if (tags && [tags isKindOfClass:[NSArray class]] && [tags containsObject:tag]) { + QSObject *newObject = [self objectForLinkdingBookmark:bookmark]; + if (newObject) { + [objects addObject:newObject]; + } + } + } + + return objects; +} + +- (QSObject *)objectForLinkdingBookmark:(NSDictionary *)bookmark { + NSNumber *bookmarkId = [bookmark objectForKey:@"id"]; + NSString *url = [bookmark objectForKey:@"url"]; + NSString *title = [bookmark objectForKey:@"title"]; + NSString *description = [bookmark objectForKey:@"description"]; + + if (!bookmarkId || !url) return nil; + + QSObject *newObject = [QSObject makeObjectWithIdentifier:[NSString stringWithFormat:@"linkding-%@", bookmarkId]]; + [newObject setObject:url forType:QSURLType]; + [newObject setName:title.length > 0 ? title : url]; + [newObject setDetails:description.length > 0 ? description : @""]; + [newObject setPrimaryType:QSURLType]; + + return newObject; +} + +@end diff --git a/REFACTOR_GUIDE.md b/REFACTOR_GUIDE.md new file mode 100644 index 0000000..2176a98 --- /dev/null +++ b/REFACTOR_GUIDE.md @@ -0,0 +1,118 @@ +# QSDeliciousPlugIn Refactor Guide + +This document outlines the refactoring changes made to support multiple social bookmark providers using the Strategy Pattern. + +## Overview + +The plugin has been refactored from a monolithic implementation to a modular, extensible architecture that makes it easy to add new bookmark providers. + +## Architecture + +### Core Components + +1. **SocialSite Enum** (`SocialSite.h/.m`) + - Defines supported bookmark services + - Helper methods for display names and URLs + +2. **QSBookmarkProvider Protocol** (`QSBookmarkProvider.h`) + - Defines the interface all providers must implement + - Key methods: `canHandleSite:username:password:host:`, `fetchBookmarksForSite:username:password:host:includeTags:` + +3. **QSBookmarkProviderFactory** (`QSBookmarkProviderFactory.h/.m`) + - Singleton factory that manages all providers + - Returns the appropriate provider for a given configuration + +4. **Provider Implementations** + - `QSDeliciousAPIProvider`: Handles XML-based Delicious / Pinboard v1 API (Delicious, Magnolia, Pinboard) + - `QSLinkdingProvider`: Handles JSON-based Linkding API + +### Strategy Pattern Implementation + +The main source file now uses the strategy pattern: + +```objective-c +// Get the appropriate provider using the factory +QSBookmarkProviderFactory *factory = [QSBookmarkProviderFactory sharedFactory]; +id provider = [factory providerForSite:site username:username password:password host:host]; + +if (!provider) { + NSLog(@"No provider available for site %ld with username %@", (long)site, username); + return @[]; +} + +return [provider fetchBookmarksForSite:site username:username password:password host:host includeTags:includeTags]; +``` + +## Supported Services + +| Service | ID | API Type | Authentication | Host Required | +|---------|----|---------| -------------- | ------------- | +| del.icio.us | 0 | XML/Basic Auth | Username/Password | No | +| ma.gnolia.com | 1 | XML/Basic Auth | Username/Password | No | +| Pinboard | 2 | XML/Basic Auth | Username/Password | No | +| Linkding | 3 | JSON/Token Auth | Username/API Token | Yes | + +## Adding New Providers + +1. Add a new case to the `SocialSite` enum +2. Update `SocialSiteHelper` methods +3. Create a new provider class implementing `QSBookmarkProvider` +4. Add the provider to `QSBookmarkProviderFactory.setupProviders` + +Example new provider structure: + +```objective-c +@interface QSMyNewProvider : NSObject +@end + +@implementation QSMyNewProvider + +- (BOOL)canHandleSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host { + return (site == SocialSiteMyNew) && username.length > 0 && password.length > 0; +} + +- (NSArray *)fetchBookmarksForSite:(SocialSite)site username:(NSString *)username password:(NSString *)password host:(NSString *)host includeTags:(BOOL)includeTags { + // Implementation here +} + +// ... other required methods +@end +``` + +## Interface Bindings + +The NIB file should be updated to include: + +- **Settings Dictionary** with keys: + - `username` (NSString) + - `password` (NSString) - bound to File's Owner directly + - `site` (NSInteger) - SocialSite enum value + - `host` (NSString) - required for Linkding, optional for others + - `includeTags` (BOOL) + +## Migration from Old Code + +The original `QSDeliciousPlugIn_Source.m` has been refactored into `QSDeliciousPlugIn_Source_New.m`. Key changes: + +1. Removed hardcoded site logic +2. Removed XML parsing from main class (moved to providers) +3. Added strategy pattern implementation +4. Added support for custom hosts (Linkding) +5. Simplified the main object fetching logic + +## Testing + +Tests are included in `QSDeliciousPlugInTests.m` using Swift Testing framework: +- Factory provider selection tests +- Configuration validation tests +- Helper method tests + +## Linkding Configuration + +For Linkding users: +1. Set Site to "Linkding" (value 3) +2. Enter your Linkding server URL in the Host field (e.g., `https://bookmarks.example.com`) +3. Use your API Token as the Password +4. Enter your username (though it's mainly for caching purposes in Linkding) + +The Linkding provider will automatically handle URL construction and JSON parsing. diff --git a/SocialSite.h b/SocialSite.h new file mode 100644 index 0000000..f2c848c --- /dev/null +++ b/SocialSite.h @@ -0,0 +1,23 @@ +// +// SocialSite.h +// QSDeliciousPlugIn +// +// Social bookmark site enumeration +// + +#import + +typedef NS_ENUM(NSInteger, SocialSite) { + SocialSiteDelicious = 0, + SocialSiteMagnolia = 1, + SocialSitePinboard = 2, + SocialSiteLinkding = 3 +}; + +@interface SocialSiteHelper : NSObject + ++ (NSString *)displayNameForSite:(SocialSite)site; ++ (NSString *)siteURLForSite:(SocialSite)site; ++ (NSString *)reversedSiteURLForSite:(SocialSite)site; + +@end \ No newline at end of file diff --git a/SocialSite.m b/SocialSite.m new file mode 100644 index 0000000..d002235 --- /dev/null +++ b/SocialSite.m @@ -0,0 +1,55 @@ +// +// SocialSite.m +// QSDeliciousPlugIn +// + +#import "SocialSite.h" + +@implementation SocialSiteHelper + ++ (NSString *)displayNameForSite:(SocialSite)site { + switch (site) { + case SocialSiteDelicious: + return @"del.icio.us"; + case SocialSiteMagnolia: + return @"ma.gnolia.com"; + case SocialSitePinboard: + return @"Pinboard"; + case SocialSiteLinkding: + return @"Linkding"; + default: + return @"Unknown"; + } +} + ++ (NSString *)siteURLForSite:(SocialSite)site { + switch (site) { + case SocialSiteDelicious: + return @"del.icio.us"; + case SocialSiteMagnolia: + return @"ma.gnolia.com"; + case SocialSitePinboard: + return @"pinboard.in"; + case SocialSiteLinkding: + return @""; // Will be provided by user as host + default: + return nil; + } +} + ++ (NSString *)reversedSiteURLForSite:(SocialSite)site { + switch (site) { + case SocialSiteDelicious: + return @"us.icio.del"; + case SocialSiteMagnolia: + return @"com.gnolia.ma"; + case SocialSitePinboard: + return @"in.pinboard"; + case SocialSiteLinkding: + return @"linkding"; // Generic identifier + default: + return nil; + } +} + +@end \ No newline at end of file diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index ad177ea..c117aca 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -1 +1,18 @@ -"QSDeliciousPrefPane" = "del.icio.us"; +"QSDeliciousPrefPane" = "Social Bookmarks"; + +/* Site Names */ +"SiteDelicious" = "del.icio.us"; +"SiteMagnolia" = "ma.gnolia.com"; +"SitePinboard" = "Pinboard"; +"SiteLinkding" = "Linkding"; + +/* UI Labels */ +"Username" = "Username"; +"Password" = "Password"; +"Host" = "Host"; +"IncludeTags" = "Include Tags"; +"Site" = "Bookmark Service"; + +/* Help Text */ +"LinkdingHostHelp" = "For Linkding, enter your server URL (e.g., https://bookmarks.example.com)"; +"LinkdingPasswordHelp" = "For Linkding, use your API Token as the password"; -- cgit