gd-tools/src_bac/anki_search.cpp
2024-02-04 14:24:04 -04:00

311 lines
9.4 KiB
C++
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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());
}
}