use crate::domain::{ResolvedConfig, SyncItemResult};
use crate::infrastructure::config::resolve_confluence_credentials;
use crate::support::*;
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ConfluenceArchiveResponse {
pub(crate) id: Option<String>,
}
fn remote_eligible(item: &SyncItemResult) -> bool {
item.action == "deleted" && item.status != "superseded" && item.platform_id.is_some()
}
fn resolved_base_url(resolved: &ResolvedConfig) -> AppResult<&str> {
resolved.target.base_url.as_deref().ok_or_else(|| {
AppError::new(
"CONFIG_MISSING_BASE_URL",
format!(
"run `conpub root <dir> --base-url <url>`, bind with --base-url, or set {ENV_BASE_URL}"
),
)
})
}
fn confluence_write_credentials(action: &str) -> AppResult<(String, String)> {
let credentials = resolve_confluence_credentials()?;
match (credentials.email, credentials.api_key) {
(Some(email), Some(api_key)) => Ok((email, api_key)),
(email, api_key) => {
let mut missing = Vec::new();
if api_key.is_none() {
missing.push(format!(
"api_key ([confluence] api_key or {ENV_CONFLUENCE_API_KEY})"
));
}
if email.is_none() {
missing.push(format!(
"email ([confluence] email or {ENV_CONFLUENCE_EMAIL})"
));
}
Err(AppError::new(
"PUBLISH_CONFIG_ERROR",
format!(
"Confluence credentials missing to {action}: {}",
missing.join("; ")
),
))
}
}
}
pub(crate) fn runtime_archive_deleted(
resolved: &ResolvedConfig,
items: &mut [SyncItemResult],
) -> AppResult<bool> {
let page_ids = items
.iter()
.filter(|item| remote_eligible(item))
.filter_map(|item| item.platform_id.clone())
.collect::<Vec<_>>();
if page_ids.is_empty() {
return Ok(false);
}
let base_url = resolved_base_url(resolved)?;
let (email, api_key) = confluence_write_credentials("archive deleted pages")?;
let runtime = build_async_runtime()?;
let task_id = runtime.block_on(archive_deleted_pages(base_url, &email, &api_key, &page_ids))?;
for item in items {
if remote_eligible(item) {
item.status = "archived".to_string();
item.archive_task_id = task_id.clone();
item.reason = Some("archive request accepted by Confluence".to_string());
}
}
Ok(true)
}
pub(crate) async fn archive_deleted_pages(
base_url: &str,
email: &str,
api_key: &str,
page_ids: &[String],
) -> AppResult<Option<String>> {
let base_url = normalize_confluence_base_url(base_url);
let url = format!("{base_url}/wiki/rest/api/content/archive");
let pages = page_ids
.iter()
.map(|id| {
let id = id
.parse::<u64>()
.map(serde_json::Value::from)
.unwrap_or_else(|_| serde_json::Value::String(id.clone()));
json!({ "id": id })
})
.collect::<Vec<_>>();
let response = reqwest::Client::new()
.post(url)
.basic_auth(email, Some(api_key))
.header("Accept", "application/json")
.json(&json!({ "pages": pages }))
.send()
.await
.map_err(|err| AppError::new("ARCHIVE_REQUEST_ERROR", err.to_string()))?;
let status = response.status();
if status.as_u16() != 202 {
return Err(AppError::new(
"ARCHIVE_REQUEST_FAILED",
format!("Confluence archive request returned {status}"),
));
}
let text = response
.text()
.await
.map_err(|err| AppError::new("ARCHIVE_RESPONSE_ERROR", err.to_string()))?;
let parsed = serde_json::from_str::<ConfluenceArchiveResponse>(&text).ok();
Ok(parsed.and_then(|body| body.id))
}
pub(crate) fn runtime_delete_deleted(
resolved: &ResolvedConfig,
items: &mut [SyncItemResult],
) -> AppResult<bool> {
let page_ids = items
.iter()
.filter(|item| remote_eligible(item))
.filter_map(|item| item.platform_id.clone())
.collect::<Vec<_>>();
if page_ids.is_empty() {
return Ok(false);
}
let base_url = resolved_base_url(resolved)?;
let (email, api_key) = confluence_write_credentials("delete pages")?;
let runtime = build_async_runtime()?;
let results = runtime.block_on(delete_pages(base_url, &email, &api_key, &page_ids))?;
let by_id = results
.into_iter()
.collect::<std::collections::HashMap<_, _>>();
for item in items {
if !remote_eligible(item) {
continue;
}
let Some(id) = item.platform_id.as_ref() else {
continue;
};
match by_id.get(id) {
Some(Ok(())) => {
item.status = "deleted_remote".to_string();
item.reason = Some("Confluence page deleted".to_string());
}
Some(Err(message)) => {
item.status = "failed".to_string();
item.error = Some(message.clone());
}
None => {}
}
}
Ok(true)
}
type DeleteOutcomes = Vec<(String, Result<(), String>)>;
pub(crate) async fn delete_pages(
base_url: &str,
email: &str,
api_key: &str,
page_ids: &[String],
) -> AppResult<DeleteOutcomes> {
let base_url = normalize_confluence_base_url(base_url);
let client = reqwest::Client::new();
let mut outcomes = Vec::with_capacity(page_ids.len());
for id in page_ids {
let url = format!("{base_url}/wiki/rest/api/content/{id}");
let outcome = match client
.delete(&url)
.basic_auth(email, Some(api_key))
.send()
.await
{
Err(err) => Err(format!("delete request error: {err}")),
Ok(response) => match response.status().as_u16() {
200 | 202 | 204 => Ok(()),
404 => Ok(()),
status => Err(format!("Confluence delete returned {status}")),
},
};
outcomes.push((id.clone(), outcome));
}
Ok(outcomes)
}