Skip to main content

wekan_cli/client/
boards.rs

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