From 7e50da7816640b478c835704f966b1d62e250365 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 15 May 2026 08:33:17 +0200 Subject: [PATCH] fix: match scalar array properties in views --- docs/ABSTRACTIONS.md | 10 +- docs/ARCHITECTURE.md | 2 +- ...122-scalar-array-frontmatter-properties.md | 33 ++++ docs/adr/README.md | 1 + src-tauri/src/vault/cache.rs | 4 +- src-tauri/src/vault/entry.rs | 4 +- src-tauri/src/vault/frontmatter.rs | 29 +++- .../src/vault/frontmatter_regression_tests.rs | 29 ++-- src-tauri/src/vault/view_tests.rs | 28 ++++ src-tauri/src/vault/view_value_conversions.rs | 6 + src-tauri/src/vault/views.rs | 53 ++++++- src/hooks/frontmatterOps.test.ts | 7 + src/hooks/frontmatterOps.ts | 16 +- src/types.ts | 8 +- src/utils/vaultMetadataNormalization.ts | 11 ++ src/utils/viewFilterArrayFields.ts | 119 ++++++++++++++ src/utils/viewFilterArrayProperties.ts | 61 +++++++ src/utils/viewFilters.arrayProperties.test.ts | 70 ++++++++ src/utils/viewFilters.ts | 149 ++++++------------ 19 files changed, 504 insertions(+), 136 deletions(-) create mode 100644 docs/adr/0122-scalar-array-frontmatter-properties.md create mode 100644 src/utils/viewFilterArrayFields.ts create mode 100644 src/utils/viewFilterArrayProperties.ts create mode 100644 src/utils/viewFilters.arrayProperties.test.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index a7f37713..8b71e84d 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -97,7 +97,7 @@ classDiagram +WorkspaceIdentity? workspace +Boolean trashed ⚠ legacy +Number? trashedAt ⚠ legacy - +Record~string,string~ properties + +Record~string,VaultPropertyValue~ properties } class TypeDocument { @@ -149,7 +149,7 @@ interface VaultEntry { archived: boolean // Archived flag trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent) trashedAt: number | null // Kept for backward compatibility (Trash system removed) - properties: Record // Scalar frontmatter fields (custom properties) + properties: Record // Scalar and scalar-array custom properties fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior } ``` @@ -250,7 +250,7 @@ Each entity type can have a corresponding **type document**: any markdown note w **Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail. -**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders. +**Instance schema/defaults**: Custom scalar/scalar-array properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders. **UI behavior**: - Clicking a section group header pins the type document at the top of the NoteList if it exists @@ -282,6 +282,8 @@ Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `Frontm - **List**: Multi-line ` - item` or inline `[item1, item2]` - **Null**: `owner:` (empty value) +Custom frontmatter fields with scalar values are exposed through `VaultEntry.properties`. Custom fields with scalar arrays are also exposed there, unless any array value contains a wikilink; wikilink-bearing fields belong to `VaultEntry.relationships`. Single-item scalar arrays continue to normalize to their scalar value for compatibility, while multi-item scalar arrays remain arrays so saved view filters can match exact elements. + ### Custom Relationships The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap: @@ -353,7 +355,7 @@ type SidebarSelection = ### Saved Views -Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior. +Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. Filter conditions on scalar-array custom properties, such as `tags: [blues, chicago]`, evaluate `contains`, `any_of`, and related set operators against exact array elements rather than substrings. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior. In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-owned `rootPath` and `workspace` provenance. `SidebarSelection.kind === 'view'` can include that `rootPath`, and view identity is `(rootPath, filename)` rather than filename alone. This lets two vaults both expose `views/focus.yml` without colliding in sidebar selection, note-list filtering, counts, sort/column persistence, edit, or delete flows. A saved View with `rootPath` filters only entries from its own workspace and persists changes through `save_view_cmd` / `delete_view_cmd` against that source vault. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2db43b19..65327453 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -449,7 +449,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin ### Cache File -`~/.laputa/cache/.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem. +`~/.laputa/cache/.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v14 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem. `/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash. diff --git a/docs/adr/0122-scalar-array-frontmatter-properties.md b/docs/adr/0122-scalar-array-frontmatter-properties.md new file mode 100644 index 00000000..48c2c570 --- /dev/null +++ b/docs/adr/0122-scalar-array-frontmatter-properties.md @@ -0,0 +1,33 @@ +# 0122. Scalar Array Frontmatter Properties + +Status: active + +Date: 2026-05-15 + +## Context + +Saved Views filter against `VaultEntry.properties` in the renderer and in the Rust view evaluator. Before this decision, Tolaria preserved custom scalar frontmatter values as properties but dropped multi-element non-wikilink arrays during a full vault scan. That made a view such as `tags / contains / blues` unstable: optimistic renderer state could see a changed array for a while, but reload, view switch, or restart rebuilt the entry without the array-backed property. + +Relationship arrays already have separate semantics because wikilink-bearing fields are stored in `VaultEntry.relationships`. Plain scalar arrays need to stay queryable as custom properties without being treated as relationship fields. + +## Decision + +**Tolaria preserves custom scalar-array frontmatter fields in `VaultEntry.properties`, while wikilink-bearing arrays remain relationships.** Single-item scalar arrays continue to normalize to a scalar value for compatibility; multi-item scalar arrays remain arrays. + +Saved View filters evaluate scalar-array properties with set semantics. `contains` and `any_of` match exact case-insensitive elements, not substrings inside an element. Scalar properties keep their existing case-insensitive text matching behavior. + +The vault cache version is bumped so existing caches that dropped array properties are rebuilt from disk. + +## Options considered + +- **Option A (chosen): Preserve scalar arrays as properties** - keeps YAML frontmatter expressive, fixes reload/restart behavior, and avoids hardcoded fields such as `tags`. The cost is widening `VaultEntry.properties` from scalar-only to scalar-or-array. +- **Option B: Flatten arrays to comma-delimited strings** - keeps the old property type, but cannot distinguish exact elements from substrings and makes filters ambiguous. +- **Option C: Treat every array as a relationship** - reuses existing relationship matching, but non-wikilink values such as tags are not graph edges and should not appear as relationships. + +## Consequences + +Views can filter custom scalar arrays consistently across save, reload, view switches, and app restart. + +Property chips and sorting must tolerate property arrays. The note-list chip resolver already expands array values; custom-property sorting falls back to string comparison for arrays. + +Any future custom-property logic must handle `VaultPropertyValue` rather than assuming every property is a scalar. diff --git a/docs/adr/README.md b/docs/adr/README.md index a2613513..447c4c15 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -173,3 +173,4 @@ proposed → active → superseded | [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active | | [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active | | [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | AppImage external fallback for audio and video previews | active | +| [0122](0122-scalar-array-frontmatter-properties.md) | Scalar array frontmatter properties | active | diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index 3333da5c..28f86830 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -19,8 +19,8 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry /// Bump this when VaultEntry fields change to force a full rescan. /// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items) -/// v13: preserve plain square brackets in parsed markdown H1 titles -const CACHE_VERSION: u32 = 13; +/// v14: preserve scalar-array custom frontmatter properties in VaultEntry +const CACHE_VERSION: u32 = 14; const CACHE_WRITE_LOCK_STALE_SECS: u64 = 30; #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src-tauri/src/vault/entry.rs b/src-tauri/src/vault/entry.rs index 65a75e05..e3cbc4d5 100644 --- a/src-tauri/src/vault/entry.rs +++ b/src-tauri/src/vault/entry.rs @@ -73,8 +73,8 @@ pub struct VaultEntry { /// Extracted from `[[target]]` and `[[target|display]]` patterns. #[serde(rename = "outgoingLinks", default)] pub outgoing_links: Vec, - /// Custom scalar frontmatter properties (non-relationship, non-structural). - /// Only includes strings, numbers, and booleans — arrays/objects are excluded. + /// Custom scalar and scalar-array frontmatter properties (non-relationship, non-structural). + /// Objects and arrays containing wikilinks are excluded. #[serde(default)] pub properties: HashMap, /// Properties to display as chips in the note list for this Type's notes. diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index d761848d..55da5f2a 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -289,7 +289,26 @@ fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option Option { + let mut values = Vec::new(); + for item in arr { + let sanitized = sanitize_array_item(item)?; + match sanitized { + serde_json::Value::String(ref s) if contains_wikilink(s) => return None, + serde_json::Value::String(_) + | serde_json::Value::Number(_) + | serde_json::Value::Bool(_) => values.push(sanitized), + _ => return None, + } + } + + match values.as_slice() { + [single] => Some(single.clone()), + _ => Some(serde_json::Value::Array(values)), + } +} + +/// Extract custom scalar and scalar-array properties from raw YAML frontmatter. pub(crate) fn extract_properties( data: &HashMap, ) -> HashMap { @@ -310,13 +329,9 @@ pub(crate) fn extract_properties( serde_json::Value::Number(_) | serde_json::Value::Bool(_) => { properties.insert(key.clone(), value.clone()); } - // Handle single-element arrays: unwrap to scalar. - // This ensures YAML like "Owner: [Luca]" or "Owner:\n - Luca" works correctly. serde_json::Value::Array(arr) => { - if let [serde_json::Value::String(s)] = arr.as_slice() { - if !contains_wikilink(s) { - properties.insert(key.clone(), serde_json::Value::String(s.clone())); - } + if let Some(value) = scalar_array_property_value(arr) { + properties.insert(key.clone(), value); } } _ => {} diff --git a/src-tauri/src/vault/frontmatter_regression_tests.rs b/src-tauri/src/vault/frontmatter_regression_tests.rs index cf41361d..25d975be 100644 --- a/src-tauri/src/vault/frontmatter_regression_tests.rs +++ b/src-tauri/src/vault/frontmatter_regression_tests.rs @@ -78,16 +78,10 @@ fn assert_alias_parser_recovers(case: AliasRecoveryCase<'_>) { #[test] fn test_filtered_properties_stay_hidden() { - let cases = [ - HiddenPropertyCase { - content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n", - hidden_key: "Mentor", - }, - HiddenPropertyCase { - content: "---\nTags:\n - productivity\n - writing\nCompany: Acme Corp\n---\n# Test\n", - hidden_key: "Tags", - }, - ]; + let cases = [HiddenPropertyCase { + content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n", + hidden_key: "Mentor", + }]; for case in cases { assert_filtered_property_stays_hidden(case); @@ -114,6 +108,21 @@ fn test_single_element_array_properties_unwrap_to_scalars() { } } +#[test] +fn test_multi_element_array_properties_are_preserved() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "playlist.md", + "---\ntags:\n - blues\n - chicago\n---\n# Playlist\n", + ); + + assert_eq!( + entry.properties.get("tags"), + Some(&serde_json::json!(["blues", "chicago"])) + ); +} + #[test] fn test_blank_scalar_properties_are_preserved() { let dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/vault/view_tests.rs b/src-tauri/src/vault/view_tests.rs index 03fa563f..87e3d293 100644 --- a/src-tauri/src/vault/view_tests.rs +++ b/src-tauri/src/vault/view_tests.rs @@ -405,6 +405,34 @@ filters: assert_eq!(result, vec![0]); } + #[test] + fn test_contains_matches_scalar_array_property_element() { + let yaml = r#" +name: Tagged +filters: + all: + - field: tags + op: contains + value: blues +"#; + let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); + + let matching = make_entry(|e| { + e.properties + .insert("tags".to_string(), serde_json::json!(["blues", "chicago"])); + }); + let partial = make_entry(|e| { + e.properties.insert( + "tags".to_string(), + serde_json::json!(["bluegrass", "chicago"]), + ); + }); + + let entries = vec![matching, partial]; + let result = evaluate_view(&def, &entries); + assert_eq!(result, vec![0]); + } + #[test] fn test_body_contains_filters_on_snippet() { let yaml = r#" diff --git a/src-tauri/src/vault/view_value_conversions.rs b/src-tauri/src/vault/view_value_conversions.rs index 0d5eb3e8..422f0775 100644 --- a/src-tauri/src/vault/view_value_conversions.rs +++ b/src-tauri/src/vault/view_value_conversions.rs @@ -7,6 +7,12 @@ pub(super) fn json_scalar_to_string(value: &serde_json::Value) -> Option } } +pub(super) fn json_scalar_array_to_strings(value: &serde_json::Value) -> Option> { + value + .as_array() + .map(|sequence| sequence.iter().filter_map(json_scalar_to_string).collect()) +} + pub(super) fn yaml_value_to_string(value: &serde_yaml::Value) -> Option { match value { serde_yaml::Value::String(value) => Some(value.clone()), diff --git a/src-tauri/src/vault/views.rs b/src-tauri/src/vault/views.rs index 89d46e97..d9a101f6 100644 --- a/src-tauri/src/vault/views.rs +++ b/src-tauri/src/vault/views.rs @@ -12,7 +12,8 @@ use super::view_date_filters::parse_date_filter_timestamp; use super::view_migration::{is_view_definition_file, migrate_views}; use super::view_relationships::{evaluate_relationship_op, relationship_candidates}; use super::view_value_conversions::{ - json_scalar_to_string, yaml_value_to_string, yaml_value_to_string_vec, + json_scalar_array_to_strings, json_scalar_to_string, yaml_value_to_string, + yaml_value_to_string_vec, }; use super::VaultEntry; @@ -292,6 +293,7 @@ fn supports_regex(op: &FilterOp) -> bool { enum ConditionField<'a> { Scalar(Option), + PropertyArray(Vec), Relationship(&'a [String]), } @@ -314,6 +316,9 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool { } match field_value { + ConditionField::PropertyArray(values) => { + evaluate_property_array_op(&cond.op, &values, &cond.value) + } ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value), ConditionField::Scalar(value) => evaluate_scalar_op( &cond.op, @@ -349,6 +354,9 @@ fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionF fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> { if let Some(prop) = entry.properties.get(field) { + if let Some(values) = json_scalar_array_to_strings(prop) { + return ConditionField::PropertyArray(values); + } return ConditionField::Scalar(json_scalar_to_string(prop)); } if let Some(relationships) = entry.relationships.get(field) { @@ -380,6 +388,7 @@ fn invalid_regex_requested(cond: &FilterCondition, regex: Option<&Regex>) -> boo fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool { let matched = match field { ConditionField::Scalar(Some(value)) => regex.is_match(value), + ConditionField::PropertyArray(values) => values.iter().any(|value| regex.is_match(value)), ConditionField::Relationship(values) => values.iter().any(|item| { relationship_candidates(item) .into_iter() @@ -395,6 +404,48 @@ fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &R } } +fn evaluate_property_array_op( + op: &FilterOp, + values: &[String], + raw_value: &Option, +) -> bool { + match op { + FilterOp::Contains => property_array_matches_value(values, raw_value), + FilterOp::NotContains => !property_array_matches_value(values, raw_value), + FilterOp::AnyOf => property_array_matches_any(values, raw_value), + FilterOp::NoneOf => !property_array_matches_any(values, raw_value), + FilterOp::Equals => values.len() == 1 && property_array_matches_value(values, raw_value), + FilterOp::NotEquals => { + !(values.len() == 1 && property_array_matches_value(values, raw_value)) + } + FilterOp::IsEmpty => values.is_empty(), + FilterOp::IsNotEmpty => !values.is_empty(), + _ => false, + } +} + +fn property_array_matches_value(values: &[String], raw_value: &Option) -> bool { + raw_value + .as_ref() + .and_then(yaml_value_to_string) + .is_some_and(|target| property_array_contains(values, &target)) +} + +fn property_array_matches_any(values: &[String], raw_value: &Option) -> bool { + raw_value + .as_ref() + .and_then(yaml_value_to_string_vec) + .unwrap_or_default() + .iter() + .any(|target| property_array_contains(values, target)) +} + +fn property_array_contains(values: &[String], target: &str) -> bool { + values + .iter() + .any(|value| value.eq_ignore_ascii_case(target)) +} + fn evaluate_scalar_op( op: &FilterOp, field_value: Option<&str>, diff --git a/src/hooks/frontmatterOps.test.ts b/src/hooks/frontmatterOps.test.ts index 34a508d7..e6ddadbd 100644 --- a/src/hooks/frontmatterOps.test.ts +++ b/src/hooks/frontmatterOps.test.ts @@ -51,6 +51,13 @@ describe('frontmatterToEntryPatch', () => { expect(result.relationshipPatch).toBeNull() }) + it('preserves unknown non-wikilink arrays as property arrays', () => { + const result = frontmatterToEntryPatch('update', 'tags', ['blues', 'chicago']) + expect(result.patch).toEqual({}) + expect(result.propertiesPatch).toEqual({ tags: ['blues', 'chicago'] }) + expect(result.relationshipPatch).toBeNull() + }) + it('produces relationship patch for wikilink values on unknown keys', () => { const result = frontmatterToEntryPatch('update', 'Notes', ['[[note-a]]', '[[note-b|Note B]]']) expect(result.patch).toEqual({}) diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index fa27c0aa..fd28514a 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -15,7 +15,7 @@ type MarkdownContent = string type ToastMessage = string | null type VaultPath = string type WikilinkText = string -type ScalarPropertyValue = string | number | boolean | null +type PropertyPatchValue = VaultEntry['properties'][string] const ENTRY_DELETE_MAP: Record> = { title: { title: '' }, @@ -50,8 +50,8 @@ function extractWikilinks(value: FrontmatterValue): WikilinkText[] { export type RelationshipPatch = Record /** Properties patch: a partial update to merge into `entry.properties`. - * Keys map to their new scalar values. A `null` value means "remove this key". */ -export type PropertiesPatch = Record + * Keys map to their new property values. A `null` value means "remove this key". */ +export type PropertiesPatch = Record export interface EntryPatchResult { patch: Partial @@ -98,7 +98,11 @@ function visibleValue(value: FrontmatterValue | undefined): false | null { return value === false ? false : null } -function scalarPropertyValue(value: FrontmatterValue): ScalarPropertyValue { +function propertyValue(value: FrontmatterValue): PropertyPatchValue { + if (Array.isArray(value)) { + const values = value.map(String) + return values.length === 1 ? values[0] ?? '' : values + } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value return String(value) } @@ -153,7 +157,7 @@ function propertiesUpdatePatch({ }: FrontmatterPatchInput): PropertiesPatch | null { const knownUpdates = knownFrontmatterUpdates(value) if (systemMetadataKey || Object.hasOwn(knownUpdates, lookupKey) || value == null) return null - return singleEntryRecord({ key, value: scalarPropertyValue(value) }) + return singleEntryRecord({ key, value: propertyValue(value) }) } function updateEntryPatch(input: FrontmatterPatchInput): EntryPatchResult { @@ -179,7 +183,7 @@ export function frontmatterToEntryPatch( export function contentToEntryPatch(content: MarkdownContent): Partial { const fm = parseFrontmatter(content) const merged: Partial = {} - const customProps: Record = {} + const customProps: Record = {} for (const [key, value] of Object.entries(fm)) { const { patch, propertiesPatch } = frontmatterToEntryPatch('update', key, value) Object.assign(merged, patch) diff --git a/src/types.ts b/src/types.ts index a18ff828..a231153f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,10 @@ import type { ThemeMode } from './lib/themeMode' import type { AppLocale } from './lib/i18n' import type { DateDisplayFormat } from './utils/dateDisplay' +export type VaultPropertyScalar = string | number | boolean | null +export type VaultPropertyArray = Array +export type VaultPropertyValue = VaultPropertyScalar | VaultPropertyArray + export interface VaultEntry { path: string filename: string @@ -53,8 +57,8 @@ export interface VaultEntry { listPropertiesDisplay: string[] /** All wikilink targets found in the note content. Extracted from [[target]] patterns. */ outgoingLinks: string[] - /** Custom scalar frontmatter properties (non-relationship, non-structural). */ - properties: Record + /** Custom scalar and scalar-array frontmatter properties (non-relationship, non-structural). */ + properties: Record /** Whether the note body has an H1 heading on the first non-empty line. */ hasH1: boolean /** File kind: "markdown", "text", or "binary". Determines editor behavior. diff --git a/src/utils/vaultMetadataNormalization.ts b/src/utils/vaultMetadataNormalization.ts index 397ce8c0..e71912b7 100644 --- a/src/utils/vaultMetadataNormalization.ts +++ b/src/utils/vaultMetadataNormalization.ts @@ -101,6 +101,17 @@ function normalizeProperties(value: unknown): VaultEntry['properties'] { const source = recordFrom(value) const result: VaultEntry['properties'] = {} for (const [key, rawValue] of Object.entries(source)) { + if (Array.isArray(rawValue)) { + const values = rawValue.filter((item): item is string | number | boolean => ( + typeof item === 'string' + || typeof item === 'boolean' + || (typeof item === 'number' && Number.isFinite(item)) + )) + if (values.length === rawValue.length) { + Reflect.set(result, key, values.length === 1 ? values[0] : values) + } + continue + } if ( rawValue === null || typeof rawValue === 'string' diff --git a/src/utils/viewFilterArrayFields.ts b/src/utils/viewFilterArrayFields.ts new file mode 100644 index 00000000..b3c12d0d --- /dev/null +++ b/src/utils/viewFilterArrayFields.ts @@ -0,0 +1,119 @@ +import type { FilterCondition } from '../types' +import { evaluatePropertyArrayCondition } from './viewFilterArrayProperties' + +export type ViewFilterArrayKind = 'property' | 'relationship' + +interface ArrayFieldCondition { + cond: FilterCondition + values: string[] + arrayKind: ViewFilterArrayKind + condVal: string + regex: RegExp | null +} + +function toStringValue(value: unknown): string { + if (value == null) return '' + if (typeof value === 'string') return value + return String(value) +} + +function conditionList(value: unknown): string[] | null { + return Array.isArray(value) ? value.map(toStringValue) : null +} + +class WikilinkValue { + private readonly trimmed: string + + constructor(private readonly raw: string) { + this.trimmed = raw.trim() + } + + get isBracketed(): boolean { + return this.trimmed.startsWith('[[') + } + + get normalizedStem(): string { + return this.stem.toLowerCase() + } + + get candidates(): string[] { + const pipe = this.inner.indexOf('|') + if (pipe >= 0) return [this.trimmed, this.inner.slice(0, pipe), this.inner.slice(pipe + 1)] + return [this.trimmed, this.inner] + } + + includesStem(target: WikilinkValue): boolean { + return this.normalizedStem.includes(target.normalizedStem) + } + + equals(target: WikilinkValue): boolean { + const targetParts = target.parts + return this.parts.some((part) => targetParts.some((targetPart) => part === targetPart)) + } + + private get parts(): string[] { + const pipe = this.inner.indexOf('|') + if (pipe >= 0) return [this.inner.substring(0, pipe).toLowerCase(), this.inner.substring(pipe + 1).toLowerCase()] + return [this.inner.toLowerCase()] + } + + private get stem(): string { + return this.inner.split('|')[0] ?? this.inner + } + + private get inner(): string { + return this.trimmed.replace(/^\[\[/, '').replace(/\]\]$/, '') + } +} + +class RelationshipArrayField { + private readonly links: WikilinkValue[] + + constructor(values: string[]) { + this.links = values.map((value) => new WikilinkValue(value)) + } + + contains(targetValue: string): boolean { + const target = new WikilinkValue(targetValue) + return this.links.some((link) => target.isBracketed ? link.equals(target) : link.includesStem(target)) + } + + equals(targetValue: string): boolean { + return this.links.length === 1 && this.links[0]?.equals(new WikilinkValue(targetValue)) === true + } + + matchesAny(targets: string[] | null): boolean { + return targets?.some((target) => this.links.some((link) => link.equals(new WikilinkValue(target)))) ?? false + } + + matchesRegex(regex: RegExp): boolean { + return this.links.some((link) => link.candidates.some((candidate) => regex.test(candidate))) + } +} + +const RELATIONSHIP_ARRAY_OPERATORS = { + contains: (field, value) => field.contains(value), + not_contains: (field, value) => !field.contains(value), + equals: (field, value) => field.equals(value), + not_equals: (field, value) => !field.equals(value), + any_of: (field, _value, cond) => field.matchesAny(conditionList(cond.value)), + none_of: (field, _value, cond) => !field.matchesAny(conditionList(cond.value)), +} satisfies Partial boolean>> + +function textMatchResult(op: FilterCondition['op'], matched: boolean): boolean { + if (op === 'contains' || op === 'equals') return matched + if (op === 'not_contains' || op === 'not_equals') return !matched + return false +} + +function evaluateRelationshipArrayCondition(cond: FilterCondition, values: string[], condVal: string, regex: RegExp | null): boolean { + const { op } = cond + const field = new RelationshipArrayField(values) + if (regex) return textMatchResult(op, field.matchesRegex(regex)) + return RELATIONSHIP_ARRAY_OPERATORS[op]?.(field, condVal, cond) ?? false +} + +export function evaluateArrayFieldCondition({ cond, values, arrayKind, condVal, regex }: ArrayFieldCondition): boolean { + if (arrayKind === 'property') return evaluatePropertyArrayCondition(cond, values, condVal, regex) + return evaluateRelationshipArrayCondition(cond, values, condVal, regex) +} diff --git a/src/utils/viewFilterArrayProperties.ts b/src/utils/viewFilterArrayProperties.ts new file mode 100644 index 00000000..b118a461 --- /dev/null +++ b/src/utils/viewFilterArrayProperties.ts @@ -0,0 +1,61 @@ +import type { FilterCondition } from '../types' + +function toStringValue(value: unknown): string { + if (value == null) return '' + if (typeof value === 'string') return value + return String(value) +} + +function conditionList(value: unknown): string[] | null { + return Array.isArray(value) ? value.map(toStringValue) : null +} + +function textMatchResult(op: FilterCondition['op'], matched: boolean): boolean { + if (op === 'contains' || op === 'equals') return matched + if (op === 'not_contains' || op === 'not_equals') return !matched + return false +} + +class PropertyArrayField { + private readonly normalizedValues: Set + + constructor(private readonly values: string[]) { + this.normalizedValues = new Set(values.map((value) => value.toLowerCase())) + } + + contains(target: string): boolean { + return this.normalizedValues.has(target.toLowerCase()) + } + + equals(target: string): boolean { + return this.values.length === 1 && this.contains(target) + } + + matchesAny(targets: string[] | null): boolean { + return targets?.some((target) => this.contains(target)) ?? false + } + + matchesRegex(regex: RegExp): boolean { + return this.values.some((value) => regex.test(value)) + } +} + +const PROPERTY_ARRAY_OPERATORS = { + contains: (field, value) => field.contains(value), + not_contains: (field, value) => !field.contains(value), + equals: (field, value) => field.equals(value), + not_equals: (field, value) => !field.equals(value), + any_of: (field, _value, cond) => field.matchesAny(conditionList(cond.value)), + none_of: (field, _value, cond) => !field.matchesAny(conditionList(cond.value)), +} satisfies Partial boolean>> + +export function evaluatePropertyArrayCondition( + cond: FilterCondition, + values: string[], + condVal: string, + regex: RegExp | null, +): boolean { + const field = new PropertyArrayField(values) + if (regex) return textMatchResult(cond.op, field.matchesRegex(regex)) + return PROPERTY_ARRAY_OPERATORS[cond.op]?.(field, condVal, cond) ?? false +} diff --git a/src/utils/viewFilters.arrayProperties.test.ts b/src/utils/viewFilters.arrayProperties.test.ts new file mode 100644 index 00000000..7920568c --- /dev/null +++ b/src/utils/viewFilters.arrayProperties.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { VaultEntry, ViewDefinition } from '../types' +import { evaluateView } from './viewFilters' + +const NOW = Math.floor(Date.now() / 1000) + +function makeEntry(title: string, tags: string[]): VaultEntry { + return { + path: `/vault/${title.toLowerCase()}.md`, + filename: `${title.toLowerCase()}.md`, + title, + isA: null, + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: NOW, + createdAt: NOW, + fileSize: 100, + 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, + outgoingLinks: [], + properties: { tags }, + listPropertiesDisplay: [], + hasH1: true, + } +} + +function tagsView(value: string): ViewDefinition { + return { + name: 'Tags', + icon: null, + color: null, + sort: null, + filters: { all: [{ field: 'tags', op: 'contains', value }] }, + } +} + +describe('evaluateView array properties', () => { + it('contains matches an exact element in a multi-value frontmatter array', () => { + const entries = [ + makeEntry('Blues', ['blues', 'chicago']), + makeEntry('Jazz', ['jazz', 'chicago']), + ] + + expect(evaluateView(tagsView('blues'), entries).map((entry) => entry.title)).toEqual(['Blues']) + }) + + it('contains does not match partial substrings inside array elements', () => { + const entries = [ + makeEntry('Bluegrass', ['bluegrass', 'chicago']), + makeEntry('Blue', ['blue', 'chicago']), + ] + + expect(evaluateView(tagsView('blue'), entries).map((entry) => entry.title)).toEqual(['Blue']) + }) +}) diff --git a/src/utils/viewFilters.ts b/src/utils/viewFilters.ts index 6b052932..595f9336 100644 --- a/src/utils/viewFilters.ts +++ b/src/utils/viewFilters.ts @@ -1,20 +1,24 @@ -import type { VaultEntry, ViewDefinition, FilterGroup, FilterNode, FilterCondition } from '../types' +import type { VaultEntry, ViewDefinition, FilterGroup, FilterNode, FilterCondition, VaultPropertyValue } from '../types' import { toDateFilterTimestamp } from './filterDates' import { compileSafeUserRegex } from './safeRegex' +import { evaluateArrayFieldCondition, type ViewFilterArrayKind } from './viewFilterArrayFields' -type ResolvedField = { scalar?: string | number | boolean | null; array?: string[] } +type FieldScalar = string | number | boolean | null +type ResolvedField = + | { kind: 'scalar'; value: FieldScalar } + | { kind: 'array'; values: string[]; arrayKind: ViewFilterArrayKind } type BuiltInFieldReader = (entry: VaultEntry) => ResolvedField type TextOp = FilterCondition['op'] const BUILT_IN_FIELD_READERS = new Map([ - ['type', (entry) => ({ scalar: entry.isA })], - ['isa', (entry) => ({ scalar: entry.isA })], - ['status', (entry) => ({ scalar: entry.status })], - ['title', (entry) => ({ scalar: entry.title })], - ['filename', (entry) => ({ scalar: entry.filename })], - ['archived', (entry) => ({ scalar: entry.archived })], - ['favorite', (entry) => ({ scalar: entry.favorite })], - ['body', (entry) => ({ scalar: entry.snippet })], + ['type', (entry) => scalarField(entry.isA)], + ['isa', (entry) => scalarField(entry.isA)], + ['status', (entry) => scalarField(entry.status)], + ['title', (entry) => scalarField(entry.title)], + ['filename', (entry) => scalarField(entry.filename)], + ['archived', (entry) => scalarField(entry.archived)], + ['favorite', (entry) => scalarField(entry.favorite)], + ['body', (entry) => scalarField(entry.snippet)], ]) /** Evaluate a view's filters against a list of entries, returning only matches. */ @@ -41,14 +45,27 @@ function findCaseInsensitiveKey(record: Record, lower: string): return Object.keys(record).find((k) => k.toLowerCase() === lower) } +function scalarField(value: FieldScalar): ResolvedField { + return { kind: 'scalar', value } +} + +function arrayField(values: string[], arrayKind: ViewFilterArrayKind): ResolvedField { + return { kind: 'array', values, arrayKind } +} + +function propertyField(value: VaultPropertyValue): ResolvedField { + if (Array.isArray(value)) return arrayField(value.map(toString), 'property') + return scalarField(value) +} + function resolveRelationshipField(entry: VaultEntry, lower: string): ResolvedField | null { const relKey = findCaseInsensitiveKey(entry.relationships, lower) - return relKey ? { array: Reflect.get(entry.relationships, relKey) as string[] } : null + return relKey ? arrayField(Reflect.get(entry.relationships, relKey) as string[], 'relationship') : null } function resolvePropertyField(entry: VaultEntry, lower: string): ResolvedField | null { const propKey = findCaseInsensitiveKey(entry.properties, lower) - return propKey ? { scalar: Reflect.get(entry.properties, propKey) as ResolvedField['scalar'] } : null + return propKey ? propertyField(Reflect.get(entry.properties, propKey) as VaultPropertyValue) : null } function resolveField(entry: VaultEntry, field: string): ResolvedField { @@ -56,45 +73,7 @@ function resolveField(entry: VaultEntry, field: string): ResolvedField { return BUILT_IN_FIELD_READERS.get(lower)?.(entry) ?? resolveRelationshipField(entry, lower) ?? resolvePropertyField(entry, lower) - ?? { scalar: null } -} - -function wikilinkStem(raw: string): string { - let s = raw.trim() - if (s.startsWith('[[')) s = s.slice(2) - if (s.endsWith(']]')) s = s.slice(0, -2) - const pipe = s.indexOf('|') - if (pipe >= 0) s = s.substring(0, pipe) - return s.toLowerCase() -} - -function relationshipCandidates(raw: string): string[] { - const trimmed = raw.trim() - let inner = trimmed - if (inner.startsWith('[[')) inner = inner.slice(2) - if (inner.endsWith(']]')) inner = inner.slice(0, -2) - const pipe = inner.indexOf('|') - if (pipe >= 0) { - return [trimmed, inner.slice(0, pipe), inner.slice(pipe + 1)] - } - return [trimmed, inner] -} - -/** Extract all comparable parts (path and alias) from a wikilink string. */ -function wikilinkParts(raw: string): string[] { - let s = raw.trim() - if (s.startsWith('[[')) s = s.slice(2) - if (s.endsWith(']]')) s = s.slice(0, -2) - const pipe = s.indexOf('|') - if (pipe >= 0) return [s.substring(0, pipe).toLowerCase(), s.substring(pipe + 1).toLowerCase()] - return [s.toLowerCase()] -} - -/** Check if two wikilink values match by comparing all path/alias combinations. */ -function wikilinkEquals(a: string, b: string): boolean { - const partsA = wikilinkParts(a) - const partsB = wikilinkParts(b) - return partsA.some(pa => partsB.some(pb => pa === pb)) + ?? scalarField(null) } function toString(v: unknown): string { @@ -116,68 +95,36 @@ function usesRegex(cond: FilterCondition): boolean { function evaluateEmptyCondition(op: FilterCondition['op'], resolved: ReturnType): boolean | null { if (op === 'is_empty') { - if (resolved.array) return resolved.array.length === 0 - const s = resolved.scalar + if (resolved.kind === 'array') return resolved.values.length === 0 + const s = resolved.value return s == null || s === '' || s === false } if (op === 'is_not_empty') { - if (resolved.array) return resolved.array.length > 0 - const s = resolved.scalar + if (resolved.kind === 'array') return resolved.values.length > 0 + const s = resolved.value return s != null && s !== '' && s !== false } return null } -function evaluateRegexArrayCondition(op: FilterCondition['op'], values: string[], regex: RegExp): boolean { - const matched = values.some((item) => relationshipCandidates(item).some((candidate) => regex.test(candidate))) +function textMatchResult(op: FilterCondition['op'], matched: boolean): boolean { if (op === 'contains' || op === 'equals') return matched if (op === 'not_contains' || op === 'not_equals') return !matched return false } -function hasArrayMatch(values: string[], condVal: string): boolean { - const stem = wikilinkStem(condVal) - const isWikilink = condVal.trim().startsWith('[[') - return values.some((item) => ( - isWikilink ? wikilinkEquals(item, condVal) : wikilinkStem(item).includes(stem) - )) -} - -function isSingleRelationshipMatch(values: string[], condVal: string): boolean { - return values.length === 1 && wikilinkEquals(values[0], condVal) -} - -const ARRAY_MATCHERS = { - contains: hasArrayMatch, - not_contains: hasArrayMatch, - equals: isSingleRelationshipMatch, - not_equals: isSingleRelationshipMatch, -} satisfies Partial boolean>> - -const NEGATED_ARRAY_OPS = new Set(['not_contains', 'not_equals', 'none_of']) -const ARRAY_SET_OPS = new Set(['any_of', 'none_of']) - -function evaluateArrayCondition(cond: FilterCondition, values: string[], condVal: string, regex: RegExp | null): boolean { - const { op, value } = cond - if (regex) return evaluateRegexArrayCondition(op, values, regex) - - const matcher = ARRAY_MATCHERS[op as keyof typeof ARRAY_MATCHERS] - if (matcher) { - const matched = matcher(values, condVal) - return NEGATED_ARRAY_OPS.has(op) ? !matched : matched - } - if (!ARRAY_SET_OPS.has(op)) return false - if (!Array.isArray(value)) return false - - const matched = values.some((item) => (value as string[]).some((v) => wikilinkEquals(item, v))) - return NEGATED_ARRAY_OPS.has(op) ? !matched : matched +function evaluateArrayCondition(cond: FilterCondition, resolved: Extract, condVal: string, regex: RegExp | null): boolean { + return evaluateArrayFieldCondition({ + cond, + values: resolved.values, + arrayKind: resolved.arrayKind, + condVal, + regex, + }) } function evaluateRegexScalarCondition(op: FilterCondition['op'], fieldRaw: string, regex: RegExp): boolean { - const matched = regex.test(fieldRaw) - if (op === 'equals' || op === 'contains') return matched - if (op === 'not_equals' || op === 'not_contains') return !matched - return false + return textMatchResult(op, regex.test(fieldRaw)) } function conditionList(value: unknown): string[] | null { @@ -236,13 +183,13 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean { const regex = usesRegex(cond) ? compileRegex(cond, condVal) : null if (usesRegex(cond) && !regex) return false - if (resolved.array) { - return evaluateArrayCondition(cond, resolved.array, condVal, regex) + if (resolved.kind === 'array') { + return evaluateArrayCondition(cond, resolved, condVal, regex) } if (cond.op === 'before' || cond.op === 'after') { - return evaluateDateCondition(cond, resolved.scalar, condVal) + return evaluateDateCondition(cond, resolved.value, condVal) } - return evaluateTextCondition(cond, toString(resolved.scalar), condVal, regex) + return evaluateTextCondition(cond, toString(resolved.value), condVal, regex) }