1use std::collections::BTreeMap;
34
35use reqwest::Method;
36use serde::Deserialize;
37
38use crate::client::Client;
39use crate::error::{Error, Result};
40
41const INIT_PATH: &str = "api/v1/knowledge_bases/init";
42const SEARCH_PATH: &str = "api/v1/knowledge_bases/search";
43
44const DEFAULT_CATEGORY_ICON: &str = "f115";
47
48#[derive(Debug)]
50pub struct KnowledgeBaseApi<'a> {
51 client: &'a Client,
52}
53
54impl<'a> KnowledgeBaseApi<'a> {
55 pub(crate) fn new(client: &'a Client) -> Self {
56 Self { client }
57 }
58
59 pub async fn list(&self) -> Result<Vec<KbAnswer>> {
64 let assets = self.init(&[]).await?;
65 let mut answers: Vec<KbAnswer> = assets
66 .answers
67 .iter()
68 .map(|(&id, raw)| assets.to_answer(id, raw, None))
69 .collect();
70 answers.sort_by_key(|a| a.id);
71 Ok(answers)
72 }
73
74 pub async fn get(&self, answer_id: u64) -> Result<KbAnswer> {
81 let assets = self.init(&[]).await?;
82 let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
83
84 let content_id = assets.primary_translation(raw).and_then(|t| t.content_id);
87 let body = self.body_of(content_id).await?;
88
89 Ok(assets.to_answer(answer_id, raw, Some(body)))
90 }
91
92 pub async fn list_knowledge_bases(&self) -> Result<Vec<KbInfo>> {
96 let assets = self.init(&[]).await?;
97 let mut kbs: Vec<KbInfo> = assets
98 .kbs
99 .iter()
100 .map(|(&id, raw)| assets.to_kb_info(id, raw))
101 .collect();
102 kbs.sort_by_key(|k| k.id);
103 Ok(kbs)
104 }
105
106 pub async fn list_categories(&self) -> Result<Vec<KbCategory>> {
111 let assets = self.init(&[]).await?;
112 let mut categories: Vec<KbCategory> = assets
113 .categories
114 .iter()
115 .filter(|(_, raw)| raw.knowledge_base_id.is_some())
116 .map(|(&id, raw)| assets.to_category(id, raw))
117 .collect();
118 categories.sort_by_key(|c| c.id);
119 Ok(categories)
120 }
121
122 pub async fn get_category(&self, category_id: u64) -> Result<KbCategory> {
128 let assets = self.init(&[]).await?;
129 let raw = assets.categories.get(&category_id).ok_or(Error::NotFound)?;
130 Ok(assets.to_category(category_id, raw))
131 }
132
133 pub async fn create_category(&self, kb_id: u64, category: NewKbCategory) -> Result<KbCategory> {
141 let assets = self.init(&[]).await?;
142 if !assets.kbs.contains_key(&kb_id) {
143 return Err(Error::NotFound);
144 }
145 let kb_locale_id = assets
146 .primary_locale_id(kb_id)
147 .ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
148
149 let icon = category.icon.unwrap_or_else(|| DEFAULT_CATEGORY_ICON.to_string());
150 let payload = serde_json::json!({
151 "category_icon": icon,
152 "parent_id": category.parent_id,
153 "translations_attributes": [{
154 "kb_locale_id": kb_locale_id,
155 "title": category.title,
156 }],
157 });
158
159 let path = format!("api/v1/knowledge_bases/{kb_id}/categories");
160 let req = self.client.request(Method::POST, &path).json(&payload);
161 let created: CategoryWriteResponse = self.client.send_json(req).await?;
162 self.get_category(created.id).await
163 }
164
165 pub async fn update_category(&self, category_id: u64, patch: KbCategoryPatch) -> Result<KbCategory> {
173 let assets = self.init(&[]).await?;
174 let raw = assets.categories.get(&category_id).ok_or(Error::NotFound)?;
175 if patch.title.is_none() && patch.icon.is_none() && patch.parent_id.is_none() {
176 return Ok(assets.to_category(category_id, raw));
177 }
178 let kb_id = raw
179 .knowledge_base_id
180 .ok_or_else(|| Error::Config(format!("no knowledge base found for category {category_id}")))?;
181
182 let mut payload = serde_json::json!({});
183 if let Some(icon) = &patch.icon {
184 payload["category_icon"] = serde_json::json!(icon);
185 }
186 if let Some(parent) = patch.parent_id {
187 payload["parent_id"] = serde_json::json!(parent);
188 }
189 if let Some(title) = &patch.title {
190 let translation_id = *raw
191 .translation_ids
192 .first()
193 .ok_or_else(|| Error::Config("category has no translation to update".into()))?;
194 payload["translations_attributes"] = serde_json::json!([{
195 "id": translation_id,
196 "title": title,
197 }]);
198 }
199
200 let path = format!("api/v1/knowledge_bases/{kb_id}/categories/{category_id}");
201 let req = self.client.request(Method::PATCH, &path).json(&payload);
202 let _: CategoryWriteResponse = self.client.send_json(req).await?;
203 self.get_category(category_id).await
204 }
205
206 pub async fn delete_category(&self, category_id: u64) -> Result<()> {
213 let assets = self.init(&[]).await?;
214 let raw = assets.categories.get(&category_id).ok_or(Error::NotFound)?;
215 let kb_id = raw
216 .knowledge_base_id
217 .ok_or_else(|| Error::Config(format!("no knowledge base found for category {category_id}")))?;
218 let path = format!("api/v1/knowledge_bases/{kb_id}/categories/{category_id}");
219 let req = self.client.request(Method::DELETE, &path);
220 self.client.send_unit(req).await
221 }
222
223 pub async fn create_answer(&self, answer: NewKbAnswer) -> Result<KbAnswer> {
233 let assets = self.init(&[]).await?;
234 let kb_id = assets.kb_of_category(answer.category_id).ok_or_else(|| {
235 Error::Config(format!(
236 "no knowledge base found for category {}",
237 answer.category_id
238 ))
239 })?;
240 let kb_locale_id = assets
241 .primary_locale_id(kb_id)
242 .ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
243
244 let payload = serde_json::json!({
245 "category_id": answer.category_id,
246 "translations_attributes": [{
247 "title": answer.title,
248 "kb_locale_id": kb_locale_id,
249 "content_attributes": { "body": answer.body },
250 }],
251 });
252
253 let path = format!("api/v1/knowledge_bases/{kb_id}/answers");
254 let req = self.client.request(Method::POST, &path).json(&payload);
255 let created: AnswerWriteResponse = self.client.send_json(req).await?;
256 if answer.published {
257 self.set_published(created.id, kb_id, true).await?;
258 }
259 self.get(created.id).await
260 }
261
262 pub async fn update_answer(&self, answer_id: u64, patch: KbAnswerPatch) -> Result<KbAnswer> {
269 let assets = self.init(&[]).await?;
270 let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
271 let category_id = raw
272 .category_id
273 .ok_or_else(|| Error::Config("answer has no category".into()))?;
274 let kb_id = assets.kb_of_category(category_id).ok_or_else(|| {
275 Error::Config(format!(
276 "no knowledge base found for category {category_id}"
277 ))
278 })?;
279 let kb_locale_id = assets
280 .primary_locale_id(kb_id)
281 .ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
282
283 let translation_id = *raw
284 .translation_ids
285 .first()
286 .ok_or_else(|| Error::Config("answer has no translation to update".into()))?;
287 let translation = assets
288 .translations
289 .get(&translation_id)
290 .ok_or_else(|| Error::Config("answer translation missing from payload".into()))?;
291 let content_id = translation.content_id;
292
293 let title = match patch.title {
296 Some(t) => t,
297 None => translation.title.clone().unwrap_or_default(),
298 };
299 let body = match patch.body {
300 Some(b) => b,
301 None => self.body_of(content_id).await?.unwrap_or_default(),
302 };
303
304 let payload = serde_json::json!({
307 "category_id": category_id,
308 "translations_attributes": [{
309 "id": translation_id,
310 "kb_locale_id": kb_locale_id,
311 "title": title,
312 "content_attributes": { "body": body },
313 }],
314 });
315
316 let path = format!("api/v1/knowledge_bases/{kb_id}/answers/{answer_id}");
317 let req = self.client.request(Method::PATCH, &path).json(&payload);
318 let _: AnswerWriteResponse = self.client.send_json(req).await?;
319 if let Some(published) = patch.published {
320 self.set_published(answer_id, kb_id, published).await?;
321 }
322 self.get(answer_id).await
323 }
324
325 pub async fn delete_answer(&self, answer_id: u64) -> Result<()> {
331 let assets = self.init(&[]).await?;
332 let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
333 let category_id = raw
334 .category_id
335 .ok_or_else(|| Error::Config("answer has no category".into()))?;
336 let kb_id = assets.kb_of_category(category_id).ok_or_else(|| {
337 Error::Config(format!(
338 "no knowledge base found for category {category_id}"
339 ))
340 })?;
341 let path = format!("api/v1/knowledge_bases/{kb_id}/answers/{answer_id}");
342 let req = self.client.request(Method::DELETE, &path);
343 self.client.send_unit(req).await
344 }
345
346 async fn body_of(&self, content_id: Option<u64>) -> Result<Option<String>> {
348 let Some(cid) = content_id else {
349 return Ok(None);
350 };
351 let assets = self.init(&[cid]).await?;
352 Ok(assets.contents.get(&cid).and_then(|c| c.body.clone()))
353 }
354
355 async fn set_published(&self, answer_id: u64, kb_id: u64, published: bool) -> Result<()> {
359 if published {
360 let path = format!("api/v1/knowledge_bases/{kb_id}/answers/{answer_id}/publish");
361 let req = self.client.request(Method::POST, &path);
362 self.client.send_unit(req).await
363 } else {
364 let path = format!(
365 "api/v1/knowledge_bases/{kb_id}/answers/{answer_id}/has_publishing_update"
366 );
367 let body = serde_json::json!({
368 "published_at": null,
369 "internal_at": null,
370 "archived_at": null,
371 });
372 let req = self.client.request(Method::POST, &path).json(&body);
373 self.client.send_unit(req).await
374 }
375 }
376
377 pub fn search(&self, query: impl Into<String>) -> SearchKnowledgeBase<'a> {
381 SearchKnowledgeBase {
382 client: self.client,
383 query: query.into(),
384 flavor: "public".to_string(),
385 per_page: 50,
386 }
387 }
388
389 async fn init(&self, content_ids: &[u64]) -> Result<KbAssets> {
392 let body = serde_json::json!({ "answer_translation_content_ids": content_ids });
393 let req = self.client.request(Method::POST, INIT_PATH).json(&body);
394 self.client.send_json(req).await
395 }
396}
397
398#[derive(Debug)]
400pub struct SearchKnowledgeBase<'a> {
401 client: &'a Client,
402 query: String,
403 flavor: String,
404 per_page: u32,
405}
406
407impl SearchKnowledgeBase<'_> {
408 pub fn flavor(mut self, flavor: impl Into<String>) -> Self {
412 self.flavor = flavor.into();
413 self
414 }
415
416 pub fn per_page(mut self, n: u32) -> Self {
418 self.per_page = n;
419 self
420 }
421
422 pub async fn send(self) -> Result<Vec<KbSearchHit>> {
424 let body = serde_json::json!({
425 "query": self.query,
426 "flavor": self.flavor,
427 "per_page": self.per_page,
428 "include_subtitle": true,
429 });
430 let req = self.client.request(Method::POST, SEARCH_PATH).json(&body);
431 let resp: SearchResponse = self.client.send_json(req).await?;
432
433 let hits = resp.details;
438 if hits.is_empty() {
439 return Ok(Vec::new());
440 }
441
442 let assets = KnowledgeBaseApi::new(self.client).init(&[]).await?;
443 Ok(hits
444 .into_iter()
445 .map(|d| KbSearchHit {
446 answer_id: assets.translations.get(&d.id).map(|t| t.answer_id),
447 translation_id: d.id,
448 title: strip_highlight(d.title),
449 category: d.subtitle,
450 snippet: strip_highlight(d.body),
451 url: d.url,
452 })
453 .collect())
454 }
455}
456
457#[derive(Debug, Clone)]
459pub struct KbAnswer {
460 pub id: u64,
462 pub title: Option<String>,
464 pub category_id: Option<u64>,
466 pub category: Option<String>,
468 pub published: bool,
470 pub body: Option<String>,
473}
474
475#[derive(Debug, Clone)]
478pub struct NewKbAnswer {
479 pub category_id: u64,
482 pub title: String,
484 pub body: String,
486 pub published: bool,
488}
489
490#[derive(Debug, Clone, Default)]
493pub struct KbAnswerPatch {
494 pub title: Option<String>,
496 pub body: Option<String>,
498 pub published: Option<bool>,
501}
502
503#[derive(Debug, Clone)]
505pub struct KbSearchHit {
506 pub answer_id: Option<u64>,
509 pub translation_id: u64,
511 pub title: Option<String>,
513 pub category: Option<String>,
515 pub snippet: Option<String>,
517 pub url: Option<String>,
519}
520
521#[derive(Debug, Clone)]
524pub struct KbInfo {
525 pub id: u64,
527 pub title: Option<String>,
530 pub active: bool,
532}
533
534#[derive(Debug, Clone)]
536pub struct KbCategory {
537 pub id: u64,
539 pub knowledge_base_id: u64,
541 pub parent_id: Option<u64>,
543 pub icon: Option<String>,
545 pub position: Option<u64>,
547 pub title: Option<String>,
549 pub answer_ids: Vec<u64>,
551 pub child_ids: Vec<u64>,
553 pub created_at: Option<String>,
555 pub updated_at: Option<String>,
557}
558
559#[derive(Debug, Clone)]
565pub struct NewKbCategory {
566 pub title: String,
568 pub icon: Option<String>,
570 pub parent_id: Option<u64>,
572}
573
574#[derive(Debug, Clone, Default)]
577pub struct KbCategoryPatch {
578 pub title: Option<String>,
580 pub icon: Option<String>,
582 pub parent_id: Option<Option<u64>>,
585}
586
587#[derive(Debug, Default, Deserialize)]
591struct KbAssets {
592 #[serde(rename = "KnowledgeBaseAnswer", default)]
593 answers: BTreeMap<u64, RawAnswer>,
594 #[serde(rename = "KnowledgeBaseAnswerTranslation", default)]
595 translations: BTreeMap<u64, RawTranslation>,
596 #[serde(rename = "KnowledgeBaseAnswerTranslationContent", default)]
597 contents: BTreeMap<u64, RawContent>,
598 #[serde(rename = "KnowledgeBaseCategory", default)]
599 categories: BTreeMap<u64, RawCategory>,
600 #[serde(rename = "KnowledgeBaseCategoryTranslation", default)]
601 category_translations: BTreeMap<u64, RawCategoryTranslation>,
602 #[serde(rename = "KnowledgeBaseLocale", default)]
603 locales: BTreeMap<u64, RawLocale>,
604 #[serde(rename = "KnowledgeBase", default)]
605 kbs: BTreeMap<u64, RawKb>,
606 #[serde(rename = "KnowledgeBaseTranslation", default)]
607 kb_translations: BTreeMap<u64, RawKbTranslation>,
608}
609
610impl KbAssets {
611 fn primary_translation(&self, raw: &RawAnswer) -> Option<&RawTranslation> {
613 raw.translation_ids
614 .first()
615 .and_then(|tid| self.translations.get(tid))
616 }
617
618 fn kb_of_category(&self, category_id: u64) -> Option<u64> {
620 self.categories
621 .get(&category_id)
622 .and_then(|c| c.knowledge_base_id)
623 }
624
625 fn primary_locale_id(&self, kb_id: u64) -> Option<u64> {
628 let mut first = None;
629 for (&id, loc) in &self.locales {
630 if loc.knowledge_base_id != kb_id {
631 continue;
632 }
633 if loc.primary {
634 return Some(id);
635 }
636 first.get_or_insert(id);
637 }
638 first
639 }
640
641 fn to_answer(&self, id: u64, raw: &RawAnswer, body: Option<Option<String>>) -> KbAnswer {
645 let title = self.primary_translation(raw).and_then(|t| t.title.clone());
646 let category = raw
647 .category_id
648 .and_then(|cid| self.categories.get(&cid))
649 .and_then(|c| c.translation_ids.first())
650 .and_then(|ctid| self.category_translations.get(ctid))
651 .and_then(|ct| ct.title.clone());
652 KbAnswer {
653 id,
654 title,
655 category_id: raw.category_id,
656 category,
657 published: raw.published_at.is_some(),
658 body: body.flatten(),
659 }
660 }
661
662 fn to_category(&self, id: u64, raw: &RawCategory) -> KbCategory {
665 KbCategory {
666 id,
667 knowledge_base_id: raw.knowledge_base_id.unwrap_or_default(),
668 parent_id: raw.parent_id,
669 icon: raw.category_icon.clone(),
670 position: raw.position,
671 title: self.category_title(raw),
672 answer_ids: raw.answer_ids.clone(),
673 child_ids: raw.child_ids.clone(),
674 created_at: raw.created_at.clone(),
675 updated_at: raw.updated_at.clone(),
676 }
677 }
678
679 fn category_title(&self, raw: &RawCategory) -> Option<String> {
682 let primary = raw
683 .knowledge_base_id
684 .and_then(|kb| self.primary_locale_id(kb));
685 for tid in &raw.translation_ids {
686 let Some(ct) = self.category_translations.get(tid) else {
687 continue;
688 };
689 if let (Some(ct_locale), Some(primary)) = (ct.kb_locale_id, primary) {
690 if ct_locale == primary {
691 return ct.title.clone();
692 }
693 }
694 }
695 raw.translation_ids
696 .first()
697 .and_then(|tid| self.category_translations.get(tid))
698 .and_then(|ct| ct.title.clone())
699 }
700
701 fn to_kb_info(&self, id: u64, raw: &RawKb) -> KbInfo {
705 let primary = self.primary_locale_id(id);
706 let title = self
707 .kb_translations
708 .values()
709 .find(|t| matches!((t.kb_locale_id, primary), (Some(l), Some(p)) if l == p))
710 .or_else(|| {
711 self.kb_translations.values().find(|t| {
712 t.kb_locale_id
713 .and_then(|l| self.locales.get(&l))
714 .is_some_and(|loc| loc.knowledge_base_id == id)
715 })
716 })
717 .and_then(|t| t.title.clone());
718 KbInfo {
719 id,
720 title,
721 active: raw.active,
722 }
723 }
724}
725
726#[derive(Debug, Deserialize)]
727struct RawAnswer {
728 #[serde(default)]
729 category_id: Option<u64>,
730 #[serde(default)]
731 translation_ids: Vec<u64>,
732 #[serde(default)]
733 published_at: Option<String>,
734}
735
736#[derive(Debug, Deserialize)]
737struct RawTranslation {
738 answer_id: u64,
739 #[serde(default)]
740 title: Option<String>,
741 #[serde(default)]
742 content_id: Option<u64>,
743}
744
745#[derive(Debug, Deserialize)]
746struct RawContent {
747 #[serde(default)]
748 body: Option<String>,
749}
750
751#[derive(Debug, Deserialize)]
752struct RawCategory {
753 #[serde(default)]
754 knowledge_base_id: Option<u64>,
755 #[serde(default)]
756 translation_ids: Vec<u64>,
757 #[serde(default)]
758 parent_id: Option<u64>,
759 #[serde(default)]
760 category_icon: Option<String>,
761 #[serde(default)]
762 position: Option<u64>,
763 #[serde(default)]
764 answer_ids: Vec<u64>,
765 #[serde(default)]
766 child_ids: Vec<u64>,
767 #[serde(default)]
768 created_at: Option<String>,
769 #[serde(default)]
770 updated_at: Option<String>,
771}
772
773#[derive(Debug, Deserialize)]
774struct RawKb {
775 #[serde(default)]
776 active: bool,
777}
778
779#[derive(Debug, Deserialize)]
780struct RawKbTranslation {
781 #[serde(default)]
782 title: Option<String>,
783 #[serde(default)]
784 kb_locale_id: Option<u64>,
785}
786
787#[derive(Debug, Deserialize)]
788struct RawLocale {
789 knowledge_base_id: u64,
790 #[serde(default)]
791 primary: bool,
792}
793
794#[derive(Debug, Deserialize)]
795struct RawCategoryTranslation {
796 #[serde(default)]
797 title: Option<String>,
798 #[serde(default)]
799 kb_locale_id: Option<u64>,
800}
801
802#[derive(Debug, Deserialize)]
804struct CategoryWriteResponse {
805 id: u64,
806}
807
808#[derive(Debug, Deserialize)]
810struct AnswerWriteResponse {
811 id: u64,
812}
813
814#[derive(Debug, Deserialize)]
815struct SearchResponse {
816 #[serde(default)]
817 details: Vec<SearchDetail>,
818}
819
820#[derive(Debug, Deserialize)]
821struct SearchDetail {
822 id: u64,
823 #[serde(default)]
824 title: Option<String>,
825 #[serde(default)]
826 body: Option<String>,
827 #[serde(default)]
828 subtitle: Option<String>,
829 #[serde(default)]
830 url: Option<String>,
831}
832
833fn strip_highlight(s: Option<String>) -> Option<String> {
836 s.map(|s| s.replace("<em>", "").replace("</em>", ""))
837}