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