diff --git a/src-tauri/src/frontmatter/ops.rs b/src-tauri/src/frontmatter/ops.rs index 2b8274e7..52d0449b 100644 --- a/src-tauri/src/frontmatter/ops.rs +++ b/src-tauri/src/frontmatter/ops.rs @@ -8,6 +8,7 @@ fn is_value_continuation(line: FrontmatterLine<'_>) -> bool { #[derive(Clone, Copy)] enum SystemKey { + Type, Icon, Order, SidebarLabel, @@ -17,6 +18,7 @@ enum SystemKey { impl SystemKey { fn canonical(self) -> &'static str { match self { + Self::Type => "type", Self::Icon => "_icon", Self::Order => "_order", Self::SidebarLabel => "_sidebar_label", @@ -26,12 +28,26 @@ impl SystemKey { fn legacy_aliases(self) -> &'static [&'static str] { match self { + Self::Type => &["type"], Self::Icon => &["icon"], Self::Order => &["order"], Self::SidebarLabel => &["sidebar_label", "sidebar label"], Self::Sort => &["sort"], } } + + fn alias_match_mode(self) -> KeyMatchMode { + match self { + Self::Type => KeyMatchMode::CaseInsensitive, + _ => KeyMatchMode::Exact, + } + } +} + +#[derive(Clone, Copy)] +enum KeyMatchMode { + Exact, + CaseInsensitive, } #[derive(Clone, Copy)] @@ -47,31 +63,47 @@ impl<'a> PropertyKey<'a> { fn as_str(self) -> &'a str { self.0 } + + fn matches(self, candidate: &str, mode: KeyMatchMode) -> bool { + match mode { + KeyMatchMode::Exact => candidate == self.as_str(), + KeyMatchMode::CaseInsensitive => candidate.eq_ignore_ascii_case(self.as_str()), + } + } +} + +impl<'a> FrontmatterLine<'a> { + fn key(self) -> Option<&'a str> { + let trimmed = self.0.trim_start(); + if let Some(raw) = trimmed.strip_prefix('"') { + return quoted_yaml_key(raw, '"'); + } + if let Some(raw) = trimmed.strip_prefix('\'') { + return quoted_yaml_key(raw, '\''); + } + trimmed + .split_once(':') + .map(|(key, _)| key.trim()) + .filter(|key| !key.is_empty()) + } +} + +fn quoted_yaml_key(raw: &str, quote: char) -> Option<&str> { + let (key, rest) = raw.split_once(quote)?; + rest.trim_start().starts_with(':').then_some(key) } #[derive(Clone, Copy)] struct FieldUpdate<'a> { key: PropertyKey<'a>, value: Option<&'a FrontmatterValue>, + match_mode: KeyMatchMode, } impl<'a> FieldUpdate<'a> { fn matches_line(self, line: FrontmatterLine<'_>) -> bool { - let trimmed = line.0.trim_start(); - - if trimmed.starts_with(self.key.as_str()) - && trimmed[self.key.as_str().len()..].starts_with(':') - { - return true; - } - - let double_quoted = format!("\"{}\":", self.key.as_str()); - if trimmed.starts_with(&double_quoted) { - return true; - } - - let single_quoted = format!("'{}\':", self.key.as_str()); - trimmed.starts_with(&single_quoted) + line.key() + .is_some_and(|candidate| self.key.matches(candidate, self.match_mode)) } fn prepend_to(self, content: DocumentText<'_>) -> String { @@ -141,6 +173,7 @@ fn canonical_system_key(key: PropertyKey<'_>) -> Option { .replace(' ', "_") .as_str() { + "type" => Some(SystemKey::Type), "_icon" | "icon" => Some(SystemKey::Icon), "_order" | "order" => Some(SystemKey::Order), "_sidebar_label" | "sidebar_label" | "sidebar label" => Some(SystemKey::SidebarLabel), @@ -158,6 +191,7 @@ pub fn update_frontmatter_content( let update = FieldUpdate { key: PropertyKey(key), value: value.as_ref(), + match_mode: KeyMatchMode::Exact, }; let Some(system_key) = canonical_system_key(update.key) else { return update.apply_to_content(DocumentText(content)); @@ -168,6 +202,7 @@ pub fn update_frontmatter_content( updated = FieldUpdate { key: PropertyKey(alias), value: None, + match_mode: system_key.alias_match_mode(), } .apply_to_content(DocumentText(&updated))?; } @@ -175,6 +210,7 @@ pub fn update_frontmatter_content( FieldUpdate { key: PropertyKey(system_key.canonical()), value: update.value, + match_mode: KeyMatchMode::Exact, } .apply_to_content(DocumentText(&updated)) } diff --git a/src-tauri/src/frontmatter/ops_update_tests.rs b/src-tauri/src/frontmatter/ops_update_tests.rs index d96372df..7ea986f3 100644 --- a/src-tauri/src/frontmatter/ops_update_tests.rs +++ b/src-tauri/src/frontmatter/ops_update_tests.rs @@ -190,3 +190,34 @@ fn test_update_frontmatter_canonicalizes_system_metadata_keys() { assert_updated_content(case); } } + +#[test] +fn test_update_frontmatter_canonicalizes_type_key_case() { + let cases = [ + UpdateCase { + content: "---\nType: Note\n---\n# Test\n", + key: "type", + value: Some(FrontmatterValue::String("Project".to_string())), + expected_present: &["type: Project"], + expected_absent: &["Type: Note"], + }, + UpdateCase { + content: "---\nTYPE: Note\n---\n# Test\n", + key: "Type", + value: Some(FrontmatterValue::String("Person".to_string())), + expected_present: &["type: Person"], + expected_absent: &["TYPE: Note"], + }, + UpdateCase { + content: "---\nType: Note\nstatus: Active\n---\n# Test\n", + key: "type", + value: None, + expected_present: &["status: Active", "# Test"], + expected_absent: &["Type: Note", "\ntype:"], + }, + ]; + + for case in cases { + assert_updated_content(case); + } +} diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 31cb3c28..00648b31 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -182,6 +182,22 @@ fn sanitize_value(value: &serde_json::Value) -> serde_json::Value { } } +fn type_key_priority(key: &str) -> u8 { + match key { + "type" => 0, + "Type" => 1, + "TYPE" => 2, + _ => 3, + } +} + +fn find_type_value(data: &HashMap) -> Option<&serde_json::Value> { + data.iter() + .filter(|(key, _)| key.eq_ignore_ascii_case("type")) + .min_by_key(|(key, _)| type_key_priority(key)) + .map(|(_, value)| value) +} + /// Parse frontmatter from raw YAML data extracted by gray_matter. fn parse_frontmatter(data: &HashMap) -> Frontmatter { static KNOWN_KEYS: &[&str] = &[ @@ -216,11 +232,19 @@ fn parse_frontmatter(data: &HashMap) -> Frontmatter { "_favorite_index", "_list_properties_display", ]; - let filtered: serde_json::Map = data - .iter() - .filter(|(k, _)| KNOWN_KEYS.contains(&k.as_str())) - .map(|(k, v)| (k.clone(), sanitize_value(v))) - .collect(); + let mut filtered = serde_json::Map::new(); + + if let Some(value) = find_type_value(data) { + filtered.insert("type".to_string(), sanitize_value(value)); + } + + for (key, value) in data { + if key.eq_ignore_ascii_case("type") || !KNOWN_KEYS.contains(&key.as_str()) { + continue; + } + filtered.insert(key.clone(), sanitize_value(value)); + } + let value = serde_json::Value::Object(filtered); serde_json::from_value(value).unwrap_or_default() } diff --git a/src-tauri/src/vault/mod_tests/type_and_links.rs b/src-tauri/src/vault/mod_tests/type_and_links.rs index 4dc0ef05..5efefd97 100644 --- a/src-tauri/src/vault/mod_tests/type_and_links.rs +++ b/src-tauri/src/vault/mod_tests/type_and_links.rs @@ -85,6 +85,16 @@ fn test_parse_type_key_lowercase() { assert_eq!(entry.is_a, Some("Project".to_string())); } +#[test] +fn test_parse_type_key_case_insensitive() { + for (key, expected_type) in [("Type", "Project"), ("TYPE", "Person")] { + let dir = TempDir::new().unwrap(); + let content = format!("---\n{key}: {expected_type}\n---\n# Test\n"); + let entry = parse_test_entry(&dir, "note/test.md", &content); + assert_eq!(entry.is_a, Some(expected_type.to_string())); + } +} + #[test] fn test_type_key_generates_type_relationship() { let dir = TempDir::new().unwrap(); diff --git a/src/hooks/mockFrontmatterHelpers.test.ts b/src/hooks/mockFrontmatterHelpers.test.ts index b9348aac..12ade37a 100644 --- a/src/hooks/mockFrontmatterHelpers.test.ts +++ b/src/hooks/mockFrontmatterHelpers.test.ts @@ -119,6 +119,16 @@ describe('mockFrontmatterHelpers', () => { const result = updateMockFrontmatter('/test.md', 'status', null) expect(result).toContain('status: null') }) + + it('canonicalizes Type to lowercase type', () => { + window.__mockContent = { + '/test.md': '---\nType: Note\n---\n\n# Hello\n', + } + + const result = updateMockFrontmatter('/test.md', 'type', 'Project') + expect(result).toContain('type: Project') + expect(result).not.toContain('Type: Note') + }) }) describe('deleteMockFrontmatterProperty', () => { @@ -167,5 +177,15 @@ describe('mockFrontmatterHelpers', () => { const result = deleteMockFrontmatterProperty('/test.md', 'status') expect(result).toBe('') }) + + it('deletes Type through lowercase type', () => { + window.__mockContent = { + '/test.md': '---\nType: Note\nstatus: Active\n---\n\n# Hello\n', + } + + const result = deleteMockFrontmatterProperty('/test.md', 'type') + expect(result).not.toContain('Type: Note') + expect(result).toContain('status: Active') + }) }) }) diff --git a/src/hooks/mockFrontmatterHelpers.ts b/src/hooks/mockFrontmatterHelpers.ts index 5807565e..9ff67daf 100644 --- a/src/hooks/mockFrontmatterHelpers.ts +++ b/src/hooks/mockFrontmatterHelpers.ts @@ -1,33 +1,50 @@ import type { FrontmatterValue } from '../components/Inspector' -function formatYamlValue(value: FrontmatterValue): string { +type VaultPath = string +type MarkdownContent = string +type FrontmatterKey = string +type YamlKey = string +type YamlValue = string +type YamlLine = string +type ReplacementLine = string | null + +function isTypeKey(key: FrontmatterKey): boolean { + return key.trim().toLowerCase() === 'type' +} + +function canonicalWriteKey(key: FrontmatterKey): FrontmatterKey { + return isTypeKey(key) ? 'type' : key +} + +function formatYamlValue(value: FrontmatterValue): YamlValue { if (Array.isArray(value)) return '\n' + value.map(v => ` - "${v}"`).join('\n') if (typeof value === 'boolean') return value ? 'true' : 'false' if (value === null) return 'null' return String(value) } -function formatYamlKey(key: string): string { +function formatYamlKey(key: FrontmatterKey): YamlKey { return key.includes(' ') ? `"${key}"` : key } -function buildKeyPattern(key: string): RegExp { - return new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm') +function buildKeyPattern(key: FrontmatterKey): RegExp { + const flags = isTypeKey(key) ? 'im' : 'm' + return new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, flags) } -function parseFrontmatter(content: string): { fm: string; rest: string } | null { +function parseFrontmatter(content: MarkdownContent): { fm: MarkdownContent; rest: MarkdownContent } | null { if (!content.startsWith('---\n')) return null const fmEnd = content.indexOf('\n---', 4) if (fmEnd === -1) return null return { fm: content.slice(4, fmEnd), rest: content.slice(fmEnd + 4) } } -function formatKeyValue(yamlKey: string, yamlValue: string, isArray: boolean): string { +function formatKeyValue(yamlKey: YamlKey, yamlValue: YamlValue, isArray: boolean): YamlLine { return isArray ? `${yamlKey}:${yamlValue}` : `${yamlKey}: ${yamlValue}` } -function processKeyInLines(lines: string[], keyPattern: RegExp, replacement: string | null): string[] { - const newLines: string[] = [] +function processKeyInLines(lines: YamlLine[], keyPattern: RegExp, replacement: ReplacementLine): YamlLine[] { + const newLines: YamlLine[] = [] let i = 0 while (i < lines.length) { if (keyPattern.test(lines[i])) { @@ -42,9 +59,10 @@ function processKeyInLines(lines: string[], keyPattern: RegExp, replacement: str return newLines } -export function updateMockFrontmatter(path: string, key: string, value: FrontmatterValue): string { +export function updateMockFrontmatter(path: VaultPath, key: FrontmatterKey, value: FrontmatterValue): MarkdownContent { const content = window.__mockContent?.[path] || '' - const yamlKey = formatYamlKey(key) + const writeKey = canonicalWriteKey(key) + const yamlKey = formatYamlKey(writeKey) const yamlValue = formatYamlValue(value) const isArray = Array.isArray(value) @@ -64,7 +82,7 @@ export function updateMockFrontmatter(path: string, key: string, value: Frontmat return `---\n${fm}\n${formatKeyValue(yamlKey, yamlValue, isArray)}\n---${rest}` } -export function deleteMockFrontmatterProperty(path: string, key: string): string { +export function deleteMockFrontmatterProperty(path: VaultPath, key: FrontmatterKey): MarkdownContent { const content = window.__mockContent?.[path] || '' const parsed = parseFrontmatter(content) if (!parsed) return content