huc-tapir 0.7.1

Text & Annotation Processor for Indexing Resources
use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};

/// we use '.get(X).and_then(|v| -> v.as_str())' quite often, shortcut it to '.get_as_str()'
pub trait JsonExt {
    fn get_as_str(&self, key: &str) -> Option<&str>;
}

impl JsonExt for Value {
    fn get_as_str(&self, key: &str) -> Option<&str> {
        self.get(key).and_then(Value::as_str)
    }
}

pub struct Indexer {
    pub root: Value,
    workdir: PathBuf,
    fields: Vec<(String, String)>,
}

impl Indexer {
    pub fn new(workdir: PathBuf, fields: Vec<(String, String)>) -> Self {
        Self {
            root: json!({}),
            workdir,
            fields,
        }
    }

    pub(crate) fn add_str(&mut self, key: &str, val: &str) {
        self.root[key] = json!(val);
    }

    fn contrive_date(&mut self, body: &Value) -> Option<Value> {
        let actual = body.get("dateSent");
        let not_before = body.get("dateSentNotBefore");
        let not_after = body.get("dateSentNotAfter");
        let id = body.get_as_str("id").unwrap_or("unknown");

        let mut date_obj = serde_json::Map::new();

        if let Some(act) = actual {
            date_obj.insert("gte".to_string(), act.clone());
            date_obj.insert("lte".to_string(), act.clone());
            if not_before.is_some() {
                eprintln!("{}: has both actual date AND notBefore!", id);
            }
            if not_after.is_some() {
                eprintln!("{}: has both actual date AND notAfter!", id);
            }
        } else {
            if let Some(nb) = not_before {
                date_obj.insert("gte".to_string(), nb.clone());
            }
            if let Some(na) = not_after {
                date_obj.insert("lte".to_string(), na.clone());
            }
        }

        if !date_obj.is_empty() {
            Some(Value::Object(date_obj))
        } else {
            None
        }
    }

    pub fn index_anno(&mut self, body: &Value) {
        if let Some(id) = body.get_as_str("id") {
            self.add_str("id", id);
        }

        for (key, path) in &self.fields {
            if let Some(field_val) = body.get(path) {
                self.root[key] = field_val.clone();
            }
        }

        if let Some(date) = self.contrive_date(body) {
            self.root["date"] = date.clone();
            if let Some(gte) = date.get("gte") {
                self.root["dateSortable"] = gte.clone();
            } else if let Some(lte) = date.get("lte") {
                self.root["dateSortable"] = lte.clone();
            }
        } else {
            self.root["date"] = json!({"gte": "0001", "lte": "9999"});
            self.root["dateSortable"] = json!("9999");
            eprintln!(
                "{}: no dateSent, winging it.",
                body.get_as_str("id").unwrap_or("unknown")
            );
        }
    }

    fn add_unique(&mut self, key: &str, candidate: &str) {
        let arr = self
            .root
            .as_object_mut()
            .unwrap()
            .entry(key)
            .or_insert(json!([]))
            .as_array_mut()
            .unwrap();

        if !arr.iter().any(|v| v.as_str() == Some(candidate)) {
            arr.push(json!(candidate));
        }
    }

    pub(crate) fn index_artwork(&mut self, body: &Value) {
        if let Some(ref_obj) = body.get("tei:ref") {
            if let Some(id) = ref_obj.get_as_str("id") {
                self.add_unique("artworkIds", id);
            }

            if let Some(label) = find_by_path(ref_obj, "label.en.search").and_then(Value::as_str) {
                self.add_unique("artworksEN", label);
            }
        }
    }

    pub(crate) fn index_bible_citation(&mut self, body: &Value, ref_id: &str) {
        self.add_unique("bibleRefIds", ref_id);

        if let Some(label) = body.get_as_str("label") {
            self.add_unique("bibleRefs", label);
        } else {
            eprintln!("no body.label found for bible ref: {}", ref_id);
        }
    }

