use std::path::PathBuf;
use chrono::Utc;
use prost_types::value::Kind;
use tonic::Request;
use crate::anytype::rpc::object::list_export::Request as ObjectListExportRequest;
use crate::anytype::rpc::object::show::Request as ObjectShowRequest;
use crate::auth::with_token;
use crate::client::AnytypeGrpcClient;
use crate::deadline::{
GrpcCallOptions, GrpcDeadlineError, GrpcTimeoutClass, GrpcTimeoutOutcome,
with_grpc_call_options,
};
pub use crate::error::BackupError;
pub use crate::model::export::Format as ExportFormat;
#[derive(Debug, Clone)]
pub struct SpaceBackupOptions {
pub space_id: String,
pub backup_dir: PathBuf,
pub filename_prefix: String,
pub object_ids: Vec<String>,
pub format: ExportFormat,
pub zip: bool,
pub include_nested: bool,
pub include_files: bool,
pub is_json: bool,
pub include_archived: bool,
pub no_progress: bool,
pub include_backlinks: bool,
pub include_space: bool,
pub md_include_properties_and_schema: bool,
}
impl SpaceBackupOptions {
pub fn new(space_id: impl Into<String>) -> Self {
Self {
space_id: space_id.into(),
backup_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
filename_prefix: "backup".to_string(),
object_ids: Vec::new(),
format: ExportFormat::Protobuf,
zip: true,
include_nested: true,
include_files: true,
is_json: false,
include_archived: false,
no_progress: false,
include_backlinks: false,
include_space: false,
md_include_properties_and_schema: true,
}
}
}
#[derive(Debug, Clone)]
pub struct SpaceBackupResult {
pub output_path: PathBuf,
pub server_path: PathBuf,
pub exported: i32,
pub generated_name: String,
}
impl AnytypeGrpcClient {
pub async fn backup_space(
&self,
options: SpaceBackupOptions,
) -> Result<SpaceBackupResult, BackupError> {
if options.space_id.trim().is_empty() {
return Err(BackupError::InvalidOptions {
message: "space_id is required".to_string(),
});
}
std::fs::create_dir_all(&options.backup_dir).map_err(|source| BackupError::BackupIo {
path: options.backup_dir.clone(),
source,
})?;
let space_name = self
.lookup_space_name(&options.space_id)
.await
.unwrap_or_else(|_| options.space_id.clone());
let mut commands = self.client_commands();
let request = ObjectListExportRequest {
space_id: options.space_id.clone(),
path: options.backup_dir.to_string_lossy().to_string(),
object_ids: options.object_ids.clone(),
format: options.format as i32,
zip: options.zip,
include_nested: options.include_nested,
include_files: options.include_files,
is_json: options.is_json,
include_archived: options.include_archived,
no_progress: options.no_progress,
links_state_filters: None,
include_backlinks: options.include_backlinks,
include_space: options.include_space,
md_include_properties_and_schema: options.md_include_properties_and_schema,
};
let request = with_token(Request::new(request), self.token())?;
let request = with_grpc_call_options(request, GrpcCallOptions::long_read());
let started = std::time::Instant::now();
let response = commands
.object_list_export(request)
.await
.map_err(|status| {
backup_deadline_or_status(
status,
GrpcTimeoutClass::LongUnary,
GrpcTimeoutOutcome::ReadAborted,
started.elapsed(),
)
})?
.into_inner();
if let Some(error) = response.error
&& error.code != 0
{
return Err(BackupError::BackupApiResponse {
code: error.code,
description: error.description,
});
}
if response.path.trim().is_empty() {
return Err(BackupError::MissingExportPath);
}
let server_path = PathBuf::from(&response.path);
let source_path = if server_path.is_absolute() {
server_path.clone()
} else {
options.backup_dir.join(server_path.clone())
};
let generated_name =
generated_target_name(&options.filename_prefix, &space_name, options.zip);
let target_path = options.backup_dir.join(&generated_name);
if source_path != target_path {
std::fs::rename(&source_path, &target_path).map_err(|source| {
BackupError::BackupMove {
from: source_path.clone(),
to: target_path.clone(),
source,
}
})?;
}
Ok(SpaceBackupResult {
output_path: target_path,
server_path,
exported: response.succeed,
generated_name,
})
}
async fn lookup_space_name(&self, space_id: &str) -> Result<String, BackupError> {
let mut commands = self.client_commands();
let request = ObjectShowRequest {
object_id: space_id.to_string(),
space_id: space_id.to_string(),
include_relations_as_dependent_objects: false,
..Default::default()
};
let request = with_token(Request::new(request), self.token())?;
let request = with_grpc_call_options(request, GrpcCallOptions::ordinary_read());
let started = std::time::Instant::now();
let response = commands
.object_show(request)
.await
.map_err(|status| {
backup_deadline_or_status(
status,
GrpcTimeoutClass::OrdinaryUnary,
GrpcTimeoutOutcome::ReadAborted,
started.elapsed(),
)
})?
.into_inner();
if let Some(error) = response.error
&& error.code != 0
{
return Err(BackupError::SpaceNameLookup {
space_id: space_id.to_string(),
message: format!(
"ObjectShow failed: {} (code {})",
error.description, error.code
),
});
}
let object_view = response
.object_view
.ok_or_else(|| BackupError::SpaceNameLookup {
space_id: space_id.to_string(),
message: "missing object_view".to_string(),
})?;
let name = object_view
.details
.iter()
.filter_map(|set| set.details.as_ref())
.find_map(|details| {
details
.fields
.get("name")
.and_then(|value| match &value.kind {
Some(Kind::StringValue(name)) if !name.trim().is_empty() => {
Some(name.trim().to_string())
}
_ => None,
})
})
.ok_or_else(|| BackupError::SpaceNameLookup {
space_id: space_id.to_string(),
message: "space object has no non-empty name".to_string(),
})?;
Ok(name)
}
}
fn backup_deadline_or_status(
status: tonic::Status,
class: GrpcTimeoutClass,
outcome: GrpcTimeoutOutcome,
elapsed: std::time::Duration,
) -> BackupError {
GrpcDeadlineError::from_status(&status, class, outcome, elapsed).map_or_else(
|| BackupError::BackupRpc { source: status },
|source| BackupError::Deadline { source },
)
}
fn generated_target_name(prefix: &str, space_name: &str, zip: bool) -> String {
let ts = Utc::now().format("%Y%m%d-%H%M%S");
let prefix = sanitize_path_component(prefix);
let space_name = sanitize_path_component(space_name);
let base = if prefix.is_empty() {
format!("{space_name}_{ts}")
} else {
format!("{prefix}_{space_name}_{ts}")
};
if zip { format!("{base}.zip") } else { base }
}
fn sanitize_path_component(input: &str) -> String {
const SEP: char = '_';
let mut out = String::with_capacity(input.len());
let mut prev_sep = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_sep = false;
} else if !prev_sep {
out.push(SEP);
prev_sep = true;
}
}
let trimmed = out.trim_matches(SEP).to_string();
if trimmed.is_empty() {
"space".to_string()
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_component() {
assert_eq!(sanitize_path_component("My Space"), "my_space");
assert_eq!(sanitize_path_component(" $$$ "), "space");
assert_eq!(sanitize_path_component("a/b\\c"), "a_b_c");
}
#[test]
fn target_name_has_zip_when_requested() {
let name = generated_target_name("backup", "My Space", true);
assert!(name.starts_with("backup_my_space_"));
assert!(name.ends_with(".zip"));
}
}