collabmd 0.1.31 → 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 (99) hide show
  1. package/dist/client/assets/{drawio-editor-B4sHHwWJ.js → drawio-editor-BMTwP5mD.js} +1 -1
  2. package/dist/client/assets/drawioEditor-D6clB5OH.js +2 -0
  3. package/dist/client/assets/{editor-session-BKHEYkbY.js → editor-session-Dzy_85Wp.js} +2 -2
  4. package/dist/client/assets/embedded-editor-base-CgEkjNYn.css +1 -0
  5. package/dist/client/assets/{excalidraw-editor-CHbeNZu6.js → excalidraw-editor-CAP9F8CB.js} +3 -3
  6. package/dist/client/assets/{excalidrawEditor-BhaTX2Ko.js → excalidrawEditor-DD4AvzYP.js} +2 -2
  7. package/dist/client/assets/exportDocument-BbkN85c4.css +1 -0
  8. package/dist/client/assets/index-BIt5EZXz.css +1 -0
  9. package/dist/client/assets/{index-kPgEYvEX.js → index-Czg_KL-i.js} +2 -2
  10. package/dist/client/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/client/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/client/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/client/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/client/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/client/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  18. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  19. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  20. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  21. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  22. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  23. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  24. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  25. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  26. package/dist/client/assets/main-DeowJOSp.js +1368 -0
  27. package/dist/client/assets/vault-api-client-D7Ck0CvR.css +1 -0
  28. package/dist/client/assets/vault-api-client-p7h7cXKM.js +1 -0
  29. package/dist/client/drawio-editor.html +1 -1
  30. package/dist/client/excalidraw-editor.html +1 -1
  31. package/dist/client/export-document.html +2 -2
  32. package/dist/client/index.html +2 -2
  33. package/package.json +8 -5
  34. package/src/client/app/main-entry.js +2 -0
  35. package/src/client/application/app-shell/git-feature.js +28 -36
  36. package/src/client/application/app-shell/presence-feature.js +3 -1
  37. package/src/client/application/diagram-preview-hydrator.js +24 -15
  38. package/src/client/application/mermaid-preview-hydrator.js +121 -4
  39. package/src/client/application/plantuml-preview-hydrator.js +6 -16
  40. package/src/client/application/preview-renderer.js +11 -2
  41. package/src/client/bootstrap/collabmd-app-shell.js +182 -50
  42. package/src/client/domain/excalidraw-document-switch.js +3 -0
  43. package/src/client/excalidraw-editor.js +187 -84
  44. package/src/client/infrastructure/auth-client.js +4 -4
  45. package/src/client/infrastructure/backlinks-api-client.js +18 -0
  46. package/src/client/infrastructure/comment-thread-store.js +4 -0
  47. package/src/client/infrastructure/editor-session.js +6 -0
  48. package/src/client/infrastructure/editor-view-adapter.js +23 -0
  49. package/src/client/infrastructure/git-api-client.js +134 -0
  50. package/src/client/infrastructure/plantuml-api-client.js +21 -0
  51. package/src/client/infrastructure/vault-api-client.js +115 -0
  52. package/src/client/presentation/backlinks-panel.js +6 -9
  53. package/src/client/presentation/bases-preview-controller.js +1413 -34
  54. package/src/client/presentation/comments-panel.js +50 -5
  55. package/src/client/presentation/excalidraw-embed-controller.js +251 -15
  56. package/src/client/presentation/excalidraw-embed-reconciler.js +0 -1
  57. package/src/client/presentation/file-action-controller.js +97 -0
  58. package/src/client/presentation/file-explorer-controller.js +2 -0
  59. package/src/client/presentation/file-explorer-view.js +368 -0
  60. package/src/client/presentation/file-history-view-controller.js +41 -40
  61. package/src/client/presentation/git-diff-view-controller.js +70 -53
  62. package/src/client/presentation/git-panel-controller.js +12 -30
  63. package/src/client/styles/components/scrollbars.css +5 -0
  64. package/src/client/styles/export-document.css +45 -2
  65. package/src/client/styles/features/auth-gate.css +13 -24
  66. package/src/client/styles/features/comment-card.css +6 -0
  67. package/src/client/styles/features/comments-drawer.css +38 -18
  68. package/src/client/styles/features/diagram-preview.css +33 -0
  69. package/src/client/styles/features/embedded-preview.css +37 -11
  70. package/src/client/styles/features/preview-markdown.css +559 -39
  71. package/src/client/styles/foundation/themes.css +4 -0
  72. package/src/client/styles/foundation/tokens.css +4 -3
  73. package/src/client/styles/layout/sidebar.css +72 -0
  74. package/src/server/auth/auth-common.js +224 -0
  75. package/src/server/auth/auth-constants.js +30 -0
  76. package/src/server/auth/auth-strategies.js +320 -0
  77. package/src/server/auth/create-auth-service.js +24 -545
  78. package/src/server/domain/backlink-index.js +177 -5
  79. package/src/server/domain/bases/base-definition.js +382 -0
  80. package/src/server/domain/bases/base-expression-runtime.js +1196 -0
  81. package/src/server/domain/bases/base-index-snapshot-store.js +565 -0
  82. package/src/server/domain/bases/base-query-metadata.js +242 -0
  83. package/src/server/domain/bases/base-query-results.js +215 -0
  84. package/src/server/domain/bases/base-query-service.js +406 -1992
  85. package/src/server/domain/bases/base-transform.js +116 -0
  86. package/src/server/domain/collaboration/collaboration-room.js +36 -1
  87. package/src/server/domain/collaboration/room-client-state-store.js +1 -0
  88. package/src/server/domain/collaboration/room-persistence-controller.js +1 -1
  89. package/src/server/infrastructure/http/create-vault-api-query-handler.js +161 -1
  90. package/src/server/infrastructure/http/http-response.js +56 -0
  91. package/src/server/infrastructure/persistence/vault-file-store.js +110 -0
  92. package/dist/client/assets/drawioEditor-DGpo2hcx.js +0 -2
  93. package/dist/client/assets/embedded-editor-base-DgmRcsR8.css +0 -1
  94. package/dist/client/assets/exportDocument-DmV7Gngb.css +0 -1
  95. package/dist/client/assets/index-D69HOZHr.css +0 -1
  96. package/dist/client/assets/main-DSxNTtD-.js +0 -1243
  97. package/dist/client/assets/vault-api-client-BzHQseMN.js +0 -1
  98. package/dist/client/assets/vault-api-client-De7kMvrE.css +0 -1
  99. /package/dist/client/assets/{exportDocument-CtZKWE2c.js → exportDocument-Zfwq1XVx.js} +0 -0
