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 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#[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}