Skip to main content

wekan_cli/client/cards/
bulk.rs

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
125// Compare multisets: duplicate requests and duplicate result records are meaningful.
126fn 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}