@@ -1,1652 +1,103 @@
1
- import { basename, dirname, extname } from 'node:path';
2
-
3
- import yaml from 'js-yaml';
4
-
5
- import { createWikiTargetIndex, resolveWikiTargetWithIndex } from '../../../domain/wiki-link-resolver.js';
6
- import {
7
- getVaultFileExtension,
8
- isImageAttachmentFilePath,
9
- isMarkdownFilePath,
10
- stripVaultFileExtension,
11
- } from '../../../domain/file-kind.js';
12
- import { extractYamlFrontmatter } from '../../../domain/yaml-frontmatter.js';
13
- import { mapWithConcurrency } from '../../shared/async-utils.js';
14
-
15
- const INTERNAL_LINK_RE = /(!)?\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
16
- const INLINE_TAG_RE = /(^|[\s(])#([A-Za-z0-9/_-]+)/g;
17
- const SUPPORTED_VIEW_TYPES = new Set(['cards', 'list', 'table']);
18
- const INDEX_READ_CONCURRENCY = 8;
19
-
20
- function isPlainObject(value) {
21
- return Object.prototype.toString.call(value) === '[object Object]';
22
- }
23
-
24
- function capitalize(value = '') {
25
- const normalized = String(value ?? '').trim();
26
- if (!normalized) {
27
- return '';
28
- }
29
-
30
- return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`;
31
- }
32
-
33
- function normalizeTags(value) {
34
- if (Array.isArray(value)) {
35
- return value
36
- .map((entry) => String(entry ?? '').trim().replace(/^#/u, ''))
37
- .filter(Boolean);
38
- }
39
-
40
- const normalized = String(value ?? '').trim().replace(/^#/u, '');
41
- return normalized ? [normalized] : [];
42
- }
43
-
44
- function normalizeVaultPathSegments(pathValue = '') {
45
- return String(pathValue ?? '')
46
- .replace(/\\/g, '/')
47
- .split('/')
48
- .filter(Boolean);
49
- }
50
-
51
- function resolveVaultRelativePath(fromFilePath, relativePath) {
52
- const sourceSegments = normalizeVaultPathSegments(fromFilePath);
53
- const targetSegments = String(relativePath ?? '')
54
- .split('/')
55
- .map((segment) => {
56
- try {
57
- return decodeURIComponent(segment);
58
- } catch {
59
- return segment;
60
- }
61
- });
62
-
63
- if (targetSegments.length === 0) {
64
- return '';
65
- }
66
-
67
- sourceSegments.pop();
68
- const resolvedSegments = [...sourceSegments];
69
-
70
- for (const rawSegment of targetSegments) {
71
- const segment = String(rawSegment ?? '').trim();
72
- if (!segment || segment === '.') {
73
- continue;
74
- }
75
-
76
- if (segment === '..') {
77
- if (resolvedSegments.length === 0) {
78
- return '';
79
- }
80
- resolvedSegments.pop();
81
- continue;
82
- }
83
-
84
- resolvedSegments.push(segment);
85
- }
86
-
87
- return resolvedSegments.join('/');
88
- }
89
-
90
- function extractInlineTags(markdownText = '') {
91
- const tags = new Set();
92
- let match;
93
- while ((match = INLINE_TAG_RE.exec(String(markdownText ?? ''))) !== null) {
94
- tags.add(match[2]);
95
- }
96
- return [...tags];
97
- }
98
-
99
- function extractReferences(markdownText = '', wikiTargetIndex) {
100
- const links = [];
101
- const embeds = [];
102
- let match;
103
- while ((match = INTERNAL_LINK_RE.exec(String(markdownText ?? ''))) !== null) {
104
- const isEmbed = Boolean(match[1]);
105
- const rawTarget = String(match[2] ?? '').trim();
106
- if (!rawTarget) {
107
- continue;
108
- }
109
-
110
- const resolvedPath = resolveWikiTargetWithIndex(rawTarget, wikiTargetIndex);
111
- const linkValue = createLinkValue(resolvedPath || rawTarget, {
112
- exists: Boolean(resolvedPath),
113
- rawTarget,
114
- });
115
- links.push(linkValue);
116
- if (isEmbed) {
117
- embeds.push(linkValue);
118
- }
119
- }
120
- return { embeds, links };
121
- }
122
-
123
- function dedupeLinkValues(values = []) {
124
- const seen = new Set();
125
- return values.filter((value) => {
126
- const key = value?.external
127
- ? `external:${value.url || value.rawTarget || value.display || ''}`
128
- : `path:${value.path || value.rawTarget || value.display || ''}`;
129
- if (!key || seen.has(key)) {
130
- return false;
131
- }
132
- seen.add(key);
133
- return true;
134
- });
135
- }
136
-
137
- function createLinkValue(target, {
138
- display = '',
139
- exists = false,
140
- external = false,
141
- rawTarget = '',
142
- } = {}) {
143
- return {
144
- __baseType: 'link',
145
- display: display ? String(display) : '',
146
- exists: Boolean(exists),
147
- external: Boolean(external),
148
- path: external ? '' : String(target ?? ''),
149
- rawTarget: String(rawTarget || target || ''),
150
- url: external ? String(target ?? '') : '',
151
- };
152
- }
153
-
154
- function createFormulaNamespace(row, evaluationState) {
155
- return {
156
- __baseType: 'formula-namespace',
157
- evaluationState,
158
- row,
159
- };
160
- }
161
-
162
- function createContextFileValue(fileValue) {
163
- return fileValue ?? null;
164
- }
165
-
166
- function compareVaultPaths(left = '', right = '') {
167
- return String(left).localeCompare(String(right), undefined, { sensitivity: 'base' });
168
- }
169
-
170
- function isWorkspaceFileEntry(entry = {}) {
171
- return entry?.nodeType === 'file' || entry?.type !== 'directory';
172
- }
173
-
174
- function listWorkspaceFilePaths(workspaceState = {}) {
175
- if (Array.isArray(workspaceState?.filePaths)) {
176
- return [...workspaceState.filePaths].sort(compareVaultPaths);
177
- }
178
-
179
- return Array.from(workspaceState?.entries?.values?.() ?? [])
180
- .filter((entry) => isWorkspaceFileEntry(entry))
181
- .map((entry) => entry.path)
182
- .sort(compareVaultPaths);
183
- }
184
-
185
- function normalizeWikiTargetKey(target = '') {
186
- const normalizedTarget = String(target ?? '').trim();
187
- if (!normalizedTarget) {
188
- return '';
189
- }
190
-
191
- return getVaultFileExtension(normalizedTarget)
192
- ? normalizedTarget
193
- : `${normalizedTarget}.md`;
194
- }
195
-
196
- function collectWikiTargetKeysForFilePath(filePath = '') {
197
- const normalizedPath = String(filePath ?? '').trim();
198
- if (!normalizedPath) {
199
- return [];
200
- }
201
-
202
- const segments = normalizedPath.split('/').filter(Boolean);
203
- const keys = [];
204
- for (let index = 0; index < segments.length; index += 1) {
205
- keys.push(segments.slice(index).join('/'));
206
- }
207
-
208
- if (isMarkdownFilePath(normalizedPath)) {
209
- const rawSegments = normalizedPath.replace(/\.md$/i, '').split('/').filter(Boolean);
210
- for (let index = 0; index < rawSegments.length; index += 1) {
211
- keys.push(rawSegments.slice(index).join('/'));
212
- }
213
- }
214
-
215
- return [...new Set(keys)];
216
- }
217
-
218
- function normalizeBaseDefinition(source = '') {
219
- const raw = yaml.load(String(source ?? '')) ?? {};
220
- const rawObject = isPlainObject(raw) ? raw : {};
221
- const rawProperties = isPlainObject(rawObject.properties) ? rawObject.properties : {};
222
- const properties = Object.entries(rawProperties).reduce((acc, [id, config]) => {
223
- const normalizedConfig = isPlainObject(config) ? { ...config } : {};
224
- acc[id] = {
225
- displayName: typeof normalizedConfig.displayName === 'string' ? normalizedConfig.displayName : null,
226
- formula: typeof normalizedConfig.formula === 'string' ? normalizedConfig.formula : null,
227
- id,
228
- raw: normalizedConfig,
229
- };
230
- return acc;
231
- }, {});
232
-
233
- const rawViews = Array.isArray(rawObject.views) ? rawObject.views : [];
234
- const views = rawViews.map((rawView, index) => normalizeBaseView(rawView, index));
235
- if (views.length === 0) {
236
- views.push(normalizeBaseView({ name: 'Table', type: 'table' }, 0));
237
- }
238
-
239
- return {
240
- filters: rawObject.filters ?? null,
241
- properties,
242
- raw: rawObject,
243
- views,
244
- };
245
- }
246
-
247
- function normalizeBaseView(rawView, index) {
248
- const view = isPlainObject(rawView) ? rawView : {};
249
- const type = typeof view.type === 'string' ? view.type : 'table';
250
- const name = typeof view.name === 'string' && view.name.trim()
251
- ? view.name.trim()
252
- : `${capitalize(type)} ${index + 1}`;
253
- return {
254
- extra: Object.entries(view).reduce((acc, [key, value]) => {
255
- if ([
256
- 'filters',
257
- 'groupBy',
258
- 'group_by',
259
- 'image',
260
- 'limit',
261
- 'name',
262
- 'order',
263
- 'properties',
264
- 'sort',
265
- 'sorts',
266
- 'summaries',
267
- 'type',
268
- ].includes(key)) {
269
- return acc;
270
- }
271
- acc[key] = value;
272
- return acc;
273
- }, {}),
274
- filters: view.filters ?? null,
275
- groupBy: typeof view.groupBy === 'string'
276
- ? view.groupBy
277
- : (typeof view.group_by === 'string' ? view.group_by : null),
278
- id: `view-${index}`,
279
- image: typeof view.image === 'string' ? view.image : null,
280
- limit: Number.isFinite(Number(view.limit)) ? Math.max(0, Number(view.limit)) : null,
281
- name,
282
- order: normalizeViewOrder(view.order ?? view.properties),
283
- sort: normalizeViewSort(view.sort ?? view.sorts),
284
- summaries: normalizeSummaries(view.summaries),
285
- supported: SUPPORTED_VIEW_TYPES.has(type),
286
- type,
287
- };
288
- }
289
-
290
- function normalizeViewOrder(value) {
291
- if (Array.isArray(value)) {
292
- return value
293
- .map((entry) => {
294
- if (typeof entry === 'string') {
295
- return entry;
296
- }
297
- if (isPlainObject(entry)) {
298
- return entry.id ?? entry.property ?? entry.name ?? null;
299
- }
300
- return null;
301
- })
302
- .filter(Boolean);
303
- }
304
-
305
- if (typeof value === 'string' && value.trim()) {
306
- return [value.trim()];
307
- }
308
-
309
- return [];
310
- }
311
-
312
- function normalizeViewSort(value) {
313
- if (typeof value === 'string' && value.trim()) {
314
- return [{ direction: 'asc', property: value.trim() }];
315
- }
316
-
317
- if (!Array.isArray(value)) {
318
- return [];
319
- }
320
-
321
- return value
322
- .map((entry) => {
323
- if (typeof entry === 'string') {
324
- return { direction: 'asc', property: entry };
325
- }
326
- if (!isPlainObject(entry)) {
327
- return null;
328
- }
329
-
330
- const property = entry.property ?? entry.id ?? entry.name ?? null;
331
- if (typeof property !== 'string' || !property.trim()) {
332
- return null;
333
- }
334
-
335
- const direction = String(entry.direction ?? entry.order ?? 'asc').toLowerCase() === 'desc'
336
- ? 'desc'
337
- : 'asc';
338
- return { direction, property: property.trim() };
339
- })
340
- .filter(Boolean);
341
- }
342
-
343
- function normalizeSummaries(value) {
344
- if (!value) {
345
- return [];
346
- }
347
-
348
- if (Array.isArray(value)) {
349
- return value
350
- .map((entry, index) => normalizeSummary(entry, index))
351
- .filter(Boolean);
352
- }
353
-
354
- if (isPlainObject(value)) {
355
- const entries = [];
356
- Object.entries(value).forEach(([property, propertySummaries]) => {
357
- if (Array.isArray(propertySummaries)) {
358
- propertySummaries.forEach((entry, index) => {
359
- const normalized = normalizeSummary(entry, index, property);
360
- if (normalized) {
361
- entries.push(normalized);
362
- }
363
- });
364
- return;
365
- }
366
-
367
- const normalized = normalizeSummary(propertySummaries, entries.length, property);
368
- if (normalized) {
369
- entries.push(normalized);
370
- }
371
- });
372
- return entries;
373
- }
374
-
375
- return [];
376
- }
377
-
378
- function normalizeSummary(entry, index, property = null) {
379
- if (typeof entry === 'string') {
380
- return {
381
- id: `summary-${index}`,
382
- label: capitalize(entry),
383
- property,
384
- type: entry.toLowerCase(),
385
- };
386
- }
387
-
388
- if (!isPlainObject(entry)) {
389
- return null;
390
- }
391
-
392
- const summaryProperty = typeof (entry.property ?? property) === 'string'
393
- ? String(entry.property ?? property).trim()
394
- : null;
395
- const formula = typeof entry.formula === 'string' ? entry.formula : null;
396
- const type = typeof entry.type === 'string' ? entry.type.toLowerCase() : (formula ? 'custom' : null);
397
- if (!summaryProperty) {
398
- return null;
399
- }
400
-
401
- return {
402
- formula,
403
- id: `summary-${index}`,
404
- label: typeof entry.name === 'string' ? entry.name : capitalize(type || 'Summary'),
405
- property: summaryProperty,
406
- type,
407
- };
408
- }
409
-
410
- function tokenizeExpression(source = '') {
411
- const tokens = [];
412
- const text = String(source ?? '');
413
- let index = 0;
414
-
415
- while (index < text.length) {
416
- const character = text[index];
417
- if (/\s/u.test(character)) {
418
- index += 1;
419
- continue;
420
- }
421
-
422
- const twoChar = text.slice(index, index + 2);
423
- const threeChar = text.slice(index, index + 3);
424
- if (['&&', '||', '==', '!=', '<=', '>='].includes(twoChar)) {
425
- tokens.push({ type: 'operator', value: twoChar });
426
- index += 2;
427
- continue;
428
- }
429
- if (threeChar === '...') {
430
- tokens.push({ type: 'operator', value: threeChar });
431
- index += 3;
432
- continue;
433
- }
434
- if ('+-*/%!<>()[]{}.,:'.includes(character)) {
435
- tokens.push({
436
- type: '()[]{}.,:'.includes(character) ? 'punctuation' : 'operator',
437
- value: character,
438
- });
439
- index += 1;
440
- continue;
441
- }
442
- if (character === '"' || character === '\'') {
443
- let value = '';
444
- let cursor = index + 1;
445
- while (cursor < text.length) {
446
- const nextCharacter = text[cursor];
447
- if (nextCharacter === '\\') {
448
- const escaped = text[cursor + 1] ?? '';
449
- value += escaped;
450
- cursor += 2;
451
- continue;
452
- }
453
- if (nextCharacter === character) {
454
- break;
455
- }
456
- value += nextCharacter;
457
- cursor += 1;
458
- }
459
- if (text[cursor] !== character) {
460
- throw new Error(`Unterminated string in expression: ${source}`);
461
- }
462
- tokens.push({ type: 'string', value });
463
- index = cursor + 1;
464
- continue;
465
- }
466
- if (/\d/u.test(character)) {
467
- let cursor = index + 1;
468
- while (cursor < text.length && /[\d.]/u.test(text[cursor])) {
469
- cursor += 1;
470
- }
471
- tokens.push({ type: 'number', value: text.slice(index, cursor) });
472
- index = cursor;
473
- continue;
474
- }
475
- if (/[A-Za-z_$]/u.test(character)) {
476
- let cursor = index + 1;
477
- while (cursor < text.length && /[A-Za-z0-9_$]/u.test(text[cursor])) {
478
- cursor += 1;
479
- }
480
- tokens.push({ type: 'identifier', value: text.slice(index, cursor) });
481
- index = cursor;
482
- continue;
483
- }
484
-
485
- throw new Error(`Unexpected token "${character}" in expression: ${source}`);
486
- }
487
-
488
- return tokens;
489
- }
490
-
491
- const BINARY_PRECEDENCE = {
492
- '||': 1,
493
- '&&': 2,
494
- '==': 3,
495
- '!=': 3,
496
- '<': 4,
497
- '<=': 4,
498
- '>': 4,
499
- '>=': 4,
500
- '+': 5,
501
- '-': 5,
502
- '*': 6,
503
- '/': 6,
504
- '%': 6,
505
- };
506
-
507
- function parseExpression(source = '') {
508
- const tokens = tokenizeExpression(source);
509
- let index = 0;
510
-
511
- const peek = () => tokens[index] ?? null;
512
- const consume = (expectedValue = null) => {
513
- const token = tokens[index] ?? null;
514
- if (!token) {
515
- throw new Error(`Unexpected end of expression: ${source}`);
516
- }
517
- if (expectedValue !== null && token.value !== expectedValue) {
518
- throw new Error(`Expected "${expectedValue}" in expression: ${source}`);
519
- }
520
- index += 1;
521
- return token;
522
- };
523
-
524
- const parsePrimary = () => {
525
- const token = peek();
526
- if (!token) {
527
- throw new Error(`Unexpected end of expression: ${source}`);
528
- }
529
-
530
- if (token.type === 'number') {
531
- consume();
532
- return { type: 'Literal', value: Number(token.value) };
533
- }
534
-
535
- if (token.type === 'string') {
536
- consume();
537
- return { type: 'Literal', value: token.value };
538
- }
539
-
540
- if (token.type === 'identifier') {
541
- consume();
542
- if (token.value === 'true') return { type: 'Literal', value: true };
543
- if (token.value === 'false') return { type: 'Literal', value: false };
544
- if (token.value === 'null') return { type: 'Literal', value: null };
545
- return { name: token.value, type: 'Identifier' };
546
- }
547
-
548
- if (token.value === '(') {
549
- consume('(');
550
- const expression = parseBinaryExpression(0);
551
- consume(')');
552
- return expression;
553
- }
554
-
555
- if (token.value === '[') {
556
- consume('[');
557
- const elements = [];
558
- while (peek() && peek().value !== ']') {
559
- elements.push(parseBinaryExpression(0));
560
- if (peek()?.value === ',') {
561
- consume(',');
562
- }
563
- }
564
- consume(']');
565
- return { elements, type: 'ArrayExpression' };
566
- }
567
-
568
- if (token.value === '{') {
569
- consume('{');
570
- const properties = [];
571
- while (peek() && peek().value !== '}') {
572
- const keyToken = consume();
573
- const key = keyToken.type === 'identifier' ? keyToken.value : String(keyToken.value ?? '');
574
- consume(':');
575
- properties.push({ key, value: parseBinaryExpression(0) });
576
- if (peek()?.value === ',') {
577
- consume(',');
578
- }
579
- }
580
- consume('}');
581
- return { properties, type: 'ObjectExpression' };
582
- }
583
-
584
- throw new Error(`Unexpected token "${token.value}" in expression: ${source}`);
585
- };
586
-
587
- const parsePostfix = (baseNode) => {
588
- let node = baseNode;
589
- while (peek()) {
590
- if (peek().value === '.') {
591
- consume('.');
592
- const property = consume();
593
- if (property.type !== 'identifier') {
594
- throw new Error(`Expected property name in expression: ${source}`);
595
- }
596
- node = {
597
- computed: false,
598
- object: node,
599
- property: { name: property.value, type: 'Identifier' },
600
- type: 'MemberExpression',
601
- };
602
- continue;
603
- }
604
-
605
- if (peek().value === '[') {
606
- consume('[');
607
- const property = parseBinaryExpression(0);
608
- consume(']');
609
- node = {
610
- computed: true,
611
- object: node,
612
- property,
613
- type: 'MemberExpression',
614
- };
615
- continue;
616
- }
617
-
618
- if (peek().value === '(') {
619
- consume('(');
620
- const args = [];
621
- while (peek() && peek().value !== ')') {
622
- args.push(parseBinaryExpression(0));
623
- if (peek()?.value === ',') {
624
- consume(',');
625
- }
626
- }
627
- consume(')');
628
- node = {
629
- arguments: args,
630
- callee: node,
631
- type: 'CallExpression',
632
- };
633
- continue;
634
- }
635
-
636
- break;
637
- }
638
- return node;
639
- };
640
-
641
- const parseUnary = () => {
642
- const token = peek();
643
- if (token?.type === 'operator' && ['!', '-'].includes(token.value)) {
644
- consume();
645
- return {
646
- argument: parseUnary(),
647
- operator: token.value,
648
- type: 'UnaryExpression',
649
- };
650
- }
651
-
652
- return parsePostfix(parsePrimary());
653
- };
654
-
655
- const parseBinaryExpression = (minimumPrecedence) => {
656
- let left = parseUnary();
657
- while (true) {
658
- const operator = peek();
659
- const precedence = operator ? BINARY_PRECEDENCE[operator.value] : null;
660
- if (!precedence || precedence < minimumPrecedence) {
661
- break;
662
- }
663
-
664
- consume();
665
- const right = parseBinaryExpression(precedence + 1);
666
- left = {
667
- left,
668
- operator: operator.value,
669
- right,
670
- type: 'BinaryExpression',
671
- };
672
- }
673
- return left;
674
- };
675
-
676
- const ast = parseBinaryExpression(0);
677
- if (index < tokens.length) {
678
- throw new Error(`Unexpected token "${tokens[index].value}" in expression: ${source}`);
679
- }
680
- return ast;
681
- }
682
-
683
- class ExpressionScope {
684
- constructor({ locals = {}, parent = null, rootContext }) {
685
- this.locals = locals;
686
- this.parent = parent;
687
- this.rootContext = rootContext;
688
- }
689
-
690
- child(locals = {}) {
691
- return new ExpressionScope({
692
- locals,
693
- parent: this,
694
- rootContext: this.rootContext,
695
- });
696
- }
697
-
698
- has(name) {
699
- if (Object.hasOwn(this.locals, name)) {
700
- return true;
701
- }
702
- return this.parent?.has(name) ?? false;
703
- }
704
-
705
- get(name) {
706
- if (Object.hasOwn(this.locals, name)) {
707
- return this.locals[name];
708
- }
709
- if (this.parent) {
710
- return this.parent.get(name);
711
- }
712
- return this.rootContext.resolveIdentifier(name);
713
- }
714
- }
715
-
716
- function normalizeDuration(duration) {
717
- const match = String(duration ?? '').trim().match(/^(-?\d+)\s*([smhdw])$/iu);
718
- if (!match) {
719
- return null;
720
- }
721
-
722
- const amount = Number(match[1]);
723
- const unit = match[2].toLowerCase();
724
- const multiplier = {
725
- d: 86_400_000,
726
- h: 3_600_000,
727
- m: 60_000,
728
- s: 1_000,
729
- w: 604_800_000,
730
- }[unit];
731
- return Number.isFinite(amount) && multiplier ? amount * multiplier : null;
732
- }
733
-
734
- function parseDateValue(value) {
735
- if (value instanceof Date) {
736
- return Number.isFinite(value.getTime()) ? value : null;
737
- }
738
- if (typeof value === 'number') {
739
- const date = new Date(value);
740
- return Number.isFinite(date.getTime()) ? date : null;
741
- }
742
- if (typeof value !== 'string') {
743
- return null;
744
- }
745
-
746
- const normalized = value.trim();
747
- if (!normalized) {
748
- return null;
749
- }
750
-
751
- if (/^\d{8}$/u.test(normalized)) {
752
- return new Date(`${normalized.slice(0, 4)}-${normalized.slice(4, 6)}-${normalized.slice(6, 8)}T00:00:00`);
753
- }
754
-
755
- if (/^\d{8}\d{4}$/u.test(normalized)) {
756
- return new Date(`${normalized.slice(0, 4)}-${normalized.slice(4, 6)}-${normalized.slice(6, 8)}T${normalized.slice(8, 10)}:${normalized.slice(10, 12)}:00`);
757
- }
758
-
759
- const date = new Date(normalized);
760
- return Number.isFinite(date.getTime()) ? date : null;
761
- }
762
-
763
- function isDateLikeString(value) {
764
- if (typeof value !== 'string') {
765
- return false;
766
- }
767
-
768
- const normalized = value.trim();
769
- if (!normalized) {
770
- return false;
771
- }
772
-
773
- return (
774
- /^\d{8}(?:\d{4})?$/u.test(normalized)
775
- || /^\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/u.test(normalized)
776
- );
777
- }
778
-
779
- function coerceDateComparable(value) {
780
- if (!isDateLikeString(value)) {
781
- return value;
782
- }
783
-
784
- return parseDateValue(value) ?? value;
785
- }
786
-
787
- function normalizeComparablePair(left, right) {
788
- if (left instanceof Date || right instanceof Date) {
789
- return [
790
- left instanceof Date ? left : coerceDateComparable(left),
791
- right instanceof Date ? right : coerceDateComparable(right),
792
- ];
793
- }
794
-
795
- if (typeof left === 'string' && typeof right === 'string' && isDateLikeString(left) && isDateLikeString(right)) {
796
- return [
797
- parseDateValue(left) ?? left,
798
- parseDateValue(right) ?? right,
799
- ];
800
- }
801
-
802
- return [left, right];
803
- }
804
-
805
- function formatDateValue(value, format = 'YYYY-MM-DD') {
806
- const date = parseDateValue(value);
807
- if (!date) {
808
- return '';
809
- }
810
-
811
- const padded = (number) => String(number).padStart(2, '0');
812
- return String(format ?? 'YYYY-MM-DD')
813
- .replace(/YYYY/g, String(date.getFullYear()))
814
- .replace(/MM/g, padded(date.getMonth() + 1))
815
- .replace(/DD/g, padded(date.getDate()))
816
- .replace(/HH/g, padded(date.getHours()))
817
- .replace(/mm/g, padded(date.getMinutes()))
818
- .replace(/ss/g, padded(date.getSeconds()));
819
- }
820
-
821
- function toComparableValue(value) {
822
- if (value instanceof Date) {
823
- return value.getTime();
824
- }
825
- return value;
826
- }
827
-
828
- function toDisplayText(value) {
829
- if (value == null) {
830
- return '';
831
- }
832
- if (value instanceof Date) {
833
- return formatDateValue(value, 'YYYY-MM-DD HH:mm:ss');
834
- }
835
- if (Array.isArray(value)) {
836
- return value.map((entry) => toDisplayText(entry)).join(', ');
837
- }
838
- if (typeof value === 'boolean') {
839
- return value ? 'true' : 'false';
840
- }
841
- if (typeof value === 'number') {
842
- return Number.isFinite(value) ? String(value) : '';
843
- }
844
- if (value?.__baseType === 'file') {
845
- return value.path || value.name || '';
846
- }
847
- if (value?.__baseType === 'link') {
848
- return value.display || value.path || value.url || '';
849
- }
850
- if (isPlainObject(value)) {
851
- try {
852
- return JSON.stringify(value);
853
- } catch {
854
- return '[object Object]';
855
- }
856
- }
857
- return String(value);
858
- }
859
-
860
- function valuesEqual(left, right) {
861
- const [normalizedLeft, normalizedRight] = normalizeComparablePair(left, right);
862
-
863
- left = normalizedLeft;
864
- right = normalizedRight;
865
-
866
- if (left?.__baseType === 'file' && right?.__baseType === 'file') {
867
- return left.path === right.path;
868
- }
869
- if (left?.__baseType === 'link' && right?.__baseType === 'link') {
870
- return left.url === right.url && left.path === right.path;
871
- }
872
- if (left instanceof Date || right instanceof Date) {
873
- return toComparableValue(left) === toComparableValue(right);
874
- }
875
- return left === right;
876
- }
877
-
878
- function resolveImageAttachmentPath(value, {
879
- rowFilePath = '',
880
- snapshot = null,
881
- sourcePath = '',
882
- } = {}) {
883
- const text = String(value ?? '').trim();
884
- const rowsByPath = snapshot?.rowsByPath;
885
- if (!text || !rowsByPath) {
886
- return null;
887
- }
888
-
889
- const candidates = [];
890
- const seen = new Set();
891
- const appendCandidate = (candidate) => {
892
- const normalized = String(candidate ?? '').trim().replace(/\\/g, '/');
893
- if (!normalized || seen.has(normalized)) {
894
- return;
895
- }
896
- seen.add(normalized);
897
- candidates.push(normalized);
898
- };
899
-
900
- if (rowFilePath) {
901
- appendCandidate(resolveVaultRelativePath(rowFilePath, text));
902
- }
903
- if (sourcePath) {
904
- appendCandidate(resolveVaultRelativePath(sourcePath, text));
905
- }
906
- appendCandidate(text);
907
-
908
- return candidates.find((candidate) => (
909
- isImageAttachmentFilePath(candidate)
910
- && rowsByPath.has(candidate)
911
- )) ?? null;
912
- }
913
-
914
- function serializeBaseValue(value, options = {}) {
915
- if (value == null || value === '') {
916
- return { text: '', type: 'empty', value: null };
917
- }
918
- if (value instanceof Date) {
919
- return {
920
- iso: value.toISOString(),
921
- text: formatDateValue(value, 'YYYY-MM-DD HH:mm:ss'),
922
- type: 'date',
923
- value: value.toISOString(),
924
- };
925
- }
926
- if (Array.isArray(value)) {
927
- return {
928
- items: value.map((entry) => serializeBaseValue(entry, options)),
929
- text: toDisplayText(value),
930
- type: 'list',
931
- value: value.map((entry) => toDisplayText(entry)),
932
- };
933
- }
934
- if (value?.__baseType === 'file') {
935
- return {
936
- path: value.path,
937
- text: value.path,
938
- type: 'file',
939
- value: value.path,
940
- };
941
- }
942
- if (value?.__baseType === 'link') {
943
- return {
944
- external: value.external,
945
- path: value.path,
946
- text: value.display || value.path || value.url,
947
- type: 'link',
948
- url: value.url,
949
- value: value.display || value.path || value.url,
950
- };
951
- }
952
- if (typeof value === 'boolean') {
953
- return { text: value ? 'true' : 'false', type: 'boolean', value };
954
- }
955
- if (typeof value === 'number') {
956
- return { text: Number.isFinite(value) ? String(value) : '', type: 'number', value };
957
- }
958
- if (typeof value === 'string') {
959
- const imagePath = resolveImageAttachmentPath(value, options);
960
- if (imagePath) {
961
- return {
962
- path: imagePath,
963
- text: value,
964
- type: 'image',
965
- value: imagePath,
966
- };
967
- }
968
- return { text: value, type: 'string', value };
969
- }
970
- return {
971
- text: toDisplayText(value),
972
- type: 'object',
973
- value,
974
- };
975
- }
976
-
977
- function compareValues(left, right) {
978
- const [normalizedLeft, normalizedRight] = normalizeComparablePair(left, right);
979
- left = normalizedLeft;
980
- right = normalizedRight;
981
-
982
- const comparableLeft = toComparableValue(left);
983
- const comparableRight = toComparableValue(right);
984
- if (comparableLeft == null && comparableRight == null) return 0;
985
- if (comparableLeft == null) return 1;
986
- if (comparableRight == null) return -1;
987
- if (typeof comparableLeft === 'number' && typeof comparableRight === 'number') {
988
- return comparableLeft - comparableRight;
989
- }
990
- return String(toDisplayText(comparableLeft)).localeCompare(String(toDisplayText(comparableRight)), undefined, { sensitivity: 'base' });
991
- }
992
-
993
- function applyBinaryOperator(operator, left, right) {
994
- if (operator === '&&') {
995
- return Boolean(left) && Boolean(right);
996
- }
997
- if (operator === '||') {
998
- return Boolean(left) || Boolean(right);
999
- }
1000
- if (operator === '==') {
1001
- return valuesEqual(left, right);
1002
- }
1003
- if (operator === '!=') {
1004
- return !valuesEqual(left, right);
1005
- }
1006
- if (operator === '<') {
1007
- return compareValues(left, right) < 0;
1008
- }
1009
- if (operator === '<=') {
1010
- return compareValues(left, right) <= 0;
1011
- }
1012
- if (operator === '>') {
1013
- return compareValues(left, right) > 0;
1014
- }
1015
- if (operator === '>=') {
1016
- return compareValues(left, right) >= 0;
1017
- }
1018
- if (operator === '+') {
1019
- if (left instanceof Date && typeof right === 'string') {
1020
- const duration = normalizeDuration(right);
1021
- return duration == null ? left : new Date(left.getTime() + duration);
1022
- }
1023
- if (typeof left === 'string' || typeof right === 'string') {
1024
- return `${toDisplayText(left)}${toDisplayText(right)}`;
1025
- }
1026
- return Number(left ?? 0) + Number(right ?? 0);
1027
- }
1028
- if (operator === '-') {
1029
- if (left instanceof Date && typeof right === 'string') {
1030
- const duration = normalizeDuration(right);
1031
- return duration == null ? left : new Date(left.getTime() - duration);
1032
- }
1033
- if (left instanceof Date && right instanceof Date) {
1034
- return left.getTime() - right.getTime();
1035
- }
1036
- return Number(left ?? 0) - Number(right ?? 0);
1037
- }
1038
- if (operator === '*') {
1039
- return Number(left ?? 0) * Number(right ?? 0);
1040
- }
1041
- if (operator === '/') {
1042
- return Number(right ?? 0) === 0 ? null : Number(left ?? 0) / Number(right ?? 0);
1043
- }
1044
- if (operator === '%') {
1045
- return Number(right ?? 0) === 0 ? null : Number(left ?? 0) % Number(right ?? 0);
1046
- }
1047
-
1048
- throw new Error(`Unsupported operator: ${operator}`);
1049
- }
1050
-
1051
- function invokeGlobalFunction(name, argNodes, scope, evaluate) {
1052
- if (name === 'if') {
1053
- const condition = evaluate(argNodes[0], scope);
1054
- return condition ? evaluate(argNodes[1], scope) : evaluate(argNodes[2], scope);
1055
- }
1056
-
1057
- const args = argNodes.map((node) => evaluate(node, scope));
1058
- switch (name) {
1059
- case 'date':
1060
- return parseDateValue(args[0]);
1061
- case 'link': {
1062
- const target = String(args[0] ?? '').trim();
1063
- if (!target) {
1064
- return null;
1065
- }
1066
- const external = /^[a-z]+:/iu.test(target);
1067
- return createLinkValue(target, {
1068
- display: typeof args[1] === 'string' ? args[1] : '',
1069
- exists: external,
1070
- external,
1071
- });
1072
- }
1073
- case 'list':
1074
- return args;
1075
- case 'max':
1076
- return args.flat().reduce((acc, value) => (acc == null || compareValues(value, acc) > 0 ? value : acc), null);
1077
- case 'min':
1078
- return args.flat().reduce((acc, value) => (acc == null || compareValues(value, acc) < 0 ? value : acc), null);
1079
- case 'now':
1080
- return new Date();
1081
- case 'today': {
1082
- const date = new Date();
1083
- date.setHours(0, 0, 0, 0);
1084
- return date;
1085
- }
1086
- default:
1087
- throw new Error(`Unknown function: ${name}`);
1088
- }
1089
- }
1090
-
1091
- function invokeMethod(target, name, argNodes, scope, evaluate, rootContext) {
1092
- if (target?.__baseType === 'formula-namespace') {
1093
- if (argNodes.length > 0) {
1094
- throw new Error(`Formula namespace property "${name}" is not callable`);
1095
- }
1096
- return rootContext.resolveFormulaValue(name, target.row, target.evaluationState);
1097
- }
1098
-
1099
- if (Array.isArray(target)) {
1100
- if (name === 'contains') {
1101
- const needle = evaluate(argNodes[0], scope);
1102
- return target.some((entry) => valuesEqual(entry, needle));
1103
- }
1104
- if (name === 'containsAll') {
1105
- return argNodes.every((node) => target.some((entry) => valuesEqual(entry, evaluate(node, scope))));
1106
- }
1107
- if (name === 'containsAny') {
1108
- return argNodes.some((node) => target.some((entry) => valuesEqual(entry, evaluate(node, scope))));
1109
- }
1110
- if (name === 'filter') {
1111
- const predicate = argNodes[0];
1112
- return target.filter((value, index) => Boolean(evaluate(predicate, scope.child({ index, value }))));
1113
- }
1114
- if (name === 'flat') {
1115
- return target.flat();
1116
- }
1117
- if (name === 'isEmpty') {
1118
- return target.length === 0;
1119
- }
1120
- if (name === 'join') {
1121
- return target.map((entry) => toDisplayText(entry)).join(String(evaluate(argNodes[0], scope) ?? ''));
1122
- }
1123
- if (name === 'map') {
1124
- const mapper = argNodes[0];
1125
- return target.map((value, index) => evaluate(mapper, scope.child({ index, value })));
1126
- }
1127
- if (name === 'reduce') {
1128
- const reducer = argNodes[0];
1129
- let accumulator = argNodes.length > 1 ? evaluate(argNodes[1], scope) : null;
1130
- target.forEach((value, index) => {
1131
- accumulator = evaluate(reducer, scope.child({ acc: accumulator, index, value }));
1132
- });
1133
- return accumulator;
1134
- }
1135
- if (name === 'reverse') {
1136
- return [...target].reverse();
1137
- }
1138
- if (name === 'slice') {
1139
- return target.slice(Number(evaluate(argNodes[0], scope) ?? 0), argNodes[1] ? Number(evaluate(argNodes[1], scope)) : undefined);
1140
- }
1141
- if (name === 'sort') {
1142
- return [...target].sort(compareValues);
1143
- }
1144
- if (name === 'unique') {
1145
- const uniqueValues = [];
1146
- target.forEach((value) => {
1147
- if (!uniqueValues.some((entry) => valuesEqual(entry, value))) {
1148
- uniqueValues.push(value);
1149
- }
1150
- });
1151
- return uniqueValues;
1152
- }
1153
- }
1154
-
1155
- if (typeof target === 'string') {
1156
- switch (name) {
1157
- case 'contains':
1158
- return target.toLowerCase().includes(String(evaluate(argNodes[0], scope) ?? '').toLowerCase());
1159
- case 'endsWith':
1160
- return target.endsWith(String(evaluate(argNodes[0], scope) ?? ''));
1161
- case 'isEmpty':
1162
- return target.length === 0;
1163
- case 'lower':
1164
- return target.toLowerCase();
1165
- case 'replace':
1166
- return target.replaceAll(String(evaluate(argNodes[0], scope) ?? ''), String(evaluate(argNodes[1], scope) ?? ''));
1167
- case 'split':
1168
- return target.split(String(evaluate(argNodes[0], scope) ?? ''));
1169
- case 'startsWith':
1170
- return target.startsWith(String(evaluate(argNodes[0], scope) ?? ''));
1171
- case 'title':
1172
- return target.replace(/\b\p{L}/gu, (match) => match.toUpperCase());
1173
- case 'trim':
1174
- return target.trim();
1175
- case 'upper':
1176
- return target.toUpperCase();
1177
- default:
1178
- break;
1179
- }
1180
- }
1181
-
1182
- if (typeof target === 'number') {
1183
- switch (name) {
1184
- case 'ceil':
1185
- return Math.ceil(target);
1186
- case 'floor':
1187
- return Math.floor(target);
1188
- case 'isEmpty':
1189
- return false;
1190
- case 'round':
1191
- return Number.isFinite(evaluate(argNodes[0], scope))
1192
- ? Number(target.toFixed(Number(evaluate(argNodes[0], scope))))
1193
- : Math.round(target);
1194
- case 'toFixed':
1195
- return target.toFixed(Number(evaluate(argNodes[0], scope) ?? 0));
1196
- default:
1197
- break;
1198
- }
1199
- }
1200
-
1201
- const asDate = parseDateValue(target);
1202
- if (asDate) {
1203
- switch (name) {
1204
- case 'format':
1205
- return formatDateValue(asDate, String(evaluate(argNodes[0], scope) ?? 'YYYY-MM-DD'));
1206
- case 'isEmpty':
1207
- return false;
1208
- default:
1209
- break;
1210
- }
1211
- }
1212
-
1213
- if (target?.__baseType === 'file') {
1214
- switch (name) {
1215
- case 'asLink':
1216
- return createLinkValue(target.path, {
1217
- display: typeof evaluate(argNodes[0], scope) === 'string' ? evaluate(argNodes[0], scope) : '',
1218
- exists: true,
1219
- });
1220
- case 'hasLink': {
1221
- const needle = evaluate(argNodes[0], scope);
1222
- const needlePath = needle?.__baseType === 'file'
1223
- ? needle.path
1224
- : (needle?.__baseType === 'link' ? needle.path : String(needle ?? ''));
1225
- return target.links.some((link) => link.path === needlePath || link.rawTarget === needlePath);
1226
- }
1227
- case 'hasProperty':
1228
- return Object.hasOwn(target.properties, String(evaluate(argNodes[0], scope) ?? ''));
1229
- case 'hasTag':
1230
- return argNodes.some((node) => {
1231
- const value = String(evaluate(node, scope) ?? '').replace(/^#/u, '');
1232
- return target.tags.some((tag) => tag === value || tag.startsWith(`${value}/`));
1233
- });
1234
- case 'inFolder': {
1235
- const folder = String(evaluate(argNodes[0], scope) ?? '').replace(/\/+$/u, '');
1236
- return !folder || target.folder === folder || target.folder.startsWith(`${folder}/`);
1237
- }
1238
- default:
1239
- break;
1240
- }
1241
- }
1242
-
1243
- if (target?.__baseType === 'link') {
1244
- switch (name) {
1245
- case 'asFile':
1246
- return target.path ? rootContext.snapshot.rowsByPath.get(target.path)?.file ?? null : null;
1247
- case 'linksTo': {
1248
- const other = evaluate(argNodes[0], scope);
1249
- const otherPath = other?.__baseType === 'file'
1250
- ? other.path
1251
- : (other?.__baseType === 'link' ? other.path : String(other ?? ''));
1252
- return target.path === otherPath;
1253
- }
1254
- default:
1255
- break;
1256
- }
1257
- }
1258
-
1259
- if (isPlainObject(target)) {
1260
- switch (name) {
1261
- case 'isEmpty':
1262
- return Object.keys(target).length === 0;
1263
- case 'keys':
1264
- return Object.keys(target);
1265
- case 'values':
1266
- return Object.values(target);
1267
- default:
1268
- break;
1269
- }
1270
- }
1271
-
1272
- throw new Error(`Unsupported method "${name}"`);
1273
- }
1274
-
1275
- function evaluateAst(ast, scope) {
1276
- const rootContext = scope.rootContext;
1277
- const evaluate = (node, nextScope = scope) => evaluateAst(node, nextScope);
1278
-
1279
- switch (ast?.type) {
1280
- case 'ArrayExpression':
1281
- return ast.elements.map((element) => evaluate(element, scope));
1282
- case 'BinaryExpression':
1283
- return applyBinaryOperator(ast.operator, evaluate(ast.left, scope), evaluate(ast.right, scope));
1284
- case 'CallExpression': {
1285
- if (ast.callee?.type === 'Identifier') {
1286
- return invokeGlobalFunction(ast.callee.name, ast.arguments, scope, evaluate);
1287
- }
1288
- if (ast.callee?.type === 'MemberExpression') {
1289
- const target = evaluate(ast.callee.object, scope);
1290
- const property = ast.callee.computed
1291
- ? evaluate(ast.callee.property, scope)
1292
- : ast.callee.property.name;
1293
- return invokeMethod(target, String(property ?? ''), ast.arguments, scope, evaluate, rootContext);
1294
- }
1295
- throw new Error('Unsupported call target');
1296
- }
1297
- case 'Identifier':
1298
- return scope.get(ast.name);
1299
- case 'Literal':
1300
- return ast.value;
1301
- case 'MemberExpression': {
1302
- const target = evaluate(ast.object, scope);
1303
- const property = ast.computed ? evaluate(ast.property, scope) : ast.property.name;
1304
- if (target?.__baseType === 'formula-namespace') {
1305
- return rootContext.resolveFormulaValue(String(property ?? ''), target.row, target.evaluationState);
1306
- }
1307
- if (target == null) {
1308
- return null;
1309
- }
1310
- return target[property];
1311
- }
1312
- case 'ObjectExpression':
1313
- return ast.properties.reduce((acc, property) => {
1314
- acc[property.key] = evaluate(property.value, scope);
1315
- return acc;
1316
- }, {});
1317
- case 'UnaryExpression': {
1318
- const value = evaluate(ast.argument, scope);
1319
- return ast.operator === '!' ? !value : -Number(value ?? 0);
1320
- }
1321
- default:
1322
- throw new Error(`Unsupported expression node "${ast?.type}"`);
1323
- }
1324
- }
1325
-
1326
- function evaluateExpression(expressionSource, rootContext, locals = {}) {
1327
- const cache = rootContext.astCache;
1328
- let ast = cache.get(expressionSource);
1329
- if (!ast) {
1330
- ast = parseExpression(expressionSource);
1331
- cache.set(expressionSource, ast);
1332
- }
1333
- const scope = new ExpressionScope({ locals, rootContext });
1334
- return evaluateAst(ast, scope);
1335
- }
1336
-
1337
- function evaluateFilterNode(filterNode, rootContext) {
1338
- if (!filterNode) {
1339
- return true;
1340
- }
1341
- if (typeof filterNode === 'string') {
1342
- return Boolean(evaluateExpression(filterNode, rootContext));
1343
- }
1344
- if (!isPlainObject(filterNode)) {
1345
- return true;
1346
- }
1347
-
1348
- if (Array.isArray(filterNode.and)) {
1349
- return filterNode.and.every((entry) => evaluateFilterNode(entry, rootContext));
1350
- }
1351
- if (Array.isArray(filterNode.or)) {
1352
- return filterNode.or.some((entry) => evaluateFilterNode(entry, rootContext));
1353
- }
1354
- if (filterNode.not != null) {
1355
- return !evaluateFilterNode(filterNode.not, rootContext);
1356
- }
1357
-
1358
- return true;
1359
- }
1360
-
1361
- function normalizeFormulaLookupName(name = '') {
1362
- return String(name ?? '').replace(/^formula\./u, '');
1363
- }
1364
-
1365
- function createEvaluationRootContext({
1366
- astCache = new Map(),
1367
- currentRow,
1368
- definition,
1369
- evaluationState = null,
1370
- identifierResolver = null,
1371
- snapshot,
1372
- thisFile,
1373
- }) {
1374
- const nextEvaluationState = evaluationState ?? {
1375
- cache: new Map(),
1376
- stack: new Set(),
1377
- };
1378
-
1379
- return {
1380
- astCache,
1381
- currentRow,
1382
- definition,
1383
- evaluationState: nextEvaluationState,
1384
- resolveFormulaValue: (name, row, state = nextEvaluationState) => {
1385
- const formulaName = normalizeFormulaLookupName(name);
1386
- const formulaPropertyId = Object.keys(definition.properties).find((propertyId) => {
1387
- const config = definition.properties[propertyId];
1388
- return config?.formula && normalizeFormulaLookupName(propertyId) === formulaName;
1389
- });
1390
- if (!formulaPropertyId) {
1391
- return null;
1392
- }
1393
-
1394
- const cacheKey = `${row.file.path}::${formulaPropertyId}`;
1395
- if (state.cache.has(cacheKey)) {
1396
- return state.cache.get(cacheKey);
1397
- }
1398
- if (state.stack.has(cacheKey)) {
1399
- throw new Error(`Circular formula reference: ${formulaPropertyId}`);
1400
- }
1401
-
1402
- state.stack.add(cacheKey);
1403
- const nextContext = createEvaluationRootContext({
1404
- astCache,
1405
- currentRow: row,
1406
- definition,
1407
- evaluationState: state,
1408
- snapshot,
1409
- thisFile,
1410
- });
1411
- const result = evaluateExpression(definition.properties[formulaPropertyId].formula, nextContext);
1412
- state.stack.delete(cacheKey);
1413
- state.cache.set(cacheKey, result);
1414
- return result;
1415
- },
1416
- resolveIdentifier: (name) => {
1417
- if (typeof identifierResolver === 'function') {
1418
- const resolved = identifierResolver(name);
1419
- if (resolved !== undefined) {
1420
- return resolved;
1421
- }
1422
- }
1423
- if (name === 'file') {
1424
- return currentRow.file;
1425
- }
1426
- if (name === 'formula') {
1427
- return createFormulaNamespace(currentRow, nextEvaluationState);
1428
- }
1429
- if (name === 'note') {
1430
- return currentRow.noteProperties;
1431
- }
1432
- if (name === 'this') {
1433
- return createContextFileValue(thisFile);
1434
- }
1435
- if (name === 'values') {
1436
- return [];
1437
- }
1438
- if (Object.hasOwn(currentRow.noteProperties, name)) {
1439
- return currentRow.noteProperties[name];
1440
- }
1441
- return null;
1442
- },
1443
- snapshot,
1444
- thisFile,
1445
- };
1446
- }
1447
-
1448
- function resolvePropertyLabel(propertyId, definition) {
1449
- const config = definition.properties[propertyId];
1450
- if (config?.displayName) {
1451
- return config.displayName;
1452
- }
1453
- return propertyId.startsWith('file.')
1454
- ? propertyId.slice(5)
1455
- : propertyId.replace(/^note\./u, '');
1456
- }
1457
-
1458
- function getPropertyValue(propertyId, row, definition, snapshot, thisFile) {
1459
- if (propertyId.startsWith('file.')) {
1460
- return propertyId.split('.').slice(1).reduce((acc, segment) => acc?.[segment], row.file);
1461
- }
1462
-
1463
- if (propertyId.startsWith('note.')) {
1464
- return row.noteProperties[propertyId.slice(5)] ?? null;
1465
- }
1466
-
1467
- const propertyConfig = definition.properties[propertyId];
1468
- if (propertyConfig?.formula) {
1469
- const context = createEvaluationRootContext({
1470
- currentRow: row,
1471
- definition,
1472
- snapshot,
1473
- thisFile,
1474
- });
1475
- return context.resolveFormulaValue(propertyId, row, context.evaluationState);
1476
- }
1477
-
1478
- const formulaConfig = definition.properties[`formula.${propertyId}`];
1479
- if (formulaConfig?.formula) {
1480
- const context = createEvaluationRootContext({
1481
- currentRow: row,
1482
- definition,
1483
- snapshot,
1484
- thisFile,
1485
- });
1486
- return context.resolveFormulaValue(propertyId, row, context.evaluationState);
1487
- }
1488
-
1489
- return row.noteProperties[propertyId] ?? null;
1490
- }
1491
-
1492
- function buildColumns(definition, view) {
1493
- const order = view.order.length > 0
1494
- ? view.order
1495
- : ['file.name', ...Object.keys(definition.properties)];
1496
- return order.map((propertyId) => ({
1497
- id: propertyId,
1498
- label: resolvePropertyLabel(propertyId, definition),
1499
- }));
1
+ import {
2
+ buildColumns,
3
+ collectEvaluatedPropertyIds,
4
+ findView,
5
+ normalizeBaseDefinition,
6
+ } from './base-definition.js';
7
+ import {
8
+ compareValues,
9
+ createEvaluationRootContext,
10
+ evaluateFilterNode,
11
+ getPropertyValue,
12
+ serializeBaseValue,
13
+ } from './base-expression-runtime.js';
14
+ import { BaseIndexSnapshotStore } from './base-index-snapshot-store.js';
15
+ import {
16
+ buildAvailableProperties,
17
+ collectDistinctPropertyValues,
18
+ createPropertyCatalogSnapshot,
19
+ inferValueType,
20
+ } from './base-query-metadata.js';
21
+ import { buildQueryResultPayload, rowMatchesSearch } from './base-query-results.js';
22
+ import { transformBaseSource } from './base-transform.js';
23
+
24
+ export { serializeBaseDefinition } from './base-definition.js';
25
+
26
+ function buildSortChain(activeView) {
27
+ const entries = [];
28
+ (activeView.sort ?? []).forEach((sortConfig) => {
29
+ if (
30
+ sortConfig?.property
31
+ && !entries.some((entry) => entry.property === sortConfig.property)
32
+ ) {
33
+ entries.push(sortConfig);
34
+ }
35
+ });
36
+ return entries;
1500
37
  }
1501
38
 
1502
- function collectEvaluatedPropertyIds(columns, view) {
1503
- const propertyIds = new Set(columns.map((column) => column.id));
1504
-
1505
- if (view.groupBy) {
1506
- propertyIds.add(view.groupBy);
39
+ function sortRows(rows, sortChain = []) {
40
+ if (!Array.isArray(sortChain) || sortChain.length === 0) {
41
+ return rows;
1507
42
  }
1508
43
 
1509
- view.sort.forEach((sortConfig) => {
1510
- if (sortConfig?.property) {
1511
- propertyIds.add(sortConfig.property);
44
+ return rows.slice().sort((left, right) => {
45
+ for (const sortConfig of sortChain) {
46
+ const delta = compareValues(left.rawCells[sortConfig.property], right.rawCells[sortConfig.property]);
47
+ if (delta !== 0) {
48
+ return sortConfig.direction === 'desc' ? -delta : delta;
49
+ }
1512
50
  }
1513
- });
1514
51
 
1515
- view.summaries.forEach((summary) => {
1516
- if (summary?.property) {
1517
- propertyIds.add(summary.property);
1518
- }
52
+ return String(left.file.path ?? '').localeCompare(String(right.file.path ?? ''));
1519
53
  });
54
+ }
1520
55
 
1521
- return [...propertyIds];
56
+ function escapeRegExp(value = '') {
57
+ return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
1522
58
  }
1523
59
 
1524
- function summarizeValues(values = [], summary, definition, snapshot, thisFile) {
1525
- const meaningfulValues = values.filter((value) => value != null && value !== '');
1526
- switch (summary.type) {
1527
- case 'average': {
1528
- const numbers = meaningfulValues.map(Number).filter(Number.isFinite);
1529
- return numbers.length > 0 ? numbers.reduce((sum, value) => sum + value, 0) / numbers.length : null;
1530
- }
1531
- case 'checked':
1532
- return values.filter(Boolean).length;
1533
- case 'custom': {
1534
- const context = createEvaluationRootContext({
1535
- astCache: new Map(),
1536
- currentRow: {
1537
- file: thisFile,
1538
- noteProperties: thisFile?.properties ?? {},
1539
- },
1540
- definition,
1541
- identifierResolver: (name) => (name === 'values' ? values : undefined),
1542
- snapshot,
1543
- thisFile,
1544
- });
1545
- return evaluateExpression(summary.formula, context);
1546
- }
1547
- case 'earliest':
1548
- return meaningfulValues.reduce((acc, value) => (acc == null || compareValues(value, acc) < 0 ? value : acc), null);
1549
- case 'empty':
1550
- return values.filter((value) => value == null || value === '').length;
1551
- case 'filled':
1552
- return meaningfulValues.length;
1553
- case 'latest':
1554
- return meaningfulValues.reduce((acc, value) => (acc == null || compareValues(value, acc) > 0 ? value : acc), null);
1555
- case 'max':
1556
- return meaningfulValues.reduce((acc, value) => (acc == null || compareValues(value, acc) > 0 ? value : acc), null);
1557
- case 'median': {
1558
- const numbers = meaningfulValues.map(Number).filter(Number.isFinite).sort((left, right) => left - right);
1559
- if (numbers.length === 0) return null;
1560
- const middle = Math.floor(numbers.length / 2);
1561
- return numbers.length % 2 === 0 ? (numbers[middle - 1] + numbers[middle]) / 2 : numbers[middle];
1562
- }
1563
- case 'min':
1564
- return meaningfulValues.reduce((acc, value) => (acc == null || compareValues(value, acc) < 0 ? value : acc), null);
1565
- case 'range': {
1566
- const sorted = meaningfulValues.slice().sort(compareValues);
1567
- if (sorted.length === 0) return null;
1568
- const first = sorted[0];
1569
- const last = sorted[sorted.length - 1];
1570
- if (first instanceof Date && last instanceof Date) {
1571
- return last.getTime() - first.getTime();
1572
- }
1573
- return Number(last ?? 0) - Number(first ?? 0);
1574
- }
1575
- case 'stddev': {
1576
- const numbers = meaningfulValues.map(Number).filter(Number.isFinite);
1577
- if (numbers.length === 0) return null;
1578
- const average = numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
1579
- const variance = numbers.reduce((sum, value) => sum + ((value - average) ** 2), 0) / numbers.length;
1580
- return Math.sqrt(variance);
1581
- }
1582
- case 'sum':
1583
- return meaningfulValues.map(Number).filter(Number.isFinite).reduce((sum, value) => sum + value, 0);
1584
- case 'unchecked':
1585
- return values.filter((value) => value === false || value == null).length;
1586
- case 'unique': {
1587
- const uniqueValues = [];
1588
- meaningfulValues.forEach((value) => {
1589
- if (!uniqueValues.some((entry) => valuesEqual(entry, value))) {
1590
- uniqueValues.push(value);
1591
- }
1592
- });
1593
- return uniqueValues.length;
1594
- }
1595
- default:
1596
- return null;
60
+ function pruneFilterNodeForProperty(filterNode, propertyId = '') {
61
+ if (!propertyId || !filterNode) {
62
+ return filterNode;
1597
63
  }
1598
- }
1599
64
 
1600
- function createSummaryPayload(columns, rows, summaries, definition, snapshot, thisFile) {
1601
- return summaries.map((summary) => {
1602
- const column = columns.find((entry) => entry.id === summary.property);
1603
- const values = rows.map((row) => row.rawCells[summary.property]);
1604
- const value = summarizeValues(values, summary, definition, snapshot, thisFile);
1605
- return {
1606
- id: summary.id,
1607
- label: summary.label,
1608
- property: summary.property,
1609
- propertyLabel: column?.label ?? summary.property,
1610
- value: serializeBaseValue(value, {
1611
- snapshot,
1612
- sourcePath: thisFile?.path ?? '',
1613
- }),
1614
- };
1615
- });
1616
- }
65
+ if (typeof filterNode === 'string') {
66
+ const escapedPropertyId = escapeRegExp(propertyId);
67
+ const propertyFilterPattern = new RegExp(
68
+ `^!?${escapedPropertyId}(?:\\.(?:contains|startsWith|endsWith|isEmpty)\\(.*\\)|\\s*(?:==|!=|>=|<=|>|<)\\s*.+)$`,
69
+ 'u',
70
+ );
71
+ return propertyFilterPattern.test(String(filterNode ?? '').trim()) ? null : filterNode;
72
+ }
1617
73
 
1618
- function rowMatchesSearch(row, columns, query) {
1619
- const normalizedQuery = String(query ?? '').trim().toLowerCase();
1620
- if (!normalizedQuery) {
1621
- return true;
74
+ if (Array.isArray(filterNode?.and)) {
75
+ const children = filterNode.and
76
+ .map((child) => pruneFilterNodeForProperty(child, propertyId))
77
+ .filter(Boolean);
78
+ return children.length > 0 ? { and: children } : null;
1622
79
  }
1623
80
 
1624
- const haystack = [
1625
- row.file.path,
1626
- ...columns.map((column) => toDisplayText(row.rawCells[column.id])),
1627
- ].join('\n').toLowerCase();
1628
- return haystack.includes(normalizedQuery);
1629
- }
81
+ if (Array.isArray(filterNode?.or)) {
82
+ const children = filterNode.or
83
+ .map((child) => pruneFilterNodeForProperty(child, propertyId))
84
+ .filter(Boolean);
85
+ return children.length > 0 ? { or: children } : null;
86
+ }
1630
87
 
1631
- function createCsv(rows, columns) {
1632
- const escapeCell = (value) => {
1633
- const text = String(value ?? '');
1634
- if (/[,"\n]/u.test(text)) {
1635
- return `"${text.replace(/"/g, '""')}"`;
88
+ if (filterNode?.not != null) {
89
+ if (Array.isArray(filterNode.not)) {
90
+ const children = filterNode.not
91
+ .map((child) => pruneFilterNodeForProperty(child, propertyId))
92
+ .filter(Boolean);
93
+ return children.length > 0 ? { not: children } : null;
1636
94
  }
1637
- return text;
1638
- };
1639
-
1640
- const lines = [
1641
- columns.map((column) => escapeCell(column.label)).join(','),
1642
- ...rows.map((row) => columns.map((column) => escapeCell(toDisplayText(row.rawCells[column.id]))).join(',')),
1643
- ];
1644
- return `${lines.join('\n')}\n`;
1645
- }
1646
95
 
