diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..353c3e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +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/.gitignore b/.gitignore index 3322429..0411abb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /storage/stats/*.csv /storage/cache/* !.gitkeep +.env .DS_Store diff --git a/.htaccess b/.htaccess index fb82cc7..cccbb8d 100644 --- a/.htaccess +++ b/.htaccess @@ -17,3 +17,7 @@ Options -Indexes Require all denied + + + Require all denied + diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e5ac3..e5f323f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,14 @@ All notable changes to Ty Clifford's Content Management System are documented he ### Added -- Added `api.php` for authenticated remote management of content, media uploads, and comments. -- Added a native SwiftUI macOS 12+ client under `macclient/` for editing posts/pages, uploading media, moderating comments, local autosave, and optional remote draft autosave. +- Added `api.php` for `.env`-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 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 -- Blocked direct web access to the `macclient/` source folder through `.htaccess`. +- 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 0d0f2d3..23d0538 100644 --- a/README.md +++ b/README.md @@ -44,28 +44,27 @@ php cli/blog.php rebuild ## Mac Client and API -The native macOS 12+ client lives in `macclient/`. +The native AppKit macOS 12+ client lives in `macclient/`. ```bash cd macclient -swift run TCMSMacClient +./build-app.sh +open TCMSMacClient.app ``` -Point the client at any TCMS site URL. It will call `api.php` at that site for posts, pages, media, comments, local autosave restore, and optional remote draft autosave. +Point the client at any TCMS site URL. It will call `api.php` at that site for posts, pages, media, comments, local autosave, and optional remote draft autosave. -For remote servers, set an API token in `config/site.json` or through the `TCMS_API_TOKEN` environment variable: +API credentials are stored in the site root `.env` file: -```json -{ - "api": { - "enabled": true, - "token": "replace-with-a-long-random-token", - "allow_local_without_token": true - } -} +```text +TCMS_API_ENABLED=true +TCMS_API_USERNAME=admin +TCMS_API_PASSWORD=change-this-password +TCMS_API_PASSWORD_HASH= +TCMS_API_TOKEN=change-this-token ``` -Send the token as a Bearer token or `X-TCMS-Token`. Localhost requests may use the tokenless development fallback while `allow_local_without_token` is enabled. +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. ## Comments @@ -107,7 +106,7 @@ php cli/blog.php stats:summary ## Apache -The included `.htaccess` enables pretty URLs and blocks direct access to app code, configuration, SQLite, CSV, JSON databases, and Markdown content files. +The included `.htaccess` enables pretty URLs and blocks direct access to app code, configuration, `.env`, the Mac client source, SQLite, CSV, JSON databases, and Markdown content files. For a subdirectory install such as `https://example.com/blog`, either place the app in that folder and let the CMS auto-detect the path, or set: diff --git a/api.php b/api.php index d8651fc..c8f29b2 100644 --- a/api.php +++ b/api.php @@ -9,6 +9,8 @@ $repo = $app->repository(); $comments = $app->comments(); $config = $app->config(); +tcms_load_env(BLOG_ROOT . '/.env'); + header('Content-Type: application/json; charset=utf-8'); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { @@ -25,7 +27,9 @@ try { 'name' => 'Ty Clifford Content Management System API', 'site' => $config->get('site', []), 'authenticated' => tcms_is_authenticated($config), - 'token_configured' => tcms_configured_token($config) !== '', + 'auth_required' => true, + 'credentials_configured' => tcms_credentials_configured(), + 'token_configured' => tcms_configured_token() !== '', 'capabilities' => [ 'content' => ['list', 'get', 'save', 'delete'], 'media' => ['list', 'upload'], @@ -34,7 +38,7 @@ try { ]); } - if (!$config->get('api.enabled', true)) { + if (!tcms_api_enabled($config)) { tcms_error('The API is disabled for this TCMS site.', 403); } tcms_require_auth($config); @@ -67,6 +71,39 @@ function tcms_payload(): array return $_POST; } +function tcms_load_env(string $path): void +{ + if (!is_file($path)) { + return; + } + + foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + $value = trim($value); + if ($key === '' || !preg_match('/^[A-Z0-9_]+$/', $key)) { + continue; + } + if ( + (str_starts_with($value, '"') && str_ends_with($value, '"')) || + (str_starts_with($value, "'") && str_ends_with($value, "'")) + ) { + $value = substr($value, 1, -1); + } + + if (getenv($key) === false) { + putenv($key . '=' . $value); + } + $_ENV[$key] = $value; + $_SERVER[$key] ??= $value; + } +} + /** @param array $data */ function tcms_json(array $data, int $status = 200): never { @@ -83,26 +120,79 @@ 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); + } if (!tcms_is_authenticated($config)) { - tcms_error('API authentication failed. Send the configured token as a Bearer token or X-TCMS-Token header.', 401); + 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); } } function tcms_is_authenticated($config): bool { - $configured = tcms_configured_token($config); - if ($configured === '') { - return (bool) $config->get('api.allow_local_without_token', true) && tcms_is_local_request(); + if (!tcms_credentials_configured()) { + return false; } - $provided = tcms_request_token(); - return $provided !== '' && hash_equals($configured, $provided); + $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; + } + + [$username, $password] = $basic; + return tcms_username_matches($username) && tcms_password_matches($password); } -function tcms_configured_token($config): string +function tcms_api_enabled($config): bool { - $envToken = trim((string) getenv('TCMS_API_TOKEN')); - return $envToken !== '' ? $envToken : trim((string) $config->get('api.token', '')); + $envValue = tcms_env('TCMS_API_ENABLED'); + if ($envValue !== '') { + return filter_var($envValue, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true; + } + + return (bool) $config->get('api.enabled', true); +} + +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'); +} + +function tcms_configured_username(): string +{ + return tcms_env('TCMS_API_USERNAME'); +} + +function tcms_configured_password(): string +{ + return tcms_env('TCMS_API_PASSWORD'); +} + +function tcms_configured_password_hash(): string +{ + return tcms_env('TCMS_API_PASSWORD_HASH'); +} + +function tcms_env(string $key): string +{ + $value = getenv($key); + if ($value === false) { + $value = $_ENV[$key] ?? $_SERVER[$key] ?? ''; + } + + return trim((string) $value); } function tcms_request_token(): string @@ -116,10 +206,43 @@ function tcms_request_token(): string return trim((string) ($_SERVER['HTTP_X_TCMS_TOKEN'] ?? $headers['X-TCMS-Token'] ?? $headers['x-tcms-token'] ?? '')); } -function tcms_is_local_request(): bool +/** @return array{0: string, 1: string}|null */ +function tcms_request_basic_credentials(): ?array { - $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); - return in_array($remote, ['127.0.0.1', '::1', 'localhost', ''], true); + if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { + return [(string) $_SERVER['PHP_AUTH_USER'], (string) $_SERVER['PHP_AUTH_PW']]; + } + + $headers = function_exists('getallheaders') ? getallheaders() : []; + $auth = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $headers['Authorization'] ?? $headers['authorization'] ?? ''); + if (!preg_match('/^Basic\s+(.+)$/i', $auth, $matches)) { + return null; + } + + $decoded = base64_decode(trim($matches[1]), true); + if (!is_string($decoded) || !str_contains($decoded, ':')) { + return null; + } + + [$username, $password] = explode(':', $decoded, 2); + return [$username, $password]; +} + +function tcms_username_matches(string $username): bool +{ + $configured = tcms_configured_username(); + return $configured !== '' && hash_equals($configured, $username); +} + +function tcms_password_matches(string $password): bool +{ + $hash = tcms_configured_password_hash(); + if ($hash !== '') { + return password_verify($password, $hash); + } + + $configured = tcms_configured_password(); + return $configured !== '' && hash_equals($configured, $password); } function tcms_content_list(ContentRepository $repo): never diff --git a/app/Config.php b/app/Config.php index b3666f7..4f96789 100644 --- a/app/Config.php +++ b/app/Config.php @@ -158,8 +158,6 @@ final class Config ], 'api' => [ 'enabled' => true, - 'token' => '', - 'allow_local_without_token' => true, ], 'social' => [], ]; diff --git a/macclient/.gitignore b/macclient/.gitignore index 8b1a46e..25ba8e2 100644 --- a/macclient/.gitignore +++ b/macclient/.gitignore @@ -1,4 +1,5 @@ -.build/ +build/ +TCMSMacClient.app/ DerivedData/ *.xcuserdata/ *.xcuserstate diff --git a/macclient/AppKitClient/Info.plist b/macclient/AppKitClient/Info.plist new file mode 100644 index 0000000..4f78c37 --- /dev/null +++ b/macclient/AppKitClient/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + TCMSMacClient + CFBundleIdentifier + com.tyclifford.tcms.macclient + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + TCMS Mac Client + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + + diff --git a/macclient/AppKitClient/main.m b/macclient/AppKitClient/main.m new file mode 100644 index 0000000..8007213 --- /dev/null +++ b/macclient/AppKitClient/main.m @@ -0,0 +1,802 @@ +#import + +static NSString *TCMSString(id value) { + if ([value isKindOfClass:[NSString class]]) { + return value; + } + if ([value respondsToSelector:@selector(stringValue)]) { + return [value stringValue]; + } + return @""; +} + +static NSArray *TCMSTagsFromString(NSString *value) { + NSMutableArray *tags = [NSMutableArray array]; + for (NSString *part in [value componentsSeparatedByString:@","]) { + NSString *tag = [part stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (tag.length > 0) { + [tags addObject:tag]; + } + } + return tags; +} + +static NSString *TCMSJoinTags(id value) { + if ([value isKindOfClass:[NSArray class]]) { + return [(NSArray *)value componentsJoinedByString:@", "]; + } + return TCMSString(value); +} + +@interface TCMSAPIClient : NSObject +@property (nonatomic, copy) NSString *baseURLString; +@property (nonatomic, copy) NSString *username; +@property (nonatomic, copy) NSString *password; +@property (nonatomic, copy) NSString *token; +- (void)requestAction:(NSString *)action query:(NSDictionary *)query method:(NSString *)method body:(NSDictionary *)body completion:(void (^)(NSDictionary *, NSError *))completion; +- (void)uploadFile:(NSURL *)fileURL completion:(void (^)(NSDictionary *, NSError *))completion; +@end + +@implementation TCMSAPIClient + +- (NSURL *)endpointURL { + NSURL *base = [NSURL URLWithString:self.baseURLString ?: @""]; + if (!base) { + return nil; + } + if ([[base.lastPathComponent lowercaseString] isEqualToString:@"api.php"]) { + return base; + } + return [base URLByAppendingPathComponent:@"api.php"]; +} + +- (NSURL *)URLForAction:(NSString *)action query:(NSDictionary *)query { + NSURL *endpoint = [self endpointURL]; + if (!endpoint) { + return nil; + } + NSURLComponents *components = [NSURLComponents componentsWithURL:endpoint resolvingAgainstBaseURL:NO]; + NSMutableArray *items = [NSMutableArray arrayWithObject:[NSURLQueryItem queryItemWithName:@"action" value:action]]; + for (NSString *key in query) { + [items addObject:[NSURLQueryItem queryItemWithName:key value:TCMSString(query[key])]]; + } + components.queryItems = items; + return components.URL; +} + +- (NSMutableURLRequest *)requestForAction:(NSString *)action query:(NSDictionary *)query method:(NSString *)method { + NSURL *url = [self URLForAction:action query:query ?: @{}]; + if (!url) { + return nil; + } + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:30]; + request.HTTPMethod = method; + [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + + if (self.username.length > 0 && self.password.length > 0) { + NSString *raw = [NSString stringWithFormat:@"%@:%@", self.username, self.password]; + NSString *encoded = [[raw dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0]; + [request setValue:[@"Basic " stringByAppendingString:encoded] forHTTPHeaderField:@"Authorization"]; + } else if (self.token.length > 0) { + [request setValue:[@"Bearer " stringByAppendingString:self.token] forHTTPHeaderField:@"Authorization"]; + } + + return request; +} + +- (void)requestAction:(NSString *)action query:(NSDictionary *)query method:(NSString *)method body:(NSDictionary *)body completion:(void (^)(NSDictionary *, NSError *))completion { + NSMutableURLRequest *request = [self requestForAction:action query:query method:method ?: @"GET"]; + if (!request) { + NSError *error = [NSError errorWithDomain:@"TCMSAPI" code:1 userInfo:@{NSLocalizedDescriptionKey: @"Invalid TCMS URL."}]; + completion(nil, error); + return; + } + + if (body) { + request.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil]; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + } + + [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + [self finishWithData:data response:response error:error completion:completion]; + }] resume]; +} + +- (void)uploadFile:(NSURL *)fileURL completion:(void (^)(NSDictionary *, NSError *))completion { + NSMutableURLRequest *request = [self requestForAction:@"media.upload" query:@{} method:@"POST"]; + if (!request) { + NSError *error = [NSError errorWithDomain:@"TCMSAPI" code:1 userInfo:@{NSLocalizedDescriptionKey: @"Invalid TCMS URL."}]; + completion(nil, error); + return; + } + + NSData *fileData = [NSData dataWithContentsOfURL:fileURL]; + if (!fileData) { + NSError *error = [NSError errorWithDomain:@"TCMSAPI" code:2 userInfo:@{NSLocalizedDescriptionKey: @"Unable to read selected file."}]; + completion(nil, error); + return; + } + + NSString *boundary = [@"TCMSBoundary-" stringByAppendingString:NSUUID.UUID.UUIDString]; + NSMutableData *body = [NSMutableData data]; + NSString *header = [NSString stringWithFormat:@"--%@\r\nContent-Disposition: form-data; name=\"file\"; filename=\"%@\"\r\nContent-Type: application/octet-stream\r\n\r\n", boundary, fileURL.lastPathComponent]; + [body appendData:[header dataUsingEncoding:NSUTF8StringEncoding]]; + [body appendData:fileData]; + [body appendData:[[NSString stringWithFormat:@"\r\n--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]]; + + [request setValue:[@"multipart/form-data; boundary=" stringByAppendingString:boundary] forHTTPHeaderField:@"Content-Type"]; + [[[NSURLSession sharedSession] uploadTaskWithRequest:request fromData:body completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + [self finishWithData:data response:response error:error completion:completion]; + }] resume]; +} + +- (void)finishWithData:(NSData *)data response:(NSURLResponse *)response error:(NSError *)error completion:(void (^)(NSDictionary *, NSError *))completion { + if (error) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, error); + }); + return; + } + + NSDictionary *json = data.length > 0 ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : @{}; + NSInteger status = [(NSHTTPURLResponse *)response statusCode]; + if (status < 200 || status >= 300) { + NSString *message = TCMSString(json[@"error"]); + if (message.length == 0) { + message = [NSString stringWithFormat:@"HTTP %ld", (long)status]; + } + NSError *apiError = [NSError errorWithDomain:@"TCMSAPI" code:status userInfo:@{NSLocalizedDescriptionKey: message}]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(json, apiError); + }); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(json ?: @{}, nil); + }); +} + +@end + +@interface TCMSAppDelegate : NSObject +@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) NSMutableDictionary *editorItem; +@property (nonatomic, copy) NSString *editorMarkdown; +@property (nonatomic, copy) NSString *originalSlug; +@property (nonatomic, copy) NSString *section; +@property (nonatomic, assign) BOOL dirty; +@property (nonatomic, strong) NSTimer *autosaveTimer; +@property (nonatomic, strong) TCMSAPIClient *client; + +@property (nonatomic, strong) NSTextField *siteField; +@property (nonatomic, strong) NSTextField *usernameField; +@property (nonatomic, strong) NSSecureTextField *passwordField; +@property (nonatomic, strong) NSSecureTextField *tokenField; +@property (nonatomic, strong) NSTextField *autosaveField; +@property (nonatomic, strong) NSButton *remoteAutosaveButton; + +@property (nonatomic, strong) NSTextField *titleField; +@property (nonatomic, strong) NSTextField *slugField; +@property (nonatomic, strong) NSPopUpButton *typePopup; +@property (nonatomic, strong) NSPopUpButton *statusPopup; +@property (nonatomic, strong) NSTextField *categoryField; +@property (nonatomic, strong) NSTextField *tagsField; +@property (nonatomic, strong) NSTextField *summaryField; +@property (nonatomic, strong) NSTextField *authorField; +@property (nonatomic, strong) NSTextField *coverField; +@property (nonatomic, strong) NSButton *allowCommentsButton; +@property (nonatomic, strong) NSTextView *markdownView; +@property (nonatomic, strong) NSPopUpButton *commentStatusPopup; +@end + +@implementation TCMSAppDelegate + +- (void)applicationDidFinishLaunching:(NSNotification *)notification { + self.section = @"posts"; + self.tableItems = [NSMutableArray array]; + self.editorItem = [self blankItemOfType:@"post"]; + self.editorMarkdown = @"# Untitled\n\nStart writing here."; + self.originalSlug = @""; + self.client = [TCMSAPIClient new]; + [self loadSettings]; + [self buildWindow]; + [self showContentSection:@"posts"]; + [self startAutosaveTimer]; +} + +- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { + return YES; +} + +- (NSMutableDictionary *)blankItemOfType:(NSString *)type { + NSString *title = [type isEqualToString:@"page"] ? @"New Page" : @"New Post"; + return [@{ + @"title": title, + @"slug": @"", + @"type": type, + @"status": @"draft", + @"date": [[NSDate date] description], + @"category": @"Notes", + @"tags": @[], + @"summary": @"", + @"author": @"Editor", + @"cover": @"", + @"allow_comments": @YES, + @"menu_order": @0 + } mutableCopy]; +} + +- (void)buildWindow { + self.window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 1180, 740) + styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable + backing:NSBackingStoreBuffered + defer:NO]; + self.window.title = @"TCMS Mac Client"; + [self.window center]; + + NSView *content = self.window.contentView; + NSView *sidebar = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 190, 740)]; + sidebar.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin; + sidebar.wantsLayer = YES; + sidebar.layer.backgroundColor = [NSColor colorWithCalibratedWhite:0.08 alpha:1].CGColor; + [content addSubview:sidebar]; + + NSTextField *brand = [self label:@"TCMS" frame:NSMakeRect(18, 680, 154, 34) font:[NSFont boldSystemFontOfSize:28]]; + brand.textColor = NSColor.whiteColor; + [sidebar addSubview:brand]; + + NSArray *buttons = @[ + @[@"Posts", @"posts"], + @[@"Pages", @"pages"], + @[@"Media", @"media"], + @[@"Comments", @"comments"], + @[@"Settings", @"settings"] + ]; + CGFloat y = 620; + for (NSArray *entry in buttons) { + NSButton *button = [NSButton buttonWithTitle:entry[0] target:self action:@selector(sidebarClicked:)]; + button.identifier = entry[1]; + button.frame = NSMakeRect(18, y, 154, 36); + button.bezelStyle = NSBezelStyleTexturedRounded; + [sidebar addSubview:button]; + y -= 46; + } + + self.statusLabel = [self label:@"Ready" frame:NSMakeRect(14, 14, 160, 80) font:[NSFont systemFontOfSize:12]]; + self.statusLabel.textColor = [NSColor colorWithCalibratedWhite:0.78 alpha:1]; + self.statusLabel.lineBreakMode = NSLineBreakByWordWrapping; + [sidebar addSubview:self.statusLabel]; + + self.workspace = [[NSView alloc] initWithFrame:NSMakeRect(190, 0, 990, 740)]; + self.workspace.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + [content addSubview:self.workspace]; + [self.window makeKeyAndOrderFront:nil]; +} + +- (NSTextField *)label:(NSString *)text frame:(NSRect)frame font:(NSFont *)font { + NSTextField *label = [[NSTextField alloc] initWithFrame:frame]; + label.stringValue = text; + label.font = font; + label.editable = NO; + label.selectable = NO; + label.bezeled = NO; + label.drawsBackground = NO; + return label; +} + +- (NSTextField *)field:(NSString *)placeholder frame:(NSRect)frame { + NSTextField *field = [[NSTextField alloc] initWithFrame:frame]; + field.placeholderString = placeholder; + field.delegate = self; + return field; +} + +- (void)sidebarClicked:(NSButton *)sender { + NSString *target = sender.identifier; + if ([target isEqualToString:@"posts"] || [target isEqualToString:@"pages"]) { + [self showContentSection:target]; + } else if ([target isEqualToString:@"media"]) { + [self showMediaSection]; + } else if ([target isEqualToString:@"comments"]) { + [self showCommentsSection]; + } else { + [self showSettingsSection]; + } +} + +- (void)clearWorkspace { + for (NSView *view in self.workspace.subviews.copy) { + [view removeFromSuperview]; + } +} + +- (void)showContentSection:(NSString *)section { + self.section = section; + [self clearWorkspace]; + NSString *type = [section isEqualToString:@"pages"] ? @"page" : @"post"; + + NSTextField *heading = [self label:[section capitalizedString] frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]; + [self.workspace addSubview:heading]; + [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 90, 30) action:@selector(refreshCurrentSection)]; + [self addButton:@"New" frame:NSMakeRect(310, 690, 70, 30) action:@selector(newContent)]; + [self addButton:@"Save Draft" frame:NSMakeRect(390, 690, 100, 30) action:@selector(saveDraft)]; + [self addButton:@"Publish" frame:NSMakeRect(500, 690, 90, 30) action:@selector(publishContent)]; + [self addButton:@"Delete" frame:NSMakeRect(600, 690, 80, 30) action:@selector(deleteContent)]; + + [self buildTableWithFrame:NSMakeRect(20, 20, 280, 650) columns:@[@"title", @"status"]]; + + CGFloat left = 320; + CGFloat width = self.workspace.bounds.size.width - left - 20; + self.titleField = [self field:@"Title" frame:NSMakeRect(left, 646, width - 230, 28)]; + self.statusPopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(left + width - 220, 646, 100, 28)]; + [self.statusPopup addItemsWithTitles:@[@"draft", @"published"]]; + self.typePopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(left + width - 110, 646, 90, 28)]; + [self.typePopup addItemsWithTitles:@[@"post", @"page"]]; + [self.workspace addSubview:self.titleField]; + [self.workspace addSubview:self.statusPopup]; + [self.workspace addSubview:self.typePopup]; + + self.slugField = [self field:@"Slug" frame:NSMakeRect(left, 608, (width / 2) - 6, 28)]; + self.categoryField = [self field:@"Category" frame:NSMakeRect(left + (width / 2) + 6, 608, (width / 2) - 26, 28)]; + self.tagsField = [self field:@"Tags" frame:NSMakeRect(left, 570, width - 20, 28)]; + self.summaryField = [self field:@"Summary" frame:NSMakeRect(left, 532, width - 20, 28)]; + self.authorField = [self field:@"Author" frame:NSMakeRect(left, 494, (width / 2) - 6, 28)]; + self.coverField = [self field:@"Cover" frame:NSMakeRect(left + (width / 2) + 6, 494, (width / 2) - 26, 28)]; + self.allowCommentsButton = [NSButton checkboxWithTitle:@"Allow comments" target:self action:@selector(markDirty)]; + self.allowCommentsButton.frame = NSMakeRect(left, 460, 180, 24); + for (NSView *view in @[self.slugField, self.categoryField, self.tagsField, self.summaryField, self.authorField, self.coverField, self.allowCommentsButton]) { + [self.workspace addSubview:view]; + } + + NSScrollView *editorScroll = [[NSScrollView alloc] initWithFrame:NSMakeRect(left, 20, width - 20, 430)]; + editorScroll.borderType = NSBezelBorder; + editorScroll.hasVerticalScroller = YES; + editorScroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.markdownView = [[NSTextView alloc] initWithFrame:editorScroll.bounds]; + self.markdownView.font = [NSFont fontWithName:@"Menlo" size:13] ?: [NSFont monospacedSystemFontOfSize:13 weight:NSFontWeightRegular]; + self.markdownView.delegate = self; + editorScroll.documentView = self.markdownView; + [self.workspace addSubview:editorScroll]; + + self.editorItem = [self blankItemOfType:type]; + self.editorMarkdown = [NSString stringWithFormat:@"# %@\n\nStart writing here.", TCMSString(self.editorItem[@"title"])]; + self.originalSlug = @""; + [self loadEditorFields]; + [self refreshContentType:type]; +} + +- (void)showMediaSection { + self.section = @"media"; + [self clearWorkspace]; + [self.workspace addSubview:[self label:@"Media" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]]; + [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 90, 30) action:@selector(refreshCurrentSection)]; + [self addButton:@"Upload" frame:NSMakeRect(310, 690, 90, 30) action:@selector(uploadMedia)]; + [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"name", @"mime", @"url"]]; + [self refreshMedia]; +} + +- (void)showCommentsSection { + self.section = @"comments"; + [self clearWorkspace]; + [self.workspace addSubview:[self label:@"Comments" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]]; + self.commentStatusPopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(210, 690, 120, 30)]; + [self.commentStatusPopup addItemsWithTitles:@[@"pending", @"approved", @"spam", @"all"]]; + [self.workspace addSubview:self.commentStatusPopup]; + [self addButton:@"Refresh" frame:NSMakeRect(340, 690, 90, 30) action:@selector(refreshCurrentSection)]; + [self addButton:@"Approve" frame:NSMakeRect(440, 690, 90, 30) action:@selector(approveComment)]; + [self addButton:@"Pending" frame:NSMakeRect(540, 690, 90, 30) action:@selector(pendingComment)]; + [self addButton:@"Spam" frame:NSMakeRect(640, 690, 80, 30) action:@selector(spamComment)]; + [self addButton:@"Delete" frame:NSMakeRect(730, 690, 80, 30) action:@selector(deleteComment)]; + [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"author", @"status", @"slug", @"body"]]; + [self refreshComments]; +} + +- (void)showSettingsSection { + self.section = @"settings"; + [self clearWorkspace]; + [self.workspace addSubview:[self label:@"Settings" frame:NSMakeRect(20, 690, 220, 28) font:[NSFont boldSystemFontOfSize:24]]]; + [self.workspace addSubview:[self label:@"TCMS URL" frame:NSMakeRect(20, 640, 140, 24) font:[NSFont systemFontOfSize:13]]]; + [self.workspace addSubview:[self label:@"Username" frame:NSMakeRect(20, 596, 140, 24) font:[NSFont systemFontOfSize:13]]]; + [self.workspace addSubview:[self label:@"Password" frame:NSMakeRect(20, 552, 140, 24) font:[NSFont systemFontOfSize:13]]]; + [self.workspace addSubview:[self label:@"Bearer Token" frame:NSMakeRect(20, 508, 140, 24) font:[NSFont systemFontOfSize:13]]]; + [self.workspace addSubview:[self label:@"Autosave Seconds" frame:NSMakeRect(20, 464, 140, 24) font:[NSFont systemFontOfSize:13]]]; + + self.siteField = [self field:@"https://example.com/blog" frame:NSMakeRect(170, 638, 520, 28)]; + self.usernameField = [self field:@"admin" frame:NSMakeRect(170, 594, 260, 28)]; + self.passwordField = [[NSSecureTextField alloc] initWithFrame:NSMakeRect(170, 550, 260, 28)]; + self.tokenField = [[NSSecureTextField alloc] initWithFrame:NSMakeRect(170, 506, 520, 28)]; + self.autosaveField = [self field:@"30" frame:NSMakeRect(170, 462, 120, 28)]; + self.remoteAutosaveButton = [NSButton checkboxWithTitle:@"Also save remotely as draft" target:nil action:nil]; + self.remoteAutosaveButton.frame = NSMakeRect(170, 420, 260, 24); + for (NSView *view in @[self.siteField, self.usernameField, self.passwordField, self.tokenField, self.autosaveField, self.remoteAutosaveButton]) { + [self.workspace addSubview:view]; + } + + [self addButton:@"Save Settings" frame:NSMakeRect(170, 370, 120, 32) action:@selector(saveSettings)]; + [self addButton:@"Test Auth" frame:NSMakeRect(300, 370, 100, 32) action:@selector(testConnection)]; + [self populateSettingsFields]; +} + +- (NSButton *)addButton:(NSString *)title frame:(NSRect)frame action:(SEL)action { + NSButton *button = [NSButton buttonWithTitle:title target:self action:action]; + button.frame = frame; + button.bezelStyle = NSBezelStyleRounded; + [self.workspace addSubview:button]; + return button; +} + +- (void)buildTableWithFrame:(NSRect)frame columns:(NSArray *)columns { + NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:frame]; + scroll.borderType = NSBezelBorder; + scroll.hasVerticalScroller = YES; + scroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.tableView = [[NSTableView alloc] initWithFrame:scroll.bounds]; + self.tableView.delegate = self; + self.tableView.dataSource = self; + for (NSString *identifier in columns) { + NSTableColumn *column = [[NSTableColumn alloc] initWithIdentifier:identifier]; + column.title = [identifier capitalizedString]; + column.width = frame.size.width / MAX(columns.count, 1); + [self.tableView addTableColumn:column]; + } + scroll.documentView = self.tableView; + [self.workspace addSubview:scroll]; +} + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { + return self.tableItems.count; +} + +- (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { + NSTextField *field = [tableView makeViewWithIdentifier:tableColumn.identifier owner:self]; + if (!field) { + field = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, tableColumn.width, 24)]; + field.identifier = tableColumn.identifier; + field.editable = NO; + field.selectable = NO; + field.bezeled = NO; + field.drawsBackground = NO; + field.lineBreakMode = NSLineBreakByTruncatingTail; + } + NSDictionary *item = self.tableItems[row]; + field.stringValue = TCMSString(item[tableColumn.identifier]); + return field; +} + +- (void)tableViewSelectionDidChange:(NSNotification *)notification { + NSInteger row = self.tableView.selectedRow; + if (row < 0 || row >= self.tableItems.count) { + return; + } + NSDictionary *item = self.tableItems[row]; + if ([self.section isEqualToString:@"posts"] || [self.section isEqualToString:@"pages"]) { + [self loadContentSlug:TCMSString(item[@"slug"])]; + } +} + +- (void)loadSettings { + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + self.client.baseURLString = [defaults stringForKey:@"baseURL"] ?: @"http://127.0.0.1:8097"; + self.client.username = [defaults stringForKey:@"username"] ?: @"admin"; + self.client.password = [defaults stringForKey:@"password"] ?: @""; + self.client.token = [defaults stringForKey:@"token"] ?: @""; +} + +- (void)populateSettingsFields { + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + self.siteField.stringValue = self.client.baseURLString ?: @""; + self.usernameField.stringValue = self.client.username ?: @""; + self.passwordField.stringValue = self.client.password ?: @""; + self.tokenField.stringValue = self.client.token ?: @""; + NSInteger interval = [defaults integerForKey:@"autosaveInterval"]; + self.autosaveField.stringValue = [NSString stringWithFormat:@"%ld", (long)(interval > 0 ? interval : 30)]; + self.remoteAutosaveButton.state = [defaults boolForKey:@"remoteAutosave"] ? NSControlStateValueOn : NSControlStateValueOff; +} + +- (void)saveSettings { + self.client.baseURLString = self.siteField.stringValue; + self.client.username = self.usernameField.stringValue; + self.client.password = self.passwordField.stringValue; + self.client.token = self.tokenField.stringValue; + NSInteger interval = MAX(10, self.autosaveField.integerValue); + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + [defaults setObject:self.client.baseURLString forKey:@"baseURL"]; + [defaults setObject:self.client.username forKey:@"username"]; + [defaults setObject:self.client.password forKey:@"password"]; + [defaults setObject:self.client.token forKey:@"token"]; + [defaults setInteger:interval forKey:@"autosaveInterval"]; + [defaults setBool:self.remoteAutosaveButton.state == NSControlStateValueOn forKey:@"remoteAutosave"]; + [self startAutosaveTimer]; + [self setStatus:@"Settings saved."]; +} + +- (void)testConnection { + [self saveSettings]; + [self.client requestAction:@"ping" query:@{} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { + [self setStatus:error ? error.localizedDescription : [NSString stringWithFormat:@"Connected. Authenticated: %@", [json[@"authenticated"] boolValue] ? @"yes" : @"no"]]; + }]; +} + +- (void)refreshCurrentSection { + if ([self.section isEqualToString:@"posts"]) { + [self refreshContentType:@"post"]; + } else if ([self.section isEqualToString:@"pages"]) { + [self refreshContentType:@"page"]; + } else if ([self.section isEqualToString:@"media"]) { + [self refreshMedia]; + } else if ([self.section isEqualToString:@"comments"]) { + [self refreshComments]; + } +} + +- (void)refreshContentType:(NSString *)type { + [self.client requestAction:@"content.list" query:@{@"type": type, @"include_drafts": @"1"} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { + if (error) { + [self setStatus:error.localizedDescription]; + return; + } + self.tableItems = [TCMSString(type) isEqualToString:@"page"] ? [NSMutableArray arrayWithArray:json[@"items"] ?: @[]] : [NSMutableArray arrayWithArray:json[@"items"] ?: @[]]; + [self.tableView reloadData]; + [self setStatus:@"Content refreshed."]; + }]; +} + +- (void)loadContentSlug:(NSString *)slug { + if (slug.length == 0) { + return; + } + [self.client requestAction:@"content.get" query:@{@"slug": slug} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { + if (error) { + [self setStatus:error.localizedDescription]; + return; + } + self.editorItem = [json[@"item"] mutableCopy]; + self.editorMarkdown = TCMSString(self.editorItem[@"markdown"]); + self.originalSlug = TCMSString(self.editorItem[@"slug"]); + self.dirty = NO; + [self loadEditorFields]; + [self setStatus:[NSString stringWithFormat:@"Loaded %@.", TCMSString(self.editorItem[@"title"])]]; + }]; +} + +- (void)newContent { + NSString *type = [self.section isEqualToString:@"pages"] ? @"page" : @"post"; + self.editorItem = [self blankItemOfType:type]; + self.editorMarkdown = [NSString stringWithFormat:@"# %@\n\nStart writing here.", TCMSString(self.editorItem[@"title"])]; + self.originalSlug = @""; + [self loadEditorFields]; + [self markDirty]; +} + +- (void)loadEditorFields { + self.titleField.stringValue = TCMSString(self.editorItem[@"title"]); + self.slugField.stringValue = TCMSString(self.editorItem[@"slug"]); + [self.statusPopup selectItemWithTitle:TCMSString(self.editorItem[@"status"]).length ? TCMSString(self.editorItem[@"status"]) : @"draft"]; + [self.typePopup selectItemWithTitle:TCMSString(self.editorItem[@"type"]).length ? TCMSString(self.editorItem[@"type"]) : @"post"]; + self.categoryField.stringValue = TCMSString(self.editorItem[@"category"]); + self.tagsField.stringValue = TCMSJoinTags(self.editorItem[@"tags"]); + self.summaryField.stringValue = TCMSString(self.editorItem[@"summary"]); + self.authorField.stringValue = TCMSString(self.editorItem[@"author"]); + self.coverField.stringValue = TCMSString(self.editorItem[@"cover"]); + self.allowCommentsButton.state = [self.editorItem[@"allow_comments"] boolValue] ? NSControlStateValueOn : NSControlStateValueOff; + self.markdownView.string = self.editorMarkdown ?: @""; +} + +- (NSMutableDictionary *)editorPayloadItemWithStatus:(NSString *)status { + NSMutableDictionary *item = [self.editorItem mutableCopy] ?: [NSMutableDictionary dictionary]; + item[@"title"] = self.titleField.stringValue; + item[@"slug"] = self.slugField.stringValue; + item[@"type"] = self.typePopup.titleOfSelectedItem ?: @"post"; + item[@"status"] = status ?: (self.statusPopup.titleOfSelectedItem ?: @"draft"); + item[@"category"] = self.categoryField.stringValue; + item[@"tags"] = TCMSTagsFromString(self.tagsField.stringValue); + item[@"summary"] = self.summaryField.stringValue; + item[@"author"] = self.authorField.stringValue; + item[@"cover"] = self.coverField.stringValue; + item[@"allow_comments"] = @(self.allowCommentsButton.state == NSControlStateValueOn); + return item; +} + +- (void)saveDraft { + [self saveContentWithStatus:@"draft"]; +} + +- (void)publishContent { + [self saveContentWithStatus:@"published"]; +} + +- (void)saveContentWithStatus:(NSString *)status { + NSMutableDictionary *item = [self editorPayloadItemWithStatus:status]; + NSDictionary *body = @{ + @"original_slug": self.originalSlug ?: @"", + @"slug": TCMSString(item[@"slug"]), + @"markdown": self.markdownView.string ?: @"", + @"metadata": item + }; + [self.client requestAction:@"content.save" query:@{} method:@"POST" body:body completion:^(NSDictionary *json, NSError *error) { + if (error) { + [self setStatus:error.localizedDescription]; + return; + } + self.editorItem = [json[@"item"] mutableCopy]; + self.editorMarkdown = TCMSString(self.editorItem[@"markdown"]); + self.originalSlug = TCMSString(self.editorItem[@"slug"]); + self.dirty = NO; + [self loadEditorFields]; + [self refreshContentType:TCMSString(self.editorItem[@"type"]).length ? TCMSString(self.editorItem[@"type"]) : @"post"]; + [self setStatus:@"Content saved."]; + }]; +} + +- (void)deleteContent { + NSString *slug = TCMSString(self.editorItem[@"slug"]); + if (slug.length == 0) { + [self setStatus:@"Nothing selected to delete."]; + return; + } + [self.client requestAction:@"content.delete" query:@{} method:@"POST" body:@{@"slug": slug} completion:^(NSDictionary *json, NSError *error) { + if (error) { + [self setStatus:error.localizedDescription]; + return; + } + [self setStatus:@"Content deleted."]; + [self newContent]; + [self refreshCurrentSection]; + }]; +} + +- (void)refreshMedia { + [self.client requestAction:@"media.list" query:@{} method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { + if (error) { + [self setStatus:error.localizedDescription]; + return; + } + self.tableItems = [NSMutableArray arrayWithArray:json[@"items"] ?: @[]]; + [self.tableView reloadData]; + [self setStatus:@"Media refreshed."]; + }]; +} + +- (void)uploadMedia { + NSOpenPanel *panel = [NSOpenPanel openPanel]; + panel.canChooseDirectories = NO; + panel.canChooseFiles = YES; + panel.allowsMultipleSelection = NO; + if ([panel runModal] != NSModalResponseOK) { + return; + } + [self.client uploadFile:panel.URL completion:^(NSDictionary *json, NSError *error) { + if (error) { + [self setStatus:error.localizedDescription]; + return; + } + [self setStatus:@"Media uploaded."]; + [self refreshMedia]; + }]; +} + +- (void)refreshComments { + NSString *status = self.commentStatusPopup.titleOfSelectedItem ?: @"pending"; + NSDictionary *query = [status isEqualToString:@"all"] ? @{} : @{@"status": status}; + [self.client requestAction:@"comments.list" query:query method:@"GET" body:nil completion:^(NSDictionary *json, NSError *error) { + if (error) { + [self setStatus:error.localizedDescription]; + return; + } + self.tableItems = [NSMutableArray arrayWithArray:json[@"items"] ?: @[]]; + [self.tableView reloadData]; + [self setStatus:@"Comments refreshed."]; + }]; +} + +- (NSDictionary *)selectedTableItem { + NSInteger row = self.tableView.selectedRow; + if (row < 0 || row >= self.tableItems.count) { + return nil; + } + return self.tableItems[row]; +} + +- (void)approveComment { [self setSelectedCommentStatus:@"approved"]; } +- (void)pendingComment { [self setSelectedCommentStatus:@"pending"]; } +- (void)spamComment { [self setSelectedCommentStatus:@"spam"]; } + +- (void)setSelectedCommentStatus:(NSString *)status { + NSDictionary *comment = [self selectedTableItem]; + if (!comment) { + [self setStatus:@"Select a comment first."]; + return; + } + [self.client requestAction:@"comments.status" query:@{} method:@"POST" body:@{@"id": comment[@"id"], @"status": status} completion:^(NSDictionary *json, NSError *error) { + [self setStatus:error ? error.localizedDescription : @"Comment updated."]; + if (!error) { + [self refreshComments]; + } + }]; +} + +- (void)deleteComment { + NSDictionary *comment = [self selectedTableItem]; + if (!comment) { + [self setStatus:@"Select a comment first."]; + return; + } + [self.client requestAction:@"comments.delete" query:@{} method:@"POST" body:@{@"id": comment[@"id"]} completion:^(NSDictionary *json, NSError *error) { + [self setStatus:error ? error.localizedDescription : @"Comment deleted."]; + if (!error) { + [self refreshComments]; + } + }]; +} + +- (void)controlTextDidChange:(NSNotification *)obj { + [self markDirty]; +} + +- (void)textDidChange:(NSNotification *)notification { + [self markDirty]; +} + +- (void)markDirty { + self.dirty = YES; +} + +- (void)startAutosaveTimer { + [self.autosaveTimer invalidate]; + NSInteger interval = MAX(10, [NSUserDefaults.standardUserDefaults integerForKey:@"autosaveInterval"] ?: 30); + self.autosaveTimer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(autosave) userInfo:nil repeats:YES]; +} + +- (void)autosave { + if (!self.dirty || !self.markdownView) { + return; + } + [self saveLocalDraft]; + if ([NSUserDefaults.standardUserDefaults boolForKey:@"remoteAutosave"]) { + [self saveContentWithStatus:@"draft"]; + } +} + +- (void)saveLocalDraft { + NSMutableDictionary *item = [self editorPayloadItemWithStatus:nil]; + NSDictionary *record = @{ + @"server": self.client.baseURLString ?: @"", + @"saved_at": [[NSDate date] description], + @"item": item, + @"markdown": self.markdownView.string ?: @"" + }; + NSData *data = [NSJSONSerialization dataWithJSONObject:record options:NSJSONWritingPrettyPrinted error:nil]; + if (!data) { + return; + } + NSURL *base = [NSFileManager.defaultManager URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask].firstObject; + NSURL *folder = [base URLByAppendingPathComponent:@"TCMS Mac Client/Drafts" isDirectory:YES]; + [NSFileManager.defaultManager createDirectoryAtURL:folder withIntermediateDirectories:YES attributes:nil error:nil]; + NSString *name = TCMSString(item[@"slug"]).length ? TCMSString(item[@"slug"]) : TCMSString(item[@"title"]); + name = [name stringByReplacingOccurrencesOfString:@"[^A-Za-z0-9._-]+" withString:@"-" options:NSRegularExpressionSearch range:NSMakeRange(0, name.length)]; + NSURL *file = [[folder URLByAppendingPathComponent:name.length ? name : @"untitled"] URLByAppendingPathExtension:@"json"]; + [data writeToURL:file atomically:YES]; + [self setStatus:@"Local autosave complete."]; +} + +- (void)setStatus:(NSString *)message { + self.statusLabel.stringValue = message ?: @""; +} + +@end + +int main(int argc, const char *argv[]) { + @autoreleasepool { + NSApplication *application = NSApplication.sharedApplication; + TCMSAppDelegate *delegate = [TCMSAppDelegate new]; + application.delegate = delegate; + [application setActivationPolicy:NSApplicationActivationPolicyRegular]; + [application run]; + } + return 0; +} diff --git a/macclient/Package.swift b/macclient/Package.swift deleted file mode 100644 index a4ede13..0000000 --- a/macclient/Package.swift +++ /dev/null @@ -1,19 +0,0 @@ -// swift-tools-version: 5.5 - -import PackageDescription - -let package = Package( - name: "TCMSMacClient", - platforms: [ - .macOS(.v12) - ], - products: [ - .executable(name: "TCMSMacClient", targets: ["TCMSMacClient"]) - ], - targets: [ - .executableTarget( - name: "TCMSMacClient", - path: "Sources/TCMSMacClient" - ) - ] -) diff --git a/macclient/README.md b/macclient/README.md index b60af5a..6676031 100644 --- a/macclient/README.md +++ b/macclient/README.md @@ -1,15 +1,16 @@ # TCMS Mac Client -Native SwiftUI macOS 12+ client for Ty Clifford's Content Management System. +Native AppKit macOS 12+ client for Ty Clifford's Content Management System. ## Run ```bash cd macclient -swift run TCMSMacClient +./build-app.sh +open TCMSMacClient.app ``` -You can also open this folder in Xcode and run the `TCMSMacClient` package target. +The built app bundle is written to `macclient/TCMSMacClient.app`. ## Connect @@ -20,7 +21,7 @@ https://example.com/blog http://127.0.0.1:8097 ``` -The client automatically calls `api.php` under that URL. If the server has an API token configured, paste it in Settings. Local development can use TCMS' localhost fallback when `api.token` is blank and `api.allow_local_without_token` is enabled. +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`. ## Features diff --git a/macclient/Sources/TCMSMacClient/APIClient.swift b/macclient/Sources/TCMSMacClient/APIClient.swift deleted file mode 100644 index 433cb50..0000000 --- a/macclient/Sources/TCMSMacClient/APIClient.swift +++ /dev/null @@ -1,254 +0,0 @@ -import Foundation - -struct TCMSAPIClient { - var baseURL: URL - var token: String - - private var endpointURL: URL { - if baseURL.lastPathComponent.lowercased() == "api.php" { - return baseURL - } - return baseURL.appendingPathComponent("api.php") - } - - private var encoder: JSONEncoder { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - return encoder - } - - private var decoder: JSONDecoder { - JSONDecoder() - } - - func ping() async throws -> PingResponse { - try await request(action: "ping") - } - - func listContent(type: String, includeDrafts: Bool = true) async throws -> [ContentItem] { - let response: ContentListResponse = try await request( - action: "content.list", - query: [ - URLQueryItem(name: "type", value: type), - URLQueryItem(name: "include_drafts", value: includeDrafts ? "1" : "0") - ] - ) - return response.items - } - - func getContent(slug: String) async throws -> ContentItem { - let response: ContentItemResponse = try await request( - action: "content.get", - query: [URLQueryItem(name: "slug", value: slug)] - ) - return response.item - } - - func saveContent(_ item: ContentItem, originalSlug: String, markdown: String) async throws -> ContentItem { - let body = ContentSaveRequest( - originalSlug: originalSlug, - slug: item.slug, - markdown: markdown, - metadata: ContentMetadata(item: item) - ) - let response: ContentItemResponse = try await request(action: "content.save", method: "POST", body: body) - return response.item - } - - func deleteContent(slug: String) async throws -> Bool { - let body = SlugRequest(slug: slug) - let response: BasicResponse = try await request(action: "content.delete", method: "POST", body: body) - return response.ok - } - - func listMedia() async throws -> [MediaItem] { - let response: MediaListResponse = try await request(action: "media.list") - return response.items - } - - func uploadMedia(fileURL: URL, folder: String = "") async throws -> MediaItem { - var request = try makeRequest(action: "media.upload", method: "POST") - let boundary = "TCMSBoundary-\(UUID().uuidString)" - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - - var body = Data() - if !folder.isEmpty { - body.appendMultipartField(name: "folder", value: folder, boundary: boundary) - } - let data = try Data(contentsOf: fileURL) - body.appendMultipartFile( - name: "file", - filename: fileURL.lastPathComponent, - mimeType: Self.mimeType(for: fileURL), - data: data, - boundary: boundary - ) - body.appendString("--\(boundary)--\r\n") - - let (responseData, response) = try await URLSession.shared.upload(for: request, from: body) - try validate(responseData: responseData, response: response) - return try decoder.decode(MediaItemResponse.self, from: responseData).item - } - - func listComments(status: String, slug: String = "") async throws -> [CommentItem] { - var query = [URLQueryItem]() - if status != "all" { - query.append(URLQueryItem(name: "status", value: status)) - } - if !slug.isEmpty { - query.append(URLQueryItem(name: "slug", value: slug)) - } - let response: CommentListResponse = try await request(action: "comments.list", query: query) - return response.items - } - - func setCommentStatus(id: Int, status: String) async throws -> Bool { - let response: BasicResponse = try await request( - action: "comments.status", - method: "POST", - body: CommentStatusRequest(id: id, status: status) - ) - return response.ok - } - - func deleteComment(id: Int) async throws -> Bool { - let response: BasicResponse = try await request( - action: "comments.delete", - method: "POST", - body: CommentDeleteRequest(id: id) - ) - return response.ok - } - - private func request( - action: String, - query: [URLQueryItem] = [], - method: String = "GET" - ) async throws -> Response { - let request = try makeRequest(action: action, query: query, method: method) - let (data, response) = try await URLSession.shared.data(for: request) - try validate(responseData: data, response: response) - return try decoder.decode(Response.self, from: data) - } - - private func request( - action: String, - query: [URLQueryItem] = [], - method: String = "POST", - body: Body - ) async throws -> Response { - var request = try makeRequest(action: action, query: query, method: method) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try encoder.encode(body) - let (data, response) = try await URLSession.shared.data(for: request) - try validate(responseData: data, response: response) - return try decoder.decode(Response.self, from: data) - } - - private func makeRequest(action: String, query: [URLQueryItem] = [], method: String) throws -> URLRequest { - guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: false) else { - throw TCMSClientError.invalidURL - } - components.queryItems = [URLQueryItem(name: "action", value: action)] + query - guard let url = components.url else { - throw TCMSClientError.invalidURL - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.timeoutInterval = 30 - request.setValue("application/json", forHTTPHeaderField: "Accept") - if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - return request - } - - private func validate(responseData: Data, response: URLResponse) throws { - guard let http = response as? HTTPURLResponse else { - return - } - guard (200..<300).contains(http.statusCode) else { - let decoded = try? decoder.decode(APIErrorResponse.self, from: responseData) - throw TCMSClientError.server(decoded?.error ?? "HTTP \(http.statusCode)") - } - } - - private static func mimeType(for url: URL) -> String { - switch url.pathExtension.lowercased() { - case "jpg", "jpeg": return "image/jpeg" - case "png": return "image/png" - case "gif": return "image/gif" - case "webp": return "image/webp" - case "svg": return "image/svg+xml" - case "mp4", "m4v": return "video/mp4" - case "webm": return "video/webm" - case "mp3": return "audio/mpeg" - case "wav": return "audio/wav" - case "pdf": return "application/pdf" - default: return "application/octet-stream" - } - } -} - -enum TCMSClientError: LocalizedError { - case invalidURL - case server(String) - case missingServer - - var errorDescription: String? { - switch self { - case .invalidURL: - return "The TCMS URL is not valid." - case .server(let message): - return message - case .missingServer: - return "Set a TCMS URL in Settings first." - } - } -} - -private struct ContentSaveRequest: Encodable { - var originalSlug: String - var slug: String - var markdown: String - var metadata: ContentMetadata - - enum CodingKeys: String, CodingKey { - case originalSlug = "original_slug" - case slug, markdown, metadata - } -} - -private struct SlugRequest: Encodable { - var slug: String -} - -private struct CommentStatusRequest: Encodable { - var id: Int - var status: String -} - -private struct CommentDeleteRequest: Encodable { - var id: Int -} - -private extension Data { - mutating func appendString(_ string: String) { - append(Data(string.utf8)) - } - - mutating func appendMultipartField(name: String, value: String, boundary: String) { - appendString("--\(boundary)\r\n") - appendString("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") - appendString("\(value)\r\n") - } - - mutating func appendMultipartFile(name: String, filename: String, mimeType: String, data: Data, boundary: String) { - appendString("--\(boundary)\r\n") - appendString("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n") - appendString("Content-Type: \(mimeType)\r\n\r\n") - append(data) - appendString("\r\n") - } -} diff --git a/macclient/Sources/TCMSMacClient/BlogClientModel.swift b/macclient/Sources/TCMSMacClient/BlogClientModel.swift deleted file mode 100644 index 576e87b..0000000 --- a/macclient/Sources/TCMSMacClient/BlogClientModel.swift +++ /dev/null @@ -1,324 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -final class BlogClientModel: ObservableObject { - enum Section: String, CaseIterable, Identifiable { - case posts = "Posts" - case pages = "Pages" - case media = "Media" - case comments = "Comments" - case settings = "Settings" - - var id: String { rawValue } - } - - @Published var section: Section = .posts - @Published var baseURLString: String - @Published var apiToken: String - @Published var autosaveInterval: Double - @Published var remoteAutosaveDraft: Bool - @Published var contents: [ContentItem] = [] - @Published var editorItem = ContentItem() - @Published var editorMarkdown = "# Untitled\n\nStart writing here." - @Published var selectedSlug: String? - @Published var mediaItems: [MediaItem] = [] - @Published var comments: [CommentItem] = [] - @Published var commentStatus = "pending" - @Published var message = "Ready" - @Published var isBusy = false - @Published var lastAutosave: Date? - @Published var localDrafts: [DraftRecord] = [] - - private var originalSlug = "" - private var isDirty = false - private var timer: Timer? - private let draftStore = DraftStore() - private let defaults = UserDefaults.standard - - init() { - baseURLString = defaults.string(forKey: "baseURLString") ?? "http://127.0.0.1:8097" - apiToken = defaults.string(forKey: "apiToken") ?? "" - autosaveInterval = defaults.double(forKey: "autosaveInterval") - if autosaveInterval < 10 { - autosaveInterval = 30 - } - remoteAutosaveDraft = defaults.bool(forKey: "remoteAutosaveDraft") - restartAutosaveTimer() - localDrafts = draftStore.loadAll(serverURL: baseURLString) - } - - deinit { - timer?.invalidate() - } - - var client: TCMSAPIClient? { - guard let url = URL(string: baseURLString.trimmingCharacters(in: .whitespacesAndNewlines)) else { - return nil - } - return TCMSAPIClient(baseURL: url, token: apiToken) - } - - var visibleContents: [ContentItem] { - let type = section == .pages ? "page" : "post" - return contents.filter { $0.type == type } - } - - func saveSettings() { - defaults.set(baseURLString, forKey: "baseURLString") - defaults.set(apiToken, forKey: "apiToken") - defaults.set(autosaveInterval, forKey: "autosaveInterval") - defaults.set(remoteAutosaveDraft, forKey: "remoteAutosaveDraft") - restartAutosaveTimer() - localDrafts = draftStore.loadAll(serverURL: baseURLString) - message = "Settings saved." - } - - func loadSection() async { - switch section { - case .posts: - await refreshContent(type: "post") - case .pages: - await refreshContent(type: "page") - case .media: - await refreshMedia() - case .comments: - await refreshComments() - case .settings: - localDrafts = draftStore.loadAll(serverURL: baseURLString) - } - } - - func ping() async { - await run("Connected.") { - let response = try await requireClient().ping() - message = response.authenticated - ? "Connected to \(response.name)." - : "Connected, but management requests need an API token." - } - } - - func refreshContent(type: String? = nil) async { - let requestedType = type ?? (section == .pages ? "page" : "post") - await run("Content refreshed.") { - contents = try await requireClient().listContent(type: requestedType) - if selectedSlug == nil, let first = contents.first { - await selectContent(first) - } - } - } - - func selectContent(_ item: ContentItem) async { - await run("Loaded \(item.title).") { - let full = try await requireClient().getContent(slug: item.slug) - editorItem = full - editorMarkdown = full.markdown - originalSlug = full.slug - selectedSlug = full.slug - isDirty = false - } - } - - func newContent(type: String) { - let title = type == "page" ? "New Page" : "New Post" - editorItem = ContentItem(title: title, type: type, markdown: "# \(title)\n\nStart writing here.") - editorMarkdown = editorItem.markdown - originalSlug = "" - selectedSlug = nil - markDirty() - } - - func saveContent(status: String? = nil) async { - var item = editorItem - if let status { - item.status = status - } - if item.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - item.title = "Untitled" - } - - await run(status == "published" ? "Published." : "Saved.") { - let saved = try await requireClient().saveContent(item, originalSlug: originalSlug, markdown: editorMarkdown) - editorItem = saved - editorMarkdown = saved.markdown - originalSlug = saved.slug - selectedSlug = saved.slug - isDirty = false - draftStore.remove(id: saved.slug) - await refreshContent(type: saved.type) - } - } - - func deleteSelectedContent() async { - let slug = editorItem.slug - guard !slug.isEmpty else { - message = "Nothing selected to delete." - return - } - await run("Deleted \(slug).") { - _ = try await requireClient().deleteContent(slug: slug) - editorItem = ContentItem(type: section == .pages ? "page" : "post") - editorMarkdown = editorItem.markdown - originalSlug = "" - selectedSlug = nil - isDirty = false - await refreshContent(type: section == .pages ? "page" : "post") - } - } - - func refreshMedia() async { - await run("Media refreshed.") { - mediaItems = try await requireClient().listMedia() - } - } - - func uploadMedia(fileURL: URL) async { - await run("Uploaded \(fileURL.lastPathComponent).") { - let accessing = fileURL.startAccessingSecurityScopedResource() - defer { - if accessing { - fileURL.stopAccessingSecurityScopedResource() - } - } - _ = try await requireClient().uploadMedia(fileURL: fileURL) - mediaItems = try await requireClient().listMedia() - } - } - - func refreshComments() async { - await run("Comments refreshed.") { - comments = try await requireClient().listComments(status: commentStatus) - } - } - - func setComment(_ comment: CommentItem, status: String) async { - await run("Comment updated.") { - _ = try await requireClient().setCommentStatus(id: comment.id, status: status) - comments = try await requireClient().listComments(status: commentStatus) - } - } - - func deleteComment(_ comment: CommentItem) async { - await run("Comment deleted.") { - _ = try await requireClient().deleteComment(id: comment.id) - comments = try await requireClient().listComments(status: commentStatus) - } - } - - func markDirty() { - isDirty = true - } - - func stringBinding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { self.editorItem[keyPath: keyPath] }, - set: { - self.editorItem[keyPath: keyPath] = $0 - self.markDirty() - } - ) - } - - func boolBinding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { self.editorItem[keyPath: keyPath] }, - set: { - self.editorItem[keyPath: keyPath] = $0 - self.markDirty() - } - ) - } - - var tagsBinding: Binding { - Binding( - get: { self.editorItem.tags.joined(separator: ", ") }, - set: { - self.editorItem.tags = $0 - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - self.markDirty() - } - ) - } - - var markdownBinding: Binding { - Binding( - get: { self.editorMarkdown }, - set: { - self.editorMarkdown = $0 - self.markDirty() - } - ) - } - - func restoreDraft(_ draft: DraftRecord) { - editorItem = draft.item - editorMarkdown = draft.markdown - originalSlug = draft.item.slug - selectedSlug = draft.item.slug - section = draft.item.type == "page" ? .pages : .posts - markDirty() - message = "Restored local autosave from \(draft.savedAt.formatted())." - } - - private func autosave() { - guard isDirty else { - return - } - do { - try draftStore.save(item: editorItem, markdown: editorMarkdown, serverURL: baseURLString) - lastAutosave = Date() - localDrafts = draftStore.loadAll(serverURL: baseURLString) - message = "Local autosave complete." - } catch { - message = "Autosave failed: \(error.localizedDescription)" - } - - if remoteAutosaveDraft { - Task { - var draft = editorItem - draft.status = "draft" - do { - let saved = try await requireClient().saveContent(draft, originalSlug: originalSlug, markdown: editorMarkdown) - editorItem = saved - editorMarkdown = saved.markdown - originalSlug = saved.slug - selectedSlug = saved.slug - message = "Remote draft autosaved." - } catch { - message = "Remote autosave failed: \(error.localizedDescription)" - } - } - } - } - - private func restartAutosaveTimer() { - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: max(10, autosaveInterval), repeats: true) { [weak self] _ in - Task { @MainActor in - self?.autosave() - } - } - } - - private func requireClient() throws -> TCMSAPIClient { - guard let client else { - throw TCMSClientError.missingServer - } - return client - } - - private func run(_ successMessage: String, operation: @MainActor @escaping () async throws -> Void) async { - isBusy = true - defer { isBusy = false } - do { - try await operation() - if !successMessage.isEmpty { - message = successMessage - } - } catch { - message = error.localizedDescription - } - } -} diff --git a/macclient/Sources/TCMSMacClient/ContentView.swift b/macclient/Sources/TCMSMacClient/ContentView.swift deleted file mode 100644 index 3cdcaaa..0000000 --- a/macclient/Sources/TCMSMacClient/ContentView.swift +++ /dev/null @@ -1,348 +0,0 @@ -import AppKit -import SwiftUI - -struct ContentView: View { - @StateObject private var model = BlogClientModel() - - var body: some View { - NavigationView { - sidebar - detail - } - .toolbar { - ToolbarItemGroup { - Button("Refresh") { - Task { await model.loadSection() } - } - .disabled(model.isBusy) - - if model.section == .posts || model.section == .pages { - Button("Save Draft") { - Task { await model.saveContent(status: "draft") } - } - Button("Publish") { - Task { await model.saveContent(status: "published") } - } - Button("Delete") { - Task { await model.deleteSelectedContent() } - } - .disabled(model.editorItem.slug.isEmpty) - } - } - } - .task { - await model.loadSection() - } - .onChange(of: model.section) { _ in - Task { await model.loadSection() } - } - } - - private var sidebar: some View { - VStack(alignment: .leading, spacing: 12) { - Text("TCMS") - .font(.largeTitle.bold()) - .padding(.horizontal) - .padding(.top) - - VStack(alignment: .leading, spacing: 4) { - ForEach(BlogClientModel.Section.allCases) { section in - Button { - model.section = section - } label: { - HStack { - Text(section.rawValue) - Spacer() - } - .padding(.horizontal) - .padding(.vertical, 8) - .background(model.section == section ? Color.accentColor.opacity(0.16) : Color.clear) - .cornerRadius(6) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 8) - - Spacer() - - Text(model.message) - .font(.footnote) - .foregroundColor(.secondary) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(minWidth: 210) - } - - @ViewBuilder - private var detail: some View { - switch model.section { - case .posts: - ContentLibraryView(model: model, type: "post") - case .pages: - ContentLibraryView(model: model, type: "page") - case .media: - MediaView(model: model) - case .comments: - CommentsView(model: model) - case .settings: - SettingsView(model: model) - } - } -} - -struct ContentLibraryView: View { - @ObservedObject var model: BlogClientModel - var type: String - - var body: some View { - HStack(spacing: 0) { - VStack(spacing: 0) { - HStack { - Text(type == "page" ? "Pages" : "Posts") - .font(.title2.bold()) - Spacer() - Button("New") { - model.newContent(type: type) - } - } - .padding() - - List(model.visibleContents) { item in - VStack(alignment: .leading, spacing: 5) { - Text(item.title).font(.headline) - HStack { - Text(item.slug.isEmpty ? "unsaved" : item.slug) - Text(item.status) - } - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.vertical, 4) - .background(model.selectedSlug == item.slug ? Color.accentColor.opacity(0.10) : Color.clear) - .onTapGesture { - Task { await model.selectContent(item) } - } - } - } - .frame(minWidth: 260, idealWidth: 300, maxWidth: 360) - - Divider() - - EditorView(model: model) - } - } -} - -struct EditorView: View { - @ObservedObject var model: BlogClientModel - - var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - HStack { - TextField("Title", text: model.stringBinding(\.title)) - .font(.title2) - Picker("Status", selection: model.stringBinding(\.status)) { - Text("Draft").tag("draft") - Text("Published").tag("published") - } - .pickerStyle(.segmented) - .frame(width: 220) - } - - HStack { - TextField("Slug", text: model.stringBinding(\.slug)) - Picker("Type", selection: model.stringBinding(\.type)) { - Text("Post").tag("post") - Text("Page").tag("page") - } - .pickerStyle(.segmented) - .frame(width: 180) - } - - HStack { - TextField("Category", text: model.stringBinding(\.category)) - TextField("Tags", text: model.tagsBinding) - } - - HStack { - TextField("Author", text: model.stringBinding(\.author)) - TextField("Cover URL", text: model.stringBinding(\.cover)) - } - - TextField("Summary", text: model.stringBinding(\.summary)) - Toggle("Allow comments", isOn: model.boolBinding(\.allowComments)) - - if let lastAutosave = model.lastAutosave { - Text("Last local autosave: \(lastAutosave.formatted(date: .omitted, time: .standard))") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - } - .frame(maxHeight: 260) - - Divider() - - TextEditor(text: model.markdownBinding) - .font(.system(.body, design: .monospaced)) - .padding(8) - } - } -} - -struct MediaView: View { - @ObservedObject var model: BlogClientModel - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text("Media").font(.title.bold()) - Spacer() - Button("Upload Media") { - chooseMedia() - } - Button("Refresh") { - Task { await model.refreshMedia() } - } - } - .padding() - - List(model.mediaItems) { item in - VStack(alignment: .leading, spacing: 4) { - Text(item.name).font(.headline) - Text(item.url).font(.caption).foregroundColor(.secondary) - Text("\(item.mime) ยท \(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file))") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.vertical, 4) - } - } - } - - private func chooseMedia() { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.canChooseFiles = true - if panel.runModal() == .OK, let url = panel.url { - Task { await model.uploadMedia(fileURL: url) } - } - } -} - -struct CommentsView: View { - @ObservedObject var model: BlogClientModel - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text("Comments").font(.title.bold()) - Spacer() - Picker("Status", selection: $model.commentStatus) { - Text("Pending").tag("pending") - Text("Approved").tag("approved") - Text("Spam").tag("spam") - Text("All").tag("all") - } - .frame(width: 190) - Button("Refresh") { - Task { await model.refreshComments() } - } - } - .padding() - - List(model.comments) { comment in - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(comment.author).font(.headline) - Text("#\(comment.id)") - .font(.caption) - .foregroundColor(.secondary) - Text(comment.status) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Button("Approve") { - Task { await model.setComment(comment, status: "approved") } - } - Button("Pending") { - Task { await model.setComment(comment, status: "pending") } - } - Button("Spam") { - Task { await model.setComment(comment, status: "spam") } - } - Button("Delete") { - Task { await model.deleteComment(comment) } - } - } - Text(comment.slug).font(.caption).foregroundColor(.secondary) - Text(comment.body) - } - .padding(.vertical, 6) - } - } - .onChange(of: model.commentStatus) { _ in - Task { await model.refreshComments() } - } - } -} - -struct SettingsView: View { - @ObservedObject var model: BlogClientModel - - var body: some View { - Form { - Section("Connection") { - TextField("TCMS URL", text: $model.baseURLString) - SecureField("API Token", text: $model.apiToken) - HStack { - Button("Save Settings") { - model.saveSettings() - } - Button("Test Connection") { - Task { await model.ping() } - } - } - } - - Section("Autosave") { - Stepper(value: $model.autosaveInterval, in: 10...900, step: 5) { - Text("Every \(Int(model.autosaveInterval)) seconds") - } - Toggle("Also save remotely as draft", isOn: $model.remoteAutosaveDraft) - Button("Apply Autosave Settings") { - model.saveSettings() - } - } - - Section("Local Autosaves") { - if model.localDrafts.isEmpty { - Text("No local autosaves for this server.") - .foregroundColor(.secondary) - } else { - ForEach(model.localDrafts) { draft in - HStack { - VStack(alignment: .leading) { - Text(draft.item.title).font(.headline) - Text(draft.savedAt.formatted()) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Button("Restore") { - model.restoreDraft(draft) - } - } - } - } - } - } - .padding() - } -} diff --git a/macclient/Sources/TCMSMacClient/DraftStore.swift b/macclient/Sources/TCMSMacClient/DraftStore.swift deleted file mode 100644 index 1f92be7..0000000 --- a/macclient/Sources/TCMSMacClient/DraftStore.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation - -struct DraftRecord: Codable, Identifiable { - var id: String { item.slug.isEmpty ? item.title : item.slug } - var serverURL: String - var savedAt: Date - var item: ContentItem - var markdown: String -} - -final class DraftStore { - private let folder: URL - - init() { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSTemporaryDirectory()) - folder = base.appendingPathComponent("TCMS Mac Client", isDirectory: true) - try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - } - - func save(item: ContentItem, markdown: String, serverURL: String) throws { - let record = DraftRecord(serverURL: serverURL, savedAt: Date(), item: item, markdown: markdown) - let data = try JSONEncoder.tcms.encode(record) - try data.write(to: fileURL(for: record.id), options: .atomic) - } - - func remove(id: String) { - try? FileManager.default.removeItem(at: fileURL(for: id)) - } - - func loadAll(serverURL: String) -> [DraftRecord] { - let urls = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? [] - return urls.compactMap { url in - guard let data = try? Data(contentsOf: url), - let record = try? JSONDecoder.tcms.decode(DraftRecord.self, from: data), - record.serverURL == serverURL else { - return nil - } - return record - } - .sorted { $0.savedAt > $1.savedAt } - } - - private func fileURL(for id: String) -> URL { - let safe = id.replacingOccurrences(of: "[^A-Za-z0-9._-]+", with: "-", options: .regularExpression) - return folder.appendingPathComponent(safe.isEmpty ? "untitled" : safe).appendingPathExtension("json") - } -} - -private extension JSONEncoder { - static var tcms: JSONEncoder { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - return encoder - } -} - -private extension JSONDecoder { - static var tcms: JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder - } -} diff --git a/macclient/Sources/TCMSMacClient/Models.swift b/macclient/Sources/TCMSMacClient/Models.swift deleted file mode 100644 index 191fba0..0000000 --- a/macclient/Sources/TCMSMacClient/Models.swift +++ /dev/null @@ -1,194 +0,0 @@ -import Foundation - -struct ContentItem: Codable, Identifiable, Hashable { - var id: String { slug.isEmpty ? title : slug } - var slug: String - var title: String - var type: String - var status: String - var date: String - var modified: String - var category: String - var tags: [String] - var summary: String - var author: String - var cover: String - var allowComments: Bool - var menuOrder: Int - var url: String - var excerpt: String - var markdown: String - - init( - slug: String = "", - title: String = "Untitled", - type: String = "post", - status: String = "draft", - date: String = ISO8601DateFormatter().string(from: Date()), - modified: String = "", - category: String = "Notes", - tags: [String] = [], - summary: String = "", - author: String = "Editor", - cover: String = "", - allowComments: Bool = true, - menuOrder: Int = 0, - url: String = "", - excerpt: String = "", - markdown: String = "# Untitled\n\nStart writing here." - ) { - self.slug = slug - self.title = title - self.type = type - self.status = status - self.date = date - self.modified = modified - self.category = category - self.tags = tags - self.summary = summary - self.author = author - self.cover = cover - self.allowComments = allowComments - self.menuOrder = menuOrder - self.url = url - self.excerpt = excerpt - self.markdown = markdown - } - - enum CodingKeys: String, CodingKey { - case slug, title, type, status, date, modified, category, tags, summary, author, cover, url, excerpt, markdown - case allowComments = "allow_comments" - case menuOrder = "menu_order" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - slug = try container.decodeIfPresent(String.self, forKey: .slug) ?? "" - title = try container.decodeIfPresent(String.self, forKey: .title) ?? "Untitled" - type = try container.decodeIfPresent(String.self, forKey: .type) ?? "post" - status = try container.decodeIfPresent(String.self, forKey: .status) ?? "draft" - date = try container.decodeIfPresent(String.self, forKey: .date) ?? "" - modified = try container.decodeIfPresent(String.self, forKey: .modified) ?? "" - category = try container.decodeIfPresent(String.self, forKey: .category) ?? "Notes" - tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] - summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "" - author = try container.decodeIfPresent(String.self, forKey: .author) ?? "Editor" - cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" - allowComments = try container.decodeIfPresent(Bool.self, forKey: .allowComments) ?? true - menuOrder = try container.decodeIfPresent(Int.self, forKey: .menuOrder) ?? 0 - url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" - excerpt = try container.decodeIfPresent(String.self, forKey: .excerpt) ?? "" - markdown = try container.decodeIfPresent(String.self, forKey: .markdown) ?? "" - } -} - -struct ContentMetadata: Codable { - var title: String - var slug: String - var type: String - var status: String - var date: String - var category: String - var tags: [String] - var summary: String - var author: String - var cover: String - var allowComments: Bool - var menuOrder: Int - - init(item: ContentItem) { - title = item.title - slug = item.slug - type = item.type - status = item.status - date = item.date - category = item.category - tags = item.tags - summary = item.summary - author = item.author - cover = item.cover - allowComments = item.allowComments - menuOrder = item.menuOrder - } - - enum CodingKeys: String, CodingKey { - case title, slug, type, status, date, category, tags, summary, author, cover - case allowComments = "allow_comments" - case menuOrder = "menu_order" - } -} - -struct MediaItem: Codable, Identifiable, Hashable { - var id: String { path } - var name: String - var path: String - var url: String - var size: Int - var modified: String - var mime: String -} - -struct CommentItem: Codable, Identifiable, Hashable { - var id: Int - var slug: String - var author: String - var email: String - var website: String - var body: String - var status: String - var createdAt: String - var updatedAt: String - - enum CodingKeys: String, CodingKey { - case id, slug, author, email, website, body, status - case createdAt = "created_at" - case updatedAt = "updated_at" - } -} - -struct PingResponse: Codable { - var ok: Bool - var name: String - var authenticated: Bool - var tokenConfigured: Bool - - enum CodingKeys: String, CodingKey { - case ok, name, authenticated - case tokenConfigured = "token_configured" - } -} - -struct ContentListResponse: Codable { - var ok: Bool - var items: [ContentItem] -} - -struct ContentItemResponse: Codable { - var ok: Bool - var item: ContentItem -} - -struct MediaListResponse: Codable { - var ok: Bool - var items: [MediaItem] -} - -struct MediaItemResponse: Codable { - var ok: Bool - var item: MediaItem -} - -struct CommentListResponse: Codable { - var ok: Bool - var items: [CommentItem] -} - -struct BasicResponse: Codable { - var ok: Bool - var error: String? -} - -struct APIErrorResponse: Codable { - var ok: Bool? - var error: String? -} diff --git a/macclient/Sources/TCMSMacClient/TCMSMacClientApp.swift b/macclient/Sources/TCMSMacClient/TCMSMacClientApp.swift deleted file mode 100644 index ebb69ff..0000000 --- a/macclient/Sources/TCMSMacClient/TCMSMacClientApp.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -@main -struct TCMSMacClientApp: App { - var body: some Scene { - WindowGroup { - ContentView() - .frame(minWidth: 1040, minHeight: 680) - } - } -} diff --git a/macclient/build-app.sh b/macclient/build-app.sh new file mode 100755 index 0000000..3e4f7f3 --- /dev/null +++ b/macclient/build-app.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +ROOT="$(cd "$(dirname "$0")" && pwd)" +APP="$ROOT/TCMSMacClient.app" +SRC="$ROOT/AppKitClient/main.m" + +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$ROOT/AppKitClient/Info.plist" "$APP/Contents/Info.plist" +/usr/bin/clang -fobjc-arc -mmacosx-version-min=12.0 -framework Cocoa "$SRC" -o "$APP/Contents/MacOS/TCMSMacClient" +printf 'APPL????' > "$APP/Contents/PkgInfo" + +echo "Built $APP" diff --git a/router.php b/router.php index 2f0d6c7..fc25b56 100644 --- a/router.php +++ b/router.php @@ -4,6 +4,12 @@ declare(strict_types=1); $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; $file = __DIR__ . $path; +if (preg_match('#/(?:\.env(?:\..*)?)$#', $path)) { + http_response_code(403); + echo 'Forbidden'; + return true; +} + if ($path !== '/' && is_file($file)) { return false; }