1use reqwest::header::ACCEPT;
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize};
4
5use super::{
6 ClientError, WekanClient, datetime::validate_response_date, transport::decode_success,
7};
8
9#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "lowercase")]
11pub enum CustomFieldType {
12 Text,
13 Number,
14 Date,
15 Dropdown,
16 Checkbox,
17 Currency,
18 Stringtemplate,
19}
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(deny_unknown_fields)]
23pub struct CustomFieldSummary {
24 #[serde(rename = "_id")]
25 pub custom_field_id: String,
26 pub name: String,
27 #[serde(rename = "type")]
28 pub field_type: CustomFieldType,
29}
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(deny_unknown_fields)]
33pub struct CustomFieldDropdownItem {
34 #[serde(rename = "_id")]
35 pub item_id: String,
36 pub name: String,
37}
38
39#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
41#[serde(deny_unknown_fields, rename_all = "camelCase")]
42pub struct CustomFieldSettings {
43 #[serde(
44 default,
45 skip_serializing_if = "Option::is_none",
46 deserialize_with = "nullable_setting"
47 )]
48 pub currency_code: Option<Option<String>>,
49 #[serde(
50 default,
51 skip_serializing_if = "Option::is_none",
52 deserialize_with = "nullable_items"
53 )]
54 pub dropdown_items: Option<Option<Vec<CustomFieldDropdownItem>>>,
55 #[serde(
56 default,
57 skip_serializing_if = "Option::is_none",
58 deserialize_with = "nullable_setting"
59 )]
60 pub stringtemplate_format: Option<Option<String>>,
61 #[serde(
62 default,
63 skip_serializing_if = "Option::is_none",
64 deserialize_with = "nullable_setting"
65 )]
66 pub stringtemplate_separator: Option<Option<String>>,
67}
68
69impl CustomFieldSettings {
70 pub(crate) fn validate_dropdown_item_ids(&self) -> Result<(), &'static str> {
71 if let Some(Some(items)) = &self.dropdown_items {
72 for item in items {
73 let id = super::strings::trim_javascript_whitespace(&item.item_id);
76 validate_custom_field_identifier(id).map_err(|_| {
77 "dropdown item IDs must not be dot-only after Wekan trims whitespace; no request was sent"
78 })?;
79 }
80 }
81 Ok(())
82 }
83}
84
85pub(crate) fn validate_custom_field_identifier(value: &str) -> Result<(), &'static str> {
86 match value {
87 "." | ".." => {
88 Err("dot-only identifiers cannot be sent by the HTTP client; no request was sent")
89 }
90 _ => Ok(()),
91 }
92}
93
94pub(crate) fn custom_field_input_error(message: &str) -> ClientError {
95 ClientError::Protocol {
96 diagnostic: None,
97 message: message.to_owned(),
98 success_status_received: false,
99 }
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
103#[serde(deny_unknown_fields, rename_all = "camelCase")]
104pub struct CustomFieldDocument {
105 #[serde(rename = "_id")]
106 pub custom_field_id: String,
107 pub board_ids: Vec<String>,
108 pub name: String,
109 #[serde(rename = "type")]
110 pub field_type: CustomFieldType,
111 #[serde(deserialize_with = "settings_object")]
112 pub settings: CustomFieldSettings,
113 pub show_on_card: bool,
114 pub automatically_on_card: bool,
115 pub always_on_card: bool,
116 pub show_label_on_mini_card: bool,
117 pub show_sum_at_top_of_list: bool,
118 pub created_at: Option<String>,
119 pub modified_at: String,
120}
121
122#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
123#[serde(rename_all = "camelCase")]
124pub struct CreateCustomFieldRequest {
125 pub name: String,
126 #[serde(rename = "type")]
127 pub field_type: CustomFieldType,
128 pub settings: CustomFieldSettings,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub show_on_card: Option<bool>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub automatically_on_card: Option<bool>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub show_label_on_mini_card: Option<bool>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub show_sum_at_top_of_list: Option<bool>,
137}
138
139#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
140#[serde(rename_all = "camelCase")]
141pub struct UpdateCustomFieldRequest {
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub name: Option<String>,
144 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
145 pub field_type: Option<CustomFieldType>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub settings: Option<CustomFieldSettings>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub show_on_card: Option<bool>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub automatically_on_card: Option<bool>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub always_on_card: Option<bool>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub show_label_on_mini_card: Option<bool>,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub show_sum_at_top_of_list: Option<bool>,
158}
159
160#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
161pub struct CreateCustomFieldItemsRequest {
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub items: Option<serde_json::Value>,
164}
165
166#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
167pub struct UpdateCustomFieldItemRequest {
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub name: Option<String>,
170}
171
172#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct CustomFieldIdResponse {
175 #[serde(rename = "_id")]
176 pub id: String,
177}
178
179impl WekanClient {
180 pub async fn board_custom_fields(
181 &self,
182 board: &str,
183 token: &SecretString,
184 ) -> Result<Vec<CustomFieldSummary>, ClientError> {
185 let body = self
186 .execute_success_request(
187 self.http
188 .get(self.custom_field_endpoint(board, None, false, None)?)
189 .header(ACCEPT, "application/json")
190 .bearer_auth(token.expose_secret()),
191 )
192 .await?;
193 let fields: Vec<CustomFieldSummary> =
194 decode_success(&body, "custom-field collection", true)?;
195 for field in &fields {
196 require_id(&field.custom_field_id)?;
197 }
198 Ok(fields)
199 }
200
201 pub async fn custom_field(
202 &self,
203 board: &str,
204 field: &str,
205 token: &SecretString,
206 ) -> Result<Option<CustomFieldDocument>, ClientError> {
207 let body = self
208 .execute_success_request(
209 self.http
210 .get(self.custom_field_endpoint(board, Some(field), false, None)?)
211 .header(ACCEPT, "application/json")
212 .bearer_auth(token.expose_secret()),
213 )
214 .await?;
215 if body.is_empty() {
216 return Ok(None);
218 }
219 let document: CustomFieldDocument = decode_success(&body, "custom field", true)?;
220 require_id(&document.custom_field_id)?;
221 if document.custom_field_id != field || !document.board_ids.iter().any(|id| id == board) {
222 return Err(protocol_error(
223 "the custom-field response identifiers did not match the requested scope",
224 ));
225 }
226 for id in &document.board_ids {
227 require_id(id)?;
228 }
229 if let Some(Some(items)) = &document.settings.dropdown_items {
230 for item in items {
231 require_id(&item.item_id)?;
232 }
233 }
234 for date in std::iter::once(&document.modified_at).chain(document.created_at.iter()) {
235 validate_response_date(date).map_err(|()| {
236 protocol_error("the custom-field response contained an invalid date-time")
237 })?;
238 }
239 Ok(Some(document))
240 }
241
242 pub async fn create_custom_field(
243 &self,
244 board: &str,
245 request: &CreateCustomFieldRequest,
246 token: &SecretString,
247 ) -> Result<CustomFieldIdResponse, ClientError> {
248 request
249 .settings
250 .validate_dropdown_item_ids()
251 .map_err(custom_field_input_error)?;
252 let body = self
253 .execute_success_request(
254 self.http
255 .post(self.custom_field_endpoint(board, None, false, None)?)
256 .header(ACCEPT, "application/json")
257 .bearer_auth(token.expose_secret())
258 .json(request),
259 )
260 .await?;
261 decode_id(&body, None)
262 }
263
264 pub async fn update_custom_field(
265 &self,
266 board: &str,
267 field: &str,
268 request: &UpdateCustomFieldRequest,
269 token: &SecretString,
270 ) -> Result<CustomFieldIdResponse, ClientError> {
271 if let Some(settings) = &request.settings {
272 settings
273 .validate_dropdown_item_ids()
274 .map_err(custom_field_input_error)?;
275 }
276 let body = self
277 .execute_success_request(
278 self.http
279 .put(self.custom_field_endpoint(board, Some(field), false, None)?)
280 .header(ACCEPT, "application/json")
281 .bearer_auth(token.expose_secret())
282 .json(request),
283 )
284 .await?;
285 decode_id(&body, Some(field))
286 }
287
288 pub async fn delete_custom_field(
289 &self,
290 board: &str,
291 field: &str,
292 token: &SecretString,
293 ) -> Result<CustomFieldIdResponse, ClientError> {
294 let body = self
295 .execute_success_request(
296 self.http
297 .delete(self.custom_field_endpoint(board, Some(field), false, None)?)
298 .header(ACCEPT, "application/json")
299 .bearer_auth(token.expose_secret()),
300 )
301 .await?;
302 decode_id(&body, Some(field))
303 }
304
305 pub async fn create_custom_field_items(
306 &self,
307 board: &str,
308 field: &str,
309 request: &CreateCustomFieldItemsRequest,
310 token: &SecretString,
311 ) -> Result<CustomFieldIdResponse, ClientError> {
312 let body = self
313 .execute_success_request(
314 self.http
315 .post(self.custom_field_endpoint(board, Some(field), true, None)?)
316 .header(ACCEPT, "application/json")
317 .bearer_auth(token.expose_secret())
318 .json(request),
319 )
320 .await?;
321 decode_id(&body, Some(field))
322 }
323
324 pub async fn update_custom_field_item(
325 &self,
326 board: &str,
327 field: &str,
328 item: &str,
329 request: &UpdateCustomFieldItemRequest,
330 token: &SecretString,
331 ) -> Result<CustomFieldIdResponse, ClientError> {
332 let body = self
333 .execute_success_request(
334 self.http
335 .put(self.custom_field_endpoint(board, Some(field), true, Some(item))?)
336 .header(ACCEPT, "application/json")
337 .bearer_auth(token.expose_secret())
338 .json(request),
339 )
340 .await?;
341 decode_id(&body, Some(item))
342 }
343
344 pub async fn delete_custom_field_item(
345 &self,
346 board: &str,
347 field: &str,
348 item: &str,
349 token: &SecretString,
350 ) -> Result<CustomFieldIdResponse, ClientError> {
351 let body = self
352 .execute_success_request(
353 self.http
354 .delete(self.custom_field_endpoint(board, Some(field), true, Some(item))?)
355 .header(ACCEPT, "application/json")
356 .bearer_auth(token.expose_secret()),
357 )
358 .await?;
359 decode_id(&body, Some(field))
361 }
362
363 fn custom_field_endpoint(
364 &self,
365 board: &str,
366 field: Option<&str>,
367 dropdown: bool,
368 item: Option<&str>,
369 ) -> Result<reqwest::Url, ClientError> {
370 for id in [Some(board), field, item].into_iter().flatten() {
371 validate_custom_field_identifier(id).map_err(custom_field_input_error)?;
372 }
373 let mut endpoint = self
374 .server()
375 .join("api/boards")
376 .map_err(|_| ClientError::Protocol {
377 diagnostic: None,
378 message: "could not build the custom-field endpoint".to_owned(),
379 success_status_received: false,
380 })?;
381 let mut segments = endpoint
382 .path_segments_mut()
383 .map_err(|()| ClientError::Protocol {
384 diagnostic: None,
385 message: "could not add custom-field identifiers".to_owned(),
386 success_status_received: false,
387 })?;
388 segments.extend([board, "custom-fields"]);
389 if let Some(field) = field {
390 segments.push(field);
391 }
392 if dropdown {
393 segments.push("dropdown-items");
394 }
395 if let Some(item) = item {
396 segments.push(item);
397 }
398 drop(segments);
399 Ok(endpoint)
400 }
401}
402
403fn decode_id(body: &[u8], expected: Option<&str>) -> Result<CustomFieldIdResponse, ClientError> {
404 let response: CustomFieldIdResponse =
405 decode_success(body, "custom-field acknowledgement", true)?;
406 require_id(&response.id)?;
407 if expected.is_some_and(|id| id != response.id) {
408 return Err(protocol_error(
409 "Wekan acknowledged a different custom-field identifier than requested",
410 ));
411 }
412 Ok(response)
413}
414
415fn require_id(id: &str) -> Result<(), ClientError> {
416 if super::strings::trim_javascript_whitespace(id).is_empty() {
417 Err(protocol_error(
418 "the custom-field response contained an empty identifier",
419 ))
420 } else {
421 Ok(())
422 }
423}
424
425fn protocol_error(message: &str) -> ClientError {
426 ClientError::Protocol {
427 diagnostic: None,
428 message: message.to_owned(),
429 success_status_received: true,
430 }
431}
432
433fn nullable_setting<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
434 deserializer: D,
435) -> Result<Option<Option<T>>, D::Error> {
436 Option::<T>::deserialize(deserializer).map(Some)
437}
438
439struct Object<T>(T);
440impl<'de, T: Deserialize<'de>> Deserialize<'de> for Object<T> {
441 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
442 struct Visitor<T>(std::marker::PhantomData<T>);
443 impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Visitor<T> {
444 type Value = Object<T>;
445 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
446 f.write_str("a JSON object")
447 }
448 fn visit_map<M: serde::de::MapAccess<'de>>(
449 self,
450 map: M,
451 ) -> Result<Self::Value, M::Error> {
452 T::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Object)
453 }
454 }
455 deserializer.deserialize_map(Visitor(std::marker::PhantomData))
456 }
457}
458fn settings_object<'de, D: serde::Deserializer<'de>>(
459 deserializer: D,
460) -> Result<CustomFieldSettings, D::Error> {
461 Object::<CustomFieldSettings>::deserialize(deserializer).map(|value| value.0)
462}
463fn nullable_items<'de, D: serde::Deserializer<'de>>(
464 deserializer: D,
465) -> Result<Option<Option<Vec<CustomFieldDropdownItem>>>, D::Error> {
466 Option::<Vec<Object<CustomFieldDropdownItem>>>::deserialize(deserializer)
467 .map(|items| Some(items.map(|items| items.into_iter().map(|item| item.0).collect())))
468}