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 { 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 { 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 { 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 { 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 { 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::now(); now.to_rfc2822() } fn get_title(filename: &Path) -> Result { 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.*)"\]\]"#).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()) }