collabmd 0.1.32 → 0.1.34

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 (43) 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-BLrtj9YM.js} +2 -2
  9. package/dist/client/assets/main-CfSHaGes.js +1368 -0
  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 +6 -5
  16. package/src/client/application/app-shell/git-feature.js +44 -36
  17. package/src/client/application/app-shell/ui-feature-shell.js +84 -63
  18. package/src/client/bootstrap/collabmd-app-shell.js +6 -0
  19. package/src/client/infrastructure/editor-session.js +4 -0
  20. package/src/client/infrastructure/editor-view-adapter.js +23 -0
  21. package/src/client/infrastructure/vault-api-client.js +48 -0
  22. package/src/client/presentation/bases-preview-controller.js +1388 -28
  23. package/src/client/presentation/comments-panel.js +50 -5
  24. package/src/client/presentation/excalidraw-embed-controller.js +4 -1
  25. package/src/client/presentation/file-history-view-controller.js +9 -1
  26. package/src/client/presentation/git-panel-controller.js +101 -91
  27. package/src/client/styles/components/scrollbars.css +5 -0
  28. package/src/client/styles/features/preview-markdown.css +468 -23
  29. package/src/server/domain/backlink-index.js +202 -11
  30. package/src/server/domain/bases/base-definition.js +138 -11
  31. package/src/server/domain/bases/base-expression-runtime.js +54 -17
  32. package/src/server/domain/bases/base-index-snapshot-store.js +167 -20
  33. package/src/server/domain/bases/base-query-metadata.js +242 -0
  34. package/src/server/domain/bases/base-query-results.js +18 -8
  35. package/src/server/domain/bases/base-query-service.js +360 -49
  36. package/src/server/domain/bases/base-transform.js +116 -0
  37. package/src/server/infrastructure/http/create-request-handler.js +63 -28
  38. package/src/server/infrastructure/http/create-vault-api-command-handler.js +275 -256
  39. package/src/server/infrastructure/http/create-vault-api-query-handler.js +258 -184
  40. package/src/server/infrastructure/persistence/vault-file-store.js +68 -12
  41. package/dist/client/assets/index-CxSbUTs2.css +0 -1
  42. package/dist/client/assets/main-BjHHMlv0.js +0 -1249
  43. /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) {
@@ -316,22 +371,64 @@ export class BacklinkIndex {
316
371
  previousState = null,
317
372
  nextState = null,
318
373
  } = {}) {
319
- let refreshIndex = false;
320
374
  const previousEntries = previousState?.entries ?? new Map();
321
375
  const nextEntries = nextState?.entries ?? new Map();
376
+ const changedPaths = Array.from(new Set(workspaceChange.changedPaths ?? []));
377
+ const { impactedSources, renameMap } = this._computeWorkspaceChangeMeta(workspaceChange, changedPaths, previousEntries, nextEntries);
378
+
379
+ let refreshIndex = this._applyDeletedPaths(workspaceChange.deletedPaths);
380
+ refreshIndex = this._applyRenamedPaths(workspaceChange.renamedPaths, refreshIndex);
381
+ refreshIndex = await this._applyChangedPaths(changedPaths, previousEntries, nextEntries, refreshIndex);
322
382
 
323
- for (const pathValue of workspaceChange.deletedPaths ?? []) {
383
+ if (refreshIndex) {
384
+ const sourcesToRefresh = this._collectRefreshSources(impactedSources, changedPaths, workspaceChange.renamedPaths, nextEntries);
385
+ this._refreshWikiTargetIndex();
386
+ await this._refreshImpactedSources(sourcesToRefresh, { nextEntries, renameMap });
387
+ }
388
+ }
389
+
390
+ _computeWorkspaceChangeMeta(workspaceChange, changedPaths, previousEntries, nextEntries) {
391
+ const createdPaths = changedPaths.filter((pathValue) => (
392
+ nextEntries.has(pathValue) && !previousEntries.has(pathValue)
393
+ ));
394
+ const removedChangedPaths = changedPaths.filter((pathValue) => (
395
+ !nextEntries.has(pathValue) && previousEntries.has(pathValue)
396
+ ));
397
+ const membershipAffectedPaths = [
398
+ ...(workspaceChange.deletedPaths ?? []),
399
+ ...removedChangedPaths,
400
+ ...createdPaths,
401
+ ...(workspaceChange.renamedPaths ?? []).flatMap((entry) => [entry?.oldPath, entry?.newPath]).filter(Boolean),
402
+ ];
403
+ const impactedSources = this._collectImpactedSourcesForMembershipChanges(membershipAffectedPaths);
404
+ const renameMap = new Map(
405
+ (workspaceChange.renamedPaths ?? [])
406
+ .filter((entry) => entry?.oldPath && entry?.newPath)
407
+ .map((entry) => [entry.oldPath, entry.newPath]),
408
+ );
409
+ return { impactedSources, renameMap };
410
+ }
411
+
412
+ _applyDeletedPaths(deletedPaths) {
413
+ let refreshIndex = false;
414
+ for (const pathValue of deletedPaths ?? []) {
324
415
  refreshIndex = this.onFileDeleted(pathValue, { refreshIndex: false }) || refreshIndex;
325
416
  }
417
+ return refreshIndex;
418
+ }
326
419
 
327
- for (const entry of workspaceChange.renamedPaths ?? []) {
420
+ _applyRenamedPaths(renamedPaths, refreshIndex) {
421
+ for (const entry of renamedPaths ?? []) {
328
422
  if (!entry?.oldPath || !entry?.newPath) {
329
423
  continue;
330
424
  }
331
425
  refreshIndex = this.onFileRenamed(entry.oldPath, entry.newPath, { refreshIndex: false }) || refreshIndex;
332
426
  }
427
+ return refreshIndex;
428
+ }
333
429
 
334
- for (const pathValue of new Set(workspaceChange.changedPaths ?? [])) {
430
+ async _applyChangedPaths(changedPaths, previousEntries, nextEntries, refreshIndex) {
431
+ for (const pathValue of changedPaths) {
335
432
  const existsNow = nextEntries.has(pathValue);
336
433
  const existedBefore = previousEntries.has(pathValue);
337
434
  if (!existsNow) {
@@ -359,10 +456,22 @@ export class BacklinkIndex {
359
456
  refreshIndex = this.onFileCreated(pathValue, content, { refreshIndex: false }) || refreshIndex;
360
457
  }
361
458
  }
459
+ return refreshIndex;
460
+ }
362
461
 
363
- if (refreshIndex) {
364
- this._refreshWikiTargetIndex();
365
- }
462
+ _collectRefreshSources(impactedSources, changedPaths, renamedPaths, nextEntries) {
463
+ const sourcesToRefresh = new Set(impactedSources);
464
+ changedPaths.forEach((pathValue) => {
465
+ if (nextEntries.has(pathValue) && isMarkdownFilePath(pathValue)) {
466
+ sourcesToRefresh.add(pathValue);
467
+ }
468
+ });
469
+ (renamedPaths ?? []).forEach((entry) => {
470
+ if (entry?.newPath && isMarkdownFilePath(entry.newPath)) {
471
+ sourcesToRefresh.add(entry.newPath);
472
+ }
473
+ });
474
+ return sourcesToRefresh;
366
475
  }
367
476
 
368
477
  /**
@@ -408,6 +517,7 @@ export class BacklinkIndex {
408
517
  _indexFile(filePath, content) {
409
518
  const resolvedTargets = new Set();
410
519
  const contextsByTarget = new Map();
520
+ const rawTargetKeys = new Set();
411
521
  const lines = content.split('\n');
412
522
 
413
523
  for (const line of lines) {
@@ -419,6 +529,11 @@ export class BacklinkIndex {
419
529
  continue;
420
530
  }
421
531
 
532
+ const rawTargetKey = normalizeWikiTargetKey(target);
533
+ if (rawTargetKey) {
534
+ rawTargetKeys.add(rawTargetKey);
535
+ }
536
+
422
537
  const resolved = this._resolveTarget(target);
423
538
  if (!resolved || resolved === filePath) {
424
539
  continue;
@@ -434,6 +549,17 @@ export class BacklinkIndex {
434
549
  }
435
550
 
436
551
  this.contextsBySource.delete(filePath);
552
+ this._removeRawTargetSourceContributions(filePath);
553
+
554
+ if (rawTargetKeys.size > 0) {
555
+ this.rawTargetKeysBySource.set(filePath, rawTargetKeys);
556
+ rawTargetKeys.forEach((rawTargetKey) => {
557
+ if (!this.rawTargetSources.has(rawTargetKey)) {
558
+ this.rawTargetSources.set(rawTargetKey, new Set());
559
+ }
560
+ this.rawTargetSources.get(rawTargetKey).add(filePath);
561
+ });
562
+ }
437
563
 
438
564
  if (resolvedTargets.size > 0) {
439
565
  this.contextsBySource.set(filePath, contextsByTarget);
@@ -450,6 +576,7 @@ export class BacklinkIndex {
450
576
 
451
577
  _removeForwardLinks(filePath) {
452
578
  this.contextsBySource.delete(filePath);
579
+ this._removeRawTargetSourceContributions(filePath);
453
580
 
454
581
  const oldTargets = this.forward.get(filePath);
455
582
  if (!oldTargets) return;
@@ -478,6 +605,70 @@ export class BacklinkIndex {
478
605
  _refreshWikiTargetIndex() {
479
606
  this._wikiTargetIndex = createWikiTargetIndex(this._fileList);
480
607
  }
608
+
609
+ _removeRawTargetSourceContributions(filePath) {
610
+ const rawTargetKeys = this.rawTargetKeysBySource.get(filePath);
611
+ if (!rawTargetKeys) {
612
+ return;
613
+ }
614
+
615
+ rawTargetKeys.forEach((rawTargetKey) => {
616
+ const sources = this.rawTargetSources.get(rawTargetKey);
617
+ if (!sources) {
618
+ return;
619
+ }
620
+
621
+ sources.delete(filePath);
622
+ if (sources.size === 0) {
623
+ this.rawTargetSources.delete(rawTargetKey);
624
+ }
625
+ });
626
+ this.rawTargetKeysBySource.delete(filePath);
627
+ }
628
+
629
+ _collectImpactedSourcesForMembershipChanges(pathValues = []) {
630
+ const affectedTargetKeys = new Set();
631
+ pathValues.forEach((pathValue) => {
632
+ collectWikiTargetKeysForFilePath(pathValue).forEach((targetKey) => {
633
+ affectedTargetKeys.add(targetKey);
634
+ });
635
+ });
636
+
637
+ const impactedSources = new Set();
638
+ affectedTargetKeys.forEach((targetKey) => {
639
+ this.rawTargetSources.get(targetKey)?.forEach((sourcePath) => {
640
+ impactedSources.add(sourcePath);
641
+ });
642
+ });
643
+ return impactedSources;
644
+ }
645
+
646
+ async _refreshImpactedSources(impactedSources = new Set(), {
647
+ nextEntries = new Map(),
648
+ renameMap = new Map(),
649
+ } = {}) {
650
+ const refreshedSources = new Set();
651
+
652
+ for (const sourcePath of impactedSources) {
653
+ const livePath = renameMap.get(sourcePath) ?? sourcePath;
654
+ if (
655
+ refreshedSources.has(livePath)
656
+ || !livePath
657
+ || !isMarkdownFilePath(livePath)
658
+ || !nextEntries.has(livePath)
659
+ ) {
660
+ continue;
661
+ }
662
+
663
+ const content = await this.vaultFileStore.readMarkdownFile(livePath);
664
+ if (content === null) {
665
+ continue;
666
+ }
667
+
668
+ refreshedSources.add(livePath);
669
+ this.updateFile(livePath, content, { refreshIndex: false });
670
+ }
671
+ }
481
672
  }
482
673
 
483
674
  /** 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,