huc-tapir 0.5.1

Text & Annotation Processor for Indexing Resources
mod indexer;

use clap::Parser;
use indexer::Indexer;
use serde_json::Value;
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::PathBuf;

#[derive(Parser)]
#[command(
    about = env!("CARGO_PKG_DESCRIPTION"),
    version = env!("CARGO_PKG_VERSION"),
    propagate_version = true
)]
struct Cli {
    /// Input W3C Web Annotation file (in JSONL) [default: stdin]
    #[arg(short = 'i', long = "input")]
    input: Option<PathBuf>,

    /// Output ElasticSearch index file (in JSON) [default: stdout]
    #[arg(short = 'o', long = "output")]
    output: Option<PathBuf>,

    /// Add field to index. Use 'key=value' for custom mapping,
    /// or 'key' to use the key as both field name and value.
    #[arg(short='f', long = "with-field", value_parser = parse_field_spec)]
    fields: Vec<(String, String)>,

    /// Pretty print the generated ElasticSearch index file
    #[arg(short = 'p', long = "pretty", default_value = "false")]
    pretty: bool,

    /// Quiet mode
    #[arg(short = 'q', long = "quiet", default_value = "false")]
    quiet: bool,

    ///Workdir containing extracted text files (in TXT) for <input>
    #[arg(short = 'w', long = "workdir", default_value = "./work")]
    workdir: PathBuf,
}

trait DefaultToStdin {
    fn open(&self) -> Box<dyn BufRead>;
}

impl DefaultToStdin for Option<PathBuf> {
    fn open(&self) -> Box<dyn BufRead> {
        match self {
            None => Box::new(BufReader::new(io::stdin())),
            Some(filename) if filename == "-" => Box::new(BufReader::new(io::stdin())),
            Some(filename) => {
                let file = File::open(filename).expect("Unable to open input file");
                Box::new(BufReader::new(file))
            }
        }
    }
}

trait DefaultToStdout {
    fn create(&self) -> Box<dyn Write>;
}

impl DefaultToStdout for Option<PathBuf> {
    fn create(&self) -> Box<dyn Write> {
        match self {
            None => Box::new(BufWriter::new(io::stdout())),
            Some(filename) if filename == "-" => Box::new(BufWriter::new(io::stdout())),
            Some(filename) => {
                let file = File::create(filename).expect("Unable to create output file");
                Box::new(BufWriter::new(file))
            }
        }
    }
}

fn parse_field_spec(s: &str) -> Result<(String, String), String> {
    match s.find("=") {
        Some(pos) => Ok((s[..pos].to_string(), s[pos + 1..].to_string())),
        None => Ok((s.to_string(), s.to_string())),
    }
}

fn main() -> io::Result<()> {
    let args = Cli::parse();

    // ("recipient", "recipient"),
    // ("file", "n"),
    // ("location", "location"),
    // ("msId", "identifier"),
    // ("sender", "sender"),
    // ("title", "title"),
    // ("correspondent", "participant"),
    // ("period", "namedPeriod"),

    let mut indexer = Indexer::new(args.workdir, args.fields);

    let mut indexed_type_counts: HashMap<String, usize> = HashMap::new();
    let mut ignored_type_counts: HashMap<String, usize> = HashMap::new();

    for (line_num, line_result) in args.input.open().lines().enumerate() {
        let line = line_result?;
        let anno: Value = match serde_json::from_str(&line) {
            Ok(v) => v,
            Err(_) => {
                eprintln!("Error parsing JSON on line {}", line_num + 1);
                continue;
            }
        };

        if let (Some(id), Some(body)) = (anno.get("id"), anno.get("body")) {
            let anno_id = id.as_str().expect("Missing 'id' in annotation");

            let opt_body = body.get("type").and_then(|v| v.as_str());
            let body_type = opt_body.expect("Missing 'body.type' in annotation");

            let mut indexed: bool = true;
            match body_type {
                "Document" => {
                    indexer.add_str("type", "intro");
                    indexer.index_anno(body);
                }
                "Division" => {
                    if let Some(tei_type) = body.get("tei:type").and_then(|v| v.as_str()) {
                        match tei_type {
                            "original" => indexer.store_text(&anno, "letterOriginalText"),
                            "translation" => indexer.store_text(&anno, "letterTranslatedText"),
                            "about" => indexer.store_text(&anno, "introText"),
                            _ => {}
                        }
                    }
                }
                "Entity" => {
                    if let Some(tei_type) = body.get("tei:type").and_then(|v| v.as_str()) {
                        match tei_type {
                            "artwork" => indexer.index_artwork(body),
                            "person" => indexer.index_person(body, anno_id),
                            _ => {}
                        }
                    }
                }
                "Letter" => {
                    indexer.add_str("type", "letter");
                    indexer.index_anno(body);
                }
                "Note" => {
                    if let Some(subtype) = body.get("subtype").and_then(|v| v.as_str()) {
                        if matches!(subtype, "notes" | "typednotes" | "langnotes") {
                            indexer.store_text(&anno, "letterNotesText");
                        }
                    }
                }
                "Reference" => {
                    if let Some(cref) = body.get("tei:cRef").and_then(|v| v.as_str()) {
                        if cref.starts_with("bible-") {
                            indexer.index_bible_citation(body, cref)
                        }
                    }
                }
                _ => {
                    indexed = false;
                }
            }

            if !args.quiet {
                let target_map = if indexed {
                    &mut indexed_type_counts
                } else {
                    &mut ignored_type_counts
                };

                if let Some(count) = target_map.get_mut(body_type) {
                    // Cache hit: avoid .to_string()
                    *count += 1;
                } else {
                    // Cache miss: have to allocate via .to_string()
                    target_map.insert(body_type.to_string(), 1);
                }
            }
        }
    }

    indexer.sort_fields();

    let mut writer = args.output.create();
    let json = if args.pretty {
        serde_json::to_string_pretty(&indexer.root)
    } else {
        serde_json::to_string(&indexer.root)
    }?;
    writeln!(writer, "{}", json)?;
    writer.flush()?;

    if !args.quiet {
        if !indexed_type_counts.is_empty() {
            eprintln!("  ✅ Indexed annotations: {:?}", indexed_type_counts);
        }
        if !ignored_type_counts.is_empty() {
            eprintln!("  ⚠️ Ignored annotations: {:?}", ignored_type_counts);
        }
    }

    Ok(())
}