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