diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/bin/jt.rs | 29 | ||||
-rw-r--r-- | src/cmd.rs | 143 | ||||
-rw-r--r-- | src/config.rs | 130 | ||||
-rw-r--r-- | src/error.rs | 116 | ||||
-rw-r--r-- | src/git.rs | 62 | ||||
-rw-r--r-- | src/journal.rs | 258 | ||||
-rw-r--r-- | src/lib.rs | 7 | ||||
-rw-r--r-- | src/opt.rs | 75 | ||||
-rw-r--r-- | src/template.rs | 66 |
9 files changed, 886 insertions, 0 deletions
diff --git a/src/bin/jt.rs b/src/bin/jt.rs new file mode 100644 index 0000000..21bf254 --- /dev/null +++ b/src/bin/jt.rs @@ -0,0 +1,29 @@ +use jt::config::Configuration; +use jt::opt::{Opt, SubCommand}; + +use clap::Parser; + +fn main() { + if let Err(err) = do_work() { + eprintln!("ERROR: {:?}", err); + std::process::exit(1); + } +} + +fn do_work() -> anyhow::Result<()> { + pretty_env_logger::init_custom_env("JT_LOG"); + let opt = Opt::parse(); + let config = Configuration::read(&opt)?; + match opt.cmd { + SubCommand::Config(x) => x.run(&config)?, + SubCommand::Init(x) => x.run(&config)?, + SubCommand::IsJournal(x) => x.run(&config)?, + SubCommand::New(x) => x.run(&config)?, + SubCommand::NewTopic(x) => x.run(&config)?, + SubCommand::List(x) => x.run(&config)?, + SubCommand::Edit(x) => x.run(&config)?, + SubCommand::Remove(x) => x.run(&config)?, + SubCommand::Finish(x) => x.run(&config)?, + } + Ok(()) +} diff --git a/src/cmd.rs b/src/cmd.rs new file mode 100644 index 0000000..9c96af1 --- /dev/null +++ b/src/cmd.rs @@ -0,0 +1,143 @@ +use crate::config::Configuration; +use crate::error::JournalError; +use crate::journal::Journal; +use clap::Parser; +use log::debug; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +pub struct Config {} + +impl Config { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + config.dump(); + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct Init { + #[clap(help = "Short name for journal")] + journalname: String, + + #[clap(help = "Short description of journal, its title")] + description: String, +} + +impl Init { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + debug!( + "init: journalname={:?} description={:?}", + self.journalname, self.description + ); + Journal::init(&config.dirname, &config.entries)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct IsJournal {} + +impl IsJournal { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + if !Journal::is_journal(&config.dirname, &config.entries) { + return Err(JournalError::NotAJournal( + config.dirname.display().to_string(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct New { + #[clap(help = "Title of new draft")] + title: String, + + #[clap(long, help = "Add links to topic pages")] + topic: Vec<PathBuf>, +} + +impl New { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + journal.new_draft(&self.title, &self.topic, &config.editor)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct List {} + +impl List { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + journal.list_drafts()?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct NewTopic { + #[clap(help = "Path to topic page in journal")] + path: PathBuf, + + #[clap(help = "Title of topic page")] + title: String, +} + +impl NewTopic { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + journal.new_topic(&self.path, &self.title, &config.editor)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct Edit { + /// Draft id. + draft: String, +} + +impl Edit { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + let filename = journal.pick_draft(&self.draft)?; + journal.edit_draft(&config.editor, &filename)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct Remove { + /// Draft id. + draft: String, +} + +impl Remove { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + let filename = journal.pick_draft(&self.draft)?; + journal.remove_draft(&filename)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct Finish { + /// Draft id. + draft: String, + + /// Set base name of published file. + basename: String, +} + +impl Finish { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + let filename = journal.pick_draft(&self.draft)?; + journal.finish_draft(&filename, &self.basename)?; + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6995e24 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,130 @@ +//! Configuration file handling. + +use crate::error::JournalError; +use crate::opt::Opt; + +use directories_next::ProjectDirs; +use serde::Deserialize; +use std::default::Default; +use std::path::{Path, PathBuf}; + +const APP: &str = "jt"; + +// The configuration file we read. +// +// Some of the fields are optional in the file. We will use default +// values for those, or get them command line options. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct InputConfiguration { + dirname: Option<PathBuf>, + editor: Option<String>, + entries: Option<PathBuf>, +} + +impl InputConfiguration { + fn read(filename: &Path) -> Result<Self, JournalError> { + let text = std::fs::read(filename) + .map_err(|err| JournalError::ReadConfig(filename.to_path_buf(), err))?; + let config = serde_yaml::from_slice(&text) + .map_err(|err| JournalError::ConfigSyntax(filename.to_path_buf(), err))?; + Ok(config) + } +} + +/// The run-time configuration. +/// +/// This is the configuration as read from the configuration file, if +/// any, and with all command line options applied. Nothing here is +/// optional. +#[derive(Debug, Deserialize)] +pub struct Configuration { + /// The directory where the journal is stored. + pub dirname: PathBuf, + + /// The editor to open for editing journal entry drafts. + pub editor: String, + + /// The directory where new entries are put. + /// + /// This is the full path name, not relative to `dirname`. + pub entries: PathBuf, +} + +impl Configuration { + /// Read configuration file. + /// + /// The configuration is read from the file specified by the user + /// on the command line, or from a default location following the + /// XDG base directory specification. Note that only one of those + /// is read. + /// + /// It's OK for the default configuration file to be missing, but + /// if one is specified by the user explicitly, that one MUST + /// exist. + pub fn read(opt: &Opt) -> Result<Self, JournalError> { + let proj_dirs = + ProjectDirs::from("", "", APP).expect("could not figure out home directory"); + let filename = match &opt.global.config { + Some(path) => { + if !path.exists() { + return Err(JournalError::ConfigMissing(path.to_path_buf())); + } + path.to_path_buf() + } + None => proj_dirs.config_dir().to_path_buf().join("config.yaml"), + }; + let input = if filename.exists() { + InputConfiguration::read(&filename)? + } else { + InputConfiguration::default() + }; + + let dirname = if let Some(path) = &opt.global.dirname { + path.to_path_buf() + } else if let Some(path) = &input.dirname { + expand_tilde(path) + } else { + proj_dirs.data_dir().to_path_buf() + }; + + Ok(Self { + dirname: dirname.clone(), + editor: if let Some(name) = &opt.global.editor { + name.to_string() + } else if let Some(name) = &input.editor { + name.to_string() + } else { + "/usr/bin/editor".to_string() + }, + entries: if let Some(entries) = &opt.global.entries { + dirname.join(entries) + } else if let Some(entries) = &input.entries { + dirname.join(entries) + } else { + dirname.join("entries") + }, + }) + } + + /// Write configuration to stdout. + pub fn dump(&self) { + println!("{:#?}", self); + } +} + +fn expand_tilde(path: &Path) -> PathBuf { + if path.starts_with("~/") { + if let Some(home) = std::env::var_os("HOME") { + let mut expanded = PathBuf::from(home); + for comp in path.components().skip(1) { + expanded.push(comp); + } + expanded + } else { + path.to_path_buf() + } + } else { + path.to_path_buf() + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..c86ad13 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,116 @@ +//! Errors returned from functions in this crate. + +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum JournalError { + /// Configuration file does not exist. + #[error("specified configuration file does not exist: {0}")] + ConfigMissing(PathBuf), + + /// Failed to read the configuration file. + /// + /// This is for permission problems and such. + #[error("failed to read configuration file {0}")] + ReadConfig(PathBuf, #[source] std::io::Error), + + /// Configuration file has a syntax error. + #[error("failed to understand configuration file syntax: {0}")] + ConfigSyntax(PathBuf, #[source] serde_yaml::Error), + + /// The specified directory does not look like a journal. + #[error("directory {0} is not a journal")] + NotAJournal(String), + + /// The specified directory for the journal does not exist. + #[error("journal directory does not exist: {0}")] + NoJournalDirectory(PathBuf), + + /// Failed to create the directory for the journal. + #[error("failed to create journal directory {0}")] + CreateDirectory(PathBuf, #[source] std::io::Error), + + /// Failed to rename entry when finishing it. + #[error("failed to rename journal entry {0} to {1}: {2}")] + RenameEntry(PathBuf, PathBuf, #[source] std::io::Error), + + /// Failed to rename draft. + #[error("failed to remove draft {0}: {1}")] + RemoveDraft(PathBuf, #[source] std::io::Error), + + /// Failed to write entry. + #[error("failed to create journal entry {0}: {1}")] + WriteEntry(PathBuf, #[source] std::io::Error), + + /// Failed to write topic page. + #[error("failed to create topic page {0}: {1}")] + WriteTopic(PathBuf, #[source] std::io::Error), + + /// User chose a draft that doesn't exist. + #[error("No draft {0} in {1}")] + NoSuchDraft(String, PathBuf), + + /// Too many drafts. + #[error("there are already {0} drafts in {1}, can't create more")] + TooManyDrafts(usize, PathBuf), + + /// User chose a topic that doesn't exist. + #[error("No topic page {0}")] + NoSuchTopic(PathBuf), + + /// Failed to read draft. + #[error("failed to drafts {0}: {1}")] + ReadDraft(PathBuf, #[source] std::io::Error), + + /// Draft is not UTF8. + #[error("draft {0} is not UTF8: {1}")] + DraftNotUUtf8(PathBuf, #[source] std::string::FromUtf8Error), + + /// Failed to get metadata for specific file in drafts folder. + #[error("failed to stat draft in {0}: {1}")] + StatDraft(PathBuf, #[source] std::io::Error), + + /// Error spawning git. + #[error("failed to start git in {0}: {1}")] + SpawnGit(PathBuf, std::io::Error), + + /// Git init failed. + #[error("git init failed in {0}: {1}")] + GitInit(PathBuf, String), + + /// Git add failed. + #[error("git add failed in {0}: {1}")] + GitAdd(PathBuf, String), + + /// Git commit failed. + #[error("git commit failed in {0}: {1}")] + GitCommit(PathBuf, String), + + /// Error spawning editor. + #[error("failed to start editor {0}: {1}")] + SpawnEditor(PathBuf, #[source] std::io::Error), + + /// Editor failed. + #[error("editor {0} failed: {1}")] + EditorFailed(PathBuf, String), + + /// Template not found. + #[error("template not found: {0}")] + TemplateNotFound(String), + + /// Failed to render a Tera template. + #[error("template {0} failed to render: {1}")] + TemplateRender(String, #[source] tera::Error), + + /// Failed to make a path relative to a directory. + #[error("failed to make {0} relative to {1}: {2}")] + RelativePath(PathBuf, PathBuf, std::path::StripPrefixError), + + /// Problem with glob pattern. + #[error("Error in glob pattern {0}: {1}")] + PatternError(String, #[source] glob::PatternError), + + /// Problem when matching glob pattern on actual files. + #[error("Failed to match glob pattern {0}: {1}")] + GlobError(String, #[source] glob::GlobError), +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..5bb53f0 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,62 @@ +use crate::error::JournalError; +use log::debug; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +pub fn init<P: AsRef<Path>>(dirname: P) -> Result<(), JournalError> { + let dirname = dirname.as_ref(); + debug!("git init {}", dirname.display()); + + let output = Command::new("git") + .arg("init") + .current_dir(dirname) + .output() + .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?; + debug!("git init exit code was {:?}", output.status.code()); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(JournalError::GitInit(dirname.to_path_buf(), stderr)); + } + Ok(()) +} + +pub fn add<P: AsRef<Path>>(dirname: P, files: &[P]) -> Result<(), JournalError> { + let args = &["add", "--"]; + let mut args: Vec<OsString> = args.iter().map(OsString::from).collect(); + for f in files { + args.push(OsString::from(f.as_ref())); + } + + let dirname = dirname.as_ref(); + debug!("git add in {}", dirname.display()); + + let output = Command::new("git") + .args(&args) + .current_dir(dirname) + .output() + .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?; + debug!("git exit code was {:?}", output.status.code()); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(JournalError::GitAdd(dirname.to_path_buf(), stderr)); + } + Ok(()) +} + +pub fn commit<P: AsRef<Path>>(dirname: P, msg: &str) -> Result<(), JournalError> { + let dirname = dirname.as_ref(); + debug!("git commit in {}", dirname.display()); + + let output = Command::new("git") + .args(["commit", "-m", msg]) + .current_dir(dirname) + .output() + .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?; + debug!("git exit code was {:?}", output.status.code()); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(JournalError::GitCommit(dirname.to_path_buf(), stderr)); + } + Ok(()) +} diff --git a/src/journal.rs b/src/journal.rs new file mode 100644 index 0000000..a0d7cda --- /dev/null +++ b/src/journal.rs @@ -0,0 +1,258 @@ +use crate::error::JournalError; +use crate::git; +use crate::template::Templates; +use chrono::{DateTime, Local}; +use glob::glob; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tera::Context; + +const MAX_DRAFT_COUNT: usize = 1000; + +pub struct Journal { + dirname: PathBuf, + entries: PathBuf, + templates: Templates, +} + +impl Journal { + pub fn is_journal(path: &Path, entries: &Path) -> bool { + is_dir(path) && is_dir(entries) + } + + pub fn init(path: &Path, entries: &Path) -> Result<Self, JournalError> { + std::fs::create_dir(path) + .map_err(|err| JournalError::CreateDirectory(path.to_path_buf(), err))?; + git::init(path)?; + std::fs::create_dir(entries) + .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?; + Ok(Self { + dirname: path.to_path_buf(), + entries: entries.to_path_buf(), + templates: Templates::new(path)?, + }) + } + + pub fn new(path: &Path, entries: &Path) -> Result<Self, JournalError> { + if Self::is_journal(path, entries) { + let dirname = path.to_path_buf(); + let entries = entries.to_path_buf(); + let templates = Templates::new(path)?; + Ok(Self { + dirname, + entries, + templates, + }) + } else { + Err(JournalError::NotAJournal(path.display().to_string())) + } + } + + fn dirname(&self) -> &Path { + &self.dirname + } + + fn relative(&self, path: &Path) -> Result<PathBuf, JournalError> { + let path = path.strip_prefix(self.dirname()).map_err(|err| { + JournalError::RelativePath(path.to_path_buf(), self.dirname().to_path_buf(), err) + })?; + Ok(path.to_path_buf()) + } + + fn drafts(&self) -> PathBuf { + self.dirname().join("drafts") + } + + fn entries(&self) -> PathBuf { + self.entries.clone() + } + + pub fn new_draft( + &self, + title: &str, + topics: &[PathBuf], + editor: &str, + ) -> Result<(), JournalError> { + let drafts = self.drafts(); + if !drafts.exists() { + std::fs::create_dir(&drafts) + .map_err(|err| JournalError::CreateDirectory(drafts.to_path_buf(), err))?; + } + + let mut context = Context::new(); + context.insert("title", title); + context.insert("date", ¤t_timestamp()); + let mut full_topics = vec![]; + for topic in topics.iter() { + let pathname = topic_path(self.dirname(), topic); + if !pathname.exists() { + return Err(JournalError::NoSuchTopic(topic.to_path_buf())); + } + full_topics.push(topic.display().to_string()); + } + context.insert("topics", &full_topics); + + let pathname = self.pick_file_id(&drafts)?; + let text = self.templates.new_draft(&context)?; + std::fs::write(&pathname, text) + .map_err(|err| JournalError::WriteEntry(pathname.to_path_buf(), err))?; + self.edit(editor, &pathname)?; + Ok(()) + } + + fn pick_file_id(&self, dirname: &Path) -> Result<PathBuf, JournalError> { + for i in 0..MAX_DRAFT_COUNT { + let basename = format!("{}.md", i); + let pathname = dirname.join(basename); + if !pathname.exists() { + return Ok(pathname); + } + } + Err(JournalError::TooManyDrafts( + MAX_DRAFT_COUNT, + dirname.to_path_buf(), + )) + } + + pub fn pick_draft(&self, id: &str) -> Result<PathBuf, JournalError> { + let drafts = self.drafts(); + let filename = drafts.join(format!("{}.md", id)); + if filename.exists() { + Ok(filename) + } else { + Err(JournalError::NoSuchDraft(id.to_string(), self.drafts())) + } + } + + pub fn list_drafts(&self) -> Result<(), JournalError> { + let prefix = format!("{}/", self.drafts().display()); + let pattern = format!("{}*.md", prefix); + let entries = + glob(&pattern).map_err(|err| JournalError::PatternError(pattern.to_string(), err))?; + for entry in entries { + let entry = entry.map_err(|err| JournalError::GlobError(pattern.to_string(), err))?; + let title = get_title(&entry)?; + let entry = entry.file_stem().unwrap().to_string_lossy(); + let entry = entry.strip_prefix(&prefix).unwrap_or(&entry); + println!("{} {}", entry, title); + } + Ok(()) + } + + fn edit(&self, editor: &str, filename: &Path) -> Result<(), JournalError> { + if editor == "none" { + return Ok(()); + } + match Command::new(editor).arg(filename).output() { + Err(err) => Err(JournalError::SpawnEditor(filename.to_path_buf(), err)), + Ok(output) => { + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(JournalError::EditorFailed( + filename.to_path_buf(), + stderr.into_owned(), + )) + } + } + } + } + + pub fn edit_draft(&self, editor: &str, filename: &Path) -> Result<(), JournalError> { + self.edit(editor, filename)?; + Ok(()) + } + + pub fn remove_draft(&self, filename: &Path) -> Result<(), JournalError> { + std::fs::remove_file(filename) + .map_err(|err| JournalError::RemoveDraft(filename.to_path_buf(), err))?; + Ok(()) + } + + pub fn finish_draft(&self, filename: &Path, basename: &str) -> Result<(), JournalError> { + let entries = self.entries(); + if !entries.exists() { + std::fs::create_dir(&entries) + .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?; + } + + let subdir = entries.join(Local::now().format("%Y/%m/%d").to_string()); + std::fs::create_dir_all(&subdir) + .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?; + + let basename = PathBuf::from(format!("{}.mdwn", basename)); + let entry = subdir.join(basename); + std::fs::rename(filename, &entry).map_err(|err| { + JournalError::RenameEntry(filename.to_path_buf(), entry.to_path_buf(), err) + })?; + + let entry = self.relative(&entry)?; + git::add(self.dirname(), &[&entry])?; + + let msg = format!("journal entry {}", entry.display()); + git::commit(self.dirname(), &msg)?; + Ok(()) + } + + pub fn new_topic(&self, path: &Path, title: &str, editor: &str) -> Result<(), JournalError> { + let mut context = Context::new(); + context.insert("title", title); + + let dirname = self.dirname(); + if !dirname.exists() { + return Err(JournalError::NoJournalDirectory(dirname.to_path_buf())); + } + + let pathname = topic_path(self.dirname(), path); + let parent = pathname.parent().unwrap(); + if !parent.is_dir() { + std::fs::create_dir_all(parent) + .map_err(|err| JournalError::CreateDirectory(parent.to_path_buf(), err))?; + } + + let text = self.templates.new_topic(&context)?; + std::fs::write(&pathname, text) + .map_err(|err| JournalError::WriteTopic(pathname.to_path_buf(), err))?; + self.edit(editor, &pathname)?; + + let topic = self.relative(&pathname)?; + git::add(self.dirname(), &[&topic])?; + + let msg = format!("new topic {}", topic.display()); + git::commit(self.dirname(), &msg)?; + + Ok(()) + } +} + +fn is_dir(path: &Path) -> bool { + if let Ok(meta) = std::fs::symlink_metadata(path) { + meta.is_dir() + } else { + false + } +} + +fn topic_path(dirname: &Path, topic: &Path) -> PathBuf { + dirname.join(format!("{}.mdwn", topic.display())) +} + +fn current_timestamp() -> String { + let now: DateTime<Local> = Local::now(); + now.to_rfc2822() +} + +fn get_title(filename: &Path) -> Result<String, JournalError> { + let text = std::fs::read(filename) + .map_err(|err| JournalError::ReadDraft(filename.to_path_buf(), err))?; + let text = String::from_utf8_lossy(&text); + let pat = Regex::new(r#"^\[\[!meta title="(?P<title>.*)"\]\]"#).unwrap(); + if let Some(caps) = pat.captures(&text) { + if let Some(m) = caps.name("title") { + return Ok(m.as_str().to_string()); + } + } + Ok("(untitled)".to_string()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..509e6f1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod cmd; +pub mod config; +pub mod error; +pub mod git; +pub mod journal; +pub mod opt; +pub mod template; diff --git a/src/opt.rs b/src/opt.rs new file mode 100644 index 0000000..5a81ab6 --- /dev/null +++ b/src/opt.rs @@ -0,0 +1,75 @@ +//! Command line options. + +use crate::cmd; +use clap::Parser; +use std::path::PathBuf; + +/// A parsed command line. +#[derive(Debug, Parser)] +#[clap(about = "maintain a journal")] +pub struct Opt { + /// Global options, common for all subcommands. + #[clap(flatten)] + pub global: GlobalOptions, + + /// The subcommand. + #[clap(subcommand)] + pub cmd: SubCommand, +} + +/// Global options. +/// +/// These options are common to all subcommands. +#[derive(Debug, Parser)] +pub struct GlobalOptions { + /// Which configuration file to read. + #[structopt(short, long, help = "Configuration file")] + pub config: Option<PathBuf>, + + /// Which directory to use for the journal. + #[structopt(short, long, help = "Directory where journal should be stored")] + pub dirname: Option<PathBuf>, + + /// Sub-directory in journal where new entries are put. + #[structopt(long)] + pub entries: Option<PathBuf>, + + /// Which editor to invoke for editing journal entry drafts. + #[structopt( + long, + short, + help = "Invoke EDITOR for user to edit draft journal entry" + )] + pub editor: Option<String>, +} + +/// A subcommand. +#[derive(Debug, Parser)] +pub enum SubCommand { + /// Show configuration. + Config(cmd::Config), + + /// Create a new journal in the chosen directory. + Init(cmd::Init), + + /// Check if a directory is a journal. + IsJournal(cmd::IsJournal), + + /// Create draft for a new journal entry. + New(cmd::New), + + /// List current drafts. + List(cmd::List), + + /// Create topic page. + NewTopic(cmd::NewTopic), + + /// Invoke editor on journal entry draft. + Edit(cmd::Edit), + + /// Remove a journal entry draft. + Remove(cmd::Remove), + + /// Finish a journal entry draft. + Finish(cmd::Finish), +} diff --git a/src/template.rs b/src/template.rs new file mode 100644 index 0000000..f001a34 --- /dev/null +++ b/src/template.rs @@ -0,0 +1,66 @@ +use crate::error::JournalError; +use std::path::Path; +use tera::{Context, Tera}; + +const NEW_ENTRY: &str = r#"[[!meta title="{{ title }}"]] +[[!meta date="{{ date }}"]] +{% for topic in topics %} +[[!meta link="{{ topic }}"]] +{% endfor %} + +"#; + +const NEW_TOPIC: &str = r#"[[!meta title="{{ title }}"]] + +Describe the topic here. + +# Entries + +[[!inline pages="link(.)" archive=yes reverse=yes trail=yes]] +"#; + +pub struct Templates { + tera: Tera, +} + +impl Templates { + pub fn new(dirname: &Path) -> Result<Self, JournalError> { + let glob = format!("{}/.config/templates/*", dirname.display()); + let mut tera = if let Ok(tera) = Tera::new(&glob) { + tera + } else { + Tera::default() + }; + add_default_template(&mut tera, "new_entry", NEW_ENTRY); + add_default_template(&mut tera, "new_topic", NEW_TOPIC); + Ok(Self { tera }) + } + + pub fn new_draft(&self, context: &Context) -> Result<String, JournalError> { + self.render("new_entry", context) + } + + pub fn new_topic(&self, context: &Context) -> Result<String, JournalError> { + self.render("new_topic", context) + } + + fn render(&self, name: &str, context: &Context) -> Result<String, JournalError> { + match self.tera.render(name, context) { + Ok(s) => Ok(s), + Err(e) => match e.kind { + tera::ErrorKind::TemplateNotFound(x) => Err(JournalError::TemplateNotFound(x)), + _ => Err(JournalError::TemplateRender(name.to_string(), e)), + }, + } + } +} + +fn add_default_template(tera: &mut Tera, name: &str, template: &str) { + let context = Context::new(); + if let Err(err) = tera.render(name, &context) { + if let tera::ErrorKind::TemplateNotFound(_) = err.kind { + tera.add_raw_template(name, template) + .expect("Tera::add_raw_template"); + } + } +} |