Major refactor of data model
Moved all password data except ID and secret value to SwiftData table as preparation for further data collection
This commit is contained in:
parent
c3f8103482
commit
61ee809df5
10 changed files with 315 additions and 135 deletions
92
password/data/manager/PasswordManager.swift
Normal file
92
password/data/manager/PasswordManager.swift
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
//
|
||||||
|
// PasswordManager.swift
|
||||||
|
// password
|
||||||
|
//
|
||||||
|
// Created by Markus Thielker on 17.01.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUICore
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
class PasswordManager {
|
||||||
|
|
||||||
|
let passwordRepository: PasswordRepository
|
||||||
|
let passwordKeychainRepository: PasswordKeychainRepository
|
||||||
|
|
||||||
|
private let context: ModelContext
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: ModelContext,
|
||||||
|
passwordRepository: PasswordRepository,
|
||||||
|
passwordKeychainRepository: PasswordKeychainRepository
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.passwordRepository = passwordRepository
|
||||||
|
self.passwordKeychainRepository = passwordKeychainRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func getAllPasswords() -> [Password] {
|
||||||
|
return passwordRepository.getAllPasswords()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func getPassword(withID id: UUID) -> Password? {
|
||||||
|
return passwordRepository.getPassword(withID: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPasswordKeychain(withID id: UUID) -> PasswordKC? {
|
||||||
|
return passwordKeychainRepository.getPassword(withID: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func createPassword(name: String, value: String) -> Password {
|
||||||
|
|
||||||
|
print("creating password \(name)")
|
||||||
|
|
||||||
|
let password = Password(name: name)
|
||||||
|
|
||||||
|
do {
|
||||||
|
|
||||||
|
try context.transaction {
|
||||||
|
passwordRepository.createPassword(password)
|
||||||
|
let passwordKC = PasswordKC(id: password.id, value: value)
|
||||||
|
try passwordKeychainRepository.createPassword(passwordKC)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("password created successfully")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
context.rollback()
|
||||||
|
print("password creation failed: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func deletePassword(_ password: Password) -> Bool {
|
||||||
|
|
||||||
|
print("deleting password \(password.name)")
|
||||||
|
|
||||||
|
var successful = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
|
||||||
|
try context.transaction {
|
||||||
|
passwordRepository.deletePassword(password)
|
||||||
|
try passwordKeychainRepository.deletePassword(withID: password.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
successful = true
|
||||||
|
print("password deleted successfully")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
context.rollback()
|
||||||
|
print("password deletion failed: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return successful
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,16 +6,17 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct Password: Identifiable, Codable {
|
@Model
|
||||||
let id: UUID
|
class Password: Identifiable {
|
||||||
let name: String
|
|
||||||
let value: String
|
@Attribute(.unique) var id: UUID
|
||||||
|
@Attribute(.unique) var name: String
|
||||||
var createdAt: Date = Date()
|
var createdAt: Date = Date()
|
||||||
|
|
||||||
init(id: UUID = UUID(), name: String, value: String) {
|
init(id: UUID = UUID(), name: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.value = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
19
password/data/model/PasswordKC.swift
Normal file
19
password/data/model/PasswordKC.swift
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
//
|
||||||
|
// PasswordKC.swift
|
||||||
|
// password
|
||||||
|
//
|
||||||
|
// Created by Markus Thielker on 17.01.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PasswordKC: Identifiable, Codable {
|
||||||
|
|
||||||
|
let id: UUID
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
init(id: UUID, value: String) {
|
||||||
|
self.id = id
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
80
password/data/repository/PasswordKeychainRepository.swift
Normal file
80
password/data/repository/PasswordKeychainRepository.swift
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
//
|
||||||
|
// PasswordRepository.swift
|
||||||
|
// password
|
||||||
|
//
|
||||||
|
// Created by Markus Thielker on 16.01.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum KeychainError: Error {
|
||||||
|
case unknown
|
||||||
|
case duplicateItem
|
||||||
|
case itemNotFound
|
||||||
|
case unexpectedPasswordData
|
||||||
|
}
|
||||||
|
|
||||||
|
class PasswordKeychainRepository {
|
||||||
|
|
||||||
|
private let serviceName = "dev.thielker.password"
|
||||||
|
|
||||||
|
func getPassword(withID id: UUID) -> PasswordKC? {
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: serviceName,
|
||||||
|
kSecAttrAccount as String: id.uuidString,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard let data = result as? Data,
|
||||||
|
let password = try? JSONDecoder().decode(PasswordKC.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPassword(_ password: PasswordKC) throws {
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword, // Generic password item
|
||||||
|
kSecAttrService as String: serviceName, // Service name for your app
|
||||||
|
kSecAttrAccount as String: password.id.uuidString, // Unique identifier
|
||||||
|
kSecValueData as String: try JSONEncoder().encode(password) // Encode password data
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
if status == errSecDuplicateItem {
|
||||||
|
throw KeychainError.duplicateItem
|
||||||
|
} else {
|
||||||
|
throw KeychainError.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePassword(withID id: UUID) throws {
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: serviceName,
|
||||||
|
kSecAttrAccount as String: id.uuidString
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
if status == errSecItemNotFound {
|
||||||
|
throw KeychainError.itemNotFound
|
||||||
|
} else {
|
||||||
|
throw KeychainError.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,120 +2,74 @@
|
||||||
// PasswordRepository.swift
|
// PasswordRepository.swift
|
||||||
// password
|
// password
|
||||||
//
|
//
|
||||||
// Created by Markus Thielker on 16.01.25.
|
// Created by Markus Thielker on 17.01.25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import SwiftUICore
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
enum KeychainError: Error {
|
enum PasswordRepositoryError: Error {
|
||||||
case unknown
|
case notFound
|
||||||
case duplicateItem
|
|
||||||
case itemNotFound
|
|
||||||
case unexpectedPasswordData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol PasswordRepository {
|
class PasswordRepository {
|
||||||
func getAllPasswords() -> [Password]
|
|
||||||
func getPassword(withID id: UUID) -> Password?
|
|
||||||
func savePassword(_ password: Password) throws
|
|
||||||
func deletePassword(withID id: UUID) throws
|
|
||||||
}
|
|
||||||
|
|
||||||
class KeychainPasswordRepository: PasswordRepository {
|
private let context: ModelContext
|
||||||
|
|
||||||
private let serviceName = "dev.thielker.password"
|
init(_ context: ModelContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func getAllPasswords() -> [Password] {
|
func getAllPasswords() -> [Password] {
|
||||||
|
|
||||||
// TODO: fix query to work with 'kSecMatchLimit as String: kSecMatchLimitAll'
|
print("fetching passwords")
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: serviceName,
|
|
||||||
kSecMatchLimit as String: 100,
|
|
||||||
kSecReturnAttributes as String: true,
|
|
||||||
kSecReturnData as String: true,
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
|
|
||||||
if status != errSecSuccess {
|
|
||||||
print("Error retrieving passwords: \(SecCopyErrorMessageString(status, nil) ?? "Unknown error" as CFString)")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let items = result as? [[String: Any]] else {
|
|
||||||
print("No passwords found.")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
var passwords: [Password] = []
|
var passwords: [Password] = []
|
||||||
for item in items {
|
do {
|
||||||
if let data = item[kSecValueData as String] as? Data,
|
|
||||||
let password = try? JSONDecoder().decode(Password.self, from: data) {
|
let request = FetchDescriptor<Password>()
|
||||||
passwords.append(password)
|
passwords = try context.fetch(request)
|
||||||
}
|
|
||||||
|
print("found \(passwords.count) passwords")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("fetching password failed: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return passwords
|
return passwords
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func getPassword(withID id: UUID) -> Password? {
|
func getPassword(withID id: UUID) -> Password? {
|
||||||
|
|
||||||
let query: [String: Any] = [
|
print("fetching password with id \(id)")
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: serviceName,
|
|
||||||
kSecAttrAccount as String: id.uuidString,
|
|
||||||
kSecReturnData as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
var password: Password?
|
||||||
SecItemCopyMatching(query as CFDictionary, &result)
|
do {
|
||||||
|
|
||||||
guard let data = result as? Data,
|
let request = FetchDescriptor<Password>(predicate: #Predicate { $0.id == id })
|
||||||
let password = try? JSONDecoder().decode(Password.self, from: data) else {
|
password = try context.fetch(request).first
|
||||||
return nil
|
|
||||||
|
print("found password: \(password == nil)")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("fetching password failed: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return password
|
return password
|
||||||
}
|
}
|
||||||
|
|
||||||
func savePassword(_ password: Password) throws {
|
@MainActor
|
||||||
|
func createPassword(_ password: Password) {
|
||||||
let query: [String: Any] = [
|
context.insert(password)
|
||||||
kSecClass as String: kSecClassGenericPassword, // Generic password item
|
print("inserted password \(password.name)")
|
||||||
kSecAttrService as String: serviceName, // Service name for your app
|
|
||||||
kSecAttrAccount as String: password.id.uuidString, // Unique identifier
|
|
||||||
kSecValueData as String: try JSONEncoder().encode(password) // Encode password data
|
|
||||||
]
|
|
||||||
|
|
||||||
let status = SecItemAdd(query as CFDictionary, nil)
|
|
||||||
|
|
||||||
guard status == errSecSuccess else {
|
|
||||||
if status == errSecDuplicateItem {
|
|
||||||
throw KeychainError.duplicateItem
|
|
||||||
} else {
|
|
||||||
throw KeychainError.unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deletePassword(withID id: UUID) throws {
|
@MainActor
|
||||||
|
func deletePassword(_ password: Password) {
|
||||||
let query: [String: Any] = [
|
context.delete(password)
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
print("deleted password \(password.name)")
|
||||||
kSecAttrService as String: serviceName,
|
|
||||||
kSecAttrAccount as String: id.uuidString
|
|
||||||
]
|
|
||||||
|
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
|
||||||
|
|
||||||
guard status == errSecSuccess else {
|
|
||||||
if status == errSecItemNotFound {
|
|
||||||
throw KeychainError.itemNotFound
|
|
||||||
} else {
|
|
||||||
throw KeychainError.unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,7 @@ struct passwordApp: App {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
VStack {
|
VStack {
|
||||||
if isAuthenticated {
|
if isAuthenticated {
|
||||||
ListView(viewModel: ListViewModel())
|
ContextWrapper()
|
||||||
} else {
|
} else {
|
||||||
Button("Authenticate") {
|
Button("Authenticate") {
|
||||||
authenticate()
|
authenticate()
|
||||||
|
@ -60,5 +60,15 @@ struct passwordApp: App {
|
||||||
authenticate()
|
authenticate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modelContainer(for: [Password.self])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContextWrapper: View {
|
||||||
|
|
||||||
|
@Environment(\.modelContext) var context
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ListView(viewModel: ListViewModel(context: context))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ struct AddPasswordView: View {
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
HStack {
|
HStack {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
viewModel.savePassword(name: name, value: value)
|
viewModel.createPassword(name: name, value: value)
|
||||||
name = ""
|
name = ""
|
||||||
value = ""
|
value = ""
|
||||||
dismiss()
|
dismiss()
|
||||||
|
@ -47,5 +47,9 @@ struct AddPasswordView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AddPasswordView(viewModel: .init())
|
|
||||||
|
@Previewable
|
||||||
|
@Environment(\.modelContext) var context
|
||||||
|
|
||||||
|
AddPasswordView(viewModel: .init(context: context))
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,12 @@ import SwiftUI
|
||||||
struct DetailView: View {
|
struct DetailView: View {
|
||||||
|
|
||||||
let password: Password
|
let password: Password
|
||||||
|
let passwordKC: PasswordKC
|
||||||
|
|
||||||
@State var value: String = ""
|
@State var value: String = ""
|
||||||
|
|
||||||
func validateInput(input: String, password: Password) -> Bool {
|
func validateInput(input: String, password: Password) -> Bool {
|
||||||
return input == password.value
|
return input == passwordKC.value
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@ -47,6 +49,7 @@ struct DetailView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
let password = Password(name: "macbook", value: "password")
|
let password = Password(name: "macbook")
|
||||||
DetailView(password: password)
|
let passwordKC = PasswordKC(id: password.id, value: "admin")
|
||||||
|
DetailView(password: password, passwordKC: passwordKC)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import _SwiftData_SwiftUI
|
||||||
|
|
||||||
struct ListView: View {
|
struct ListView: View {
|
||||||
|
|
||||||
|
@ -16,7 +17,10 @@ struct ListView: View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
List {
|
List {
|
||||||
ForEach(viewModel.passwords) { password in
|
ForEach(viewModel.passwords) { password in
|
||||||
NavigationLink(destination: DetailView(password: password)) {
|
NavigationLink(destination: DetailView(
|
||||||
|
password: password,
|
||||||
|
passwordKC: viewModel.getPasswordKeychain(withID: password.id)!
|
||||||
|
)) {
|
||||||
Text(password.name)
|
Text(password.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,7 +32,7 @@ struct ListView: View {
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
}
|
}
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.loadAllPasswords()
|
viewModel.getAllPasswords()
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "arrow.trianglehead.clockwise")
|
Image(systemName: "arrow.trianglehead.clockwise")
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
|
@ -41,11 +45,15 @@ struct ListView: View {
|
||||||
AddPasswordView(viewModel: viewModel)
|
AddPasswordView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.loadAllPasswords()
|
viewModel.getAllPasswords()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ListView(viewModel: .init())
|
|
||||||
|
@Previewable
|
||||||
|
@Environment(\.modelContext) var context
|
||||||
|
|
||||||
|
ListView(viewModel: .init(context: context))
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,48 +6,57 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
class ListViewModel: ObservableObject {
|
class ListViewModel: ObservableObject {
|
||||||
|
|
||||||
@Published var passwords: [Password] = []
|
@Published var passwords: [Password] = []
|
||||||
|
|
||||||
private let passwordRepository: PasswordRepository
|
private let context: ModelContext
|
||||||
|
private let passwordManager: PasswordManager
|
||||||
|
|
||||||
init(passwordRepository: PasswordRepository = KeychainPasswordRepository()) {
|
@MainActor
|
||||||
self.passwordRepository = passwordRepository
|
init(context: ModelContext) {
|
||||||
loadAllPasswords()
|
self.context = context
|
||||||
|
|
||||||
|
let passwordRepository = PasswordRepository(context)
|
||||||
|
let passwordKeychainRepository = PasswordKeychainRepository()
|
||||||
|
|
||||||
|
self.passwordManager = PasswordManager(
|
||||||
|
context: context,
|
||||||
|
passwordRepository: passwordRepository,
|
||||||
|
passwordKeychainRepository: passwordKeychainRepository
|
||||||
|
)
|
||||||
|
|
||||||
|
passwords = getAllPasswords()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAllPasswords() {
|
@MainActor
|
||||||
passwords = passwordRepository.getAllPasswords()
|
func getAllPasswords() -> [Password] {
|
||||||
|
return passwordManager.getAllPasswords()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPassword(withID id: UUID) -> Password? {
|
@MainActor
|
||||||
return passwordRepository.getPassword(withID: id)
|
func getPassword(withID id: UUID) -> Password? {
|
||||||
|
return passwordManager.getPassword(withID: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func savePassword(name: String, value: String) {
|
@MainActor
|
||||||
let newPassword = Password(name: name, value: value)
|
func getPasswordKeychain(withID id: UUID) -> PasswordKC? {
|
||||||
|
return passwordManager.getPasswordKeychain(withID: id)
|
||||||
do {
|
|
||||||
|
|
||||||
try passwordRepository.savePassword(newPassword)
|
|
||||||
print ("Saved password successfully")
|
|
||||||
loadAllPasswords()
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
print("Error saving password: \(error)")
|
|
||||||
// TODO: display error to user
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func createPassword(name: String, value: String) {
|
||||||
|
passwordManager.createPassword(name: name, value: value)
|
||||||
|
passwords = getAllPasswords()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func deletePassword(_ password: Password) {
|
func deletePassword(_ password: Password) {
|
||||||
do {
|
let success = passwordManager.deletePassword(password)
|
||||||
try passwordRepository.deletePassword(withID: password.id)
|
if success {
|
||||||
loadAllPasswords()
|
passwords = getAllPasswords()
|
||||||
} catch {
|
|
||||||
print("Error deleting password: \(error)")
|
|
||||||
// TODO: display error to user
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue