// // 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 = 1 self.cleanName = name.clean guard save(mainImage: image) else { return nil } upload(mainImage: image) { success in guard success else { return } Cap.all[self.id] = self Cap.tiles[self.id] = self Cap.save() Cap.updateMosaicWithNewCap(id: self.id, image) Cap.delegate?.capHasUpdates(self) } } /** 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) { upload(image: image) { saved in guard saved else { completion(false) return } // Increment cap count self.count += 1 completion(true) } } // MARK: - Image upload private func upload(mainImage: UIImage, completion: @escaping (_ success: Bool) -> Void) { self.createFolder { created in guard created else { return } self.upload(image: mainImage, number: 0, savedCallback: completion) } } 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 func createFolder(completion: @escaping (_ success: Bool) -> Void) { // Create folder for cap let path = "/Images/\(id)" DropboxController.client.files.createFolderV2(path: path).response { _, error in if let err = error { self.event("Could not create folder for new cap \(self.id): \(err)") completion(false) } else { self.event("Created folder for new cap \(self.id)") 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.upload(url: url) { success in } } private static func upload(url: URL, completion: @escaping (Bool) -> Void) { let cap = Int(url.lastPathComponent.components(separatedBy: "-").first!)! 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 { upload(url: url) { 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) DispatchQueue.main.async { createAndSaveMosaic() } } } /// 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: - GridView private static func size(for tiles: Int) -> CGSize { let columns = CGFloat(mosaicColumns) // Add half of a cell due to row shift let width = (columns + 0.5) * mosaicCellSize let rows = (CGFloat(tiles) / columns).rounded(.up) // Add margin because the last row does not overlap let height = rows * mosaicRowHeight + mosaicMargin return CGSize(width: width, height: height) } static func origin(for tile: Int) -> CGPoint { let row = tile / mosaicColumns let column = tile - row * mosaicColumns let x = ( CGFloat(column) + (row.isEven ? 0 : 0.5) ) * mosaicCellSize let y = CGFloat(row) * mosaicRowHeight return CGPoint(x: x, y: y) } private static func makeTile(_ tile: Int, image: UIImage) -> RoundedImageView { let point = origin(for: tile) let frame = CGRect(origin: point, size: CGSize(width: mosaicCellSize, height: mosaicCellSize)) let view = RoundedImageView(frame: frame) view.image = image return view } private static func makeMosaicCanvas() -> UIView { let view = UIView(frame: CGRect(origin: .zero, size: size(for: Cap.totalCapCount))) view.backgroundColor = UIColor(red: 36/255, green: 36/255, blue: 36/255, alpha: 1) return view } private static func makeMosaic() -> UIImage? { let canvas = makeMosaicCanvas() for cap in Cap.all.values { if let img = cap.image { let view = makeTile(cap.id - 1, image: img) canvas.addSubview(view) } else { error("No image for cap \(cap.id)") } } return render(view: canvas) } private static func render(view: UIView) -> UIImage? { UIGraphicsBeginImageContextWithOptions(view.bounds.size, view.isOpaque, 0) defer { UIGraphicsEndImageContext() } view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) return UIGraphicsGetImageFromCurrentImageContext() } private static func createAndSaveMosaic() { guard !DiskManager.mosaicExists else { event("Mosaic already created") return } updateMosaic() } static var mosaic: UIImage? { guard let data = DiskManager.mosaicData else { error("No mosaic data on disk") return nil } guard let image = UIImage(data: data, scale: UIScreen.main.scale) else { error("Failed to create image from mosaic data") return nil } return image } private static func updateMosaicWithNewCap(id: Int, _ image: UIImage) { guard let old = mosaic else { return } let view = UIImageView(image: old) let canvas = makeMosaicCanvas() let tile = makeTile(id - 1, image: image) canvas.addSubview(view) canvas.addSubview(tile) guard let img = render(view: canvas) else { error("Failed to update mosaic for cap \(id)") return } saveMosaic(img) } static func tile(for point: CGPoint) -> Int? { let s = point.y.truncatingRemainder(dividingBy: mosaicRowHeight) let row = Int(point.y / mosaicRowHeight) guard s > mosaicMargin else { return nil } let column: CGFloat if row.isEven { column = point.x / mosaicCellSize } else { column = (point.x - mosaicCellSize / 2) / mosaicCellSize } return row * mosaicColumns + Int(column) } static func switchTilesInMosaic(_ mosaic: UIImageView, tile1: Int, tile2: Int) { let tileView1 = makeTile(tile1, image: Cap.tileImage(tile: tile1)!) let tileView2 = makeTile(tile2, image: Cap.tileImage(tile: tile2)!) mosaic.addSubview(tileView1) mosaic.addSubview(tileView2) defer { tileView1.removeFromSuperview() tileView2.removeFromSuperview() } guard let img = render(view: mosaic) else { error("Failed to switch \(tile1) and \(tile2) in mosaic") return } mosaic.image = img saveMosaic(img) } static func updateMosaic() { guard let image = makeMosaic() else { error("No mosaik image created") return } saveMosaic(image) } static func saveMosaic(_ image: UIImage) { guard let data = image.pngData() else { error("Failed to convert mosaic to data") return } guard DiskManager.saveMosaicData(data) else { error("Failed to write mosaic to disk") return } event("Mosaic saved") } } // 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 } }