Skip to main content

zammad_api/api/
knowledge_base.rs

1//! Knowledge Base ("FAQ") endpoints.
2//!
3//! Zammad's Knowledge Base API does not hand back plain records the way the
4//! ticket or user endpoints do. Instead it returns an *assets* bundle: a set of
5//! id-keyed maps (`KnowledgeBaseAnswer`, `KnowledgeBaseAnswerTranslation`,
6//! `KnowledgeBaseAnswerTranslationContent`, `KnowledgeBaseCategory`, ...) that
7//! the caller is expected to stitch together. This module hides that stitching
8//! and exposes flat [`KbAnswer`] / [`KbSearchHit`] values.
9//!
10//! The following operations are provided:
11//!
12//! - [`KnowledgeBaseApi::list`] — every answer with its title and category
13//!   (no body).
14//! - [`KnowledgeBaseApi::search`] — full-text search, returning highlighted
15//!   title/body snippets.
16//! - [`KnowledgeBaseApi::get`] — a single answer including its full body.
17//! - [`KnowledgeBaseApi::create_answer`] /
18//!   [`KnowledgeBaseApi::update_answer`] /
19//!   [`KnowledgeBaseApi::delete_answer`] — answer writes, including the
20//!   publishing state machine.
21//! - [`KnowledgeBaseApi::list_knowledge_bases`] /
22//!   [`KnowledgeBaseApi::list_categories`] /
23//!   [`KnowledgeBaseApi::get_category`] — the structure, read from the
24//!   `init` bundle.
25//! - [`KnowledgeBaseApi::create_category`] /
26//!   [`KnowledgeBaseApi::update_category`] /
27//!   [`KnowledgeBaseApi::delete_category`] — category CRUD.
28//!
29//! The answer body is not part of the default `init` payload; it is fetched in
30//! a second `init` call that names the wanted
31//! `answer_translation_content_ids`.
32
33use 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
44/// Icon used when [`NewKbCategory::icon`] is `None`; Zammad requires an
45/// icon on category create.
46const DEFAULT_CATEGORY_ICON: &str = "f115";
47
48/// Knowledge Base (FAQ) endpoints.
49#[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    /// List every answer known to the instance, flattened to
60    /// [`KbAnswer`]s (title + category, no body).
61    ///
62    /// `POST /api/v1/knowledge_bases/init`.
63    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    /// Fetch a single answer by its numeric id, including its full body.
75    ///
76    /// Returns [`Error::NotFound`] when no answer with that id exists.
77    ///
78    /// Issues two `POST /api/v1/knowledge_bases/init` calls: one for the
79    /// structure and one that requests the answer's translation content.
80    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        // The body lives on the translation's content record, addressed by
85        // `content_id`. Ask a second `init` to include it.
86        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    /// Every knowledge base on the instance (from the `init` bundle).
93    ///
94    /// `POST /api/v1/knowledge_bases/init`.
95    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    /// Every category of every knowledge base (from the `init` bundle),
107    /// sorted by id.
108    ///
109    /// `POST /api/v1/knowledge_bases/init`.
110    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    /// Fetch a single category by its numeric id.
123    ///
124    /// Returns [`Error::NotFound`] when no category with that id exists.
125    ///
126    /// `POST /api/v1/knowledge_bases/init`.
127    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    /// Create a category in the given knowledge base (primary locale).
134    ///
135    /// `kb_id` is explicit because a new category cannot be resolved from
136    /// `init`. Returns [`Error::NotFound`] when the kb_id is unknown and
137    /// [`Error::Config`] when the KB has no locale.
138    ///
139    /// `POST /api/v1/knowledge_bases/:kb_id/categories`.
140    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    /// Partial update; kb_id resolved from the category via `init`.
166    ///
167    /// An all-`None` patch skips the HTTP call and returns the current
168    /// state. Returns [`Error::NotFound`] when no category with that id
169    /// exists.
170    ///
171    /// `PATCH /api/v1/knowledge_bases/:kb_id/categories/:id`.
172    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    /// Delete a category.
207    ///
208    /// Zammad refuses (422) when the category still has answers or child
209    /// categories; that surfaces as [`Error::Api`].
210    ///
211    /// `DELETE /api/v1/knowledge_bases/:kb_id/categories/:id`.
212    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    /// Create a new answer in a category, in the knowledge base's primary
224    /// locale.
225    ///
226    /// When [`NewKbAnswer::published`] is `true` a second
227    /// `POST .../answers/:id/publish` call makes it public right away;
228    /// otherwise it is left as a draft. The knowledge base and locale are
229    /// resolved from the category.
230    ///
231    /// `POST /api/v1/knowledge_bases/:kb_id/answers`.
232    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    /// Update an existing answer's title, body, and/or published state. Fields
263    /// left as `None` in the [`KbAnswerPatch`] are preserved.
264    ///
265    /// Returns [`Error::NotFound`] when no answer with that id exists.
266    ///
267    /// `PATCH /api/v1/knowledge_bases/:kb_id/answers/:id`.
268    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        // Merge: keep the current title/body for whichever field the caller
294        // left unset, so a partial update never blanks the other.
295        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        // `content_attributes` is addressed by the translation's `id`;
305        // Zammad updates the existing content in place.
306        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    /// Delete an answer.
326    ///
327    /// The knowledge base is resolved answer → category → kb.
328    ///
329    /// `DELETE /api/v1/knowledge_bases/:kb_id/answers/:id`.
330    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    /// Fetch the body for a translation content id, if any.
347    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    /// Move an answer in the publishing state machine: `publish` makes it
356    /// public, `has_publishing_update` (with all state timestamps cleared)
357    /// reverts it to a draft.
358    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    /// Full-text search across the Knowledge Base.
378    ///
379    /// Returns a builder; call [`SearchKnowledgeBase::send`] to run it.
380    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    /// `POST /api/v1/knowledge_bases/init`, optionally requesting the body
390    /// content for the given answer-translation content ids.
391    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/// Builder for `POST /api/v1/knowledge_bases/search`.
399#[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    /// Result flavor. `"public"` (the default) searches published,
409    /// publicly-visible answers; `"agent"` also covers answers only visible
410    /// to signed-in agents.
411    pub fn flavor(mut self, flavor: impl Into<String>) -> Self {
412        self.flavor = flavor.into();
413        self
414    }
415
416    /// Maximum number of hits to return (default 50).
417    pub fn per_page(mut self, n: u32) -> Self {
418        self.per_page = n;
419        self
420    }
421
422    /// Run the search.
423    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        // `details` carries the presentable fields (title, body snippet,
434        // category subtitle, url); `id` there is the answer *translation* id.
435        // Resolve each to its stable answer id via `init` so callers can hand
436        // the id straight to [`KnowledgeBaseApi::get`].
437        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/// A single Knowledge Base answer, flattened from Zammad's assets bundle.
458#[derive(Debug, Clone)]
459pub struct KbAnswer {
460    /// Numeric answer id (stable; accepted by [`KnowledgeBaseApi::get`]).
461    pub id: u64,
462    /// Answer title, in the Knowledge Base's primary locale.
463    pub title: Option<String>,
464    /// Id of the category the answer belongs to.
465    pub category_id: Option<u64>,
466    /// Title of that category, in the primary locale.
467    pub category: Option<String>,
468    /// Whether the answer has been published.
469    pub published: bool,
470    /// The answer body (HTML). Only populated by [`KnowledgeBaseApi::get`];
471    /// always `None` for [`KnowledgeBaseApi::list`].
472    pub body: Option<String>,
473}
474
475/// Fields for creating a Knowledge Base answer via
476/// [`KnowledgeBaseApi::create_answer`].
477#[derive(Debug, Clone)]
478pub struct NewKbAnswer {
479    /// Category the answer belongs to. The knowledge base and locale are
480    /// resolved from it.
481    pub category_id: u64,
482    /// Answer title.
483    pub title: String,
484    /// Answer body (HTML or plain text).
485    pub body: String,
486    /// Publish the answer immediately (`true`) or leave it a draft (`false`).
487    pub published: bool,
488}
489
490/// A partial update for [`KnowledgeBaseApi::update_answer`]. Every field is
491/// optional; `None` leaves the current value untouched.
492#[derive(Debug, Clone, Default)]
493pub struct KbAnswerPatch {
494    /// New title.
495    pub title: Option<String>,
496    /// New body (HTML or plain text).
497    pub body: Option<String>,
498    /// `Some(true)` publishes, `Some(false)` reverts to a draft, `None` leaves
499    /// the published state unchanged.
500    pub published: Option<bool>,
501}
502
503/// A single full-text search hit.
504#[derive(Debug, Clone)]
505pub struct KbSearchHit {
506    /// Stable answer id, resolved from the matched translation. `None` only if
507    /// the translation could not be found in the structure payload.
508    pub answer_id: Option<u64>,
509    /// Id of the answer *translation* that matched.
510    pub translation_id: u64,
511    /// Answer title (search highlight markers stripped).
512    pub title: Option<String>,
513    /// Category the answer sits under.
514    pub category: Option<String>,
515    /// A snippet of the body around the match (highlight markers stripped).
516    pub snippet: Option<String>,
517    /// Relative help-center URL of the answer.
518    pub url: Option<String>,
519}
520
521/// A knowledge base, as listed by
522/// [`KnowledgeBaseApi::list_knowledge_bases`].
523#[derive(Debug, Clone)]
524pub struct KbInfo {
525    /// Numeric knowledge base id.
526    pub id: u64,
527    /// Title in the KB's primary locale (from `KnowledgeBaseTranslation`),
528    /// else first.
529    pub title: Option<String>,
530    /// Whether the knowledge base is active.
531    pub active: bool,
532}
533
534/// A Knowledge Base category, flattened from Zammad's assets bundle.
535#[derive(Debug, Clone)]
536pub struct KbCategory {
537    /// Numeric category id (stable; accepted by the category methods).
538    pub id: u64,
539    /// Id of the knowledge base the category belongs to.
540    pub knowledge_base_id: u64,
541    /// Id of the parent category, or `None` for a top-level category.
542    pub parent_id: Option<u64>,
543    /// FontAwesome icon code (e.g. `"f115"`).
544    pub icon: Option<String>,
545    /// Sort position within its parent.
546    pub position: Option<u64>,
547    /// Title in the KB's primary locale (first listed translation).
548    pub title: Option<String>,
549    /// Ids of the answers in this category.
550    pub answer_ids: Vec<u64>,
551    /// Ids of the direct child categories.
552    pub child_ids: Vec<u64>,
553    /// Creation timestamp (ISO 8601).
554    pub created_at: Option<String>,
555    /// Last-update timestamp (ISO 8601).
556    pub updated_at: Option<String>,
557}
558
559/// Fields for creating a Knowledge Base category via
560/// [`KnowledgeBaseApi::create_category`].
561///
562/// `icon: None` → `"f115"` (the docs' example icon; Zammad requires an
563/// icon). `parent_id: None` → top-level category.
564#[derive(Debug, Clone)]
565pub struct NewKbCategory {
566    /// Category title (primary locale).
567    pub title: String,
568    /// FontAwesome icon code; `None` falls back to `"f115"`.
569    pub icon: Option<String>,
570    /// Parent category id; `None` creates a top-level category.
571    pub parent_id: Option<u64>,
572}
573
574/// A partial update for [`KnowledgeBaseApi::update_category`]. Every field is
575/// optional; `None` leaves the current value untouched.
576#[derive(Debug, Clone, Default)]
577pub struct KbCategoryPatch {
578    /// New title.
579    pub title: Option<String>,
580    /// New FontAwesome icon code.
581    pub icon: Option<String>,
582    /// Tri-state `parent_id`: `None` = unchanged, `Some(None)` = clear
583    /// parent, `Some(Some(id))` = set parent.
584    pub parent_id: Option<Option<u64>>,
585}
586
587// --- wire types -------------------------------------------------------------
588
589/// The `init` assets bundle, with only the maps this module joins on.
590#[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    /// The primary-locale translation for an answer (the first listed).
612    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    /// The knowledge base a category belongs to.
619    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    /// The primary `kb_locale_id` of a knowledge base (needed to address a
626    /// translation on write). Falls back to that KB's first locale.
627    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    /// Build a [`KbAnswer`]. `body` is `Some(...)` for a fetched answer (the
642    /// inner `Option` being whether a body actually exists), `None` for a
643    /// listing where the body was never requested.
644    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    /// Build a [`KbCategory`]. The title is the primary-locale translation
663    /// when the KB's primary locale is known, else the first listed one.
664    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    /// The category title: the translation in the KB's primary locale when
680    /// both locale ids are known, else the first listed translation.
681    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    /// Build a [`KbInfo`]. The title is the `kb_translations` entry whose
702    /// locale is the KB's primary one, else the first entry belonging to
703    /// that KB (via its locale).
704    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/// The `{ id }` envelope returned by category create/update.
803#[derive(Debug, Deserialize)]
804struct CategoryWriteResponse {
805    id: u64,
806}
807
808/// The `{ id, assets }` envelope returned by answer create/update.
809#[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
833/// Drop the `<em>…</em>` markers Zammad wraps around matched terms in search
834/// highlights, leaving plain text.
835fn strip_highlight(s: Option<String>) -> Option<String> {
836    s.map(|s| s.replace("<em>", "").replace("</em>", ""))
837}