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 = "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"] }

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 create
- [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>,
{
pub(crate) text: String,
pub(crate) result: Result<T, kxio::net::Error>,
pub(crate) result: Result<T, kxio::Error>,
}
impl<T> APIResult<T>
@ -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 {
Err(e) => {
e!(prt, "err: {e:#?}");
Self::error(e.into())
}
}
}
pub fn error(err: kxio::Error) -> Self {
Self {
text: s!(""),
result: Err(e),
},
result: Err(err),
}
}
}

View file

@ -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<String>,
},
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(())
}
}
}
}

View file

@ -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>) -> 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<T>(
&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<String, String>,
form: multipart::Form,
) -> APIResult<T>
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<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_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
.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!(

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
.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
))

View file

@ -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
))

View file

@ -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
))

View file

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

View file

@ -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)

View file

@ -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!(

View file

@ -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();

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]
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