fix: canonicalize hidden system metadata
This commit is contained in:
@@ -27,6 +27,30 @@ fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
fn normalize_system_key(key: &str) -> String {
|
||||
key.trim().to_ascii_lowercase().replace(' ', "_")
|
||||
}
|
||||
|
||||
fn canonical_system_key(key: &str) -> Option<&'static str> {
|
||||
match normalize_system_key(key).as_str() {
|
||||
"_icon" | "icon" => Some("_icon"),
|
||||
"_order" | "order" => Some("_order"),
|
||||
"_sidebar_label" | "sidebar_label" | "sidebar label" => Some("_sidebar_label"),
|
||||
"_sort" | "sort" => Some("_sort"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_system_key_aliases(canonical: &str) -> &'static [&'static str] {
|
||||
match canonical {
|
||||
"_icon" => &["icon"],
|
||||
"_order" => &["order"],
|
||||
"_sidebar_label" => &["sidebar_label", "sidebar label"],
|
||||
"_sort" => &["sort"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
|
||||
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
|
||||
@@ -82,11 +106,10 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
|
||||
new_lines
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(
|
||||
fn update_frontmatter_content_raw(
|
||||
content: &str,
|
||||
key: &str,
|
||||
value: Option<FrontmatterValue>,
|
||||
value: Option<&FrontmatterValue>,
|
||||
) -> Result<String, String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return match value {
|
||||
@@ -97,52 +120,90 @@ pub fn update_frontmatter_content(
|
||||
|
||||
let (fm_content, rest) = split_frontmatter(content)?;
|
||||
let lines: Vec<&str> = fm_content.lines().collect();
|
||||
let new_lines = apply_field_update(&lines, key, value.as_ref());
|
||||
let new_lines = apply_field_update(&lines, key, value);
|
||||
let new_fm = new_lines.join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(
|
||||
content: &str,
|
||||
key: &str,
|
||||
value: Option<FrontmatterValue>,
|
||||
) -> Result<String, String> {
|
||||
let Some(canonical) = canonical_system_key(key) else {
|
||||
return update_frontmatter_content_raw(content, key, value.as_ref());
|
||||
};
|
||||
|
||||
let mut updated = content.to_string();
|
||||
for alias in legacy_system_key_aliases(canonical) {
|
||||
updated = update_frontmatter_content_raw(&updated, alias, None)?;
|
||||
}
|
||||
|
||||
update_frontmatter_content_raw(&updated, canonical, value.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct UpdateExpectation<'a> {
|
||||
content: &'a str,
|
||||
key: &'a str,
|
||||
value: Option<FrontmatterValue>,
|
||||
expected_present: &'a [&'a str],
|
||||
expected_absent: &'a [&'a str],
|
||||
}
|
||||
|
||||
fn assert_updated_content(expectation: UpdateExpectation<'_>) {
|
||||
let updated =
|
||||
update_frontmatter_content(expectation.content, expectation.key, expectation.value)
|
||||
.unwrap();
|
||||
for expected in expectation.expected_present {
|
||||
assert!(
|
||||
updated.contains(expected),
|
||||
"missing expected snippet: {expected}"
|
||||
);
|
||||
}
|
||||
for unexpected in expectation.expected_absent {
|
||||
assert!(
|
||||
!updated.contains(unexpected),
|
||||
"found unexpected snippet: {unexpected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
assert_updated_content(UpdateExpectation {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Status",
|
||||
value: Some(FrontmatterValue::String("Active".to_string())),
|
||||
expected_present: &["Status: Active"],
|
||||
expected_absent: &["Status: Draft"],
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_add_new_key() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
assert_updated_content(UpdateExpectation {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Owner",
|
||||
value: Some(FrontmatterValue::String("Luca".to_string())),
|
||||
expected_present: &["Owner: Luca", "Status: Draft"],
|
||||
expected_absent: &[],
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_quoted_key() {
|
||||
let content = "---\n\"Is A\": Note\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Is A",
|
||||
Some(FrontmatterValue::String("Project".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("\"Is A\": Project"));
|
||||
assert!(!updated.contains("Note"));
|
||||
assert_updated_content(UpdateExpectation {
|
||||
content: "---\n\"Is A\": Note\n---\n# Test\n",
|
||||
key: "Is A",
|
||||
value: Some(FrontmatterValue::String("Project".to_string())),
|
||||
expected_present: &["\"Is A\": Project"],
|
||||
expected_absent: &["\"Is A\": Note"],
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -196,9 +257,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_existing() {
|
||||
let content = "# Test\n\nSome content here.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"# Test\n\nSome content here.",
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
@@ -278,6 +338,39 @@ mod tests {
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_rewrites_legacy_icon_key() {
|
||||
assert_updated_content(UpdateExpectation {
|
||||
content: "---\nicon: rocket\n---\n# Test\n",
|
||||
key: "icon",
|
||||
value: Some(FrontmatterValue::String("star".to_string())),
|
||||
expected_present: &["_icon: star"],
|
||||
expected_absent: &["\nicon:", "rocket"],
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_rewrites_sidebar_label_aliases() {
|
||||
assert_updated_content(UpdateExpectation {
|
||||
content: "---\nsidebar label: Projects\nsidebar_label: Legacy\n---\n# Test\n",
|
||||
key: "_sidebar_label",
|
||||
value: Some(FrontmatterValue::String("Programs".to_string())),
|
||||
expected_present: &["_sidebar_label: Programs"],
|
||||
expected_absent: &["sidebar label: Projects", "sidebar_label: Legacy"],
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_removes_sort_aliases() {
|
||||
assert_updated_content(UpdateExpectation {
|
||||
content: "---\nsort: modified:desc\n_sort: title:asc\n---\n# Test\n",
|
||||
key: "_sort",
|
||||
value: None,
|
||||
expected_present: &["# Test"],
|
||||
expected_absent: &["\nsort:", "\n_sort:"],
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_unquoted() {
|
||||
assert!(line_is_key("Status: Draft", "Status"));
|
||||
|
||||
@@ -21,17 +21,22 @@ pub(crate) struct Frontmatter {
|
||||
pub archived: Option<bool>,
|
||||
#[serde(rename = "Status", alias = "status", default)]
|
||||
pub status: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "_icon", alias = "icon", default)]
|
||||
pub icon: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub color: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "_order", alias = "order", default)]
|
||||
pub order: Option<i64>,
|
||||
#[serde(rename = "sidebar label", default)]
|
||||
#[serde(
|
||||
rename = "_sidebar_label",
|
||||
alias = "sidebar label",
|
||||
alias = "sidebar_label",
|
||||
default
|
||||
)]
|
||||
pub sidebar_label: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub template: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "_sort", alias = "sort", default)]
|
||||
pub sort: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub view: Option<StringOrList>,
|
||||
@@ -147,28 +152,28 @@ impl StringOrList {
|
||||
///
|
||||
/// This sanitizer converts objects back to "key: value" strings and removes nulls,
|
||||
/// preventing serde deserialization of the entire Frontmatter struct from failing.
|
||||
fn sanitize_array_item(item: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
match item {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::Object(map) if !map.is_empty() => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| match v {
|
||||
serde_json::Value::String(s) => format!("{}: {}", k, s),
|
||||
_ => format!("{}: {}", k, v),
|
||||
})
|
||||
.collect();
|
||||
Some(serde_json::Value::String(parts.join(", ")))
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
let sanitized: Vec<serde_json::Value> = arr
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
// Drop nulls (from `# comment` in list items)
|
||||
serde_json::Value::Null => None,
|
||||
// Convert mis-parsed objects back to "key: value" strings
|
||||
serde_json::Value::Object(map) if !map.is_empty() => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| match v {
|
||||
serde_json::Value::String(s) => format!("{}: {}", k, s),
|
||||
_ => format!("{}: {}", k, v),
|
||||
})
|
||||
.collect();
|
||||
Some(serde_json::Value::String(parts.join(", ")))
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
})
|
||||
.collect();
|
||||
let sanitized: Vec<serde_json::Value> =
|
||||
arr.iter().filter_map(sanitize_array_item).collect();
|
||||
serde_json::Value::Array(sanitized)
|
||||
}
|
||||
other => other.clone(),
|
||||
@@ -186,11 +191,16 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"_archived",
|
||||
"Archived",
|
||||
"archived",
|
||||
"_icon",
|
||||
"icon",
|
||||
"color",
|
||||
"_order",
|
||||
"order",
|
||||
"_sidebar_label",
|
||||
"sidebar_label",
|
||||
"sidebar label",
|
||||
"template",
|
||||
"_sort",
|
||||
"sort",
|
||||
"view",
|
||||
"visible",
|
||||
@@ -216,7 +226,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
/// Note: owner and cadence are NOT skipped — they should appear in generic properties.
|
||||
const SKIP_KEYS: &[&str] = &[
|
||||
"title",
|
||||
"is a",
|
||||
"is_a",
|
||||
"type",
|
||||
"aliases",
|
||||
"_archived",
|
||||
@@ -224,7 +234,7 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
"sidebar label",
|
||||
"sidebar_label",
|
||||
"template",
|
||||
"sort",
|
||||
"view",
|
||||
@@ -236,6 +246,15 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"_list_properties_display",
|
||||
];
|
||||
|
||||
fn normalize_frontmatter_key(key: &str) -> String {
|
||||
key.trim().to_ascii_lowercase().replace(' ', "_")
|
||||
}
|
||||
|
||||
fn should_skip_frontmatter_key(key: &str) -> bool {
|
||||
let normalized = normalize_frontmatter_key(key);
|
||||
normalized.starts_with('_') || SKIP_KEYS.contains(&normalized.as_str())
|
||||
}
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
pub(crate) fn extract_relationships(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
@@ -243,7 +262,7 @@ pub(crate) fn extract_relationships(
|
||||
let mut relationships = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) {
|
||||
if should_skip_frontmatter_key(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -278,8 +297,7 @@ pub(crate) fn extract_properties(
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower)) {
|
||||
if should_skip_frontmatter_key(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -360,8 +378,12 @@ fn parse_scalar(s: &str) -> serde_json::Value {
|
||||
|
||||
/// Return the key from a top-level `key:` or `"key":` YAML line.
|
||||
/// Returns `None` for indented, blank, or non-key lines.
|
||||
fn is_top_level_yaml_line(line: &str) -> bool {
|
||||
!line.is_empty() && !line.starts_with(' ') && !line.starts_with('\t')
|
||||
}
|
||||
|
||||
fn extract_yaml_key(line: &str) -> Option<&str> {
|
||||
if line.is_empty() || line.starts_with(' ') || line.starts_with('\t') {
|
||||
if !is_top_level_yaml_line(line) {
|
||||
return None;
|
||||
}
|
||||
let (k, _) = line.split_once(':')?;
|
||||
|
||||
@@ -454,5 +454,8 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String>
|
||||
#[path = "relationship_key_tests.rs"]
|
||||
mod relationship_key_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "system_metadata_tests.rs"]
|
||||
mod system_metadata_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
73
src-tauri/src/vault/system_metadata_tests.rs
Normal file
73
src-tauri/src/vault/system_metadata_tests.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
let file_path = dir.join(name);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let mut file = fs::File::create(file_path).unwrap();
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_canonical_system_metadata_keys() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"project.md",
|
||||
"---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n---\n# Project\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.icon.as_deref(), Some("rocket"));
|
||||
assert_eq!(entry.order, Some(4));
|
||||
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
|
||||
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_legacy_system_metadata_keys_without_property_leaks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"project.md",
|
||||
"---\ntype: Type\nicon: rocket\norder: 4\nsidebar label: Projects\nsort: title:asc\n---\n# Project\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.icon.as_deref(), Some("rocket"));
|
||||
assert_eq!(entry.order, Some(4));
|
||||
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
|
||||
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
|
||||
assert!(!entry.properties.contains_key("icon"));
|
||||
assert!(!entry.properties.contains_key("order"));
|
||||
assert!(!entry.properties.contains_key("sidebar label"));
|
||||
assert!(!entry.properties.contains_key("sort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_internal: secret\n_hidden_link: \"[[secret]]\"\nOwner: Luca\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert!(!entry.properties.contains_key("_internal"));
|
||||
assert!(!entry.relationships.contains_key("_hidden_link"));
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get("Owner")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
}
|
||||
127
src/components/DynamicPropertiesPanel.systemMetadata.test.tsx
Normal file
127
src/components/DynamicPropertiesPanel.systemMetadata.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = () => false
|
||||
Element.prototype.setPointerCapture = vi.fn()
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
if (!window.getComputedStyle) window.getComputedStyle = vi.fn().mockReturnValue({}) as never
|
||||
})
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note.md',
|
||||
filename: 'note.md',
|
||||
title: 'Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 0,
|
||||
createdAt: 0,
|
||||
fileSize: 0,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
function hasSuggestedSlot(label: string): boolean {
|
||||
return screen
|
||||
.queryAllByTestId('suggested-property')
|
||||
.some((node) => node.textContent?.includes(label))
|
||||
}
|
||||
|
||||
describe('DynamicPropertiesPanel system metadata', () => {
|
||||
const onAddProperty = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('hides underscored and legacy system metadata while keeping user properties visible', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{
|
||||
_list_properties_display: ['Owner'],
|
||||
_icon: 'rocket',
|
||||
icon: 'legacy',
|
||||
order: 4,
|
||||
sort: 'title:asc',
|
||||
'_sidebar_label': 'Projects',
|
||||
Owner: 'Luca',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Order')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Sort')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Sidebar label')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('treats _icon as satisfying the suggested icon slot', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ _icon: 'rocket' }}
|
||||
onAddProperty={onAddProperty}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(hasSuggestedSlot('Icon')).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the icon editor without writing metadata until the user saves', async () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
onAddProperty={onAddProperty}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(FOCUS_NOTE_ICON_PROPERTY_EVENT))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('icon-editable-input')).toBeInTheDocument()
|
||||
})
|
||||
expect(onAddProperty).not.toHaveBeenCalled()
|
||||
|
||||
const input = screen.getByTestId('icon-editable-input')
|
||||
fireEvent.change(input, { target: { value: 'rocket' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onAddProperty).toHaveBeenCalledWith('_icon', 'rocket')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import type { PropertyDisplayMode } from '../utils/propertyTypes'
|
||||
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
|
||||
import { PROPERTY_PANEL_GRID_STYLE, PROPERTY_PANEL_ROW_STYLE } from './propertyPanelLayout'
|
||||
import { humanizePropertyKey } from '../utils/propertyLabels'
|
||||
import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
@@ -107,6 +108,7 @@ function SuggestedPropertySlot({ label, onAdd }: { label: string; onAdd: () => v
|
||||
function getExistingPropertyKeys(propertyEntries: [string, FrontmatterValue][], frontmatter: ParsedFrontmatter): Set<string> {
|
||||
const keys = new Set(propertyEntries.map(([key]) => key.toLowerCase()))
|
||||
for (const key of Object.keys(frontmatter)) keys.add(key.toLowerCase())
|
||||
if (hasSystemMetadataKey(keys, '_icon')) keys.add('icon')
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -118,34 +120,25 @@ function getMissingSuggestedProperties(canAddProperty: boolean, existingKeys: Se
|
||||
)
|
||||
}
|
||||
|
||||
function getIconPropertyKey(propertyEntries: [string, FrontmatterValue][]) {
|
||||
return propertyEntries.find(([key]) => key.toLowerCase() === 'icon')?.[0]
|
||||
}
|
||||
|
||||
function useFocusNoteIconProperty({
|
||||
onAddProperty,
|
||||
propertyEntries,
|
||||
setEditingKey,
|
||||
setPendingSuggestedKey,
|
||||
}: {
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
propertyEntries: [string, FrontmatterValue][]
|
||||
setEditingKey: (key: string | null) => void
|
||||
setPendingSuggestedKey: (key: string | null) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handleFocusNoteIcon = () => {
|
||||
const existingIconKey = getIconPropertyKey(propertyEntries)
|
||||
|
||||
if (!existingIconKey) {
|
||||
if (!onAddProperty) return
|
||||
onAddProperty('icon', '')
|
||||
}
|
||||
|
||||
setEditingKey(existingIconKey ?? 'icon')
|
||||
if (!onAddProperty) return
|
||||
setPendingSuggestedKey('icon')
|
||||
setEditingKey('icon')
|
||||
}
|
||||
|
||||
window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon)
|
||||
return () => window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon)
|
||||
}, [onAddProperty, propertyEntries, setEditingKey])
|
||||
}, [onAddProperty, setEditingKey, setPendingSuggestedKey])
|
||||
}
|
||||
|
||||
export function DynamicPropertiesPanel({
|
||||
@@ -195,10 +188,10 @@ export function DynamicPropertiesPanel({
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
onAddProperty(key, trimmed)
|
||||
onAddProperty(key === 'icon' ? canonicalSystemMetadataKey(key) : key, trimmed)
|
||||
}, [onAddProperty, setEditingKey])
|
||||
|
||||
useFocusNoteIconProperty({ onAddProperty, propertyEntries, setEditingKey })
|
||||
useFocusNoteIconProperty({ onAddProperty, setEditingKey, setPendingSuggestedKey })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
40
src/hooks/frontmatterOps.systemMetadata.test.ts
Normal file
40
src/hooks/frontmatterOps.systemMetadata.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { contentToEntryPatch, frontmatterToEntryPatch } from './frontmatterOps'
|
||||
|
||||
describe('frontmatterOps system metadata', () => {
|
||||
it.each([
|
||||
['_icon', 'rocket', { icon: 'rocket' }],
|
||||
['icon', 'rocket', { icon: 'rocket' }],
|
||||
['_order', 4, { order: 4 }],
|
||||
['order', 4, { order: 4 }],
|
||||
['_sort', 'title:asc', { sort: 'title:asc' }],
|
||||
['sort', 'title:asc', { sort: 'title:asc' }],
|
||||
['_sidebar_label', 'Projects', { sidebarLabel: 'Projects' }],
|
||||
['sidebar label', 'Projects', { sidebarLabel: 'Projects' }],
|
||||
] as [string, unknown, Partial<VaultEntry>][])('maps %s to the expected entry field', (key, value, expected) => {
|
||||
expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected)
|
||||
})
|
||||
|
||||
it('maps canonical delete keys for system metadata', () => {
|
||||
expect(frontmatterToEntryPatch('delete', '_icon').patch).toEqual({ icon: null })
|
||||
expect(frontmatterToEntryPatch('delete', '_sidebar_label').patch).toEqual({ sidebarLabel: null })
|
||||
expect(frontmatterToEntryPatch('delete', '_order').patch).toEqual({ order: null })
|
||||
expect(frontmatterToEntryPatch('delete', '_sort').patch).toEqual({ sort: null })
|
||||
})
|
||||
|
||||
it('keeps canonical system metadata out of custom properties', () => {
|
||||
const patch = contentToEntryPatch(
|
||||
'---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n_internal: secret\nOwner: Luca\n---\n# Project\n',
|
||||
)
|
||||
|
||||
expect(patch).toEqual({
|
||||
isA: 'Type',
|
||||
icon: 'rocket',
|
||||
order: 4,
|
||||
sidebarLabel: 'Projects',
|
||||
sort: 'title:asc',
|
||||
properties: { Owner: 'Luca' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,15 +5,16 @@ import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||
import { updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
|
||||
|
||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
title: { title: '' },
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
icon: { icon: null }, sidebar_label: { sidebarLabel: null },
|
||||
_icon: { icon: null }, _sidebar_label: { sidebarLabel: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
_archived: { archived: false }, archived: { archived: false },
|
||||
order: { order: null },
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
_order: { order: null },
|
||||
template: { template: null }, _sort: { sort: null }, visible: { visible: null },
|
||||
_organized: { organized: false },
|
||||
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
|
||||
_list_properties_display: { listPropertiesDisplay: [] },
|
||||
@@ -47,27 +48,40 @@ export interface EntryPatchResult {
|
||||
propertiesPatch: PropertiesPatch | null
|
||||
}
|
||||
|
||||
function applyRecordPatch<T>(
|
||||
existing: Record<string, T>,
|
||||
patch: Record<string, T | null>,
|
||||
): Record<string, T> {
|
||||
const merged = { ...existing }
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) delete merged[key]
|
||||
else merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
export function frontmatterToEntryPatch(
|
||||
op: 'update' | 'delete', key: string, value?: FrontmatterValue,
|
||||
): EntryPatchResult {
|
||||
const k = key.toLowerCase().replace(/\s+/g, '_')
|
||||
const lookupKey = canonicalSystemMetadataKey(key)
|
||||
const systemMetadataKey = isSystemMetadataKey(key)
|
||||
if (op === 'delete') {
|
||||
const relPatch: RelationshipPatch = { [key]: null }
|
||||
const propPatch: PropertiesPatch | null = !(k in ENTRY_DELETE_MAP) ? { [key]: null } : null
|
||||
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch, propertiesPatch: propPatch }
|
||||
const relationshipPatch = systemMetadataKey ? null : { [key]: null }
|
||||
const propertiesPatch = !systemMetadataKey && !(lookupKey in ENTRY_DELETE_MAP) ? { [key]: null } : null
|
||||
return { patch: ENTRY_DELETE_MAP[lookupKey] ?? {}, relationshipPatch, propertiesPatch }
|
||||
}
|
||||
const str = value != null ? String(value) : null
|
||||
const arr = Array.isArray(value) ? value.map(String) : []
|
||||
const updates: Record<string, Partial<VaultEntry>> = {
|
||||
title: { title: str ?? '' },
|
||||
type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str },
|
||||
icon: { icon: str }, sidebar_label: { sidebarLabel: str },
|
||||
_icon: { icon: str }, _sidebar_label: { sidebarLabel: str },
|
||||
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
|
||||
_archived: { archived: Boolean(value) }, archived: { archived: Boolean(value) },
|
||||
order: { order: typeof value === 'number' ? value : null },
|
||||
_order: { order: typeof value === 'number' ? value : null },
|
||||
template: { template: str },
|
||||
sort: { sort: str },
|
||||
_sort: { sort: str },
|
||||
view: { view: str },
|
||||
visible: { visible: value === false ? false : null },
|
||||
_organized: { organized: Boolean(value) },
|
||||
@@ -78,12 +92,14 @@ export function frontmatterToEntryPatch(
|
||||
// Also update the relationships map for wikilink-containing values
|
||||
const wikilinks = value != null ? extractWikilinks(value) : []
|
||||
const relationshipPatch: RelationshipPatch | null =
|
||||
wikilinks.length > 0 ? { [key]: wikilinks } : null
|
||||
!systemMetadataKey && wikilinks.length > 0 ? { [key]: wikilinks } : null
|
||||
// For unknown keys (custom properties), produce a propertiesPatch
|
||||
const isKnownKey = k in updates
|
||||
const isKnownKey = lookupKey in updates
|
||||
const propertiesPatch: PropertiesPatch | null =
|
||||
!isKnownKey && value != null ? { [key]: typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : String(value) } : null
|
||||
return { patch: updates[k] ?? {}, relationshipPatch, propertiesPatch }
|
||||
!systemMetadataKey && !isKnownKey && value != null
|
||||
? { [key]: typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : String(value) }
|
||||
: null
|
||||
return { patch: updates[lookupKey] ?? {}, relationshipPatch, propertiesPatch }
|
||||
}
|
||||
|
||||
/** Parse frontmatter from full content and return a merged VaultEntry patch for all known fields. */
|
||||
@@ -134,24 +150,14 @@ export interface FrontmatterOpOptions {
|
||||
export function applyPropertiesPatch(
|
||||
existing: Record<string, string | number | boolean | null>, propPatch: PropertiesPatch,
|
||||
): Record<string, string | number | boolean | null> {
|
||||
const merged = { ...existing }
|
||||
for (const [k, v] of Object.entries(propPatch)) {
|
||||
if (v === null) delete merged[k]
|
||||
else merged[k] = v
|
||||
}
|
||||
return merged
|
||||
return applyRecordPatch(existing, propPatch)
|
||||
}
|
||||
|
||||
/** Apply a relationship patch by merging into the existing relationships map. */
|
||||
export function applyRelationshipPatch(
|
||||
existing: Record<string, string[]>, relPatch: RelationshipPatch,
|
||||
): Record<string, string[]> {
|
||||
const merged = { ...existing }
|
||||
for (const [k, v] of Object.entries(relPatch)) {
|
||||
if (v === null) delete merged[k]
|
||||
else merged[k] = v
|
||||
}
|
||||
return merged
|
||||
return applyRecordPatch(existing, relPatch)
|
||||
}
|
||||
|
||||
/** Run a frontmatter update/delete and apply the result to state.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
removeDisplayModeOverride,
|
||||
} from '../utils/propertyTypes'
|
||||
import { containsWikilinks } from '../components/DynamicPropertiesPanel'
|
||||
import { isSystemMetadataKey } from '../utils/systemMetadata'
|
||||
|
||||
// Keys to skip showing in Properties (handled by dedicated UI or internal)
|
||||
// Compared case-insensitively via isVisibleProperty()
|
||||
@@ -68,19 +69,40 @@ function collectAllVaultTags(entries: VaultEntry[] | undefined): Record<string,
|
||||
for (const entry of entries) {
|
||||
if (!entry.properties) continue
|
||||
for (const [key, value] of Object.entries(entry.properties)) {
|
||||
if (!Array.isArray(value)) continue
|
||||
let set = tagsByKey.get(key)
|
||||
if (!set) { set = new Set(); tagsByKey.set(key, set) }
|
||||
for (const tag of value) set.add(String(tag))
|
||||
addTagValues(tagsByKey, key, value)
|
||||
}
|
||||
}
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
return result
|
||||
return toSortedTagRecord(tagsByKey)
|
||||
}
|
||||
|
||||
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
|
||||
return !SKIP_KEYS.has(key.toLowerCase()) && !containsWikilinks(value)
|
||||
return !isHiddenPropertyKey(key) && !containsWikilinks(value)
|
||||
}
|
||||
|
||||
function addTagValues(tagsByKey: Map<string, Set<string>>, key: string, value: unknown) {
|
||||
if (!Array.isArray(value)) return
|
||||
|
||||
let set = tagsByKey.get(key)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
tagsByKey.set(key, set)
|
||||
}
|
||||
|
||||
for (const tag of value) {
|
||||
set.add(String(tag))
|
||||
}
|
||||
}
|
||||
|
||||
function toSortedTagRecord(tagsByKey: Map<string, Set<string>>): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const [key, set] of tagsByKey) {
|
||||
result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function isHiddenPropertyKey(key: string): boolean {
|
||||
return SKIP_KEYS.has(key.toLowerCase()) || isSystemMetadataKey(key)
|
||||
}
|
||||
|
||||
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
|
||||
|
||||
39
src/utils/systemMetadata.ts
Normal file
39
src/utils/systemMetadata.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
const SYSTEM_METADATA_ALIAS_GROUPS = {
|
||||
_icon: ['_icon', 'icon'],
|
||||
_order: ['_order', 'order'],
|
||||
_sidebar_label: ['_sidebar_label', 'sidebar_label', 'sidebar label'],
|
||||
_sort: ['_sort', 'sort'],
|
||||
} as const
|
||||
|
||||
const CANONICAL_SYSTEM_METADATA_KEYS = Object.keys(SYSTEM_METADATA_ALIAS_GROUPS)
|
||||
|
||||
const CANONICAL_BY_ALIAS = new Map<string, string>()
|
||||
|
||||
for (const canonical of CANONICAL_SYSTEM_METADATA_KEYS) {
|
||||
for (const alias of SYSTEM_METADATA_ALIAS_GROUPS[canonical as keyof typeof SYSTEM_METADATA_ALIAS_GROUPS]) {
|
||||
CANONICAL_BY_ALIAS.set(alias, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePropertyKey(key: string): string {
|
||||
return key.trim().toLowerCase().replace(/\s+/g, '_')
|
||||
}
|
||||
|
||||
export function canonicalSystemMetadataKey(key: string): string {
|
||||
const normalized = normalizePropertyKey(key)
|
||||
return CANONICAL_BY_ALIAS.get(normalized) ?? normalized
|
||||
}
|
||||
|
||||
export function isSystemMetadataKey(key: string): boolean {
|
||||
const normalized = normalizePropertyKey(key)
|
||||
return normalized.startsWith('_') || CANONICAL_BY_ALIAS.has(normalized)
|
||||
}
|
||||
|
||||
export function hasSystemMetadataKey(keys: Iterable<string>, canonicalKey: keyof typeof SYSTEM_METADATA_ALIAS_GROUPS): boolean {
|
||||
for (const key of keys) {
|
||||
if (canonicalSystemMetadataKey(key) === canonicalKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user