fix: recognize type frontmatter case-insensitively

This commit is contained in:
lucaronin
2026-05-01 17:45:14 +02:00
parent 494aeb9533
commit 5b2b4cd569
6 changed files with 170 additions and 31 deletions

View File

@@ -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<SystemKey> {
.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))
}

View File

@@ -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);
}
}

View File

@@ -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<String, serde_json::Value>) -> 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<String, serde_json::Value>) -> Frontmatter {
static KNOWN_KEYS: &[&str] = &[
@@ -216,11 +232,19 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
"_favorite_index",
"_list_properties_display",
];
let filtered: serde_json::Map<String, serde_json::Value> = 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()
}

View File

@@ -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();

View File

@@ -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')
})
})
})

View File

@@ -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