use crate::ankiconnect::{Deck, Field, FieldValue, MediaFile, Model, Note as AnkiNote, Tag};
use crate::error::{Error, Result};
use crate::metadata::CompletedNote;
use crate::query;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::process::Command as AsyncCommand;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Format {
Plain,
Svg,
Png,
}
impl Format {
pub fn extension(&self) -> &'static str {
match self {
Format::Plain => "txt",
Format::Svg => "svg",
Format::Png => "png",
}
}
pub fn parse(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"plain" => Ok(Format::Plain),
"svg" => Ok(Format::Svg),
"png" => Ok(Format::Png),
other => Err(Error::custom(format!(
"unknown card format '{}' (expected \"svg\", \"png\", or \"plain\")",
other
))),
}
}
pub fn typst_arg(&self) -> &'static str {
match self {
Format::Plain => panic!("Plain format should not be compiled"),
Format::Svg => "svg",
Format::Png => "png",
}
}
}
#[derive(Debug, Clone)]
pub struct CompileConfig {
pub temp_file: PathBuf,
pub output_dir: PathBuf,
pub extra_args: Vec<String>,
pub completed_notes_metadata: Vec<CompletedNote>,
pub required_formats: Vec<Format>,
}
impl CompileConfig {
pub fn new(
temp_file: PathBuf,
output_dir: PathBuf,
completed_notes_metadata: Vec<CompletedNote>,
) -> Result<Self> {
Ok(Self {
temp_file,
output_dir,
extra_args: Vec::new(),
required_formats: query::determine_required_formats(&completed_notes_metadata)?,
completed_notes_metadata,
})
}
pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
self.extra_args = args;
self
}
}
#[derive(Debug)]
pub struct CompileResult {
pub notes: Vec<AnkiNote>,
pub output_files: HashMap<Format, Vec<PathBuf>>,
}
pub async fn compile_temp_file(config: &CompileConfig) -> Result<CompileResult> {
if config.completed_notes_metadata.is_empty() {
return Ok(CompileResult {
notes: Vec::new(),
output_files: HashMap::new(),
});
}
let futures: Vec<_> = config
.required_formats
.iter()
.map(|format| {
let format_clone = format.clone();
async move {
let files = compile_format(config, &format_clone).await;
(format_clone, files)
}
})
.collect();
let results = futures::future::join_all(futures).await;
let mut output_files = HashMap::new();
for (format, files_result) in results {
let files = files_result?;
output_files.insert(format, files);
}
let notes =
associate_files_with_notes(config, &config.completed_notes_metadata, &output_files).await?;
Ok(CompileResult {
notes,
output_files,
})
}
async fn compile_format(config: &CompileConfig, format: &Format) -> Result<Vec<PathBuf>> {
let output_pattern = config
.output_dir
.join(format!("output-{{p}}.{}", format.extension()));
let mut cmd = AsyncCommand::new("typst");
cmd.arg("compile").arg("--format").arg(format.typst_arg());
for arg in &config.extra_args {
cmd.arg(arg);
}
cmd.arg(&config.temp_file).arg(&output_pattern);
let output = cmd
.output()
.await
.map_err(|e| Error::custom(format!("Failed to execute typst compile: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::custom(format!(
"Typst compilation failed: {}",
stderr
)));
}
let mut output_files = Vec::new();
let output_dir = config.output_dir.as_path();
if output_dir.exists() {
let entries = fs::read_dir(output_dir).map_err(|e| {
Error::custom(format!(
"Failed to read output directory {}: {}",
output_dir.display(),
e
))
})?;
for entry in entries {
let entry = entry
.map_err(|e| Error::custom(format!("Failed to read directory entry: {}", e)))?;
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.starts_with("output-")
&& path
.extension()
.is_some_and(|ext| ext == format.extension())
{
output_files.push(path);
}
}
}
}
output_files.sort_by(|a, b| {
let extract_page_num = |path: &PathBuf| -> u32 {
path.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|s| s.strip_prefix("output-"))
.and_then(|s| s.parse().ok())
.unwrap_or(0)
};
extract_page_num(a).cmp(&extract_page_num(b))
});
Ok(output_files)
}
async fn associate_files_with_notes(
_config: &CompileConfig,
metadata_notes: &[CompletedNote],
output_files: &HashMap<Format, Vec<PathBuf>>,
) -> Result<Vec<AnkiNote>> {
let mut anki_notes = Vec::new();
let timestamp = chrono::Utc::now().timestamp();
let file_associations = create_file_associations(metadata_notes, output_files)?;
for (note_index, metadata_note) in metadata_notes.iter().enumerate() {
let mut fields = HashMap::new();
let mut picture_files = Vec::new();
let mut sorted_fields: Vec<_> = metadata_note.data.keys().collect();
sorted_fields.sort();
for field_name in sorted_fields {
let field_value = &metadata_note.data[field_name];
let field_format = Format::parse(field_value.format.as_str())?;
let output_file = file_associations.get(&(note_index, field_name.as_str()));
match field_format {
Format::Plain => {
fields.insert(
Field::new(field_name.clone()),
FieldValue::new(field_value.value.clone()),
);
}
Format::Svg => {
match output_file.and_then(|files| files.get(&Format::Svg)) {
Some(svg_path) => {
let svg = fs::read_to_string(svg_path).map_err(|e| {
Error::custom(format!(
"Failed to read SVG '{}': {}",
svg_path.display(),
e
))
})?;
fields.insert(
Field::new(field_name.clone()),
FieldValue::new(Some(theme_svg(&svg))),
);
}
None => {
fields.insert(
Field::new(field_name.clone()),
FieldValue::new(field_value.value.clone()),
);
}
}
}
Format::Png => {
if let Some(output_file) = output_file {
let media_file = create_media_file(
output_file,
&metadata_note.label,
field_name,
timestamp,
&field_format,
)?;
fields.insert(
Field::new(field_name.clone()),
FieldValue::new(Some(String::new())),
);
picture_files.push(media_file);
} else {
fields.insert(
Field::new(field_name.clone()),
FieldValue::new(field_value.value.clone()),
);
}
}
}
}
let anki_note = AnkiNote {
deck_name: Deck::new(metadata_note.deck.clone()),
model_name: Model::new(metadata_note.model.clone()),
fields,
tags: Some(
metadata_note
.tags
.iter()
.map(|tag| Tag::new(tag.clone()))
.collect(),
),
options: None,
audio: None,
video: None,
picture: if picture_files.is_empty() {
None
} else {
Some(picture_files)
},
};
anki_notes.push(anki_note);
}
Ok(anki_notes)
}
type FileAssociations<'a> = HashMap<(usize, &'a str), HashMap<Format, PathBuf>>;
fn create_file_associations<'a>(
metadata_notes: &'a [CompletedNote],
output_files: &'a HashMap<Format, Vec<PathBuf>>,
) -> Result<FileAssociations<'a>> {
let mut associations = HashMap::new();
let mut format_to_files: HashMap<Format, &Vec<PathBuf>> = HashMap::new();
for (format, files) in output_files.iter() {
format_to_files.insert(format.clone(), files);
}
let mut global_field_index = 0;
for (note_index, metadata_note) in metadata_notes.iter().enumerate() {
let mut sorted_fields: Vec<_> = metadata_note.data.keys().collect();
sorted_fields.sort();
for field_name in sorted_fields {
let mut field_files = HashMap::new();
for (format, files) in &format_to_files {
if global_field_index < files.len() {
field_files.insert(format.clone(), files[global_field_index].clone());
}
}
associations.insert((note_index, field_name.as_str()), field_files);
global_field_index += 1;
}
}
Ok(associations)
}
fn theme_svg(svg: &str) -> String {
svg.replace("fill=\"#000000\"", "fill=\"currentColor\"")
.replace("stroke=\"#000000\"", "stroke=\"currentColor\"")
}
fn sanitize_filename_part(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect()
}
pub fn create_media_file(
output_file: &HashMap<Format, PathBuf>,
note_label: &str,
field_name: &str,
timestamp: i64,
format: &Format,
) -> Result<MediaFile> {
let path = &output_file[format];
let absolute_path = std::fs::canonicalize(path).map_err(|e| {
Error::custom(format!(
"Failed to resolve output file path '{}': {}",
path.display(),
e
))
})?;
Ok(MediaFile {
filename: format!(
"{}@@{}@@{}.{}",
sanitize_filename_part(note_label),
sanitize_filename_part(field_name),
timestamp,
format.extension()
),
data: None,
path: Some(absolute_path.to_string_lossy().to_string()),
url: None,
skip_hash: None,
fields: Some(vec![Field::new(field_name.to_string())]),
})
}
pub fn cleanup_output_files(dir: &Path) -> Result<()> {
if !dir.exists() {
return Ok(()); }
let entries = fs::read_dir(dir)
.map_err(|e| Error::custom(format!("Failed to read directory {}: {}", dir.display(), e)))?;
for entry in entries {
let entry = entry.map_err(|e| {
Error::custom(format!(
"Failed to read directory entry in {}: {}",
dir.display(),
e
))
})?;
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.starts_with("output-") || filename.ends_with("_render.typ") {
fs::remove_file(&path).map_err(|e| {
Error::custom(format!(
"Failed to remove output file {}: {}",
path.display(),
e
))
})?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_parse_accepts_known_formats_case_insensitively() {
assert_eq!(Format::parse("svg").unwrap(), Format::Svg);
assert_eq!(Format::parse("PNG").unwrap(), Format::Png);
assert_eq!(Format::parse("Plain").unwrap(), Format::Plain);
}
#[test]
fn format_parse_rejects_unknown_formats() {
assert!(Format::parse("jpeg").is_err());
assert!(Format::parse("").is_err());
}
#[test]
fn format_extension_matches_the_format() {
assert_eq!(Format::Svg.extension(), "svg");
assert_eq!(Format::Png.extension(), "png");
assert_eq!(Format::Plain.extension(), "txt");
}
#[test]
fn theme_svg_recolours_black_to_currentcolor() {
let themed = theme_svg(r##"<path fill="#000000"/><path stroke="#000000"/>"##);
assert!(themed.contains(r#"fill="currentColor""#));
assert!(themed.contains(r#"stroke="currentColor""#));
assert!(!themed.contains("#000000"));
}
#[test]
fn theme_svg_leaves_non_black_colours_untouched() {
let svg = r##"<path fill="#0074d9"/>"##;
assert_eq!(theme_svg(svg), svg);
}
}