mirror of
https://github.com/goatcorp/Dalamud.git
synced 2025-12-28 03:19:19 +01:00
chore: convert Dalamud to file-scoped namespaces
This commit is contained in:
parent
b093323acc
commit
987ff8dc8f
368 changed files with 55081 additions and 55450 deletions
|
|
@ -16,280 +16,279 @@ using Dalamud.Utility;
|
|||
using Lumina.Excel.GeneratedSheets;
|
||||
using Serilog;
|
||||
|
||||
namespace Dalamud.Game.Network.Internal
|
||||
namespace Dalamud.Game.Network.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// This class handles network notifications and uploading market board data.
|
||||
/// </summary>
|
||||
[ServiceManager.EarlyLoadedService]
|
||||
internal class NetworkHandlers : IServiceType
|
||||
{
|
||||
/// <summary>
|
||||
/// This class handles network notifications and uploading market board data.
|
||||
/// </summary>
|
||||
[ServiceManager.EarlyLoadedService]
|
||||
internal class NetworkHandlers : IServiceType
|
||||
private readonly List<MarketBoardItemRequest> marketBoardRequests = new();
|
||||
|
||||
private readonly IMarketBoardUploader uploader;
|
||||
|
||||
[ServiceManager.ServiceDependency]
|
||||
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
|
||||
|
||||
private MarketBoardPurchaseHandler marketBoardPurchaseHandler;
|
||||
|
||||
[ServiceManager.ServiceConstructor]
|
||||
private NetworkHandlers(GameNetwork gameNetwork)
|
||||
{
|
||||
private readonly List<MarketBoardItemRequest> marketBoardRequests = new();
|
||||
this.uploader = new UniversalisMarketBoardUploader();
|
||||
|
||||
private readonly IMarketBoardUploader uploader;
|
||||
gameNetwork.NetworkMessage += this.OnNetworkMessage;
|
||||
}
|
||||
|
||||
[ServiceManager.ServiceDependency]
|
||||
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
|
||||
/// <summary>
|
||||
/// Event which gets fired when a duty is ready.
|
||||
/// </summary>
|
||||
public event EventHandler<ContentFinderCondition> CfPop;
|
||||
|
||||
private MarketBoardPurchaseHandler marketBoardPurchaseHandler;
|
||||
private void OnNetworkMessage(IntPtr dataPtr, ushort opCode, uint sourceActorId, uint targetActorId, NetworkMessageDirection direction)
|
||||
{
|
||||
var dataManager = Service<DataManager>.GetNullable();
|
||||
|
||||
[ServiceManager.ServiceConstructor]
|
||||
private NetworkHandlers(GameNetwork gameNetwork)
|
||||
if (dataManager?.IsDataReady != true)
|
||||
return;
|
||||
|
||||
if (direction == NetworkMessageDirection.ZoneUp)
|
||||
{
|
||||
this.uploader = new UniversalisMarketBoardUploader();
|
||||
|
||||
gameNetwork.NetworkMessage += this.OnNetworkMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event which gets fired when a duty is ready.
|
||||
/// </summary>
|
||||
public event EventHandler<ContentFinderCondition> CfPop;
|
||||
|
||||
private void OnNetworkMessage(IntPtr dataPtr, ushort opCode, uint sourceActorId, uint targetActorId, NetworkMessageDirection direction)
|
||||
{
|
||||
var dataManager = Service<DataManager>.GetNullable();
|
||||
|
||||
if (dataManager?.IsDataReady != true)
|
||||
return;
|
||||
|
||||
if (direction == NetworkMessageDirection.ZoneUp)
|
||||
{
|
||||
if (this.configuration.IsMbCollect)
|
||||
{
|
||||
if (opCode == dataManager.ClientOpCodes["MarketBoardPurchaseHandler"])
|
||||
{
|
||||
this.marketBoardPurchaseHandler = MarketBoardPurchaseHandler.Read(dataPtr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (opCode == dataManager.ServerOpCodes["CfNotifyPop"])
|
||||
{
|
||||
this.HandleCfPop(dataPtr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.configuration.IsMbCollect)
|
||||
{
|
||||
if (opCode == dataManager.ServerOpCodes["MarketBoardItemRequestStart"])
|
||||
if (opCode == dataManager.ClientOpCodes["MarketBoardPurchaseHandler"])
|
||||
{
|
||||
var data = MarketBoardItemRequest.Read(dataPtr);
|
||||
this.marketBoardRequests.Add(data);
|
||||
|
||||
Log.Verbose($"NEW MB REQUEST START: item#{data.CatalogId} amount#{data.AmountToArrive}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (opCode == dataManager.ServerOpCodes["MarketBoardOfferings"])
|
||||
{
|
||||
var listing = MarketBoardCurrentOfferings.Read(dataPtr);
|
||||
|
||||
var request = this.marketBoardRequests.LastOrDefault(r => r.CatalogId == listing.ItemListings[0].CatalogId && !r.IsDone);
|
||||
|
||||
if (request == default)
|
||||
{
|
||||
Log.Error($"Market Board data arrived without a corresponding request: item#{listing.ItemListings[0].CatalogId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.Listings.Count + listing.ItemListings.Count > request.AmountToArrive)
|
||||
{
|
||||
Log.Error($"Too many Market Board listings received for request: {request.Listings.Count + listing.ItemListings.Count} > {request.AmountToArrive} item#{listing.ItemListings[0].CatalogId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ListingsRequestId != -1 && request.ListingsRequestId != listing.RequestId)
|
||||
{
|
||||
Log.Error($"Non-matching RequestIds for Market Board data request: {request.ListingsRequestId}, {listing.RequestId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ListingsRequestId == -1 && request.Listings.Count > 0)
|
||||
{
|
||||
Log.Error($"Market Board data request sequence break: {request.ListingsRequestId}, {request.Listings.Count}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ListingsRequestId == -1)
|
||||
{
|
||||
request.ListingsRequestId = listing.RequestId;
|
||||
Log.Verbose($"First Market Board packet in sequence: {listing.RequestId}");
|
||||
}
|
||||
|
||||
request.Listings.AddRange(listing.ItemListings);
|
||||
|
||||
Log.Verbose(
|
||||
"Added {0} ItemListings to request#{1}, now {2}/{3}, item#{4}",
|
||||
listing.ItemListings.Count,
|
||||
request.ListingsRequestId,
|
||||
request.Listings.Count,
|
||||
request.AmountToArrive,
|
||||
request.CatalogId);
|
||||
|
||||
if (request.IsDone)
|
||||
{
|
||||
Log.Verbose(
|
||||
"Market Board request finished, starting upload: request#{0} item#{1} amount#{2}",
|
||||
request.ListingsRequestId,
|
||||
request.CatalogId,
|
||||
request.AmountToArrive);
|
||||
|
||||
Task.Run(() => this.uploader.Upload(request))
|
||||
.ContinueWith((task) => Log.Error(task.Exception, "Market Board offerings data upload failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (opCode == dataManager.ServerOpCodes["MarketBoardHistory"])
|
||||
{
|
||||
var listing = MarketBoardHistory.Read(dataPtr);
|
||||
|
||||
var request = this.marketBoardRequests.LastOrDefault(r => r.CatalogId == listing.CatalogId);
|
||||
|
||||
if (request == default)
|
||||
{
|
||||
Log.Error($"Market Board data arrived without a corresponding request: item#{listing.CatalogId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ListingsRequestId != -1)
|
||||
{
|
||||
Log.Error($"Market Board data history sequence break: {request.ListingsRequestId}, {request.Listings.Count}");
|
||||
return;
|
||||
}
|
||||
|
||||
request.History.AddRange(listing.HistoryListings);
|
||||
|
||||
Log.Verbose("Added history for item#{0}", listing.CatalogId);
|
||||
|
||||
if (request.AmountToArrive == 0)
|
||||
{
|
||||
Log.Verbose("Request had 0 amount, uploading now");
|
||||
|
||||
Task.Run(() => this.uploader.Upload(request))
|
||||
.ContinueWith((task) => Log.Error(task.Exception, "Market Board history data upload failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
}
|
||||
|
||||
if (opCode == dataManager.ServerOpCodes["MarketTaxRates"])
|
||||
{
|
||||
var category = (uint)Marshal.ReadInt32(dataPtr);
|
||||
|
||||
// Result dialog packet does not contain market tax rates
|
||||
if (category != 720905)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var taxes = MarketTaxRates.Read(dataPtr);
|
||||
|
||||
if (taxes.Category != 0xb0009)
|
||||
return;
|
||||
|
||||
Log.Verbose(
|
||||
"MarketTaxRates: limsa#{0} grid#{1} uldah#{2} ish#{3} kugane#{4} cr#{5} sh#{6}",
|
||||
taxes.LimsaLominsaTax,
|
||||
taxes.GridaniaTax,
|
||||
taxes.UldahTax,
|
||||
taxes.IshgardTax,
|
||||
taxes.KuganeTax,
|
||||
taxes.CrystariumTax,
|
||||
taxes.SharlayanTax);
|
||||
|
||||
Task.Run(() => this.uploader.UploadTax(taxes))
|
||||
.ContinueWith((task) => Log.Error(task.Exception, "Market Board tax data upload failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (opCode == dataManager.ServerOpCodes["MarketBoardPurchase"])
|
||||
{
|
||||
if (this.marketBoardPurchaseHandler == null)
|
||||
return;
|
||||
|
||||
var purchase = MarketBoardPurchase.Read(dataPtr);
|
||||
|
||||
var sameQty = purchase.ItemQuantity == this.marketBoardPurchaseHandler.ItemQuantity;
|
||||
var itemMatch = purchase.CatalogId == this.marketBoardPurchaseHandler.CatalogId;
|
||||
var itemMatchHq = purchase.CatalogId == this.marketBoardPurchaseHandler.CatalogId + 1_000_000;
|
||||
|
||||
// Transaction succeeded
|
||||
if (sameQty && (itemMatch || itemMatchHq))
|
||||
{
|
||||
Log.Verbose($"Bought {purchase.ItemQuantity}x {this.marketBoardPurchaseHandler.CatalogId} for {this.marketBoardPurchaseHandler.PricePerUnit * purchase.ItemQuantity} gils, listing id is {this.marketBoardPurchaseHandler.ListingId}");
|
||||
|
||||
var handler = this.marketBoardPurchaseHandler; // Capture the object so that we don't pass in a null one when the task starts.
|
||||
|
||||
Task.Run(() => this.uploader.UploadPurchase(handler))
|
||||
.ContinueWith((task) => Log.Error(task.Exception, "Market Board purchase data upload failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
this.marketBoardPurchaseHandler = null;
|
||||
this.marketBoardPurchaseHandler = MarketBoardPurchaseHandler.Read(dataPtr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private unsafe void HandleCfPop(IntPtr dataPtr)
|
||||
if (opCode == dataManager.ServerOpCodes["CfNotifyPop"])
|
||||
{
|
||||
var dataManager = Service<DataManager>.GetNullable();
|
||||
if (dataManager == null)
|
||||
return;
|
||||
this.HandleCfPop(dataPtr);
|
||||
return;
|
||||
}
|
||||
|
||||
using var stream = new UnmanagedMemoryStream((byte*)dataPtr.ToPointer(), 64);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
var notifyType = reader.ReadByte();
|
||||
stream.Position += 0x1B;
|
||||
var conditionId = reader.ReadUInt16();
|
||||
|
||||
if (notifyType != 3)
|
||||
return;
|
||||
|
||||
var cfConditionSheet = dataManager.GetExcelSheet<ContentFinderCondition>()!;
|
||||
var cfCondition = cfConditionSheet.GetRow(conditionId);
|
||||
|
||||
if (cfCondition == null)
|
||||
if (this.configuration.IsMbCollect)
|
||||
{
|
||||
if (opCode == dataManager.ServerOpCodes["MarketBoardItemRequestStart"])
|
||||
{
|
||||
Log.Error($"CFC key {conditionId} not in Lumina data.");
|
||||
var data = MarketBoardItemRequest.Read(dataPtr);
|
||||
this.marketBoardRequests.Add(data);
|
||||
|
||||
Log.Verbose($"NEW MB REQUEST START: item#{data.CatalogId} amount#{data.AmountToArrive}");
|
||||
return;
|
||||
}
|
||||
|
||||
var cfcName = cfCondition.Name.ToString();
|
||||
if (cfcName.IsNullOrEmpty())
|
||||
if (opCode == dataManager.ServerOpCodes["MarketBoardOfferings"])
|
||||
{
|
||||
cfcName = "Duty Roulette";
|
||||
cfCondition.Image = 112324;
|
||||
}
|
||||
var listing = MarketBoardCurrentOfferings.Read(dataPtr);
|
||||
|
||||
// Flash window
|
||||
if (this.configuration.DutyFinderTaskbarFlash && !NativeFunctions.ApplicationIsActivated())
|
||||
{
|
||||
var flashInfo = new NativeFunctions.FlashWindowInfo
|
||||
var request = this.marketBoardRequests.LastOrDefault(r => r.CatalogId == listing.ItemListings[0].CatalogId && !r.IsDone);
|
||||
|
||||
if (request == default)
|
||||
{
|
||||
Size = (uint)Marshal.SizeOf<NativeFunctions.FlashWindowInfo>(),
|
||||
Count = uint.MaxValue,
|
||||
Timeout = 0,
|
||||
Flags = NativeFunctions.FlashWindow.All | NativeFunctions.FlashWindow.TimerNoFG,
|
||||
Hwnd = Process.GetCurrentProcess().MainWindowHandle,
|
||||
};
|
||||
NativeFunctions.FlashWindowEx(ref flashInfo);
|
||||
}
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
if (this.configuration.DutyFinderChatMessage)
|
||||
{
|
||||
Service<ChatGui>.GetNullable()?.Print($"Duty pop: {cfcName}");
|
||||
Log.Error($"Market Board data arrived without a corresponding request: item#{listing.ItemListings[0].CatalogId}");
|
||||
return;
|
||||
}
|
||||
|
||||
this.CfPop?.InvokeSafely(this, cfCondition);
|
||||
}).ContinueWith((task) => Log.Error(task.Exception, "CfPop.Invoke failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
if (request.Listings.Count + listing.ItemListings.Count > request.AmountToArrive)
|
||||
{
|
||||
Log.Error($"Too many Market Board listings received for request: {request.Listings.Count + listing.ItemListings.Count} > {request.AmountToArrive} item#{listing.ItemListings[0].CatalogId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ListingsRequestId != -1 && request.ListingsRequestId != listing.RequestId)
|
||||
{
|
||||
Log.Error($"Non-matching RequestIds for Market Board data request: {request.ListingsRequestId}, {listing.RequestId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ListingsRequestId == -1 && request.Listings.Count > 0)
|
||||
{
|
||||
Log.Error($"Market Board data request sequence break: {request.ListingsRequestId}, {request.Listings.Count}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ListingsRequestId == -1)
|
||||
{
|
||||
request.ListingsRequestId = listing.RequestId;
|
||||
Log.Verbose($"First Market Board packet in sequence: {listing.RequestId}");
|
||||
}
|
||||
|
||||
request.Listings.AddRange(listing.ItemListings);
|
||||
|
||||
Log.Verbose(
|
||||
"Added {0} ItemListings to request#{1}, now {2}/{3}, item#{4}",
|
||||
listing.ItemListings.Count,
|
||||
request.ListingsRequestId,
|
||||
request.Listings.Count,
|
||||
request.AmountToArrive,
|
||||
request.CatalogId);
|
||||
|
||||
if (request.IsDone)
|
||||
{
|
||||
Log.Verbose(
|
||||
"Market Board request finished, starting upload: request#{0} item#{1} amount#{2}",
|
||||
request.ListingsRequestId,
|
||||
request.CatalogId,
|
||||
request.AmountToArrive);
|
||||
|
||||
Task.Run(() => this.uploader.Upload(request))
|
||||
.ContinueWith((task) => Log.Error(task.Exception, "Market Board offerings data upload failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (opCode == dataManager.ServerOpCodes["MarketBoardHistory"])
|
||||
{
|
||||
var listing = MarketBoardHistory.Read(dataPtr);
|
||||
|
||||
var request = this.marketBoardRequests.LastOrDefault(r => r.CatalogId == listing.CatalogId);
|
||||
|
||||
if (request == default)
|
||||
{
|
||||
Log.Error($"Market Board data arrived without a corresponding request: item#{listing.CatalogId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ListingsRequestId != -1)
|
||||
{
|
||||
Log.Error($"Market Board data history sequence break: {request.ListingsRequestId}, {request.Listings.Count}");
|
||||
return;
|
||||
}
|
||||
|
||||
request.History.AddRange(listing.HistoryListings);
|
||||
|
||||
Log.Verbose("Added history for item#{0}", listing.CatalogId);
|
||||
|
||||
if (request.AmountToArrive == 0)
|
||||
{
|
||||
Log.Verbose("Request had 0 amount, uploading now");
|
||||
|
||||
Task.Run(() => this.uploader.Upload(request))
|
||||
.ContinueWith((task) => Log.Error(task.Exception, "Market Board history data upload failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
}
|
||||
|
||||
if (opCode == dataManager.ServerOpCodes["MarketTaxRates"])
|
||||
{
|
||||
var category = (uint)Marshal.ReadInt32(dataPtr);
|
||||
|
||||
// Result dialog packet does not contain market tax rates
|
||||
if (category != 720905)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var taxes = MarketTaxRates.Read(dataPtr);
|
||||
|
||||
if (taxes.Category != 0xb0009)
|
||||
return;
|
||||
|
||||
Log.Verbose(
|
||||
"MarketTaxRates: limsa#{0} grid#{1} uldah#{2} ish#{3} kugane#{4} cr#{5} sh#{6}",
|
||||
taxes.LimsaLominsaTax,
|
||||
taxes.GridaniaTax,
|
||||
taxes.UldahTax,
|
||||
taxes.IshgardTax,
|
||||
taxes.KuganeTax,
|
||||
taxes.CrystariumTax,
|
||||
taxes.SharlayanTax);
|
||||
|
||||
Task.Run(() => this.uploader.UploadTax(taxes))
|
||||
.ContinueWith((task) => Log.Error(task.Exception, "Market Board tax data upload failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (opCode == dataManager.ServerOpCodes["MarketBoardPurchase"])
|
||||
{
|
||||
if (this.marketBoardPurchaseHandler == null)
|
||||
return;
|
||||
|
||||
var purchase = MarketBoardPurchase.Read(dataPtr);
|
||||
|
||||
var sameQty = purchase.ItemQuantity == this.marketBoardPurchaseHandler.ItemQuantity;
|
||||
var itemMatch = purchase.CatalogId == this.marketBoardPurchaseHandler.CatalogId;
|
||||
var itemMatchHq = purchase.CatalogId == this.marketBoardPurchaseHandler.CatalogId + 1_000_000;
|
||||
|
||||
// Transaction succeeded
|
||||
if (sameQty && (itemMatch || itemMatchHq))
|
||||
{
|
||||
Log.Verbose($"Bought {purchase.ItemQuantity}x {this.marketBoardPurchaseHandler.CatalogId} for {this.marketBoardPurchaseHandler.PricePerUnit * purchase.ItemQuantity} gils, listing id is {this.marketBoardPurchaseHandler.ListingId}");
|
||||
|
||||
var handler = this.marketBoardPurchaseHandler; // Capture the object so that we don't pass in a null one when the task starts.
|
||||
|
||||
Task.Run(() => this.uploader.UploadPurchase(handler))
|
||||
.ContinueWith((task) => Log.Error(task.Exception, "Market Board purchase data upload failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
this.marketBoardPurchaseHandler = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void HandleCfPop(IntPtr dataPtr)
|
||||
{
|
||||
var dataManager = Service<DataManager>.GetNullable();
|
||||
if (dataManager == null)
|
||||
return;
|
||||
|
||||
using var stream = new UnmanagedMemoryStream((byte*)dataPtr.ToPointer(), 64);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
var notifyType = reader.ReadByte();
|
||||
stream.Position += 0x1B;
|
||||
var conditionId = reader.ReadUInt16();
|
||||
|
||||
if (notifyType != 3)
|
||||
return;
|
||||
|
||||
var cfConditionSheet = dataManager.GetExcelSheet<ContentFinderCondition>()!;
|
||||
var cfCondition = cfConditionSheet.GetRow(conditionId);
|
||||
|
||||
if (cfCondition == null)
|
||||
{
|
||||
Log.Error($"CFC key {conditionId} not in Lumina data.");
|
||||
return;
|
||||
}
|
||||
|
||||
var cfcName = cfCondition.Name.ToString();
|
||||
if (cfcName.IsNullOrEmpty())
|
||||
{
|
||||
cfcName = "Duty Roulette";
|
||||
cfCondition.Image = 112324;
|
||||
}
|
||||
|
||||
// Flash window
|
||||
if (this.configuration.DutyFinderTaskbarFlash && !NativeFunctions.ApplicationIsActivated())
|
||||
{
|
||||
var flashInfo = new NativeFunctions.FlashWindowInfo
|
||||
{
|
||||
Size = (uint)Marshal.SizeOf<NativeFunctions.FlashWindowInfo>(),
|
||||
Count = uint.MaxValue,
|
||||
Timeout = 0,
|
||||
Flags = NativeFunctions.FlashWindow.All | NativeFunctions.FlashWindow.TimerNoFG,
|
||||
Hwnd = Process.GetCurrentProcess().MainWindowHandle,
|
||||
};
|
||||
NativeFunctions.FlashWindowEx(ref flashInfo);
|
||||
}
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
if (this.configuration.DutyFinderChatMessage)
|
||||
{
|
||||
Service<ChatGui>.GetNullable()?.Print($"Duty pop: {cfcName}");
|
||||
}
|
||||
|
||||
this.CfPop?.InvokeSafely(this, cfCondition);
|
||||
}).ContinueWith((task) => Log.Error(task.Exception, "CfPop.Invoke failed."), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue