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