2017-12-31 02:21:33 +01:00
|
|
|
#include "resourcemanager.hpp"
|
2017-06-15 23:13:01 +02:00
|
|
|
#include "util/urlfetch.hpp"
|
2017-01-18 04:52:47 +01:00
|
|
|
|
2018-05-08 15:12:04 +02:00
|
|
|
#include <QIcon>
|
2017-01-18 04:52:47 +01:00
|
|
|
#include <QPixmap>
|
2017-01-13 18:59:11 +01:00
|
|
|
|
2017-04-14 17:52:22 +02:00
|
|
|
namespace chatterino {
|
2017-12-31 22:58:35 +01:00
|
|
|
namespace singletons {
|
2017-01-13 18:59:11 +01:00
|
|
|
|
2017-06-13 21:13:58 +02:00
|
|
|
namespace {
|
2017-01-13 18:59:11 +01:00
|
|
|
|
2018-01-11 20:16:25 +01:00
|
|
|
inline messages::Image *lli(const char *pixmapPath, qreal scale = 1)
|
2017-01-13 18:59:11 +01:00
|
|
|
{
|
2018-01-11 20:16:25 +01:00
|
|
|
return new messages::Image(new QPixmap(pixmapPath), scale);
|
2017-01-13 18:59:11 +01:00
|
|
|
}
|
|
|
|
|
2018-01-12 19:37:11 +01:00
|
|
|
template <typename Type>
|
|
|
|
inline bool ReadValue(const rapidjson::Value &object, const char *key, Type &out)
|
|
|
|
{
|
|
|
|
if (!object.HasMember(key)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const auto &value = object[key];
|
|
|
|
|
|
|
|
if (!value.Is<Type>()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
out = value.Get<Type>();
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
template <>
|
|
|
|
inline bool ReadValue<QString>(const rapidjson::Value &object, const char *key, QString &out)
|
|
|
|
{
|
|
|
|
if (!object.HasMember(key)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const auto &value = object[key];
|
|
|
|
|
|
|
|
if (!value.IsString()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
out = value.GetString();
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
template <>
|
|
|
|
inline bool ReadValue<std::vector<QString>>(const rapidjson::Value &object, const char *key,
|
|
|
|
std::vector<QString> &out)
|
|
|
|
{
|
|
|
|
if (!object.HasMember(key)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const auto &value = object[key];
|
|
|
|
|
|
|
|
if (!value.IsArray()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const rapidjson::Value &innerValue : value.GetArray()) {
|
|
|
|
if (!innerValue.IsString()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
out.emplace_back(innerValue.GetString());
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parse a single cheermote set (or "action") from the twitch api
|
|
|
|
inline bool ParseSingleCheermoteSet(ResourceManager::JSONCheermoteSet &set,
|
|
|
|
const rapidjson::Value &action)
|
|
|
|
{
|
|
|
|
if (!action.IsObject()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(action, "prefix", set.prefix)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(action, "scales", set.scales)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(action, "backgrounds", set.backgrounds)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(action, "states", set.states)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(action, "type", set.type)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(action, "updated_at", set.updatedAt)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(action, "priority", set.priority)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Tiers
|
|
|
|
if (!action.HasMember("tiers")) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const auto &tiersValue = action["tiers"];
|
|
|
|
|
|
|
|
if (!tiersValue.IsArray()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const rapidjson::Value &tierValue : tiersValue.GetArray()) {
|
|
|
|
ResourceManager::JSONCheermoteSet::CheermoteTier tier;
|
|
|
|
|
|
|
|
if (!tierValue.IsObject()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(tierValue, "min_bits", tier.minBits)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(tierValue, "id", tier.id)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ReadValue(tierValue, "color", tier.color)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Images
|
|
|
|
if (!tierValue.HasMember("images")) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const auto &imagesValue = tierValue["images"];
|
|
|
|
|
|
|
|
if (!imagesValue.IsObject()) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Read images object
|
|
|
|
for (const auto &imageBackgroundValue : imagesValue.GetObject()) {
|
|
|
|
QString background = imageBackgroundValue.name.GetString();
|
|
|
|
bool backgroundExists = false;
|
|
|
|
for (const auto &bg : set.backgrounds) {
|
|
|
|
if (background == bg) {
|
|
|
|
backgroundExists = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!backgroundExists) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const rapidjson::Value &imageBackgroundStates = imageBackgroundValue.value;
|
|
|
|
if (!imageBackgroundStates.IsObject()) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Read each key which represents a background
|
|
|
|
for (const auto &imageBackgroundState : imageBackgroundStates.GetObject()) {
|
|
|
|
QString state = imageBackgroundState.name.GetString();
|
|
|
|
bool stateExists = false;
|
|
|
|
for (const auto &_state : set.states) {
|
|
|
|
if (state == _state) {
|
|
|
|
stateExists = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!stateExists) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const rapidjson::Value &imageScalesValue = imageBackgroundState.value;
|
|
|
|
if (!imageScalesValue.IsObject()) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Read each key which represents a scale
|
|
|
|
for (const auto &imageScaleValue : imageScalesValue.GetObject()) {
|
|
|
|
QString scale = imageScaleValue.name.GetString();
|
|
|
|
bool scaleExists = false;
|
|
|
|
for (const auto &_scale : set.scales) {
|
|
|
|
if (scale == _scale) {
|
|
|
|
scaleExists = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!scaleExists) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const rapidjson::Value &imageScaleURLValue = imageScaleValue.value;
|
|
|
|
if (!imageScaleURLValue.IsString()) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
QString url = imageScaleURLValue.GetString();
|
|
|
|
|
|
|
|
bool ok = false;
|
|
|
|
qreal scaleNumber = scale.toFloat(&ok);
|
|
|
|
if (!ok) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
qreal chatterinoScale = 1 / scaleNumber;
|
|
|
|
|
|
|
|
auto image = new messages::Image(url, chatterinoScale);
|
|
|
|
|
|
|
|
// TODO(pajlada): Fill in name and tooltip
|
|
|
|
tier.images[background][state][scale] = image;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
set.tiers.emplace_back(tier);
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Look through the results of https://api.twitch.tv/kraken/bits/actions?channel_id=11148817 for
|
|
|
|
// cheermote sets or "Actions" as they are called in the API
|
|
|
|
inline void ParseCheermoteSets(std::vector<ResourceManager::JSONCheermoteSet> &sets,
|
|
|
|
const rapidjson::Document &d)
|
|
|
|
{
|
|
|
|
if (!d.IsObject()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!d.HasMember("actions")) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const auto &actionsValue = d["actions"];
|
|
|
|
|
|
|
|
if (!actionsValue.IsArray()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const auto &action : actionsValue.GetArray()) {
|
|
|
|
ResourceManager::JSONCheermoteSet set;
|
|
|
|
bool res = ParseSingleCheermoteSet(set, action);
|
|
|
|
|
|
|
|
if (res) {
|
|
|
|
sets.emplace_back(set);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-06-13 21:13:58 +02:00
|
|
|
} // namespace
|
2017-12-31 02:21:33 +01:00
|
|
|
ResourceManager::ResourceManager()
|
2017-12-17 02:18:13 +01:00
|
|
|
: badgeStaff(lli(":/images/staff_bg.png"))
|
|
|
|
, badgeAdmin(lli(":/images/admin_bg.png"))
|
|
|
|
, badgeGlobalModerator(lli(":/images/globalmod_bg.png"))
|
|
|
|
, badgeModerator(lli(":/images/moderator_bg.png"))
|
|
|
|
, badgeTurbo(lli(":/images/turbo_bg.png"))
|
|
|
|
, badgeBroadcaster(lli(":/images/broadcaster_bg.png"))
|
|
|
|
, badgePremium(lli(":/images/twitchprime_bg.png"))
|
|
|
|
, badgeVerified(lli(":/images/verified.png", 0.25))
|
|
|
|
, badgeSubscriber(lli(":/images/subscriber.png", 0.25))
|
2017-12-19 00:09:38 +01:00
|
|
|
, badgeCollapsed(lli(":/images/collapse.png"))
|
2017-12-17 02:18:13 +01:00
|
|
|
, cheerBadge100000(lli(":/images/cheer100000"))
|
|
|
|
, cheerBadge10000(lli(":/images/cheer10000"))
|
|
|
|
, cheerBadge5000(lli(":/images/cheer5000"))
|
|
|
|
, cheerBadge1000(lli(":/images/cheer1000"))
|
|
|
|
, cheerBadge100(lli(":/images/cheer100"))
|
|
|
|
, cheerBadge1(lli(":/images/cheer1"))
|
2018-01-17 16:52:51 +01:00
|
|
|
, moderationmode_enabled(lli(":/images/moderatormode_enabled"))
|
|
|
|
, moderationmode_disabled(lli(":/images/moderatormode_disabled"))
|
|
|
|
, splitHeaderContext(lli(":/images/tool_moreCollapser_off16.png"))
|
2018-04-14 21:59:51 +02:00
|
|
|
, buttonBan(lli(":/images/button_ban.png", 0.25))
|
|
|
|
, buttonTimeout(lli(":/images/button_timeout.png", 0.25))
|
2017-01-13 18:59:11 +01:00
|
|
|
{
|
2018-05-08 15:12:04 +02:00
|
|
|
this->split.left = QIcon(":/images/split/splitleft.png");
|
|
|
|
this->split.right = QIcon(":/images/split/splitright.png");
|
|
|
|
this->split.up = QIcon(":/images/split/splitup.png");
|
|
|
|
this->split.down = QIcon(":/images/split/splitdown.png");
|
|
|
|
this->split.move = QIcon(":/images/split/splitmove.png");
|
2018-06-06 10:46:23 +02:00
|
|
|
|
|
|
|
this->buttons.ban = QPixmap(":/images/buttons/ban.png");
|
|
|
|
this->buttons.unban = QPixmap(":/images/buttons/unban.png");
|
|
|
|
this->buttons.mod = QPixmap(":/images/buttons/mod.png");
|
|
|
|
this->buttons.unmod = QPixmap(":/images/buttons/unmod.png");
|
|
|
|
|
2018-04-26 18:10:26 +02:00
|
|
|
qDebug() << "init ResourceManager";
|
2018-04-28 15:20:18 +02:00
|
|
|
}
|
2018-04-26 18:10:26 +02:00
|
|
|
|
2018-04-28 15:20:18 +02:00
|
|
|
void ResourceManager::initialize()
|
|
|
|
{
|
2017-10-27 21:22:06 +02:00
|
|
|
this->loadDynamicTwitchBadges();
|
2017-08-12 13:20:52 +02:00
|
|
|
|
|
|
|
this->loadChatterinoBadges();
|
2017-06-15 23:13:01 +02:00
|
|
|
}
|
|
|
|
|
2017-12-31 02:21:33 +01:00
|
|
|
ResourceManager::BadgeVersion::BadgeVersion(QJsonObject &&root)
|
2018-01-11 20:16:25 +01:00
|
|
|
: badgeImage1x(new messages::Image(root.value("image_url_1x").toString()))
|
|
|
|
, badgeImage2x(new messages::Image(root.value("image_url_2x").toString()))
|
|
|
|
, badgeImage4x(new messages::Image(root.value("image_url_4x").toString()))
|
2017-06-16 08:03:13 +02:00
|
|
|
, description(root.value("description").toString().toStdString())
|
|
|
|
, title(root.value("title").toString().toStdString())
|
|
|
|
, clickAction(root.value("clickAction").toString().toStdString())
|
|
|
|
, clickURL(root.value("clickURL").toString().toStdString())
|
2017-06-15 23:13:01 +02:00
|
|
|
{
|
|
|
|
}
|
|
|
|
|
2017-12-31 02:21:33 +01:00
|
|
|
void ResourceManager::loadChannelData(const QString &roomID, bool bypassCache)
|
2017-06-15 23:13:01 +02:00
|
|
|
{
|
2017-11-04 14:57:29 +01:00
|
|
|
QString url = "https://badges.twitch.tv/v1/badges/channels/" + roomID + "/display?language=en";
|
2017-06-07 10:09:24 +02:00
|
|
|
|
2017-10-27 21:22:06 +02:00
|
|
|
util::NetworkRequest req(url);
|
|
|
|
req.setCaller(QThread::currentThread());
|
|
|
|
|
|
|
|
req.getJSON([this, roomID](QJsonObject &root) {
|
2017-06-17 11:37:13 +02:00
|
|
|
QJsonObject sets = root.value("badge_sets").toObject();
|
|
|
|
|
2017-12-31 02:21:33 +01:00
|
|
|
ResourceManager::Channel &ch = this->channels[roomID];
|
2017-07-02 15:11:33 +02:00
|
|
|
|
2017-06-17 11:37:13 +02:00
|
|
|
for (QJsonObject::iterator it = sets.begin(); it != sets.end(); ++it) {
|
|
|
|
QJsonObject versions = it.value().toObject().value("versions").toObject();
|
|
|
|
|
2017-07-02 15:11:33 +02:00
|
|
|
auto &badgeSet = ch.badgeSets[it.key().toStdString()];
|
2017-06-17 11:37:13 +02:00
|
|
|
auto &versionsMap = badgeSet.versions;
|
|
|
|
|
|
|
|
for (auto versionIt = std::begin(versions); versionIt != std::end(versions);
|
|
|
|
++versionIt) {
|
|
|
|
std::string kkey = versionIt.key().toStdString();
|
|
|
|
QJsonObject versionObj = versionIt.value().toObject();
|
2017-12-17 02:18:13 +01:00
|
|
|
BadgeVersion v(std::move(versionObj));
|
2017-06-17 11:37:13 +02:00
|
|
|
versionsMap.emplace(kkey, v);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-07-02 15:11:33 +02:00
|
|
|
ch.loaded = true;
|
2017-06-17 11:37:13 +02:00
|
|
|
});
|
2018-01-12 19:37:11 +01:00
|
|
|
|
|
|
|
QString cheermoteURL = "https://api.twitch.tv/kraken/bits/actions?channel_id=" + roomID;
|
|
|
|
|
|
|
|
util::twitch::get2(
|
2018-01-23 21:40:51 +01:00
|
|
|
cheermoteURL, QThread::currentThread(), true, [this, roomID](const rapidjson::Document &d) {
|
2018-01-12 19:37:11 +01:00
|
|
|
ResourceManager::Channel &ch = this->channels[roomID];
|
|
|
|
|
|
|
|
ParseCheermoteSets(ch.jsonCheermoteSets, d);
|
|
|
|
|
|
|
|
for (auto &set : ch.jsonCheermoteSets) {
|
|
|
|
CheermoteSet cheermoteSet;
|
|
|
|
cheermoteSet.regex =
|
|
|
|
QRegularExpression("^" + set.prefix.toLower() + "([1-9][0-9]*)$");
|
|
|
|
|
|
|
|
for (auto &tier : set.tiers) {
|
|
|
|
Cheermote cheermote;
|
|
|
|
|
|
|
|
cheermote.color = QColor(tier.color);
|
|
|
|
cheermote.minBits = tier.minBits;
|
|
|
|
|
|
|
|
// TODO(pajlada): We currently hardcode dark here :|
|
|
|
|
// We will continue to do so for now since we haven't had to
|
|
|
|
// solve that anywhere else
|
|
|
|
cheermote.emoteDataAnimated.image1x = tier.images["dark"]["animated"]["1"];
|
|
|
|
cheermote.emoteDataAnimated.image2x = tier.images["dark"]["animated"]["2"];
|
|
|
|
cheermote.emoteDataAnimated.image3x = tier.images["dark"]["animated"]["4"];
|
|
|
|
|
|
|
|
cheermote.emoteDataStatic.image1x = tier.images["dark"]["static"]["1"];
|
|
|
|
cheermote.emoteDataStatic.image2x = tier.images["dark"]["static"]["2"];
|
|
|
|
cheermote.emoteDataStatic.image3x = tier.images["dark"]["static"]["4"];
|
|
|
|
|
|
|
|
cheermoteSet.cheermotes.emplace_back(cheermote);
|
|
|
|
}
|
|
|
|
|
|
|
|
std::sort(cheermoteSet.cheermotes.begin(), cheermoteSet.cheermotes.end(),
|
|
|
|
[](const auto &lhs, const auto &rhs) {
|
|
|
|
return lhs.minBits < rhs.minBits; //
|
|
|
|
});
|
|
|
|
|
|
|
|
ch.cheermoteSets.emplace_back(cheermoteSet);
|
|
|
|
}
|
|
|
|
});
|
2017-06-16 10:01:21 +02:00
|
|
|
}
|
|
|
|
|
2017-12-31 02:21:33 +01:00
|
|
|
void ResourceManager::loadDynamicTwitchBadges()
|
2017-10-27 21:22:06 +02:00
|
|
|
{
|
|
|
|
static QString url("https://badges.twitch.tv/v1/badges/global/display?language=en");
|
|
|
|
|
|
|
|
util::NetworkRequest req(url);
|
|
|
|
req.setCaller(QThread::currentThread());
|
|
|
|
req.getJSON([this](QJsonObject &root) {
|
|
|
|
QJsonObject sets = root.value("badge_sets").toObject();
|
|
|
|
for (QJsonObject::iterator it = sets.begin(); it != sets.end(); ++it) {
|
|
|
|
QJsonObject versions = it.value().toObject().value("versions").toObject();
|
|
|
|
|
|
|
|
auto &badgeSet = this->badgeSets[it.key().toStdString()];
|
|
|
|
auto &versionsMap = badgeSet.versions;
|
|
|
|
|
|
|
|
for (auto versionIt = std::begin(versions); versionIt != std::end(versions);
|
|
|
|
++versionIt) {
|
|
|
|
std::string kkey = versionIt.key().toStdString();
|
|
|
|
QJsonObject versionObj = versionIt.value().toObject();
|
2017-12-17 02:18:13 +01:00
|
|
|
BadgeVersion v(std::move(versionObj));
|
2017-10-27 21:22:06 +02:00
|
|
|
versionsMap.emplace(kkey, v);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this->dynamicBadgesLoaded = true;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2017-12-31 02:21:33 +01:00
|
|
|
void ResourceManager::loadChatterinoBadges()
|
2017-08-12 13:20:52 +02:00
|
|
|
{
|
|
|
|
this->chatterinoBadges.clear();
|
|
|
|
|
2017-10-27 21:22:06 +02:00
|
|
|
static QString url("https://fourtf.com/chatterino/badges.json");
|
|
|
|
|
|
|
|
util::NetworkRequest req(url);
|
|
|
|
req.setCaller(QThread::currentThread());
|
2017-08-12 13:20:52 +02:00
|
|
|
|
2017-10-27 21:22:06 +02:00
|
|
|
req.getJSON([this](QJsonObject &root) {
|
2017-08-12 13:20:52 +02:00
|
|
|
QJsonArray badgeVariants = root.value("badges").toArray();
|
|
|
|
for (QJsonArray::iterator it = badgeVariants.begin(); it != badgeVariants.end(); ++it) {
|
|
|
|
QJsonObject badgeVariant = it->toObject();
|
|
|
|
const std::string badgeVariantTooltip =
|
|
|
|
badgeVariant.value("tooltip").toString().toStdString();
|
|
|
|
const QString &badgeVariantImageURL = badgeVariant.value("image").toString();
|
|
|
|
|
|
|
|
auto badgeVariantPtr = std::make_shared<ChatterinoBadge>(
|
2018-01-11 20:16:25 +01:00
|
|
|
badgeVariantTooltip, new messages::Image(badgeVariantImageURL));
|
2017-08-12 13:20:52 +02:00
|
|
|
|
|
|
|
QJsonArray badgeVariantUsers = badgeVariant.value("users").toArray();
|
|
|
|
|
|
|
|
for (QJsonArray::iterator it = badgeVariantUsers.begin(); it != badgeVariantUsers.end();
|
|
|
|
++it) {
|
|
|
|
const std::string username = it->toString().toStdString();
|
|
|
|
this->chatterinoBadges[username] =
|
|
|
|
std::shared_ptr<ChatterinoBadge>(badgeVariantPtr);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-01-12 19:37:11 +01:00
|
|
|
} // namespace singletons
|
2017-06-07 10:09:24 +02:00
|
|
|
} // namespace chatterino
|