Caps-iOS/Caps/Data/Database.swift

1015 lines
31 KiB
Swift
Raw Normal View History

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() }
}
2022-06-10 21:20:49 +02:00
var nextCapId: Int {
2023-03-13 11:07:22 +01:00
var next = 1
while caps[next] != nil {
next += 1
}
return next
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
@AppStorage("classifierClasses")
private var _classifierClassesString: String = ""
private var _classifierClassesCache: Set<Int>?
private var classifierClasses: Set<Int> {
get {
_classifierClassesCache ?? loadClassifierClasses()
}
set {
_classifierClassesCache = newValue
DispatchQueue.main.async {
self._classifierClassesString = newValue.map { "\($0)" }.joined(separator: ",")
}
}
}
private func loadClassifierClasses() -> Set<Int> {
let elements: [Int] = _classifierClassesString.components(separatedBy: ",").compactMap {
guard let id = Int($0) else {
log("Failed to load classifier class from '\($0)'")
return nil
}
return id
}
_classifierClassesCache = Set(elements)
return _classifierClassesCache!
}
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()
}
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 serverClassifierClassesUrl: URL {
serverUrl.appendingPathComponent("classifier.classes")
}
2022-06-21 19:38:51 +02:00
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 {
2023-02-26 18:03:57 +01:00
log("Failed to read database file: \(error)")
2022-06-10 21:20:49 +02:00
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 {
2023-02-26 18:03:57 +01:00
log("Failed to decode database file: \(error)")
2022-06-10 21:20:49 +02:00
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 {
2023-02-26 18:03:57 +01:00
log("Failed to encode database: \(error)")
2022-06-10 21:20:49 +02:00
return
2020-08-09 21:04:30 +02:00
}
2022-06-10 21:20:49 +02:00
do {
try data.write(to: localDbUrl)
} catch {
2023-02-26 18:03:57 +01:00
log("Failed to save database: \(error)")
2020-08-09 21:04:30 +02:00
}
2023-02-26 18:03:57 +01:00
log("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 {
2023-02-26 18:03:57 +01:00
log("Downloading cap data from \(serverDbUrl)")
2022-06-10 21:20:49 +02:00
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(from: serverDbUrl)
} catch {
2023-02-26 18:03:57 +01:00
log("Failed to download classifier version: \(error)")
2020-05-16 11:21:55 +02:00
return false
}
2023-02-19 00:38:52 +01:00
guard let httpResponse = response as? HTTPURLResponse else {
return false
}
guard httpResponse.statusCode == 200 else {
2023-02-26 18:03:57 +01:00
log("Failed to download caps: \(httpResponse.statusCode)")
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 {
2023-02-26 18:03:57 +01:00
log("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")
2022-06-10 21:20:49 +02:00
} 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
}
2023-02-26 18:03:57 +01:00
log("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 {
2023-02-26 18:03:57 +01:00
log("Failed to download classifier version: \(error)")
2022-06-10 21:20:49 +02:00
return false
}
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
guard let string = String(data: data, encoding: .utf8) else {
log("Classifier version is invalid data (not a string)")
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 {
2023-02-26 18:03:57 +01:00
log("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
}
2023-02-26 18:03:57 +01:00
log("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 {
2023-02-26 18:03:57 +01:00
log("Downloading classifier")
2022-06-10 21:20:49 +02:00
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 {
2023-02-26 18:03:57 +01:00
log("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 {
2023-02-26 18:03:57 +01:00
log("Failed to replace classifier: \(error)")
2022-06-10 21:20:49 +02:00
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
}
2023-02-26 18:03:57 +01:00
log("Downloaded classifier \(classifierVersion)")
2022-06-10 21:20:49 +02:00
return true
2020-05-16 11:21:55 +02:00
}
@discardableResult
func downloadClassifierClasses() async -> Bool {
log("Downloading classifier classes")
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(from: serverClassifierClassesUrl)
} catch {
log("Failed to download classifier classes: \(error)")
return false
}
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
return false
}
guard let string = String(data: data, encoding: .utf8) else {
log("Classifier classes is invalid data (not a string)")
return false
}
let classes = string.components(separatedBy: ",")
// Validate input
var isValid = true
let ids: [Int] = classes.compactMap { s in
guard let id = Int(s) else {
log("Invalid id '\(s)' in downloaded classes list")
isValid = false
return nil
}
if caps[id] == nil {
// Caps which are deleted may still be recognized
return nil
}
return id
}
guard isValid else {
return false
}
self.classifierClasses = Set(ids)
return true
}
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
}
2023-02-19 00:38:52 +01:00
func cap(for id: Int) -> Cap? {
caps[id]
}
2022-06-10 21:20:49 +02:00
// MARK: Adding new data
func save(newCap name: String) -> Cap {
let cap = Cap(id: nextCapId, name: name)
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
}
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
2023-07-28 13:20:12 +02:00
private func imageId(from url: URL) -> CapImage? {
let parts = url.deletingPathExtension().lastPathComponent.components(separatedBy: "-")
guard parts.count == 2 else {
log("File \(url.lastPathComponent) is not a cap image")
return nil
}
guard let capId = Int(parts.first!),
let version = Int(parts.last!) else {
log("File \(url.lastPathComponent) is not a cap image")
return nil
}
return .init(cap: capId, version: version)
}
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 {
2023-02-26 18:03:57 +01:00
log("No image data found for image \(url.lastPathComponent) (Cap \(cap))")
2021-01-13 21:43:46 +01:00
return false
}
2022-06-10 21:20:49 +02:00
let url = serverUrl
2023-02-26 18:03:57 +01:00
.appendingPathComponent("image")
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 {
2023-02-26 18:03:57 +01:00
log("Failed to upload image \(url.path): 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
2023-03-12 12:14:38 +01:00
func setMainImage(_ version: Int, for capId: Int) async -> Cap? {
guard hasServerAuthentication else {
log("No authorization to set main image")
return nil
}
guard var cap = cap(for: capId) else {
log("No cap \(capId) to set main image")
return nil
}
guard version < cap.imageCount else {
log("Invalid main image \(version) for \(capId) with only \(cap.imageCount) images")
return nil
}
cap.mainImage = version
let finalCap = cap
DispatchQueue.main.async {
self.caps[capId] = finalCap
log("Set main image \(version) for \(capId)")
}
return finalCap
}
func delete(image: CapImage) async -> Bool {
guard hasServerAuthentication else {
2023-03-13 11:07:22 +01:00
log("No authorization to delete cap image")
2023-03-12 12:14:38 +01:00
return false
}
2023-03-13 11:07:22 +01:00
guard let cap = cap(for: image.cap) else {
log("No cap \(image.cap) to delete cap image")
2023-03-12 12:14:38 +01:00
return false
}
guard image.version < cap.imageCount else {
2023-03-13 11:07:22 +01:00
log("Invalid image \(image.version) to delete for \(cap.id) with only \(cap.imageCount) images")
2023-03-12 12:14:38 +01:00
return false
}
let url = serverUrl
.appendingPathComponent("delete/\(cap.id)/\(image.version)")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key")
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
log("Unexpected response deleting image \(image.version) of cap \(cap.id): \(response)")
return false
}
guard httpResponse.statusCode == 200 else {
log("Failed to delete image \(image.version) of cap \(cap.id): Response \(httpResponse.statusCode)")
return false
}
let newCap: Cap
do {
newCap = try JSONDecoder().decode(Cap.self, from: data)
} catch {
log("Invalid response data deleting image \(image.version) of cap \(cap.id): \(data)")
return false
}
// Delete cached images
images.removeCachedImages(for: cap.id)
// Update cap
caps[newCap.id] = newCap
log("Deleted image \(image.version) of cap \(cap.id)")
return true
} catch {
log("Failed to delete image \(image.version) of cap \(cap.id): \(error)")
return false
}
}
2023-03-13 11:07:22 +01:00
func delete(cap: Int) async -> Bool {
guard hasServerAuthentication else {
log("No authorization to delete cap")
return false
}
guard caps[cap] != nil else {
log("No cap \(cap) to delete")
return false
}
let url = serverUrl.appendingPathComponent("delete/\(cap)")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key")
do {
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
log("Unexpected response deleting cap \(cap): \(response)")
return false
}
guard httpResponse.statusCode == 200 else {
log("Failed to delete cap \(cap): Response \(httpResponse.statusCode)")
return false
}
// Delete cached images
images.removeCachedImages(for: cap)
// Delete cap
caps[cap] = nil
log("Deleted cap \(cap)")
return true
} catch {
log("Failed to delete cap \(cap): \(error)")
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
}
}
}
func canClassify(cap id: Int) -> Bool {
classifierClasses.contains(id)
}
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)
}
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 {
2023-02-26 18:03:57 +01:00
log("Failed to load available grids: \(error)")
2022-06-21 19:38:51 +02:00
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
2023-02-26 18:03:57 +01:00
log("Grid \(grid) loaded (\(newCaps.count) new caps)")
2022-06-21 19:38:51 +02:00
return loaded
} catch {
2023-02-26 18:03:57 +01:00
log("Failed to load grid \(grid): \(error)")
2022-06-21 19:38:51 +02:00
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)
2023-02-26 18:03:57 +01:00
log("Grid \(name) saved")
2022-06-21 19:38:51 +02:00
return true
} catch {
2023-02-26 18:03:57 +01:00
log("Failed to save grid \(name): \(error)")
2022-06-21 19:38:51 +02:00
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 {
2023-02-26 18:03:57 +01:00
log("Failed to save grid image \(grid): \(error)")
2022-06-21 19:38:51 +02:00
return false
}
}
2022-06-10 21:20:49 +02:00
// MARK: Statistics
var numberOfCaps: Int {
caps.count
}
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 {
classifierClasses.count
2022-06-10 21:20:49 +02:00
}
2023-07-28 13:20:12 +02:00
func imageCacheSize() -> 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
}
2023-07-28 13:20:12 +02:00
private var cachedImages: [URL] {
do {
return try fm.contentsOfDirectory(at: images.folder, includingPropertiesForKeys: nil)
} catch {
log("Failed to get cached images: \(error)")
return []
}
}
func clearImageCache() {
let allImages = cachedImages
let unnecessaryImages = allImages
.filter {
guard let id = imageId(from: $0) else {
return true
}
guard let cap = caps[id.cap] else {
return true
}
return cap.mainImage != id.version
}
log("Deleting \(unnecessaryImages.count) of \(allImages.count) cached images")
for cachedImage in unnecessaryImages {
do {
try fm.removeItem(at: cachedImage)
} catch {
log("Failed to delete cached image \(cachedImage.lastPathComponent): \(error)")
}
}
}
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)") }
2022-06-21 19:38:51 +02:00
.reduce(into: [:]) { $0[$1.id] = $1 }
db.image = UIImage(systemSymbol: .photo)
return db
}
2022-06-10 21:20:49 +02:00
}