From 6d234609e79f2a53fd02d2f1bbba65101d821f15 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Mon, 16 Dec 2024 08:03:31 +0000 Subject: [PATCH] feat: add command 'nextcloud card add-attachment' --- Cargo.toml | 1 + README.md | 2 +- src/api_result.rs | 19 ++- src/nextcloud/card.rs | 43 ++++- src/nextcloud/client.rs | 96 ++++++++--- src/nextcloud/model.rs | 55 +++++++ src/nextcloud/tests/board/get.rs | 2 +- src/nextcloud/tests/card/add_attachment.rs | 153 ++++++++++++++++++ src/nextcloud/tests/card/add_label.rs | 2 +- src/nextcloud/tests/card/create.rs | 2 +- src/nextcloud/tests/card/get.rs | 2 +- src/nextcloud/tests/card/mod.rs | 1 + src/nextcloud/tests/deck/get.rs | 2 +- src/nextcloud/tests/stack/get.rs | 2 +- src/tests/given.rs | 2 +- .../responses/nextcloud-attachment-add.json | 1 + trello-to-deck.toml | 3 +- 17 files changed, 354 insertions(+), 34 deletions(-) create mode 100644 src/nextcloud/tests/card/add_attachment.rs create mode 100644 src/tests/responses/nextcloud-attachment-add.json diff --git a/Cargo.toml b/Cargo.toml index 2b9bd1a..f2a4523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ derive_more = { version = "1.0", features = [ ] } # kxio = {path = "../kxio/"} kxio = "4.0" +reqwest = { version = "0.12" , features = ["multipart", "stream"]} serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.41", features = ["full"] } diff --git a/README.md b/README.md index da10c79..fb2eac0 100644 --- a/README.md +++ b/README.md @@ -80,4 +80,4 @@ As part of building the import server, the following commands exercise each oper - [x] nextcloud card get - shows card title - [x] nextcloud card create - [x] nextcloud card add-label -- [ ] nextcloud card add-attachment \ No newline at end of file +- [x] nextcloud card add-attachment \ No newline at end of file diff --git a/src/api_result.rs b/src/api_result.rs index dcedca1..79713b5 100644 --- a/src/api_result.rs +++ b/src/api_result.rs @@ -9,7 +9,7 @@ where T: for<'a> serde::Deserialize<'a>, { pub(crate) text: String, - pub(crate) result: Result, + pub(crate) result: Result, } impl APIResult @@ -27,13 +27,20 @@ where e!(prt, "{e}: {text}"); e }) - .map_err(kxio::net::Error::from); + .map_err(kxio::net::Error::from) + .map_err(kxio::Error::from); Self { text, result } } - Err(e) => Self { - text: s!(""), - result: Err(e), - }, + Err(e) => { + e!(prt, "err: {e:#?}"); + Self::error(e.into()) + } + } + } + pub fn error(err: kxio::Error) -> Self { + Self { + text: s!(""), + result: Err(err), } } } diff --git a/src/nextcloud/card.rs b/src/nextcloud/card.rs index 4ae1d8c..a4e4612 100644 --- a/src/nextcloud/card.rs +++ b/src/nextcloud/card.rs @@ -1,4 +1,6 @@ // +use std::path::PathBuf; + use clap::Parser; use crate::{execute::Execute, p, FullCtx}; @@ -22,7 +24,6 @@ pub enum NextcloudCardCommand { #[clap(long)] description: Option, }, - AddLabel { #[clap(long, action = clap::ArgAction::SetTrue)] dump: bool, @@ -31,6 +32,14 @@ pub enum NextcloudCardCommand { card_id: i64, label_id: i64, }, + AddAttachment { + #[clap(long, action = clap::ArgAction::SetTrue)] + dump: bool, + board_id: i64, + stack_id: i64, + card_id: i64, + file: PathBuf, + }, } impl Execute for NextcloudCardCommand { @@ -113,6 +122,38 @@ impl Execute for NextcloudCardCommand { } Ok(()) } + Self::AddAttachment { + dump, + board_id, + stack_id, + card_id, + file, + } => { + let api_result = ctx + .deck_client() + .add_attachment_to_card( + (*board_id).into(), + (*stack_id).into(), + (*card_id).into(), + ctx.fs.file(file), + ) + .await; + if *dump { + p!(ctx.prt, "{}", api_result.text); + } else { + let attachment = api_result.result?; + p!( + ctx.prt, + "{}:{}:{}:{}:{}", + board_id, + stack_id, + card_id, + attachment.id, + attachment.extended_data.path + ); + } + Ok(()) + } } } } diff --git a/src/nextcloud/client.rs b/src/nextcloud/client.rs index e9b857a..dd7a602 100644 --- a/src/nextcloud/client.rs +++ b/src/nextcloud/client.rs @@ -1,15 +1,19 @@ // use bytes::Bytes; -use kxio::net::{Net, ReqBuilder}; +use kxio::{ + fs::FileHandle, + net::{Net, ReqBuilder}, +}; +use reqwest::multipart; +use serde::de::DeserializeOwned; use serde_json::json; -use crate::nextcloud::model::NextcloudLabelId; use crate::{ api_result::APIResult, f, nextcloud::model::{ - Board, Card, NextcloudBoardId, NextcloudCardId, NextcloudHostname, NextcloudPassword, - NextcloudStackId, NextcloudUsername, Stack, + Attachment, Board, Card, NextcloudBoardId, NextcloudCardId, NextcloudHostname, + NextcloudLabelId, NextcloudPassword, NextcloudStackId, NextcloudUsername, Stack, }, FullCtx, }; @@ -34,7 +38,7 @@ impl<'ctx> DeckClient<'ctx> { fn url(&self, path: impl Into) -> String { f!( - "https://{}/index.php/apps/deck/api/v1.0/{}", + "{}/index.php/apps/deck/api/v1.0/{}", self.hostname, path.into() ) @@ -76,20 +80,37 @@ impl<'ctx> DeckClient<'ctx> { .await } - pub(crate) async fn add_label_to_card( + async fn request_with_form( &self, - board_id: NextcloudBoardId, - stack_id: NextcloudStackId, - card_id: NextcloudCardId, - label_id: NextcloudLabelId, - ) -> APIResult<()> { - self.request_with_body( - f!("boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignLabel"), - json!({ - "labelId": label_id - }) - .to_string(), - |net, url| net.put(url), + path: String, + // form_data: HashMap, + form: multipart::Form, + ) -> APIResult + where + T: DeserializeOwned, + { + // let form: multipart::Form = multipart::Form::new(); + // let full_path = self.ctx.fs.base().join(form_data.get("file").expect("file")); + // e!(self.ctx.prt, "Uploading file: {}", full_path.display()); + // let form = form.file("file", Path::new(&full_path)) + // .await + // .expect("read file"); + let request_builder = self + .ctx + .net + .client() + .post(self.url(&path)) + .basic_auth(self.username.as_str(), Some(self.password.as_str())) + .header("accept", "application/json") + // .form(&form_data); + .multipart(form); + // let data = request_builder.multipart(); + APIResult::new( + match self.ctx.net.send(request_builder).await { + Ok(response) => Ok(response), + Err(err) => Err(err), + }, + &self.ctx.prt, ) .await } @@ -149,4 +170,43 @@ impl<'ctx> DeckClient<'ctx> { ) .await } + + pub(crate) async fn add_label_to_card( + &self, + board_id: NextcloudBoardId, + stack_id: NextcloudStackId, + card_id: NextcloudCardId, + label_id: NextcloudLabelId, + ) -> APIResult<()> { + self.request_with_body( + f!("boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignLabel"), + json!({ + "labelId": label_id + }) + .to_string(), + |net, url| net.put(url), + ) + .await + } + + pub(crate) async fn add_attachment_to_card( + &self, + board_id: NextcloudBoardId, + stack_id: NextcloudStackId, + card_id: NextcloudCardId, + file: FileHandle, + ) -> APIResult { + let form: multipart::Form = multipart::Form::new(); + let form = form.text("type", "file"); + let form = form + .file("file", file.as_pathbuf()) + .await + .expect("read file"); + self.request_with_form( + // f!("apps/deck/cards/{card_id}/attachment"), + f!("boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments"), + form, + ) + .await + } } diff --git a/src/nextcloud/model.rs b/src/nextcloud/model.rs index 0d2a57f..05aab2e 100644 --- a/src/nextcloud/model.rs +++ b/src/nextcloud/model.rs @@ -182,3 +182,58 @@ pub(crate) struct Acl { pub(crate) permission_share: bool, pub(crate) permission_manage: bool, } + +// {"id":92,"cardId":332,"type":"file","data":"async-rust-book (2).webp","lastModified":1734506800,"createdAt":1734506800,"createdBy":"pcampbell"," +// deletedAt":0,"extendedData":{"path":"\/Deck\/async-rust-book (2).webp","fileid":746424,"data":"async-rust-book (2).webp","filesize":137128,"mimetype":"image\/webp","info":{"dirname":".","basename": +// "async-rust-book (2).webp","extension":"webp","filename":"async-rust-book (2)"},"hasPreview":true,"permissions":1,"attachmentCreator":{"displayName":"Paul Campbell","id":"pcampbell","email":"pcampb +// ell@kemitix.net"}}} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Attachment { + pub(crate) id: i64, + #[serde(rename = "cardId")] + pub(crate) card_id: i64, + #[serde(rename = "type")] + pub(crate) attachment_type: String, + pub(crate) data: String, + #[serde(rename = "lastModified")] + pub(crate) last_modified: i64, + #[serde(rename = "createdAt")] + pub(crate) created_at: i64, + #[serde(rename = "createdBy")] + pub(crate) created_by: String, + #[serde(rename = "deletedAt")] + pub(crate) deleted_at: i64, + #[serde(rename = "extendedData")] + pub(crate) extended_data: AttachmentExtendedData, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct AttachmentExtendedData { + pub(crate) path: String, + pub(crate) fileid: i64, + pub(crate) data: String, + pub(crate) filesize: i64, + pub(crate) mimetype: String, + pub(crate) info: AttachmentInfo, + #[serde(rename = "hasPreview")] + pub(crate) has_preview: bool, + pub(crate) permissions: i64, + #[serde(rename = "attachmentCreator")] + pub(crate) attachment_creator: AttachmentCreator, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct AttachmentInfo { + pub(crate) dirname: String, + pub(crate) basename: String, + pub(crate) extension: String, + pub(crate) filename: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct AttachmentCreator { + #[serde(rename = "displayName")] + pub(crate) display_name: String, + pub(crate) id: String, + pub(crate) email: String, +} diff --git a/src/nextcloud/tests/board/get.rs b/src/nextcloud/tests/board/get.rs index da2008c..8760c69 100644 --- a/src/nextcloud/tests/board/get.rs +++ b/src/nextcloud/tests/board/get.rs @@ -20,7 +20,7 @@ fn ctx() -> FullCtx { mock_net .on() .get(crate::f!( - "https://{hostname}/index.php/apps/deck/api/v1.0/boards/{board_id}/stacks", + "{hostname}/index.php/apps/deck/api/v1.0/boards/{board_id}/stacks", )) .respond(StatusCode::OK) .body(include_str!( diff --git a/src/nextcloud/tests/card/add_attachment.rs b/src/nextcloud/tests/card/add_attachment.rs new file mode 100644 index 0000000..206394a --- /dev/null +++ b/src/nextcloud/tests/card/add_attachment.rs @@ -0,0 +1,153 @@ +use crate::f; +use crate::nextcloud::model::{ + Attachment, AttachmentCreator, AttachmentExtendedData, AttachmentInfo, +}; +use kxio::fs::TempFileSystem; +use serde::Serialize; +use std::path::PathBuf; +// +use super::*; + +#[rstest::fixture] +fn stack_id() -> NextcloudStackId { + NextcloudStackId::new(1) +} + +#[rstest::fixture] +fn card_id() -> NextcloudCardId { + NextcloudCardId::new(1) +} + +#[rstest::fixture] +fn ctx_path() -> (FullCtx, PathBuf, TempFileSystem) { + let fs = given::a_filesystem(); + let path = fs.base().join("attachment.png"); + let file = fs.file(&path); + file.write(include_bytes!("../../../tests/responses/trello-attachment-save.png").as_slice()) + .expect("write attachment"); + let temp_fs = fs.clone(); + + let nextcloud_config = given::a_nextcloud_config(); + let hostname = &nextcloud_config.hostname; + let board_id = &nextcloud_config.board_id; + let stack_id = stack_id(); + let card_id = card_id(); + + let mock_net = given::a_network(); + mock_net. + on() + .post(f!( + "{hostname}/index.php/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments", + )) + .respond(StatusCode::OK) + .body(serde_json::to_string(&Attachment { + id: 102, + card_id: 332, + attachment_type: "file".to_string(), + data: "async-rust-book.webp".to_string(), + last_modified: 1734511846, + created_at: 1734511846, + created_by: "pcampbell".to_string(), + deleted_at: 0, + extended_data: AttachmentExtendedData { + path: "/Deck/async-rust-book.webp".to_string(), + fileid: 746505, + data: "async-rust-book.webp".to_string(), + filesize: 137128, + mimetype: "image/webp".to_string(), + info: AttachmentInfo { + dirname: ".".to_string(), + basename: "async-rust-book.webp".to_string(), + extension: "webp".to_string(), + filename: "async-rust-book".to_string(), + }, + has_preview: true, + permissions: 1, + attachment_creator: AttachmentCreator { + display_name: "Paul Campbell".to_string(), + id: "pcampbell".to_string(), + email: "pcampbell@kemitix.net".to_string(), + }, + }, + }).expect("json attachment")) + .expect("mock request"); + + ( + FullCtx { + fs: fs.as_real(), + net: mock_net.into(), + prt: given::a_printer(), + cfg: AppConfig { + trello: given::a_trello_config(), + nextcloud: nextcloud_config, + }, + }, + path, + temp_fs, + ) +} + +#[rstest::rstest] +#[test_log::test(tokio::test)] +async fn dump( + ctx_path: (FullCtx, PathBuf, TempFileSystem), + stack_id: NextcloudStackId, + card_id: NextcloudCardId, +) { + //given + let (ctx, path, _temp_fs) = ctx_path; + let prt = ctx.prt.clone(); + let prt = prt.as_test().unwrap(); + + //when + Command::Nextcloud(NextcloudCommand::Card( + NextcloudCardCommand::AddAttachment { + dump: true, + board_id: ctx.cfg.nextcloud.board_id.into(), + stack_id: stack_id.into(), + card_id: card_id.into(), + file: path, + }, + )) + .execute(&ctx) + .await + .expect("execute"); + + //then + let output = prt.output(); + assert_eq!( + output.trim(), + include_str!("../../../tests/responses/nextcloud-attachment-add.json") + ); +} + +#[rstest::rstest] +#[test_log::test(tokio::test)] +async fn no_dump( + ctx_path: (FullCtx, PathBuf, TempFileSystem), + stack_id: NextcloudStackId, + card_id: NextcloudCardId, +) { + //given + let (ctx, path, _temp_fs) = ctx_path; + let prt = ctx.prt.clone(); + let prt = prt.as_test().unwrap(); + + //when + Command::Nextcloud(NextcloudCommand::Card( + NextcloudCardCommand::AddAttachment { + dump: false, + board_id: ctx.cfg.nextcloud.board_id.into(), + stack_id: stack_id.into(), + card_id: card_id.into(), + file: path, + }, + )) + .execute(&ctx) + .await + .expect("execute"); + + //then + let output = prt.output(); + assert_eq!(output.trim(), "2:1:1:102:/Deck/async-rust-book.webp"); +} diff --git a/src/nextcloud/tests/card/add_label.rs b/src/nextcloud/tests/card/add_label.rs index 5ebdd98..ada6c30 100644 --- a/src/nextcloud/tests/card/add_label.rs +++ b/src/nextcloud/tests/card/add_label.rs @@ -24,7 +24,7 @@ fn ctx() -> FullCtx { mock_net .on() .put(crate::f!( - "https://{}/index.php/apps/deck/api/v1.0/boards/{}/stacks/1/cards/1/assignLabel", + "{}/index.php/apps/deck/api/v1.0/boards/{}/stacks/1/cards/1/assignLabel", nextcloud_config.hostname, nextcloud_config.board_id )) diff --git a/src/nextcloud/tests/card/create.rs b/src/nextcloud/tests/card/create.rs index 1f7d299..8a28b21 100644 --- a/src/nextcloud/tests/card/create.rs +++ b/src/nextcloud/tests/card/create.rs @@ -15,7 +15,7 @@ fn ctx() -> FullCtx { mock_net .on() .post(crate::f!( - "https://{}/index.php/apps/deck/api/v1.0/boards/{}/stacks/1/cards", + "{}/index.php/apps/deck/api/v1.0/boards/{}/stacks/1/cards", nextcloud_config.hostname, nextcloud_config.board_id )) diff --git a/src/nextcloud/tests/card/get.rs b/src/nextcloud/tests/card/get.rs index ffe30c5..d800cf4 100644 --- a/src/nextcloud/tests/card/get.rs +++ b/src/nextcloud/tests/card/get.rs @@ -20,7 +20,7 @@ fn ctx() -> FullCtx { mock_net .on() .get(crate::f!( - "https://{}/index.php/apps/deck/api/v1.0/boards/{}/stacks/1/cards/321", + "{}/index.php/apps/deck/api/v1.0/boards/{}/stacks/1/cards/321", nextcloud_config.hostname, nextcloud_config.board_id )) diff --git a/src/nextcloud/tests/card/mod.rs b/src/nextcloud/tests/card/mod.rs index 3a1726d..cba62c2 100644 --- a/src/nextcloud/tests/card/mod.rs +++ b/src/nextcloud/tests/card/mod.rs @@ -1,6 +1,7 @@ // use super::*; +mod add_attachment; mod add_label; mod create; mod get; diff --git a/src/nextcloud/tests/deck/get.rs b/src/nextcloud/tests/deck/get.rs index 541c075..08cdef6 100644 --- a/src/nextcloud/tests/deck/get.rs +++ b/src/nextcloud/tests/deck/get.rs @@ -10,7 +10,7 @@ fn ctx() -> FullCtx { mock_net .on() .get(crate::f!( - "https://{}/index.php/apps/deck/api/v1.0/boards", + "{}/index.php/apps/deck/api/v1.0/boards", nextcloud_config.hostname, )) .respond(StatusCode::OK) diff --git a/src/nextcloud/tests/stack/get.rs b/src/nextcloud/tests/stack/get.rs index b8f55f5..f9f6bec 100644 --- a/src/nextcloud/tests/stack/get.rs +++ b/src/nextcloud/tests/stack/get.rs @@ -24,7 +24,7 @@ fn ctx() -> FullCtx { mock_net .on() .get(crate::f!( - "https://{hostname}/index.php/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}", + "{hostname}/index.php/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}", )) .respond(StatusCode::OK) .body(include_str!( diff --git a/src/tests/given.rs b/src/tests/given.rs index ada6e4e..c7a22aa 100644 --- a/src/tests/given.rs +++ b/src/tests/given.rs @@ -35,7 +35,7 @@ pub(crate) fn a_trello_config() -> TrelloConfig { } pub(crate) fn a_nextcloud_config() -> NextcloudConfig { - let hostname = s!("host-name").into(); + let hostname = s!("https://host-name").into(); let username = s!("username").into(); let password = s!("password").into(); let board_id = 2.into(); diff --git a/src/tests/responses/nextcloud-attachment-add.json b/src/tests/responses/nextcloud-attachment-add.json new file mode 100644 index 0000000..434cdf3 --- /dev/null +++ b/src/tests/responses/nextcloud-attachment-add.json @@ -0,0 +1 @@ +{"id":102,"cardId":332,"type":"file","data":"async-rust-book.webp","lastModified":1734511846,"createdAt":1734511846,"createdBy":"pcampbell","deletedAt":0,"extendedData":{"path":"/Deck/async-rust-book.webp","fileid":746505,"data":"async-rust-book.webp","filesize":137128,"mimetype":"image/webp","info":{"dirname":".","basename":"async-rust-book.webp","extension":"webp","filename":"async-rust-book"},"hasPreview":true,"permissions":1,"attachmentCreator":{"displayName":"Paul Campbell","id":"pcampbell","email":"pcampbell@kemitix.net"}}} \ No newline at end of file diff --git a/trello-to-deck.toml b/trello-to-deck.toml index 8fb90b5..a7351a3 100644 --- a/trello-to-deck.toml +++ b/trello-to-deck.toml @@ -7,5 +7,6 @@ board_name = "Tasyn Kanban" [nextcloud] username = "pcampbell" password = "9Nqck-BMtt7-A9fHi-K4z5b-yd2HA" -hostname = "cloud.kemitix.net" +#hostname = "https://127.0.0.1:61932" +hostname = "https://cloud.kemitix.net" board_id = 1