- auth / Mac app rebuild
This commit is contained in:
@@ -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
|
||||||
@@ -3,4 +3,5 @@
|
|||||||
/storage/stats/*.csv
|
/storage/stats/*.csv
|
||||||
/storage/cache/*
|
/storage/cache/*
|
||||||
!.gitkeep
|
!.gitkeep
|
||||||
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -17,3 +17,7 @@ Options -Indexes
|
|||||||
<FilesMatch "\.(sqlite|db|csv|json|lock)$">
|
<FilesMatch "\.(sqlite|db|csv|json|lock)$">
|
||||||
Require all denied
|
Require all denied
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
|
<FilesMatch "^\.env(?:\..*)?$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|||||||
+3
-3
@@ -6,14 +6,14 @@ All notable changes to Ty Clifford's Content Management System are documented he
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added `api.php` for authenticated remote management of content, media uploads, and comments.
|
- Added `api.php` for `.env`-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 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=<id>]` and `[video poster=<poster> file=<file>]`.
|
- 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.
|
- Added `lone-embed.php` as the generated HTML5 video player target for video shortcodes.
|
||||||
|
|
||||||
### Changed
|
### 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
|
## 2026-07-04
|
||||||
|
|
||||||
|
|||||||
@@ -44,28 +44,27 @@ php cli/blog.php rebuild
|
|||||||
|
|
||||||
## Mac Client and API
|
## Mac Client and API
|
||||||
|
|
||||||
The native macOS 12+ client lives in `macclient/`.
|
The native AppKit macOS 12+ client lives in `macclient/`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd macclient
|
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
|
```text
|
||||||
{
|
TCMS_API_ENABLED=true
|
||||||
"api": {
|
TCMS_API_USERNAME=admin
|
||||||
"enabled": true,
|
TCMS_API_PASSWORD=change-this-password
|
||||||
"token": "replace-with-a-long-random-token",
|
TCMS_API_PASSWORD_HASH=
|
||||||
"allow_local_without_token": true
|
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
|
## Comments
|
||||||
|
|
||||||
@@ -107,7 +106,7 @@ php cli/blog.php stats:summary
|
|||||||
|
|
||||||
## Apache
|
## 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:
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ $repo = $app->repository();
|
|||||||
$comments = $app->comments();
|
$comments = $app->comments();
|
||||||
$config = $app->config();
|
$config = $app->config();
|
||||||
|
|
||||||
|
tcms_load_env(BLOG_ROOT . '/.env');
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
||||||
@@ -25,7 +27,9 @@ try {
|
|||||||
'name' => 'Ty Clifford Content Management System API',
|
'name' => 'Ty Clifford Content Management System API',
|
||||||
'site' => $config->get('site', []),
|
'site' => $config->get('site', []),
|
||||||
'authenticated' => tcms_is_authenticated($config),
|
'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' => [
|
'capabilities' => [
|
||||||
'content' => ['list', 'get', 'save', 'delete'],
|
'content' => ['list', 'get', 'save', 'delete'],
|
||||||
'media' => ['list', 'upload'],
|
'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_error('The API is disabled for this TCMS site.', 403);
|
||||||
}
|
}
|
||||||
tcms_require_auth($config);
|
tcms_require_auth($config);
|
||||||
@@ -67,6 +71,39 @@ function tcms_payload(): array
|
|||||||
return $_POST;
|
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<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
function tcms_json(array $data, int $status = 200): never
|
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
|
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)) {
|
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
|
function tcms_is_authenticated($config): bool
|
||||||
{
|
{
|
||||||
$configured = tcms_configured_token($config);
|
if (!tcms_credentials_configured()) {
|
||||||
if ($configured === '') {
|
return false;
|
||||||
return (bool) $config->get('api.allow_local_without_token', true) && tcms_is_local_request();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$provided = tcms_request_token();
|
$configuredToken = tcms_configured_token();
|
||||||
return $provided !== '' && hash_equals($configured, $provided);
|
$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'));
|
$envValue = tcms_env('TCMS_API_ENABLED');
|
||||||
return $envToken !== '' ? $envToken : trim((string) $config->get('api.token', ''));
|
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
|
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'] ?? ''));
|
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'] ?? '');
|
if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
|
||||||
return in_array($remote, ['127.0.0.1', '::1', 'localhost', ''], true);
|
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
|
function tcms_content_list(ContentRepository $repo): never
|
||||||
|
|||||||
@@ -158,8 +158,6 @@ final class Config
|
|||||||
],
|
],
|
||||||
'api' => [
|
'api' => [
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'token' => '',
|
|
||||||
'allow_local_without_token' => true,
|
|
||||||
],
|
],
|
||||||
'social' => [],
|
'social' => [],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.build/
|
build/
|
||||||
|
TCMSMacClient.app/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.xcuserdata/
|
*.xcuserdata/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>TCMSMacClient</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.tyclifford.tcms.macclient</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>TCMS Mac Client</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>12.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
#import <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
|
static NSString *TCMSString(id value) {
|
||||||
|
if ([value isKindOfClass:[NSString class]]) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if ([value respondsToSelector:@selector(stringValue)]) {
|
||||||
|
return [value stringValue];
|
||||||
|
}
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSArray *TCMSTagsFromString(NSString *value) {
|
||||||
|
NSMutableArray *tags = [NSMutableArray array];
|
||||||
|
for (NSString *part in [value componentsSeparatedByString:@","]) {
|
||||||
|
NSString *tag = [part stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
if (tag.length > 0) {
|
||||||
|
[tags addObject:tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString *TCMSJoinTags(id value) {
|
||||||
|
if ([value isKindOfClass:[NSArray class]]) {
|
||||||
|
return [(NSArray *)value componentsJoinedByString:@", "];
|
||||||
|
}
|
||||||
|
return TCMSString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 <NSApplicationDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSTextViewDelegate>
|
||||||
|
@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) 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<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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
+5
-4
@@ -1,15 +1,16 @@
|
|||||||
# TCMS Mac Client
|
# 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
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd macclient
|
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
|
## Connect
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ https://example.com/blog
|
|||||||
http://127.0.0.1:8097
|
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
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -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<Response: Decodable>(
|
|
||||||
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<Body: Encodable, Response: Decodable>(
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ContentItem, String>) -> Binding<String> {
|
|
||||||
Binding(
|
|
||||||
get: { self.editorItem[keyPath: keyPath] },
|
|
||||||
set: {
|
|
||||||
self.editorItem[keyPath: keyPath] = $0
|
|
||||||
self.markDirty()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolBinding(_ keyPath: WritableKeyPath<ContentItem, Bool>) -> Binding<Bool> {
|
|
||||||
Binding(
|
|
||||||
get: { self.editorItem[keyPath: keyPath] },
|
|
||||||
set: {
|
|
||||||
self.editorItem[keyPath: keyPath] = $0
|
|
||||||
self.markDirty()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tagsBinding: Binding<String> {
|
|
||||||
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<String> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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?
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct TCMSMacClientApp: App {
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
ContentView()
|
|
||||||
.frame(minWidth: 1040, minHeight: 680)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Executable
+13
@@ -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"
|
||||||
@@ -4,6 +4,12 @@ declare(strict_types=1);
|
|||||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
$file = __DIR__ . $path;
|
$file = __DIR__ . $path;
|
||||||
|
|
||||||
|
if (preg_match('#/(?:\.env(?:\..*)?)$#', $path)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo 'Forbidden';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if ($path !== '/' && is_file($file)) {
|
if ($path !== '/' && is_file($file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user