init
This commit is contained in:
commit
e124a47765
19374 changed files with 9806149 additions and 0 deletions
593
Kreta.DataAccessManual/Util/DataUtil.cs
Normal file
593
Kreta.DataAccessManual/Util/DataUtil.cs
Normal file
|
@ -0,0 +1,593 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Kreta.Framework;
|
||||
using Kreta.Framework.Util;
|
||||
using SDA.DataProvider;
|
||||
|
||||
namespace Kreta.DataAccessManual.Util
|
||||
{
|
||||
internal static class DataUtil
|
||||
{
|
||||
#region Session
|
||||
|
||||
public static bool ActivateSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
return false;
|
||||
|
||||
if (UserContext.Instance == null || !UserContext.Instance.Activated)
|
||||
{
|
||||
SDAServer.Instance.SessionManager.ActivateSession(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void DeactivateSession(string sessionId)
|
||||
{
|
||||
if (UserContext.Instance != null && UserContext.Instance.Activated)
|
||||
{
|
||||
SDAServer.Instance.SessionManager.DeActivateSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ActivateOrganizationSystemSession(string intezmenyAzonosito)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(intezmenyAzonosito))
|
||||
return false;
|
||||
|
||||
if (UserContext.Instance == null || !UserContext.Instance.Activated)
|
||||
{
|
||||
SDAServer.Instance.SessionManager.ActivateOrganizationSystemSession(intezmenyAzonosito);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void DeactivateOrganizationSystemSession()
|
||||
{
|
||||
if (UserContext.Instance != null && UserContext.Instance.Activated)
|
||||
{
|
||||
SDAServer.Instance.SessionManager.DeactivateOrganizationSystemSession();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ActivateMobileSession(string intezmenyAzonosito)
|
||||
{
|
||||
return ActivateOrganizationSystemSession(intezmenyAzonosito);
|
||||
}
|
||||
|
||||
public static void DeactivateMobileSession()
|
||||
{
|
||||
DeactivateOrganizationSystemSession();
|
||||
}
|
||||
|
||||
public static bool ActivateSystemSession()
|
||||
{
|
||||
if (UserContext.Instance == null || !UserContext.Instance.Activated)
|
||||
{
|
||||
SDAServer.Instance.SessionManager.ActivateSystemSession();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void DeactivateSystemSession()
|
||||
{
|
||||
if (UserContext.Instance != null && UserContext.Instance.Activated)
|
||||
{
|
||||
SDAServer.Instance.SessionManager.DeactivateSystemSession();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ActivateServiceSystemSession(string connectionString)
|
||||
{
|
||||
if (UserContext.Instance == null || !UserContext.Instance.Activated)
|
||||
{
|
||||
SDAServer.Instance.SessionManager.ActivateServiceSystemSession(connectionString);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void DeactivateServiceSystemSession()
|
||||
{
|
||||
if (UserContext.Instance != null && UserContext.Instance.Activated)
|
||||
{
|
||||
SDAServer.Instance.SessionManager.DeactivateServiceSystemSession();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public static SDAConnection GetReadOnlyConnection(string intezmenyAzonosito)
|
||||
{
|
||||
var sqlConnectionStringBuilder = new SqlConnectionStringBuilder(SDAServer.Instance.ConnectionManager.GetIntezmenyConnectionString(intezmenyAzonosito))
|
||||
{
|
||||
ApplicationIntent = ApplicationIntent.ReadOnly
|
||||
};
|
||||
|
||||
return new SDAConnection(sqlConnectionStringBuilder.ToString());
|
||||
}
|
||||
|
||||
[Obsolete(@"A Kreta.Core.Extensions.SortingAndPaging<T>-t kell használni model listákkal, mivel ezeket mostmár tudjuk sorbarendezni a GridParameters-el,
|
||||
így nincs szükség rá, hogy elmenjen a DataSet a Web-re ezért nincs is szükség itt sorrenezni őket!")]
|
||||
public static DataTable SortingAndPaging(DataTable dataTable, GridParameters gridParameters)
|
||||
{
|
||||
if (gridParameters == null)
|
||||
{
|
||||
return dataTable;
|
||||
}
|
||||
|
||||
int rowCount = dataTable.Rows.Count;
|
||||
if (rowCount > 0)
|
||||
{
|
||||
string orderBy = gridParameters.OrderBy ?? "";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(orderBy.Trim()))
|
||||
{
|
||||
var comparer = new AlphanumComparator<string>();
|
||||
|
||||
string[] orderByList = orderBy.Split(',');
|
||||
|
||||
foreach (string strOrderBy in orderByList)
|
||||
{
|
||||
string[] strOrderByColType = strOrderBy.Trim().Split(' ');
|
||||
|
||||
//A rendezés oszlopa
|
||||
string strOrderByCol = strOrderByColType[0];
|
||||
|
||||
//A rendezés típusa
|
||||
string strOrderByType;
|
||||
|
||||
//A rendezés típusának beállítása. ASC, vagy DESC a tartalma
|
||||
if (strOrderByColType.Length > 1)
|
||||
{
|
||||
strOrderByType = strOrderByColType[1].IndexOf("ASC", StringComparison.OrdinalIgnoreCase) >= 0 ? "ASC" : "DESC";
|
||||
}
|
||||
else
|
||||
{
|
||||
strOrderByType = "ASC";
|
||||
}
|
||||
|
||||
var tableName = dataTable.TableName;
|
||||
|
||||
if (strOrderByType == "ASC")
|
||||
{
|
||||
dataTable = dataTable.AsEnumerable().OrderBy(x => ToString(x[strOrderByCol]), comparer).CopyToDataTable();
|
||||
}
|
||||
else
|
||||
{
|
||||
dataTable = dataTable.AsEnumerable().OrderByDescending(x => ToString(x[strOrderByCol]), comparer).CopyToDataTable();
|
||||
}
|
||||
|
||||
dataTable.TableName = tableName;
|
||||
}
|
||||
}
|
||||
|
||||
if (gridParameters.FirstRow <= gridParameters.LastRow)
|
||||
{
|
||||
int pageSize = gridParameters.LastRow - gridParameters.FirstRow + 1;
|
||||
if (pageSize < rowCount)
|
||||
{
|
||||
dataTable = dataTable.AsEnumerable().Skip(gridParameters.FirstRow).Take(pageSize).CopyToDataTable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (gridParameters.LoadResultSetInfo && !dataTable.ExtendedProperties.ContainsKey("RowCount"))
|
||||
{
|
||||
dataTable.ExtendedProperties.Add("RowCount", rowCount);
|
||||
}
|
||||
|
||||
return dataTable;
|
||||
}
|
||||
|
||||
public static DataSet AsDataSet(this DataTable dataTable)
|
||||
{
|
||||
if (dataTable.DataSet == null)
|
||||
{
|
||||
var dataSet = new DataSet();
|
||||
dataSet.Tables.Add(dataTable);
|
||||
}
|
||||
|
||||
return dataTable.DataSet;
|
||||
}
|
||||
|
||||
public static DataTable GetData(SDACommand command, GridParameters gridParameters)
|
||||
{
|
||||
using (var adapter = new SDADataAdapter())
|
||||
{
|
||||
adapter.SelectCommand = command;
|
||||
string mOriginalcommandtext = command.CommandText;
|
||||
|
||||
string orderBy = gridParameters.OrderBy ?? "";
|
||||
GenerateOrderString(command, ref orderBy, out string fullinnerfilter);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fullinnerfilter) || !string.IsNullOrWhiteSpace(orderBy))
|
||||
{
|
||||
command.CommandText = string.Format(@"
|
||||
SELECT szurttabla.*
|
||||
FROM
|
||||
( {0} ) szurttabla {1}", mOriginalcommandtext, fullinnerfilter ?? "");
|
||||
}
|
||||
|
||||
//Ha van rendezési feltétel
|
||||
if (!string.IsNullOrWhiteSpace(orderBy))
|
||||
{
|
||||
command.CommandText += string.Format(" order by {0}", orderBy);
|
||||
}
|
||||
|
||||
//Feltoltes a sorok szamanak lekerdezesevel egyutt
|
||||
var table = new DataTable();
|
||||
if (gridParameters.LoadResultSetInfo && !table.ExtendedProperties.ContainsKey("RowCount"))
|
||||
{
|
||||
table.ExtendedProperties.Add("RowCount", GetRowCount(command));
|
||||
}
|
||||
|
||||
int firstRow = gridParameters.FirstRow;
|
||||
int lastRow = gridParameters.LastRow == -1 ? SDAServer.Instance.Configuration.MaximalRecordCountPerRequest - 1 : gridParameters.LastRow;
|
||||
|
||||
bool reduceDbLoadEnabled = false;
|
||||
// optimalizált lekérdezés template - csak a szükséges sorokat kéri majd le az adapter
|
||||
// ha engedélyezve van és ha van értelme (azaz a kért utolsó sor különbözik a max. sortól)
|
||||
if (lastRow < SDAServer.Instance.Configuration.MaximalRecordCountPerRequest - 1)
|
||||
{
|
||||
reduceDbLoadEnabled = true;
|
||||
// ORACLE
|
||||
|
||||
// subquery-ben nem lehet order by
|
||||
|
||||
bool canWrap = !(mOriginalcommandtext.Contains("order by") || mOriginalcommandtext.Contains("ORDER BY"));
|
||||
if (canWrap)
|
||||
{
|
||||
command.CommandText = string.Format(@"
|
||||
select * from
|
||||
(
|
||||
select row_number() over(order by {0}) as rnum_, szurttabla.*
|
||||
from
|
||||
( {1} ) szurttabla
|
||||
{2}
|
||||
) qqq
|
||||
where qqq.rnum_ between {3} and {4}",
|
||||
!string.IsNullOrWhiteSpace(orderBy.Trim()) ? orderBy : "(select 0)",
|
||||
mOriginalcommandtext,
|
||||
fullinnerfilter,
|
||||
firstRow + 1,
|
||||
lastRow + 1);
|
||||
}
|
||||
// nem tudunk optimalizálni
|
||||
else
|
||||
{
|
||||
reduceDbLoadEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
#region Language
|
||||
|
||||
//nyelvesites (c_name,c_name_1........)
|
||||
//SetMultilanguage(command);
|
||||
|
||||
#endregion Language
|
||||
|
||||
//Lekerdezes lefuttatasa
|
||||
if (reduceDbLoadEnabled)
|
||||
adapter.Fill(table);
|
||||
else
|
||||
adapter.Fill(table, (int)firstRow, (int)lastRow);
|
||||
|
||||
//ne legyen benne datasetben
|
||||
if (table.DataSet != null)
|
||||
{
|
||||
table.DataSet.Tables.Remove(table);
|
||||
}
|
||||
|
||||
// osszes mezore beallitom, hogy adatbazisbol jott
|
||||
var cols = new List<string>();
|
||||
foreach (DataColumn col in table.Columns)
|
||||
{
|
||||
cols.Add(col.ColumnName.ToUpper());
|
||||
}
|
||||
table.ExtendedProperties["DatabaseColumns"] = cols;
|
||||
|
||||
return table;
|
||||
}
|
||||
}
|
||||
|
||||
public static int CheckRowCount(SDACommand command)
|
||||
{
|
||||
command.CommandType = CommandType.Text;
|
||||
command.Connection = UserContext.Instance.SDAConnection;
|
||||
command.Transaction = UserContext.Instance.SDATransaction;
|
||||
|
||||
using (var adapter = new SDADataAdapter())
|
||||
{
|
||||
adapter.SelectCommand = command;
|
||||
return GetRowCount(command);
|
||||
}
|
||||
}
|
||||
|
||||
private static void GenerateOrderString(SDACommand command, ref string orderBy, out string fullinnerfilter)
|
||||
{
|
||||
const string orderFormatString = @"
|
||||
CASE WHEN (LEN({0}) < 10 AND {0} NOT LIKE '%[^0-9]%') OR (LEN({0}) = 10 AND ISDATE({0}) = 1)
|
||||
THEN CAST({0} AS INT)
|
||||
ELSE CASE WHEN LEFT({0}, 1) BETWEEN '0' AND '9' AND {0} LIKE '%[^0-9]%'
|
||||
THEN CAST(LEFT(SUBSTRING(CONVERT(VARCHAR, {0}), 1, ISNULL(PATINDEX('%[^0-9]%', CONVERT(VARCHAR, {0})), 0) - 1),9) AS INT)
|
||||
ELSE 2147483647
|
||||
END
|
||||
END {1}, {0} {1}";
|
||||
|
||||
//Ha van rendezési feltétel
|
||||
fullinnerfilter = "";
|
||||
if (!string.IsNullOrWhiteSpace(orderBy.Trim()))
|
||||
{
|
||||
//Több rendezési szempont esetén felbontjuk az egyes oszlopokra
|
||||
string[] orderByList = orderBy.Split(',');
|
||||
|
||||
//OrderBy-t ürítjük, majd újra összerakjuk a DNAME oszlopok miatt
|
||||
orderBy = "";
|
||||
|
||||
//Számoljuk a DNAME-es rendezési szempontok számát
|
||||
int dnameNo = 0;
|
||||
|
||||
//Végigmegyünk a rendezési szempontokon
|
||||
foreach (string strOrderBy in orderByList)
|
||||
{
|
||||
//A végső order by kifejezés tárolásához szükséges változó
|
||||
string strTempOrderBy = "";
|
||||
|
||||
string[] strOrderByColType = strOrderBy.Trim().Split(' ');
|
||||
|
||||
//A rendezés oszlopa
|
||||
string strOrderByCol = strOrderByColType[0];
|
||||
|
||||
//A rendezés típusa
|
||||
string strOrderByType;
|
||||
|
||||
//A rendezés típusának beállítása. ASC, vagy DESC a tartalma
|
||||
if (strOrderByColType.Length > 1)
|
||||
{ strOrderByType = strOrderByColType[1].IndexOf("ASC", StringComparison.OrdinalIgnoreCase) >= 0 ? " ASC" : " DESC"; }
|
||||
else
|
||||
{
|
||||
strOrderByType = " ASC";
|
||||
}
|
||||
|
||||
//Ha az aktuális rendezési szempont DNAME-es oszlop, akkor nyelvesítjük
|
||||
//és hozzákapcsolunk egy DictionaryItemBase táblát
|
||||
if (!string.IsNullOrWhiteSpace(strOrderByCol) && strOrderByCol.IndexOf("_DNAME", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
//leszedem a DNAME-et mert igy kapom meg az oszlopnevet amivel joinolok
|
||||
string DNAME = Regex.Replace(strOrderByCol, "_DNAME", "", RegexOptions.IgnoreCase);
|
||||
|
||||
//A tábla neve
|
||||
string strTableName = "t_dictionaryitembase";
|
||||
//A tábla alias-a, hogy több DNAME-es oszloppal is működjön
|
||||
string strTableAlias = "dict" + dnameNo.ToString();
|
||||
|
||||
strTempOrderBy = string.Format(orderFormatString, $"{strTableAlias}.c_name", strOrderByType);
|
||||
|
||||
fullinnerfilter += string.Format(" left join {0} {1} on {1}.id = szurttabla.{2}", strTableName, strTableAlias, DNAME);
|
||||
|
||||
//Megöveljük a DNAME-es rendezési szempontok számlálóját
|
||||
dnameNo++;
|
||||
}
|
||||
// Ha az aktuális rendezési szempont BNAME-es oszlop
|
||||
else if (!string.IsNullOrWhiteSpace(strOrderByCol) && strOrderByCol.IndexOf("_BNAME", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
//leszedem a BNAME-et mert igy kapom meg az oszlopnevet amivel joinolok
|
||||
string BNAME = Regex.Replace(strOrderByCol, "_BNAME", "", RegexOptions.IgnoreCase);
|
||||
|
||||
if (strOrderByType.Equals(" ASC", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
strTempOrderBy = BNAME + " asc";
|
||||
}
|
||||
else
|
||||
{
|
||||
strTempOrderBy = BNAME + " desc";
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(strOrderByCol))
|
||||
{
|
||||
if (command.CommandText.Contains($"[\"]{strOrderByCol}[\"]"))
|
||||
{
|
||||
strTempOrderBy = string.Format(orderFormatString, $"\"{strOrderByCol}\"", strOrderByType);
|
||||
}
|
||||
else
|
||||
{
|
||||
strTempOrderBy = string.Format(orderFormatString, strOrderByCol, strOrderByType);
|
||||
}
|
||||
}
|
||||
//Ha nincs benne a lekérdezésben az az oszlop, amire rendezünk, akkor nem lehet
|
||||
//ez alapján rendezni, nem adjuk hozzá
|
||||
else
|
||||
{
|
||||
strTempOrderBy = "";
|
||||
}
|
||||
|
||||
//Végső, teljes OrderBy rész összerakása
|
||||
if (!string.IsNullOrWhiteSpace(strTempOrderBy))
|
||||
orderBy += strTempOrderBy + ",";
|
||||
}
|
||||
|
||||
orderBy = orderBy.TrimEnd(',');
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetRowCount(SDACommand command)
|
||||
{
|
||||
string oldCommandText = command.CommandText;
|
||||
|
||||
//utolso where helye
|
||||
int lastIndexOfWhere = command.CommandText.LastIndexOf("where", StringComparison.OrdinalIgnoreCase) == -1 ? 0 : command.CommandText.LastIndexOf("where", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
//utolso orderby helye
|
||||
int lastIndexOfOrderBy = command.CommandText.LastIndexOf("order by", StringComparison.OrdinalIgnoreCase) == -1 ? command.CommandText.Length : command.CommandText.LastIndexOf("order by", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
//levagom az order by-os végét, ha az a legutolsó
|
||||
if (lastIndexOfOrderBy > lastIndexOfWhere)
|
||||
{
|
||||
command.CommandText = command.CommandText.Substring(0, lastIndexOfOrderBy);
|
||||
}
|
||||
|
||||
command.CommandText = "SELECT COUNT(1) FROM ( " + command.CommandText + " ) ttt";
|
||||
|
||||
int rowsCount = Convert.ToInt32(command.ExecuteScalar());
|
||||
|
||||
command.CommandText = oldCommandText;
|
||||
|
||||
return rowsCount;
|
||||
}
|
||||
|
||||
public static string ToString(object data)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ((data == null) || (data == DBNull.Value))
|
||||
? string.Empty
|
||||
: data.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public static int ToInt32(object data)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ((data == null) || (data == DBNull.Value) || string.IsNullOrWhiteSpace(data.ToString()))
|
||||
? 0
|
||||
: Convert.ToInt32(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class AlphanumComparator<T> : IComparer<T>
|
||||
{
|
||||
private enum ChunkType { Alphanumeric, Numeric };
|
||||
private bool InChunk(char ch, char otherCh)
|
||||
{
|
||||
ChunkType type = ChunkType.Alphanumeric;
|
||||
|
||||
if (char.IsDigit(otherCh))
|
||||
{
|
||||
type = ChunkType.Numeric;
|
||||
}
|
||||
|
||||
if ((type == ChunkType.Alphanumeric && char.IsDigit(ch))
|
||||
|| (type == ChunkType.Numeric && !char.IsDigit(ch)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public int Compare(T x, T y)
|
||||
{
|
||||
if (!(x is string s1) || !(y is string s2) || (s1 == null) || (s2 == null))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int thisMarker = 0;
|
||||
int thatMarker = 0;
|
||||
|
||||
while ((thisMarker < s1.Length) || (thatMarker < s2.Length))
|
||||
{
|
||||
if (thisMarker >= s1.Length)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (thatMarker >= s2.Length)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
char thisCh = s1[thisMarker];
|
||||
char thatCh = s2[thatMarker];
|
||||
|
||||
var thisChunk = new StringBuilder();
|
||||
var thatChunk = new StringBuilder();
|
||||
|
||||
while ((thisMarker < s1.Length) && (thisChunk.Length == 0 || InChunk(thisCh, thisChunk[0])))
|
||||
{
|
||||
thisChunk.Append(thisCh);
|
||||
thisMarker++;
|
||||
|
||||
if (thisMarker < s1.Length)
|
||||
{
|
||||
thisCh = s1[thisMarker];
|
||||
}
|
||||
}
|
||||
|
||||
while ((thatMarker < s2.Length) && (thatChunk.Length == 0 || InChunk(thatCh, thatChunk[0])))
|
||||
{
|
||||
thatChunk.Append(thatCh);
|
||||
thatMarker++;
|
||||
|
||||
if (thatMarker < s2.Length)
|
||||
{
|
||||
thatCh = s2[thatMarker];
|
||||
}
|
||||
}
|
||||
|
||||
int result = 0;
|
||||
// If both chunks contain numeric characters, sort them numerically
|
||||
if (char.IsDigit(thisChunk[0]) && char.IsDigit(thatChunk[0]))
|
||||
{
|
||||
if (int.TryParse(thisChunk.ToString(), out int thisIntNumericChunk) && int.TryParse(thatChunk.ToString(), out int thatIntNumericChunk))
|
||||
{
|
||||
if (thisIntNumericChunk < thatIntNumericChunk)
|
||||
{
|
||||
result = -1;
|
||||
}
|
||||
|
||||
if (thisIntNumericChunk > thatIntNumericChunk)
|
||||
{
|
||||
result = 1;
|
||||
}
|
||||
}
|
||||
else if (long.TryParse(thisChunk.ToString(), out long thisLongNumericChunk) && long.TryParse(thatChunk.ToString(), out long thatLongNumericChunk))
|
||||
{
|
||||
if (thisLongNumericChunk < thatLongNumericChunk)
|
||||
{
|
||||
result = -1;
|
||||
}
|
||||
|
||||
if (thisLongNumericChunk > thatLongNumericChunk)
|
||||
{
|
||||
result = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = thisChunk.ToString().CompareTo(thatChunk.ToString());
|
||||
}
|
||||
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue