collabmd 0.1.32 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/client/assets/{drawio-editor-Br4qyh3r.js → drawio-editor-BMTwP5mD.js} +1 -1
  2. package/dist/client/assets/{drawioEditor-ClmnTx5J.js → drawioEditor-D6clB5OH.js} +2 -2
  3. package/dist/client/assets/{editor-session-Cf-iV5-3.js → editor-session-Dzy_85Wp.js} +1 -1
  4. package/dist/client/assets/{excalidraw-editor-CXAxVhlw.js → excalidraw-editor-CAP9F8CB.js} +3 -3
  5. package/dist/client/assets/{excalidrawEditor-DoH_kMdu.js → excalidrawEditor-DD4AvzYP.js} +2 -2
  6. package/dist/client/assets/{exportDocument-9wTrBZNS.css → exportDocument-BbkN85c4.css} +1 -1
  7. package/dist/client/assets/index-BIt5EZXz.css +1 -0
  8. package/dist/client/assets/{index-C8QZYHvV.js → index-Czg_KL-i.js} +2 -2
  9. package/dist/client/assets/{main-BjHHMlv0.js → main-DeowJOSp.js} +261 -142
  10. package/dist/client/assets/{vault-api-client-Bf9tB_GW.js → vault-api-client-p7h7cXKM.js} +1 -1
  11. package/dist/client/drawio-editor.html +1 -1
  12. package/dist/client/excalidraw-editor.html +1 -1
  13. package/dist/client/export-document.html +2 -2
  14. package/dist/client/index.html +2 -2
  15. package/package.json +5 -5
  16. package/src/client/bootstrap/collabmd-app-shell.js +6 -0
  17. package/src/client/infrastructure/editor-session.js +4 -0
  18. package/src/client/infrastructure/editor-view-adapter.js +23 -0
  19. package/src/client/infrastructure/vault-api-client.js +48 -0
  20. package/src/client/presentation/bases-preview-controller.js +1388 -28
  21. package/src/client/presentation/comments-panel.js +50 -5
  22. package/src/client/styles/components/scrollbars.css +5 -0
  23. package/src/client/styles/features/preview-markdown.css +468 -23
  24. package/src/server/domain/backlink-index.js +177 -5
  25. package/src/server/domain/bases/base-definition.js +138 -11
  26. package/src/server/domain/bases/base-expression-runtime.js +54 -17
  27. package/src/server/domain/bases/base-index-snapshot-store.js +167 -20
  28. package/src/server/domain/bases/base-query-metadata.js +242 -0
  29. package/src/server/domain/bases/base-query-results.js +18 -8
  30. package/src/server/domain/bases/base-query-service.js +360 -49
  31. package/src/server/domain/bases/base-transform.js +116 -0
  32. package/src/server/infrastructure/http/create-vault-api-query-handler.js +52 -0
  33. package/dist/client/assets/index-CxSbUTs2.css +0 -1
  34. /package/dist/client/assets/{exportDocument-Bia2hI25.js → exportDocument-Zfwq1XVx.js} +0 -0
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { createWikiTargetIndex, resolveWikiTargetWithIndex } from '../../domain/wiki-link-resolver.js';
14
- import { isMarkdownFilePath } from '../../domain/file-kind.js';
14
+ import { getVaultFileExtension, isMarkdownFilePath } from '../../domain/file-kind.js';
15
15
  import { mapWithConcurrency } from '../shared/async-utils.js';
16
16
 
17
17
  const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
@@ -27,6 +27,39 @@ function createDeferred() {
27
27
  return { promise, reject, resolve };
28
28
  }
29
29
 
30
+ function normalizeWikiTargetKey(target = '') {
31
+ const normalizedTarget = String(target ?? '').trim();
32
+ if (!normalizedTarget) {
33
+ return '';
34
+ }
35
+
36
+ return getVaultFileExtension(normalizedTarget)
37
+ ? normalizedTarget
38
+ : `${normalizedTarget}.md`;
39
+ }
40
+
41
+ function collectWikiTargetKeysForFilePath(filePath = '') {
42
+ const normalizedPath = String(filePath ?? '').trim();
43
+ if (!normalizedPath) {
44
+ return [];
45
+ }
46
+
47
+ const segments = normalizedPath.split('/').filter(Boolean);
48
+ const keys = [];
49
+ for (let index = 0; index < segments.length; index += 1) {
50
+ keys.push(segments.slice(index).join('/'));
51
+ }
52
+
53
+ if (isMarkdownFilePath(normalizedPath)) {
54
+ const rawSegments = normalizedPath.replace(/\.md$/i, '').split('/').filter(Boolean);
55
+ for (let index = 0; index < rawSegments.length; index += 1) {
56
+ keys.push(rawSegments.slice(index).join('/'));
57
+ }
58
+ }
59
+
60
+ return [...new Set(keys)];
61
+ }
62
+
30
63
  export class BacklinkIndex {
31
64
  constructor({
32
65
  rebuildDelayMs = 150,
@@ -44,6 +77,10 @@ export class BacklinkIndex {
44
77
  this.reverse = new Map();
45
78
  /** @type {Map<string, Map<string, string[]>>} sourcePath → targetPath → contexts[] */
46
79
  this.contextsBySource = new Map();
80
+ /** @type {Map<string, Set<string>>} sourcePath → normalized raw target keys */
81
+ this.rawTargetKeysBySource = new Map();
82
+ /** @type {Map<string, Set<string>>} normalized raw target key → source paths */
83
+ this.rawTargetSources = new Map();
47
84
  /** @type {string[]} cached flat target file list for link resolution */
48
85
  this._fileList = [];
49
86
  /** @type {string[]} cached markdown source file list for content scans */
@@ -140,13 +177,16 @@ export class BacklinkIndex {
140
177
  this.forward.clear();
141
178
  this.reverse.clear();
142
179
  this.contextsBySource.clear();
180
+ this.rawTargetKeysBySource.clear();
181
+ this.rawTargetSources.clear();
143
182
 
144
183
  const snapshot = workspaceState ?? await this._resolveWorkspaceState();
145
184
  this._fileList = Array.from(snapshot?.filePaths ?? snapshot?.markdownPaths ?? []);
146
- this._sourceFileList = Array.from(
147
- snapshot?.markdownPaths ?? this._fileList.filter((filePath) => isMarkdownFilePath(filePath)),
148
- ).filter((filePath) => this._fileList.includes(filePath));
149
185
  this._fileSet = new Set(this._fileList);
186
+ this._sourceFileList = Array.from(
187
+ snapshot?.markdownPaths
188
+ ?? this._fileList.filter((filePath) => isMarkdownFilePath(filePath)),
189
+ ).filter((filePath) => this._fileSet.has(filePath));
150
190
  this._refreshWikiTargetIndex();
151
191
 
152
192
  const fileContents = await mapWithConcurrency(
@@ -256,6 +296,21 @@ export class BacklinkIndex {
256
296
  this.contextsBySource.set(newPath, sourceContexts);
257
297
  }
258
298
 
299
+ if (this.rawTargetKeysBySource.has(oldPath)) {
300
+ const rawTargetKeys = this.rawTargetKeysBySource.get(oldPath);
301
+ this.rawTargetKeysBySource.delete(oldPath);
302
+ this.rawTargetKeysBySource.set(newPath, rawTargetKeys);
303
+ rawTargetKeys.forEach((rawTargetKey) => {
304
+ const sources = this.rawTargetSources.get(rawTargetKey);
305
+ if (!sources) {
306
+ return;
307
+ }
308
+
309
+ sources.delete(oldPath);
310
+ sources.add(newPath);
311
+ });
312
+ }
313
+
259
314
  // Move forward links
260
315
  const oldForward = this.forward.get(oldPath);
261
316
  if (oldForward) {
@@ -319,6 +374,25 @@ export class BacklinkIndex {
319
374
  let refreshIndex = false;
320
375
  const previousEntries = previousState?.entries ?? new Map();
321
376
  const nextEntries = nextState?.entries ?? new Map();
377
+ const changedPaths = Array.from(new Set(workspaceChange.changedPaths ?? []));
378
+ const createdPaths = changedPaths.filter((pathValue) => (
379
+ nextEntries.has(pathValue) && !previousEntries.has(pathValue)
380
+ ));
381
+ const removedChangedPaths = changedPaths.filter((pathValue) => (
382
+ !nextEntries.has(pathValue) && previousEntries.has(pathValue)
383
+ ));
384
+ const membershipAffectedPaths = [
385
+ ...(workspaceChange.deletedPaths ?? []),
386
+ ...removedChangedPaths,
387
+ ...createdPaths,
388
+ ...(workspaceChange.renamedPaths ?? []).flatMap((entry) => [entry?.oldPath, entry?.newPath]).filter(Boolean),
389
+ ];
390
+ const impactedSources = this._collectImpactedSourcesForMembershipChanges(membershipAffectedPaths);
391
+ const renameMap = new Map(
392
+ (workspaceChange.renamedPaths ?? [])
393
+ .filter((entry) => entry?.oldPath && entry?.newPath)
394
+ .map((entry) => [entry.oldPath, entry.newPath]),
395
+ );
322
396
 
323
397
  for (const pathValue of workspaceChange.deletedPaths ?? []) {
324
398
  refreshIndex = this.onFileDeleted(pathValue, { refreshIndex: false }) || refreshIndex;
@@ -331,7 +405,7 @@ export class BacklinkIndex {
331
405
  refreshIndex = this.onFileRenamed(entry.oldPath, entry.newPath, { refreshIndex: false }) || refreshIndex;
332
406
  }
333
407
 
334
- for (const pathValue of new Set(workspaceChange.changedPaths ?? [])) {
408
+ for (const pathValue of changedPaths) {
335
409
  const existsNow = nextEntries.has(pathValue);
336
410
  const existedBefore = previousEntries.has(pathValue);
337
411
  if (!existsNow) {
@@ -361,7 +435,23 @@ export class BacklinkIndex {
361
435
  }
362
436
 
363
437
  if (refreshIndex) {
438
+ const sourcesToRefresh = new Set(impactedSources);
439
+ changedPaths.forEach((pathValue) => {
440
+ if (nextEntries.has(pathValue) && isMarkdownFilePath(pathValue)) {
441
+ sourcesToRefresh.add(pathValue);
442
+ }
443
+ });
444
+ (workspaceChange.renamedPaths ?? []).forEach((entry) => {
445
+ if (entry?.newPath && isMarkdownFilePath(entry.newPath)) {
446
+ sourcesToRefresh.add(entry.newPath);
447
+ }
448
+ });
449
+
364
450
  this._refreshWikiTargetIndex();
451
+ await this._refreshImpactedSources(sourcesToRefresh, {
452
+ nextEntries,
453
+ renameMap,
454
+ });
365
455
  }
366
456
  }
367
457
 
@@ -408,6 +498,7 @@ export class BacklinkIndex {
408
498
  _indexFile(filePath, content) {
409
499
  const resolvedTargets = new Set();
410
500
  const contextsByTarget = new Map();
501
+ const rawTargetKeys = new Set();
411
502
  const lines = content.split('\n');
412
503
 
413
504
  for (const line of lines) {
@@ -419,6 +510,11 @@ export class BacklinkIndex {
419
510
  continue;
420
511
  }
421
512
 
513
+ const rawTargetKey = normalizeWikiTargetKey(target);
514
+ if (rawTargetKey) {
515
+ rawTargetKeys.add(rawTargetKey);
516
+ }
517
+
422
518
  const resolved = this._resolveTarget(target);
423
519
  if (!resolved || resolved === filePath) {
424
520
  continue;
@@ -434,6 +530,17 @@ export class BacklinkIndex {
434
530
  }
435
531
 
436
532
  this.contextsBySource.delete(filePath);
533
+ this._removeRawTargetSourceContributions(filePath);
534
+
535
+ if (rawTargetKeys.size > 0) {
536
+ this.rawTargetKeysBySource.set(filePath, rawTargetKeys);
537
+ rawTargetKeys.forEach((rawTargetKey) => {
538
+ if (!this.rawTargetSources.has(rawTargetKey)) {
539
+ this.rawTargetSources.set(rawTargetKey, new Set());
540
+ }
541
+ this.rawTargetSources.get(rawTargetKey).add(filePath);
542
+ });
543
+ }
437
544
 
438
545
  if (resolvedTargets.size > 0) {
439
546
  this.contextsBySource.set(filePath, contextsByTarget);
@@ -450,6 +557,7 @@ export class BacklinkIndex {
450
557
 
451
558
  _removeForwardLinks(filePath) {
452
559
  this.contextsBySource.delete(filePath);
560
+ this._removeRawTargetSourceContributions(filePath);
453
561
 
454
562
  const oldTargets = this.forward.get(filePath);
455
563
  if (!oldTargets) return;
@@ -478,6 +586,70 @@ export class BacklinkIndex {
478
586
  _refreshWikiTargetIndex() {
479
587
  this._wikiTargetIndex = createWikiTargetIndex(this._fileList);
480
588
  }
589
+
590
+ _removeRawTargetSourceContributions(filePath) {
591
+ const rawTargetKeys = this.rawTargetKeysBySource.get(filePath);
592
+ if (!rawTargetKeys) {
593
+ return;
594
+ }
595
+
596
+ rawTargetKeys.forEach((rawTargetKey) => {
597
+ const sources = this.rawTargetSources.get(rawTargetKey);
598
+ if (!sources) {
599
+ return;
600
+ }
601
+
602
+ sources.delete(filePath);
603
+ if (sources.size === 0) {
604
+ this.rawTargetSources.delete(rawTargetKey);
605
+ }
606
+ });
607
+ this.rawTargetKeysBySource.delete(filePath);
608
+ }
609
+
610
+ _collectImpactedSourcesForMembershipChanges(pathValues = []) {
611
+ const affectedTargetKeys = new Set();
612
+ pathValues.forEach((pathValue) => {
613
+ collectWikiTargetKeysForFilePath(pathValue).forEach((targetKey) => {
614
+ affectedTargetKeys.add(targetKey);
615
+ });
616
+ });
617
+
618
+ const impactedSources = new Set();
619
+ affectedTargetKeys.forEach((targetKey) => {
620
+ this.rawTargetSources.get(targetKey)?.forEach((sourcePath) => {
621
+ impactedSources.add(sourcePath);
622
+ });
623
+ });
624
+ return impactedSources;
625
+ }
626
+
627
+ async _refreshImpactedSources(impactedSources = new Set(), {
628
+ nextEntries = new Map(),
629
+ renameMap = new Map(),
630
+ } = {}) {
631
+ const refreshedSources = new Set();
632
+
633
+ for (const sourcePath of impactedSources) {
634
+ const livePath = renameMap.get(sourcePath) ?? sourcePath;
635
+ if (
636
+ refreshedSources.has(livePath)
637
+ || !livePath
638
+ || !isMarkdownFilePath(livePath)
639
+ || !nextEntries.has(livePath)
640
+ ) {
641
+ continue;
642
+ }
643
+
644
+ const content = await this.vaultFileStore.readMarkdownFile(livePath);
645
+ if (content === null) {
646
+ continue;
647
+ }
648
+
649
+ refreshedSources.add(livePath);
650
+ this.updateFile(livePath, content, { refreshIndex: false });
651
+ }
652
+ }
481
653
  }
482
654
 
483
655
  /** Flatten a vault tree into an array of file paths. */
@@ -15,6 +15,25 @@ function capitalize(value = '') {
15
15
  return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`;
16
16
  }
17
17
 
18
+ function normalizeDirection(value, fallback = 'asc') {
19
+ return String(value ?? fallback).toLowerCase() === 'desc' ? 'desc' : 'asc';
20
+ }
21
+
22
+ function normalizeFormulaPropertyId(value = '') {
23
+ const normalized = String(value ?? '').trim();
24
+ if (!normalized) {
25
+ return '';
26
+ }
27
+
28
+ return normalized.startsWith('formula.')
29
+ ? normalized
30
+ : `formula.${normalized}`;
31
+ }
32
+
33
+ function bareFormulaName(value = '') {
34
+ return normalizeFormulaPropertyId(value).replace(/^formula\./u, '');
35
+ }
36
+
18
37
  function normalizeBaseView(rawView, index) {
19
38
  const view = isPlainObject(rawView) ? rawView : {};
20
39
  const type = typeof view.type === 'string' ? view.type : 'table';
@@ -43,9 +62,7 @@ function normalizeBaseView(rawView, index) {
43
62
  return acc;
44
63
  }, {}),
45
64
  filters: view.filters ?? null,
46
- groupBy: typeof view.groupBy === 'string'
47
- ? view.groupBy
48
- : (typeof view.group_by === 'string' ? view.group_by : null),
65
+ groupBy: normalizeViewGroupBy(view.groupBy ?? view.group_by),
49
66
  id: `view-${index}`,
50
67
  image: typeof view.image === 'string' ? view.image : null,
51
68
  limit: Number.isFinite(Number(view.limit)) ? Math.max(0, Number(view.limit)) : null,
@@ -80,6 +97,31 @@ function normalizeViewOrder(value) {
80
97
  return [];
81
98
  }
82
99
 
100
+ function normalizeViewGroupBy(value) {
101
+ if (typeof value === 'string' && value.trim()) {
102
+ return {
103
+ direction: 'asc',
104
+ explicitDirection: false,
105
+ property: value.trim(),
106
+ };
107
+ }
108
+
109
+ if (!isPlainObject(value)) {
110
+ return null;
111
+ }
112
+
113
+ const property = value.property ?? value.id ?? value.name ?? null;
114
+ if (typeof property !== 'string' || !property.trim()) {
115
+ return null;
116
+ }
117
+
118
+ return {
119
+ direction: normalizeDirection(value.direction ?? value.order, 'asc'),
120
+ explicitDirection: Object.hasOwn(value, 'direction') || Object.hasOwn(value, 'order'),
121
+ property: property.trim(),
122
+ };
123
+ }
124
+
83
125
  function normalizeViewSort(value) {
84
126
  if (typeof value === 'string' && value.trim()) {
85
127
  return [{ direction: 'asc', property: value.trim() }];
@@ -103,9 +145,7 @@ function normalizeViewSort(value) {
103
145
  return null;
104
146
  }
105
147
 
106
- const direction = String(entry.direction ?? entry.order ?? 'asc').toLowerCase() === 'desc'
107
- ? 'desc'
108
- : 'asc';
148
+ const direction = normalizeDirection(entry.direction ?? entry.order, 'asc');
109
149
  return { direction, property: property.trim() };
110
150
  })
111
151
  .filter(Boolean);
@@ -183,20 +223,70 @@ function resolvePropertyLabel(propertyId, definition) {
183
223
  if (config?.displayName) {
184
224
  return config.displayName;
185
225
  }
226
+
227
+ if (propertyId.startsWith('formula.')) {
228
+ return bareFormulaName(propertyId);
229
+ }
230
+
186
231
  return propertyId.startsWith('file.')
187
232
  ? propertyId.slice(5)
188
233
  : propertyId.replace(/^note\./u, '');
189
234
  }
190
235
 
236
+ function normalizeFormulaEntries(rawFormulas, rawProperties) {
237
+ const formulas = {};
238
+ const appendFormula = (propertyId, formula, { legacy = false } = {}) => {
239
+ const normalizedId = normalizeFormulaPropertyId(propertyId);
240
+ if (!normalizedId || typeof formula !== 'string') {
241
+ return;
242
+ }
243
+
244
+ formulas[normalizedId] = {
245
+ formula,
246
+ id: normalizedId,
247
+ legacy,
248
+ name: bareFormulaName(normalizedId),
249
+ };
250
+ };
251
+
252
+ if (isPlainObject(rawFormulas)) {
253
+ Object.entries(rawFormulas).forEach(([name, formula]) => {
254
+ appendFormula(name, formula);
255
+ });
256
+ }
257
+
258
+ Object.entries(rawProperties).forEach(([id, config]) => {
259
+ if (typeof config?.formula === 'string') {
260
+ appendFormula(id, config.formula, { legacy: true });
261
+ }
262
+ });
263
+
264
+ return formulas;
265
+ }
266
+
267
+ function createFormulaLookup(formulas = {}) {
268
+ const lookup = new Map();
269
+
270
+ Object.keys(formulas).forEach((propertyId) => {
271
+ const bareName = bareFormulaName(propertyId);
272
+ if (bareName) {
273
+ lookup.set(bareName, propertyId);
274
+ }
275
+ lookup.set(propertyId, propertyId);
276
+ });
277
+
278
+ return lookup;
279
+ }
280
+
191
281
  export function normalizeBaseDefinition(source = '') {
192
282
  const raw = yaml.load(String(source ?? '')) ?? {};
193
283
  const rawObject = isPlainObject(raw) ? raw : {};
194
284
  const rawProperties = isPlainObject(rawObject.properties) ? rawObject.properties : {};
285
+ const formulas = normalizeFormulaEntries(rawObject.formulas, rawProperties);
195
286
  const properties = Object.entries(rawProperties).reduce((acc, [id, config]) => {
196
287
  const normalizedConfig = isPlainObject(config) ? { ...config } : {};
197
288
  acc[id] = {
198
289
  displayName: typeof normalizedConfig.displayName === 'string' ? normalizedConfig.displayName : null,
199
- formula: typeof normalizedConfig.formula === 'string' ? normalizedConfig.formula : null,
200
290
  id,
201
291
  raw: normalizedConfig,
202
292
  };
@@ -211,6 +301,8 @@ export function normalizeBaseDefinition(source = '') {
211
301
 
212
302
  return {
213
303
  filters: rawObject.filters ?? null,
304
+ formulas,
305
+ formulaLookup: createFormulaLookup(formulas),
214
306
  properties,
215
307
  raw: rawObject,
216
308
  views,
@@ -220,7 +312,10 @@ export function normalizeBaseDefinition(source = '') {
220
312
  export function buildColumns(definition, view) {
221
313
  const order = view.order.length > 0
222
314
  ? view.order
223
- : ['file.name', ...Object.keys(definition.properties)];
315
+ : ['file.name', ...new Set([
316
+ ...Object.keys(definition.properties),
317
+ ...Object.keys(definition.formulas),
318
+ ])];
224
319
  return order.map((propertyId) => ({
225
320
  id: propertyId,
226
321
  label: resolvePropertyLabel(propertyId, definition),
@@ -230,8 +325,8 @@ export function buildColumns(definition, view) {
230
325
  export function collectEvaluatedPropertyIds(columns, view) {
231
326
  const propertyIds = new Set(columns.map((column) => column.id));
232
327
 
233
- if (view.groupBy) {
234
- propertyIds.add(view.groupBy);
328
+ if (view.groupBy?.property) {
329
+ propertyIds.add(view.groupBy.property);
235
330
  }
236
331
 
237
332
  view.sort.forEach((sortConfig) => {
@@ -249,7 +344,39 @@ export function collectEvaluatedPropertyIds(columns, view) {
249
344
  return [...propertyIds];
250
345
  }
251
346
 
347
+ export function findView(definition, requestedView = '') {
348
+ return definition.views.find((entry) => entry.name === requestedView || entry.id === requestedView) ?? definition.views[0];
349
+ }
350
+
351
+ export function normalizeRawDefinitionForWrite(definition) {
352
+ const rawDefinition = isPlainObject(definition?.raw)
353
+ ? structuredClone(definition.raw)
354
+ : structuredClone(isPlainObject(definition) ? definition : {});
355
+ const rawProperties = isPlainObject(rawDefinition.properties) ? rawDefinition.properties : {};
356
+ const formulas = isPlainObject(rawDefinition.formulas)
357
+ ? { ...rawDefinition.formulas }
358
+ : {};
359
+
360
+ Object.entries(rawProperties).forEach(([propertyId, config]) => {
361
+ if (!isPlainObject(config) || typeof config.formula !== 'string') {
362
+ return;
363
+ }
364
+
365
+ formulas[bareFormulaName(propertyId)] = config.formula;
366
+ delete config.formula;
367
+ });
368
+
369
+ rawDefinition.properties = rawProperties;
370
+ if (Object.keys(formulas).length > 0) {
371
+ rawDefinition.formulas = formulas;
372
+ } else {
373
+ delete rawDefinition.formulas;
374
+ }
375
+
376
+ return rawDefinition;
377
+ }
378
+
252
379
  export function serializeBaseDefinition(definition) {
253
- const raw = definition?.raw ?? definition ?? {};
380
+ const raw = normalizeRawDefinitionForWrite(definition);
254
381
  return `${yaml.dump(raw, { lineWidth: -1, noRefs: true }).trim()}\n`;
255
382
  }
@@ -111,6 +111,10 @@ function normalizeDuration(duration) {
111
111
  return Number.isFinite(amount) && multiplier ? amount * multiplier : null;
112
112
  }
113
113
 
114
+ function hasFormulaDefinition(definition, propertyId = '') {
115
+ return Boolean(definition?.formulas?.[propertyId]);
116
+ }
117
+
114
118
  export function parseDateValue(value) {
115
119
  if (value instanceof Date) {
116
120
  return Number.isFinite(value.getTime()) ? value : null;
@@ -476,6 +480,26 @@ function invokeMethod(target, name, argNodes, scope, evaluate, rootContext) {
476
480
  return rootContext.resolveFormulaValue(name, target.row, target.evaluationState);
477
481
  }
478
482
 
483
+ if (target == null) {
484
+ switch (name) {
485
+ case 'contains':
486
+ case 'containsAll':
487
+ case 'containsAny':
488
+ case 'endsWith':
489
+ case 'hasLink':
490
+ case 'hasProperty':
491
+ case 'hasTag':
492
+ case 'inFolder':
493
+ case 'linksTo':
494
+ case 'startsWith':
495
+ return false;
496
+ case 'isEmpty':
497
+ return true;
498
+ default:
499
+ break;
500
+ }
501
+ }
502
+
479
503
  if (Array.isArray(target)) {
480
504
  if (name === 'contains') {
481
505
  const needle = evaluate(argNodes[0], scope);
@@ -1031,6 +1055,9 @@ export function evaluateFilterNode(filterNode, rootContext) {
1031
1055
  return filterNode.or.some((entry) => evaluateFilterNode(entry, rootContext));
1032
1056
  }
1033
1057
  if (filterNode.not != null) {
1058
+ if (Array.isArray(filterNode.not)) {
1059
+ return !filterNode.not.some((entry) => evaluateFilterNode(entry, rootContext));
1060
+ }
1034
1061
  return !evaluateFilterNode(filterNode.not, rootContext);
1035
1062
  }
1036
1063
 
@@ -1041,6 +1068,22 @@ function normalizeFormulaLookupName(name = '') {
1041
1068
  return String(name ?? '').replace(/^formula\./u, '');
1042
1069
  }
1043
1070
 
1071
+ function resolveFormulaPropertyId(definition, name = '') {
1072
+ const formulaName = normalizeFormulaLookupName(name);
1073
+ if (!formulaName) {
1074
+ return null;
1075
+ }
1076
+
1077
+ const precomputedLookup = definition?.formulaLookup;
1078
+ if (precomputedLookup instanceof Map) {
1079
+ return precomputedLookup.get(formulaName) ?? precomputedLookup.get(`formula.${formulaName}`) ?? null;
1080
+ }
1081
+
1082
+ return Object.keys(definition?.formulas ?? {}).find((propertyId) => (
1083
+ normalizeFormulaLookupName(propertyId) === formulaName
1084
+ )) ?? null;
1085
+ }
1086
+
1044
1087
  export function createEvaluationRootContext({
1045
1088
  astCache = new Map(),
1046
1089
  currentRow,
@@ -1061,11 +1104,7 @@ export function createEvaluationRootContext({
1061
1104
  definition,
1062
1105
  evaluationState: nextEvaluationState,
1063
1106
  resolveFormulaValue: (name, row, state = nextEvaluationState) => {
1064
- const formulaName = normalizeFormulaLookupName(name);
1065
- const formulaPropertyId = Object.keys(definition.properties).find((propertyId) => {
1066
- const config = definition.properties[propertyId];
1067
- return config?.formula && normalizeFormulaLookupName(propertyId) === formulaName;
1068
- });
1107
+ const formulaPropertyId = resolveFormulaPropertyId(definition, name);
1069
1108
  if (!formulaPropertyId) {
1070
1109
  return null;
1071
1110
  }
@@ -1087,7 +1126,7 @@ export function createEvaluationRootContext({
1087
1126
  snapshot,
1088
1127
  thisFile,
1089
1128
  });
1090
- const result = evaluateExpression(definition.properties[formulaPropertyId].formula, nextContext);
1129
+ const result = evaluateExpression(definition.formulas[formulaPropertyId].formula, nextContext);
1091
1130
  state.stack.delete(cacheKey);
1092
1131
  state.cache.set(cacheKey, result);
1093
1132
  return result;
@@ -1124,18 +1163,13 @@ export function createEvaluationRootContext({
1124
1163
  };
1125
1164
  }
1126
1165
 
1127
- export function getPropertyValue(propertyId, row, definition, snapshot, thisFile) {
1166
+ export function getPropertyValue(propertyId, row, definition, snapshot, thisFile, rootContext = null) {
1128
1167
  if (propertyId.startsWith('file.')) {
1129
1168
  return propertyId.split('.').slice(1).reduce((acc, segment) => acc?.[segment], row.file);
1130
1169
  }
1131
1170
 
1132
- if (propertyId.startsWith('note.')) {
1133
- return row.noteProperties[propertyId.slice(5)] ?? null;
1134
- }
1135
-
1136
- const propertyConfig = definition.properties[propertyId];
1137
- if (propertyConfig?.formula) {
1138
- const context = createEvaluationRootContext({
1171
+ if (hasFormulaDefinition(definition, propertyId)) {
1172
+ const context = rootContext ?? createEvaluationRootContext({
1139
1173
  currentRow: row,
1140
1174
  definition,
1141
1175
  snapshot,
@@ -1144,9 +1178,12 @@ export function getPropertyValue(propertyId, row, definition, snapshot, thisFile
1144
1178
  return context.resolveFormulaValue(propertyId, row, context.evaluationState);
1145
1179
  }
1146
1180
 
1147
- const formulaConfig = definition.properties[`formula.${propertyId}`];
1148
- if (formulaConfig?.formula) {
1149
- const context = createEvaluationRootContext({
1181
+ if (propertyId.startsWith('note.')) {
1182
+ return row.noteProperties[propertyId.slice(5)] ?? null;
1183
+ }
1184
+
1185
+ if (hasFormulaDefinition(definition, `formula.${propertyId}`)) {
1186
+ const context = rootContext ?? createEvaluationRootContext({
1150
1187
  currentRow: row,
1151
1188
  definition,
1152
1189
  snapshot,