1use std::path::PathBuf;
7
8use chrono::Utc;
9use prost_types::value::Kind;
10use tonic::Request;
11
12use crate::anytype::rpc::object::list_export::Request as ObjectListExportRequest;
13use crate::anytype::rpc::object::show::Request as ObjectShowRequest;
14use crate::auth::with_token;
15use crate::client::AnytypeGrpcClient;
16use crate::deadline::{
17 GrpcCallOptions, GrpcDeadlineError, GrpcTimeoutClass, GrpcTimeoutOutcome,
18 with_grpc_call_options,
19};
20pub use crate::error::BackupError;
21pub use crate::model::export::Format as ExportFormat;
22
23#[derive(Debug, Clone)]
25pub struct SpaceBackupOptions {
26 pub space_id: String,
28 pub backup_dir: PathBuf,
30 pub filename_prefix: String,
32 pub object_ids: Vec<String>,
34 pub format: ExportFormat,
36 pub zip: bool,
38 pub include_nested: bool,
40 pub include_files: bool,
42 pub is_json: bool,
44 pub include_archived: bool,
46 pub no_progress: bool,
48 pub include_backlinks: bool,
50 pub include_space: bool,
52 pub md_include_properties_and_schema: bool,
54}
55
56impl SpaceBackupOptions {
57 pub fn new(space_id: impl Into<String>) -> Self {
59 Self {
60 space_id: space_id.into(),
61 backup_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
62 filename_prefix: "backup".to_string(),
63 object_ids: Vec::new(),
64 format: ExportFormat::Protobuf,
65 zip: true,
66 include_nested: true,
67 include_files: true,
68 is_json: false,
69 include_archived: false,
70 no_progress: false,
71 include_backlinks: false,
72 include_space: false,
73 md_include_properties_and_schema: true,
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct SpaceBackupResult {
81 pub output_path: PathBuf,
83 pub server_path: PathBuf,
85 pub exported: i32,
87 pub generated_name: String,
89}
90
91impl AnytypeGrpcClient {
92 pub async fn backup_space(
95 &self,
96 options: SpaceBackupOptions,
97 ) -> Result<SpaceBackupResult, BackupError> {
98 if options.space_id.trim().is_empty() {
99 return Err(BackupError::InvalidOptions {
100 message: "space_id is required".to_string(),
101 });
102 }
103
104 std::fs::create_dir_all(&options.backup_dir).map_err(|source| BackupError::BackupIo {
105 path: options.backup_dir.clone(),
106 source,
107 })?;
108
109 let space_name = self
112 .lookup_space_name(&options.space_id)
113 .await
114 .unwrap_or_else(|_| options.space_id.clone());
115
116 let mut commands = self.client_commands();
117 let request = ObjectListExportRequest {
118 space_id: options.space_id.clone(),
119 path: options.backup_dir.to_string_lossy().to_string(),
120 object_ids: options.object_ids.clone(),
121 format: options.format as i32,
122 zip: options.zip,
123 include_nested: options.include_nested,
124 include_files: options.include_files,
125 is_json: options.is_json,
126 include_archived: options.include_archived,
127 no_progress: options.no_progress,
128 links_state_filters: None,
129 include_backlinks: options.include_backlinks,
130 include_space: options.include_space,
131 md_include_properties_and_schema: options.md_include_properties_and_schema,
132 };
133 let request = with_token(Request::new(request), self.token())?;
134 let request = with_grpc_call_options(request, GrpcCallOptions::long_read());
135 let started = std::time::Instant::now();
136 let response = commands
137 .object_list_export(request)
138 .await
139 .map_err(|status| {
140 backup_deadline_or_status(
141 status,
142 GrpcTimeoutClass::LongUnary,
143 GrpcTimeoutOutcome::ReadAborted,
144 started.elapsed(),
145 )
146 })?
147 .into_inner();
148
149 if let Some(error) = response.error
150 && error.code != 0
151 {
152 return Err(BackupError::BackupApiResponse {
153 code: error.code,
154 description: error.description,
155 });
156 }
157
158 if response.path.trim().is_empty() {
159 return Err(BackupError::MissingExportPath);
160 }
161
162 let server_path = PathBuf::from(&response.path);
163 let source_path = if server_path.is_absolute() {
164 server_path.clone()
165 } else {
166 options.backup_dir.join(server_path.clone())
167 };
168 let generated_name =
169 generated_target_name(&options.filename_prefix, &space_name, options.zip);
170 let target_path = options.backup_dir.join(&generated_name);
171
172 if source_path != target_path {
173 std::fs::rename(&source_path, &target_path).map_err(|source| {
174 BackupError::BackupMove {
175 from: source_path.clone(),
176 to: target_path.clone(),
177 source,
178 }
179 })?;
180 }
181
182 Ok(SpaceBackupResult {
183 output_path: target_path,
184 server_path,
185 exported: response.succeed,
186 generated_name,
187 })
188 }
189
190 async fn lookup_space_name(&self, space_id: &str) -> Result<String, BackupError> {
191 let mut commands = self.client_commands();
192 let request = ObjectShowRequest {
193 object_id: space_id.to_string(),
194 space_id: space_id.to_string(),
195 include_relations_as_dependent_objects: false,
196 ..Default::default()
197 };
198 let request = with_token(Request::new(request), self.token())?;
199 let request = with_grpc_call_options(request, GrpcCallOptions::ordinary_read());
200 let started = std::time::Instant::now();
201 let response = commands
202 .object_show(request)
203 .await
204 .map_err(|status| {
205 backup_deadline_or_status(
206 status,
207 GrpcTimeoutClass::OrdinaryUnary,
208 GrpcTimeoutOutcome::ReadAborted,
209 started.elapsed(),
210 )
211 })?
212 .into_inner();
213
214 if let Some(error) = response.error
215 && error.code != 0
216 {
217 return Err(BackupError::SpaceNameLookup {
218 space_id: space_id.to_string(),
219 message: format!(
220 "ObjectShow failed: {} (code {})",
221 error.description, error.code
222 ),
223 });
224 }
225
226 let object_view = response
227 .object_view
228 .ok_or_else(|| BackupError::SpaceNameLookup {
229 space_id: space_id.to_string(),
230 message: "missing object_view".to_string(),
231 })?;
232
233 let name = object_view
234 .details
235 .iter()
236 .filter_map(|set| set.details.as_ref())
237 .find_map(|details| {
238 details
239 .fields
240 .get("name")
241 .and_then(|value| match &value.kind {
242 Some(Kind::StringValue(name)) if !name.trim().is_empty() => {
243 Some(name.trim().to_string())
244 }
245 _ => None,
246 })
247 })
248 .ok_or_else(|| BackupError::SpaceNameLookup {
249 space_id: space_id.to_string(),
250 message: "space object has no non-empty name".to_string(),
251 })?;
252
253 Ok(name)
254 }
255}
256
257fn backup_deadline_or_status(
258 status: tonic::Status,
259 class: GrpcTimeoutClass,
260 outcome: GrpcTimeoutOutcome,
261 elapsed: std::time::Duration,
262) -> BackupError {
263 GrpcDeadlineError::from_status(&status, class, outcome, elapsed).map_or_else(
264 || BackupError::BackupRpc { source: status },
265 |source| BackupError::Deadline { source },
266 )
267}
268
269fn generated_target_name(prefix: &str, space_name: &str, zip: bool) -> String {
270 let ts = Utc::now().format("%Y%m%d-%H%M%S");
271 let prefix = sanitize_path_component(prefix);
272 let space_name = sanitize_path_component(space_name);
273 let base = if prefix.is_empty() {
274 format!("{space_name}_{ts}")
275 } else {
276 format!("{prefix}_{space_name}_{ts}")
277 };
278 if zip { format!("{base}.zip") } else { base }
279}
280
281fn sanitize_path_component(input: &str) -> String {
282 const SEP: char = '_';
283 let mut out = String::with_capacity(input.len());
284 let mut prev_sep = false;
285 for ch in input.chars() {
286 if ch.is_ascii_alphanumeric() {
287 out.push(ch.to_ascii_lowercase());
288 prev_sep = false;
289 } else if !prev_sep {
290 out.push(SEP);
291 prev_sep = true;
292 }
293 }
294 let trimmed = out.trim_matches(SEP).to_string();
295 if trimmed.is_empty() {
296 "space".to_string()
297 } else {
298 trimmed
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn sanitize_component() {
308 assert_eq!(sanitize_path_component("My Space"), "my_space");
309 assert_eq!(sanitize_path_component(" $$$ "), "space");
310 assert_eq!(sanitize_path_component("a/b\\c"), "a_b_c");
311 }
312
313 #[test]
314 fn target_name_has_zip_when_requested() {
315 let name = generated_target_name("backup", "My Space", true);
316 assert!(name.starts_with("backup_my_space_"));
317 assert!(name.ends_with(".zip"));
318 }
319}