Implemented possibility to read parent object while writing custom binary field
This commit is contained in:
parent
3514a58c40
commit
fcbb2ef227
8 changed files with 372 additions and 29 deletions
|
|
@ -25,6 +25,7 @@
|
|||
package zutil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A utility class containing Array specific utility methods
|
||||
|
|
@ -45,15 +46,87 @@ public class ArrayUtil {
|
|||
}
|
||||
|
||||
/**
|
||||
* Searches for a given object inside of an array.
|
||||
* Searches for a given object inside an array.
|
||||
* The method uses reference comparison or {@link #equals(Object)} to check for equality.
|
||||
*
|
||||
* @return True if the given Object is found inside the array, false otherwise.
|
||||
*/
|
||||
public static <T> boolean contains(T[] array, T obj) {
|
||||
for (final T element : array)
|
||||
if (element == obj || obj != null && obj.equals(element))
|
||||
if (Objects.equals(obj, element))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple arras into one.
|
||||
*
|
||||
* @param arrays the arrays to be combined.
|
||||
* @return one array containing all the elements of the provided arrays.
|
||||
* @param <T>
|
||||
*/
|
||||
public static <T> T[] combine(T[]... arrays) {
|
||||
int totalLength = 0;
|
||||
for (T[] array : arrays) {
|
||||
totalLength += array.length;
|
||||
}
|
||||
|
||||
int outputPos = 0;
|
||||
Object[] output = new Object[totalLength];
|
||||
|
||||
for (T[] array : arrays) {
|
||||
System.arraycopy(array, 0, output, outputPos, array.length);
|
||||
outputPos += array.length;
|
||||
}
|
||||
|
||||
return (T[]) output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple arras into one.
|
||||
*
|
||||
* @param arrays the arrays to be combined.
|
||||
* @return one array containing all the elements of the provided arrays.
|
||||
* @param <T>
|
||||
*/
|
||||
public static int[] combine(int[]... arrays) {
|
||||
int totalLength = 0;
|
||||
for (int[] array : arrays) {
|
||||
totalLength += array.length;
|
||||
}
|
||||
|
||||
int outputPos = 0;
|
||||
int[] output = new int[totalLength];
|
||||
|
||||
for (int[] array : arrays) {
|
||||
System.arraycopy(array, 0, output, outputPos, array.length);
|
||||
outputPos += array.length;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple arras into one.
|
||||
*
|
||||
* @param arrays the arrays to be combined.
|
||||
* @return one array containing all the elements of the provided arrays.
|
||||
* @param <T>
|
||||
*/
|
||||
public static byte[] combine(byte[]... arrays) {
|
||||
int totalLength = 0;
|
||||
for (byte[] array : arrays) {
|
||||
totalLength += array.length;
|
||||
}
|
||||
|
||||
int outputPos = 0;
|
||||
byte[] output = new byte[totalLength];
|
||||
|
||||
for (byte[] array : arrays) {
|
||||
System.arraycopy(array, 0, output, outputPos, array.length);
|
||||
outputPos += array.length;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ import zutil.parser.binary.BinaryStruct.BinaryField;
|
|||
import zutil.parser.binary.BinaryStruct.CustomBinaryField;
|
||||
import zutil.parser.binary.BinaryStruct.VariableLengthBinaryField;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ public class BinaryFieldData {
|
|||
private Class<? extends BinaryFieldSerializer> serializerClass;
|
||||
|
||||
|
||||
protected static List<BinaryFieldData> getStructFieldList(Class<? extends BinaryStruct> clazz) {
|
||||
public static List<BinaryFieldData> getStructFieldList(Class<? extends BinaryStruct> clazz) {
|
||||
if (!cache.containsKey(clazz)) {
|
||||
try {
|
||||
ArrayList<BinaryFieldData> list = new ArrayList<>();
|
||||
|
|
@ -198,7 +198,9 @@ public class BinaryFieldData {
|
|||
|
||||
public BinaryFieldSerializer getSerializer() {
|
||||
try {
|
||||
return serializerClass.getDeclaredConstructor().newInstance();
|
||||
Constructor<? extends BinaryFieldSerializer> constructor = serializerClass.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance();
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new RuntimeException("Unable to instantiate class: " + serializerClass, e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,17 +31,59 @@ import java.io.OutputStream;
|
|||
/**
|
||||
* An Interface defining a custom field parser and writer.
|
||||
* <p>
|
||||
* One singleton instance of the serializer will be instantiated for the lifetime of the
|
||||
* A new instance of the serializer will be instantiated for every time serialization is required.
|
||||
* {@link BinaryStructInputStream} and {@link BinaryStructOutputStream} objects.
|
||||
* <p>
|
||||
* NOTE: Partial octet serializing not supported.
|
||||
*
|
||||
*/
|
||||
public interface BinaryFieldSerializer<T> {
|
||||
|
||||
/**
|
||||
* Read the given field from the stream.
|
||||
*
|
||||
* @param in the stream where the data should be read from.
|
||||
* @param field meta-data about the target field that will be assigned.
|
||||
* @param parentObject the parent object that owns the field.
|
||||
* @return the value that should be assigned to the field.
|
||||
*/
|
||||
default T read(InputStream in,
|
||||
BinaryFieldData field,
|
||||
Object parentObject) throws IOException {
|
||||
return read(in, field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the given field from the stream.
|
||||
*
|
||||
* @param in the stream where the data should be read from.
|
||||
* @param field meta-data about the target field that will be assigned.
|
||||
* @return the value that should be assigned to the field.
|
||||
*/
|
||||
T read(InputStream in,
|
||||
BinaryFieldData field) throws IOException;
|
||||
|
||||
/**
|
||||
* Write the given field to the output stream.
|
||||
*
|
||||
* @param out the stream where the field data should be written to.
|
||||
* @param obj the object that should be serialized and written to the stream.
|
||||
* @param field meta-data about the source field that will be serialized.
|
||||
* @param parentObject the parent object that owns the field.
|
||||
*/
|
||||
default void write(OutputStream out,
|
||||
T obj,
|
||||
BinaryFieldData field,
|
||||
Object parentObject) throws IOException {
|
||||
write(out, obj, field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the given field to the output stream.
|
||||
*
|
||||
* @param out the stream where the field data should be written to.
|
||||
* @param obj the object that should be serialized and written to the stream.
|
||||
* @param field meta-data about the source field that will be serialized.
|
||||
*/
|
||||
void write(OutputStream out,
|
||||
T obj,
|
||||
BinaryFieldData field) throws IOException;
|
||||
|
|
|
|||
|
|
@ -41,14 +41,12 @@ import java.util.Map;
|
|||
*
|
||||
* @author Ziver
|
||||
*/
|
||||
public class BinaryStructInputStream {
|
||||
public class BinaryStructInputStream extends InputStream{
|
||||
|
||||
private InputStream in;
|
||||
private byte data;
|
||||
private int dataBitIndex = -1;
|
||||
|
||||
private Map<Class, BinaryFieldSerializer> serializerCache = new HashMap<>();
|
||||
|
||||
|
||||
public BinaryStructInputStream(InputStream in) {
|
||||
this.in = in;
|
||||
|
|
@ -85,13 +83,9 @@ public class BinaryStructInputStream {
|
|||
int totalReadLength = 0;
|
||||
for (BinaryFieldData field : structDataList) {
|
||||
if (field.hasSerializer()) {
|
||||
BinaryFieldSerializer serializer = serializerCache.get(field.getSerializerClass());
|
||||
if (serializer == null) {
|
||||
serializer = field.getSerializer();
|
||||
serializerCache.put(serializer.getClass(), serializer);
|
||||
}
|
||||
BinaryFieldSerializer<Object> serializer = field.getSerializer();
|
||||
|
||||
Object value = serializer.read(in, field);
|
||||
Object value = serializer.read(in, field, struct);
|
||||
field.setValue(struct, value);
|
||||
} else {
|
||||
byte[] valueData = new byte[(int) Math.ceil(field.getBitLength(struct) / 8.0)];
|
||||
|
|
@ -119,21 +113,35 @@ public class BinaryStructInputStream {
|
|||
return totalReadLength;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
return in.read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
return in.read(b, off, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see InputStream#markSupported()
|
||||
*/
|
||||
@Override
|
||||
public boolean markSupported() {
|
||||
return in.markSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see InputStream#mark(int)
|
||||
*/
|
||||
@Override
|
||||
public void mark(int limit) {
|
||||
in.mark(limit);
|
||||
}
|
||||
/**
|
||||
* @see InputStream#reset()
|
||||
*/
|
||||
@Override
|
||||
public void reset() throws IOException {
|
||||
in.reset();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ import zutil.ByteUtil;
|
|||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -42,14 +40,12 @@ import java.util.Map;
|
|||
*
|
||||
* @author Ziver
|
||||
*/
|
||||
public class BinaryStructOutputStream {
|
||||
public class BinaryStructOutputStream extends OutputStream {
|
||||
|
||||
private OutputStream out;
|
||||
private byte rest;
|
||||
private int restBitLength; // length from Most Significant Bit
|
||||
|
||||
private Map<Class, BinaryFieldSerializer> serializerCache = new HashMap<>();
|
||||
|
||||
|
||||
public BinaryStructOutputStream(OutputStream out) {
|
||||
this.out = out;
|
||||
|
|
@ -71,16 +67,23 @@ public class BinaryStructOutputStream {
|
|||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see OutputStream#write(int b)
|
||||
*/
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
out.write(b);
|
||||
}
|
||||
/**
|
||||
* @see OutputStream#write(byte[])
|
||||
*/
|
||||
public void write(byte b[]) throws IOException {
|
||||
public void write(byte[] b) throws IOException {
|
||||
out.write(b);
|
||||
}
|
||||
/**
|
||||
* @see OutputStream#write(byte[], int, int)
|
||||
*/
|
||||
public void write(byte b[], int off, int len) throws IOException {
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
out.write(b, off, len);
|
||||
}
|
||||
|
||||
|
|
@ -93,14 +96,10 @@ public class BinaryStructOutputStream {
|
|||
|
||||
for (BinaryFieldData field : structDataList) {
|
||||
if (field.hasSerializer()) {
|
||||
BinaryFieldSerializer serializer = serializerCache.get(field.getSerializerClass());
|
||||
if (serializer == null) {
|
||||
serializer = field.getSerializer();
|
||||
serializerCache.put(serializer.getClass(), serializer);
|
||||
}
|
||||
BinaryFieldSerializer<Object> serializer = field.getSerializer();
|
||||
|
||||
localFlush();
|
||||
serializer.write(out, field.getValue(struct), field);
|
||||
serializer.write(out, field.getValue(struct), field, struct);
|
||||
} else {
|
||||
int fieldBitLength = field.getBitLength(struct);
|
||||
byte[] data = field.getByteValue(struct);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2020 Ziver Koc
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package zutil.parser.binary.serializer;
|
||||
|
||||
import zutil.parser.binary.BinaryFieldData;
|
||||
import zutil.parser.binary.BinaryFieldSerializer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.StreamCorruptedException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Serializer handles data that is prefixed by two byte length.
|
||||
* <p>
|
||||
* Currently only these types are supported:
|
||||
* <ul>
|
||||
* <li>byte[]</li>
|
||||
* <li>String</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class TwoByteLengthPrefixedDataSerializer implements BinaryFieldSerializer<Object> {
|
||||
|
||||
@Override
|
||||
public Object read(InputStream in, BinaryFieldData field) throws IOException {
|
||||
int b = in.read();
|
||||
if (b < 0)
|
||||
throw new StreamCorruptedException("Stream ended prematurely when reading first length byte.");
|
||||
int length = (b & 0xFF) << 8;
|
||||
|
||||
b = in.read();
|
||||
if (b < 0)
|
||||
throw new StreamCorruptedException("Stream ended prematurely when reading second length byte.");
|
||||
length |= b & 0xFF;
|
||||
|
||||
byte[] payload = new byte[length];
|
||||
in.read(payload);
|
||||
|
||||
if (field.getType().isAssignableFrom(String.class))
|
||||
return new String(payload, StandardCharsets.UTF_8);
|
||||
return payload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(OutputStream out, Object obj, BinaryFieldData field) throws IOException {
|
||||
if (obj == null)
|
||||
return;
|
||||
|
||||
byte[] payload;
|
||||
if (obj instanceof String)
|
||||
payload = ((String) obj).getBytes(StandardCharsets.UTF_8);
|
||||
else
|
||||
payload = (byte[]) obj;
|
||||
|
||||
int length = payload.length;
|
||||
|
||||
out.write((length & 0xFF00) >> 8);
|
||||
out.write(length & 0xFF);
|
||||
out.write(payload);
|
||||
}
|
||||
}
|
||||
39
test/zutil/ArrayUtilTest.java
Normal file
39
test/zutil/ArrayUtilTest.java
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package zutil;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class ArrayUtilTest {
|
||||
|
||||
@Test
|
||||
public void toIntArray() {
|
||||
assertArrayEquals(new int[]{}, ArrayUtil.toIntArray(Collections.emptyList()));
|
||||
assertArrayEquals(new int[]{1, 2, 3}, ArrayUtil.toIntArray(Arrays.asList(1, 2, 3)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contains() {
|
||||
assertFalse(ArrayUtil.contains(new Integer[]{}, 1));
|
||||
assertTrue(ArrayUtil.contains(new Integer[]{1}, 1));
|
||||
assertTrue(ArrayUtil.contains(new Integer[]{2, 1, 3}, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void combine() {
|
||||
assertArrayEquals(new Integer[]{}, ArrayUtil.combine(new Integer[]{}, new Integer[]{}));
|
||||
assertArrayEquals(new Integer[]{1, 2}, ArrayUtil.combine(new Integer[]{1, 2}, new Integer[]{}));
|
||||
assertArrayEquals(new Integer[]{1, 2, 3, 4}, ArrayUtil.combine(new Integer[]{1, 2}, new Integer[]{3, 4}));
|
||||
|
||||
assertArrayEquals(new int[]{}, ArrayUtil.combine(new int[]{}, new int[]{}));
|
||||
assertArrayEquals(new int[]{1, 2}, ArrayUtil.combine(new int[]{1, 2}, new int[]{}));
|
||||
assertArrayEquals(new int[]{1, 2, 3, 4}, ArrayUtil.combine(new int[]{1, 2}, new int[]{3, 4}));
|
||||
|
||||
assertArrayEquals(new byte[]{}, ArrayUtil.combine(new byte[]{}, new byte[]{}));
|
||||
assertArrayEquals(new byte[]{1, 2}, ArrayUtil.combine(new byte[]{1, 2}, new byte[]{}));
|
||||
assertArrayEquals(new byte[]{1, 2, 3, 4}, ArrayUtil.combine(new byte[]{1, 2}, new byte[]{3, 4}));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package zutil.parser.binary.serializer;
|
||||
|
||||
import org.junit.Test;
|
||||
import zutil.ArrayUtil;
|
||||
import zutil.ByteUtil;
|
||||
import zutil.parser.binary.BinaryFieldData;
|
||||
import zutil.parser.binary.BinaryStruct;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.StreamCorruptedException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class TwoByteLengthPrefixedDataSerializerTest implements BinaryStruct {
|
||||
|
||||
@BinaryField(index = 10, length = 1)
|
||||
private String tmpStringField;
|
||||
@BinaryField(index = 20, length = 1)
|
||||
private byte[] tmpByteField;
|
||||
|
||||
@Test(expected = StreamCorruptedException.class)
|
||||
public void readPrematureEnd0() throws IOException {
|
||||
TwoByteLengthPrefixedDataSerializer serializer = new TwoByteLengthPrefixedDataSerializer();
|
||||
|
||||
// 0 length stream
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(new byte[0]);
|
||||
serializer.read(inputStream, null, this);
|
||||
}
|
||||
|
||||
@Test(expected = StreamCorruptedException.class)
|
||||
public void readPrematureEnd1() throws IOException {
|
||||
TwoByteLengthPrefixedDataSerializer serializer = new TwoByteLengthPrefixedDataSerializer();
|
||||
|
||||
// 1 length stream
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(new byte[]{0x00});
|
||||
serializer.read(inputStream, null, this);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void read() throws IOException, NoSuchFieldException {
|
||||
TwoByteLengthPrefixedDataSerializer serializer = new TwoByteLengthPrefixedDataSerializer();
|
||||
List<BinaryFieldData> fieldDataList = BinaryFieldData.getStructFieldList(this.getClass());
|
||||
BinaryFieldData stringFieldData = fieldDataList.get(0);
|
||||
BinaryFieldData byteFieldData = fieldDataList.get(1);
|
||||
|
||||
// 0 Length
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(new byte[]{0x00, 0x00});
|
||||
assertEquals("", serializer.read(inputStream, stringFieldData, this));
|
||||
inputStream.reset();
|
||||
assertArrayEquals(new byte[0], (byte[])serializer.read(inputStream, byteFieldData, this));
|
||||
|
||||
// String "1234"
|
||||
inputStream = new ByteArrayInputStream(ArrayUtil.combine(new byte[]{0x00, 0x04}, "1234".getBytes(StandardCharsets.UTF_8)));
|
||||
assertEquals("1234", serializer.read(inputStream, stringFieldData, this));
|
||||
inputStream.reset();
|
||||
assertArrayEquals("1234".getBytes(StandardCharsets.UTF_8), (byte[])serializer.read(inputStream, byteFieldData, this));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void write() throws IOException {
|
||||
TwoByteLengthPrefixedDataSerializer serializer = new TwoByteLengthPrefixedDataSerializer();
|
||||
List<BinaryFieldData> fieldDataList = BinaryFieldData.getStructFieldList(this.getClass());
|
||||
BinaryFieldData stringFieldData = fieldDataList.get(0);
|
||||
BinaryFieldData byteFieldData = fieldDataList.get(1);
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
// 0 Length
|
||||
outputStream.reset();outputStream.reset();
|
||||
serializer.write(outputStream, null, stringFieldData, this);
|
||||
assertArrayEquals(new byte[]{}, outputStream.toByteArray());
|
||||
outputStream.reset();
|
||||
serializer.write(outputStream, null, byteFieldData, this);
|
||||
assertArrayEquals(new byte[]{}, outputStream.toByteArray());
|
||||
|
||||
// 0 Length
|
||||
outputStream.reset();
|
||||
serializer.write(outputStream, "", stringFieldData, this);
|
||||
assertArrayEquals(new byte[]{0, 0}, outputStream.toByteArray());
|
||||
outputStream.reset();
|
||||
serializer.write(outputStream, new byte[0], byteFieldData, this);
|
||||
assertArrayEquals(new byte[]{0, 0}, outputStream.toByteArray());
|
||||
|
||||
// String "1234"
|
||||
outputStream.reset();
|
||||
serializer.write(outputStream, "1234", stringFieldData, this);
|
||||
assertArrayEquals(new byte[]{0, 4, 49, 50, 51, 52}, outputStream.toByteArray());
|
||||
outputStream.reset();
|
||||
serializer.write(outputStream, "1234".getBytes(StandardCharsets.UTF_8), byteFieldData, this);
|
||||
assertArrayEquals(new byte[]{0, 4, 49, 50, 51, 52}, outputStream.toByteArray());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue