Files
tcms/macclient/AppKitClient/main.m
T
Ty Clifford 75d38d4eb8 - I made both surfaces friendlier to screen readers.
CMS changes landed in 
[layout.php](/Users/tyemeclifford/Documents/GH/blog/themes/neon/layout.php), 
[style.css](/Users/tyemeclifford/Documents/GH/blog/themes/neon/assets/style.css), 
[app.js](/Users/tyemeclifford/Documents/GH/blog/themes/neon/assets/app.js), 
and 
[lone-embed.php](/Users/tyemeclifford/Documents/GH/blog/lone-embed.php): 
skip link, named search/forms, current page state, live notices/empty 
states, labelled media/embed controls, visible focus states, theme 
control pressed states, and a focus-trapped/lightbox dialog with proper 
ARIA.
Mac app changes landed in 
[main.m](/Users/tyemeclifford/Documents/GH/blog/macclient/AppKitClient/main.m), 
and I rebuilt 
[TCMS](/Users/tyemeclifford/Documents/GH/blog/macclient/TCMS.app/Contents/MacOS/TCMS): 
AppKit controls/tables/fields/previews now get accessibility 
labels/help/value text, workspace sections are named, media/comment 
tables expose useful context, and status messages are announced via 
macOS accessibility notifications.
2026-07-05 23:36:27 -04:00

1893 lines
87 KiB
Objective-C

#import <Cocoa/Cocoa.h>
#import <AVKit/AVKit.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);
}
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]];
}
static void TCMSSetAccessibility(NSView *view, NSString *label, NSString *help) {
if (!view) {
return;
}
if (label.length > 0) {
[view setAccessibilityLabel:label];
}
if (help.length > 0) {
[view setAccessibilityHelp:help];
}
}
static void TCMSSetAccessibilityValue(NSView *view, NSString *value) {
if (!view) {
return;
}
[view setAccessibilityValue:value ?: @""];
}
@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 <NSApplicationDelegate, NSWindowDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSTextViewDelegate, NSMenuItemValidation>
@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) NSMutableArray<NSDictionary *> *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;
TCMSSetAccessibility(self.window.contentView, @"TCMS workspace", @"Manage posts, pages, media, comments, and settings.");
[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;
TCMSSetAccessibility(sidebar, @"Navigation and status", @"Choose a TCMS section and hear current status messages.");
[content addSubview:sidebar];
NSTextField *brand = [self label:@"TCMS" frame:NSMakeRect(18, 680, 154, 34) font:[NSFont boldSystemFontOfSize:28]];
brand.textColor = NSColor.whiteColor;
TCMSSetAccessibility(brand, @"TCMS", @"Ty Clifford's Content Management System.");
[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;
TCMSSetAccessibility(button, [NSString stringWithFormat:@"Show %@", entry[0]], [NSString stringWithFormat:@"Switch to the %@ section.", entry[0]]);
[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;
TCMSSetAccessibility(self.statusLabel, @"Status", @"Latest TCMS status message.");
TCMSSetAccessibilityValue(self.statusLabel, @"Ready");
[sidebar addSubview:self.statusLabel];
self.workspace = [[NSView alloc] initWithFrame:NSMakeRect(190, 0, 990, 740)];
self.workspace.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
TCMSSetAccessibility(self.workspace, @"Posts section", @"Main TCMS editing workspace.");
[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;
TCMSSetAccessibility(label, text, nil);
return label;
}
- (NSTextField *)field:(NSString *)placeholder frame:(NSRect)frame {
NSTextField *field = [[NSTextField alloc] initWithFrame:frame];
field.placeholderString = placeholder;
field.delegate = self;
field.autoresizingMask = NSViewMinYMargin;
TCMSSetAccessibility(field, placeholder, nil);
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)announceAccessibilityMessage:(NSString *)message {
if (message.length == 0) {
return;
}
NSDictionary *info = @{
NSAccessibilityAnnouncementKey: message,
NSAccessibilityPriorityKey: @(NSAccessibilityPriorityMedium)
};
NSAccessibilityPostNotificationWithUserInfo(self.window ?: NSApp, NSAccessibilityAnnouncementRequestedNotification, info);
}
- (void)configureWorkspaceAccessibilityWithTitle:(NSString *)title heading:(NSView *)heading {
NSString *label = [NSString stringWithFormat:@"%@ section", title.length > 0 ? title : @"TCMS"];
TCMSSetAccessibility(self.workspace, label, @"Main TCMS workspace.");
NSAccessibilityPostNotification(heading ?: self.workspace, NSAccessibilityLayoutChangedNotification);
}
- (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 configureWorkspaceAccessibilityWithTitle:[section capitalizedString] heading:heading];
NSButton *refreshButton = [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 78, 30) action:@selector(refreshCurrentSection)];
NSButton *newButton = [self addButton:@"New" frame:NSMakeRect(296, 690, 58, 30) action:@selector(newContent)];
NSButton *draftButton = [self addButton:@"Draft" frame:NSMakeRect(362, 690, 70, 30) action:@selector(saveDraft)];
NSButton *updateButton = [self addButton:@"Update" frame:NSMakeRect(440, 690, 76, 30) action:@selector(updateContent)];
NSButton *publishButton = [self addButton:@"Publish" frame:NSMakeRect(524, 690, 78, 30) action:@selector(publishContent)];
NSButton *uploadMediaButton = [self addButton:@"Upload Media" frame:NSMakeRect(610, 690, 118, 30) action:@selector(uploadEditorMedia)];
NSButton *deleteButton = [self addButton:@"Delete" frame:NSMakeRect(736, 690, 70, 30) action:@selector(deleteContent)];
TCMSSetAccessibility(refreshButton, [NSString stringWithFormat:@"Refresh %@ list", section], nil);
TCMSSetAccessibility(newButton, [NSString stringWithFormat:@"Create new %@", type], nil);
TCMSSetAccessibility(draftButton, @"Save current content as draft", nil);
TCMSSetAccessibility(updateButton, @"Update current content", nil);
TCMSSetAccessibility(publishButton, @"Publish current content", nil);
TCMSSetAccessibility(uploadMediaButton, @"Upload media and insert it in the editor", nil);
TCMSSetAccessibility(deleteButton, @"Delete current content", @"Deletes the selected post or page after confirmation.");
NSScrollView *listScroll = [self buildTableWithFrame:NSMakeRect(20, 56, 280, 614) columns:@[@"title", @"status"]];
listScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin;
TCMSSetAccessibility(listScroll, [NSString stringWithFormat:@"%@ list", [section capitalizedString]], @"Select a row to load it into the editor.");
TCMSSetAccessibility(self.tableView, [NSString stringWithFormat:@"%@ table", [section capitalizedString]], @"Select a row to load it into the editor.");
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;
TCMSSetAccessibility(self.previousPageButton, @"Previous page", [NSString stringWithFormat:@"Show the previous page of %@.", section]);
[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;
TCMSSetAccessibility(self.pageInfoLabel, @"Page", @"Current list page.");
TCMSSetAccessibilityValue(self.pageInfoLabel, @"Page 1 of 1");
[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;
TCMSSetAccessibility(self.nextPageButton, @"Next page", [NSString stringWithFormat:@"Show the next page of %@.", section]);
[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";
TCMSSetAccessibility(self.perPagePopup, @"Items per page", [NSString stringWithFormat:@"Choose how many %@ to show per page.", section]);
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;
TCMSSetAccessibility(self.statusPopup, @"Status", @"Choose draft or published.");
[self.statusPopup addItemsWithTitles:@[@"draft", @"published"]];
self.typePopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(left + width - 110, 646, 90, 28)];
self.typePopup.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin;
TCMSSetAccessibility(self.typePopup, @"Content type", @"Choose post or page.");
[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;
TCMSSetAccessibility(self.allowCommentsButton, @"Allow comments", @"Allow readers to leave comments on this content.");
self.spellCheckButton = [NSButton checkboxWithTitle:@"Spell check" target:self action:@selector(editorOptionChanged:)];
self.spellCheckButton.frame = NSMakeRect(left + 190, 460, 120, 24);
self.spellCheckButton.autoresizingMask = NSViewMinYMargin;
TCMSSetAccessibility(self.spellCheckButton, @"Spell check", @"Toggle continuous spell checking in the Markdown editor.");
self.autoCorrectButton = [NSButton checkboxWithTitle:@"Auto-correct" target:self action:@selector(editorOptionChanged:)];
self.autoCorrectButton.frame = NSMakeRect(left + 320, 460, 130, 24);
self.autoCorrectButton.autoresizingMask = NSViewMinYMargin;
TCMSSetAccessibility(self.autoCorrectButton, @"Auto-correct", @"Toggle automatic corrections in the Markdown editor.");
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;
TCMSSetAccessibility(self.editorScroll, @"Markdown editor", @"Edit the Markdown body for the current post or page.");
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;
TCMSSetAccessibility(self.markdownView, @"Markdown editor", @"Edit the Markdown body for the current post or page.");
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;
NSTextField *heading = [self label:@"Media" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]];
[self.workspace addSubview:heading];
[self configureWorkspaceAccessibilityWithTitle:@"Media" heading:heading];
NSButton *refreshButton = [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 78, 30) action:@selector(refreshCurrentSection)];
NSButton *uploadButton = [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;
TCMSSetAccessibility(refreshButton, @"Refresh media list", nil);
TCMSSetAccessibility(uploadButton, @"Upload media", @"Upload a file to the TCMS media library.");
TCMSSetAccessibility(self.openMediaButton, @"Open selected media", @"Open the selected media item in the default app.");
TCMSSetAccessibility(self.mediaURLCopyButton, @"Copy selected media URL", nil);
TCMSSetAccessibility(self.mediaEmbedCopyButton, @"Copy Markdown embed for selected media", nil);
NSScrollView *mediaScroll = [self buildTableWithFrame:NSMakeRect(20, 56, 420, 614) columns:@[@"name", @"mime", @"size"]];
mediaScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin;
TCMSSetAccessibility(mediaScroll, @"Media list", @"Select a row to preview media.");
TCMSSetAccessibility(self.tableView, @"Media table", @"Select a row to preview media.");
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;
TCMSSetAccessibility(self.previousPageButton, @"Previous media page", nil);
[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;
TCMSSetAccessibility(self.pageInfoLabel, @"Page", @"Current media list page.");
TCMSSetAccessibilityValue(self.pageInfoLabel, @"Page 1 of 1");
[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;
TCMSSetAccessibility(self.nextPageButton, @"Next media page", nil);
[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";
TCMSSetAccessibility(self.perPagePopup, @"Media items per page", nil);
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;
TCMSSetAccessibility(self.mediaPreviewContainer, @"Media preview", @"Preview for the selected media item.");
[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];
TCMSSetAccessibility(self.mediaPreviewLabel, @"Selected media", nil);
TCMSSetAccessibilityValue(self.mediaPreviewLabel, @"No media selected");
[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];
TCMSSetAccessibility(self.mediaDetailLabel, @"Media details", nil);
[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;
TCMSSetAccessibility(self.mediaImageView, @"Image preview", @"Preview image for the selected media item.");
[self.mediaPreviewContainer addSubview:self.mediaImageView];
self.mediaPlayerView = [[AVPlayerView alloc] initWithFrame:previewFrame];
self.mediaPlayerView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
self.mediaPlayerView.controlsStyle = AVPlayerViewControlsStyleDefault;
self.mediaPlayerView.hidden = YES;
TCMSSetAccessibility(self.mediaPlayerView, @"Media player", @"Play the selected audio or video item.");
[self.mediaPreviewContainer addSubview:self.mediaPlayerView];
[self resetMediaPreview];
[self refreshMedia];
[self refreshWindowLayout];
}
- (void)showCommentsSection {
self.section = @"comments";
[self clearWorkspace];
NSTextField *heading = [self label:@"Comments" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]];
[self.workspace addSubview:heading];
[self configureWorkspaceAccessibilityWithTitle:@"Comments" heading:heading];
self.commentStatusPopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(210, 690, 120, 30)];
self.commentStatusPopup.autoresizingMask = NSViewMinYMargin;
[self.commentStatusPopup addItemsWithTitles:@[@"pending", @"approved", @"spam", @"all"]];
TCMSSetAccessibility(self.commentStatusPopup, @"Comment status filter", @"Choose which comments to list.");
[self.workspace addSubview:self.commentStatusPopup];
NSButton *refreshButton = [self addButton:@"Refresh" frame:NSMakeRect(340, 690, 90, 30) action:@selector(refreshCurrentSection)];
NSButton *approveButton = [self addButton:@"Approve" frame:NSMakeRect(440, 690, 90, 30) action:@selector(approveComment)];
NSButton *pendingButton = [self addButton:@"Pending" frame:NSMakeRect(540, 690, 90, 30) action:@selector(pendingComment)];
NSButton *spamButton = [self addButton:@"Spam" frame:NSMakeRect(640, 690, 80, 30) action:@selector(spamComment)];
NSButton *deleteButton = [self addButton:@"Delete" frame:NSMakeRect(730, 690, 80, 30) action:@selector(deleteComment)];
TCMSSetAccessibility(refreshButton, @"Refresh comments", nil);
TCMSSetAccessibility(approveButton, @"Approve selected comment", nil);
TCMSSetAccessibility(pendingButton, @"Mark selected comment pending", nil);
TCMSSetAccessibility(spamButton, @"Mark selected comment as spam", nil);
TCMSSetAccessibility(deleteButton, @"Delete selected comment", @"Deletes the selected comment from the server.");
NSScrollView *commentsScroll = [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"author", @"status", @"slug", @"body"]];
commentsScroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
TCMSSetAccessibility(commentsScroll, @"Comments list", @"Select a comment row before moderating it.");
TCMSSetAccessibility(self.tableView, @"Comments table", @"Select a comment row before moderating it.");
[self refreshComments];
[self refreshWindowLayout];
}
- (void)showSettingsSection {
self.section = @"settings";
[self clearWorkspace];
NSTextField *heading = [self label:@"Settings" frame:NSMakeRect(20, 690, 220, 28) font:[NSFont boldSystemFontOfSize:24]];
[self.workspace addSubview:heading];
[self configureWorkspaceAccessibilityWithTitle:@"Settings" heading:heading];
[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;
TCMSSetAccessibility(self.siteField, @"TCMS URL", @"Base URL for the TCMS site.");
self.usernameField = [self field:@"admin" frame:NSMakeRect(170, 594, 260, 28)];
self.usernameField.autoresizingMask = NSViewMinYMargin;
TCMSSetAccessibility(self.usernameField, @"Username", @"Username for the TCMS API.");
self.passwordField = [[NSSecureTextField alloc] initWithFrame:NSMakeRect(170, 550, 260, 28)];
self.passwordField.delegate = self;
self.passwordField.autoresizingMask = NSViewMinYMargin;
TCMSSetAccessibility(self.passwordField, @"Password", @"Password for the TCMS API.");
self.autosaveField = [self field:@"30" frame:NSMakeRect(170, 506, 120, 28)];
TCMSSetAccessibility(self.autosaveField, @"Autosave seconds", @"Minimum autosave interval is 10 seconds.");
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;
TCMSSetAccessibility(self.remoteAutosaveButton, @"Also save remotely as draft", @"When enabled, autosave sends a draft copy to the server.");
for (NSView *view in @[self.siteField, self.usernameField, self.passwordField, self.autosaveField, self.remoteAutosaveButton]) {
[self.workspace addSubview:view];
}
NSButton *saveButton = [self addButton:@"Save Settings" frame:NSMakeRect(170, 414, 120, 32) action:@selector(saveSettings)];
NSButton *testButton = [self addButton:@"Test Auth" frame:NSMakeRect(300, 414, 100, 32) action:@selector(testConnection)];
TCMSSetAccessibility(saveButton, @"Save settings", nil);
TCMSSetAccessibility(testButton, @"Test authentication", @"Save settings, then test the TCMS API credentials.");
[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;
TCMSSetAccessibility(button, title, nil);
[self.workspace addSubview:button];
return button;
}
- (NSScrollView *)buildTableWithFrame:(NSRect)frame columns:(NSArray<NSString *> *)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;
TCMSSetAccessibility(scroll, @"Table", @"Use arrow keys to move through rows.");
TCMSSetAccessibility(self.tableView, @"Table", @"Use arrow keys to move through rows.");
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];
NSString *pageText = total == 0 ? @"0 / 0" : [NSString stringWithFormat:@"%ld / %ld", (long)self.contentListPage, (long)totalPages];
self.pageInfoLabel.stringValue = pageText;
TCMSSetAccessibilityValue(self.pageInfoLabel, total == 0 ? @"No pages" : [NSString stringWithFormat:@"Page %ld of %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];
}
TCMSSetAccessibilityValue(self.tableView, [self currentListStatusMessage]);
[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]);
TCMSSetAccessibility(field, [NSString stringWithFormat:@"%@: %@", tableColumn.title ?: TCMSString(tableColumn.identifier), field.stringValue], nil);
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<NSString *> *)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:@"<audio controls src=\"%@\"></audio>", [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 = @"";
TCMSSetAccessibilityValue(self.mediaPreviewContainer, @"No media selected");
TCMSSetAccessibilityValue(self.mediaPreviewLabel, @"No media selected");
TCMSSetAccessibilityValue(self.mediaDetailLabel, @"");
}
- (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;
NSString *mediaName = TCMSString(item[@"name"]).length ? TCMSString(item[@"name"]) : url.lastPathComponent;
self.mediaPreviewLabel.stringValue = mediaName;
self.mediaDetailLabel.stringValue = [self mediaDetailForItem:item];
TCMSSetAccessibilityValue(self.mediaPreviewContainer, [NSString stringWithFormat:@"%@. %@", mediaName, self.mediaDetailLabel.stringValue]);
TCMSSetAccessibilityValue(self.mediaPreviewLabel, mediaName);
TCMSSetAccessibilityValue(self.mediaDetailLabel, self.mediaDetailLabel.stringValue);
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;
TCMSSetAccessibility(self.mediaImageView, [NSString stringWithFormat:@"Image preview for %@", mediaName], nil);
[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;
TCMSSetAccessibility(self.mediaPlayerView, [mime hasPrefix:@"audio/"] ? [NSString stringWithFormat:@"Audio player for %@", mediaName] : [NSString stringWithFormat:@"Video player for %@", mediaName], nil);
[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];
NSString *status = self.commentStatusPopup.titleOfSelectedItem ?: @"pending";
NSString *message = [NSString stringWithFormat:@"Showing %ld %@ comments.", (long)self.tableItems.count, status];
TCMSSetAccessibilityValue(self.tableView, message);
[self setStatus:message];
}];
}
- (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 ?: @"";
TCMSSetAccessibilityValue(self.statusLabel, message ?: @"");
[self announceAccessibilityMessage: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;
}