use std::fs;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
use clap::Parser;
use glob::Pattern;
use once_cell::sync::OnceCell;
use serde_json::json;
use toml::Value as TomlValue;
use html_escape::encode_double_quoted_attribute;
use crate::card_wrapper::CardInfo;
use crate::utils;
use std::sync::{Arc, RwLock};
pub const DEFAULT_CONFIG_FILENAME: &str = "typ2anki.toml";
#[derive(Parser, Debug)]
#[command(about = "Typ2Anki config parser")]
struct Cli {
#[arg(long = "config-file", default_value = DEFAULT_CONFIG_FILENAME)]
config_file: String,
#[arg(long = "check-duplicates")]
check_duplicates: bool,
#[arg(short = 'e', long = "exclude-decks", action = clap::ArgAction::Append)]
exclude_decks: Vec<String>,
#[arg(long = "exclude-files", action = clap::ArgAction::Append)]
exclude_files: Vec<String>,
#[arg(long = "generation-concurrency", default_value = "")]
generation_concurrency: String,
#[arg(long = "max-card-width", default_value = "auto")]
max_card_width: String,
#[arg(long = "no-cache")]
no_cache: bool,
#[arg(long = "recompile-on-config-change", default_value = "_")]
recompile_on_config_change: String,
#[arg(long = "dry-run")]
dry_run: bool,
#[arg(long = "print-config", hide = true)]
print_config: bool,
#[arg(value_parser, num_args = 0..)]
path: Vec<String>,
}
fn load_toml_config(path: &Path) -> Option<TomlValue> {
if !path.exists() {
return None;
}
match fs::read_to_string(path) {
Ok(s) => match s.parse::<TomlValue>() {
Ok(v) => Some(v),
Err(e) => panic!("Error parsing TOML {}: {}", path.display(), e),
},
Err(e) => panic!("Error reading config file {}: {}", path.display(), e),
}
}
fn get_real_path_simple(p: &str) -> String {
match fs::canonicalize(p) {
Ok(p) => p.to_string_lossy().to_string(),
Err(_) => p.to_string(),
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub check_duplicates: bool,
pub exclude_decks: Vec<Pattern>,
pub exclude_decks_string: Vec<String>,
pub exclude_files: Vec<Pattern>,
pub asked_path: String,
pub path: PathBuf,
pub recompile_on_config_change: Arc<RwLock<Option<bool>>>,
pub dry_run: bool,
pub max_card_width: String,
pub skip_cache: bool,
pub generation_concurrency: usize,
pub is_zip: bool,
pub config_hash: Option<String>,
pub output_type: String,
pub typst_input: Vec<(String, String)>,
}
impl Config {
pub fn is_deck_excluded(&self, deck_name: &str) -> bool {
self.exclude_decks.iter().any(|p| p.matches(deck_name))
}
pub fn is_file_excluded(&self, file_name: &str) -> bool {
self.exclude_files.iter().any(|p| p.matches(file_name))
}
pub fn template_front(&self, _card_info: &CardInfo, front_image_path: &str) -> String {
format!(
r#"<img src="{}">"#,
encode_double_quoted_attribute(front_image_path)
)
}
pub fn template_back(&self, _card_info: &CardInfo, back_image_path: &str) -> String {
format!(
r#"<img src="{}">"#,
encode_double_quoted_attribute(back_image_path)
)
}
pub fn destruct(&self) {
if self.dry_run {
println!("Destroying config (dry run)");
}
if self.is_zip && self.asked_path != self.path.to_string_lossy() {
if let Err(e) = fs::remove_dir_all(&self.path) {
eprintln!(
"Warning: Failed to remove temporary extracted zip directory {}: {}",
self.path.display(),
e
);
}
}
}
pub fn compute_hash(&mut self) {
let relevant_config = json!({
"output_type": self.output_type,
"max_card_width": self.max_card_width,
"exclude_decks": self.exclude_decks_string.clone().sort(),
});
let relevant_config = utils::json_sorted_keys(&relevant_config);
let s = serde_json::to_string(&relevant_config).unwrap();
self.config_hash = Some(utils::hash_string(&s));
}
}
pub struct ConfigGuard;
impl Drop for ConfigGuard {
fn drop(&mut self) {
let _ = std::panic::catch_unwind(|| {
let cfg = get();
cfg.destruct();
});
}
}
fn parse_generation_concurrency(s: &str) -> usize {
if s.is_empty() {
1
} else if s == "max" {
num_cpus::get()
} else {
s.parse::<usize>().unwrap_or(1).max(1)
}
}
pub fn parse_config() -> Config {
let cli = Cli::parse();
let asked_path = if cli.path.is_empty() {
".".to_string()
} else {
cli.path.join(" ")
};
let mut check_duplicates = cli.check_duplicates;
let mut exclude_decks = cli.exclude_decks.clone();
let mut exclude_files = cli.exclude_files.clone();
let mut dry_run = cli.dry_run;
let mut max_card_width = cli.max_card_width.clone();
let mut skip_cache = cli.no_cache;
let mut generation_concurrency = parse_generation_concurrency(&cli.generation_concurrency);
let mut recompile_on_config_change = cli.recompile_on_config_change.clone();
let mut path = get_real_path_simple(&asked_path);
let is_zip = if path.to_lowercase().ends_with(".zip") {
true
} else {
false
};
if is_zip {
let dir = tempdir()
.expect("Failed to create temporary directory for zip extraction")
.path()
.to_path_buf();
path = dir.to_string_lossy().to_string();
}
if !cli.config_file.is_empty() {
let config_file_path = Path::new(&path).join(&cli.config_file);
if let Some(table) = load_toml_config(&config_file_path) {
if !check_duplicates {
if let Some(v) = table.get("check_duplicates") {
if let Some(b) = v.as_bool() {
check_duplicates = b;
}
}
}
if exclude_decks.is_empty() {
if let Some(v) = table.get("exclude_decks").and_then(|x| x.as_array()) {
exclude_decks = v
.iter()
.filter_map(|e| e.as_str().map(|s| s.to_string()))
.collect();
}
}
if exclude_files.is_empty() {
if let Some(v) = table.get("exclude_files").and_then(|x| x.as_array()) {
exclude_files = v
.iter()
.filter_map(|e| e.as_str().map(|s| s.to_string()))
.collect();
}
}
if !dry_run {
if let Some(v) = table.get("dry_run").and_then(|x| x.as_bool()) {
dry_run = v;
}
}
if max_card_width == "auto" {
if let Some(v) = table.get("max_card_width").and_then(|x| x.as_str()) {
max_card_width = v.to_string();
}
}
if table.get("check_checksums").is_some() && cli.no_cache {
} else if cli.no_cache == false {
if let Some(v) = table.get("check_checksums").and_then(|x| x.as_bool()) {
skip_cache = v;
}
}
if generation_concurrency == 1 {
if let Some(v) = table.get("generation_concurrency").and_then(|x| {
Some(parse_generation_concurrency(
x.as_str()
.unwrap_or(x.as_integer().unwrap_or(1).to_string().as_str()),
))
}) {
generation_concurrency = v.max(1) as usize;
}
}
if recompile_on_config_change == "_" {
if let Some(v) = table
.get("recompile_on_config_change")
.and_then(|x| x.as_str())
{
recompile_on_config_change = v.to_string();
}
}
}
}
let mut typst_input: Vec<(String, String)> = Vec::new();
typst_input.push(("typ2anki_compile".to_string(), "1".to_string()));
if max_card_width != "auto" {
typst_input.push(("max_card_width".to_string(), max_card_width.clone()));
}
if !check_duplicates && generation_concurrency > 1 {
eprintln!("WARNING: Concurrent generation can't be enabled without duplicate checking. Disabling concurrent generation.");
generation_concurrency = 1;
} else if generation_concurrency > num_cpus::get() {
eprintln!("WARNING: Requested generation concurrency ({}) exceeds number of CPU cores ({}). It is inefficient. Reducing to {}. You can set generation-concurrency to 'max' so that it always takes the amount of logical threads on a given machine.", generation_concurrency, num_cpus::get(), num_cpus::get());
generation_concurrency = num_cpus::get();
}
let mut cfg = Config {
check_duplicates,
exclude_decks: exclude_decks
.iter()
.map(|s| Pattern::new(s).unwrap_or_default())
.collect(),
exclude_files: exclude_files
.iter()
.map(|s| Pattern::new(s).unwrap_or_default())
.collect(),
exclude_decks_string: exclude_decks,
asked_path: asked_path.clone(),
path: PathBuf::from(path),
recompile_on_config_change: Arc::new(
match recompile_on_config_change.to_ascii_lowercase().as_str() {
"y" | "yes" => Some(true),
"n" | "no" => Some(false),
"_" => None,
_ => None,
}
.into(),
),
dry_run,
max_card_width,
skip_cache,
generation_concurrency,
is_zip,
config_hash: None,
output_type: "png".to_string(),
typst_input,
};
cfg.compute_hash();
cfg
}
static CACHED_CONFIG: OnceCell<Config> = OnceCell::new();
pub fn get() -> &'static Config {
CACHED_CONFIG.get_or_init(|| parse_config())
}