Optionally preserve modified time of exported files

Add a new argument --preserve-mtime to keep the original modified time
attribute of notes being exported, instead of setting them to the
current time.
This commit is contained in:
Davis Davalos-DeLosh 2023-12-28 23:27:02 -06:00 committed by Nick Groenen
parent fc7ebd111a
commit 564bee1d92
6 changed files with 127 additions and 3 deletions

32
Cargo.lock generated
View File

@ -17,6 +17,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.6.0" version = "2.6.0"
@ -120,6 +126,18 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" 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]] [[package]]
name = "futures" name = "futures"
version = "0.3.30" version = "0.3.30"
@ -358,6 +376,7 @@ name = "obsidian-export"
version = "23.12.0" version = "23.12.0"
dependencies = [ dependencies = [
"eyre", "eyre",
"filetime",
"gumdrop", "gumdrop",
"ignore", "ignore",
"matter", "matter",
@ -441,7 +460,7 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.6.0",
"getopts", "getopts",
"memchr", "memchr",
"unicase", "unicase",
@ -485,6 +504,15 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "regex" name = "regex"
version = "1.10.6" version = "1.10.6"
@ -565,7 +593,7 @@ version = "0.38.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.6.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",

View File

@ -39,6 +39,7 @@ serde_yaml = "0.9.34"
slug = "0.1.5" slug = "0.1.5"
snafu = "0.8.3" snafu = "0.8.3"
unicode-normalization = "0.1.23" unicode-normalization = "0.1.23"
filetime = "0.2.23"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"

View File

@ -62,6 +62,12 @@ skip = [
# result. We can then choose to again skip that version, or decide more # result. We can then choose to again skip that version, or decide more
# drastic action is needed. # drastic action is needed.
"syn:<=1.0.109", "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" wildcards = "deny"
allow-wildcard-paths = false allow-wildcard-paths = false

View File

@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
use std::{fmt, str}; use std::{fmt, str};
pub use context::Context; pub use context::Context;
use filetime::set_file_mtime;
use frontmatter::{frontmatter_from_str, frontmatter_to_str}; use frontmatter::{frontmatter_from_str, frontmatter_to_str};
pub use frontmatter::{Frontmatter, FrontmatterStrategy}; pub use frontmatter::{Frontmatter, FrontmatterStrategy};
use pathdiff::diff_paths; use pathdiff::diff_paths;
@ -160,6 +161,20 @@ pub enum ExportError {
source: ignore::Error, 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()))] #[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. /// This occurs when an operation is requested on a file or directory which does not exist.
PathDoesNotExist { path: PathBuf }, PathDoesNotExist { path: PathBuf },
@ -227,6 +242,7 @@ pub struct Exporter<'a> {
vault_contents: Option<Vec<PathBuf>>, vault_contents: Option<Vec<PathBuf>>,
walk_options: WalkOptions<'a>, walk_options: WalkOptions<'a>,
process_embeds_recursively: bool, process_embeds_recursively: bool,
preserve_mtime: bool,
postprocessors: Vec<&'a Postprocessor<'a>>, postprocessors: Vec<&'a Postprocessor<'a>>,
embed_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", "process_embeds_recursively",
&self.process_embeds_recursively, &self.process_embeds_recursively,
) )
.field("preserve_mtime", &self.preserve_mtime)
.field( .field(
"postprocessors", "postprocessors",
&format!("<{} postprocessors active>", self.postprocessors.len()), &format!("<{} postprocessors active>", self.postprocessors.len()),
@ -270,6 +287,7 @@ impl<'a> Exporter<'a> {
frontmatter_strategy: FrontmatterStrategy::Auto, frontmatter_strategy: FrontmatterStrategy::Auto,
walk_options: WalkOptions::default(), walk_options: WalkOptions::default(),
process_embeds_recursively: true, process_embeds_recursively: true,
preserve_mtime: false,
vault_contents: None, vault_contents: None,
postprocessors: vec![], postprocessors: vec![],
embed_postprocessors: vec![], embed_postprocessors: vec![],
@ -312,6 +330,15 @@ impl<'a> Exporter<'a> {
self 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 /// Append a function to the chain of [postprocessors][Postprocessor] to run on exported
/// Obsidian Markdown notes. /// Obsidian Markdown notes.
pub fn add_postprocessor(&mut self, processor: &'a Postprocessor<'_>) -> &mut Self { 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), true => self.parse_and_export_obsidian_note(src, dest),
false => copy_file(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<()> { fn parse_and_export_obsidian_note(&self, src: &Path, dest: &Path) -> Result<()> {
@ -766,6 +799,16 @@ fn create_file(dest: &Path) -> Result<File> {
Ok(file) 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<()> { fn copy_file(src: &Path, dest: &Path) -> Result<()> {
fs::copy(src, dest) fs::copy(src, dest)
.or_else(|err| { .or_else(|err| {

View File

@ -57,6 +57,13 @@ struct Opts {
#[options(no_short, help = "Don't process embeds recursively", default = "false")] #[options(no_short, help = "Don't process embeds recursively", default = "false")]
no_recursive_embeds: bool, no_recursive_embeds: bool,
#[options(
no_short,
help = "Preserve the mtime of exported files",
default = "false"
)]
preserve_mtime: bool,
#[options( #[options(
no_short, no_short,
help = "Convert soft line breaks to hard line breaks. This mimics Obsidian's 'Strict line breaks' setting", 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); let mut exporter = Exporter::new(root, destination);
exporter.frontmatter_strategy(args.frontmatter_strategy); exporter.frontmatter_strategy(args.frontmatter_strategy);
exporter.process_embeds_recursively(!args.no_recursive_embeds); exporter.process_embeds_recursively(!args.no_recursive_embeds);
exporter.preserve_mtime(args.preserve_mtime);
exporter.walk_options(walk_options); exporter.walk_options(walk_options);
if args.hard_linebreaks { if args.hard_linebreaks {

View File

@ -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] #[test]
fn test_non_ascii_filenames() { fn test_non_ascii_filenames() {
let tmp_dir = TempDir::new().expect("failed to make tempdir"); let tmp_dir = TempDir::new().expect("failed to make tempdir");