files_sdk/sharing/
bundles.rs

1//! Bundle (Share Link) operations
2//!
3//! Bundles are the API/SDK term for Share Links in the Files.com web interface.
4//! They allow you to share files and folders with external users via a public URL.
5
6use crate::{FilesClient, PaginationInfo, Result};
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9
10/// Bundle permissions enum
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum BundlePermission {
14    /// Read-only access
15    Read,
16    /// Write-only access (upload)
17    Write,
18    /// Read and write access
19    ReadWrite,
20    /// Full access
21    Full,
22    /// No access
23    None,
24    /// Preview only (no download)
25    PreviewOnly,
26}
27
28/// A Bundle entity (Share Link)
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct BundleEntity {
31    /// Bundle ID
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub id: Option<i64>,
34
35    /// Bundle code - forms the end part of the public URL
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub code: Option<String>,
38
39    /// Public URL of share link
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub url: Option<String>,
42
43    /// Public description
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub description: Option<String>,
46
47    /// Bundle internal note
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub note: Option<String>,
50
51    /// Is password protected?
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub password_protected: Option<bool>,
54
55    /// Permissions that apply to folders in this share link
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub permissions: Option<String>,
58
59    /// Preview only mode
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub preview_only: Option<bool>,
62
63    /// Require registration to access?
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub require_registration: Option<bool>,
66
67    /// Require explicit share recipient?
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub require_share_recipient: Option<bool>,
70
71    /// Require logout after each access?
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub require_logout: Option<bool>,
74
75    /// Legal clickwrap text
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub clickwrap_body: Option<String>,
78
79    /// ID of clickwrap to use
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub clickwrap_id: Option<i64>,
82
83    /// Skip name in registration?
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub skip_name: Option<bool>,
86
87    /// Skip email in registration?
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub skip_email: Option<bool>,
90
91    /// Skip company in registration?
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub skip_company: Option<bool>,
94
95    /// Bundle expiration date/time
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub expires_at: Option<String>,
98
99    /// Date when share becomes accessible
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub start_access_on_date: Option<String>,
102
103    /// Bundle created at
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub created_at: Option<String>,
106
107    /// Don't create subfolders for submissions?
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub dont_separate_submissions_by_folder: Option<bool>,
110
111    /// Maximum number of uses
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub max_uses: Option<i64>,
114
115    /// Template for submission subfolder paths
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub path_template: Option<String>,
118
119    /// Timezone for path template timestamps
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub path_template_time_zone: Option<String>,
122
123    /// Send receipt to uploader?
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub send_email_receipt_to_uploader: Option<bool>,
126
127    /// Snapshot ID containing bundle contents
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub snapshot_id: Option<i64>,
130
131    /// Bundle creator user ID
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub user_id: Option<i64>,
134
135    /// Bundle creator username
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub username: Option<String>,
138
139    /// Associated inbox ID
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub inbox_id: Option<i64>,
142
143    /// Has associated inbox?
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub has_inbox: Option<bool>,
146
147    /// Prevent folder uploads?
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub dont_allow_folders_in_uploads: Option<bool>,
150
151    /// Paths included in bundle (not provided when listing)
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub paths: Option<Vec<String>>,
154
155    /// Page link and button color
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub color_left: Option<String>,
158
159    /// Top bar link color
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub color_link: Option<String>,
162
163    /// Page link and button color
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub color_text: Option<String>,
166
167    /// Top bar background color
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub color_top: Option<String>,
170
171    /// Top bar text color
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub color_top_text: Option<String>,
174}
175
176/// Handler for bundle operations
177pub struct BundleHandler {
178    client: FilesClient,
179}
180
181impl BundleHandler {
182    /// Create a new bundle handler
183    pub fn new(client: FilesClient) -> Self {
184        Self { client }
185    }
186
187    /// List bundles
188    ///
189    /// # Arguments
190    /// * `user_id` - Filter by user ID (0 for current user)
191    /// * `cursor` - Pagination cursor
192    /// * `per_page` - Results per page
193    ///
194    /// # Returns
195    /// Tuple of (bundles, pagination_info)
196    ///
197    /// # Example
198    /// ```no_run
199    /// use files_sdk::{FilesClient, BundleHandler};
200    ///
201    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
202    /// let client = FilesClient::builder().api_key("key").build()?;
203    /// let handler = BundleHandler::new(client);
204    /// let (bundles, pagination) = handler.list(None, None, None).await?;
205    /// # Ok(())
206    /// # }
207    /// ```
208    pub async fn list(
209        &self,
210        user_id: Option<i64>,
211        cursor: Option<&str>,
212        per_page: Option<i64>,
213    ) -> Result<(Vec<BundleEntity>, PaginationInfo)> {
214        let mut params = vec![];
215        if let Some(uid) = user_id {
216            params.push(("user_id", uid.to_string()));
217        }
218        if let Some(c) = cursor {
219            params.push(("cursor", c.to_string()));
220        }
221        if let Some(pp) = per_page {
222            params.push(("per_page", pp.to_string()));
223        }
224
225        let query = if params.is_empty() {
226            String::new()
227        } else {
228            format!(
229                "?{}",
230                params
231                    .iter()
232                    .map(|(k, v)| format!("{}={}", k, v))
233                    .collect::<Vec<_>>()
234                    .join("&")
235            )
236        };
237
238        let response = self.client.get_raw(&format!("/bundles{}", query)).await?;
239        let bundles: Vec<BundleEntity> = serde_json::from_value(response)?;
240
241        let pagination = PaginationInfo {
242            cursor_next: None,
243            cursor_prev: None,
244        };
245
246        Ok((bundles, pagination))
247    }
248
249    /// Get a specific bundle
250    ///
251    /// # Arguments
252    /// * `id` - Bundle ID
253    ///
254    /// # Returns
255    /// The bundle entity
256    pub async fn get(&self, id: i64) -> Result<BundleEntity> {
257        let response = self.client.get_raw(&format!("/bundles/{}", id)).await?;
258        Ok(serde_json::from_value(response)?)
259    }
260
261    /// Create a new bundle
262    ///
263    /// # Arguments
264    /// * `paths` - List of paths to include in bundle (required)
265    /// * `password` - Password protection
266    /// * `expires_at` - Expiration date/time
267    /// * `max_uses` - Maximum number of accesses
268    /// * `description` - Public description
269    /// * `note` - Internal note
270    /// * `code` - Custom bundle code for URL
271    /// * `require_registration` - Require user registration
272    /// * `permissions` - Permission level (read, write, read_write, full, preview_only)
273    ///
274    /// # Returns
275    /// The created bundle
276    #[allow(clippy::too_many_arguments)]
277    pub async fn create(
278        &self,
279        paths: Vec<String>,
280        password: Option<&str>,
281        expires_at: Option<&str>,
282        max_uses: Option<i64>,
283        description: Option<&str>,
284        note: Option<&str>,
285        code: Option<&str>,
286        require_registration: Option<bool>,
287        permissions: Option<&str>,
288    ) -> Result<BundleEntity> {
289        let mut body = json!({
290            "paths": paths,
291        });
292
293        if let Some(p) = password {
294            body["password"] = json!(p);
295        }
296        if let Some(e) = expires_at {
297            body["expires_at"] = json!(e);
298        }
299        if let Some(m) = max_uses {
300            body["max_uses"] = json!(m);
301        }
302        if let Some(d) = description {
303            body["description"] = json!(d);
304        }
305        if let Some(n) = note {
306            body["note"] = json!(n);
307        }
308        if let Some(c) = code {
309            body["code"] = json!(c);
310        }
311        if let Some(r) = require_registration {
312            body["require_registration"] = json!(r);
313        }
314        if let Some(perm) = permissions {
315            body["permissions"] = json!(perm);
316        }
317
318        let response = self.client.post_raw("/bundles", body).await?;
319        Ok(serde_json::from_value(response)?)
320    }
321
322    /// Update a bundle
323    ///
324    /// # Arguments
325    /// * `id` - Bundle ID
326    /// * `password` - Password protection
327    /// * `expires_at` - Expiration date/time
328    /// * `max_uses` - Maximum number of accesses
329    /// * `description` - Public description
330    /// * `note` - Internal note
331    ///
332    /// # Returns
333    /// The updated bundle
334    #[allow(clippy::too_many_arguments)]
335    pub async fn update(
336        &self,
337        id: i64,
338        password: Option<&str>,
339        expires_at: Option<&str>,
340        max_uses: Option<i64>,
341        description: Option<&str>,
342        note: Option<&str>,
343    ) -> Result<BundleEntity> {
344        let mut body = json!({});
345
346        if let Some(p) = password {
347            body["password"] = json!(p);
348        }
349        if let Some(e) = expires_at {
350            body["expires_at"] = json!(e);
351        }
352        if let Some(m) = max_uses {
353            body["max_uses"] = json!(m);
354        }
355        if let Some(d) = description {
356            body["description"] = json!(d);
357        }
358        if let Some(n) = note {
359            body["note"] = json!(n);
360        }
361
362        let response = self
363            .client
364            .patch_raw(&format!("/bundles/{}", id), body)
365            .await?;
366        Ok(serde_json::from_value(response)?)
367    }
368
369    /// Delete a bundle
370    ///
371    /// # Arguments
372    /// * `id` - Bundle ID
373    pub async fn delete(&self, id: i64) -> Result<()> {
374        self.client.delete_raw(&format!("/bundles/{}", id)).await?;
375        Ok(())
376    }
377
378    /// Share a bundle via email
379    ///
380    /// # Arguments
381    /// * `id` - Bundle ID
382    /// * `to` - Email recipients (comma-separated or array)
383    /// * `note` - Optional note to include in email
384    ///
385    /// # Returns
386    /// Success confirmation
387    pub async fn share(&self, id: i64, to: Vec<String>, note: Option<&str>) -> Result<()> {
388        let mut body = json!({
389            "to": to,
390        });
391
392        if let Some(n) = note {
393            body["note"] = json!(n);
394        }
395
396        self.client
397            .post_raw(&format!("/bundles/{}/share", id), body)
398            .await?;
399        Ok(())
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_handler_creation() {
409        let client = FilesClient::builder().api_key("test-key").build().unwrap();
410        let _handler = BundleHandler::new(client);
411    }
412}