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 DEFAULT_CATEGORY_ICON: &str = "f115";
#[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 list_knowledge_bases(&self) -> Result<Vec<KbInfo>> {
let assets = self.init(&[]).await?;
let mut kbs: Vec<KbInfo> = assets
.kbs
.iter()
.map(|(&id, raw)| assets.to_kb_info(id, raw))
.collect();
kbs.sort_by_key(|k| k.id);
Ok(kbs)
}
pub async fn list_categories(&self) -> Result<Vec<KbCategory>> {
let assets = self.init(&[]).await?;
let mut categories: Vec<KbCategory> = assets
.categories
.iter()
.filter(|(_, raw)| raw.knowledge_base_id.is_some())
.map(|(&id, raw)| assets.to_category(id, raw))
.collect();
categories.sort_by_key(|c| c.id);
Ok(categories)
}
pub async fn get_category(&self, category_id: u64) -> Result<KbCategory> {
let assets = self.init(&[]).await?;
let raw = assets.categories.get(&category_id).ok_or(Error::NotFound)?;
Ok(assets.to_category(category_id, raw))
}
pub async fn create_category(&self, kb_id: u64, category: NewKbCategory) -> Result<KbCategory> {
let assets = self.init(&[]).await?;
if !assets.kbs.contains_key(&kb_id) {
return Err(Error::NotFound);
}
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 icon = category.icon.unwrap_or_else(|| DEFAULT_CATEGORY_ICON.to_string());
let payload = serde_json::json!({
"category_icon": icon,
"parent_id": category.parent_id,
"translations_attributes": [{
"kb_locale_id": kb_locale_id,
"title": category.title,
}],
});
let path = format!("api/v1/knowledge_bases/{kb_id}/categories");
let req = self.client.request(Method::POST, &path).json(&payload);
let created: CategoryWriteResponse = self.client.send_json(req).await?;
self.get_category(created.id).await
}
pub async fn update_category(&self, category_id: u64, patch: KbCategoryPatch) -> Result<KbCategory> {
let assets = self.init(&[]).await?;
let raw = assets.categories.get(&category_id).ok_or(Error::NotFound)?;
if patch.title.is_none() && patch.icon.is_none() && patch.parent_id.is_none() {
return Ok(assets.to_category(category_id, raw));
}
let kb_id = raw
.knowledge_base_id
.ok_or_else(|| Error::Config(format!("no knowledge base found for category {category_id}")))?;
let mut payload = serde_json::json!({});
if let Some(icon) = &patch.icon {
payload["category_icon"] = serde_json::json!(icon);
}
if let Some(parent) = patch.parent_id {
payload["parent_id"] = serde_json::json!(parent);
}
if let Some(title) = &patch.title {
let translation_id = *raw
.translation_ids
.first()
.ok_or_else(|| Error::Config("category has no translation to update".into()))?;
payload["translations_attributes"] = serde_json::json!([{
"id": translation_id,
"title": title,
}]);
}
let path = format!("api/v1/knowledge_bases/{kb_id}/categories/{category_id}");
let req = self.client.request(Method::PATCH, &path).json(&payload);
let _: CategoryWriteResponse = self.client.send_json(req).await?;
self.get_category(category_id).await
}
pub async fn delete_category(&self, category_id: u64) -> Result<()> {
let assets = self.init(&[]).await?;
let raw = assets.categories.get(&category_id).ok_or(Error::NotFound)?;
let kb_id = raw
.knowledge_base_id
.ok_or_else(|| Error::Config(format!("no knowledge base found for category {category_id}")))?;
let path = format!("api/v1/knowledge_bases/{kb_id}/categories/{category_id}");
let req = self.client.request(Method::DELETE, &path);
self.client.send_unit(req).await
}
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 payload = serde_json::json!({
"category_id": answer.category_id,
"translations_attributes": [{
"title": answer.title,
"kb_locale_id": kb_locale_id,
"content_attributes": { "body": answer.body },
}],
});
let path = format!("api/v1/knowledge_bases/{kb_id}/answers");
let req = self.client.request(Method::POST, &path).json(&payload);
let created: AnswerWriteResponse = self.client.send_json(req).await?;
if answer.published {
self.set_published(created.id, kb_id, true).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 payload = serde_json::json!({
"category_id": category_id,
"translations_attributes": [{
"id": translation_id,
"kb_locale_id": kb_locale_id,
"title": title,
"content_attributes": { "body": body },
}],
});
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?;
if let Some(published) = patch.published {
self.set_published(answer_id, kb_id, published).await?;
}
self.get(answer_id).await
}
pub async fn delete_answer(&self, answer_id: u64) -> Result<()> {
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 path = format!("api/v1/knowledge_bases/{kb_id}/answers/{answer_id}");
let req = self.client.request(Method::DELETE, &path);
self.client.send_unit(req).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()))
}
async fn set_published(&self, answer_id: u64, kb_id: u64, published: bool) -> Result<()> {
if published {
let path = format!("api/v1/knowledge_bases/{kb_id}/answers/{answer_id}/publish");
let req = self.client.request(Method::POST, &path);
self.client.send_unit(req).await
} else {
let path = format!(
"api/v1/knowledge_bases/{kb_id}/answers/{answer_id}/has_publishing_update"
);
let body = serde_json::json!({
"published_at": null,
"internal_at": null,
"archived_at": null,
});
let req = self.client.request(Method::POST, &path).json(&body);
self.client.send_unit(req).await
}
}
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, Clone)]
pub struct KbInfo {
pub id: u64,
pub title: Option<String>,
pub active: bool,
}
#[derive(Debug, Clone)]
pub struct KbCategory {
pub id: u64,
pub knowledge_base_id: u64,
pub parent_id: Option<u64>,
pub icon: Option<String>,
pub position: Option<u64>,
pub title: Option<String>,
pub answer_ids: Vec<u64>,
pub child_ids: Vec<u64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NewKbCategory {
pub title: String,
pub icon: Option<String>,
pub parent_id: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct KbCategoryPatch {
pub title: Option<String>,
pub icon: Option<String>,
pub parent_id: Option<Option<u64>>,
}
#[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>,
#[serde(rename = "KnowledgeBase", default)]
kbs: BTreeMap<u64, RawKb>,
#[serde(rename = "KnowledgeBaseTranslation", default)]
kb_translations: BTreeMap<u64, RawKbTranslation>,
}
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(),
}
}
fn to_category(&self, id: u64, raw: &RawCategory) -> KbCategory {
KbCategory {
id,
knowledge_base_id: raw.knowledge_base_id.unwrap_or_default(),
parent_id: raw.parent_id,
icon: raw.category_icon.clone(),
position: raw.position,
title: self.category_title(raw),
answer_ids: raw.answer_ids.clone(),
child_ids: raw.child_ids.clone(),
created_at: raw.created_at.clone(),
updated_at: raw.updated_at.clone(),
}
}
fn category_title(&self, raw: &RawCategory) -> Option<String> {
let primary = raw
.knowledge_base_id
.and_then(|kb| self.primary_locale_id(kb));
for tid in &raw.translation_ids {
let Some(ct) = self.category_translations.get(tid) else {
continue;
};
if let (Some(ct_locale), Some(primary)) = (ct.kb_locale_id, primary) {
if ct_locale == primary {
return ct.title.clone();
}
}
}
raw.translation_ids
.first()
.and_then(|tid| self.category_translations.get(tid))
.and_then(|ct| ct.title.clone())
}
fn to_kb_info(&self, id: u64, raw: &RawKb) -> KbInfo {
let primary = self.primary_locale_id(id);
let title = self
.kb_translations
.values()
.find(|t| matches!((t.kb_locale_id, primary), (Some(l), Some(p)) if l == p))
.or_else(|| {
self.kb_translations.values().find(|t| {
t.kb_locale_id
.and_then(|l| self.locales.get(&l))
.is_some_and(|loc| loc.knowledge_base_id == id)
})
})
.and_then(|t| t.title.clone());
KbInfo {
id,
title,
active: raw.active,
}
}
}
#[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>,
#[serde(default)]
parent_id: Option<u64>,
#[serde(default)]
category_icon: Option<String>,
#[serde(default)]
position: Option<u64>,
#[serde(default)]
answer_ids: Vec<u64>,
#[serde(default)]
child_ids: Vec<u64>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawKb {
#[serde(default)]
active: bool,
}
#[derive(Debug, Deserialize)]
struct RawKbTranslation {
#[serde(default)]
title: Option<String>,
#[serde(default)]
kb_locale_id: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct RawLocale {
knowledge_base_id: u64,
#[serde(default)]
primary: bool,
}
#[derive(Debug, Deserialize)]
struct RawCategoryTranslation {
#[serde(default)]
title: Option<String>,
#[serde(default)]
kb_locale_id: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct CategoryWriteResponse {
id: u64,
}
#[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>", ""))
}