    // TODO: add 'personIds'
    fn index_person_ref(&mut self, person_ref: &Value, anno_id: &str) {
        if let Some(id) = person_ref.get_as_str("id") {
            self.add_unique("personIds", id);
        }

        match person_ref
            .get("sortLabel")
            .or_else(|| person_ref.get("displayLabel"))
            .and_then(Value::as_str)
        {
            Some(label) => self.add_unique("persons", label),
            None => eprintln!("Missing 'sortLabel' and 'displayLabel' in {}", anno_id),
        }
    }

    pub(crate) fn index_person(&mut self, body: &Value, anno_id: &str) {
        if let Some(tei_ref) = body.get("tei:ref") {
            if let Some(arr) = tei_ref.as_array() {
                for item in arr {
                    self.index_person_ref(item, anno_id);
                }
            } else {
                self.index_person_ref(tei_ref, anno_id);
            }
        }
    }

    fn extract_text(&self, source: &str, start: usize, end: usize) -> Value {
        let filename = Path::new(source)
            .file_name()
            .map(|name| self.workdir.join(name).with_extension("txt"));

        if let Some(path) = filename {
            return match fs::read_to_string(&path) {
                Ok(content) => {
                    let text: String = content.chars().skip(start).take(end - start).collect();
                    json!(text)
                }
                Err(e) => {
                    let err_msg = format!("{}[{}..{}]: {}", path.display(), start, end, e);
                    eprintln!("{}", err_msg);
                    json!(err_msg)
                }
            };
        }
        json!("File path error")
    }

    pub(crate) fn store_text(&mut self, anno: &Value, text_type: &str) {
        let mut extracted_texts = Vec::new();

        if let Some(targets) = anno.get("target").and_then(Value::as_array) {
            for target in targets {
                let is_normal = target.get_as_str("type") == Some("NormalText");
                if is_normal {
                    let source = target.get_as_str("source");
                    let selector = target.get("selector");
                    let start = selector
                        .and_then(|s| s.get("start"))
                        .and_then(|v| v.as_u64());
                    let end = selector.and_then(|s| s.get("end")).and_then(Value::as_u64);

                    if let (Some(src), Some(s), Some(e)) = (source, start, end) {
                        extracted_texts.push(self.extract_text(src, s as usize, e as usize));
                    }
                }
            }
        }

        if !extracted_texts.is_empty() {
            let arr = self
                .root
                .as_object_mut()
                .unwrap()
                .entry(text_type)
                .or_insert(json!([]))
                .as_array_mut()
                .unwrap();
            arr.extend(extracted_texts);
        }
    }

    pub(crate) fn sort_fields(&mut self) {
        let sortable_fields = vec![
            "artworkIds",
            "artworksEN",
            "correspondent",
            "correspondentId",
            "bibleRefs",
            "bibleRefIds",
            "personIds",
            "persons",
        ];

        fn parse_id(val: &Value) -> Option<(&str, u32)> {
            let s = val.as_str()?;
            let (prefix, num_str) = s.rsplit_once('_')?;
            let num = num_str.parse::<u32>().ok()?;
            Some((prefix, num))
        }

        if let Some(obj) = self.root.as_object_mut() {
            for field in sortable_fields {
                if let Some(Value::Array(arr)) = obj.get_mut(field) {
                    arr.sort_by(|a, b| match (parse_id(a), parse_id(b)) {
                        (Some((prefix_a, num_a)), Some((prefix_b, num_b))) => {
                            prefix_a.cmp(&prefix_b).then_with(|| num_a.cmp(&num_b))
                        }
                        _ => match (a.as_str(), b.as_str()) {
                            (Some(str_a), Some(str_b)) => str_a.cmp(&str_b),
                            _ => std::cmp::Ordering::Equal,
                        },
                    });
                }
            }
        }
    }
}

fn find_by_path<'a>(obj: &'a Value, path: &str) -> Option<&'a Value> {
    let mut current = obj;
    for token in path.split('.') {
        // current is already &Value, so get(token) returns Option<&Value>
        current = current.get(token)?;
    }
    Some(current)
}