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