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