1use reqwest::{StatusCode, header::ACCEPT};
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Deserializer, Serialize};
4use serde_json::Value;
5
6use crate::client::{ClientError, WekanClient, decoding::decode_value, transport::decode_success};
7
8#[derive(Clone, Debug, Deserialize, Serialize)]
9#[serde(deny_unknown_fields, rename_all = "camelCase")]
10pub struct BulkCreateCardInput {
11 #[serde(
12 default,
13 deserialize_with = "present",
14 skip_serializing_if = "Option::is_none"
15 )]
16 pub title: Option<Option<String>>,
17 #[serde(
18 default,
19 deserialize_with = "present",
20 skip_serializing_if = "Option::is_none"
21 )]
22 pub description: Option<Option<String>>,
23 #[serde(
24 default,
25 deserialize_with = "required_string",
26 skip_serializing_if = "Option::is_none"
27 )]
28 pub parent_id: Option<String>,
29 #[serde(
30 default,
31 deserialize_with = "required_string",
32 skip_serializing_if = "Option::is_none"
33 )]
34 pub swimlane_id: Option<String>,
35 #[serde(
36 default,
37 deserialize_with = "present",
38 skip_serializing_if = "Option::is_none"
39 )]
40 pub members: Option<Value>,
41 #[serde(
42 default,
43 deserialize_with = "present",
44 skip_serializing_if = "Option::is_none"
45 )]
46 pub assignees: Option<Value>,
47}
48
49fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
50 T::deserialize(d).map(Some)
51}
52fn required_string<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
53 String::deserialize(d).map(Some)
54}
55
56#[derive(Debug, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct BulkCreateCardsRequest {
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub swimlane_id: Option<String>,
61 pub cards: Vec<BulkCreateCardInput>,
62}
63
64#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
65#[serde(deny_unknown_fields)]
66pub struct BulkCreateCardResponse {
67 pub index: usize,
68 #[serde(default, rename = "_id", deserialize_with = "required_string")]
69 pub card_id: Option<String>,
70 #[serde(default, deserialize_with = "required_string")]
71 pub error: Option<String>,
72}
73
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
75#[serde(deny_unknown_fields, rename_all = "camelCase")]
76pub struct BulkDeleteCardsResponse {
77 pub deleted: Vec<String>,
78 pub not_found: Vec<String>,
79}
80
81#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
82#[serde(deny_unknown_fields, rename_all = "camelCase")]
83pub struct BulkLabelUpdate {
84 #[serde(rename = "_id")]
85 pub card_id: String,
86 pub label_ids: Vec<Option<String>>,
87}
88
89#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
90#[serde(deny_unknown_fields, rename_all = "camelCase")]
91pub struct BulkLabelsResponse {
92 pub updated: Vec<BulkLabelUpdate>,
93 pub not_found: Vec<String>,
94}
95
96#[derive(Debug, Serialize)]
97#[serde(rename_all = "camelCase")]
98pub struct BulkLabelsRequest {
99 pub card_ids: Vec<String>,
100 #[serde(skip_serializing_if = "Vec::is_empty")]
101 pub add_label_ids: Vec<String>,
102 #[serde(skip_serializing_if = "Vec::is_empty")]
103 pub remove_label_ids: Vec<String>,
104}
105
106#[derive(Debug, thiserror::Error)]
107pub enum BulkLabelsError {
108 #[error(transparent)]
109 Client(#[from] ClientError),
110 #[error("Wekan rejected labels absent from the board")]
111 InvalidLabels {
112 server_error: String,
113 invalid_label_ids: Vec<String>,
114 },
115}
116
117fn protocol(message: &str) -> ClientError {
118 ClientError::Protocol {
119 message: message.into(),
120 diagnostic: None,
121 success_status_received: true,
122 }
123}
124
125fn correlate(
127 requested: &[String],
128 returned: impl Iterator<Item = String>,
129) -> Result<(), ClientError> {
130 let mut expected = requested.to_vec();
131 let mut actual: Vec<_> = returned.collect();
132 expected.sort();
133 actual.sort();
134 if expected != actual {
135 return Err(protocol(
136 "Wekan returned bulk card IDs that do not match the request",
137 ));
138 }
139 Ok(())
140}
141
142impl WekanClient {
143 pub async fn bulk_create_cards(
144 &self,
145 board: &str,
146 list: &str,
147 request: &BulkCreateCardsRequest,
148 token: &SecretString,
149 ) -> Result<Vec<BulkCreateCardResponse>, ClientError> {
150 for id in [board, list] {
151 crate::client::boards::members::validate_id(id)?;
152 }
153 let mut endpoint = self.card_endpoint(board, list, None)?;
154 endpoint.path_segments_mut().expect("base URL").push("bulk");
155 let body = self
156 .execute_success_request(
157 self.http
158 .post(endpoint)
159 .header(ACCEPT, "application/json")
160 .bearer_auth(token.expose_secret())
161 .json(request),
162 )
163 .await?;
164 let results: Vec<BulkCreateCardResponse> =
165 decode_success(&body, "bulk card creation", true)?;
166 if results.len() != request.cards.len()
167 || results.iter().enumerate().any(|(i, result)| {
168 result.index != i
169 || result.card_id.is_some() == result.error.is_some()
170 || result.card_id.as_ref().is_some_and(String::is_empty)
171 })
172 {
173 return Err(protocol(
174 "Wekan returned invalid bulk creation indices or outcomes",
175 ));
176 }
177 Ok(results)
178 }
179
180 pub async fn bulk_delete_cards(
181 &self,
182 board: &str,
183 card_ids: &[String],
184 token: &SecretString,
185 ) -> Result<BulkDeleteCardsResponse, ClientError> {
186 let endpoint = self.bulk_card_endpoint(board, "bulk")?;
187 let body = self
188 .execute_success_request(
189 self.http
190 .delete(endpoint)
191 .header(ACCEPT, "application/json")
192 .bearer_auth(token.expose_secret())
193 .json(&serde_json::json!({"cardIds": card_ids})),
194 )
195 .await?;
196 let result: BulkDeleteCardsResponse = decode_success(&body, "bulk card deletion", true)?;
197 correlate(
198 card_ids,
199 result.deleted.iter().chain(&result.not_found).cloned(),
200 )?;
201 Ok(result)
202 }
203
204 pub async fn bulk_card_labels(
205 &self,
206 board: &str,
207 request: &BulkLabelsRequest,
208 token: &SecretString,
209 ) -> Result<BulkLabelsResponse, BulkLabelsError> {
210 let endpoint = self.bulk_card_endpoint(board, "labels")?;
211 let body = self
212 .execute_success_request_with_error(
213 self.http
214 .post(endpoint)
215 .header(ACCEPT, "application/json")
216 .bearer_auth(token.expose_secret())
217 .json(request),
218 label_error,
219 )
220 .await?;
221 let result: BulkLabelsResponse = decode_success(&body, "bulk card labels", true)?;
222 correlate(
223 &request.card_ids,
224 result
225 .updated
226 .iter()
227 .map(|item| item.card_id.clone())
228 .chain(result.not_found.iter().cloned()),
229 )?;
230 Ok(result)
231 }
232
233 fn bulk_card_endpoint(&self, board: &str, action: &str) -> Result<reqwest::Url, ClientError> {
234 crate::client::boards::members::validate_id(board)?;
235 let mut endpoint = self.board_endpoint(board, Some("cards"))?;
236 endpoint.path_segments_mut().expect("base URL").push(action);
237 Ok(endpoint)
238 }
239}
240
241fn label_error(status: StatusCode, body: &[u8]) -> Result<Option<BulkLabelsError>, ClientError> {
242 #[derive(Deserialize)]
243 #[serde(deny_unknown_fields, rename_all = "camelCase")]
244 struct InvalidLabels {
245 error: String,
246 invalid_label_ids: Vec<String>,
247 }
248 if status == StatusCode::BAD_REQUEST {
249 if let Ok(value) = serde_json::from_slice::<Value>(body) {
250 if value.get("invalidLabelIds").is_some() {
251 let error: InvalidLabels = decode_value(value, "bulk label error", false)?;
252 return Ok(Some(BulkLabelsError::InvalidLabels {
253 server_error: error.error,
254 invalid_label_ids: error.invalid_label_ids,
255 }));
256 }
257 }
258 }
259 Ok(None)
260}