ankify 0.1.0

Generate and sync Anki flashcards from your Typst documents.
Documentation
//! Module dealing with querying Typst files for metadata.

use std::path::Path;

use crate::compile::Format;
use crate::metadata::{
    AnkiConnectChecks, CacheOptions, Checks, CompletedNote, CompletedNoteDataValueWithFormat,
    CompletedTypstAnkifyConfiguration, Note, NoteDataValue, NoteDataValueWithFormat, NoteDefaults,
    TypstAnkifyConfiguration,
};

use crate::error::{Error, Result};
use tokio::process::Command;

/// Function that essentially executes the following shell command:
/// ```sh
/// typst query <file> "<ankify-configuration>"
/// ```
///
/// Should return `TypstAnkifyConfiguration` if successful
pub async fn query_ankify_configuration(
    typst_file: &Path,
    extra_args: Option<&[&str]>,
) -> Result<CompletedTypstAnkifyConfiguration> {
    let values = query_typst_metadata(typst_file, "ankify-configuration", extra_args).await?;

    // A document need not call `configure()` at all — fall back to defaults in
    // that case. If `configure()` is called more than once, the last call
    // reflects the most up-to-date (merged) configuration.
    let config: TypstAnkifyConfiguration = match values.last() {
        Some(config_value) => serde_json::from_value(config_value.clone())
            .map_err(|e| Error::typst(format!("Failed to parse ankify configuration: {}", e)))?,
        None => TypstAnkifyConfiguration {
            ankiconnect_url: None,
            verbose: None,
            setup: None,
            cache: None,
            checks: None,
            defaults: None,
        },
    };

    // Apply defaults to the configuration and convert to completed version
    Ok(apply_configuration_defaults(config))
}

/// Function that essentially executes the following shell command:
/// ```sh
/// typst query <file> "<ankify-note>"
/// ```
///
/// Should return `Vec<Note>` if successful
pub async fn query_ankify_notes(
    typst_file: &Path,
    extra_args: Option<&[&str]>,
) -> Result<Vec<Note>> {
    let values = query_typst_metadata(typst_file, "ankify-note", extra_args).await?;

    let mut notes = Vec::new();
    for value in values {
        let note: Note = serde_json::from_value(value)
            .map_err(|e| Error::typst(format!("Failed to parse ankify note: {}", e)))?;
        notes.push(note);
    }

    Ok(notes)
}

/// Helper function to apply defaults to a single note based on configuration
fn apply_defaults_to_note(note: &mut Note, config: &CompletedTypstAnkifyConfiguration) {
    // Apply string field defaults
    if note.model.is_empty() {
        note.model = config.defaults.model.clone().unwrap_or_default();
    }
    if note.deck.is_empty() {
        note.deck = config.defaults.deck.clone().unwrap_or_default();
    }
    if note.tags.is_empty() {
        note.tags = config.defaults.tags.clone().unwrap_or_default();
    }

    // Apply format default
    if note.format.is_none() {
        note.format = config
            .defaults
            .format
            .clone()
            .or_else(|| Some("png".to_string()));
    }

    // Apply other field defaults
    if note.other.as_object().is_none_or(|obj| obj.is_empty()) {
        note.other = config.defaults.other.clone().unwrap_or_default();
    }
}

/// Helper function to normalize data fields to WithFormat variant
fn normalize_data_fields(note: &mut Note) {
    // Convert Simple values to WithFormat and ensure all have formats
    for (_key, value) in note.data.iter_mut() {
        match value {
            NoteDataValue::Simple(simple_val) => {
                *value = NoteDataValue::WithFormat(NoteDataValueWithFormat {
                    value: simple_val.clone(),
                    format: note.format.clone(),
                });
            }
            NoteDataValue::WithFormat(with_format) => {
                if with_format.format.is_none() {
                    with_format.format = note.format.clone();
                }
            }
        }
    }
}

