2020-05-16 11:21:55 +02:00
|
|
|
import Foundation
|
2022-06-10 21:20:49 +02:00
|
|
|
import SwiftUI
|
|
|
|
import Vision
|
|
|
|
import CryptoKit
|
2020-05-16 11:21:55 +02:00
|
|
|
|
2022-06-10 21:20:49 +02:00
|
|
|
final class Database: ObservableObject {
|
|
|
|
|
|
|
|
private let imageCompressionQuality: CGFloat = 0.3
|
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
@AppStorage("classifier")
|
|
|
|
private(set) var classifierVersion = 0
|
2020-05-16 11:21:55 +02:00
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
@AppStorage("serverClassifier")
|
|
|
|
private(set) var serverClassifierVersion = 0
|
2020-05-16 11:21:55 +02:00
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
let images: ImageCache
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private let encoder = JSONEncoder()
|
|
|
|
private let decoder = JSONDecoder()
|
|
|
|
|
|
|
|
let serverUrl: URL
|
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
let folderUrl: URL
|
|
|
|
|
2022-06-10 21:20:49 +02:00
|
|
|
@AppStorage("authKey")
|
2022-06-11 11:27:56 +02:00
|
|
|
private var serverAuthenticationKey: String = ""
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
var hasServerAuthentication: Bool {
|
2022-06-11 11:27:56 +02:00
|
|
|
serverAuthenticationKey != ""
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
@Published
|
|
|
|
private(set) var caps: [Int : Cap] {
|
|
|
|
didSet { scheduleSave() }
|
2020-06-18 22:55:51 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
var nextCapId: Int {
|
|
|
|
(caps.values.max()?.id ?? 0) + 1
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
@AppStorage("changed")
|
|
|
|
private var changedCapStorage: String = ""
|
|
|
|
|
|
|
|
private(set) var changedCaps: Set<Int> {
|
|
|
|
get {
|
|
|
|
Set(changedCapStorage.components(separatedBy: ",").compactMap(Int.init))
|
|
|
|
}
|
|
|
|
set {
|
|
|
|
changedCapStorage = newValue.map { "\($0)" }.joined(separator: ",")
|
|
|
|
}
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-24 12:06:39 +02:00
|
|
|
|
|
|
|
@AppStorage("uploads")
|
|
|
|
private var pendingImageUploadStorage: String = ""
|
|
|
|
|
|
|
|
private(set) var imageUploads: [Int: [Int]] {
|
|
|
|
get {
|
|
|
|
pendingImageUploadStorage.components(separatedBy: ";").reduce(into: [:]) { dict, string in
|
|
|
|
let parts = string.components(separatedBy: "-")
|
|
|
|
guard parts.count == 2 else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
guard let cap = Int(parts[0]) else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
dict[cap] = parts[1].components(separatedBy: ":").compactMap(Int.init)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
set {
|
|
|
|
pendingImageUploadStorage = newValue.map { cap, images in
|
|
|
|
"\(cap)-\(images.map { "\($0)" }.joined(separator: ":"))"
|
|
|
|
}.joined(separator: ";")
|
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private var uploadTimer: Timer?
|
|
|
|
|
|
|
|
/// The classifications for all caps from the classifier
|
|
|
|
@Published
|
|
|
|
var matches = [Int : Float]()
|
|
|
|
|
|
|
|
@Published
|
|
|
|
var image: UIImage? = nil {
|
|
|
|
didSet { classifyImage() }
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private var classifier: Classifier?
|
|
|
|
|
|
|
|
/**
|
|
|
|
The time to wait for changes to be written to disk.
|
|
|
|
|
|
|
|
This delay is used to prevent file writes for each small update to the caps.
|
|
|
|
*/
|
|
|
|
private let saveDelay: TimeInterval = 1
|
|
|
|
|
|
|
|
/**
|
|
|
|
The time when a save should occur.
|
|
|
|
|
|
|
|
No save is necessary if this property is `nil`.
|
|
|
|
*/
|
|
|
|
private var nextSaveTime: Date?
|
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
@Published
|
|
|
|
var isUploading = false
|
2022-06-10 21:20:49 +02:00
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
init(server: URL, folder: URL = FileManager.default.documentDirectory) {
|
2022-06-10 21:20:49 +02:00
|
|
|
self.serverUrl = server
|
2022-06-21 19:38:51 +02:00
|
|
|
self.folderUrl = folder
|
2022-06-10 21:20:49 +02:00
|
|
|
self.caps = [:]
|
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
let imageFolder = folder.appendingPathComponent("images")
|
|
|
|
self.images = try! ImageCache(
|
|
|
|
folder: imageFolder,
|
|
|
|
server: server,
|
|
|
|
thumbnailSize: CapsApp.thumbnailImageSize)
|
|
|
|
|
|
|
|
ensureFolderExistence(gridStorageFolder)
|
2022-06-10 21:20:49 +02:00
|
|
|
loadCaps()
|
2020-06-18 22:55:51 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
func mainImage(for cap: Int) -> Int {
|
|
|
|
caps[cap]?.mainImage ?? 0
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: URLs
|
|
|
|
|
|
|
|
private var fm: FileManager {
|
|
|
|
.default
|
|
|
|
}
|
|
|
|
|
|
|
|
private var localDbUrl: URL {
|
|
|
|
folderUrl.appendingPathComponent("db.json")
|
|
|
|
}
|
|
|
|
|
|
|
|
private var localClassifierUrl: URL {
|
|
|
|
folderUrl.appendingPathComponent("classifier.mlmodel")
|
|
|
|
}
|
|
|
|
|
|
|
|
private var imageUploadFolderUrl: URL {
|
|
|
|
folderUrl.appendingPathComponent("uploads")
|
|
|
|
}
|
|
|
|
|
|
|
|
private var serverDbUrl: URL {
|
|
|
|
serverUrl.appendingPathComponent("caps.json")
|
|
|
|
}
|
|
|
|
|
|
|
|
private var serverClassifierUrl: URL {
|
|
|
|
serverUrl.appendingPathComponent("classifier.mlmodel")
|
|
|
|
}
|
|
|
|
|
|
|
|
private var serverClassifierVersionUrl: URL {
|
2022-06-24 12:06:39 +02:00
|
|
|
serverUrl.appendingPathComponent("version")
|
2022-06-21 19:38:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private var gridStorageFolder: URL {
|
|
|
|
folderUrl.appendingPathComponent("grids")
|
|
|
|
}
|
|
|
|
|
|
|
|
func mainImageUrl(for cap: Int) -> URL? {
|
|
|
|
guard let path = caps[cap]?.mainImagePath else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return serverUrl.appendingPathComponent(path)
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
// MARK: Disk storage
|
|
|
|
|
|
|
|
private func loadCaps() {
|
|
|
|
guard fm.fileExists(atPath: localDbUrl.path) else {
|
|
|
|
return
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
let data: Data
|
2021-01-13 21:43:46 +01:00
|
|
|
do {
|
2022-06-10 21:20:49 +02:00
|
|
|
data = try Data(contentsOf: localDbUrl)
|
2021-01-13 21:43:46 +01:00
|
|
|
} catch {
|
2022-06-10 21:20:49 +02:00
|
|
|
print("Failed to read database file: \(error)")
|
|
|
|
return
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2020-05-16 11:21:55 +02:00
|
|
|
do {
|
2022-06-10 21:20:49 +02:00
|
|
|
let array = try JSONDecoder().decode([Cap].self, from: data)
|
|
|
|
self.caps = array.reduce(into: [:]) { $0[$1.id] = $1 }
|
|
|
|
// Prevent immediate save after modifying caps
|
|
|
|
nextSaveTime = nil
|
2020-05-16 11:21:55 +02:00
|
|
|
} catch {
|
2022-06-10 21:20:49 +02:00
|
|
|
print("Failed to decode database file: \(error)")
|
|
|
|
return
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func scheduleSave() {
|
|
|
|
nextSaveTime = Date.now.addingTimeInterval(saveDelay)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + saveDelay) {
|
|
|
|
self.performScheduledSave()
|
2021-01-10 16:11:31 +01:00
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func performScheduledSave() {
|
|
|
|
guard let date = nextSaveTime else {
|
|
|
|
// No save necessary, or already saved
|
|
|
|
return
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard date < .now else {
|
|
|
|
// Save pushed to future
|
|
|
|
return
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
save()
|
|
|
|
nextSaveTime = nil
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func save() {
|
|
|
|
let data: Data
|
|
|
|
do {
|
|
|
|
data = try encoder.encode(caps.values.sorted())
|
|
|
|
} catch {
|
|
|
|
print("Failed to encode database: \(error)")
|
|
|
|
return
|
2020-08-09 21:04:30 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
do {
|
|
|
|
try data.write(to: localDbUrl)
|
|
|
|
} catch {
|
|
|
|
print("Failed to save database: \(error)")
|
2020-08-09 21:04:30 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
print("Database saved")
|
2020-08-09 21:04:30 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
@discardableResult
|
2022-06-10 21:20:49 +02:00
|
|
|
private func ensureFolderExistence(_ url: URL) -> Bool {
|
|
|
|
guard !fm.fileExists(atPath: url.path) else {
|
|
|
|
return true
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
|
|
|
do {
|
2022-06-10 21:20:49 +02:00
|
|
|
try fm.createDirectory(at: url, withIntermediateDirectories: true)
|
2020-05-16 11:21:55 +02:00
|
|
|
return true
|
|
|
|
} catch {
|
2022-06-10 21:20:49 +02:00
|
|
|
log("Failed to create folder \(url.path): \(error)")
|
2020-05-16 11:21:55 +02:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
// MARK: Downloads
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
func downloadCaps() async -> Bool {
|
|
|
|
print("Downloading cap data")
|
|
|
|
let data: Data
|
|
|
|
let response: URLResponse
|
|
|
|
do {
|
|
|
|
(data, response) = try await URLSession.shared.data(from: serverDbUrl)
|
|
|
|
} catch {
|
|
|
|
print("Failed to download classifier version: \(error)")
|
2020-05-16 11:21:55 +02:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
2020-05-16 11:21:55 +02:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
let capData: [CapData]
|
|
|
|
do {
|
|
|
|
capData = try decoder.decode([CapData].self, from: data)
|
|
|
|
} catch {
|
|
|
|
print("Failed to decode server database: \(error)")
|
2020-05-16 11:21:55 +02:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
var inserts = 0
|
|
|
|
var updates = 0
|
|
|
|
for cap in capData {
|
|
|
|
guard var oldCap = caps[cap.id] else {
|
|
|
|
caps[cap.id] = Cap(data: cap)
|
|
|
|
inserts += 1
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
guard oldCap != cap else {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if changedCaps.contains(oldCap.id) {
|
|
|
|
#warning("Merge changed caps with server updates")
|
|
|
|
} else {
|
|
|
|
oldCap.update(with: cap)
|
2022-06-24 12:06:39 +02:00
|
|
|
let save = oldCap
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.caps[cap.id] = save
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
updates += 1
|
|
|
|
}
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
print("Updated database from server (\(inserts) added, \(updates) updated)")
|
2020-05-16 11:21:55 +02:00
|
|
|
return true
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
func serverHasNewClassifier() async -> Bool {
|
|
|
|
let data: Data
|
|
|
|
let response: URLResponse
|
2020-05-16 11:21:55 +02:00
|
|
|
do {
|
2022-06-10 21:20:49 +02:00
|
|
|
(data, response) = try await URLSession.shared.data(from: serverClassifierVersionUrl)
|
2020-05-16 11:21:55 +02:00
|
|
|
} catch {
|
2022-06-10 21:20:49 +02:00
|
|
|
print("Failed to download classifier version: \(error)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
2020-05-16 11:21:55 +02:00
|
|
|
return false
|
|
|
|
}
|
2020-06-18 22:55:51 +02:00
|
|
|
|
2022-06-10 21:20:49 +02:00
|
|
|
guard let string = String(data: data, encoding: .utf8) else {
|
|
|
|
log("Classifier version is invalid data (not a string)")
|
2020-06-18 22:55:51 +02:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard let serverVersion = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
|
|
|
log("Classifier version has an invalid value '\(string)'")
|
|
|
|
return false
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.serverClassifierVersion = serverVersion
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard serverVersion > self.classifierVersion else {
|
2022-06-24 12:06:39 +02:00
|
|
|
print("No new classifier available (Local: \(classifierVersion) Server: \(serverVersion))")
|
2022-06-10 21:20:49 +02:00
|
|
|
return false
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-24 12:06:39 +02:00
|
|
|
print("New classifier available (Local: \(classifierVersion) Server: \(serverVersion))")
|
2022-06-10 21:20:49 +02:00
|
|
|
return true
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
2021-01-13 21:43:46 +01:00
|
|
|
@discardableResult
|
2022-06-10 21:20:49 +02:00
|
|
|
func downloadClassifier() async -> Bool {
|
|
|
|
print("Downloading classifier")
|
|
|
|
let tempUrl: URL
|
|
|
|
let response: URLResponse
|
2020-05-16 11:21:55 +02:00
|
|
|
do {
|
2022-06-10 21:20:49 +02:00
|
|
|
(tempUrl, response) = try await URLSession.shared.download(from: serverClassifierUrl)
|
2020-05-16 11:21:55 +02:00
|
|
|
} catch {
|
2022-06-10 21:20:49 +02:00
|
|
|
print("Failed to download classifier version: \(error)")
|
2020-05-16 11:21:55 +02:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
2020-05-16 11:21:55 +02:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
do {
|
2022-06-10 21:20:49 +02:00
|
|
|
let url = self.localClassifierUrl
|
|
|
|
if fm.fileExists(atPath: url.path) {
|
|
|
|
try self.fm.removeItem(at: url)
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
try self.fm.moveItem(at: tempUrl, to: url)
|
2020-05-16 11:21:55 +02:00
|
|
|
} catch {
|
2022-06-10 21:20:49 +02:00
|
|
|
print("Failed to replace classifier: \(error)")
|
|
|
|
return false
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.classifierVersion = self.serverClassifierVersion
|
|
|
|
self.classifier = nil
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
print("Downloaded classifier \(classifierVersion)")
|
|
|
|
return true
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
Indicate that the cap has pending operations, such as determining the color or a thumbnail
|
|
|
|
*/
|
|
|
|
func hasPendingOperations(for cap: Int) -> Bool {
|
|
|
|
return false
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
// MARK: Adding new data
|
|
|
|
|
|
|
|
func save(newCap name: String) -> Cap {
|
2022-06-24 12:06:10 +02:00
|
|
|
let cap = Cap(id: nextCapId, name: name, classifier: nil)
|
2022-06-10 21:20:49 +02:00
|
|
|
caps[cap.id] = cap
|
2022-06-11 11:27:56 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.changedCaps.insert(cap.id)
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
return cap
|
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
func save(_ image: UIImage, for capId: Int) -> Bool {
|
2022-06-24 12:06:39 +02:00
|
|
|
guard let cap = caps[capId] else {
|
2022-06-10 21:20:49 +02:00
|
|
|
log("Failed to save image for missing cap \(capId)")
|
|
|
|
return false
|
|
|
|
}
|
2022-06-24 12:06:39 +02:00
|
|
|
guard images.save(image, for: CapImage(cap: cap.id, version: cap.imageCount)) else {
|
2022-06-10 21:20:49 +02:00
|
|
|
return false
|
|
|
|
}
|
2022-06-24 12:06:39 +02:00
|
|
|
log("Saved image \(cap.imageCount) for cap \(capId)")
|
|
|
|
if imageUploads[capId] != nil {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.imageUploads[capId]!.append(cap.imageCount)
|
2022-06-10 21:20:49 +02:00
|
|
|
}
|
2022-06-24 12:06:39 +02:00
|
|
|
} else {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.imageUploads[capId] = [cap.imageCount]
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
|
|
|
}
|
2022-06-24 12:06:39 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.caps[capId]!.imageCount += 1
|
|
|
|
}
|
|
|
|
return true
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-12-11 19:26:11 +01:00
|
|
|
|
|
|
|
func update(name: String, for capId: Int) -> Bool {
|
|
|
|
guard var cap = caps[capId] else {
|
|
|
|
log("Failed to update name for missing cap \(capId)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
cap.name = name
|
|
|
|
caps[capId] = cap
|
|
|
|
changedCaps.insert(capId)
|
|
|
|
return true
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
// MARK: Uploads
|
|
|
|
|
|
|
|
func startRegularUploads() {
|
2022-06-11 11:27:56 +02:00
|
|
|
guard uploadTimer == nil else {
|
2022-06-10 21:20:49 +02:00
|
|
|
return
|
2020-06-18 22:55:51 +02:00
|
|
|
}
|
2022-06-11 11:27:56 +02:00
|
|
|
log("Starting upload timer")
|
2022-06-10 21:20:49 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.uploadTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: self.uploadTimerElapsed)
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func uploadTimerElapsed(timer: Timer) {
|
|
|
|
Task {
|
|
|
|
await uploadAll()
|
|
|
|
}
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func uploadAll() async {
|
|
|
|
guard !isUploading else {
|
2022-06-11 11:27:56 +02:00
|
|
|
log("Already uploading")
|
2022-06-10 21:20:49 +02:00
|
|
|
return
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.isUploading = true
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-21 19:38:51 +02:00
|
|
|
defer {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.isUploading = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
guard !changedCaps.isEmpty || pendingImageUploadCount > 0 else {
|
|
|
|
return
|
|
|
|
}
|
2022-06-11 11:27:56 +02:00
|
|
|
log("Starting uploads")
|
|
|
|
let uploaded = await uploadAllChangedCaps()
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.changedCaps.subtract(uploaded)
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
await uploadAllImages()
|
2022-06-11 11:27:56 +02:00
|
|
|
log("Uploads finished")
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
Indicate that the cap has pending uploads, either changes or images
|
|
|
|
*/
|
|
|
|
func hasPendingUpdates(for cap: Int) -> Bool {
|
|
|
|
changedCaps.contains(cap) || imageUploads[cap] != nil
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
var pendingImageUploadCount: Int {
|
2022-06-24 12:06:39 +02:00
|
|
|
imageUploads.values.reduce(0) { $0 + $1.count }
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func capId(from url: URL) -> Int? {
|
|
|
|
Int(url.lastPathComponent.components(separatedBy: "-").first!)
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func uploadAllImages() async {
|
|
|
|
guard hasServerAuthentication else {
|
|
|
|
log("No server authentication to upload to server")
|
2021-01-13 21:43:46 +01:00
|
|
|
return
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-24 12:06:39 +02:00
|
|
|
for (cap, images) in imageUploads {
|
|
|
|
for image in images {
|
|
|
|
guard let url = self.images.availableImageUrl(CapImage(cap: cap, version: image)) else {
|
|
|
|
log("Missing upload image \(image) for cap \(cap)")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
guard await upload(imageAt: url, for: cap) else {
|
|
|
|
log("Failed to upload image \(url.lastPathComponent)")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
log("Uploaded image \(image) for cap \(cap)")
|
|
|
|
let remaining = imageUploads[cap]?.filter { $0 != image }
|
|
|
|
if let r = remaining, !r.isEmpty {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.imageUploads[cap] = r
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.imageUploads[cap] = nil
|
|
|
|
}
|
|
|
|
}
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
private func upload(imageAt url: URL, for cap: Int) async -> Bool {
|
2022-06-11 11:27:56 +02:00
|
|
|
guard hasServerAuthentication else {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
guard let data = try? Data(contentsOf: url) else {
|
2021-01-13 21:43:46 +01:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
let url = serverUrl
|
|
|
|
.appendingPathComponent("images")
|
2022-06-11 11:27:56 +02:00
|
|
|
.appendingPathComponent("\(cap)")
|
2022-06-10 21:20:49 +02:00
|
|
|
var request = URLRequest(url: url)
|
2022-06-11 11:27:56 +02:00
|
|
|
request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key")
|
2022-06-10 21:20:49 +02:00
|
|
|
request.httpMethod = "POST"
|
|
|
|
do {
|
2022-06-11 11:27:56 +02:00
|
|
|
let (_, response) = try await URLSession.shared.upload(for: request, from: data)
|
2022-06-10 21:20:49 +02:00
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
|
|
log("Unexpected response for upload of image \(url.lastPathComponent): \(response)")
|
|
|
|
return false
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard httpResponse.statusCode == 200 else {
|
|
|
|
log("Failed to upload image \(url.lastPathComponent): Response \(httpResponse.statusCode)")
|
2021-01-13 21:43:46 +01:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
return true
|
|
|
|
} catch {
|
|
|
|
log("Failed to upload image \(url.lastPathComponent): \(error)")
|
|
|
|
return false
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
var pendingCapUploadCount: Int {
|
|
|
|
changedCaps.count
|
|
|
|
}
|
|
|
|
|
2022-06-11 11:27:56 +02:00
|
|
|
private func uploadAllChangedCaps() async -> Set<Int> {
|
2022-06-10 21:20:49 +02:00
|
|
|
guard hasServerAuthentication else {
|
|
|
|
log("No server authentication to upload to server")
|
2022-06-11 11:27:56 +02:00
|
|
|
return .init()
|
2022-06-10 21:20:49 +02:00
|
|
|
}
|
|
|
|
var uploaded = Set<Int>()
|
|
|
|
for capId in changedCaps {
|
|
|
|
guard let cap = caps[capId] else {
|
2022-06-11 11:27:56 +02:00
|
|
|
log("Missing cap \(capId) to upload")
|
2022-06-10 21:20:49 +02:00
|
|
|
uploaded.insert(capId)
|
2021-01-13 21:43:46 +01:00
|
|
|
continue
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard await upload(cap: cap) else {
|
2021-01-13 21:43:46 +01:00
|
|
|
continue
|
|
|
|
}
|
2022-06-11 11:27:56 +02:00
|
|
|
log("Uploaded cap \(capId)")
|
2022-06-10 21:20:49 +02:00
|
|
|
uploaded.insert(capId)
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-11 11:27:56 +02:00
|
|
|
return uploaded
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
private func upload(cap: Cap) async -> Bool {
|
2022-06-11 11:27:56 +02:00
|
|
|
guard hasServerAuthentication else {
|
2021-01-13 21:43:46 +01:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
let data: Data
|
|
|
|
do {
|
|
|
|
/// `Cap` and `CapData` have equivalent JSON layout
|
2022-06-11 11:27:56 +02:00
|
|
|
data = try encoder.encode(cap.data)
|
2022-06-10 21:20:49 +02:00
|
|
|
} catch {
|
|
|
|
log("Failed to encode cap \(cap.id) for upload: \(error)")
|
2021-01-13 21:43:46 +01:00
|
|
|
return false
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
let url = serverUrl
|
2022-06-11 11:27:56 +02:00
|
|
|
.appendingPathComponent("cap")
|
2022-06-10 21:20:49 +02:00
|
|
|
var request = URLRequest(url: url)
|
|
|
|
request.httpMethod = "POST"
|
2022-06-11 11:27:56 +02:00
|
|
|
request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key")
|
2022-06-10 21:20:49 +02:00
|
|
|
do {
|
|
|
|
let (_, response) = try await URLSession.shared.upload(for: request, from: data)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
|
|
log("Unexpected response for upload of cap \(cap.id): \(response)")
|
|
|
|
return false
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard httpResponse.statusCode == 200 else {
|
|
|
|
log("Failed to upload cap \(cap.id): Response \(httpResponse.statusCode)")
|
|
|
|
return false
|
|
|
|
}
|
2022-06-11 11:27:56 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.changedCaps.remove(cap.id)
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
return true
|
|
|
|
} catch {
|
|
|
|
log("Failed to upload cap \(cap.id): \(error)")
|
2021-01-13 21:43:46 +01:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
// MARK: Classification
|
|
|
|
|
|
|
|
/// The compiled recognition model on disk
|
|
|
|
private var recognitionModel: VNCoreMLModel? {
|
|
|
|
guard fm.fileExists(atPath: localClassifierUrl.path) else {
|
|
|
|
log("No recognition model to load from disk")
|
|
|
|
return nil
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
do {
|
|
|
|
log("Loading model from disk")
|
|
|
|
let newUrl = try MLModel.compileModel(at: localClassifierUrl)
|
|
|
|
let model = try MLModel(contentsOf: newUrl)
|
|
|
|
return try VNCoreMLModel(for: model)
|
|
|
|
} catch {
|
|
|
|
log("Failed to load recognition model: \(error)")
|
|
|
|
return nil
|
2021-01-13 21:43:46 +01:00
|
|
|
}
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func classifyImage() {
|
|
|
|
guard let image = image?.cgImage else {
|
|
|
|
matches.removeAll()
|
|
|
|
log("Image removed")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
DispatchQueue.global().async {
|
|
|
|
guard let classifier = self.getClassifier() else {
|
2020-05-16 11:21:55 +02:00
|
|
|
return
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
log("Image classification started")
|
|
|
|
classifier.recognize(image: image) { matches in
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.matches = matches ?? [:]
|
|
|
|
}
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
private func getClassifier() -> Classifier? {
|
|
|
|
if let classifier = classifier {
|
|
|
|
return classifier
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
guard let model = recognitionModel else {
|
|
|
|
return nil
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
return Classifier(model: model)
|
2020-06-18 22:55:51 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
2022-06-21 19:38:51 +02:00
|
|
|
// MARK: Grid
|
|
|
|
|
|
|
|
var availableGrids: [String] {
|
|
|
|
do {
|
|
|
|
return try fm.contentsOfDirectory(at: gridStorageFolder, includingPropertiesForKeys: nil)
|
|
|
|
.filter { $0.pathExtension == "caps" }
|
|
|
|
.map { $0.deletingPathExtension().lastPathComponent }
|
|
|
|
} catch {
|
|
|
|
print("Failed to load available grids: \(error)")
|
|
|
|
return []
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func gridFileUrl(_ grid: String) -> URL {
|
|
|
|
gridStorageFolder.appendingPathComponent(grid).appendingPathExtension("caps")
|
|
|
|
}
|
|
|
|
|
|
|
|
private func gridImageUrl(_ grid: String) -> URL {
|
|
|
|
gridStorageFolder.appendingPathComponent(grid).appendingPathExtension("jpg")
|
|
|
|
}
|
|
|
|
|
|
|
|
func load(grid: String) -> ImageGrid? {
|
|
|
|
let url = gridFileUrl(grid)
|
|
|
|
guard fm.fileExists(atPath: url.path) else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
do {
|
|
|
|
let data = try Data(contentsOf: url)
|
|
|
|
var loaded = try decoder.decode(ImageGrid.self, from: data)
|
|
|
|
// Add all missing caps to the end of the image
|
|
|
|
let newCaps = Set(caps.keys).subtracting(loaded.capPlacements).sorted()
|
|
|
|
loaded.capPlacements += newCaps
|
|
|
|
print("Grid \(grid) loaded (\(newCaps.count) new caps)")
|
|
|
|
return loaded
|
|
|
|
} catch {
|
|
|
|
print("Failed to load grid \(grid): \(error)")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
func save(_ grid: ImageGrid, named name: String) -> Bool {
|
|
|
|
let url = gridFileUrl(name)
|
|
|
|
do {
|
|
|
|
let data = try encoder.encode(grid)
|
|
|
|
try data.write(to: url)
|
|
|
|
print("Grid \(name) saved")
|
|
|
|
return true
|
|
|
|
} catch {
|
|
|
|
print("Failed to save grid \(name): \(error)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: Grid images
|
|
|
|
|
|
|
|
func load(gridImage: String) -> UIImage? {
|
|
|
|
let url = gridImageUrl(gridImage)
|
|
|
|
return UIImage(at: url)
|
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
func save(gridImage: UIImage, for grid: String) -> Bool {
|
|
|
|
guard let data = gridImage.jpegData(compressionQuality: 0.9) else {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
let url = gridImageUrl(grid)
|
|
|
|
do {
|
|
|
|
try data.write(to: url)
|
|
|
|
return true
|
|
|
|
} catch {
|
|
|
|
print("Failed to save grid image \(grid): \(error)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-10 21:20:49 +02:00
|
|
|
// MARK: Statistics
|
|
|
|
|
|
|
|
var numberOfCaps: Int {
|
|
|
|
caps.count
|
2020-06-18 22:55:51 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
var numberOfImages: Int {
|
|
|
|
caps.values.reduce(0) { $0 + $1.imageCount }
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
|
|
|
|
var averageImageCount: Float {
|
|
|
|
Float(numberOfImages) / Float(numberOfCaps)
|
|
|
|
}
|
|
|
|
|
|
|
|
var classifierClassCount: Int {
|
|
|
|
let version = classifierVersion
|
|
|
|
return caps.values.filter { $0.classifiable(by: version) }.count
|
|
|
|
}
|
|
|
|
|
2022-06-24 12:05:38 +02:00
|
|
|
func imageCacheSize() async -> Int {
|
2022-06-21 19:38:51 +02:00
|
|
|
fm.directorySize(images.folder)
|
2022-06-10 21:20:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
var databaseSize: Int {
|
|
|
|
localDbUrl.fileSize
|
|
|
|
}
|
|
|
|
|
|
|
|
var classifierSize: Int {
|
|
|
|
localClassifierUrl.fileSize
|
2020-05-16 11:21:55 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-10 21:20:49 +02:00
|
|
|
extension Database {
|
|
|
|
|
|
|
|
static var mock: Database {
|
|
|
|
let db = Database(server: URL(string: "https://christophhagen.de/caps")!)
|
|
|
|
db.caps = [
|
|
|
|
Cap(id: 123, name: "My new cap"),
|
|
|
|
Cap(id: 234, name: "My favorite cap"),
|
|
|
|
Cap(id: 345, name: "My oldest cap"),
|
|
|
|
Cap(id: 456, name: "My new cap"),
|
|
|
|
Cap(id: 567, name: "My favorite cap"),
|
|
|
|
Cap(id: 678, name: "My oldest cap"),
|
|
|
|
].reduce(into: [:]) { $0[$1.id] = $1 }
|
|
|
|
db.image = UIImage(systemSymbol: .photo)
|
|
|
|
return db
|
|
|
|
}
|
2022-06-21 19:38:51 +02:00
|
|
|
|
|
|
|
static var largeMock: Database {
|
|
|
|
let db = Database(server: URL(string: "https://christophhagen.de/caps")!)
|
|
|
|
db.caps = (1..<500)
|
|
|
|
.map { Cap(id: $0, name: "Cap \($0)", classifier: nil)}
|
|
|
|
.reduce(into: [:]) { $0[$1.id] = $1 }
|
|
|
|
db.image = UIImage(systemSymbol: .photo)
|
|
|
|
return db
|
|
|
|
}
|
2022-06-10 21:20:49 +02:00
|
|
|
}
|