// // Store.swift // LeStorage // // Created by Laurent Morvillier on 02/02/2024. // import Foundation import UIKit public enum StoreError: Error, LocalizedError { case missingService case missingUserId case collectionNotRegistered(type: String) case cannotSyncCollection(name: String) case apiCallCollectionNotRegistered(type: String) public var errorDescription: String? { switch self { case .missingService: return "Services instance is nil" case .missingUserId: return "The user id is missing" case .collectionNotRegistered(let type): return "The collection \(type) is not registered" case .cannotSyncCollection(let name): return "Tries to load the collection \(name) from the server while it's not authorized" case .apiCallCollectionNotRegistered(let type): return "The api call collection has not been registered for \(type)" } } } final public class Store { /// The Store singleton public static let main = Store() /// The dictionary of registered StoredCollections fileprivate var _collections: [String : any SomeCollection] = [:] /// The name of the directory to store the json files static let storageDirectory = "storage" /// The store identifier, used to name the store directory, and to perform filtering requests to the server public fileprivate(set) var identifier: String? = nil public init() { self._createDirectory(directory: Store.storageDirectory) } public required init(identifier: String) { self.identifier = identifier let directory = "\(Store.storageDirectory)/\(identifier)" self._createDirectory(directory: directory) } /// Creates the store directory /// - Parameters: /// - directory: the name of the directory fileprivate func _createDirectory(directory: String) { FileManager.default.createDirectoryInDocuments(directoryName: directory) } /// A method to provide ids corresponding to the django storage public static func randomId() -> String { return UUID().uuidString.lowercased() } /// Registers a collection /// - Parameters: /// - indexed: Creates an index to quickly access the data /// - inMemory: Indicates if the collection should only live in memory, and not write into a file public func registerCollection(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection { if let collection: StoredCollection = try? self.collection() { return collection } let collection = StoredCollection(store: self, indexed: indexed, inMemory: inMemory, limit: limit) self._collections[T.resourceName()] = collection return collection } /// Registers a synchronized collection /// - Parameters: /// - indexed: Creates an index to quickly access the data /// - inMemory: Indicates if the collection should only live in memory, and not write into a file public func registerSynchronizedCollection(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection { if let collection: StoredCollection = try? self.collection() { return collection } let collection = StoredCollection(store: self, indexed: indexed, inMemory: inMemory, limit: limit) self._collections[T.resourceName()] = collection StoreCenter.main.loadApiCallCollection(type: T.self) return collection } /// Registers a singleton object /// - Parameters: /// - synchronized: indicates if the data is synchronized with the server /// - inMemory: Indicates if the collection should only live in memory, and not write into a file /// - sendsUpdate: Indicates if updates of items should be sent to the server public func registerObject(synchronized: Bool, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredSingleton { let storedObject = StoredSingleton(store: self, inMemory: inMemory) self._collections[T.resourceName()] = storedObject if synchronized { StoreCenter.main.loadApiCallCollection(type: T.self) } return storedObject } // MARK: - Convenience /// Looks for an instance by id /// - Parameters: /// - id: the id of the data public func findById(_ id: T.ID) -> T? { guard let collection = self._collections[T.resourceName()] as? StoredCollection else { Logger.w("Collection \(T.resourceName()) not registered") return nil } return collection.findById(id) } /// Filters a collection by predicate /// - Parameters: /// - isIncluded: a predicate to returns if a data should be filtered in public func filter(isIncluded: (T) throws -> (Bool)) rethrows -> [T] { do { return try self.collection().filter(isIncluded) } catch { return [] } } /// Returns a collection by type func collection() throws -> StoredCollection { if let collection = self._collections[T.resourceName()] as? StoredCollection { return collection } throw StoreError.collectionNotRegistered(type: T.resourceName()) } func registerOrGetSyncedCollection(_ type: T.Type) -> StoredCollection { do { return try self.collection() } catch { return self.registerSynchronizedCollection(indexed: true, inMemory: false) } } /// Loads all collection with the data from the server public func loadCollectionsFromServer() { for collection in self._syncedCollections() { Task { try? await collection.loadDataFromServerIfAllowed() } } } /// Loads all synchronized collection with server data if they don't already have a local file public func loadCollectionsFromServerIfNoFile() { for collection in self._syncedCollections() { Task { do { try await collection.loadCollectionsFromServerIfNoFile() } catch { Logger.error(error) } } } } /// Returns the list of synchronized collection inside the store fileprivate func _syncedCollections() -> [any SomeSyncedCollection] { return self._collections.values.compactMap { $0 as? any SomeSyncedCollection } } /// Resets all registered collection public func reset() { for collection in self._collections.values { collection.reset() } } /// Returns the names of all collections public func collectionNames() -> [(String, any Storable.Type)] { return self._collections.values.map { ($0.resourceName, $0.type) } } // MARK: - Synchronization /// Calls addOrUpdateIfNewer from the collection corresponding to the instance func addOrUpdateIfNewer(_ instance: T, shared: Bool) { let collection: StoredCollection = self.registerOrGetSyncedCollection(T.self) collection.addOrUpdateIfNewer(instance, shared: shared) } /// Calls deleteById from the collection corresponding to the instance func deleteNoSync(instance: T) { do { let collection: StoredCollection = try self.collection() collection.delete(instance: instance) } catch { Logger.error(error) } } /// Calls deleteById from the collection corresponding to the instance func deleteNoSync(type: T.Type, id: String) throws { let collection: StoredCollection = try self.collection() collection.deleteByStringIdNoSync(id) } /// Calls deleteById from the collection corresponding to the instance func referenceCount(type: T.Type, id: String) -> Int { var count: Int = 0 for collection in self._collections.values { count += collection.referenceCount(type: type, id: id) } return count } // MARK: - Write /// Returns the directory URL of the store fileprivate func _directoryPath() throws -> URL { var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory) if let identifier { url.append(component: identifier) } return url } /// Writes some content into a file inside the Store directory /// - Parameters: /// - content: the content to write /// - fileName: the name of the file func write(content: String, fileName: String) throws { var fileURL = try self._directoryPath() fileURL.append(component: fileName) try content.write(to: fileURL, atomically: false, encoding: .utf8) } /// Returns the URL matching a Storable type /// - Parameters: /// - type: a Storable type func fileURL(type: T.Type) throws -> URL { let fileURL = try self._directoryPath() return fileURL.appending(component: T.fileName()) } /// Removes a file matching a Storable type /// - Parameters: /// - type: a Storable type func removeFile(type: T.Type) { do { let url: URL = try self.fileURL(type: type) if FileManager.default.fileExists(atPath: url.path()) { try FileManager.default.removeItem(at: url) } } catch { Logger.error(error) } } /// Retrieves all the items on the server public func getItems() async throws -> [T] { if let identifier = self.identifier { return try await StoreCenter.main.getItems(identifier: identifier) } else { return try await StoreCenter.main.getItems() } } /// Requests an insertion to the StoreCenter /// - Parameters: /// - instance: an object to insert // func sendInsertion(_ instance: T) async throws -> T? { // return try await StoreCenter.main.sendInsertion(instance) // } // // /// Requests an update to the StoreCenter // /// - Parameters: // /// - instance: an object to update // @discardableResult func sendUpdate(_ instance: T) async throws -> T? { // return try await StoreCenter.main.sendUpdate(instance) // } // // /// Requests a deletion to the StoreCenter // /// - Parameters: // /// - instance: an object to delete // func sendDeletion(_ instance: T) async throws { // return try await StoreCenter.main.sendDeletion(instance) // } /// Returns whether all collections have loaded locally public func fileCollectionsAllLoaded() -> Bool { let fileCollections = self._collections.values.filter { $0.inMemory == false } return fileCollections.allSatisfy { $0.hasLoaded } } }