Lots of updates
- Add unlock - Update Sorting menu - Prepare to load multiple tile images - New logging - Calculate thumbnails and colors before schowing grid
This commit is contained in:
@ -14,7 +14,7 @@ import SQLite
|
||||
|
||||
struct Cap {
|
||||
|
||||
// MARK: - Static variables
|
||||
// MARK: - Static constants
|
||||
|
||||
static let sufficientImageCount = 10
|
||||
|
||||
@ -37,9 +37,6 @@ struct Cap {
|
||||
/// The unique number of the cap
|
||||
let id: Int
|
||||
|
||||
/// The tile position of the cap
|
||||
let tile: Int
|
||||
|
||||
/// The name of the cap
|
||||
let name: String
|
||||
|
||||
@ -49,9 +46,6 @@ struct Cap {
|
||||
/// The number of images existing for the cap
|
||||
let count: Int
|
||||
|
||||
/// The average color of the cap
|
||||
let color: UIColor
|
||||
|
||||
/// Indicate if the cap can be found by the recognition model
|
||||
let matched: Bool
|
||||
|
||||
@ -60,174 +54,117 @@ struct Cap {
|
||||
|
||||
// MARK: Init
|
||||
|
||||
init(name: String, id: Int, color: UIColor) {
|
||||
init(name: String, id: Int) {
|
||||
self.id = id
|
||||
self.count = 1
|
||||
self.name = name
|
||||
self.cleanName = ""
|
||||
self.tile = id
|
||||
self.color = color
|
||||
self.matched = false
|
||||
self.uploaded = false
|
||||
}
|
||||
|
||||
// MARK: SQLite
|
||||
|
||||
static let table = Table("data")
|
||||
|
||||
static let createQuery: String = {
|
||||
table.create(ifNotExists: true) { t in
|
||||
t.column(rowId, primaryKey: true)
|
||||
t.column(rowName)
|
||||
t.column(rowCount)
|
||||
t.column(rowTile)
|
||||
t.column(rowRed)
|
||||
t.column(rowGreen)
|
||||
t.column(rowBlue)
|
||||
t.column(rowMatched)
|
||||
t.column(rowUploaded)
|
||||
}
|
||||
}()
|
||||
|
||||
static let rowId = Expression<Int>("id")
|
||||
|
||||
static let rowName = Expression<String>("name")
|
||||
|
||||
static let rowCount = Expression<Int>("count")
|
||||
|
||||
static let rowTile = Expression<Int>("tile")
|
||||
|
||||
static let rowRed = Expression<Int>("red")
|
||||
static let rowGreen = Expression<Int>("green")
|
||||
static let rowBlue = Expression<Int>("blue")
|
||||
|
||||
static let rowMatched = Expression<Bool>("matched")
|
||||
|
||||
static let rowUploaded = Expression<Bool>("uploaded")
|
||||
|
||||
init(row: Row) {
|
||||
self.id = row[Cap.rowId]
|
||||
self.name = row[Cap.rowName]
|
||||
self.count = row[Cap.rowCount]
|
||||
self.tile = row[Cap.rowTile]
|
||||
self.cleanName = name.clean
|
||||
self.matched = row[Cap.rowMatched]
|
||||
self.uploaded = row[Cap.rowUploaded]
|
||||
|
||||
let r = CGFloat(row[Cap.rowRed]) / 255
|
||||
let g = CGFloat(row[Cap.rowGreen]) / 255
|
||||
let b = CGFloat(row[Cap.rowBlue]) / 255
|
||||
self.color = UIColor(red: r, green: g, blue: b, alpha: 1.0)
|
||||
}
|
||||
|
||||
init(id: Int, name: String, count: Int) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.count = count
|
||||
self.tile = id - 1
|
||||
self.cleanName = name.clean
|
||||
self.matched = false
|
||||
self.color = UIColor.gray
|
||||
self.uploaded = false
|
||||
self.uploaded = true
|
||||
}
|
||||
|
||||
func renamed(to name: String) -> Cap {
|
||||
Cap(from: self, renamed: name)
|
||||
}
|
||||
|
||||
init(from cap: Cap, renamed newName: String) {
|
||||
self.id = cap.id
|
||||
self.count = cap.count
|
||||
self.name = newName
|
||||
self.cleanName = newName.clean
|
||||
self.matched = cap.matched
|
||||
self.uploaded = cap.uploaded
|
||||
}
|
||||
|
||||
// MARK: SQLite
|
||||
|
||||
init(row: Row) {
|
||||
self.id = row[Cap.columnId]
|
||||
self.name = row[Cap.columnName]
|
||||
self.count = row[Cap.columnCount]
|
||||
self.cleanName = name.clean
|
||||
self.matched = row[Cap.columnMatched]
|
||||
self.uploaded = row[Cap.columnUploaded]
|
||||
|
||||
}
|
||||
|
||||
static let table = Table("data")
|
||||
|
||||
static var createQuery: String {
|
||||
table.create(ifNotExists: true) { t in
|
||||
t.column(columnId, primaryKey: true)
|
||||
t.column(columnName)
|
||||
t.column(columnCount)
|
||||
t.column(columnMatched)
|
||||
t.column(columnUploaded)
|
||||
}
|
||||
}
|
||||
|
||||
static let columnId = Expression<Int>("id")
|
||||
|
||||
static let columnName = Expression<String>("name")
|
||||
|
||||
static let columnCount = Expression<Int>("count")
|
||||
|
||||
static let columnMatched = Expression<Bool>("matched")
|
||||
|
||||
static let columnUploaded = Expression<Bool>("uploaded")
|
||||
|
||||
var insertQuery: Insert {
|
||||
let colors = color.rgb
|
||||
return Cap.table.insert(
|
||||
Cap.rowId <- id,
|
||||
Cap.rowName <- name,
|
||||
Cap.rowCount <- count,
|
||||
Cap.rowTile <- tile,
|
||||
Cap.rowRed <- colors.red,
|
||||
Cap.rowGreen <- colors.green,
|
||||
Cap.rowBlue <- colors.blue,
|
||||
Cap.rowMatched <- matched,
|
||||
Cap.rowUploaded <- uploaded)
|
||||
Cap.columnId <- id,
|
||||
Cap.columnName <- name,
|
||||
Cap.columnCount <- count,
|
||||
Cap.columnMatched <- matched,
|
||||
Cap.columnUploaded <- uploaded)
|
||||
}
|
||||
|
||||
// MARK: Text
|
||||
// MARK: Display
|
||||
|
||||
func matchDescription(match: Float?) -> String {
|
||||
guard let match = match else {
|
||||
return hasSufficientImages ? "" : "⚠️"
|
||||
func matchLabelText(match: Float?, appIsUnlocked: Bool) -> String {
|
||||
if let match = match {
|
||||
let percent = Int((match * 100).rounded())
|
||||
return String(format: "%d %%", arguments: [percent])
|
||||
}
|
||||
let percent = Int((match * 100).rounded())
|
||||
return String(format: "%d %%", arguments: [percent])
|
||||
|
||||
guard matched else {
|
||||
return "📵"
|
||||
}
|
||||
guard appIsUnlocked, !hasSufficientImages else {
|
||||
return ""
|
||||
}
|
||||
return "⚠️"
|
||||
}
|
||||
|
||||
/// The cap id and the number of images
|
||||
var subtitle: String {
|
||||
func countLabelText(appIsUnlocked: Bool) -> String {
|
||||
guard appIsUnlocked else {
|
||||
return "\(id)"
|
||||
}
|
||||
guard count != 1 else {
|
||||
return "\(id) (1 image)"
|
||||
}
|
||||
return "\(id) (\(count) images)"
|
||||
}
|
||||
|
||||
// MARK: - Images
|
||||
// MARK: Images
|
||||
|
||||
var hasSufficientImages: Bool {
|
||||
count > Cap.sufficientImageCount
|
||||
}
|
||||
|
||||
var hasImage: Bool {
|
||||
app.storage.hasImage(for: id)
|
||||
}
|
||||
|
||||
/// The main image of the cap
|
||||
var image: UIImage? {
|
||||
app.storage.image(for: id)
|
||||
}
|
||||
|
||||
/// The main image of the cap
|
||||
var thumbnail: UIImage? {
|
||||
app.storage.thumbnail(for: id)
|
||||
count >= Cap.sufficientImageCount
|
||||
}
|
||||
|
||||
static func thumbnail(for image: UIImage) -> UIImage {
|
||||
let len = GridViewController.len * 2
|
||||
return image.resize(to: CGSize.init(width: len, height: len))
|
||||
}
|
||||
|
||||
func updateLocalThumbnail() {
|
||||
guard let img = image else {
|
||||
return
|
||||
}
|
||||
let thumbnail = Cap.thumbnail(for: img)
|
||||
guard app.storage.save(thumbnail: thumbnail, for: id) else {
|
||||
error("Failed to save thumbnail")
|
||||
return
|
||||
}
|
||||
log("Created thumbnail for cap \(id)")
|
||||
}
|
||||
|
||||
func updateLocalColor() {
|
||||
guard let color = image?.averageColor else {
|
||||
return
|
||||
}
|
||||
app.database.update(color: color, for: id)
|
||||
}
|
||||
|
||||
/**
|
||||
Download the main image of the cap.
|
||||
- Note: The downloaded image is automatically saved to disk
|
||||
- returns: `true`, if the image will be downloaded, `false`, if the image is already being downloaded.
|
||||
*/
|
||||
@discardableResult
|
||||
func downloadMainImage(completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
app.database.downloadMainImage(for: id, completion: completion)
|
||||
}
|
||||
|
||||
/**
|
||||
Download a specified image of the cap.
|
||||
- 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
|
||||
- returns: `true`, if the image will be downloaded, `false`, if the image is already being downloaded.
|
||||
*/
|
||||
func downloadImage(_ number: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
app.database.downloadImage(for: id, version: number, completion: completion)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Protocol Hashable
|
||||
@ -241,17 +178,6 @@ extension Cap: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Protocol CustomStringConvertible
|
||||
|
||||
extension Cap: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
let rgb = color.rgb
|
||||
return String(format: "%04d", id) + ";\(name);\(count);\(tile);\(rgb.red);\(rgb.green);\(rgb.blue)\n"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protocol Logger
|
||||
@ -261,6 +187,7 @@ extension Cap: Logger { }
|
||||
// MARK: - String extension
|
||||
|
||||
extension String {
|
||||
|
||||
var clean: String {
|
||||
return lowercased().replacingOccurrences(of: "[^a-z0-9 ]", with: "", options: .regularExpression)
|
||||
}
|
||||
|
@ -59,10 +59,6 @@ class Classifier: Logger {
|
||||
let matches = result.reduce(into: [:]) { $0[Int($1.identifier)!] = $1.confidence }
|
||||
|
||||
log("Classifed image with \(matches.count) classes")
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
app.database.update(recognizedCaps: Set(matches.keys))
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
}
|
||||
|
132
CapCollector/Data/Colors.swift
Normal file
132
CapCollector/Data/Colors.swift
Normal file
@ -0,0 +1,132 @@
|
||||
//
|
||||
// Colors.swift
|
||||
// CapCollector
|
||||
//
|
||||
// Created by Christoph on 26.05.20.
|
||||
// Copyright © 2020 CH. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SQLite
|
||||
|
||||
extension Database {
|
||||
|
||||
enum Colors {
|
||||
|
||||
static let table = Table("colors")
|
||||
|
||||
static let columnRed = Expression<Double>("red")
|
||||
|
||||
static let columnGreen = Expression<Double>("green")
|
||||
|
||||
static let columnBlue = Expression<Double>("blue")
|
||||
|
||||
static var createQuery: String {
|
||||
table.create(ifNotExists: true) { t in
|
||||
t.column(Cap.columnId, primaryKey: true)
|
||||
t.column(columnRed)
|
||||
t.column(columnGreen)
|
||||
t.column(columnBlue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var colors: [Int : UIColor] {
|
||||
do {
|
||||
let rows = try db.prepare(Database.Colors.table)
|
||||
return rows.reduce(into: [:]) { dict, row in
|
||||
let id = row[Cap.columnId]
|
||||
let r = CGFloat(row[Database.Colors.columnRed])
|
||||
let g = CGFloat(row[Database.Colors.columnGreen])
|
||||
let b = CGFloat(row[Database.Colors.columnBlue])
|
||||
dict[id] = UIColor(red: r, green: g, blue: b, alpha: 1.0)
|
||||
}
|
||||
} catch {
|
||||
log("Failed to load cap colors: \(error)")
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
var capsWithColors: Set<Int> {
|
||||
do {
|
||||
let rows = try db.prepare(Database.Colors.table.select(Cap.columnId))
|
||||
return Set(rows.map { $0[Cap.columnId]})
|
||||
} catch {
|
||||
log("Failed to load caps with colors: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
var capsWithoutColors: Set<Int> {
|
||||
Set(1...capCount).subtracting(capsWithColors)
|
||||
}
|
||||
|
||||
func removeColor(for cap: Int) -> Bool {
|
||||
do {
|
||||
try db.run(Colors.table.filter(Cap.columnId == cap).delete())
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to delete cap color \(cap): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func set(color: UIColor, for cap: Int) -> Bool {
|
||||
guard let _ = row(for: cap) else {
|
||||
return insert(color: color, for: cap)
|
||||
}
|
||||
return update(color: color, for: cap)
|
||||
}
|
||||
|
||||
private func insert(color: UIColor, for cap: Int) -> Bool {
|
||||
let (red, green, blue) = color.rgb
|
||||
let query = Database.Colors.table.insert(
|
||||
Cap.columnId <- cap,
|
||||
Database.Colors.columnRed <- red,
|
||||
Database.Colors.columnGreen <- green,
|
||||
Database.Colors.columnBlue <- blue)
|
||||
|
||||
do {
|
||||
try db.run(query)
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to insert color for cap \(cap): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func update(color: UIColor, for cap: Int) -> Bool {
|
||||
let (red, green, blue) = color.rgb
|
||||
let query = Database.Colors.table.filter(Cap.columnId == cap).update(
|
||||
Database.Colors.columnRed <- red,
|
||||
Database.Colors.columnGreen <- green,
|
||||
Database.Colors.columnBlue <- blue)
|
||||
|
||||
do {
|
||||
try db.run(query)
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to update color for cap \(cap): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for cap: Int) -> Row? {
|
||||
do {
|
||||
return try db.pluck(Database.Colors.table.filter(Cap.columnId == cap))
|
||||
} catch {
|
||||
log("Failed to get color for cap \(cap): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func color(for cap: Int) -> UIColor? {
|
||||
guard let row = self.row(for: cap) else {
|
||||
return nil
|
||||
}
|
||||
let r = CGFloat(row[Database.Colors.columnRed])
|
||||
let g = CGFloat(row[Database.Colors.columnGreen])
|
||||
let b = CGFloat(row[Database.Colors.columnBlue])
|
||||
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
|
||||
}
|
||||
}
|
@ -13,27 +13,13 @@ import SQLite
|
||||
|
||||
protocol DatabaseDelegate: class {
|
||||
|
||||
func database(didChangeCap cap: Int)
|
||||
|
||||
func database(didAddCap cap: Cap)
|
||||
|
||||
func databaseRequiresFullRefresh()
|
||||
}
|
||||
|
||||
struct Weak {
|
||||
func database(didChangeCap cap: Int)
|
||||
|
||||
weak var value : DatabaseDelegate?
|
||||
func database(didLoadImageForCap cap: Int)
|
||||
|
||||
init (_ value: DatabaseDelegate) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == Weak {
|
||||
|
||||
mutating func reap () {
|
||||
self = self.filter { $0.value != nil }
|
||||
}
|
||||
func databaseNeedsFullRefresh()
|
||||
}
|
||||
|
||||
final class Database {
|
||||
@ -46,14 +32,8 @@ final class Database {
|
||||
|
||||
let download: Download
|
||||
|
||||
private var listeners = [Weak]()
|
||||
|
||||
// MARK: Listeners
|
||||
|
||||
func add(listener: DatabaseDelegate) {
|
||||
listeners.append(Weak(listener))
|
||||
}
|
||||
|
||||
weak var delegate: DatabaseDelegate?
|
||||
|
||||
init?(url: URL, server: URL) {
|
||||
guard let db = try? Connection(url.path) else {
|
||||
return nil
|
||||
@ -65,6 +45,8 @@ final class Database {
|
||||
do {
|
||||
try db.run(Cap.createQuery)
|
||||
try db.run(upload.createQuery)
|
||||
try db.run(Database.Colors.createQuery)
|
||||
try db.run(Database.TileImage.createQuery)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@ -82,15 +64,20 @@ final class Database {
|
||||
(try? db.prepare(Cap.table))?.map(Cap.init) ?? []
|
||||
}
|
||||
|
||||
/// A dictionary of all caps, indexed by their ids
|
||||
var capDict: [Int : Cap] {
|
||||
caps.reduce(into: [:]) { $0[$1.id] = $1 }
|
||||
}
|
||||
|
||||
/// The ids of the caps which weren't included in the last classification
|
||||
var unmatchedCaps: [Int] {
|
||||
let query = Cap.table.select(Cap.rowId).filter(Cap.rowMatched == false)
|
||||
return (try? db.prepare(query).map { $0[Cap.rowId] }) ?? []
|
||||
let query = Cap.table.select(Cap.columnId).filter(Cap.columnMatched == false)
|
||||
return (try? db.prepare(query).map { $0[Cap.columnId] }) ?? []
|
||||
}
|
||||
|
||||
/// The number of caps which could be recognized during the last classification
|
||||
var recognizedCapCount: Int {
|
||||
(try? db.scalar(Cap.table.filter(Cap.rowMatched == true).count)) ?? 0
|
||||
(try? db.scalar(Cap.table.filter(Cap.columnMatched == true).count)) ?? 0
|
||||
}
|
||||
|
||||
/// The number of caps currently in the database
|
||||
@ -100,32 +87,54 @@ final class Database {
|
||||
|
||||
/// The total number of images for all caps
|
||||
var imageCount: Int {
|
||||
(try? db.prepare(Cap.table).reduce(0) { $0 + $1[Cap.rowCount] }) ?? 0
|
||||
(try? db.prepare(Cap.table).reduce(0) { $0 + $1[Cap.columnCount] }) ?? 0
|
||||
}
|
||||
|
||||
/// The caps without a downloaded image
|
||||
var capsWithoutImages: [Cap] {
|
||||
caps.filter({ !app.storage.hasImage(for: $0.id) })
|
||||
}
|
||||
|
||||
/// The number of caps without a downloaded image
|
||||
var capsWithoutImages: Int {
|
||||
caps.filter({ !$0.hasImage }).count
|
||||
var capCountWithoutImages: Int {
|
||||
capsWithoutImages.count
|
||||
}
|
||||
|
||||
/// The caps without a downloaded image
|
||||
var capsWithoutThumbnails: [Cap] {
|
||||
caps.filter({ !app.storage.hasThumbnail(for: $0.id) })
|
||||
}
|
||||
|
||||
/// The number of caps without a downloaded image
|
||||
var capCountWithoutThumbnails: Int {
|
||||
capsWithoutThumbnails.count
|
||||
}
|
||||
|
||||
|
||||
var pendingUploads: [(cap: Int, version: Int)] {
|
||||
var pendingImageUploads: [(cap: Int, version: Int)] {
|
||||
do {
|
||||
return try db.prepare(upload.table).map { row in
|
||||
(cap: row[upload.rowCapId], version: row[upload.rowCapVersion])
|
||||
}
|
||||
} catch {
|
||||
log("Failed to get pending uploads")
|
||||
log("Failed to get pending image uploads")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicate if there are any unfinished uploads
|
||||
var hasPendingUploads: Bool {
|
||||
var hasPendingImageUploads: Bool {
|
||||
((try? db.scalar(upload.table.count)) ?? 0) > 0
|
||||
}
|
||||
|
||||
var pendingCapUploads: [Cap] {
|
||||
do {
|
||||
return try db.prepare(Cap.table.filter(Cap.columnUploaded == false)).map(Cap.init)
|
||||
} catch {
|
||||
log("Failed to get pending cap uploads")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
var classifierVersion: Int {
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: Classifier.userDefaultsKey)
|
||||
@ -150,29 +159,27 @@ final class Database {
|
||||
- returns: `true`, if the cap was created.
|
||||
*/
|
||||
func createCap(image: UIImage, name: String) -> Bool {
|
||||
guard let color = image.averageColor else {
|
||||
return false
|
||||
}
|
||||
let cap = Cap(name: name, id: capCount, color: color)
|
||||
let cap = Cap(name: name, id: capCount + 1)
|
||||
guard insert(cap: cap) else {
|
||||
log("Cap not inserted")
|
||||
return false
|
||||
}
|
||||
guard app.storage.save(image: image, for: cap.id) else {
|
||||
log("Cap image not saved")
|
||||
return false
|
||||
}
|
||||
listeners.forEach { $0.value?.database(didAddCap: cap) }
|
||||
upload.upload(name: name, for: cap.id) { success in
|
||||
guard success else {
|
||||
return
|
||||
}
|
||||
self.update(uploaded: true, for: cap.id)
|
||||
self.upload.uploadImage(for: cap.id, version: 0) { count in
|
||||
guard let count = count else {
|
||||
self.upload.uploadImage(for: cap.id, version: 0) { actualVersion in
|
||||
guard let actualVersion = actualVersion else {
|
||||
self.log("Failed to upload first image for cap \(cap.id)")
|
||||
return
|
||||
}
|
||||
self.log("Uploaded first image for cap \(cap.id)")
|
||||
self.update(count: count, for: cap.id)
|
||||
self.update(count: actualVersion + 1, for: cap.id)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@ -185,11 +192,13 @@ final class Database {
|
||||
- note: When a new cap is created, use `createCap(image:name:)` instead
|
||||
*/
|
||||
@discardableResult
|
||||
private func insert(cap: Cap, notifyDelegate: Bool = true) -> Bool {
|
||||
private func insert(cap: Cap, notify: Bool = true) -> Bool {
|
||||
do {
|
||||
try db.run(cap.insertQuery)
|
||||
if notifyDelegate {
|
||||
listeners.forEach { $0.value?.database(didAddCap: cap) }
|
||||
if notify {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.database(didAddCap: cap)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
@ -211,14 +220,12 @@ final class Database {
|
||||
log("Failed update count \(version) for cap \(cap)")
|
||||
return false
|
||||
}
|
||||
listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
|
||||
guard addPendingUpload(for: cap, version: version) else {
|
||||
log("Failed to add cap \(cap) version \(version) to upload queue")
|
||||
return false
|
||||
}
|
||||
upload.uploadImage(for: cap, version: version) { count in
|
||||
guard let _ = count else {
|
||||
upload.uploadImage(for: cap, version: version) { actualVersion in
|
||||
guard let actualVersion = actualVersion else {
|
||||
self.log("Failed to upload image \(version) for cap \(cap)")
|
||||
return
|
||||
}
|
||||
@ -226,18 +233,23 @@ final class Database {
|
||||
self.log("Failed to remove version \(version) for cap \(cap) from upload queue")
|
||||
return
|
||||
}
|
||||
self.log("Uploaded version \(version) for cap \(cap)")
|
||||
self.log("Uploaded version \(actualVersion) for cap \(cap)")
|
||||
self.update(count: actualVersion + 1, for: cap)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Updating cap properties
|
||||
|
||||
private func update(_ property: String, for cap: Int, setter: Setter...) -> Bool {
|
||||
private func update(_ property: String, for cap: Int, notify: Bool = true, setter: Setter...) -> Bool {
|
||||
do {
|
||||
let query = updateQuery(for: cap).update(setter)
|
||||
try db.run(query)
|
||||
listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
if notify {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.database(didChangeCap: cap)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to update \(property) for cap \(cap): \(error)")
|
||||
@ -247,33 +259,36 @@ final class Database {
|
||||
|
||||
@discardableResult
|
||||
private func update(uploaded: Bool, for cap: Int) -> Bool {
|
||||
update("uploaded", for: cap, setter: Cap.rowUploaded <- uploaded)
|
||||
update("uploaded", for: cap, setter: Cap.columnUploaded <- uploaded)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func update(count: Int, for cap: Int) -> Bool {
|
||||
update("count", for: cap, setter: Cap.columnCount <- count)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func update(matched: Bool, for cap: Int) -> Bool {
|
||||
update("matched", for: cap, setter: Cap.columnMatched <- matched)
|
||||
}
|
||||
|
||||
// MARK: External editing
|
||||
|
||||
/**
|
||||
Update the `name` of a cap.
|
||||
*/
|
||||
@discardableResult
|
||||
func update(name: String, for cap: Int) -> Bool {
|
||||
update("name", for: cap, setter: Cap.rowName <- name)
|
||||
guard update("name", for: cap, setter: Cap.columnName <- name, Cap.columnUploaded <- false) else {
|
||||
return false
|
||||
}
|
||||
uploadRemainingCaps()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(color: UIColor, for cap: Int) -> Bool {
|
||||
let (red, green, blue) = color.rgb
|
||||
return update("color", for: cap, setter: Cap.rowRed <- red, Cap.rowGreen <- green, Cap.rowBlue <- blue)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(tile: Int, for cap: Int) -> Bool {
|
||||
update("tile", for: cap, setter: Cap.rowTile <- tile)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(count: Int, for cap: Int) -> Bool {
|
||||
update("count", for: cap, setter: Cap.rowCount <- count)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(matched: Bool, for cap: Int) -> Bool {
|
||||
update("matched", for: cap, setter: Cap.rowMatched <- matched)
|
||||
private func updateWithoutUpload(name: String, for cap: Int) -> Bool {
|
||||
update("name", for: cap, notify: false, setter: Cap.columnName <- name)
|
||||
}
|
||||
|
||||
func update(recognizedCaps: Set<Int>) {
|
||||
@ -298,7 +313,9 @@ final class Database {
|
||||
}
|
||||
}
|
||||
|
||||
func addPendingUpload(for cap: Int, version: Int) -> Bool {
|
||||
// MARK: Uploads
|
||||
|
||||
private func addPendingUpload(for cap: Int, version: Int) -> Bool {
|
||||
do {
|
||||
try db.run(upload.insertQuery(for: cap, version: version))
|
||||
return true
|
||||
@ -308,7 +325,7 @@ final class Database {
|
||||
}
|
||||
}
|
||||
|
||||
func removePendingUpload(for cap: Int, version: Int) -> Bool {
|
||||
private func removePendingUpload(for cap: Int, version: Int) -> Bool {
|
||||
do {
|
||||
try db.run(upload.deleteQuery(for: cap, version: version))
|
||||
return true
|
||||
@ -335,8 +352,8 @@ final class Database {
|
||||
|
||||
private func count(for cap: Int) -> Int? {
|
||||
do {
|
||||
let row = try db.pluck(updateQuery(for: cap).select(Cap.rowCount))
|
||||
return row?[Cap.rowCount]
|
||||
let row = try db.pluck(updateQuery(for: cap).select(Cap.columnCount))
|
||||
return row?[Cap.columnCount]
|
||||
} catch {
|
||||
log("Failed to get count for cap \(cap)")
|
||||
return nil
|
||||
@ -345,7 +362,7 @@ final class Database {
|
||||
|
||||
func countOfCaps(withImageCountLessThan limit: Int) -> Int {
|
||||
do {
|
||||
return try db.scalar(Cap.table.filter(Cap.rowCount < limit).count)
|
||||
return try db.scalar(Cap.table.filter(Cap.columnCount < limit).count)
|
||||
} catch {
|
||||
log("Failed to get caps with less than \(limit) images")
|
||||
return 0
|
||||
@ -358,7 +375,7 @@ final class Database {
|
||||
var capsFound = 0
|
||||
repeat {
|
||||
currentCount += 1
|
||||
capsFound = try db.scalar(Cap.table.filter(Cap.rowCount == currentCount).count)
|
||||
capsFound = try db.scalar(Cap.table.filter(Cap.columnCount == currentCount).count)
|
||||
} while capsFound == 0
|
||||
|
||||
return (currentCount, capsFound)
|
||||
@ -368,58 +385,77 @@ final class Database {
|
||||
}
|
||||
|
||||
func updateQuery(for cap: Int) -> Table {
|
||||
Cap.table.filter(Cap.rowId == cap)
|
||||
Cap.table.filter(Cap.columnId == cap)
|
||||
}
|
||||
|
||||
// MARK: Downloads
|
||||
|
||||
@discardableResult
|
||||
func downloadMainImage(for cap: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
return download.image(for: cap, version: 0) { image in
|
||||
// Guaranteed to be on the main queue
|
||||
guard let image = image else {
|
||||
completion(nil)
|
||||
func downloadMainImage(for cap: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
|
||||
return download.mainImage(for: cap) { success in
|
||||
guard success else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
defer {
|
||||
completion(image)
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.database(didLoadImageForCap: cap)
|
||||
}
|
||||
if !app.storage.save(thumbnail: Cap.thumbnail(for: image), for: cap) {
|
||||
self.log("Failed to save thumbnail for cap \(cap)")
|
||||
}
|
||||
guard let color = image.averageColor else {
|
||||
self.log("Failed to calculate color for cap \(cap)")
|
||||
return
|
||||
}
|
||||
self.update(color: color, for: cap)
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func downloadImage(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
func downloadImage(for cap: Int, version: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
|
||||
return download.image(for: cap, version: version, completion: completion)
|
||||
}
|
||||
|
||||
func getServerDatabaseSize(completion: @escaping (_ size: Int64?) -> Void) {
|
||||
download.databaseSize(completion: completion)
|
||||
}
|
||||
|
||||
func downloadServerDatabase(progress: Download.Delegate.ProgressHandler? = nil, completion: @escaping (_ success: Bool) -> Void, processed: (() -> Void)? = nil) {
|
||||
download.database(progress: progress) { tempUrl in
|
||||
guard let url = tempUrl else {
|
||||
self.log("Failed to download database")
|
||||
completion(false)
|
||||
func downloadCapNames(completion: @escaping (_ success: Bool) -> Void) {
|
||||
log("Downloading cap names")
|
||||
download.names { names in
|
||||
guard let names = names else {
|
||||
DispatchQueue.main.async {
|
||||
completion(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
completion(true)
|
||||
self.processServerDatabase(at: url)
|
||||
processed?()
|
||||
self.update(names: names)
|
||||
DispatchQueue.main.async {
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func update(names: [String]) {
|
||||
let notify = capCount > 0
|
||||
log("Downloaded cap names (initialDownload: \(!notify))")
|
||||
let caps = self.capDict
|
||||
let changed: [Int] = names.enumerated().compactMap { id, name in
|
||||
let id = id + 1
|
||||
guard let existingName = caps[id]?.name else {
|
||||
// Insert cap
|
||||
let cap = Cap(id: id, name: name, count: 0)
|
||||
guard insert(cap: cap, notify: notify) else {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
guard existingName != name else {
|
||||
// Name unchanged
|
||||
return nil
|
||||
}
|
||||
guard updateWithoutUpload(name: name, for: id) else {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
if !notify {
|
||||
log("Added \(changed.count) new caps after initial download")
|
||||
delegate?.databaseNeedsFullRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadMainCapImages(progress: @escaping (_ current: Int, _ total: Int) -> Void) {
|
||||
let caps = self.caps.filter { !$0.hasImage }.map { $0.id }
|
||||
let caps = capsWithoutImages.map { $0.id }
|
||||
|
||||
var downloaded = 0
|
||||
let total = caps.count
|
||||
@ -458,7 +494,6 @@ final class Database {
|
||||
}
|
||||
self.log("Finished all image downloads")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func hasNewClassifier(completion: @escaping (_ version: Int?, _ size: Int64?) -> Void) {
|
||||
@ -474,7 +509,7 @@ final class Database {
|
||||
completion(nil, nil)
|
||||
return
|
||||
}
|
||||
self.log("Getting classifier size: Own version \(ownVersion), server version \(version)")
|
||||
self.log("Getting size of classifier \(version)")
|
||||
self.download.classifierSize { size in
|
||||
completion(version, size)
|
||||
}
|
||||
@ -513,37 +548,50 @@ final class Database {
|
||||
}
|
||||
}
|
||||
|
||||
func downloadImageCounts() {
|
||||
guard !hasPendingUploads else {
|
||||
log("Waiting to refresh server image counts (uploads pending)")
|
||||
return
|
||||
}
|
||||
func downloadImageCounts(completion: @escaping (_ success: Bool) -> Void) {
|
||||
log("Refreshing server image counts")
|
||||
app.database.download.imageCounts { counts in
|
||||
download.imageCounts { counts in
|
||||
guard let counts = counts else {
|
||||
self.log("Failed to download server image counts")
|
||||
DispatchQueue.main.async {
|
||||
completion(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.didDownload(imageCounts: counts)
|
||||
let newCaps = self.didDownload(imageCounts: counts)
|
||||
|
||||
guard newCaps.count > 0 else {
|
||||
DispatchQueue.main.async {
|
||||
completion(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.log("Found \(newCaps.count) new caps on the server.")
|
||||
self.downloadInfo(for: newCaps) { success in
|
||||
DispatchQueue.main.async {
|
||||
completion(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func didDownload(imageCounts newCounts: [(cap: Int, count: Int)]) {
|
||||
let capsCounts = self.caps.reduce(into: [:]) { $0[$1.id] = $1.count }
|
||||
private func didDownload(imageCounts newCounts: [Int]) -> [Int : Int] {
|
||||
let capsCounts = capDict
|
||||
if newCounts.count != capsCounts.count {
|
||||
log("Downloaded \(newCounts.count) image counts, but \(app.database.capCount) caps stored locally")
|
||||
return
|
||||
log("Downloaded \(newCounts.count) image counts, but \(capsCounts.count) caps stored locally")
|
||||
}
|
||||
let changed = newCounts.compactMap { id, newCount -> Int? in
|
||||
guard let oldCount = capsCounts[id] else {
|
||||
var newCaps = [Int : Int]()
|
||||
let changed = newCounts.enumerated().compactMap { id, newCount -> Int? in
|
||||
let id = id + 1
|
||||
guard let oldCount = capsCounts[id]?.count else {
|
||||
log("Received count \(newCount) for unknown cap \(id)")
|
||||
newCaps[id] = newCount
|
||||
return nil
|
||||
}
|
||||
guard oldCount != newCount else {
|
||||
return nil
|
||||
}
|
||||
app.database.update(count: newCount, for: id)
|
||||
self.update(count: newCount, for: id)
|
||||
return id
|
||||
}
|
||||
switch changed.count {
|
||||
@ -556,28 +604,72 @@ final class Database {
|
||||
default:
|
||||
log("Refreshed image counts for all caps (\(changed.count) changed)")
|
||||
}
|
||||
|
||||
return newCaps
|
||||
}
|
||||
|
||||
private func downloadInfo(for newCaps: [Int : Int], completion: @escaping (_ success: Bool) -> Void) {
|
||||
var success = true
|
||||
let group = DispatchGroup()
|
||||
for (id, count) in newCaps {
|
||||
group.enter()
|
||||
download.name(for: id) { name in
|
||||
guard let name = name else {
|
||||
self.log("Failed to get name for new cap \(id)")
|
||||
success = false
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
let cap = Cap(id: id, name: name, count: count)
|
||||
self.insert(cap: cap)
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
if group.wait(timeout: .now() + .seconds(30)) != .success {
|
||||
self.log("Timed out waiting for images to be downloaded")
|
||||
}
|
||||
completion(success)
|
||||
}
|
||||
|
||||
func downloadImageCount(for cap: Int) {
|
||||
download.imageCount(for: cap) { count in
|
||||
guard let count = count else {
|
||||
return
|
||||
}
|
||||
self.update(count: count, for: cap)
|
||||
}
|
||||
}
|
||||
|
||||
private func processServerDatabase(at url: URL) {
|
||||
guard let db = ServerDatabase(downloadedTo: url) else {
|
||||
log("Failed to open downloaded server database")
|
||||
func uploadRemainingCaps() {
|
||||
let uploads = self.pendingCapUploads
|
||||
guard uploads.count > 0 else {
|
||||
log("No pending cap uploads")
|
||||
return
|
||||
}
|
||||
for (id, count, name) in db.caps {
|
||||
let cap = Cap(id: id, name: name, count: count)
|
||||
insert(cap: cap, notifyDelegate: false)
|
||||
log("\(uploads.count) cap uploads pending")
|
||||
|
||||
for cap in uploads {
|
||||
upload.upload(name: cap.name, for: cap.id) { success in
|
||||
guard success else {
|
||||
self.log("Failed to upload cap \(cap.id)")
|
||||
return
|
||||
}
|
||||
self.log("Uploaded cap \(cap.id)")
|
||||
self.update(uploaded: true, for: cap.id)
|
||||
}
|
||||
}
|
||||
listeners.forEach { $0.value?.databaseRequiresFullRefresh() }
|
||||
}
|
||||
|
||||
func uploadRemainingImages() {
|
||||
guard pendingUploads.count > 0 else {
|
||||
log("No pending uploads")
|
||||
let uploads = pendingImageUploads
|
||||
guard uploads.count > 0 else {
|
||||
log("No pending image uploads")
|
||||
return
|
||||
}
|
||||
log("\(pendingUploads.count) image uploads pending")
|
||||
log("\(uploads.count) image uploads pending")
|
||||
|
||||
for (cap, version) in pendingUploads {
|
||||
for (cap, version) in uploads {
|
||||
upload.uploadImage(for: cap, version: version) { count in
|
||||
guard let _ = count else {
|
||||
self.log("Failed to upload version \(version) of cap \(cap)")
|
||||
@ -607,13 +699,19 @@ final class Database {
|
||||
log("No need to switch main image with itself for cap \(cap)")
|
||||
return
|
||||
}
|
||||
upload.setMainImage(for: cap, to: version) { color in
|
||||
guard let color = color else {
|
||||
upload.setMainImage(for: cap, to: version) { success in
|
||||
guard success else {
|
||||
self.log("Could not make \(version) the main image for cap \(cap)")
|
||||
return
|
||||
}
|
||||
self.update(color: color, for: cap)
|
||||
self.listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
guard app.storage.switchMainImage(to: version, for: cap) else {
|
||||
self.log("Could not switch \(version) to main image for cap \(cap)")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.database(didLoadImageForCap: cap)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -23,20 +23,32 @@ final class Download {
|
||||
let delegate = Delegate()
|
||||
|
||||
self.serverUrl = server
|
||||
self.session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
|
||||
self.session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
|
||||
self.delegate = delegate
|
||||
}
|
||||
|
||||
// MARK: Paths
|
||||
|
||||
private static func serverDatabaseUrl(server: URL) -> URL {
|
||||
server.appendingPathComponent("db.sqlite3")
|
||||
}
|
||||
|
||||
var serverDatabaseUrl: URL {
|
||||
Download.serverDatabaseUrl(server: serverUrl)
|
||||
var serverNameListUrl: URL {
|
||||
Download.serverNameListUrl(server: serverUrl)
|
||||
}
|
||||
|
||||
private static func serverNameListUrl(server: URL) -> URL {
|
||||
server.appendingPathComponent("names.txt")
|
||||
}
|
||||
|
||||
private var serverClassifierVersionUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.version")
|
||||
}
|
||||
|
||||
private var serverRecognitionModelUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.mlmodel")
|
||||
}
|
||||
|
||||
private var serverAllCountsUrl: URL {
|
||||
serverUrl.appendingPathComponent("counts")
|
||||
}
|
||||
|
||||
var serverImageUrl: URL {
|
||||
serverUrl.appendingPathComponent("images")
|
||||
}
|
||||
@ -45,22 +57,14 @@ final class Download {
|
||||
serverImageUrl.appendingPathComponent(String(format: "%04d/%04d-%02d.jpg", cap, cap, version))
|
||||
}
|
||||
|
||||
private func serverNameUrl(for cap: Int) -> URL {
|
||||
serverUrl.appendingPathComponent("name/\(cap)")
|
||||
}
|
||||
|
||||
private func serverImageCountUrl(for cap: Int) -> URL {
|
||||
serverUrl.appendingPathComponent("count/\(cap)")
|
||||
}
|
||||
|
||||
private var serverClassifierVersionUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.version")
|
||||
}
|
||||
|
||||
private var serverAllCountsUrl: URL {
|
||||
serverUrl.appendingPathComponent("count/all")
|
||||
}
|
||||
|
||||
var serverRecognitionModelUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.mlmodel")
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
|
||||
final class Delegate: NSObject, URLSessionDownloadDelegate {
|
||||
@ -116,7 +120,7 @@ final class Download {
|
||||
- Returns: `true`, of the file download was started, `false`, if the image is already downloading.
|
||||
*/
|
||||
@discardableResult
|
||||
func mainImage(for cap: Int, completion: ((_ image: UIImage?) -> Void)?) -> Bool {
|
||||
func mainImage(for cap: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
|
||||
let url = serverImageUrl(for: cap)
|
||||
let query = "Main image of cap \(cap)"
|
||||
guard !downloadingMainImages.contains(cap) else {
|
||||
@ -125,28 +129,19 @@ final class Download {
|
||||
downloadingMainImages.insert(cap)
|
||||
|
||||
let task = session.downloadTask(with: url) { fileUrl, response, error in
|
||||
self.downloadingMainImages.remove(cap)
|
||||
DispatchQueue.main.async {
|
||||
self.downloadingMainImages.remove(cap)
|
||||
}
|
||||
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
|
||||
DispatchQueue.main.async {
|
||||
completion?(nil)
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard app.storage.saveImage(at: fileUrl, for: cap) else {
|
||||
self.log("Request '\(query)' could not move downloaded file")
|
||||
DispatchQueue.main.async {
|
||||
completion?(nil)
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let image = app.storage.image(for: cap) else {
|
||||
self.log("Request '\(query)' received an invalid image")
|
||||
completion?(nil)
|
||||
return
|
||||
}
|
||||
completion?(image)
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
task.resume()
|
||||
return true
|
||||
@ -157,35 +152,23 @@ final class Download {
|
||||
- Parameter cap: The id of the cap.
|
||||
- Parameter version: The image version to download.
|
||||
- Parameter completion: A closure with the resulting image
|
||||
- Note: The closure will be called from the main queue.
|
||||
- Returns: `true`, of the file download was started, `false`, if the image is already downloading.
|
||||
*/
|
||||
@discardableResult
|
||||
func image(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
func image(for cap: Int, version: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
|
||||
let url = serverImageUrl(for: cap, version: version)
|
||||
let query = "Image of cap \(cap) version \(version)"
|
||||
let task = session.downloadTask(with: url) { fileUrl, response, error in
|
||||
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
|
||||
DispatchQueue.main.async {
|
||||
completion(nil)
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard app.storage.saveImage(at: fileUrl, for: cap, version: version) else {
|
||||
self.log("Request '\(query)' could not move downloaded file")
|
||||
DispatchQueue.main.async {
|
||||
completion(nil)
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let image = app.storage.image(for: cap, version: version) else {
|
||||
self.log("Request '\(query)' received an invalid image")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(image)
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
task.resume()
|
||||
return true
|
||||
@ -194,57 +177,40 @@ final class Download {
|
||||
func imageCount(for cap: Int, completion: @escaping (_ count: Int?) -> Void) {
|
||||
let url = serverImageCountUrl(for: cap)
|
||||
let query = "Image count for cap \(cap)"
|
||||
let task = session.dataTask(with: url) { data, response, error in
|
||||
let int = self.convertIntResponse(to: query, data, response, error)
|
||||
completion(int)
|
||||
}
|
||||
task.resume()
|
||||
session.startTaskExpectingInt(with: url, query: query, completion: completion)
|
||||
}
|
||||
|
||||
func imageCounts(completion: @escaping ([(cap: Int, count: Int)]?) -> Void) {
|
||||
let url = serverAllCountsUrl
|
||||
func name(for cap: Int, completion: @escaping (_ name: String?) -> Void) {
|
||||
let url = serverNameUrl(for: cap)
|
||||
let query = "Name for cap \(cap)"
|
||||
session.startTaskExpectingString(with: url, query: query, completion: completion)
|
||||
}
|
||||
|
||||
func imageCounts(completion: @escaping ([Int]?) -> Void) {
|
||||
let query = "Image count of all caps"
|
||||
let task = session.dataTask(with: url) { data, response, error in
|
||||
guard let string = self.convertStringResponse(to: query, data, response, error) else {
|
||||
session.startTaskExpectingData(with: serverAllCountsUrl, query: query) { data in
|
||||
guard let data = data else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert the encoded string into (id, count) pairs
|
||||
let parts = string.components(separatedBy: ";")
|
||||
let array: [(cap: Int, count: Int)] = parts.compactMap { s in
|
||||
let p = s.components(separatedBy: "#")
|
||||
guard p.count == 2, let cap = Int(p[0]), let count = Int(p[1]) else {
|
||||
return nil
|
||||
}
|
||||
return (cap, count)
|
||||
}
|
||||
completion(array)
|
||||
completion(data.map(Int.init))
|
||||
}
|
||||
}
|
||||
|
||||
func names(completion: @escaping ([String]?) -> Void) {
|
||||
let query = "Download of server database"
|
||||
session.startTaskExpectingString(with: serverNameListUrl, query: query) { string in
|
||||
completion(string?.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n"))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func databaseSize(completion: @escaping (_ size: Int64?) -> Void) {
|
||||
size(of: "database size", to: serverDatabaseUrl, completion: completion)
|
||||
size(of: "database size", to: serverNameListUrl, completion: completion)
|
||||
}
|
||||
func database(progress: Delegate.ProgressHandler? = nil, completion: @escaping (URL?) -> Void) {
|
||||
//let query = "Download of server database"
|
||||
let task = session.downloadTask(with: serverDatabaseUrl)
|
||||
delegate.registerForProgress(task, callback: progress) {url in
|
||||
self.log("Database download complete")
|
||||
completion(url)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
|
||||
func classifierVersion(completion: @escaping (Int?) -> Void) {
|
||||
let query = "Server classifier version"
|
||||
let task = session.dataTask(with: serverClassifierVersionUrl) { data, response, error in
|
||||
let int = self.convertIntResponse(to: query, data, response, error)
|
||||
completion(int)
|
||||
}
|
||||
task.resume()
|
||||
session.startTaskExpectingInt(with: serverClassifierVersionUrl, query: query, completion: completion)
|
||||
}
|
||||
|
||||
func classifierSize(completion: @escaping (Int64?) -> Void) {
|
||||
@ -326,3 +292,68 @@ final class Download {
|
||||
}
|
||||
|
||||
extension Download: Logger { }
|
||||
|
||||
extension URLSession {
|
||||
|
||||
func startTaskExpectingData(with url: URL, query: String, completion: @escaping (Data?) -> Void) {
|
||||
let task = dataTask(with: url) { data, response, error in
|
||||
if let error = error {
|
||||
log("Request '\(query)' produced an error: \(error)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let response = response else {
|
||||
log("Request '\(query)' received no response")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let urlResponse = response as? HTTPURLResponse else {
|
||||
log("Request '\(query)' received an invalid response: \(response)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard urlResponse.statusCode == 200 else {
|
||||
log("Request '\(query)' failed with status code \(urlResponse.statusCode)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let d = data else {
|
||||
log("Request '\(query)' received no data")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(d)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func startTaskExpectingString(with url: URL, query: String, completion: @escaping (String?) -> Void) {
|
||||
startTaskExpectingData(with: url, query: query) { data in
|
||||
guard let data = data else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
log("Request '\(query)' received invalid data (not a string)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(string)
|
||||
}
|
||||
}
|
||||
|
||||
func startTaskExpectingInt(with url: URL, query: String, completion: @escaping (Int?) -> Void) {
|
||||
startTaskExpectingString(with: url, query: query) { string in
|
||||
guard let string = string else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
log("Request '\(query)' received an invalid value '\(string)'")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(int)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,47 +0,0 @@
|
||||
//
|
||||
// ServerDatabase.swift
|
||||
// CapCollector
|
||||
//
|
||||
// Created by Christoph on 27.04.20.
|
||||
// Copyright © 2020 CH. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
final class ServerDatabase {
|
||||
|
||||
let db: Connection
|
||||
|
||||
var table: Table {
|
||||
Table("caps")
|
||||
}
|
||||
|
||||
let rowId = Expression<Int>("id")
|
||||
|
||||
let rowName = Expression<String>("name")
|
||||
|
||||
let rowCount = Expression<Int>("count")
|
||||
|
||||
init?(downloadedTo url: URL) {
|
||||
guard let db = try? Connection(url.path) else {
|
||||
return nil
|
||||
}
|
||||
self.db = db
|
||||
log("Server database loaded with \(capCount) caps")
|
||||
}
|
||||
|
||||
/// The number of caps currently in the database
|
||||
var capCount: Int {
|
||||
(try? db.scalar(table.count)) ?? 0
|
||||
}
|
||||
|
||||
var caps: [(id: Int, count: Int, name: String)] {
|
||||
guard let rows = try? db.prepare(table) else {
|
||||
return []
|
||||
}
|
||||
return rows.map { ($0[rowId], $0[rowCount], $0[rowName]) }
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerDatabase: Logger { }
|
@ -43,8 +43,12 @@ final class Storage {
|
||||
baseUrl.appendingPathComponent("\(cap)-thumb.jpg")
|
||||
}
|
||||
|
||||
// MARK: Storage
|
||||
private func tileImageUrl(for image: String) -> URL {
|
||||
baseUrl.appendingPathComponent(image.clean + ".tile")
|
||||
}
|
||||
|
||||
// MARK: Storage
|
||||
|
||||
/**
|
||||
Save an image to disk
|
||||
- parameter url: The url where the downloaded image is stored
|
||||
@ -160,6 +164,20 @@ final class Storage {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: High-level functions
|
||||
|
||||
func switchMainImage(to version: Int, for cap: Int) -> Bool {
|
||||
guard deleteThumbnail(for: cap) else {
|
||||
return false
|
||||
}
|
||||
let newImagePath = localImageUrl(for: cap, version: version)
|
||||
guard fm.fileExists(atPath: newImagePath.path) else {
|
||||
return deleteImage(for: cap, version: version)
|
||||
}
|
||||
let oldImagePath = localImageUrl(for: cap, version: 0)
|
||||
return move(newImagePath, to: oldImagePath)
|
||||
}
|
||||
|
||||
// MARK: Status
|
||||
|
||||
/**
|
||||
@ -171,6 +189,15 @@ final class Storage {
|
||||
fm.fileExists(atPath: localImageUrl(for: cap, version: 0).path)
|
||||
}
|
||||
|
||||
/**
|
||||
Check if a thumbnail exists for a cap
|
||||
- parameter cap: The id of the cap
|
||||
- returns: True, if a thumbnail exists
|
||||
*/
|
||||
func hasThumbnail(for cap: Int) -> Bool {
|
||||
fm.fileExists(atPath: thumbnailUrl(for: cap).path)
|
||||
}
|
||||
|
||||
func existingImageUrl(for cap: Int, version: Int = 0) -> URL? {
|
||||
let url = localImageUrl(for: cap, version: version)
|
||||
return fm.fileExists(atPath: url.path) ? url : nil
|
||||
@ -212,21 +239,6 @@ final class Storage {
|
||||
return image
|
||||
}
|
||||
|
||||
/**
|
||||
Get the image for a cap.
|
||||
If the image exists on disk, it is returned.
|
||||
If no image exists locally, then this function returns nil.
|
||||
- parameter cap: The id of the cap
|
||||
- parameter version: The image version
|
||||
- returns: The image, or `nil`
|
||||
*/
|
||||
func ciImage(for cap: Int, version: Int = 0) -> CIImage? {
|
||||
guard let url = existingImageUrl(for: cap, version: version) else {
|
||||
return nil
|
||||
}
|
||||
return CIImage(contentsOf: url)
|
||||
}
|
||||
|
||||
/**
|
||||
Get the thumbnail data for a cap.
|
||||
If the image exists on disk, it is returned.
|
||||
@ -268,15 +280,18 @@ final class Storage {
|
||||
}
|
||||
}
|
||||
|
||||
func averageColor(for cap: Int, version: Int = 0) -> UIColor? {
|
||||
guard let inputImage = ciImage(for: cap, version: version) else {
|
||||
func ciImage(for cap: Int) -> CIImage? {
|
||||
let url = thumbnailUrl(for: cap)
|
||||
guard fm.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
guard let image = CIImage(contentsOf: url) else {
|
||||
error("Failed to read CIImage for main image of cap \(cap)")
|
||||
return nil
|
||||
}
|
||||
return inputImage.averageColor
|
||||
return image
|
||||
}
|
||||
|
||||
|
||||
private func readData(from url: URL) -> Data? {
|
||||
guard fm.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
@ -304,14 +319,25 @@ final class Storage {
|
||||
|
||||
@discardableResult
|
||||
func deleteImage(for cap: Int, version: Int) -> Bool {
|
||||
guard let url = existingImageUrl(for: cap, version: version) else {
|
||||
let url = localImageUrl(for: cap, version: version)
|
||||
return delete(at: url)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func deleteThumbnail(for cap: Int) -> Bool {
|
||||
let url = thumbnailUrl(for: cap)
|
||||
return delete(at: url)
|
||||
}
|
||||
|
||||
private func delete(at url: URL) -> Bool {
|
||||
guard fm.fileExists(atPath: url.path) else {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try fm.removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to delete image \(version) for cap \(cap): \(error)")
|
||||
log("Failed to delete file \(url.lastPathComponent): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
134
CapCollector/Data/TileImage.swift
Normal file
134
CapCollector/Data/TileImage.swift
Normal file
@ -0,0 +1,134 @@
|
||||
//
|
||||
// TileImage.swift
|
||||
// CapCollector
|
||||
//
|
||||
// Created by Christoph on 20.05.20.
|
||||
// Copyright © 2020 CH. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension Database {
|
||||
|
||||
struct TileImage {
|
||||
|
||||
let name: String
|
||||
|
||||
let width: Int
|
||||
|
||||
/// The tiles of each cap, with the index being the tile, and the value being the cap id.
|
||||
let caps: [Int]
|
||||
|
||||
var encodedCaps: Data {
|
||||
caps.map(UInt16.init).withUnsafeBytes { (p) in
|
||||
Data(buffer: p.bindMemory(to: UInt8.self))
|
||||
}
|
||||
}
|
||||
|
||||
init(name: String, width: Int, caps: [Int]) {
|
||||
self.name = name
|
||||
self.width = width
|
||||
self.caps = caps
|
||||
}
|
||||
|
||||
init(row: Row) {
|
||||
self.name = row[TileImage.columnName]
|
||||
self.width = row[TileImage.columnWidth]
|
||||
self.caps = row[TileImage.columnCaps].withUnsafeBytes { p in
|
||||
p.bindMemory(to: UInt16.self).map(Int.init)
|
||||
}
|
||||
}
|
||||
|
||||
var insertQuery: Insert {
|
||||
TileImage.table.insert(
|
||||
TileImage.columnName <- name,
|
||||
TileImage.columnWidth <- width,
|
||||
TileImage.columnCaps <- encodedCaps)
|
||||
}
|
||||
|
||||
var updateQuery: Update {
|
||||
TileImage.table.update(
|
||||
TileImage.columnWidth <- width,
|
||||
TileImage.columnCaps <- encodedCaps)
|
||||
}
|
||||
|
||||
static let columnName = Expression<String>("name")
|
||||
|
||||
static let columnWidth = Expression<Int>("width")
|
||||
|
||||
static let columnCaps = Expression<Data>("caps")
|
||||
|
||||
static let table = Table("images")
|
||||
|
||||
|
||||
static func named(_ name: String) -> Table {
|
||||
table.filter(columnName == name)
|
||||
}
|
||||
static func exists(_ name: String) -> Table {
|
||||
named(name).select(columnName)
|
||||
}
|
||||
|
||||
static var createQuery: String {
|
||||
table.create(ifNotExists: true) { t in
|
||||
t.column(Cap.columnId, primaryKey: true)
|
||||
t.column(columnName)
|
||||
t.column(columnWidth)
|
||||
t.column(columnCaps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func save(tileImage: TileImage) -> Bool {
|
||||
guard exists(tileImage.name) else {
|
||||
return insert(tileImage)
|
||||
}
|
||||
return update(tileImage)
|
||||
}
|
||||
|
||||
var tileImages: [TileImage] {
|
||||
(try? db.prepare(TileImage.table).map(TileImage.init)) ?? []
|
||||
}
|
||||
|
||||
func tileImage(named name: String) -> TileImage? {
|
||||
do {
|
||||
guard let row = try db.pluck(TileImage.named(name)) else {
|
||||
return nil
|
||||
}
|
||||
return TileImage(row: row)
|
||||
} catch {
|
||||
log("Failed to get tile image \(name): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func exists(_ tileImage: String) -> Bool {
|
||||
do {
|
||||
return try db.pluck(TileImage.exists(tileImage)) != nil
|
||||
} catch {
|
||||
log("Failed to check tile image \(tileImage): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func insert(_ tileImage: TileImage) -> Bool {
|
||||
do {
|
||||
try db.run(tileImage.insertQuery)
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to insert tile image \(tileImage): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func update(_ tileImage: TileImage) -> Bool {
|
||||
do {
|
||||
try db.run(tileImage.updateQuery)
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to update tile image \(tileImage): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
@ -90,6 +90,7 @@ struct Upload {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
@ -134,44 +135,35 @@ struct Upload {
|
||||
|
||||
/**
|
||||
Sets the main image for a cap to a different version.
|
||||
|
||||
- Parameter cap: The id of the cap
|
||||
- Parameter version: The version to set as the main version.
|
||||
- Parameter completion: A callback with the new average color on completion.
|
||||
- Parameter completion: A callback with the result on completion.
|
||||
*/
|
||||
func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ averageColor: UIColor?) -> Void) {
|
||||
guard let averageColor = app.storage.averageColor(for: cap, version: version) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ success: Bool) -> Void) {
|
||||
let url = serverChangeMainImageUrl(for: cap, to: version)
|
||||
var request = URLRequest(url: url)
|
||||
let averageRGB = averageColor.rgb
|
||||
request.addValue("\(averageRGB.red)", forHTTPHeaderField: "r")
|
||||
request.addValue("\(averageRGB.green)", forHTTPHeaderField: "g")
|
||||
request.addValue("\(averageRGB.blue)", forHTTPHeaderField: "b")
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
let task = URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
if let error = error {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): \(error)")
|
||||
completion(nil)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard let response = response else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): No response")
|
||||
completion(nil)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard let urlResponse = response as? HTTPURLResponse else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): \(response)")
|
||||
completion(nil)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard urlResponse.statusCode == 200 else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): Response \(urlResponse.statusCode)")
|
||||
completion(nil)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
completion(averageColor)
|
||||
completion(true)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
Reference in New Issue
Block a user