use anyhow::{Context, Result};
use atlassian_cli_output::OutputFormat;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use super::utils::ConfluenceContext;
use crate::commands::common::{render_success, MutationResult};
use crate::query::UrlParamsBuilder;
pub async fn list_pages(
ctx: &ConfluenceContext<'_>,
space_key: Option<&str>,
limit: Option<usize>,
) -> Result<()> {
#[derive(Deserialize)]
struct PagesResponse {
results: Vec<Page>,
}
#[derive(Deserialize)]
struct Page {
id: String,
#[serde(default)]
title: Option<String>,
#[serde(rename = "type", default)]
page_type: Option<String>,
#[serde(default)]
status: Option<String>,
}
let query_string = {
let mut builder = UrlParamsBuilder::new();
if let Some(l) = limit {
builder = builder.add("limit", &l.to_string());
}
builder = builder.add_optional("space-key", space_key);
let params = builder.finish();
if params.is_empty() {
String::new()
} else {
format!("?{}", params)
}
};
let response: PagesResponse = ctx
.client
.get(&format!("/wiki/api/v2/pages{}", query_string))
.await
.context("Failed to list pages")?;
#[derive(Serialize)]
struct Row<'a> {
id: &'a str,
title: &'a str,
page_type: &'a str,
status: &'a str,
}
let rows: Vec<Row<'_>> = response
.results
.iter()
.map(|p| Row {
id: p.id.as_str(),
title: p.title.as_deref().unwrap_or(""),
page_type: p.page_type.as_deref().unwrap_or(""),
status: p.status.as_deref().unwrap_or(""),
})
.collect();
ctx.renderer.render(&rows)
}
pub async fn get_page(ctx: &ConfluenceContext<'_>, page_id: &str, body_only: bool) -> Result<()> {
let page: Value = ctx
.client
.get(&format!(
"/wiki/api/v2/pages/{}?body-format=storage",
page_id
))
.await
.with_context(|| format!("Failed to get page {}", page_id))?;
let format = ctx.renderer.format();
if body_only {
let body_html = page
.get("body")
.and_then(|b| b.get("storage"))
.and_then(|s| s.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("");
if format == OutputFormat::Markdown {
let md = htmd::convert(body_html).context("Failed to convert HTML to markdown")?;
println!("{}", md);
} else {
println!("{}", body_html);
}
return Ok(());
}
if format == OutputFormat::Markdown {
let title = page
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("Untitled");
let id = page.get("id").and_then(|i| i.as_str()).unwrap_or("");
let status = page.get("status").and_then(|s| s.as_str()).unwrap_or("");
let body_html = page
.get("body")
.and_then(|b| b.get("storage"))
.and_then(|s| s.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("");
let body_md = htmd::convert(body_html).context("Failed to convert HTML to markdown")?;
let md = format!(
"# {title}\n\n\
| Field | Value |\n\
| --- | --- |\n\
| ID | {id} |\n\
| Status | {status} |\n\n\
{body_md}"
);
return ctx.renderer.render_raw(&md);
}
println!("{}", serde_json::to_string_pretty(&page)?);
Ok(())
}
pub async fn create_page(
ctx: &ConfluenceContext<'_>,
space_id: &str,
title: &str,
body_file: Option<&PathBuf>,
parent_id: Option<&str>,
) -> Result<()> {
let body_content = if let Some(file) = body_file {
fs::read_to_string(file)
.with_context(|| format!("Failed to read body file: {}", file.display()))?
} else {
"<p>Page content</p>".to_string()
};
let mut payload = json!({
"spaceId": space_id,
"status": "current",
"title": title,
"body": {
"representation": "storage",
"value": body_content
}
});
if let Some(pid) = parent_id {
payload["parentId"] = json!(pid);
}
#[derive(Deserialize)]
struct CreateResponse {
id: String,
title: String,
}
let response: CreateResponse = ctx
.client
.post("/wiki/api/v2/pages", &payload)
.await
.context("Failed to create page")?;
tracing::info!(id = %response.id, title = %response.title, "Page created successfully");
render_success(
ctx.renderer,
&format!("✅ Created page: {} (ID: {})", response.title, response.id),
&MutationResult::with_id(format!("Created page: {}", response.title), &response.id),
)
}
pub async fn update_page(
ctx: &ConfluenceContext<'_>,
page_id: &str,
title: Option<&str>,
body_file: Option<&PathBuf>,
target_status: Option<&str>,
message: Option<&str>,
) -> Result<()> {
let current: Value = ctx
.client
.get(&format!("/wiki/api/v2/pages/{}", page_id))
.await
.with_context(|| format!("Failed to get page {}", page_id))?;
let current_version = current
.get("version")
.and_then(|v| v.get("number"))
.and_then(|n| n.as_i64())
.unwrap_or(1);
let current_status = current
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("current");
let target_status = target_status.unwrap_or(current_status);
let new_version = match (current_status, target_status) {
("draft", "current") => {
tracing::info!(%page_id, "Publishing draft page with version 1");
1 }
("draft", "draft") => {
tracing::debug!(%page_id, version = %current_version, "Updating draft, keeping version");
current_version }
("current", "current") => {
tracing::debug!(%page_id, version = %(current_version + 1), "Updating published page, incrementing version");
current_version + 1 }
("current", "draft") => {
anyhow::bail!(
"Cannot change published page back to draft status. Page {} is already published.",
page_id
);
}
_ => current_version + 1,
};
let mut version_obj = json!({ "number": new_version });
if let Some(msg) = message {
version_obj["message"] = json!(msg);
}
let mut payload = json!({
"id": page_id,
"status": target_status,
"version": version_obj
});
if let Some(t) = title {
payload["title"] = json!(t);
} else {
payload["title"] = current.get("title").cloned().unwrap_or(json!("Untitled"));
}
if let Some(file) = body_file {
let body_content = fs::read_to_string(file)
.with_context(|| format!("Failed to read body file: {}", file.display()))?;
payload["body"] = json!({
"representation": "storage",
"value": body_content
});
}
let _: Value = ctx
.client
.put(&format!("/wiki/api/v2/pages/{}", page_id), &payload)
.await
.with_context(|| format!("Failed to update page {}", page_id))?;
tracing::info!(%page_id, status = %target_status, version = %new_version, "Page updated successfully");
render_success(
ctx.renderer,
&format!("✅ Updated page: {page_id} (v{new_version}, status: {target_status})"),
&MutationResult::with_id(format!("Updated page: {page_id}"), page_id),
)
}
pub async fn publish_page(
ctx: &ConfluenceContext<'_>,
page_id: &str,
title: Option<&str>,
body_file: &PathBuf,
message: Option<&str>,
) -> Result<()> {
let current: Value = ctx
.client
.get(&format!("/wiki/api/v2/pages/{}", page_id))
.await
.with_context(|| format!("Failed to get page {}", page_id))?;
let current_status = current
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("current");
if current_status != "draft" {
anyhow::bail!(
"Page {} is already published (status: '{}').\nUse 'confluence page update' to update published pages.",
page_id,
current_status
);
}
let body_content = fs::read_to_string(body_file)
.with_context(|| format!("Failed to read body file: {}", body_file.display()))?;
let page_title = title
.map(|t| t.to_string())
.or_else(|| {
current
.get("title")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "Untitled".to_string());
let mut version_obj = json!({ "number": 1 });
if let Some(msg) = message {
version_obj["message"] = json!(msg);
} else {
version_obj["message"] = json!("Published via CLI");
}
let payload = json!({
"id": page_id,
"status": "current",
"title": page_title,
"version": version_obj,
"body": {
"representation": "storage",
"value": body_content
}
});
tracing::info!(%page_id, title = %page_title, "Publishing draft page");
let _: Value = ctx
.client
.put(&format!("/wiki/api/v2/pages/{}", page_id), &payload)
.await
.with_context(|| format!("Failed to publish page {}", page_id))?;
tracing::info!(%page_id, "Draft page published successfully");
render_success(
ctx.renderer,
&format!("✅ Published page: {} (ID: {})", page_title, page_id),
&MutationResult::with_id(format!("Published page: {}", page_title), page_id),
)
}
pub async fn delete_page(ctx: &ConfluenceContext<'_>, page_id: &str, force: bool) -> Result<()> {
if !force {
println!(
"⚠️ This will permanently delete page {}. Use --force to confirm.",
page_id
);
return Ok(());
}
let _: Value = ctx
.client
.delete(&format!("/wiki/api/v2/pages/{}", page_id))
.await
.with_context(|| format!("Failed to delete page {}", page_id))?;
tracing::info!(%page_id, "Page deleted successfully");
render_success(
ctx.renderer,
&format!("✅ Deleted page: {page_id}"),
&MutationResult::with_id(format!("Deleted page: {page_id}"), page_id),
)
}
pub async fn list_page_versions(ctx: &ConfluenceContext<'_>, page_id: &str) -> Result<()> {
#[derive(Deserialize)]
struct VersionsResponse {
results: Vec<PageVersion>,
}
#[derive(Deserialize)]
struct PageVersion {
number: i64,
message: Option<String>,
#[serde(rename = "createdAt")]
created_at: String,
}
let response: VersionsResponse = ctx
.client
.get(&format!("/wiki/api/v2/pages/{}/versions", page_id))
.await
.with_context(|| format!("Failed to list versions for page {}", page_id))?;
#[derive(Serialize)]
struct Row<'a> {
number: i64,
message: &'a str,
created_at: &'a str,
}
let rows: Vec<Row<'_>> = response
.results
.iter()
.map(|v| Row {
number: v.number,
message: v.message.as_deref().unwrap_or(""),
created_at: v.created_at.as_str(),
})
.collect();
ctx.renderer.render(&rows)
}
pub async fn add_page_label(ctx: &ConfluenceContext<'_>, page_id: &str, label: &str) -> Result<()> {
let payload = json!([{
"prefix": "global",
"name": label
}]);
let _: Value = ctx
.client
.post(
&format!("/wiki/rest/api/content/{}/label", page_id),
&payload,
)
.await
.with_context(|| format!("Failed to add label to page {}", page_id))?;
tracing::info!(%page_id, %label, "Label added successfully");
render_success(
ctx.renderer,
&format!("✅ Added label '{label}' to page {page_id}"),
&MutationResult::with_id(format!("Added label '{label}' to page {page_id}"), page_id),
)
}
pub async fn remove_page_label(
ctx: &ConfluenceContext<'_>,
page_id: &str,
label: &str,
) -> Result<()> {
let query_string = UrlParamsBuilder::new().add("name", label).finish();
let _: Value = ctx
.client
.delete(&format!(
"/wiki/rest/api/content/{}/label?{}",
page_id, query_string
))
.await
.with_context(|| format!("Failed to remove label from page {}", page_id))?;
tracing::info!(%page_id, %label, "Label removed successfully");
render_success(
ctx.renderer,
&format!("✅ Removed label '{label}' from page {page_id}"),
&MutationResult::with_id(
format!("Removed label '{label}' from page {page_id}"),
page_id,
),
)
}
pub async fn list_page_comments(ctx: &ConfluenceContext<'_>, page_id: &str) -> Result<()> {
#[derive(Deserialize)]
struct CommentsResponse {
results: Vec<Comment>,
}
#[derive(Deserialize)]
struct Comment {
id: String,
title: String,
#[serde(rename = "createdAt")]
created_at: String,
}
let response: CommentsResponse = ctx
.client
.get(&format!("/wiki/api/v2/pages/{}/footer-comments", page_id))
.await
.with_context(|| format!("Failed to list comments for page {}", page_id))?;
#[derive(Serialize)]
struct Row<'a> {
id: &'a str,
title: &'a str,
created_at: &'a str,
}
let rows: Vec<Row<'_>> = response
.results
.iter()
.map(|c| Row {
id: c.id.as_str(),
title: c.title.as_str(),
created_at: c.created_at.as_str(),
})
.collect();
ctx.renderer.render(&rows)
}
pub async fn add_page_comment(
ctx: &ConfluenceContext<'_>,
page_id: &str,
comment: &str,
) -> Result<()> {
let payload = json!({
"pageId": page_id,
"status": "current",
"body": {
"representation": "storage",
"value": format!("<p>{}</p>", comment)
}
});
#[derive(Deserialize)]
struct CreateResponse {
id: String,
}
let response: CreateResponse = ctx
.client
.post("/wiki/api/v2/footer-comments", &payload)
.await
.with_context(|| format!("Failed to add comment to page {}", page_id))?;
tracing::info!(page_id = %page_id, comment_id = %response.id, "Comment added successfully");
render_success(
ctx.renderer,
&format!("✅ Added comment to page {page_id} (ID: {})", response.id),
&MutationResult::with_id(format!("Added comment to page {page_id}"), &response.id),
)
}
pub async fn get_page_restrictions(ctx: &ConfluenceContext<'_>, page_id: &str) -> Result<()> {
let restrictions: Value = ctx
.client
.get(&format!("/wiki/rest/api/content/{}/restriction", page_id))
.await
.with_context(|| format!("Failed to get restrictions for page {}", page_id))?;
println!("{}", serde_json::to_string_pretty(&restrictions)?);
Ok(())
}
pub async fn add_page_restriction(
ctx: &ConfluenceContext<'_>,
page_id: &str,
operation: &str,
subject_type: &str,
subject_id: &str,
) -> Result<()> {
let payload = json!({
"operation": operation,
"restrictions": {
subject_type: [{
"type": subject_type,
"identifier": subject_id
}]
}
});
let _: Value = ctx
.client
.post(
&format!("/wiki/rest/api/content/{}/restriction", page_id),
&payload,
)
.await
.with_context(|| format!("Failed to add restriction to page {}", page_id))?;
tracing::info!(%page_id, %operation, %subject_id, "Restriction added successfully");
println!(
"✅ Added {} restriction for {} to page {}",
operation, subject_id, page_id
);
Ok(())
}
pub async fn remove_page_restriction(
ctx: &ConfluenceContext<'_>,
page_id: &str,
operation: &str,
subject_type: &str,
subject_id: &str,
) -> Result<()> {
let query_string = UrlParamsBuilder::new()
.add("operation", operation)
.add(&format!("{}.identifier", subject_type), subject_id)
.finish();
let _: Value = ctx
.client
.delete(&format!(
"/wiki/rest/api/content/{}/restriction?{}",
page_id, query_string
))
.await
.with_context(|| format!("Failed to remove restriction from page {}", page_id))?;
tracing::info!(%page_id, %operation, %subject_id, "Restriction removed successfully");
println!(
"✅ Removed {} restriction for {} from page {}",
operation, subject_id, page_id
);
Ok(())
}
pub async fn list_blogposts(
ctx: &ConfluenceContext<'_>,
space_id: Option<&str>,
limit: Option<usize>,
) -> Result<()> {
#[derive(Deserialize)]
struct BlogpostsResponse {
results: Vec<Blogpost>,
}
#[derive(Deserialize)]
struct Blogpost {
id: String,
title: String,
status: String,
}
let query_string = {
let mut builder = UrlParamsBuilder::new();
if let Some(l) = limit {
builder = builder.add("limit", &l.to_string());
}
builder = builder.add_optional("space-id", space_id);
let params = builder.finish();
if params.is_empty() {
String::new()
} else {
format!("?{}", params)
}
};
let response: BlogpostsResponse = ctx
.client
.get(&format!("/wiki/api/v2/blogposts{}", query_string))
.await
.context("Failed to list blog posts")?;
#[derive(Serialize)]
struct Row<'a> {
id: &'a str,
title: &'a str,
status: &'a str,
}
let rows: Vec<Row<'_>> = response
.results
.iter()
.map(|b| Row {
id: b.id.as_str(),
title: b.title.as_str(),
status: b.status.as_str(),
})
.collect();
ctx.renderer.render(&rows)
}
pub async fn get_blogpost(
ctx: &ConfluenceContext<'_>,
blogpost_id: &str,
body_only: bool,
) -> Result<()> {
let blogpost: Value = ctx
.client
.get(&format!(
"/wiki/api/v2/blogposts/{}?body-format=storage",
blogpost_id
))
.await
.with_context(|| format!("Failed to get blog post {}", blogpost_id))?;
let format = ctx.renderer.format();
if body_only {
let body_html = blogpost
.get("body")
.and_then(|b| b.get("storage"))
.and_then(|s| s.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("");
if format == OutputFormat::Markdown {
let md = htmd::convert(body_html).context("Failed to convert HTML to markdown")?;
println!("{}", md);
} else {
println!("{}", body_html);
}
return Ok(());
}
if format == OutputFormat::Markdown {
let title = blogpost
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("Untitled");
let id = blogpost.get("id").and_then(|i| i.as_str()).unwrap_or("");
let status = blogpost
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("");
let body_html = blogpost
.get("body")
.and_then(|b| b.get("storage"))
.and_then(|s| s.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("");
let body_md = htmd::convert(body_html).context("Failed to convert HTML to markdown")?;
let md = format!(
"# {title}\n\n\
| Field | Value |\n\
| --- | --- |\n\
| ID | {id} |\n\
| Status | {status} |\n\n\
{body_md}"
);
return ctx.renderer.render_raw(&md);
}
println!("{}", serde_json::to_string_pretty(&blogpost)?);
Ok(())
}
pub async fn create_blog(
ctx: &ConfluenceContext<'_>,
space_id: &str,
title: &str,
body_file: Option<&PathBuf>,
) -> Result<()> {
let body_content = if let Some(file) = body_file {
fs::read_to_string(file)
.with_context(|| format!("Failed to read body file: {}", file.display()))?
} else {
"<p>Blog post content</p>".to_string()
};
let payload = json!({
"spaceId": space_id,
"status": "current",
"title": title,
"type": "blogpost",
"body": {
"representation": "storage",
"value": body_content
}
});
#[derive(Deserialize)]
struct CreateResponse {
id: String,
title: String,
}
let response: CreateResponse = ctx
.client
.post("/wiki/api/v2/blogposts", &payload)
.await
.context("Failed to create blog post")?;
tracing::info!(id = %response.id, title = %response.title, "Blog post created successfully");
println!(
"✅ Created blog post: {} (ID: {})",
response.title, response.id
);
Ok(())
}
pub async fn update_blogpost(
ctx: &ConfluenceContext<'_>,
blogpost_id: &str,
title: Option<&str>,
body_file: Option<&PathBuf>,
target_status: Option<&str>,
message: Option<&str>,
) -> Result<()> {
let current: Value = ctx
.client
.get(&format!("/wiki/api/v2/blogposts/{}", blogpost_id))
.await
.with_context(|| format!("Failed to get blog post {}", blogpost_id))?;
let current_version = current
.get("version")
.and_then(|v| v.get("number"))
.and_then(|n| n.as_i64())
.unwrap_or(1);
let current_status = current
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("current");
let target_status = target_status.unwrap_or(current_status);
let new_version = match (current_status, target_status) {
("draft", "current") => {
tracing::info!(%blogpost_id, "Publishing draft blog post with version 1");
1 }
("draft", "draft") => {
tracing::debug!(%blogpost_id, version = %current_version, "Updating draft, keeping version");
current_version }
("current", "current") => {
tracing::debug!(%blogpost_id, version = %(current_version + 1), "Updating published blog post, incrementing version");
current_version + 1 }
("current", "draft") => {
anyhow::bail!(
"Cannot change published blog post back to draft status. Blog post {} is already published.",
blogpost_id
);
}
_ => current_version + 1,
};
let mut version_obj = json!({ "number": new_version });
if let Some(msg) = message {
version_obj["message"] = json!(msg);
}
let mut payload = json!({
"id": blogpost_id,
"status": target_status,
"version": version_obj
});
if let Some(t) = title {
payload["title"] = json!(t);
} else {
payload["title"] = current.get("title").cloned().unwrap_or(json!("Untitled"));
}
if let Some(file) = body_file {
let body_content = fs::read_to_string(file)
.with_context(|| format!("Failed to read body file: {}", file.display()))?;
payload["body"] = json!({
"representation": "storage",
"value": body_content
});
}
let _: Value = ctx
.client
.put(&format!("/wiki/api/v2/blogposts/{}", blogpost_id), &payload)
.await
.with_context(|| format!("Failed to update blog post {}", blogpost_id))?;
tracing::info!(%blogpost_id, status = %target_status, version = %new_version, "Blog post updated successfully");
render_success(
ctx.renderer,
&format!("✅ Updated blog post: {blogpost_id} (v{new_version}, status: {target_status})"),
&MutationResult::with_id(format!("Updated blog post: {blogpost_id}"), blogpost_id),
)
}
pub async fn publish_blogpost(
ctx: &ConfluenceContext<'_>,
blogpost_id: &str,
title: Option<&str>,
body_file: &PathBuf,
message: Option<&str>,
) -> Result<()> {
let current: Value = ctx
.client
.get(&format!("/wiki/api/v2/blogposts/{}", blogpost_id))
.await
.with_context(|| format!("Failed to get blog post {}", blogpost_id))?;
let current_status = current
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("current");
if current_status != "draft" {
anyhow::bail!(
"Blog post {} is already published (status: '{}').\nUse 'confluence blog update' to update published blog posts.",
blogpost_id,
current_status
);
}
let body_content = fs::read_to_string(body_file)
.with_context(|| format!("Failed to read body file: {}", body_file.display()))?;
let blogpost_title = title
.map(|t| t.to_string())
.or_else(|| {
current
.get("title")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "Untitled".to_string());
let mut version_obj = json!({ "number": 1 });
if let Some(msg) = message {
version_obj["message"] = json!(msg);
} else {
version_obj["message"] = json!("Published via CLI");
}
let payload = json!({
"id": blogpost_id,
"status": "current",
"title": blogpost_title,
"version": version_obj,
"body": {
"representation": "storage",
"value": body_content
}
});
tracing::info!(%blogpost_id, title = %blogpost_title, "Publishing draft blog post");
let _: Value = ctx
.client
.put(&format!("/wiki/api/v2/blogposts/{}", blogpost_id), &payload)
.await
.with_context(|| format!("Failed to publish blog post {}", blogpost_id))?;
tracing::info!(%blogpost_id, "Draft blog post published successfully");
render_success(
ctx.renderer,
&format!(
"✅ Published blog post: {} (ID: {})",
blogpost_title, blogpost_id
),
&MutationResult::with_id(
format!("Published blog post: {}", blogpost_title),
blogpost_id,
),
)
}
pub async fn delete_blogpost(
ctx: &ConfluenceContext<'_>,
blogpost_id: &str,
force: bool,
) -> Result<()> {
if !force {
println!(
"⚠️ This will permanently delete blog post {}. Use --force to confirm.",
blogpost_id
);
return Ok(());
}
let _: Value = ctx
.client
.delete(&format!("/wiki/api/v2/blogposts/{}", blogpost_id))
.await
.with_context(|| format!("Failed to delete blog post {}", blogpost_id))?;
tracing::info!(%blogpost_id, "Blog post deleted successfully");
render_success(
ctx.renderer,
&format!("✅ Deleted blog post: {blogpost_id}"),
&MutationResult::with_id(format!("Deleted blog post: {blogpost_id}"), blogpost_id),
)
}