Skip to main content

anytype_rpc/
backup.rs

1//! Space backup helpers based on ObjectListExport.
2//!
3//! Note: when `zip` is true, compression is performed by the Anytype server.
4//! This helper does not re-compress backup output locally.
5
6use 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/// Options for a space backup request.
24#[derive(Debug, Clone)]
25pub struct SpaceBackupOptions {
26    /// Target space ID.
27    pub space_id: String,
28    /// Destination folder for backup output.
29    pub backup_dir: PathBuf,
30    /// Prefix used in generated target name.
31    pub filename_prefix: String,
32    /// Object IDs to export. Empty means full space export.
33    pub object_ids: Vec<String>,
34    /// Export format.
35    pub format: ExportFormat,
36    /// Ask server to produce a zip archive.
37    pub zip: bool,
38    /// Include linked objects.
39    pub include_nested: bool,
40    /// Include attached files.
41    pub include_files: bool,
42    /// For protobuf export, produce JSON payload format.
43    pub is_json: bool,
44    /// Include archived objects (default false).
45    pub include_archived: bool,
46    /// Disable export progress events.
47    pub no_progress: bool,
48    /// Include backlinks.
49    pub include_backlinks: bool,
50    /// Include space metadata.
51    pub include_space: bool,
52    /// Include properties frontmatter and schema for markdown export.
53    pub md_include_properties_and_schema: bool,
54}
55
56impl SpaceBackupOptions {
57    /// Creates backup options for a full-space backup with practical defaults.
58    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/// Result from `backup_space`.
79#[derive(Debug, Clone)]
80pub struct SpaceBackupResult {
81    /// Final local backup path after target naming/relocation.
82    pub output_path: PathBuf,
83    /// Server-reported export path before local relocation.
84    pub server_path: PathBuf,
85    /// Number of exported objects reported by the server.
86    pub exported: i32,
87    /// Generated target filename or directory name.
88    pub generated_name: String,
89}
90
91impl AnytypeGrpcClient {
92    /// Exports a space backup using gRPC `ObjectListExport` and moves the server output to
93    /// a deterministic target name: `<prefix>_<space-name>_<timestamp>`.
94    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        // Space-name lookup is for output naming only. Export should still succeed
110        // if name lookup fails for an otherwise valid space id.
111        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}