629 lines
19 KiB
Swift
629 lines
19 KiB
Swift
//
|
|
// 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<Cap> {
|
|
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
|
|
}
|
|
}
|