aboutsummaryrefslogtreecommitdiff
path: root/ricochet/src/core
diff options
context:
space:
mode:
authorRichard Qin <richard.qin.x@gmail.com>2026-10-30 02:24:12 +0800
committerRichard Qin <richard.qin.x@gmail.com>2026-10-30 02:24:12 +0800
commit464313d02b76385d3a2e8809a4595afabca88dae (patch)
tree6cbf81416b856ff15fdc4239de5e3624ef10b2cc /ricochet/src/core
parentcf58b3b4dfa0dbbd0ca492484ca241b320c4ba9e (diff)
parentbdefdb301db843e87aee8c6c35808d295bb66ee5 (diff)
downloadrcc-project-master.tar.gz
rcc-project-master.tar.xz
Merge remote-tracking branch 'ricochet-import/master'HEADmaster
Diffstat (limited to 'ricochet/src/core')
-rw-r--r--ricochet/src/core/browser.cc66
-rw-r--r--ricochet/src/core/form_handler.cc64
-rw-r--r--ricochet/src/core/license.cc69
-rw-r--r--ricochet/src/core/local_data.cc171
-rw-r--r--ricochet/src/core/page_loader.cc78
-rw-r--r--ricochet/src/core/page_processor.cc97
-rw-r--r--ricochet/src/core/utils.cc85
7 files changed, 630 insertions, 0 deletions
diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc
new file mode 100644
index 0000000..c413a0d
--- /dev/null
+++ b/ricochet/src/core/browser.cc
@@ -0,0 +1,66 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/browser.hh"
+
+#include <fstream>
+
+#include "ricochet/core/local_data.hh"
+
+namespace ricochet::core {
+
+std::string Browser::format_url( std::string input )
+{
+ if ( input.empty() ) {
+ return input;
+ }
+
+ if ( input.starts_with( "http://" ) || input.starts_with( "https://" ) ) {
+ return input;
+ }
+
+ const bool is_domain = input.contains( '.' ) && !input.contains( ' ' );
+ if ( is_domain ) {
+ input.insert( 0, "https://" );
+ return input;
+ }
+
+ for ( char& ch : input ) {
+ if ( ch == ' ' ) {
+ ch = '+';
+ }
+ }
+ return "https://lite.duckduckgo.com/lite/?q=" + input;
+}
+
+std::string Browser::get_configured_homepage()
+{
+ std::ifstream file( get_config_path( "config.txt" ) );
+ if ( file.is_open() ) {
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t eq = line.find( '=' );
+ if ( eq != std::string::npos && line.substr( 0, eq ) == "homepage" ) {
+ return line.substr( eq + 1 );
+ }
+ }
+ }
+ return "https://wikipedia.org";
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/form_handler.cc b/ricochet/src/core/form_handler.cc
new file mode 100644
index 0000000..6100a30
--- /dev/null
+++ b/ricochet/src/core/form_handler.cc
@@ -0,0 +1,64 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/form_handler.hh"
+#include "ricochet/core/utils.hh"
+
+namespace ricochet::core {
+
+InputAction submit_form( const render::InputField& target_input,
+ const std::vector<render::InputField>& inputs,
+ const std::string& current_url,
+ std::vector<HttpRequest>& history,
+ std::size_t& history_idx )
+{
+ std::string body;
+ for ( const auto& in : inputs ) {
+ if ( in.action == target_input.action && !in.name.empty() && in.type != "submit" && in.type != "button" ) {
+ if ( ( in.type == "checkbox" || in.type == "radio" ) && !in.checked ) {
+ continue;
+ }
+ std::string encoded = in.value;
+ for ( char& ch : encoded ) {
+ if ( ch == ' ' ) {
+ ch = '+';
+ }
+ }
+ if ( !body.empty() ) {
+ body += "&";
+ }
+ body += in.name + "=" + encoded;
+ }
+ }
+ const std::string action
+ = target_input.action.empty() ? current_url : normalize_url( target_input.action, current_url );
+ HttpRequest next_req { .url = action, .method = "GET", .body = "" };
+ if ( target_input.method == "post" || target_input.method == "POST" ) {
+ next_req.method = "POST";
+ next_req.body = body;
+ } else {
+ const char sep = action.contains( '?' ) ? '&' : '?';
+ next_req.url += sep + body;
+ }
+ history.resize( history_idx + 1 );
+ history.push_back( next_req );
+ history_idx++;
+ return InputAction::Navigate;
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/license.cc b/ricochet/src/core/license.cc
new file mode 100644
index 0000000..587a942
--- /dev/null
+++ b/ricochet/src/core/license.cc
@@ -0,0 +1,69 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/license.hh"
+#include <iostream>
+
+namespace ricochet::core {
+
+void print_license_notice()
+{
+ std::cout << "Ricochet Copyright (C) 2026 Your Name\n"
+ << "Ricochet comes with ABSOLUTELY NO WARRANTY; for details type 'show w'.\n"
+ << "This is free software, and you are welcome to redistribute it\n"
+ << "under certain conditions; type 'show c' for details.\n\n";
+}
+
+void show_warranty()
+{
+ std::cout << R"(
+[ Ricochet - WARRANTY INFORMATION ]
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER
+PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER
+EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+
+THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.
+SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY
+SERVICING, REPAIR OR CORRECTION.
+)" << '\n';
+}
+
+void show_copying()
+{
+ std::cout << R"(
+[ Ricochet - COPYING CONDITIONS ]
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+)" << '\n';
+}
+
+} // namespace ricochet::core \ No newline at end of file
diff --git a/ricochet/src/core/local_data.cc b/ricochet/src/core/local_data.cc
new file mode 100644
index 0000000..9115844
--- /dev/null
+++ b/ricochet/src/core/local_data.cc
@@ -0,0 +1,171 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/local_data.hh"
+#include <cstdlib>
+#include <fstream>
+#include <sys/stat.h>
+#include <vector>
+
+namespace ricochet::core {
+
+std::string get_config_path( const std::string& filename )
+{
+ const char* home = std::getenv( "HOME" ); // NOLINT(concurrency-mt-unsafe)
+ const std::string dir = ( home != nullptr ) ? std::string( home ) + "/.ricochet" : ".ricochet";
+ static_cast<void>( mkdir( dir.c_str(), 0755 ) );
+ return dir + "/" + filename;
+}
+
+void save_bookmark( const std::string& title, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& url )
+{
+ if ( url == "ricochet://bookmarks" ) {
+ return;
+ }
+ std::ofstream file( get_config_path( "bookmarks.txt" ), std::ios::app );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ std::string safe_title = title;
+ if ( safe_title.empty() ) {
+ safe_title = url;
+ }
+ for ( char& c : safe_title ) {
+ if ( c == '\n' || c == '\r' || c == '\t' ) {
+ c = ' ';
+ }
+ }
+ file << url << '\t' << safe_title << '\n';
+}
+
+std::string generate_bookmarks_html()
+{
+ std::ifstream file( get_config_path( "bookmarks.txt" ) );
+ std::string html = "<html><head><title>My Bookmarks</title></head><body><h1>Saved Bookmarks</h1><ul>";
+ if ( !file.is_open() ) {
+ html += "<li>No bookmarks found. Press 'b' on any page to add one!</li>";
+ } else {
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t tab_pos = line.find( '\t' );
+ if ( tab_pos != std::string::npos ) {
+ const std::string url = line.substr( 0, tab_pos );
+ const std::string title = line.substr( tab_pos + 1 );
+ html += "<li><a href=\"";
+ html += url;
+ html += "\">[Link] ";
+ html += title;
+ html += "</a></li>";
+ }
+ }
+ }
+ html += "</ul></body></html>";
+ return html;
+}
+
+void append_to_history( const std::string& url, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& title )
+{
+ if ( url.starts_with( "ricochet://" ) ) {
+ return;
+ }
+ std::ofstream file( get_config_path( "history.txt" ), std::ios::app );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ std::string safe_title = title;
+ if ( safe_title.empty() ) {
+ safe_title = url;
+ }
+ for ( char& c : safe_title ) {
+ if ( c == '\n' || c == '\r' || c == '\t' ) {
+ c = ' ';
+ }
+ }
+ file << url << '\t' << safe_title << '\n';
+}
+
+std::string generate_history_html()
+{
+ std::ifstream file( get_config_path( "history.txt" ) );
+ std::string html = "<html><head><title>History</title></head><body><h1>Browsing History</h1><ul>";
+ if ( !file.is_open() ) {
+ html += "<li>No history found. Start browsing!</li>";
+ } else {
+ std::vector<std::string> lines;
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ lines.push_back( std::move( line ) );
+ }
+
+ std::size_t count = 0;
+ for ( auto it = lines.rbegin(); it != lines.rend() && count < 100; ++it, ++count ) {
+ const std::size_t tab_pos = it->find( '\t' );
+ if ( tab_pos != std::string::npos ) {
+ const std::string url = it->substr( 0, tab_pos );
+ const std::string title = it->substr( tab_pos + 1 );
+ html += "<li><a href=\"";
+ html += url;
+ html += "\">[Link] ";
+ html += title;
+ html += "</a></li>";
+ }
+ }
+ }
+ html += "</ul></body></html>";
+ return html;
+}
+
+std::string generate_help_html()
+{
+ return R"(<html>
+ <head><title>Ricochet Help Manual</title></head>
+ <body>
+ <h1>Ricochet User Manual</h1>
+ <h2>1. Navigation & Browsing</h2>
+ <ul>
+ <li><b>j / k</b> : Scroll down / up</li>
+ <li><b>H / L</b> : History backward / forward</li>
+ <li><b>/</b> : Open prompt to enter a new URL</li>
+ </ul>
+ <h2>2. Page Interaction</h2>
+ <ul>
+ <li><b>f</b> : Toggle Link Hints (type the two letters to follow a link instantly)</li>
+ <li><b>n</b> : Find in page (search for text)</li>
+ <li><b>N</b> : Jump to the next search match</li>
+ <li><b>i</b> : Enter form input mode (fill textboxes, click buttons, toggle checkboxes)</li>
+ </ul>
+ <h2>3. Bookmarks & History</h2>
+ <ul>
+ <li><b>b</b> : Bookmark the current page</li>
+ <li><b>B</b> : View all saved bookmarks</li>
+ <li><b>h</b> : View browsing history</li>
+ </ul>
+ <h2>4. System</h2>
+ <ul>
+ <li><b>?</b> : Show this help manual</li>
+ <li><b>q</b> : Quit Ricochet</li>
+ </ul>
+ </body>
+ </html>)";
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/page_loader.cc b/ricochet/src/core/page_loader.cc
new file mode 100644
index 0000000..b9a5da5
--- /dev/null
+++ b/ricochet/src/core/page_loader.cc
@@ -0,0 +1,78 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/page_loader.hh"
+#include "ricochet/core/local_data.hh"
+#include "ricochet/core/page_processor.hh"
+#include "ricochet/core/utils.hh"
+#include "ricochet/parser/html_lexer.hh"
+#include "ricochet/parser/tree_builder.hh"
+#include "ricochet/render/renderer.hh"
+
+namespace ricochet::core {
+
+PageData load_page( const HttpRequest& req )
+{
+ std::string html_body;
+ if ( req.url == "ricochet://bookmarks" ) {
+ html_body = generate_bookmarks_html();
+ } else if ( req.url == "ricochet://history" ) {
+ html_body = generate_history_html();
+ } else if ( req.url == "ricochet://help" ) {
+ html_body = generate_help_html();
+ } else {
+ const net::HttpClient client;
+ auto response_result = client.fetch( req.url, req.method, req.body );
+ if ( !response_result.has_value() ) {
+ return { .lines
+ = { "\033[1;31m[!] Failed to load:\033[0m " + req.url, "", "Reason: " + response_result.error() },
+ .links = {},
+ .inputs = {},
+ .title = "Error" };
+ }
+
+ const auto& resp = response_result.value();
+ std::string c_type = resp.content_type;
+ for ( char& c : c_type ) {
+ if ( c >= 'A' && c <= 'Z' ) {
+ c = static_cast<char>( c + 32 );
+ }
+ }
+
+ const bool is_text
+ = c_type.starts_with( "text/" ) || c_type.contains( "xml" ) || c_type.contains( "json" ) || c_type.empty();
+
+ if ( !is_text ) {
+ return process_binary_download( req, resp );
+ }
+ html_body = response_result.value().body;
+ }
+
+ const parser::HtmlLexer lexer;
+ const parser::TreeBuilder builder;
+ const render::Renderer renderer;
+
+ const auto dom_root = builder.build( lexer.tokenize( html_body ) );
+ const render::RenderResult result = renderer.render( dom_root );
+ const std::string title = extract_title( dom_root );
+
+ return {
+ .lines = split_into_lines( result.text ), .links = result.links, .inputs = result.inputs, .title = title };
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/page_processor.cc b/ricochet/src/core/page_processor.cc
new file mode 100644
index 0000000..00728a9
--- /dev/null
+++ b/ricochet/src/core/page_processor.cc
@@ -0,0 +1,97 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/page_processor.hh"
+#include <fstream>
+#include <ranges>
+#include <vector>
+
+namespace ricochet::core {
+
+std::string extract_title( const parser::DomNode& root_node )
+{
+ std::vector<const parser::DomNode*> stack;
+ stack.push_back( &root_node );
+
+ while ( !stack.empty() ) {
+ const parser::DomNode* current = stack.back();
+ stack.pop_back();
+
+ std::string base_tag = current->tag_name;
+ const std::size_t space_pos = base_tag.find( ' ' );
+ if ( space_pos != std::string::npos ) {
+ base_tag = base_tag.substr( 0, space_pos );
+ }
+
+ for ( char& c : base_tag ) {
+ if ( c >= 'A' && c <= 'Z' ) {
+ c = static_cast<char>( c + 32 );
+ }
+ }
+
+ if ( base_tag == "title" ) {
+ std::string full_title;
+ for ( const auto& child : current->children ) {
+ if ( child.tag_name.empty() ) {
+ full_title += child.text_content;
+ }
+ }
+ if ( !full_title.empty() ) {
+ return full_title;
+ }
+ }
+
+ for ( const auto& child : std::ranges::reverse_view( current->children ) ) {
+ stack.push_back( &child );
+ }
+ }
+ return "";
+}
+
+PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp )
+{
+ std::string fname = "download.dat";
+ const std::size_t slash = req.url.rfind( '/' );
+ if ( slash != std::string::npos && slash + 1 < req.url.length() ) {
+ fname = req.url.substr( slash + 1 );
+ const std::size_t q_pos = fname.find( '?' );
+ if ( q_pos != std::string::npos ) {
+ fname = fname.substr( 0, q_pos );
+ }
+ }
+ if ( fname.empty() ) {
+ fname = "download.dat";
+ }
+
+ std::ofstream out( fname, std::ios::binary );
+ out.write( resp.body.data(), static_cast<std::streamsize>( resp.body.size() ) );
+ out.close();
+
+ return { .lines = { "\033[1;32m[ Download Complete ]\033[0m",
+ "",
+ "Saved File : " + fname,
+ "File Type : " + resp.content_type,
+ "File Size : " + std::to_string( resp.body.size() ) + " bytes",
+ "",
+ "Press 'H' to return to the previous page." },
+ .links = {},
+ .inputs = {},
+ .title = "Download Manager" };
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/utils.cc b/ricochet/src/core/utils.cc
new file mode 100644
index 0000000..a410c8a
--- /dev/null
+++ b/ricochet/src/core/utils.cc
@@ -0,0 +1,85 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/utils.hh"
+
+namespace ricochet::core {
+
+std::vector<std::string> split_into_lines( const std::string& text )
+{
+ std::vector<std::string> lines;
+ std::size_t start = 0;
+ while ( start < text.length() ) {
+ const std::size_t end = text.find( '\n', start );
+ if ( end == std::string::npos ) {
+ lines.push_back( text.substr( start ) );
+ break;
+ }
+ lines.push_back( text.substr( start, end - start ) );
+ start = end + 1;
+ }
+ return lines;
+}
+
+std::string normalize_url( const std::string& target, const std::string& current_url )
+{
+ if ( target.starts_with( "/" ) ) {
+ if ( target.starts_with( "//" ) ) {
+ return "https:" + target;
+ }
+ const std::size_t slash_pos = current_url.find( '/', 8 );
+ const std::string domain
+ = ( slash_pos == std::string::npos ) ? current_url : current_url.substr( 0, slash_pos );
+ return domain + target;
+ }
+ if ( !target.starts_with( "http" ) ) {
+ const std::size_t last_slash = current_url.rfind( '/' );
+ if ( last_slash > 7 ) {
+ return current_url.substr( 0, last_slash + 1 ) + target;
+ }
+ return current_url + "/" + target;
+ }
+ return target;
+}
+
+std::string strip_ddg_tracker( std::string target )
+{
+ const std::size_t uddg_pos = target.find( "uddg=" );
+ if ( uddg_pos != std::string::npos ) {
+ const std::size_t end_pos = target.find( '&', uddg_pos );
+ const std::string encoded = target.substr( uddg_pos + 5, end_pos - ( uddg_pos + 5 ) );
+ std::string decoded;
+ for ( std::size_t i = 0; i < encoded.length(); ++i ) {
+ if ( encoded[i] == '%' && i + 2 < encoded.length() ) {
+ const std::string hex = encoded.substr( i + 1, 2 );
+ try {
+ decoded += static_cast<char>( std::stoi( hex, nullptr, 16 ) );
+ } catch ( const std::exception& ) {
+ (void)0;
+ }
+ i += 2;
+ } else {
+ decoded += encoded[i];
+ }
+ }
+ return decoded;
+ }
+ return target;
+}
+
+} // namespace ricochet::core