fix: recognize type frontmatter case-insensitively
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user