use clap::ArgMatches;
use clap_complete::Shell::*;
use regex::Regex;
use rusqlite::Error;
use crate::config::ConfigOptions;
use crate::graph::zk_graph_dot_output;
use crate::zettel::strip_multiple_whitespace;
use crate::Database;
use crate::Zettel;
use crate::cli;
use crate::io::{abs_path, file_exists};
struct Printer
{
zettel: Vec<Zettel>,
additional: Vec<String>,
format: String,
link_separator: String,
}
impl Printer
{
fn set_zettelkasten(&mut self, new_zettel: Vec<Zettel>)
{
self.zettel = new_zettel;
}
fn set_additional(&mut self, new_additional: Vec<String>)
{
self.additional = new_additional;
}
fn set_format(&mut self, new_format: String)
{
self.format = new_format;
}
fn set_link_separator(&mut self, new_separator: String)
{
self.link_separator = new_separator;
}
fn print_one(&mut self, cfg: &ConfigOptions, zettel: Zettel)
{
self.zettel = vec![zettel];
self.print(cfg);
}
fn print(&mut self, cfg: &ConfigOptions)
{
let len_diff = self.zettel.len() - self.additional.len();
let mut empty_diff = vec!["".to_string(); len_diff];
self.additional.append(&mut empty_diff);
for (z, a) in self.zettel.iter().zip(&self.additional) {
let mut result = self.format.to_string();
result = result.replace("%t", &z.title);
result = result.replace("%p", &z.project);
result = result.replace("%P", &z.filename(cfg));
result = result.replace("%l", &z.links.join(&self.link_separator));
result = result.replace("%a", a);
result = result.replace("%b", &z.backlinks.join(&self.link_separator));
println!("{}", result);
}
}
}
impl Default for Printer
{
fn default() -> Printer
{
Printer { zettel: vec![],
additional: vec![],
format: "[%p] %t".to_string(),
link_separator: "|".to_string() }
}
}
pub fn sync(matches: &ArgMatches, cfg: &ConfigOptions) -> Result<(), Error>
{
Database::new(&cfg.db_file())?.init()?;
let project = realproject(if let Some(p) = matches.get_one::<String>("PROJECT") {
p
} else {
""
});
if let Some(title) = matches.get_one::<String>("CREATE") {
create(cfg, title, project)?;
} else if let Some(path) = matches.get_one::<String>("UPDATE") {
update(cfg, path)?;
} else if let Some(title) = matches.get_one::<String>("MOVE") {
mv(cfg, title, project)?;
} else if matches.contains_id("RENAME") {
let args_arr = matches.get_many::<String>("RENAME")
.unwrap_or_default()
.map(|a| a.to_string())
.collect::<Vec<_>>();
rename(cfg, &args_arr)?;
} else if matches.get_flag("GENERATE") {
generate(cfg)?;
}
Ok(())
}
pub fn query(matches: &ArgMatches, cfg: &ConfigOptions) -> Result<(), Error>
{
let db = Database::new(&cfg.db_file())?;
db.init()?;
let mut zs: Vec<Zettel> = db.all()?;
let mut printer = Printer::default();
let exact = matches.get_flag("EXACT_MATCH");
if let Some(title) = matches.get_one::<String>("TITLE") {
zs = filter_title(zs, title, exact);
}
if let Some(project) = matches.get_one::<String>("PROJECT") {
zs = filter_project(zs, realproject(project), exact);
}
if let Some(tag) = matches.get_one::<String>("TAG") {
zs = filter_tag(zs, tag, exact);
}
if let Some(linked_from) = matches.get_one::<String>("LINKS") {
zs = intersect(&zs, &fwlinks(&db.all()?, linked_from, exact));
}
if let Some(links_to) = matches.get_one::<String>("BACKLINKS") {
zs = intersect(&zs, &backlinks(&zs, links_to, exact));
}
if let Some(text) = matches.get_one::<String>("TEXT_REGEX") {
let vs = filter_text(zs.clone(), text, cfg);
let mut texts: Vec<String> = vec![];
let mut found = vec![];
for (z, t) in vs {
found.push(z);
texts.push(t);
}
printer.set_additional(texts);
zs = intersect(&zs, &found);
}
if matches.get_flag("LONERS") {
zs = filter_isolated(zs);
}
if matches.get_flag("GRAPH") {
zk_graph_dot_output(&zs);
return Ok(());
}
if let Some(format) = matches.get_one::<String>("FORMAT") {
let link_sep = matches.get_one::<&str>("LINK_SEP").unwrap_or(&" | ");
printer.set_format(replace_literals(format));
printer.set_link_separator(replace_literals(link_sep));
}
printer.set_zettelkasten(zs);
printer.print(cfg);
Ok(())
}
fn replace_literals(s: &str) -> String
{
s.replace(r"\n", "\n").replace(r"\t", "\t")
}
pub fn ls(matches: &ArgMatches, cfg: &ConfigOptions) -> Result<(), Error>
{
let db = Database::new(&cfg.db_file())?;
db.init()?;
let obj = if let Some(m) = matches.get_one::<String>("OBJECT") {
m
} else {
""
};
match obj {
"tags" => print_list_of_strings(&db.list_tags()?),
"ghosts" => print_list_of_strings(&db.zettel_not_yet_created()?),
"projects" => print_list_of_strings(&db.list_projects()?),
"path" => println!("{}", cfg.zettelkasten),
_ => eprintln!("error: expected one of: 'tags', 'ghosts', 'projects', 'path'; got '{}'",
obj),
}
Ok(())
}
pub fn compl(matches: &ArgMatches) -> Result<(), Error>
{
let shell = matches.get_one::<String>("SHELL").unwrap();
let sh = match shell.as_str() {
"zsh" => Some(Zsh),
"bash" => Some(Bash),
"fish" => Some(Fish),
_ => None,
};
if let Some(sh) = sh {
let app = &mut cli::build();
clap_complete::generate(sh, app, app.get_name().to_string(), &mut std::io::stdout());
} else {
eprintln!("error: '{}' isn't a (supported) shell", shell);
}
Ok(())
}
fn print_list_of_strings(elems: &[String])
{
elems.iter().for_each(|e| {
println!("{}", e);
})
}
fn filter_title(zs: Vec<Zettel>, pattern: &str, exact: bool) -> Vec<Zettel>
{
let re = Regex::new(&format!("^{}$", pattern)).unwrap();
zs.into_iter()
.filter(|z| {
if exact {
pattern == z.title
} else {
re.is_match(&z.title)
}
})
.collect()
}
fn filter_project(zs: Vec<Zettel>, pattern: &str, exact: bool) -> Vec<Zettel>
{
let re = Regex::new(&format!("^{}$", pattern)).unwrap();
zs.into_iter()
.filter(|z| {
if exact {
pattern == z.project
} else {
re.is_match(&z.project)
}
})
.collect()
}
fn filter_text(zs: Vec<Zettel>, pattern: &str, cfg: &ConfigOptions) -> Vec<(Zettel, String)>
{
zs.into_iter()
.map(|z| (z.clone(), z.find_pattern(cfg, pattern)))
.filter(|(_, t)| !t.is_empty())
.collect()
}
fn filter_tag(zs: Vec<Zettel>, pattern: &str, exact: bool) -> Vec<Zettel>
{
let re = Regex::new(&format!("^{}(/.*)?$", pattern)).unwrap();
zs.into_iter()
.filter(|z| {
if let Some(t) = z.tags.first() {
return if exact { pattern == t } else { re.is_match(t) };
}
false
})
.collect()
}
fn filter_isolated(zs: Vec<Zettel>) -> Vec<Zettel>
{
zs.into_iter()
.filter(|z| z.links.is_empty() && z.backlinks.is_empty())
.collect()
}
fn intersect<T: Eq + Clone>(a: &[T], b: &[T]) -> Vec<T>
{
a.to_owned()
.iter()
.cloned()
.filter(|z| b.contains(z))
.collect::<Vec<_>>()
}
fn fwlinks(all: &[Zettel], linked_from: &str, exact: bool) -> Vec<Zettel>
{
let fwlinks = &filter_title(all.to_owned(), linked_from, exact);
all.iter()
.cloned()
.filter(|z| {
for fw in fwlinks {
if fw.links.contains(&z.title) {
return true;
}
}
false
})
.collect()
}
fn backlinks(all: &[Zettel], links_to: &str, exact: bool) -> Vec<Zettel>
{
let re = Regex::new(&format!("^{}$", links_to)).unwrap();
all.iter()
.cloned()
.filter(|z| {
for l in z.links.clone() {
if links_to == l || (!exact && re.is_match(&l)) {
return true;
};
}
false
})
.collect()
}
fn create(cfg: &ConfigOptions, title: &str, project: &str) -> Result<(), Error>
{
let db = Database::new(&cfg.db_file())?;
let actual_title = strip_multiple_whitespace(title);
let zettel = Zettel::new(&actual_title, project);
if actual_title != title {
eprintln!("warning: truncating newlines, tabs and/or multiple consecutive whitespaces");
}
if zettel.title.is_empty() || zettel.title.starts_with('.') {
eprintln!("error: empty/dotfile titles are not accepted");
return Ok(());
}
let exists_in_fs = file_exists(&zettel.filename(cfg));
let exists_in_db = db.find_by_title(&zettel.title)?.len() == 1;
if exists_in_fs && exists_in_db {
eprintln!("error: couldn't create new Zettel: one with the same title already exists");
return Ok(());
} else if !exists_in_fs && exists_in_db {
eprintln!("Zettel exists in the database but not on the filesystem; replaced entry");
db.delete(&zettel)?;
} else if exists_in_fs && !exists_in_db {
eprintln!("Zettel exists on the filesystem but not in the database; added entry");
} else {
zettel.create(cfg);
Printer::default().print_one(cfg, zettel.clone());
}
db.save(&zettel)?;
Ok(())
}
fn rename(cfg: &ConfigOptions, arr: &[String]) -> Result<(), Error>
{
let db = Database::new(&cfg.db_file())?;
let old_title: String = arr.iter()
.cloned()
.find(|x| !db.find_by_title(x).unwrap_or_default().is_empty())
.unwrap_or(String::from(""));
let new_title: String = arr.last().unwrap_or(&String::from("")).to_string();
if old_title == new_title {
eprintln!("error: first match is the same as the new title ('{}'), so no rename",
old_title);
return Ok(());
}
let results = db.find_by_title(&old_title)?;
let overwrite_failsafe = db.find_by_title(&new_title)?;
if overwrite_failsafe.first().is_some() {
eprintln!("error: a note with the new title already exists: won't overwrite");
return Ok(());
}
let old_zettel = if results.first().is_none() {
eprintln!("error: no Zettel with that title");
return Ok(());
} else {
results.first().unwrap()
};
let new_zettel = Zettel::new(&new_title, &old_zettel.project);
let mut dial = dialoguer::Confirm::new();
let prompt = dial.with_prompt(format!("{} --> {}", old_title, new_title));
if prompt.interact().unwrap_or_default() {
crate::io::rename(&old_zettel.filename(cfg), &new_zettel.filename(cfg));
db.change_title(old_zettel, &new_title).unwrap();
let backlinks = backlinks(&db.all()?, &old_title, true);
backlinks.iter().for_each(|bl| {
let contents = crate::io::file_to_string(&bl.filename(cfg));
let regex_string =
&format!(r"\[\[{}\]\]", old_title).replace(' ', r"[\n\t ]");
let old_title_reg = Regex::new(regex_string).unwrap();
let new_contents =
old_title_reg.replace_all(&contents, format!(r"[[{}]]", new_title));
crate::io::write_to_file(&bl.filename(cfg), &new_contents);
db.update(cfg, bl).unwrap();
})
}
Ok(())
}
fn mv(cfg: &ConfigOptions, pattern: &str, project: &str) -> Result<(), Error>
{
let db = Database::new(&cfg.db_file())?;
let re = Regex::new(pattern).unwrap();
let zs: Vec<Zettel> = db.all()?
.into_iter()
.filter(|z| re.is_match(&z.title))
.collect();
let mut printer = Printer::default();
printer.set_zettelkasten(zs.clone());
printer.print(cfg);
let mut dial = dialoguer::Confirm::new();
let prompt =
dial.with_prompt(format!(">> These notes will be transferred to the {}. Proceed?",
if project.is_empty() {
"main zettelkasten".to_string()
} else {
format!("'{}' project", project)
}));
if prompt.interact().unwrap_or_default() {
crate::io::mkdir(&format!("{}/{}", cfg.zettelkasten, project));
let new_notes = zs.iter().map(|z| Zettel { title: z.title.clone(),
project: project.to_string(),
links: z.links.clone(),
backlinks: z.backlinks.clone(),
tags: z.tags.clone() });
let pairs = zs.iter().zip(new_notes);
pairs.for_each(|(old, new)| {
crate::io::rename(&old.filename(cfg), &new.filename(cfg));
db.change_project(old, project).unwrap();
});
}
Ok(())
}
fn update(cfg: &ConfigOptions, path: &str) -> Result<(), Error>
{
let db = Database::new(&cfg.db_file())?;
let path_abs = abs_path(path);
if file_exists(&path_abs) {
let zettel = Zettel::from_file(cfg, &path_abs);
if file_exists(&zettel.filename(cfg)) {
db.update(cfg, &zettel)?;
} else {
eprintln!("error: file is not in the Zettelkasten");
}
} else {
eprintln!("error: provided path isn't a file");
}
Ok(())
}
fn generate(cfg: &ConfigOptions) -> Result<(), Error>
{
let start = std::time::Instant::now();
let mem_db = Database::new_in_memory(&cfg.db_file())?;
mem_db.init()?;
mem_db.generate(cfg)?;
mem_db.write_to(&cfg.db_file())?;
println!("database generated successfully, took {}ms",
start.elapsed().as_millis());
Ok(())
}
fn realproject(project: &str) -> &str
{
match project {
"main" | "" => "", any => any, }
}