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