use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
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);
}
}
pub(crate) fn index_bib_reference(&mut self, body: &Value, anno_id: &str, property_name: &str) {
if let Some(label) = body.get_as_str("label") {
self.add_unique(property_name, label);
} else {
let subtype = body.get_as_str("subtype").unwrap_or("BibReference");
let source_hint = body.get_as_str("url").unwrap_or(anno_id);
eprintln!("no body.label found for {} in: {}", subtype, source_hint)
}
}
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 read_text_segment(&self, source: &str, start: usize, end: usize) -> Value {
let filename = Path::new(source)
.file_name()
.map(|name| self.workdir.join(name).with_added_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 {
match find_text_location(target) {
Ok(Some((source, start, end))) => {
extracted_texts.push(self.read_text_segment(source, start, end));
}
Ok(None) => {}
Err(msg) => {
eprintln!("Failed to extract text: {}", msg);
}
}
}
}
if extracted_texts.is_empty() {
let source_hint = find_by_path(anno, "body.id").unwrap_or(anno);
eprintln!("No suitable text targets found in {}", source_hint);
} else {
self.root
.as_object_mut()
.unwrap()
.entry(text_type)
.or_insert(json!([]))
.as_array_mut()
.unwrap()
.extend(extracted_texts);
}
}
pub(crate) fn sort_fields(&mut self, fields_to_sort: Vec<String>) {
fn parse_id(val: &Value) -> Option<(&str, u32)> {
let s = val.as_str()?;
let (prefix, num_str) = s.split_once('_')?;
let num = num_str.parse::<u32>().ok()?;
Some((prefix, num))
}
if let Some(obj) = self.root.as_object_mut() {
for field in fields_to_sort {
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 = current.get(token)?;
}
Some(current)
}
fn find_text_location<'a>(
target: &'a Value,
) -> Result<Option<(&'a str, usize, usize)>, &'static str> {
let target_type = target
.get_as_str("type")
.ok_or("target should have a 'type'")?;
if target_type == "NormalText"
&& let Some(selector) = target.get("selector")
{
let selector_type = selector
.get_as_str("type")
.ok_or("selector should have a 'type'")?;
if selector_type == "TextPositionSelector" {
let source = target
.get_as_str("source")
.ok_or("target should have a 'source'")?;
let start = selector
.get("start")
.ok_or("selector should have a 'start'")?
.as_u64()
.ok_or("'start' should be a number (>=0)")?;
let end = selector
.get("end")
.ok_or("selector should have an 'end'")?
.as_u64()
.ok_or("'end' should be a number (>=0)")?;
if start > end {
return Err("'start' should not be greater than 'end'");
}
return Ok(Some((source, start as usize, end as usize)));
}
}
Ok(None)
}