diff --git a/.env.example b/.env.example index 353c3e6..7ae37d8 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,3 @@ TCMS_API_ENABLED=true TCMS_API_USERNAME=admin TCMS_API_PASSWORD=change-this-password TCMS_API_PASSWORD_HASH= -TCMS_API_TOKEN=change-this-token diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f323f..a5fb96d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,20 @@ All notable changes to Ty Clifford's Content Management System are documented he ### Added -- Added `api.php` for `.env`-authenticated remote management of content, media uploads, and comments. +- Added `api.php` for `.env` username/password authenticated remote management of content, media uploads, and comments. - Added a native AppKit macOS 12+ client under `macclient/` for editing posts/pages, uploading media, moderating comments, local autosave, and optional remote draft autosave. +- Added Mac client post/page list pagination with configurable per-page counts. +- Added Mac client editor spell-check and auto-correct options. +- Added a Mac client Update action for saving existing posts/pages without changing their current draft/published status. - Added a declared shortcode parser with PHP-array/JSON configuration, including `[youtube=]` and `[video poster= file=]`. - Added `lone-embed.php` as the generated HTML5 video player target for video shortcodes. ### Changed +- Simplified API and Mac client authentication to `.env` username/password only, removing token-based auth. +- Fixed Mac client resizing, maximized editor text reflow, startup layout refresh, guarded undo/redo behavior, keyboard focus traversal, and standard Command-key shortcuts including Settings and Update. +- Fixed Mac client Update so edited Markdown is read from the live text storage and stale save responses no longer overwrite the editor. +- Expanded the API content save endpoint to accept `markdown`, `content`, or `body` payload fields for editor content. - Blocked direct web access to `.env` and the `macclient/` source folder through `.htaccess`, with matching `.env` protection in the local PHP router. ## 2026-07-04 diff --git a/README.md b/README.md index 23d0538..46982f5 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,9 @@ TCMS_API_ENABLED=true TCMS_API_USERNAME=admin TCMS_API_PASSWORD=change-this-password TCMS_API_PASSWORD_HASH= -TCMS_API_TOKEN=change-this-token ``` -The Mac client can authenticate with the `.env` username/password. Scripts can also send `TCMS_API_TOKEN` as a Bearer token or `X-TCMS-Token`. Use `TCMS_API_PASSWORD_HASH` with `password_hash()` output instead of `TCMS_API_PASSWORD` for production. +The Mac client authenticates with the `.env` username and password. Use `TCMS_API_PASSWORD_HASH` with `password_hash()` output instead of `TCMS_API_PASSWORD` for production. ## Comments diff --git a/api.php b/api.php index c8f29b2..a132109 100644 --- a/api.php +++ b/api.php @@ -29,7 +29,6 @@ try { 'authenticated' => tcms_is_authenticated($config), 'auth_required' => true, 'credentials_configured' => tcms_credentials_configured(), - 'token_configured' => tcms_configured_token() !== '', 'capabilities' => [ 'content' => ['list', 'get', 'save', 'delete'], 'media' => ['list', 'upload'], @@ -121,10 +120,10 @@ function tcms_error(string $message, int $status = 400, array $extra = []): neve function tcms_require_auth($config): void { if (!tcms_credentials_configured()) { - tcms_error('API credentials are not configured. Create a site .env file with TCMS_API_USERNAME and TCMS_API_PASSWORD, TCMS_API_PASSWORD_HASH, or TCMS_API_TOKEN.', 503); + tcms_error('API credentials are not configured. Create a site .env file with TCMS_API_USERNAME and TCMS_API_PASSWORD or TCMS_API_PASSWORD_HASH.', 503); } if (!tcms_is_authenticated($config)) { - tcms_error('API authentication failed. Use HTTP Basic credentials from .env or send TCMS_API_TOKEN as a Bearer token or X-TCMS-Token header.', 401); + tcms_error('API authentication failed. Use the username and password from the site .env file.', 401); } } @@ -134,12 +133,6 @@ function tcms_is_authenticated($config): bool return false; } - $configuredToken = tcms_configured_token(); - $providedToken = tcms_request_token(); - if ($configuredToken !== '' && $providedToken !== '' && hash_equals($configuredToken, $providedToken)) { - return true; - } - $basic = tcms_request_basic_credentials(); if ($basic === null) { return false; @@ -161,13 +154,8 @@ function tcms_api_enabled($config): bool function tcms_credentials_configured(): bool { - return tcms_configured_token() !== '' - || (tcms_configured_username() !== '' && (tcms_configured_password() !== '' || tcms_configured_password_hash() !== '')); -} - -function tcms_configured_token(): string -{ - return tcms_env('TCMS_API_TOKEN'); + return tcms_configured_username() !== '' + && (tcms_configured_password() !== '' || tcms_configured_password_hash() !== ''); } function tcms_configured_username(): string @@ -195,17 +183,6 @@ function tcms_env(string $key): string return trim((string) $value); } -function tcms_request_token(): string -{ - $headers = function_exists('getallheaders') ? getallheaders() : []; - $auth = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $headers['Authorization'] ?? $headers['authorization'] ?? ''); - if (preg_match('/^Bearer\s+(.+)$/i', $auth, $matches)) { - return trim($matches[1]); - } - - return trim((string) ($_SERVER['HTTP_X_TCMS_TOKEN'] ?? $headers['X-TCMS-Token'] ?? $headers['x-tcms-token'] ?? '')); -} - /** @return array{0: string, 1: string}|null */ function tcms_request_basic_credentials(): ?array { @@ -283,7 +260,15 @@ function tcms_content_save(ContentRepository $repo, array $payload): never $slug = (string) ($payload['slug'] ?? $metadata['slug'] ?? ''); $originalSlug = (string) ($payload['original_slug'] ?? $payload['originalSlug'] ?? ''); $existing = $originalSlug !== '' ? $repo->find($originalSlug, true) : ($slug !== '' ? $repo->find($slug, true) : null); - $markdown = array_key_exists('markdown', $payload) ? (string) $payload['markdown'] : (string) ($existing['markdown'] ?? ''); + if (array_key_exists('markdown', $payload)) { + $markdown = (string) $payload['markdown']; + } elseif (array_key_exists('content', $payload)) { + $markdown = (string) $payload['content']; + } elseif (array_key_exists('body', $payload)) { + $markdown = (string) $payload['body']; + } else { + $markdown = (string) ($existing['markdown'] ?? ''); + } $allowed = [ 'title', 'slug', 'type', 'status', 'date', 'category', 'tags', 'summary', diff --git a/macclient/AppKitClient/main.m b/macclient/AppKitClient/main.m index 8007213..646cc87 100644 --- a/macclient/AppKitClient/main.m +++ b/macclient/AppKitClient/main.m @@ -28,11 +28,31 @@ static NSString *TCMSJoinTags(id value) { 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; +} + +@interface TCMSMarkdownTextView : NSTextView +@end + +@implementation TCMSMarkdownTextView +- (void)insertTab:(id)sender { + [self.window selectNextKeyView:self]; +} +- (void)insertBacktab:(id)sender { + [self.window selectPreviousKeyView:self]; +} +@end + @interface TCMSAPIClient : NSObject @property (nonatomic, copy) NSString *baseURLString; @property (nonatomic, copy) NSString *username; @property (nonatomic, copy) NSString *password; -@property (nonatomic, copy) NSString *token; - (void)requestAction:(NSString *)action query:(NSDictionary *)query method:(NSString *)method body:(NSDictionary *)body completion:(void (^)(NSDictionary *, NSError *))completion; - (void)uploadFile:(NSURL *)fileURL completion:(void (^)(NSDictionary *, NSError *))completion; @end @@ -73,13 +93,9 @@ static NSString *TCMSJoinTags(id value) { request.HTTPMethod = method; [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; - if (self.username.length > 0 && self.password.length > 0) { - NSString *raw = [NSString stringWithFormat:@"%@:%@", self.username, self.password]; - NSString *encoded = [[raw dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]; - [request setValue:[@"Basic " stringByAppendingString:encoded] forHTTPHeaderField:@"Authorization"]; - } else if (self.token.length > 0) { - [request setValue:[@"Bearer " stringByAppendingString:self.token] forHTTPHeaderField:@"Authorization"]; - } + NSString *raw = [NSString stringWithFormat:@"%@:%@", self.username ?: @"", self.password ?: @""]; + NSString *encoded = [[raw dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]; + [request setValue:[@"Basic " stringByAppendingString:encoded] forHTTPHeaderField:@"Authorization"]; return request; } @@ -159,24 +175,26 @@ static NSString *TCMSJoinTags(id value) { @end -@interface TCMSAppDelegate : NSObject +@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) NSSecureTextField *tokenField; @property (nonatomic, strong) NSTextField *autosaveField; @property (nonatomic, strong) NSButton *remoteAutosaveButton; @@ -190,8 +208,15 @@ static NSString *TCMSJoinTags(id value) { @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) 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 @@ -199,20 +224,61 @@ static NSString *TCMSJoinTags(id value) { - (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 [@{ @@ -237,6 +303,9 @@ static NSString *TCMSJoinTags(id value) { backing:NSBackingStoreBuffered defer:NO]; self.window.title = @"TCMS Mac Client"; + self.window.minSize = NSMakeSize(1040, 680); + self.window.delegate = self; + self.window.releasedWhenClosed = NO; [self.window center]; NSView *content = self.window.contentView; @@ -263,6 +332,7 @@ static NSString *TCMSJoinTags(id value) { button.identifier = entry[1]; button.frame = NSMakeRect(18, y, 154, 36); button.bezelStyle = NSBezelStyleTexturedRounded; + button.autoresizingMask = NSViewMinYMargin; [sidebar addSubview:button]; y -= 46; } @@ -270,6 +340,7 @@ static NSString *TCMSJoinTags(id value) { 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)]; @@ -278,6 +349,75 @@ static NSString *TCMSJoinTags(id value) { [self.window makeKeyAndOrderFront:nil]; } +- (void)buildMenus { + NSMenu *mainMenu = [NSMenu new]; + + NSMenuItem *appItem = [NSMenuItem new]; + [mainMenu addItem:appItem]; + NSMenu *appMenu = [[NSMenu alloc] initWithTitle:@"TCMS Mac Client"]; + [appMenu addItemWithTitle:@"About TCMS Mac Client" 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 Mac Client" 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 Mac Client" 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; @@ -286,6 +426,7 @@ static NSString *TCMSJoinTags(id value) { label.selectable = NO; label.bezeled = NO; label.drawsBackground = NO; + label.autoresizingMask = NSViewMinYMargin; return label; } @@ -293,6 +434,7 @@ static NSString *TCMSJoinTags(id value) { NSTextField *field = [[NSTextField alloc] initWithFrame:frame]; field.placeholderString = placeholder; field.delegate = self; + field.autoresizingMask = NSViewMinYMargin; return field; } @@ -313,12 +455,15 @@ static NSString *TCMSJoinTags(id value) { for (NSView *view in self.workspace.subviews.copy) { [view removeFromSuperview]; } + self.editorScroll = nil; + self.markdownView = 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]; @@ -326,48 +471,110 @@ static NSString *TCMSJoinTags(id value) { [self addButton:@"New" frame:NSMakeRect(310, 690, 70, 30) action:@selector(newContent)]; [self addButton:@"Save Draft" frame:NSMakeRect(390, 690, 100, 30) action:@selector(saveDraft)]; [self addButton:@"Publish" frame:NSMakeRect(500, 690, 90, 30) action:@selector(publishContent)]; - [self addButton:@"Delete" frame:NSMakeRect(600, 690, 80, 30) action:@selector(deleteContent)]; + [self addButton:@"Update" frame:NSMakeRect(600, 690, 80, 30) action:@selector(updateContent)]; + [self addButton:@"Delete" frame:NSMakeRect(690, 690, 80, 30) action:@selector(deleteContent)]; - [self buildTableWithFrame:NSMakeRect(20, 20, 280, 650) columns:@[@"title", @"status"]]; + 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); - for (NSView *view in @[self.slugField, self.categoryField, self.tagsField, self.summaryField, self.authorField, self.coverField, self.allowCommentsButton]) { + 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]; } - NSScrollView *editorScroll = [[NSScrollView alloc] initWithFrame:NSMakeRect(left, 20, width - 20, 430)]; - editorScroll.borderType = NSBezelBorder; - editorScroll.hasVerticalScroller = YES; - editorScroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - self.markdownView = [[NSTextView alloc] initWithFrame:editorScroll.bounds]; + self.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; - editorScroll.documentView = self.markdownView; - [self.workspace addSubview:editorScroll]; + 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 { @@ -376,8 +583,10 @@ static NSString *TCMSJoinTags(id value) { [self.workspace addSubview:[self label:@"Media" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]]; [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 90, 30) action:@selector(refreshCurrentSection)]; [self addButton:@"Upload" frame:NSMakeRect(310, 690, 90, 30) action:@selector(uploadMedia)]; - [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"name", @"mime", @"url"]]; + NSScrollView *mediaScroll = [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"name", @"mime", @"url"]]; + mediaScroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; [self refreshMedia]; + [self refreshWindowLayout]; } - (void)showCommentsSection { @@ -385,6 +594,7 @@ static NSString *TCMSJoinTags(id value) { [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)]; @@ -392,8 +602,10 @@ static NSString *TCMSJoinTags(id value) { [self addButton:@"Pending" frame:NSMakeRect(540, 690, 90, 30) action:@selector(pendingComment)]; [self addButton:@"Spam" frame:NSMakeRect(640, 690, 80, 30) action:@selector(spamComment)]; [self addButton:@"Delete" frame:NSMakeRect(730, 690, 80, 30) action:@selector(deleteComment)]; - [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"author", @"status", @"slug", @"body"]]; + 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 { @@ -403,41 +615,48 @@ static NSString *TCMSJoinTags(id value) { [self.workspace addSubview:[self label:@"TCMS URL" frame:NSMakeRect(20, 640, 140, 24) font:[NSFont systemFontOfSize:13]]]; [self.workspace addSubview:[self label:@"Username" frame:NSMakeRect(20, 596, 140, 24) font:[NSFont systemFontOfSize:13]]]; [self.workspace addSubview:[self label:@"Password" frame:NSMakeRect(20, 552, 140, 24) font:[NSFont systemFontOfSize:13]]]; - [self.workspace addSubview:[self label:@"Bearer Token" frame:NSMakeRect(20, 508, 140, 24) font:[NSFont systemFontOfSize:13]]]; - [self.workspace addSubview:[self label:@"Autosave Seconds" frame:NSMakeRect(20, 464, 140, 24) font:[NSFont systemFontOfSize:13]]]; + [self.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.tokenField = [[NSSecureTextField alloc] initWithFrame:NSMakeRect(170, 506, 520, 28)]; - self.autosaveField = [self field:@"30" frame:NSMakeRect(170, 462, 120, 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, 420, 260, 24); - for (NSView *view in @[self.siteField, self.usernameField, self.passwordField, self.tokenField, self.autosaveField, self.remoteAutosaveButton]) { + 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, 370, 120, 32) action:@selector(saveSettings)]; - [self addButton:@"Test Auth" frame:NSMakeRect(300, 370, 100, 32) action:@selector(testConnection)]; + [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; } -- (void)buildTableWithFrame:(NSRect)frame columns:(NSArray *)columns { +- (NSScrollView *)buildTableWithFrame:(NSRect)frame columns:(NSArray *)columns { NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:frame]; scroll.borderType = NSBezelBorder; scroll.hasVerticalScroller = YES; - scroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.tableView = [[NSTableView alloc] initWithFrame:scroll.bounds]; self.tableView.delegate = self; self.tableView.dataSource = self; + self.tableView.columnAutoresizingStyle = NSTableViewUniformColumnAutoresizingStyle; for (NSString *identifier in columns) { NSTableColumn *column = [[NSTableColumn alloc] initWithIdentifier:identifier]; column.title = [identifier capitalizedString]; @@ -446,6 +665,76 @@ static NSString *TCMSJoinTags(id value) { } 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; + [self refreshWindowLayout]; +} + +- (NSString *)contentListStatusMessageForType:(NSString *)type { + NSInteger total = self.allTableItems.count; + NSString *noun = [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]; +} + +- (void)previousContentPage { + if (self.contentListPage <= 1) { + return; + } + self.contentListPage -= 1; + [self applyContentListPagination]; + [self setStatus:[self contentListStatusMessageForType:[self.section isEqualToString:@"pages"] ? @"page" : @"post"]]; +} + +- (void)nextContentPage { + NSInteger totalPages = [self contentListTotalPages]; + if (self.contentListPage >= totalPages) { + return; + } + self.contentListPage += 1; + [self applyContentListPagination]; + [self setStatus:[self contentListStatusMessageForType:[self.section isEqualToString:@"pages"] ? @"page" : @"post"]]; +} + +- (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 contentListStatusMessageForType:[self.section isEqualToString:@"pages"] ? @"page" : @"post"]]; } - (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { @@ -484,7 +773,6 @@ static NSString *TCMSJoinTags(id value) { self.client.baseURLString = [defaults stringForKey:@"baseURL"] ?: @"http://127.0.0.1:8097"; self.client.username = [defaults stringForKey:@"username"] ?: @"admin"; self.client.password = [defaults stringForKey:@"password"] ?: @""; - self.client.token = [defaults stringForKey:@"token"] ?: @""; } - (void)populateSettingsFields { @@ -492,7 +780,6 @@ static NSString *TCMSJoinTags(id value) { self.siteField.stringValue = self.client.baseURLString ?: @""; self.usernameField.stringValue = self.client.username ?: @""; self.passwordField.stringValue = self.client.password ?: @""; - self.tokenField.stringValue = self.client.token ?: @""; NSInteger interval = [defaults integerForKey:@"autosaveInterval"]; self.autosaveField.stringValue = [NSString stringWithFormat:@"%ld", (long)(interval > 0 ? interval : 30)]; self.remoteAutosaveButton.state = [defaults boolForKey:@"remoteAutosave"] ? NSControlStateValueOn : NSControlStateValueOff; @@ -502,19 +789,124 @@ static NSString *TCMSJoinTags(id value) { self.client.baseURLString = self.siteField.stringValue; self.client.username = self.usernameField.stringValue; self.client.password = self.passwordField.stringValue; - self.client.token = self.tokenField.stringValue; NSInteger interval = MAX(10, self.autosaveField.integerValue); NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; [defaults setObject:self.client.baseURLString forKey:@"baseURL"]; [defaults setObject:self.client.username forKey:@"username"]; [defaults setObject:self.client.password forKey:@"password"]; - [defaults setObject:self.client.token forKey:@"token"]; [defaults setInteger:interval forKey:@"autosaveInterval"]; [defaults setBool:self.remoteAutosaveButton.state == NSControlStateValueOn forKey:@"remoteAutosave"]; [self startAutosaveTimer]; [self setStatus:@"Settings saved."]; } +- (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; + } + 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) { @@ -540,9 +932,10 @@ static NSString *TCMSJoinTags(id value) { [self setStatus:error.localizedDescription]; return; } - self.tableItems = [TCMSString(type) isEqualToString:@"page"] ? [NSMutableArray arrayWithArray:json[@"items"] ?: @[]] : [NSMutableArray arrayWithArray:json[@"items"] ?: @[]]; - [self.tableView reloadData]; - [self setStatus:@"Content refreshed."]; + NSArray *items = [json[@"items"] isKindOfClass:[NSArray class]] ? json[@"items"] : @[]; + self.allTableItems = [NSMutableArray arrayWithArray:items]; + [self applyContentListPagination]; + [self setStatus:[self contentListStatusMessageForType:type]]; }]; } @@ -603,19 +996,46 @@ static NSString *TCMSJoinTags(id value) { } - (void)saveDraft { - [self saveContentWithStatus:@"draft"]; + [self saveContentWithStatus:@"draft" message:@"Draft saved."]; } - (void)publishContent { - [self saveContentWithStatus:@"published"]; + [self saveContentWithStatus:@"published" message:@"Content published."]; } - (void)saveContentWithStatus:(NSString *)status { + [self saveContentWithStatus:status message:@"Content saved."]; +} + +- (void)updateContent { + [self saveContentWithStatus:nil message:@"Content 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 { + 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": self.markdownView.string ?: @"", + @"markdown": submittedMarkdown, + @"content": submittedMarkdown, + @"body": submittedMarkdown, @"metadata": item }; [self.client requestAction:@"content.save" query:@{} method:@"POST" body:body completion:^(NSDictionary *json, NSError *error) { @@ -623,13 +1043,17 @@ static NSString *TCMSJoinTags(id value) { [self setStatus:error.localizedDescription]; return; } - self.editorItem = [json[@"item"] mutableCopy]; - self.editorMarkdown = TCMSString(self.editorItem[@"markdown"]); + 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 = NO; + self.dirty = !serverEchoedSubmittedMarkdown; [self loadEditorFields]; [self refreshContentType:TCMSString(self.editorItem[@"type"]).length ? TCMSString(self.editorItem[@"type"]) : @"post"]; - [self setStatus:@"Content saved."]; + [self setStatus:serverEchoedSubmittedMarkdown ? (message ?: @"Content saved.") : @"Server response did not include the updated Markdown. Editor changes preserved."]; }]; } diff --git a/macclient/README.md b/macclient/README.md index 6676031..befe167 100644 --- a/macclient/README.md +++ b/macclient/README.md @@ -21,11 +21,14 @@ https://example.com/blog http://127.0.0.1:8097 ``` -The client automatically calls `api.php` under that URL. Enter the username/password stored in the site's `.env`, or use the bearer token from `TCMS_API_TOKEN`. +The client automatically calls `api.php` under that URL. Enter the username/password stored in the site's `.env`. ## Features -- Create, edit, draft, publish, and delete posts and pages. +- Create, edit, update, draft, publish, and delete posts and pages. +- Page through large post/page lists with configurable per-page counts. +- Toggle spell check and auto-correct in the Markdown editor. +- Use standard Mac shortcuts, including Command-S for Update, Command-Comma for Settings, and Command-Q for Quit. - Upload media to `bl-content/uploads`. - List and moderate comments. - Local autosave to Application Support.