use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use cloudillo_core::scheduler::{Task, TaskId};
use cloudillo_types::meta_adapter::{
ActionView, FileStatus, FileView, ListProfileOptions, MANAGED_PARENT_ID, Profile, SearchPart,
TRASH_PARENT_ID,
};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use crate::{
extract::{TextSink, extract_fields},
indexer::OBJ_FILE,
prelude::*,
rules::ActionSearchRules,
};
pub const OBJ_PROFILE: char = 'P';
pub const OBJ_ACTION: char = 'A';
pub const OBJECT_DEBOUNCE_SECS: i64 = 5;
const MAX_TITLE_CHARS: usize = 1024;
const MAX_TAGS_CHARS: usize = 1024;
const MAX_BODY_CHARS: usize = 16_000;
pub fn schedule_object(app: &App, tn_id: TnId, obj_tp: char, obj_id: &str) {
let app = app.clone();
let obj_id: Box<str> = obj_id.into();
tokio::spawn(async move {
let key = format!("search.object:{}:{}:{}", tn_id.0, obj_tp, obj_id);
let task = IndexObjectTask { tn_id, obj_tp, obj_id: obj_id.clone() };
if let Err(e) =
app.scheduler.task(Arc::new(task)).key(key).after(OBJECT_DEBOUNCE_SECS).await
{
warn!(tn_id = %tn_id, %obj_tp, %obj_id, error = %e,
"Failed to schedule search object index task");
}
});
}
pub async fn index_object(app: &App, tn_id: TnId, obj_tp: char, obj_id: &str) -> ClResult<()> {
match obj_tp {
OBJ_FILE => index_file(app, tn_id, obj_id).await,
OBJ_PROFILE => index_profile(app, tn_id, obj_id).await,
OBJ_ACTION => index_action(app, tn_id, obj_id).await,
_ => Err(Error::ValidationError(format!("unknown search object type '{obj_tp}'"))),
}
}
pub async fn index_file(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
if let Some(file) = app.meta_adapter.read_file(tn_id, file_id).await? {
return index_file_row(app, tn_id, &file).await;
}
let fts_cl = !crate::store_text(app, tn_id).await;
app.meta_adapter
.replace_search_row(tn_id, OBJ_FILE, file_id, None, fts_cl)
.await
}
pub async fn index_file_row(app: &App, tn_id: TnId, file: &FileView) -> ClResult<()> {
let tags = file.tags.as_ref().map(|t| t.join(" ")).filter(|t| !t.is_empty());
let part = file_part(file, tags.as_deref());
let fts_cl = !crate::store_text(app, tn_id).await;
app.meta_adapter
.replace_search_row(tn_id, OBJ_FILE, &file.file_id, part.as_ref(), fts_cl)
.await
}
pub fn is_indexable(file: &FileView) -> bool {
file.parent_id.as_deref() != Some(TRASH_PARENT_ID)
&& file.parent_id.as_deref() != Some(MANAGED_PARENT_ID)
&& !file.hidden
&& !matches!(file.status, FileStatus::Deleted)
}
fn file_part<'a>(file: &'a FileView, tags: Option<&'a str>) -> Option<SearchPart<'a>> {
is_indexable(file).then(|| SearchPart {
title: Some(&*file.file_name),
tags,
..Default::default()
})
}
pub async fn index_profile(app: &App, tn_id: TnId, id_tag: &str) -> ClResult<()> {
let opts = ListProfileOptions { id_tag: Some(id_tag.to_owned()), ..Default::default() };
let profile = app.meta_adapter.list_profiles(tn_id, &opts).await?.into_iter().next();
if let Some(profile) = profile {
return index_profile_row(app, tn_id, &profile).await;
}
let fts_cl = !crate::store_text(app, tn_id).await;
app.meta_adapter
.replace_search_row(tn_id, OBJ_PROFILE, id_tag, None, fts_cl)
.await
}
pub async fn index_profile_row(
app: &App,
tn_id: TnId,
profile: &Profile<Box<str>>,
) -> ClResult<()> {
let part = SearchPart {
title: Some(&profile.name),
body: Some(&profile.id_tag),
..Default::default()
};
let fts_cl = !crate::store_text(app, tn_id).await;
app.meta_adapter
.replace_search_row(tn_id, OBJ_PROFILE, &profile.id_tag, Some(&part), fts_cl)
.await
}
pub async fn index_action(app: &App, tn_id: TnId, action_id: &str) -> ClResult<()> {
if let Some(action) = app.meta_adapter.get_action(tn_id, action_id).await? {
return index_action_row(app, tn_id, &action).await;
}
let fts_cl = !crate::store_text(app, tn_id).await;
app.meta_adapter
.replace_search_row(tn_id, OBJ_ACTION, action_id, None, fts_cl)
.await
}
pub async fn index_action_row(app: &App, tn_id: TnId, action: &ActionView) -> ClResult<()> {
let text = action_text(app, action);
let part = text.as_ref().map(|t| SearchPart {
title: t.title.as_deref(),
body: t.body.as_deref(),
tags: t.tags.as_deref(),
..Default::default()
});
let fts_cl = !crate::store_text(app, tn_id).await;
app.meta_adapter
.replace_search_row(tn_id, OBJ_ACTION, &action.action_id, part.as_ref(), fts_cl)
.await
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ActionText {
title: Option<String>,
body: Option<String>,
tags: Option<String>,
}
fn action_text(app: &App, action: &ActionView) -> Option<ActionText> {
if !is_live(action.status.as_deref(), action.sub_typ.as_deref()) {
return None;
}
let rules = action_rules(app, &action.typ, action.sub_typ.as_deref())?;
extract_action(&action_document(action), &rules)
}
fn is_live(status: Option<&str>, sub_typ: Option<&str>) -> bool {
status == Some("A") && sub_typ != Some("DEL")
}
fn extract_action(doc: &serde_json::Value, rules: &ActionSearchRules) -> Option<ActionText> {
let field = |field_rules: &[crate::rules::FieldRule], budget: usize| {
let mut sink = TextSink::new(budget);
extract_fields(doc, field_rules, &mut sink);
(!sink.is_empty()).then(|| sink.into_string())
};
let text = ActionText {
title: field(&rules.title, MAX_TITLE_CHARS),
body: field(&rules.body, MAX_BODY_CHARS),
tags: field(&rules.tags, MAX_TAGS_CHARS),
};
(text != ActionText::default()).then_some(text)
}
fn action_document(action: &ActionView) -> serde_json::Value {
serde_json::json!({
"content": action.content,
"type": action.typ,
"subType": action.sub_typ,
"issuerTag": action.issuer.id_tag,
"audienceTag": action.audience.as_ref().map(|a| &a.id_tag),
"subject": action.subject,
"attachments": action.attachments.as_ref().map(|list| {
list.iter().map(|a| &a.file_id).collect::<Vec<_>>()
}),
})
}
pub type ActionRulesCache = Arc<RwLock<HashMap<Box<str>, Option<Arc<ActionSearchRules>>>>>;
pub fn new_action_rules_cache() -> ActionRulesCache {
Arc::default()
}
fn action_rules(app: &App, typ: &str, sub_typ: Option<&str>) -> Option<Arc<ActionSearchRules>> {
let lookup = app.ext::<cloudillo_core::ActionSearchRulesFn>().ok()?;
let (key, manifest) = lookup(typ, sub_typ)?;
let cache = app.ext::<ActionRulesCache>().ok();
if let Some(cache) = cache
&& let Some(cached) = cache.read().get(&key)
{
return cached.clone();
}
let rules = manifest.as_ref().and_then(|m| {
ActionSearchRules::parse(m)
.inspect_err(|e| warn!(%key, error = %e, "Invalid action search manifest"))
.ok()
.map(Arc::new)
});
if let Some(cache) = cache {
cache.write().insert(key, rules.clone());
}
rules
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexObjectTask {
pub tn_id: TnId,
pub obj_tp: char,
pub obj_id: Box<str>,
}
#[async_trait]
impl Task<App> for IndexObjectTask {
fn kind() -> &'static str {
"search.object"
}
fn kind_of(&self) -> &'static str {
Self::kind()
}
fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
}
fn serialize(&self) -> String {
let mut obj = serde_json::Map::with_capacity(3);
obj.insert("tn_id".into(), self.tn_id.0.into());
obj.insert("obj_tp".into(), self.obj_tp.to_string().into());
obj.insert("obj_id".into(), self.obj_id.as_ref().into());
serde_json::Value::Object(obj).to_string()
}
async fn run(&self, app: &App) -> ClResult<()> {
index_object(app, self.tn_id, self.obj_tp, &self.obj_id).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rules(json: &serde_json::Value) -> ActionSearchRules {
ActionSearchRules::parse(json).expect("rules")
}
fn body_rules() -> ActionSearchRules {
rules(&serde_json::json!({ "v": 1, "body": [{ "field": "content", "extract": "text" }] }))
}
#[test]
fn one_content_walk_covers_all_three_legacy_content_shapes() {
for content in [
serde_json::json!("bare string post"),
serde_json::json!({ "text": "bare string post" }),
serde_json::json!({ "content": "bare string post" }),
] {
let doc = serde_json::json!({ "content": content });
let text = extract_action(&doc, &body_rules()).expect("indexable");
assert_eq!(text.body.as_deref(), Some("bare string post"));
assert_eq!(text.title, None);
}
}
#[test]
fn conv_takes_its_name_as_the_title() {
let conv = rules(&serde_json::json!({
"v": 1,
"title": ["content.name"],
"body": [{ "field": "content", "extract": "text" }]
}));
let doc = serde_json::json!({ "content": { "name": "Tervezés", "topic": "Q3" } });
let text = extract_action(&doc, &conv).expect("indexable");
assert_eq!(text.title.as_deref(), Some("Tervezés"));
assert!(text.body.as_deref().is_some_and(|b| b.contains("Q3")));
}
#[test]
fn an_action_with_no_text_is_not_indexed() {
let doc = serde_json::json!({ "content": { "dim": [640, 480] } });
assert_eq!(extract_action(&doc, &body_rules()), None);
}
#[test]
fn the_wrapper_document_exposes_more_than_content() {
let doc = serde_json::json!({
"content": { "text": "szia" },
"type": "MSG",
"issuerTag": "alice.example.com"
});
let with_issuer =
rules(&serde_json::json!({ "v": 1, "body": ["content"], "tags": ["issuerTag"] }));
let text = extract_action(&doc, &with_issuer).expect("indexable");
assert_eq!(text.body.as_deref(), Some("szia"));
assert_eq!(text.tags.as_deref(), Some("alice.example.com"));
}
fn file_view(parent_id: Option<&str>, status: &str) -> FileView {
serde_json::from_value(serde_json::json!({
"fileId": "f1~doc",
"fileName": "Jegyzetek",
"parentId": parent_id,
"createdAt": 0,
"status": status,
}))
.expect("file view")
}
#[test]
fn a_live_file_contributes_its_name_and_tags() {
let file = file_view(None, "A");
let part = file_part(&file, Some("munka projekt")).expect("indexable");
assert_eq!(part.title, Some("Jegyzetek"));
assert_eq!(part.tags, Some("munka projekt"));
}
#[test]
fn a_trashed_file_is_dropped_from_the_index_like_a_deleted_one() {
assert!(file_part(&file_view(Some(TRASH_PARENT_ID), "A"), None).is_none());
assert!(file_part(&file_view(None, "D"), None).is_none());
assert!(file_part(&file_view(Some("f1~folder"), "A"), None).is_some());
}
#[test]
fn managed_and_hidden_files_are_not_searchable() {
let managed = file_view(Some(MANAGED_PARENT_ID), "A");
assert!(!is_indexable(&managed));
assert!(file_part(&managed, None).is_none());
let mut hidden = file_view(None, "A");
hidden.hidden = true;
assert!(!is_indexable(&hidden));
assert!(is_indexable(&file_view(None, "A")));
}
#[test]
fn a_del_tombstone_and_a_non_active_row_are_dropped_before_any_manifest() {
assert!(is_live(Some("A"), None));
assert!(is_live(Some("A"), Some("TEXT")));
assert!(!is_live(Some("A"), Some("DEL")), "a DEL tombstone must not be indexed");
assert!(!is_live(Some("P"), None), "a pending action is not published yet");
assert!(!is_live(Some("V"), None), "an inbound action mid-verification is not live");
assert!(!is_live(None, None));
}
}