From daa34132c4e441887afdb7e4934349ec7f76e5c4 Mon Sep 17 00:00:00 2001 From: Laurent Date: Fri, 2 Feb 2024 23:06:13 +0100 Subject: [PATCH] base commit --- LeStorage.xcodeproj/project.pbxproj | 28 ++++ LeStorage/Services.swift | 92 ++++++++++++ LeStorage/Store.swift | 24 ++- LeStorage/StoredCollection.swift | 181 +++++++++++++++++++++++ LeStorage/Utils/Codable+Extensions.swift | 53 +++++++ LeStorage/Utils/FileUtils.swift | 70 +++++++++ LeStorage/Utils/Logger.swift | 65 ++++++++ 7 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 LeStorage/Services.swift create mode 100644 LeStorage/StoredCollection.swift create mode 100644 LeStorage/Utils/Codable+Extensions.swift create mode 100644 LeStorage/Utils/FileUtils.swift create mode 100644 LeStorage/Utils/Logger.swift diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index ae3236d..8534b28 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -12,6 +12,11 @@ C425D4442B6D24E1002A7B48 /* LeStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */; }; C425D4452B6D24E1002A7B48 /* LeStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C425D4372B6D24E1002A7B48 /* LeStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; }; + C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; }; + C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; }; + C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D522B6D2C5F00ADC637 /* Logger.swift */; }; + C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */; }; + C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D602B6D3C1300ADC637 /* Services.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -31,6 +36,11 @@ C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeStorageTests.swift; sourceTree = ""; }; C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; + C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = ""; }; + C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = ""; }; + C4A47D522B6D2C5F00ADC637 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; + C4A47D602B6D3C1300ADC637 /* Services.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -75,7 +85,10 @@ children = ( C425D4372B6D24E1002A7B48 /* LeStorage.h */, C425D4382B6D24E1002A7B48 /* LeStorage.docc */, + C4A47D602B6D3C1300ADC637 /* Services.swift */, C425D4572B6D2519002A7B48 /* Store.swift */, + C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */, + C4A47D582B6D352900ADC637 /* Utils */, ); path = LeStorage; sourceTree = ""; @@ -88,6 +101,16 @@ path = LeStorageTests; sourceTree = ""; }; + C4A47D582B6D352900ADC637 /* Utils */ = { + isa = PBXGroup; + children = ( + C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, + C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */, + C4A47D522B6D2C5F00ADC637 /* Logger.swift */, + ); + path = Utils; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -197,7 +220,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */, + C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */, C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */, + C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */, + C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, + C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, C425D4582B6D2519002A7B48 /* Store.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift new file mode 100644 index 0000000..82aecb9 --- /dev/null +++ b/LeStorage/Services.swift @@ -0,0 +1,92 @@ +// +// ChatService.swift +// Chat +// +// Created by Laurent Morvillier on 11/12/2023. +// + +import Foundation + +enum Method: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} + +enum ServiceError: Error { + case urlCreationError(url: String) +} + +class Services { + + init(url: String) { + self._baseURL = url + } + + fileprivate var _baseURL: String + + fileprivate var jsonDecoder: JSONDecoder = { + let decoder = JSONDecoder() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + decoder.dateDecodingStrategy = .formatted(dateFormatter) + return decoder + }() + + // MARK: - Base + + fileprivate func runRequest(_ request: URLRequest) async throws -> T { + let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) + return try jsonDecoder.decode(T.self, from: task.0) + } + + fileprivate func getRequest(servicePath: String) throws -> URLRequest { + return try self._baseRequest(servicePath: servicePath, method: .get) + } + + fileprivate func postRequest(servicePath: String) throws -> URLRequest { + return try self._baseRequest(servicePath: servicePath, method: .post) + } + + fileprivate func putRequest(servicePath: String) throws -> URLRequest { + return try self._baseRequest(servicePath: servicePath, method: .put) + } + + fileprivate func deleteRequest(servicePath: String) throws -> URLRequest { + return try self._baseRequest(servicePath: servicePath, method: .delete) + } + + fileprivate func _baseRequest(servicePath: String, method: Method) throws -> URLRequest { + let urlString = _baseURL + servicePath + guard let url = URL(string: urlString) else { + throw ServiceError.urlCreationError(url: urlString) + } + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + return request + } + + // MARK: - Services + + func get() async throws -> [T] { + let getRequest = try getRequest(servicePath: T.resourceName + "/") + return try await self.runRequest(getRequest) + } + + func insert(_ instance: T) async throws -> T { + let postRequest = try postRequest(servicePath: T.resourceName + "/") + return try await self.runRequest(postRequest) + } + + func update(_ instance: T) async throws -> T { + let postRequest = try putRequest(servicePath: T.resourceName + "/") + return try await self.runRequest(postRequest) + } + + func delete(_ instance: T) async throws -> T { + let postRequest = try deleteRequest(servicePath: T.resourceName + "/") + return try await self.runRequest(postRequest) + } + +} diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index 9717ccc..c269e05 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -7,8 +7,28 @@ import Foundation -public class Store { +protocol ServiceProvider { + var service: Services? { get } +} + +public class Store: ServiceProvider { + + fileprivate var _synchronizationApiURL: String? + fileprivate var _services: Services? + + public init(synchronizationApiURL: String? = nil) { + self._synchronizationApiURL = synchronizationApiURL + if let url = synchronizationApiURL { + self._services = Services(url: url) + } + } + + public func registerCollection(synchronized: Bool) -> StoredCollection { + return StoredCollection(synchronized: synchronized, serviceProvider: self) + } - public init() { } + var service: Services? { + return self._services + } } diff --git a/LeStorage/StoredCollection.swift b/LeStorage/StoredCollection.swift new file mode 100644 index 0000000..5c377a9 --- /dev/null +++ b/LeStorage/StoredCollection.swift @@ -0,0 +1,181 @@ +// +// StoredCollection.swift +// LeStorage +// +// Created by Laurent Morvillier on 02/02/2024. +// + +import Foundation + +public protocol Storable : Codable, Identifiable where ID : Hashable { + static var resourceName: String { get } +} + +public class StoredCollection : RandomAccessCollection, ObservableObject { + + let synchronized: Bool + + @Published public fileprivate(set) var items: [T] = [] + + fileprivate var _serviceProvider: ServiceProvider + + fileprivate var _hasChanged: Bool = false { + didSet { + if self._hasChanged == true { + self.objectWillChange.send() + self._scheduleWrite() + self._hasChanged = false + } + } + } + + init(synchronized: Bool, serviceProvider: ServiceProvider) { + self.synchronized = synchronized + self._serviceProvider = serviceProvider + self._load() + } + + fileprivate var _fileName: String { + return T.resourceName + ".json" + } + + public func addOrUpdate(instance: T) { + defer { + self._hasChanged = true + } + + if let index = self.items.firstIndex(where: { $0.id == instance.id }) { + self.items[index] = instance + self._sendUpdateIfNecessary(instance) + } else { + self.items.append(instance) + self._sendInsertionIfNecessary(instance) + } + + } + + public func delete(instance: T) { + defer { + self._hasChanged = true + } + + self.items.removeAll { $0.id == instance.id } + self._sendDeletionIfNecessary(instance) + } + + fileprivate func _scheduleWrite() { + self._write() + } + + fileprivate func _write() { + DispatchQueue(label: "lestorage.queue.write", qos: .background).async { + do { + let jsonString = try self.items.jsonString() + let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName) + } catch { + Logger.error(error) // TODO how to notify the main project + } + } + } + + fileprivate func _load() { + + do { + let url = try FileUtils.directoryURLForFileName(self._fileName) + if FileManager.default.fileExists(atPath: url.path()) { + self._loadAsync() + } + } catch { + Logger.log(error) + } + + } + + fileprivate func _loadAsync() { + DispatchQueue(label: "lestorage.queue.read", qos: .background).async { + + do { + let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName) + if let decoded: [T] = try jsonString.decodeArray() { + DispatchQueue.main.sync { + Logger.log("loaded \(self._fileName) with \(decoded.count) items") + self.items = decoded + self.objectWillChange.send() + } + } + + } catch { + Logger.error(error) // TODO how to notify the main project + } + } + + } + + // MARK: - Synchronization + + fileprivate func _sendInsertionIfNecessary(_ instance: T) { + guard self.synchronized else { + return + } + + Task { + do { + let _ = try await self._serviceProvider.service?.insert(instance) + } catch { + Logger.error(error) + } + } + + } + + fileprivate func _sendUpdateIfNecessary(_ instance: T) { + guard self.synchronized else { + return + } + + Task { + do { + let _ = try await self._serviceProvider.service?.insert(instance) + } catch { + Logger.error(error) + } + } + + } + + fileprivate func _sendDeletionIfNecessary(_ instance: T) { + guard self.synchronized else { + return + } + + Task { + do { + let _ = try await self._serviceProvider.service?.delete(instance) + } catch { + Logger.error(error) + } + } + + } + + // MARK: - RandomAccessCollection + + public var startIndex: Int { return self.items.startIndex } + + public var endIndex: Int { return self.items.endIndex } + + public func index(after i: Int) -> Int { + return self.items.index(after: i) + } + + open subscript(index: Int) -> T { + get { + return self.items[index] + } + set(newValue) { + self.items[index] = newValue + self._hasChanged = true + } + } + +} diff --git a/LeStorage/Utils/Codable+Extensions.swift b/LeStorage/Utils/Codable+Extensions.swift new file mode 100644 index 0000000..1c8e98f --- /dev/null +++ b/LeStorage/Utils/Codable+Extensions.swift @@ -0,0 +1,53 @@ +// +// Codable+Extensions.swift +// Poker Analytics 6 +// +// Created by Laurent Morvillier on 18/01/2023. +// + +import Foundation + +extension Encodable { + + func jsonString() throws -> String { + let data = try self.jsonData() + return String(data: data, encoding: .utf8) ?? "" + } + + func jsonData() throws -> Data { + let encoder: JSONEncoder = JSONEncoder() + return try encoder.encode(self) + } + + func prettyJSONString() throws -> String { + let encoder: JSONEncoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8) ?? "" + } + +} + +extension String { + + func decode() throws -> T? { + return try self.data(using: .utf8)?.decode() + } + + func decodeArray() throws -> [T]? { + return try self.data(using: .utf8)?.decodeArray() + } + +} + +extension Data { + + func decode() throws -> T { + return try JSONDecoder().decode(T.self, from: self) + } + + func decodeArray() throws -> [T] { + return try JSONDecoder().decode([T].self, from: self) + } + +} diff --git a/LeStorage/Utils/FileUtils.swift b/LeStorage/Utils/FileUtils.swift new file mode 100644 index 0000000..a9a39a0 --- /dev/null +++ b/LeStorage/Utils/FileUtils.swift @@ -0,0 +1,70 @@ +// +// FileWriter.swift +// Poker Analytics 4 +// +// Created by Laurent Morvillier on 04/09/2018. +// + +import Foundation + +enum FileError : Error { + case documentDirectoryNotFound +} + +enum FileFormat { + case csv + case html +} + +class FileUtils { + + static func pathsFromDocumentsDirectory() throws -> [String] { + let documentsURL: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return try FileManager.default.contentsOfDirectory(atPath: documentsURL.path) + } + + static func readDocumentFile(fileName: String) throws -> String { + let fileURL: URL = try self.directoryURLForFileName(fileName) +// Logger.log("url = \(fileURL.absoluteString)") + return try String(contentsOf: fileURL, encoding: .utf8) + +// if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { +// let fileURL: URL = dir.appendingPathComponent(fileName) +// Logger.log("url = \(fileURL.absoluteString)") +// return try String(contentsOf: fileURL, encoding: .utf8) +// } +// throw FileError.documentDirectoryNotFound + } + + static func readFile(fileURL: URL) throws -> String { + return try String(contentsOf: fileURL, encoding: .utf8) + } + + static func directoryURLForFileName(_ fileName: String) throws -> URL { + if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + return dir.appendingPathComponent(fileName) + } + throw FileError.documentDirectoryNotFound + } + + static func writeToDocumentDirectory(content: String, fileName: String) throws -> URL { + + if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let fileURL: URL = dir.appendingPathComponent(fileName) + try content.write(to: fileURL, atomically: false, encoding: .utf8) + return fileURL + } + throw FileError.documentDirectoryNotFound + } + + @discardableResult static func writeToDocumentDirectory(data: Data, fileName: String) throws -> URL { + + if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let fileURL: URL = dir.appendingPathComponent(fileName) + try data.write(to: fileURL) + return fileURL + } + throw FileError.documentDirectoryNotFound + } + +} diff --git a/LeStorage/Utils/Logger.swift b/LeStorage/Utils/Logger.swift new file mode 100644 index 0000000..43d26f1 --- /dev/null +++ b/LeStorage/Utils/Logger.swift @@ -0,0 +1,65 @@ +// +// Logger.swift +// Poker Analytics 4 +// +// Created by Laurent Morvillier on 29/03/2018. +// + +import Foundation +#if !DEBUG +import Firebase +#endif + +@objc public class Logger : NSObject { + + @objc static public func log(_ message: Any, file: String = #file, function: String = #function, line: Int = #line) { + let filestr: NSString = NSString(string: file) + print("\(filestr.lastPathComponent).\(line).\(function): \(message)") + } + + @objc static public func error(_ error: Error) { + Logger.error(error, file: #file, function: #function, line: #line) + } + + static public func error(_ error: Error, file: String = #file, function: String = #function, line: Int = #line) { + let filestr: NSString = NSString(string: file) + var fireBaseError: Error { + if let customError = error as? CustomNSError & LocalizedError { + return customError.fireBaseError + } else { + return error + } + } + print("ERROR: \(filestr.lastPathComponent).\(line).\(function): \(fireBaseError)") + } + + @objc static public func w(_ message: Any, file: String = #file, function: String = #function, line: Int = #line) { + let filestr: NSString = NSString(string: file) + print("Warning: \(filestr.lastPathComponent).\(line).\(function): \(message)") + } + + @objc static public func crashLogging(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + let fileName: String = file.components(separatedBy: "/").last ?? file + #if DEBUG + NSLogv("%@.%i.%@: %@", getVaList([fileName, line, function, message])) + #else + Crashlytics.crashlytics().log(format: "%@.%i.%@: %@", arguments: getVaList([fileName, line, function, message])) + #endif + } + +} + +extension LocalizedError where Self: CustomNSError { + var simpleErrorDescription: String { + let mirror = Mirror(reflecting: self) + if let label = mirror.children.first?.label { + return label + } else { + return String(describing:self) + } + } + + var fireBaseError: NSError { + NSError(domain: Self.errorDomain + "." + self.simpleErrorDescription, code: self.errorCode, userInfo: self.errorUserInfo) + } +}