1use std::{collections::HashMap, sync::Arc};
26
27use async_trait::async_trait;
28use cloudillo_core::scheduler::{Task, TaskId};
29use cloudillo_types::meta_adapter::{
30 ActionView, FileStatus, FileView, ListProfileOptions, MANAGED_PARENT_ID, Profile, SearchPart,
31 TRASH_PARENT_ID,
32};
33use parking_lot::RwLock;
34use serde::{Deserialize, Serialize};
35
36use crate::{
37 extract::{TextSink, extract_fields},
38 indexer::OBJ_FILE,
39 prelude::*,
40 rules::ActionSearchRules,
41};
42
43pub const OBJ_PROFILE: char = 'P';
45pub const OBJ_ACTION: char = 'A';
47
48pub const OBJECT_DEBOUNCE_SECS: i64 = 5;
55
56const MAX_TITLE_CHARS: usize = 1024;
61const MAX_TAGS_CHARS: usize = 1024;
62const MAX_BODY_CHARS: usize = 16_000;
63
64pub fn schedule_object(app: &App, tn_id: TnId, obj_tp: char, obj_id: &str) {
70 let app = app.clone();
71 let obj_id: Box<str> = obj_id.into();
72 tokio::spawn(async move {
73 let key = format!("search.object:{}:{}:{}", tn_id.0, obj_tp, obj_id);
74 let task = IndexObjectTask { tn_id, obj_tp, obj_id: obj_id.clone() };
75 if let Err(e) =
76 app.scheduler.task(Arc::new(task)).key(key).after(OBJECT_DEBOUNCE_SECS).await
77 {
78 warn!(tn_id = %tn_id, %obj_tp, %obj_id, error = %e,
79 "Failed to schedule search object index task");
80 }
81 });
82}
83
84pub async fn index_object(app: &App, tn_id: TnId, obj_tp: char, obj_id: &str) -> ClResult<()> {
87 match obj_tp {
88 OBJ_FILE => index_file(app, tn_id, obj_id).await,
89 OBJ_PROFILE => index_profile(app, tn_id, obj_id).await,
90 OBJ_ACTION => index_action(app, tn_id, obj_id).await,
91 _ => Err(Error::ValidationError(format!("unknown search object type '{obj_tp}'"))),
92 }
93}
94
95pub async fn index_file(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
101 if let Some(file) = app.meta_adapter.read_file(tn_id, file_id).await? {
102 return index_file_row(app, tn_id, &file).await;
103 }
104 let fts_cl = !crate::store_text(app, tn_id).await;
105 app.meta_adapter
106 .replace_search_row(tn_id, OBJ_FILE, file_id, None, fts_cl)
107 .await
108}
109
110pub async fn index_file_row(app: &App, tn_id: TnId, file: &FileView) -> ClResult<()> {
118 let tags = file.tags.as_ref().map(|t| t.join(" ")).filter(|t| !t.is_empty());
121 let part = file_part(file, tags.as_deref());
122 let fts_cl = !crate::store_text(app, tn_id).await;
123 app.meta_adapter
124 .replace_search_row(tn_id, OBJ_FILE, &file.file_id, part.as_ref(), fts_cl)
125 .await
126}
127
128pub fn is_indexable(file: &FileView) -> bool {
151 file.parent_id.as_deref() != Some(TRASH_PARENT_ID)
152 && file.parent_id.as_deref() != Some(MANAGED_PARENT_ID)
153 && !file.hidden
154 && !matches!(file.status, FileStatus::Deleted)
155}
156
157fn file_part<'a>(file: &'a FileView, tags: Option<&'a str>) -> Option<SearchPart<'a>> {
159 is_indexable(file).then(|| SearchPart {
160 title: Some(&*file.file_name),
161 tags,
162 ..Default::default()
163 })
164}
165
166pub async fn index_profile(app: &App, tn_id: TnId, id_tag: &str) -> ClResult<()> {
171 let opts = ListProfileOptions { id_tag: Some(id_tag.to_owned()), ..Default::default() };
177 let profile = app.meta_adapter.list_profiles(tn_id, &opts).await?.into_iter().next();
178 if let Some(profile) = profile {
179 return index_profile_row(app, tn_id, &profile).await;
180 }
181 let fts_cl = !crate::store_text(app, tn_id).await;
182 app.meta_adapter
183 .replace_search_row(tn_id, OBJ_PROFILE, id_tag, None, fts_cl)
184 .await
185}
186
187pub async fn index_profile_row(
189 app: &App,
190 tn_id: TnId,
191 profile: &Profile<Box<str>>,
192) -> ClResult<()> {
193 let part = SearchPart {
194 title: Some(&profile.name),
195 body: Some(&profile.id_tag),
196 ..Default::default()
197 };
198 let fts_cl = !crate::store_text(app, tn_id).await;
199 app.meta_adapter
200 .replace_search_row(tn_id, OBJ_PROFILE, &profile.id_tag, Some(&part), fts_cl)
201 .await
202}
203
204pub async fn index_action(app: &App, tn_id: TnId, action_id: &str) -> ClResult<()> {
212 if let Some(action) = app.meta_adapter.get_action(tn_id, action_id).await? {
213 return index_action_row(app, tn_id, &action).await;
214 }
215 let fts_cl = !crate::store_text(app, tn_id).await;
216 app.meta_adapter
217 .replace_search_row(tn_id, OBJ_ACTION, action_id, None, fts_cl)
218 .await
219}
220
221pub async fn index_action_row(app: &App, tn_id: TnId, action: &ActionView) -> ClResult<()> {
225 let text = action_text(app, action);
226 let part = text.as_ref().map(|t| SearchPart {
227 title: t.title.as_deref(),
228 body: t.body.as_deref(),
229 tags: t.tags.as_deref(),
230 ..Default::default()
231 });
232 let fts_cl = !crate::store_text(app, tn_id).await;
233 app.meta_adapter
234 .replace_search_row(tn_id, OBJ_ACTION, &action.action_id, part.as_ref(), fts_cl)
235 .await
236}
237
238#[derive(Debug, Default, PartialEq, Eq)]
241struct ActionText {
242 title: Option<String>,
243 body: Option<String>,
244 tags: Option<String>,
245}
246
247fn action_text(app: &App, action: &ActionView) -> Option<ActionText> {
248 if !is_live(action.status.as_deref(), action.sub_typ.as_deref()) {
249 return None;
250 }
251 let rules = action_rules(app, &action.typ, action.sub_typ.as_deref())?;
252 extract_action(&action_document(action), &rules)
253}
254
255fn is_live(status: Option<&str>, sub_typ: Option<&str>) -> bool {
262 status == Some("A") && sub_typ != Some("DEL")
263}
264
265fn extract_action(doc: &serde_json::Value, rules: &ActionSearchRules) -> Option<ActionText> {
270 let field = |field_rules: &[crate::rules::FieldRule], budget: usize| {
271 let mut sink = TextSink::new(budget);
272 extract_fields(doc, field_rules, &mut sink);
273 (!sink.is_empty()).then(|| sink.into_string())
274 };
275 let text = ActionText {
276 title: field(&rules.title, MAX_TITLE_CHARS),
277 body: field(&rules.body, MAX_BODY_CHARS),
278 tags: field(&rules.tags, MAX_TAGS_CHARS),
279 };
280 (text != ActionText::default()).then_some(text)
282}
283
284fn action_document(action: &ActionView) -> serde_json::Value {
291 serde_json::json!({
292 "content": action.content,
293 "type": action.typ,
294 "subType": action.sub_typ,
295 "issuerTag": action.issuer.id_tag,
296 "audienceTag": action.audience.as_ref().map(|a| &a.id_tag),
297 "subject": action.subject,
298 "attachments": action.attachments.as_ref().map(|list| {
299 list.iter().map(|a| &a.file_id).collect::<Vec<_>>()
300 }),
301 })
302}
303
304pub type ActionRulesCache = Arc<RwLock<HashMap<Box<str>, Option<Arc<ActionSearchRules>>>>>;
318
319pub fn new_action_rules_cache() -> ActionRulesCache {
322 Arc::default()
323}
324
325fn action_rules(app: &App, typ: &str, sub_typ: Option<&str>) -> Option<Arc<ActionSearchRules>> {
328 let lookup = app.ext::<cloudillo_core::ActionSearchRulesFn>().ok()?;
331 let (key, manifest) = lookup(typ, sub_typ)?;
332
333 let cache = app.ext::<ActionRulesCache>().ok();
336 if let Some(cache) = cache
337 && let Some(cached) = cache.read().get(&key)
338 {
339 return cached.clone();
340 }
341 let rules = manifest.as_ref().and_then(|m| {
345 ActionSearchRules::parse(m)
346 .inspect_err(|e| warn!(%key, error = %e, "Invalid action search manifest"))
347 .ok()
348 .map(Arc::new)
349 });
350 if let Some(cache) = cache {
351 cache.write().insert(key, rules.clone());
352 }
353 rules
354}
355
356#[derive(Debug, Serialize, Deserialize)]
358pub struct IndexObjectTask {
359 pub tn_id: TnId,
360 pub obj_tp: char,
361 pub obj_id: Box<str>,
362}
363
364#[async_trait]
365impl Task<App> for IndexObjectTask {
366 fn kind() -> &'static str {
367 "search.object"
368 }
369
370 fn kind_of(&self) -> &'static str {
371 Self::kind()
372 }
373
374 fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
375 Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
376 }
377
378 fn serialize(&self) -> String {
379 let mut obj = serde_json::Map::with_capacity(3);
383 obj.insert("tn_id".into(), self.tn_id.0.into());
384 obj.insert("obj_tp".into(), self.obj_tp.to_string().into());
385 obj.insert("obj_id".into(), self.obj_id.as_ref().into());
386 serde_json::Value::Object(obj).to_string()
387 }
388
389 async fn run(&self, app: &App) -> ClResult<()> {
390 index_object(app, self.tn_id, self.obj_tp, &self.obj_id).await
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 fn rules(json: &serde_json::Value) -> ActionSearchRules {
399 ActionSearchRules::parse(json).expect("rules")
400 }
401
402 fn body_rules() -> ActionSearchRules {
404 rules(&serde_json::json!({ "v": 1, "body": [{ "field": "content", "extract": "text" }] }))
405 }
406
407 #[test]
408 fn one_content_walk_covers_all_three_legacy_content_shapes() {
409 for content in [
412 serde_json::json!("bare string post"),
413 serde_json::json!({ "text": "bare string post" }),
414 serde_json::json!({ "content": "bare string post" }),
415 ] {
416 let doc = serde_json::json!({ "content": content });
417 let text = extract_action(&doc, &body_rules()).expect("indexable");
418 assert_eq!(text.body.as_deref(), Some("bare string post"));
419 assert_eq!(text.title, None);
420 }
421 }
422
423 #[test]
424 fn conv_takes_its_name_as_the_title() {
425 let conv = rules(&serde_json::json!({
426 "v": 1,
427 "title": ["content.name"],
428 "body": [{ "field": "content", "extract": "text" }]
429 }));
430 let doc = serde_json::json!({ "content": { "name": "Tervezés", "topic": "Q3" } });
431 let text = extract_action(&doc, &conv).expect("indexable");
432 assert_eq!(text.title.as_deref(), Some("Tervezés"));
433 assert!(text.body.as_deref().is_some_and(|b| b.contains("Q3")));
436 }
437
438 #[test]
439 fn an_action_with_no_text_is_not_indexed() {
440 let doc = serde_json::json!({ "content": { "dim": [640, 480] } });
444 assert_eq!(extract_action(&doc, &body_rules()), None);
445 }
446
447 #[test]
448 fn the_wrapper_document_exposes_more_than_content() {
449 let doc = serde_json::json!({
450 "content": { "text": "szia" },
451 "type": "MSG",
452 "issuerTag": "alice.example.com"
453 });
454 let with_issuer =
455 rules(&serde_json::json!({ "v": 1, "body": ["content"], "tags": ["issuerTag"] }));
456 let text = extract_action(&doc, &with_issuer).expect("indexable");
457 assert_eq!(text.body.as_deref(), Some("szia"));
458 assert_eq!(text.tags.as_deref(), Some("alice.example.com"));
459 }
460
461 fn file_view(parent_id: Option<&str>, status: &str) -> FileView {
463 serde_json::from_value(serde_json::json!({
464 "fileId": "f1~doc",
465 "fileName": "Jegyzetek",
466 "parentId": parent_id,
467 "createdAt": 0,
468 "status": status,
469 }))
470 .expect("file view")
471 }
472
473 #[test]
474 fn a_live_file_contributes_its_name_and_tags() {
475 let file = file_view(None, "A");
476 let part = file_part(&file, Some("munka projekt")).expect("indexable");
477 assert_eq!(part.title, Some("Jegyzetek"));
478 assert_eq!(part.tags, Some("munka projekt"));
479 }
480
481 #[test]
482 fn a_trashed_file_is_dropped_from_the_index_like_a_deleted_one() {
483 assert!(file_part(&file_view(Some(TRASH_PARENT_ID), "A"), None).is_none());
487 assert!(file_part(&file_view(None, "D"), None).is_none());
488 assert!(file_part(&file_view(Some("f1~folder"), "A"), None).is_some());
490 }
491
492 #[test]
493 fn managed_and_hidden_files_are_not_searchable() {
494 let managed = file_view(Some(MANAGED_PARENT_ID), "A");
498 assert!(!is_indexable(&managed));
499 assert!(file_part(&managed, None).is_none());
500 let mut hidden = file_view(None, "A");
501 hidden.hidden = true;
502 assert!(!is_indexable(&hidden));
503 assert!(is_indexable(&file_view(None, "A")));
505 }
506
507 #[test]
508 fn a_del_tombstone_and_a_non_active_row_are_dropped_before_any_manifest() {
509 assert!(is_live(Some("A"), None));
510 assert!(is_live(Some("A"), Some("TEXT")));
511 assert!(!is_live(Some("A"), Some("DEL")), "a DEL tombstone must not be indexed");
512 assert!(!is_live(Some("P"), None), "a pending action is not published yet");
513 assert!(!is_live(Some("V"), None), "an inbound action mid-verification is not live");
514 assert!(!is_live(None, None));
515 }
516}
517
518