1use std::collections::BTreeMap;
23
24use reqwest::Method;
25use serde::Deserialize;
26
27use crate::client::Client;
28use crate::error::{Error, Result};
29
30const INIT_PATH: &str = "api/v1/knowledge_bases/init";
31const SEARCH_PATH: &str = "api/v1/knowledge_bases/search";
32
33const PUBLISH_ACTION: &str = "publish";
35const UNPUBLISH_ACTION: &str = "unpublish";
37
38#[derive(Debug)]
40pub struct KnowledgeBaseApi<'a> {
41 client: &'a Client,
42}
43
44impl<'a> KnowledgeBaseApi<'a> {
45 pub(crate) fn new(client: &'a Client) -> Self {
46 Self { client }
47 }
48
49 pub async fn list(&self) -> Result<Vec<KbAnswer>> {
54 let assets = self.init(&[]).await?;
55 let mut answers: Vec<KbAnswer> = assets
56 .answers
57 .iter()
58 .map(|(&id, raw)| assets.to_answer(id, raw, None))
59 .collect();
60 answers.sort_by_key(|a| a.id);
61 Ok(answers)
62 }
63
64 pub async fn get(&self, answer_id: u64) -> Result<KbAnswer> {
71 let assets = self.init(&[]).await?;
72 let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
73
74 let content_id = assets.primary_translation(raw).and_then(|t| t.content_id);
77 let body = self.body_of(content_id).await?;
78
79 Ok(assets.to_answer(answer_id, raw, Some(body)))
80 }
81
82 pub async fn create_answer(&self, answer: NewKbAnswer) -> Result<KbAnswer> {
91 let assets = self.init(&[]).await?;
92 let kb_id = assets.kb_of_category(answer.category_id).ok_or_else(|| {
93 Error::Config(format!(
94 "no knowledge base found for category {}",
95 answer.category_id
96 ))
97 })?;
98 let kb_locale_id = assets
99 .primary_locale_id(kb_id)
100 .ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
101
102 let mut body = serde_json::json!({
103 "category_id": answer.category_id,
104 "translations": [{
105 "title": answer.title,
106 "kb_locale_id": kb_locale_id,
107 "content": { "body": answer.body },
108 }],
109 });
110 if answer.published {
111 body["additional_action"] = serde_json::json!(PUBLISH_ACTION);
112 }
113
114 let path = format!("api/v1/knowledge_bases/{kb_id}/answers");
115 let req = self.client.request(Method::POST, &path).json(&body);
116 let created: AnswerWriteResponse = self.client.send_json(req).await?;
117 self.get(created.id).await
118 }
119
120 pub async fn update_answer(&self, answer_id: u64, patch: KbAnswerPatch) -> Result<KbAnswer> {
127 let assets = self.init(&[]).await?;
128 let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
129 let category_id = raw
130 .category_id
131 .ok_or_else(|| Error::Config("answer has no category".into()))?;
132 let kb_id = assets.kb_of_category(category_id).ok_or_else(|| {
133 Error::Config(format!(
134 "no knowledge base found for category {category_id}"
135 ))
136 })?;
137 let kb_locale_id = assets
138 .primary_locale_id(kb_id)
139 .ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
140
141 let translation_id = *raw
142 .translation_ids
143 .first()
144 .ok_or_else(|| Error::Config("answer has no translation to update".into()))?;
145 let translation = assets
146 .translations
147 .get(&translation_id)
148 .ok_or_else(|| Error::Config("answer translation missing from payload".into()))?;
149 let content_id = translation.content_id;
150
151 let title = match patch.title {
154 Some(t) => t,
155 None => translation.title.clone().unwrap_or_default(),
156 };
157 let body = match patch.body {
158 Some(b) => b,
159 None => self.body_of(content_id).await?.unwrap_or_default(),
160 };
161
162 let mut content = serde_json::json!({ "body": body });
163 if let Some(cid) = content_id {
164 content["id"] = serde_json::json!(cid);
165 }
166 let mut payload = serde_json::json!({
167 "category_id": category_id,
168 "translations": [{
169 "id": translation_id,
170 "kb_locale_id": kb_locale_id,
171 "title": title,
172 "content": content,
173 }],
174 });
175 if let Some(published) = patch.published {
176 let action = if published {
177 PUBLISH_ACTION
178 } else {
179 UNPUBLISH_ACTION
180 };
181 payload["additional_action"] = serde_json::json!(action);
182 }
183
184 let path = format!("api/v1/knowledge_bases/{kb_id}/answers/{answer_id}");
185 let req = self.client.request(Method::PATCH, &path).json(&payload);
186 let _: AnswerWriteResponse = self.client.send_json(req).await?;
187 self.get(answer_id).await
188 }
189
190 async fn body_of(&self, content_id: Option<u64>) -> Result<Option<String>> {
192 let Some(cid) = content_id else {
193 return Ok(None);
194 };
195 let assets = self.init(&[cid]).await?;
196 Ok(assets.contents.get(&cid).and_then(|c| c.body.clone()))
197 }
198
199 pub fn search(&self, query: impl Into<String>) -> SearchKnowledgeBase<'a> {
203 SearchKnowledgeBase {
204 client: self.client,
205 query: query.into(),
206 flavor: "public".to_string(),
207 per_page: 50,
208 }
209 }
210
211 async fn init(&self, content_ids: &[u64]) -> Result<KbAssets> {
214 let body = serde_json::json!({ "answer_translation_content_ids": content_ids });
215 let req = self.client.request(Method::POST, INIT_PATH).json(&body);
216 self.client.send_json(req).await
217 }
218}
219
220#[derive(Debug)]
222pub struct SearchKnowledgeBase<'a> {
223 client: &'a Client,
224 query: String,
225 flavor: String,
226 per_page: u32,
227}
228
229impl SearchKnowledgeBase<'_> {
230 pub fn flavor(mut self, flavor: impl Into<String>) -> Self {
234 self.flavor = flavor.into();
235 self
236 }
237
238 pub fn per_page(mut self, n: u32) -> Self {
240 self.per_page = n;
241 self
242 }
243
244 pub async fn send(self) -> Result<Vec<KbSearchHit>> {
246 let body = serde_json::json!({
247 "query": self.query,
248 "flavor": self.flavor,
249 "per_page": self.per_page,
250 "include_subtitle": true,
251 });
252 let req = self.client.request(Method::POST, SEARCH_PATH).json(&body);
253 let resp: SearchResponse = self.client.send_json(req).await?;
254
255 let hits = resp.details;
260 if hits.is_empty() {
261 return Ok(Vec::new());
262 }
263
264 let assets = KnowledgeBaseApi::new(self.client).init(&[]).await?;
265 Ok(hits
266 .into_iter()
267 .map(|d| KbSearchHit {
268 answer_id: assets.translations.get(&d.id).map(|t| t.answer_id),
269 translation_id: d.id,
270 title: strip_highlight(d.title),
271 category: d.subtitle,
272 snippet: strip_highlight(d.body),
273 url: d.url,
274 })
275 .collect())
276 }
277}
278
279#[derive(Debug, Clone)]
281pub struct KbAnswer {
282 pub id: u64,
284 pub title: Option<String>,
286 pub category_id: Option<u64>,
288 pub category: Option<String>,
290 pub published: bool,
292 pub body: Option<String>,
295}
296
297#[derive(Debug, Clone)]
300pub struct NewKbAnswer {
301 pub category_id: u64,
304 pub title: String,
306 pub body: String,
308 pub published: bool,
310}
311
312#[derive(Debug, Clone, Default)]
315pub struct KbAnswerPatch {
316 pub title: Option<String>,
318 pub body: Option<String>,
320 pub published: Option<bool>,
323}
324
325#[derive(Debug, Clone)]
327pub struct KbSearchHit {
328 pub answer_id: Option<u64>,
331 pub translation_id: u64,
333 pub title: Option<String>,
335 pub category: Option<String>,
337 pub snippet: Option<String>,
339 pub url: Option<String>,
341}
342
343#[derive(Debug, Default, Deserialize)]
347struct KbAssets {
348 #[serde(rename = "KnowledgeBaseAnswer", default)]
349 answers: BTreeMap<u64, RawAnswer>,
350 #[serde(rename = "KnowledgeBaseAnswerTranslation", default)]
351 translations: BTreeMap<u64, RawTranslation>,
352 #[serde(rename = "KnowledgeBaseAnswerTranslationContent", default)]
353 contents: BTreeMap<u64, RawContent>,
354 #[serde(rename = "KnowledgeBaseCategory", default)]
355 categories: BTreeMap<u64, RawCategory>,
356 #[serde(rename = "KnowledgeBaseCategoryTranslation", default)]
357 category_translations: BTreeMap<u64, RawCategoryTranslation>,
358 #[serde(rename = "KnowledgeBaseLocale", default)]
359 locales: BTreeMap<u64, RawLocale>,
360}
361
362impl KbAssets {
363 fn primary_translation(&self, raw: &RawAnswer) -> Option<&RawTranslation> {
365 raw.translation_ids
366 .first()
367 .and_then(|tid| self.translations.get(tid))
368 }
369
370 fn kb_of_category(&self, category_id: u64) -> Option<u64> {
372 self.categories
373 .get(&category_id)
374 .and_then(|c| c.knowledge_base_id)
375 }
376
377 fn primary_locale_id(&self, kb_id: u64) -> Option<u64> {
380 let mut first = None;
381 for (&id, loc) in &self.locales {
382 if loc.knowledge_base_id != kb_id {
383 continue;
384 }
385 if loc.primary {
386 return Some(id);
387 }
388 first.get_or_insert(id);
389 }
390 first
391 }
392
393 fn to_answer(&self, id: u64, raw: &RawAnswer, body: Option<Option<String>>) -> KbAnswer {
397 let title = self.primary_translation(raw).and_then(|t| t.title.clone());
398 let category = raw
399 .category_id
400 .and_then(|cid| self.categories.get(&cid))
401 .and_then(|c| c.translation_ids.first())
402 .and_then(|ctid| self.category_translations.get(ctid))
403 .and_then(|ct| ct.title.clone());
404 KbAnswer {
405 id,
406 title,
407 category_id: raw.category_id,
408 category,
409 published: raw.published_at.is_some(),
410 body: body.flatten(),
411 }
412 }
413}
414
415#[derive(Debug, Deserialize)]
416struct RawAnswer {
417 #[serde(default)]
418 category_id: Option<u64>,
419 #[serde(default)]
420 translation_ids: Vec<u64>,
421 #[serde(default)]
422 published_at: Option<String>,
423}
424
425#[derive(Debug, Deserialize)]
426struct RawTranslation {
427 answer_id: u64,
428 #[serde(default)]
429 title: Option<String>,
430 #[serde(default)]
431 content_id: Option<u64>,
432}
433
434#[derive(Debug, Deserialize)]
435struct RawContent {
436 #[serde(default)]
437 body: Option<String>,
438}
439
440#[derive(Debug, Deserialize)]
441struct RawCategory {
442 #[serde(default)]
443 knowledge_base_id: Option<u64>,
444 #[serde(default)]
445 translation_ids: Vec<u64>,
446}
447
448#[derive(Debug, Deserialize)]
449struct RawLocale {
450 knowledge_base_id: u64,
451 #[serde(default)]
452 primary: bool,
453}
454
455#[derive(Debug, Deserialize)]
456struct RawCategoryTranslation {
457 #[serde(default)]
458 title: Option<String>,
459}
460
461#[derive(Debug, Deserialize)]
463struct AnswerWriteResponse {
464 id: u64,
465}
466
467#[derive(Debug, Deserialize)]
468struct SearchResponse {
469 #[serde(default)]
470 details: Vec<SearchDetail>,
471}
472
473#[derive(Debug, Deserialize)]
474struct SearchDetail {
475 id: u64,
476 #[serde(default)]
477 title: Option<String>,
478 #[serde(default)]
479 body: Option<String>,
480 #[serde(default)]
481 subtitle: Option<String>,
482 #[serde(default)]
483 url: Option<String>,
484}
485
486fn strip_highlight(s: Option<String>) -> Option<String> {
489 s.map(|s| s.replace("<em>", "").replace("</em>", ""))
490}