Skip to main content

fm/io/
opendal.rs

1use std::borrow::Cow;
2
3use anyhow::{anyhow, Context, Result};
4use opendal::{services, Entry, EntryMode, Operator};
5use serde::{Deserialize, Serialize};
6use serde_yaml_ng::{from_str, to_string as to_yml_string};
7use tokio::{fs::File, io::AsyncWriteExt};
8
9use crate::common::{path_to_config_folder, path_to_string, tilde, CONFIG_FOLDER};
10use crate::io::{CowStr, DrawMenu};
11use crate::modes::{human_size, FileInfo};
12use crate::{impl_content, impl_selectable, log_info, log_line};
13
14/// Configuration of an Opendal Google Drive.
15/// it holds every configured information from the token file.
16/// it's used to read a token file (and build itself), to connect to it,
17/// or to create a token file from user input.
18#[derive(Serialize, Deserialize, Debug)]
19pub struct GoogleDriveConfig {
20    drive_name: String,
21    root_folder: String,
22    refresh_token: String,
23    client_id: String,
24    client_secret: String,
25}
26
27impl GoogleDriveConfig {
28    pub fn new(
29        drive_name: String,
30        root_folder: String,
31        refresh_token: String,
32        client_id: String,
33        client_secret: String,
34    ) -> Self {
35        Self {
36            drive_name,
37            root_folder,
38            refresh_token,
39            client_id,
40            client_secret,
41        }
42    }
43
44    pub fn serialize(&self) -> Result<String> {
45        Ok(to_yml_string(self)?)
46    }
47
48    fn build_token_filename(config_name: &str) -> String {
49        let token_base_path = tilde(CONFIG_FOLDER);
50        format!("{token_base_path}/token_{config_name}.yaml")
51    }
52
53    /// Read the token & root folder from the token file.
54    async fn from_config(config_name: &str) -> Result<Self> {
55        let config_filename = Self::build_token_filename(config_name);
56        let token_data = tokio::fs::read_to_string(&config_filename).await?;
57        let google_drive_token: Self = from_str(&token_data)?;
58        Ok(google_drive_token)
59    }
60
61    /// Set up the Google Drive backend.
62    async fn build_operator(&self) -> Result<Operator> {
63        let builder = services::Gdrive::default()
64            .refresh_token(&self.refresh_token)
65            .client_id(&self.client_id)
66            .client_secret(&self.client_secret)
67            .root(&self.root_folder);
68
69        let op = Operator::new(builder)?.finish();
70        Ok(op)
71    }
72}
73
74/// Builds a google drive opendal container from a token filename.
75#[tokio::main]
76pub async fn google_drive(token_file: &str) -> Result<OpendalContainer> {
77    let google_drive_config = GoogleDriveConfig::from_config(token_file).await?;
78    log_info!("found google drive config {token_file}");
79    let op = google_drive_config.build_operator().await?;
80    log_info!("created operator");
81
82    // List all files and directories at the root level.
83    // let entries = op.list(&google_drive_config.root_folder).await?;
84    let entries = match op.list(&google_drive_config.root_folder).await {
85        Ok(entries) => entries,
86        Err(err) => {
87            log_info!("Error: {err:?}");
88            return Err(anyhow!("error: {err:?}"));
89        }
90    };
91    log_info!("listed entries");
92
93    // Create the container
94    let opendal_container = OpendalContainer::new(
95        op,
96        OpendalKind::GoogleDrive,
97        &google_drive_config.drive_name,
98        &google_drive_config.root_folder,
99        entries,
100    );
101
102    Ok(opendal_container)
103}
104
105/// Different kind of opendal container
106#[derive(Default)]
107pub enum OpendalKind {
108    #[default]
109    Empty,
110    GoogleDrive,
111}
112
113impl OpendalKind {
114    fn repr(&self) -> &'static str {
115        match self {
116            Self::Empty => "empty",
117            Self::GoogleDrive => "Google Drive",
118        }
119    }
120}
121
122/// Returns a vector of cloud token names read from config folder.
123///
124/// Every "could config" file is named `cloud_something.yaml`, this function extracts the
125/// "something" part.
126///
127/// # Errors
128///
129/// May fail if we can't read the config folder (fm should have crashed already).
130pub fn get_cloud_token_names() -> Result<Vec<String>> {
131    Ok(std::fs::read_dir(path_to_config_folder()?)?
132        .filter_map(|e| e.ok())
133        .filter(|e| e.path().is_file())
134        .map(|e| e.file_name().to_string_lossy().to_string())
135        .filter(|filename| filename.starts_with("token_") && filename.ends_with(".yaml"))
136        .map(|filename| filename.replace("token_", "").replace(".yaml", ""))
137        .collect())
138}
139
140/// Formating used to display elements.
141pub trait ModeFormat {
142    fn mode_fmt(&self) -> &'static str;
143}
144
145impl ModeFormat for Entry {
146    fn mode_fmt(&self) -> &'static str {
147        match self.metadata().mode() {
148            EntryMode::Unknown => "? ",
149            EntryMode::DIR => "d ",
150            EntryMode::FILE => ". ",
151        }
152    }
153}
154
155/// Holds any relevant content of an opendal container.
156/// It has an operator, allowing action on the remote files and knows
157/// about the root path and current content.
158#[derive(Default)]
159pub struct OpendalContainer {
160    /// Operator executing requests
161    op: Option<Operator>,
162    /// What kind of OpenDal container is it ?
163    /// ATM only GoogleDrive and Unknown
164    kind: OpendalKind,
165    /// Friendly name of the container to be displayed
166    name: String,
167    /// Current path in the cloud
168    path: std::path::PathBuf,
169    /// Configured root path
170    root: std::path::PathBuf,
171    /// Current index
172    pub index: usize,
173    /// Retrieved files
174    pub content: Vec<Entry>,
175    /// Last retrieved information
176    /// We keep a pair: index and string.
177    /// It may be cached in the future
178    pub metadata_repr: Option<(usize, String)>,
179}
180
181impl OpendalContainer {
182    fn new(
183        op: Operator,
184        kind: OpendalKind,
185        drive_name: &str,
186        root_path: &str,
187        content: Vec<Entry>,
188    ) -> Self {
189        Self {
190            op: Some(op),
191            name: format!("{kind_format}/{drive_name}", kind_format = kind.repr()),
192            path: std::path::PathBuf::from(root_path),
193            root: std::path::PathBuf::from(root_path),
194            kind,
195            index: 0,
196            content,
197            metadata_repr: None,
198        }
199    }
200
201    fn selected_filepath(&self) -> Option<String> {
202        Some(format!(
203            "{path}{sep}{filename}",
204            path = self.path.display(),
205            sep = if self.path == self.root { "" } else { "/" },
206            filename = self.selected_filename()?,
207        ))
208    }
209
210    /// Update the metadata with for the currently selected file
211    #[tokio::main]
212    pub async fn update_metadata(&mut self) -> Result<()> {
213        let Some(op) = &self.op else {
214            return Ok(());
215        };
216        let Some(filename) = self.selected_filename() else {
217            return Ok(());
218        };
219        let metadata = op
220            .stat_with(&self.selected_filepath().context("No selected file")?)
221            .await?;
222        let last_modified = match metadata.last_modified() {
223            Some(dt) => &dt.format("%Y/%m/%d %H:%M:%S").to_string(),
224            None => "",
225        };
226        let size = human_size(metadata.content_length());
227        let metadata_repr = format!("{size} {last_modified} {filename} ");
228        self.metadata_repr = Some((self.index, metadata_repr));
229
230        Ok(())
231    }
232
233    /// True if the opendal container is really set. IE if it's connected to a remote container.
234    pub fn is_set(&self) -> bool {
235        self.op.is_some()
236    }
237
238    fn cloud_build_dest_filename(&self, local_file: &FileInfo) -> String {
239        let filename = local_file.filename.as_ref();
240        let mut dest_path = self.path.clone();
241        dest_path.push(filename);
242        path_to_string(&dest_path)
243    }
244
245    /// Upload the local file to the remote container in its current path.
246    #[tokio::main]
247    pub async fn upload(&self, local_file: &FileInfo) -> Result<()> {
248        let Some(op) = &self.op else {
249            return Ok(());
250        };
251        let dest_path_str = self.cloud_build_dest_filename(local_file);
252        let bytes = tokio::fs::read(&local_file.path).await?;
253        op.write(&dest_path_str, bytes).await?;
254        log_line!(
255            "Uploaded {filename} to {path}",
256            filename = local_file.filename,
257            path = self.path.display()
258        );
259        Ok(())
260    }
261
262    fn selected_filename(&self) -> Option<&str> {
263        self.selected()?.path().split('/').next_back()
264    }
265
266    fn create_downloaded_path(&self, dest: &std::path::Path) -> Option<std::path::PathBuf> {
267        let distant_filename = self.selected_filename()?;
268        let mut dest = dest.to_path_buf();
269        dest.push(distant_filename);
270        if dest.exists() {
271            log_info!(
272                "Local file {dest} already exists. Can't download here",
273                dest = dest.display()
274            );
275            log_line!("Local file {dest} already exists. Choose another path or rename the existing file first.", dest=dest.display());
276            None
277        } else {
278            Some(dest)
279        }
280    }
281
282    /// Download the currently selected remote file to dest. The filename is preserved.
283    /// Nothing is done if a local file with same filename already exists in current path.
284    ///
285    /// This will most likely change in the future since it's not the default behavior of
286    /// most modern file managers.
287    #[tokio::main]
288    pub async fn download(&self, dest: &std::path::Path) -> Result<()> {
289        let Some(op) = &self.op else {
290            return Ok(());
291        };
292        let Some(selected) = self.selected() else {
293            return Ok(());
294        };
295        let distant_filepath = selected.path();
296        let Some(dest_full_path) = self.create_downloaded_path(dest) else {
297            return Ok(());
298        };
299        let buf = op.read(distant_filepath).await?;
300        let mut file = File::create(&dest_full_path).await?;
301        file.write_all(&buf.to_bytes()).await?;
302        log_info!(
303            "Downloaded {distant_filepath} to local file {path}",
304            path = dest_full_path.display(),
305        );
306        Ok(())
307    }
308
309    /// Creates a new remote directory with dirname in current path.
310    #[tokio::main]
311    pub async fn create_newdir(&mut self, dirname: String) -> Result<()> {
312        let current_path = &self.path;
313        let Some(op) = &self.op else {
314            return Err(anyhow!("Cloud container has no operator"));
315        };
316        let fp = current_path.join(dirname);
317        let mut fullpath = path_to_string(&fp);
318        if !fullpath.ends_with('/') {
319            fullpath.push('/');
320        }
321        op.create_dir(&fullpath).await?;
322        Ok(())
323    }
324
325    /// Disconnect itself, reseting it's parameters.
326    pub fn disconnect(&mut self) {
327        let desc = self.name.to_owned();
328        self.op = None;
329        self.kind = OpendalKind::Empty;
330        self.name = "empty".to_owned();
331        self.path = std::path::PathBuf::from("");
332        self.root = std::path::PathBuf::from("");
333        self.index = 0;
334        self.content = vec![];
335        log_info!("Disconnected from {desc}");
336    }
337
338    /// Delete the currently selected remote file
339    /// Nothing is done if current path is empty.
340    #[tokio::main]
341    pub async fn delete(&mut self) -> Result<()> {
342        let Some(op) = &self.op else {
343            return Ok(());
344        };
345        let Some(entry) = self.selected() else {
346            return Ok(());
347        };
348        let file_to_delete = entry.path();
349        op.delete(file_to_delete).await?;
350        log_info!("Deleted {file_to_delete}");
351        log_line!("Deleted {file_to_delete}");
352        Ok(())
353    }
354
355    async fn update_path(&mut self, path: &str) -> Result<()> {
356        if let Some(op) = &self.op {
357            self.content = op.list(path).await?;
358            self.path = std::path::PathBuf::from(path);
359            self.index = 0;
360            self.metadata_repr = None;
361        };
362        Ok(())
363    }
364
365    /// Enter in the selected file or directory.
366    ///
367    /// # Errors:
368    ///
369    /// Will fail if the selected file is not a directory of the current path is empty.
370    #[tokio::main]
371    pub async fn enter_selected(&mut self) -> Result<()> {
372        let path = self.selected().context("no path")?.path().to_owned();
373        self.update_path(&path).await
374    }
375
376    fn ensure_index_in_bounds(&mut self) {
377        self.index = std::cmp::min(self.content.len().saturating_sub(1), self.index)
378    }
379
380    /// Refresh the current remote path.
381    /// Nothing is done if no connexion is established.
382    #[tokio::main]
383    pub async fn refresh_current(&mut self) -> Result<()> {
384        let old_index = self.index;
385        self.update_path(&path_to_string(&self.path)).await?;
386        self.index = old_index;
387        self.ensure_index_in_bounds();
388        Ok(())
389    }
390
391    /// Move to remote parent directory if possible
392    #[tokio::main]
393    pub async fn move_to_parent(&mut self) -> Result<()> {
394        if self.op.is_some() {
395            if self.path == self.root {
396                return Ok(());
397            };
398            if let Some(parent) = self.path.to_owned().parent() {
399                self.update_path(&path_to_string(&parent)).await?;
400            }
401        }
402        Ok(())
403    }
404
405    /// Format a description of the current container: Name and path.
406    pub fn desc(&self) -> String {
407        format!(
408            "{desc}{sep}{path}",
409            desc = self.name,
410            sep = if self.path == self.root { "" } else { "/" },
411            path = self.path.display()
412        )
413    }
414}
415
416impl_content!(OpendalContainer, Entry);
417
418impl CowStr for Entry {
419    fn cow_str(&self) -> Cow<'_, str> {
420        format!("{mode} {path}", mode = self.mode_fmt(), path = self.path()).into()
421    }
422}
423
424impl DrawMenu<Entry> for OpendalContainer {}