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