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;
16pub use crate::error::BackupError;
17pub use crate::model::export::Format as ExportFormat;
18
19/// Options for a space backup request.
20#[derive(Debug, Clone)]
21pub struct SpaceBackupOptions {
22    /// Target space ID.
23    pub space_id: String,
24    /// Destination folder for backup output.
25    pub backup_dir: PathBuf,
26    /// Prefix used in generated target name.
27    pub filename_prefix: String,
28    /// Object IDs to export. Empty means full space export.
29    pub object_ids: Vec<String>,
30    /// Export format.
31    pub format: ExportFormat,
32    /// Ask server to produce a zip archive.
33    pub zip: bool,
34    /// Include linked objects.
35    pub include_nested: bool,
36    /// Include attached files.
37    pub include_files: bool,
38    /// For protobuf export, produce JSON payload format.
39    pub is_json: bool,
40    /// Include archived objects (default false).
41    pub include_archived: bool,
42    /// Disable export progress events.
43    pub no_progress: bool,
44    /// Include backlinks.
45    pub include_backlinks: bool,
46    /// Include space metadata.
47    pub include_space: bool,
48    /// Include properties frontmatter and schema for markdown export.
49    pub md_include_properties_and_schema: bool,
50}
51
52impl SpaceBackupOptions {
53    /// Creates backup options for a full-space backup with practical defaults.
54    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/// Result from `backup_space`.
75#[derive(Debug, Clone)]
76pub struct SpaceBackupResult {
77    /// Final local backup path after target naming/relocation.
78    pub output_path: PathBuf,
79    /// Server-reported export path before local relocation.
80    pub server_path: PathBuf,
81    /// Number of exported objects reported by the server.
82    pub exported: i32,
83    /// Generated target filename or directory name.
84    pub generated_name: String,
85}
86
87impl AnytypeGrpcClient {
88    /// Exports a space backup using gRPC `ObjectListExport` and moves the server output to
89    /// a deterministic target name: `<prefix>_<space-name>_<timestamp>`.
90    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        // Space-name lookup is for output naming only. Export should still succeed
106        // if name lookup fails for an otherwise valid space id.
107        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}