// // Cap.swift // CapCollector // // Created by Christoph on 19.11.18. // Copyright © 2018 CH. All rights reserved. // import Foundation import UIKit import SwiftyDropbox protocol CapsDelegate: class { func capHasUpdates(_ cap: Cap) func capsLoaded() } final class Cap { // MARK: - Static variables static let jpgQuality: CGFloat = 0.3 private static let mosaicColumns = 40 static let mosaicCellSize: CGFloat = 60 private static let mosaicRowHeight = mosaicCellSize * 0.866 private static let mosaicMargin = mosaicCellSize - mosaicRowHeight static var delegate: CapsDelegate? static var shouldSave = true { didSet { save() } } static var hasMatches = false { didSet { guard !hasMatches else { return } all.forEach { _, cap in cap.match = nil } } } static var nextUnusedId: Int { return (all.keys.max() ?? 0) + 1 } /// The number of caps currently in the database static var totalCapCount: Int { return all.count } /// The total number of images for all caps static var imageCount: Int { return all.reduce(0) { sum, cap in return sum + cap.value.count } } /** Match all cap names against the given string and return matches. - note: Each space-separated part of the string is matched individually */ static func caps(matching text: String) -> [Cap] { let cleaned = text.clean let found = all.compactMap { (_,cap) -> Cap? in // For each part of text, check if name contains it for textItem in cleaned.components(separatedBy: " ") { if textItem != "" && !cap.name.contains(textItem) { return nil } } return cap } return found } // MARK: - Variables /// The unique number of the cap let id: Int /// The tile position of the cap var tile: Int /// The name of the cap var name: String { didSet { cleanName = name.clean Cap.save() event("Updated name for cap \(id) to \(name)") Cap.delegate?.capHasUpdates(self) } } /// The name of the cap wothout special characters private(set) var cleanName: String /// The number of images existing for the cap private(set) var count: Int { didSet { Cap.save() event("Updated count for cap \(id) to \(count)") Cap.delegate?.capHasUpdates(self) } } /// The similarity of the cap to the currently processed image var match: Float? = nil // MARK: - All caps /// A dictionary of all known caps static var all = [Int : Cap]() // MARK: - Tile information /// A dictionary of the caps for the tiles static var tiles = [Int : Cap]() /** Get the cap image for a tile. */ static func tileImage(tile: Int) -> UIImage? { return tiles[tile]?.thumbnail } /** Switch two tiles. */ static func switchTiles(_ lhs: Int, _ rhs: Int) { let l = tiles[lhs]! let r = tiles[rhs]! l.tile = rhs r.tile = lhs tiles[rhs] = l tiles[lhs] = r event("Switched tiles \(lhs) and \(rhs)") } // MARK: - Initialization /** Create a new cap with an image - parameter image: The main image of the cap - parameter name: The name of the cap */ init?(image: UIImage, name: String) { self.id = Cap.nextUnusedId self.tile = id - 1 self.name = name self.count = 0 self.cleanName = name.clean guard save(mainImage: image) else { return nil } Cap.all[self.id] = self Cap.tiles[self.id] = self Cap.shouldCreateFolderForCap(self.id) Cap.save() Cap.delegate?.capHasUpdates(self) //Cap.updateMosaicWithNewCap(id: self.id, image) DispatchQueue.global(qos: .userInitiated).async { self.add(image: image) { _ in } } } /** Create a cap from a line in the cap list file */ init?(line: String) { guard line != "" else { return nil } let parts = line.components(separatedBy: ";") guard parts.count == 4 else { Cap.error("Cap names: Invalid line \(line)") return nil } guard let nr = Int(parts[0]) else { Cap.error("Invalid id in line \(line)") return nil } guard let count = Int(parts[2]) else { Cap.error("Invalid count in line \(line)") return nil } guard let tile = Int(parts[3]) else { Cap.error("Invalid tile in line \(line)") return nil } self.id = nr self.name = parts[1] self.count = count self.cleanName = name.clean self.tile = tile Cap.tiles[tile] = self Cap.all[id] = self } // MARK: - Images /// The main image of the cap var image: UIImage? { guard let data = DiskManager.image(for: id) else { return nil } return UIImage(data: data) } /// The main image of the cap var thumbnail: UIImage? { if let data = DiskManager.thumbnail(for: id) { return UIImage(data: data) } return makeThumbnail() } @discardableResult func makeThumbnail() -> UIImage? { guard let img = image else { return nil } let len = GridViewController.len * 2 let thumb = img.resize(to: CGSize.init(width: len, height: len)) guard let data = thumb.pngData() else { error("Failed to get PNG data from thumbnail for cap \(id)") return nil } _ = DiskManager.save(thumbnailData: data, for: id) event("Created thumbnail for cap \(id)") return thumb } /** Download a specified image of the cap. - Note: If the downloaded image is the main image, it is automatically saved to disk - Note: If the main image is requested and already downloaded, it is returned directly - parameter number: The number of the image - parameter completion: The completion handler, called with the image if successful - parameter image: The image, if the download was successful, or nil on error */ func downloadImage(_ number: Int = 0, completion: @escaping (_ image: UIImage?) -> Void) { if number == 0, let image = self.image { event("Main image for cap \(id) already downloaded") completion(image) return } let path = "/Images/\(id)/\(id)-\(number).jpg" DropboxController.client.files.download(path: path).response { data, dbError in if let error = dbError { self.error("Failed to download image data (\(number)) for cap \(self.id): \(error)") completion(nil) return } guard let d = data?.1 else { self.error("Failed to download image data (\(number)) for cap \(self.id)") completion(nil) return } guard let image = UIImage(data: d) else { self.error("Corrupted image data (\(number)) for cap \(self.id)") completion(nil) return } if number == 0 { guard self.save(mainImage: image) else { completion(nil) return } } self.event("Downloaded image data (\(number)) for cap \(self.id)") completion(image) } } func save(mainImage: UIImage) -> Bool { guard let data = mainImage.jpegData(compressionQuality: Cap.jpgQuality) else { error("Failed to convert image to data") return false } guard DiskManager.save(imageData: data, for: id) else { error("Failed to save main image for cap \(id)") return false } event("Saved main image for cap \(id) to disk") guard let _ = makeThumbnail() else { return true } Cap.delegate?.capHasUpdates(self) return true } func add(image: UIImage, completion: @escaping (Bool) -> Void) { self.upload(image: image) { saved in guard saved else { completion(false) return } // Increment cap count self.count += 1 completion(true) } } // MARK: - Image upload private func folderExists(completion: @escaping (_ exists: Bool?) -> Void) { let path = "/Images" DropboxController.client.files.listFolder(path: path).response { response, error in if let e = error { self.error("Failed to get image folder list: \(e)") completion(nil) return } guard let result = response else { self.error("Failed to get image folder list") completion(nil) return } let exists = result.entries.contains { $0.name == "\(self.id)" } completion(exists) } } private static func createFolder(forCap cap: Int, completion: @escaping (_ success: Bool) -> Void) { guard shouldCreateFolder(forCap: cap) else { completion(true) return } // Create folder for cap let path = "/Images/\(cap)" DropboxController.client.files.createFolderV2(path: path).response { _, error in if let err = error { self.event("Could not create folder for cap \(cap): \(err)") completion(false) } else { self.event("Created folder for cap \(cap)") didCreateFolder(forCap: cap) completion(true) } } } private func upload(image: UIImage, savedCallback: @escaping (Bool) -> Void) { upload(image: image, number: count, savedCallback: savedCallback) } private func upload(image: UIImage, number: Int, savedCallback: @escaping (Bool) -> Void) { // Convert to data guard let data = image.jpegData(compressionQuality: Cap.jpgQuality) else { error("Failed to convert image to data") return } let fileName = "\(id)-\(number).jpg" // Save image to upload folder guard let url = DiskManager.saveForUpload(imageData: data, name: fileName) else { error("Could not save image for cap \(id) to upload folder") savedCallback(false) return } event("Saved image \(number) for cap \(id) for upload") savedCallback(true) Cap.uploadCapImage(at: url, forCap: id) { success in } } private static func uploadCapImage(at url: URL, forCap cap: Int, completion: @escaping (Bool) -> Void) { createFolder(forCap: cap) { created in guard created else { return } uploadCapImage(at: url, forCapWithExistingFolder: cap, completion: completion) } } private static func uploadCapImage(at url: URL, forCapWithExistingFolder cap: Int, completion: @escaping (Bool) -> Void) { let path = "/Images/\(cap)/" + url.lastPathComponent let data: Data do { data = try Data(contentsOf: url) } catch { self.error("Could not read data from url \(url.path): \(error)") completion(false) return } DropboxController.client.files.upload(path: path, input: data).response { response, error in if let err = error { self.error("Failed to upload file at url: \(url): \(err)") completion(false) return } Cap.event("Uploaded image \(path)") guard DiskManager.removeFromUpload(url: url) else { self.error("Could not delete uploaded image for cap \(cap) at url \(url)") completion(false) return } completion(true) } } static func uploadRemainingImages() { guard let list = DiskManager.pendingUploads else { return } guard list.count != 0 else { event("No pending uploads") return } event("\(list.count) image uploads pending") for url in list { let cap = Int(url.lastPathComponent.components(separatedBy: "-").first!)! uploadCapImage(at: url, forCap: cap) { didUpload in // Delete image from disk if uploaded guard didUpload else { self.error("Could not upload image at url \(url)") return } guard DiskManager.removeFromUpload(url: url) else { self.error("Could not delete uploaded image at url \(url)") return } } } } // MARK: - Counts func updateCount(completion: @escaping (Bool) -> Void) { getImageCount { response in guard let count = response else { self.error("Could not update count for cap \(self.id)") completion(false) return } self.count = count completion(true) } } private func getImageCount(completion: @escaping (Int?) -> Void) { DropboxController.client.files.listFolder(path: "/Images/\(id)").response { response, error in if let err = error { self.error("Error getting folder content of cap \(self.id): \(err)") completion(nil) return } guard let files = response?.entries else { self.error("No content for folder of cap \(self.id)") completion(nil) return } completion(files.count) } } // MARK: - Sorted caps static var unsortedCaps: Set { return Set(all.values) } static func capList(sortedBy criteria: SortCriteria, ascending: Bool) -> [Cap] { if ascending { return sorted([Cap](all.values), ascendingBy: criteria) } else { return sorted([Cap](all.values), descendingBy: criteria) } } private static func sorted(_ list: [Cap], ascendingBy parameter: SortCriteria) -> [Cap] { switch parameter { case .id: return list.sorted { $0.id < $1.id } case .count: return list.sorted { $0.count < $1.count } case .name: return list.sorted { $0.name < $1.name } case .match: return list.sorted { $0.match ?? 0 < $1.match ?? 0 } } } private static func sorted(_ list: [Cap], descendingBy parameter: SortCriteria) -> [Cap] { switch parameter { case .id: return list.sorted { $0.id > $1.id } case .count: return list.sorted { $0.count > $1.count } case .name: return list.sorted { $0.name > $1.name } case .match: return list.sorted { $0.match ?? 0 > $1.match ?? 0 } } } // MARK: - Loading, Saving & Uploading cap list /** Either load the names from disk or download them from dropbox. - parameter completion: The handler that is called with true on success, false on failure */ static func load() { NameFile.makeAvailable { content in guard let lines = content else { return } self.readNames(from: lines) } } /// Read all caps from the content of a file private static func readNames(from fileContent: String) { let parts = fileContent.components(separatedBy: "\n") for line in parts { _ = Cap(line: line) } event("Loaded \(totalCapCount) caps from file") delegate?.capsLoaded() } static func getCapStatistics() -> [Int] { let counts = all.values.map { $0.count } var c = [Int](repeating: 0, count: counts.max()! + 1) counts.forEach { c[$0] += 1 } return c } static func save() { guard shouldSave else { return } let content = namesAsString() NameFile.save(names: content) } static func saveAndUpload(completion: @escaping (Bool) -> Void) { let content = namesAsString() NameFile.saveAndUpload(names: content) { success in guard success else { completion(false) return } Persistence.lastUploadedCapCount = totalCapCount Persistence.lastUploadedImageCount = imageCount completion(true) } } private static func namesAsString() -> String { return capList(sortedBy: .id, ascending: true).reduce("") { $0 + $1.description } } // MARK: - Folders to upload static func shouldCreateFolderForCap(_ cap: Int) { let oldCaps = Persistence.folderNotCreated guard !oldCaps.contains(cap) else { return } let newCaps = oldCaps + [cap] Persistence.folderNotCreated = newCaps } static func shouldCreateFolder(forCap cap: Int) -> Bool { return Persistence.folderNotCreated.contains(cap) } static func didCreateFolder(forCap cap: Int) { let oldCaps = Persistence.folderNotCreated guard oldCaps.contains(cap) else { return } Persistence.folderNotCreated = oldCaps.filter { $0 != cap } } } // MARK: - Protocol Hashable extension Cap: Hashable { static func == (lhs: Cap, rhs: Cap) -> Bool { return lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } } // MARK: - Protocol CustomStringConvertible extension Cap: CustomStringConvertible { var description: String { return "\(id);\(name);\(count);\(tile)\n" } } // MARK: - Protocol Logger extension Cap: Logger { static let logToken = "[CAP]" } // MARK: - String extension extension String { var clean: String { return lowercased().replacingOccurrences(of: "[^a-z0-9 ]", with: "", options: .regularExpression) } } // MARK: - Int extension private extension Int { var isEven: Bool { return self % 2 == 0 } }