mirror of
https://github.com/Thealexbarney/LibHac.git
synced 2024-11-14 10:49:41 +01:00
Add directory mode. Add path normalizer with tests
This commit is contained in:
parent
2bf6613165
commit
8d362d3b0c
9 changed files with 482 additions and 38 deletions
|
@ -136,7 +136,7 @@ namespace LibHac.IO
|
|||
length = (int)Math.Min(Length - offset, length);
|
||||
}
|
||||
|
||||
BaseStorage.Read(block.Buffer, offset, length, 0);
|
||||
BaseStorage.Read(block.Buffer.AsSpan(0, length), offset);
|
||||
block.Length = length;
|
||||
block.Index = index;
|
||||
block.Dirty = false;
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
namespace LibHac.IO
|
||||
using System;
|
||||
|
||||
namespace LibHac.IO
|
||||
{
|
||||
public interface IFileSystem
|
||||
{
|
||||
|
@ -7,11 +9,19 @@
|
|||
void CreateFile(string path, long size);
|
||||
void DeleteDirectory(string path);
|
||||
void DeleteFile(string path);
|
||||
IDirectory OpenDirectory(string path);
|
||||
IDirectory OpenDirectory(string path, OpenDirectoryMode mode);
|
||||
IFile OpenFile(string path);
|
||||
void RenameDirectory(string srcPath, string dstPath);
|
||||
void RenameFile(string srcPath, string dstPath);
|
||||
bool DirectoryExists(string path);
|
||||
bool FileExists(string path);
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum OpenDirectoryMode
|
||||
{
|
||||
Directories = 1,
|
||||
Files = 2,
|
||||
All = Directories | Files
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ namespace LibHac.IO
|
|||
{
|
||||
public static void Extract(this IFileSystem fs, string outDir)
|
||||
{
|
||||
var root = fs.OpenDirectory("/");
|
||||
var root = fs.OpenDirectory("/", OpenDirectoryMode.All);
|
||||
|
||||
foreach (var filename in root.EnumerateFiles())
|
||||
{
|
||||
|
@ -33,7 +33,7 @@ namespace LibHac.IO
|
|||
{
|
||||
if (entry.Type == DirectoryEntryType.Directory)
|
||||
{
|
||||
foreach(string a in EnumerateFiles(directory.ParentFileSystem.OpenDirectory(entry.Name)))
|
||||
foreach (string a in EnumerateFiles(directory.ParentFileSystem.OpenDirectory(entry.Name, OpenDirectoryMode.All)))
|
||||
{
|
||||
yield return a;
|
||||
}
|
||||
|
|
102
src/LibHac/IO/PathTools.cs
Normal file
102
src/LibHac/IO/PathTools.cs
Normal file
|
@ -0,0 +1,102 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace LibHac.IO
|
||||
{
|
||||
public static class PathTools
|
||||
{
|
||||
public static readonly char DirectorySeparator = '/';
|
||||
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
public static string Normalize(string inPath)
|
||||
{
|
||||
ReadOnlySpan<char> path = inPath.AsSpan();
|
||||
|
||||
if (path.Length == 0) return DirectorySeparator.ToString();
|
||||
|
||||
if (path[0] != DirectorySeparator)
|
||||
{
|
||||
throw new InvalidDataException($"{nameof(path)} must begin with '{DirectorySeparator}'");
|
||||
}
|
||||
|
||||
Span<char> initialBuffer = stackalloc char[0x200];
|
||||
var sb = new ValueStringBuilder(initialBuffer);
|
||||
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
char c = path[i];
|
||||
|
||||
if (IsDirectorySeparator(c) && i + 1 < path.Length)
|
||||
{
|
||||
// Skip this character if it's a directory separator and if the next character is, too,
|
||||
// e.g. "parent//child" => "parent/child"
|
||||
if (IsDirectorySeparator(path[i + 1])) continue;
|
||||
|
||||
// Skip this character and the next if it's referring to the current directory,
|
||||
// e.g. "parent/./child" => "parent/child"
|
||||
if (IsCurrentDirectory(path, i))
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip this character and the next two if it's referring to the parent directory,
|
||||
// e.g. "parent/child/../grandchild" => "parent/grandchild"
|
||||
if (IsParentDirectory(path, i))
|
||||
{
|
||||
// Unwind back to the last slash (and if there isn't one, clear out everything).
|
||||
for (int s = sb.Length - 1; s >= 0; s--)
|
||||
{
|
||||
if (IsDirectorySeparator(sb[s]))
|
||||
{
|
||||
sb.Length = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
sb.Append(c);
|
||||
}
|
||||
|
||||
// If we haven't changed the source path, return the original
|
||||
if (sb.Length == inPath.Length)
|
||||
{
|
||||
return inPath;
|
||||
}
|
||||
|
||||
if (sb.Length == 0)
|
||||
{
|
||||
sb.Append(DirectorySeparator);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
internal static bool IsDirectorySeparator(char c)
|
||||
{
|
||||
return c == DirectorySeparator;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
internal static bool IsCurrentDirectory(ReadOnlySpan<char> path, int index)
|
||||
{
|
||||
return (index + 2 == path.Length || IsDirectorySeparator(path[index + 2])) &&
|
||||
path[index + 1] == '.';
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
internal static bool IsParentDirectory(ReadOnlySpan<char> path, int index)
|
||||
{
|
||||
return index + 2 < path.Length &&
|
||||
(index + 3 == path.Length || IsDirectorySeparator(path[index + 3])) &&
|
||||
path[index + 1] == '.' && path[index + 2] == '.';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,8 +7,9 @@ namespace LibHac.IO
|
|||
public IFileSystem ParentFileSystem { get; }
|
||||
|
||||
private RomfsDir Directory { get; }
|
||||
private OpenDirectoryMode Mode { get; }
|
||||
|
||||
public RomFsDirectory(RomFsFileSystem fs, string path)
|
||||
public RomFsDirectory(RomFsFileSystem fs, string path, OpenDirectoryMode mode)
|
||||
{
|
||||
if (!fs.DirectoryDict.TryGetValue(path, out RomfsDir dir))
|
||||
{
|
||||
|
@ -17,6 +18,7 @@ namespace LibHac.IO
|
|||
|
||||
ParentFileSystem = fs;
|
||||
Directory = dir;
|
||||
Mode = mode;
|
||||
}
|
||||
|
||||
public DirectoryEntry[] Read()
|
||||
|
@ -26,22 +28,29 @@ namespace LibHac.IO
|
|||
var entries = new DirectoryEntry[count];
|
||||
int index = 0;
|
||||
|
||||
var dirEntry = Directory.FirstChild;
|
||||
|
||||
while (dirEntry != null)
|
||||
if (Mode.HasFlag(OpenDirectoryMode.Directories))
|
||||
{
|
||||
entries[index] = new DirectoryEntry(dirEntry.FullPath, DirectoryEntryType.Directory, 0);
|
||||
dirEntry = dirEntry.NextSibling;
|
||||
index++;
|
||||
RomfsDir dirEntry = Directory.FirstChild;
|
||||
|
||||
while (dirEntry != null)
|
||||
{
|
||||
entries[index] = new DirectoryEntry(dirEntry.FullPath, DirectoryEntryType.Directory, 0);
|
||||
dirEntry = dirEntry.NextSibling;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
RomfsFile fileEntry = Directory.FirstFile;
|
||||
|
||||
while (fileEntry != null)
|
||||
if (Mode.HasFlag(OpenDirectoryMode.Files))
|
||||
{
|
||||
entries[index] = new DirectoryEntry(fileEntry.FullPath, DirectoryEntryType.File, fileEntry.DataLength);
|
||||
fileEntry = fileEntry.NextSibling;
|
||||
index++;
|
||||
RomfsFile fileEntry = Directory.FirstFile;
|
||||
|
||||
while (fileEntry != null)
|
||||
{
|
||||
entries[index] =
|
||||
new DirectoryEntry(fileEntry.FullPath, DirectoryEntryType.File, fileEntry.DataLength);
|
||||
fileEntry = fileEntry.NextSibling;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
|
@ -50,20 +59,27 @@ namespace LibHac.IO
|
|||
public int GetEntryCount()
|
||||
{
|
||||
int count = 0;
|
||||
RomfsDir dirEntry = Directory.FirstChild;
|
||||
|
||||
while (dirEntry != null)
|
||||
if (Mode.HasFlag(OpenDirectoryMode.Directories))
|
||||
{
|
||||
count++;
|
||||
dirEntry = dirEntry.NextSibling;
|
||||
RomfsDir dirEntry = Directory.FirstChild;
|
||||
|
||||
while (dirEntry != null)
|
||||
{
|
||||
count++;
|
||||
dirEntry = dirEntry.NextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
RomfsFile fileEntry = Directory.FirstFile;
|
||||
|
||||
while (fileEntry != null)
|
||||
if (Mode.HasFlag(OpenDirectoryMode.Files))
|
||||
{
|
||||
count++;
|
||||
fileEntry = fileEntry.NextSibling;
|
||||
RomfsFile fileEntry = Directory.FirstFile;
|
||||
|
||||
while (fileEntry != null)
|
||||
{
|
||||
count++;
|
||||
fileEntry = fileEntry.NextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
|
|
|
@ -39,7 +39,7 @@ namespace LibHac.IO
|
|||
|
||||
public long SetSize()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,32 +86,32 @@ namespace LibHac.IO
|
|||
|
||||
public void Commit()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void CreateDirectory(string path)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void CreateFile(string path, long size)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void DeleteDirectory(string path)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void DeleteFile(string path)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public IDirectory OpenDirectory(string path)
|
||||
public IDirectory OpenDirectory(string path, OpenDirectoryMode mode)
|
||||
{
|
||||
return new RomFsDirectory(this, path);
|
||||
return new RomFsDirectory(this, path, mode);
|
||||
}
|
||||
|
||||
public IFile OpenFile(string path)
|
||||
|
@ -131,17 +131,17 @@ namespace LibHac.IO
|
|||
|
||||
public void RenameDirectory(string srcPath, string dstPath)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void RenameFile(string srcPath, string dstPath)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public bool DirectoryExists(string path)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
return DirectoryDict.ContainsKey(path);
|
||||
}
|
||||
|
||||
public bool FileExists(string path)
|
||||
|
|
271
src/LibHac/IO/ValueStringBuilder.cs
Normal file
271
src/LibHac/IO/ValueStringBuilder.cs
Normal file
|
@ -0,0 +1,271 @@
|
|||
using System;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LibHac.IO
|
||||
{
|
||||
internal ref struct ValueStringBuilder
|
||||
{
|
||||
private char[] _arrayToReturnToPool;
|
||||
private Span<char> _chars;
|
||||
private int _pos;
|
||||
|
||||
public ValueStringBuilder(Span<char> initialBuffer)
|
||||
{
|
||||
_arrayToReturnToPool = null;
|
||||
_chars = initialBuffer;
|
||||
_pos = 0;
|
||||
}
|
||||
|
||||
public ValueStringBuilder(int initialCapacity)
|
||||
{
|
||||
_arrayToReturnToPool = ArrayPool<char>.Shared.Rent(initialCapacity);
|
||||
_chars = _arrayToReturnToPool;
|
||||
_pos = 0;
|
||||
}
|
||||
|
||||
public int Length
|
||||
{
|
||||
get => _pos;
|
||||
set
|
||||
{
|
||||
Debug.Assert(value >= 0);
|
||||
Debug.Assert(value <= _chars.Length);
|
||||
_pos = value;
|
||||
}
|
||||
}
|
||||
|
||||
public int Capacity => _chars.Length;
|
||||
|
||||
public void EnsureCapacity(int capacity)
|
||||
{
|
||||
if (capacity > _chars.Length)
|
||||
Grow(capacity - _chars.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a pinnable reference to the builder.
|
||||
/// Does not ensure there is a null char after <see cref="Length"/>
|
||||
/// This overload is pattern matched in the C# 7.3+ compiler so you can omit
|
||||
/// the explicit method call, and write eg "fixed (char* c = builder)"
|
||||
/// </summary>
|
||||
public ref char GetPinnableReference()
|
||||
{
|
||||
return ref MemoryMarshal.GetReference(_chars);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a pinnable reference to the builder.
|
||||
/// </summary>
|
||||
/// <param name="terminate">Ensures that the builder has a null char after <see cref="Length"/></param>
|
||||
public ref char GetPinnableReference(bool terminate)
|
||||
{
|
||||
if (terminate)
|
||||
{
|
||||
EnsureCapacity(Length + 1);
|
||||
_chars[Length] = '\0';
|
||||
}
|
||||
return ref MemoryMarshal.GetReference(_chars);
|
||||
}
|
||||
|
||||
public ref char this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
Debug.Assert(index < _pos);
|
||||
return ref _chars[index];
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var s = _chars.Slice(0, _pos).ToString();
|
||||
Dispose();
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Returns the underlying storage of the builder.</summary>
|
||||
public Span<char> RawChars => _chars;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a span around the contents of the builder.
|
||||
/// </summary>
|
||||
/// <param name="terminate">Ensures that the builder has a null char after <see cref="Length"/></param>
|
||||
public ReadOnlySpan<char> AsSpan(bool terminate)
|
||||
{
|
||||
if (terminate)
|
||||
{
|
||||
EnsureCapacity(Length + 1);
|
||||
_chars[Length] = '\0';
|
||||
}
|
||||
return _chars.Slice(0, _pos);
|
||||
}
|
||||
|
||||
public ReadOnlySpan<char> AsSpan() => _chars.Slice(0, _pos);
|
||||
public ReadOnlySpan<char> AsSpan(int start) => _chars.Slice(start, _pos - start);
|
||||
public ReadOnlySpan<char> AsSpan(int start, int length) => _chars.Slice(start, length);
|
||||
|
||||
public bool TryCopyTo(Span<char> destination, out int charsWritten)
|
||||
{
|
||||
if (_chars.Slice(0, _pos).TryCopyTo(destination))
|
||||
{
|
||||
charsWritten = _pos;
|
||||
Dispose();
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
charsWritten = 0;
|
||||
Dispose();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Insert(int index, char value, int count)
|
||||
{
|
||||
if (_pos > _chars.Length - count)
|
||||
{
|
||||
Grow(count);
|
||||
}
|
||||
|
||||
int remaining = _pos - index;
|
||||
_chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count));
|
||||
_chars.Slice(index, count).Fill(value);
|
||||
_pos += count;
|
||||
}
|
||||
|
||||
public void Insert(int index, string s)
|
||||
{
|
||||
int count = s.Length;
|
||||
|
||||
if (_pos > (_chars.Length - count))
|
||||
{
|
||||
Grow(count);
|
||||
}
|
||||
|
||||
int remaining = _pos - index;
|
||||
_chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count));
|
||||
s.AsSpan().CopyTo(_chars.Slice(index));
|
||||
_pos += count;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Append(char c)
|
||||
{
|
||||
int pos = _pos;
|
||||
if ((uint)pos < (uint)_chars.Length)
|
||||
{
|
||||
_chars[pos] = c;
|
||||
_pos = pos + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
GrowAndAppend(c);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Append(string s)
|
||||
{
|
||||
int pos = _pos;
|
||||
if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc.
|
||||
{
|
||||
_chars[pos] = s[0];
|
||||
_pos = pos + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendSlow(s);
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendSlow(string s)
|
||||
{
|
||||
int pos = _pos;
|
||||
if (pos > _chars.Length - s.Length)
|
||||
{
|
||||
Grow(s.Length);
|
||||
}
|
||||
|
||||
s.AsSpan().CopyTo(_chars.Slice(pos));
|
||||
_pos += s.Length;
|
||||
}
|
||||
|
||||
public void Append(char c, int count)
|
||||
{
|
||||
if (_pos > _chars.Length - count)
|
||||
{
|
||||
Grow(count);
|
||||
}
|
||||
|
||||
Span<char> dst = _chars.Slice(_pos, count);
|
||||
for (int i = 0; i < dst.Length; i++)
|
||||
{
|
||||
dst[i] = c;
|
||||
}
|
||||
_pos += count;
|
||||
}
|
||||
|
||||
public void Append(ReadOnlySpan<char> value)
|
||||
{
|
||||
int pos = _pos;
|
||||
if (pos > _chars.Length - value.Length)
|
||||
{
|
||||
Grow(value.Length);
|
||||
}
|
||||
|
||||
value.CopyTo(_chars.Slice(_pos));
|
||||
_pos += value.Length;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public Span<char> AppendSpan(int length)
|
||||
{
|
||||
int origPos = _pos;
|
||||
if (origPos > _chars.Length - length)
|
||||
{
|
||||
Grow(length);
|
||||
}
|
||||
|
||||
_pos = origPos + length;
|
||||
return _chars.Slice(origPos, length);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private void GrowAndAppend(char c)
|
||||
{
|
||||
Grow(1);
|
||||
Append(c);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private void Grow(int requiredAdditionalCapacity)
|
||||
{
|
||||
Debug.Assert(requiredAdditionalCapacity > 0);
|
||||
|
||||
char[] poolArray = ArrayPool<char>.Shared.Rent(Math.Max(_pos + requiredAdditionalCapacity, _chars.Length * 2));
|
||||
|
||||
_chars.CopyTo(poolArray);
|
||||
|
||||
char[] toReturn = _arrayToReturnToPool;
|
||||
_chars = _arrayToReturnToPool = poolArray;
|
||||
if (toReturn != null)
|
||||
{
|
||||
ArrayPool<char>.Shared.Return(toReturn);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Dispose()
|
||||
{
|
||||
char[] toReturn = _arrayToReturnToPool;
|
||||
this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again
|
||||
if (toReturn != null)
|
||||
{
|
||||
ArrayPool<char>.Shared.Return(toReturn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
45
tests/LibHac.Tests/PathToolsTests.cs
Normal file
45
tests/LibHac.Tests/PathToolsTests.cs
Normal file
|
@ -0,0 +1,45 @@
|
|||
using System.IO;
|
||||
using LibHac.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace LibHac.Tests
|
||||
{
|
||||
public class PathToolsTests
|
||||
{
|
||||
public static object[][] NormalizedPathTestItems =
|
||||
{
|
||||
new object[] {"", "/"},
|
||||
new object[] {"/", "/"},
|
||||
new object[] {"/.", "/"},
|
||||
new object[] {"/a/b/c", "/a/b/c"},
|
||||
new object[] {"/a/b/../c", "/a/c"},
|
||||
new object[] {"/a/b/c/..", "/a/b"},
|
||||
new object[] {"/a/b/c/.", "/a/b/c"},
|
||||
new object[] {"/a/../../..", "/"},
|
||||
new object[] {"/a/../../../a/b/c", "/a/b/c"},
|
||||
new object[] {"//a/b//.//c", "/a/b/c"},
|
||||
|
||||
|
||||
new object[] {"/a/b/c/", "/a/b/c/"},
|
||||
new object[] {"/a/./b/../c/", "/a/c/"},
|
||||
new object[] {"/a/../../../", "/"},
|
||||
new object[] {"//a/b//.//c/", "/a/b/c/"},
|
||||
new object[] {@"/tmp/../", @"/"},
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NormalizedPathTestItems))]
|
||||
public static void NormalizePath(string path, string expected)
|
||||
{
|
||||
string actual = PathTools.Normalize(path);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void NormalizeThrowsOnInvalidStartChar()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => PathTools.Normalize(@"c:\a\b\c"));
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue