feat: add command 'nextcloud card add-attachment'
Some checks failed
Test / build (map[name:nightly]) (push) Successful in 2m18s
Test / build (map[name:stable]) (push) Successful in 2m17s
Release Please / Release-plz (push) Failing after 27s

This commit is contained in:
Paul Campbell 2024-12-16 08:03:31 +00:00
parent 36a2258bc6
commit 6d234609e7
17 changed files with 354 additions and 34 deletions

View file

@ -15,6 +15,7 @@ derive_more = { version = "1.0", features = [
] } ] }
# kxio = {path = "../kxio/"} # kxio = {path = "../kxio/"}
kxio = "4.0" kxio = "4.0"
reqwest = { version = "0.12" , features = ["multipart", "stream"]}
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tokio = { version = "1.41", features = ["full"] } tokio = { version = "1.41", features = ["full"] }

View file

@ -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 get - shows card title
- [x] nextcloud card create - [x] nextcloud card create
- [x] nextcloud card add-label - [x] nextcloud card add-label
- [ ] nextcloud card add-attachment - [x] nextcloud card add-attachment

View file

@ -9,7 +9,7 @@ where
T: for<'a> serde::Deserialize<'a>, T: for<'a> serde::Deserialize<'a>,
{ {
pub(crate) text: String, pub(crate) text: String,
pub(crate) result: Result<T, kxio::net::Error>, pub(crate) result: Result<T, kxio::Error>,
} }
impl<T> APIResult<T> impl<T> APIResult<T>
@ -27,13 +27,20 @@ where
e!(prt, "{e}: {text}"); e!(prt, "{e}: {text}");
e e
}) })
.map_err(kxio::net::Error::from); .map_err(kxio::net::Error::from)
.map_err(kxio::Error::from);
Self { text, result } Self { text, result }
} }
Err(e) => Self { Err(e) => {
e!(prt, "err: {e:#?}");
Self::error(e.into())
}
}
}
pub fn error(err: kxio::Error) -> Self {
Self {
text: s!(""), text: s!(""),
result: Err(e), result: Err(err),
},
} }
} }
} }

View file

@ -1,4 +1,6 @@
// //
use std::path::PathBuf;
use clap::Parser; use clap::Parser;
use crate::{execute::Execute, p, FullCtx}; use crate::{execute::Execute, p, FullCtx};
@ -22,7 +24,6 @@ pub enum NextcloudCardCommand {
#[clap(long)] #[clap(long)]
description: Option<String>, description: Option<String>,
}, },
AddLabel { AddLabel {
#[clap(long, action = clap::ArgAction::SetTrue)] #[clap(long, action = clap::ArgAction::SetTrue)]
dump: bool, dump: bool,
@ -31,6 +32,14 @@ pub enum NextcloudCardCommand {
card_id: i64, card_id: i64,
label_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 { impl Execute for NextcloudCardCommand {
@ -113,6 +122,38 @@ impl Execute for NextcloudCardCommand {
} }
Ok(()) 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(())
}
} }
} }
} }

View file

@ -1,15 +1,19 @@
// //
use bytes::Bytes; 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 serde_json::json;
use crate::nextcloud::model::NextcloudLabelId;
use crate::{ use crate::{
api_result::APIResult, api_result::APIResult,
f, f,
nextcloud::model::{ nextcloud::model::{
Board, Card, NextcloudBoardId, NextcloudCardId, NextcloudHostname, NextcloudPassword, Attachment, Board, Card, NextcloudBoardId, NextcloudCardId, NextcloudHostname,
NextcloudStackId, NextcloudUsername, Stack, NextcloudLabelId, NextcloudPassword, NextcloudStackId, NextcloudUsername, Stack,
}, },
FullCtx, FullCtx,
}; };
@ -34,7 +38,7 @@ impl<'ctx> DeckClient<'ctx> {
fn url(&self, path: impl Into<String>) -> String { fn url(&self, path: impl Into<String>) -> String {
f!( f!(
"https://{}/index.php/apps/deck/api/v1.0/{}", "{}/index.php/apps/deck/api/v1.0/{}",
self.hostname, self.hostname,
path.into() path.into()
) )
@ -76,20 +80,37 @@ impl<'ctx> DeckClient<'ctx> {
.await .await
} }
pub(crate) async fn add_label_to_card( async fn request_with_form<T>(
&self, &self,
board_id: NextcloudBoardId, path: String,
stack_id: NextcloudStackId, // form_data: HashMap<String, String>,
card_id: NextcloudCardId, form: multipart::Form,
label_id: NextcloudLabelId, ) -> APIResult<T>
) -> APIResult<()> { where
self.request_with_body( T: DeserializeOwned,
f!("boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignLabel"), {
json!({ // let form: multipart::Form = multipart::Form::new();
"labelId": label_id // let full_path = self.ctx.fs.base().join(form_data.get("file").expect("file"));
}) // e!(self.ctx.prt, "Uploading file: {}", full_path.display());
.to_string(), // let form = form.file("file", Path::new(&full_path))
|net, url| net.put(url), // .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 .await
} }
@ -149,4 +170,43 @@ impl<'ctx> DeckClient<'ctx> {
) )
.await .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<Attachment> {
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
}
} }

View file

@ -182,3 +182,58 @@ pub(crate) struct Acl {
pub(crate) permission_share: bool, pub(crate) permission_share: bool,
pub(crate) permission_manage: 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,
}

View file

@ -20,7 +20,7 @@ fn ctx() -> FullCtx {
mock_net mock_net
.on() .on()
.get(crate::f!( .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) .respond(StatusCode::OK)
.body(include_str!( .body(include_str!(

View file

@ -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");
}

View file

@ -24,7 +24,7 @@ fn ctx() -> FullCtx {
mock_net mock_net
.on() .on()
.put(crate::f!( .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.hostname,
nextcloud_config.board_id nextcloud_config.board_id
)) ))

View file

@ -15,7 +15,7 @@ fn ctx() -> FullCtx {
mock_net mock_net
.on() .on()
.post(crate::f!( .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.hostname,
nextcloud_config.board_id nextcloud_config.board_id
)) ))

View file

@ -20,7 +20,7 @@ fn ctx() -> FullCtx {
mock_net mock_net
.on() .on()
.get(crate::f!( .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.hostname,
nextcloud_config.board_id nextcloud_config.board_id
)) ))

View file

@ -1,6 +1,7 @@
// //
use super::*; use super::*;
mod add_attachment;
mod add_label; mod add_label;
mod create; mod create;
mod get; mod get;

View file

@ -10,7 +10,7 @@ fn ctx() -> FullCtx {
mock_net mock_net
.on() .on()
.get(crate::f!( .get(crate::f!(
"https://{}/index.php/apps/deck/api/v1.0/boards", "{}/index.php/apps/deck/api/v1.0/boards",
nextcloud_config.hostname, nextcloud_config.hostname,
)) ))
.respond(StatusCode::OK) .respond(StatusCode::OK)

View file

@ -24,7 +24,7 @@ fn ctx() -> FullCtx {
mock_net mock_net
.on() .on()
.get(crate::f!( .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) .respond(StatusCode::OK)
.body(include_str!( .body(include_str!(

View file

@ -35,7 +35,7 @@ pub(crate) fn a_trello_config() -> TrelloConfig {
} }
pub(crate) fn a_nextcloud_config() -> NextcloudConfig { 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 username = s!("username").into();
let password = s!("password").into(); let password = s!("password").into();
let board_id = 2.into(); let board_id = 2.into();

View file

@ -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"}}}

View file

@ -7,5 +7,6 @@ board_name = "Tasyn Kanban"
[nextcloud] [nextcloud]
username = "pcampbell" username = "pcampbell"
password = "9Nqck-BMtt7-A9fHi-K4z5b-yd2HA" 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 board_id = 1