Add the ability to print a nearly arbitrary CBOR encoded value
This commit is contained in:
parent
c3278e807b
commit
f383ea9f66
6 changed files with 332 additions and 43 deletions
|
@ -25,4 +25,5 @@ target_sources(LibCBOR
|
|||
"Source/Core.cpp"
|
||||
"Source/Decoder.cpp"
|
||||
"Source/Encoder.cpp"
|
||||
"Source/Printer.cpp"
|
||||
)
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
#include "Core.hpp"
|
||||
|
||||
#include <limits>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
|
||||
|
@ -20,6 +21,13 @@ namespace CBOR
|
|||
Item(class Decoder &decoder);
|
||||
|
||||
public:
|
||||
static constexpr
|
||||
std::uint64_t ArgumentIndefinite = std::numeric_limits<std::uint64_t>::max();
|
||||
|
||||
MajorType GetMajor() const;
|
||||
MinorType GetMinor() const;
|
||||
std::uint64_t GetArgument() const;
|
||||
|
||||
bool Bool();
|
||||
Special Special();
|
||||
|
||||
|
@ -37,13 +45,13 @@ namespace CBOR
|
|||
float Float();
|
||||
double Double();
|
||||
|
||||
std::span<std::uint8_t> Binary();
|
||||
std::string_view String();
|
||||
class Binary IndefiniteBinary();
|
||||
class String IndefiniteString();
|
||||
class Array Array();
|
||||
class Map Map();
|
||||
class TaggedItem TaggedItem();
|
||||
std::span<const std::uint8_t> Binary();
|
||||
std::string_view String();
|
||||
class Binary IndefiniteBinary();
|
||||
class String IndefiniteString();
|
||||
class Array Array();
|
||||
class Map Map();
|
||||
class TaggedItem TaggedItem();
|
||||
|
||||
private:
|
||||
friend class Decoder;
|
||||
|
@ -102,8 +110,8 @@ namespace CBOR
|
|||
Binary(class Decoder &decoder);
|
||||
|
||||
public:
|
||||
bool Done();
|
||||
std::span<std::uint8_t> Next();
|
||||
bool Done();
|
||||
std::span<const std::uint8_t> Next();
|
||||
|
||||
private:
|
||||
friend class Decoder;
|
||||
|
@ -175,7 +183,14 @@ namespace CBOR
|
|||
class Decoder
|
||||
{
|
||||
public:
|
||||
Decoder(std::span<std::uint8_t> buffer);
|
||||
Decoder(std::span<const std::uint8_t> buffer);
|
||||
|
||||
static constexpr
|
||||
std::uint64_t ArgumentIndefinite = std::numeric_limits<std::uint64_t>::max();
|
||||
|
||||
MajorType GetMajor() const;
|
||||
MinorType GetMinor() const;
|
||||
std::uint64_t GetArgument() const;
|
||||
|
||||
bool Bool();
|
||||
Special Special();
|
||||
|
@ -194,14 +209,15 @@ namespace CBOR
|
|||
float Float();
|
||||
double Double();
|
||||
|
||||
std::span<std::uint8_t> Binary();
|
||||
std::string_view String();
|
||||
class Binary IndefiniteBinary();
|
||||
class String IndefiniteString();
|
||||
class Array Array();
|
||||
class Map Map();
|
||||
class TaggedItem TaggedItem();
|
||||
std::span<const std::uint8_t> Binary();
|
||||
std::string_view String();
|
||||
class Binary IndefiniteBinary();
|
||||
class String IndefiniteString();
|
||||
class Array Array();
|
||||
class Map Map();
|
||||
class TaggedItem TaggedItem();
|
||||
|
||||
class Item AsItem();
|
||||
private:
|
||||
friend class Binary;
|
||||
friend class String;
|
||||
|
@ -209,8 +225,8 @@ namespace CBOR
|
|||
friend class Map;
|
||||
friend class TaggedItem;
|
||||
|
||||
std::size_t mCurrent;
|
||||
std::span<std::uint8_t> mBuffer;
|
||||
std::size_t mCurrent;
|
||||
std::span<const std::uint8_t> mBuffer;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
12
LibCBOR/Include/CBOR/Printer.hpp
Normal file
12
LibCBOR/Include/CBOR/Printer.hpp
Normal file
|
@ -0,0 +1,12 @@
|
|||
#ifndef LIBCBOR_PRINTER_HPP
|
||||
#define LIBCBOR_PRINTER_HPP
|
||||
|
||||
#include <ostream>
|
||||
#include <span>
|
||||
|
||||
namespace CBOR
|
||||
{
|
||||
void Print(std::ostream &out, std::span<const std::uint8_t> buffer);
|
||||
}
|
||||
|
||||
#endif // LIBCBOR_PRINTER_HPP
|
|
@ -57,7 +57,7 @@ namespace CBOR
|
|||
{
|
||||
static constexpr std::size_t Indefinite = std::numeric_limits<std::size_t>::max();
|
||||
|
||||
std::size_t SpaceLeft(std::span<std::uint8_t> buffer, std::size_t offset)
|
||||
std::size_t SpaceLeft(std::span<const std::uint8_t> buffer, std::size_t offset)
|
||||
{
|
||||
if (offset >= buffer.size()) {
|
||||
return 0;
|
||||
|
@ -65,7 +65,7 @@ namespace CBOR
|
|||
return buffer.size() - offset;
|
||||
}
|
||||
|
||||
void EnsureEnoughSpace(std::span<std::uint8_t> buffer, std::size_t offset,
|
||||
void EnsureEnoughSpace(std::span<const std::uint8_t> buffer, std::size_t offset,
|
||||
std::size_t spaceRequired)
|
||||
{
|
||||
if (SpaceLeft(buffer, offset) < spaceRequired) {
|
||||
|
@ -75,19 +75,13 @@ namespace CBOR
|
|||
}
|
||||
}
|
||||
|
||||
std::uint8_t Read1B(std::span<std::uint8_t> buffer, std::size_t current)
|
||||
std::uint8_t Read1B(std::span<const std::uint8_t> buffer, std::size_t current)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 1);
|
||||
return buffer[current];
|
||||
}
|
||||
|
||||
std::uint8_t Consume1B(std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 1);
|
||||
return buffer[current++];
|
||||
}
|
||||
|
||||
std::uint16_t Consume2B(std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
std::uint16_t Read2B(std::span<const std::uint8_t> buffer, std::size_t current)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 2);
|
||||
std::uint16_t result = 0;
|
||||
|
@ -96,7 +90,7 @@ namespace CBOR
|
|||
return NetworkToHost(result);
|
||||
}
|
||||
|
||||
std::uint32_t Consume4B(std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
std::uint32_t Read4B(std::span<const std::uint8_t> buffer, std::size_t current)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 4);
|
||||
std::uint32_t result = 0;
|
||||
|
@ -107,7 +101,48 @@ namespace CBOR
|
|||
return NetworkToHost(result);
|
||||
}
|
||||
|
||||
std::uint64_t Consume8B(std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
std::uint64_t Read8B(std::span<const std::uint8_t> buffer, std::size_t current)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 8);
|
||||
std::uint64_t result = 0;
|
||||
result |= std::uint64_t(buffer[current++]) ;
|
||||
result |= std::uint64_t(buffer[current++]) << 8;
|
||||
result |= std::uint64_t(buffer[current++]) << 16;
|
||||
result |= std::uint64_t(buffer[current++]) << 24;
|
||||
result |= std::uint64_t(buffer[current++]) << 32;
|
||||
result |= std::uint64_t(buffer[current++]) << 40;
|
||||
result |= std::uint64_t(buffer[current++]) << 48;
|
||||
result |= std::uint64_t(buffer[current++]) << 56;
|
||||
return NetworkToHost(result);
|
||||
}
|
||||
|
||||
std::uint8_t Consume1B(std::span<const std::uint8_t> buffer, std::size_t ¤t)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 1);
|
||||
return buffer[current++];
|
||||
}
|
||||
|
||||
std::uint16_t Consume2B(std::span<const std::uint8_t> buffer, std::size_t ¤t)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 2);
|
||||
std::uint16_t result = 0;
|
||||
result |= std::uint16_t(buffer[current++]) ;
|
||||
result |= std::uint16_t(buffer[current++]) << 8;
|
||||
return NetworkToHost(result);
|
||||
}
|
||||
|
||||
std::uint32_t Consume4B(std::span<const std::uint8_t> buffer, std::size_t ¤t)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 4);
|
||||
std::uint32_t result = 0;
|
||||
result |= std::uint32_t(buffer[current++]) ;
|
||||
result |= std::uint32_t(buffer[current++]) << 8;
|
||||
result |= std::uint32_t(buffer[current++]) << 16;
|
||||
result |= std::uint32_t(buffer[current++]) << 24;
|
||||
return NetworkToHost(result);
|
||||
}
|
||||
|
||||
std::uint64_t Consume8B(std::span<const std::uint8_t> buffer, std::size_t ¤t)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, 8);
|
||||
std::uint64_t result = 0;
|
||||
|
@ -137,7 +172,8 @@ namespace CBOR
|
|||
return ArgumentPosition(header & std::to_underlying(ArgumentPosition::PositionMask));
|
||||
}
|
||||
|
||||
std::uint64_t ArgumentValue(std::uint8_t header, std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
std::uint64_t ArgumentValue(std::uint8_t header, std::span<const std::uint8_t> buffer,
|
||||
std::size_t ¤t)
|
||||
{
|
||||
ArgumentPosition position = GetArgumentPosition(header);
|
||||
if (std::to_underlying(position) <= 23) {
|
||||
|
@ -158,8 +194,30 @@ namespace CBOR
|
|||
}
|
||||
}
|
||||
|
||||
std::uint64_t ReadArgumentValue(std::uint8_t header, std::span<const std::uint8_t> buffer,
|
||||
std::size_t current)
|
||||
{
|
||||
ArgumentPosition position = GetArgumentPosition(header);
|
||||
if (std::to_underlying(position) <= 23) {
|
||||
return std::to_underlying(position);
|
||||
}
|
||||
switch (position) {
|
||||
case ArgumentPosition::Next1B:
|
||||
return Read1B(buffer, current);
|
||||
case ArgumentPosition::Next2B:
|
||||
return Read2B(buffer, current);
|
||||
case ArgumentPosition::Next4B:
|
||||
return Read4B(buffer, current);
|
||||
case ArgumentPosition::Next8B:
|
||||
return Read8B(buffer, current);
|
||||
default:
|
||||
throw MalformedDataError("argument position is reserved for future use, incorrect, "
|
||||
"or the parser is out of date");
|
||||
}
|
||||
}
|
||||
|
||||
template <std::unsigned_integral T>
|
||||
T ExtractUnsigned(std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
T ExtractUnsigned(std::span<const std::uint8_t> buffer, std::size_t ¤t)
|
||||
{
|
||||
static constexpr std::uint64_t maxValue = std::numeric_limits<T>::max();
|
||||
|
||||
|
@ -214,7 +272,8 @@ namespace CBOR
|
|||
|
||||
// Note(3011): In this case it includes zero, even though zero is not technically positive.
|
||||
template <std::signed_integral T>
|
||||
T SignedPositive(std::uint8_t header, std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
T SignedPositive(std::uint8_t header, std::span<const std::uint8_t> buffer,
|
||||
std::size_t ¤t)
|
||||
{
|
||||
static constexpr std::uint64_t maxValue = std::numeric_limits<T>::max();
|
||||
|
||||
|
@ -266,7 +325,8 @@ namespace CBOR
|
|||
}
|
||||
|
||||
template <std::signed_integral T>
|
||||
T SignedNegative(std::uint8_t header, std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
T SignedNegative(std::uint8_t header, std::span<const std::uint8_t> buffer,
|
||||
std::size_t ¤t)
|
||||
{
|
||||
static constexpr auto actualMin = std::numeric_limits<T>::min();
|
||||
static constexpr std::uint64_t minValue = -std::int64_t(actualMin + 1);
|
||||
|
@ -319,7 +379,7 @@ namespace CBOR
|
|||
}
|
||||
|
||||
template <std::signed_integral T>
|
||||
T ExtractSigned(std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
T ExtractSigned(std::span<const std::uint8_t> buffer, std::size_t ¤t)
|
||||
{
|
||||
std::uint8_t header = Consume1B(buffer, current);
|
||||
MajorType major = GetMajorType(header);
|
||||
|
@ -335,15 +395,17 @@ namespace CBOR
|
|||
}
|
||||
}
|
||||
|
||||
std::span<std::uint8_t> ExtractBinary(std::size_t size, std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
std::span<const std::uint8_t>
|
||||
ExtractBinary(std::size_t size, std::span<const std::uint8_t> buffer, std::size_t ¤t)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, size);
|
||||
std::span<std::uint8_t> result(buffer.data() + current, size);
|
||||
std::span<const std::uint8_t> result(buffer.data() + current, size);
|
||||
current += size;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string_view ExtractString(std::size_t size, std::span<std::uint8_t> buffer, std::size_t ¤t)
|
||||
std::string_view ExtractString(std::size_t size, std::span<const std::uint8_t> buffer,
|
||||
std::size_t ¤t)
|
||||
{
|
||||
EnsureEnoughSpace(buffer, current, size);
|
||||
std::string_view result(reinterpret_cast<const char *>(buffer.data() + current), size);
|
||||
|
@ -356,6 +418,21 @@ namespace CBOR
|
|||
: mDecoder(&decoder)
|
||||
{}
|
||||
|
||||
MajorType Item::GetMajor() const
|
||||
{
|
||||
return mDecoder->GetMajor();
|
||||
}
|
||||
|
||||
MinorType Item::GetMinor() const
|
||||
{
|
||||
return mDecoder->GetMinor();
|
||||
}
|
||||
|
||||
std::uint64_t Item::GetArgument() const
|
||||
{
|
||||
return mDecoder->GetArgument();
|
||||
}
|
||||
|
||||
bool Item::Bool()
|
||||
{
|
||||
return mDecoder->Bool();
|
||||
|
@ -416,7 +493,7 @@ namespace CBOR
|
|||
return mDecoder->Double();
|
||||
}
|
||||
|
||||
std::span<std::uint8_t> Item::Binary()
|
||||
std::span<const std::uint8_t> Item::Binary()
|
||||
{
|
||||
return mDecoder->Binary();
|
||||
}
|
||||
|
@ -531,7 +608,7 @@ namespace CBOR
|
|||
return mDone;
|
||||
}
|
||||
|
||||
std::span<std::uint8_t> Binary::Next()
|
||||
std::span<const std::uint8_t> Binary::Next()
|
||||
{
|
||||
if (!mHeaderParsed) {
|
||||
std::uint8_t header = Consume1B(mDecoder->mBuffer, mDecoder->mCurrent);
|
||||
|
@ -767,10 +844,37 @@ namespace CBOR
|
|||
return KeyValue(*mDecoder);
|
||||
}
|
||||
|
||||
Decoder::Decoder(std::span<std::uint8_t> buffer)
|
||||
Decoder::Decoder(std::span<const std::uint8_t> buffer)
|
||||
: mCurrent(0), mBuffer(buffer)
|
||||
{}
|
||||
|
||||
MajorType Decoder::GetMajor() const
|
||||
{
|
||||
std::uint8_t header = Read1B(mBuffer, mCurrent);
|
||||
return GetMajorType(header);
|
||||
}
|
||||
|
||||
MinorType Decoder::GetMinor() const
|
||||
{
|
||||
std::uint8_t header = Read1B(mBuffer, mCurrent);
|
||||
if (GetMajorType(header) != MajorType::Other) {
|
||||
throw InvalidUsageError("use the GetArgument function instead");
|
||||
}
|
||||
return GetMinorType(header);
|
||||
}
|
||||
|
||||
std::uint64_t Decoder::GetArgument() const
|
||||
{
|
||||
std::uint8_t header = Read1B(mBuffer, mCurrent);
|
||||
if (GetMajorType(header) != MajorType::Other) {
|
||||
throw InvalidUsageError("use the GetMinor function instead");
|
||||
}
|
||||
if (GetArgumentPosition(header) == ArgumentPosition::Indefinite) {
|
||||
return Decoder::ArgumentIndefinite;
|
||||
}
|
||||
return ReadArgumentValue(header, mBuffer, mCurrent);
|
||||
}
|
||||
|
||||
bool Decoder::Bool()
|
||||
{
|
||||
std::uint8_t header = Consume1B(mBuffer, mCurrent);
|
||||
|
@ -881,7 +985,7 @@ namespace CBOR
|
|||
throw TypeMismatchError("double", ToString(major));
|
||||
}
|
||||
|
||||
std::span<std::uint8_t> Decoder::Binary()
|
||||
std::span<const std::uint8_t> Decoder::Binary()
|
||||
{
|
||||
std::uint8_t header = Consume1B(mBuffer, mCurrent);
|
||||
MajorType major = GetMajorType(header);
|
||||
|
@ -937,4 +1041,9 @@ namespace CBOR
|
|||
{
|
||||
return { *this };
|
||||
}
|
||||
|
||||
Item Decoder::AsItem()
|
||||
{
|
||||
return { *this };
|
||||
}
|
||||
}
|
||||
|
|
146
LibCBOR/Source/Printer.cpp
Normal file
146
LibCBOR/Source/Printer.cpp
Normal file
|
@ -0,0 +1,146 @@
|
|||
#include "Printer.hpp"
|
||||
|
||||
#include "Core.hpp"
|
||||
#include "Decoder.hpp"
|
||||
|
||||
#include <print>
|
||||
#include <string>
|
||||
|
||||
namespace CBOR
|
||||
{
|
||||
namespace
|
||||
{
|
||||
void Print(std::ostream &out, std::size_t depth, CBOR::Item item);
|
||||
|
||||
char AsChar(std::uint8_t nibble)
|
||||
{
|
||||
if (nibble < 10) {
|
||||
return nibble + '0';
|
||||
}
|
||||
if (nibble < 16) {
|
||||
return nibble + 'A';
|
||||
}
|
||||
return 'X';
|
||||
}
|
||||
|
||||
std::string AsString(std::uint8_t byte)
|
||||
{
|
||||
std::string result;
|
||||
result.push_back(AsChar((byte >> 4) & 15));
|
||||
result.push_back(AsChar((byte ) & 15));
|
||||
return result;
|
||||
}
|
||||
|
||||
void PrintBinary(std::ostream &out, CBOR::Binary binary)
|
||||
{
|
||||
out << "b\"";
|
||||
while (!binary.Done()) {
|
||||
std::span<const std::uint8_t> chunk = binary.Next();
|
||||
for (std::uint8_t byte: chunk) {
|
||||
out << AsString(byte);
|
||||
}
|
||||
}
|
||||
out << '\"';
|
||||
}
|
||||
|
||||
void PrintString(std::ostream &out, CBOR::String string)
|
||||
{
|
||||
out << '\"';
|
||||
while (!string.Done()) {
|
||||
out << string.Next();
|
||||
}
|
||||
out << '\"';
|
||||
}
|
||||
|
||||
void PrintArray(std::ostream &out, std::size_t depth, CBOR::Array array)
|
||||
{
|
||||
out << "[\n";
|
||||
while (!array.Done()) {
|
||||
out << std::string((depth + 1) * 4, ' ');
|
||||
Print(out, depth + 1, array.Next());
|
||||
out << ",\n";
|
||||
}
|
||||
out << std::string(depth * 4, ' ') << "]";
|
||||
}
|
||||
|
||||
void PrintMap(std::ostream &out, std::size_t depth, CBOR::Map map)
|
||||
{
|
||||
out << "{\n";
|
||||
while (!map.Done()) {
|
||||
CBOR::KeyValue kv = map.Next();
|
||||
out << std::string((depth + 1) * 4, ' ');
|
||||
Print(out, depth + 1, kv.Key());
|
||||
out << ": ";
|
||||
Print(out, depth + 1, kv.Value());
|
||||
out << ",\n";
|
||||
}
|
||||
out << std::string(depth * 4, ' ') << "}";
|
||||
}
|
||||
|
||||
void PrintTagged(std::ostream &out, std::size_t depth, CBOR::TaggedItem item)
|
||||
{
|
||||
out << item.Tag();
|
||||
out << '(';
|
||||
Print(out, depth, item.Item());
|
||||
out << ')';
|
||||
}
|
||||
|
||||
void Print(std::ostream &out, std::size_t depth, CBOR::Item item)
|
||||
{
|
||||
switch (item.GetMajor()) {
|
||||
case MajorType::Unsigned:
|
||||
out << item.Uint64();
|
||||
break;
|
||||
case MajorType::Negative:
|
||||
out << item.Int64();
|
||||
break;
|
||||
case MajorType::Binary:
|
||||
PrintBinary(out, item.IndefiniteBinary());
|
||||
break;
|
||||
case MajorType::String:
|
||||
PrintString(out, item.IndefiniteString());
|
||||
break;
|
||||
case MajorType::Array:
|
||||
PrintArray(out, depth, item.Array());
|
||||
break;
|
||||
case MajorType::Map:
|
||||
PrintMap(out, depth, item.Map());
|
||||
break;
|
||||
case MajorType::Tag:
|
||||
PrintTagged(out, depth, item.TaggedItem());
|
||||
break;
|
||||
case MajorType::Other:
|
||||
switch (item.GetMinor()) {
|
||||
case MinorType::False:
|
||||
out << "false";
|
||||
break;
|
||||
case MinorType::True:
|
||||
out << "true";
|
||||
break;
|
||||
case MinorType::Null:
|
||||
out << "null";
|
||||
break;
|
||||
case MinorType::Undefined:
|
||||
out << "undefined";
|
||||
break;
|
||||
case MinorType::Float:
|
||||
std::print(out, "{:.6f}", item.Float());
|
||||
break;
|
||||
case MinorType::Double:
|
||||
std::print(out, "{:.15f}", item.Double());
|
||||
break;
|
||||
default:
|
||||
out << "invalid_value";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Print(std::ostream &out, std::span<const std::uint8_t> buffer)
|
||||
{
|
||||
CBOR::Decoder dec(buffer);
|
||||
Print(out, 0, dec.AsItem());
|
||||
out << '\n';
|
||||
}
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
#include "CBOR/Decoder.hpp"
|
||||
#include "CBOR/Encoder.hpp"
|
||||
#include "Cbor/Printer.hpp"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <print>
|
||||
#include <ranges>
|
||||
#include <stdexcept>
|
||||
|
@ -206,6 +208,9 @@ int main()
|
|||
SomeStruct result1 = Decode1(std::span<std::uint8_t>(buffer.data(), encodedSize));
|
||||
SomeStruct result2 = Decode2(std::span<std::uint8_t>(buffer.data(), encodedSize));
|
||||
|
||||
std::println("JSON-esque serialization:");
|
||||
CBOR::Print(std::cout, std::span<std::uint8_t>(buffer.data(), encodedSize));
|
||||
|
||||
Compare(expected, result1);
|
||||
Compare(expected, result2);
|
||||
std::println("The test has been completed successfully.");
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue