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