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