summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bin/jt.rs29
-rw-r--r--src/cmd.rs143
-rw-r--r--src/config.rs130
-rw-r--r--src/error.rs116
-rw-r--r--src/git.rs62
-rw-r--r--src/journal.rs258
-rw-r--r--src/lib.rs7
-rw-r--r--src/opt.rs75
-rw-r--r--src/template.rs66
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", &current_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");
+ }
+ }
+}