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
@@ -66,8 +66,8 @@ function extractReferences(markdownText = '', wikiTargetIndex) {
66
66
  return { embeds, links };
67
67
  }
68
68
 
69
- function isWorkspaceFileEntry(entry = {}) {
70
- return entry?.nodeType === 'file' || entry?.type !== 'directory';
69
+ function isWorkspaceFileEntry(entry = null) {
70
+ return Boolean(entry) && (entry.nodeType === 'file' || entry.type !== 'directory');
71
71
  }
72
72
 
73
73
  function listWorkspaceFilePaths(workspaceState = {}) {
@@ -114,6 +114,23 @@ function collectWikiTargetKeysForFilePath(filePath = '') {
114
114
  return [...new Set(keys)];
115
115
  }
116
116
 
117
+ function createBacklinkEntry(sourcePath = '') {
118
+ return createLinkValue(sourcePath, {
119
+ display: basename(sourcePath),
120
+ exists: true,
121
+ });
122
+ }
123
+
124
+ function addRawTargetSourceContribution(snapshot, sourcePath, rawTargetKey) {
125
+ if (!rawTargetKey) {
126
+ return;
127
+ }
128
+
129
+ const sources = snapshot.rawTargetSourcesByKey.get(rawTargetKey) ?? new Set();
130
+ sources.add(sourcePath);
131
+ snapshot.rawTargetSourcesByKey.set(rawTargetKey, sources);
132
+ }
133
+
117
134
  export class BaseIndexSnapshotStore {
118
135
  constructor({
119
136
  vaultFileStore,
@@ -176,6 +193,11 @@ export class BaseIndexSnapshotStore {
176
193
  .map((link) => link?.path)
177
194
  .filter((targetPath) => targetPath && targetPath !== filePath),
178
195
  ),
196
+ rawTargetKeys: new Set(
197
+ references.links
198
+ .map((link) => normalizeWikiTargetKey(link?.rawTarget))
199
+ .filter(Boolean),
200
+ ),
179
201
  row: {
180
202
  file: fileValue,
181
203
  noteProperties,
@@ -185,25 +207,99 @@ export class BaseIndexSnapshotStore {
185
207
  }
186
208
 
187
209
  rebuildBacklinks(snapshot) {
188
- const backlinksByTarget = new Map(snapshot.filePaths.map((filePath) => [filePath, []]));
210
+ snapshot.backlinkSourcesByTarget = new Map();
211
+ snapshot.backlinksByPath = new Map(snapshot.filePaths.map((filePath) => [filePath, []]));
189
212
 
190
213
  snapshot.rowsByPath.forEach((row, filePath) => {
191
214
  const forwardLinks = snapshot.forwardLinksByPath.get(filePath) ?? new Set();
192
215
  forwardLinks.forEach((targetPath) => {
193
- if (!backlinksByTarget.has(targetPath) || targetPath === row.file.path) {
216
+ if (!snapshot.rowsByPath.has(targetPath) || targetPath === row.file.path) {
194
217
  return;
195
218
  }
196
219
 
197
- backlinksByTarget.get(targetPath).push(createLinkValue(row.file.path, {
198
- display: basename(row.file.path),
199
- exists: true,
200
- }));
220
+ const sources = snapshot.backlinkSourcesByTarget.get(targetPath) ?? new Set();
221
+ sources.add(filePath);
222
+ snapshot.backlinkSourcesByTarget.set(targetPath, sources);
201
223
  });
202
224
  });
203
225
 
204
- snapshot.backlinksByPath = backlinksByTarget;
205
- snapshot.rowsByPath.forEach((row, filePath) => {
206
- row.file.backlinks = dedupeLinkValues(backlinksByTarget.get(filePath) ?? []);
226
+ this.refreshSerializedBacklinks(snapshot, snapshot.filePaths);
227
+ }
228
+
229
+ removeSourceBacklinkContributions(snapshot, sourcePath, affectedTargetPaths = new Set()) {
230
+ const forwardLinks = snapshot.forwardLinksByPath.get(sourcePath) ?? new Set();
231
+ forwardLinks.forEach((targetPath) => {
232
+ const sources = snapshot.backlinkSourcesByTarget.get(targetPath);
233
+ if (!sources) {
234
+ return;
235
+ }
236
+
237
+ sources.delete(sourcePath);
238
+ if (sources.size === 0) {
239
+ snapshot.backlinkSourcesByTarget.delete(targetPath);
240
+ }
241
+ affectedTargetPaths.add(targetPath);
242
+ });
243
+ }
244
+
245
+ removeRawTargetSourceContributions(snapshot, sourcePath) {
246
+ const rawTargetKeys = snapshot.rawTargetKeysBySourcePath.get(sourcePath) ?? new Set();
247
+ rawTargetKeys.forEach((rawTargetKey) => {
248
+ const sources = snapshot.rawTargetSourcesByKey.get(rawTargetKey);
249
+ if (!sources) {
250
+ return;
251
+ }
252
+
253
+ sources.delete(sourcePath);
254
+ if (sources.size === 0) {
255
+ snapshot.rawTargetSourcesByKey.delete(rawTargetKey);
256
+ }
257
+ });
258
+ snapshot.rawTargetKeysBySourcePath.delete(sourcePath);
259
+ }
260
+
261
+ addRawTargetSourceContributions(snapshot, sourcePath, rawTargetKeys = new Set()) {
262
+ snapshot.rawTargetKeysBySourcePath.set(sourcePath, rawTargetKeys);
263
+ rawTargetKeys.forEach((rawTargetKey) => {
264
+ addRawTargetSourceContribution(snapshot, sourcePath, rawTargetKey);
265
+ });
266
+ }
267
+
268
+ addSourceBacklinkContributions(snapshot, sourcePath, affectedTargetPaths = new Set()) {
269
+ if (!snapshot.rowsByPath.has(sourcePath)) {
270
+ return;
271
+ }
272
+
273
+ const forwardLinks = snapshot.forwardLinksByPath.get(sourcePath) ?? new Set();
274
+ forwardLinks.forEach((targetPath) => {
275
+ if (!snapshot.rowsByPath.has(targetPath) || targetPath === sourcePath) {
276
+ return;
277
+ }
278
+
279
+ const sources = snapshot.backlinkSourcesByTarget.get(targetPath) ?? new Set();
280
+ sources.add(sourcePath);
281
+ snapshot.backlinkSourcesByTarget.set(targetPath, sources);
282
+ affectedTargetPaths.add(targetPath);
283
+ });
284
+ }
285
+
286
+ refreshSerializedBacklinks(snapshot, targetPaths = []) {
287
+ Array.from(new Set(targetPaths)).forEach((targetPath) => {
288
+ if (!snapshot.rowsByPath.has(targetPath)) {
289
+ snapshot.backlinkSourcesByTarget.delete(targetPath);
290
+ snapshot.backlinksByPath.delete(targetPath);
291
+ return;
292
+ }
293
+
294
+ const sources = snapshot.backlinkSourcesByTarget.get(targetPath) ?? new Set();
295
+ const backlinks = dedupeLinkValues(
296
+ Array.from(sources, (sourcePath) => createBacklinkEntry(sourcePath)),
297
+ );
298
+ snapshot.backlinksByPath.set(targetPath, backlinks);
299
+ const row = snapshot.rowsByPath.get(targetPath);
300
+ if (row?.file) {
301
+ row.file.backlinks = backlinks;
302
+ }
207
303
  });
208
304
  }
209
305
 
@@ -219,17 +315,28 @@ export class BaseIndexSnapshotStore {
219
315
 
220
316
  const rowsByPath = new Map();
221
317
  const forwardLinksByPath = new Map();
318
+ const rawTargetKeysBySourcePath = new Map();
319
+ const rawTargetSourcesByKey = new Map();
222
320
  fileEntries.forEach((filePath) => {
223
321
  const markdownContent = markdownContents.get(filePath) ?? null;
224
322
  const rowRecord = this.createSnapshotRow(filePath, resolvedWorkspaceState, wikiTargetIndex, markdownContent);
225
323
  rowsByPath.set(filePath, rowRecord.row);
226
324
  forwardLinksByPath.set(filePath, rowRecord.forwardLinks);
325
+ rawTargetKeysBySourcePath.set(filePath, rowRecord.rawTargetKeys);
326
+ rowRecord.rawTargetKeys.forEach((rawTargetKey) => {
327
+ addRawTargetSourceContribution({
328
+ rawTargetSourcesByKey,
329
+ }, filePath, rawTargetKey);
330
+ });
227
331
  });
228
332
 
229
333
  const snapshot = {
334
+ backlinkSourcesByTarget: new Map(),
230
335
  backlinksByPath: new Map(),
231
336
  filePaths: fileEntries,
232
337
  forwardLinksByPath,
338
+ rawTargetKeysBySourcePath,
339
+ rawTargetSourcesByKey,
233
340
  rowsByPath,
234
341
  scannedAt: resolvedWorkspaceState.scannedAt,
235
342
  wikiTargetIndex,
@@ -246,8 +353,10 @@ export class BaseIndexSnapshotStore {
246
353
  }
247
354
 
248
355
  removeSnapshotPath(snapshot, filePath) {
356
+ this.removeRawTargetSourceContributions(snapshot, filePath);
249
357
  snapshot.rowsByPath.delete(filePath);
250
358
  snapshot.forwardLinksByPath.delete(filePath);
359
+ snapshot.backlinkSourcesByTarget.delete(filePath);
251
360
  snapshot.backlinksByPath.delete(filePath);
252
361
  snapshot.filePaths = snapshot.filePaths.filter((candidatePath) => candidatePath !== filePath);
253
362
  }
@@ -274,13 +383,12 @@ export class BaseIndexSnapshotStore {
274
383
  }
275
384
 
276
385
  const impactedPaths = new Set();
277
- snapshot.rowsByPath.forEach((row, filePath) => {
278
- const links = row?.file?.links ?? [];
279
- if (links.some((link) => affectedTargetKeys.has(normalizeWikiTargetKey(link?.rawTarget)))) {
386
+ affectedTargetKeys.forEach((targetKey) => {
387
+ const sourcePaths = snapshot.rawTargetSourcesByKey.get(targetKey);
388
+ sourcePaths?.forEach((filePath) => {
280
389
  impactedPaths.add(filePath);
281
- }
390
+ });
282
391
  });
283
-
284
392
  return impactedPaths;
285
393
  }
286
394
 
@@ -296,8 +404,10 @@ export class BaseIndexSnapshotStore {
296
404
  ? await this.vaultFileStore.readMarkdownFile(filePath)
297
405
  : null;
298
406
  const rowRecord = this.createSnapshotRow(filePath, workspaceState, snapshot.wikiTargetIndex, markdownContent);
407
+ this.removeRawTargetSourceContributions(snapshot, filePath);
299
408
  snapshot.rowsByPath.set(filePath, rowRecord.row);
300
409
  snapshot.forwardLinksByPath.set(filePath, rowRecord.forwardLinks);
410
+ this.addRawTargetSourceContributions(snapshot, filePath, rowRecord.rawTargetKeys);
301
411
  });
302
412
  }
303
413
 
@@ -377,6 +487,8 @@ export class BaseIndexSnapshotStore {
377
487
  }
378
488
 
379
489
  const snapshot = this.indexSnapshot;
490
+ const affectedTargetPaths = new Set();
491
+ const affectedSourcePaths = new Set(markdownPathsToRefresh);
380
492
  if (membershipChanged) {
381
493
  const membershipAffectedPaths = [
382
494
  ...deletedFilePaths,
@@ -385,6 +497,33 @@ export class BaseIndexSnapshotStore {
385
497
  ...renamedFileEntries.flatMap((entry) => [entry.oldPath, entry.newPath]),
386
498
  ];
387
499
  const impactedSourcePaths = this.collectImpactedSourcesForMembershipChanges(snapshot, membershipAffectedPaths);
500
+ impactedSourcePaths.forEach((pathValue) => affectedSourcePaths.add(pathValue));
501
+ [...deletedFilePaths, ...removedChangedFilePaths].forEach((pathValue) => {
502
+ if (isMarkdownFilePath(pathValue)) {
503
+ affectedSourcePaths.add(pathValue);
504
+ }
505
+ affectedTargetPaths.add(pathValue);
506
+ });
507
+ renamedFileEntries.forEach((entry) => {
508
+ if (isMarkdownFilePath(entry.oldPath)) {
509
+ affectedSourcePaths.add(entry.oldPath);
510
+ }
511
+ if (isMarkdownFilePath(entry.newPath)) {
512
+ affectedSourcePaths.add(entry.newPath);
513
+ }
514
+ affectedTargetPaths.add(entry.oldPath);
515
+ affectedTargetPaths.add(entry.newPath);
516
+ });
517
+ createdFilePaths.forEach((pathValue) => {
518
+ if (isMarkdownFilePath(pathValue)) {
519
+ affectedSourcePaths.add(pathValue);
520
+ }
521
+ affectedTargetPaths.add(pathValue);
522
+ });
523
+
524
+ affectedSourcePaths.forEach((pathValue) => {
525
+ this.removeSourceBacklinkContributions(snapshot, pathValue, affectedTargetPaths);
526
+ });
388
527
 
389
528
  [...deletedFilePaths, ...removedChangedFilePaths].forEach((pathValue) => {
390
529
  this.removeSnapshotPath(snapshot, pathValue);
@@ -401,18 +540,26 @@ export class BaseIndexSnapshotStore {
401
540
 
402
541
  snapshot.wikiTargetIndex = createWikiTargetIndex(snapshot.filePaths);
403
542
  await this.refreshSnapshotRows(snapshot, nextState, [
404
- ...markdownPathsToRefresh,
405
543
  ...createdFilePaths,
406
544
  ...renamedFileEntries.map((entry) => entry.newPath),
407
- ...impactedSourcePaths,
545
+ ...affectedSourcePaths,
408
546
  ]);
409
547
  } else {
410
- await this.refreshSnapshotRows(snapshot, nextState, markdownPathsToRefresh);
548
+ affectedSourcePaths.forEach((pathValue) => {
549
+ this.removeSourceBacklinkContributions(snapshot, pathValue, affectedTargetPaths);
550
+ });
551
+ await this.refreshSnapshotRows(snapshot, nextState, [...affectedSourcePaths]);
411
552
  }
412
553
 
554
+ affectedSourcePaths.forEach((pathValue) => {
555
+ this.addSourceBacklinkContributions(snapshot, pathValue, affectedTargetPaths);
556
+ });
413
557
  snapshot.workspaceState = nextState;
414
558
  snapshot.scannedAt = nextState?.scannedAt ?? snapshot.scannedAt;
415
- this.rebuildBacklinks(snapshot);
559
+ this.refreshSerializedBacklinks(snapshot, [
560
+ ...snapshot.filePaths.filter((filePath) => !snapshot.backlinksByPath.has(filePath)),
561
+ ...affectedTargetPaths,
562
+ ]);
416
563
  return snapshot;
417
564
  }
418
565
  }
@@ -0,0 +1,242 @@
1
+ import { isImageAttachmentFilePath } from '../../../domain/file-kind.js';
2
+ import { parseDateValue, toDisplayText } from './base-expression-runtime.js';
3
+
4
+ const FILE_PROPERTY_DEFINITIONS = [
5
+ { id: 'file.name', kind: 'file', label: 'file name', valueType: 'text' },
6
+ { id: 'file.basename', kind: 'file', label: 'file base name', valueType: 'text' },
7
+ { id: 'file.backlinks', kind: 'file', label: 'file backlinks', valueType: 'list' },
8
+ { id: 'file.ctime', kind: 'file', label: 'created time', valueType: 'date' },
9
+ { id: 'file.embeds', kind: 'file', label: 'file embeds', valueType: 'list' },
10
+ { id: 'file.ext', kind: 'file', label: 'file extension', valueType: 'text' },
11
+ { id: 'file.folder', kind: 'file', label: 'folder', valueType: 'text' },
12
+ { id: 'file.links', kind: 'file', label: 'file links', valueType: 'list' },
13
+ { id: 'file.mtime', kind: 'file', label: 'modified time', valueType: 'date' },
14
+ { id: 'file.path', kind: 'file', label: 'file path', valueType: 'text' },
15
+ { id: 'file.size', kind: 'file', label: 'file size', valueType: 'number' },
16
+ { id: 'file.tags', kind: 'file', label: 'file tags', valueType: 'list' },
17
+ ];
18
+
19
+ function simplifyType(type) {
20
+ if (!type || type === 'unknown') {
21
+ return 'unknown';
22
+ }
23
+
24
+ return type;
25
+ }
26
+
27
+ function mergeTypes(left = 'unknown', right = 'unknown') {
28
+ if (left === 'unknown') {
29
+ return right;
30
+ }
31
+ if (right === 'unknown' || left === right) {
32
+ return left;
33
+ }
34
+
35
+ const scalarTypes = new Set(['boolean', 'date', 'number', 'text', 'image', 'link']);
36
+ if (scalarTypes.has(left) && scalarTypes.has(right)) {
37
+ if ((left === 'image' && right === 'text') || (left === 'text' && right === 'image')) {
38
+ return 'image';
39
+ }
40
+ if ((left === 'link' && right === 'text') || (left === 'text' && right === 'link')) {
41
+ return 'link';
42
+ }
43
+ return 'text';
44
+ }
45
+
46
+ if (left === 'list' || right === 'list') {
47
+ return 'list';
48
+ }
49
+
50
+ return 'unknown';
51
+ }
52
+
53
+ export function inferValueType(value) {
54
+ if (value == null || value === '') {
55
+ return 'unknown';
56
+ }
57
+
58
+ if (Array.isArray(value)) {
59
+ return 'list';
60
+ }
61
+
62
+ if (value instanceof Date) {
63
+ return 'date';
64
+ }
65
+
66
+ if (value?.__baseType === 'link') {
67
+ return 'link';
68
+ }
69
+
70
+ if (value?.__baseType === 'file') {
71
+ return 'link';
72
+ }
73
+
74
+ if (typeof value === 'boolean') {
75
+ return 'boolean';
76
+ }
77
+
78
+ if (typeof value === 'number') {
79
+ return 'number';
80
+ }
81
+
82
+ if (typeof value === 'string') {
83
+ if (isImageAttachmentFilePath(value)) {
84
+ return 'image';
85
+ }
86
+
87
+ if (parseDateValue(value)) {
88
+ return 'date';
89
+ }
90
+
91
+ return 'text';
92
+ }
93
+
94
+ return 'unknown';
95
+ }
96
+
97
+ function createSortDirections(valueType = 'text') {
98
+ switch (valueType) {
99
+ case 'date':
100
+ return [
101
+ { id: 'asc', label: 'Old to new' },
102
+ { id: 'desc', label: 'New to old' },
103
+ ];
104
+ case 'number':
105
+ return [
106
+ { id: 'asc', label: '0 → 1' },
107
+ { id: 'desc', label: '1 → 0' },
108
+ ];
109
+ default:
110
+ return [
111
+ { id: 'asc', label: 'A → Z' },
112
+ { id: 'desc', label: 'Z → A' },
113
+ ];
114
+ }
115
+ }
116
+
117
+ function createFilterOperators(valueType = 'text') {
118
+ if (valueType === 'boolean') {
119
+ return ['is', 'is not', 'is empty', 'is not empty'];
120
+ }
121
+
122
+ if (valueType === 'number' || valueType === 'date') {
123
+ return ['is', 'is not', '>', '>=', '<', '<=', 'is empty', 'is not empty'];
124
+ }
125
+
126
+ if (valueType === 'list') {
127
+ return ['contains', 'does not contain', 'is empty', 'is not empty'];
128
+ }
129
+
130
+ return ['is', 'is not', 'contains', 'does not contain', 'starts with', 'ends with', 'is empty', 'is not empty'];
131
+ }
132
+
133
+ export function createPropertyCatalogSnapshot(snapshot) {
134
+ const noteProperties = new Map();
135
+
136
+ snapshot?.rowsByPath?.forEach((row) => {
137
+ Object.entries(row?.noteProperties ?? {}).forEach(([propertyName, value]) => {
138
+ const propertyId = `note.${propertyName}`;
139
+ const current = noteProperties.get(propertyId) ?? {
140
+ id: propertyId,
141
+ kind: 'note',
142
+ label: propertyName,
143
+ valueType: 'unknown',
144
+ };
145
+ current.valueType = mergeTypes(current.valueType, inferValueType(value));
146
+ noteProperties.set(propertyId, current);
147
+ });
148
+ });
149
+
150
+ return {
151
+ fileProperties: FILE_PROPERTY_DEFINITIONS.map((entry) => ({ ...entry })),
152
+ noteProperties: Array.from(noteProperties.values()).sort((left, right) => left.label.localeCompare(right.label)),
153
+ scannedAt: snapshot?.scannedAt ?? '',
154
+ };
155
+ }
156
+
157
+ export function buildAvailableProperties({
158
+ activeView,
159
+ columns = [],
160
+ definition,
161
+ formulaValueTypes = {},
162
+ propertyCatalog,
163
+ }) {
164
+ const visibleIds = new Set(
165
+ Array.isArray(activeView?.order) && activeView.order.length > 0
166
+ ? activeView.order
167
+ : columns.map((column) => column.id),
168
+ );
169
+ const baseProperties = [
170
+ ...(propertyCatalog?.fileProperties ?? []),
171
+ ...(propertyCatalog?.noteProperties ?? []),
172
+ ];
173
+
174
+ const formulaProperties = Object.values(definition?.formulas ?? {}).map((formula) => ({
175
+ id: formula.id,
176
+ kind: 'formula',
177
+ label: definition?.properties?.[formula.id]?.displayName ?? formula.name,
178
+ valueType: simplifyType(formulaValueTypes[formula.id] ?? 'unknown'),
179
+ }));
180
+
181
+ return [...baseProperties, ...formulaProperties]
182
+ .map((property) => {
183
+ const valueType = simplifyType(property.valueType);
184
+ return {
185
+ filterOperators: createFilterOperators(valueType),
186
+ groupable: true,
187
+ id: property.id,
188
+ kind: property.kind,
189
+ label: property.label,
190
+ sortable: true,
191
+ sortDirections: createSortDirections(valueType),
192
+ valueType,
193
+ visible: visibleIds.has(property.id),
194
+ };
195
+ })
196
+ .sort((left, right) => left.label.localeCompare(right.label));
197
+ }
198
+
199
+ function flattenValues(value) {
200
+ if (Array.isArray(value)) {
201
+ return value.flatMap((entry) => flattenValues(entry));
202
+ }
203
+
204
+ if (value == null || value === '') {
205
+ return [];
206
+ }
207
+
208
+ return [value];
209
+ }
210
+
211
+ export function collectDistinctPropertyValues(rows = [], propertyId = '', query = '') {
212
+ const counts = new Map();
213
+ const normalizedQuery = String(query ?? '').trim().toLowerCase();
214
+
215
+ rows.forEach((row) => {
216
+ flattenValues(row?.rawCells?.[propertyId]).forEach((value) => {
217
+ const text = toDisplayText(value);
218
+ if (!text) {
219
+ return;
220
+ }
221
+
222
+ if (normalizedQuery && !text.toLowerCase().includes(normalizedQuery)) {
223
+ return;
224
+ }
225
+
226
+ const entry = counts.get(text) ?? {
227
+ count: 0,
228
+ text,
229
+ value,
230
+ };
231
+ entry.count += 1;
232
+ counts.set(text, entry);
233
+ });
234
+ });
235
+
236
+ return Array.from(counts.values())
237
+ .sort((left, right) => (
238
+ right.count - left.count
239
+ || left.text.localeCompare(right.text)
240
+ ))
241
+ .slice(0, 100);
242
+ }
@@ -123,10 +123,10 @@ export function rowMatchesSearch(row, columns, query) {
123
123
  return true;
124
124
  }
125
125
 
126
- const haystack = [
127
- row.file.path,
128
- ...columns.map((column) => toDisplayText(row.rawCells[column.id])),
129
- ].join('\n').toLowerCase();
126
+ const haystack = columns
127
+ .map((column) => toDisplayText(row.rawCells[column.id]))
128
+ .join('\n')
129
+ .toLowerCase();
130
130
  return haystack.includes(normalizedQuery);
131
131
  }
132
132
 
@@ -134,18 +134,20 @@ export function buildQueryResultPayload({
134
134
  activeView,
135
135
  columns,
136
136
  definition,
137
+ includeCsv = false,
137
138
  rows,
138
139
  snapshot,
139
140
  thisFile,
140
141
  }) {
141
142
  const groupsByKey = new Map();
142
- if (activeView.groupBy) {
143
+ if (activeView.groupBy?.property) {
143
144
  rows.forEach((row) => {
144
- const groupValue = row.rawCells[activeView.groupBy];
145
+ const groupValue = row.rawCells[activeView.groupBy.property];
145
146
  const groupKey = toDisplayText(groupValue) || 'Empty';
146
147
  const entry = groupsByKey.get(groupKey) ?? {
147
148
  key: groupKey,
148
149
  label: groupKey,
150
+ rawValue: groupValue,
149
151
  rows: [],
150
152
  value: serializeBaseValue(groupValue, {
151
153
  rowFilePath: row.file.path,
@@ -183,7 +185,15 @@ export function buildQueryResultPayload({
183
185
  }));
184
186
  const rowsByPath = new Map(serializedRows.map((row) => [row.path, row]));
185
187
 
186
- const groups = Array.from(groupsByKey.values()).map((group) => ({
188
+ let groups = Array.from(groupsByKey.values());
189
+ if (activeView.groupBy?.explicitDirection) {
190
+ groups = groups.sort((left, right) => {
191
+ const delta = compareValues(left.rawValue, right.rawValue);
192
+ return activeView.groupBy.direction === 'desc' ? -delta : delta;
193
+ });
194
+ }
195
+
196
+ groups = groups.map((group) => ({
187
197
  key: group.key,
188
198
  label: group.label,
189
199
  summaries: createSummaryPayload(columns, group.rows, activeView.summaries, definition, snapshot, thisFile),
@@ -192,7 +202,7 @@ export function buildQueryResultPayload({
192
202
  }));
193
203
 
194
204
  return {
195
- csv: createCsv(rows, columns),
205
+ ...(includeCsv ? { csv: createCsv(rows, columns) } : {}),
196
206
  groups,
197
207
  rows: serializedRows,
198
208
  summaries: createSummaryPayload(columns, rows, activeView.summaries, definition, snapshot, thisFile),