base commit

multistore
Laurent 2 years ago
parent 04a833b2c7
commit daa34132c4
  1. 28
      LeStorage.xcodeproj/project.pbxproj
  2. 92
      LeStorage/Services.swift
  3. 24
      LeStorage/Store.swift
  4. 181
      LeStorage/StoredCollection.swift
  5. 53
      LeStorage/Utils/Codable+Extensions.swift
  6. 70
      LeStorage/Utils/FileUtils.swift
  7. 65
      LeStorage/Utils/Logger.swift

@ -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 = "<group>"; };
C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; };
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; };
C4A47D522B6D2C5F00ADC637 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
C4A47D602B6D3C1300ADC637 /* Services.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -88,6 +101,16 @@
path = LeStorageTests;
sourceTree = "<group>";
};
C4A47D582B6D352900ADC637 /* Utils */ = {
isa = PBXGroup;
children = (
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */,
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */,
C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
);
path = Utils;
sourceTree = "<group>";
};
/* 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;

@ -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<T : Decodable>(_ 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<T : Storable>() async throws -> [T] {
let getRequest = try getRequest(servicePath: T.resourceName + "/")
return try await self.runRequest(getRequest)
}
func insert<T : Storable>(_ instance: T) async throws -> T {
let postRequest = try postRequest(servicePath: T.resourceName + "/")
return try await self.runRequest(postRequest)
}
func update<T : Storable>(_ instance: T) async throws -> T {
let postRequest = try putRequest(servicePath: T.resourceName + "/")
return try await self.runRequest(postRequest)
}
func delete<T : Storable>(_ instance: T) async throws -> T {
let postRequest = try deleteRequest(servicePath: T.resourceName + "/")
return try await self.runRequest(postRequest)
}
}

@ -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<T : Storable>(synchronized: Bool) -> StoredCollection<T> {
return StoredCollection(synchronized: synchronized, serviceProvider: self)
}
public init() { }
var service: Services? {
return self._services
}
}

@ -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<T : Storable> : 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
}
}
}

@ -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<T : Decodable>() throws -> T? {
return try self.data(using: .utf8)?.decode()
}
func decodeArray<T : Decodable>() throws -> [T]? {
return try self.data(using: .utf8)?.decodeArray()
}
}
extension Data {
func decode<T : Decodable>() throws -> T {
return try JSONDecoder().decode(T.self, from: self)
}
func decodeArray<T : Decodable>() throws -> [T] {
return try JSONDecoder().decode([T].self, from: self)
}
}

@ -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
}
}

@ -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)
}
}
Loading…
Cancel
Save