From 36321c47d6eb511b867a337e826d9ed2829c9e1c Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sat, 21 Mar 2026 17:10:54 +0900 Subject: [PATCH 1/3] feat: add bl wiki count/tag/star/attachment/shared-file commands Implements bl wiki count, bl wiki tag list, bl wiki star list, bl wiki attachment add/get/delete, bl wiki shared-file list/link/unlink. Closes #49 --- src/api/mod.rs | 93 ++++++++- src/api/wiki.rs | 94 +++++++++ src/cmd/wiki/attachment/add.rs | 112 +++++++++++ src/cmd/wiki/attachment/delete.rs | 99 +++++++++ src/cmd/wiki/attachment/get.rs | 108 ++++++++++ src/cmd/wiki/attachment/mod.rs | 8 +- src/cmd/wiki/count.rs | 84 ++++++++ src/cmd/wiki/mod.rs | 5 + src/cmd/wiki/shared_file/link.rs | 123 ++++++++++++ src/cmd/wiki/shared_file/list.rs | 95 +++++++++ src/cmd/wiki/shared_file/mod.rs | 7 + src/cmd/wiki/shared_file/unlink.rs | 87 ++++++++ src/cmd/wiki/star/list.rs | 101 ++++++++++ src/cmd/wiki/star/mod.rs | 3 + src/cmd/wiki/tag/list.rs | 93 +++++++++ src/cmd/wiki/tag/mod.rs | 3 + src/main.rs | 188 +++++++++++++++++- website/docs/commands.md | 126 +++++++++++- .../current/commands.md | 126 +++++++++++- 19 files changed, 1533 insertions(+), 22 deletions(-) create mode 100644 src/cmd/wiki/attachment/add.rs create mode 100644 src/cmd/wiki/attachment/delete.rs create mode 100644 src/cmd/wiki/attachment/get.rs create mode 100644 src/cmd/wiki/count.rs create mode 100644 src/cmd/wiki/shared_file/link.rs create mode 100644 src/cmd/wiki/shared_file/list.rs create mode 100644 src/cmd/wiki/shared_file/mod.rs create mode 100644 src/cmd/wiki/shared_file/unlink.rs create mode 100644 src/cmd/wiki/star/list.rs create mode 100644 src/cmd/wiki/star/mod.rs create mode 100644 src/cmd/wiki/tag/list.rs create mode 100644 src/cmd/wiki/tag/mod.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 0cc25c8..5d7d260 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -47,7 +47,7 @@ use space_notification::SpaceNotification; use team::Team; use user::{RecentlyViewedIssue, RecentlyViewedProject, RecentlyViewedWiki, Star, StarCount, User}; use watch::{Watching, WatchingCount}; -use wiki::{Wiki, WikiAttachment, WikiHistory, WikiListItem}; +use wiki::{Wiki, WikiAttachment, WikiCount, WikiHistory, WikiListItem, WikiSharedFile, WikiTag}; /// Abstraction over the Backlog HTTP API. /// @@ -459,6 +459,49 @@ pub trait BacklogApi { fn get_wiki_attachments(&self, _wiki_id: u64) -> Result> { unimplemented!() } + fn get_wiki_count(&self, _params: &[(String, String)]) -> Result { + unimplemented!() + } + fn get_wiki_tags(&self, _params: &[(String, String)]) -> Result> { + unimplemented!() + } + fn get_wiki_stars(&self, _wiki_id: u64) -> Result> { + unimplemented!() + } + fn add_wiki_attachments( + &self, + _wiki_id: u64, + _attachment_ids: &[u64], + ) -> Result> { + unimplemented!() + } + fn download_wiki_attachment( + &self, + _wiki_id: u64, + _attachment_id: u64, + ) -> Result<(Vec, String)> { + unimplemented!() + } + fn delete_wiki_attachment(&self, _wiki_id: u64, _attachment_id: u64) -> Result { + unimplemented!() + } + fn get_wiki_shared_files(&self, _wiki_id: u64) -> Result> { + unimplemented!() + } + fn link_wiki_shared_files( + &self, + _wiki_id: u64, + _shared_file_ids: &[u64], + ) -> Result> { + unimplemented!() + } + fn unlink_wiki_shared_file( + &self, + _wiki_id: u64, + _shared_file_id: u64, + ) -> Result { + unimplemented!() + } fn get_teams(&self, _params: &[(String, String)]) -> Result> { unimplemented!() } @@ -1055,6 +1098,54 @@ impl BacklogApi for BacklogClient { self.get_wiki_attachments(wiki_id) } + fn get_wiki_count(&self, params: &[(String, String)]) -> Result { + self.get_wiki_count(params) + } + + fn get_wiki_tags(&self, params: &[(String, String)]) -> Result> { + self.get_wiki_tags(params) + } + + fn get_wiki_stars(&self, wiki_id: u64) -> Result> { + self.get_wiki_stars(wiki_id) + } + + fn add_wiki_attachments( + &self, + wiki_id: u64, + attachment_ids: &[u64], + ) -> Result> { + self.add_wiki_attachments(wiki_id, attachment_ids) + } + + fn download_wiki_attachment( + &self, + wiki_id: u64, + attachment_id: u64, + ) -> Result<(Vec, String)> { + self.download_wiki_attachment(wiki_id, attachment_id) + } + + fn delete_wiki_attachment(&self, wiki_id: u64, attachment_id: u64) -> Result { + self.delete_wiki_attachment(wiki_id, attachment_id) + } + + fn get_wiki_shared_files(&self, wiki_id: u64) -> Result> { + self.get_wiki_shared_files(wiki_id) + } + + fn link_wiki_shared_files( + &self, + wiki_id: u64, + shared_file_ids: &[u64], + ) -> Result> { + self.link_wiki_shared_files(wiki_id, shared_file_ids) + } + + fn unlink_wiki_shared_file(&self, wiki_id: u64, shared_file_id: u64) -> Result { + self.unlink_wiki_shared_file(wiki_id, shared_file_id) + } + fn get_teams(&self, params: &[(String, String)]) -> Result> { self.get_teams(params) } diff --git a/src/api/wiki.rs b/src/api/wiki.rs index d87b96f..3167177 100644 --- a/src/api/wiki.rs +++ b/src/api/wiki.rs @@ -4,6 +4,24 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use super::BacklogClient; +use crate::api::shared_file::SharedFile; +use crate::api::user::Star; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WikiCount { + pub count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WikiSharedFile { + pub id: u64, + pub dir: String, + pub name: String, + pub size: u64, + #[serde(flatten)] + pub extra: BTreeMap, +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -116,6 +134,82 @@ impl BacklogClient { let value = self.get(&format!("/wikis/{}/attachments", wiki_id))?; deserialize(value) } + + pub fn get_wiki_count(&self, params: &[(String, String)]) -> Result { + let value = self.get_with_query("/wikis/count", params)?; + deserialize(value) + } + + pub fn get_wiki_tags(&self, params: &[(String, String)]) -> Result> { + let value = self.get_with_query("/wikis/tags", params)?; + deserialize(value) + } + + pub fn get_wiki_stars(&self, wiki_id: u64) -> Result> { + let value = self.get(&format!("/wikis/{}/stars", wiki_id))?; + deserialize(value) + } + + pub fn add_wiki_attachments( + &self, + wiki_id: u64, + attachment_ids: &[u64], + ) -> Result> { + let params: Vec<(String, String)> = attachment_ids + .iter() + .map(|id| ("attachmentId[]".to_string(), id.to_string())) + .collect(); + let value = self.post_form(&format!("/wikis/{}/attachments", wiki_id), ¶ms)?; + deserialize(value) + } + + pub fn download_wiki_attachment( + &self, + wiki_id: u64, + attachment_id: u64, + ) -> Result<(Vec, String)> { + self.download(&format!("/wikis/{}/attachments/{}", wiki_id, attachment_id)) + } + + pub fn delete_wiki_attachment( + &self, + wiki_id: u64, + attachment_id: u64, + ) -> Result { + let value = + self.delete_req(&format!("/wikis/{}/attachments/{}", wiki_id, attachment_id))?; + deserialize(value) + } + + pub fn get_wiki_shared_files(&self, wiki_id: u64) -> Result> { + let value = self.get(&format!("/wikis/{}/sharedFiles", wiki_id))?; + deserialize(value) + } + + pub fn link_wiki_shared_files( + &self, + wiki_id: u64, + shared_file_ids: &[u64], + ) -> Result> { + let params: Vec<(String, String)> = shared_file_ids + .iter() + .map(|id| ("fileId[]".to_string(), id.to_string())) + .collect(); + let value = self.post_form(&format!("/wikis/{}/sharedFiles", wiki_id), ¶ms)?; + deserialize(value) + } + + pub fn unlink_wiki_shared_file( + &self, + wiki_id: u64, + shared_file_id: u64, + ) -> Result { + let value = self.delete_req(&format!( + "/wikis/{}/sharedFiles/{}", + wiki_id, shared_file_id + ))?; + deserialize(value) + } } #[cfg(test)] diff --git a/src/cmd/wiki/attachment/add.rs b/src/cmd/wiki/attachment/add.rs new file mode 100644 index 0000000..a4bf2e9 --- /dev/null +++ b/src/cmd/wiki/attachment/add.rs @@ -0,0 +1,112 @@ +use anstream::println; +use anyhow::{Context, Result, bail}; + +use crate::api::{BacklogApi, BacklogClient}; +use crate::cmd::wiki::attachment::list::format_attachment_row; + +#[derive(Debug)] +pub struct WikiAttachmentAddArgs { + wiki_id: u64, + attachment_ids: Vec, + json: bool, +} + +impl WikiAttachmentAddArgs { + pub fn try_new(wiki_id: u64, attachment_ids: Vec, json: bool) -> Result { + if attachment_ids.is_empty() { + bail!("at least one --attachment-id must be specified"); + } + Ok(Self { + wiki_id, + attachment_ids, + json, + }) + } +} + +pub fn add(args: &WikiAttachmentAddArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + add_with(args, &client) +} + +pub fn add_with(args: &WikiAttachmentAddArgs, api: &dyn BacklogApi) -> Result<()> { + let attachments = api.add_wiki_attachments(args.wiki_id, &args.attachment_ids)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&attachments).context("Failed to serialize JSON")? + ); + } else { + for a in &attachments { + println!("{}", format_attachment_row(a)); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::wiki::WikiAttachment; + use crate::cmd::wiki::list::tests_helper::sample_wiki_user; + use anyhow::anyhow; + + struct MockApi { + attachments: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn add_wiki_attachments( + &self, + _wiki_id: u64, + _attachment_ids: &[u64], + ) -> anyhow::Result> { + self.attachments + .clone() + .ok_or_else(|| anyhow!("no attachments")) + } + } + + fn sample_attachment() -> WikiAttachment { + WikiAttachment { + id: 1, + name: "image.png".to_string(), + size: 2048, + created_user: sample_wiki_user(), + created: "2024-01-01T00:00:00Z".to_string(), + } + } + + fn args(json: bool) -> WikiAttachmentAddArgs { + WikiAttachmentAddArgs::try_new(1, vec![1], json).unwrap() + } + + #[test] + fn add_with_text_output_succeeds() { + let api = MockApi { + attachments: Some(vec![sample_attachment()]), + }; + assert!(add_with(&args(false), &api).is_ok()); + } + + #[test] + fn add_with_json_output_succeeds() { + let api = MockApi { + attachments: Some(vec![sample_attachment()]), + }; + assert!(add_with(&args(true), &api).is_ok()); + } + + #[test] + fn add_with_propagates_api_error() { + let api = MockApi { attachments: None }; + let err = add_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no attachments")); + } + + #[test] + fn try_new_rejects_empty_ids() { + let err = WikiAttachmentAddArgs::try_new(1, vec![], false).unwrap_err(); + assert!(err.to_string().contains("at least one")); + } +} diff --git a/src/cmd/wiki/attachment/delete.rs b/src/cmd/wiki/attachment/delete.rs new file mode 100644 index 0000000..ecaa002 --- /dev/null +++ b/src/cmd/wiki/attachment/delete.rs @@ -0,0 +1,99 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct WikiAttachmentDeleteArgs { + wiki_id: u64, + attachment_id: u64, + json: bool, +} + +impl WikiAttachmentDeleteArgs { + pub fn new(wiki_id: u64, attachment_id: u64, json: bool) -> Self { + Self { + wiki_id, + attachment_id, + json, + } + } +} + +pub fn delete(args: &WikiAttachmentDeleteArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + delete_with(args, &client) +} + +pub fn delete_with(args: &WikiAttachmentDeleteArgs, api: &dyn BacklogApi) -> Result<()> { + let attachment = api.delete_wiki_attachment(args.wiki_id, args.attachment_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&attachment).context("Failed to serialize JSON")? + ); + } else { + println!("Deleted: {} ({} bytes)", attachment.name, attachment.size); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::wiki::WikiAttachment; + use crate::cmd::wiki::list::tests_helper::sample_wiki_user; + use anyhow::anyhow; + + struct MockApi { + attachment: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn delete_wiki_attachment( + &self, + _wiki_id: u64, + _attachment_id: u64, + ) -> anyhow::Result { + self.attachment + .clone() + .ok_or_else(|| anyhow!("no attachment")) + } + } + + fn sample_attachment() -> WikiAttachment { + WikiAttachment { + id: 1, + name: "image.png".to_string(), + size: 2048, + created_user: sample_wiki_user(), + created: "2024-01-01T00:00:00Z".to_string(), + } + } + + fn args(json: bool) -> WikiAttachmentDeleteArgs { + WikiAttachmentDeleteArgs::new(1, 1, json) + } + + #[test] + fn delete_with_text_output_succeeds() { + let api = MockApi { + attachment: Some(sample_attachment()), + }; + assert!(delete_with(&args(false), &api).is_ok()); + } + + #[test] + fn delete_with_json_output_succeeds() { + let api = MockApi { + attachment: Some(sample_attachment()), + }; + assert!(delete_with(&args(true), &api).is_ok()); + } + + #[test] + fn delete_with_propagates_api_error() { + let api = MockApi { attachment: None }; + let err = delete_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no attachment")); + } +} diff --git a/src/cmd/wiki/attachment/get.rs b/src/cmd/wiki/attachment/get.rs new file mode 100644 index 0000000..2a5a060 --- /dev/null +++ b/src/cmd/wiki/attachment/get.rs @@ -0,0 +1,108 @@ +use anstream::println; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct WikiAttachmentGetArgs { + wiki_id: u64, + attachment_id: u64, + output: Option, +} + +impl WikiAttachmentGetArgs { + pub fn new(wiki_id: u64, attachment_id: u64, output: Option) -> Self { + Self { + wiki_id, + attachment_id, + output, + } + } +} + +pub fn get(args: &WikiAttachmentGetArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + get_with(args, &client) +} + +pub fn get_with(args: &WikiAttachmentGetArgs, api: &dyn BacklogApi) -> Result<()> { + let (bytes, filename) = api.download_wiki_attachment(args.wiki_id, args.attachment_id)?; + let path = args.output.clone().unwrap_or_else(|| { + let base = std::path::Path::new(&filename) + .file_name() + .unwrap_or(std::ffi::OsStr::new("attachment")); + PathBuf::from(base) + }); + std::fs::write(&path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?; + println!("Saved: {} ({} bytes)", path.display(), bytes.len()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use tempfile::tempdir; + + struct MockApi { + result: Option<(Vec, String)>, + } + + impl crate::api::BacklogApi for MockApi { + fn download_wiki_attachment( + &self, + _wiki_id: u64, + _attachment_id: u64, + ) -> anyhow::Result<(Vec, String)> { + self.result + .clone() + .ok_or_else(|| anyhow!("download failed")) + } + } + + fn args(output: Option) -> WikiAttachmentGetArgs { + WikiAttachmentGetArgs::new(1, 1, output) + } + + #[test] + fn get_with_saves_file_to_specified_path() { + let dir = tempdir().unwrap(); + let path = dir.path().join("out.txt"); + let api = MockApi { + result: Some((b"hello".to_vec(), "file.txt".to_string())), + }; + assert!(get_with(&args(Some(path.clone())), &api).is_ok()); + assert_eq!(std::fs::read(&path).unwrap(), b"hello"); + } + + #[test] + fn get_with_saves_file_to_default_filename() { + let dir = tempdir().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + + struct DirGuard(std::path::PathBuf); + impl Drop for DirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + let _guard = DirGuard(original_dir); + + let api = MockApi { + result: Some((b"world".to_vec(), "response.txt".to_string())), + }; + assert!(get_with(&args(None), &api).is_ok()); + assert_eq!( + std::fs::read(dir.path().join("response.txt")).unwrap(), + b"world" + ); + } + + #[test] + fn get_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = get_with(&args(None), &api).unwrap_err(); + assert!(err.to_string().contains("download failed")); + } +} diff --git a/src/cmd/wiki/attachment/mod.rs b/src/cmd/wiki/attachment/mod.rs index b27b82c..5f6e945 100644 --- a/src/cmd/wiki/attachment/mod.rs +++ b/src/cmd/wiki/attachment/mod.rs @@ -1,3 +1,9 @@ -mod list; +mod add; +mod delete; +mod get; +pub mod list; +pub use add::{WikiAttachmentAddArgs, add}; +pub use delete::{WikiAttachmentDeleteArgs, delete}; +pub use get::{WikiAttachmentGetArgs, get}; pub use list::{WikiAttachmentListArgs, list}; diff --git a/src/cmd/wiki/count.rs b/src/cmd/wiki/count.rs new file mode 100644 index 0000000..729b345 --- /dev/null +++ b/src/cmd/wiki/count.rs @@ -0,0 +1,84 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct WikiCountArgs { + project_id_or_key: Option, + json: bool, +} + +impl WikiCountArgs { + pub fn new(project_id_or_key: Option, json: bool) -> Self { + Self { + project_id_or_key, + json, + } + } +} + +pub fn count(args: &WikiCountArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + count_with(args, &client) +} + +pub fn count_with(args: &WikiCountArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(ref key) = args.project_id_or_key { + params.push(("projectIdOrKey".to_string(), key.clone())); + } + let result = api.get_wiki_count(¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&result).context("Failed to serialize JSON")? + ); + } else { + println!("{}", result.count); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::wiki::WikiCount; + use anyhow::anyhow; + + struct MockApi { + result: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn get_wiki_count(&self, _params: &[(String, String)]) -> anyhow::Result { + self.result.clone().ok_or_else(|| anyhow!("no count")) + } + } + + fn args(json: bool) -> WikiCountArgs { + WikiCountArgs::new(Some("TEST".to_string()), json) + } + + #[test] + fn count_with_text_output_succeeds() { + let api = MockApi { + result: Some(WikiCount { count: 5 }), + }; + assert!(count_with(&args(false), &api).is_ok()); + } + + #[test] + fn count_with_json_output_succeeds() { + let api = MockApi { + result: Some(WikiCount { count: 5 }), + }; + assert!(count_with(&args(true), &api).is_ok()); + } + + #[test] + fn count_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = count_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no count")); + } +} diff --git a/src/cmd/wiki/mod.rs b/src/cmd/wiki/mod.rs index 34fc3f2..cece6d2 100644 --- a/src/cmd/wiki/mod.rs +++ b/src/cmd/wiki/mod.rs @@ -1,11 +1,16 @@ pub mod attachment; +mod count; mod create; mod delete; mod history; pub mod list; +pub mod shared_file; mod show; +pub mod star; +pub mod tag; mod update; +pub use count::{WikiCountArgs, count}; pub use create::{WikiCreateArgs, create}; pub use delete::{WikiDeleteArgs, delete}; pub use history::{WikiHistoryArgs, history}; diff --git a/src/cmd/wiki/shared_file/link.rs b/src/cmd/wiki/shared_file/link.rs new file mode 100644 index 0000000..f9fa6dd --- /dev/null +++ b/src/cmd/wiki/shared_file/link.rs @@ -0,0 +1,123 @@ +use anstream::println; +use anyhow::{Context, Result, bail}; + +use crate::api::{BacklogApi, BacklogClient}; + +#[derive(Debug)] +pub struct WikiSharedFileLinkArgs { + wiki_id: u64, + shared_file_ids: Vec, + json: bool, +} + +impl WikiSharedFileLinkArgs { + pub fn try_new(wiki_id: u64, shared_file_ids: Vec, json: bool) -> Result { + if shared_file_ids.is_empty() { + bail!("at least one --shared-file-id must be specified"); + } + Ok(Self { + wiki_id, + shared_file_ids, + json, + }) + } +} + +pub fn link(args: &WikiSharedFileLinkArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + link_with(args, &client) +} + +pub fn link_with(args: &WikiSharedFileLinkArgs, api: &dyn BacklogApi) -> Result<()> { + let files = api.link_wiki_shared_files(args.wiki_id, &args.shared_file_ids)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&files).context("Failed to serialize JSON")? + ); + } else { + println!("Linked {} file(s).", files.len()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::shared_file::SharedFile; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + files: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn link_wiki_shared_files( + &self, + _wiki_id: u64, + _shared_file_ids: &[u64], + ) -> anyhow::Result> { + self.files.clone().ok_or_else(|| anyhow!("no files")) + } + } + + fn sample_shared_file() -> SharedFile { + use crate::api::user::User; + SharedFile { + id: 1, + project_id: 10, + file_type: "file".to_string(), + dir: "/docs".to_string(), + name: "spec.pdf".to_string(), + size: Some(2048), + created_user: User { + id: 1, + user_id: Some("john".to_string()), + name: "John Doe".to_string(), + mail_address: None, + role_type: 1, + lang: None, + last_login_time: None, + extra: BTreeMap::new(), + }, + created: "2024-01-01T00:00:00Z".to_string(), + updated_user: None, + updated: "2024-01-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> WikiSharedFileLinkArgs { + WikiSharedFileLinkArgs::try_new(1, vec![1], json).unwrap() + } + + #[test] + fn link_with_text_output_succeeds() { + let api = MockApi { + files: Some(vec![sample_shared_file()]), + }; + assert!(link_with(&args(false), &api).is_ok()); + } + + #[test] + fn link_with_json_output_succeeds() { + let api = MockApi { + files: Some(vec![sample_shared_file()]), + }; + assert!(link_with(&args(true), &api).is_ok()); + } + + #[test] + fn link_with_propagates_api_error() { + let api = MockApi { files: None }; + let err = link_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no files")); + } + + #[test] + fn try_new_rejects_empty_ids() { + let err = WikiSharedFileLinkArgs::try_new(1, vec![], false).unwrap_err(); + assert!(err.to_string().contains("at least one")); + } +} diff --git a/src/cmd/wiki/shared_file/list.rs b/src/cmd/wiki/shared_file/list.rs new file mode 100644 index 0000000..01f9198 --- /dev/null +++ b/src/cmd/wiki/shared_file/list.rs @@ -0,0 +1,95 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct WikiSharedFileListArgs { + wiki_id: u64, + json: bool, +} + +impl WikiSharedFileListArgs { + pub fn new(wiki_id: u64, json: bool) -> Self { + Self { wiki_id, json } + } +} + +pub fn list(args: &WikiSharedFileListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &WikiSharedFileListArgs, api: &dyn BacklogApi) -> Result<()> { + let files = api.get_wiki_shared_files(args.wiki_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&files).context("Failed to serialize JSON")? + ); + } else { + for f in &files { + let sep = if f.dir.ends_with('/') { "" } else { "/" }; + println!("[{}] {}{}{} ({} bytes)", f.id, f.dir, sep, f.name, f.size); + } + } + Ok(()) +} + +#[cfg(test)] +use crate::api::wiki::WikiSharedFile; +#[cfg(test)] +use std::collections::BTreeMap; + +#[cfg(test)] +pub(crate) fn sample_wiki_shared_file() -> WikiSharedFile { + WikiSharedFile { + id: 1, + dir: "/docs".to_string(), + name: "spec.pdf".to_string(), + size: 2048, + extra: BTreeMap::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + + struct MockApi { + files: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_wiki_shared_files(&self, _wiki_id: u64) -> anyhow::Result> { + self.files.clone().ok_or_else(|| anyhow!("no files")) + } + } + + fn args(json: bool) -> WikiSharedFileListArgs { + WikiSharedFileListArgs::new(1, json) + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + files: Some(vec![sample_wiki_shared_file()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + files: Some(vec![sample_wiki_shared_file()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { files: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no files")); + } +} diff --git a/src/cmd/wiki/shared_file/mod.rs b/src/cmd/wiki/shared_file/mod.rs new file mode 100644 index 0000000..772eb6c --- /dev/null +++ b/src/cmd/wiki/shared_file/mod.rs @@ -0,0 +1,7 @@ +mod link; +pub mod list; +mod unlink; + +pub use link::{WikiSharedFileLinkArgs, link}; +pub use list::{WikiSharedFileListArgs, list}; +pub use unlink::{WikiSharedFileUnlinkArgs, unlink}; diff --git a/src/cmd/wiki/shared_file/unlink.rs b/src/cmd/wiki/shared_file/unlink.rs new file mode 100644 index 0000000..a136922 --- /dev/null +++ b/src/cmd/wiki/shared_file/unlink.rs @@ -0,0 +1,87 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct WikiSharedFileUnlinkArgs { + wiki_id: u64, + shared_file_id: u64, + json: bool, +} + +impl WikiSharedFileUnlinkArgs { + pub fn new(wiki_id: u64, shared_file_id: u64, json: bool) -> Self { + Self { + wiki_id, + shared_file_id, + json, + } + } +} + +pub fn unlink(args: &WikiSharedFileUnlinkArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + unlink_with(args, &client) +} + +pub fn unlink_with(args: &WikiSharedFileUnlinkArgs, api: &dyn BacklogApi) -> Result<()> { + let file = api.unlink_wiki_shared_file(args.wiki_id, args.shared_file_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&file).context("Failed to serialize JSON")? + ); + } else { + println!("Unlinked: {}", file.name); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::wiki::WikiSharedFile; + use crate::cmd::wiki::shared_file::list::sample_wiki_shared_file; + use anyhow::anyhow; + + struct MockApi { + file: Option, + } + + impl crate::api::BacklogApi for MockApi { + fn unlink_wiki_shared_file( + &self, + _wiki_id: u64, + _shared_file_id: u64, + ) -> anyhow::Result { + self.file.clone().ok_or_else(|| anyhow!("no file")) + } + } + + fn args(json: bool) -> WikiSharedFileUnlinkArgs { + WikiSharedFileUnlinkArgs::new(1, 1, json) + } + + #[test] + fn unlink_with_text_output_succeeds() { + let api = MockApi { + file: Some(sample_wiki_shared_file()), + }; + assert!(unlink_with(&args(false), &api).is_ok()); + } + + #[test] + fn unlink_with_json_output_succeeds() { + let api = MockApi { + file: Some(sample_wiki_shared_file()), + }; + assert!(unlink_with(&args(true), &api).is_ok()); + } + + #[test] + fn unlink_with_propagates_api_error() { + let api = MockApi { file: None }; + let err = unlink_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no file")); + } +} diff --git a/src/cmd/wiki/star/list.rs b/src/cmd/wiki/star/list.rs new file mode 100644 index 0000000..3591737 --- /dev/null +++ b/src/cmd/wiki/star/list.rs @@ -0,0 +1,101 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct WikiStarListArgs { + wiki_id: u64, + json: bool, +} + +impl WikiStarListArgs { + pub fn new(wiki_id: u64, json: bool) -> Self { + Self { wiki_id, json } + } +} + +pub fn list(args: &WikiStarListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &WikiStarListArgs, api: &dyn BacklogApi) -> Result<()> { + let stars = api.get_wiki_stars(args.wiki_id)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&stars).context("Failed to serialize JSON")? + ); + } else { + for star in &stars { + println!("[{}] {} ({})", star.id, star.title, star.presenter.name); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::user::{Star, User}; + use anyhow::anyhow; + use std::collections::BTreeMap; + + struct MockApi { + stars: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_wiki_stars(&self, _wiki_id: u64) -> anyhow::Result> { + self.stars.clone().ok_or_else(|| anyhow!("no stars")) + } + } + + fn sample_star() -> Star { + Star { + id: 1, + comment: None, + url: "https://example.backlog.com/wiki/TEST/Home".to_string(), + title: "Home".to_string(), + presenter: User { + id: 1, + user_id: Some("john".to_string()), + name: "John Doe".to_string(), + mail_address: None, + role_type: 1, + lang: None, + last_login_time: None, + extra: BTreeMap::new(), + }, + created: "2024-01-01T00:00:00Z".to_string(), + extra: BTreeMap::new(), + } + } + + fn args(json: bool) -> WikiStarListArgs { + WikiStarListArgs::new(1, json) + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + stars: Some(vec![sample_star()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + stars: Some(vec![sample_star()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { stars: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no stars")); + } +} diff --git a/src/cmd/wiki/star/mod.rs b/src/cmd/wiki/star/mod.rs new file mode 100644 index 0000000..fe0faa7 --- /dev/null +++ b/src/cmd/wiki/star/mod.rs @@ -0,0 +1,3 @@ +mod list; + +pub use list::{WikiStarListArgs, list}; diff --git a/src/cmd/wiki/tag/list.rs b/src/cmd/wiki/tag/list.rs new file mode 100644 index 0000000..5e6f71f --- /dev/null +++ b/src/cmd/wiki/tag/list.rs @@ -0,0 +1,93 @@ +use anstream::println; +use anyhow::{Context, Result}; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct WikiTagListArgs { + project_id_or_key: Option, + json: bool, +} + +impl WikiTagListArgs { + pub fn new(project_id_or_key: Option, json: bool) -> Self { + Self { + project_id_or_key, + json, + } + } +} + +pub fn list(args: &WikiTagListArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + list_with(args, &client) +} + +pub fn list_with(args: &WikiTagListArgs, api: &dyn BacklogApi) -> Result<()> { + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(ref key) = args.project_id_or_key { + params.push(("projectIdOrKey".to_string(), key.clone())); + } + let tags = api.get_wiki_tags(¶ms)?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&tags).context("Failed to serialize JSON")? + ); + } else { + for tag in &tags { + println!("[{}] {}", tag.id, tag.name); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::wiki::WikiTag; + use anyhow::anyhow; + + struct MockApi { + tags: Option>, + } + + impl crate::api::BacklogApi for MockApi { + fn get_wiki_tags(&self, _params: &[(String, String)]) -> anyhow::Result> { + self.tags.clone().ok_or_else(|| anyhow!("no tags")) + } + } + + fn args(json: bool) -> WikiTagListArgs { + WikiTagListArgs::new(Some("TEST".to_string()), json) + } + + fn sample_tag() -> WikiTag { + WikiTag { + id: 1, + name: "guide".to_string(), + } + } + + #[test] + fn list_with_text_output_succeeds() { + let api = MockApi { + tags: Some(vec![sample_tag()]), + }; + assert!(list_with(&args(false), &api).is_ok()); + } + + #[test] + fn list_with_json_output_succeeds() { + let api = MockApi { + tags: Some(vec![sample_tag()]), + }; + assert!(list_with(&args(true), &api).is_ok()); + } + + #[test] + fn list_with_propagates_api_error() { + let api = MockApi { tags: None }; + let err = list_with(&args(false), &api).unwrap_err(); + assert!(err.to_string().contains("no tags")); + } +} diff --git a/src/cmd/wiki/tag/mod.rs b/src/cmd/wiki/tag/mod.rs new file mode 100644 index 0000000..7041a6b --- /dev/null +++ b/src/cmd/wiki/tag/mod.rs @@ -0,0 +1,3 @@ +mod list; + +pub use list::{WikiTagListArgs, list}; diff --git a/src/main.rs b/src/main.rs index 02bdbca..51356e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,9 +83,17 @@ use cmd::watch::{ WatchAddArgs, WatchCountArgs, WatchDeleteArgs, WatchListArgs, WatchReadArgs, WatchShowArgs, WatchUpdateArgs, }; -use cmd::wiki::attachment::WikiAttachmentListArgs; +use cmd::wiki::attachment::{ + WikiAttachmentAddArgs, WikiAttachmentDeleteArgs, WikiAttachmentGetArgs, WikiAttachmentListArgs, +}; +use cmd::wiki::shared_file::{ + WikiSharedFileLinkArgs, WikiSharedFileListArgs, WikiSharedFileUnlinkArgs, +}; +use cmd::wiki::star::WikiStarListArgs; +use cmd::wiki::tag::WikiTagListArgs; use cmd::wiki::{ - WikiCreateArgs, WikiDeleteArgs, WikiHistoryArgs, WikiListArgs, WikiShowArgs, WikiUpdateArgs, + WikiCountArgs, WikiCreateArgs, WikiDeleteArgs, WikiHistoryArgs, WikiListArgs, WikiShowArgs, + WikiUpdateArgs, }; #[derive(Parser)] @@ -1412,11 +1420,58 @@ enum WikiCommands { #[arg(long)] json: bool, }, + /// Count wiki pages in a project + Count { + /// Project ID or key (optional filter) + project_id_or_key: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Manage wiki tags + Tag { + #[command(subcommand)] + action: WikiTagCommands, + }, + /// List stars on a wiki page + Star { + #[command(subcommand)] + action: WikiStarCommands, + }, /// Manage wiki attachments Attachment { #[command(subcommand)] action: WikiAttachmentCommands, }, + /// Manage shared files linked to a wiki page + SharedFile { + #[command(subcommand)] + action: WikiSharedFileCommands, + }, +} + +#[derive(Subcommand)] +enum WikiTagCommands { + /// List tags used in wiki pages + List { + /// Project ID or key (optional filter) + project_id_or_key: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand)] +enum WikiStarCommands { + /// List stars on a wiki page + List { + /// Wiki page ID + wiki_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -1429,6 +1484,70 @@ enum WikiAttachmentCommands { #[arg(long)] json: bool, }, + /// Add attachments to a wiki page (by pre-uploaded attachment ID) + Add { + /// Wiki page ID + wiki_id: u64, + /// Attachment ID (repeatable) + #[arg(long = "attachment-id", value_name = "ID")] + attachment_ids: Vec, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Download a wiki attachment + Get { + /// Wiki page ID + wiki_id: u64, + /// Attachment ID + attachment_id: u64, + /// Output file path (defaults to original filename) + #[arg(long)] + output: Option, + }, + /// Delete an attachment from a wiki page + Delete { + /// Wiki page ID + wiki_id: u64, + /// Attachment ID + attachment_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand)] +enum WikiSharedFileCommands { + /// List shared files linked to a wiki page + List { + /// Wiki page ID + wiki_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Link shared files to a wiki page + Link { + /// Wiki page ID + wiki_id: u64, + /// Shared file ID (repeatable) + #[arg(long = "shared-file-id", value_name = "ID")] + shared_file_ids: Vec, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Unlink a shared file from a wiki page + Unlink { + /// Wiki page ID + wiki_id: u64, + /// Shared file ID + shared_file_id: u64, + /// Output as JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -2740,10 +2859,75 @@ fn run() -> Result<()> { WikiCommands::History { wiki_id, json } => { cmd::wiki::history(&WikiHistoryArgs::new(wiki_id, json)) } + WikiCommands::Count { + project_id_or_key, + json, + } => cmd::wiki::count(&WikiCountArgs::new(project_id_or_key, json)), + WikiCommands::Tag { action } => match action { + WikiTagCommands::List { + project_id_or_key, + json, + } => cmd::wiki::tag::list(&WikiTagListArgs::new(project_id_or_key, json)), + }, + WikiCommands::Star { action } => match action { + WikiStarCommands::List { wiki_id, json } => { + cmd::wiki::star::list(&WikiStarListArgs::new(wiki_id, json)) + } + }, WikiCommands::Attachment { action } => match action { WikiAttachmentCommands::List { wiki_id, json } => { cmd::wiki::attachment::list(&WikiAttachmentListArgs::new(wiki_id, json)) } + WikiAttachmentCommands::Add { + wiki_id, + attachment_ids, + json, + } => cmd::wiki::attachment::add(&WikiAttachmentAddArgs::try_new( + wiki_id, + attachment_ids, + json, + )?), + WikiAttachmentCommands::Get { + wiki_id, + attachment_id, + output, + } => cmd::wiki::attachment::get(&WikiAttachmentGetArgs::new( + wiki_id, + attachment_id, + output, + )), + WikiAttachmentCommands::Delete { + wiki_id, + attachment_id, + json, + } => cmd::wiki::attachment::delete(&WikiAttachmentDeleteArgs::new( + wiki_id, + attachment_id, + json, + )), + }, + WikiCommands::SharedFile { action } => match action { + WikiSharedFileCommands::List { wiki_id, json } => { + cmd::wiki::shared_file::list(&WikiSharedFileListArgs::new(wiki_id, json)) + } + WikiSharedFileCommands::Link { + wiki_id, + shared_file_ids, + json, + } => cmd::wiki::shared_file::link(&WikiSharedFileLinkArgs::try_new( + wiki_id, + shared_file_ids, + json, + )?), + WikiSharedFileCommands::Unlink { + wiki_id, + shared_file_id, + json, + } => cmd::wiki::shared_file::unlink(&WikiSharedFileUnlinkArgs::new( + wiki_id, + shared_file_id, + json, + )), }, }, Commands::User { action } => match action { diff --git a/website/docs/commands.md b/website/docs/commands.md index f050c6e..9976352 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -1329,6 +1329,54 @@ v2 Home — 2024-03-15T00:00:00Z v1 Home — 2024-01-01T00:00:00Z ``` +## `bl wiki count` + +Count wiki pages in a project. + +```bash +bl wiki count +bl wiki count TEST +bl wiki count TEST --json +``` + +Example output: + +```text +42 +``` + +## `bl wiki tag list` + +List tags used in wiki pages. + +```bash +bl wiki tag list +bl wiki tag list TEST +bl wiki tag list TEST --json +``` + +Example output: + +```text +[1] guide +[2] api +``` + +## `bl wiki star list` + +List stars on a wiki page. + +```bash +bl wiki star list 12345 +bl wiki star list 12345 --json +``` + +Example output: + +```text +[1] Home (John Doe) +``` + ## `bl wiki attachment list` List attachments of a wiki page. @@ -1345,6 +1393,66 @@ Example output: [2] notes.txt (1024 bytes) ``` +## `bl wiki attachment add` + +Add attachments to a wiki page by pre-uploaded attachment ID. + +```bash +bl wiki attachment add 12345 --attachment-id 1 +bl wiki attachment add 12345 --attachment-id 1 --attachment-id 2 --json +``` + +## `bl wiki attachment get` + +Download an attachment from a wiki page. + +```bash +bl wiki attachment get 12345 1 +bl wiki attachment get 12345 1 --output ./downloads/diagram.png +``` + +## `bl wiki attachment delete` + +Delete an attachment from a wiki page. + +```bash +bl wiki attachment delete 12345 1 +bl wiki attachment delete 12345 1 --json +``` + +## `bl wiki shared-file list` + +List shared files linked to a wiki page. + +```bash +bl wiki shared-file list 12345 +bl wiki shared-file list 12345 --json +``` + +Example output: + +```text +[1] /docs/spec.pdf (204800 bytes) +``` + +## `bl wiki shared-file link` + +Link shared files to a wiki page. + +```bash +bl wiki shared-file link 12345 --shared-file-id 1 +bl wiki shared-file link 12345 --shared-file-id 1 --shared-file-id 2 --json +``` + +## `bl wiki shared-file unlink` + +Unlink a shared file from a wiki page. + +```bash +bl wiki shared-file unlink 12345 1 +bl wiki shared-file unlink 12345 1 --json +``` + ## `bl team list` List all teams in the space. @@ -1965,21 +2073,21 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | Command | API endpoint | Status | | --- | --- | --- | | `bl wiki list` | `GET /api/v2/wikis` | ✅ Implemented | -| `bl wiki count` | `GET /api/v2/wikis/count` | Planned | -| `bl wiki tag list` | `GET /api/v2/wikis/tags` | Planned | +| `bl wiki count` | `GET /api/v2/wikis/count` | ✅ Implemented | +| `bl wiki tag list` | `GET /api/v2/wikis/tags` | ✅ Implemented | | `bl wiki show ` | `GET /api/v2/wikis/{wikiId}` | ✅ Implemented | | `bl wiki create` | `POST /api/v2/wikis` | ✅ Implemented | | `bl wiki update ` | `PATCH /api/v2/wikis/{wikiId}` | ✅ Implemented | | `bl wiki delete ` | `DELETE /api/v2/wikis/{wikiId}` | ✅ Implemented | | `bl wiki history ` | `GET /api/v2/wikis/{wikiId}/history` | ✅ Implemented | -| `bl wiki star list ` | `GET /api/v2/wikis/{wikiId}/stars` | Planned | +| `bl wiki star list ` | `GET /api/v2/wikis/{wikiId}/stars` | ✅ Implemented | | `bl wiki attachment list ` | `GET /api/v2/wikis/{wikiId}/attachments` | ✅ Implemented | -| `bl wiki attachment add ` | `POST /api/v2/wikis/{wikiId}/attachments` | Planned | -| `bl wiki attachment get ` | `GET /api/v2/wikis/{wikiId}/attachments/{attachmentId}` | Planned | -| `bl wiki attachment delete ` | `DELETE /api/v2/wikis/{wikiId}/attachments/{attachmentId}` | Planned | -| `bl wiki shared-file list ` | `GET /api/v2/wikis/{wikiId}/sharedFiles` | Planned | -| `bl wiki shared-file link ` | `POST /api/v2/wikis/{wikiId}/sharedFiles` | Planned | -| `bl wiki shared-file unlink ` | `DELETE /api/v2/wikis/{wikiId}/sharedFiles/{id}` | Planned | +| `bl wiki attachment add ` | `POST /api/v2/wikis/{wikiId}/attachments` | ✅ Implemented | +| `bl wiki attachment get ` | `GET /api/v2/wikis/{wikiId}/attachments/{attachmentId}` | ✅ Implemented | +| `bl wiki attachment delete ` | `DELETE /api/v2/wikis/{wikiId}/attachments/{attachmentId}` | ✅ Implemented | +| `bl wiki shared-file list ` | `GET /api/v2/wikis/{wikiId}/sharedFiles` | ✅ Implemented | +| `bl wiki shared-file link ` | `POST /api/v2/wikis/{wikiId}/sharedFiles` | ✅ Implemented | +| `bl wiki shared-file unlink ` | `DELETE /api/v2/wikis/{wikiId}/sharedFiles/{id}` | ✅ Implemented | ### Shared Files diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md index 70d8a7b..dbf9ec3 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -1329,6 +1329,54 @@ v2 Home — 2024-03-15T00:00:00Z v1 Home — 2024-01-01T00:00:00Z ``` +## `bl wiki count` + +プロジェクト内の Wiki ページ数を取得します。 + +```bash +bl wiki count +bl wiki count TEST +bl wiki count TEST --json +``` + +出力例: + +```text +42 +``` + +## `bl wiki tag list` + +Wiki ページで使われているタグを一覧表示します。 + +```bash +bl wiki tag list +bl wiki tag list TEST +bl wiki tag list TEST --json +``` + +出力例: + +```text +[1] guide +[2] api +``` + +## `bl wiki star list` + +Wiki ページのスターを一覧表示します。 + +```bash +bl wiki star list 12345 +bl wiki star list 12345 --json +``` + +出力例: + +```text +[1] Home (John Doe) +``` + ## `bl wiki attachment list` Wiki ページの添付ファイルを一覧表示します。 @@ -1345,6 +1393,66 @@ bl wiki attachment list 12345 --json [2] notes.txt (1024 bytes) ``` +## `bl wiki attachment add` + +事前アップロード済みの添付ファイル ID を指定して Wiki ページに添付ファイルを追加します。 + +```bash +bl wiki attachment add 12345 --attachment-id 1 +bl wiki attachment add 12345 --attachment-id 1 --attachment-id 2 --json +``` + +## `bl wiki attachment get` + +Wiki ページの添付ファイルをダウンロードします。 + +```bash +bl wiki attachment get 12345 1 +bl wiki attachment get 12345 1 --output ./downloads/diagram.png +``` + +## `bl wiki attachment delete` + +Wiki ページから添付ファイルを削除します。 + +```bash +bl wiki attachment delete 12345 1 +bl wiki attachment delete 12345 1 --json +``` + +## `bl wiki shared-file list` + +Wiki ページにリンクされている共有ファイルを一覧表示します。 + +```bash +bl wiki shared-file list 12345 +bl wiki shared-file list 12345 --json +``` + +出力例: + +```text +[1] /docs/spec.pdf (204800 bytes) +``` + +## `bl wiki shared-file link` + +共有ファイルを Wiki ページにリンクします。 + +```bash +bl wiki shared-file link 12345 --shared-file-id 1 +bl wiki shared-file link 12345 --shared-file-id 1 --shared-file-id 2 --json +``` + +## `bl wiki shared-file unlink` + +Wiki ページから共有ファイルのリンクを解除します。 + +```bash +bl wiki shared-file unlink 12345 1 +bl wiki shared-file unlink 12345 1 --json +``` + ## `bl team list` スペース内のチームを一覧表示します。 @@ -1969,21 +2077,21 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | コマンド | API エンドポイント | 状態 | | --- | --- | --- | | `bl wiki list` | `GET /api/v2/wikis` | ✅ 実装済み | -| `bl wiki count` | `GET /api/v2/wikis/count` | 計画中 | -| `bl wiki tag list` | `GET /api/v2/wikis/tags` | 計画中 | +| `bl wiki count` | `GET /api/v2/wikis/count` | ✅ 実装済み | +| `bl wiki tag list` | `GET /api/v2/wikis/tags` | ✅ 実装済み | | `bl wiki show ` | `GET /api/v2/wikis/{wikiId}` | ✅ 実装済み | | `bl wiki create` | `POST /api/v2/wikis` | ✅ 実装済み | | `bl wiki update ` | `PATCH /api/v2/wikis/{wikiId}` | ✅ 実装済み | | `bl wiki delete ` | `DELETE /api/v2/wikis/{wikiId}` | ✅ 実装済み | | `bl wiki history ` | `GET /api/v2/wikis/{wikiId}/history` | ✅ 実装済み | -| `bl wiki star list ` | `GET /api/v2/wikis/{wikiId}/stars` | 計画中 | +| `bl wiki star list ` | `GET /api/v2/wikis/{wikiId}/stars` | ✅ 実装済み | | `bl wiki attachment list ` | `GET /api/v2/wikis/{wikiId}/attachments` | ✅ 実装済み | -| `bl wiki attachment add ` | `POST /api/v2/wikis/{wikiId}/attachments` | 計画中 | -| `bl wiki attachment get ` | `GET /api/v2/wikis/{wikiId}/attachments/{attachmentId}` | 計画中 | -| `bl wiki attachment delete ` | `DELETE /api/v2/wikis/{wikiId}/attachments/{attachmentId}` | 計画中 | -| `bl wiki shared-file list ` | `GET /api/v2/wikis/{wikiId}/sharedFiles` | 計画中 | -| `bl wiki shared-file link ` | `POST /api/v2/wikis/{wikiId}/sharedFiles` | 計画中 | -| `bl wiki shared-file unlink ` | `DELETE /api/v2/wikis/{wikiId}/sharedFiles/{id}` | 計画中 | +| `bl wiki attachment add ` | `POST /api/v2/wikis/{wikiId}/attachments` | ✅ 実装済み | +| `bl wiki attachment get ` | `GET /api/v2/wikis/{wikiId}/attachments/{attachmentId}` | ✅ 実装済み | +| `bl wiki attachment delete ` | `DELETE /api/v2/wikis/{wikiId}/attachments/{attachmentId}` | ✅ 実装済み | +| `bl wiki shared-file list ` | `GET /api/v2/wikis/{wikiId}/sharedFiles` | ✅ 実装済み | +| `bl wiki shared-file link ` | `POST /api/v2/wikis/{wikiId}/sharedFiles` | ✅ 実装済み | +| `bl wiki shared-file unlink ` | `DELETE /api/v2/wikis/{wikiId}/sharedFiles/{id}` | ✅ 実装済み | ### Shared Files From fe013bcb8dd2a781eafabeaa0ece3ec73526be95 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sat, 21 Mar 2026 17:23:33 +0900 Subject: [PATCH 2/3] fix: align link_wiki_shared_files return type to WikiSharedFile and add API tests Addresses review comment: return type inconsistency between list/link/unlink --- src/api/mod.rs | 4 +- src/api/wiki.rs | 162 ++++++++++++++++++++++++++++++- src/cmd/wiki/shared_file/link.rs | 38 ++------ 3 files changed, 168 insertions(+), 36 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 5d7d260..3509b4d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -492,7 +492,7 @@ pub trait BacklogApi { &self, _wiki_id: u64, _shared_file_ids: &[u64], - ) -> Result> { + ) -> Result> { unimplemented!() } fn unlink_wiki_shared_file( @@ -1138,7 +1138,7 @@ impl BacklogApi for BacklogClient { &self, wiki_id: u64, shared_file_ids: &[u64], - ) -> Result> { + ) -> Result> { self.link_wiki_shared_files(wiki_id, shared_file_ids) } diff --git a/src/api/wiki.rs b/src/api/wiki.rs index 3167177..8c283c1 100644 --- a/src/api/wiki.rs +++ b/src/api/wiki.rs @@ -4,7 +4,6 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use super::BacklogClient; -use crate::api::shared_file::SharedFile; use crate::api::user::Star; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -190,7 +189,7 @@ impl BacklogClient { &self, wiki_id: u64, shared_file_ids: &[u64], - ) -> Result> { + ) -> Result> { let params: Vec<(String, String)> = shared_file_ids .iter() .map(|id| ("fileId[]".to_string(), id.to_string())) @@ -380,4 +379,163 @@ mod tests { let wiki: Wiki = serde_json::from_value(v).unwrap(); assert!(wiki.created_user.user_id.is_none()); } + + fn shared_file_json() -> serde_json::Value { + json!({ + "id": 1, + "dir": "/docs", + "name": "spec.pdf", + "size": 2048_u64 + }) + } + + fn star_json() -> serde_json::Value { + json!({ + "id": 1, + "comment": null, + "url": "https://example.backlog.com/wiki/TEST/Home", + "title": "Home", + "presenter": { + "id": 1, "userId": "john", "name": "John Doe", + "roleType": 1, "mailAddress": "john@example.com" + }, + "created": "2024-01-01T00:00:00Z" + }) + } + + #[test] + fn get_wiki_count_returns_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/wikis/count") + .query_param("apiKey", TEST_KEY) + .query_param("projectIdOrKey", "TEST"); + then.status(200).json_body(json!({"count": 42})); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let result = client + .get_wiki_count(&[("projectIdOrKey".to_string(), "TEST".to_string())]) + .unwrap(); + assert_eq!(result.count, 42); + } + + #[test] + fn get_wiki_tags_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/wikis/tags") + .query_param("apiKey", TEST_KEY); + then.status(200) + .json_body(json!([{"id": 1, "name": "guide"}])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let tags = client.get_wiki_tags(&[]).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name, "guide"); + } + + #[test] + fn get_wiki_stars_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/wikis/1/stars") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([star_json()])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let stars = client.get_wiki_stars(1).unwrap(); + assert_eq!(stars.len(), 1); + assert_eq!(stars[0].title, "Home"); + } + + #[test] + fn add_wiki_attachments_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST) + .path("/wikis/1/attachments") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([attachment_json()])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let attachments = client.add_wiki_attachments(1, &[1]).unwrap(); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].name, "image.png"); + } + + #[test] + fn download_wiki_attachment_returns_bytes() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/wikis/1/attachments/1"); + then.status(200) + .header("Content-Disposition", "attachment; filename=\"image.png\"") + .body(b"binarydata"); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let (bytes, filename) = client.download_wiki_attachment(1, 1).unwrap(); + assert_eq!(bytes, b"binarydata"); + assert_eq!(filename, "image.png"); + } + + #[test] + fn delete_wiki_attachment_returns_attachment() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE) + .path("/wikis/1/attachments/1") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(attachment_json()); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let attachment = client.delete_wiki_attachment(1, 1).unwrap(); + assert_eq!(attachment.name, "image.png"); + } + + #[test] + fn get_wiki_shared_files_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/wikis/1/sharedFiles") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([shared_file_json()])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let files = client.get_wiki_shared_files(1).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "spec.pdf"); + } + + #[test] + fn link_wiki_shared_files_returns_list() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST) + .path("/wikis/1/sharedFiles") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(json!([shared_file_json()])); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let files = client.link_wiki_shared_files(1, &[1]).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "spec.pdf"); + } + + #[test] + fn unlink_wiki_shared_file_returns_file() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE) + .path("/wikis/1/sharedFiles/1") + .query_param("apiKey", TEST_KEY); + then.status(200).json_body(shared_file_json()); + }); + let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); + let file = client.unlink_wiki_shared_file(1, 1).unwrap(); + assert_eq!(file.name, "spec.pdf"); + } } diff --git a/src/cmd/wiki/shared_file/link.rs b/src/cmd/wiki/shared_file/link.rs index f9fa6dd..8701537 100644 --- a/src/cmd/wiki/shared_file/link.rs +++ b/src/cmd/wiki/shared_file/link.rs @@ -44,12 +44,12 @@ pub fn link_with(args: &WikiSharedFileLinkArgs, api: &dyn BacklogApi) -> Result< #[cfg(test)] mod tests { use super::*; - use crate::api::shared_file::SharedFile; + use crate::api::wiki::WikiSharedFile; + use crate::cmd::wiki::shared_file::list::sample_wiki_shared_file; use anyhow::anyhow; - use std::collections::BTreeMap; struct MockApi { - files: Option>, + files: Option>, } impl crate::api::BacklogApi for MockApi { @@ -57,37 +57,11 @@ mod tests { &self, _wiki_id: u64, _shared_file_ids: &[u64], - ) -> anyhow::Result> { + ) -> anyhow::Result> { self.files.clone().ok_or_else(|| anyhow!("no files")) } } - fn sample_shared_file() -> SharedFile { - use crate::api::user::User; - SharedFile { - id: 1, - project_id: 10, - file_type: "file".to_string(), - dir: "/docs".to_string(), - name: "spec.pdf".to_string(), - size: Some(2048), - created_user: User { - id: 1, - user_id: Some("john".to_string()), - name: "John Doe".to_string(), - mail_address: None, - role_type: 1, - lang: None, - last_login_time: None, - extra: BTreeMap::new(), - }, - created: "2024-01-01T00:00:00Z".to_string(), - updated_user: None, - updated: "2024-01-01T00:00:00Z".to_string(), - extra: BTreeMap::new(), - } - } - fn args(json: bool) -> WikiSharedFileLinkArgs { WikiSharedFileLinkArgs::try_new(1, vec![1], json).unwrap() } @@ -95,7 +69,7 @@ mod tests { #[test] fn link_with_text_output_succeeds() { let api = MockApi { - files: Some(vec![sample_shared_file()]), + files: Some(vec![sample_wiki_shared_file()]), }; assert!(link_with(&args(false), &api).is_ok()); } @@ -103,7 +77,7 @@ mod tests { #[test] fn link_with_json_output_succeeds() { let api = MockApi { - files: Some(vec![sample_shared_file()]), + files: Some(vec![sample_wiki_shared_file()]), }; assert!(link_with(&args(true), &api).is_ok()); } From e4712413b8c9897864686cda634b2a5c1b89e9aa Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sat, 21 Mar 2026 17:29:45 +0900 Subject: [PATCH 3/3] fix: replace binarydata with hello in test to pass spell check --- src/api/wiki.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/wiki.rs b/src/api/wiki.rs index 8c283c1..f39f7f5 100644 --- a/src/api/wiki.rs +++ b/src/api/wiki.rs @@ -473,11 +473,11 @@ mod tests { when.method(GET).path("/wikis/1/attachments/1"); then.status(200) .header("Content-Disposition", "attachment; filename=\"image.png\"") - .body(b"binarydata"); + .body(b"hello"); }); let client = super::super::BacklogClient::new_with(&server.base_url(), TEST_KEY).unwrap(); let (bytes, filename) = client.download_wiki_attachment(1, 1).unwrap(); - assert_eq!(bytes, b"binarydata"); + assert_eq!(bytes, b"hello"); assert_eq!(filename, "image.png"); }