1use reqwest::header::ACCEPT;
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize};
4use serde_json::Number;
5use time::{OffsetDateTime, format_description::well_known::Rfc3339};
6
7use super::{ClientError, WekanClient, transport::decode_success};
8
9pub mod card_settings;
10mod copy;
11pub mod domains;
12mod export;
13pub mod import;
14pub use copy::CopyBoardRequest;
15pub mod labels;
16pub mod members;
17
18#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum BoardPermission {
21 #[default]
22 Private,
23 Public,
24}
25
26impl BoardPermission {
27 pub(crate) const fn as_str(self) -> &'static str {
28 match self {
29 Self::Private => "private",
30 Self::Public => "public",
31 }
32 }
33}
34
35#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(rename_all = "lowercase")]
37pub enum BoardColor {
38 #[default]
39 Belize,
40 Nephritis,
41 Pomegranate,
42 Pumpkin,
43 Wisteria,
44 Moderatepink,
45 Strongcyan,
46 Limegreen,
47 Midnight,
48 Dark,
49 Relax,
50 Corteza,
51 Appleglasspastel,
52 Clearblue,
53 Cleargreen,
54 Clearorange,
55 Clearpink,
56 Clearpurple,
57 Clearred,
58 Natural,
59 Modern,
60 Moderndark,
61 Exodark,
62 Cleandark,
63 Cleanlight,
64}
65
66impl BoardColor {
67 pub(crate) const fn as_str(self) -> &'static str {
68 match self {
69 Self::Belize => "belize",
70 Self::Nephritis => "nephritis",
71 Self::Pomegranate => "pomegranate",
72 Self::Pumpkin => "pumpkin",
73 Self::Wisteria => "wisteria",
74 Self::Moderatepink => "moderatepink",
75 Self::Strongcyan => "strongcyan",
76 Self::Limegreen => "limegreen",
77 Self::Midnight => "midnight",
78 Self::Dark => "dark",
79 Self::Relax => "relax",
80 Self::Corteza => "corteza",
81 Self::Appleglasspastel => "appleglasspastel",
82 Self::Clearblue => "clearblue",
83 Self::Cleargreen => "cleargreen",
84 Self::Clearorange => "clearorange",
85 Self::Clearpink => "clearpink",
86 Self::Clearpurple => "clearpurple",
87 Self::Clearred => "clearred",
88 Self::Natural => "natural",
89 Self::Modern => "modern",
90 Self::Moderndark => "moderndark",
91 Self::Exodark => "exodark",
92 Self::Cleandark => "cleandark",
93 Self::Cleanlight => "cleanlight",
94 }
95 }
96}
97
98#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
99#[serde(rename_all = "kebab-case")]
100pub enum PresentParentTask {
101 PrefixWithFullPath,
102 PrefixWithParent,
103 SubtextWithFullPath,
104 SubtextWithParent,
105 NoParent,
106}
107
108impl PresentParentTask {
109 pub(crate) const fn as_str(self) -> &'static str {
110 match self {
111 Self::PrefixWithFullPath => "prefix-with-full-path",
112 Self::PrefixWithParent => "prefix-with-parent",
113 Self::SubtextWithFullPath => "subtext-with-full-path",
114 Self::SubtextWithParent => "subtext-with-parent",
115 Self::NoParent => "no-parent",
116 }
117 }
118}
119
120#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
121#[serde(rename_all = "kebab-case")]
122pub enum BoardType {
123 Board,
124 TemplateBoard,
125 TemplateContainer,
126}
127
128impl BoardType {
129 pub(crate) const fn as_str(self) -> &'static str {
130 match self {
131 Self::Board => "board",
132 Self::TemplateBoard => "template-board",
133 Self::TemplateContainer => "template-container",
134 }
135 }
136}
137
138#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(rename_all = "lowercase")]
140pub enum BoardWatchLevel {
141 Watching,
142 Tracking,
143 Muted,
144}
145
146impl BoardWatchLevel {
147 pub(crate) const fn as_str(self) -> &'static str {
148 match self {
149 Self::Watching => "watching",
150 Self::Tracking => "tracking",
151 Self::Muted => "muted",
152 }
153 }
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
157#[serde(rename_all = "camelCase")]
158pub struct CreateBoardRequest {
159 #[serde(
160 skip_serializing_if = "is_true",
161 serialize_with = "serialize_owner_flag"
162 )]
163 pub is_admin: bool,
164 #[serde(
165 skip_serializing_if = "is_true",
166 serialize_with = "serialize_owner_flag"
167 )]
168 pub is_active: bool,
169 pub title: String,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub owner: Option<String>,
172 pub permission: BoardPermission,
173 pub color: BoardColor,
174 #[serde(skip_serializing_if = "is_false")]
175 pub is_no_comments: bool,
176 #[serde(skip_serializing_if = "is_false")]
177 pub is_comment_only: bool,
178 #[serde(skip_serializing_if = "is_false")]
179 pub is_worker: bool,
180}
181
182const fn is_false(value: &bool) -> bool {
183 !*value
184}
185
186const fn is_true(value: &bool) -> bool {
187 *value
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct CreateBoardResult {
192 pub board_id: String,
193 pub default_swimlane_id: String,
194}
195
196#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct RenameBoardResult {
198 pub board_id: String,
199 pub title: String,
200}
201
202#[derive(Clone, Debug, Eq, PartialEq)]
203pub struct DeleteBoardResult {
204 pub board_id: String,
205}
206
207#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
208#[serde(deny_unknown_fields)]
209pub struct BoardCounts {
210 pub private: u64,
211 pub public: u64,
212}
213
214#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
215#[serde(deny_unknown_fields, rename_all = "camelCase")]
216pub struct BoardSummary {
217 #[serde(rename = "_id")]
218 pub board_id: String,
219 pub title: String,
220}
221
222#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
223#[serde(
224 deny_unknown_fields,
225 rename_all(deserialize = "camelCase", serialize = "snake_case")
226)]
227pub struct BoardMember {
228 pub user_id: String,
229 pub is_admin: bool,
230 pub is_active: bool,
231 pub is_no_comments: Option<bool>,
232 pub is_comment_only: Option<bool>,
233 pub is_worker: Option<bool>,
234 pub is_normal_assigned_only: Option<bool>,
235 pub is_comment_assigned_only: Option<bool>,
236 pub is_read_only: Option<bool>,
237 pub is_read_assigned_only: Option<bool>,
238}
239
240#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
241#[serde(
242 deny_unknown_fields,
243 rename_all(deserialize = "camelCase", serialize = "snake_case")
244)]
245pub struct BoardWatcher {
246 pub user_id: String,
247 pub level: BoardWatchLevel,
248}
249
250#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
251#[serde(
252 deny_unknown_fields,
253 rename_all(deserialize = "camelCase", serialize = "snake_case")
254)]
255pub struct BoardLabel {
256 #[serde(rename(deserialize = "_id", serialize = "label_id"))]
257 pub label_id: String,
258 pub name: Option<String>,
259 pub color: String,
260}
261
262#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
263#[serde(
264 deny_unknown_fields,
265 rename_all(deserialize = "camelCase", serialize = "snake_case")
266)]
267pub struct BoardOrganization {
268 pub org_id: String,
269 pub org_display_name: String,
270 pub is_active: bool,
271}
272
273#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
274#[serde(
275 deny_unknown_fields,
276 rename_all(deserialize = "camelCase", serialize = "snake_case")
277)]
278pub struct BoardTeam {
279 pub team_id: String,
280 pub team_display_name: String,
281 pub is_active: bool,
282}
283
284#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
285#[serde(
286 deny_unknown_fields,
287 rename_all(deserialize = "camelCase", serialize = "snake_case")
288)]
289pub struct BoardDomain {
290 pub domain: String,
291 pub is_active: bool,
292}
293
294#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
295#[serde(
296 deny_unknown_fields,
297 rename_all(deserialize = "camelCase", serialize = "snake_case")
298)]
299pub struct BoardDocument {
300 #[serde(rename(deserialize = "_id", serialize = "board_id"))]
301 pub board_id: String,
302 pub title: String,
303 pub slug: Option<String>,
304 pub archived: Option<bool>,
305 pub archived_at: Option<String>,
306 pub created_at: Option<String>,
307 pub modified_at: Option<String>,
308 pub stars: Option<Number>,
309 #[serde(default)]
310 pub labels: Vec<BoardLabel>,
311 pub members: Vec<BoardMember>,
312 #[serde(default)]
313 pub watchers: Vec<BoardWatcher>,
314 pub permission: Option<BoardPermission>,
315 #[serde(default)]
316 pub orgs: Vec<BoardOrganization>,
317 #[serde(default)]
318 pub teams: Vec<BoardTeam>,
319 #[serde(default)]
320 pub domains: Vec<BoardDomain>,
321 #[serde(default)]
322 pub import_usernames: Vec<String>,
323 pub color: Option<BoardColor>,
324 #[serde(default)]
325 pub custom_theme_colors: Vec<String>,
326 #[serde(rename(deserialize = "backgroundImageURL", serialize = "background_image_url"))]
327 pub background_image_url: Option<String>,
328 pub background_image_id: Option<String>,
329 pub allows_card_counter_list: Option<bool>,
330 pub card_aging: Option<bool>,
331 pub show_dependencies: Option<bool>,
332 pub card_aging_days1: Option<Number>,
333 pub card_aging_days2: Option<Number>,
334 pub card_aging_days3: Option<Number>,
335 pub allows_board_member_list: Option<bool>,
336 pub description: Option<String>,
337 pub subtasks_default_board_id: Option<String>,
338 pub migration_version: Option<Number>,
339 pub subtasks_default_list_id: Option<String>,
340 pub date_settings_default_board_id: Option<String>,
341 pub date_settings_default_list_id: Option<String>,
342 pub allows_subtasks: Option<bool>,
343 pub allows_subtasks_on_minicard: Option<bool>,
344 pub allows_attachments: Option<bool>,
345 pub allows_attachments_on_minicard: Option<bool>,
346 pub allows_checklists: Option<bool>,
347 pub allows_checklists_on_minicard: Option<bool>,
348 pub allows_custom_fields: Option<bool>,
349 pub allows_custom_fields_on_minicard: Option<bool>,
350 pub allows_checklist_count_badge_on_minicard: Option<bool>,
351 pub allows_comments: Option<bool>,
352 pub allows_description_title: Option<bool>,
353 pub allows_description_title_on_minicard: Option<bool>,
354 pub allows_description_text: Option<bool>,
355 pub allows_description_text_on_minicard: Option<bool>,
356 pub allows_cover_attachment_on_minicard: Option<bool>,
357 pub allows_cover_attachment_on_card: Option<bool>,
358 pub allows_badge_attachment_on_minicard: Option<bool>,
359 pub allows_attachment_count_on_card: Option<bool>,
360 pub allows_checklist_count_badge_on_card: Option<bool>,
361 pub allows_card_sorting_by_number_on_minicard: Option<bool>,
362 pub allows_card_number: Option<bool>,
363 pub allows_card_number_on_minicard: Option<bool>,
364 pub allows_activities: Option<bool>,
365 pub allows_labels: Option<bool>,
366 pub allows_labels_on_minicard: Option<bool>,
367 pub allows_creator: Option<bool>,
368 pub allows_creator_on_minicard: Option<bool>,
369 pub allows_assignee: Option<bool>,
370 pub allows_assignee_on_minicard: Option<bool>,
371 pub allows_members: Option<bool>,
372 pub allows_members_on_minicard: Option<bool>,
373 pub allows_requested_by: Option<bool>,
374 pub allows_requested_by_on_minicard: Option<bool>,
375 pub allows_card_sorting_by_number: Option<bool>,
376 pub allows_show_lists: Option<bool>,
377 pub allows_assigned_by: Option<bool>,
378 pub allows_assigned_by_on_minicard: Option<bool>,
379 pub allows_show_lists_on_minicard: Option<bool>,
380 pub allows_checklist_at_minicard: Option<bool>,
381 pub allows_received_date: Option<bool>,
382 pub restrict_comment_editing: Option<bool>,
383 pub allows_personal_list_width: Option<bool>,
384 pub auto_width: Option<bool>,
385 pub allows_received_date_on_minicard: Option<bool>,
386 pub allows_start_date: Option<bool>,
387 pub allows_start_date_on_minicard: Option<bool>,
388 pub allows_end_date: Option<bool>,
389 pub allows_end_date_on_minicard: Option<bool>,
390 pub allows_due_date: Option<bool>,
391 pub allows_due_date_on_minicard: Option<bool>,
392 pub allows_due_complete: Option<bool>,
393 pub allows_due_complete_on_minicard: Option<bool>,
394 pub present_parent_task: Option<PresentParentTask>,
395 pub received_at: Option<String>,
396 pub start_at: Option<String>,
397 pub due_at: Option<String>,
398 pub end_at: Option<String>,
399 pub spent_time: Option<Number>,
400 pub is_overtime: Option<bool>,
401 #[serde(rename(deserialize = "type", serialize = "board_type"))]
402 pub board_type: Option<BoardType>,
403 pub sort: Option<Number>,
404 pub show_activities: Option<bool>,
405}
406
407#[derive(Deserialize)]
408#[serde(deny_unknown_fields, rename_all = "camelCase")]
409struct CreateBoardResponse {
410 #[serde(rename = "_id")]
411 board_id: String,
412 default_swimlane_id: String,
413}
414
415#[derive(Serialize)]
416struct RenameBoardRequest<'a> {
417 title: &'a str,
418}
419
420#[derive(Deserialize)]
421#[serde(deny_unknown_fields)]
422struct RenameBoardResponse {
423 #[serde(rename = "_id")]
424 board_id: String,
425 title: String,
426}
427
428#[derive(Deserialize)]
429#[serde(deny_unknown_fields)]
430struct IdResponse {
431 #[serde(rename = "_id")]
432 id: String,
433}
434
435impl WekanClient {
436 pub async fn public_boards(
437 &self,
438 token: &SecretString,
439 ) -> Result<Vec<BoardSummary>, ClientError> {
440 let body = self
441 .execute_success_request(
442 self.http
443 .get(self.boards_api_endpoint("api/boards", "public boards")?)
444 .header(ACCEPT, "application/json")
445 .bearer_auth(token.expose_secret()),
446 )
447 .await?;
448 let boards = decode_success(&body, "public boards", true)?;
449 validate_board_summaries(boards, "public boards")
450 }
451
452 pub async fn board_counts(&self, token: &SecretString) -> Result<BoardCounts, ClientError> {
453 let body = self
454 .execute_success_request(
455 self.http
456 .get(self.boards_api_endpoint("api/boards_count", "board counts")?)
457 .header(ACCEPT, "application/json")
458 .bearer_auth(token.expose_secret()),
459 )
460 .await?;
461 decode_success(&body, "board counts", true)
462 }
463
464 pub async fn board(
465 &self,
466 board_id: &str,
467 token: &SecretString,
468 ) -> Result<BoardDocument, ClientError> {
469 let body = self
470 .execute_success_request(
471 self.http
472 .get(self.board_endpoint(board_id, None)?)
473 .header(ACCEPT, "application/json")
474 .bearer_auth(token.expose_secret()),
475 )
476 .await?;
477 let board = decode_success(&body, "board", true)?;
478 validate_board_document(board)
479 }
480
481 pub async fn create_board(
482 &self,
483 request: &CreateBoardRequest,
484 token: &SecretString,
485 ) -> Result<CreateBoardResult, ClientError> {
486 let body = self
487 .execute_success_request(
488 self.http
489 .post(self.boards_api_endpoint("api/boards", "board creation")?)
490 .header(ACCEPT, "application/json")
491 .bearer_auth(token.expose_secret())
492 .json(request),
493 )
494 .await?;
495 let response: CreateBoardResponse = decode_success(&body, "board creation", true)?;
496 require_non_empty(&response.board_id, "board creation", "board id")?;
497 require_non_empty(
498 &response.default_swimlane_id,
499 "board creation",
500 "default swimlane id",
501 )?;
502 Ok(CreateBoardResult {
503 board_id: response.board_id,
504 default_swimlane_id: response.default_swimlane_id,
505 })
506 }
507
508 pub async fn rename_board(
509 &self,
510 board_id: &str,
511 title: &str,
512 token: &SecretString,
513 ) -> Result<RenameBoardResult, ClientError> {
514 let body = self
515 .execute_success_request(
516 self.http
517 .put(self.board_endpoint(board_id, Some("title"))?)
518 .header(ACCEPT, "application/json")
519 .bearer_auth(token.expose_secret())
520 .json(&RenameBoardRequest { title }),
521 )
522 .await?;
523 let response: RenameBoardResponse = decode_success(&body, "board rename", true)?;
524 require_non_empty(&response.board_id, "board rename", "board id")?;
525 require_non_empty(&response.title, "board rename", "title")?;
526 Ok(RenameBoardResult {
527 board_id: response.board_id,
528 title: response.title,
529 })
530 }
531
532 pub async fn delete_board(
533 &self,
534 board_id: &str,
535 token: &SecretString,
536 ) -> Result<DeleteBoardResult, ClientError> {
537 let body = self
538 .execute_success_request(
539 self.http
540 .delete(self.board_endpoint(board_id, None)?)
541 .header(ACCEPT, "application/json")
542 .bearer_auth(token.expose_secret()),
543 )
544 .await?;
545 let response: IdResponse = decode_success(&body, "board deletion", true)?;
546 require_non_empty(&response.id, "board deletion", "board id")?;
547 Ok(DeleteBoardResult {
548 board_id: response.id,
549 })
550 }
551
552 fn boards_api_endpoint(
553 &self,
554 path: &str,
555 operation: &str,
556 ) -> Result<reqwest::Url, ClientError> {
557 self.server()
558 .join(path)
559 .map_err(|error| ClientError::Protocol {
560 diagnostic: None,
561 message: format!("could not build the {operation} endpoint: {error}"),
562 success_status_received: false,
563 })
564 }
565
566 pub(super) fn board_endpoint(
567 &self,
568 board_id: &str,
569 suffix: Option<&str>,
570 ) -> Result<reqwest::Url, ClientError> {
571 let mut endpoint = self.boards_api_endpoint("api/boards", "board")?;
572 let mut segments = endpoint
573 .path_segments_mut()
574 .map_err(|()| ClientError::Protocol {
575 diagnostic: None,
576 message: "could not add the board id to the endpoint".to_owned(),
577 success_status_received: false,
578 })?;
579 segments.push(board_id);
580 if let Some(suffix) = suffix {
581 segments.push(suffix);
582 }
583 drop(segments);
584 Ok(endpoint)
585 }
586}
587
588fn validate_board_summaries(
589 boards: Vec<BoardSummary>,
590 operation: &str,
591) -> Result<Vec<BoardSummary>, ClientError> {
592 for board in &boards {
593 require_non_empty(&board.board_id, operation, "board id")?;
594 require_non_empty(&board.title, operation, "title")?;
595 }
596 Ok(boards)
597}
598
599fn validate_board_document(board: BoardDocument) -> Result<BoardDocument, ClientError> {
600 require_non_empty(&board.board_id, "board", "board id")?;
601 require_non_empty(&board.title, "board", "title")?;
602 for (field, value) in [
603 ("archivedAt", board.archived_at.as_deref()),
604 ("createdAt", board.created_at.as_deref()),
605 ("modifiedAt", board.modified_at.as_deref()),
606 ("receivedAt", board.received_at.as_deref()),
607 ("startAt", board.start_at.as_deref()),
608 ("dueAt", board.due_at.as_deref()),
609 ("endAt", board.end_at.as_deref()),
610 ] {
611 if let Some(value) = value {
612 validate_date_time(value, field)?;
613 }
614 }
615 Ok(board)
616}
617
618fn validate_date_time(value: &str, field: &str) -> Result<(), ClientError> {
619 OffsetDateTime::parse(value, &Rfc3339)
620 .map(|_| ())
621 .map_err(|_| ClientError::Protocol {
622 diagnostic: None,
623 message: format!("the board response contained an invalid {field} date-time"),
624 success_status_received: true,
625 })
626}
627
628fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
629 if value.is_empty() {
630 Err(ClientError::Protocol {
631 diagnostic: None,
632 message: format!("the {operation} response contained an empty {field}"),
633 success_status_received: true,
634 })
635 } else {
636 Ok(())
637 }
638}
639
640fn serialize_owner_flag<S: serde::Serializer>(
641 value: &bool,
642 serializer: S,
643) -> Result<S::Ok, S::Error> {
644 serializer.serialize_str(if *value { "true" } else { "false" })
645}