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    pub title: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub owner: Option<String>,
153    pub permission: BoardPermission,
154    pub color: BoardColor,
155    #[serde(skip_serializing_if = "is_false")]
156    pub is_no_comments: bool,
157    #[serde(skip_serializing_if = "is_false")]
158    pub is_comment_only: bool,
159    #[serde(skip_serializing_if = "is_false")]
160    pub is_worker: bool,
161}
162
163const fn is_false(value: &bool) -> bool {
164    !*value
165}
166
167#[derive(Clone, Debug, Eq, PartialEq)]
168pub struct CreateBoardResult {
169    pub board_id: String,
170    pub default_swimlane_id: String,
171}
172
173#[derive(Clone, Debug, Eq, PartialEq)]
174pub struct RenameBoardResult {
175    pub board_id: String,
176    pub title: String,
177}
178
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct DeleteBoardResult {
181    pub board_id: String,
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
185#[serde(deny_unknown_fields)]
186pub struct BoardCounts {
187    pub private: u64,
188    pub public: u64,
189}
190
191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192#[serde(deny_unknown_fields, rename_all = "camelCase")]
193pub struct BoardSummary {
194    #[serde(rename = "_id")]
195    pub board_id: String,
196    pub title: String,
197}
198
199#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
200#[serde(
201    deny_unknown_fields,
202    rename_all(deserialize = "camelCase", serialize = "snake_case")
203)]
204pub struct BoardMember {
205    pub user_id: String,
206    pub is_admin: bool,
207    pub is_active: bool,
208    pub is_no_comments: Option<bool>,
209    pub is_comment_only: Option<bool>,
210    pub is_worker: Option<bool>,
211    pub is_normal_assigned_only: Option<bool>,
212    pub is_comment_assigned_only: Option<bool>,
213    pub is_read_only: Option<bool>,
214    pub is_read_assigned_only: Option<bool>,
215}
216
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218#[serde(
219    deny_unknown_fields,
220    rename_all(deserialize = "camelCase", serialize = "snake_case")
221)]
222pub struct BoardWatcher {
223    pub user_id: String,
224    pub level: BoardWatchLevel,
225}
226
227#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
228#[serde(
229    deny_unknown_fields,
230    rename_all(deserialize = "camelCase", serialize = "snake_case")
231)]
232pub struct BoardLabel {
233    #[serde(rename(deserialize = "_id", serialize = "label_id"))]
234    pub label_id: String,
235    pub name: Option<String>,
236    pub color: String,
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 BoardOrganization {
245    pub org_id: String,
246    pub org_display_name: String,
247    pub is_active: bool,
248}
249
250#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
251#[serde(
252    deny_unknown_fields,
253    rename_all(deserialize = "camelCase", serialize = "snake_case")
254)]
255pub struct BoardTeam {
256    pub team_id: String,
257    pub team_display_name: String,
258    pub is_active: bool,
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 BoardDomain {
267    pub domain: 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 BoardDocument {
277    #[serde(rename(deserialize = "_id", serialize = "board_id"))]
278    pub board_id: String,
279    pub title: String,
280    pub slug: Option<String>,
281    pub archived: Option<bool>,
282    pub archived_at: Option<String>,
283    pub created_at: Option<String>,
284    pub modified_at: Option<String>,
285    pub stars: Option<Number>,
286    #[serde(default)]
287    pub labels: Vec<BoardLabel>,
288    pub members: Vec<BoardMember>,
289    #[serde(default)]
290    pub watchers: Vec<BoardWatcher>,
291    pub permission: Option<BoardPermission>,
292    #[serde(default)]
293    pub orgs: Vec<BoardOrganization>,
294    #[serde(default)]
295    pub teams: Vec<BoardTeam>,
296    #[serde(default)]
297    pub domains: Vec<BoardDomain>,
298    #[serde(default)]
299    pub import_usernames: Vec<String>,
300    pub color: Option<BoardColor>,
301    #[serde(default)]
302    pub custom_theme_colors: Vec<String>,
303    #[serde(rename(deserialize = "backgroundImageURL", serialize = "background_image_url"))]
304    pub background_image_url: Option<String>,
305    pub background_image_id: Option<String>,
306    pub allows_card_counter_list: Option<bool>,
307    pub card_aging: Option<bool>,
308    pub show_dependencies: Option<bool>,
309    pub card_aging_days1: Option<Number>,
310    pub card_aging_days2: Option<Number>,
311    pub card_aging_days3: Option<Number>,
312    pub allows_board_member_list: Option<bool>,
313    pub description: Option<String>,
314    pub subtasks_default_board_id: Option<String>,
315    pub migration_version: Option<Number>,
316    pub subtasks_default_list_id: Option<String>,
317    pub date_settings_default_board_id: Option<String>,
318    pub date_settings_default_list_id: Option<String>,
319    pub allows_subtasks: Option<bool>,
320    pub allows_subtasks_on_minicard: Option<bool>,
321    pub allows_attachments: Option<bool>,
322    pub allows_attachments_on_minicard: Option<bool>,
323    pub allows_checklists: Option<bool>,
324    pub allows_checklists_on_minicard: Option<bool>,
325    pub allows_custom_fields: Option<bool>,
326    pub allows_custom_fields_on_minicard: Option<bool>,
327    pub allows_checklist_count_badge_on_minicard: Option<bool>,
328    pub allows_comments: Option<bool>,
329    pub allows_description_title: Option<bool>,
330    pub allows_description_title_on_minicard: Option<bool>,
331    pub allows_description_text: Option<bool>,
332    pub allows_description_text_on_minicard: Option<bool>,
333    pub allows_cover_attachment_on_minicard: Option<bool>,
334    pub allows_cover_attachment_on_card: Option<bool>,
335    pub allows_badge_attachment_on_minicard: Option<bool>,
336    pub allows_attachment_count_on_card: Option<bool>,
337    pub allows_checklist_count_badge_on_card: Option<bool>,
338    pub allows_card_sorting_by_number_on_minicard: Option<bool>,
339    pub allows_card_number: Option<bool>,
340    pub allows_card_number_on_minicard: Option<bool>,
341    pub allows_activities: Option<bool>,
342    pub allows_labels: Option<bool>,
343    pub allows_labels_on_minicard: Option<bool>,
344    pub allows_creator: Option<bool>,
345    pub allows_creator_on_minicard: Option<bool>,
346    pub allows_assignee: Option<bool>,
347    pub allows_assignee_on_minicard: Option<bool>,
348    pub allows_members: Option<bool>,
349    pub allows_members_on_minicard: Option<bool>,
350    pub allows_requested_by: Option<bool>,
351    pub allows_requested_by_on_minicard: Option<bool>,
352    pub allows_card_sorting_by_number: Option<bool>,
353    pub allows_show_lists: Option<bool>,
354    pub allows_assigned_by: Option<bool>,
355    pub allows_assigned_by_on_minicard: Option<bool>,
356    pub allows_show_lists_on_minicard: Option<bool>,
357    pub allows_checklist_at_minicard: Option<bool>,
358    pub allows_received_date: Option<bool>,
359    pub restrict_comment_editing: Option<bool>,
360    pub allows_personal_list_width: Option<bool>,
361    pub auto_width: Option<bool>,
362    pub allows_received_date_on_minicard: Option<bool>,
363    pub allows_start_date: Option<bool>,
364    pub allows_start_date_on_minicard: Option<bool>,
365    pub allows_end_date: Option<bool>,
366    pub allows_end_date_on_minicard: Option<bool>,
367    pub allows_due_date: Option<bool>,
368    pub allows_due_date_on_minicard: Option<bool>,
369    pub allows_due_complete: Option<bool>,
370    pub allows_due_complete_on_minicard: Option<bool>,
371    pub present_parent_task: Option<PresentParentTask>,
372    pub received_at: Option<String>,
373    pub start_at: Option<String>,
374    pub due_at: Option<String>,
375    pub end_at: Option<String>,
376    pub spent_time: Option<Number>,
377    pub is_overtime: Option<bool>,
378    #[serde(rename(deserialize = "type", serialize = "board_type"))]
379    pub board_type: Option<BoardType>,
380    pub sort: Option<Number>,
381    pub show_activities: Option<bool>,
382}
383
384#[derive(Deserialize)]
385#[serde(deny_unknown_fields, rename_all = "camelCase")]
386struct CreateBoardResponse {
387    #[serde(rename = "_id")]
388    board_id: String,
389    default_swimlane_id: String,
390}
391
392#[derive(Serialize)]
393struct RenameBoardRequest<'a> {
394    title: &'a str,
395}
396
397#[derive(Deserialize)]
398#[serde(deny_unknown_fields)]
399struct RenameBoardResponse {
400    #[serde(rename = "_id")]
401    board_id: String,
402    title: String,
403}
404
405#[derive(Deserialize)]
406#[serde(deny_unknown_fields)]
407struct IdResponse {
408    #[serde(rename = "_id")]
409    id: String,
410}
411
412impl WekanClient {
413    pub async fn public_boards(
414        &self,
415        token: &SecretString,
416    ) -> Result<Vec<BoardSummary>, ClientError> {
417        let body = self
418            .execute_success_request(
419                self.http
420                    .get(self.boards_api_endpoint("api/boards", "public boards")?)
421                    .header(ACCEPT, "application/json")
422                    .bearer_auth(token.expose_secret()),
423            )
424            .await?;
425        let boards = decode_success(&body, "public boards", true)?;
426        validate_board_summaries(boards, "public boards")
427    }
428
429    pub async fn board_counts(&self, token: &SecretString) -> Result<BoardCounts, ClientError> {
430        let body = self
431            .execute_success_request(
432                self.http
433                    .get(self.boards_api_endpoint("api/boards_count", "board counts")?)
434                    .header(ACCEPT, "application/json")
435                    .bearer_auth(token.expose_secret()),
436            )
437            .await?;
438        decode_success(&body, "board counts", true)
439    }
440
441    pub async fn board(
442        &self,
443        board_id: &str,
444        token: &SecretString,
445    ) -> Result<BoardDocument, ClientError> {
446        let body = self
447            .execute_success_request(
448                self.http
449                    .get(self.board_endpoint(board_id, None)?)
450                    .header(ACCEPT, "application/json")
451                    .bearer_auth(token.expose_secret()),
452            )
453            .await?;
454        let board = decode_success(&body, "board", true)?;
455        validate_board_document(board)
456    }
457
458    pub async fn create_board(
459        &self,
460        request: &CreateBoardRequest,
461        token: &SecretString,
462    ) -> Result<CreateBoardResult, ClientError> {
463        let body = self
464            .execute_success_request(
465                self.http
466                    .post(self.boards_api_endpoint("api/boards", "board creation")?)
467                    .header(ACCEPT, "application/json")
468                    .bearer_auth(token.expose_secret())
469                    .json(request),
470            )
471            .await?;
472        let response: CreateBoardResponse = decode_success(&body, "board creation", true)?;
473        require_non_empty(&response.board_id, "board creation", "board id")?;
474        require_non_empty(
475            &response.default_swimlane_id,
476            "board creation",
477            "default swimlane id",
478        )?;
479        Ok(CreateBoardResult {
480            board_id: response.board_id,
481            default_swimlane_id: response.default_swimlane_id,
482        })
483    }
484
485    pub async fn rename_board(
486        &self,
487        board_id: &str,
488        title: &str,
489        token: &SecretString,
490    ) -> Result<RenameBoardResult, ClientError> {
491        let body = self
492            .execute_success_request(
493                self.http
494                    .put(self.board_endpoint(board_id, Some("title"))?)
495                    .header(ACCEPT, "application/json")
496                    .bearer_auth(token.expose_secret())
497                    .json(&RenameBoardRequest { title }),
498            )
499            .await?;
500        let response: RenameBoardResponse = decode_success(&body, "board rename", true)?;
501        require_non_empty(&response.board_id, "board rename", "board id")?;
502        require_non_empty(&response.title, "board rename", "title")?;
503        Ok(RenameBoardResult {
504            board_id: response.board_id,
505            title: response.title,
506        })
507    }
508
509    pub async fn delete_board(
510        &self,
511        board_id: &str,
512        token: &SecretString,
513    ) -> Result<DeleteBoardResult, ClientError> {
514        let body = self
515            .execute_success_request(
516                self.http
517                    .delete(self.board_endpoint(board_id, None)?)
518                    .header(ACCEPT, "application/json")
519                    .bearer_auth(token.expose_secret()),
520            )
521            .await?;
522        let response: IdResponse = decode_success(&body, "board deletion", true)?;
523        require_non_empty(&response.id, "board deletion", "board id")?;
524        Ok(DeleteBoardResult {
525            board_id: response.id,
526        })
527    }
528
529    fn boards_api_endpoint(
530        &self,
531        path: &str,
532        operation: &str,
533    ) -> Result<reqwest::Url, ClientError> {
534        self.server()
535            .join(path)
536            .map_err(|error| ClientError::Protocol {
537                diagnostic: None,
538                message: format!("could not build the {operation} endpoint: {error}"),
539                success_status_received: false,
540            })
541    }
542
543    fn board_endpoint(
544        &self,
545        board_id: &str,
546        suffix: Option<&str>,
547    ) -> Result<reqwest::Url, ClientError> {
548        let mut endpoint = self.boards_api_endpoint("api/boards", "board")?;
549        let mut segments = endpoint
550            .path_segments_mut()
551            .map_err(|()| ClientError::Protocol {
552                diagnostic: None,
553                message: "could not add the board id to the endpoint".to_owned(),
554                success_status_received: false,
555            })?;
556        segments.push(board_id);
557        if let Some(suffix) = suffix {
558            segments.push(suffix);
559        }
560        drop(segments);
561        Ok(endpoint)
562    }
563}
564
565fn validate_board_summaries(
566    boards: Vec<BoardSummary>,
567    operation: &str,
568) -> Result<Vec<BoardSummary>, ClientError> {
569    for board in &boards {
570        require_non_empty(&board.board_id, operation, "board id")?;
571        require_non_empty(&board.title, operation, "title")?;
572    }
573    Ok(boards)
574}
575
576fn validate_board_document(board: BoardDocument) -> Result<BoardDocument, ClientError> {
577    require_non_empty(&board.board_id, "board", "board id")?;
578    require_non_empty(&board.title, "board", "title")?;
579    for (field, value) in [
580        ("archivedAt", board.archived_at.as_deref()),
581        ("createdAt", board.created_at.as_deref()),
582        ("modifiedAt", board.modified_at.as_deref()),
583        ("receivedAt", board.received_at.as_deref()),
584        ("startAt", board.start_at.as_deref()),
585        ("dueAt", board.due_at.as_deref()),
586        ("endAt", board.end_at.as_deref()),
587    ] {
588        if let Some(value) = value {
589            validate_date_time(value, field)?;
590        }
591    }
592    Ok(board)
593}
594
595fn validate_date_time(value: &str, field: &str) -> Result<(), ClientError> {
596    OffsetDateTime::parse(value, &Rfc3339)
597        .map(|_| ())
598        .map_err(|_| ClientError::Protocol {
599            diagnostic: None,
600            message: format!("the board response contained an invalid {field} date-time"),
601            success_status_received: true,
602        })
603}
604
605fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
606    if value.is_empty() {
607        Err(ClientError::Protocol {
608            diagnostic: None,
609            message: format!("the {operation} response contained an empty {field}"),
610            success_status_received: true,
611        })
612    } else {
613        Ok(())
614    }
615}