gd-tools/src_bac/anki_search.cpp

311 lines
9.4 KiB
C++
Raw Normal View History

2024-02-04 18:24:04 +00:00
/*
* gd-tools - a set of programs to enhance goldendict for immersion learning.
* Copyright (C) 2023 Ajatt-Tools
*
* 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 "anki_search.h"
#include "precompiled.h"
#include "util.h"
// reference
// https://camo.githubusercontent.com/192ba92ba3971bbb9eecede21c5f8d3a0a1afd52516af28a199335813e75f243/68747470733a2f2f692e696d6775722e636f6d2f6958335648484f2e706e67
// todo print tags (with links)
using namespace std::string_view_literals;
using json = nlohmann::json;
static constexpr auto ankiconnect_addr{ "127.0.0.1:8765"sv };
static constexpr std::chrono::seconds timeout{ 3U };
static constexpr std::size_t expected_n_fields{ 10 };
static constexpr std::string_view help_text = R"EOF(usage: gd-ankisearch [OPTIONS]
Search your Anki collection and output Note Ids that match query.
OPTIONS
--field-name NAME optional field to limit search to.
--deck-name NAME optional deck to limit search to.
--show-fields F1,F2 optional comma-separated list of fields to show.
--word WORD required search term
EXAMPLES
gd-ankisearch --field-name VocabKanji --word %GDWORD%
gd-ankisearch --deck-name Mining --word %GDWORD%
)EOF";
static constexpr std::string_view css_style = R"EOF(<style>
.gd-ankisearch-table {
font-family: arial, sans-serif;
border-collapse: collapse;
border-spacing: 0;
max-width: 100%;
width: 100%;
display: block;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.gd-ankisearch-table td,
.gd-ankisearch-table th {
border: 1px solid #dddddd;
text-align: left;
padding: 4px;
}
.gd-ankisearch-table tr:nth-child(odd) {
background-color: #dddddd55;
}
.gd-ankisearch-table tr:first-child {
background-color: #00000011;
}
.gd-ankisearch-table tr.new {
border-bottom: 2px solid #3b82f6;
background-color: #3b82f611;
}
.gd-ankisearch-table tr.learning {
border-bottom: 2px solid #dc2626;
background-color: #dc262611;
}
.gd-ankisearch-table tr.review {
border-bottom: 2px solid #16a34a;
background-color: #16a34a11;
}
.gd-ankisearch-table tr.buried {
border-bottom: 2px solid #858585;
background-color: #85858511;
}
.gd-ankisearch-table tr.suspended {
border-bottom: 2px solid #ffe88d;
background-color: #ffe88d11;
}
.gd-ankisearch-table img {
max-height: 200px;
max-width: 200px;
}
.gd-ankisearch-table a {
color: #1b2c5d;
text-decoration: none;
}
.gd-ankisearch-table a:hover {
filter: hue-rotate(-20deg) brightness(120%);
}
</style>
)EOF";
using NameToValMap = std::unordered_map<std::string, std::string>;
struct card_info
{
uint64_t id;
int64_t queue;
int64_t type;
std::string deck_name;
NameToValMap fields;
};
auto split_anki_field_names(std::string_view const show_fields) -> std::vector<std::string>
{
// Expects a list of Anki fields separated by commas.
std::vector<std::string> parsed{};
parsed.reserve(expected_n_fields);
std::ranges::copy(
show_fields //
| std::views::split(',') //
| std::views::transform([](auto v) { return std::string(v.begin(), v.end()); }), //
std::back_inserter(parsed)
);
return parsed;
}
struct search_params
{
std::string_view gd_word{};
std::string_view field_name{};
std::string_view deck_name{};
std::vector<std::string> show_fields{};
void assign(std::string_view const key, std::string_view const value)
{
if (key == "--field-name") {
field_name = value;
} else if (key == "--deck-name") {
deck_name = value;
} else if (key == "--show-fields") {
show_fields = split_anki_field_names(value);
} else if (key == "--word") {
gd_word = value;
}
}
};
auto make_find_cards_request_str(search_params const& params) -> std::string
{
auto request = json::parse(R"EOF({
"action": "findCards",
"version": 6,
"params": {
"query": "deck:current"
}
})EOF");
std::string query{ params.gd_word };
if (not params.field_name.empty()) {
query = fmt::format("\"{}:*{}*\"", params.field_name, query);
}
if (not params.deck_name.empty()) {
query = fmt::format("\"deck:{}\" {}", params.deck_name, query);
}
request["params"]["query"] = query;
return request.dump();
}
auto make_get_media_dir_path_request_str() -> std::string
{
return R"EOF({
"action": "getMediaDirPath",
"version": 6
})EOF";
}
auto make_ankiconnect_request(std::string_view const request_str) -> cpr::Response
{
return cpr::Post(
cpr::Url{ ankiconnect_addr },
cpr::Body{ request_str },
cpr::Header{ { "Content-Type", "application/json" } },
cpr::Timeout{ timeout }
);
}
auto make_info_request_str(std::vector<uint64_t> const& cids)
{
auto request = json::parse(R"EOF({
"action": "cardsInfo",
"version": 6,
"params": {
"cards": []
}
})EOF");
request["params"]["cards"] = cids;
return request.dump();
}
auto get_cids_info(std::vector<uint64_t> const& cids) -> nlohmann::json
{
auto const request_str = make_info_request_str(cids);
cpr::Response const r = make_ankiconnect_request(request_str);
raise_if(r.status_code != cpr::status::HTTP_OK, "Couldn't connect to Anki.");
auto const obj = json::parse(r.text);
raise_if(not obj["error"].is_null(), "Error getting data from AnkiConnect.");
return obj["result"];
}
auto find_cids(search_params const& params) -> std::vector<uint64_t>
{
auto const request_str = make_find_cards_request_str(params);
cpr::Response const r = make_ankiconnect_request(request_str);
raise_if(r.status_code != cpr::status::HTTP_OK, "Couldn't connect to Anki.");
auto const obj = json::parse(r.text);
raise_if(not obj["error"].is_null(), "Error getting data from AnkiConnect.");
return obj["result"];
}
auto fetch_media_dir_path() -> std::string
{
auto const request_str = make_get_media_dir_path_request_str();
cpr::Response const r = make_ankiconnect_request(request_str);
raise_if(r.status_code != cpr::status::HTTP_OK, "Couldn't connect to Anki.");
auto const obj = json::parse(r.text);
raise_if(not obj["error"].is_null(), "Error getting data from AnkiConnect.");
return obj["result"];
}
void print_table_header(search_params const& params)
{
// Print the first row (header) that contains <th></th> tags, starting with Card ID.
fmt::print("<tr>");
fmt::print("<th>Card ID</th>");
fmt::print("<th>Deck name</th>");
for (auto const& field: params.show_fields) { fmt::print("<th>{}</th>", field); }
fmt::print("</tr>\n");
}
auto card_json_to_obj(nlohmann::json const& card_json) -> card_info
{
// https://github.com/FooSoft/anki-connect#cardsinfo
return {
.id = card_json["cardId"],
.queue = card_json["queue"],
.type = card_json["type"],
.deck_name = card_json["deckName"], //
.fields =
[&card_json]() {
NameToValMap result{};
for (auto const& element: card_json["fields"].items()) {
result.emplace(element.key(), element.value()["value"]);
}
return result;
}(), //
};
}
auto gd_format(std::string const& field_content, std::string const& media_dir_path) -> std::string
{
// Make sure GoldenDict displays images correctly by specifying the full path.
static std::regex const img_re{ "(<img[^<>]*src=\")" };
static std::regex const any_undesirables{ R"EOF(\[sound:|\]|<[^<>]+>|["'.,!?]+|||||| |||\(|\))EOF" };
auto const link_content = strtrim(std::regex_replace(field_content, any_undesirables, " "));
auto const link_text = std::regex_replace(field_content, img_re, fmt::format("$1file://{}/", media_dir_path));
return link_content.empty() ? link_text : fmt::format("<a href=\"ankisearch:{}\">{}</a>", link_content, link_text);
}
void print_cards_info(search_params const& params)
{
auto const cids = find_cids(params);
if (cids.empty()) {
return fmt::print("No cards found.\n");
}
auto const media_dir_path = fetch_media_dir_path();
fmt::print("<table class=\"gd-ankisearch-table\">\n");
print_table_header(params);
for (auto const& card: get_cids_info(cids) | std::views::transform(card_json_to_obj)) {
fmt::print("<tr class=\"{}\">", determine_card_class(card.queue, card.type));
fmt::print("<td><a href=\"ankisearch:cid:{}\">{}</a></td>", card.id, card.id);
fmt::print("<td>{}</td>", card.deck_name);
for (auto const& field_name: params.show_fields) {
fmt::print(
"<td>{}</td>",
(card.fields.contains(field_name) and not card.fields.at(field_name).empty()
? gd_format(card.fields.at(field_name), media_dir_path)
: "Not present")
);
}
fmt::print("</tr>\n");
}
fmt::print("</table>\n");
fmt::print("{}\n", css_style);
}
void search_anki_cards(std::span<std::string_view const> const args)
{
try {
print_cards_info(fill_args<search_params>(args));
} catch (gd::help_requested const& ex) {
fmt::print(help_text);
} catch (gd::runtime_error const& ex) {
fmt::print("{}\n", ex.what());
}
}