From 0fda98b233ae0f44b1f0a8691958087619b243e1 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Sat, 12 Oct 2019 08:10:32 +0200 Subject: Start on 4chan, go to previous/next chapter when reaching beginning/end --- src/Body.cpp | 4 +- src/QuickMedia.cpp | 212 +++++++++++++++++++++++++++++++++++++++++------ src/plugins/Fourchan.cpp | 187 +++++++++++++++++++++++++++++++++++++++++ src/plugins/Plugin.cpp | 34 +++++++- 4 files changed, 408 insertions(+), 29 deletions(-) create mode 100644 src/plugins/Fourchan.cpp (limited to 'src') diff --git a/src/Body.cpp b/src/Body.cpp index c87844c..fb44929 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -54,6 +54,7 @@ namespace QuickMedia { void Body::clear_items() { items.clear(); + selected_item = 0; //item_thumbnail_textures.clear(); } @@ -151,6 +152,7 @@ namespace QuickMedia { // Find the starting row that can be drawn to make selected row visible as well int visible_rows = 0; int first_visible_item = selected_item; + assert(first_visible_item >= 0 && first_visible_item < (int)items.size()); for(; first_visible_item >= 0 && visible_rows < max_visible_rows; --first_visible_item) { auto &item = items[first_visible_item]; if(item->visible) @@ -235,7 +237,7 @@ namespace QuickMedia { const Json::Value ¤t_json = item_progress["current"]; const Json::Value &total_json = item_progress["total"]; if(current_json.isNumeric() && total_json.isNumeric()) { - progress_text.setString(std::string("Progress: ") + std::to_string(current_json.asInt()) + "/" + std::to_string(total_json.asInt())); + progress_text.setString(std::string("Page: ") + std::to_string(current_json.asInt()) + "/" + std::to_string(total_json.asInt())); auto bounds = progress_text.getLocalBounds(); progress_text.setPosition(std::floor(item_pos.x + size.x - bounds.width - 10.0f), std::floor(item_pos.y)); window.draw(progress_text); diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 73e44b9..05c9004 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -2,6 +2,7 @@ #include "../plugins/Manganelo.hpp" #include "../plugins/Youtube.hpp" #include "../plugins/Pornhub.hpp" +#include "../plugins/Fourchan.hpp" #include "../include/Scale.hpp" #include "../include/Program.h" #include "../include/VideoPlayer.hpp" @@ -103,6 +104,9 @@ namespace QuickMedia { } else if(strcmp(argv[1], "pornhub") == 0) { current_plugin = new Pornhub(); plugin_logo_path = "../../../images/pornhub_logo.png"; + } else if(strcmp(argv[1], "4chan") == 0) { + current_plugin = new Fourchan(); + plugin_logo_path = "../../../images/4chan_logo.png"; } else { usage(); return -1; @@ -150,6 +154,16 @@ namespace QuickMedia { window.setKeyRepeatEnabled(true); break; } + case Page::CONTENT_LIST: { + body->draw_thumbnails = true; + content_list_page(); + break; + } + case Page::CONTENT_DETAILS: { + body->draw_thumbnails = true; + content_details_page(); + break; + } default: window.close(); break; @@ -233,7 +247,7 @@ namespace QuickMedia { return file_overwrite(path, Json::writeString(json_builder, json)) == 0; } - static std::string manga_extract_id_from_url(const std::string &url) { + static bool manga_extract_id_from_url(const std::string &url, std::string &manga_id) { bool manganelo_website = false; if(url.find("mangakakalot") != std::string::npos) manganelo_website = true; @@ -247,24 +261,24 @@ namespace QuickMedia { err_msg += url; err_msg += " doesn't contain manga id"; show_notification("Manga", err_msg, Urgency::CRITICAL); - abort(); + return false; } - std::string manga_id = url.substr(index + 6); + manga_id = url.substr(index + 6); if(manga_id.size() <= 2) { std::string err_msg = "Url "; err_msg += url; err_msg += " doesn't contain manga id"; show_notification("Manga", err_msg, Urgency::CRITICAL); - abort(); + return false; } - return manga_id; + return true; } else { std::string err_msg = "Unexpected url "; err_msg += url; err_msg += " is not manganelo or mangakakalot"; show_notification("Manga", err_msg, Urgency::CRITICAL); - abort(); + return false; } } @@ -292,7 +306,9 @@ namespace QuickMedia { return false; } - std::string manga_id = manga_extract_id_from_url(content_url); + std::string manga_id; + if(!manga_extract_id_from_url(content_url, manga_id)) + return false; content_storage_file = content_storage_dir.join(base64_encode(manga_id)); content_storage_json.clear(); content_storage_json["name"] = content_title; @@ -303,11 +319,16 @@ namespace QuickMedia { watched_videos.clear(); if(content_url.empty()) next_page = Page::SEARCH_RESULT; + } else if(next_page == Page::CONTENT_LIST) { + content_list_url = content_url; } current_page = next_page; return true; }; + PluginResult front_page_result = current_plugin->get_front_page(body->items); + body->clamp_selection(); + sf::Vector2f body_pos; sf::Vector2f body_size; bool resized = true; @@ -370,7 +391,7 @@ namespace QuickMedia { #if 0 search_bar->onTextUpdateCallback = [this](const std::string &text) { body->filter_search_fuzzy(text); - body->clamp_selection(); + body->selected_item = 0; }; search_bar->onTextSubmitCallback = [this](const std::string &text) { @@ -649,7 +670,7 @@ namespace QuickMedia { if(sf::Mouse::isButtonPressed(sf::Mouse::Left)) { auto mouse_pos = sf::Mouse::getPosition(window); - if(mouse_pos.y >= window_size.y - ui_height) { + if(mouse_pos.y >= window_size.y - ui_height && mouse_pos.y <= window_size.y) { if(seekable) video_player->set_progress((double)mouse_pos.x / (double)window_size.x); else @@ -687,10 +708,29 @@ namespace QuickMedia { return exec_program(args, nullptr, nullptr); } + void Program::select_episode(BodyItem *item, bool start_from_beginning) { + images_url = item->url; + chapter_title = item->title; + image_index = 0; + current_page = Page::IMAGES; + if(start_from_beginning) + return; + + const Json::Value &json_chapters = content_storage_json["chapters"]; + if(json_chapters.isObject()) { + const Json::Value &json_chapter = json_chapters[chapter_title]; + if(json_chapter.isObject()) { + const Json::Value ¤t = json_chapter["current"]; + if(current.isNumeric()) + image_index = current.asInt() - 1; + } + } + } + void Program::episode_list_page() { search_bar->onTextUpdateCallback = [this](const std::string &text) { body->filter_search_fuzzy(text); - body->clamp_selection(); + body->selected_item = 0; }; search_bar->onTextSubmitCallback = [this](const std::string &text) -> bool { @@ -698,21 +738,7 @@ namespace QuickMedia { if(!selected_item) return false; - images_url = selected_item->url; - chapter_title = selected_item->title; - image_index = 0; - current_page = Page::IMAGES; - - const Json::Value &json_chapters = content_storage_json["chapters"]; - if(json_chapters.isObject()) { - const Json::Value &json_chapter = json_chapters[chapter_title]; - if(json_chapter.isObject()) { - const Json::Value ¤t = json_chapter["current"]; - if(current.isNumeric()) - image_index = current.asInt() - 1; - } - } - + select_episode(selected_item, false); return true; }; @@ -790,8 +816,7 @@ namespace QuickMedia { error_message.setString(std::string("Failed to load image for page ") + std::to_string(image_index + 1)); } } else if(image_result == ImageResult::END) { - // TODO: Improve this message - error_message.setString("End of chapter"); + error_message.setString("End of " + chapter_title); } else { // TODO: Convert ImageResult error to a string and show to user error_message.setString(std::string("Network error, failed to get image for page ") + std::to_string(image_index + 1)); @@ -800,6 +825,7 @@ namespace QuickMedia { int num_images = 0; image_plugin->get_number_of_images(images_url, num_images); + image_index = std::min(image_index, num_images); Json::Value &json_chapters = content_storage_json["chapters"]; Json::Value json_chapter; @@ -858,11 +884,22 @@ namespace QuickMedia { if(image_index > 0) { --image_index; return; + } else if(image_index == 0 && body->selected_item < (int)body->items.size() - 1) { + // TODO: Make this work if the list is sorted differently than from newest to oldest. + body->selected_item++; + select_episode(body->items[body->selected_item].get(), true); + image_index = 99999; // Start at the page that shows we are at the end of the chapter + return; } } else if(event.key.code == sf::Keyboard::Down) { if(image_index < num_images) { ++image_index; return; + } else if(image_index == num_images && body->selected_item > 0) { + // TODO: Make this work if the list is sorted differently than from newest to oldest. + body->selected_item--; + select_episode(body->items[body->selected_item].get(), true); + return; } } else if(event.key.code == sf::Keyboard::Escape) { current_page = Page::EPISODE_LIST; @@ -913,4 +950,125 @@ namespace QuickMedia { window.display(); } } + + void Program::content_list_page() { + if(current_plugin->get_content_list(content_list_url, body->items) != PluginResult::OK) { + show_notification("Content list", "Failed to get content list for url: " + content_list_url, Urgency::CRITICAL); + current_page = Page::SEARCH_SUGGESTION; + return; + } + + search_bar->onTextUpdateCallback = [this](const std::string &text) { + body->filter_search_fuzzy(text); + body->selected_item = 0; + }; + + search_bar->onTextSubmitCallback = [this](const std::string &text) -> bool { + BodyItem *selected_item = body->get_selected(); + if(!selected_item) + return false; + + content_episode = selected_item->title; + content_url = selected_item->url; + current_page = Page::CONTENT_DETAILS; + body->clear_items(); + return true; + }; + + sf::Vector2f body_pos; + sf::Vector2f body_size; + bool resized = true; + sf::Event event; + + while (current_page == Page::CONTENT_LIST) { + while (window.pollEvent(event)) { + base_event_handler(event, Page::SEARCH_SUGGESTION); + if(event.type == sf::Event::Resized) + resized = true; + } + + // TODO: This code is duplicated in many places. Handle it in one place. + if(resized) { + search_bar->onWindowResize(window_size); + + float body_padding_horizontal = 50.0f; + float body_padding_vertical = 50.0f; + float body_width = window_size.x - body_padding_horizontal * 2.0f; + if(body_width < 400) { + body_width = window_size.x; + body_padding_horizontal = 0.0f; + } + + float search_bottom = search_bar->getBottom(); + body_pos = sf::Vector2f(body_padding_horizontal, search_bottom + body_padding_vertical); + body_size = sf::Vector2f(body_width, window_size.y - search_bottom); + } + + search_bar->update(); + + window.clear(back_color); + body->draw(window, body_pos, body_size); + search_bar->draw(window); + window.display(); + } + } + + void Program::content_details_page() { + if(current_plugin->get_content_details(content_list_url, content_url, body->items) != PluginResult::OK) { + show_notification("Content details", "Failed to get content details for url: " + content_url, Urgency::CRITICAL); + // TODO: This will return to an empty content list. + // Each page should have its own @Body so we can return to the last page and still have the data loaded + // however the cached images should be cleared. + current_page = Page::CONTENT_LIST; + return; + } + + // Instead of using search bar to searching, use it for commenting. + // TODO: Have an option for the search bar to be multi-line. + search_bar->onTextUpdateCallback = nullptr; + + search_bar->onTextSubmitCallback = [this](const std::string &text) -> bool { + if(text.empty()) + return false; + + return true; + }; + + sf::Vector2f body_pos; + sf::Vector2f body_size; + bool resized = true; + sf::Event event; + + while (current_page == Page::CONTENT_DETAILS) { + while (window.pollEvent(event)) { + base_event_handler(event, Page::SEARCH_SUGGESTION); + if(event.type == sf::Event::Resized) + resized = true; + } + + // TODO: This code is duplicated in many places. Handle it in one place. + if(resized) { + search_bar->onWindowResize(window_size); + + float body_padding_horizontal = 50.0f; + float body_padding_vertical = 50.0f; + float body_width = window_size.x - body_padding_horizontal * 2.0f; + if(body_width < 400) { + body_width = window_size.x; + body_padding_horizontal = 0.0f; + } + + float search_bottom = search_bar->getBottom(); + body_pos = sf::Vector2f(body_padding_horizontal, search_bottom + body_padding_vertical); + body_size = sf::Vector2f(body_width, window_size.y - search_bottom); + } + + search_bar->update(); + + window.clear(back_color); + body->draw(window, body_pos, body_size); + search_bar->draw(window); + window.display(); + } + } } \ No newline at end of file diff --git a/src/plugins/Fourchan.cpp b/src/plugins/Fourchan.cpp new file mode 100644 index 0000000..0c3bffb --- /dev/null +++ b/src/plugins/Fourchan.cpp @@ -0,0 +1,187 @@ +#include "../../plugins/Fourchan.hpp" +#include +#include + +// API documentation: https://github.com/4chan/4chan-API + +static const std::string fourchan_url = "https://a.4cdn.org/"; +static const std::string fourchan_image_url = "https://i.4cdn.org/"; + +// Legacy recaptcha command: curl 'https://www.google.com/recaptcha/api/fallback?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc' -H 'Referer: https://boards.4channel.org/' -H 'Cookie: CONSENT=YES' + +/* +Answering recaptcha: +curl 'https://www.google.com/recaptcha/api/fallback?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc' +-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0' +-H 'Referer: https://www.google.com/recaptcha/api/fallback?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc' +-H 'Content-Type: application/x-www-form-urlencoded' +--data 'c=03AOLTBLQ66PjSi9s8S-R1vUS2Jgm-Z_ghEejvvjAaeF3FoR9MiM0zHhCxuertrCo7MAcFUEqcIg4l2WJzVtrJhJVLkncF12OzCaeIvbm46hgDZDZjLD89-LMn1Zs0TP37P-Hd4cuRG8nHuEBXc2ZBD8CVX-6HAs9VBgSmsgQeKF1PWm1tAMBccJhlh4rAOkpjzaEXMMGOe17N0XViwDYZxLGhe4H8IAG2KNB1fb4rz4YKJTPbL30_FvHw7zkdFtojjWiqVW0yCN6N192dhfd9oKz2r9pGRrR6N4AkkX-L0DsBD4yNK3QRsQn3dB1fs3JRZPAh1yqUqTQYhOaqdggyc1EwL8FZHouGRkHTOcCmLQjyv6zuhi6CJbg&response=1&response=4&response=5&response=7' +*/ + +/* Posting message: +curl 'https://sys.4chan.org/bant/post' +-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0' +-H 'Referer: https://boards.4chan.org/' +-H 'Content-Type: multipart/form-data; boundary=---------------------------119561554312148213571335532670' +-H 'Origin: https://boards.4chan.org' +-H 'Cookie: __cfduid=d4bd4932e46bc3272fae4ce7a4e2aac511546800687; 4chan_pass=_SsBuZaATt3dIqfVEWlpemhU5XLQ6i9RC' +--data-binary $'-----------------------------119561554312148213571335532670\r\nContent-Disposition: form-data; name="resto"\r\n\r\n8640736\r\n-----------------------------119561554312148213571335532670\r\nContent-Disposition: form-data; name="com"\r\n\r\n>>8640771\r\nShe looks finnish\r\n-----------------------------119561554312148213571335532670\r\nContent-Disposition: form-data; name="mode"\r\n\r\nregist\r\n-----------------------------119561554312148213571335532670\r\nContent-Disposition: form-data; name="pwd"\r\n\r\n_SsBuZaATt3dIqfVEWlpemhU5XLQ6i9RC\r\n-----------------------------119561554312148213571335532670\r\nContent-Disposition: form-data; name="g-recaptcha-response"\r\n\r\n03AOLTBLS5lshp5aPj5pG6xdVMQ0pHuHxAtJoCEYuPLNKYlsRWNCPQegjB9zgL-vwdGMzjcT-L9iW4bnQ5W3TqUWHOVqtsfnx9GipLUL9o2XbC6r9zy-EEiPde7l6J0WcZbr9nh_MGcUpKl6RGaZoYB3WwXaDq74N5hkmEAbqM_CBtbAVVlQyPmemI2HhO2J6K0yFVKBrBingtIZ6-oXBXZ4jC4rT0PeOuVaH_gf_EBjTpb55ueaPmTbeLGkBxD4-wL1qA8F8h0D8c\r\n-----------------------------119561554312148213571335532670--\r\n' +*/ + +namespace QuickMedia { + PluginResult Fourchan::get_front_page(BodyItems &result_items) { + std::string server_response; + if(download_to_string(fourchan_url + "boards.json", server_response) != DownloadResult::OK) + return PluginResult::NET_ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(server_response.data(), server_response.data() + server_response.size(), &json_root, &json_errors)) { + fprintf(stderr, "4chan front page json error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + const Json::Value &boards = json_root["boards"]; + if(boards.isArray()) { + for(const Json::Value &board : boards) { + const Json::Value &board_id = board["board"]; // /g/, /a/, /b/ etc + const Json::Value &board_title = board["title"]; + const Json::Value &board_description = board["meta_description"]; + if(board_id.isString() && board_title.isString() && board_description.isString()) { + std::string board_description_str = board_description.asString(); + html_unescape_sequences(board_description_str); + auto body_item = std::make_unique(board_title.asString()); + body_item->url = board_id.asString(); + result_items.emplace_back(std::move(body_item)); + } + } + } + + return PluginResult::OK; + } + + SearchResult Fourchan::search(const std::string &url, BodyItems &result_items) { + return SearchResult::OK; + } + + SuggestionResult Fourchan::update_search_suggestions(const std::string &text, BodyItems &result_items) { + return SuggestionResult::OK; + } + + static bool string_ends_with(const std::string &str, const std::string &ends_with_str) { + size_t len = ends_with_str.size(); + return len == 0 || (str.size() >= len && memcmp(&str[str.size() - len], ends_with_str.data(), len) == 0); + } + + PluginResult Fourchan::get_content_list(const std::string &url, BodyItems &result_items) { + std::string server_response; + if(download_to_string(fourchan_url + url + "/catalog.json", server_response) != DownloadResult::OK) + return PluginResult::NET_ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(server_response.data(), server_response.data() + server_response.size(), &json_root, &json_errors)) { + fprintf(stderr, "4chan catalog json error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + if(json_root.isArray()) { + for(const Json::Value &page_data : json_root) { + if(!page_data.isObject()) + continue; + + const Json::Value &threads = page_data["threads"]; + if(!threads.isArray()) + continue; + + for(const Json::Value &thread : threads) { + if(!thread.isObject()) + continue; + + const Json::Value &com = thread["com"]; + if(!com.isString()) + continue; + + const Json::Value &thread_num = thread["no"]; + if(!thread_num.isNumeric()) + continue; + + auto body_item = std::make_unique(com.asString()); + body_item->url = std::to_string(thread_num.asInt64()); + + const Json::Value &ext = thread["ext"]; + const Json::Value &tim = thread["tim"]; + if(tim.isNumeric() && ext.isString()) { + std::string ext_str = ext.asString(); + if(ext_str == ".png" || ext_str == ".jpg" || ext_str == ".jpeg") { + } else { + fprintf(stderr, "TODO: Support file extension: %s\n", ext_str.c_str()); + } + // "s" means small, that's the url 4chan uses for thumbnails. + // thumbnails always has .jpg extension even if they are gifs or webm. + body_item->thumbnail_url = fourchan_image_url + url + "/" + std::to_string(tim.asInt64()) + "s.jpg"; + } + + result_items.emplace_back(std::move(body_item)); + } + } + } + + return PluginResult::OK; + } + + PluginResult Fourchan::get_content_details(const std::string &list_url, const std::string &url, BodyItems &result_items) { + std::string server_response; + if(download_to_string(fourchan_url + list_url + "/thread/" + url + ".json", server_response) != DownloadResult::OK) + return PluginResult::NET_ERR; + + Json::Value json_root; + Json::CharReaderBuilder json_builder; + std::unique_ptr json_reader(json_builder.newCharReader()); + std::string json_errors; + if(!json_reader->parse(server_response.data(), server_response.data() + server_response.size(), &json_root, &json_errors)) { + fprintf(stderr, "4chan thread json error: %s\n", json_errors.c_str()); + return PluginResult::ERR; + } + + const Json::Value &posts = json_root["posts"]; + if(posts.isArray()) { + for(const Json::Value &post : posts) { + if(!post.isObject()) + continue; + + const Json::Value &com = post["com"]; + if(!com.isString()) + continue; + + const Json::Value &post_num = post["no"]; + if(!post_num.isNumeric()) + continue; + + auto body_item = std::make_unique(com.asString()); + body_item->url = std::to_string(post_num.asInt64()); + + const Json::Value &ext = post["ext"]; + const Json::Value &tim = post["tim"]; + if(tim.isNumeric() && ext.isString()) { + std::string ext_str = ext.asString(); + if(ext_str == ".png" || ext_str == ".jpg" || ext_str == ".jpeg") { + } else { + fprintf(stderr, "TODO: Support file extension: %s\n", ext_str.c_str()); + } + // "s" means small, that's the url 4chan uses for thumbnails. + // thumbnails always has .jpg extension even if they are gifs or webm. + body_item->thumbnail_url = fourchan_image_url + list_url + "/" + std::to_string(tim.asInt64()) + "s.jpg"; + } + + result_items.emplace_back(std::move(body_item)); + } + } + + return PluginResult::OK; + } +} \ No newline at end of file diff --git a/src/plugins/Plugin.cpp b/src/plugins/Plugin.cpp index 79c6403..c56ff67 100644 --- a/src/plugins/Plugin.cpp +++ b/src/plugins/Plugin.cpp @@ -2,6 +2,7 @@ #include "../../include/Program.h" #include #include +#include static int accumulate_string(char *data, int size, void *userdata) { std::string *str = (std::string*)userdata; @@ -28,7 +29,8 @@ namespace QuickMedia { } DownloadResult download_to_string(const std::string &url, std::string &result, const std::vector &additional_args) { - std::vector args = { "curl", "-H", "Accept-Language: en-US,en;q=0.5", "--compressed", "-s", "-L" }; + sf::Clock timer; + std::vector args = { "curl", "-f", "-H", "Accept-Language: en-US,en;q=0.5", "--compressed", "-s", "-L" }; for(const CommandArg &arg : additional_args) { args.push_back(arg.option.c_str()); args.push_back(arg.value.c_str()); @@ -38,6 +40,7 @@ namespace QuickMedia { args.push_back(nullptr); if(exec_program(args.data(), accumulate_string, &result) != 0) return DownloadResult::NET_ERR; + fprintf(stderr, "Download duration for %s: %d ms\n", url.c_str(), timer.getElapsedTime().asMilliseconds()); return DownloadResult::OK; } @@ -64,6 +67,35 @@ namespace QuickMedia { return str.substr(start, end - start + 1); } + struct HtmlEscapeSequence { + std::string escape_sequence; + std::string unescaped_str; + }; + + void string_replace_all(std::string &str, const std::string &old_str, const std::string &new_str) { + size_t index = 0; + while(true) { + index = str.find(old_str, index); + if(index == std::string::npos) + return; + str.replace(index, old_str.size(), new_str); + } + } + + void html_unescape_sequences(std::string &str) { + const std::array escape_sequences = { + HtmlEscapeSequence { """, "\"" }, + HtmlEscapeSequence { "'", "'" }, + HtmlEscapeSequence { "<", "<" }, + HtmlEscapeSequence { ">", ">" }, + HtmlEscapeSequence { "&", "&" } // This should be last, to not accidentally replace a new sequence caused by replacing this + }; + + for(const HtmlEscapeSequence &escape_sequence : escape_sequences) { + string_replace_all(str, escape_sequence.escape_sequence, escape_sequence.unescaped_str); + } + } + std::string Plugin::url_param_encode(const std::string ¶m) const { std::ostringstream result; result.fill('0'); -- cgit v1.2.3