From 905c60d434b7f7d6405aaf3496b4e60fcbca5117 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sun, 29 Mar 2015 23:42:04 -0700 Subject: [PATCH] get a list of posts --- Blog.xcodeproj/project.pbxproj | 74 +++++++++++++- Blog/AppDelegate.h | 5 +- Blog/AppDelegate.m | 12 ++- Blog/Base.lproj/Main.storyboard | 25 ++++- Blog/BlogController.h | 43 ++++++++ Blog/BlogController.m | 138 +++++++++++++++++++++++++ Blog/BlogService.h | 39 +++++++ Blog/BlogService.m | 125 +++++++++++++++++++++++ Blog/BlogStatus.h | 18 ++++ Blog/BlogStatus.m | 19 ++++ Blog/DetailViewController.h | 7 +- Blog/DetailViewController.m | 17 ++-- Blog/JSONHTTPClient.h | 33 ++++++ Blog/JSONHTTPClient.m | 133 ++++++++++++++++++++++++ Blog/MasterViewController.h | 7 +- Blog/MasterViewController.m | 80 +++++++++++---- Blog/ModelStore.h | 37 +++++++ Blog/ModelStore.m | 175 ++++++++++++++++++++++++++++++++ Blog/NSDate+marshmallows.h | 21 ++++ Blog/NSDate+marshmallows.m | 129 +++++++++++++++++++++++ Blog/NSString+marshmallows.h | 20 ++++ Blog/NSString+marshmallows.m | 66 ++++++++++++ Blog/Post.h | 32 ++++++ Blog/Post.m | 158 ++++++++++++++++++++++++++++ Blog/main.m | 2 +- BlogTests/BlogTests.m | 2 +- 26 files changed, 1373 insertions(+), 44 deletions(-) create mode 100644 Blog/BlogController.h create mode 100644 Blog/BlogController.m create mode 100644 Blog/BlogService.h create mode 100644 Blog/BlogService.m create mode 100644 Blog/BlogStatus.h create mode 100644 Blog/BlogStatus.m create mode 100644 Blog/JSONHTTPClient.h create mode 100644 Blog/JSONHTTPClient.m create mode 100644 Blog/ModelStore.h create mode 100644 Blog/ModelStore.m create mode 100644 Blog/NSDate+marshmallows.h create mode 100644 Blog/NSDate+marshmallows.m create mode 100644 Blog/NSString+marshmallows.h create mode 100644 Blog/NSString+marshmallows.m create mode 100644 Blog/Post.h create mode 100644 Blog/Post.m diff --git a/Blog.xcodeproj/project.pbxproj b/Blog.xcodeproj/project.pbxproj index f51f84c..4807cd0 100644 --- a/Blog.xcodeproj/project.pbxproj +++ b/Blog.xcodeproj/project.pbxproj @@ -16,6 +16,14 @@ 7B5C4BED19F2606900667D48 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7B5C4BEC19F2606900667D48 /* Images.xcassets */; }; 7B5C4BF019F2606900667D48 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7B5C4BEE19F2606900667D48 /* LaunchScreen.xib */; }; 7B5C4BFC19F2606900667D48 /* BlogTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7B5C4BFB19F2606900667D48 /* BlogTests.m */; }; + 7B9E64281A227BFE0072FF42 /* Post.m in Sources */ = {isa = PBXBuildFile; fileRef = 7B9E64271A227BFE0072FF42 /* Post.m */; }; + 7B9E642D1A227FA20072FF42 /* NSDate+marshmallows.m in Sources */ = {isa = PBXBuildFile; fileRef = 7B9E642A1A227FA20072FF42 /* NSDate+marshmallows.m */; }; + 7B9E642E1A227FA20072FF42 /* NSString+marshmallows.m in Sources */ = {isa = PBXBuildFile; fileRef = 7B9E642C1A227FA20072FF42 /* NSString+marshmallows.m */; }; + 7B9E64421A22F3840072FF42 /* BlogService.m in Sources */ = {isa = PBXBuildFile; fileRef = 7B9E64411A22F3840072FF42 /* BlogService.m */; }; + 7B9E644C1A230B940072FF42 /* JSONHTTPClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 7B9E644B1A230B940072FF42 /* JSONHTTPClient.m */; }; + 7B9E644F1A23129B0072FF42 /* BlogStatus.m in Sources */ = {isa = PBXBuildFile; fileRef = 7B9E644E1A23129B0072FF42 /* BlogStatus.m */; }; + 7BF029331A27117200E42EDE /* ModelStore.m in Sources */ = {isa = PBXBuildFile; fileRef = 7BF029321A27117200E42EDE /* ModelStore.m */; }; + 7BF029381A280CB200E42EDE /* BlogController.m in Sources */ = {isa = PBXBuildFile; fileRef = 7BF029371A280CB200E42EDE /* BlogController.m */; }; B8B8958B2AA40812EFE04FEF /* libPods-BlogTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C36BC848680D4F831E4DE23 /* libPods-BlogTests.a */; }; /* End PBXBuildFile section */ @@ -46,6 +54,22 @@ 7B5C4BF519F2606900667D48 /* BlogTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BlogTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7B5C4BFA19F2606900667D48 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7B5C4BFB19F2606900667D48 /* BlogTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BlogTests.m; sourceTree = ""; }; + 7B9E64261A227BFE0072FF42 /* Post.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Post.h; sourceTree = ""; }; + 7B9E64271A227BFE0072FF42 /* Post.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Post.m; sourceTree = ""; }; + 7B9E64291A227FA20072FF42 /* NSDate+marshmallows.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+marshmallows.h"; sourceTree = ""; }; + 7B9E642A1A227FA20072FF42 /* NSDate+marshmallows.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+marshmallows.m"; sourceTree = ""; }; + 7B9E642B1A227FA20072FF42 /* NSString+marshmallows.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+marshmallows.h"; sourceTree = ""; }; + 7B9E642C1A227FA20072FF42 /* NSString+marshmallows.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+marshmallows.m"; sourceTree = ""; }; + 7B9E64401A22F3840072FF42 /* BlogService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogService.h; sourceTree = ""; }; + 7B9E64411A22F3840072FF42 /* BlogService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogService.m; sourceTree = ""; }; + 7B9E644A1A230B940072FF42 /* JSONHTTPClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = JSONHTTPClient.h; sourceTree = ""; }; + 7B9E644B1A230B940072FF42 /* JSONHTTPClient.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = JSONHTTPClient.m; sourceTree = ""; }; + 7B9E644D1A23129B0072FF42 /* BlogStatus.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogStatus.h; sourceTree = ""; }; + 7B9E644E1A23129B0072FF42 /* BlogStatus.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogStatus.m; sourceTree = ""; }; + 7BF029311A27117200E42EDE /* ModelStore.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModelStore.h; sourceTree = ""; }; + 7BF029321A27117200E42EDE /* ModelStore.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModelStore.m; sourceTree = ""; }; + 7BF029361A280CB200E42EDE /* BlogController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogController.h; sourceTree = ""; }; + 7BF029371A280CB200E42EDE /* BlogController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogController.m; sourceTree = ""; }; 9C36BC848680D4F831E4DE23 /* libPods-BlogTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BlogTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; A2EB178BEF4356711B2710AE /* Pods.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = Pods.release.xcconfig; path = "Pods/Target Support Files/Pods/Pods.release.xcconfig"; sourceTree = ""; }; AB1234AC662F1A1B7BD87AB0 /* Pods-BlogTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BlogTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-BlogTests/Pods-BlogTests.release.xcconfig"; sourceTree = ""; }; @@ -107,6 +131,9 @@ 7B5C4BDB19F2606900667D48 /* Blog */ = { isa = PBXGroup; children = ( + 7B9E64331A2281FC0072FF42 /* Categories */, + 7BF029341A27118300E42EDE /* Models */, + 7BF029351A27119B00E42EDE /* Service */, 7B5C4BE019F2606900667D48 /* AppDelegate.h */, 7B5C4BE119F2606900667D48 /* AppDelegate.m */, 7B5C4BE319F2606900667D48 /* MasterViewController.h */, @@ -147,6 +174,43 @@ name = "Supporting Files"; sourceTree = ""; }; + 7B9E64331A2281FC0072FF42 /* Categories */ = { + isa = PBXGroup; + children = ( + 7B9E64291A227FA20072FF42 /* NSDate+marshmallows.h */, + 7B9E642A1A227FA20072FF42 /* NSDate+marshmallows.m */, + 7B9E642B1A227FA20072FF42 /* NSString+marshmallows.h */, + 7B9E642C1A227FA20072FF42 /* NSString+marshmallows.m */, + ); + name = Categories; + sourceTree = ""; + }; + 7BF029341A27118300E42EDE /* Models */ = { + isa = PBXGroup; + children = ( + 7B9E644D1A23129B0072FF42 /* BlogStatus.h */, + 7B9E644E1A23129B0072FF42 /* BlogStatus.m */, + 7B9E64261A227BFE0072FF42 /* Post.h */, + 7B9E64271A227BFE0072FF42 /* Post.m */, + ); + name = Models; + sourceTree = ""; + }; + 7BF029351A27119B00E42EDE /* Service */ = { + isa = PBXGroup; + children = ( + 7B9E64401A22F3840072FF42 /* BlogService.h */, + 7B9E64411A22F3840072FF42 /* BlogService.m */, + 7B9E644A1A230B940072FF42 /* JSONHTTPClient.h */, + 7B9E644B1A230B940072FF42 /* JSONHTTPClient.m */, + 7BF029311A27117200E42EDE /* ModelStore.h */, + 7BF029321A27117200E42EDE /* ModelStore.m */, + 7BF029361A280CB200E42EDE /* BlogController.h */, + 7BF029371A280CB200E42EDE /* BlogController.m */, + ); + name = Service; + sourceTree = ""; + }; F9CC479BA9A49F1EDD27B0AB /* Frameworks */ = { isa = PBXGroup; children = ( @@ -209,7 +273,6 @@ TargetAttributes = { 7B5C4BD819F2606900667D48 = { CreatedOnToolsVersion = 6.0.1; - DevelopmentTeam = B2W6993X5Z; }; 7B5C4BF419F2606900667D48 = { CreatedOnToolsVersion = 6.0.1; @@ -324,10 +387,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7B9E64281A227BFE0072FF42 /* Post.m in Sources */, 7B5C4BE219F2606900667D48 /* AppDelegate.m in Sources */, + 7B9E644C1A230B940072FF42 /* JSONHTTPClient.m in Sources */, + 7B9E642D1A227FA20072FF42 /* NSDate+marshmallows.m in Sources */, 7B5C4BE519F2606900667D48 /* MasterViewController.m in Sources */, + 7B9E642E1A227FA20072FF42 /* NSString+marshmallows.m in Sources */, + 7B9E64421A22F3840072FF42 /* BlogService.m in Sources */, 7B5C4BDF19F2606900667D48 /* main.m in Sources */, + 7BF029331A27117200E42EDE /* ModelStore.m in Sources */, + 7BF029381A280CB200E42EDE /* BlogController.m in Sources */, 7B5C4BE819F2606900667D48 /* DetailViewController.m in Sources */, + 7B9E644F1A23129B0072FF42 /* BlogStatus.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -453,6 +524,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEBUG_INFORMATION_FORMAT = dwarf; INFOPLIST_FILE = Blog/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Blog/AppDelegate.h b/Blog/AppDelegate.h index b2d0c9a..d1abc35 100644 --- a/Blog/AppDelegate.h +++ b/Blog/AppDelegate.h @@ -6,12 +6,11 @@ // Copyright (c) 2014 Guru Logic Inc. All rights reserved. // -#import +@import UIKit; @interface AppDelegate : UIResponder -@property (strong, nonatomic) UIWindow *window; - +@property (nonatomic, strong) UIWindow *window; @end diff --git a/Blog/AppDelegate.m b/Blog/AppDelegate.m index c41c038..731cf81 100644 --- a/Blog/AppDelegate.m +++ b/Blog/AppDelegate.m @@ -8,15 +8,19 @@ #import "AppDelegate.h" #import "DetailViewController.h" +#import "BlogService.h" @interface AppDelegate () +@property (nonatomic, readonly, strong) BlogService *blogService; + @end @implementation AppDelegate - - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + _blogService = [BlogService new]; + // Override point for customization after application launch. UISplitViewController *splitViewController = (UISplitViewController *)self.window.rootViewController; UINavigationController *navigationController = [splitViewController.viewControllers lastObject]; @@ -47,10 +51,10 @@ // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } -#pragma mark - Split view +#pragma mark - UISplitViewDelegate methods - (BOOL)splitViewController:(UISplitViewController *)splitViewController collapseSecondaryViewController:(UIViewController *)secondaryViewController ontoPrimaryViewController:(UIViewController *)primaryViewController { - if ([secondaryViewController isKindOfClass:[UINavigationController class]] && [[(UINavigationController *)secondaryViewController topViewController] isKindOfClass:[DetailViewController class]] && ([(DetailViewController *)[(UINavigationController *)secondaryViewController topViewController] detailItem] == nil)) { + if ([secondaryViewController isKindOfClass:[UINavigationController class]] && [[(UINavigationController *)secondaryViewController topViewController] isKindOfClass:[DetailViewController class]] && ([(DetailViewController *)[(UINavigationController *)secondaryViewController topViewController] post] == nil)) { // Return YES to indicate that we have handled the collapse by doing nothing; the secondary controller will be discarded. return YES; } else { @@ -58,4 +62,6 @@ } } +#pragma mark - AppObjectDelegate methods + @end diff --git a/Blog/Base.lproj/Main.storyboard b/Blog/Base.lproj/Main.storyboard index eeb60d1..5f4cdfc 100644 --- a/Blog/Base.lproj/Main.storyboard +++ b/Blog/Base.lproj/Main.storyboard @@ -1,7 +1,7 @@ - + - + @@ -113,9 +113,28 @@ - + + + + + + + + + + + + + + + + + + + + diff --git a/Blog/BlogController.h b/Blog/BlogController.h new file mode 100644 index 0000000..dc19529 --- /dev/null +++ b/Blog/BlogController.h @@ -0,0 +1,43 @@ +// +// BlogController.h +// Blog +// +// Created by Sami Samhuri on 2014-11-27. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +@import Foundation; + +NSString *BlogStatusChangedNotification; +NSString *BlogDraftsChangedNotification; +NSString *BlogDraftAddedNotification; +NSString *BlogDraftRemovedNotification; +NSString *BlogPublishedPostsChangedNotification; +NSString *BlogPublishedPostAddedNotification; +NSString *BlogPublishedPostRemovedNotification; +NSString *BlogPostChangedNotification; +NSString *BlogPostDeletedNotification; + +@class PMKPromise; +@class ModelStore; +@class BlogService; + +@interface BlogController : NSObject + +- (instancetype)initWithService:(BlogService *)service store:(ModelStore *)store; + +- (NSURL *)previewURLForPostWithPath:(NSString *)path; + +- (PMKPromise *)requestBlogStatus; + +- (PMKPromise *)requestDrafts; +- (PMKPromise *)requestPublishedPosts; +- (PMKPromise *)requestPostWithPath:(NSString *)path; + +- (PMKPromise *)requestCreateDraftWithID:(NSString *)draftID title:(NSString *)title body:(NSString *)body link:(NSString *)link; +- (PMKPromise *)requestUpdatePostWithPath:(NSString *)path title:(NSString *)title body:(NSString *)body link:(NSString *)link; +- (PMKPromise *)requestPublishDraftWithPath:(NSString *)path; +- (PMKPromise *)requestUnpublishPostWithPath:(NSString *)path; +- (PMKPromise *)requestDeletePostWithPath:(NSString *)path; + +@end diff --git a/Blog/BlogController.m b/Blog/BlogController.m new file mode 100644 index 0000000..2e3e853 --- /dev/null +++ b/Blog/BlogController.m @@ -0,0 +1,138 @@ +// +// BlogController.m +// Blog +// +// Created by Sami Samhuri on 2014-11-27. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import "BlogController.h" +#import +#import "BlogService.h" +#import "ModelStore.h" +#import "BlogStatus.h" +#import "Post.h" + +NSString *BlogStatusChangedNotification = @"BlogStatusChangedNotification"; +NSString *BlogDraftsChangedNotification = @"BlogDraftsChangedNotification"; +NSString *BlogDraftAddedNotification = @"BlogDraftAddedNotification"; +NSString *BlogDraftRemovedNotification = @"BlogDraftRemovedNotification"; +NSString *BlogPublishedPostsChangedNotification = @"BlogPublishedPostsChangedNotification"; +NSString *BlogPublishedPostAddedNotification = @"BlogPostAddedNotification"; +NSString *BlogPublishedPostRemovedNotification = @"BlogPostRemovedNotification"; +NSString *BlogPostChangedNotification = @"BlogPostChangedNotification"; +NSString *BlogPostDeletedNotification = @"BlogPostDeletedNotification"; + +@implementation BlogController { + BlogService *_service; + ModelStore *_store; +} + +- (instancetype)initWithService:(BlogService *)service store:(ModelStore *)store { + self = [super init]; + _service = service; + _store = store; + return self; +} + +- (NSURL *)previewURLForPostWithPath:(NSString *)path { + return [_service previewURLForPostWithPath:path]; +} + +- (PMKPromise *)requestBlogStatus { + BlogStatus *status = [_store blogStatus]; + if (status) { + return [PMKPromise promiseWithValue:status]; + } + else { + return [_service requestBlogStatus].then(^(BlogStatus *status) { + [_store saveBlogStatus:status]; + return status; + }); + } +} + +- (PMKPromise *)requestDrafts { + NSArray *posts = [_store drafts]; + if (posts) { + return [PMKPromise promiseWithValue:posts]; + } + else { + return [_service requestDrafts].then(^(NSArray *posts) { + [_store saveDrafts:posts]; + return posts; + }); + } +} + +- (PMKPromise *)requestPublishedPosts { + NSArray *posts = [_store publishedPosts]; + if (posts) { + return [PMKPromise promiseWithValue:posts]; + } + else { + return [_service requestPublishedPosts].then(^(NSArray *posts) { + [_store savePublishedPosts:posts]; + return posts; + }); + } +} + +- (PMKPromise *)requestPostWithPath:(NSString *)path { + Post *post = [_store postWithPath:path]; + if (post) { + return [PMKPromise promiseWithValue:post]; + } + else { + return [_service requestPostWithPath:path].then(^(Post *post) { + [_store savePost:post]; + return post; + }); + } +} + +- (PMKPromise *)requestCreateDraftWithID:(NSString *)draftID title:(NSString *)title body:(NSString *)body link:(NSString *)link { + Post *post = [[Post alloc] initWithDictionary:@{@"objectID": draftID ?: [NSNull null], + @"title": title ?: [NSNull null], + @"body": body ?: [NSNull null], + @"link": link ?: [NSNull null], + @"draft": @YES, + } error:nil]; + return [_service requestCreateDraftWithID:draftID title:title body:body link:link].then(^(Post *post) { + [_store addDraft:post]; + return post; + }); +} + +- (PMKPromise *)requestUpdatePostWithPath:(NSString *)path title:(NSString *)title body:(NSString *)body link:(NSString *)link { + return [_service requestUpdatePostWithPath:path title:title body:body link:link].then(^(Post *post) { + [_store savePost:post]; + return post; + }); +} + +- (PMKPromise *)requestPublishDraftWithPath:(NSString *)path { + return [_service requestPublishDraftWithPath:path].then(^(Post *post) { + [_store removeDraftWithPath:path]; + [_store addPublishedPost:post]; + return post; + }); +} + +- (PMKPromise *)requestUnpublishPostWithPath:(NSString *)path { + return [_service requestUnpublishPostWithPath:path].then(^(Post *post) { + [_store removePostWithPath:path]; + [_store addDraft:post]; + return post; + }); +} + +- (PMKPromise *)requestDeletePostWithPath:(NSString *)path { + return [_service requestDeletePostWithPath:path].then(^(id _) { + [_store removePostWithPath:path]; + [_store removeDraftWithPath:path]; + return _; + }); +} + +@end diff --git a/Blog/BlogService.h b/Blog/BlogService.h new file mode 100644 index 0000000..13b2808 --- /dev/null +++ b/Blog/BlogService.h @@ -0,0 +1,39 @@ +// +// BlogService.h +// Blog +// +// Created by Sami Samhuri on 2014-11-23. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +@import Foundation; +#import + +extern NSString * const BlogServiceErrorDomain; + +typedef NS_ENUM(NSUInteger, BlogServiceErrorCode) { + BlogServiceErrorCodeWTF +}; + +@class JSONHTTPClient; + +@interface BlogService : NSObject + +- (instancetype)initWithRootURL:(NSString *)rootURL client:(JSONHTTPClient *)client; + +- (NSURL *)previewURLForPostWithPath:(NSString *)path; + +- (PMKPromise *)requestBlogStatus; +- (PMKPromise *)requestPublishEnvironment:(NSString *)environment; + +- (PMKPromise *)requestDrafts; +- (PMKPromise *)requestPublishedPosts; +- (PMKPromise *)requestPostWithPath:(NSString *)path; + +- (PMKPromise *)requestCreateDraftWithID:(NSString *)draftID title:(NSString *)title body:(NSString *)body link:(NSString *)link; +- (PMKPromise *)requestUpdatePostWithPath:(NSString *)path title:(NSString *)title body:(NSString *)body link:(NSString *)link; +- (PMKPromise *)requestPublishDraftWithPath:(NSString *)path; +- (PMKPromise *)requestUnpublishPostWithPath:(NSString *)path; +- (PMKPromise *)requestDeletePostWithPath:(NSString *)path; + +@end diff --git a/Blog/BlogService.m b/Blog/BlogService.m new file mode 100644 index 0000000..ea73c96 --- /dev/null +++ b/Blog/BlogService.m @@ -0,0 +1,125 @@ +// +// BlogService.m +// Blog +// +// Created by Sami Samhuri on 2014-11-23. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import "BlogService.h" +#import +#import "NSString+marshmallows.h" +#import "JSONHTTPClient.h" +#import "BlogStatus.h" +#import "Post.h" + +NSString * const BlogServiceErrorDomain = @"BlogServiceErrorDomain"; + +@interface BlogService () + +@property (nonatomic, readonly, strong) NSString *rootURL; +@property (nonatomic, readonly, strong) JSONHTTPClient *client; + +@end + +@implementation BlogService + +- (instancetype)initWithRootURL:(NSString *)rootURL client:(JSONHTTPClient *)client { + NSParameterAssert([rootURL length]); + NSParameterAssert(client); + self = [super init]; + _rootURL = [rootURL mm_stringByReplacing:@"/$" with:@""]; + _client = client; + return self; +} + +- (NSURL *)previewURLForPostWithPath:(NSString *)path { + return [self urlFor:@"%@/preview", path]; +} + +- (NSURL *)urlFor:(NSString *)path, ... { + va_list args; + va_start(args, path); + path = [[NSString alloc] initWithFormat:path arguments:args]; + va_end(args); + + NSString *slash = [path hasPrefix:@"/"] ? @"" : @"/"; + NSString *urlString = [self.rootURL stringByAppendingFormat:@"%@%@", slash, path]; + NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + @throw [NSException exceptionWithName:@"BlogServiceException" reason:@"Invalid URL" userInfo:@{@"URL": urlString ?: [NSNull null]}]; + } + return url; +} + +- (id (^)(NSDictionary *))decodePostBlock { + return ^(NSDictionary *root) { + NSError *error = nil; + Post *post = [MTLJSONAdapter modelOfClass:[Post class] fromJSONDictionary:root[@"post"] error:&error]; + return post ?: error; + }; +} + +- (id (^)(NSDictionary *))decodePostsBlock { + return ^(NSDictionary *root) { + NSError *error = nil; + NSArray *posts = [MTLJSONAdapter modelsOfClass:[Post class] fromJSONArray:root[@"posts"] error:&error]; + return posts ?: error; + }; +} + +- (PMKPromise *)requestBlogStatus { + return [self.client get:[self urlFor:@"/status"] headers:nil].then(^(NSDictionary *root) { + NSError *error = nil; + BlogStatus *status = [MTLJSONAdapter modelOfClass:[BlogStatus class] fromJSONDictionary:root[@"status"] error:&error]; + return status ?: error; + }); +} + +- (PMKPromise *)requestPublishEnvironment:(NSString *)environment { + NSDictionary *fields = @{@"env": environment ?: @"staging"}; + return [self.client postJSON:[self urlFor:@"/publish"] headers:nil fields:fields]; +} + +- (PMKPromise *)requestDrafts { + return [self.client get:[self urlFor:@"/drafts"] headers:nil].then([self decodePostsBlock]); +} + +- (PMKPromise *)requestPublishedPosts { + return [self.client get:[self urlFor:@"/posts"] headers:nil].then([self decodePostsBlock]); +} + +- (PMKPromise *)requestPostWithPath:(NSString *)path { + return [self.client get:[self urlFor:path] headers:nil].then([self decodePostBlock]); +} + +- (PMKPromise *)requestCreateDraftWithID:(NSString *)draftID title:(NSString *)title body:(NSString *)body link:(NSString *)link { + NSDictionary *fields = @{@"id": draftID, + @"title": title, + @"body": body, + @"link": link, + }; + return [self.client postJSON:[self urlFor:@"/drafts"] headers:nil fields:fields].then([self decodePostBlock]); +} + +- (PMKPromise *)requestPublishDraftWithPath:(NSString *)path { + return [self.client post:[self urlFor:@"%@/publish", path] headers:nil].then([self decodePostBlock]); +} + +- (PMKPromise *)requestUnpublishPostWithPath:(NSString *)path { + return [self.client post:[self urlFor:@"%@/unpublish", path] headers:nil]; +} + +- (PMKPromise *)requestUpdatePostWithPath:(NSString *)path title:(NSString *)title body:(NSString *)body link:(NSString *)link { + NSDictionary *fields = @{@"title": title, + @"body": body, + @"link": link, + }; + return [self.client putJSON:[self urlFor:path] headers:nil fields:fields]; +} + +- (PMKPromise *)requestDeletePostWithPath:(NSString *)path { + return [self.client delete:[self urlFor:path] headers:nil]; +} + +@end diff --git a/Blog/BlogStatus.h b/Blog/BlogStatus.h new file mode 100644 index 0000000..b7cfdc2 --- /dev/null +++ b/Blog/BlogStatus.h @@ -0,0 +1,18 @@ +// +// BlogStatus.h +// Blog +// +// Created by Sami Samhuri on 2014-11-23. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +@import Foundation; +#import + +@interface BlogStatus : MTLModel + +@property (nonatomic, readonly, strong) NSString *localVersion; +@property (nonatomic, readonly, strong) NSString *remoteVersion; +@property (nonatomic, readonly, getter=isDirty) BOOL dirty; + +@end diff --git a/Blog/BlogStatus.m b/Blog/BlogStatus.m new file mode 100644 index 0000000..e4384ec --- /dev/null +++ b/Blog/BlogStatus.m @@ -0,0 +1,19 @@ +// +// BlogStatus.m +// Blog +// +// Created by Sami Samhuri on 2014-11-23. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import "BlogStatus.h" + +@implementation BlogStatus + ++ (NSDictionary *)JSONKeyPathsByPropertyKey { + return @{@"localVersion": @"local-version", + @"remoteVersion": @"remote-version", + }; +} + +@end diff --git a/Blog/DetailViewController.h b/Blog/DetailViewController.h index d2ef2a9..4b324a7 100644 --- a/Blog/DetailViewController.h +++ b/Blog/DetailViewController.h @@ -6,12 +6,13 @@ // Copyright (c) 2014 Guru Logic Inc. All rights reserved. // -#import +@import UIKit; + +@class Post; @interface DetailViewController : UIViewController -@property (strong, nonatomic) id detailItem; -@property (weak, nonatomic) IBOutlet UILabel *detailDescriptionLabel; +@property (strong, nonatomic) Post *post; @end diff --git a/Blog/DetailViewController.m b/Blog/DetailViewController.m index a075fdb..0c36b48 100644 --- a/Blog/DetailViewController.m +++ b/Blog/DetailViewController.m @@ -7,19 +7,22 @@ // #import "DetailViewController.h" +#import "Post.h" @interface DetailViewController () +@property (weak, nonatomic) IBOutlet UILabel *detailDescriptionLabel; + @end @implementation DetailViewController #pragma mark - Managing the detail item -- (void)setDetailItem:(id)newDetailItem { - if (_detailItem != newDetailItem) { - _detailItem = newDetailItem; - +- (void)setPost:(id)newPost { + if (_post != newPost) { + _post = newPost; + // Update the view. [self configureView]; } @@ -27,8 +30,10 @@ - (void)configureView { // Update the user interface for the detail item. - if (self.detailItem) { - self.detailDescriptionLabel.text = [self.detailItem description]; + if (self.post) { + // FIXME: date, link (edit, open), status (draft, published), delete, preview, publish + self.navigationItem.title = self.post.title ?: @"Untitled"; + self.detailDescriptionLabel.text = self.post.body; } } diff --git a/Blog/JSONHTTPClient.h b/Blog/JSONHTTPClient.h new file mode 100644 index 0000000..3b22fe8 --- /dev/null +++ b/Blog/JSONHTTPClient.h @@ -0,0 +1,33 @@ +// +// JSONHTTPClient.h +// Blog +// +// Created by Sami Samhuri on 2014-11-23. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import +#import + +extern NSString * const JSONHTTPClientErrorDomain; + +typedef enum : NSUInteger { + JSONHTTPClientErrorCodeWTF, + JSONHTTPClientErrorCodeInvalidResponse, + JSONHTTPClientErrorCodeRequestFailed +} JSONHTTPClientErrorCode; + +@interface JSONHTTPClient : NSObject + +@property (nonatomic, strong) NSDictionary *defaultHeaders; + +- (instancetype)initWithSession:(NSURLSession *)session; + +- (PMKPromise *)request:(NSURLRequest *)request; +- (PMKPromise *)get:(NSURL *)url headers:(NSDictionary *)headers; +- (PMKPromise *)putJSON:(NSURL *)url headers:(NSDictionary *)headers fields:(NSDictionary *)fields; +- (PMKPromise *)postJSON:(NSURL *)url headers:(NSDictionary *)headers fields:(NSDictionary *)fields; +- (PMKPromise *)post:(NSURL *)url headers:(NSDictionary *)headers; +- (PMKPromise *)delete:(NSURL *)url headers:(NSDictionary *)headers; + +@end diff --git a/Blog/JSONHTTPClient.m b/Blog/JSONHTTPClient.m new file mode 100644 index 0000000..36a80ff --- /dev/null +++ b/Blog/JSONHTTPClient.m @@ -0,0 +1,133 @@ +// +// JSONHTTPClient.m +// Blog +// +// Created by Sami Samhuri on 2014-11-23. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import "JSONHTTPClient.h" + +NSString * const JSONHTTPClientErrorDomain = @"JSONHTTPClientErrorDomain"; + +@interface JSONHTTPClient () + +@property (nonatomic, readonly, strong) NSURLSession *session; + +@end + +@implementation JSONHTTPClient + +- (instancetype)initWithSession:(NSURLSession *)session { + self = [super init]; + _session = session; + return self; +} + +- (PMKPromise *)get:(NSURL *)url headers:(NSDictionary *)headers { + return [self request:[self requestWithMethod:@"GET" URL:url headers:headers data:nil]]; +} + +- (PMKPromise *)putJSON:(NSURL *)url headers:(NSDictionary *)headers fields:(NSDictionary *)fields { + return [self JSONRequestWithMethod:@"PUT" url:url headers:headers fields:fields]; +} + +- (PMKPromise *)postJSON:(NSURL *)url headers:(NSDictionary *)headers fields:(NSDictionary *)fields { + return [self JSONRequestWithMethod:@"POST" url:url headers:headers fields:fields]; +} + +- (PMKPromise *)post:(NSURL *)url headers:(NSDictionary *)headers { + return [self request:[self requestWithMethod:@"POST" URL:url headers:headers data:nil]]; +} + +- (PMKPromise *)delete:(NSURL *)url headers:(NSDictionary *)headers { + return [self request:[self requestWithMethod:@"DELETE" URL:url headers:headers data:nil]]; +} + +- (PMKPromise *)request:(NSURLRequest *)request { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [[self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + NSHTTPURLResponse *httpResponse = [response isKindOfClass:[NSHTTPURLResponse class]] ? (NSHTTPURLResponse *)response : nil; + if (error) { + reject(error); + } + else if (httpResponse) { + NSDictionary *headers = [httpResponse allHeaderFields]; + NSString *type = headers[@"Content-Type"]; + if (httpResponse.statusCode >= 200 && httpResponse.statusCode < 300) { + if ([type hasPrefix:@"application/json"]) { + NSError *jsonError = nil; + id root = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; + if (root) + { + fulfill(PMKManifold(root, headers, @(httpResponse.statusCode))); + } + else { + reject(jsonError); + } + } + else if ([data length] > 0) { + NSDictionary *info = @{NSLocalizedDescriptionKey: @"response type is not JSON", + @"type": type ?: [NSNull null], + @"length": headers[@"Content-Length"] ?: [NSNull null], + @"request": request, + @"response": httpResponse, + }; + NSError *error = [NSError errorWithDomain:JSONHTTPClientErrorDomain code:JSONHTTPClientErrorCodeInvalidResponse userInfo:info]; + reject(error); + } + else { + fulfill(PMKManifold(nil, headers, @(httpResponse.statusCode))); + } + } + else { + NSDictionary *info = @{NSLocalizedDescriptionKey: @"HTTP request failed", + @"status": @(httpResponse.statusCode), + @"request": request, + @"response": httpResponse, + }; + NSError *error = [NSError errorWithDomain:JSONHTTPClientErrorDomain code:JSONHTTPClientErrorCodeRequestFailed userInfo:info]; + reject(error); + } + } + else { + NSDictionary *info = @{NSLocalizedDescriptionKey: @"response is not an HTTP response"}; + NSError *error = [NSError errorWithDomain:JSONHTTPClientErrorDomain code:JSONHTTPClientErrorCodeWTF userInfo:info]; + reject(error); + } + }] resume]; + }]; +} + +- (NSURLRequest *)requestWithMethod:(NSString *)method URL:(NSURL *)url headers:(NSDictionary *)headers data:(NSData *)data { + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + [request setHTTPMethod:method]; + [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + for (NSString *key in [self.defaultHeaders allKeys]) { + [request setValue:self.defaultHeaders[key] forHTTPHeaderField:key]; + } + for (NSString *key in [headers allKeys]) { + [request setValue:headers[key] forHTTPHeaderField:key]; + } + if (data) { + [request setValue:[NSString stringWithFormat:@"%lu", [data length]] forKey:@"Content-Length"]; + [request setHTTPBody:data]; + } + return request; +} + +- (PMKPromise *)JSONRequestWithMethod:(NSString *)method url:(NSURL *)url headers:(NSDictionary *)headers fields:(NSDictionary *)fields { + NSMutableDictionary *newHeaders = [headers ?: @{} mutableCopy]; + newHeaders[@"Content-Type"] = @"application/json"; + NSError *error = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:fields options:0 error:&error]; + if (data) { + return [self request:[self requestWithMethod:method URL:url headers:newHeaders data:data]]; + } + else { + NSLog(@"error: %@ %@", error, [error userInfo]); + return [PMKPromise promiseWithValue:error]; + } +} + +@end diff --git a/Blog/MasterViewController.h b/Blog/MasterViewController.h index dff3356..997fecd 100644 --- a/Blog/MasterViewController.h +++ b/Blog/MasterViewController.h @@ -6,14 +6,9 @@ // Copyright (c) 2014 Guru Logic Inc. All rights reserved. // -#import - -@class DetailViewController; +@import UIKit; @interface MasterViewController : UITableViewController -@property (strong, nonatomic) DetailViewController *detailViewController; - - @end diff --git a/Blog/MasterViewController.m b/Blog/MasterViewController.m index 67278c7..9e86de2 100644 --- a/Blog/MasterViewController.m +++ b/Blog/MasterViewController.m @@ -8,10 +8,22 @@ #import "MasterViewController.h" #import "DetailViewController.h" +#import "Post.h" +#import "BlogController.h" +#import "ModelStore.h" +#import "BlogService.h" +#import "YapDatabaseConnection.h" +#import "YapDatabase.h" +#import "JSONHTTPClient.h" @interface MasterViewController () -@property NSMutableArray *objects; +@property (strong, nonatomic) NSMutableArray *posts; +@property (strong, nonatomic) DetailViewController *detailViewController; +@property (strong, nonatomic) IBOutlet UIBarButtonItem *publishButton; +@property (strong, nonatomic) IBOutlet UIBarButtonItem *addButton; +@property (strong, nonatomic) BlogController *blogController; + @end @implementation MasterViewController @@ -22,16 +34,44 @@ self.clearsSelectionOnViewWillAppear = NO; self.preferredContentSize = CGSizeMake(320.0, 600.0); } + NSString *cachesPath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).lastObject; + NSString *path = [cachesPath stringByAppendingPathComponent:@"blog.sqlite"]; + YapDatabase *database = [[YapDatabase alloc] initWithPath:path]; + YapDatabaseConnection *connection = [database newConnection]; + ModelStore *store = [[ModelStore alloc] initWithConnection:connection]; + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration]; + JSONHTTPClient *client = [[JSONHTTPClient alloc] initWithSession:session]; + BlogService *service = [[BlogService alloc] initWithRootURL:@"http://ocean.samhuri.net:6706/" client:client]; + self.blogController = [[BlogController alloc] initWithService:service store:store]; } - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view, typically from a nib. -// self.navigationItem.leftBarButtonItem = self.editButtonItem; + UINavigationController *detailNavController = self.splitViewController.viewControllers.lastObject; + self.detailViewController = (DetailViewController *)detailNavController.topViewController; +} - UIBarButtonItem *addButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(insertNewObject:)]; - self.navigationItem.rightBarButtonItem = addButton; - self.detailViewController = (DetailViewController *)[[self.splitViewController.viewControllers lastObject] topViewController]; +- (void)viewWillAppear:(BOOL)animated; +{ + [super viewWillAppear:animated]; + if (!self.posts) { + [self requestDrafts]; + } +} + +- (void)requestDrafts { + // TODO: show a spinner + [self.blogController requestDrafts].then(^(NSArray *drafts) { + return [self.blogController requestPublishedPosts].then(^(NSArray *posts) { + NSLog(@"drafts = %@", drafts); + NSLog(@"posts = %@", posts); + self.posts = [drafts mutableCopy]; + [self.posts addObjectsFromArray:posts]; + [self.tableView reloadData]; + }); + }); } - (void)didReceiveMemoryWarning { @@ -39,23 +79,25 @@ // Dispose of any resources that can be recreated. } -- (void)insertNewObject:(id)sender { - if (!self.objects) { - self.objects = [[NSMutableArray alloc] init]; - } - [self.objects insertObject:[NSDate date] atIndex:0]; +- (IBAction)insertNewObject:(id)sender { + Post *post = [[Post alloc] initWithDictionary:@{@"draft": @(YES)} error:nil]; + [self.posts insertObject:post atIndex:0]; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; [self.tableView insertRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; } +- (IBAction)publish:(id)sender { + NSLog(@"publish"); +} + #pragma mark - Segues - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender { if ([[segue identifier] isEqualToString:@"showDetail"]) { NSIndexPath *indexPath = [self.tableView indexPathForSelectedRow]; - NSDate *object = self.objects[indexPath.row]; + Post *post = self.posts[indexPath.row]; DetailViewController *controller = (DetailViewController *)[[segue destinationViewController] topViewController]; - [controller setDetailItem:object]; + [controller setPost:post]; controller.navigationItem.leftBarButtonItem = self.splitViewController.displayModeButtonItem; controller.navigationItem.leftItemsSupplementBackButton = YES; } @@ -68,14 +110,16 @@ } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { - return self.objects.count; + return self.posts.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath]; - NSDate *object = self.objects[indexPath.row]; - cell.textLabel.text = [object description]; + Post *post = self.posts[indexPath.row]; + // FIXME: unique title + cell.textLabel.text = post.title ?: @"Untitled"; + cell.detailTextLabel.text = post.draft ? @"Draft" : post.formattedDate; return cell; } @@ -86,9 +130,11 @@ - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { if (editingStyle == UITableViewCellEditingStyleDelete) { - [self.objects removeObjectAtIndex:indexPath.row]; + [self.posts removeObjectAtIndex:indexPath.row]; + // TODO: delete from server [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade]; - } else if (editingStyle == UITableViewCellEditingStyleInsert) { + } + else if (editingStyle == UITableViewCellEditingStyleInsert) { // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view. } } diff --git a/Blog/ModelStore.h b/Blog/ModelStore.h new file mode 100644 index 0000000..a421530 --- /dev/null +++ b/Blog/ModelStore.h @@ -0,0 +1,37 @@ +// +// ModelStore.h +// Blog +// +// Created by Sami Samhuri on 2014-11-26. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import + +@class PMKPromise; +@class YapDatabaseConnection; +@class BlogStatus; +@class Post; + +@interface ModelStore : NSObject + +- (instancetype)initWithConnection:(YapDatabaseConnection *)connection; + +- (BlogStatus *)blogStatus; + +- (NSArray *)drafts; +- (NSArray *)publishedPosts; +- (Post *)postWithPath:(NSString *)path; + +- (PMKPromise *)saveBlogStatus:(BlogStatus *)blogStatus; +- (PMKPromise *)savePost:(Post *)post; +- (PMKPromise *)saveDrafts:(NSArray *)posts; +- (PMKPromise *)savePublishedPosts:(NSArray *)posts; + +- (PMKPromise *)addDraft:(Post *)post; +- (PMKPromise *)addPublishedPost:(Post *)post; + +- (PMKPromise *)removeDraftWithPath:(NSString *)path; +- (PMKPromise *)removePostWithPath:(NSString *)path; + +@end diff --git a/Blog/ModelStore.m b/Blog/ModelStore.m new file mode 100644 index 0000000..032f0cc --- /dev/null +++ b/Blog/ModelStore.m @@ -0,0 +1,175 @@ +// +// ModelStore.m +// Blog +// +// Created by Sami Samhuri on 2014-11-26. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import "ModelStore.h" +#import +#import +#import "BlogStatus.h" +#import "Post.h" + +@implementation ModelStore { + YapDatabaseConnection *_connection; +} + +- (instancetype)initWithConnection:(YapDatabaseConnection *)connection { + self = [super init]; + _connection = connection; + return self; +} + +- (BlogStatus *)blogStatus { + __block BlogStatus *status = nil; + [_connection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + status = [transaction objectForKey:@"status" inCollection:@"BlogStatus"]; + }]; + return status; +} + +- (NSArray *)drafts { + __block NSMutableArray *posts = nil; + [_connection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + NSArray *postPaths = [transaction objectForKey:@"drafts" inCollection:@"PostCollection"]; + if (postPaths) { + [transaction enumerateObjectsForKeys:postPaths inCollection:@"Post" unorderedUsingBlock:^(NSUInteger keyIndex, id object, BOOL *stop) { + [posts addObject:object]; + }]; + } + }]; + return posts; +} + +- (NSArray *)publishedPosts { + __block NSMutableArray *posts = nil; + [_connection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + NSArray *postPaths = [transaction objectForKey:@"published" inCollection:@"PostCollection"]; + if (postPaths) { + [transaction enumerateObjectsForKeys:postPaths inCollection:@"Post" unorderedUsingBlock:^(NSUInteger keyIndex, id object, BOOL *stop) { + [posts addObject:object]; + }]; + } + }]; + return posts; +} + +- (Post *)postWithPath:(NSString *)path { + __block Post *post = nil; + [_connection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + post = [transaction objectForKey:path inCollection:@"Post"]; + }]; + return post; +} + +- (PMKPromise *)saveBlogStatus:(BlogStatus *)blogStatus { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [_connection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + [transaction setObject:blogStatus forKey:@"status" inCollection:@"BlogStatus"]; + } completionBlock:^{ + fulfill(blogStatus); + }]; + }]; +} + +- (PMKPromise *)savePost:(Post *)post { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [_connection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + [transaction setObject:post forKey:post.path inCollection:@"Post"]; + } completionBlock:^{ + fulfill(post); + }]; + }]; +} + +- (PMKPromise *)saveDrafts:(NSArray *)posts { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [_connection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + NSMutableArray *postIDs = [NSMutableArray array]; + for (Post *post in posts) { + [transaction setObject:post forKey:post.path inCollection:@"Post"]; + [postIDs addObject:post.objectID]; + } + [transaction setObject:postIDs forKey:@"drafts" inCollection:@"PostCollection"]; + } completionBlock:^{ + fulfill(posts); + }]; + }]; +} + +- (PMKPromise *)savePublishedPosts:(NSArray *)posts { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [_connection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + NSMutableArray *postIDs = [NSMutableArray array]; + for (Post *post in posts) { + [transaction setObject:post forKey:post.path inCollection:@"Post"]; + [postIDs addObject:post.objectID]; + } + [transaction setObject:postIDs forKey:@"published" inCollection:@"PostCollection"]; + } completionBlock:^{ + fulfill(posts); + }]; + }]; +} + +- (PMKPromise *)addDraft:(Post *)post { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [_connection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + [transaction setObject:post forKey:post.path inCollection:@"Post"]; + NSMutableArray *postIDs = [[transaction objectForKey:@"drafts" inCollection:@"PostCollection"] mutableCopy]; + if (![postIDs containsObject:post.path]) { + [postIDs addObject:post.path]; + [transaction setObject:postIDs forKey:@"drafts" inCollection:@"PostCollection"]; + } + } completionBlock:^{ + fulfill(post); + }]; + }]; +} + +- (PMKPromise *)addPublishedPost:(Post *)post { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [_connection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + [transaction setObject:post forKey:post.path inCollection:@"Post"]; + NSMutableArray *postIDs = [[transaction objectForKey:@"published" inCollection:@"PostCollection"] mutableCopy]; + if (![postIDs containsObject:post.path]) { + [postIDs addObject:post.path]; + [transaction setObject:postIDs forKey:@"published" inCollection:@"PostCollection"]; + } + } completionBlock:^{ + fulfill(post); + }]; + }]; +} + +- (PMKPromise *)removeDraftWithPath:(NSString *)path { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [_connection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + NSMutableArray *postIDs = [[transaction objectForKey:@"drafts" inCollection:@"PostCollection"] mutableCopy]; + if ([postIDs containsObject:path]) { + [postIDs removeObject:path]; + [transaction setObject:postIDs forKey:@"drafts" inCollection:@"PostCollection"]; + } + } completionBlock:^{ + fulfill(path); + }]; + }]; +} + +- (PMKPromise *)removePostWithPath:(NSString *)path { + return [PMKPromise new:^(PMKPromiseFulfiller fulfill, PMKPromiseRejecter reject) { + [_connection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + NSMutableArray *postIDs = [[transaction objectForKey:@"published" inCollection:@"PostCollection"] mutableCopy]; + if ([postIDs containsObject:path]) { + [postIDs removeObject:path]; + [transaction setObject:postIDs forKey:@"published" inCollection:@"PostCollection"]; + } + } completionBlock:^{ + fulfill(path); + }]; + }]; +} + +@end diff --git a/Blog/NSDate+marshmallows.h b/Blog/NSDate+marshmallows.h new file mode 100644 index 0000000..c10df21 --- /dev/null +++ b/Blog/NSDate+marshmallows.h @@ -0,0 +1,21 @@ +// +// NSDate+marshmallows.h +// Marshmallows +// +// Created by Sami Samhuri on 11-06-18. +// Copyright 2011 Sam1 Samhuri. All rights reserved. +// + +#import + + +@interface NSDate (Marshmallows) + +@property (nonatomic, readonly) NSInteger mm_year; +@property (nonatomic, readonly) NSInteger mm_month; +@property (nonatomic, readonly) NSInteger mm_day; + ++ (NSDate *)mm_dateWithYear:(NSInteger)year month:(NSInteger)month day:(NSInteger)day; +- (NSString *)mm_relativeToNow; + +@end diff --git a/Blog/NSDate+marshmallows.m b/Blog/NSDate+marshmallows.m new file mode 100644 index 0000000..df53965 --- /dev/null +++ b/Blog/NSDate+marshmallows.m @@ -0,0 +1,129 @@ +// +// NSDate+marshmallows.m +// Marshmallows +// +// Created by Sami Samhuri on 11-06-18. +// Copyright 2011 Sami Samhuri. All rights reserved. +// + +#import "NSDate+marshmallows.h" + +#define MINUTE 60.0 +#define HOUR (60.0 * MINUTE) +#define DAY (24.0 * HOUR) +#define WEEK (7.0 * DAY) +#define MONTH (30.0 * DAY) +#define YEAR (365.25 * DAY) + +@implementation NSDate (Marshmallows) + ++ (NSDate *)mm_dateWithYear:(NSInteger)year month:(NSInteger)month day:(NSInteger)day { + NSCalendar *calendar = [NSCalendar currentCalendar]; + NSDateComponents *components = [NSDateComponents new]; + [components setYear:year]; + [components setMonth:month]; + [components setDay:day]; + return [calendar dateFromComponents:components]; +} + +- (NSString *)mm_relativeToNow { + double diff = [[NSDate date] timeIntervalSinceDate:self]; + NSString *result = nil; + + // future + if (diff < -2 * YEAR) { + result = [NSString stringWithFormat:@"in %d years", abs(diff / YEAR)]; + } + else if (diff < -YEAR) { + result = @"next year"; + } + else if (diff < -8 * WEEK) { + result = [NSString stringWithFormat:@"in %d months", abs(diff / MONTH)]; + } + else if (diff < -4 * WEEK) { + result = @"next month"; + } + else if (diff < -2 * WEEK) { + result = [NSString stringWithFormat:@"in %d weeks", abs(diff / WEEK)]; + } + else if (diff < -WEEK) { + result = @"next week"; + } + else if (diff < -2 * DAY) { + result = [NSString stringWithFormat:@"in %d days", abs(diff / DAY)]; + } + else if (diff < -DAY) { + result = @"tomorrow"; + } + else if (diff < -2 * HOUR) { + result = [NSString stringWithFormat:@"in %d hours", abs(diff / HOUR)]; + } + else if (diff < -HOUR) { + result = @"in an hour"; + } + else if (diff < -2 * MINUTE) { + result = [NSString stringWithFormat:@"in %d minutes", abs(diff / MINUTE)]; + } + else if (diff < -MINUTE) { + result = @"in a minute"; + } + + // present + else if (diff < MINUTE) { + result = @"right now"; + } + + // past + else if (diff < 2 * MINUTE) { + result = @"a minute ago"; + } + else if (diff < HOUR) { + result = [NSString stringWithFormat:@"%d minutes ago", (int)(diff / MINUTE)]; + } + else if (diff < 2 * HOUR) { + result = @"an hour ago"; + } + else if (diff < DAY) { + result = [NSString stringWithFormat:@"%d hours ago", (int)(diff / HOUR)]; + } + else if (diff < 2 * DAY) { + result = @"yesterday"; + } + else if (diff < WEEK) { + result = [NSString stringWithFormat:@"%d days ago", (int)(diff / DAY)]; + } + else if (diff < 2 * WEEK) { + result = @"last week"; + } + else if (diff < 4 * WEEK) { + result = [NSString stringWithFormat:@"%d weeks ago", (int)(diff / WEEK)]; + } + else if (diff < 8 * WEEK) { + result = @"last month"; + } + else if (diff < YEAR) { + result = [NSString stringWithFormat:@"%d months ago", (int)(diff / MONTH)]; + } + else if (diff < 2 * YEAR) { + result = @"last year"; + } + else { + result = [NSString stringWithFormat:@"%d years ago", (int)(diff / YEAR)]; + } + + return result; +} + +- (NSInteger)mm_year { + return [[NSCalendar currentCalendar] components:NSCalendarUnitYear fromDate:self].year; +} + +- (NSInteger)mm_month { + return [[NSCalendar currentCalendar] components:NSCalendarUnitMonth fromDate:self].month; +} + +- (NSInteger)mm_day { + return [[NSCalendar currentCalendar] components:NSCalendarUnitDay fromDate:self].day; +} + +@end diff --git a/Blog/NSString+marshmallows.h b/Blog/NSString+marshmallows.h new file mode 100644 index 0000000..a524274 --- /dev/null +++ b/Blog/NSString+marshmallows.h @@ -0,0 +1,20 @@ +// +// NSString+marshmallows.h +// Marshmallows +// +// Created by Sami Samhuri on 11-09-03. +// Copyright 2011 Sami Samhuri. All rights reserved. +// + +#import + +@interface NSString (Marshmallows) + +- (NSString *)mm_firstMatch:(NSString *)pattern; +- (NSString *)mm_stringByReplacing:(NSString *)pattern with:(NSString *)replacement; +- (NSString *)mm_stringByReplacingFirst:(NSString *)pattern with:(NSString *)replacement; +- (NSString *)mm_stringByTrimmingWhitespace; +- (NSString *)mm_stringByURLEncoding; +- (NSString *)mm_stringByURLDecoding; + +@end diff --git a/Blog/NSString+marshmallows.m b/Blog/NSString+marshmallows.m new file mode 100644 index 0000000..1150e98 --- /dev/null +++ b/Blog/NSString+marshmallows.m @@ -0,0 +1,66 @@ +// +// NSString+marshmallows.m +// Marshmallows +// +// Created by Sami Samhuri on 11-09-03. +// Copyright 2011 Sami Samhuri. All rights reserved. +// + +#import "NSString+marshmallows.h" + +@implementation NSString (Marshmallows) + +- (NSString *)mm_stringByTrimmingWhitespace { + return [self stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +- (NSString *)mm_firstMatch:(NSString *)pattern { + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern + options:0 + error:NULL]; + NSRange match = [regex rangeOfFirstMatchInString:self + options:NSMatchingReportCompletion + range:NSMakeRange(0, self.length)]; + return match.location == NSNotFound ? nil : [self substringWithRange:match]; +} + +- (NSString *)mm_stringByReplacing:(NSString *)pattern with:(NSString *)replacement { + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern + options:0 + error:NULL]; + return [regex stringByReplacingMatchesInString:self + options:NSMatchingReportCompletion + range:NSMakeRange(0, [self length]) + withTemplate:@""]; +} + +- (NSString *)mm_stringByReplacingFirst:(NSString *)pattern with:(NSString *)replacement { + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern + options:0 + error:NULL]; + NSRange match = [regex rangeOfFirstMatchInString:self + options:NSMatchingReportCompletion + range:NSMakeRange(0, self.length)]; + if (match.location != NSNotFound) { + NSString *rest = [self substringFromIndex:match.location + match.length]; + return [[[self substringToIndex:match.location] + stringByAppendingString:replacement] + stringByAppendingString:rest]; + } + return [self copy]; +} + +- (NSString *) mm_stringByURLEncoding { + return (NSString *)CFBridgingRelease(CFURLCreateStringByAddingPercentEscapes(NULL, + (CFStringRef)self, + NULL, + (CFStringRef)@"!*'();:@&=+$,/?%#[]", + kCFStringEncodingUTF8)); +} + +- (NSString *)mm_stringByURLDecoding { + return [[self stringByReplacingOccurrencesOfString:@"+" withString:@" "] + stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; +} + +@end diff --git a/Blog/Post.h b/Blog/Post.h new file mode 100644 index 0000000..b0d84dd --- /dev/null +++ b/Blog/Post.h @@ -0,0 +1,32 @@ +// +// Post.h +// Blog +// +// Created by Sami Samhuri on 2014-11-23. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import + +@interface Post : MTLModel + +@property (nonatomic, readonly, strong) NSString *objectID; +@property (nonatomic, readonly, strong) NSString *slug; +@property (nonatomic, readonly, strong) NSString *author; +@property (nonatomic, readonly, strong) NSString *title; +@property (nonatomic, readonly, strong) NSString *date; +@property (nonatomic, readonly, strong) NSDate *time; +@property (nonatomic, readonly) NSTimeInterval timestamp; +@property (nonatomic, readonly, strong) NSString *body; +@property (nonatomic, readonly, strong) NSString *path; +@property (nonatomic, readonly, strong) NSURL *url; +@property (nonatomic, readonly, getter=isDraft) BOOL draft; +@property (nonatomic, readonly, getter=isLink) BOOL link; +@property (nonatomic, readonly) NSString *formattedDate; + +- (instancetype)copyWithBody:(NSString *)body; +- (instancetype)copyWithTitle:(NSString *)title; +- (instancetype)copyWithURL:(NSURL *)url; +- (BOOL)isEqualToPost:(Post *)other; + +@end diff --git a/Blog/Post.m b/Blog/Post.m new file mode 100644 index 0000000..e280141 --- /dev/null +++ b/Blog/Post.m @@ -0,0 +1,158 @@ +// +// Post.m +// Blog +// +// Created by Sami Samhuri on 2014-11-23. +// Copyright (c) 2014 Guru Logic Inc. All rights reserved. +// + +#import "Post.h" +#import "NSDate+marshmallows.h" +#import "NSString+marshmallows.h" +#import + +@implementation Post + +@synthesize objectID = _objectID; +@synthesize slug = _slug; +@synthesize author = _author; +@synthesize title = _title; +@synthesize date = _date; +@synthesize time = _time; +@synthesize body = _body; +@synthesize path = _path; +@synthesize url = _url; +@synthesize formattedDate = _formattedDate; + ++ (NSDictionary *)JSONKeyPathsByPropertyKey { + return @{@"objectID": @"id", + @"path": @"url", + @"url": @"link", + @"time": @"", // ignore + }; +} + ++ (NSValueTransformer *)urlJSONTransformer { + return [MTLValueTransformer reversibleTransformerWithForwardBlock:^NSURL *(NSString *str) { + return [NSURL URLWithString:str]; + } reverseBlock:^NSString *(NSURL *url) { + return [url absoluteString]; + }]; +} + +- (id)copyWithZone:(NSZone *)zone { + return self; +} + +- (instancetype)copyWithBody:(NSString *)body { + return [[Post alloc] initWithDictionary:@{@"objectID": self.objectID ?: [NSNull null], + @"slug": self.slug ?: [NSNull null], + @"author": self.author ?: [NSNull null], + @"title": self.title ?: [NSNull null], + @"date": self.date ?: [NSNull null], + @"body": body ?: [NSNull null], + @"path": self.path ?: [NSNull null], + @"url": self.url ?: [NSNull null], + } error:nil]; +} + +- (instancetype)copyWithTitle:(NSString *)title { + return [[Post alloc] initWithDictionary:@{@"objectID": self.objectID ?: [NSNull null], + @"slug": self.slug ?: [NSNull null], + @"author": self.author ?: [NSNull null], + @"title": title ?: [NSNull null], + @"date": self.date ?: [NSNull null], + @"body": self.body ?: [NSNull null], + @"path": self.path ?: [NSNull null], + @"url": self.url ?: [NSNull null], + } error:nil]; +} + +- (instancetype)copyWithURL:(NSURL *)url { + return [[Post alloc] initWithDictionary:@{@"objectID": self.objectID ?: [NSNull null], + @"slug": self.slug ?: [NSNull null], + @"author": self.author ?: [NSNull null], + @"title": self.title ?: [NSNull null], + @"date": self.date ?: [NSNull null], + @"body": self.body ?: [NSNull null], + @"path": self.path ?: [NSNull null], + @"url": url ?: [NSNull null], + } error:nil]; +} + +- (BOOL)isEqualToPost:(Post *)other { + return [self.objectID isEqual:other.objectID]; +} + +- (BOOL)isEqual:(id)object { + return self == object || ([object isMemberOfClass:[Post class]] && [self isEqualToPost:object]); +} + +- (NSUInteger)hash { + return [(self.objectID ?: self.slug) hash]; +} + +- (NSString *)objectID { + if (!_objectID && _draft) { + CFUUIDRef uuid = CFUUIDCreate(NULL); + CFStringRef uuidString = CFUUIDCreateString(NULL, uuid); + CFRelease(uuid); + _objectID = (__bridge NSString *)uuidString; + } + return _objectID; +} + +- (NSString *)author { + if (!_author) { + _author = @"Sami Samhuri"; + } + return _author; +} + +- (NSString *)slug { + if (!_slug && !self.draft && self.title) { + _slug = [[[[[self.title lowercaseString] + mm_stringByReplacing:@"'" with:@""] + mm_stringByReplacing:@"[^[:alpha:]\\d_]" with:@"-"] + mm_stringByReplacing:@"^-+|-+$" with:@""] + mm_stringByReplacing:@"-+" with:@"-"]; + } + return _slug; +} + +- (BOOL)isLink { + return self.url != nil; +} + +- (NSDate *)time { + if (!_time && self.timestamp) { + _time = [NSDate dateWithTimeIntervalSince1970:self.timestamp]; + } + return _time; +} + +- (NSString *)formattedDate { + if (!_formattedDate && self.time) { + _formattedDate = [NSString stringWithFormat:@"%ld-%02ld-%02ld", (long)self.time.mm_year, (long)self.time.mm_month, (long)self.time.mm_day]; + } + return _formattedDate; +} + +- (NSString *)path { + if (!_path && self.slug) { + if (self.draft) { + _path = [NSString stringWithFormat:@"/drafts/%@", self.slug]; + } + else if (self.date) { + NSString *paddedMonth = [self paddedMonthForDate:self.date]; + _path = [NSString stringWithFormat:@"/posts/%ld/%@/%@", (long)self.time.mm_year, paddedMonth, self.slug]; + } + } + return _path; +} + +- (NSString *)paddedMonthForDate:(NSDate *)date { + return [NSString stringWithFormat:@"%02ld", (long)date.mm_month]; +} + +@end diff --git a/Blog/main.m b/Blog/main.m index f082a94..05a9050 100644 --- a/Blog/main.m +++ b/Blog/main.m @@ -6,7 +6,7 @@ // Copyright (c) 2014 Guru Logic Inc. All rights reserved. // -#import +@import UIKit; #import "AppDelegate.h" int main(int argc, char * argv[]) { diff --git a/BlogTests/BlogTests.m b/BlogTests/BlogTests.m index dab0177..d3dffb2 100644 --- a/BlogTests/BlogTests.m +++ b/BlogTests/BlogTests.m @@ -6,7 +6,7 @@ // Copyright (c) 2014 Guru Logic Inc. All rights reserved. // -#import +@import UIKit; #import @interface BlogTests : XCTestCase