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