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