import Foundation struct TCMSAPIClient { var baseURL: URL var token: String private var endpointURL: URL { if baseURL.lastPathComponent.lowercased() == "api.php" { return baseURL } return baseURL.appendingPathComponent("api.php") } private var encoder: JSONEncoder { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] return encoder } private var decoder: JSONDecoder { JSONDecoder() } func ping() async throws -> PingResponse { try await request(action: "ping") } func listContent(type: String, includeDrafts: Bool = true) async throws -> [ContentItem] { let response: ContentListResponse = try await request( action: "content.list", query: [ URLQueryItem(name: "type", value: type), URLQueryItem(name: "include_drafts", value: includeDrafts ? "1" : "0") ] ) return response.items } func getContent(slug: String) async throws -> ContentItem { let response: ContentItemResponse = try await request( action: "content.get", query: [URLQueryItem(name: "slug", value: slug)] ) return response.item } func saveContent(_ item: ContentItem, originalSlug: String, markdown: String) async throws -> ContentItem { let body = ContentSaveRequest( originalSlug: originalSlug, slug: item.slug, markdown: markdown, metadata: ContentMetadata(item: item) ) let response: ContentItemResponse = try await request(action: "content.save", method: "POST", body: body) return response.item } func deleteContent(slug: String) async throws -> Bool { let body = SlugRequest(slug: slug) let response: BasicResponse = try await request(action: "content.delete", method: "POST", body: body) return response.ok } func listMedia() async throws -> [MediaItem] { let response: MediaListResponse = try await request(action: "media.list") return response.items } func uploadMedia(fileURL: URL, folder: String = "") async throws -> MediaItem { var request = try makeRequest(action: "media.upload", method: "POST") let boundary = "TCMSBoundary-\(UUID().uuidString)" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var body = Data() if !folder.isEmpty { body.appendMultipartField(name: "folder", value: folder, boundary: boundary) } let data = try Data(contentsOf: fileURL) body.appendMultipartFile( name: "file", filename: fileURL.lastPathComponent, mimeType: Self.mimeType(for: fileURL), data: data, boundary: boundary ) body.appendString("--\(boundary)--\r\n") let (responseData, response) = try await URLSession.shared.upload(for: request, from: body) try validate(responseData: responseData, response: response) return try decoder.decode(MediaItemResponse.self, from: responseData).item } func listComments(status: String, slug: String = "") async throws -> [CommentItem] { var query = [URLQueryItem]() if status != "all" { query.append(URLQueryItem(name: "status", value: status)) } if !slug.isEmpty { query.append(URLQueryItem(name: "slug", value: slug)) } let response: CommentListResponse = try await request(action: "comments.list", query: query) return response.items } func setCommentStatus(id: Int, status: String) async throws -> Bool { let response: BasicResponse = try await request( action: "comments.status", method: "POST", body: CommentStatusRequest(id: id, status: status) ) return response.ok } func deleteComment(id: Int) async throws -> Bool { let response: BasicResponse = try await request( action: "comments.delete", method: "POST", body: CommentDeleteRequest(id: id) ) return response.ok } private func request( action: String, query: [URLQueryItem] = [], method: String = "GET" ) async throws -> Response { let request = try makeRequest(action: action, query: query, method: method) let (data, response) = try await URLSession.shared.data(for: request) try validate(responseData: data, response: response) return try decoder.decode(Response.self, from: data) } private func request( action: String, query: [URLQueryItem] = [], method: String = "POST", body: Body ) async throws -> Response { var request = try makeRequest(action: action, query: query, method: method) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try encoder.encode(body) let (data, response) = try await URLSession.shared.data(for: request) try validate(responseData: data, response: response) return try decoder.decode(Response.self, from: data) } private func makeRequest(action: String, query: [URLQueryItem] = [], method: String) throws -> URLRequest { guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: false) else { throw TCMSClientError.invalidURL } components.queryItems = [URLQueryItem(name: "action", value: action)] + query guard let url = components.url else { throw TCMSClientError.invalidURL } var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = 30 request.setValue("application/json", forHTTPHeaderField: "Accept") if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } return request } private func validate(responseData: Data, response: URLResponse) throws { guard let http = response as? HTTPURLResponse else { return } guard (200..<300).contains(http.statusCode) else { let decoded = try? decoder.decode(APIErrorResponse.self, from: responseData) throw TCMSClientError.server(decoded?.error ?? "HTTP \(http.statusCode)") } } private static func mimeType(for url: URL) -> String { switch url.pathExtension.lowercased() { case "jpg", "jpeg": return "image/jpeg" case "png": return "image/png" case "gif": return "image/gif" case "webp": return "image/webp" case "svg": return "image/svg+xml" case "mp4", "m4v": return "video/mp4" case "webm": return "video/webm" case "mp3": return "audio/mpeg" case "wav": return "audio/wav" case "pdf": return "application/pdf" default: return "application/octet-stream" } } } enum TCMSClientError: LocalizedError { case invalidURL case server(String) case missingServer var errorDescription: String? { switch self { case .invalidURL: return "The TCMS URL is not valid." case .server(let message): return message case .missingServer: return "Set a TCMS URL in Settings first." } } } private struct ContentSaveRequest: Encodable { var originalSlug: String var slug: String var markdown: String var metadata: ContentMetadata enum CodingKeys: String, CodingKey { case originalSlug = "original_slug" case slug, markdown, metadata } } private struct SlugRequest: Encodable { var slug: String } private struct CommentStatusRequest: Encodable { var id: Int var status: String } private struct CommentDeleteRequest: Encodable { var id: Int } private extension Data { mutating func appendString(_ string: String) { append(Data(string.utf8)) } mutating func appendMultipartField(name: String, value: String, boundary: String) { appendString("--\(boundary)\r\n") appendString("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") appendString("\(value)\r\n") } mutating func appendMultipartFile(name: String, filename: String, mimeType: String, data: Data, boundary: String) { appendString("--\(boundary)\r\n") appendString("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n") appendString("Content-Type: \(mimeType)\r\n\r\n") append(data) appendString("\r\n") } }