This commit is contained in:
JMARyA 2023-11-04 08:22:18 +01:00
commit c4f493966a
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
5 changed files with 893 additions and 0 deletions

27
src/args.rs Normal file
View file

@ -0,0 +1,27 @@
use clap::{arg, command, ArgMatches};
pub fn get_args() -> ArgMatches {
command!()
.about("Patch the frontmatter of markdown files")
.arg(arg!([markdown] "Markdown File").required(true))
.arg(arg!(-p --patch <PATCH> "Apply JSON Patch file. If set to '-' read from stdin").required(false).conflicts_with("merge"))
.arg(arg!(--merge <FILE> "Merge Markdown frontmatter. If set to '-' read from stdin").required(false).conflicts_with("patch"))
.arg(arg!(-a --add "Only patch add operations").required(false).conflicts_with("merge"))
.arg(arg!(-m --modify "Only patch modify operations").required(false).conflicts_with("merge"))
.arg(arg!(-d --delete "Only patch delete operations").required(false).conflicts_with("merge"))
.arg(arg!(-v --verbose "Print out what changes will be made to the document").required(false))
.arg(arg!(-n --dryrun "Dont modify the input file. Only print what would be done").required(false))
.arg(arg!(-o --output <OUTPUT> "Write patched file to output path. Dont modify the input file directly. If set to '-' output to stdout"))
.arg(arg!(--notest "Ignore tests in JSON Patch files").conflicts_with("merge"))
.arg(
arg!(-x --exclude <JSONPATH>... "Exclude json path from patch")
.required(false)
.conflicts_with("keys"),
)
.arg(
arg!(-k --keys <JSONPATH>... "Only include the specified json paths in patch")
.required(false)
.conflicts_with("exclude"),
)
.get_matches()
}

364
src/main.rs Normal file
View file

