- API, Mac client
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user