mod helpers;
pub use helpers::{apply_filename_style, prettify_filename, slugify, slugify_title};
use crate::config::Config;
use crate::date::{date_to_path, parse_date};
use crate::error::{DiaryxError, Result};
use crate::fs::{AsyncFileSystem, FileSystem};
use crate::link_parser;
use crate::template::{Template, TemplateContext, TemplateManager};
use chrono::{NaiveDate, Utc};
use indexmap::IndexMap;
use serde_yaml::Value;
use std::path::{Path, PathBuf};
pub struct DiaryxApp<FS: AsyncFileSystem> {
fs: FS,
}
pub struct DiaryxAppSync<FS: FileSystem> {
fs: FS,
}
impl<FS: AsyncFileSystem> DiaryxApp<FS> {
pub fn new(fs: FS) -> Self {
Self { fs }
}
pub fn fs(&self) -> &FS {
&self.fs
}
pub async fn create_entry(&self, path: &str) -> Result<()> {
let content = format!("---\ntitle: {}\n---\n\n# {}\n\n", path, path);
self.fs
.create_new(std::path::Path::new(path), &content)
.await
.map_err(|e| DiaryxError::FileWrite {
path: PathBuf::from(path),
source: e,
})?;
Ok(())
}
#[allow(dead_code)]
pub fn template_manager(&self, _workspace_dir: Option<&Path>) -> TemplateManager<&FS> {
unimplemented!("TemplateManager is not yet refactored to AsyncFileSystem");
}
#[allow(dead_code)]
pub async fn create_entry_with_template(
&self,
_path: &Path,
_template: &Template,
_context: &TemplateContext,
) -> Result<()> {
Err(DiaryxError::Unsupported(
"Template-based entry creation is not yet supported for AsyncFileSystem".to_string(),
))
}
#[allow(dead_code)]
pub async fn create_entry_from_template(
&self,
_path: &Path,
_template_name: Option<&str>,
_title: Option<&str>,
_workspace_dir: Option<&Path>,
) -> Result<()> {
Err(DiaryxError::Unsupported(
"Template-based entry creation is not yet supported for AsyncFileSystem".to_string(),
))
}
async fn parse_file(&self, path: &str) -> Result<(IndexMap<String, Value>, String)> {
let path_buf = PathBuf::from(path);
let content = self
.fs
.read_to_string(std::path::Path::new(path))
.await
.map_err(|e| DiaryxError::FileRead {
path: path_buf.clone(),
source: e,
})?;
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return Err(DiaryxError::NoFrontmatter(path_buf));
}
let rest = &content[4..]; let end_idx = rest
.find("\n---\n")
.or_else(|| rest.find("\n---\r\n"))
.ok_or_else(|| DiaryxError::NoFrontmatter(path_buf.clone()))?;
let frontmatter_str = &rest[..end_idx];
let body = &rest[end_idx + 5..];
let frontmatter: IndexMap<String, Value> = serde_yaml::from_str(frontmatter_str)?;
Ok((frontmatter, body.to_string()))
}
async fn parse_file_or_create_frontmatter(
&self,
path: &str,
) -> Result<(IndexMap<String, Value>, String)> {
let path_buf = PathBuf::from(path);
let content = match self.fs.read_to_string(std::path::Path::new(path)).await {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok((IndexMap::new(), String::new()));
}
Err(e) => {
return Err(DiaryxError::FileRead {
path: path_buf,
source: e,
});
}
};
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return Ok((IndexMap::new(), content));
}
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))
}
}
}
async fn reconstruct_file(
&self,
path: &str,
frontmatter: &IndexMap<String, Value>,
body: &str,
) -> Result<()> {
let yaml_str = serde_yaml::to_string(frontmatter)?;
let content = format!("---\n{}---\n{}", yaml_str, body);
self.fs
.write_file(std::path::Path::new(path), &content)
.await
.map_err(|e| DiaryxError::FileWrite {
path: PathBuf::from(path),
source: e,
})?;
Ok(())
}
pub async fn set_frontmatter_property(
&self,
path: &str,
key: &str,
value: Value,
) -> Result<()> {
let (mut frontmatter, body) = self.parse_file_or_create_frontmatter(path).await?;
frontmatter.insert(key.to_string(), value);
self.reconstruct_file(path, &frontmatter, &body).await
}
pub async fn remove_frontmatter_property(&self, path: &str, key: &str) -> Result<()> {
match self.parse_file(path).await {
Ok((mut frontmatter, body)) => {
frontmatter.shift_remove(key);
self.reconstruct_file(path, &frontmatter, &body).await
}
Err(DiaryxError::NoFrontmatter(_)) => Ok(()), Err(e) => Err(e),
}
}
pub async fn rename_frontmatter_property(
&self,
path: &str,
old_key: &str,
new_key: &str,
) -> Result<bool> {
let (frontmatter, body) = match self.parse_file(path).await {
Ok(result) => result,
Err(DiaryxError::NoFrontmatter(_)) => return Ok(false), Err(e) => return Err(e),
};
if !frontmatter.contains_key(old_key) {
return Ok(false);
}
let mut result: IndexMap<String, Value> = IndexMap::new();
for (k, v) in frontmatter {
if k == old_key {
result.insert(new_key.to_string(), v);
} else {
result.insert(k, v);
}
}
self.reconstruct_file(path, &result, &body).await?;
Ok(true)
}
pub async fn get_frontmatter_property(&self, path: &str, key: &str) -> Result<Option<Value>> {
match self.parse_file(path).await {
Ok((frontmatter, _)) => Ok(frontmatter.get(key).cloned()),
Err(DiaryxError::NoFrontmatter(_)) => Ok(None), Err(e) => Err(e),
}
}
pub async fn get_all_frontmatter(&self, path: &str) -> Result<IndexMap<String, Value>> {
match self.parse_file(path).await {
Ok((frontmatter, _)) => Ok(frontmatter),
Err(DiaryxError::NoFrontmatter(_)) => Ok(IndexMap::new()), Err(e) => Err(e),
}
}
pub async fn get_content(&self, path: &str) -> Result<String> {
let (_, body) = self.parse_file_or_create_frontmatter(path).await?;
Ok(body)
}
pub async fn set_content(&self, path: &str, content: &str) -> Result<()> {
let (frontmatter, _) = self.parse_file_or_create_frontmatter(path).await?;
self.reconstruct_file(path, &frontmatter, content).await
}
pub async fn clear_content(&self, path: &str) -> Result<()> {
self.set_content(path, "").await
}
pub async fn touch_updated(&self, path: &str) -> Result<()> {
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.set_frontmatter_property(path, "updated", Value::String(timestamp))
.await
}
pub async fn save_content(&self, path: &str, content: &str) -> Result<()> {
self.save_content_with_options(path, content, true).await
}
pub async fn save_content_with_options(
&self,
path: &str,
content: &str,
auto_update_timestamp: bool,
) -> Result<()> {
self.set_content(path, content).await?;
if auto_update_timestamp {
self.touch_updated(path).await?;
}
Ok(())
}
pub async fn append_content(&self, path: &str, content: &str) -> Result<()> {
let (frontmatter, body) = self.parse_file_or_create_frontmatter(path).await?;
let new_body = if body.is_empty() {
content.to_string()
} else if body.ends_with('\n') {
format!("{}{}", body, content)
} else {
format!("{}\n{}", body, content)
};
self.reconstruct_file(path, &frontmatter, &new_body).await
}
pub async fn prepend_content(&self, path: &str, content: &str) -> Result<()> {
let (frontmatter, body) = self.parse_file_or_create_frontmatter(path).await?;
let new_body = if body.is_empty() {
content.to_string()
} else if content.ends_with('\n') {
format!("{}{}", content, body)
} else {
format!("{}\n{}", content, body)
};
self.reconstruct_file(path, &frontmatter, &new_body).await
}
pub async fn add_attachment(&self, path: &str, attachment_path: &str) -> Result<()> {
let (mut frontmatter, body) = self.parse_file_or_create_frontmatter(path).await?;
let attachments = frontmatter
.entry("attachments".to_string())
.or_insert(Value::Sequence(vec![]));
if let Value::Sequence(list) = attachments {
let new_attachment = Value::String(attachment_path.to_string());
if !list.contains(&new_attachment) {
list.push(new_attachment);
}
}
self.reconstruct_file(path, &frontmatter, &body).await
}
pub async fn remove_attachment(&self, path: &str, attachment_path: &str) -> Result<()> {
let (mut frontmatter, body) = match self.parse_file(path).await {
Ok(result) => result,
Err(DiaryxError::NoFrontmatter(_)) => return Ok(()),
Err(e) => return Err(e),
};
if let Some(Value::Sequence(list)) = frontmatter.get_mut("attachments") {
list.retain(|item| {
if let Value::String(s) = item {
s != attachment_path
} else {
true
}
});
if list.is_empty() {
frontmatter.shift_remove("attachments");
}
}
self.reconstruct_file(path, &frontmatter, &body).await
}
pub async fn get_attachments(&self, path: &str) -> Result<Vec<String>> {
let (frontmatter, _) = match self.parse_file(path).await {
Ok(result) => result,
Err(DiaryxError::NoFrontmatter(_)) => return Ok(vec![]),
Err(e) => return Err(e),
};
match frontmatter.get("attachments") {
Some(Value::Sequence(list)) => Ok(list
.iter()
.filter_map(|v| {
if let Value::String(s) = v {
Some(s.clone())
} else {
None
}
})
.collect()),
_ => Ok(vec![]),
}
}
}
impl<FS: FileSystem> DiaryxAppSync<FS> {
pub fn new(fs: FS) -> Self {
Self { fs }
}
pub fn fs(&self) -> &FS {
&self.fs
}
pub fn create_entry(&self, path: &str) -> Result<()> {
let content = format!("---\ntitle: {}\n---\n\n# {}\n\n", path, path);
self.fs.create_new(std::path::Path::new(path), &content)?;
Ok(())
}
pub fn create_entry_with_template(
&self,
path: &Path,
template: &Template,
context: &TemplateContext,
) -> Result<()> {
let content = template.render(context);
self.fs.create_new(path, &content)?;
Ok(())
}
pub fn create_entry_from_template(
&self,
path: &Path,
template_name: Option<&str>,
title: Option<&str>,
workspace_dir: Option<&Path>,
) -> Result<()> {
let manager = self.template_manager(workspace_dir);
let template_name = template_name.unwrap_or("note");
let template = manager
.get(template_name)
.ok_or_else(|| DiaryxError::TemplateNotFound(template_name.to_string()))?;
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled");
let mut context = TemplateContext::new().with_filename(filename);
if let Some(t) = title {
context = context.with_title(t);
} else {
context = context.with_title(prettify_filename(filename));
}
self.create_entry_with_template(path, &template, &context)
}
pub fn template_manager(&self, workspace_dir: Option<&Path>) -> TemplateManager<&FS> {
let mut manager = TemplateManager::new(&self.fs);
if let Some(dir) = workspace_dir {
manager = manager.with_workspace_dir(dir);
}
manager
}
fn parse_file(&self, path: &str) -> Result<(IndexMap<String, Value>, String)> {
let path_buf = PathBuf::from(path);
let content = self
.fs
.read_to_string(std::path::Path::new(path))
.map_err(|e| DiaryxError::FileRead {
path: path_buf.clone(),
source: e,
})?;
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return Err(DiaryxError::NoFrontmatter(path_buf));
}
let rest = &content[4..]; let end_idx = rest
.find("\n---\n")
.or_else(|| rest.find("\n---\r\n"))
.ok_or_else(|| DiaryxError::NoFrontmatter(path_buf.clone()))?;
let frontmatter_str = &rest[..end_idx];
let body = &rest[end_idx + 5..];
let frontmatter: IndexMap<String, Value> = serde_yaml::from_str(frontmatter_str)?;
Ok((frontmatter, body.to_string()))
}
fn parse_file_or_create_frontmatter(
&self,
path: &str,
) -> Result<(IndexMap<String, Value>, String)> {
let path_buf = PathBuf::from(path);
let content = match self.fs.read_to_string(std::path::Path::new(path)) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok((IndexMap::new(), String::new()));
}
Err(e) => {
return Err(DiaryxError::FileRead {
path: path_buf,
source: e,
});
}
};
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return Ok((IndexMap::new(), content));
}
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))
}
}
}
fn reconstruct_file(
&self,
path: &str,
frontmatter: &IndexMap<String, Value>,
body: &str,
) -> Result<()> {
let yaml_str = serde_yaml::to_string(frontmatter)?;
let content = format!("---\n{}---\n{}", yaml_str, body);
self.fs
.write_file(std::path::Path::new(path), &content)
.map_err(|e| DiaryxError::FileWrite {
path: PathBuf::from(path),
source: e,
})?;
Ok(())
}
pub fn set_frontmatter_property(&self, path: &str, key: &str, value: Value) -> Result<()> {
let (mut frontmatter, body) = self.parse_file_or_create_frontmatter(path)?;
frontmatter.insert(key.to_string(), value);
self.reconstruct_file(path, &frontmatter, &body)
}
pub fn remove_frontmatter_property(&self, path: &str, key: &str) -> Result<()> {
match self.parse_file(path) {
Ok((mut frontmatter, body)) => {
frontmatter.shift_remove(key);
self.reconstruct_file(path, &frontmatter, &body)
}
Err(DiaryxError::NoFrontmatter(_)) => Ok(()), Err(e) => Err(e),
}
}
pub fn rename_frontmatter_property(
&self,
path: &str,
old_key: &str,
new_key: &str,
) -> Result<bool> {
let (frontmatter, body) = match self.parse_file(path) {
Ok(result) => result,
Err(DiaryxError::NoFrontmatter(_)) => return Ok(false), Err(e) => return Err(e),
};
if !frontmatter.contains_key(old_key) {
return Ok(false);
}
let mut result: IndexMap<String, Value> = IndexMap::new();
for (k, v) in frontmatter {
if k == old_key {
result.insert(new_key.to_string(), v);
} else {
result.insert(k, v);
}
}
self.reconstruct_file(path, &result, &body)?;
Ok(true)
}
pub fn get_content(&self, path: &str) -> Result<String> {
match self.parse_file_or_create_frontmatter(path) {
Ok((_frontmatter, body)) => Ok(body),
Err(e) => Err(e),
}
}
pub fn set_content(&self, path: &str, content: &str) -> Result<()> {
let (frontmatter, _old_body) = self.parse_file_or_create_frontmatter(path)?;
self.reconstruct_file(path, &frontmatter, content)
}
pub fn clear_content(&self, path: &str) -> Result<()> {
self.set_content(path, "")
}
pub fn append_content(&self, path: &str, content: &str) -> Result<()> {
let (frontmatter, mut body) = self.parse_file_or_create_frontmatter(path)?;
if body.is_empty() {
body = content.to_string();
} else if body.ends_with('\n') {
body.push_str(content);
} else {
body.push('\n');
body.push_str(content);
}
self.reconstruct_file(path, &frontmatter, &body)
}
pub fn prepend_content(&self, path: &str, content: &str) -> Result<()> {
let (frontmatter, body) = self.parse_file_or_create_frontmatter(path)?;
let new_body = if body.is_empty() {
content.to_string()
} else if content.ends_with('\n') {
format!("{}{}", content, body)
} else {
format!("{}\n{}", content, body)
};
self.reconstruct_file(path, &frontmatter, &new_body)
}
pub fn get_frontmatter_property(&self, path: &str, key: &str) -> Result<Option<Value>> {
match self.parse_file(path) {
Ok((frontmatter, _)) => Ok(frontmatter.get(key).cloned()),
Err(DiaryxError::NoFrontmatter(_)) => Ok(None), Err(e) => Err(e),
}
}
pub fn get_all_frontmatter(&self, path: &str) -> Result<IndexMap<String, Value>> {
match self.parse_file(path) {
Ok((frontmatter, _)) => Ok(frontmatter),
Err(DiaryxError::NoFrontmatter(_)) => Ok(IndexMap::new()), Err(e) => Err(e),
}
}
pub fn add_attachment(&self, path: &str, attachment_path: &str) -> Result<()> {
let (mut frontmatter, body) = self.parse_file_or_create_frontmatter(path)?;
let attachments = frontmatter
.entry("attachments".to_string())
.or_insert(Value::Sequence(vec![]));
if let Value::Sequence(list) = attachments {
let new_attachment = Value::String(attachment_path.to_string());
if !list.contains(&new_attachment) {
list.push(new_attachment);
}
}
self.reconstruct_file(path, &frontmatter, &body)
}
pub fn remove_attachment(&self, path: &str, attachment_path: &str) -> Result<()> {
let (mut frontmatter, body) = match self.parse_file(path) {
Ok(result) => result,
Err(DiaryxError::NoFrontmatter(_)) => return Ok(()),
Err(e) => return Err(e),
};
if let Some(Value::Sequence(list)) = frontmatter.get_mut("attachments") {
list.retain(|item| {
if let Value::String(s) = item {
s != attachment_path
} else {
true
}
});
if list.is_empty() {
frontmatter.shift_remove("attachments");
}
}
self.reconstruct_file(path, &frontmatter, &body)
}
pub fn get_attachments(&self, path: &str) -> Result<Vec<String>> {
let (frontmatter, _) = match self.parse_file(path) {
Ok(result) => result,
Err(DiaryxError::NoFrontmatter(_)) => return Ok(vec![]),
Err(e) => return Err(e),
};
match frontmatter.get("attachments") {
Some(Value::Sequence(list)) => Ok(list
.iter()
.filter_map(|v| {
if let Value::String(s) = v {
Some(s.clone())
} else {
None
}
})
.collect()),
_ => Ok(vec![]),
}
}
pub fn resolve_attachment(
&self,
entry_path: &str,
attachment_name: &str,
) -> Result<Option<PathBuf>> {
use crate::workspace::IndexFrontmatter;
let entry_path = Path::new(entry_path);
let entry_dir = entry_path.parent().unwrap_or(Path::new("."));
let content = match self.fs.read_to_string(entry_path) {
Ok(c) => c,
Err(_) => return Ok(None),
};
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return Ok(None);
}
let rest = &content[4..];
let end_idx = match rest.find("\n---\n").or_else(|| rest.find("\n---\r\n")) {
Some(idx) => idx,
None => return Ok(None),
};
let frontmatter_str = &rest[..end_idx];
let frontmatter: IndexFrontmatter = match serde_yaml::from_str(frontmatter_str) {
Ok(fm) => fm,
Err(_) => return Ok(None),
};
for att_path in frontmatter.attachments_list() {
let parsed = link_parser::parse_link(att_path);
let resolved = entry_dir.join(&parsed.path);
if resolved.file_name().map(|n| n.to_string_lossy()) == Some(attachment_name.into())
&& self.fs.exists(&resolved)
{
return Ok(Some(resolved));
}
if parsed.path == attachment_name
|| parsed.path.ends_with(&format!("/{}", attachment_name))
{
let resolved = entry_dir.join(&parsed.path);
if self.fs.exists(&resolved) {
return Ok(Some(resolved));
}
}
}
if let Some(ref parent_rel) = frontmatter.part_of {
let parsed_parent = link_parser::parse_link(parent_rel);
let canonical_parent = link_parser::to_canonical(&parsed_parent, entry_path);
let parent_path = PathBuf::from(canonical_parent);
if self.fs.exists(&parent_path) {
return self.resolve_attachment(&parent_path.to_string_lossy(), attachment_name);
}
}
Ok(None)
}
pub fn sort_frontmatter(&self, path: &str, pattern: Option<&str>) -> Result<()> {
let (frontmatter, body) = match self.parse_file(path) {
Ok(result) => result,
Err(DiaryxError::NoFrontmatter(_)) => return Ok(()), Err(e) => return Err(e),
};
let sorted = match pattern {
Some(p) => self.sort_by_pattern(frontmatter, p),
None => self.sort_alphabetically(frontmatter),
};
self.reconstruct_file(path, &sorted, &body)
}
fn sort_alphabetically(&self, frontmatter: IndexMap<String, Value>) -> IndexMap<String, Value> {
let mut pairs: Vec<_> = frontmatter.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
pairs.into_iter().collect()
}
fn sort_by_pattern(
&self,
frontmatter: IndexMap<String, Value>,
pattern: &str,
) -> IndexMap<String, Value> {
let priority_keys: Vec<&str> = pattern.split(',').map(|s| s.trim()).collect();
let mut result = IndexMap::new();
let mut remaining: IndexMap<String, Value> = frontmatter;
for key in &priority_keys {
if *key == "*" {
let mut rest: Vec<_> = remaining.drain(..).collect();
rest.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in rest {
result.insert(k, v);
}
break;
} else if let Some(value) = remaining.shift_remove(*key) {
result.insert(key.to_string(), value);
}
}
if !remaining.is_empty() {
let mut rest: Vec<_> = remaining.drain(..).collect();
rest.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in rest {
result.insert(k, v);
}
}
result
}
pub fn get_dated_entry_path(&self, date_str: &str, config: &Config) -> Result<PathBuf> {
let date = parse_date(date_str)?;
Ok(date_to_path(&config.daily_entry_dir(), &date))
}
pub fn resolve_path(&self, path_str: &str, config: &Config) -> PathBuf {
if let Ok(date) = parse_date(path_str) {
date_to_path(&config.daily_entry_dir(), &date)
} else {
PathBuf::from(path_str)
}
}
pub fn create_dated_entry(&self, date: &NaiveDate, config: &Config) -> Result<PathBuf> {
self.create_dated_entry_with_template(date, config, None)
}
pub fn create_dated_entry_with_template(
&self,
date: &NaiveDate,
config: &Config,
template_name: Option<&str>,
) -> Result<PathBuf> {
let daily_dir = config.daily_entry_dir();
let path = date_to_path(&daily_dir, date);
if let Some(parent) = path.parent() {
self.fs.create_dir_all(parent)?;
}
self.ensure_daily_index_hierarchy(date, config)?;
let manager = self.template_manager(Some(&config.default_workspace));
let effective_template_name = template_name.unwrap_or("daily");
let template = manager
.get(effective_template_name)
.ok_or_else(|| DiaryxError::TemplateNotFound(effective_template_name.to_string()))?;
let title = date.format("%B %d, %Y").to_string(); let month_index_name = Self::month_index_filename(date);
let daily_prefix = config.daily_entry_folder.clone().unwrap_or_default();
let daily_prefix = daily_prefix.trim_start_matches('/').to_string();
let join_path = |base: &str, part: &str| -> String {
if base.is_empty() {
part.to_string()
} else {
format!("{}/{}", base, part)
}
};
let year = date.format("%Y").to_string();
let month = date.format("%m").to_string();
let month_index_canonical = join_path(
&daily_prefix,
&format!("{}/{}/{}", year, month, month_index_name),
);
let entry_filename = format!("{}.md", date.format("%Y-%m-%d"));
let entry_canonical = join_path(
&daily_prefix,
&format!("{}/{}/{}", year, month, entry_filename),
);
let context = TemplateContext::new()
.with_title(&title)
.with_date(*date)
.with_part_of(link_parser::format_link_with_format(
&month_index_canonical,
&link_parser::path_to_title(&month_index_name),
config.link_format,
&entry_canonical,
));
let content = template.render(&context);
self.fs.create_new(&path, &content)?;
let month_index_path = path.parent().unwrap().join(&month_index_name);
let link = link_parser::format_link_with_format(
&entry_canonical,
&link_parser::path_to_title(&entry_filename),
config.link_format,
&month_index_canonical,
);
self.add_to_index_contents(&month_index_path, &link)?;
Ok(path)
}
pub fn ensure_dated_entry(&self, date: &NaiveDate, config: &Config) -> Result<PathBuf> {
self.ensure_dated_entry_with_template(date, config, None)
}
pub fn ensure_dated_entry_with_template(
&self,
date: &NaiveDate,
config: &Config,
template_name: Option<&str>,
) -> Result<PathBuf> {
let path = date_to_path(&config.daily_entry_dir(), date);
if self.fs.exists(&path) {
return Ok(path);
}
if let Err(_e) = self.validate_daily_hierarchy(date, config) {
}
self.ensure_daily_index_hierarchy(date, config)?;
self.create_dated_entry_with_template(date, config, template_name)
}
pub fn validate_daily_hierarchy(
&self,
date: &NaiveDate,
config: &Config,
) -> Result<Vec<String>> {
let mut warnings = Vec::new();
let daily_dir = config.daily_entry_dir();
let duplicates = self.detect_duplicates(date, config)?;
for dup in duplicates {
warnings.push(format!("Duplicate folder found: {}", dup));
}
let year = date.format("%Y").to_string();
let month = date.format("%m").to_string();
let daily_index_path = daily_dir.join("daily_index.md");
let year_dir = daily_dir.join(&year);
let year_index_path = year_dir.join(Self::year_index_filename(date));
let month_dir = year_dir.join(&month);
let _month_index_path = month_dir.join(Self::month_index_filename(date));
if self.fs.exists(&year_index_path) {
let year_index_name = Self::year_index_filename(date);
if self.fs.exists(&daily_index_path) {
let year_index_rel = format!("{}/{}", year, year_index_name);
let daily_content =
self.get_frontmatter_property(&daily_index_path.to_string_lossy(), "contents")?;
let is_linked = match daily_content {
Some(Value::Sequence(seq)) => {
seq.iter().any(|v| v.as_str() == Some(&year_index_rel))
}
_ => false,
};
if !is_linked {
warnings.push(format!(
"Year index {} is not listed in daily_index.md contents",
year
));
}
}
}
Ok(warnings)
}
pub fn detect_duplicates(&self, date: &NaiveDate, config: &Config) -> Result<Vec<String>> {
let mut duplicates = Vec::new();
let daily_dir = config.daily_entry_dir();
let year = date.format("%Y").to_string();
let month = date.format("%m").to_string(); let month_name = date.format("%B").to_string();
let year_dir = daily_dir.join(&year);
if self.fs.exists(&year_dir) {
let named_month_path = year_dir.join(&month_name);
let _numbered_month_path = year_dir.join(&month);
if self.fs.exists(&named_month_path) && month_name != month {
duplicates.push(named_month_path.to_string_lossy().to_string());
}
}
Ok(duplicates)
}
pub fn merge_duplicates(&self, from_path: &Path, to_path: &Path) -> Result<()> {
if !self.fs.exists(from_path) {
return Ok(());
}
self.fs.create_dir_all(to_path)?;
let entries = self.fs.list_files(from_path)?;
for entry in entries {
let file_name = entry.file_name().unwrap_or_default();
let dest = to_path.join(file_name);
if self.fs.is_dir(&entry) {
self.merge_duplicates(&entry, &dest)?;
} else {
if self.fs.exists(&dest) {
} else {
self.fs.move_file(&entry, &dest)?;
}
}
}
let remaining = self.fs.list_files(from_path)?;
if remaining.is_empty() {
self.fs.delete_file(from_path)?;
}
Ok(())
}
fn ensure_daily_index_hierarchy(&self, date: &NaiveDate, config: &Config) -> Result<()> {
let daily_dir = config.daily_entry_dir();
let year = date.format("%Y").to_string();
let month = date.format("%m").to_string();
let daily_prefix = config.daily_entry_folder.clone().unwrap_or_default();
let daily_prefix = daily_prefix.trim_start_matches('/').to_string();
let join_path = |base: &str, part: &str| -> String {
if base.is_empty() {
part.to_string()
} else {
format!("{}/{}", base, part)
}
};
let daily_index_filename = "daily_index.md";
let daily_index_canonical = join_path(&daily_prefix, daily_index_filename);
let daily_index_path = daily_dir.join(daily_index_filename);
let year_index_filename = Self::year_index_filename(date);
let year_index_canonical =
join_path(&daily_prefix, &format!("{}/{}", year, year_index_filename));
let year_dir = daily_dir.join(&year);
let year_index_path = year_dir.join(&year_index_filename);
let month_index_filename = Self::month_index_filename(date);
let month_index_canonical = join_path(
&daily_prefix,
&format!("{}/{}/{}", year, month, month_index_filename),
);
let month_dir = year_dir.join(&month);
let month_index_path = month_dir.join(&month_index_filename);
self.fs.create_dir_all(&month_dir)?;
let daily_index_created = !self.fs.exists(&daily_index_path);
if daily_index_created {
let part_of = if config.daily_entry_folder.is_some() {
self.find_workspace_root_relative(&daily_dir)
} else {
None
};
self.create_daily_index(&daily_index_path, part_of.as_deref())?;
if let Some(ref root_rel) = part_of {
let workspace_root = daily_dir.join(root_rel);
let root_canonical = "";
if self.fs.exists(&workspace_root) {
let link = link_parser::format_link_with_format(
&daily_index_canonical,
"Daily Index",
config.link_format,
root_canonical,
);
self.add_to_index_contents(&workspace_root, &link)?;
}
}
}
if !self.fs.exists(&year_index_path) {
let part_of_link = link_parser::format_link_with_format(
&daily_index_canonical,
"Daily Index",
config.link_format,
&year_index_canonical,
);
self.create_year_index(&year_index_path, date, &part_of_link)?;
}
let link = link_parser::format_link_with_format(
&year_index_canonical,
&year,
config.link_format,
&daily_index_canonical,
);
self.add_to_index_contents(&daily_index_path, &link)?;
if !self.fs.exists(&month_index_path) {
let part_of_link = link_parser::format_link_with_format(
&year_index_canonical,
&year,
config.link_format,
&month_index_canonical,
);
self.create_month_index(&month_index_path, date, &part_of_link)?;
}
let link = link_parser::format_link_with_format(
&month_index_canonical,
&date.format("%B").to_string(), config.link_format,
&year_index_canonical,
);
self.add_to_index_contents(&year_index_path, &link)?;
Ok(())
}
fn find_workspace_root_relative(&self, from_dir: &Path) -> Option<String> {
let parent = from_dir.parent()?;
let candidates = [
parent
.file_name()
.and_then(|n| n.to_str())
.map(|name| format!("{}.md", name)),
Some("README.md".to_string()),
Some("index.md".to_string()),
];
for candidate in candidates.iter().flatten() {
let index_path = parent.join(candidate);
if self.fs.exists(&index_path) {
let index_str = index_path.to_string_lossy();
if let Ok(Some(_)) = self.get_frontmatter_property(&index_str, "contents") {
return Some(format!("../{}", candidate));
}
}
}
None
}
fn create_daily_index(&self, path: &Path, part_of: Option<&str>) -> Result<()> {
let part_of_line = match part_of {
Some(p) => format!("part_of: \"{}\"\n", p),
None => String::new(),
};
let content = format!(
"---\n\
title: Daily Entries\n\
{}contents: []\n\
---\n\n\
# Daily Entries\n\n\
This index contains all daily journal entries organized by year and month.\n",
part_of_line
);
self.fs.write_file(path, &content)?;
Ok(())
}
fn create_year_index(&self, path: &Path, date: &NaiveDate, part_of: &str) -> Result<()> {
let year = date.format("%Y").to_string();
let content = format!(
"---\n\
title: {year}\n\
part_of: \"{part_of}\"\n\
contents: []\n\
---\n\n\
# {year}\n\n\
Daily entries for {year}.\n"
);
self.fs.write_file(path, &content)?;
Ok(())
}
fn create_month_index(&self, path: &Path, date: &NaiveDate, part_of: &str) -> Result<()> {
let year = date.format("%Y").to_string();
let month_name = date.format("%B").to_string(); let title = format!("{} {}", month_name, year);
let content = format!(
"---\n\
title: {title}\n\
part_of: \"{part_of}\"\n\
contents: []\n\
---\n\n\
# {title}\n\n\
Daily entries for {title}.\n"
);
self.fs.write_file(path, &content)?;
Ok(())
}
pub fn add_to_index_contents(&self, index_path: &Path, entry: &str) -> Result<bool> {
let index_str = index_path.to_string_lossy();
match self.get_frontmatter_property(&index_str, "contents") {
Ok(Some(Value::Sequence(mut items))) => {
let entry_value = Value::String(entry.to_string());
if !items.contains(&entry_value) {
items.push(entry_value);
items.sort_by(|a, b| {
let a_str = a.as_str().unwrap_or("");
let b_str = b.as_str().unwrap_or("");
a_str.cmp(b_str)
});
self.set_frontmatter_property(&index_str, "contents", Value::Sequence(items))?;
return Ok(true);
}
Ok(false)
}
Ok(None) => {
let items = vec![Value::String(entry.to_string())];
self.set_frontmatter_property(&index_str, "contents", Value::Sequence(items))?;
Ok(true)
}
_ => {
Ok(false)
}
}
}
pub fn remove_from_index_contents(&self, index_path: &Path, entry: &str) -> Result<bool> {
let index_str = index_path.to_string_lossy();
match self.get_frontmatter_property(&index_str, "contents") {
Ok(Some(Value::Sequence(mut items))) => {
let before_len = items.len();
items.retain(|item| item.as_str() != Some(entry));
if items.len() != before_len {
items.sort_by(|a, b| {
let a_str = a.as_str().unwrap_or("");
let b_str = b.as_str().unwrap_or("");
a_str.cmp(b_str)
});
self.set_frontmatter_property(&index_str, "contents", Value::Sequence(items))?;
return Ok(true);
}
Ok(false)
}
Ok(None) | Ok(Some(_)) => {
Ok(false)
}
Err(_) => {
Ok(false)
}
}
}
fn year_index_filename(date: &NaiveDate) -> String {
format!("{}_index.md", date.format("%Y"))
}
fn month_index_filename(date: &NaiveDate) -> String {
format!(
"{}_{}.md",
date.format("%Y"),
date.format("%B").to_string().to_lowercase()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::SyncToAsyncFs;
use crate::test_utils::MockFileSystem;
#[test]
fn test_get_content() {
let fs = MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\nHello, world!");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs));
let content = crate::fs::block_on_test(app.get_content("test.md")).unwrap();
assert_eq!(content, "Hello, world!");
}
#[test]
fn test_get_content_empty_body() {
let fs = MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\n");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs));
let content = crate::fs::block_on_test(app.get_content("test.md")).unwrap();
assert_eq!(content, "");
}
#[test]
fn test_get_content_no_frontmatter() {
let fs = MockFileSystem::new().with_file("test.md", "Just plain content");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs));
let content = crate::fs::block_on_test(app.get_content("test.md")).unwrap();
assert_eq!(content, "Just plain content");
}
#[test]
fn test_set_content() {
let fs = MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\nOld content");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.set_content("test.md", "New content")).unwrap();
let result = fs.get_content("test.md").unwrap();
assert!(result.contains("title: Test"));
assert!(result.contains("New content"));
assert!(!result.contains("Old content"));
}
#[test]
fn test_set_content_preserves_frontmatter() {
let fs = MockFileSystem::new().with_file(
"test.md",
"---\ntitle: My Title\ndescription: A description\n---\nOld",
);
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.set_content("test.md", "New body")).unwrap();
let result = fs.get_content("test.md").unwrap();
assert!(result.contains("title: My Title"));
assert!(result.contains("description: A description"));
assert!(result.contains("New body"));
}
#[test]
fn test_clear_content() {
let fs =
MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\nSome content here");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.clear_content("test.md")).unwrap();
let result = fs.get_content("test.md").unwrap();
assert!(result.contains("title: Test"));
assert!(result.ends_with("---\n"));
}
#[test]
fn test_append_content() {
let fs = MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\nFirst line");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.append_content("test.md", "Second line")).unwrap();
let result = fs.get_content("test.md").unwrap();
assert!(result.contains("First line"));
assert!(result.contains("Second line"));
let first_pos = result.find("First line").unwrap();
let second_pos = result.find("Second line").unwrap();
assert!(second_pos > first_pos);
}
#[test]
fn test_append_content_adds_newline() {
let fs = MockFileSystem::new()
.with_file("test.md", "---\ntitle: Test\n---\nNo trailing newline");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.append_content("test.md", "Appended")).unwrap();
let content = crate::fs::block_on_test(app.get_content("test.md")).unwrap();
assert!(content.contains("No trailing newline\nAppended"));
}
#[test]
fn test_append_content_to_empty_body() {
let fs = MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\n");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.append_content("test.md", "New content")).unwrap();
let content = crate::fs::block_on_test(app.get_content("test.md")).unwrap();
assert_eq!(content, "New content");
}
#[test]
fn test_prepend_content() {
let fs =
MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\nExisting content");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.prepend_content("test.md", "# Header")).unwrap();
let result = fs.get_content("test.md").unwrap();
assert!(result.contains("# Header"));
assert!(result.contains("Existing content"));
let header_pos = result.find("# Header").unwrap();
let existing_pos = result.find("Existing content").unwrap();
assert!(header_pos < existing_pos);
}
#[test]
fn test_prepend_content_adds_newline() {
let fs = MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\nExisting");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.prepend_content("test.md", "Prepended")).unwrap();
let content = crate::fs::block_on_test(app.get_content("test.md")).unwrap();
assert!(content.contains("Prepended\nExisting"));
}
#[test]
fn test_prepend_content_to_empty_body() {
let fs = MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\n");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.prepend_content("test.md", "New content")).unwrap();
let content = crate::fs::block_on_test(app.get_content("test.md")).unwrap();
assert_eq!(content, "New content");
}
#[test]
fn test_content_operations_on_nonexistent_file() {
let fs = MockFileSystem::new();
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.set_content("new.md", "Content")).unwrap();
let result = fs.get_content("new.md").unwrap();
assert!(result.contains("Content"));
}
#[test]
fn test_multiple_content_operations() {
let fs = MockFileSystem::new().with_file("test.md", "---\ntitle: Test\n---\n");
let app = DiaryxApp::new(SyncToAsyncFs::new(fs.clone()));
crate::fs::block_on_test(app.append_content("test.md", "Line 1")).unwrap();
crate::fs::block_on_test(app.append_content("test.md", "Line 2")).unwrap();
crate::fs::block_on_test(app.prepend_content("test.md", "# Title")).unwrap();
let content = crate::fs::block_on_test(app.get_content("test.md")).unwrap();
assert!(content.starts_with("# Title"));
assert!(content.contains("Line 1"));
assert!(content.contains("Line 2"));
}
#[test]
fn test_ensure_daily_index_hierarchy_repairs_links() {
let fs = MockFileSystem::new();
let app = DiaryxAppSync::new(fs.clone());
let config = Config::default();
let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
let daily_dir = config.daily_entry_dir();
fs.create_dir_all(&daily_dir).unwrap();
app.create_daily_index(&daily_dir.join("daily_index.md"), None)
.unwrap();
let year_dir = daily_dir.join("2025");
fs.create_dir_all(&year_dir).unwrap();
app.create_year_index(&year_dir.join("2025_index.md"), &date, "../daily_index.md")
.unwrap();
app.ensure_daily_index_hierarchy(&date, &config).unwrap();
let daily_index_path = daily_dir.join("daily_index.md");
let daily_content_after = app
.get_frontmatter_property(daily_index_path.to_str().unwrap(), "contents")
.unwrap();
if let Some(Value::Sequence(seq)) = daily_content_after {
assert!(!seq.is_empty());
assert_eq!(seq[0].as_str(), Some("[2025](/2025/2025_index.md)"));
} else {
panic!("Contents should be a sequence");
}
}
#[test]
fn test_detect_duplicates() {
let fs = MockFileSystem::new();
let app = DiaryxAppSync::new(fs.clone());
let config = Config::default();
let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
let daily_dir = config.daily_entry_dir();
let year_dir = daily_dir.join("2025");
fs.create_dir_all(&year_dir).unwrap();
let duplicate_dir = year_dir.join("January");
fs.create_dir_all(&duplicate_dir).unwrap();
let duplicates = app.detect_duplicates(&date, &config).unwrap();
assert_eq!(duplicates.len(), 1);
assert!(duplicates[0].ends_with("January"));
}
#[test]
fn test_merge_duplicates() {
let fs = MockFileSystem::new();
let app = DiaryxAppSync::new(fs.clone());
let from_dir = Path::new("from_dir");
let to_dir = Path::new("to_dir");
fs.create_dir_all(from_dir).unwrap();
fs.write_file(&from_dir.join("note.md"), "content").unwrap();
app.merge_duplicates(from_dir, to_dir).unwrap();
assert!(!fs.exists(from_dir));
assert!(fs.exists(to_dir));
assert!(fs.exists(&to_dir.join("note.md")));
assert_eq!(
fs.get_content(to_dir.join("note.md").to_str().unwrap())
.unwrap(),
"content"
);
}
}