diff --git a/CHANGELOG.md b/CHANGELOG.md index f72fc502..f272df9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Minor: Made filters searchable in the Settings dialog search bar. (#5890) - Minor: Updated emojis to Unicode 16.0. (#6155) - Minor: Allow disabling of double-click tab renaming through setting. (#6163, #6184) +- Minor: The JSON selector in the upload response can now query arrays using their indices like `foo.0`. (#6193) - Minor: Added hotkey Action for opening account selector. (#6192) - Bugfix: Don't create native messaging manifest file if browser directory doesn't exist. (#6116) - Bugfix: Fixed scrolling now working on inputs in the settings. (#6128) diff --git a/src/common/network/NetworkResult.cpp b/src/common/network/NetworkResult.cpp index c6544eaa..9a996e21 100644 --- a/src/common/network/NetworkResult.cpp +++ b/src/common/network/NetworkResult.cpp @@ -42,6 +42,25 @@ QJsonArray NetworkResult::parseJsonArray() const return jsonDoc.array(); } +QJsonValue NetworkResult::parseJsonValue() const +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + return QJsonValue::fromJson(this->data_); +#else + QJsonDocument jsonDoc(QJsonDocument::fromJson(this->data_)); + if (jsonDoc.isArray()) + { + return jsonDoc.array(); + } + if (jsonDoc.isObject()) + { + return jsonDoc.object(); + } + + return {}; // undefined +#endif +} + rapidjson::Document NetworkResult::parseRapidJson() const { rapidjson::Document ret(rapidjson::kObjectType); diff --git a/src/common/network/NetworkResult.hpp b/src/common/network/NetworkResult.hpp index 9f0ada78..699f97b2 100644 --- a/src/common/network/NetworkResult.hpp +++ b/src/common/network/NetworkResult.hpp @@ -23,6 +23,13 @@ public: /// Parses the result as json and returns the root as an array. /// Returns empty object if parsing failed. QJsonArray parseJsonArray() const; + + /// Parses the result as json and returns the root as a generic value. + /// Returns empty object if parsing failed. + /// + /// In Qt 6.9+, this will parse scalar values as well. + QJsonValue parseJsonValue() const; + /// Parses the result as json and returns the document. rapidjson::Document parseRapidJson() const; const QByteArray &getData() const; diff --git a/src/singletons/ImageUploader.cpp b/src/singletons/ImageUploader.cpp index ca082a72..d89b6a8d 100644 --- a/src/singletons/ImageUploader.cpp +++ b/src/singletons/ImageUploader.cpp @@ -44,8 +44,57 @@ std::optional convertToPng(const QImage &image) } // namespace +namespace chatterino::imageuploader::detail { + +// extracting link to either image or its deletion from response body +QString getJSONValue(QJsonValue responseJson, QStringView jsonPattern) +{ + for (auto key : jsonPattern.tokenize(u'.')) + { + if (responseJson.isObject()) + { + responseJson = responseJson[key]; + } + else if (responseJson.isArray()) + { + bool ok = false; + auto idx = key.toLongLong(&ok); + if (ok) + { + responseJson = responseJson[idx]; + } + } + else + { + // we reached a scalar value, no need to continue + break; + } + } + return responseJson.toString(); +} + +QString getLinkFromResponse(const NetworkResult &response, QString pattern) +{ + QRegularExpression regExp("{(.+)}", + QRegularExpression::InvertedGreedinessOption); + auto match = regExp.match(pattern); + + auto jsonRoot = response.parseJsonValue(); + while (match.hasMatch()) + { + pattern.replace(match.captured(0), + getJSONValue(jsonRoot, match.capturedView(1))); + match = regExp.match(pattern); + } + return pattern; +} + +} // namespace chatterino::imageuploader::detail + namespace chatterino { +using namespace imageuploader::detail; + // logging information on successful uploads to a json file void ImageUploader::logToFile(const QString &originalFilePath, const QString &imageLink, @@ -97,31 +146,6 @@ void ImageUploader::logToFile(const QString &originalFilePath, logSaveFile.commit(); } -// extracting link to either image or its deletion from response body -QString getJSONValue(QJsonValue responseJson, QString jsonPattern) -{ - for (const QString &key : jsonPattern.split(".")) - { - responseJson = responseJson[key]; - } - return responseJson.toString(); -} - -QString getLinkFromResponse(NetworkResult response, QString pattern) -{ - QRegularExpression regExp("{(.+)}", - QRegularExpression::InvertedGreedinessOption); - auto match = regExp.match(pattern); - - while (match.hasMatch()) - { - pattern.replace(match.captured(0), - getJSONValue(response.parseJson(), match.captured(1))); - match = regExp.match(pattern); - } - return pattern; -} - void ImageUploader::sendImageUploadRequest(RawImageData imageData, ChannelPtr channel, QPointer textEdit) diff --git a/src/singletons/ImageUploader.hpp b/src/singletons/ImageUploader.hpp index 80cd4ef1..6ea6cc55 100644 --- a/src/singletons/ImageUploader.hpp +++ b/src/singletons/ImageUploader.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -14,6 +15,33 @@ class Channel; class NetworkResult; using ChannelPtr = std::shared_ptr; +} // namespace chatterino + +namespace chatterino::imageuploader::detail { + +/// Traverses the JSON value with a pattern where each key is separated by dots. +/// +/// If the pattern doesn't match, an empty string is returned. +/// +/// **Example**: +/// +/// - JSON: `{"foo": {"bar": [1, "baz"]}}` +/// - pattern: `foo.bar.1` +/// - return value: `"baz"` +QString getJSONValue(QJsonValue responseJson, QStringView jsonPattern); + +/// Interpolates `pattern` with the JSON response. +/// **Example**: +/// +/// - response: `{"foo": {"bar": [1, "baz", "qox"]}}` +/// - pattern: `https://example.com/{foo.bar.1}.{foo.bar.2}` +/// - return value: `"https://example.com/baz.qox"` +QString getLinkFromResponse(const NetworkResult &response, QString pattern); + +} // namespace chatterino::imageuploader::detail + +namespace chatterino { + struct RawImageData { QByteArray data; QString format; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 277401b6..02e948b5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -55,6 +55,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/EventSubMessages.cpp ${CMAKE_CURRENT_LIST_DIR}/src/WebSocketPool.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NativeMessaging.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/ImageUploader.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp ${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.hpp diff --git a/tests/src/ImageUploader.cpp b/tests/src/ImageUploader.cpp new file mode 100644 index 00000000..de2b4a5e --- /dev/null +++ b/tests/src/ImageUploader.cpp @@ -0,0 +1,261 @@ +#include "singletons/ImageUploader.hpp" + +#include "common/network/NetworkResult.hpp" +#include "Test.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +namespace chatterino::imageuploader::detail { + +TEST(ImageUploaderDetail_GetJsonValue, object) +{ + const char *json = R"({ + "foo": "bar", + "baz": { + "qox": { + "a": 1, + "b": "bb", + "c": null, + "d": true, + "1": "one" + } + } + })"; + auto jv = QJsonDocument::fromJson(json).object(); + ASSERT_FALSE(jv.empty()); + + std::vector> cases{ + {"foo", "bar"}, + {"something", ""}, + {"0", ""}, + {"baz", ""}, + {"baz.qox", ""}, + {"baz.something", ""}, + {"baz.qox.a", ""}, + {"baz.qox.a.b", ""}, + {"baz.qox.a.b.c", ""}, + {"baz.qox.b", "bb"}, + {"baz.qox.b.1", "bb"}, + {"baz.qox.b.a.whatever", "bb"}, + {"baz.qox.c", ""}, + {"baz.qox.c.b", ""}, + {"baz.qox.c.d.e", ""}, + {"baz.qox.d", ""}, + {"baz.qox.d.f", ""}, + {"baz.qox.1", "one"}, + {"baz.qox.1.2", "one"}, + {"baz.qox.2", ""}, + {"baz.qox.2.4", ""}, + {"baz.qox.something", ""}, + {"baz.qox.something.else", ""}, + {"baz.qox.something.else.no", ""}, + }; + + for (const auto &[pattern, exp] : cases) + { + ASSERT_EQ(getJSONValue(jv, pattern), exp) << pattern; + } +} + +TEST(ImageUploaderDetail_GetJsonValue, array) +{ + const char *json = R"([ + "bar", + 2, + true, + "f", + null, + { + "qox": { + "b": "bb" + }, + "nest": [ + ["a", "b", ["c"]], + [42, "d"] + ] + }, + ["x"] + ])"; + auto jv = QJsonDocument::fromJson(json).array(); + ASSERT_FALSE(jv.empty()); + + std::vector> cases{ + {"foo", ""}, + {"-1", ""}, + {"0x0", ""}, + {"0", "bar"}, + {"0.", "bar"}, + {"0.0", "bar"}, + {"1", ""}, + {"2", ""}, + {"3", "f"}, + {"4", ""}, + {"5", ""}, + {"5.0", ""}, + {"5.qox", ""}, + {"5.qox.b", "bb"}, + {"5.qox.0", ""}, + {"5.nest", ""}, + {"5.nest.0", ""}, + {"5.nest.0.0", "a"}, + {"5.nest.0.1", "b"}, + {"5.nest.0.2", ""}, + {"5.nest.0.2.0", "c"}, + {"5.nest.0.2.0.1", "c"}, + {"5.nest.0.2.1", ""}, + {"5.nest.0.3", ""}, + {"5.nest.0.3.0", ""}, + {"5.nest.0.3.0.0", ""}, + {"5.nest.1", ""}, + {"5.nest.1.0", ""}, + {"5.nest.1.1", "d"}, + {"5.nest.1.1.0", "d"}, + {"5.nest.1.2", ""}, + {"6.0", "x"}, + {"6.zero", ""}, + {"6.-1", ""}, + {"6.+0", "x"}, + {"7", ""}, + {"7.0", ""}, + {"8", ""}, + }; + + for (const auto &[pattern, exp] : cases) + { + ASSERT_EQ(getJSONValue(jv, pattern), exp) << pattern; + } +} + +TEST(ImageUploaderDetail_GetJsonValue, scalar) +{ + ASSERT_EQ(getJSONValue({}, u""), ""); + ASSERT_EQ(getJSONValue({}, u"a"), ""); + ASSERT_EQ(getJSONValue({}, u"0"), ""); + ASSERT_EQ(getJSONValue({}, u"a.b"), ""); + + ASSERT_EQ(getJSONValue(QJsonValue::Null, u""), ""); + ASSERT_EQ(getJSONValue(QJsonValue::Null, u"a"), ""); + ASSERT_EQ(getJSONValue(QJsonValue::Null, u"0"), ""); + ASSERT_EQ(getJSONValue(QJsonValue::Null, u"a.b"), ""); + + ASSERT_EQ(getJSONValue(true, u""), ""); + ASSERT_EQ(getJSONValue(true, u"a"), ""); + ASSERT_EQ(getJSONValue(true, u"0"), ""); + ASSERT_EQ(getJSONValue(true, u"a.b"), ""); + + ASSERT_EQ(getJSONValue(42, u""), ""); + ASSERT_EQ(getJSONValue(42, u"a"), ""); + ASSERT_EQ(getJSONValue(42, u"0"), ""); + ASSERT_EQ(getJSONValue(42, u"a.b"), ""); + + ASSERT_EQ(getJSONValue("abc", u""), "abc"); + ASSERT_EQ(getJSONValue("abc", u"a"), "abc"); + ASSERT_EQ(getJSONValue("abc", u"0"), "abc"); + ASSERT_EQ(getJSONValue("abc", u"a.b"), "abc"); +} + +TEST(ImageUploaderDetail_GetLinkFromResponse, basic) +{ + const char *json = R"({ + "foo": "bar", + "baz": { + "qox": { + "a": 1, + "b": "bb", + "c": null, + "d": true, + "1": "one" + }, + "arr": [ + ["a", "b", ["c"]], + [42, "d", "baz"], + { + "a": "wow" + } + ] + } + })"; + + std::vector> cases{ + {"a", "a"}, + {"foo", "foo"}, + {"{foo}", "bar"}, + {"foo{foo}", "foobar"}, + {"foo.{baz.qox}", "foo."}, + {"foo.{baz.qox.b}", "foo.bb"}, + {"{foo", "{foo"}, + {"foo}", "foo}"}, + {"{}", "{}"}, + {"{.}", ""}, + {"f{..}g", "fg"}, + {"{baz.qox.b}", "bb"}, + {"{baz.qox.{baz.arr.0.1}}", "}"}, + {"a{foo}b{foo}c{baz.arr.0.0}", "abarbbarca"}, + {".{foo}.{foo}.{baz.arr.0.0}.", ".bar.bar.a."}, + {"https://{foo}.{foo}/{baz.arr.0.0}.png", "https://bar.bar/a.png"}, + }; + + NetworkResult res(NetworkResult::NetworkError::NoError, 200, json); + for (const auto &[pattern, exp] : cases) + { + ASSERT_EQ(getLinkFromResponse(res, pattern), exp) << pattern; + } +} + +TEST(ImageUploaderDetail_GetLinkFromResponse, scalar) +{ + auto res = [](const char *json) { + return NetworkResult(NetworkResult::NetworkError::NoError, 200, json); + }; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{}-"), "-{}-"); + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{.}-"), + "-my string-"); + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{0}-"), + "-my string-"); + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{a}-"), + "-my string-"); + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{a.b}-"), + "-my string-"); +#else + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{}-"), "-{}-"); + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{.}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{0}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{a}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res(R"("my string")"), "-{a.b}-"), "--"); +#endif + + ASSERT_EQ(getLinkFromResponse(res("my string"), "-{}-"), "-{}-"); + ASSERT_EQ(getLinkFromResponse(res("my string"), "-{.}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("my string"), "-{0}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("my string"), "-{a}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("my string"), "-{a.b}-"), "--"); + + ASSERT_EQ(getLinkFromResponse(res("42"), "-{}-"), "-{}-"); + ASSERT_EQ(getLinkFromResponse(res("42"), "-{.}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("42"), "-{0}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("42"), "-{a}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("42"), "-{a.b}-"), "--"); + + ASSERT_EQ(getLinkFromResponse(res("true"), "-{}-"), "-{}-"); + ASSERT_EQ(getLinkFromResponse(res("true"), "-{.}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("true"), "-{0}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("true"), "-{a}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("true"), "-{a.b}-"), "--"); + + ASSERT_EQ(getLinkFromResponse(res("null"), "-{}-"), "-{}-"); + ASSERT_EQ(getLinkFromResponse(res("null"), "-{.}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("null"), "-{0}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("null"), "-{a}-"), "--"); + ASSERT_EQ(getLinkFromResponse(res("null"), "-{a.b}-"), "--"); +} + +} // namespace chatterino::imageuploader::detail