From cd7a570240d3ad2342b0675eabcd298c81798e9a Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 25 Mar 2026 10:37:52 +0100 Subject: [PATCH] feat: add Rust backend for pinned properties + refactor hook complexity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PinnedPropertyConfig struct to VaultEntry (Rust) - Extract _pinned_properties from YAML frontmatter (key:icon format) - Filter underscore-prefixed system properties from properties/relationships - Bump vault cache version to 9 - Refactor usePinnedProperties hook (cc 15→8) for CodeScene compliance - Add 6 Rust tests for pinned properties extraction - Add pinnedProperties field to TypeScript VaultEntry interface - Wire _pinned_properties mapping in frontmatterOps entry patch Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/vault/cache.rs | 2 +- src-tauri/src/vault/entry.rs | 10 +++ src-tauri/src/vault/frontmatter.rs | 117 ++++++++++++++++++++++++++++- src-tauri/src/vault/mod.rs | 4 +- src/components/EditorContent.tsx | 3 +- src/hooks/frontmatterOps.ts | 1 + src/hooks/useNoteCreation.ts | 2 +- src/hooks/usePinnedProperties.ts | 83 +++++++++----------- src/hooks/usePropertyPanelState.ts | 1 + src/mock-tauri/mock-entries.ts | 47 ++++++++++++ src/mock-tauri/mock-handlers.ts | 2 +- src/types.ts | 8 ++ 12 files changed, 227 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index 55f85ef7..9a682cc7 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry}; // --- Vault Cache --- /// Bump this when VaultEntry fields change to force a full rescan. -const CACHE_VERSION: u32 = 8; +const CACHE_VERSION: u32 = 9; #[derive(Debug, Serialize, Deserialize)] struct VaultCache { diff --git a/src-tauri/src/vault/entry.rs b/src-tauri/src/vault/entry.rs index fa545456..3f658c6c 100644 --- a/src-tauri/src/vault/entry.rs +++ b/src-tauri/src/vault/entry.rs @@ -1,6 +1,13 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// A single pinned-property config item stored in the type's frontmatter. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct PinnedPropertyConfig { + pub key: String, + pub icon: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct VaultEntry { pub path: String, @@ -59,4 +66,7 @@ pub struct VaultEntry { /// Only includes strings, numbers, and booleans — arrays/objects are excluded. #[serde(default)] pub properties: HashMap, + /// Pinned properties configuration for Type entries. + #[serde(rename = "pinnedProperties", default)] + pub pinned_properties: Vec, } diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 448e29e5..2c880c89 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -1,3 +1,4 @@ +use crate::vault::entry::PinnedPropertyConfig; use crate::vault::parsing::contains_wikilink; use serde::Deserialize; use std::collections::HashMap; @@ -193,6 +194,9 @@ pub(crate) fn extract_relationships( let mut relationships = HashMap::new(); for (key, value) in data { + if key.starts_with('_') { + continue; + } if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) { continue; } @@ -228,6 +232,9 @@ pub(crate) fn extract_properties( let mut properties = HashMap::new(); for (key, value) in data { + if key.starts_with('_') { + continue; + } let lower = key.to_ascii_lowercase(); if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower)) { continue; @@ -265,6 +272,38 @@ pub(crate) fn resolve_is_a(fm_is_a: Option) -> Option { fm_is_a.and_then(|a| a.into_vec().into_iter().next()) } +/// Parse a single pinned-property entry from "key:icon" format. +fn parse_pinned_entry(s: &str) -> PinnedPropertyConfig { + match s.split_once(':') { + Some((key, icon)) if !icon.is_empty() => PinnedPropertyConfig { + key: key.trim().to_string(), + icon: Some(icon.trim().to_string()), + }, + _ => PinnedPropertyConfig { + key: s.trim().to_string(), + icon: None, + }, + } +} + +/// Extract `_pinned_properties` from raw YAML frontmatter. +pub(crate) fn extract_pinned_properties( + data: &HashMap, +) -> Vec { + let value = match data.get("_pinned_properties") { + Some(v) => v, + None => return Vec::new(), + }; + match value { + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str()) + .map(parse_pinned_entry) + .collect(), + _ => Vec::new(), + } +} + /// Convert gray_matter::Pod to serde_json::Value fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value { match pod { @@ -284,17 +323,25 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value { } } -/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data. +/// Extract frontmatter, relationships, custom properties, and pinned-property config. pub(crate) fn extract_fm_and_rels( data: Option, ) -> ( Frontmatter, HashMap>, HashMap, + Vec, ) { let hash = match data { Some(gray_matter::Pod::Hash(map)) => map, - _ => return (Frontmatter::default(), HashMap::new(), HashMap::new()), + _ => { + return ( + Frontmatter::default(), + HashMap::new(), + HashMap::new(), + Vec::new(), + ) + } }; let json_map: HashMap = hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect(); @@ -302,5 +349,71 @@ pub(crate) fn extract_fm_and_rels( parse_frontmatter(&json_map), extract_relationships(&json_map), extract_properties(&json_map), + extract_pinned_properties(&json_map), ) } + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pinned_entry_with_icon() { + let result = parse_pinned_entry("Status:circle-dot"); + assert_eq!(result.key, "Status"); + assert_eq!(result.icon, Some("circle-dot".to_string())); + } + + #[test] + fn test_parse_pinned_entry_without_icon() { + let result = parse_pinned_entry("Priority"); + assert_eq!(result.key, "Priority"); + assert_eq!(result.icon, None); + } + + #[test] + fn test_extract_pinned_properties_array() { + let mut data = HashMap::new(); + data.insert( + "_pinned_properties".to_string(), + serde_json::json!(["Status:circle-dot", "Belongs to:arrow-up-right", "date"]), + ); + let result = extract_pinned_properties(&data); + assert_eq!(result.len(), 3); + assert_eq!(result[0].key, "Status"); + assert_eq!(result[0].icon, Some("circle-dot".to_string())); + assert_eq!(result[1].key, "Belongs to"); + assert_eq!(result[1].icon, Some("arrow-up-right".to_string())); + assert_eq!(result[2].key, "date"); + assert_eq!(result[2].icon, None); + } + + #[test] + fn test_extract_pinned_properties_missing() { + let data = HashMap::new(); + assert!(extract_pinned_properties(&data).is_empty()); + } + + #[test] + fn test_underscore_keys_excluded_from_properties() { + let mut data = HashMap::new(); + data.insert("_pinned_properties".to_string(), serde_json::json!(["a:b"])); + data.insert("_hidden".to_string(), serde_json::json!("secret")); + data.insert("visible_key".to_string(), serde_json::json!("value")); + let props = extract_properties(&data); + assert!(!props.contains_key("_pinned_properties")); + assert!(!props.contains_key("_hidden")); + assert!(props.contains_key("visible_key")); + } + + #[test] + fn test_underscore_keys_excluded_from_relationships() { + let mut data = HashMap::new(); + data.insert("_refs".to_string(), serde_json::json!("[[note]]")); + data.insert("Topics".to_string(), serde_json::json!("[[topic]]")); + let rels = extract_relationships(&data); + assert!(!rels.contains_key("_refs")); + assert!(rels.contains_key("Topics")); + } +} diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 537398c5..892c5de5 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -43,7 +43,8 @@ pub fn parse_md_file(path: &Path) -> Result { let matter = Matter::::new(); let parsed = matter.parse(&content); - let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data); + let (frontmatter, mut relationships, properties, pinned_properties) = + extract_fm_and_rels(parsed.data); let title = extract_title(frontmatter.title.as_deref(), &content, &filename); let snippet = extract_snippet(&content); @@ -103,6 +104,7 @@ pub fn parse_md_file(path: &Path) -> Result { word_count, outgoing_links, properties, + pinned_properties, }) } diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index 0dc2ded0..4882f3d7 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -14,8 +14,8 @@ import { RawEditorView } from './RawEditorView' import { countWords } from '../utils/wikilinks' import { SingleEditorView } from './SingleEditorView' import { isEmoji } from '../utils/emoji' -import { PinnedPropertiesBar } from './PinnedPropertiesBar' import { parseFrontmatter } from '../utils/frontmatter' +import { PinnedPropertiesBar } from './PinnedPropertiesBar' interface Tab { entry: VaultEntry @@ -63,6 +63,7 @@ interface EditorContentProps { onKeepMine?: (path: string) => void /** Resolve conflict by keeping the remote version. */ onKeepTheirs?: (path: string) => void + /** Update a frontmatter property (for inline pinned-property editing). */ onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise } diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index 7be0d782..4a08d1e2 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -47,6 +47,7 @@ export function frontmatterToEntryPatch( const relPatch: RelationshipPatch = { [key]: null } return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch } } + // Handle _pinned_properties for Type entries if (k === '_pinned_properties' && Array.isArray(value)) { const pinned = parsePinnedConfig(value.map(String)) diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 15d2bd02..c3a6d744 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -19,7 +19,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam aliases: [], belongsTo: [], relatedTo: [], status, archived: false, trashed: false, trashedAt: null, modifiedAt: now, createdAt: now, fileSize: 0, - snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, pinnedProperties: [], } } diff --git a/src/hooks/usePinnedProperties.ts b/src/hooks/usePinnedProperties.ts index dece1cc6..b427bfef 100644 --- a/src/hooks/usePinnedProperties.ts +++ b/src/hooks/usePinnedProperties.ts @@ -68,6 +68,29 @@ function formatLabel(key: string): string { return key.replace(/_/g, ' ') } +/** Resolve a single pinned config to its display representation. */ +function resolveOne( + entry: VaultEntry, frontmatter: ParsedFrontmatter, cfg: PinnedPropertyConfig, +): ResolvedPinnedProperty { + const { value, isRelationship } = resolveValue(entry, frontmatter, cfg.key) + return { key: cfg.key, icon: resolvePinIcon(cfg.key, cfg.icon), label: formatLabel(cfg.key), value, isRelationship } +} + +/** Find the Type entry for a note's isA field. */ +function findTypeEntry(entries: VaultEntry[], isA: string | null): VaultEntry | null { + if (!isA) return null + return entries.find((e) => e.isA === 'Type' && e.title === isA) ?? null +} + +/** Determine which properties are pinned for a given type. */ +function resolvePinnedConfigs( + isA: string | null, typeEntry: VaultEntry | null, entries: VaultEntry[], +): PinnedPropertyConfig[] { + if (!isA) return [] + if (typeEntry && typeEntry.pinnedProperties.length > 0) return typeEntry.pinnedProperties + return computeDefaults(entries, isA) +} + export interface UsePinnedPropertiesResult { pinnedConfigs: PinnedPropertyConfig[] resolved: ResolvedPinnedProperty[] @@ -78,53 +101,31 @@ export interface UsePinnedPropertiesResult { isPinned: (key: string) => boolean } +/** Hook to read and mutate pinned properties for a note based on its type. */ export function usePinnedProperties({ - entry, - entries, - frontmatter, - onUpdateTypeFrontmatter, + entry, entries, frontmatter, onUpdateTypeFrontmatter, }: { entry: VaultEntry | null entries: VaultEntry[] frontmatter: ParsedFrontmatter onUpdateTypeFrontmatter?: (typePath: string, key: string, value: FrontmatterValue) => Promise }): UsePinnedPropertiesResult { - // eslint-disable-next-line react-hooks/preserve-manual-memoization -- type lookup intentionally memoized - const typeEntry = useMemo(() => { - if (!entry?.isA) return null - return entries.find((e) => e.isA === 'Type' && e.title === entry.isA) ?? null - }, [entry?.isA, entries]) - - // eslint-disable-next-line react-hooks/preserve-manual-memoization -- pin config intentionally memoized - const pinnedConfigs = useMemo((): PinnedPropertyConfig[] => { - if (!entry?.isA) return [] - if (typeEntry && typeEntry.pinnedProperties.length > 0) return typeEntry.pinnedProperties - return computeDefaults(entries, entry.isA) - }, [entry?.isA, typeEntry, entries]) - - const resolved = useMemo((): ResolvedPinnedProperty[] => { - if (!entry) return [] - return pinnedConfigs.map((cfg) => { - const { value, isRelationship } = resolveValue(entry, frontmatter, cfg.key) - return { - key: cfg.key, - icon: resolvePinIcon(cfg.key, cfg.icon), - label: formatLabel(cfg.key), - value, - isRelationship, - } - }) - }, [entry, pinnedConfigs, frontmatter]) - + const typeEntry = useMemo(() => findTypeEntry(entries, entry?.isA ?? null), [entry?.isA, entries]) + const pinnedConfigs = useMemo( + () => resolvePinnedConfigs(entry?.isA ?? null, typeEntry, entries), + [entry?.isA, typeEntry, entries], + ) + const resolved = useMemo( + () => (entry ? pinnedConfigs.map((cfg) => resolveOne(entry, frontmatter, cfg)) : []), + [entry, pinnedConfigs, frontmatter], + ) const savePins = useCallback( (newConfigs: PinnedPropertyConfig[]) => { if (!typeEntry || !onUpdateTypeFrontmatter) return - const serialised = serialisePinnedConfig(newConfigs) - onUpdateTypeFrontmatter(typeEntry.path, '_pinned_properties', serialised) + onUpdateTypeFrontmatter(typeEntry.path, '_pinned_properties', serialisePinnedConfig(newConfigs)) }, [typeEntry, onUpdateTypeFrontmatter], ) - const pinProperty = useCallback( (key: string, icon?: string) => { if (pinnedConfigs.some((c) => c.key === key)) return @@ -132,26 +133,16 @@ export function usePinnedProperties({ }, [pinnedConfigs, savePins], ) - const unpinProperty = useCallback( (key: string) => savePins(pinnedConfigs.filter((c) => c.key !== key)), [pinnedConfigs, savePins], ) - const updatePinIcon = useCallback( (key: string, icon: string) => savePins(pinnedConfigs.map((c) => (c.key === key ? { ...c, icon } : c))), [pinnedConfigs, savePins], ) - - const reorderPins = useCallback( - (configs: PinnedPropertyConfig[]) => savePins(configs), - [savePins], - ) - - const isPinned = useCallback( - (key: string) => pinnedConfigs.some((c) => c.key === key), - [pinnedConfigs], - ) + const reorderPins = useCallback((configs: PinnedPropertyConfig[]) => savePins(configs), [savePins]) + const isPinned = useCallback((key: string) => pinnedConfigs.some((c) => c.key === key), [pinnedConfigs]) return { pinnedConfigs, resolved, pinProperty, unpinProperty, updatePinIcon, reorderPins, isPinned } } diff --git a/src/hooks/usePropertyPanelState.ts b/src/hooks/usePropertyPanelState.ts index 5aef19e3..304a1289 100644 --- a/src/hooks/usePropertyPanelState.ts +++ b/src/hooks/usePropertyPanelState.ts @@ -80,6 +80,7 @@ function collectAllVaultTags(entries: VaultEntry[] | undefined): Record v?.trim() || export const mockHandlers: Record any> = { list_vault: () => MOCK_ENTRIES, reload_vault: () => MOCK_ENTRIES, - reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, trashed: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} }, + reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, trashed: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {}, pinnedProperties: [] }, sync_note_title: () => false, get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '', get_all_content: () => MOCK_CONTENT, diff --git a/src/types.ts b/src/types.ts index ea33876a..40ee763c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,9 @@ +/** A single pinned-property config item from a Type's `_pinned_properties` list. */ +export interface PinnedPropertyConfig { + key: string + icon: string | null +} + export interface VaultEntry { path: string filename: string @@ -39,6 +45,8 @@ export interface VaultEntry { outgoingLinks: string[] /** Custom scalar frontmatter properties (non-relationship, non-structural). */ properties: Record + /** Pinned properties config for Type entries. Parsed from `_pinned_properties` frontmatter. */ + pinnedProperties: PinnedPropertyConfig[] } export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved'