fix: match scalar array properties in views
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
33
docs/adr/0122-scalar-array-frontmatter-properties.md
Normal file
33
docs/adr/0122-scalar-array-frontmatter-properties.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
119
src/utils/viewFilterArrayFields.ts
Normal file
119
src/utils/viewFilterArrayFields.ts
Normal 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)
|
||||
}
|
||||
61
src/utils/viewFilterArrayProperties.ts
Normal file
61
src/utils/viewFilterArrayProperties.ts
Normal 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
|
||||
}
|
||||
70
src/utils/viewFilters.arrayProperties.test.ts
Normal file
70
src/utils/viewFilters.arrayProperties.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user