mirror of
https://github.com/Thealexbarney/LibHac.git
synced 2024-11-14 10:49:41 +01:00
Use plain structs for kvdb
This commit is contained in:
parent
be907ce4bb
commit
dee7c93285
7 changed files with 186 additions and 105 deletions
|
@ -7,7 +7,7 @@ using LibHac.Ncm;
|
||||||
namespace LibHac.Fs
|
namespace LibHac.Fs
|
||||||
{
|
{
|
||||||
[StructLayout(LayoutKind.Explicit, Size = 0x40)]
|
[StructLayout(LayoutKind.Explicit, Size = 0x40)]
|
||||||
public struct SaveDataAttribute
|
public struct SaveDataAttribute : IEquatable<SaveDataAttribute>, IComparable<SaveDataAttribute>
|
||||||
{
|
{
|
||||||
[FieldOffset(0x00)] public ulong TitleId;
|
[FieldOffset(0x00)] public ulong TitleId;
|
||||||
[FieldOffset(0x08)] public UserId UserId;
|
[FieldOffset(0x08)] public UserId UserId;
|
||||||
|
@ -15,6 +15,50 @@ namespace LibHac.Fs
|
||||||
[FieldOffset(0x20)] public SaveDataType Type;
|
[FieldOffset(0x20)] public SaveDataType Type;
|
||||||
[FieldOffset(0x21)] public byte Rank;
|
[FieldOffset(0x21)] public byte Rank;
|
||||||
[FieldOffset(0x22)] public short Index;
|
[FieldOffset(0x22)] public short Index;
|
||||||
|
|
||||||
|
public override bool Equals(object obj)
|
||||||
|
{
|
||||||
|
return obj is SaveDataAttribute attribute && Equals(attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(SaveDataAttribute other)
|
||||||
|
{
|
||||||
|
return TitleId == other.TitleId &&
|
||||||
|
Type == other.Type &&
|
||||||
|
UserId.Equals(other.UserId) &&
|
||||||
|
SaveDataId == other.SaveDataId &&
|
||||||
|
Rank == other.Rank &&
|
||||||
|
Index == other.Index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
// ReSharper disable NonReadonlyMemberInGetHashCode
|
||||||
|
int hashCode = 487790375;
|
||||||
|
hashCode = hashCode * -1521134295 + TitleId.GetHashCode();
|
||||||
|
hashCode = hashCode * -1521134295 + Type.GetHashCode();
|
||||||
|
hashCode = hashCode * -1521134295 + UserId.GetHashCode();
|
||||||
|
hashCode = hashCode * -1521134295 + SaveDataId.GetHashCode();
|
||||||
|
hashCode = hashCode * -1521134295 + Rank.GetHashCode();
|
||||||
|
hashCode = hashCode * -1521134295 + Index.GetHashCode();
|
||||||
|
return hashCode;
|
||||||
|
// ReSharper restore NonReadonlyMemberInGetHashCode
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CompareTo(SaveDataAttribute other)
|
||||||
|
{
|
||||||
|
int titleIdComparison = TitleId.CompareTo(other.TitleId);
|
||||||
|
if (titleIdComparison != 0) return titleIdComparison;
|
||||||
|
int typeComparison = Type.CompareTo(other.Type);
|
||||||
|
if (typeComparison != 0) return typeComparison;
|
||||||
|
int userIdComparison = UserId.CompareTo(other.UserId);
|
||||||
|
if (userIdComparison != 0) return userIdComparison;
|
||||||
|
int saveDataIdComparison = SaveDataId.CompareTo(other.SaveDataId);
|
||||||
|
if (saveDataIdComparison != 0) return saveDataIdComparison;
|
||||||
|
int rankComparison = Rank.CompareTo(other.Rank);
|
||||||
|
if (rankComparison != 0) return rankComparison;
|
||||||
|
return Index.CompareTo(other.Index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Explicit, Size = 0x48)]
|
[StructLayout(LayoutKind.Explicit, Size = 0x48)]
|
||||||
|
|
15
src/LibHac/FsService/SaveDataIndexerEntry.cs
Normal file
15
src/LibHac/FsService/SaveDataIndexerEntry.cs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using LibHac.Fs;
|
||||||
|
|
||||||
|
namespace LibHac.FsService
|
||||||
|
{
|
||||||
|
[StructLayout(LayoutKind.Explicit, Size = 0x40)]
|
||||||
|
public struct SaveDataIndexerEntry
|
||||||
|
{
|
||||||
|
[FieldOffset(0x00)] public ulong SaveDataId;
|
||||||
|
[FieldOffset(0x08)] public ulong Size;
|
||||||
|
[FieldOffset(0x10)] public ulong Field10;
|
||||||
|
[FieldOffset(0x18)] public SaveDataSpaceId SpaceId;
|
||||||
|
[FieldOffset(0x19)] public byte State;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,40 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Buffers.Binary;
|
|
||||||
using LibHac.Kvdb;
|
|
||||||
|
|
||||||
namespace LibHac.FsService
|
|
||||||
{
|
|
||||||
public class SaveIndexerStruct : IExportable
|
|
||||||
{
|
|
||||||
public ulong SaveId { get; private set; }
|
|
||||||
public ulong Size { get; private set; }
|
|
||||||
public byte SpaceId { get; private set; }
|
|
||||||
public byte Field19 { get; private set; }
|
|
||||||
|
|
||||||
public int ExportSize => 0x40;
|
|
||||||
private bool _isFrozen;
|
|
||||||
|
|
||||||
public void ToBytes(Span<byte> output)
|
|
||||||
{
|
|
||||||
if(output.Length < ExportSize) throw new InvalidOperationException("Output buffer is too small.");
|
|
||||||
|
|
||||||
BinaryPrimitives.WriteUInt64LittleEndian(output, SaveId);
|
|
||||||
BinaryPrimitives.WriteUInt64LittleEndian(output.Slice(8), Size);
|
|
||||||
output[0x18] = SpaceId;
|
|
||||||
output[0x19] = Field19;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void FromBytes(ReadOnlySpan<byte> input)
|
|
||||||
{
|
|
||||||
if(_isFrozen) throw new InvalidOperationException("Unable to modify frozen object.");
|
|
||||||
if (input.Length < ExportSize) throw new InvalidOperationException("Input data is too short.");
|
|
||||||
|
|
||||||
SaveId = BinaryPrimitives.ReadUInt64LittleEndian(input);
|
|
||||||
Size = BinaryPrimitives.ReadUInt64LittleEndian(input.Slice(8));
|
|
||||||
SpaceId = input[0x18];
|
|
||||||
Field19 = input[0x19];
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Freeze() => _isFrozen = true;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace LibHac.Kvdb
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A class for handling any value used by <see cref="KeyValueDatabase{TKey,TValue}"/>
|
|
||||||
/// </summary>
|
|
||||||
public class GenericValue : IExportable
|
|
||||||
{
|
|
||||||
private bool _isFrozen;
|
|
||||||
private byte[] _value;
|
|
||||||
|
|
||||||
public int ExportSize => _value?.Length ?? 0;
|
|
||||||
|
|
||||||
public void ToBytes(Span<byte> output)
|
|
||||||
{
|
|
||||||
if (output.Length < ExportSize) throw new InvalidOperationException("Output buffer is too small.");
|
|
||||||
|
|
||||||
_value.CopyTo(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void FromBytes(ReadOnlySpan<byte> input)
|
|
||||||
{
|
|
||||||
if (_isFrozen) throw new InvalidOperationException("Unable to modify frozen object.");
|
|
||||||
|
|
||||||
_value = input.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Freeze() => _isFrozen = true;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,7 +5,7 @@ namespace LibHac.Kvdb
|
||||||
{
|
{
|
||||||
public ref struct ImkvdbReader
|
public ref struct ImkvdbReader
|
||||||
{
|
{
|
||||||
private ReadOnlySpan<byte> _data;
|
private readonly ReadOnlySpan<byte> _data;
|
||||||
private int _position;
|
private int _position;
|
||||||
|
|
||||||
public ImkvdbReader(ReadOnlySpan<byte> data)
|
public ImkvdbReader(ReadOnlySpan<byte> data)
|
||||||
|
@ -18,13 +18,14 @@ namespace LibHac.Kvdb
|
||||||
{
|
{
|
||||||
entryCount = default;
|
entryCount = default;
|
||||||
|
|
||||||
if (_position + Unsafe.SizeOf<ImkvdbHeader>() > _data.Length) return ResultKvdb.InvalidKeyValue;
|
if (_position + Unsafe.SizeOf<ImkvdbHeader>() > _data.Length)
|
||||||
|
return ResultKvdb.InvalidKeyValue.Log();
|
||||||
|
|
||||||
ref ImkvdbHeader header = ref Unsafe.As<byte, ImkvdbHeader>(ref Unsafe.AsRef(_data[_position]));
|
ref ImkvdbHeader header = ref Unsafe.As<byte, ImkvdbHeader>(ref Unsafe.AsRef(_data[_position]));
|
||||||
|
|
||||||
if (header.Magic != ImkvdbHeader.ExpectedMagic)
|
if (header.Magic != ImkvdbHeader.ExpectedMagic)
|
||||||
{
|
{
|
||||||
return ResultKvdb.InvalidKeyValue;
|
return ResultKvdb.InvalidKeyValue.Log();
|
||||||
}
|
}
|
||||||
|
|
||||||
entryCount = header.EntryCount;
|
entryCount = header.EntryCount;
|
||||||
|
@ -38,13 +39,14 @@ namespace LibHac.Kvdb
|
||||||
keySize = default;
|
keySize = default;
|
||||||
valueSize = default;
|
valueSize = default;
|
||||||
|
|
||||||
if (_position + Unsafe.SizeOf<ImkvdbHeader>() > _data.Length) return ResultKvdb.InvalidKeyValue;
|
if (_position + Unsafe.SizeOf<ImkvdbHeader>() > _data.Length)
|
||||||
|
return ResultKvdb.InvalidKeyValue.Log();
|
||||||
|
|
||||||
ref ImkvdbEntryHeader header = ref Unsafe.As<byte, ImkvdbEntryHeader>(ref Unsafe.AsRef(_data[_position]));
|
ref ImkvdbEntryHeader header = ref Unsafe.As<byte, ImkvdbEntryHeader>(ref Unsafe.AsRef(_data[_position]));
|
||||||
|
|
||||||
if (header.Magic != ImkvdbEntryHeader.ExpectedMagic)
|
if (header.Magic != ImkvdbEntryHeader.ExpectedMagic)
|
||||||
{
|
{
|
||||||
return ResultKvdb.InvalidKeyValue;
|
return ResultKvdb.InvalidKeyValue.Log();
|
||||||
}
|
}
|
||||||
|
|
||||||
keySize = header.KeySize;
|
keySize = header.KeySize;
|
||||||
|
@ -63,7 +65,8 @@ namespace LibHac.Kvdb
|
||||||
|
|
||||||
_position += Unsafe.SizeOf<ImkvdbEntryHeader>();
|
_position += Unsafe.SizeOf<ImkvdbEntryHeader>();
|
||||||
|
|
||||||
if (_position + keySize + valueSize > _data.Length) return ResultKvdb.InvalidKeyValue;
|
if (_position + keySize + valueSize > _data.Length)
|
||||||
|
return ResultKvdb.InvalidKeyValue.Log();
|
||||||
|
|
||||||
key = _data.Slice(_position, keySize);
|
key = _data.Slice(_position, keySize);
|
||||||
value = _data.Slice(_position + keySize, valueSize);
|
value = _data.Slice(_position + keySize, valueSize);
|
||||||
|
|
|
@ -5,7 +5,7 @@ namespace LibHac.Kvdb
|
||||||
{
|
{
|
||||||
public ref struct ImkvdbWriter
|
public ref struct ImkvdbWriter
|
||||||
{
|
{
|
||||||
private Span<byte> _data;
|
private readonly Span<byte> _data;
|
||||||
private int _position;
|
private int _position;
|
||||||
|
|
||||||
public ImkvdbWriter(Span<byte> data)
|
public ImkvdbWriter(Span<byte> data)
|
||||||
|
@ -27,9 +27,9 @@ namespace LibHac.Kvdb
|
||||||
_position += Unsafe.SizeOf<ImkvdbHeader>();
|
_position += Unsafe.SizeOf<ImkvdbHeader>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void WriteEntry(IExportable key, IExportable value)
|
public void WriteEntry(ReadOnlySpan<byte> key, ReadOnlySpan<byte> value)
|
||||||
{
|
{
|
||||||
WriteEntryHeader(key.ExportSize, value.ExportSize);
|
WriteEntryHeader(key.Length, value.Length);
|
||||||
Write(key);
|
Write(key);
|
||||||
Write(value);
|
Write(value);
|
||||||
}
|
}
|
||||||
|
@ -47,13 +47,13 @@ namespace LibHac.Kvdb
|
||||||
_position += Unsafe.SizeOf<ImkvdbEntryHeader>();
|
_position += Unsafe.SizeOf<ImkvdbEntryHeader>();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Write(IExportable value)
|
private void Write(ReadOnlySpan<byte> value)
|
||||||
{
|
{
|
||||||
int valueSize = value.ExportSize;
|
int valueSize = value.Length;
|
||||||
if (_position + valueSize > _data.Length) throw new InvalidOperationException();
|
if (_position + valueSize > _data.Length) throw new InvalidOperationException();
|
||||||
|
|
||||||
Span<byte> dest = _data.Slice(_position, valueSize);
|
Span<byte> dest = _data.Slice(_position, valueSize);
|
||||||
value.ToBytes(dest);
|
value.CopyTo(dest);
|
||||||
|
|
||||||
_position += valueSize;
|
_position += valueSize;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +1,51 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using LibHac.Common;
|
||||||
|
using LibHac.Fs;
|
||||||
|
|
||||||
namespace LibHac.Kvdb
|
namespace LibHac.Kvdb
|
||||||
{
|
{
|
||||||
// Todo: Save and load from file
|
public class KeyValueDatabase<TKey> where TKey : unmanaged, IComparable<TKey>, IEquatable<TKey>
|
||||||
public class KeyValueDatabase<TKey, TValue>
|
|
||||||
where TKey : IComparable<TKey>, IEquatable<TKey>, IExportable, new()
|
|
||||||
where TValue : IExportable, new()
|
|
||||||
{
|
{
|
||||||
private Dictionary<TKey, TValue> KvDict { get; } = new Dictionary<TKey, TValue>();
|
public Dictionary<TKey, byte[]> KvDict { get; } = new Dictionary<TKey, byte[]>();
|
||||||
|
|
||||||
public int Count => KvDict.Count;
|
private FileSystemClient FsClient { get; }
|
||||||
|
private string FileName { get; }
|
||||||
|
|
||||||
public Result Get(TKey key, out TValue value)
|
public KeyValueDatabase() { }
|
||||||
|
|
||||||
|
public KeyValueDatabase(FileSystemClient fsClient, string fileName)
|
||||||
|
{
|
||||||
|
FsClient = fsClient;
|
||||||
|
FileName = fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result Get(ref TKey key, Span<byte> valueBuffer)
|
||||||
|
{
|
||||||
|
Result rc = GetValue(ref key, out byte[] value);
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
int size = Math.Min(valueBuffer.Length, value.Length);
|
||||||
|
|
||||||
|
value.AsSpan(0, size).CopyTo(valueBuffer);
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result GetValue(ref TKey key, out byte[] value)
|
||||||
{
|
{
|
||||||
if (!KvDict.TryGetValue(key, out value))
|
if (!KvDict.TryGetValue(key, out value))
|
||||||
{
|
{
|
||||||
return ResultKvdb.KeyNotFound;
|
return ResultKvdb.KeyNotFound.Log();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success;
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result Set(TKey key, TValue value)
|
public Result Set(ref TKey key, byte[] value)
|
||||||
{
|
{
|
||||||
key.Freeze();
|
|
||||||
|
|
||||||
KvDict[key] = value;
|
KvDict[key] = value;
|
||||||
|
|
||||||
return Result.Success;
|
return Result.Success;
|
||||||
|
@ -35,6 +53,8 @@ namespace LibHac.Kvdb
|
||||||
|
|
||||||
public Result ReadDatabaseFromBuffer(ReadOnlySpan<byte> data)
|
public Result ReadDatabaseFromBuffer(ReadOnlySpan<byte> data)
|
||||||
{
|
{
|
||||||
|
KvDict.Clear();
|
||||||
|
|
||||||
var reader = new ImkvdbReader(data);
|
var reader = new ImkvdbReader(data);
|
||||||
|
|
||||||
Result rc = reader.ReadHeader(out int entryCount);
|
Result rc = reader.ReadHeader(out int entryCount);
|
||||||
|
@ -45,13 +65,12 @@ namespace LibHac.Kvdb
|
||||||
rc = reader.ReadEntry(out ReadOnlySpan<byte> keyBytes, out ReadOnlySpan<byte> valueBytes);
|
rc = reader.ReadEntry(out ReadOnlySpan<byte> keyBytes, out ReadOnlySpan<byte> valueBytes);
|
||||||
if (rc.IsFailure()) return rc;
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
Debug.Assert(keyBytes.Length == Unsafe.SizeOf<TKey>());
|
||||||
|
|
||||||
var key = new TKey();
|
var key = new TKey();
|
||||||
var value = new TValue();
|
keyBytes.CopyTo(SpanHelpers.AsByteSpan(ref key));
|
||||||
|
|
||||||
key.FromBytes(keyBytes);
|
byte[] value = valueBytes.ToArray();
|
||||||
value.FromBytes(valueBytes);
|
|
||||||
|
|
||||||
key.Freeze();
|
|
||||||
|
|
||||||
KvDict.Add(key, value);
|
KvDict.Add(key, value);
|
||||||
}
|
}
|
||||||
|
@ -65,26 +84,97 @@ namespace LibHac.Kvdb
|
||||||
|
|
||||||
writer.WriteHeader(KvDict.Count);
|
writer.WriteHeader(KvDict.Count);
|
||||||
|
|
||||||
foreach (KeyValuePair<TKey, TValue> entry in KvDict.OrderBy(x => x.Key))
|
foreach (KeyValuePair<TKey, byte[]> entry in KvDict.OrderBy(x => x.Key))
|
||||||
{
|
{
|
||||||
writer.WriteEntry(entry.Key, entry.Value);
|
TKey key = entry.Key;
|
||||||
|
writer.WriteEntry(SpanHelpers.AsByteSpan(ref key), entry.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success;
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Result ReadDatabaseFromFile()
|
||||||
|
{
|
||||||
|
if (FsClient == null || FileName == null)
|
||||||
|
return ResultFs.PreconditionViolation.Log();
|
||||||
|
|
||||||
|
Result rc = ReadFile(out byte[] data);
|
||||||
|
|
||||||
|
if (rc.IsFailure())
|
||||||
|
{
|
||||||
|
return rc == ResultFs.PathNotFound ? Result.Success : rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReadDatabaseFromBuffer(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result WriteDatabaseToFile()
|
||||||
|
{
|
||||||
|
if (FsClient == null || FileName == null)
|
||||||
|
return ResultFs.PreconditionViolation.Log();
|
||||||
|
|
||||||
|
var buffer = new byte[GetExportedSize()];
|
||||||
|
|
||||||
|
Result rc = WriteDatabaseToBuffer(buffer);
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
return WriteFile(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
public int GetExportedSize()
|
public int GetExportedSize()
|
||||||
{
|
{
|
||||||
int size = Unsafe.SizeOf<ImkvdbHeader>();
|
int size = Unsafe.SizeOf<ImkvdbHeader>();
|
||||||
|
|
||||||
foreach (KeyValuePair<TKey, TValue> entry in KvDict)
|
foreach (byte[] value in KvDict.Values)
|
||||||
{
|
{
|
||||||
size += Unsafe.SizeOf<ImkvdbEntryHeader>();
|
size += Unsafe.SizeOf<ImkvdbEntryHeader>();
|
||||||
size += entry.Key.ExportSize;
|
size += Unsafe.SizeOf<TKey>();
|
||||||
size += entry.Value.ExportSize;
|
size += value.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Result ReadFile(out byte[] data)
|
||||||
|
{
|
||||||
|
Debug.Assert(FsClient != null);
|
||||||
|
Debug.Assert(!string.IsNullOrWhiteSpace(FileName));
|
||||||
|
|
||||||
|
data = default;
|
||||||
|
|
||||||
|
Result rc = FsClient.OpenFile(out FileHandle handle, FileName, OpenMode.Read);
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
rc = FsClient.GetFileSize(out long fileSize, handle);
|
||||||
|
|
||||||
|
if (rc.IsSuccess())
|
||||||
|
{
|
||||||
|
data = new byte[fileSize];
|
||||||
|
|
||||||
|
rc = FsClient.ReadFile(handle, 0, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
FsClient.CloseFile(handle);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result WriteFile(ReadOnlySpan<byte> data)
|
||||||
|
{
|
||||||
|
Debug.Assert(FsClient != null);
|
||||||
|
Debug.Assert(!string.IsNullOrWhiteSpace(FileName));
|
||||||
|
|
||||||
|
FsClient.DeleteFile(FileName);
|
||||||
|
|
||||||
|
Result rc = FsClient.CreateFile(FileName, data.Length);
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
rc = FsClient.OpenFile(out FileHandle handle, FileName, OpenMode.Write);
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
rc = FsClient.WriteFile(handle, 0, data, WriteOption.Flush);
|
||||||
|
FsClient.CloseFile(handle);
|
||||||
|
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue