mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 15:55:55 +02:00
v1.1.0 - View mode, daily notes, tag management, math support, and more
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
use crate::types::NoteMeta;
|
||||
use chrono::Utc;
|
||||
use chrono::{NaiveDate, NaiveDateTime, Utc};
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
struct RawFrontmatter {
|
||||
@@ -15,6 +14,32 @@ struct RawFrontmatter {
|
||||
modified: Option<String>,
|
||||
}
|
||||
|
||||
/// Try to parse a date string in multiple common formats.
|
||||
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
let s = s.trim();
|
||||
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
|
||||
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
|
||||
return Some(dt);
|
||||
}
|
||||
// Try parsing as DateTime with fixed offset then convert
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
|
||||
return Some(dt.with_timezone(&Utc));
|
||||
}
|
||||
// YYYY-MM-DD HH:MM:SS (no timezone)
|
||||
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
|
||||
return Some(ndt.and_utc());
|
||||
}
|
||||
// YYYY-MM-DDTHH:MM:SS (no timezone, with T separator)
|
||||
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Some(ndt.and_utc());
|
||||
}
|
||||
// YYYY-MM-DD (date only)
|
||||
if let Ok(nd) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
|
||||
return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
|
||||
let matter = Matter::<YAML>::new();
|
||||
let result = matter.parse(raw);
|
||||
@@ -28,15 +53,16 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
|
||||
|
||||
let created = fm
|
||||
.created
|
||||
.and_then(|s| s.parse().ok())
|
||||
.and_then(|s| parse_date_flexible(&s))
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let modified = fm
|
||||
.modified
|
||||
.and_then(|s| s.parse().ok())
|
||||
.and_then(|s| parse_date_flexible(&s))
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let id = fm.id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
// Don't generate UUID on read — empty string signals "no ID yet"
|
||||
let id = fm.id.unwrap_or_default();
|
||||
|
||||
let meta = NoteMeta {
|
||||
id,
|
||||
@@ -81,6 +107,65 @@ pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String {
|
||||
format!("{}{}", fm, body)
|
||||
}
|
||||
|
||||
/// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys.
|
||||
/// Falls back to serialize_frontmatter() if the original has no frontmatter.
|
||||
pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> String {
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(original_raw);
|
||||
|
||||
// If original had no frontmatter, use the simple serializer
|
||||
if parsed.matter.trim().is_empty() {
|
||||
return update_note_raw(meta, body);
|
||||
}
|
||||
|
||||
// Parse existing YAML into a Mapping to preserve all keys
|
||||
let mut mapping: serde_yaml::Mapping = match serde_yaml::from_str(&parsed.matter) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return update_note_raw(meta, body), // unparseable YAML, fall back
|
||||
};
|
||||
|
||||
// Update only our known fields
|
||||
if !meta.id.is_empty() {
|
||||
mapping.insert(
|
||||
serde_yaml::Value::String("id".into()),
|
||||
serde_yaml::Value::String(meta.id.clone()),
|
||||
);
|
||||
}
|
||||
mapping.insert(
|
||||
serde_yaml::Value::String("title".into()),
|
||||
serde_yaml::Value::String(meta.title.clone()),
|
||||
);
|
||||
mapping.insert(
|
||||
serde_yaml::Value::String("tags".into()),
|
||||
serde_yaml::Value::Sequence(
|
||||
meta.tags
|
||||
.iter()
|
||||
.map(|t| serde_yaml::Value::String(t.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
mapping.insert(
|
||||
serde_yaml::Value::String("pinned".into()),
|
||||
serde_yaml::Value::Bool(meta.pinned),
|
||||
);
|
||||
mapping.insert(
|
||||
serde_yaml::Value::String("created".into()),
|
||||
serde_yaml::Value::String(meta.created.to_rfc3339()),
|
||||
);
|
||||
mapping.insert(
|
||||
serde_yaml::Value::String("modified".into()),
|
||||
serde_yaml::Value::String(meta.modified.to_rfc3339()),
|
||||
);
|
||||
|
||||
// Serialize the mapping back to YAML
|
||||
let yaml_str = match serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping)) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return update_note_raw(meta, body),
|
||||
};
|
||||
|
||||
format!("---\n{}---\n{}", yaml_str, body)
|
||||
}
|
||||
|
||||
fn filename_to_title(filename: &str) -> String {
|
||||
let stem = filename.trim_end_matches(".md");
|
||||
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState};
|
||||
use crate::vault::frontmatter;
|
||||
use chrono::Utc;
|
||||
use chrono::{Local, Utc};
|
||||
use rayon::prelude::*;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
@@ -184,13 +184,19 @@ fn read_note_entry_from_str(
|
||||
|
||||
let (mut meta, content) = frontmatter::parse_note(raw, &filename);
|
||||
|
||||
// Use filesystem times if frontmatter didn't have them
|
||||
// Use filesystem times only if frontmatter didn't have created/modified fields
|
||||
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
|
||||
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
|
||||
if let Ok(fs_meta) = fs::metadata(path) {
|
||||
if let Ok(modified) = fs_meta.modified() {
|
||||
meta.modified = modified.into();
|
||||
if !has_modified {
|
||||
if let Ok(modified) = fs_meta.modified() {
|
||||
meta.modified = modified.into();
|
||||
}
|
||||
}
|
||||
if let Ok(created) = fs_meta.created() {
|
||||
meta.created = created.into();
|
||||
if !has_created {
|
||||
if let Ok(created) = fs_meta.created() {
|
||||
meta.created = created.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +239,19 @@ pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String>
|
||||
let mut updated_meta = meta.clone();
|
||||
updated_meta.modified = Utc::now();
|
||||
|
||||
let raw = frontmatter::update_note_raw(&updated_meta, body);
|
||||
// Generate UUID on first save if note didn't have one
|
||||
if updated_meta.id.is_empty() {
|
||||
updated_meta.id = Uuid::new_v4().to_string();
|
||||
}
|
||||
|
||||
// Read existing file to preserve unknown frontmatter fields
|
||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||
let raw = if existing.is_empty() {
|
||||
frontmatter::update_note_raw(&updated_meta, body)
|
||||
} else {
|
||||
frontmatter::merge_frontmatter(&existing, &updated_meta, body)
|
||||
};
|
||||
|
||||
fs::write(path, raw).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -290,6 +308,49 @@ pub fn create_note(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> {
|
||||
let today = Local::now();
|
||||
let date_str = today.format("%Y-%m-%d").to_string();
|
||||
let title = today.format("%B %d, %Y").to_string();
|
||||
|
||||
let dir = Path::new(vault_path).join("Daily");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let file_path = dir.join(format!("{}.md", date_str));
|
||||
let vault_root = Path::new(vault_path);
|
||||
|
||||
// If today's note already exists, return it
|
||||
if file_path.exists() {
|
||||
return read_note_entry(&file_path, vault_root);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let meta = NoteMeta {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
title,
|
||||
tags: vec!["daily".to_string()],
|
||||
pinned: false,
|
||||
created: now,
|
||||
modified: now,
|
||||
};
|
||||
|
||||
let raw = frontmatter::update_note_raw(&meta, "\n");
|
||||
fs::write(&file_path, raw).map_err(|e| e.to_string())?;
|
||||
|
||||
let relative = file_path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(&file_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(NoteEntry {
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
meta,
|
||||
preview: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_notebook(
|
||||
vault_path: &str,
|
||||
parent_relative: Option<&str>,
|
||||
@@ -385,7 +446,10 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
|
||||
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
meta.title = new_title.to_string();
|
||||
meta.modified = Utc::now();
|
||||
let updated = frontmatter::update_note_raw(&meta, &content);
|
||||
if meta.id.is_empty() {
|
||||
meta.id = Uuid::new_v4().to_string();
|
||||
}
|
||||
let updated = frontmatter::merge_frontmatter(&raw, &meta, &content);
|
||||
|
||||
// Rename file
|
||||
let new_filename = sanitize_filename(new_title);
|
||||
|
||||
Reference in New Issue
Block a user