- Update now reads the live editor text from NSTextStorage instead of relying on any stale editor state, sends it as markdown plus compatibility aliases, and refuses to overwrite the editor with stale Markdown if the server response doesn’t echo the new content.

I also updated [api.php](/Users/tyemeclifford/Documents/GH/blog/api.php) 
so content.save accepts markdown, content, or body for post/page 
content.
This commit is contained in:
Ty Clifford
2026-07-05 17:15:33 -04:00
parent ddaefbcc27
commit c391feb450
6 changed files with 497 additions and 80 deletions
-1
View File
@@ -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
+8 -1
View File
@@ -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=<id>]` and `[video poster=<poster> file=<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
+1 -2
View File
@@ -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
+13 -28
View File
@@ -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',
+468 -44
View File
@@ -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 *raw = [NSString stringWithFormat:@"%@:%@", self.username ?: @"", self.password ?: @""];
NSString *encoded = [[raw dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0];
[request setValue:[@"Basic " stringByAppendingString:encoded] forHTTPHeaderField:@"Authorization"];
} else if (self.token.length > 0) {
[request setValue:[@"Bearer " stringByAppendingString:self.token] forHTTPHeaderField:@"Authorization"];
}
return request;
}
@@ -159,24 +175,26 @@ static NSString *TCMSJoinTags(id value) {
@end
@interface TCMSAppDelegate : NSObject <NSApplicationDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSTextViewDelegate>
@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) 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<NSString *> *)columns {
- (NSScrollView *)buildTableWithFrame:(NSRect)frame columns:(NSArray<NSString *> *)columns {
NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:frame];
scroll.borderType = NSBezelBorder;
scroll.hasVerticalScroller = YES;
scroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
self.tableView = [[NSTableView alloc] initWithFrame:scroll.bounds];
self.tableView.delegate = self;
self.tableView.dataSource = self;
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."];
}];
}
+5 -2
View File
@@ -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.