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