Skip to main content

wekan_cli/client/
checklists.rs

1use reqwest::header::ACCEPT;
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize};
4use serde_json::Number;
5
6use super::{
7    ClientError, WekanClient, datetime::validate_response_date, transport::decode_success,
8};
9
10#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
11#[serde(deny_unknown_fields)]
12pub struct ChecklistSummary {
13    #[serde(rename(deserialize = "_id", serialize = "checklist_id"))]
14    pub checklist_id: String,
15    pub title: String,
16}
17
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(
20    deny_unknown_fields,
21    rename_all(deserialize = "camelCase", serialize = "snake_case")
22)]
23pub struct ChecklistItemSummary {
24    #[serde(rename(deserialize = "_id", serialize = "item_id"))]
25    pub item_id: String,
26    pub title: String,
27    pub is_finished: bool,
28}
29
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(
32    deny_unknown_fields,
33    rename_all(deserialize = "camelCase", serialize = "snake_case")
34)]
35pub struct ChecklistDocument {
36    #[serde(rename(deserialize = "_id", serialize = "checklist_id"))]
37    pub checklist_id: String,
38    pub card_id: String,
39    /// Denormalized metadata; v11.06 REST card moves can leave the source board ID.
40    pub board_id: Option<String>,
41    pub user_id: Option<String>,
42    pub title: String,
43    pub finished_at: Option<String>,
44    pub created_at: String,
45    pub modified_at: String,
46    pub sort: Number,
47    pub hide_checked_checklist_items: Option<bool>,
48    pub hide_all_checklist_items: Option<bool>,
49    pub show_checklist_at_minicard: Option<bool>,
50    pub items: Vec<ChecklistItemSummary>,
51}
52
53/// Exact JSON forms accepted for checklist creation's `items` field.
54#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
55#[serde(untagged)]
56pub enum ChecklistItemsInput {
57    String(String),
58    Array(Vec<String>),
59    Null,
60}
61
62#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
63pub struct CreateChecklistRequest {
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub title: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub items: Option<ChecklistItemsInput>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct CreateChecklistResult {
72    pub checklist_id: String,
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct DeleteChecklistResult {
77    pub checklist_id: String,
78}
79
80#[derive(Deserialize)]
81#[serde(deny_unknown_fields)]
82struct IdResponse {
83    #[serde(rename = "_id")]
84    id: String,
85}
86
87impl WekanClient {
88    pub async fn card_checklists(
89        &self,
90        board_id: &str,
91        card_id: &str,
92        token: &SecretString,
93    ) -> Result<Vec<ChecklistSummary>, ClientError> {
94        let body = self
95            .execute_success_request(
96                self.http
97                    .get(self.checklist_endpoint(board_id, card_id, None)?)
98                    .header(ACCEPT, "application/json")
99                    .bearer_auth(token.expose_secret()),
100            )
101            .await?;
102        let checklists: Vec<ChecklistSummary> =
103            decode_success(&body, "card checklist collection", true)?;
104        for checklist in &checklists {
105            require_id(&checklist.checklist_id, "checklist id")?;
106        }
107        Ok(checklists)
108    }
109
110    pub async fn checklist(
111        &self,
112        board_id: &str,
113        card_id: &str,
114        checklist_id: &str,
115        token: &SecretString,
116    ) -> Result<ChecklistDocument, ClientError> {
117        let body = self
118            .execute_success_request(
119                self.http
120                    .get(self.checklist_endpoint(board_id, card_id, Some(checklist_id))?)
121                    .header(ACCEPT, "application/json")
122                    .bearer_auth(token.expose_secret()),
123            )
124            .await?;
125        let checklist: ChecklistDocument = decode_success(&body, "checklist", true)?;
126        require_id(&checklist.checklist_id, "checklist id")?;
127        require_id(&checklist.card_id, "card id")?;
128        if let Some(id) = &checklist.board_id {
129            require_id(id, "board id")?;
130        }
131        if let Some(id) = &checklist.user_id {
132            require_id(id, "creator user id")?;
133        }
134        if checklist.checklist_id != checklist_id || checklist.card_id != card_id {
135            return Err(protocol_error(
136                "the checklist response identifiers did not match the requested scope",
137            ));
138        }
139        for date in [&checklist.created_at, &checklist.modified_at]
140            .into_iter()
141            .chain(checklist.finished_at.iter())
142        {
143            require_date(date)?;
144        }
145        for item in &checklist.items {
146            require_id(&item.item_id, "item id")?;
147        }
148        Ok(checklist)
149    }
150
151    pub async fn create_checklist(
152        &self,
153        board_id: &str,
154        card_id: &str,
155        request: &CreateChecklistRequest,
156        token: &SecretString,
157    ) -> Result<CreateChecklistResult, ClientError> {
158        let body = self
159            .execute_success_request(
160                self.http
161                    .post(self.checklist_endpoint(board_id, card_id, None)?)
162                    .header(ACCEPT, "application/json")
163                    .bearer_auth(token.expose_secret())
164                    .json(request),
165            )
166            .await?;
167        let response: IdResponse = decode_success(&body, "checklist creation", true)?;
168        require_id(&response.id, "created checklist id")?;
169        Ok(CreateChecklistResult {
170            checklist_id: response.id,
171        })
172    }
173
174    pub async fn delete_checklist(
175        &self,
176        board_id: &str,
177        card_id: &str,
178        checklist_id: &str,
179        token: &SecretString,
180    ) -> Result<DeleteChecklistResult, ClientError> {
181        let body = self
182            .execute_success_request(
183                self.http
184                    .delete(self.checklist_endpoint(board_id, card_id, Some(checklist_id))?)
185                    .header(ACCEPT, "application/json")
186                    .bearer_auth(token.expose_secret()),
187            )
188            .await?;
189        let response: IdResponse = decode_success(&body, "checklist deletion", true)?;
190        require_id(&response.id, "deleted checklist id")?;
191        if response.id != checklist_id {
192            return Err(protocol_error(
193                "Wekan reported a different deleted checklist ID than requested",
194            ));
195        }
196        Ok(DeleteChecklistResult {
197            checklist_id: response.id,
198        })
199    }
200
201    pub(super) fn checklist_endpoint(
202        &self,
203        board_id: &str,
204        card_id: &str,
205        checklist_id: Option<&str>,
206    ) -> Result<reqwest::Url, ClientError> {
207        let mut endpoint =
208            self.server()
209                .join("api/boards")
210                .map_err(|error| ClientError::Protocol {
211                    diagnostic: None,
212                    message: format!("could not build the checklist endpoint: {error}"),
213                    success_status_received: false,
214                })?;
215        let mut segments = endpoint
216            .path_segments_mut()
217            .map_err(|()| ClientError::Protocol {
218                diagnostic: None,
219                message: "could not add identifiers to the checklist endpoint".to_owned(),
220                success_status_received: false,
221            })?;
222        segments.extend([board_id, "cards", card_id, "checklists"]);
223        if let Some(id) = checklist_id {
224            segments.push(id);
225        }
226        drop(segments);
227        Ok(endpoint)
228    }
229}
230
231pub(super) fn protocol_error(message: &str) -> ClientError {
232    ClientError::Protocol {
233        diagnostic: None,
234        message: message.to_owned(),
235        success_status_received: true,
236    }
237}
238
239pub(super) fn require_id(value: &str, field: &str) -> Result<(), ClientError> {
240    if value.trim().is_empty() {
241        Err(protocol_error(&format!(
242            "the checklist response contained an empty {field}"
243        )))
244    } else {
245        Ok(())
246    }
247}
248
249pub(super) fn require_date(value: &str) -> Result<(), ClientError> {
250    validate_response_date(value)
251        .map_err(|()| protocol_error("the checklist response contained an invalid date-time"))
252}