use once_cell::sync::OnceCell;
use reqwest::blocking::Client;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use crate::card_wrapper::CardInfo;
use crate::{config, utils};
const ANKI_CONNECT_URL: &str = "http://localhost:8765";
pub const CARDS_CACHE_FILENAME: &str = "_typ-cards-cache.json";
fn _handle_response(resp: reqwest::blocking::Response) -> Result<Value, String> {
let v: Value = resp
.json()
.map_err(|e| format!("invalid json response: {}", e))?;
if let Some(err) = v.get("error")
&& !err.is_null()
{
return Err(format!("Anki API Error: {}", err));
}
Ok(v.get("result").cloned().unwrap_or(Value::Null))
}
fn send_request(payload: Value) -> Result<Value, String> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("reqwest build error: {}", e))?;
_handle_response(
client
.post(ANKI_CONNECT_URL)
.json(&payload)
.send()
.map_err(|e| {
format!(
"request error: {:?} (status: {}, action {:?})",
e,
e.status().unwrap_or_default(),
payload.get("action").cloned().unwrap_or(Value::Null)
)
})?,
)
}
pub fn check_anki_running() -> bool {
let client = Client::builder().timeout(Duration::from_secs(3)).build();
if client.is_err() {
return false;
}
let client = client.unwrap();
let resp = client.get(ANKI_CONNECT_URL).send();
if resp.is_err() {
return false;
}
let v: Result<Value, _> = resp.unwrap().json();
if let Ok(json) = v {
return json.get("apiVersion").is_some();
}
false
}
pub fn upload_file(filename: String, base64_data: &String) -> Result<String, String> {
let payload = json!({
"action": "storeMediaFile",
"version": 6,
"params": {
"filename": filename,
"data": base64_data
}
});
send_request(payload)?;
Ok(filename)
}
#[allow(dead_code)]
pub fn get_media_dir_path() -> Result<String, String> {
let payload = json!({
"action": "getMediaDirPath",
"version": 6
});
let res = send_request(payload)?;
res.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "unexpected response".to_string())
}
pub fn get_cards_cache_string() -> Option<String> {
let payload = json!({
"action": "retrieveMediaFile",
"version": 6,
"params": { "filename": CARDS_CACHE_FILENAME }
});
match send_request(payload) {
Ok(val) => {
if let Some(s) = val.as_str() {
match utils::b64_decode(s) {
Ok(bytes) => String::from_utf8(bytes).ok(),
Err(_) => None,
}
} else {
None
}
}
Err(_) => None,
}
}
pub fn create_deck(deck_name: &str) -> Result<(), String> {
let payload = json!({
"action": "createDeck",
"version": 6,
"params": { "deck": deck_name }
});
send_request(payload)?;
Ok(())
}
pub fn get_deck_names() -> Vec<String> {
let payload = json!({ "action": "deckNames", "version": 6 });
match send_request(payload) {
Ok(val) => val
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
Err(_) => Vec::new(),
}
}
static CACHED_DECK_NAMES: OnceCell<Vec<String>> = OnceCell::new();
static ANKI_DECK_MAP: OnceCell<Mutex<HashMap<String, String>>> = OnceCell::new();
pub fn get_anki_deck_name(typ_deck_name: &str) -> String {
let map = ANKI_DECK_MAP.get_or_init(|| Mutex::new(HashMap::new()));
let guard = map.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cached_name) = guard.get(typ_deck_name) {
return cached_name.clone();
}
drop(guard);
let cached = CACHED_DECK_NAMES.get_or_init(get_deck_names);
let s = format!("::{}", typ_deck_name);
let result = cached
.iter()
.find(|&name| name.ends_with(&s))
.cloned()
.unwrap_or_else(|| typ_deck_name.to_string());
let mut guard = map.lock().unwrap_or_else(|e| e.into_inner());
guard.insert(typ_deck_name.to_string(), result.clone());
result
}
pub fn find_note_id_by_tag(tag: &str) -> Result<Vec<i64>, String> {
let payload = json!({
"action": "findNotes",
"version": 6,
"params": { "query": format!("tag:{}", tag) }
});
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("reqwest build error: {}", e))?;
let res = send_request_and_retry(&client, payload)?;
if let Some(arr) = res.as_array() {
let mut out = Vec::new();
for v in arr {
if let Some(n) = v.as_i64() {
out.push(n);
}
}
Ok(out)
} else {
Ok(Vec::new())
}
}
type ModelInfo = (String, (String, String));
static CACHED_BASICAL_MODEL_NAME: OnceCell<ModelInfo> = OnceCell::new();
const BASIC_MODEL_LOCALES: [&str; 3] = ["Basic", "Basique", "Grundlegend"];
fn _get_basic_model_name() -> Result<ModelInfo, String> {
let payload = json!({ "action": "modelNames", "version": 6 });
let models = send_request(payload)?;
let model_list = models
.as_array()
.ok_or_else(|| "modelNames returned unexpected type".to_string())?;
let mut basic_model_name: Option<String> = None;
'outer: for locale in &BASIC_MODEL_LOCALES {
for v in model_list {
if let Some(s) = v.as_str()
&& s == *locale
{
basic_model_name = Some(s.to_string());
break 'outer;
}
}
}
let model_name = basic_model_name.ok_or_else(|| "Basic model not found in Anki".to_string())?;
let payload2 = json!({
"version": 6,
"action": "modelFieldNames",
"params": { "modelName": model_name }
});
let fields_val = send_request(payload2)?;
let fields = fields_val
.as_array()
.ok_or_else(|| "modelFieldNames returned unexpected type".to_string())?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>();
if fields.len() != 2 {
return Err(format!(
"Basic model should have 2 fields, but found {}",
fields.len()
));
}
Ok((model_name, (fields[0].clone(), fields[1].clone())))
}
fn get_basic_model_name() -> &'static ModelInfo {
CACHED_BASICAL_MODEL_NAME.get_or_init(|| {
_get_basic_model_name().unwrap_or((
"Basic".to_string(),
("Front".to_string(), "Back".to_string()),
))
})
}
pub fn send_request_and_retry(client: &Client, payload: Value) -> Result<Value, String> {
let mut attempts = 0;
loop {
match client.post(ANKI_CONNECT_URL).json(&payload).send() {
Ok(res) => return _handle_response(res),
Err(e) => {
if attempts <= 2 && e.status().is_none() {
attempts += 1;
} else {
return Err(format!(
"request error: {} (status: {}, action: {:?})",
e,
e.status().unwrap_or_default(),
payload.get("action").cloned().unwrap_or(Value::Null)
));
}
}
}
}
}
pub struct CardUploaderThread {
client: Client,
}
impl CardUploaderThread {
pub fn new() -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to build reqwest client");
Self { client }
}
fn upload_file(&self, filename: String, base64_data: &String) -> Result<String, String> {
let payload = json!({
"action": "storeMediaFile",
"version": 6,
"params": {
"filename": filename,
"data": base64_data
}
});
send_request_and_retry(&self.client, payload)?;
Ok(filename)
}
pub fn upload_card(
&self,
card: &CardInfo,
front_data_base64: &String,
back_data_base64: &String,
) -> Result<(), String> {
let cfg = config::get();
if cfg.dry_run {
return Ok(());
}
self.upload_file(card.image_path(1), front_data_base64)?;
self.upload_file(card.image_path(2), back_data_base64)?;
let note_ids = find_note_id_by_tag(&card.card_id)?;
let tags = vec![card.card_id.clone()];
let payload = if !note_ids.is_empty() {
let note_id = note_ids[0];
json!({
"action": "updateNoteFields",
"version": 6,
"params": {
"note": {
"id": note_id,
"fields": {
"Front": cfg.template_front(card,card.image_path(1).as_str()),
"Back": cfg.template_back(card,card.image_path(2).as_str()),
},
"tags": tags
}
}
})
} else {
let (model_name, (model_field_front, model_field_back)) = get_basic_model_name();
json!({
"action": "addNote",
"version": 6,
"params": {
"note": {
"deckName": card.anki_deck_name,
"modelName": model_name,
"fields": {
model_field_front: cfg.template_front(card,card.image_path(1).as_str()),
model_field_back: cfg.template_back(card,card.image_path(2).as_str()),
},
"tags": tags
}
}
})
};
send_request_and_retry(&self.client, payload)?;
Ok(())
}
}