// // 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 missingUsername case missingToken case missingKeychainStore case collectionNotRegistered(type: String) case apiCallCollectionNotRegistered(type: String) case synchronizationInactive case storeNotRegistered(id: String) public var localizedDescription: String { switch self { case .missingService: return "L'instance des services est nulle" case .missingUsername: return "Le nom d'utilisateur est manquant" case .missingUserId: return "L'identifiant utilisateur est manquant" case .missingToken: return "Aucun token n'est stocké" case .missingKeychainStore: return "Aucun magasin de trousseau n'est disponible" case .collectionNotRegistered(let type): return "La collection \(type) n'est pas enregistrée" case .apiCallCollectionNotRegistered(let type): return "La collection d'appels API n'a pas été enregistrée pour \(type)" case .synchronizationInactive: return "La synchronisation n'est pas active sur ce StoreCenter" case .storeNotRegistered(let id): return "Le magasin avec l'identifiant \(id) n'est pas enregistré" } } public var errorDescription: String? { switch self { case .missingService: return "Services instance is nil" case .missingUsername: return "The username is missing" case .missingUserId: return "The user id is missing" case .missingToken: return "There is no stored token" case .missingKeychainStore: return "There is no keychain store" case .collectionNotRegistered(let type): return "The collection \(type) is not registered" case .apiCallCollectionNotRegistered(let type): return "The api call collection has not been registered for \(type)" case .synchronizationInactive: return "The synchronization is not active on this StoreCenter" case .storeNotRegistered(let id): return "The store with identifier \(id) is not registered" } } } final public class Store { fileprivate(set) var storeCenter: StoreCenter /// The dictionary of registered collections fileprivate var _collections: [String : any SomeCollection] = [:] /// 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(storeCenter: StoreCenter) { self.storeCenter = storeCenter } public required init(storeCenter: StoreCenter, identifier: String) { self.storeCenter = storeCenter self.identifier = identifier let directory = "\(storeCenter.directoryName)/\(identifier)" self._createDirectory(directory: directory) } public static var main: Store { return StoreCenter.main.mainStore } public func alternateStore(identifier: String) throws -> Store { return try self.storeCenter.store(identifier: identifier) } /// 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() as? StoredCollection { 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, synchronousLoading: Bool = false) -> SyncedCollection { if let collection: SyncedCollection = try? self.syncedCollection() { return collection } let collection = SyncedCollection(store: self, indexed: indexed, inMemory: inMemory, limit: limit, synchronousLoading: synchronousLoading) self._collections[T.resourceName()] = collection self.storeCenter.loadApiCallCollection(type: T.self) return collection } func asyncLoadingSynchronizedCollection(inMemory: Bool = false) async -> SyncedCollection { let collection = await SyncedCollection(store: self, inMemory: inMemory) self._collections[T.resourceName()] = collection self.storeCenter.loadApiCallCollection(type: T.self) return collection } func asyncLoadingStoredCollection(inMemory: Bool = false) async -> StoredCollection { let collection = await StoredCollection(store: self, inMemory: inMemory) self._collections[T.resourceName()] = collection 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, shouldLoadDataFromServer: Bool = true) -> StoredSingleton { let storedObject = StoredSingleton(store: self, inMemory: inMemory, shouldLoadDataFromServer: shouldLoadDataFromServer) self._collections[T.resourceName()] = storedObject self._collections[T.resourceName()] = storedObject if synchronized { self.storeCenter.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? BaseCollection else { Logger.w("Collection \(T.resourceName()) not registered") return nil } return collection.findById(id) } /// Returns a collection by type func syncedCollection() throws -> SyncedCollection { if let collection = self._collections[T.resourceName()] as? SyncedCollection { return collection } throw StoreError.collectionNotRegistered(type: T.resourceName()) } /// Returns a collection by type func collection() throws -> BaseCollection { if let collection = self._collections[T.resourceName()] as? BaseCollection { return collection } throw StoreError.collectionNotRegistered(type: T.resourceName()) } func registerOrGetSyncedCollection(_ type: T.Type) -> SyncedCollection { do { return try self.syncedCollection() } catch { return self.registerSynchronizedCollection(indexed: true, inMemory: false) } } /// Loads all collection with the data from the server public func loadCollectionsFromServer(clear: Bool) { for collection in self._syncedCollections() { Task { do { try await collection.loadDataFromServerIfAllowed(clear: clear) } catch { Logger.error(error) } } } } /// 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() } } // MARK: - Synchronization /// Calls addOrUpdateIfNewer from the collection corresponding to the instance func addOrUpdateIfNewer(_ instance: T, shared: Bool) { let collection: SyncedCollection = 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: BaseCollection = 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: SyncedCollection = try self.syncedCollection() 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 } public func deleteAllDependencies(type: T.Type, shouldBeSynchronized: Bool) { do { let collection: BaseCollection = try self.collection() try self._deleteDependencies(Array(collection.items), shouldBeSynchronized: shouldBeSynchronized) } catch { Logger.error(error) } } public func deleteDependencies(type: T.Type, shouldBeSynchronized: Bool, _ handler: (T) throws -> Bool) { do { let collection: BaseCollection = try self.collection() let items = try collection.items.filter(handler) try self._deleteDependencies(items, shouldBeSynchronized: shouldBeSynchronized) } catch { Logger.error(error) } } fileprivate func _deleteDependencies(_ items: [T], shouldBeSynchronized: Bool) throws { do { let collection: BaseCollection = try self.collection() for item in items { item.deleteDependencies(store: self, shouldBeSynchronized: shouldBeSynchronized) } collection.deleteDependencies(items) } catch { Logger.error(error) } } // MARK: - Write /// Returns the directory URL of the store fileprivate func _directoryPath() throws -> URL { var url = try FileUtils.pathForDirectoryInDocuments(directory: storeCenter.directoryName) 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 { return try self.fileURL(fileName: T.fileName()) } /// Returns the URL matching a Storable type /// - Parameters: /// - type: a Storable type func fileURL(fileName: String) throws -> URL { let fileURL = try self._directoryPath() return fileURL.appending(component: 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 self.storeCenter.getItems(identifier: identifier) } else { return try await self.storeCenter.getItems() } } func loadCollectionItems(_ items: [T], clear: Bool) async { do { let collection: SyncedCollection = try self.syncedCollection() await collection.loadItems(items, clear: clear) } catch { Logger.error(error) } } /// 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 } } }