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