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