1647
- export function serializeBaseDefinition(definition) {
1648
- const raw = definition?.raw ?? definition ?? {};
1649
- return `${yaml.dump(raw, { lineWidth: -1, noRefs: true }).trim()}\n`;
96
+ const child = pruneFilterNodeForProperty(filterNode.not, propertyId);
97
+ return child ? { not: child } : null;
98
+ }
99
+
100
+ return filterNode;
1650
101
  }
1651
102
 
1652
103
  export class BaseQueryService {
@@ -1656,305 +107,112 @@ export class BaseQueryService {
1656
107
  workspaceStateSynchronizer = null,
1657
108
  }) {
1658
109
  this.vaultFileStore = vaultFileStore;
1659
- this.workspaceStateProvider = workspaceStateProvider;
1660
- this.workspaceStateSynchronizer = workspaceStateSynchronizer;
1661
- this.indexSnapshot = null;
1662
- this.lastWorkspaceState = null;
110
+ this.snapshotStore = new BaseIndexSnapshotStore({
111
+ vaultFileStore,
112
+ workspaceStateProvider,
113
+ workspaceStateSynchronizer,
114
+ });
115
+ this.propertyCatalogCache = new Map();
1663
116
  }
1664
117
 
1665
- async getWorkspaceState() {
1666
- const workspaceState = await this.workspaceStateProvider?.();
1667
- if (workspaceState) {
1668
- this.lastWorkspaceState = workspaceState;
1669
- return workspaceState;
1670
- }
1671
-
1672
- if (this.lastWorkspaceState) {
1673
- return this.lastWorkspaceState;
1674
- }
118
+ get workspaceStateProvider() {
119
+ return this.snapshotStore.workspaceStateProvider;
120
+ }
1675
121
 
1676
- const scannedWorkspaceState = await this.vaultFileStore.scanWorkspaceState();
1677
- this.lastWorkspaceState = scannedWorkspaceState;
1678
- return scannedWorkspaceState;
122
+ set workspaceStateProvider(value) {
123
+ this.snapshotStore.workspaceStateProvider = value;
1679
124
  }
1680
125
 
1681
- createSnapshotRow(filePath, workspaceState, wikiTargetIndex, markdownContent = null) {
1682
- const metadata = workspaceState.metadata.get(filePath) ?? null;
1683
- const frontmatter = markdownContent ? extractYamlFrontmatter(markdownContent) : null;
1684
- const noteProperties = isPlainObject(frontmatter?.data) ? frontmatter.data : {};
1685
- const bodyMarkdown = frontmatter?.bodyMarkdown ?? markdownContent ?? '';
1686
- const references = extractReferences(bodyMarkdown, wikiTargetIndex);
1687
- const tags = [...new Set([
1688
- ...normalizeTags(noteProperties.tags),
1689
- ...extractInlineTags(bodyMarkdown),
1690
- ])];
1691
- const fileValue = {
1692
- __baseType: 'file',
1693
- backlinks: [],
1694
- basename: stripVaultFileExtension(basename(filePath)),
1695
- ctime: Number.isFinite(metadata?.ctimeMs) ? new Date(metadata.ctimeMs) : null,
1696
- embeds: references.embeds,
1697
- ext: extname(filePath).replace(/^\./u, ''),
1698
- folder: dirname(filePath) === '.' ? '' : dirname(filePath).replace(/\\/g, '/'),
1699
- links: references.links,
1700
- mtime: Number.isFinite(metadata?.mtimeMs) ? new Date(metadata.mtimeMs) : null,
1701
- name: basename(filePath),
1702
- path: filePath,
1703
- properties: noteProperties,
1704
- size: Number(metadata?.size ?? 0) || 0,
1705
- tags,
1706
- };
126
+ get workspaceStateSynchronizer() {
127
+ return this.snapshotStore.workspaceStateSynchronizer;
128
+ }
1707
129
 
1708
- return {
1709
- forwardLinks: new Set(
1710
- references.links
1711
- .map((link) => link?.path)
1712
- .filter((targetPath) => targetPath && targetPath !== filePath),
1713
- ),
1714
- row: {
1715
- file: fileValue,
1716
- noteProperties,
1717
- path: filePath,
1718
- },
1719
- };
130
+ set workspaceStateSynchronizer(value) {
131
+ this.snapshotStore.workspaceStateSynchronizer = value;
1720
132
  }
1721
133
 
1722
- rebuildBacklinks(snapshot) {
1723
- const backlinksByTarget = new Map(snapshot.filePaths.map((filePath) => [filePath, []]));
134
+ get indexSnapshot() {
135
+ return this.snapshotStore.indexSnapshot;
136
+ }
1724
137
 
1725
- snapshot.rowsByPath.forEach((row, filePath) => {
1726
- const forwardLinks = snapshot.forwardLinksByPath.get(filePath) ?? new Set();
1727
- forwardLinks.forEach((targetPath) => {
1728
- if (!backlinksByTarget.has(targetPath) || targetPath === row.file.path) {
1729
- return;
1730
- }
138
+ set indexSnapshot(value) {
139
+ this.snapshotStore.indexSnapshot = value;
140
+ }
1731
141
 
1732
- backlinksByTarget.get(targetPath).push(createLinkValue(row.file.path, {
1733
- display: basename(row.file.path),
1734
- exists: true,
1735
- }));
1736
- });
1737
- });
142
+ get lastWorkspaceState() {
143
+ return this.snapshotStore.lastWorkspaceState;
144
+ }
1738
145
 
1739
- snapshot.backlinksByPath = backlinksByTarget;
1740
- snapshot.rowsByPath.forEach((row, filePath) => {
1741
- row.file.backlinks = dedupeLinkValues(backlinksByTarget.get(filePath) ?? []);
1742
- });
146
+ set lastWorkspaceState(value) {
147
+ this.snapshotStore.lastWorkspaceState = value;
1743
148
  }
1744
149
 
1745
- async buildIndexSnapshot(workspaceState = null) {
1746
- const resolvedWorkspaceState = workspaceState ?? await this.getWorkspaceState();
1747
- const fileEntries = listWorkspaceFilePaths(resolvedWorkspaceState);
1748
- const wikiTargetIndex = createWikiTargetIndex(fileEntries);
1749
- const markdownContents = new Map();
150
+ async getWorkspaceState() {
151
+ return this.snapshotStore.getWorkspaceState();
152
+ }
1750
153
 
1751
- await mapWithConcurrency(resolvedWorkspaceState.markdownPaths ?? [], INDEX_READ_CONCURRENCY, async (filePath) => {
1752
- markdownContents.set(filePath, await this.vaultFileStore.readMarkdownFile(filePath));
1753
- });
154
+ createSnapshotRow(...args) {
155
+ return this.snapshotStore.createSnapshotRow(...args);
156
+ }
1754
157
 
1755
- const rowsByPath = new Map();
1756
- const forwardLinksByPath = new Map();
1757
- fileEntries.forEach((filePath) => {
1758
- const markdownContent = markdownContents.get(filePath) ?? null;
1759
- const rowRecord = this.createSnapshotRow(filePath, resolvedWorkspaceState, wikiTargetIndex, markdownContent);
1760
- rowsByPath.set(filePath, rowRecord.row);
1761
- forwardLinksByPath.set(filePath, rowRecord.forwardLinks);
1762
- });
158
+ rebuildBacklinks(...args) {
159
+ return this.snapshotStore.rebuildBacklinks(...args);
160
+ }
1763
161
 
1764
- const snapshot = {
1765
- backlinksByPath: new Map(),
1766
- filePaths: fileEntries,
1767
- forwardLinksByPath,
1768
- rowsByPath,
1769
- scannedAt: resolvedWorkspaceState.scannedAt,
1770
- wikiTargetIndex,
1771
- workspaceState: resolvedWorkspaceState,
1772
- };
1773
- this.rebuildBacklinks(snapshot);
1774
- this.indexSnapshot = snapshot;
1775
- this.lastWorkspaceState = resolvedWorkspaceState;
1776
- return snapshot;
162
+ async buildIndexSnapshot(...args) {
163
+ return this.snapshotStore.buildIndexSnapshot(...args);
1777
164
  }
1778
165
 
1779
166
  async synchronizeWorkspaceState() {
1780
- await this.workspaceStateSynchronizer?.();
167
+ return this.snapshotStore.synchronizeWorkspaceState();
1781
168
  }
1782
169
 
1783
- removeSnapshotPath(snapshot, filePath) {
1784
- snapshot.rowsByPath.delete(filePath);
1785
- snapshot.forwardLinksByPath.delete(filePath);
1786
- snapshot.backlinksByPath.delete(filePath);
1787
- snapshot.filePaths = snapshot.filePaths.filter((candidatePath) => candidatePath !== filePath);
170
+ removeSnapshotPath(...args) {
171
+ return this.snapshotStore.removeSnapshotPath(...args);
1788
172
  }
1789
173
 
1790
- upsertSnapshotPath(snapshot, filePath) {
1791
- if (snapshot.filePaths.includes(filePath)) {
1792
- return;
1793
- }
1794
-
1795
- snapshot.filePaths.push(filePath);
1796
- snapshot.filePaths.sort(compareVaultPaths);
174
+ upsertSnapshotPath(...args) {
175
+ return this.snapshotStore.upsertSnapshotPath(...args);
1797
176
  }
1798
177
 
1799
- collectImpactedSourcesForMembershipChanges(snapshot, pathValues = []) {
1800
- const affectedTargetKeys = new Set();
1801
- pathValues.forEach((pathValue) => {
1802
- collectWikiTargetKeysForFilePath(pathValue).forEach((targetKey) => {
1803
- affectedTargetKeys.add(targetKey);
1804
- });
1805
- });
1806
-
1807
- if (affectedTargetKeys.size === 0) {
1808
- return new Set();
1809
- }
1810
-
1811
- const impactedPaths = new Set();
1812
- snapshot.rowsByPath.forEach((row, filePath) => {
1813
- const links = row?.file?.links ?? [];
1814
- if (links.some((link) => affectedTargetKeys.has(normalizeWikiTargetKey(link?.rawTarget)))) {
1815
- impactedPaths.add(filePath);
1816
- }
1817
- });
1818
-
1819
- return impactedPaths;
178
+ collectImpactedSourcesForMembershipChanges(...args) {
179
+ return this.snapshotStore.collectImpactedSourcesForMembershipChanges(...args);
1820
180
  }
1821
181
 
1822
- async refreshSnapshotRows(snapshot, workspaceState, pathValues = []) {
1823
- const filePathsToRefresh = Array.from(new Set(pathValues))
1824
- .filter((pathValue) => {
1825
- const entry = workspaceState?.entries?.get(pathValue);
1826
- return pathValue && isWorkspaceFileEntry(entry);
1827
- });
1828
-
1829
- await mapWithConcurrency(filePathsToRefresh, INDEX_READ_CONCURRENCY, async (filePath) => {
1830
- const markdownContent = isMarkdownFilePath(filePath)
1831
- ? await this.vaultFileStore.readMarkdownFile(filePath)
1832
- : null;
1833
- const rowRecord = this.createSnapshotRow(filePath, workspaceState, snapshot.wikiTargetIndex, markdownContent);
1834
- snapshot.rowsByPath.set(filePath, rowRecord.row);
1835
- snapshot.forwardLinksByPath.set(filePath, rowRecord.forwardLinks);
1836
- });
182
+ async refreshSnapshotRows(...args) {
183
+ return this.snapshotStore.refreshSnapshotRows(...args);
1837
184
  }
1838
185
 
1839
- async ensureIndexSnapshot({ basePath = '', sourcePath = '' } = {}) {
1840
- await this.synchronizeWorkspaceState();
1841
- let workspaceState = await this.getWorkspaceState();
1842
- const requiresFreshScan = [basePath, sourcePath]
1843
- .filter(Boolean)
1844
- .some((pathValue) => !workspaceState?.entries?.has?.(pathValue));
1845
- if (requiresFreshScan) {
1846
- workspaceState = await this.vaultFileStore.scanWorkspaceState();
1847
- this.lastWorkspaceState = workspaceState;
1848
- if (this.indexSnapshot?.scannedAt !== workspaceState?.scannedAt) {
1849
- this.indexSnapshot = null;
1850
- }
1851
- }
1852
-
1853
- if (this.indexSnapshot?.scannedAt === workspaceState?.scannedAt) {
1854
- return this.indexSnapshot;
1855
- }
1856
-
1857
- return this.buildIndexSnapshot(workspaceState);
186
+ async ensureIndexSnapshot(...args) {
187
+ return this.snapshotStore.ensureIndexSnapshot(...args);
1858
188
  }
1859
189
 
1860
- async initializeFromWorkspaceState(workspaceState = null) {
1861
- this.lastWorkspaceState = workspaceState ?? null;
1862
- if (this.indexSnapshot && workspaceState && this.indexSnapshot.scannedAt !== workspaceState.scannedAt) {
1863
- this.indexSnapshot = null;
1864
- }
1865
- return this.indexSnapshot;
190
+ async initializeFromWorkspaceState(...args) {
191
+ return this.snapshotStore.initializeFromWorkspaceState(...args);
1866
192
  }
1867
193
 
1868
- async applyWorkspaceChange(workspaceChange = {}, {
1869
- previousState = null,
1870
- nextState = null,
1871
- } = {}) {
1872
- this.lastWorkspaceState = nextState ?? this.lastWorkspaceState;
1873
- if (!this.indexSnapshot) {
1874
- return null;
1875
- }
1876
-
1877
- if (!previousState || this.indexSnapshot.scannedAt !== previousState.scannedAt) {
1878
- this.indexSnapshot = null;
1879
- return null;
1880
- }
1881
-
1882
- const changedPaths = Array.from(new Set(workspaceChange.changedPaths ?? []));
1883
- const deletedFilePaths = (workspaceChange.deletedPaths ?? []).filter((pathValue) => (
1884
- isWorkspaceFileEntry(previousState?.entries?.get(pathValue))
1885
- ));
1886
- const renamedFileEntries = (workspaceChange.renamedPaths ?? []).filter((entry) => (
1887
- isWorkspaceFileEntry(previousState?.entries?.get(entry?.oldPath))
1888
- || isWorkspaceFileEntry(nextState?.entries?.get(entry?.newPath))
1889
- ));
1890
- const createdFilePaths = changedPaths.filter((pathValue) => (
1891
- isWorkspaceFileEntry(nextState?.entries?.get(pathValue))
1892
- && !isWorkspaceFileEntry(previousState?.entries?.get(pathValue))
1893
- ));
1894
- const removedChangedFilePaths = changedPaths.filter((pathValue) => (
1895
- !isWorkspaceFileEntry(nextState?.entries?.get(pathValue))
1896
- && isWorkspaceFileEntry(previousState?.entries?.get(pathValue))
1897
- ));
1898
- const membershipChanged = deletedFilePaths.length > 0
1899
- || renamedFileEntries.length > 0
1900
- || createdFilePaths.length > 0
1901
- || removedChangedFilePaths.length > 0;
1902
-
1903
- const markdownPathsToRefresh = changedPaths.filter((pathValue) => (
1904
- isWorkspaceFileEntry(previousState?.entries?.get(pathValue))
1905
- && isWorkspaceFileEntry(nextState?.entries?.get(pathValue))
1906
- ));
1907
-
1908
- if (!membershipChanged && markdownPathsToRefresh.length === 0) {
1909
- this.indexSnapshot.workspaceState = nextState ?? this.indexSnapshot.workspaceState;
1910
- this.indexSnapshot.scannedAt = nextState?.scannedAt ?? this.indexSnapshot.scannedAt;
1911
- return this.indexSnapshot;
1912
- }
1913
-
1914
- const snapshot = this.indexSnapshot;
1915
- if (membershipChanged) {
1916
- const membershipAffectedPaths = [
1917
- ...deletedFilePaths,
1918
- ...removedChangedFilePaths,
1919
- ...createdFilePaths,
1920
- ...renamedFileEntries.flatMap((entry) => [entry.oldPath, entry.newPath]),
1921
- ];
1922
- const impactedSourcePaths = this.collectImpactedSourcesForMembershipChanges(snapshot, membershipAffectedPaths);
1923
-
1924
- [...deletedFilePaths, ...removedChangedFilePaths].forEach((pathValue) => {
1925
- this.removeSnapshotPath(snapshot, pathValue);
1926
- });
1927
- renamedFileEntries.forEach((entry) => {
1928
- this.removeSnapshotPath(snapshot, entry.oldPath);
1929
- });
1930
- [...createdFilePaths, ...renamedFileEntries.map((entry) => entry.newPath)].forEach((pathValue) => {
1931
- const nextEntry = nextState?.entries?.get(pathValue);
1932
- if (isWorkspaceFileEntry(nextEntry)) {
1933
- this.upsertSnapshotPath(snapshot, pathValue);
1934
- }
1935
- });
1936
-
1937
- snapshot.wikiTargetIndex = createWikiTargetIndex(snapshot.filePaths);
1938
- await this.refreshSnapshotRows(snapshot, nextState, [
1939
- ...markdownPathsToRefresh,
1940
- ...createdFilePaths,
1941
- ...renamedFileEntries.map((entry) => entry.newPath),
1942
- ...impactedSourcePaths,
1943
- ]);
194
+ async applyWorkspaceChange(...args) {
195
+ const result = await this.snapshotStore.applyWorkspaceChange(...args);
196
+ const scannedAt = this.snapshotStore.indexSnapshot?.scannedAt;
197
+ if (scannedAt) {
198
+ this.invalidatePropertyCatalogsExcept(scannedAt);
1944
199
  } else {
1945
- await this.refreshSnapshotRows(snapshot, nextState, markdownPathsToRefresh);
200
+ this.propertyCatalogCache.clear();
1946
201
  }
202
+ return result;
203
+ }
1947
204
 
1948
- snapshot.workspaceState = nextState;
1949
- snapshot.scannedAt = nextState?.scannedAt ?? snapshot.scannedAt;
1950
- this.rebuildBacklinks(snapshot);
1951
- return snapshot;
205
+ invalidatePropertyCatalogsExcept(scannedAt = '') {
206
+ Array.from(this.propertyCatalogCache.keys()).forEach((cacheKey) => {
207
+ if (cacheKey !== scannedAt) {
208
+ this.propertyCatalogCache.delete(cacheKey);
209
+ }
210
+ });
1952
211
  }
1953
212
 
1954
- async query({
213
+ async resolveQueryContext({
1955
214
  activeFilePath = '',
1956
215
  basePath = '',
1957
- search = '',
1958
216
  source = null,
1959
217
  sourcePath = '',
1960
218
  view: requestedView = '',
@@ -1969,128 +227,195 @@ export class BaseQueryService {
1969
227
  basePath,
1970
228
  sourcePath,
1971
229
  });
230
+ this.invalidatePropertyCatalogsExcept(snapshot?.scannedAt ?? '');
231
+
1972
232
  const thisFilePath = sourcePath || activeFilePath || basePath || '';
1973
233
  const thisFile = snapshot.rowsByPath.get(thisFilePath)?.file ?? null;
1974
- const activeView = definition.views.find((entry) => entry.name === requestedView || entry.id === requestedView) ?? definition.views[0];
234
+ const activeView = findView(definition, requestedView);
1975
235
  const columns = buildColumns(definition, activeView);
1976
236
  const evaluatedPropertyIds = collectEvaluatedPropertyIds(columns, activeView);
1977
237
 
1978
- let rows = snapshot.filePaths
1979
- .map((filePath) => snapshot.rowsByPath.get(filePath))
1980
- .filter(Boolean)
1981
- .filter((row) => {
1982
- const globalContext = createEvaluationRootContext({
1983
- currentRow: row,
1984
- definition,
1985
- snapshot,
1986
- thisFile,
1987
- });
1988
- if (!evaluateFilterNode(definition.filters, globalContext)) {
1989
- return false;
1990
- }
238
+ return {
239
+ activeView,
240
+ basePath,
241
+ baseSource,
242
+ columns,
243
+ definition,
244
+ evaluatedPropertyIds,
245
+ snapshot,
246
+ sourcePath,
247
+ thisFile,
248
+ };
249
+ }
250
+
251
+ collectCandidateRows({
252
+ activeView,
253
+ astCache = new Map(),
254
+ definition,
255
+ evaluatedPropertyIds,
256
+ snapshot,
257
+ thisFile,
258
+ }) {
259
+ const rows = [];
260
+
261
+ snapshot.filePaths.forEach((filePath) => {
262
+ const row = snapshot.rowsByPath.get(filePath);
263
+ if (!row) {
264
+ return;
265
+ }
266
+
267
+ const rowContext = this.createRowQueryContext({
268
+ astCache,
269
+ definition,
270
+ row,
271
+ snapshot,
272
+ thisFile,
273
+ });
274
+ if (!evaluateFilterNode(definition.filters, rowContext) || !evaluateFilterNode(activeView.filters, rowContext)) {
275
+ return;
276
+ }
1991
277
 
1992
- const viewContext = createEvaluationRootContext({
1993
- currentRow: row,
1994
- definition,
1995
- snapshot,
1996
- thisFile,
1997
- });
1998
- return evaluateFilterNode(activeView.filters, viewContext);
1999
- })
2000
- .map((row) => {
2001
- const rawCells = {};
2002
- evaluatedPropertyIds.forEach((propertyId) => {
2003
- rawCells[propertyId] = getPropertyValue(propertyId, row, definition, snapshot, thisFile);
2004
- });
2005
- return {
2006
- file: row.file,
2007
- rawCells,
2008
- };
278
+ const rawCells = {};
279
+ evaluatedPropertyIds.forEach((propertyId) => {
280
+ rawCells[propertyId] = getPropertyValue(propertyId, row, definition, snapshot, thisFile, rowContext);
2009
281
  });
2010
282
 
2011
- activeView.sort.forEach((sortConfig) => {
2012
- rows = rows.slice().sort((left, right) => {
2013
- const delta = compareValues(left.rawCells[sortConfig.property], right.rawCells[sortConfig.property]);
2014
- return sortConfig.direction === 'desc' ? -delta : delta;
283
+ rows.push({
284
+ file: row.file,
285
+ rawCells,
2015
286
  });
2016
287
  });
2017
288
 
2018
- if (activeView.limit != null) {
2019
- rows = rows.slice(0, activeView.limit);
2020
- }
289
+ return rows;
290
+ }
2021
291
 
2022
- rows = rows.filter((row) => rowMatchesSearch(row, columns, search));
2023
-
2024
- const groupsByKey = new Map();
2025
- if (activeView.groupBy) {
2026
- rows.forEach((row) => {
2027
- const groupValue = row.rawCells[activeView.groupBy];
2028
- const groupKey = toDisplayText(groupValue) || 'Empty';
2029
- const entry = groupsByKey.get(groupKey) ?? {
2030
- key: groupKey,
2031
- label: groupKey,
2032
- rows: [],
2033
- value: serializeBaseValue(groupValue, {
2034
- rowFilePath: row.file.path,
2035
- snapshot,
2036
- sourcePath: thisFile?.path ?? '',
2037
- }),
2038
- };
2039
- entry.rows.push(row);
2040
- groupsByKey.set(groupKey, entry);
2041
- });
2042
- } else {
2043
- groupsByKey.set('All', {
2044
- key: 'All',
2045
- label: 'All',
2046
- rows,
2047
- value: serializeBaseValue(null),
2048
- });
292
+ createRowQueryContext({
293
+ astCache = new Map(),
294
+ definition,
295
+ row,
296
+ snapshot,
297
+ thisFile,
298
+ }) {
299
+ return createEvaluationRootContext({
300
+ astCache,
301
+ currentRow: row,
302
+ definition,
303
+ snapshot,
304
+ thisFile,
305
+ });
306
+ }
307
+
308
+ getPropertyCatalog(snapshot) {
309
+ const cacheKey = snapshot?.scannedAt ?? 'unknown';
310
+ if (!this.propertyCatalogCache.has(cacheKey)) {
311
+ this.propertyCatalogCache.set(cacheKey, createPropertyCatalogSnapshot(snapshot));
2049
312
  }
2050
313
 
2051
- const serializedRows = rows.map((row) => ({
2052
- cells: columns.reduce((acc, column) => {
2053
- acc[column.id] = serializeBaseValue(row.rawCells[column.id], {
2054
- rowFilePath: row.file.path,
2055
- snapshot,
2056
- sourcePath: thisFile?.path ?? '',
2057
- });
2058
- return acc;
2059
- }, {}),
2060
- file: serializeBaseValue(row.file, {
2061
- rowFilePath: row.file.path,
2062
- snapshot,
2063
- sourcePath: thisFile?.path ?? '',
314
+ return this.propertyCatalogCache.get(cacheKey);
315
+ }
316
+
317
+ inferFormulaValueTypes(definition, rows = []) {
318
+ const valueTypes = {};
319
+
320
+ Object.keys(definition.formulas ?? {}).forEach((propertyId) => {
321
+ let inferred = 'unknown';
322
+ for (const row of rows) {
323
+ inferred = inferValueType(row?.rawCells?.[propertyId]);
324
+ if (inferred !== 'unknown') {
325
+ break;
326
+ }
327
+ }
328
+ valueTypes[propertyId] = inferred;
329
+ });
330
+
331
+ return valueTypes;
332
+ }
333
+
334
+ buildMeta({
335
+ activeView,
336
+ basePath = '',
337
+ columns,
338
+ definition,
339
+ rows,
340
+ snapshot,
341
+ }) {
342
+ const propertyCatalog = this.getPropertyCatalog(snapshot);
343
+ const formulaValueTypes = this.inferFormulaValueTypes(definition, rows);
344
+
345
+ return {
346
+ activeViewConfig: {
347
+ filters: activeView.filters ?? null,
348
+ groupBy: activeView.groupBy ?? null,
349
+ order: [...(activeView.order ?? [])],
350
+ sort: (activeView.sort ?? []).map((entry) => ({ ...entry })),
351
+ },
352
+ availableProperties: buildAvailableProperties({
353
+ activeView,
354
+ columns,
355
+ definition,
356
+ formulaValueTypes,
357
+ propertyCatalog,
2064
358
  }),
2065
- path: row.file.path,
2066
- }));
2067
- const rowsByPath = new Map(serializedRows.map((row) => [row.path, row]));
2068
-
2069
- const groups = Array.from(groupsByKey.values()).map((group) => ({
2070
- key: group.key,
2071
- label: group.label,
2072
- summaries: createSummaryPayload(columns, group.rows, activeView.summaries, definition, snapshot, thisFile),
2073
- value: group.value,
2074
- rows: group.rows.map((row) => rowsByPath.get(row.file.path)),
2075
- }));
359
+ editable: Boolean(basePath),
360
+ };
361
+ }
362
+
363
+ async query({
364
+ activeFilePath = '',
365
+ basePath = '',
366
+ includeCsv = false,
367
+ search = '',
368
+ source = null,
369
+ sourcePath = '',
370
+ view: requestedView = '',
371
+ } = {}) {
372
+ const context = await this.resolveQueryContext({
373
+ activeFilePath,
374
+ basePath,
375
+ source,
376
+ sourcePath,
377
+ view: requestedView,
378
+ });
379
+
380
+ const astCache = new Map();
381
+ let rows = this.collectCandidateRows({
382
+ ...context,
383
+ astCache,
384
+ });
385
+ rows = rows.filter((row) => rowMatchesSearch(row, context.columns, search));
386
+ rows = sortRows(rows, buildSortChain(context.activeView));
387
+
388
+ if (context.activeView.limit != null) {
389
+ rows = rows.slice(0, context.activeView.limit);
390
+ }
391
+
392
+ const payload = buildQueryResultPayload({
393
+ activeView: context.activeView,
394
+ columns: context.columns,
395
+ definition: context.definition,
396
+ includeCsv,
397
+ rows,
398
+ snapshot: context.snapshot,
399
+ thisFile: context.thisFile,
400
+ });
2076
401
 
2077
402
  return {
2078
- columns,
2079
- csv: createCsv(rows, columns),
2080
- definition,
2081
- groups,
2082
- rows: serializedRows,
2083
- summaries: createSummaryPayload(columns, rows, activeView.summaries, definition, snapshot, thisFile),
2084
- thisFile: serializeBaseValue(thisFile, {
2085
- snapshot,
2086
- sourcePath: thisFile?.path ?? '',
403
+ columns: context.columns,
404
+ definition: context.definition,
405
+ meta: this.buildMeta({
406
+ activeView: context.activeView,
407
+ basePath,
408
+ columns: context.columns,
409
+ definition: context.definition,
410
+ rows,
411
+ snapshot: context.snapshot,
2087
412
  }),
2088
- totalRows: rows.length,
413
+ ...payload,
2089
414
  view: {
2090
- ...activeView,
2091
- supported: activeView.supported,
415
+ ...context.activeView,
416
+ supported: context.activeView.supported,
2092
417
  },
2093
- views: definition.views.map((view) => ({
418
+ views: context.definition.views.map((view) => ({
2094
419
  id: view.id,
2095
420
  name: view.name,
2096
421
  supported: view.supported,
@@ -2098,4 +423,93 @@ export class BaseQueryService {
2098
423
  })),
2099
424
  };
2100
425
  }
426
+
427
+ async propertyValues({
428
+ activeFilePath = '',
429
+ basePath = '',
430
+ propertyId = '',
431
+ query = '',
432
+ source = null,
433
+ sourcePath = '',
434
+ view: requestedView = '',
435
+ } = {}) {
436
+ if (!propertyId) {
437
+ throw new Error('Missing propertyId');
438
+ }
439
+
440
+ const context = await this.resolveQueryContext({
441
+ activeFilePath,
442
+ basePath,
443
+ source,
444
+ sourcePath,
445
+ view: requestedView,
446
+ });
447
+ const propertyValueContext = {
448
+ ...context,
449
+ activeView: {
450
+ ...context.activeView,
451
+ filters: pruneFilterNodeForProperty(context.activeView?.filters, propertyId),
452
+ },
453
+ definition: {
454
+ ...context.definition,
455
+ filters: pruneFilterNodeForProperty(context.definition?.filters, propertyId),
456
+ },
457
+ };
458
+ const astCache = new Map();
459
+ const rows = this.collectCandidateRows({
460
+ ...propertyValueContext,
461
+ astCache,
462
+ evaluatedPropertyIds: [...new Set([
463
+ ...(context.evaluatedPropertyIds ?? []),
464
+ propertyId,
465
+ ])],
466
+ });
467
+ const values = collectDistinctPropertyValues(rows, propertyId, query).map((entry) => ({
468
+ count: entry.count,
469
+ text: entry.text,
470
+ value: serializeBaseValue(entry.value, {
471
+ snapshot: context.snapshot,
472
+ sourcePath: context.thisFile?.path ?? '',
473
+ }),
474
+ }));
475
+
476
+ return {
477
+ ok: true,
478
+ propertyId,
479
+ values,
480
+ };
481
+ }
482
+
483
+ async transform({
484
+ activeFilePath = '',
485
+ basePath = '',
486
+ mutation = null,
487
+ source = null,
488
+ sourcePath = '',
489
+ view = '',
490
+ } = {}) {
491
+ if (!basePath) {
492
+ throw new Error('Only .base files can be transformed');
493
+ }
494
+
495
+ const baseSource = source ?? await this.vaultFileStore.readBaseFile(basePath);
496
+ if (typeof baseSource !== 'string') {
497
+ throw new Error('Base source not found');
498
+ }
499
+
500
+ const nextSource = transformBaseSource(baseSource, mutation);
501
+ const result = await this.query({
502
+ activeFilePath,
503
+ basePath,
504
+ source: nextSource,
505
+ sourcePath,
506
+ view,
507
+ });
508
+
509
+ return {
510
+ meta: result.meta,
511
+ result,
512
+ source: nextSource,
513
+ };
514
+ }
2101
515
  }