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