// // Cap.swift // CapCollector // // Created by Christoph on 19.11.18. // Copyright © 2018 CH. All rights reserved. // import Foundation import UIKit import CoreImage 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 average color of the cap var color: UIColor? /// 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 } static func tileColor(tile: Int) -> UIColor? { return tiles[tile]?.averageColor } /** 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 } // 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) 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 || parts.count == 8 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 } if parts.count == 8 { guard let r = Int(parts[4]), let g = Int(parts[5]), let b = Int(parts[6]), let a = Int(parts[7]) else { Cap.error("Invalid color in line \(line)") return nil } self.color = UIColor(red: CGFloat(r)/255, green: CGFloat(g)/255, blue: CGFloat(b)/255, alpha: CGFloat(a)/255) } 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 { self.downloadImage { _ in Cap.delegate?.capHasUpdates(self) } 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() } var averageColor: UIColor? { if let c = color { return c } return makeAverageColor() } @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) { let path = imageFolderPath + "/\(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 main image to data for cap \(id)") 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") makeThumbnail() 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) } } private var imageFolderPath: String { return String(format: "/Images/%04d", id) } private static func imageFolderPath(for cap: Int) -> String { return String(format: "/Images/%04d", cap) } private func imageFilePath(imageId: Int) -> String { return imageFolderPath + "/\(id)-\(imageId).jpg" } // 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 = imageFolderPath(for: 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 = imageFolderPath(for: 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 } } } } func setMainImage(to imageId: Int, image: UIImage) { guard imageId != 0 else { self.event("No need to switch main image with itself") return } let tempFile = imageFilePath(imageId: count) let oldFile = imageFilePath(imageId: 0) let newFile = imageFilePath(imageId: imageId) DropboxController.shared.move(file: oldFile, to: tempFile) { success in guard success else { self.error("Could not move \(oldFile) to \(tempFile)") return } DropboxController.shared.move(file: newFile, to: oldFile) { success in guard success else { self.error("Could not move \(newFile) to \(oldFile)") return } DropboxController.shared.move(file: tempFile, to: newFile) { success in if !success { self.error("Could not move \(tempFile) to \(newFile)") } guard self.save(mainImage: image) else { return } self.event("Successfully set image \(imageId) to main image for cap \(self.id)") } } } } // 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) { let path = imageFolderPath DropboxController.client.files.listFolder(path: path).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: - Average color @discardableResult func makeAverageColor() -> UIColor? { guard let url = DiskManager.imageUrlForCap(id) else { event("No main image for cap \(id), no average color calculated") return nil } guard let inputImage = CIImage(contentsOf: url) else { error("Failed to read CIImage for main image of cap \(id)") return nil } let extentVector = CIVector(x: inputImage.extent.origin.x, y: inputImage.extent.origin.y, z: inputImage.extent.size.width, w: inputImage.extent.size.height) guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: extentVector]) else { error("Failed to create filter to calculate average for cap \(id)") return nil } guard let outputImage = filter.outputImage else { error("Failed get filter output for image of cap \(id)") return nil } var bitmap = [UInt8](repeating: 0, count: 4) let context = CIContext(options: [.workingColorSpace: kCFNull]) context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) color = UIColor( red: saturate(bitmap[0]), green: saturate(bitmap[1]), blue: saturate(bitmap[2]), alpha: CGFloat(bitmap[3]) / 255) event("Average color updated for cap \(id)") Cap.save() return color } } /// Map expected range 75-200 to 0-255 private func saturate(_ component: UInt8) -> CGFloat { return max(min(CGFloat(component) * 2 - 150, 255), 0) / 255 } // 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 { guard let c = color else { return String(format: "%04d", id) + ";\(name);\(count);\(tile)\n" } var fRed: CGFloat = 0 var fGreen: CGFloat = 0 var fBlue: CGFloat = 0 var fAlpha: CGFloat = 0 guard c.getRed(&fRed, green: &fGreen, blue: &fBlue, alpha: &fAlpha) else { return String(format: "%04d", id) + ";\(name);\(count);\(tile)\n" } let r = Int(fRed * 255.0) let g = Int(fGreen * 255.0) let b = Int(fBlue * 255.0) let a = Int(fAlpha * 255.0) return String(format: "%04d", id) + ";\(name);\(count);\(tile);\(r);\(g);\(b);\(a)\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 } }