diff --git a/docs/commands.md b/docs/commands.md index 312425f..a864f73 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -32,17 +32,21 @@ cdir helps you to switch quickly and easily between directories Usage: cdir [OPTIONS] [COMMAND] Commands: - gui Launch the GUI - config-file Print the path to the configuration file - add-path Add a directory path - import-paths Import a path file - add-shortcut Add a shortcut - delete-shortcut Delete a shortcut - print-shortcut Print a shortcut - import-shortcuts Import a shortcuts file - lasts Print last paths - pretty-print-path Pretty print a path using shortcuts - help Print this message or the help of the given subcommand(s) + gui Launch the GUI + config-file Print the path to the configuration file + add-path Add a directory path + add-shortcut Add a shortcut + delete-shortcut Delete a shortcut + print-shortcut Print a shortcut + lasts Print last paths + pretty-print-path Pretty print a path using shortcuts + import-paths Import a path file + export-paths Export paths to a YAML file + import-path-history Import path history file + export-path-history Export path history to a YAML file + import-shortcuts Import a shortcuts file + export-shortcuts Export shortcuts to a YAML file + help Print this message or the help of the given subcommand(s) Options: -c, --config-file Path to the configuration file @@ -50,8 +54,8 @@ Options: -V, --version Print version ``` -You can report to the following sections of for more details: +You can refer to the following sections for more details: -* `import-paths` [Importing Shortcuts](importing_shortcuts.md) +* Import/Export commands: [Data Import & Export](importing_shortcuts.md) -* `pretty-print-path` [Shell promp](prompt.md) \ No newline at end of file +* `pretty-print-path`: [Shell prompt](prompt.md) diff --git a/docs/features.md b/docs/features.md index 601059b..df7473a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -31,9 +31,16 @@ It is disabled by default but can be activated in the configuration file. Works with both `zsh` and `bash`. More shells may be supported in the future. -## :material-check-bold: Import shortcuts +## :material-check-bold: Data import & export -Define your shortcuts in a YAML file and import them on any computer for easy setup. +Export and import your shortcuts, current paths, and complete directory history in YAML format. + +Perfect for: + +- **Backup**: Save your navigation data +- **Migration**: Move your setup to a new computer +- **Sharing**: Share shortcuts with your team +- **Version Control**: Keep your shortcuts in git with your dotfiles ## :material-check-bold: Color themes diff --git a/docs/import_export.md b/docs/import_export.md new file mode 100644 index 0000000..c5aad31 --- /dev/null +++ b/docs/import_export.md @@ -0,0 +1,153 @@ +# Data Import & Export + +`cdir` supports importing and exporting your data in YAML format, making it easy to: + +- **Backup** your shortcuts and directory history +- **Share** shortcuts with teammates or across machines +- **Migrate** your data to a new computer + +## Export Commands + +### Export Shortcuts + +Export all your shortcuts (bookmarks) to a YAML file: + +```bash +$ cdir export-shortcuts ~/my-shortcuts.yaml +``` + +The exported file will contain: + +```yaml +- name: projects + path: /home/user/projects + description: My projects folder +- name: docs + path: /home/user/Documents + description: null +``` + +### Export Paths + +Export the current list of paths from your navigation history: + +```bash +$ cdir export-current-paths ~/current-paths.yaml +``` + +This exports the most recent unique paths you've visited. + +### Export Path History + +Export the complete path history including multiple visits to the same directory: + +```bash +$ cdir export-path-history ~/full-history.yaml +``` + +The exported file will contain: + +```yaml +- date: '1740488400' + path: /home/user/projects +- date: '1740488350' + path: /home/user/Documents +- date: '1740488300' + path: /home/user/projects +``` + +Each entry includes a UNIX timestamp (seconds since epoch). + +## Import Commands + +### Import Shortcuts + +Import shortcuts from a YAML file: + +```bash +$ cdir import-shortcuts ~/my-shortcuts.yaml +``` + +The file should contain shortcuts with `name` and `path` fields: + +```yaml +- name: proj + path: /home/user/projects +- name: tmp + path: /tmp + description: Temporary directory +``` + +### Import Paths + +Import paths into both the current paths and history: + +```bash +$ cdir import-paths ~/current-paths.yaml +``` + +This updates your current navigation state. The file format is: + +```yaml +- date: '1740488400' + path: /home/user/projects +- date: '1740488350' + path: /home/user/Documents +``` + +### Import Path History + +Import historical path data without affecting your current paths: + +```bash +$ cdir import-path-history ~/full-history.yaml +``` + +**Important difference:** Unlike `import-paths`, this command only adds entries to the `paths_history` table and does NOT update your current paths. This is useful for: + +- Merging historical data from multiple machines +- Restoring old navigation history +- Importing archived path data + +## Common Use Cases + +### Backup Everything + +```bash +# Create a backup directory +mkdir ~/cdir-backup + +# Export all your data +cdir export-shortcuts ~/cdir-backup/shortcuts.yaml +cdir export-current-paths ~/cdir-backup/current.yaml +cdir export-path-history ~/cdir-backup/history.yaml +``` + +### Setup a New Machine + +```bash +# On the new machine, import your data +cdir import-shortcuts ~/cdir-backup/shortcuts.yaml +cdir import-path-history ~/cdir-backup/history.yaml +cdir import-paths ~/cdir-backup/current.yaml +``` + +### Share Shortcuts with Your Team + +```bash +# Create a team shortcuts file +cat > team-shortcuts.yaml << EOF +- name: api + path: /company/projects/api + description: Main API service +- name: frontend + path: /company/projects/frontend + description: React frontend +- name: docs + path: /company/docs + description: Company documentation +EOF + +# Everyone can import it +cdir import-shortcuts team-shortcuts.yaml +``` diff --git a/docs/importing_shortcuts.md b/docs/importing_shortcuts.md deleted file mode 100644 index 776ddda..0000000 --- a/docs/importing_shortcuts.md +++ /dev/null @@ -1,21 +0,0 @@ -# Importing Shortcuts - -cdir supports importing shortcuts from a YAML file. - -To do so, the file should contain the list of shortcuts defined by a `name` and a `path`. - -Example: - -```yaml -- name: t1 - path: /tmp1 -- name: t2 - path: /tmp2 - description: Temporary directory 2 -``` - -Import with: - -``` -$ cdir import-shortcuts /path/to/shortcuts.yaml -``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 659ace2..6c80f2e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,7 +7,7 @@ nav: - GUI: gui.md - Installation: installation.md - Configuration: configuration.md - - Importing Shortcuts: importing_shortcuts.md + - Data Import & Export: import_export.md - Shell promp: prompt.md - License: license.md theme: diff --git a/src/expimp.rs b/src/expimp.rs index 4060340..6141f59 100644 --- a/src/expimp.rs +++ b/src/expimp.rs @@ -1,6 +1,6 @@ use std::{fs, path::PathBuf}; -use log::error; +use log::{error, info}; use serde::{Deserialize, Serialize}; use crate::store::Store; @@ -57,6 +57,49 @@ fn load_paths(store: Store, new_paths: Vec) { } } +/// Load path history from a YAML file and add them to the store's paths_history table. +/// The YAML file should contain a list of objects with `date` and `path` fields. +/// The `date` field should be a string representing a UNIX timestamp in seconds. +/// Unlike load_paths_from_yaml, this function adds directly to paths_history without updating current paths. +pub(crate) fn import_path_history_from_yaml(store: Store, yaml_file: PathBuf) { + if !yaml_file.exists() { + error!("File {} does not exist", yaml_file.display()); + return; + } + match fs::read_to_string(&yaml_file) { + Ok(contents) => { + let new_paths_res: Result, serde_yaml::Error> = + serde_yaml::from_str(contents.as_str()); + match new_paths_res { + Ok(new_paths) => { + load_path_history(store, new_paths); + } + Err(e) => { + error!("Failed to parse the file {}: {}", yaml_file.display(), e); + } + } + } + Err(e) => { + error!("Failed to read file {}: {}", yaml_file.display(), e); + } + } +} + +fn load_path_history(store: Store, new_paths: Vec) { + for entry in new_paths { + match entry.date.parse::() { + Ok(sec) => { + let _ = store + .add_path_to_history(&entry.path, sec) + .map_err(|e| error!("{}", e)); + } + Err(e) => { + error!("{}", e); + } + } + } +} + #[derive(Serialize, Deserialize, PartialEq, Debug)] struct Shortcut { name: String, @@ -96,3 +139,114 @@ fn load_shortcuts(store: Store, new_paths: Vec) { .map_err(|e| error!("{}", e)); } } + +/// Export paths from the paths table to a YAML file. +/// The YAML file will contain a list of objects with `date` and `path` fields. +/// The `date` field is a string representing a UNIX timestamp in seconds. +pub(crate) fn export_paths_to_yaml(store: Store, yaml_file: PathBuf) { + match store.list_all_paths() { + Ok(paths) => { + let export_paths: Vec = paths + .into_iter() + .map(|p| Path { + date: p.date.to_string(), + path: p.path, + }) + .collect(); + + match serde_yaml::to_string(&export_paths) { + Ok(yaml_content) => { + if let Err(e) = fs::write(&yaml_file, yaml_content) { + error!("Failed to write file {}: {}", yaml_file.display(), e); + } else { + info!( + "Exported {} current paths to {}", + export_paths.len(), + yaml_file.display() + ); + } + } + Err(e) => { + error!("Failed to serialize paths to YAML: {}", e); + } + } + } + Err(e) => { + error!("Failed to retrieve current paths: {}", e); + } + } +} + +/// Export path history from the paths_history table to a YAML file. +/// The YAML file will contain a list of objects with `date` and `path` fields. +/// The `date` field is a string representing a UNIX timestamp in seconds. +pub(crate) fn export_path_history_to_yaml(store: Store, yaml_file: PathBuf) { + match store.list_all_path_history() { + Ok(paths) => { + let export_paths: Vec = paths + .into_iter() + .map(|p| Path { + date: p.date.to_string(), + path: p.path, + }) + .collect(); + + match serde_yaml::to_string(&export_paths) { + Ok(yaml_content) => { + if let Err(e) = fs::write(&yaml_file, yaml_content) { + error!("Failed to write file {}: {}", yaml_file.display(), e); + } else { + info!( + "Exported {} path history entries to {}", + export_paths.len(), + yaml_file.display() + ); + } + } + Err(e) => { + error!("Failed to serialize paths to YAML: {}", e); + } + } + } + Err(e) => { + error!("Failed to retrieve path history: {}", e); + } + } +} + +/// Export shortcuts to a YAML file. +/// The YAML file will contain a list of objects with `name`, `path`, and optional `description` fields. +pub(crate) fn export_shortcuts_to_yaml(store: Store, yaml_file: PathBuf) { + match store.list_all_shortcuts() { + Ok(shortcuts) => { + let export_shortcuts: Vec = shortcuts + .into_iter() + .map(|s| Shortcut { + name: s.name, + path: s.path, + description: s.description, + }) + .collect(); + + match serde_yaml::to_string(&export_shortcuts) { + Ok(yaml_content) => { + if let Err(e) = fs::write(&yaml_file, yaml_content) { + error!("Failed to write file {}: {}", yaml_file.display(), e); + } else { + info!( + "Exported {} shortcuts to {}", + export_shortcuts.len(), + yaml_file.display() + ); + } + } + Err(e) => { + error!("Failed to serialize shortcuts to YAML: {}", e); + } + } + } + Err(e) => { + error!("Failed to retrieve shortcuts: {}", e); + } + } +} diff --git a/src/expimp_tests.rs b/src/expimp_tests.rs index 62d454c..bfb82b1 100644 --- a/src/expimp_tests.rs +++ b/src/expimp_tests.rs @@ -82,3 +82,167 @@ fn test_load_shortcuts() { assert_eq!(shortcut_x.path, "y"); assert_eq!(shortcut_x.description, Some(String::from("z"))); } + +#[test] +fn test_export_import_shortcuts() { + use tempfile::NamedTempFile; + + use crate::store::Store; + let store = Store::setup_test_store(); + + // Add some shortcuts + store + .add_shortcut("home", "/home/user", Some("Home directory")) + .unwrap(); + store.add_shortcut("work", "/work/project", None).unwrap(); + + // Export to a temporary file + let temp_file = NamedTempFile::new().unwrap(); + let temp_path = temp_file.path().to_path_buf(); + export_shortcuts_to_yaml(store.clone(), temp_path.clone()); + + // Create a new store and import + let new_store = Store::setup_test_store(); + load_shortcuts_from_yaml(new_store.clone(), temp_path); + + // Verify the shortcuts were imported correctly + let shortcuts = new_store.list_all_shortcuts().unwrap(); + assert_eq!(shortcuts.len(), 2); + + let home = shortcuts.iter().find(|s| s.name == "home").unwrap(); + assert_eq!(home.path, "/home/user"); + assert_eq!(home.description, Some(String::from("Home directory"))); + + let work = shortcuts.iter().find(|s| s.name == "work").unwrap(); + assert_eq!(work.path, "/work/project"); + assert_eq!(work.description, None); +} + +#[test] +fn test_export_import_current_paths() { + use tempfile::NamedTempFile; + + use crate::store::Store; + let store = Store::setup_test_store(); + + // Add some paths + store.add_path_with_time("/path/one", 1000).unwrap(); + store.add_path_with_time("/path/two", 2000).unwrap(); + + // Export to a temporary file + let temp_file = NamedTempFile::new().unwrap(); + let temp_path = temp_file.path().to_path_buf(); + export_paths_to_yaml(store.clone(), temp_path.clone()); + + // Create a new store and import + let new_store = Store::setup_test_store(); + load_paths_from_yaml(new_store.clone(), temp_path); + + // Verify the paths were imported correctly + let paths = new_store.list_all_paths().unwrap(); + assert_eq!(paths.len(), 2); + + let path_one = paths.iter().find(|p| p.path == "/path/one").unwrap(); + assert_eq!(path_one.date, 1000); + + let path_two = paths.iter().find(|p| p.path == "/path/two").unwrap(); + assert_eq!(path_two.date, 2000); +} + +#[test] +fn test_export_path_history() { + use tempfile::NamedTempFile; + + use crate::store::Store; + let store = Store::setup_test_store(); + + // Add paths multiple times to create history + store.add_path_with_time("/path/one", 1000).unwrap(); + store.add_path_with_time("/path/one", 1100).unwrap(); + store.add_path_with_time("/path/two", 2000).unwrap(); + + // Export history to a temporary file + let temp_file = NamedTempFile::new().unwrap(); + let temp_path = temp_file.path().to_path_buf(); + export_path_history_to_yaml(store.clone(), temp_path.clone()); + + // Read and verify the exported file has all history entries + let yaml_content = std::fs::read_to_string(&temp_path).unwrap(); + let exported_paths: Vec = serde_yaml::from_str(&yaml_content).unwrap(); + + // Should have 3 entries in history (even though current paths has only 2) + assert_eq!(exported_paths.len(), 3); + + // Verify we have both timestamps for /path/one + let path_one_entries: Vec<_> = exported_paths + .iter() + .filter(|p| p.path == "/path/one") + .collect(); + assert_eq!(path_one_entries.len(), 2); +} + +#[test] +fn test_export_import_path_history() { + use tempfile::NamedTempFile; + + use crate::store::Store; + let store = Store::setup_test_store(); + + // Add paths multiple times to create history + store.add_path_with_time("/history/path/one", 1000).unwrap(); + store.add_path_with_time("/history/path/two", 2000).unwrap(); + store.add_path_with_time("/history/path/one", 1500).unwrap(); // Same path, different time + store + .add_path_with_time("/history/path/three", 3000) + .unwrap(); + + // Export history to a temporary file + let temp_file = NamedTempFile::new().unwrap(); + let temp_path = temp_file.path().to_path_buf(); + export_path_history_to_yaml(store.clone(), temp_path.clone()); + + // Verify the exported file + let yaml_content = std::fs::read_to_string(&temp_path).unwrap(); + let exported_paths: Vec = serde_yaml::from_str(&yaml_content).unwrap(); + + // Should have 4 entries in history + assert_eq!(exported_paths.len(), 4); + + // Create a new store and import the history + let new_store = Store::setup_test_store(); + import_path_history_from_yaml(new_store.clone(), temp_path); + + // Verify the history was imported correctly + let imported_history = new_store.list_all_path_history().unwrap(); + assert_eq!(imported_history.len(), 4); + + // Verify we have both timestamps for /history/path/one + let path_one_entries: Vec<_> = imported_history + .iter() + .filter(|p| p.path == "/history/path/one") + .collect(); + assert_eq!(path_one_entries.len(), 2); + + // Verify the timestamps are correct + let timestamps: Vec = path_one_entries.iter().map(|p| p.date).collect(); + assert!(timestamps.contains(&1000)); + assert!(timestamps.contains(&1500)); + + // Verify other paths + let path_two = imported_history + .iter() + .find(|p| p.path == "/history/path/two") + .unwrap(); + assert_eq!(path_two.date, 2000); + + let path_three = imported_history + .iter() + .find(|p| p.path == "/history/path/three") + .unwrap(); + assert_eq!(path_three.date, 3000); + + // Important: Verify that importing history does NOT update current paths + let current_paths = new_store.list_all_paths().unwrap(); + // Current paths should be empty since we only imported to history + assert_eq!(current_paths.len(), 0); +} diff --git a/src/main.rs b/src/main.rs index bd29f2d..525a5ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,10 @@ use std::{ use clap::{Parser, Subcommand}; use config::Config; -use expimp::load_paths_from_yaml; +use expimp::{ + export_path_history_to_yaml, export_paths_to_yaml, export_shortcuts_to_yaml, + import_path_history_from_yaml, load_paths_from_yaml, +}; use log::{debug, error, info}; use ratatui::text::Text; use store::Store; @@ -54,8 +57,6 @@ enum Commands { ConfigFile, /// Add a directory path AddPath { path: String }, - /// Import a path file - ImportPaths { filename: String }, /// Add a shortcut AddShortcut { name: String, @@ -66,8 +67,6 @@ enum Commands { DeleteShortcut { name: String }, /// Print a shortcut PrintShortcut { name: String }, - /// Import a shortcuts file - ImportShortcuts { filename: String }, /// Print last paths Lasts, /// Pretty print a path using shortcuts @@ -79,6 +78,18 @@ enum Commands { /// if set, the maximum width of the string max_width: Option, }, + /// Import a path file + ImportPaths { filename: String }, + /// Export paths to a YAML file + ExportPaths { filename: String }, + /// Import path history file + ImportPathHistory { filename: String }, + /// Export path history to a YAML file + ExportPathHistory { filename: String }, + /// Import a shortcuts file + ImportShortcuts { filename: String }, + /// Export shortcuts to a YAML file + ExportShortcuts { filename: String }, } fn initialize_logs(config_path: &Option) { @@ -150,6 +161,15 @@ async fn main() -> Result<(), Box> { Some(Commands::ImportPaths { filename }) => { load_paths_from_yaml(store, PathBuf::from(filename)); } + Some(Commands::ImportPathHistory { filename }) => { + import_path_history_from_yaml(store, PathBuf::from(filename)); + } + Some(Commands::ExportPaths { filename }) => { + export_paths_to_yaml(store, PathBuf::from(filename)); + } + Some(Commands::ExportPathHistory { filename }) => { + export_path_history_to_yaml(store, PathBuf::from(filename)); + } Some(Commands::AddShortcut { name, path, @@ -176,6 +196,9 @@ async fn main() -> Result<(), Box> { Some(Commands::ImportShortcuts { filename }) => { load_shortcuts_from_yaml(store, PathBuf::from(filename)); } + Some(Commands::ExportShortcuts { filename }) => { + export_shortcuts_to_yaml(store, PathBuf::from(filename)); + } Some(Commands::Lasts) => { let list = store.list_paths(0, 10, "", false).unwrap(); let config_lock = config.lock().unwrap(); diff --git a/src/store.rs b/src/store.rs index f689633..323b20d 100644 --- a/src/store.rs +++ b/src/store.rs @@ -396,6 +396,28 @@ impl Store { result1.and(result2) } + /// Adds a path entry directly to the paths_history table without updating current paths. + /// This is useful for importing historical data. + /// + /// ### Parameters + /// path: the file path to add + /// epoc: the timestamp to associate with the path (in seconds since EPOCH) + /// + /// ### Returns + /// Ok(()) if the operation was successful, otherwise an error + pub(crate) fn add_path_to_history(&self, path: &str, epoc: u64) -> Result<(), rusqlite::Error> { + debug!("add_path_to_history path={} epoch={}", path, epoc); + let mut stmt = self + .db_conn + .prepare("INSERT INTO paths_history (path, date) VALUES ((?1),(?2))")?; + stmt.execute([path, &format!("{}", epoc)]) + .map_err(|e| { + error!("Failed to insert path '{}' time' {}: {}", path, epoc, e); + e + }) + .map(|_l| ()) + } + /// Deletes a path from the database by its ID. /// /// ### Parameters @@ -1218,12 +1240,86 @@ impl Store { }; let mut shortcuts = Vec::new(); + for shortcut in rows { shortcuts.push(shortcut?); } Ok(shortcuts) } + /// Lists all paths from the paths table. + /// The results are ordered by date (descending) and ID (descending). + /// + /// ### Returns + /// A vector of all Path entries from the paths table if the operation was successful, otherwise an error. + pub(crate) fn list_all_paths(&self) -> Result, rusqlite::Error> { + debug!("list_all_paths"); + let shortcuts = self.list_all_shortcuts().unwrap_or_default(); + let sql = String::from("SELECT id, path, date FROM paths ORDER BY date desc, id desc"); + + let mut stmt = match self.db_conn.prepare(sql.as_str()) { + Ok(stmt) => stmt, + Err(e) => { + error!("list_all_paths failed in prepare {}: {}", sql, e); + return Err(e); + } + }; + + let rows = match stmt.query_map([], |row| { + let path_str: String = row.get(1)?; + Ok(Path::new(row.get(0)?, path_str, row.get(2)?, &shortcuts)) + }) { + Ok(rows) => rows, + Err(e) => { + error!("list_all_paths failed in query_map: {}", e); + return Err(e); + } + }; + + let mut paths = Vec::new(); + for path in rows { + paths.push(path?); + } + Ok(paths) + } + + /// Lists all paths from the paths_history table. + /// The results are ordered by date (descending) and ID (descending). + /// + /// ### Returns + /// A vector of all Path entries from the paths_history table if the operation was successful, otherwise an error. + pub(crate) fn list_all_path_history(&self) -> Result, rusqlite::Error> { + debug!("list_all_path_history"); + let shortcuts = self.list_all_shortcuts().unwrap_or_default(); + let sql = + String::from("SELECT id, path, date FROM paths_history ORDER BY date desc, id desc"); + + let mut stmt = match self.db_conn.prepare(sql.as_str()) { + Ok(stmt) => stmt, + Err(e) => { + error!("list_all_path_history failed in prepare {}: {}", sql, e); + return Err(e); + } + }; + + let rows = match stmt.query_map([], |row| { + let path_str: String = row.get(1)?; + Ok(Path::new(row.get(0)?, path_str, row.get(2)?, &shortcuts)) + }) { + Ok(rows) => rows, + Err(e) => { + error!("list_all_path_history failed in query_map: {}", e); + return Err(e); + } + }; + + let mut paths = Vec::new(); + for path in rows { + paths.push(path?); + } + Ok(paths) + } + /// Creates an in-memory store for testing purposes. #[allow(dead_code)] pub(crate) fn setup_test_store() -> Store {