#import #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); } static NSString *TCMSMarkdownCompareString(NSString *value) { NSString *normalized = [TCMSString(value) stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"]; normalized = [normalized stringByReplacingOccurrencesOfString:@"\r" withString:@"\n"]; while ([normalized hasSuffix:@"\n"]) { normalized = [normalized substringToIndex:normalized.length - 1]; } return normalized; } static NSString *TCMSFormatBytes(id value) { long long bytes = 0; if ([value respondsToSelector:@selector(longLongValue)]) { bytes = [value longLongValue]; } if (bytes <= 0) { return @""; } NSArray *units = @[@"B", @"KB", @"MB", @"GB", @"TB"]; double amount = (double)bytes; NSUInteger unitIndex = 0; while (amount >= 1024.0 && unitIndex + 1 < units.count) { amount /= 1024.0; unitIndex++; } return unitIndex == 0 ? [NSString stringWithFormat:@"%lld %@", bytes, units[unitIndex]] : [NSString stringWithFormat:@"%.1f %@", amount, units[unitIndex]]; } @interface TCMSMarkdownTextView : NSTextView @end @implementation TCMSMarkdownTextView - (void)insertTab:(id)sender { [self.window selectNextKeyView:self]; } - (void)insertBacktab:(id)sender { [self.window selectPreviousKeyView:self]; } @end @interface TCMSTableView : NSTableView @end @implementation TCMSTableView - (NSMenu *)menuForEvent:(NSEvent *)event { NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; NSInteger row = [self rowAtPoint:point]; if (row >= 0) { [self selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; } else { return nil; } return [super menuForEvent:event]; } @end @interface TCMSAPIClient : NSObject @property (nonatomic, copy) NSString *baseURLString; @property (nonatomic, copy) NSString *username; @property (nonatomic, copy) NSString *password; - (NSURL *)absoluteURLForPath:(NSString *)path; - (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 *)absoluteURLForPath:(NSString *)path { NSString *rawPath = TCMSString(path); if (rawPath.length == 0) { return nil; } NSURL *directURL = [NSURL URLWithString:rawPath]; if (directURL.scheme.length > 0) { return directURL; } NSURL *base = [NSURL URLWithString:self.baseURLString ?: @""]; if (!base) { return nil; } if ([rawPath hasPrefix:@"/"]) { NSURLComponents *components = [NSURLComponents componentsWithURL:base resolvingAgainstBaseURL:NO]; components.path = rawPath; components.query = nil; components.fragment = nil; return components.URL; } NSURL *siteBase = [[base.lastPathComponent lowercaseString] isEqualToString:@"api.php"] ? [base URLByDeletingLastPathComponent] : base; return [siteBase URLByAppendingPathComponent:rawPath]; } - (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"]; NSString *raw = [NSString stringWithFormat:@"%@:%@", self.username ?: @"", self.password ?: @""]; NSString *encoded = [[raw dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]; [request setValue:[@"Basic " stringByAppendingString:encoded] 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) NSMutableArray *allTableItems; @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, assign) NSInteger contentListPage; @property (nonatomic, assign) NSInteger contentListPerPage; @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) 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) NSButton *spellCheckButton; @property (nonatomic, strong) NSButton *autoCorrectButton; @property (nonatomic, strong) NSTextView *markdownView; @property (nonatomic, strong) NSScrollView *editorScroll; @property (nonatomic, strong) NSView *mediaPreviewContainer; @property (nonatomic, strong) NSTextField *mediaPreviewLabel; @property (nonatomic, strong) NSTextField *mediaDetailLabel; @property (nonatomic, strong) NSImageView *mediaImageView; @property (nonatomic, strong) AVPlayerView *mediaPlayerView; @property (nonatomic, strong) NSButton *openMediaButton; @property (nonatomic, strong) NSButton *mediaURLCopyButton; @property (nonatomic, strong) NSButton *mediaEmbedCopyButton; @property (nonatomic, copy) NSString *mediaPreviewURLString; @property (nonatomic, strong) NSPopUpButton *commentStatusPopup; @property (nonatomic, strong) NSButton *previousPageButton; @property (nonatomic, strong) NSButton *nextPageButton; @property (nonatomic, strong) NSTextField *pageInfoLabel; @property (nonatomic, strong) NSPopUpButton *perPagePopup; @end @implementation TCMSAppDelegate - (void)applicationDidFinishLaunching:(NSNotification *)notification { self.section = @"posts"; self.tableItems = [NSMutableArray array]; self.allTableItems = [NSMutableArray array]; NSInteger savedPerPage = [NSUserDefaults.standardUserDefaults integerForKey:@"contentListPerPage"]; self.contentListPerPage = savedPerPage > 0 ? savedPerPage : 25; self.contentListPage = 1; self.editorItem = [self blankItemOfType:@"post"]; self.editorMarkdown = @"# Untitled\n\nStart writing here."; self.originalSlug = @""; self.client = [TCMSAPIClient new]; [self loadSettings]; [self buildMenus]; [self buildWindow]; [self showContentSection:@"posts"]; [self startAutosaveTimer]; dispatch_async(dispatch_get_main_queue(), ^{ [self refreshWindowLayout]; }); } - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { return NO; } - (BOOL)applicationShouldHandleReopen:(NSApplication *)sender hasVisibleWindows:(BOOL)flag { if (!flag) { [self.window makeKeyAndOrderFront:nil]; } return YES; } - (void)windowDidResize:(NSNotification *)notification { [self refreshWindowLayout]; } - (void)refreshWindowLayout { [self.workspace layoutSubtreeIfNeeded]; [self resizeEditorTextView]; [self.tableView sizeLastColumnToFit]; } - (void)resizeEditorTextView { if (!self.editorScroll || !self.markdownView) { return; } NSSize contentSize = self.editorScroll.contentSize; NSRect frame = self.markdownView.frame; frame.size.width = contentSize.width; frame.size.height = MAX(contentSize.height, frame.size.height); self.markdownView.frame = frame; self.markdownView.minSize = NSMakeSize(0, contentSize.height); self.markdownView.maxSize = NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX); self.markdownView.textContainer.containerSize = NSMakeSize(contentSize.width, CGFLOAT_MAX); self.markdownView.textContainer.widthTracksTextView = 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"; self.window.minSize = NSMakeSize(1040, 680); self.window.delegate = self; self.window.releasedWhenClosed = NO; [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; button.autoresizingMask = NSViewMinYMargin; [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; self.statusLabel.autoresizingMask = NSViewMaxYMargin; [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]; } - (void)buildMenus { NSMenu *mainMenu = [NSMenu new]; NSMenuItem *appItem = [NSMenuItem new]; [mainMenu addItem:appItem]; NSMenu *appMenu = [[NSMenu alloc] initWithTitle:@"TCMS"]; [appMenu addItemWithTitle:@"About TCMS" action:@selector(orderFrontStandardAboutPanel:) keyEquivalent:@""]; [appMenu addItem:[NSMenuItem separatorItem]]; NSMenuItem *settings = [appMenu addItemWithTitle:@"Settings..." action:@selector(showSettingsFromMenu:) keyEquivalent:@","]; settings.target = self; [appMenu addItem:[NSMenuItem separatorItem]]; NSMenuItem *servicesItem = [[NSMenuItem alloc] initWithTitle:@"Services" action:nil keyEquivalent:@""]; NSMenu *servicesMenu = [[NSMenu alloc] initWithTitle:@"Services"]; servicesItem.submenu = servicesMenu; [appMenu addItem:servicesItem]; NSApp.servicesMenu = servicesMenu; [appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItemWithTitle:@"Hide TCMS" action:@selector(hide:) keyEquivalent:@"h"]; NSMenuItem *hideOthers = [appMenu addItemWithTitle:@"Hide Others" action:@selector(hideOtherApplications:) keyEquivalent:@"h"]; hideOthers.keyEquivalentModifierMask = NSEventModifierFlagCommand | NSEventModifierFlagOption; [appMenu addItemWithTitle:@"Show All" action:@selector(unhideAllApplications:) keyEquivalent:@""]; [appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItemWithTitle:@"Quit TCMS" action:@selector(terminate:) keyEquivalent:@"q"]; appItem.submenu = appMenu; NSMenuItem *fileItem = [NSMenuItem new]; [mainMenu addItem:fileItem]; NSMenu *fileMenu = [[NSMenu alloc] initWithTitle:@"File"]; NSMenuItem *newPost = [fileMenu addItemWithTitle:@"New Post" action:@selector(newContentFromMenu:) keyEquivalent:@"n"]; newPost.target = self; NSMenuItem *update = [fileMenu addItemWithTitle:@"Update" action:@selector(updateContent) keyEquivalent:@"s"]; update.target = self; NSMenuItem *saveDraft = [fileMenu addItemWithTitle:@"Save Draft" action:@selector(saveDraft) keyEquivalent:@"s"]; saveDraft.target = self; saveDraft.keyEquivalentModifierMask = NSEventModifierFlagCommand | NSEventModifierFlagShift; NSMenuItem *publish = [fileMenu addItemWithTitle:@"Publish" action:@selector(publishContent) keyEquivalent:@""]; publish.target = self; [fileMenu addItem:[NSMenuItem separatorItem]]; [fileMenu addItemWithTitle:@"Close Window" action:@selector(performClose:) keyEquivalent:@"w"]; fileItem.submenu = fileMenu; NSMenuItem *editItem = [NSMenuItem new]; [mainMenu addItem:editItem]; NSMenu *editMenu = [[NSMenu alloc] initWithTitle:@"Edit"]; NSMenuItem *undo = [editMenu addItemWithTitle:@"Undo" action:@selector(undo:) keyEquivalent:@"z"]; undo.target = self; NSMenuItem *redo = [editMenu addItemWithTitle:@"Redo" action:@selector(redo:) keyEquivalent:@"z"]; redo.target = self; redo.keyEquivalentModifierMask = NSEventModifierFlagCommand | NSEventModifierFlagShift; [editMenu addItem:[NSMenuItem separatorItem]]; [editMenu addItemWithTitle:@"Cut" action:@selector(cut:) keyEquivalent:@"x"]; [editMenu addItemWithTitle:@"Copy" action:@selector(copy:) keyEquivalent:@"c"]; [editMenu addItemWithTitle:@"Paste" action:@selector(paste:) keyEquivalent:@"v"]; [editMenu addItemWithTitle:@"Select All" action:@selector(selectAll:) keyEquivalent:@"a"]; editItem.submenu = editMenu; NSMenuItem *windowItem = [NSMenuItem new]; [mainMenu addItem:windowItem]; NSMenu *windowMenu = [[NSMenu alloc] initWithTitle:@"Window"]; [windowMenu addItemWithTitle:@"Minimize" action:@selector(performMiniaturize:) keyEquivalent:@"m"]; [windowMenu addItemWithTitle:@"Zoom" action:@selector(performZoom:) keyEquivalent:@""]; [windowMenu addItem:[NSMenuItem separatorItem]]; [windowMenu addItemWithTitle:@"Bring All to Front" action:@selector(arrangeInFront:) keyEquivalent:@""]; windowItem.submenu = windowMenu; NSApp.windowsMenu = windowMenu; NSApp.mainMenu = mainMenu; } - (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; label.autoresizingMask = NSViewMinYMargin; return label; } - (NSTextField *)field:(NSString *)placeholder frame:(NSRect)frame { NSTextField *field = [[NSTextField alloc] initWithFrame:frame]; field.placeholderString = placeholder; field.delegate = self; field.autoresizingMask = NSViewMinYMargin; 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]; } self.editorScroll = nil; self.markdownView = nil; [self.mediaPlayerView.player pause]; self.mediaPreviewContainer = nil; self.mediaPreviewLabel = nil; self.mediaDetailLabel = nil; self.mediaImageView = nil; self.mediaPlayerView = nil; self.openMediaButton = nil; self.mediaURLCopyButton = nil; self.mediaEmbedCopyButton = nil; self.mediaPreviewURLString = nil; } - (void)showContentSection:(NSString *)section { self.section = section; [self clearWorkspace]; NSString *type = [section isEqualToString:@"pages"] ? @"page" : @"post"; self.contentListPage = 1; 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, 78, 30) action:@selector(refreshCurrentSection)]; [self addButton:@"New" frame:NSMakeRect(296, 690, 58, 30) action:@selector(newContent)]; [self addButton:@"Draft" frame:NSMakeRect(362, 690, 70, 30) action:@selector(saveDraft)]; [self addButton:@"Update" frame:NSMakeRect(440, 690, 76, 30) action:@selector(updateContent)]; [self addButton:@"Publish" frame:NSMakeRect(524, 690, 78, 30) action:@selector(publishContent)]; [self addButton:@"Upload Media" frame:NSMakeRect(610, 690, 118, 30) action:@selector(uploadEditorMedia)]; [self addButton:@"Delete" frame:NSMakeRect(736, 690, 70, 30) action:@selector(deleteContent)]; NSScrollView *listScroll = [self buildTableWithFrame:NSMakeRect(20, 56, 280, 614) columns:@[@"title", @"status"]]; listScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin; self.previousPageButton = [NSButton buttonWithTitle:@"Previous" target:self action:@selector(previousContentPage)]; self.previousPageButton.frame = NSMakeRect(20, 20, 78, 28); self.previousPageButton.bezelStyle = NSBezelStyleRounded; self.previousPageButton.autoresizingMask = NSViewMaxYMargin; [self.workspace addSubview:self.previousPageButton]; self.pageInfoLabel = [self label:@"1 / 1" frame:NSMakeRect(104, 24, 66, 20) font:[NSFont systemFontOfSize:12]]; self.pageInfoLabel.alignment = NSTextAlignmentCenter; self.pageInfoLabel.autoresizingMask = NSViewMaxYMargin; [self.workspace addSubview:self.pageInfoLabel]; self.nextPageButton = [NSButton buttonWithTitle:@"Next" target:self action:@selector(nextContentPage)]; self.nextPageButton.frame = NSMakeRect(176, 20, 54, 28); self.nextPageButton.bezelStyle = NSBezelStyleRounded; self.nextPageButton.autoresizingMask = NSViewMaxYMargin; [self.workspace addSubview:self.nextPageButton]; self.perPagePopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(236, 20, 64, 28)]; self.perPagePopup.autoresizingMask = NSViewMaxYMargin; self.perPagePopup.toolTip = @"Items per page"; self.perPagePopup.target = self; self.perPagePopup.action = @selector(contentPerPageChanged:); [self.perPagePopup addItemsWithTitles:@[@"10", @"25", @"50", @"100"]]; NSString *perPageTitle = [NSString stringWithFormat:@"%ld", (long)self.contentListPerPage]; if ([self.perPagePopup itemWithTitle:perPageTitle]) { [self.perPagePopup selectItemWithTitle:perPageTitle]; } else { self.contentListPerPage = 25; [self.perPagePopup selectItemWithTitle:@"25"]; } [self.workspace addSubview:self.perPagePopup]; 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.titleField.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.statusPopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(left + width - 220, 646, 100, 28)]; self.statusPopup.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin; [self.statusPopup addItemsWithTitles:@[@"draft", @"published"]]; self.typePopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(left + width - 110, 646, 90, 28)]; self.typePopup.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin; [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.slugField.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.categoryField = [self field:@"Category" frame:NSMakeRect(left + (width / 2) + 6, 608, (width / 2) - 26, 28)]; self.categoryField.autoresizingMask = NSViewMinXMargin | NSViewWidthSizable | NSViewMinYMargin; self.tagsField = [self field:@"Tags" frame:NSMakeRect(left, 570, width - 20, 28)]; self.tagsField.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.summaryField = [self field:@"Summary" frame:NSMakeRect(left, 532, width - 20, 28)]; self.summaryField.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.authorField = [self field:@"Author" frame:NSMakeRect(left, 494, (width / 2) - 6, 28)]; self.authorField.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.coverField = [self field:@"Cover" frame:NSMakeRect(left + (width / 2) + 6, 494, (width / 2) - 26, 28)]; self.coverField.autoresizingMask = NSViewMinXMargin | NSViewWidthSizable | NSViewMinYMargin; self.allowCommentsButton = [NSButton checkboxWithTitle:@"Allow comments" target:self action:@selector(markDirty)]; self.allowCommentsButton.frame = NSMakeRect(left, 460, 180, 24); self.allowCommentsButton.autoresizingMask = NSViewMinYMargin; self.spellCheckButton = [NSButton checkboxWithTitle:@"Spell check" target:self action:@selector(editorOptionChanged:)]; self.spellCheckButton.frame = NSMakeRect(left + 190, 460, 120, 24); self.spellCheckButton.autoresizingMask = NSViewMinYMargin; self.autoCorrectButton = [NSButton checkboxWithTitle:@"Auto-correct" target:self action:@selector(editorOptionChanged:)]; self.autoCorrectButton.frame = NSMakeRect(left + 320, 460, 130, 24); self.autoCorrectButton.autoresizingMask = NSViewMinYMargin; for (NSView *view in @[self.slugField, self.categoryField, self.tagsField, self.summaryField, self.authorField, self.coverField, self.allowCommentsButton, self.spellCheckButton, self.autoCorrectButton]) { [self.workspace addSubview:view]; } self.editorScroll = [[NSScrollView alloc] initWithFrame:NSMakeRect(left, 20, width - 20, 430)]; self.editorScroll.borderType = NSBezelBorder; self.editorScroll.hasVerticalScroller = YES; self.editorScroll.hasHorizontalScroller = NO; self.editorScroll.autohidesScrollers = YES; self.editorScroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.markdownView = [[TCMSMarkdownTextView alloc] initWithFrame:self.editorScroll.contentView.bounds]; self.markdownView.font = [NSFont fontWithName:@"Menlo" size:13] ?: [NSFont monospacedSystemFontOfSize:13 weight:NSFontWeightRegular]; self.markdownView.allowsUndo = YES; self.markdownView.verticallyResizable = YES; self.markdownView.horizontallyResizable = NO; self.markdownView.autoresizingMask = NSViewWidthSizable; self.markdownView.textContainer.widthTracksTextView = YES; self.markdownView.textContainer.heightTracksTextView = NO; self.markdownView.textContainerInset = NSMakeSize(8, 8); self.markdownView.delegate = self; self.editorScroll.documentView = self.markdownView; [self.workspace addSubview:self.editorScroll]; [self resizeEditorTextView]; [self applyEditorTextOptions]; self.editorItem = [self blankItemOfType:type]; self.editorMarkdown = [NSString stringWithFormat:@"# %@\n\nStart writing here.", TCMSString(self.editorItem[@"title"])]; self.originalSlug = @""; [self loadEditorFields]; [self wireEditorKeyLoop]; [self refreshContentType:type]; [self refreshWindowLayout]; } - (void)showMediaSection { self.section = @"media"; [self clearWorkspace]; self.contentListPage = 1; [self.workspace addSubview:[self label:@"Media" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]]; [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 78, 30) action:@selector(refreshCurrentSection)]; [self addButton:@"Upload" frame:NSMakeRect(296, 690, 78, 30) action:@selector(uploadMedia)]; self.openMediaButton = [self addButton:@"Open" frame:NSMakeRect(382, 690, 66, 30) action:@selector(openSelectedMedia:)]; self.openMediaButton.enabled = NO; self.mediaURLCopyButton = [self addButton:@"Copy URL" frame:NSMakeRect(456, 690, 88, 30) action:@selector(copySelectedMediaURL:)]; self.mediaURLCopyButton.enabled = NO; self.mediaEmbedCopyButton = [self addButton:@"Copy Embed" frame:NSMakeRect(552, 690, 104, 30) action:@selector(copySelectedMediaEmbed:)]; self.mediaEmbedCopyButton.enabled = NO; NSScrollView *mediaScroll = [self buildTableWithFrame:NSMakeRect(20, 56, 420, 614) columns:@[@"name", @"mime", @"size"]]; mediaScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin; self.tableView.target = self; self.tableView.doubleAction = @selector(openSelectedMedia:); [self configureMediaContextMenu]; self.previousPageButton = [NSButton buttonWithTitle:@"Previous" target:self action:@selector(previousContentPage)]; self.previousPageButton.frame = NSMakeRect(20, 20, 78, 28); self.previousPageButton.bezelStyle = NSBezelStyleRounded; self.previousPageButton.autoresizingMask = NSViewMaxYMargin; [self.workspace addSubview:self.previousPageButton]; self.pageInfoLabel = [self label:@"1 / 1" frame:NSMakeRect(104, 24, 66, 20) font:[NSFont systemFontOfSize:12]]; self.pageInfoLabel.alignment = NSTextAlignmentCenter; self.pageInfoLabel.autoresizingMask = NSViewMaxYMargin; [self.workspace addSubview:self.pageInfoLabel]; self.nextPageButton = [NSButton buttonWithTitle:@"Next" target:self action:@selector(nextContentPage)]; self.nextPageButton.frame = NSMakeRect(176, 20, 54, 28); self.nextPageButton.bezelStyle = NSBezelStyleRounded; self.nextPageButton.autoresizingMask = NSViewMaxYMargin; [self.workspace addSubview:self.nextPageButton]; self.perPagePopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(236, 20, 64, 28)]; self.perPagePopup.autoresizingMask = NSViewMaxYMargin; self.perPagePopup.toolTip = @"Items per page"; self.perPagePopup.target = self; self.perPagePopup.action = @selector(contentPerPageChanged:); [self.perPagePopup addItemsWithTitles:@[@"10", @"25", @"50", @"100"]]; NSString *perPageTitle = [NSString stringWithFormat:@"%ld", (long)self.contentListPerPage]; if ([self.perPagePopup itemWithTitle:perPageTitle]) { [self.perPagePopup selectItemWithTitle:perPageTitle]; } else { self.contentListPerPage = 25; [self.perPagePopup selectItemWithTitle:@"25"]; } [self.workspace addSubview:self.perPagePopup]; CGFloat previewLeft = 460; CGFloat previewWidth = self.workspace.bounds.size.width - previewLeft - 20; self.mediaPreviewContainer = [[NSView alloc] initWithFrame:NSMakeRect(previewLeft, 20, previewWidth, 650)]; self.mediaPreviewContainer.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.mediaPreviewContainer.wantsLayer = YES; self.mediaPreviewContainer.layer.backgroundColor = [NSColor colorWithCalibratedWhite:0.11 alpha:1].CGColor; [self.workspace addSubview:self.mediaPreviewContainer]; self.mediaPreviewLabel = [self label:@"No media selected" frame:NSMakeRect(16, 612, previewWidth - 32, 24) font:[NSFont boldSystemFontOfSize:15]]; self.mediaPreviewLabel.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.mediaPreviewLabel.textColor = [NSColor colorWithCalibratedWhite:0.92 alpha:1]; [self.mediaPreviewContainer addSubview:self.mediaPreviewLabel]; self.mediaDetailLabel = [self label:@"" frame:NSMakeRect(16, 584, previewWidth - 32, 20) font:[NSFont systemFontOfSize:12]]; self.mediaDetailLabel.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.mediaDetailLabel.textColor = [NSColor colorWithCalibratedWhite:0.72 alpha:1]; [self.mediaPreviewContainer addSubview:self.mediaDetailLabel]; NSRect previewFrame = NSMakeRect(16, 16, previewWidth - 32, 552); self.mediaImageView = [[NSImageView alloc] initWithFrame:previewFrame]; self.mediaImageView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.mediaImageView.imageScaling = NSImageScaleProportionallyUpOrDown; self.mediaImageView.hidden = YES; [self.mediaPreviewContainer addSubview:self.mediaImageView]; self.mediaPlayerView = [[AVPlayerView alloc] initWithFrame:previewFrame]; self.mediaPlayerView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.mediaPlayerView.controlsStyle = AVPlayerViewControlsStyleDefault; self.mediaPlayerView.hidden = YES; [self.mediaPreviewContainer addSubview:self.mediaPlayerView]; [self resetMediaPreview]; [self refreshMedia]; [self refreshWindowLayout]; } - (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.autoresizingMask = NSViewMinYMargin; [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)]; NSScrollView *commentsScroll = [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"author", @"status", @"slug", @"body"]]; commentsScroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; [self refreshComments]; [self refreshWindowLayout]; } - (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:@"Autosave Seconds" frame:NSMakeRect(20, 508, 140, 24) font:[NSFont systemFontOfSize:13]]]; self.siteField = [self field:@"https://example.com/blog" frame:NSMakeRect(170, 638, 520, 28)]; self.siteField.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; self.usernameField = [self field:@"admin" frame:NSMakeRect(170, 594, 260, 28)]; self.usernameField.autoresizingMask = NSViewMinYMargin; self.passwordField = [[NSSecureTextField alloc] initWithFrame:NSMakeRect(170, 550, 260, 28)]; self.passwordField.delegate = self; self.passwordField.autoresizingMask = NSViewMinYMargin; self.autosaveField = [self field:@"30" frame:NSMakeRect(170, 506, 120, 28)]; self.autosaveField.autoresizingMask = NSViewMinYMargin; self.remoteAutosaveButton = [NSButton checkboxWithTitle:@"Also save remotely as draft" target:nil action:nil]; self.remoteAutosaveButton.frame = NSMakeRect(170, 464, 260, 24); self.remoteAutosaveButton.autoresizingMask = NSViewMinYMargin; for (NSView *view in @[self.siteField, self.usernameField, self.passwordField, self.autosaveField, self.remoteAutosaveButton]) { [self.workspace addSubview:view]; } [self addButton:@"Save Settings" frame:NSMakeRect(170, 414, 120, 32) action:@selector(saveSettings)]; [self addButton:@"Test Auth" frame:NSMakeRect(300, 414, 100, 32) action:@selector(testConnection)]; [self populateSettingsFields]; [self wireSettingsKeyLoop]; [self refreshWindowLayout]; } - (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; button.autoresizingMask = NSViewMinYMargin; [self.workspace addSubview:button]; return button; } - (NSScrollView *)buildTableWithFrame:(NSRect)frame columns:(NSArray *)columns { NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:frame]; scroll.borderType = NSBezelBorder; scroll.hasVerticalScroller = YES; self.tableView = [[TCMSTableView alloc] initWithFrame:scroll.bounds]; self.tableView.delegate = self; self.tableView.dataSource = self; self.tableView.columnAutoresizingStyle = NSTableViewUniformColumnAutoresizingStyle; 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]; return scroll; } - (NSInteger)contentListTotalPages { NSInteger perPage = MAX(1, self.contentListPerPage); NSInteger total = self.allTableItems.count; return MAX(1, (total + perPage - 1) / perPage); } - (void)applyContentListPagination { NSInteger total = self.allTableItems.count; NSInteger perPage = MAX(1, self.contentListPerPage); NSInteger totalPages = [self contentListTotalPages]; self.contentListPage = MIN(MAX(1, self.contentListPage), totalPages); if (total == 0) { self.tableItems = [NSMutableArray array]; } else { NSInteger start = MIN((self.contentListPage - 1) * perPage, total); NSInteger count = MIN(perPage, total - start); self.tableItems = [NSMutableArray arrayWithArray:[self.allTableItems subarrayWithRange:NSMakeRange((NSUInteger)start, (NSUInteger)count)]]; } [self.tableView deselectAll:nil]; [self.tableView reloadData]; self.pageInfoLabel.stringValue = total == 0 ? @"0 / 0" : [NSString stringWithFormat:@"%ld / %ld", (long)self.contentListPage, (long)totalPages]; self.previousPageButton.enabled = total > 0 && self.contentListPage > 1; self.nextPageButton.enabled = total > 0 && self.contentListPage < totalPages; self.perPagePopup.enabled = total > 0; if ([self.section isEqualToString:@"media"]) { [self resetMediaPreview]; } [self refreshWindowLayout]; } - (NSString *)contentListStatusMessageForType:(NSString *)type { NSInteger total = self.allTableItems.count; NSString *noun = [type isEqualToString:@"media"] ? @"media items" : ([type isEqualToString:@"page"] ? @"pages" : @"posts"); if (total == 0) { return [NSString stringWithFormat:@"No %@ found.", noun]; } NSInteger perPage = MAX(1, self.contentListPerPage); NSInteger start = ((self.contentListPage - 1) * perPage) + 1; NSInteger end = start + self.tableItems.count - 1; return [NSString stringWithFormat:@"Showing %ld-%ld of %ld %@.", (long)start, (long)end, (long)total, noun]; } - (NSString *)currentListStatusMessage { if ([self.section isEqualToString:@"media"]) { return [self contentListStatusMessageForType:@"media"]; } return [self contentListStatusMessageForType:[self.section isEqualToString:@"pages"] ? @"page" : @"post"]; } - (void)previousContentPage { if (self.contentListPage <= 1) { return; } self.contentListPage -= 1; [self applyContentListPagination]; [self setStatus:[self currentListStatusMessage]]; } - (void)nextContentPage { NSInteger totalPages = [self contentListTotalPages]; if (self.contentListPage >= totalPages) { return; } self.contentListPage += 1; [self applyContentListPagination]; [self setStatus:[self currentListStatusMessage]]; } - (void)contentPerPageChanged:(id)sender { NSInteger selected = MAX(1, self.perPagePopup.titleOfSelectedItem.integerValue); self.contentListPerPage = selected; self.contentListPage = 1; [NSUserDefaults.standardUserDefaults setInteger:selected forKey:@"contentListPerPage"]; [self applyContentListPagination]; [self setStatus:[self currentListStatusMessage]]; } - (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 = [tableColumn.identifier isEqualToString:@"size"] ? TCMSFormatBytes(item[@"size"]) : 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"])]; } else if ([self.section isEqualToString:@"media"]) { [self previewMediaItem:item]; } } - (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"] ?: @""; } - (void)populateSettingsFields { NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; self.siteField.stringValue = self.client.baseURLString ?: @""; self.usernameField.stringValue = self.client.username ?: @""; self.passwordField.stringValue = self.client.password ?: @""; 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; 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 setInteger:interval forKey:@"autosaveInterval"]; [defaults setBool:self.remoteAutosaveButton.state == NSControlStateValueOn forKey:@"remoteAutosave"]; [self startAutosaveTimer]; [self setStatus:@"Settings saved."]; } - (BOOL)isContentSection { return [self.section isEqualToString:@"posts"] || [self.section isEqualToString:@"pages"]; } - (void)showSettingsFromMenu:(id)sender { [self showSettingsSection]; } - (void)newContentFromMenu:(id)sender { if (![self isContentSection]) { [self showContentSection:@"posts"]; } [self newContent]; } - (NSUndoManager *)activeUndoManager { NSResponder *responder = self.window.firstResponder; NSUndoManager *manager = [responder respondsToSelector:@selector(undoManager)] ? [responder undoManager] : nil; return manager ?: self.window.undoManager; } - (void)undo:(id)sender { NSUndoManager *manager = [self activeUndoManager]; if (manager.canUndo) { [manager undo]; } } - (void)redo:(id)sender { NSUndoManager *manager = [self activeUndoManager]; if (manager.canRedo) { [manager redo]; } } - (BOOL)validateMenuItem:(NSMenuItem *)menuItem { SEL action = menuItem.action; if (action == @selector(undo:)) { return [self activeUndoManager].canUndo; } if (action == @selector(redo:)) { return [self activeUndoManager].canRedo; } if (action == @selector(updateContent) || action == @selector(saveDraft) || action == @selector(publishContent)) { return [self isContentSection] && self.markdownView != nil; } if (action == @selector(copySelectedMediaURL:) || action == @selector(copySelectedMediaEmbed:) || action == @selector(openSelectedMedia:)) { return [self.section isEqualToString:@"media"] && [self selectedTableItem] != nil; } return YES; } - (BOOL)spellCheckEnabled { if ([NSUserDefaults.standardUserDefaults objectForKey:@"editorSpellCheck"] == nil) { return YES; } return [NSUserDefaults.standardUserDefaults boolForKey:@"editorSpellCheck"]; } - (BOOL)autoCorrectEnabled { return [NSUserDefaults.standardUserDefaults boolForKey:@"editorAutoCorrect"]; } - (void)applyEditorTextOptions { BOOL spellCheck = [self spellCheckEnabled]; BOOL autoCorrect = [self autoCorrectEnabled]; self.markdownView.continuousSpellCheckingEnabled = spellCheck; self.markdownView.automaticSpellingCorrectionEnabled = autoCorrect; self.markdownView.automaticTextReplacementEnabled = autoCorrect; self.markdownView.automaticQuoteSubstitutionEnabled = autoCorrect; self.markdownView.automaticDashSubstitutionEnabled = autoCorrect; self.spellCheckButton.state = spellCheck ? NSControlStateValueOn : NSControlStateValueOff; self.autoCorrectButton.state = autoCorrect ? NSControlStateValueOn : NSControlStateValueOff; } - (void)editorOptionChanged:(id)sender { [NSUserDefaults.standardUserDefaults setBool:self.spellCheckButton.state == NSControlStateValueOn forKey:@"editorSpellCheck"]; [NSUserDefaults.standardUserDefaults setBool:self.autoCorrectButton.state == NSControlStateValueOn forKey:@"editorAutoCorrect"]; [self applyEditorTextOptions]; } - (void)wireEditorKeyLoop { self.previousPageButton.nextKeyView = self.nextPageButton; self.nextPageButton.nextKeyView = self.perPagePopup; self.perPagePopup.nextKeyView = self.titleField; self.titleField.nextKeyView = self.statusPopup; self.statusPopup.nextKeyView = self.typePopup; self.typePopup.nextKeyView = self.slugField; self.slugField.nextKeyView = self.categoryField; self.categoryField.nextKeyView = self.tagsField; self.tagsField.nextKeyView = self.summaryField; self.summaryField.nextKeyView = self.authorField; self.authorField.nextKeyView = self.coverField; self.coverField.nextKeyView = self.allowCommentsButton; self.allowCommentsButton.nextKeyView = self.spellCheckButton; self.spellCheckButton.nextKeyView = self.autoCorrectButton; self.autoCorrectButton.nextKeyView = self.markdownView; self.markdownView.nextKeyView = self.previousPageButton; self.window.initialFirstResponder = self.titleField; } - (void)wireSettingsKeyLoop { self.siteField.nextKeyView = self.usernameField; self.usernameField.nextKeyView = self.passwordField; self.passwordField.nextKeyView = self.autosaveField; self.autosaveField.nextKeyView = self.remoteAutosaveButton; self.remoteAutosaveButton.nextKeyView = self.siteField; self.window.initialFirstResponder = self.siteField; } - (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; } NSArray *items = [json[@"items"] isKindOfClass:[NSArray class]] ? json[@"items"] : @[]; self.allTableItems = [NSMutableArray arrayWithArray:items]; [self applyContentListPagination]; [self setStatus:[self contentListStatusMessageForType:type]]; }]; } - (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; } - (NSString *)contentKindForItem:(NSDictionary *)item capitalized:(BOOL)capitalized { NSString *type = [TCMSString(item[@"type"]) lowercaseString]; NSString *kind = [type isEqualToString:@"page"] ? @"page" : @"post"; return capitalized ? [kind capitalizedString] : kind; } - (NSString *)contentTitleForItem:(NSDictionary *)item { NSString *title = TCMSString(item[@"title"]); if (title.length == 0) { title = TCMSString(item[@"slug"]); } return title.length > 0 ? title : @"Untitled"; } - (NSString *)confirmationMessageForItem:(NSDictionary *)item action:(NSString *)action { NSString *kind = [self contentKindForItem:item capitalized:YES]; NSString *title = [self contentTitleForItem:item]; return [NSString stringWithFormat:@"%@ \"%@\" was %@.", kind, title, action]; } - (void)showConfirmationWithTitle:(NSString *)title message:(NSString *)message { NSAlert *alert = [NSAlert new]; alert.alertStyle = NSAlertStyleInformational; alert.messageText = title.length > 0 ? title : @"Done"; alert.informativeText = message.length > 0 ? message : @"The action completed successfully."; [alert addButtonWithTitle:@"OK"]; if (self.window) { [alert beginSheetModalForWindow:self.window completionHandler:nil]; } else { [alert runModal]; } } - (void)confirmDestructiveActionWithTitle:(NSString *)title message:(NSString *)message confirmButton:(NSString *)confirmButton completion:(void (^)(BOOL confirmed))completion { NSAlert *alert = [NSAlert new]; alert.alertStyle = NSAlertStyleWarning; alert.messageText = title.length > 0 ? title : @"Confirm"; alert.informativeText = message.length > 0 ? message : @"This action cannot be undone."; NSButton *primary = [alert addButtonWithTitle:confirmButton.length > 0 ? confirmButton : @"Delete"]; primary.keyEquivalent = @"\r"; [alert addButtonWithTitle:@"Cancel"]; void (^handler)(NSModalResponse) = ^(NSModalResponse response) { if (completion) { completion(response == NSAlertFirstButtonReturn); } }; if (self.window) { [alert beginSheetModalForWindow:self.window completionHandler:handler]; } else { handler([alert runModal]); } } - (void)saveDraft { [self saveContentWithStatus:@"draft" message:@"Draft saved." confirmationTitle:@"Draft Saved" confirmationAction:@"saved as a draft"]; } - (void)publishContent { [self saveContentWithStatus:@"published" message:@"Content published." confirmationTitle:@"Published" confirmationAction:@"published"]; } - (void)saveContentWithStatus:(NSString *)status { [self saveContentWithStatus:status message:@"Content saved." confirmationTitle:nil confirmationAction:nil]; } - (void)updateContent { [self saveContentWithStatus:nil message:@"Content updated." confirmationTitle:@"Updated" confirmationAction:@"updated"]; } - (NSString *)currentEditorMarkdown { if (self.markdownView.hasMarkedText) { [self.markdownView unmarkText]; } NSString *textStorageString = self.markdownView.textStorage.string; if (textStorageString) { return textStorageString; } return self.markdownView.string ?: @""; } - (void)saveContentWithStatus:(NSString *)status message:(NSString *)message confirmationTitle:(NSString *)confirmationTitle confirmationAction:(NSString *)confirmationAction { if (![self isContentSection] || !self.markdownView) { [self setStatus:@"Open Posts or Pages to save content."]; return; } NSMutableDictionary *item = [self editorPayloadItemWithStatus:status]; NSString *submittedMarkdown = [self currentEditorMarkdown]; NSDictionary *body = @{ @"original_slug": self.originalSlug ?: @"", @"slug": TCMSString(item[@"slug"]), @"markdown": submittedMarkdown, @"content": submittedMarkdown, @"body": submittedMarkdown, @"metadata": item }; [self.client requestAction:@"content.save" query:@{} method:@"POST" body:body completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } NSMutableDictionary *savedItem = [json[@"item"] isKindOfClass:[NSDictionary class]] ? [json[@"item"] mutableCopy] : [item mutableCopy]; NSString *serverMarkdown = TCMSString(savedItem[@"markdown"]); BOOL serverEchoedSubmittedMarkdown = [TCMSMarkdownCompareString(serverMarkdown) isEqualToString:TCMSMarkdownCompareString(submittedMarkdown)]; savedItem[@"markdown"] = serverEchoedSubmittedMarkdown ? serverMarkdown : submittedMarkdown; self.editorItem = savedItem; self.editorMarkdown = TCMSString(savedItem[@"markdown"]); self.originalSlug = TCMSString(self.editorItem[@"slug"]); self.dirty = !serverEchoedSubmittedMarkdown; [self loadEditorFields]; [self refreshContentType:TCMSString(self.editorItem[@"type"]).length ? TCMSString(self.editorItem[@"type"]) : @"post"]; [self setStatus:serverEchoedSubmittedMarkdown ? (message ?: @"Content saved.") : @"Server response did not include the updated Markdown. Editor changes preserved."]; if (serverEchoedSubmittedMarkdown && confirmationTitle.length > 0) { [self showConfirmationWithTitle:confirmationTitle message:[self confirmationMessageForItem:self.editorItem action:confirmationAction ?: @"saved"]]; } }]; } - (void)deleteContent { NSDictionary *item = [self.editorItem copy] ?: @{}; NSString *slug = TCMSString(item[@"slug"]); if (slug.length == 0) { [self setStatus:@"Nothing selected to delete."]; return; } NSString *kind = [self contentKindForItem:item capitalized:YES]; NSString *title = [self contentTitleForItem:item]; NSString *message = [NSString stringWithFormat:@"Delete \"%@\" from the server? This cannot be undone.", title]; [self confirmDestructiveActionWithTitle:[NSString stringWithFormat:@"Delete %@?", kind] message:message confirmButton:@"Delete" completion:^(BOOL confirmed) { if (!confirmed) { 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]; [self showConfirmationWithTitle:@"Deleted" message:[self confirmationMessageForItem:item action:@"deleted"]]; }]; }]; } - (void)refreshMedia { [self.client requestAction:@"media.list" query:@{} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { if (error) { [self setStatus:error.localizedDescription]; return; } NSArray *items = [json[@"items"] isKindOfClass:[NSArray class]] ? json[@"items"] : @[]; self.allTableItems = [NSMutableArray arrayWithArray:items]; [self applyContentListPagination]; [self setStatus:[self currentListStatusMessage]]; }]; } - (void)chooseAndUploadMediaWithCompletion:(void (^)(NSDictionary *item))completion { 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; } NSDictionary *item = [json[@"item"] isKindOfClass:[NSDictionary class]] ? json[@"item"] : @{}; completion(item); }]; } - (void)uploadMedia { [self chooseAndUploadMediaWithCompletion:^(NSDictionary *item) { [self setStatus:@"Media uploaded."]; [self refreshMedia]; }]; } - (void)uploadEditorMedia { if (![self isContentSection] || !self.markdownView) { [self setStatus:@"Open Posts or Pages to insert media."]; return; } [self chooseAndUploadMediaWithCompletion:^(NSDictionary *item) { NSString *snippet = [self mediaMarkdownEmbedForItem:item]; if (snippet.length == 0) { [self setStatus:@"Media uploaded, but no embed could be generated."]; return; } [self insertMarkdownSnippet:snippet]; [self setStatus:@"Media uploaded and inserted."]; }]; } - (NSURL *)absoluteURLForMediaItem:(NSDictionary *)item { NSString *url = TCMSString(item[@"url"]); if (url.length == 0) { url = TCMSString(item[@"path"]); } return [self.client absoluteURLForPath:url]; } - (NSString *)mediaEmbedURLForItem:(NSDictionary *)item { NSString *url = TCMSString(item[@"url"]); if (url.length == 0) { url = TCMSString(item[@"path"]); } return url; } - (NSString *)mediaTitleForItem:(NSDictionary *)item { NSString *name = TCMSString(item[@"name"]); if (name.length == 0) { name = TCMSString(item[@"path"]).lastPathComponent; } NSString *base = name.stringByDeletingPathExtension; return base.length > 0 ? base : name; } - (BOOL)mediaItem:(NSDictionary *)item hasMimePrefix:(NSString *)prefix extensions:(NSArray *)extensions { NSString *mime = [TCMSString(item[@"mime"]) lowercaseString]; if ([mime hasPrefix:prefix]) { return YES; } NSString *extension = [[TCMSString(item[@"name"]).pathExtension lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; if (extension.length == 0) { extension = [[TCMSString(item[@"path"]).pathExtension lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; } return [extensions containsObject:extension]; } - (BOOL)isImageMediaItem:(NSDictionary *)item { return [self mediaItem:item hasMimePrefix:@"image/" extensions:@[@"avif", @"bmp", @"gif", @"jpeg", @"jpg", @"png", @"svg", @"webp"]]; } - (BOOL)isVideoMediaItem:(NSDictionary *)item { return [self mediaItem:item hasMimePrefix:@"video/" extensions:@[@"m4v", @"mov", @"mp4", @"ogv", @"ogg", @"webm"]]; } - (BOOL)isAudioMediaItem:(NSDictionary *)item { return [self mediaItem:item hasMimePrefix:@"audio/" extensions:@[@"aac", @"aif", @"aiff", @"flac", @"m4a", @"mp3", @"oga", @"ogg", @"wav", @"weba"]]; } - (NSString *)markdownSafeLabel:(NSString *)value { NSString *label = [TCMSString(value) stringByReplacingOccurrencesOfString:@"\n" withString:@" "]; label = [label stringByReplacingOccurrencesOfString:@"[" withString:@"("]; label = [label stringByReplacingOccurrencesOfString:@"]" withString:@")"]; return label.length > 0 ? label : @"media"; } - (NSString *)shortcodeSafeValue:(NSString *)value { NSString *clean = [TCMSString(value) stringByReplacingOccurrencesOfString:@"\"" withString:@"%22"]; clean = [clean stringByReplacingOccurrencesOfString:@"\n" withString:@""]; return clean; } - (NSString *)mediaMarkdownEmbedForItem:(NSDictionary *)item { NSString *url = [self mediaEmbedURLForItem:item]; if (url.length == 0) { return @""; } NSString *title = [self markdownSafeLabel:[self mediaTitleForItem:item]]; if ([self isImageMediaItem:item]) { return [NSString stringWithFormat:@"![%@](%@)", title, url]; } if ([self isVideoMediaItem:item]) { return [NSString stringWithFormat:@"[video file=\"%@\"]", [self shortcodeSafeValue:url]]; } if ([self isAudioMediaItem:item]) { return [NSString stringWithFormat:@"", [self shortcodeSafeValue:url]]; } return [NSString stringWithFormat:@"[%@](%@)", title, url]; } - (void)insertMarkdownSnippet:(NSString *)snippet { if (!self.markdownView || snippet.length == 0) { return; } NSString *current = self.markdownView.string ?: @""; NSRange range = self.markdownView.selectedRange; if (range.location == NSNotFound || range.location > current.length) { range = NSMakeRange(current.length, 0); } else if (NSMaxRange(range) > current.length) { range.length = current.length - range.location; } NSString *prefix = @""; NSString *suffix = @""; if (range.location > 0) { NSString *before = [current substringToIndex:range.location]; if (![before hasSuffix:@"\n\n"]) { prefix = [before hasSuffix:@"\n"] ? @"\n" : @"\n\n"; } } NSUInteger afterLocation = MIN(NSMaxRange(range), current.length); if (afterLocation < current.length) { NSString *after = [current substringFromIndex:afterLocation]; if (![after hasPrefix:@"\n\n"]) { suffix = [after hasPrefix:@"\n"] ? @"\n" : @"\n\n"; } } else { suffix = @"\n"; } NSString *insert = [NSString stringWithFormat:@"%@%@%@", prefix, snippet, suffix]; if ([self.markdownView shouldChangeTextInRange:range replacementString:insert]) { [self.markdownView.textStorage replaceCharactersInRange:range withString:insert]; [self.markdownView didChangeText]; [self.markdownView setSelectedRange:NSMakeRange(range.location + prefix.length + snippet.length, 0)]; } [self.window makeFirstResponder:self.markdownView]; [self markDirty]; } - (void)configureMediaContextMenu { NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Media"]; NSMenuItem *copyURL = [[NSMenuItem alloc] initWithTitle:@"Copy URL" action:@selector(copySelectedMediaURL:) keyEquivalent:@""]; copyURL.target = self; [menu addItem:copyURL]; NSMenuItem *copyEmbed = [[NSMenuItem alloc] initWithTitle:@"Copy Markdown Embed" action:@selector(copySelectedMediaEmbed:) keyEquivalent:@""]; copyEmbed.target = self; [menu addItem:copyEmbed]; [menu addItem:[NSMenuItem separatorItem]]; NSMenuItem *open = [[NSMenuItem alloc] initWithTitle:@"Open" action:@selector(openSelectedMedia:) keyEquivalent:@""]; open.target = self; [menu addItem:open]; self.tableView.menu = menu; } - (void)copyTextToPasteboard:(NSString *)text status:(NSString *)status { if (text.length == 0) { [self setStatus:@"Nothing to copy."]; return; } NSPasteboard *pasteboard = NSPasteboard.generalPasteboard; [pasteboard clearContents]; [pasteboard setString:text forType:NSPasteboardTypeString]; [self setStatus:status]; } - (void)copySelectedMediaURL:(id)sender { NSDictionary *item = [self selectedTableItem]; if (!item) { [self setStatus:@"Select a media item first."]; return; } NSURL *url = [self absoluteURLForMediaItem:item]; [self copyTextToPasteboard:url.absoluteString ?: [self mediaEmbedURLForItem:item] status:@"Media URL copied."]; } - (void)copySelectedMediaEmbed:(id)sender { NSDictionary *item = [self selectedTableItem]; if (!item) { [self setStatus:@"Select a media item first."]; return; } [self copyTextToPasteboard:[self mediaMarkdownEmbedForItem:item] status:@"Markdown embed copied."]; } - (NSString *)mediaDetailForItem:(NSDictionary *)item { NSMutableArray *parts = [NSMutableArray array]; NSString *mime = TCMSString(item[@"mime"]); NSString *size = TCMSFormatBytes(item[@"size"]); NSString *path = TCMSString(item[@"path"]); if (mime.length > 0) { [parts addObject:mime]; } if (size.length > 0) { [parts addObject:size]; } if (path.length > 0) { [parts addObject:path]; } return [parts componentsJoinedByString:@" "]; } - (void)resetMediaPreview { [self.mediaPlayerView.player pause]; self.mediaPlayerView.player = nil; self.mediaPlayerView.hidden = YES; self.mediaImageView.image = nil; self.mediaImageView.hidden = YES; self.mediaPreviewURLString = nil; self.openMediaButton.enabled = NO; self.mediaURLCopyButton.enabled = NO; self.mediaEmbedCopyButton.enabled = NO; self.mediaPreviewLabel.stringValue = @"No media selected"; self.mediaDetailLabel.stringValue = @""; } - (void)previewMediaItem:(NSDictionary *)item { NSURL *url = [self absoluteURLForMediaItem:item]; if (!url) { [self resetMediaPreview]; [self setStatus:@"That media item does not have a usable URL."]; return; } NSString *mime = [TCMSString(item[@"mime"]) lowercaseString]; NSString *urlString = url.absoluteString ?: @""; self.mediaPreviewURLString = urlString; self.openMediaButton.enabled = YES; self.mediaURLCopyButton.enabled = YES; self.mediaEmbedCopyButton.enabled = YES; self.mediaPreviewLabel.stringValue = TCMSString(item[@"name"]).length ? TCMSString(item[@"name"]) : url.lastPathComponent; self.mediaDetailLabel.stringValue = [self mediaDetailForItem:item]; self.mediaImageView.image = nil; self.mediaImageView.hidden = YES; [self.mediaPlayerView.player pause]; self.mediaPlayerView.player = nil; self.mediaPlayerView.hidden = YES; if ([mime hasPrefix:@"image/"]) { self.mediaImageView.hidden = NO; [self setStatus:@"Loading image preview."]; [[[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { NSImage *image = data.length > 0 ? [[NSImage alloc] initWithData:data] : nil; dispatch_async(dispatch_get_main_queue(), ^{ if (![self.mediaPreviewURLString isEqualToString:urlString]) { return; } if (image) { self.mediaImageView.image = image; [self setStatus:@"Image preview ready."]; } else { self.mediaImageView.hidden = YES; [self setStatus:error.localizedDescription ?: @"Unable to load image preview."]; } }); }] resume]; return; } if ([mime hasPrefix:@"video/"] || [mime hasPrefix:@"audio/"]) { AVPlayer *player = [AVPlayer playerWithURL:url]; self.mediaPlayerView.player = player; self.mediaPlayerView.hidden = NO; [self setStatus:[mime hasPrefix:@"audio/"] ? @"Audio ready to play." : @"Video ready to play."]; return; } [self setStatus:@"Preview unavailable for this file type. Use Open to view it."]; } - (void)openSelectedMedia:(id)sender { NSDictionary *item = [self selectedTableItem]; if (!item) { [self setStatus:@"Select a media item first."]; return; } NSURL *url = [self absoluteURLForMediaItem:item]; if (!url) { [self setStatus:@"That media item does not have a usable URL."]; return; } [NSWorkspace.sharedWorkspace openURL:url]; } - (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/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; }