Files
tcms/macclient/AppKitClient/main.m
T
2026-07-05 16:23:17 -04:00

803 lines
36 KiB
Objective-C

#import <Cocoa/Cocoa.h>
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 <NSApplicationDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSTextViewDelegate>
@property (nonatomic, strong) NSWindow *window;
@property (nonatomic, strong) NSView *workspace;
@property (nonatomic, strong) NSTextField *statusLabel;
@property (nonatomic, strong) NSTableView *tableView;
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *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<NSString *> *)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;
}