use std::collections::BTreeMap;
use reqwest::Method;
use serde::Deserialize;
use crate::client::Client;
use crate::error::{Error, Result};
const INIT_PATH: &str = "api/v1/knowledge_bases/init";
const SEARCH_PATH: &str = "api/v1/knowledge_bases/search";
const PUBLISH_ACTION: &str = "publish";
const UNPUBLISH_ACTION: &str = "unpublish";
#[derive(Debug)]
pub struct KnowledgeBaseApi<'a> {
client: &'a Client,
}
impl<'a> KnowledgeBaseApi<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
pub async fn list(&self) -> Result<Vec<KbAnswer>> {
let assets = self.init(&[]).await?;
let mut answers: Vec<KbAnswer> = assets
.answers
.iter()
.map(|(&id, raw)| assets.to_answer(id, raw, None))
.collect();
answers.sort_by_key(|a| a.id);
Ok(answers)
}
pub async fn get(&self, answer_id: u64) -> Result<KbAnswer> {
let assets = self.init(&[]).await?;
let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
let content_id = assets.primary_translation(raw).and_then(|t| t.content_id);
let body = self.body_of(content_id).await?;
Ok(assets.to_answer(answer_id, raw, Some(body)))
}
pub async fn create_answer(&self, answer: NewKbAnswer) -> Result<KbAnswer> {
let assets = self.init(&[]).await?;
let kb_id = assets.kb_of_category(answer.category_id).ok_or_else(|| {
Error::Config(format!(
"no knowledge base found for category {}",
answer.category_id
))
})?;
let kb_locale_id = assets
.primary_locale_id(kb_id)
.ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
let mut body = serde_json::json!({
"category_id": answer.category_id,
"translations": [{
"title": answer.title,
"kb_locale_id": kb_locale_id,
"content": { "body": answer.body },
}],
});
if answer.published {
body["additional_action"] = serde_json::json!(PUBLISH_ACTION);
}
let path = format!("api/v1/knowledge_bases/{kb_id}/answers");
let req = self.client.request(Method::POST, &path).json(&body);
let created: AnswerWriteResponse = self.client.send_json(req).await?;
self.get(created.id).await
}
pub async fn update_answer(&self, answer_id: u64, patch: KbAnswerPatch) -> Result<KbAnswer> {
let assets = self.init(&[]).await?;
let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
let category_id = raw
.category_id
.ok_or_else(|| Error::Config("answer has no category".into()))?;
let kb_id = assets.kb_of_category(category_id).ok_or_else(|| {
Error::Config(format!(
"no knowledge base found for category {category_id}"
))
})?;
let kb_locale_id = assets
.primary_locale_id(kb_id)
.ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
let translation_id = *raw
.translation_ids
.first()
.ok_or_else(|| Error::Config("answer has no translation to update".into()))?;
let translation = assets
.translations
.get(&translation_id)
.ok_or_else(|| Error::Config("answer translation missing from payload".into()))?;
let content_id = translation.content_id;
let title = match patch.title {
Some(t) => t,
None => translation.title.clone().unwrap_or_default(),
};
let body = match patch.body {
Some(b) => b,
None => self.body_of(content_id).await?.unwrap_or_default(),
};
let mut content = serde_json::json!({ "body": body });
if let Some(cid) = content_id {
content["id"] = serde_json::json!(cid);
}
let mut payload = serde_json::json!({
"category_id": category_id,
"translations": [{
"id": translation_id,
"kb_locale_id": kb_locale_id,
"title": title,
"content": content,
}],
});
if let Some(published) = patch.published {
let action = if published {
PUBLISH_ACTION
} else {
UNPUBLISH_ACTION
};
payload["additional_action"] = serde_json::json!(action);
}
let path = format!("api/v1/knowledge_bases/{kb_id}/answers/{answer_id}");
let req = self.client.request(Method::PATCH, &path).json(&payload);
let _: AnswerWriteResponse = self.client.send_json(req).await?;
self.get(answer_id).await
}
async fn body_of(&self, content_id: Option<u64>) -> Result<Option<String>> {
let Some(cid) = content_id else {
return Ok(None);
};
let assets = self.init(&[cid]).await?;
Ok(assets.contents.get(&cid).and_then(|c| c.body.clone()))
}
pub fn search(&self, query: impl Into<String>) -> SearchKnowledgeBase<'a> {
SearchKnowledgeBase {
client: self.client,
query: query.into(),
flavor: "public".to_string(),
per_page: 50,
}
}
async fn init(&self, content_ids: &[u64]) -> Result<KbAssets> {
let body = serde_json::json!({ "answer_translation_content_ids": content_ids });
let req = self.client.request(Method::POST, INIT_PATH).json(&body);
self.client.send_json(req).await
}
}
#[derive(Debug)]
pub struct SearchKnowledgeBase<'a> {
client: &'a Client,
query: String,
flavor: String,
per_page: u32,
}
impl SearchKnowledgeBase<'_> {
pub fn flavor(mut self, flavor: impl Into<String>) -> Self {
self.flavor = flavor.into();
self
}
pub fn per_page(mut self, n: u32) -> Self {
self.per_page = n;
self
}
pub async fn send(self) -> Result<Vec<KbSearchHit>> {
let body = serde_json::json!({
"query": self.query,
"flavor": self.flavor,
"per_page": self.per_page,
"include_subtitle": true,
});
let req = self.client.request(Method::POST, SEARCH_PATH).json(&body);
let resp: SearchResponse = self.client.send_json(req).await?;
let hits = resp.details;
if hits.is_empty() {
return Ok(Vec::new());
}
let assets = KnowledgeBaseApi::new(self.client).init(&[]).await?;
Ok(hits
.into_iter()
.map(|d| KbSearchHit {
answer_id: assets.translations.get(&d.id).map(|t| t.answer_id),
translation_id: d.id,
title: strip_highlight(d.title),
category: d.subtitle,
snippet: strip_highlight(d.body),
url: d.url,
})
.collect())
}
}
#[derive(Debug, Clone)]
pub struct KbAnswer {
pub id: u64,
pub title: Option<String>,
pub category_id: Option<u64>,
pub category: Option<String>,
pub published: bool,
pub body: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NewKbAnswer {
pub category_id: u64,
pub title: String,
pub body: String,
pub published: bool,
}
#[derive(Debug, Clone, Default)]
pub struct KbAnswerPatch {
pub title: Option<String>,
pub body: Option<String>,
pub published: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct KbSearchHit {
pub answer_id: Option<u64>,
pub translation_id: u64,
pub title: Option<String>,
pub category: Option<String>,
pub snippet: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct KbAssets {
#[serde(rename = "KnowledgeBaseAnswer", default)]
answers: BTreeMap<u64, RawAnswer>,
#[serde(rename = "KnowledgeBaseAnswerTranslation", default)]
translations: BTreeMap<u64, RawTranslation>,
#[serde(rename = "KnowledgeBaseAnswerTranslationContent", default)]
contents: BTreeMap<u64, RawContent>,
#[serde(rename = "KnowledgeBaseCategory", default)]
categories: BTreeMap<u64, RawCategory>,
#[serde(rename = "KnowledgeBaseCategoryTranslation", default)]
category_translations: BTreeMap<u64, RawCategoryTranslation>,
#[serde(rename = "KnowledgeBaseLocale", default)]
locales: BTreeMap<u64, RawLocale>,
}
impl KbAssets {
fn primary_translation(&self, raw: &RawAnswer) -> Option<&RawTranslation> {
raw.translation_ids
.first()
.and_then(|tid| self.translations.get(tid))
}
fn kb_of_category(&self, category_id: u64) -> Option<u64> {
self.categories
.get(&category_id)
.and_then(|c| c.knowledge_base_id)
}
fn primary_locale_id(&self, kb_id: u64) -> Option<u64> {
let mut first = None;
for (&id, loc) in &self.locales {
if loc.knowledge_base_id != kb_id {
continue;
}
if loc.primary {
return Some(id);
}
first.get_or_insert(id);
}
first
}
fn to_answer(&self, id: u64, raw: &RawAnswer, body: Option<Option<String>>) -> KbAnswer {
let title = self.primary_translation(raw).and_then(|t| t.title.clone());
let category = raw
.category_id
.and_then(|cid| self.categories.get(&cid))
.and_then(|c| c.translation_ids.first())
.and_then(|ctid| self.category_translations.get(ctid))
.and_then(|ct| ct.title.clone());
KbAnswer {
id,
title,
category_id: raw.category_id,
category,
published: raw.published_at.is_some(),
body: body.flatten(),
}
}
}
#[derive(Debug, Deserialize)]
struct RawAnswer {
#[serde(default)]
category_id: Option<u64>,
#[serde(default)]
translation_ids: Vec<u64>,
#[serde(default)]
published_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawTranslation {
answer_id: u64,
#[serde(default)]
title: Option<String>,
#[serde(default)]
content_id: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct RawContent {
#[serde(default)]
body: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawCategory {
#[serde(default)]
knowledge_base_id: Option<u64>,
#[serde(default)]
translation_ids: Vec<u64>,
}
#[derive(Debug, Deserialize)]
struct RawLocale {
knowledge_base_id: u64,
#[serde(default)]
primary: bool,
}
#[derive(Debug, Deserialize)]
struct RawCategoryTranslation {
#[serde(default)]
title: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnswerWriteResponse {
id: u64,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
#[serde(default)]
details: Vec<SearchDetail>,
}
#[derive(Debug, Deserialize)]
struct SearchDetail {
id: u64,
#[serde(default)]
title: Option<String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
url: Option<String>,
}
fn strip_highlight(s: Option<String>) -> Option<String> {
s.map(|s| s.replace("<em>", "").replace("</em>", ""))
}