@ -0,0 +1,364 @@
use std::io::Read;
use json_patch::PatchOperation;
use serde_json::Value;
mod args;
trait ErrorExit {
type T;
fn quit_on_error(self, msg: &str) -> Self::T;
}
impl<T, E> ErrorExit for Result<T, E> {
type T = T;
fn quit_on_error(self, msg: &str) -> T {
self.map_or_else(
|_| {
eprintln!("{} {msg}", console::Style::new().red().apply_to("Error:"));
std::process::exit(1);
},
|res| res,
)
}
}
/// seperate markdown string into frontmatter and markdown content
#[must_use]
pub fn markdown_with_frontmatter(markdown: &str) -> (Option<String>, String) {
let frontmatter_regex = regex::Regex::new(r"(?s)^---\s*\n(.*?)\n---\s*\n(.*)$").unwrap();
frontmatter_regex.captures(markdown).map_or_else(
|| (None, markdown.to_string()),
|captures| {
let frontmatter = captures.get(1).map(|m| m.as_str().to_string());
let remaining_markdown = captures
.get(2)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
(frontmatter, remaining_markdown)
},
)
}
fn assemble_markdown_doc(content: &str, frontmatter: serde_json::Value) -> String {
let frontmatter = serde_yaml::to_string(&serde_yaml::to_value(frontmatter).unwrap()).unwrap();
format!("---\n{frontmatter}---\n\n{content}")
}
fn remove_test_operations(patch: Vec<PatchOperation>) -> Vec<PatchOperation> {
patch
.into_iter()
.filter(|x| !matches!(x, json_patch::PatchOperation::Test(_)))
.collect()
}
fn remove_add_operations(patch: Vec<PatchOperation>) -> Vec<PatchOperation> {
patch
.into_iter()
.filter(|x| !matches!(x, json_patch::PatchOperation::Add(_)))
.collect()
}
fn remove_modify_operations(patch: Vec<PatchOperation>) -> Vec<PatchOperation> {
patch
.into_iter()
.filter(|x| {
!matches!(
x,
json_patch::PatchOperation::Replace(_)
| json_patch::PatchOperation::Move(_)
| json_patch::PatchOperation::Copy(_)
)
})
.collect()
}
fn remove_delete_operations(patch: Vec<PatchOperation>) -> Vec<PatchOperation> {
patch
.into_iter()
.filter(|x| !matches!(x, json_patch::PatchOperation::Remove(_)))
.collect()
}
fn exclude_json_paths(patch: &serde_json::Value, paths: &[String]) -> serde_json::Value {
let filtered: Vec<serde_json::Value> = patch
.as_array()
.unwrap()
.iter()
.filter(|x| {
let mut is_excluded = false;
for exclude in paths {
if x.as_object()
.unwrap()
.get("path")
.unwrap()
.as_str()
.unwrap()
.starts_with(exclude)
{
is_excluded = true;
}
}
!is_excluded
})
.cloned()
.collect();
serde_json::to_value(filtered).unwrap()
}
fn include_only_json_paths(patch: &serde_json::Value, paths: &[String]) -> serde_json::Value {
let filtered: Vec<serde_json::Value> = patch
.as_array()
.unwrap()
.iter()
.filter(|x| {
let mut is_included = false;
for include in paths {
if x.as_object()
.unwrap()
.get("path")
.unwrap()
.as_str()
.unwrap()
.starts_with(include)
{
is_included = true;
}
}
is_included
})
.cloned()
.collect();
serde_json::to_value(filtered).unwrap()
}
fn only_include_json_pointers(paths: &[String], json_value: &Value) -> Value {
match json_value {
Value::Object(obj) => {
let mut filtered_obj = serde_json::Map::new();
for path in paths {
if let Some(key) = path.strip_prefix('/') {
if let Some(value) = obj.get(key) {
filtered_obj
.insert(key.to_string(), only_include_json_pointers(paths, value));
}
}
}
Value::Object(filtered_obj)
}
Value::Array(arr) => {
let filtered_arr: Vec<Value> = arr
.iter()
.map(|item| only_include_json_pointers(paths, item))
.collect();
Value::Array(filtered_arr)
}
_ => json_value.clone(),
}
}
fn exclude_json_by_paths(paths: &[String], json_value: &Value) -> Value {
match json_value {
Value::Object(obj) => {
let mut filtered_obj = serde_json::Map::new();
for (key, value) in obj {
if !paths.iter().any(|path| path == &("/".to_string() + key)) {
filtered_obj.insert(key.clone(), exclude_json_by_paths(paths, value));
}
}
Value::Object(filtered_obj)
}
Value::Array(arr) => {
let filtered_arr: Vec<Value> = arr
.iter()
.map(|item| exclude_json_by_paths(paths, item))
.collect();
Value::Array(filtered_arr)
}
_ => json_value.clone(),
}
}
fn main() {
let args = args::get_args();
let markdown_file = args.get_one::<String>("markdown").unwrap();
let (frontmatter, markdown_content) = markdown_with_frontmatter(
&std::fs::read_to_string(markdown_file).quit_on_error("Could not read markdown file"),
);
let frontmatter = frontmatter
.ok_or(0)
.quit_on_error("Could not parse frontmatter");
let mut frontmatter = serde_json::to_value(
&serde_yaml::from_str::<serde_yaml::Value>(&frontmatter)
.quit_on_error("Frontmatter is no valid yaml"),
)
.unwrap();
if let Some(patch_file) = args.get_one::<String>("patch") {
let mut patch: Vec<json_patch::PatchOperation> = serde_json::from_str(
&(if patch_file == "-" {
let mut str = String::new();
std::io::stdin()
.read_to_string(&mut str)
.quit_on_error("Coult not read stdin");
str
} else {
std::fs::read_to_string(patch_file).quit_on_error("Could not read patch file")
}),
)
.quit_on_error("Could not parse patch file");
if args.get_flag("add") | args.get_flag("modify") | args.get_flag("delete") {
if !args.get_flag("add") {
patch = remove_add_operations(patch);
}
if !args.get_flag("modify") {
patch = remove_modify_operations(patch);
}
if !args.get_flag("delete") {
patch = remove_delete_operations(patch);
}
}
if args.get_flag("notest") {
patch = remove_test_operations(patch);
}
let mut patch = serde_json::to_value(patch).unwrap();
if let Some(excludes) = args.get_many::<String>("exclude") {
let excludes: Vec<String> = excludes.cloned().collect();
patch = exclude_json_paths(&patch, &excludes);
}
if let Some(includes) = args.get_many::<String>("keys") {
let includes: Vec<String> = includes.cloned().collect();
patch = include_only_json_paths(&patch, &includes);
}
let patch: Vec<json_patch::PatchOperation> = serde_json::from_value(patch).unwrap();
if args.get_flag("verbose") | args.get_flag("dryrun") {
for op in patch.clone() {
match op {
PatchOperation::Add(op) => {
eprintln!(
"Add {} at {}",
console::Style::new().green().apply_to(op.value),
console::Style::new().bright().red().apply_to(op.path)
);
}
PatchOperation::Remove(op) => {
eprintln!(
"Remove value at {}",
console::Style::new().bright().red().apply_to(op.path)
);
}
PatchOperation::Replace(op) => {
eprintln!(
"Replace value at {} with {}",
console::Style::new().bright().red().apply_to(op.path),
console::Style::new().green().apply_to(op.value)
);
}
PatchOperation::Move(op) => {
eprintln!(
"Move value from {} to {}",
console::Style::new().bright().red().apply_to(op.from),
console::Style::new().green().apply_to(op.path)
);
}
PatchOperation::Copy(op) => {
eprintln!(
"Copy value from {} to {}",
console::Style::new().bright().red().apply_to(op.from),
console::Style::new().green().apply_to(op.path)
);
}
PatchOperation::Test(op) => {
eprintln!(
"Test value at {} for {}",
console::Style::new().bright().red().apply_to(op.path),
console::Style::new().green().apply_to(op.value)
);
}
}
}
}
if !args.get_flag("dryrun") {
if let Err(e) = json_patch::patch(&mut frontmatter, &patch) {
eprintln!(
"{} Patch failed",
console::Style::new().red().apply_to("Error:")
);
eprintln!("{e}");
std::process::exit(1);
}
let markdown_doc = assemble_markdown_doc(&markdown_content, frontmatter);
if let Some(output_path) = args.get_one::<String>("output") {
if output_path == "-" {
print!("{markdown_doc}");
} else {
std::fs::write(output_path, markdown_doc)
.quit_on_error("Could not write output file");
}
} else {
std::fs::write(markdown_file, markdown_doc)
.quit_on_error("Could not write patched file");
}
}
} else if let Some(merge_file) = args.get_one::<String>("merge") {
// TODO : Add support for merging frontmatter from markdown file
let mut merge_doc: serde_json::Value = serde_json::from_str(
&(if merge_file == "-" {
let mut str = String::new();
std::io::stdin()
.read_to_string(&mut str)
.quit_on_error("Coult not read stdin");
str
} else {
std::fs::read_to_string(merge_file).quit_on_error("Could not read merge file")
}),
)
.quit_on_error("Could not parse merge file");
if let Some(excludes) = args.get_many::<String>("exclude") {
let excludes: Vec<String> = excludes.cloned().collect();
merge_doc = exclude_json_by_paths(&excludes, &merge_doc);
}
if let Some(includes) = args.get_many::<String>("keys") {
let includes: Vec<String> = includes.cloned().collect();
merge_doc = only_include_json_pointers(&includes, &merge_doc);
}
if args.get_flag("verbose") | args.get_flag("dryrun") {
eprintln!("Merging {}", serde_json::to_string(&merge_doc).unwrap());
}
if !args.get_flag("dryrun") {
json_patch::merge(&mut frontmatter, &merge_doc);
let markdown_doc = assemble_markdown_doc(&markdown_content, frontmatter);
if let Some(output_path) = args.get_one::<String>("output") {
if output_path == "-" {
print!("{markdown_doc}");
} else {
std::fs::write(output_path, markdown_doc)
.quit_on_error("Could not write output file");
}
} else {
std::fs::write(markdown_file, markdown_doc)
.quit_on_error("Could not write patched file");
}
}
}
}