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:
christophhagen
2020-06-18 22:55:51 +02:00
parent 7287607a60
commit 8892d04f62
22 changed files with 1484 additions and 930 deletions

View File

@ -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)
}

View File

@ -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
}
}

View 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)
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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 { }

View File

@ -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
}
}

View 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
}
}
}

View File

@ -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()
}