#import static NSString *TCMSString(id value) { if ([value isKindOfClass:[NSString class]]) { return value; } if ([value respondsToSelector:@selector(stringValue)]) { return [value stringValue]; } return @""; } static NSArray *TCMSTagsFromString(NSString *value) { NSMutableArray *tags = [NSMutableArray array]; for (NSString *part in [value componentsSeparatedByString:@","]) { NSString *tag = [part stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; if (tag.length > 0) { [tags addObject:tag]; } } return tags; } static NSString *TCMSJoinTags(id value) { if ([value isKindOfClass:[NSArray class]]) { return [(NSArray *)value componentsJoinedByString:@", "]; } return TCMSString(value); } @interface TCMSAPIClient : NSObject @property (nonatomic, copy) NSString *baseURLString; @property (nonatomic, copy) NSString *username; @property (nonatomic, copy) NSString *password; @property (nonatomic, copy) NSString *token; - (void)requestAction:(NSString *)action query:(NSDictionary *)query method:(NSString *)method body:(NSDictionary *)body completion:(void (^)(NSDictionary *, NSError *))completion; - (void)uploadFile:(NSURL *)fileURL completion:(void (^)(NSDictionary *, NSError *))completion; @end @implementation TCMSAPIClient - (NSURL *)endpointURL { NSURL *base = [NSURL URLWithString:self.baseURLString ?: @""]; if (!base) { return nil; } if ([[base.lastPathComponent lowercaseString] isEqualToString:@"api.php"]) { return base; } return [base URLByAppendingPathComponent:@"api.php"]; } - (NSURL *)URLForAction:(NSString *)action query:(NSDictionary *)query { NSURL *endpoint = [self endpointURL]; if (!endpoint) { return nil; } NSURLComponents *components = [NSURLComponents componentsWithURL:endpoint resolvingAgainstBaseURL:NO]; NSMutableArray *items = [NSMutableArray arrayWithObject:[NSURLQueryItem queryItemWithName:@"action" value:action]]; for (NSString *key in query) { [items addObject:[NSURLQueryItem queryItemWithName:key value:TCMSString(query[key])]]; } components.queryItems = items; return components.URL; } - (NSMutableURLRequest *)requestForAction:(NSString *)action query:(NSDictionary *)query method:(NSString *)method { NSURL *url = [self URLForAction:action query:query ?: @{}]; if (!url) { return nil; } NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:30]; request.HTTPMethod = method; [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; if (self.username.length > 0 && self.password.length > 0) { NSString *raw = [NSString stringWithFormat:@"%@:%@", self.username, self.password]; NSString *encoded = [[raw dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]; [request setValue:[@"Basic " stringByAppendingString:encoded] forHTTPHeaderField:@"Authorization"]; } else if (self.token.length > 0) { [request setValue:[@"Bearer " stringByAppendingString:self.token] forHTTPHeaderField:@"Authorization"]; } return request; } - (void)requestAction:(NSString *)action query:(NSDictionary *)query method:(NSString *)method body:(NSDictionary *)body completion:(void (^)(NSDictionary *, NSError *))completion { NSMutableURLRequest *request = [self requestForAction:action query:query method:method ?: @"GET"]; if (!request) { NSError *error = [NSError errorWithDomain:@"TCMSAPI" code:1 userInfo:@{NSLocalizedDescriptionKey: @"Invalid TCMS URL."}]; completion(nil, error); return; } if (body) { request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil]; [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; } [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { [self finishWithData:data response:response error:error completion:completion]; }] resume]; } - (void)uploadFile:(NSURL *)fileURL completion:(void (^)(NSDictionary *, NSError *))completion { NSMutableURLRequest *request = [self requestForAction:@"media.upload" query:@{} method:@"POST"]; if (!request) { NSError *error = [NSError errorWithDomain:@"TCMSAPI" code:1 userInfo:@{NSLocalizedDescriptionKey: @"Invalid TCMS URL."}]; completion(nil, error); return; } NSData *fileData = [NSData dataWithContentsOfURL:fileURL]; if (!fileData) { NSError *error = [NSError errorWithDomain:@"TCMSAPI" code:2 userInfo:@{NSLocalizedDescriptionKey: @"Unable to read selected file."}]; completion(nil, error); return; } NSString *boundary = [@"TCMSBoundary-" stringByAppendingString:NSUUID.UUID.UUIDString]; NSMutableData *body = [NSMutableData data]; NSString *header = [NSString stringWithFormat:@"--%@\r\nContent-Disposition: form-data; name=\"file\"; filename=\"%@\"\r\nContent-Type: application/octet-stream\r\n\r\n", boundary, fileURL.lastPathComponent]; [body appendData:[header dataUsingEncoding:NSUTF8StringEncoding]]; [body appendData:fileData]; [body appendData:[[NSString stringWithFormat:@"\r\n--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]]; [request setValue:[@"multipart/form-data; boundary=" stringByAppendingString:boundary] forHTTPHeaderField:@"Content-Type"]; [[[NSURLSession sharedSession] uploadTaskWithRequest:request fromData:body completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { [self finishWithData:data response:response error:error completion:completion]; }] resume]; } - (void)finishWithData:(NSData *)data response:(NSURLResponse *)response error:(NSError *)error completion:(void (^)(NSDictionary *, NSError *))completion { if (error) { dispatch_async(dispatch_get_main_queue(), ^{ completion(nil, error); }); return; } NSDictionary *json = data.length > 0 ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : @{}; NSInteger status = [(NSHTTPURLResponse *)response statusCode]; if (status < 200 || status >= 300) { NSString *message = TCMSString(json[@"error"]); if (message.length == 0) { message = [NSString stringWithFormat:@"HTTP %ld", (long)status]; } NSError *apiError = [NSError errorWithDomain:@"TCMSAPI" code:status userInfo:@{NSLocalizedDescriptionKey: message}]; dispatch_async(dispatch_get_main_queue(), ^{ completion(json, apiError); }); return; } dispatch_async(dispatch_get_main_queue(), ^{ completion(json ?: @{}, nil); }); } @end @interface TCMSAppDelegate : NSObject @property (nonatomic, strong) NSWindow *window; @property (nonatomic, strong) NSView *workspace; @property (nonatomic, strong) NSTextField *statusLabel; @property (nonatomic, strong) NSTableView *tableView; @property (nonatomic, strong) NSMutableArray *tableItems; @property (nonatomic, strong) NSMutableDictionary *editorItem; @property (nonatomic, copy) NSString *editorMarkdown; @property (nonatomic, copy) NSString *originalSlug; @property (nonatomic, copy) NSString *section; @property (nonatomic, assign) BOOL dirty; @property (nonatomic, strong) NSTimer *autosaveTimer; @property (nonatomic, strong) TCMSAPIClient *client; @property (nonatomic, strong) NSTextField *siteField; @property (nonatomic, strong) NSTextField *usernameField; @property (nonatomic, strong) NSSecureTextField *passwordField; @property (nonatomic, strong) NSSecureTextField *tokenField; @property (nonatomic, strong) NSTextField *autosaveField; @property (nonatomic, strong) NSButton *remoteAutosaveButton; @property (nonatomic, strong) NSTextField *titleField; @property (nonatomic, strong) NSTextField *slugField; @property (nonatomic, strong) NSPopUpButton *typePopup; @property (nonatomic, strong) NSPopUpButton *statusPopup; @property (nonatomic, strong) NSTextField *categoryField; @property (nonatomic, strong) NSTextField *tagsField; @property (nonatomic, strong) NSTextField *summaryField; @property (nonatomic, strong) NSTextField *authorField; @property (nonatomic, strong) NSTextField *coverField; @property (nonatomic, strong) NSButton *allowCommentsButton; @property (nonatomic, strong) NSTextView *markdownView; @property (nonatomic, strong) NSPopUpButton *commentStatusPopup; @end @implementation TCMSAppDelegate - (void)applicationDidFinishLaunching:(NSNotification *)notification { self.section = @"posts"; self.tableItems = [NSMutableArray array]; self.editorItem = [self blankItemOfType:@"post"]; self.editorMarkdown = @"# Untitled\n\nStart writing here."; self.originalSlug = @""; self.client = [TCMSAPIClient new]; [self loadSettings]; [self buildWindow]; [self showContentSection:@"posts"]; [self startAutosaveTimer]; } - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { return YES; } - (NSMutableDictionary *)blankItemOfType:(NSString *)type { NSString *title = [type isEqualToString:@"page"] ? @"New Page" : @"New Post"; return [@{ @"title": title, @"slug": @"", @"type": type, @"status": @"draft", @"date": [[NSDate date] description], @"category": @"Notes", @"tags": @[], @"summary": @"", @"author": @"Editor", @"cover": @"", @"allow_comments": @YES, @"menu_order": @0 } mutableCopy]; } - (void)buildWindow { self.window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 1180, 740) styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable backing:NSBackingStoreBuffered defer:NO]; self.window.title = @"TCMS Mac Client"; [self.window center]; NSView *content = self.window.contentView; NSView *sidebar = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 190, 740)]; sidebar.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin; sidebar.wantsLayer = YES; sidebar.layer.backgroundColor = [NSColor colorWithCalibratedWhite:0.08 alpha:1].CGColor; [content addSubview:sidebar]; NSTextField *brand = [self label:@"TCMS" frame:NSMakeRect(18, 680, 154, 34) font:[NSFont boldSystemFontOfSize:28]]; brand.textColor = NSColor.whiteColor; [sidebar addSubview:brand]; NSArray *buttons = @[ @[@"Posts", @"posts"], @[@"Pages", @"pages"], @[@"Media", @"media"], @[@"Comments", @"comments"], @[@"Settings", @"settings"] ]; CGFloat y = 620; for (NSArray *entry in buttons) { NSButton *button = [NSButton buttonWithTitle:entry[0] target:self action:@selector(sidebarClicked:)]; button.identifier = entry[1]; button.frame = NSMakeRect(18, y, 154, 36); button.bezelStyle = NSBezelStyleTexturedRounded; [sidebar addSubview:button]; y -= 46; } self.statusLabel = [self label:@"Ready" frame:NSMakeRect(14, 14, 160, 80) font:[NSFont systemFontOfSize:12]]; self.statusLabel.textColor = [NSColor colorWithCalibratedWhite:0.78 alpha:1]; self.statusLabel.lineBreakMode = NSLineBreakByWordWrapping; [sidebar addSubview:self.statusLabel]; self.workspace = [[NSView alloc] initWithFrame:NSMakeRect(190, 0, 990, 740)]; self.workspace.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; [content addSubview:self.workspace]; [self.window makeKeyAndOrderFront:nil]; } - (NSTextField *)label:(NSString *)text frame:(NSRect)frame font:(NSFont *)font { NSTextField *label = [[NSTextField alloc] initWithFrame:frame]; label.stringValue = text; label.font = font; label.editable = NO; label.selectable = NO; label.bezeled = NO; label.drawsBackground = NO; return label; } - (NSTextField *)field:(NSString *)placeholder frame:(NSRect)frame { NSTextField *field = [[NSTextField alloc] initWithFrame:frame]; field.placeholderString = placeholder; field.delegate = self; return field; } - (void)sidebarClicked:(NSButton *)sender { NSString *target = sender.identifier; if ([target isEqualToString:@"posts"] || [target isEqualToString:@"pages"]) { [self showContentSection:target]; } else if ([target isEqualToString:@"media"]) { [self showMediaSection]; } else if ([target isEqualToString:@"comments"]) { [self showCommentsSection]; } else { [self showSettingsSection]; } } - (void)clearWorkspace { for (NSView *view in self.workspace.subviews.copy) { [view removeFromSuperview]; } } - (void)showContentSection:(NSString *)section { self.section = section; [self clearWorkspace]; NSString *type = [section isEqualToString:@"pages"] ? @"page" : @"post"; NSTextField *heading = [self label:[section capitalizedString] frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]; [self.workspace addSubview:heading]; [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 90, 30) action:@selector(refreshCurrentSection)]; [self addButton:@"New" frame:NSMakeRect(310, 690, 70, 30) action:@selector(newContent)]; [self addButton:@"Save Draft" frame:NSMakeRect(390, 690, 100, 30) action:@selector(saveDraft)]; [self addButton:@"Publish" frame:NSMakeRect(500, 690, 90, 30) action:@selector(publishContent)]; [self addButton:@"Delete" frame:NSMakeRect(600, 690, 80, 30) action:@selector(deleteContent)]; [self buildTableWithFrame:NSMakeRect(20, 20, 280, 650) columns:@[@"title", @"status"]]; CGFloat left = 320; CGFloat width = self.workspace.bounds.size.width - left - 20; self.titleField = [self field:@"Title" frame:NSMakeRect(left, 646, width - 230, 28)]; self.statusPopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(left + width - 220, 646, 100, 28)]; [self.statusPopup addItemsWithTitles:@[@"draft", @"published"]]; self.typePopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(left + width - 110, 646, 90, 28)]; [self.typePopup addItemsWithTitles:@[@"post", @"page"]]; [self.workspace addSubview:self.titleField]; [self.workspace addSubview:self.statusPopup]; [self.workspace addSubview:self.typePopup]; self.slugField = [self field:@"Slug" frame:NSMakeRect(left, 608, (width / 2) - 6, 28)]; self.categoryField = [self field:@"Category" frame:NSMakeRect(left + (width / 2) + 6, 608, (width / 2) - 26, 28)]; self.tagsField = [self field:@"Tags" frame:NSMakeRect(left, 570, width - 20, 28)]; self.summaryField = [self field:@"Summary" frame:NSMakeRect(left, 532, width - 20, 28)]; self.authorField = [self field:@"Author" frame:NSMakeRect(left, 494, (width / 2) - 6, 28)]; self.coverField = [self field:@"Cover" frame:NSMakeRect(left + (width / 2) + 6, 494, (width / 2) - 26, 28)]; self.allowCommentsButton = [NSButton checkboxWithTitle:@"Allow comments" target:self action:@selector(markDirty)]; self.allowCommentsButton.frame = NSMakeRect(left, 460, 180, 24); for (NSView *view in @[self.slugField, self.categoryField, self.tagsField, self.summaryField, self.authorField, self.coverField, self.allowCommentsButton]) { [self.workspace addSubview:view]; } NSScrollView *editorScroll = [[NSScrollView alloc] initWithFrame:NSMakeRect(left, 20, width - 20, 430)]; editorScroll.borderType = NSBezelBorder; editorScroll.hasVerticalScroller = YES; editorScroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.markdownView = [[NSTextView alloc] initWithFrame:editorScroll.bounds]; self.markdownView.font = [NSFont fontWithName:@"Menlo" size:13] ?: [NSFont monospacedSystemFontOfSize:13 weight:NSFontWeightRegular]; self.markdownView.delegate = self; editorScroll.documentView = self.markdownView; [self.workspace addSubview:editorScroll]; self.editorItem = [self blankItemOfType:type]; self.editorMarkdown = [NSString stringWithFormat:@"# %@\n\nStart writing here.", TCMSString(self.editorItem[@"title"])]; self.originalSlug = @""; [self loadEditorFields]; [self refreshContentType:type]; } - (void)showMediaSection { self.section = @"media"; [self clearWorkspace]; [self.workspace addSubview:[self label:@"Media" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]]; [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 90, 30) action:@selector(refreshCurrentSection)]; [self addButton:@"Upload" frame:NSMakeRect(310, 690, 90, 30) action:@selector(uploadMedia)]; [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"name", @"mime", @"url"]]; [self refreshMedia]; } - (void)showCommentsSection { self.section = @"comments"; [self clearWorkspace]; [self.workspace addSubview:[self label:@"Comments" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]]; self.commentStatusPopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(210, 690, 120, 30)]; [self.commentStatusPopup addItemsWithTitles:@[@"pending", @"approved", @"spam", @"all"]]; [self.workspace addSubview:self.commentStatusPopup]; [self addButton:@"Refresh" frame:NSMakeRect(340, 690, 90, 30) action:@selector(refreshCurrentSection)]; [self addButton:@"Approve" frame:NSMakeRect(440, 690, 90, 30) action:@selector(approveComment)]; [self addButton:@"Pending" frame:NSMakeRect(540, 690, 90, 30) action:@selector(pendingComment)]; [self addButton:@"Spam" frame:NSMakeRect(640, 690, 80, 30) action:@selector(spamComment)]; [self addButton:@"Delete" frame:NSMakeRect(730, 690, 80, 30) action:@selector(deleteComment)]; [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"author", @"status", @"slug", @"body"]]; [self refreshComments]; } - (void)showSettingsSection { self.section = @"settings"; [self clearWorkspace]; [self.workspace addSubview:[self label:@"Settings" frame:NSMakeRect(20, 690, 220, 28) font:[NSFont boldSystemFontOfSize:24]]]; [self.workspace addSubview:[self label:@"TCMS URL" frame:NSMakeRect(20, 640, 140, 24) font:[NSFont systemFontOfSize:13]]]; [self.workspace addSubview:[self label:@"Username" frame:NSMakeRect(20, 596, 140, 24) font:[NSFont systemFontOfSize:13]]]; [self.workspace addSubview:[self label:@"Password" frame:NSMakeRect(20, 552, 140, 24) font:[NSFont systemFontOfSize:13]]]; [self.workspace addSubview:[self label:@"Bearer Token" frame:NSMakeRect(20, 508, 140, 24) font:[NSFont systemFontOfSize:13]]]; [self.workspace addSubview:[self label:@"Autosave Seconds" frame:NSMakeRect(20, 464, 140, 24) font:[NSFont systemFontOfSize:13]]]; self.siteField = [self field:@"https://example.com/blog" frame:NSMakeRect(170, 638, 520, 28)]; self.usernameField = [self field:@"admin" frame:NSMakeRect(170, 594, 260, 28)]; self.passwordField = [[NSSecureTextField alloc] initWithFrame:NSMakeRect(170, 550, 260, 28)]; self.tokenField = [[NSSecureTextField alloc] initWithFrame:NSMakeRect(170, 506, 520, 28)]; self.autosaveField = [self field:@"30" frame:NSMakeRect(170, 462, 120, 28)]; self.remoteAutosaveButton = [NSButton checkboxWithTitle:@"Also save remotely as draft" target:nil action:nil]; self.remoteAutosaveButton.frame = NSMakeRect(170, 420, 260, 24); for (NSView *view in @[self.siteField, self.usernameField, self.passwordField, self.tokenField, self.autosaveField, self.remoteAutosaveButton]) { [self.workspace addSubview:view]; } [self addButton:@"Save Settings" frame:NSMakeRect(170, 370, 120, 32) action:@selector(saveSettings)]; [self addButton:@"Test Auth" frame:NSMakeRect(300, 370, 100, 32) action:@selector(testConnection)]; [self populateSettingsFields]; } - (NSButton *)addButton:(NSString *)title frame:(NSRect)frame action:(SEL)action { NSButton *button = [NSButton buttonWithTitle:title target:self action:action]; button.frame = frame; button.bezelStyle = NSBezelStyleRounded; [self.workspace addSubview:button]; return button; } - (void)buildTableWithFrame:(NSRect)frame columns:(NSArray *)columns { NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:frame]; scroll.borderType = NSBezelBorder; scroll.hasVerticalScroller = YES; scroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.tableView = [[NSTableView alloc] initWithFrame:scroll.bounds]; self.tableView.delegate = self; self.tableView.dataSource = self; for (NSString *identifier in columns) { NSTableColumn *column = [[NSTableColumn alloc] initWithIdentifier:identifier]; column.title = [identifier capitalizedString]; column.width = frame.size.width / MAX(columns.count, 1); [self.tableView addTableColumn:column]; } scroll.documentView = self.tableView; [self.workspace addSubview:scroll]; } - (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { return self.tableItems.count; } - (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { NSTextField *field = [tableView makeViewWithIdentifier:tableColumn.identifier owner:self]; if (!field) { field = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, tableColumn.width, 24)]; field.identifier = tableColumn.identifier; field.editable = NO; field.selectable = NO; field.bezeled = NO; field.drawsBackground = NO; field.lineBreakMode = NSLineBreakByTruncatingTail; } NSDictionary *item = self.tableItems[row]; field.stringValue = TCMSString(item[tableColumn.identifier]); return field; } - (void)tableViewSelectionDidChange:(NSNotification *)notification { NSInteger row = self.tableView.selectedRow; if (row < 0 || row >= self.tableItems.count) { return; } NSDictionary *item = self.tableItems[row]; if ([self.section isEqualToString:@"posts"] || [self.section isEqualToString:@"pages"]) { [self loadContentSlug:TCMSString(item[@"slug"])]; } } - (void)loadSettings { NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; self.client.baseURLString = [defaults stringForKey:@"baseURL"] ?: @"http://127.0.0.1:8097"; self.client.username = [defaults stringForKey:@"username"] ?: @"admin"; self.client.password = [defaults stringForKey:@"password"] ?: @""; self.client.token = [defaults stringForKey:@"token"] ?: @""; } - (void)populateSettingsFields { NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; self.siteField.stringValue = self.client.baseURLString ?: @""; self.usernameField.stringValue = self.client.username ?: @""; self.passwordField.stringValue = self.client.password ?: @""; self.tokenField.stringValue = self.client.token ?: @""; NSInteger interval = [defaults integerForKey:@"autosaveInterval"]; self.autosaveField.stringValue = [NSString stringWithFormat:@"%ld", (long)(interval > 0 ? interval : 30)]; self.remoteAutosaveButton.state = [defaults boolForKey:@"remoteAutosave"] ? NSControlStateValueOn : NSControlStateValueOff; } - (void)saveSettings { self.client.baseURLString = self.siteField.stringValue; self.client.username = self.usernameField.stringValue; self.client.password = self.passwordField.stringValue; self.client.token = self.tokenField.stringValue; NSInteger interval = MAX(10, self.autosaveField.integerValue); NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; [defaults setObject:self.client.baseURLString forKey:@"baseURL"]; [defaults setObject:self.client.username forKey:@"username"]; [defaults setObject:self.client.password forKey:@"password"]; [defaults setObject:self.client.token forKey:@"token"]; [defaults setInteger:interval forKey:@"autosaveInterval"]; [defaults setBool:self.remoteAutosaveButton.state == NSControlStateValueOn forKey:@"remoteAutosave"]; [self startAutosaveTimer]; [self setStatus:@"Settings saved."]; } - (void)testConnection { [self saveSettings]; [self.client requestAction:@"ping" query:@{} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { [self setStatus:error ? error.localizedDescription : [NSString stringWithFormat:@"Connected. Authenticated: %@", [json[@"authenticated"] boolValue] ? @"yes" : @"no"]]; }]; } - (void)refreshCurrentSection { if ([self.section isEqualToString:@"posts"]) { [self refreshContentType:@"post"]; } else if ([self.section isEqualToString:@"pages"]) { [self refreshContentType:@"page"]; } else if ([self.section isEqualToString:@"media"]) { [self refreshMedia]; } else if ([self.section isEqualToString:@"comments"]) { [self refreshComments]; } } - (void)refreshContentType:(NSString *)type { [self.client requestAction:@"content.list" query:@{@"type": type, @"include_drafts": @"1"} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } self.tableItems = [TCMSString(type) isEqualToString:@"page"] ? [NSMutableArray arrayWithArray:json[@"items"] ?: @[]] : [NSMutableArray arrayWithArray:json[@"items"] ?: @[]]; [self.tableView reloadData]; [self setStatus:@"Content refreshed."]; }]; } - (void)loadContentSlug:(NSString *)slug { if (slug.length == 0) { return; } [self.client requestAction:@"content.get" query:@{@"slug": slug} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } self.editorItem = [json[@"item"] mutableCopy]; self.editorMarkdown = TCMSString(self.editorItem[@"markdown"]); self.originalSlug = TCMSString(self.editorItem[@"slug"]); self.dirty = NO; [self loadEditorFields]; [self setStatus:[NSString stringWithFormat:@"Loaded %@.", TCMSString(self.editorItem[@"title"])]]; }]; } - (void)newContent { NSString *type = [self.section isEqualToString:@"pages"] ? @"page" : @"post"; self.editorItem = [self blankItemOfType:type]; self.editorMarkdown = [NSString stringWithFormat:@"# %@\n\nStart writing here.", TCMSString(self.editorItem[@"title"])]; self.originalSlug = @""; [self loadEditorFields]; [self markDirty]; } - (void)loadEditorFields { self.titleField.stringValue = TCMSString(self.editorItem[@"title"]); self.slugField.stringValue = TCMSString(self.editorItem[@"slug"]); [self.statusPopup selectItemWithTitle:TCMSString(self.editorItem[@"status"]).length ? TCMSString(self.editorItem[@"status"]) : @"draft"]; [self.typePopup selectItemWithTitle:TCMSString(self.editorItem[@"type"]).length ? TCMSString(self.editorItem[@"type"]) : @"post"]; self.categoryField.stringValue = TCMSString(self.editorItem[@"category"]); self.tagsField.stringValue = TCMSJoinTags(self.editorItem[@"tags"]); self.summaryField.stringValue = TCMSString(self.editorItem[@"summary"]); self.authorField.stringValue = TCMSString(self.editorItem[@"author"]); self.coverField.stringValue = TCMSString(self.editorItem[@"cover"]); self.allowCommentsButton.state = [self.editorItem[@"allow_comments"] boolValue] ? NSControlStateValueOn : NSControlStateValueOff; self.markdownView.string = self.editorMarkdown ?: @""; } - (NSMutableDictionary *)editorPayloadItemWithStatus:(NSString *)status { NSMutableDictionary *item = [self.editorItem mutableCopy] ?: [NSMutableDictionary dictionary]; item[@"title"] = self.titleField.stringValue; item[@"slug"] = self.slugField.stringValue; item[@"type"] = self.typePopup.titleOfSelectedItem ?: @"post"; item[@"status"] = status ?: (self.statusPopup.titleOfSelectedItem ?: @"draft"); item[@"category"] = self.categoryField.stringValue; item[@"tags"] = TCMSTagsFromString(self.tagsField.stringValue); item[@"summary"] = self.summaryField.stringValue; item[@"author"] = self.authorField.stringValue; item[@"cover"] = self.coverField.stringValue; item[@"allow_comments"] = @(self.allowCommentsButton.state == NSControlStateValueOn); return item; } - (void)saveDraft { [self saveContentWithStatus:@"draft"]; } - (void)publishContent { [self saveContentWithStatus:@"published"]; } - (void)saveContentWithStatus:(NSString *)status { NSMutableDictionary *item = [self editorPayloadItemWithStatus:status]; NSDictionary *body = @{ @"original_slug": self.originalSlug ?: @"", @"slug": TCMSString(item[@"slug"]), @"markdown": self.markdownView.string ?: @"", @"metadata": item }; [self.client requestAction:@"content.save" query:@{} method:@"POST" body:body completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } self.editorItem = [json[@"item"] mutableCopy]; self.editorMarkdown = TCMSString(self.editorItem[@"markdown"]); self.originalSlug = TCMSString(self.editorItem[@"slug"]); self.dirty = NO; [self loadEditorFields]; [self refreshContentType:TCMSString(self.editorItem[@"type"]).length ? TCMSString(self.editorItem[@"type"]) : @"post"]; [self setStatus:@"Content saved."]; }]; } - (void)deleteContent { NSString *slug = TCMSString(self.editorItem[@"slug"]); if (slug.length == 0) { [self setStatus:@"Nothing selected to delete."]; return; } [self.client requestAction:@"content.delete" query:@{} method:@"POST" body:@{@"slug": slug} completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } [self setStatus:@"Content deleted."]; [self newContent]; [self refreshCurrentSection]; }]; } - (void)refreshMedia { [self.client requestAction:@"media.list" query:@{} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } self.tableItems = [NSMutableArray arrayWithArray:json[@"items"] ?: @[]]; [self.tableView reloadData]; [self setStatus:@"Media refreshed."]; }]; } - (void)uploadMedia { NSOpenPanel *panel = [NSOpenPanel openPanel]; panel.canChooseDirectories = NO; panel.canChooseFiles = YES; panel.allowsMultipleSelection = NO; if ([panel runModal] != NSModalResponseOK) { return; } [self.client uploadFile:panel.URL completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } [self setStatus:@"Media uploaded."]; [self refreshMedia]; }]; } - (void)refreshComments { NSString *status = self.commentStatusPopup.titleOfSelectedItem ?: @"pending"; NSDictionary *query = [status isEqualToString:@"all"] ? @{} : @{@"status": status}; [self.client requestAction:@"comments.list" query:query method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } self.tableItems = [NSMutableArray arrayWithArray:json[@"items"] ?: @[]]; [self.tableView reloadData]; [self setStatus:@"Comments refreshed."]; }]; } - (NSDictionary *)selectedTableItem { NSInteger row = self.tableView.selectedRow; if (row < 0 || row >= self.tableItems.count) { return nil; } return self.tableItems[row]; } - (void)approveComment { [self setSelectedCommentStatus:@"approved"]; } - (void)pendingComment { [self setSelectedCommentStatus:@"pending"]; } - (void)spamComment { [self setSelectedCommentStatus:@"spam"]; } - (void)setSelectedCommentStatus:(NSString *)status { NSDictionary *comment = [self selectedTableItem]; if (!comment) { [self setStatus:@"Select a comment first."]; return; } [self.client requestAction:@"comments.status" query:@{} method:@"POST" body:@{@"id": comment[@"id"], @"status": status} completion:^(NSDictionary *json, NSError *error) { [self setStatus:error ? error.localizedDescription : @"Comment updated."]; if (!error) { [self refreshComments]; } }]; } - (void)deleteComment { NSDictionary *comment = [self selectedTableItem]; if (!comment) { [self setStatus:@"Select a comment first."]; return; } [self.client requestAction:@"comments.delete" query:@{} method:@"POST" body:@{@"id": comment[@"id"]} completion:^(NSDictionary *json, NSError *error) { [self setStatus:error ? error.localizedDescription : @"Comment deleted."]; if (!error) { [self refreshComments]; } }]; } - (void)controlTextDidChange:(NSNotification *)obj { [self markDirty]; } - (void)textDidChange:(NSNotification *)notification { [self markDirty]; } - (void)markDirty { self.dirty = YES; } - (void)startAutosaveTimer { [self.autosaveTimer invalidate]; NSInteger interval = MAX(10, [NSUserDefaults.standardUserDefaults integerForKey:@"autosaveInterval"] ?: 30); self.autosaveTimer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(autosave) userInfo:nil repeats:YES]; } - (void)autosave { if (!self.dirty || !self.markdownView) { return; } [self saveLocalDraft]; if ([NSUserDefaults.standardUserDefaults boolForKey:@"remoteAutosave"]) { [self saveContentWithStatus:@"draft"]; } } - (void)saveLocalDraft { NSMutableDictionary *item = [self editorPayloadItemWithStatus:nil]; NSDictionary *record = @{ @"server": self.client.baseURLString ?: @"", @"saved_at": [[NSDate date] description], @"item": item, @"markdown": self.markdownView.string ?: @"" }; NSData *data = [NSJSONSerialization dataWithJSONObject:record options:NSJSONWritingPrettyPrinted error:nil]; if (!data) { return; } NSURL *base = [NSFileManager.defaultManager URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask].firstObject; NSURL *folder = [base URLByAppendingPathComponent:@"TCMS Mac Client/Drafts" isDirectory:YES]; [NSFileManager.defaultManager createDirectoryAtURL:folder withIntermediateDirectories:YES attributes:nil error:nil]; NSString *name = TCMSString(item[@"slug"]).length ? TCMSString(item[@"slug"]) : TCMSString(item[@"title"]); name = [name stringByReplacingOccurrencesOfString:@"[^A-Za-z0-9._-]+" withString:@"-" options:NSRegularExpressionSearch range:NSMakeRange(0, name.length)]; NSURL *file = [[folder URLByAppendingPathComponent:name.length ? name : @"untitled"] URLByAppendingPathExtension:@"json"]; [data writeToURL:file atomically:YES]; [self setStatus:@"Local autosave complete."]; } - (void)setStatus:(NSString *)message { self.statusLabel.stringValue = message ?: @""; } @end int main(int argc, const char *argv[]) { @autoreleasepool { NSApplication *application = NSApplication.sharedApplication; TCMSAppDelegate *delegate = [TCMSAppDelegate new]; application.delegate = delegate; [application setActivationPolicy:NSApplicationActivationPolicyRegular]; [application run]; } return 0; }