/// Helper function to convert Note to CompletedNote
fn note_to_completed_note(note: Note) -> CompletedNote {
    let default_format = note.format.unwrap_or_else(|| "png".to_string());

    CompletedNote {
        label: note.label,
        model: note.model,
        deck: note.deck,
        tags: note.tags,
        other: note.other,
        format: default_format.clone(),
        data: note
            .data
            .into_iter()
            .map(|(key, value)| {
                let completed_value = match value {
                    NoteDataValue::Simple(val) => CompletedNoteDataValueWithFormat {
                        value: val,
                        format: default_format.clone(),
                    },
                    NoteDataValue::WithFormat(val_with_format) => {
                        CompletedNoteDataValueWithFormat {
                            value: val_with_format.value,
                            format: val_with_format
                                .format
                                .unwrap_or_else(|| default_format.clone()),
                        }
                    }
                };
                (key, completed_value)
            })
            .collect(),
    }
}

/// Complete ankify notes metadata by applying defaults and converting to CompletedNote
pub fn complete_ankify_notes_metadata(
    mut ankify_notes: Vec<Note>,
    config: &CompletedTypstAnkifyConfiguration,
) -> Vec<CompletedNote> {
    // Apply defaults and normalize each note
    for note in &mut ankify_notes {
        apply_defaults_to_note(note, config);
        normalize_data_fields(note);
    }

    // Convert to CompletedNote
    ankify_notes
        .into_iter()
        .map(note_to_completed_note)
        .collect()
}

/// Determine which formats need to be compiled based on (completed) note metadata.
pub fn determine_required_formats(completed_notes: &[CompletedNote]) -> Result<Vec<Format>> {
    let mut formats = std::collections::HashSet::new();

    for note in completed_notes {
        // Check field-specific formats if data has format specifications
        for value in note.data.values() {
            let format = Format::parse(value.format.as_str())?;
            // Skip plain format since it doesn't need compilation
            if format != Format::Plain {
                formats.insert(format);
            }
        }
    }

    Ok(formats.into_iter().collect())
}

/// Apply defaults to configuration fields and convert to CompletedTypstAnkifyConfiguration
fn apply_configuration_defaults(
    config: TypstAnkifyConfiguration,
) -> CompletedTypstAnkifyConfiguration {
    // Apply defaults for cache
    let cache = config.cache.unwrap_or(CacheOptions {
        enabled: Some(true),
        custom_file: None,
    });
    let completed_cache = CacheOptions {
        enabled: Some(cache.enabled.unwrap_or(true)),
        custom_file: cache.custom_file,
    };

    // Apply defaults for checks
    let checks = config.checks.unwrap_or(Checks {
        typst: Some(true),
        ankiconnect: Some(AnkiConnectChecks {
            model: Some(true),
            deck: Some(true),
            tags: Some(true),
        }),
    });
    let completed_checks = Checks {
        typst: Some(checks.typst.unwrap_or(true)),
        ankiconnect: Some({
            let ankiconnect = checks.ankiconnect.unwrap_or(AnkiConnectChecks {
                model: Some(true),
                deck: Some(true),
                tags: Some(true),
            });
            AnkiConnectChecks {
                model: Some(ankiconnect.model.unwrap_or(true)),
                deck: Some(ankiconnect.deck.unwrap_or(true)),
                tags: Some(ankiconnect.tags.unwrap_or(true)),
            }
        }),
    };

    // Apply defaults for note defaults
    let defaults = config.defaults.unwrap_or(NoteDefaults {
        model: Some("Basic".to_string()),
        deck: Some("Default".to_string()),
        tags: Some(Vec::new()),
        other: None,
        format: Some("png".to_string()),
        render: None,
    });
    let completed_defaults = NoteDefaults {
        model: Some(defaults.model.unwrap_or("Basic".to_string())),
        deck: Some(defaults.deck.unwrap_or("Default".to_string())),
        tags: Some(defaults.tags.unwrap_or_default()),
        other: defaults.other,
        format: Some(defaults.format.unwrap_or("png".to_string())),
        render: defaults.render,
    };

    CompletedTypstAnkifyConfiguration {
        ankiconnect_url: config
            .ankiconnect_url
            .unwrap_or("http://127.0.0.1:8765".to_string()),
        verbose: config.verbose.unwrap_or(false),
        setup: config.setup.unwrap_or(serde_json::Value::Null),
        cache: completed_cache,
        checks: completed_checks,
        defaults: completed_defaults,
    }
}

