diff --git a/README.md b/README.md index 93d9702..69fe9ec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HelixNotes -A local-first markdown note-taking app built with Tauri, SvelteKit, and Rust. +A local markdown note-taking app built with Tauri, SvelteKit, and Rust. Your notes are stored as standard Markdown files on your local filesystem. No cloud, no lock-in. @@ -29,7 +29,7 @@ See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.or - **Obsidian import** — convert Obsidian wiki-links to standard markdown - **Themes** — light/dark mode with customizable accent colors and fonts - **Focus mode** — distraction-free writing -- **Local-first** — everything stays on your machine +- **Local** — everything stays on your machine ## Tech Stack diff --git a/package.json b/package.json index 8ff81bd..5142e0e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.0.9", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite dev", @@ -57,7 +57,9 @@ "@tiptap/extension-underline": "^3.19.0", "@tiptap/pm": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", + "@types/katex": "^0.16.8", "@types/markdown-it": "^14.1.2", + "katex": "^0.16.28", "lowlight": "^3.3.0", "markdown-it": "^14.1.0", "markdown-it-mark": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30662cc..a8e3200 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,9 +89,15 @@ importers: '@tiptap/starter-kit': specifier: ^3.19.0 version: 3.19.0 + '@types/katex': + specifier: ^0.16.8 + version: 0.16.8 '@types/markdown-it': specifier: ^14.1.2 version: 14.1.2 + katex: + specifier: ^0.16.28 + version: 0.16.28 lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -936,6 +942,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -972,6 +981,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -1048,6 +1061,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} + hasBin: true + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -1972,6 +1989,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/katex@0.16.8': {} + '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -1997,6 +2016,8 @@ snapshots: clsx@2.1.1: {} + commander@8.3.0: {} + cookie@0.6.0: {} crelt@1.0.6: {} @@ -2074,6 +2095,10 @@ snapshots: jiti@2.6.1: {} + katex@0.16.28: + dependencies: + commander: 8.3.0 + kleur@4.1.5: {} lightningcss-android-arm64@1.30.2: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a86555b..13305f5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.0.9" +version = "1.1.0" dependencies = [ "chrono", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f8d518c..8e1b39f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "helixnotes" -version = "1.0.9" -description = "Local-first markdown note-taking app" +version = "1.1.0" +description = "Local markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 388415d..9cc5bfd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -245,6 +245,21 @@ pub fn create_note( Ok(entry) } +#[tauri::command] +pub fn create_daily_note(state: State<'_, AppState>) -> Result { + let config = state.config.lock().map_err(|e| e.to_string())?; + let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; + let entry = operations::create_daily_note(vault_path)?; + + if let Ok(search_guard) = state.search_index.lock() { + if let Some(ref search) = *search_guard { + let _ = search.index_note(&entry.path); + } + } + + Ok(entry) +} + #[tauri::command] pub fn rename_note(path: String, new_title: String) -> Result { operations::rename_note(&path, &new_title) @@ -1082,6 +1097,9 @@ fn resolve_wiki_ref( reference: &str, ) -> String { let reference = reference.trim(); + // Strip Obsidian heading anchors (#) and block references (^) before lookup + let reference = reference.split('#').next().unwrap_or(reference).trim(); + let reference = reference.split('^').next().unwrap_or(reference).trim(); // Direct match by filename if let Some(rel) = file_index.get(reference) { return rel.clone(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b13040..ce86024 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,6 +66,7 @@ pub fn run() { commands::read_note, commands::save_note, commands::create_note, + commands::create_daily_note, commands::rename_note, commands::delete_note, commands::move_note, diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 98abf90..bf5a422 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -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, } +/// Try to parse a date string in multiple common formats. +fn parse_date_flexible(s: &str) -> Option> { + 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::>() { + 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::::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::::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). diff --git a/src-tauri/src/vault/operations.rs b/src-tauri/src/vault/operations.rs index d3ad4ab..e09993b 100644 --- a/src-tauri/src/vault/operations.rs +++ b/src-tauri/src/vault/operations.rs @@ -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 { + 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 { 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); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b834678..d842d62 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "HelixNotes", - "version": "1.0.9", + "version": "1.1.0", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/api.ts b/src/lib/api.ts index 869c753..d28fc16 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -92,6 +92,10 @@ export async function createNote( return invoke("create_note", { notebookRelative, title }); } +export async function createDailyNote(): Promise { + return invoke("create_daily_note"); +} + export async function renameNote( path: string, newTitle: string, diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index ad0d6f7..fc4989f 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -20,6 +20,7 @@ showCommandPalette, theme, focusMode, + readOnly, activeNote, activeNotePath, editorDirty, @@ -29,7 +30,7 @@ const appWindow = getCurrentWindow(); const isMac = navigator.platform.startsWith('Mac'); - import { loadVaultState, saveVaultState, readNote } from '$lib/api'; + import { loadVaultState, saveVaultState, readNote, createDailyNote } from '$lib/api'; import { debounce } from '$lib/utils/debounce'; import type { VaultState, FileEvent } from '$lib/types'; @@ -107,12 +108,28 @@ editor?.focusTitle(); } + async function handleDailyNote() { + try { + const entry = await createDailyNote(); + const content = await readNote(entry.path); + $activeNote = content; + $activeNotePath = entry.path; + $editorDirty = false; + editor?.loadNote(entry.path, content.content); + noteList?.refresh(); + sidebar?.refresh(); + } catch (e) { + console.error('Failed to create/open daily note:', e); + } + } + function handleMouseDown(e: MouseEvent) { if (e.button === 3) { e.preventDefault(); navigateHistory(-1); } if (e.button === 4) { e.preventDefault(); navigateHistory(1); } } function handleKeydown(e: KeyboardEvent) { + const mod = e.ctrlKey || e.metaKey; if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); navigateHistory(-1); @@ -123,19 +140,19 @@ navigateHistory(1); return; } - if (e.ctrlKey && !e.shiftKey && e.key === 'n') { + if (mod && !e.shiftKey && e.key === 'n') { e.preventDefault(); createAndFocusNote(); } - if (e.ctrlKey && e.shiftKey && e.key === 'N') { + if (mod && e.shiftKey && e.key === 'N') { e.preventDefault(); } - if (e.ctrlKey && e.shiftKey && e.key === 'F') { + if (mod && e.shiftKey && e.key === 'F') { e.preventDefault(); $showSearch = true; return; } - if (e.ctrlKey && !e.shiftKey && e.key === 'f') { + if (mod && !e.shiftKey && e.key === 'f') { e.preventDefault(); if ($activeNotePath) { editor?.openNoteSearch(); @@ -144,11 +161,11 @@ } return; } - if (e.ctrlKey && e.key === 'p') { + if (mod && e.key === 'p') { e.preventDefault(); $showCommandPalette = true; } - if (e.ctrlKey && e.key === 's') { + if (mod && e.key === 's') { e.preventDefault(); editor?.forceSave(); } @@ -220,6 +237,18 @@ + {#if !isMac}