fix: match scalar array properties in views

This commit is contained in:
lucaronin
2026-05-15 08:33:17 +02:00
parent 17a938f8cf
commit 7e50da7816
19 changed files with 504 additions and 136 deletions

View File

@@ -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<string, string> // Scalar frontmatter fields (custom properties)
properties: Record<string, VaultPropertyValue> // 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.

View File

@@ -449,7 +449,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
### Cache File
`~/.laputa/cache/<vault-hash>.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/<vault-hash>.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.
`<vault>/.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.

View File

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

View File

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

View File

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

View File

@@ -73,8 +73,8 @@ pub struct VaultEntry {
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
#[serde(rename = "outgoingLinks", default)]
pub outgoing_links: Vec<String>,
/// 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<String, serde_json::Value>,
/// Properties to display as chips in the note list for this Type's notes.

View File

@@ -289,7 +289,26 @@ fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<Strin
}
}
/// Extract custom scalar properties from raw YAML frontmatter.
fn scalar_array_property_value(arr: &[serde_json::Value]) -> Option<serde_json::Value> {
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<String, serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
@@ -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);
}
}
_ => {}

View File

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

View File

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

View File

@@ -7,6 +7,12 @@ pub(super) fn json_scalar_to_string(value: &serde_json::Value) -> Option<String>
}
}
pub(super) fn json_scalar_array_to_strings(value: &serde_json::Value) -> Option<Vec<String>> {
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<String> {
match value {
serde_yaml::Value::String(value) => Some(value.clone()),

View File

@@ -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<String>),
PropertyArray(Vec<String>),
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<serde_yaml::Value>,
) -> 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<serde_yaml::Value>) -> 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<serde_yaml::Value>) -> 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>,

View File

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

View File

@@ -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<string, Partial<VaultEntry>> = {
title: { title: '' },
@@ -50,8 +50,8 @@ function extractWikilinks(value: FrontmatterValue): WikilinkText[] {
export type RelationshipPatch = Record<FrontmatterKey, WikilinkText[] | null>
/** 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<FrontmatterKey, ScalarPropertyValue>
* Keys map to their new property values. A `null` value means "remove this key". */
export type PropertiesPatch = Record<FrontmatterKey, PropertyPatchValue>
export interface EntryPatchResult {
patch: Partial<VaultEntry>
@@ -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<VaultEntry> {
const fm = parseFrontmatter(content)
const merged: Partial<VaultEntry> = {}
const customProps: Record<FrontmatterKey, ScalarPropertyValue> = {}
const customProps: Record<FrontmatterKey, PropertyPatchValue> = {}
for (const [key, value] of Object.entries(fm)) {
const { patch, propertiesPatch } = frontmatterToEntryPatch('update', key, value)
Object.assign(merged, patch)

View File

@@ -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<string | number | boolean>
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<string, string | number | boolean | null>
/** Custom scalar and scalar-array frontmatter properties (non-relationship, non-structural). */
properties: Record<string, VaultPropertyValue>
/** 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.

View File

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

View File

@@ -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<Record<FilterCondition['op'], (field: RelationshipArrayField, value: string, cond: FilterCondition) => 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)
}

View File

@@ -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<string>
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<Record<FilterCondition['op'], (field: PropertyArrayField, value: string, cond: FilterCondition) => 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
}

View File

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

View File

@@ -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<string, BuiltInFieldReader>([
['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<string, unknown>, 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<typeof resolveField>): 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<Record<FilterCondition['op'], (values: string[], condVal: string) => boolean>>
const NEGATED_ARRAY_OPS = new Set<FilterCondition['op']>(['not_contains', 'not_equals', 'none_of'])
const ARRAY_SET_OPS = new Set<FilterCondition['op']>(['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<ResolvedField, { kind: 'array' }>, 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)
}