/// Query Typst for metadata with a specific label and additional CLI options.
/// This allows tests to pass custom flags like --root.
///
/// # Examples
///
/// ```sh
/// typst query example.typ "<ankify-configuration>" --field value
/// ```
///
/// might return this:
///
/// ```json
/// [{"ankiconnect-url":"http://127.0.0.1:8765","verbose":true,"defaults":{},"render":"ankify-render","cache":{"enabled":true,"custom-file":null},"checks":{"typst":{"data":true,"format":true},"ankiconnect":{"model":true,"deck":true,"tags":true}}}]
/// ```
///
/// Meanwhile,
///
/// ```sh
/// typst query example.typ "<ankify-note>" --field value
/// ```
///
/// might return this:
///
/// ```json
/// [{"label":"pythagoras-theorem","data":{"Front":"What is the Pythagorean theorem?","Back":{"func":"sequence","children":[{"func":"text","text":"test"},{"func":"space"},{"func":"equation","block":false,"body":{"func":"sequence","children":[{"func":"attach","base":{"func":"op","text":{"func":"text","text":"lim"},"limits":true},"b":{"func":"sequence","children":[{"func":"symbol","text":"n"},{"func":"space"},{"func":"symbol","text":"→"},{"func":"space"},{"func":"symbol","text":"∞"}]}},{"func":"space"},{"func":"frac","num":{"func":"symbol","text":"n"},"denom":{"func":"text","text":"2"}}]}},{"func":"space"},{"func":"symbol","text":"…"},{"func":"space"},{"func":"rect","height":"0% + 100pt","fill":"rgb(\"#0074d9\")"}]}},"model":"Basic","tags":["str"],"deck":"Ankify-Test","other":"dictionary","format":"png"},{"label":"quadratic-formula","data":{"Front":{"func":"text","text":"What is the quadratic formula?"},"Back":{"func":"sequence","children":[{"func":"text","text":"For"},{"func":"space"},{"func":"equation","block":false,"body":{"func":"sequence","children":[{"func":"symbol","text":"a"},{"func":"space"},{"func":"attach","base":{"func":"symbol","text":"x"},"t":{"func":"text","text":"2"}},{"func":"space"},{"func":"symbol","text":"+"},{"func":"space"},{"func":"symbol","text":"b"},{"func":"space"},{"func":"symbol","text":"x"},{"func":"space"},{"func":"symbol","text":"+"},{"func":"space"},{"func":"symbol","text":"c"},{"func":"space"},{"func":"symbol","text":"="},{"func":"space"},{"func":"text","text":"0"}]}},{"func":"text","text":":"},{"func":"space"},{"func":"equation","block":true,"body":{"func":"sequence","children":[{"func":"symbol","text":"x"},{"func":"space"},{"func":"symbol","text":"="},{"func":"space"},{"func":"frac","num":{"func":"text","text":"1"},"denom":{"func":"text","text":"2"}}]}}]}},"model":"Basic","tags":["str"],"deck":"Ankify-Test","other":"dictionary","format":"svg"}]
/// ```
pub async fn query_typst_metadata(
    typst_file: &Path,
    label: &str,
    extra_args: Option<&[&str]>,
) -> Result<Vec<serde_json::Value>> {
    // Always use the selector in the form <label> (e.g., <anki-card>)
    let selector = if label.starts_with('<') && label.ends_with('>') {
        label.to_string()
    } else {
        format!("<{}>", label)
    };

    // Build command arguments - start with basic query args
    let mut args = vec!["query"];

    // Add extra arguments first (they might include --root)
    if let Some(extra_args) = extra_args {
        args.extend(extra_args);
    }

    // Add the file and selector
    args.push(
        typst_file
            .to_str()
            .ok_or_else(|| Error::typst("Invalid file path encoding".to_string()))?,
    );
    args.push(&selector);
    args.push("--field");
    args.push("value");

    let output = Command::new("typst")
        .args(&args)
        .output()
        .await
        .map_err(|e| Error::typst(format!("Failed to execute typst command: {}", e)))?;

    if !output.status.success() {
        let _stderr = String::from_utf8_lossy(&output.stderr);
        return Err(Error::typst(format!("Typst query failed: {}", _stderr)));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    if stdout.trim().is_empty() {
        return Ok(Vec::new());
    }

    // Parse JSON output
    let values: Vec<serde_json::Value> = serde_json::from_str(&stdout)
        .map_err(|e| Error::typst(format!("Failed to parse Typst query output: {}", e)))?;

    Ok(values)
}