- API, Mac client

This commit is contained in:
Ty Clifford
2026-07-05 15:58:51 -04:00
parent 9dbd29d8aa
commit 729b29276f
14 changed files with 1682 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
.build/
DerivedData/
*.xcuserdata/
*.xcuserstate
+19
View File
@@ -0,0 +1,19 @@
// 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"
)
]
)
+32
View File
@@ -0,0 +1,32 @@
# TCMS Mac Client
Native SwiftUI macOS 12+ client for Ty Clifford's Content Management System.
## Run
```bash
cd macclient
swift run TCMSMacClient
```
You can also open this folder in Xcode and run the `TCMSMacClient` package target.
## Connect
Point the client at any TCMS install URL, such as:
```text
https://example.com/blog
http://127.0.0.1:8097
```
The client automatically calls `api.php` under that URL. If the server has an API token configured, paste it in Settings. Local development can use TCMS' localhost fallback when `api.token` is blank and `api.allow_local_without_token` is enabled.
## Features
- Create, edit, draft, publish, and delete posts and pages.
- Upload media to `bl-content/uploads`.
- List and moderate comments.
- Local autosave to Application Support.
- Optional remote autosave as draft.
- Configurable autosave interval.
@@ -0,0 +1,254 @@
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")
}
}
@@ -0,0 +1,324 @@
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
}
}
}
@@ -0,0 +1,348 @@
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()
}
}
@@ -0,0 +1,65 @@
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
}
}
@@ -0,0 +1,194 @@
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?
}
@@ -0,0 +1,11 @@
import SwiftUI
@main
struct TCMSMacClientApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 1040, minHeight: 680)
}
}
}