// // DraftsManager.swift // ComposeUI // // Created by Shadowfacts on 10/22/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import Foundation import Combine import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsManager") private let encoder = PropertyListEncoder() private let decoder = PropertyListDecoder() public class DraftsManager: NSObject, ObservableObject, NSFilePresenter { public private(set) static var shared: DraftsManager = { let draftsManager = DraftsManager(url: DraftsManager.archiveURL) draftsManager.load() return draftsManager }() private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! private static var archiveURL = appGroupDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") private var url: URL @Published private var drafts: [UUID: Draft] = [:] public init(url: URL) { self.url = url super.init() } private func merge(from container: DraftsContainer) { for draft in container.drafts.values { if let existing = self.drafts[draft.id] { existing.merge(from: draft) } else { self.drafts[draft.id] = draft } } for id in self.drafts.keys where !container.drafts.keys.contains(id) { self.drafts.removeValue(forKey: id) } } public func load(completion: ((Error?) -> Void)? = nil) { NSFileCoordinator(filePresenter: self).coordinate(readingItemAt: self.url, options: [], error: nil) { url in do { let data = try Data(contentsOf: url) let container = try decoder.decode(DraftsContainer.self, from: data) DispatchQueue.main.async { self.merge(from: container) completion?(nil) } } catch { logger.error("Error loading: \(String(describing: error))") completion?(error) } } } public func migrate(from url: URL) -> Result { do { let data = try Data(contentsOf: url) let container = try decoder.decode(DraftsContainer.self, from: data) self.merge(from: container) self.save() } catch { logger.error("Error migrating: \(String(describing: error))") return .failure(error) } return .success(()) } public func save(completion: ((Error?) -> Void)? = nil) { NSFileCoordinator(filePresenter: self).coordinate(writingItemAt: Self.archiveURL, options: .forReplacing, error: nil) { url in do { let data = try encoder.encode(DraftsContainer(drafts: self.drafts)) try data.write(to: url, options: .atomic) completion?(nil) } catch { logger.error("Error saving: \(String(describing: error))") completion?(error) } } } // MARK: Drafts API var sorted: [Draft] { return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) } public func add(_ draft: Draft) { drafts[draft.id] = draft } public func remove(_ draft: Draft) { drafts.removeValue(forKey: draft.id) } public func getBy(id: UUID) -> Draft? { return drafts[id] } // MARK: NSFilePresenter public var presentedItemURL: URL? { url } public let presentedItemOperationQueue = OperationQueue() public func presentedItemDidMove(to newURL: URL) { self.url = newURL } public func presentedItemDidChange() { self.load() } // MARK: Supporting Types struct DraftsContainer: Codable { let drafts: [UUID: Draft] init(drafts: [UUID: Draft]) { self.drafts = drafts } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft) } enum CodingKeys: CodingKey { case drafts } } // a container that always succeeds at decoding // so if a single draft can't be decoded, we don't lose all drafts struct SafeDraft: Decodable { let draft: Draft? init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() self.draft = try? container.decode(Draft.self) } } } private extension Draft { func merge(from other: Draft) { self.lastModified = other.lastModified self.accountID = other.accountID self.text = other.text self.contentWarningEnabled = other.contentWarningEnabled self.contentWarning = other.contentWarning self.attachments = other.attachments self.inReplyToID = other.inReplyToID self.visibility = other.visibility self.poll = other.poll self.localOnly = other.localOnly self.initialText = other.initialText } }