use chrono::{Local, NaiveDate};
use indexmap::IndexMap;
use serde_yaml::Value;
use std::path::{Path, PathBuf};
use crate::error::{DiaryxError, Result};
use crate::fs::FileSystem;
pub const TEMPLATE_VARIABLES: &[(&str, &str)] = &[
("title", "The entry title"),
("filename", "The filename without extension"),
(
"date",
"Current date (default: %Y-%m-%d). Use {{date:%B %d, %Y}} for custom format",
),
(
"time",
"Current time (default: %H:%M). Use {{time:%H:%M:%S}} for custom format",
),
(
"datetime",
"Current datetime (default: %Y-%m-%dT%H:%M:%S). Use {{datetime:FORMAT}} for custom",
),
(
"timestamp",
"ISO 8601 timestamp with timezone (for created/updated)",
),
("year", "Current year (4 digits)"),
("month", "Current month (2 digits)"),
("month_name", "Current month name (e.g., January)"),
("day", "Current day (2 digits)"),
("weekday", "Current weekday name (e.g., Monday)"),
];
pub const DEFAULT_NOTE_TEMPLATE: &str = r#"---
title: "{{title}}"
created: {{timestamp}}
---
# {{title}}
"#;
pub const DEFAULT_DAILY_TEMPLATE: &str = r#"---
date: {{date}}
title: "{{title}}"
created: {{timestamp}}
part_of: {{part_of}}
---
# {{title}}
"#;
#[derive(Debug, Clone)]
pub struct Template {
pub name: String,
pub raw_content: String,
}
impl Template {
pub fn new(name: impl Into<String>, raw_content: impl Into<String>) -> Self {
Self {
name: name.into(),
raw_content: raw_content.into(),
}
}
pub fn from_file<FS: FileSystem>(fs: &FS, path: &Path) -> Result<Self> {
let content = fs.read_to_string(path).map_err(|e| DiaryxError::FileRead {
path: path.to_path_buf(),
source: e,
})?;
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
Ok(Self::new(name, content))
}
pub fn builtin_note() -> Self {
Self::new("note", DEFAULT_NOTE_TEMPLATE)
}
pub fn builtin_daily() -> Self {
Self::new("daily", DEFAULT_DAILY_TEMPLATE)
}
pub fn render(&self, context: &TemplateContext) -> String {
substitute_variables(&self.raw_content, context)
}
pub fn render_parsed(
&self,
context: &TemplateContext,
) -> Result<(IndexMap<String, Value>, String)> {
let rendered = self.render(context);
parse_rendered_template(&rendered)
}
}
#[derive(Debug, Clone, Default)]
pub struct TemplateContext {
pub title: Option<String>,
pub filename: Option<String>,
pub date: Option<NaiveDate>,
pub part_of: Option<String>,
pub custom: IndexMap<String, String>,
}
impl TemplateContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
pub fn with_date(mut self, date: NaiveDate) -> Self {
self.date = Some(date);
self
}
pub fn with_part_of(mut self, part_of: impl Into<String>) -> Self {
self.part_of = Some(part_of.into());
self
}
pub fn with_custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.custom.insert(key.into(), value.into());
self
}
fn effective_date(&self) -> NaiveDate {
self.date.unwrap_or_else(|| Local::now().date_naive())
}
fn effective_title(&self) -> String {
self.title
.clone()
.or_else(|| self.filename.clone())
.unwrap_or_else(|| "Untitled".to_string())
}
}
fn substitute_variables(content: &str, context: &TemplateContext) -> String {
let mut result = content.to_string();
let now = Local::now();
let date = context.effective_date();
result = substitute_formatted_variables(&result, "date", |fmt| date.format(fmt).to_string());
result = substitute_formatted_variables(&result, "time", |fmt| now.format(fmt).to_string());
result = substitute_formatted_variables(&result, "datetime", |fmt| now.format(fmt).to_string());
let replacements: Vec<(&str, String)> = vec![
("title", context.effective_title()),
("filename", context.filename.clone().unwrap_or_default()),
("date", date.format("%Y-%m-%d").to_string()),
("time", now.format("%H:%M").to_string()),
("datetime", now.format("%Y-%m-%dT%H:%M:%S").to_string()),
("timestamp", now.format("%Y-%m-%dT%H:%M:%S%:z").to_string()),
("year", date.format("%Y").to_string()),
("month", date.format("%m").to_string()),
("month_name", date.format("%B").to_string()),
("day", date.format("%d").to_string()),
("weekday", date.format("%A").to_string()),
("part_of", context.part_of.clone().unwrap_or_default()),
];
for (var, value) in replacements {
let pattern = format!("{{{{{}}}}}", var);
result = result.replace(&pattern, &value);
}
for (key, value) in &context.custom {
let pattern = format!("{{{{{}}}}}", key);
result = result.replace(&pattern, value);
}
result
}
fn substitute_formatted_variables<F>(content: &str, var_name: &str, formatter: F) -> String
where
F: Fn(&str) -> String,
{
let mut result = content.to_string();
let prefix = format!("{{{{{}:", var_name);
while let Some(start) = result.find(&prefix) {
let rest = &result[start + prefix.len()..];
if let Some(end) = rest.find("}}") {
let format_str = &rest[..end];
let full_pattern = format!("{{{{{}:{}}}}}", var_name, format_str);
let replacement = formatter(format_str);
result = result.replace(&full_pattern, &replacement);
} else {
break;
}
}
result
}
fn parse_rendered_template(content: &str) -> Result<(IndexMap<String, Value>, String)> {
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return Ok((IndexMap::new(), content.to_string()));
}
let rest = &content[4..]; let end_idx = rest.find("\n---\n").or_else(|| rest.find("\n---\r\n"));
match end_idx {
Some(idx) => {
let frontmatter_str = &rest[..idx];
let body = &rest[idx + 5..];
let frontmatter: IndexMap<String, Value> = serde_yaml::from_str(frontmatter_str)?;
Ok((frontmatter, body.to_string()))
}
None => {
Ok((IndexMap::new(), content.to_string()))
}
}
}
pub struct TemplateManager<FS> {
fs: FS,
user_templates_dir: Option<PathBuf>,
workspace_templates_dir: Option<PathBuf>,
}
impl<FS: FileSystem> TemplateManager<FS> {
#[cfg(not(target_arch = "wasm32"))]
pub fn new(fs: FS) -> Self {
let user_templates_dir = dirs::config_dir().map(|d| d.join("diaryx").join("templates"));
Self {
fs,
user_templates_dir,
workspace_templates_dir: None,
}
}
#[cfg(target_arch = "wasm32")]
pub fn new(fs: FS) -> Self {
Self {
fs,
user_templates_dir: None,
workspace_templates_dir: None,
}
}
pub fn with_user_templates_dir(mut self, dir: PathBuf) -> Self {
self.user_templates_dir = Some(dir);
self
}
pub fn with_workspace_dir(mut self, workspace_dir: &Path) -> Self {
self.workspace_templates_dir = Some(workspace_dir.join(".diaryx").join("templates"));
self
}
pub fn user_templates_dir(&self) -> Option<&Path> {
self.user_templates_dir.as_deref()
}
pub fn workspace_templates_dir(&self) -> Option<&Path> {
self.workspace_templates_dir.as_deref()
}
pub fn get(&self, name: &str) -> Option<Template> {
if let Some(template) = self.load_from_dir(&self.workspace_templates_dir, name) {
return Some(template);
}
if let Some(template) = self.load_from_dir(&self.user_templates_dir, name) {
return Some(template);
}
self.get_builtin(name)
}
pub fn get_builtin(&self, name: &str) -> Option<Template> {
match name {
"note" => Some(Template::builtin_note()),
"daily" => Some(Template::builtin_daily()),
_ => None,
}
}
fn load_from_dir(&self, dir: &Option<PathBuf>, name: &str) -> Option<Template> {
let dir = dir.as_ref()?;
let path = dir.join(format!("{}.md", name));
if self.fs.exists(&path) {
Template::from_file(&self.fs, &path).ok()
} else {
None
}
}
pub fn list(&self) -> Vec<TemplateInfo> {
let mut templates = Vec::new();
let mut seen = std::collections::HashSet::new();
if let Some(ref dir) = self.workspace_templates_dir {
for info in self.list_templates_in_dir(dir, TemplateSource::Workspace) {
if seen.insert(info.name.clone()) {
templates.push(info);
}
}
}
if let Some(ref dir) = self.user_templates_dir {
for info in self.list_templates_in_dir(dir, TemplateSource::User) {
if seen.insert(info.name.clone()) {
templates.push(info);
}
}
}
for (name, source) in [
("note", TemplateSource::Builtin),
("daily", TemplateSource::Builtin),
] {
if seen.insert(name.to_string()) {
templates.push(TemplateInfo {
name: name.to_string(),
source,
path: None,
});
}
}
templates.sort_by(|a, b| a.name.cmp(&b.name));
templates
}
fn list_templates_in_dir(&self, dir: &Path, source: TemplateSource) -> Vec<TemplateInfo> {
let mut templates = Vec::new();
if let Ok(files) = self.fs.list_md_files(dir) {
for path in files {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
templates.push(TemplateInfo {
name: name.to_string(),
source: source.clone(),
path: Some(path),
});
}
}
}
templates
}
pub fn create_template(&self, name: &str, content: &str) -> Result<PathBuf> {
let dir = self
.user_templates_dir
.as_ref()
.ok_or(DiaryxError::NoConfigDir)?;
self.fs.create_dir_all(dir)?;
let path = dir.join(format!("{}.md", name));
self.fs.create_new(&path, content)?;
Ok(path)
}
pub fn save_template(&self, name: &str, content: &str) -> Result<PathBuf> {
let dir = self
.user_templates_dir
.as_ref()
.ok_or(DiaryxError::NoConfigDir)?;
self.fs.create_dir_all(dir)?;
let path = dir.join(format!("{}.md", name));
self.fs.write_file(&path, content)?;
Ok(path)
}
}
#[derive(Debug, Clone)]
pub struct TemplateInfo {
pub name: String,
pub source: TemplateSource,
pub path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateSource {
Builtin,
User,
Workspace,
}
impl std::fmt::Display for TemplateSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TemplateSource::Builtin => write!(f, "built-in"),
TemplateSource::User => write!(f, "user"),
TemplateSource::Workspace => write!(f, "workspace"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_variable_substitution() {
let template = Template::new("test", "Hello {{title}}!");
let context = TemplateContext::new().with_title("World");
let result = template.render(&context);
assert_eq!(result, "Hello World!");
}
#[test]
fn test_date_variables() {
let template = Template::new("test", "Date: {{date}}, Year: {{year}}, Month: {{month}}");
let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let context = TemplateContext::new().with_date(date);
let result = template.render(&context);
assert_eq!(result, "Date: 2024-06-15, Year: 2024, Month: 06");
}
#[test]
fn test_formatted_date_variable() {
let template = Template::new("test", "{{date:%B %d, %Y}}");
let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let context = TemplateContext::new().with_date(date);
let result = template.render(&context);
assert_eq!(result, "June 15, 2024");
}
#[test]
fn test_custom_variables() {
let template = Template::new("test", "Mood: {{mood}}, Weather: {{weather}}");
let context = TemplateContext::new()
.with_custom("mood", "happy")
.with_custom("weather", "sunny");
let result = template.render(&context);
assert_eq!(result, "Mood: happy, Weather: sunny");
}
#[test]
fn test_builtin_note_template() {
let template = Template::builtin_note();
let context = TemplateContext::new().with_title("My Note");
let result = template.render(&context);
assert!(result.contains("title: \"My Note\""));
assert!(result.contains("# My Note"));
assert!(result.contains("created:"));
}
#[test]
fn test_builtin_daily_template() {
let template = Template::builtin_daily();
let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let context = TemplateContext::new()
.with_title("June 15, 2024")
.with_date(date)
.with_part_of("06_june.md");
let result = template.render(&context);
assert!(result.contains("date: 2024-06-15"));
assert!(result.contains("title: \"June 15, 2024\""));
assert!(result.contains("part_of: 06_june.md"));
}
#[test]
fn test_render_parsed() {
let template = Template::new("test", "---\ntitle: \"{{title}}\"\n---\n\n# {{title}}\n");
let context = TemplateContext::new().with_title("Test");
let (frontmatter, body) = template.render_parsed(&context).unwrap();
assert_eq!(frontmatter.get("title").unwrap().as_str().unwrap(), "Test");
assert_eq!(body.trim(), "# Test");
}
#[test]
fn test_effective_title_fallback() {
let ctx = TemplateContext::new().with_title("My Title");
assert_eq!(ctx.effective_title(), "My Title");
let ctx = TemplateContext::new().with_filename("my-file");
assert_eq!(ctx.effective_title(), "my-file");
let ctx = TemplateContext::new();
assert_eq!(ctx.effective_title(), "Untitled");
}
#[test]
fn test_part_of_empty_when_not_set() {
let template = Template::new("test", "part_of: {{part_of}}");
let context = TemplateContext::new();
let result = template.render(&context);
assert_eq!(result, "part_of: ");
}
#[test]
fn test_timestamp_format() {
let template = Template::new("test", "{{timestamp}}");
let context = TemplateContext::new();
let result = template.render(&context);
assert!(result.contains("T"));
assert!(result.contains(":"));
assert!(result.contains("+") || result.contains("-"));
}
}