diff --git a/Cargo.lock b/Cargo.lock index c701165..e6ed408 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.6.0" @@ -120,6 +126,18 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "windows-sys", +] + [[package]] name = "futures" version = "0.3.30" @@ -358,6 +376,7 @@ name = "obsidian-export" version = "23.12.0" dependencies = [ "eyre", + "filetime", "gumdrop", "ignore", "matter", @@ -441,7 +460,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags", + "bitflags 2.6.0", "getopts", "memchr", "unicase", @@ -485,6 +504,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "regex" version = "1.10.6" @@ -565,7 +593,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", diff --git a/Cargo.toml b/Cargo.toml index 1dc2719..98234ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ serde_yaml = "0.9.34" slug = "0.1.5" snafu = "0.8.3" unicode-normalization = "0.1.23" +filetime = "0.2.23" [dev-dependencies] pretty_assertions = "1.4.0" diff --git a/deny.toml b/deny.toml index 77c6b2a..59617cf 100644 --- a/deny.toml +++ b/deny.toml @@ -62,6 +62,12 @@ skip = [ # result. We can then choose to again skip that version, or decide more # drastic action is needed. "syn:<=1.0.109", + # filetime depends on redox_syscall which depends on bitflags 1.x, whereas + # other dependencies in our tree depends on bitflags 2.x. This should solve + # itself when a new release is made for filetime, as redox_syscall is + # deprecated and already replaced by libredox anyway + # (https://github.com/alexcrichton/filetime/pull/103) + "bitflags:<=1.3.2", ] wildcards = "deny" allow-wildcard-paths = false diff --git a/src/lib.rs b/src/lib.rs index dac7566..6a2d821 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use std::{fmt, str}; pub use context::Context; +use filetime::set_file_mtime; use frontmatter::{frontmatter_from_str, frontmatter_to_str}; pub use frontmatter::{Frontmatter, FrontmatterStrategy}; use pathdiff::diff_paths; @@ -160,6 +161,20 @@ pub enum ExportError { source: ignore::Error, }, + #[snafu(display("Failed to read the mtime of '{}'", path.display()))] + /// This occurs when a file's modified time cannot be read + ModTimeReadError { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("Failed to set the mtime of '{}'", path.display()))] + /// This occurs when a file's modified time cannot be set + ModTimeSetError { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("No such file or directory: {}", path.display()))] /// This occurs when an operation is requested on a file or directory which does not exist. PathDoesNotExist { path: PathBuf }, @@ -227,6 +242,7 @@ pub struct Exporter<'a> { vault_contents: Option>, walk_options: WalkOptions<'a>, process_embeds_recursively: bool, + preserve_mtime: bool, postprocessors: Vec<&'a Postprocessor<'a>>, embed_postprocessors: Vec<&'a Postprocessor<'a>>, } @@ -243,6 +259,7 @@ impl<'a> fmt::Debug for Exporter<'a> { "process_embeds_recursively", &self.process_embeds_recursively, ) + .field("preserve_mtime", &self.preserve_mtime) .field( "postprocessors", &format!("<{} postprocessors active>", self.postprocessors.len()), @@ -270,6 +287,7 @@ impl<'a> Exporter<'a> { frontmatter_strategy: FrontmatterStrategy::Auto, walk_options: WalkOptions::default(), process_embeds_recursively: true, + preserve_mtime: false, vault_contents: None, postprocessors: vec![], embed_postprocessors: vec![], @@ -312,6 +330,15 @@ impl<'a> Exporter<'a> { self } + /// Set whether the modified time of exported files should be preserved. + /// + /// When `preserve` is true, the modified time of exported files will be set to the modified + /// time of the source file. + pub fn preserve_mtime(&mut self, preserve: bool) -> &mut Self { + self.preserve_mtime = preserve; + self + } + /// Append a function to the chain of [postprocessors][Postprocessor] to run on exported /// Obsidian Markdown notes. pub fn add_postprocessor(&mut self, processor: &'a Postprocessor<'_>) -> &mut Self { @@ -392,7 +419,13 @@ impl<'a> Exporter<'a> { true => self.parse_and_export_obsidian_note(src, dest), false => copy_file(src, dest), } - .context(FileExportSnafu { path: src }) + .context(FileExportSnafu { path: src })?; + + if self.preserve_mtime { + copy_mtime(src, dest).context(FileExportSnafu { path: src })?; + } + + Ok(()) } fn parse_and_export_obsidian_note(&self, src: &Path, dest: &Path) -> Result<()> { @@ -766,6 +799,16 @@ fn create_file(dest: &Path) -> Result { Ok(file) } +fn copy_mtime(src: &Path, dest: &Path) -> Result<()> { + let metadata = fs::metadata(src).context(ModTimeReadSnafu { path: src })?; + let modified_time = metadata + .modified() + .context(ModTimeReadSnafu { path: src })?; + + set_file_mtime(dest, modified_time.into()).context(ModTimeSetSnafu { path: dest })?; + Ok(()) +} + fn copy_file(src: &Path, dest: &Path) -> Result<()> { fs::copy(src, dest) .or_else(|err| { diff --git a/src/main.rs b/src/main.rs index 7c4ff19..1f38c60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,13 @@ struct Opts { #[options(no_short, help = "Don't process embeds recursively", default = "false")] no_recursive_embeds: bool, + #[options( + no_short, + help = "Preserve the mtime of exported files", + default = "false" + )] + preserve_mtime: bool, + #[options( no_short, help = "Convert soft line breaks to hard line breaks. This mimics Obsidian's 'Strict line breaks' setting", @@ -97,6 +104,7 @@ fn main() { let mut exporter = Exporter::new(root, destination); exporter.frontmatter_strategy(args.frontmatter_strategy); exporter.process_embeds_recursively(!args.no_recursive_embeds); + exporter.preserve_mtime(args.preserve_mtime); exporter.walk_options(walk_options); if args.hard_linebreaks { diff --git a/tests/export_test.rs b/tests/export_test.rs index 4b31a0c..ff90373 100644 --- a/tests/export_test.rs +++ b/tests/export_test.rs @@ -360,6 +360,44 @@ fn test_no_recursive_embeds() { ); } +#[test] +fn test_preserve_mtime() { + let tmp_dir = TempDir::new().expect("failed to make tempdir"); + + let mut exporter = Exporter::new( + PathBuf::from("tests/testdata/input/main-samples/"), + tmp_dir.path().to_path_buf(), + ); + exporter.preserve_mtime(true); + exporter.run().expect("exporter returned error"); + + let src = "tests/testdata/input/main-samples/obsidian-wikilinks.md"; + let dest = tmp_dir.path().join(PathBuf::from("obsidian-wikilinks.md")); + let src_meta = std::fs::metadata(src).unwrap(); + let dest_meta = std::fs::metadata(dest).unwrap(); + + assert_eq!(src_meta.modified().unwrap(), dest_meta.modified().unwrap()); +} + +#[test] +fn test_no_preserve_mtime() { + let tmp_dir = TempDir::new().expect("failed to make tempdir"); + + let mut exporter = Exporter::new( + PathBuf::from("tests/testdata/input/main-samples/"), + tmp_dir.path().to_path_buf(), + ); + exporter.preserve_mtime(false); + exporter.run().expect("exporter returned error"); + + let src = "tests/testdata/input/main-samples/obsidian-wikilinks.md"; + let dest = tmp_dir.path().join(PathBuf::from("obsidian-wikilinks.md")); + let src_meta = std::fs::metadata(src).unwrap(); + let dest_meta = std::fs::metadata(dest).unwrap(); + + assert_ne!(src_meta.modified().unwrap(), dest_meta.modified().unwrap()); +} + #[test] fn test_non_ascii_filenames() { let tmp_dir = TempDir::new().expect("failed to make tempdir");