d390214929
Fixes #2228.
683 lines
17 KiB
C++
683 lines
17 KiB
C++
|
|
// CompositeChat.cpp
|
|
|
|
// Implements the cCompositeChat class used to wrap a chat message with multiple parts (text, url, cmd)
|
|
|
|
#include "Globals.h"
|
|
#include "CompositeChat.h"
|
|
#include "ClientHandle.h"
|
|
#include "SelfTests.h"
|
|
|
|
|
|
|
|
|
|
|
|
#ifdef SELF_TEST
|
|
|
|
/** A simple self-test that verifies that the composite chat parser is working properly. */
|
|
class SelfTest_CompositeChat
|
|
{
|
|
public:
|
|
SelfTest_CompositeChat(void)
|
|
{
|
|
cSelfTests::Get().Register(cSelfTests::SelfTestFunction(&TestParser1), "CompositeChat parser test 1");
|
|
cSelfTests::Get().Register(cSelfTests::SelfTestFunction(&TestParser2), "CompositeChat parser test 2");
|
|
cSelfTests::Get().Register(cSelfTests::SelfTestFunction(&TestParser3), "CompositeChat parser test 3");
|
|
cSelfTests::Get().Register(cSelfTests::SelfTestFunction(&TestParser4), "CompositeChat parser test 4");
|
|
cSelfTests::Get().Register(cSelfTests::SelfTestFunction(&TestParser5), "CompositeChat parser test 5");
|
|
}
|
|
|
|
static void TestParser1(void)
|
|
{
|
|
cCompositeChat Msg;
|
|
Msg.ParseText("Testing @2color codes and http://links parser");
|
|
const cCompositeChat::cParts & Parts = Msg.GetParts();
|
|
assert_test(Parts.size() == 4);
|
|
assert_test(Parts[0]->m_PartType == cCompositeChat::ptText);
|
|
assert_test(Parts[1]->m_PartType == cCompositeChat::ptText);
|
|
assert_test(Parts[2]->m_PartType == cCompositeChat::ptUrl);
|
|
assert_test(Parts[3]->m_PartType == cCompositeChat::ptText);
|
|
assert_test(Parts[0]->m_Style == "");
|
|
assert_test(Parts[1]->m_Style == "@2");
|
|
assert_test(Parts[2]->m_Style == "@2");
|
|
assert_test(Parts[3]->m_Style == "@2");
|
|
}
|
|
|
|
static void TestParser2(void)
|
|
{
|
|
cCompositeChat Msg;
|
|
Msg.ParseText("@3Advanced stuff: @5overriding color codes and http://links.with/@4color-in-them handling");
|
|
const cCompositeChat::cParts & Parts = Msg.GetParts();
|
|
assert_test(Parts.size() == 4);
|
|
assert_test(Parts[0]->m_PartType == cCompositeChat::ptText);
|
|
assert_test(Parts[1]->m_PartType == cCompositeChat::ptText);
|
|
assert_test(Parts[2]->m_PartType == cCompositeChat::ptUrl);
|
|
assert_test(Parts[3]->m_PartType == cCompositeChat::ptText);
|
|
assert_test(Parts[0]->m_Style == "@3");
|
|
assert_test(Parts[1]->m_Style == "@5");
|
|
assert_test(Parts[2]->m_Style == "@5");
|
|
assert_test(Parts[3]->m_Style == "@5");
|
|
}
|
|
|
|
static void TestParser3(void)
|
|
{
|
|
cCompositeChat Msg;
|
|
Msg.ParseText("http://links.starting the text");
|
|
const cCompositeChat::cParts & Parts = Msg.GetParts();
|
|
assert_test(Parts.size() == 2);
|
|
assert_test(Parts[0]->m_PartType == cCompositeChat::ptUrl);
|
|
assert_test(Parts[1]->m_PartType == cCompositeChat::ptText);
|
|
assert_test(Parts[0]->m_Style == "");
|
|
assert_test(Parts[1]->m_Style == "");
|
|
}
|
|
|
|
static void TestParser4(void)
|
|
{
|
|
cCompositeChat Msg;
|
|
Msg.ParseText("links finishing the text: http://some.server");
|
|
const cCompositeChat::cParts & Parts = Msg.GetParts();
|
|
assert_test(Parts.size() == 2);
|
|
assert_test(Parts[0]->m_PartType == cCompositeChat::ptText);
|
|
assert_test(Parts[1]->m_PartType == cCompositeChat::ptUrl);
|
|
assert_test(Parts[0]->m_Style == "");
|
|
assert_test(Parts[1]->m_Style == "");
|
|
}
|
|
|
|
static void TestParser5(void)
|
|
{
|
|
cCompositeChat Msg;
|
|
Msg.ParseText("http://only.links");
|
|
const cCompositeChat::cParts & Parts = Msg.GetParts();
|
|
assert_test(Parts.size() == 1);
|
|
assert_test(Parts[0]->m_PartType == cCompositeChat::ptUrl);
|
|
assert_test(Parts[0]->m_Style == "");
|
|
}
|
|
|
|
} gTest;
|
|
#endif // SELF_TEST
|
|
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat:
|
|
|
|
cCompositeChat::cCompositeChat(void) :
|
|
m_MessageType(mtCustom)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cCompositeChat::cCompositeChat(const AString & a_ParseText, eMessageType a_MessageType) :
|
|
m_MessageType(a_MessageType)
|
|
{
|
|
ParseText(a_ParseText);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cCompositeChat::~cCompositeChat()
|
|
{
|
|
Clear();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::Clear(void)
|
|
{
|
|
for (cParts::iterator itr = m_Parts.begin(), end = m_Parts.end(); itr != end; ++itr)
|
|
{
|
|
delete *itr;
|
|
} // for itr - m_Parts[]
|
|
m_Parts.clear();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::AddTextPart(const AString & a_Message, const AString & a_Style)
|
|
{
|
|
m_Parts.push_back(new cTextPart(a_Message, a_Style));
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::AddClientTranslatedPart(const AString & a_TranslationID, const AStringVector & a_Parameters, const AString & a_Style)
|
|
{
|
|
m_Parts.push_back(new cClientTranslatedPart(a_TranslationID, a_Parameters, a_Style));
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::AddUrlPart(const AString & a_Text, const AString & a_Url, const AString & a_Style)
|
|
{
|
|
m_Parts.push_back(new cUrlPart(a_Text, a_Url, a_Style));
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::AddRunCommandPart(const AString & a_Text, const AString & a_Command, const AString & a_Style)
|
|
{
|
|
m_Parts.push_back(new cRunCommandPart(a_Text, a_Command, a_Style));
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::AddSuggestCommandPart(const AString & a_Text, const AString & a_SuggestedCommand, const AString & a_Style)
|
|
{
|
|
m_Parts.push_back(new cSuggestCommandPart(a_Text, a_SuggestedCommand, a_Style));
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::AddShowAchievementPart(const AString & a_PlayerName, const AString & a_Achievement, const AString & a_Style)
|
|
{
|
|
m_Parts.push_back(new cShowAchievementPart(a_PlayerName, a_Achievement, a_Style));
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::ParseText(const AString & a_ParseText)
|
|
{
|
|
size_t len = a_ParseText.length();
|
|
size_t first = 0; // First character of the currently parsed block
|
|
AString CurrentStyle;
|
|
AString CurrentText;
|
|
for (size_t i = 0; i < len; i++)
|
|
{
|
|
switch (a_ParseText[i])
|
|
{
|
|
case '@':
|
|
{
|
|
// Color code
|
|
i++;
|
|
if (i >= len)
|
|
{
|
|
// Not enough following text
|
|
break;
|
|
}
|
|
if (a_ParseText[i] == '@')
|
|
{
|
|
// "@@" escape, just put a "@" into the current text and keep parsing as text
|
|
if (i > first + 1)
|
|
{
|
|
CurrentText.append(a_ParseText.c_str() + first, i - first - 1);
|
|
}
|
|
first = i + 1;
|
|
continue;
|
|
}
|
|
else
|
|
{
|
|
// True color code. Create a part for the CurrentText and start parsing anew:
|
|
if (i >= first)
|
|
{
|
|
CurrentText.append(a_ParseText.c_str() + first, i - first - 1);
|
|
first = i + 1;
|
|
}
|
|
if (!CurrentText.empty())
|
|
{
|
|
m_Parts.push_back(new cTextPart(CurrentText, CurrentStyle));
|
|
CurrentText.clear();
|
|
}
|
|
AddStyle(CurrentStyle, a_ParseText.substr(i - 1, 2));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case ':':
|
|
{
|
|
const char * LinkPrefixes[] =
|
|
{
|
|
"http",
|
|
"https"
|
|
};
|
|
for (size_t Prefix = 0; Prefix < ARRAYCOUNT(LinkPrefixes); Prefix++)
|
|
{
|
|
size_t PrefixLen = strlen(LinkPrefixes[Prefix]);
|
|
if (
|
|
(i >= first + PrefixLen) && // There is enough space in front of the colon for the prefix
|
|
(strncmp(a_ParseText.c_str() + i - PrefixLen, LinkPrefixes[Prefix], PrefixLen) == 0) // the prefix matches
|
|
)
|
|
{
|
|
// Add everything before this as a text part:
|
|
if (i > first + PrefixLen)
|
|
{
|
|
CurrentText.append(a_ParseText.c_str() + first, i - first - PrefixLen);
|
|
first = i - PrefixLen;
|
|
}
|
|
if (!CurrentText.empty())
|
|
{
|
|
AddTextPart(CurrentText, CurrentStyle);
|
|
CurrentText.clear();
|
|
}
|
|
|
|
// Go till the last non-whitespace char in the text:
|
|
for (; i < len; i++)
|
|
{
|
|
if (isspace(a_ParseText[i]))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
AddUrlPart(a_ParseText.substr(first, i - first), a_ParseText.substr(first, i - first), CurrentStyle);
|
|
first = i;
|
|
break;
|
|
}
|
|
} // for Prefix - LinkPrefix[]
|
|
break;
|
|
} // case ':'
|
|
} // switch (a_ParseText[i])
|
|
} // for i - a_ParseText[]
|
|
if (first < len)
|
|
{
|
|
AddTextPart(a_ParseText.substr(first, len - first), CurrentStyle);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::SetMessageType(eMessageType a_MessageType, const AString & a_AdditionalMessageTypeData)
|
|
{
|
|
m_MessageType = a_MessageType;
|
|
m_AdditionalMessageTypeData = a_AdditionalMessageTypeData;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::UnderlineUrls(void)
|
|
{
|
|
for (cParts::iterator itr = m_Parts.begin(), end = m_Parts.end(); itr != end; ++itr)
|
|
{
|
|
if ((*itr)->m_PartType == ptUrl)
|
|
{
|
|
(*itr)->m_Style.append("u");
|
|
}
|
|
} // for itr - m_Parts[]
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AString cCompositeChat::ExtractText(void) const
|
|
{
|
|
AString Msg;
|
|
for (cParts::const_iterator itr = m_Parts.begin(), end = m_Parts.end(); itr != end; ++itr)
|
|
{
|
|
switch ((*itr)->m_PartType)
|
|
{
|
|
case ptText:
|
|
case ptClientTranslated:
|
|
case ptRunCommand:
|
|
case ptSuggestCommand:
|
|
{
|
|
Msg.append((*itr)->m_Text);
|
|
break;
|
|
}
|
|
case ptUrl:
|
|
{
|
|
Msg.append((static_cast<cUrlPart *>(*itr))->m_Url);
|
|
break;
|
|
}
|
|
case ptShowAchievement:
|
|
{
|
|
break;
|
|
}
|
|
} // switch (PartType)
|
|
} // for itr - m_Parts[]
|
|
return Msg;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cLogger::eLogLevel cCompositeChat::MessageTypeToLogLevel(eMessageType a_MessageType)
|
|
{
|
|
switch (a_MessageType)
|
|
{
|
|
case mtCustom: return cLogger::llRegular;
|
|
case mtFailure: return cLogger::llWarning;
|
|
case mtInformation: return cLogger::llInfo;
|
|
case mtSuccess: return cLogger::llRegular;
|
|
case mtWarning: return cLogger::llWarning;
|
|
case mtFatal: return cLogger::llError;
|
|
case mtDeath: return cLogger::llRegular;
|
|
case mtPrivateMessage: return cLogger::llRegular;
|
|
case mtJoin: return cLogger::llRegular;
|
|
case mtLeave: return cLogger::llRegular;
|
|
}
|
|
ASSERT(!"Unhandled MessageType");
|
|
return cLogger::llError;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::AddStyle(AString & a_Style, const AString & a_AddStyle)
|
|
{
|
|
if (a_AddStyle.empty())
|
|
{
|
|
return;
|
|
}
|
|
if (a_AddStyle[0] == '@')
|
|
{
|
|
size_t idx = a_Style.find('@');
|
|
if ((idx != AString::npos) && (idx != a_Style.length()))
|
|
{
|
|
a_Style.erase(idx, 2);
|
|
}
|
|
a_Style.append(a_AddStyle);
|
|
return;
|
|
}
|
|
a_Style.append(a_AddStyle);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AString cCompositeChat::CreateJsonString(bool a_ShouldUseChatPrefixes) const
|
|
{
|
|
Json::Value msg;
|
|
msg["text"] = cClientHandle::FormatMessageType(a_ShouldUseChatPrefixes, GetMessageType(), GetAdditionalMessageTypeData()); // The client crashes without this field being present
|
|
const cCompositeChat::cParts & Parts = GetParts();
|
|
for (cCompositeChat::cParts::const_iterator itr = Parts.begin(), end = Parts.end(); itr != end; ++itr)
|
|
{
|
|
Json::Value Part;
|
|
switch ((*itr)->m_PartType)
|
|
{
|
|
case cCompositeChat::ptText:
|
|
{
|
|
Part["text"] = (*itr)->m_Text;
|
|
AddChatPartStyle(Part, (*itr)->m_Style);
|
|
break;
|
|
}
|
|
|
|
case cCompositeChat::ptClientTranslated:
|
|
{
|
|
const cCompositeChat::cClientTranslatedPart & p = static_cast<const cCompositeChat::cClientTranslatedPart &>(**itr);
|
|
Part["translate"] = p.m_Text;
|
|
Json::Value With;
|
|
for (AStringVector::const_iterator itrW = p.m_Parameters.begin(), endW = p.m_Parameters.end(); itrW != endW; ++itr)
|
|
{
|
|
With.append(*itrW);
|
|
}
|
|
if (!p.m_Parameters.empty())
|
|
{
|
|
Part["with"] = With;
|
|
}
|
|
AddChatPartStyle(Part, p.m_Style);
|
|
break;
|
|
}
|
|
|
|
case cCompositeChat::ptUrl:
|
|
{
|
|
const cCompositeChat::cUrlPart & p = static_cast<const cCompositeChat::cUrlPart &>(**itr);
|
|
Part["text"] = p.m_Text;
|
|
Json::Value Url;
|
|
Url["action"] = "open_url";
|
|
Url["value"] = p.m_Url;
|
|
Part["clickEvent"] = Url;
|
|
AddChatPartStyle(Part, p.m_Style);
|
|
break;
|
|
}
|
|
|
|
case cCompositeChat::ptSuggestCommand:
|
|
case cCompositeChat::ptRunCommand:
|
|
{
|
|
const cCompositeChat::cCommandPart & p = static_cast<const cCompositeChat::cCommandPart &>(**itr);
|
|
Part["text"] = p.m_Text;
|
|
Json::Value Cmd;
|
|
Cmd["action"] = (p.m_PartType == cCompositeChat::ptRunCommand) ? "run_command" : "suggest_command";
|
|
Cmd["value"] = p.m_Command;
|
|
Part["clickEvent"] = Cmd;
|
|
AddChatPartStyle(Part, p.m_Style);
|
|
break;
|
|
}
|
|
|
|
case cCompositeChat::ptShowAchievement:
|
|
{
|
|
const cCompositeChat::cShowAchievementPart & p = static_cast<const cCompositeChat::cShowAchievementPart &>(**itr);
|
|
Part["translate"] = "chat.type.achievement";
|
|
|
|
Json::Value Ach;
|
|
Ach["action"] = "show_achievement";
|
|
Ach["value"] = p.m_Text;
|
|
|
|
Json::Value AchColourAndName;
|
|
AchColourAndName["color"] = "green";
|
|
AchColourAndName["translate"] = p.m_Text;
|
|
AchColourAndName["hoverEvent"] = Ach;
|
|
|
|
Json::Value Extra;
|
|
Extra.append(AchColourAndName);
|
|
|
|
Json::Value Name;
|
|
Name["text"] = p.m_PlayerName;
|
|
|
|
Json::Value With;
|
|
With.append(Name);
|
|
With.append(Extra);
|
|
|
|
Part["with"] = With;
|
|
AddChatPartStyle(Part, p.m_Style);
|
|
break;
|
|
}
|
|
}
|
|
msg["extra"].append(Part);
|
|
} // for itr - Parts[]
|
|
|
|
return msg.toStyledString();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void cCompositeChat::AddChatPartStyle(Json::Value & a_Value, const AString & a_PartStyle) const
|
|
{
|
|
size_t len = a_PartStyle.length();
|
|
for (size_t i = 0; i < len; i++)
|
|
{
|
|
switch (a_PartStyle[i])
|
|
{
|
|
case 'b':
|
|
{
|
|
// bold
|
|
a_Value["bold"] = Json::Value(true);
|
|
break;
|
|
}
|
|
|
|
case 'i':
|
|
{
|
|
// italic
|
|
a_Value["italic"] = Json::Value(true);
|
|
break;
|
|
}
|
|
|
|
case 'u':
|
|
{
|
|
// Underlined
|
|
a_Value["underlined"] = Json::Value(true);
|
|
break;
|
|
}
|
|
|
|
case 's':
|
|
{
|
|
// strikethrough
|
|
a_Value["strikethrough"] = Json::Value(true);
|
|
break;
|
|
}
|
|
|
|
case 'o':
|
|
{
|
|
// obfuscated
|
|
a_Value["obfuscated"] = Json::Value(true);
|
|
break;
|
|
}
|
|
|
|
case '@':
|
|
{
|
|
// Color, specified by the next char:
|
|
i++;
|
|
if (i >= len)
|
|
{
|
|
// String too short, didn't contain a color
|
|
break;
|
|
}
|
|
switch (a_PartStyle[i])
|
|
{
|
|
case '0': a_Value["color"] = Json::Value("black"); break;
|
|
case '1': a_Value["color"] = Json::Value("dark_blue"); break;
|
|
case '2': a_Value["color"] = Json::Value("dark_green"); break;
|
|
case '3': a_Value["color"] = Json::Value("dark_aqua"); break;
|
|
case '4': a_Value["color"] = Json::Value("dark_red"); break;
|
|
case '5': a_Value["color"] = Json::Value("dark_purple"); break;
|
|
case '6': a_Value["color"] = Json::Value("gold"); break;
|
|
case '7': a_Value["color"] = Json::Value("gray"); break;
|
|
case '8': a_Value["color"] = Json::Value("dark_gray"); break;
|
|
case '9': a_Value["color"] = Json::Value("blue"); break;
|
|
case 'a': a_Value["color"] = Json::Value("green"); break;
|
|
case 'b': a_Value["color"] = Json::Value("aqua"); break;
|
|
case 'c': a_Value["color"] = Json::Value("red"); break;
|
|
case 'd': a_Value["color"] = Json::Value("light_purple"); break;
|
|
case 'e': a_Value["color"] = Json::Value("yellow"); break;
|
|
case 'f': a_Value["color"] = Json::Value("white"); break;
|
|
} // switch (color)
|
|
} // case '@'
|
|
} // switch (Style[i])
|
|
} // for i - a_PartStyle[]
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat::cBasePart:
|
|
|
|
cCompositeChat::cBasePart::cBasePart(cCompositeChat::ePartType a_PartType, const AString & a_Text, const AString & a_Style) :
|
|
m_PartType(a_PartType),
|
|
m_Text(a_Text),
|
|
m_Style(a_Style)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat::cTextPart:
|
|
|
|
cCompositeChat::cTextPart::cTextPart(const AString & a_Text, const AString &a_Style) :
|
|
super(ptText, a_Text, a_Style)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat::cClientTranslatedPart:
|
|
|
|
cCompositeChat::cClientTranslatedPart::cClientTranslatedPart(const AString & a_TranslationID, const AStringVector & a_Parameters, const AString & a_Style) :
|
|
super(ptClientTranslated, a_TranslationID, a_Style),
|
|
m_Parameters(a_Parameters)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat::cUrlPart:
|
|
|
|
cCompositeChat::cUrlPart::cUrlPart(const AString & a_Text, const AString & a_Url, const AString & a_Style) :
|
|
super(ptUrl, a_Text, a_Style),
|
|
m_Url(a_Url)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat::cCommandPart:
|
|
|
|
cCompositeChat::cCommandPart::cCommandPart(ePartType a_PartType, const AString & a_Text, const AString & a_Command, const AString & a_Style) :
|
|
super(a_PartType, a_Text, a_Style),
|
|
m_Command(a_Command)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat::cRunCommandPart:
|
|
|
|
cCompositeChat::cRunCommandPart::cRunCommandPart(const AString & a_Text, const AString & a_Command, const AString & a_Style) :
|
|
super(ptRunCommand, a_Text, a_Command, a_Style)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat::cSuggestCommandPart:
|
|
|
|
cCompositeChat::cSuggestCommandPart::cSuggestCommandPart(const AString & a_Text, const AString & a_Command, const AString & a_Style) :
|
|
super(ptSuggestCommand, a_Text, a_Command, a_Style)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// cCompositeChat::cShowAchievementPart:
|
|
|
|
cCompositeChat::cShowAchievementPart::cShowAchievementPart(const AString & a_PlayerName, const AString & a_Achievement, const AString & a_Style) :
|
|
super(ptShowAchievement, a_Achievement, a_Style),
|
|
m_PlayerName(a_PlayerName)
|
|
{
|
|
}
|
|
|
|
|
|
|
|
|