use std::io::{self, Read};
use std::path::PathBuf;
use chrono::Local;
use minijinja::{context, Environment};
use rand::distr::Alphanumeric;
use rand::Rng;
use diwe::config::{Configuration, NoteTemplate, DEFAULT_KEY_DATE_FORMAT};
use liwe::locale::get_locale;
use liwe::model::{strip_doc_extension, Key};
#[derive(Debug, Clone, Default, clap::ValueEnum)]
pub enum IfExists {
#[default]
Suffix,
Override,
Skip,
Fail,
}
fn get_default_template() -> NoteTemplate {
NoteTemplate {
key_template: "{{slug}}".to_string(),
document_template: "# {{title}}\n\n{{content}}".to_string(),
}
}
pub struct DocumentCreator<'a> {
config: &'a Configuration,
library_path: PathBuf,
}
pub struct CreateOptions {
pub title: String,
pub template_name: Option<String>,
pub content: Option<String>,
pub key: Option<String>,
pub if_exists: IfExists,
}
pub struct CreatedDocument {
pub path: PathBuf,
}
impl<'a> DocumentCreator<'a> {
pub fn new(config: &'a Configuration, library_path: PathBuf) -> Self {
Self {
config,
library_path,
}
}
fn find_available_key(&self, base_key: &Key) -> Key {
let mut candidate_key = base_key.clone();
let mut counter = 1;
while self
.library_path
.join(candidate_key.to_path(self.config.format))
.exists()
{
let suffixed_name = format!("{}-{}", base_key, counter);
candidate_key = Key::name(&suffixed_name);
counter += 1;
}
candidate_key
}
pub fn create(&self, options: CreateOptions) -> Result<Option<CreatedDocument>, String> {
let template_name = options
.template_name
.or_else(|| self.config.library.default_template.clone())
.unwrap_or_else(|| "default".to_string());
let fallback_template = get_default_template();
let template = self
.config
.templates
.get(&template_name)
.or_else(|| {
if template_name == "default" {
Some(&fallback_template)
} else {
None
}
})
.ok_or_else(|| format!("Template '{}' not found in configuration", template_name))?;
let content = options.content.unwrap_or_default();
let key_date_format = self
.config
.library
.date_format
.clone()
.unwrap_or_else(|| DEFAULT_KEY_DATE_FORMAT.to_string());
let format_options = self.config.format_options();
let content_date_format = format_options
.date_format()
.unwrap_or("%b %d, %Y")
.to_string();
let key_time_format = self
.config
.library
.time_format
.clone()
.unwrap_or_else(|| key_date_format.clone());
let content_time_format = format_options
.time_format()
.map(|format| format.to_string())
.unwrap_or_else(|| content_date_format.clone());
let key_locale = get_locale(self.config.library.locale.as_deref());
let content_locale = get_locale(format_options.locale());
let now = Local::now();
let key_today = now
.format_localized(&key_date_format, key_locale)
.to_string();
let content_today = now
.format_localized(&content_date_format, content_locale)
.to_string();
let key_now = now
.format_localized(&key_time_format, key_locale)
.to_string();
let content_now = now
.format_localized(&content_time_format, content_locale)
.to_string();
let slug = string_to_slug(&options.title);
let id = generate_random_id();
let relative_key = match &options.key {
Some(key) => {
if strip_doc_extension(key) != key.as_str() {
return Err(format!("Key '{}' must not include a file extension", key));
}
key.clone()
}
None => render_template(
&template.key_template,
&options.title,
&slug,
&key_today,
&key_now,
&id,
&content,
)?,
};
let document_content = render_template(
&template.document_template,
&options.title,
&slug,
&content_today,
&content_now,
&id,
&content,
)?;
let base_key = Key::name(&relative_key);
if base_key.as_str().is_empty() {
return Err(if options.key.is_some() {
"Provided key is empty.".to_string()
} else {
"Generated key is empty. Use a non-empty title.".to_string()
});
}
let path_str = base_key.to_path(self.config.format);
let filename_len = std::path::Path::new(&path_str)
.file_name()
.map(|f| f.len())
.unwrap_or(path_str.len());
if filename_len > 255 {
return Err(format!(
"Generated filename is too long ({} bytes, max 255). Use a shorter title.",
filename_len
));
}
let file_path = self.library_path.join(base_key.to_path(self.config.format));
let file_exists = file_path.exists();
let final_key = match options.if_exists {
IfExists::Skip if file_exists => return Ok(None),
IfExists::Fail if file_exists => {
return Err(format!("Document '{}' already exists", base_key))
}
IfExists::Suffix => self.find_available_key(&base_key),
IfExists::Override | IfExists::Skip | IfExists::Fail => base_key,
};
let file_path = self
.library_path
.join(final_key.to_path(self.config.format));
if let Some(parent) = file_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create parent directories: {}", e))?;
}
}
std::fs::write(&file_path, document_content)
.map_err(|e| format!("Failed to write file: {}", e))?;
let absolute_path = file_path.canonicalize().unwrap_or(file_path);
Ok(Some(CreatedDocument {
path: absolute_path,
}))
}
}
pub fn read_stdin_if_available() -> String {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
return String::new();
}
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer).unwrap_or_default();
buffer
}
fn render_template(
template_str: &str,
title: &str,
slug: &str,
today: &str,
now: &str,
id: &str,
content: &str,
) -> Result<String, String> {
Environment::new()
.template_from_str(template_str)
.map_err(|e| format!("Invalid template syntax: {}", e))?
.render(context! {
title => title,
slug => slug,
today => today,
now => now,
id => id,
content => content,
})
.map_err(|e| format!("Template rendering failed: {}", e))
}
fn generate_random_id() -> String {
rand::rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect::<String>()
.to_lowercase()
}
fn string_to_slug(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}