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
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { createWikiTargetIndex, resolveWikiTargetWithIndex } from '../../domain/wiki-link-resolver.js';
14
- import { isMarkdownFilePath } from '../../domain/file-kind.js';
14
+ import { getVaultFileExtension, isMarkdownFilePath } from '../../domain/file-kind.js';
15
15
  import { mapWithConcurrency } from '../shared/async-utils.js';
16
16
 
17
17
  const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
@@ -27,6 +27,39 @@ function createDeferred() {
27
27
  return { promise, reject, resolve };
28
28
  }
29
29
 
30
+ function normalizeWikiTargetKey(target = '') {
31
+ const normalizedTarget = String(target ?? '').trim();
32
+ if (!normalizedTarget) {
33
+ return '';
34
+ }
35
+
36
+ return getVaultFileExtension(normalizedTarget)
37
+ ? normalizedTarget
38
+ : `${normalizedTarget}.md`;
39
+ }
40
+
41
+ function collectWikiTargetKeysForFilePath(filePath = '') {
42
+ const normalizedPath = String(filePath ?? '').trim();
43
+ if (!normalizedPath) {
44
+ return [];
45
+ }
46
+
47
+ const segments = normalizedPath.split('/').filter(Boolean);
48
+ const keys = [];
49
+ for (let index = 0; index < segments.length; index += 1) {
50
+ keys.push(segments.slice(index).join('/'));
51
+ }
52
+
53
+ if (isMarkdownFilePath(normalizedPath)) {
54
+ const rawSegments = normalizedPath.replace(/\.md$/i, '').split('/').filter(Boolean);
55
+ for (let index = 0; index < rawSegments.length; index += 1) {
56
+ keys.push(rawSegments.slice(index).join('/'));
57
+ }
58
+ }
59
+
60
+ return [...new Set(keys)];
61
+ }
62
+
30
63
  export class BacklinkIndex {
31
64
  constructor({
32
65
  rebuildDelayMs = 150,
@@ -44,6 +77,10 @@ export class BacklinkIndex {
44
77
  this.reverse = new Map();
45
78
  /** @type {Map<string, Map<string, string[]>>} sourcePath → targetPath → contexts[] */
46
79
  this.contextsBySource = new Map();
80
+ /** @type {Map<string, Set<string>>} sourcePath → normalized raw target keys */
81
+ this.rawTargetKeysBySource = new Map();
82
+ /** @type {Map<string, Set<string>>} normalized raw target key → source paths */
83
+ this.rawTargetSources = new Map();
47
84
  /** @type {string[]} cached flat target file list for link resolution */
48
85
  this._fileList = [];
49
86
  /** @type {string[]} cached markdown source file list for content scans */
@@ -140,13 +177,16 @@ export class BacklinkIndex {
140
177
  this.forward.clear();
141
178
  this.reverse.clear();
142
179
  this.contextsBySource.clear();
180
+ this.rawTargetKeysBySource.clear();
181
+ this.rawTargetSources.clear();
143
182
 
144
183
  const snapshot = workspaceState ?? await this._resolveWorkspaceState();
145
184
  this._fileList = Array.from(snapshot?.filePaths ?? snapshot?.markdownPaths ?? []);
146
- this._sourceFileList = Array.from(
147
- snapshot?.markdownPaths ?? this._fileList.filter((filePath) => isMarkdownFilePath(filePath)),
148
- ).filter((filePath) => this._fileList.includes(filePath));
149
185
  this._fileSet = new Set(this._fileList);
186
+ this._sourceFileList = Array.from(
187
+ snapshot?.markdownPaths
188
+ ?? this._fileList.filter((filePath) => isMarkdownFilePath(filePath)),
189
+ ).filter((filePath) => this._fileSet.has(filePath));
150
190
  this._refreshWikiTargetIndex();
151
191
 
152
192
  const fileContents = await mapWithConcurrency(
@@ -256,6 +296,21 @@ export class BacklinkIndex {
256
296
  this.contextsBySource.set(newPath, sourceContexts);
257
297
  }
258
298
 
299
+ if (this.rawTargetKeysBySource.has(oldPath)) {
300
+ const rawTargetKeys = this.rawTargetKeysBySource.get(oldPath);
301
+ this.rawTargetKeysBySource.delete(oldPath);
302
+ this.rawTargetKeysBySource.set(newPath, rawTargetKeys);
303
+ rawTargetKeys.forEach((rawTargetKey) => {
304
+ const sources = this.rawTargetSources.get(rawTargetKey);
305
+ if (!sources) {
306
+ return;
307
+ }
308
+
309
+ sources.delete(oldPath);
310
+ sources.add(newPath);
311
+ });
312
+ }
313
+
259
314
  // Move forward links
260
315
  const oldForward = this.forward.get(oldPath);
261
316
  if (oldForward) {
@@ -319,6 +374,25 @@ export class BacklinkIndex {
319
374
  let refreshIndex = false;
320
375
  const previousEntries = previousState?.entries ?? new Map();
321
376
  const nextEntries = nextState?.entries ?? new Map();
377
+ const changedPaths = Array.from(new Set(workspaceChange.changedPaths ?? []));
378
+ const createdPaths = changedPaths.filter((pathValue) => (
379
+ nextEntries.has(pathValue) && !previousEntries.has(pathValue)
380
+ ));
381
+ const removedChangedPaths = changedPaths.filter((pathValue) => (
382
+ !nextEntries.has(pathValue) && previousEntries.has(pathValue)
383
+ ));
384
+ const membershipAffectedPaths = [
385
+ ...(workspaceChange.deletedPaths ?? []),
386
+ ...removedChangedPaths,
387
+ ...createdPaths,
388
+ ...(workspaceChange.renamedPaths ?? []).flatMap((entry) => [entry?.oldPath, entry?.newPath]).filter(Boolean),
389
+ ];
390
+ const impactedSources = this._collectImpactedSourcesForMembershipChanges(membershipAffectedPaths);
391
+ const renameMap = new Map(
392
+ (workspaceChange.renamedPaths ?? [])
393
+ .filter((entry) => entry?.oldPath && entry?.newPath)
394
+ .map((entry) => [entry.oldPath, entry.newPath]),
395
+ );
322
396
 
323
397
  for (const pathValue of workspaceChange.deletedPaths ?? []) {
324
398
  refreshIndex = this.onFileDeleted(pathValue, { refreshIndex: false }) || refreshIndex;
@@ -331,7 +405,7 @@ export class BacklinkIndex {
331
405
  refreshIndex = this.onFileRenamed(entry.oldPath, entry.newPath, { refreshIndex: false }) || refreshIndex;
332
406
  }
333
407
 
334
- for (const pathValue of new Set(workspaceChange.changedPaths ?? [])) {
408
+ for (const pathValue of changedPaths) {
335
409
  const existsNow = nextEntries.has(pathValue);
336
410
  const existedBefore = previousEntries.has(pathValue);
337
411
  if (!existsNow) {
@@ -361,7 +435,23 @@ export class BacklinkIndex {
361
435
  }
362
436
 
363
437
  if (refreshIndex) {
438
+ const sourcesToRefresh = new Set(impactedSources);
439
+ changedPaths.forEach((pathValue) => {
440
+ if (nextEntries.has(pathValue) && isMarkdownFilePath(pathValue)) {
441
+ sourcesToRefresh.add(pathValue);
442
+ }
443
+ });
444
+ (workspaceChange.renamedPaths ?? []).forEach((entry) => {
445
+ if (entry?.newPath && isMarkdownFilePath(entry.newPath)) {
446
+ sourcesToRefresh.add(entry.newPath);
447
+ }
448
+ });
449
+
364
450
  this._refreshWikiTargetIndex();
451
+ await this._refreshImpactedSources(sourcesToRefresh, {
452
+ nextEntries,
453
+ renameMap,
454
+ });
365
455
  }
366
456
  }
367
457
 
@@ -408,6 +498,7 @@ export class BacklinkIndex {
408
498
  _indexFile(filePath, content) {
409
499
  const resolvedTargets = new Set();
410
500
  const contextsByTarget = new Map();
501
+ const rawTargetKeys = new Set();
411
502
  const lines = content.split('\n');
412
503
 
413
504
  for (const line of lines) {
@@ -419,6 +510,11 @@ export class BacklinkIndex {
419
510
  continue;
420
511
  }
421
512
 
513
+ const rawTargetKey = normalizeWikiTargetKey(target);
514
+ if (rawTargetKey) {
515
+ rawTargetKeys.add(rawTargetKey);
516
+ }
517
+
422
518
  const resolved = this._resolveTarget(target);
423
519
  if (!resolved || resolved === filePath) {
424
520
  continue;
@@ -434,6 +530,17 @@ export class BacklinkIndex {
434
530
  }
435
531
 
436
532
  this.contextsBySource.delete(filePath);
533
+ this._removeRawTargetSourceContributions(filePath);
534
+
535
+ if (rawTargetKeys.size > 0) {
536
+ this.rawTargetKeysBySource.set(filePath, rawTargetKeys);
537
+ rawTargetKeys.forEach((rawTargetKey) => {
538
+ if (!this.rawTargetSources.has(rawTargetKey)) {
539
+ this.rawTargetSources.set(rawTargetKey, new Set());
540
+ }
541
+ this.rawTargetSources.get(rawTargetKey).add(filePath);
542
+ });
543
+ }
437
544
 
438
545
  if (resolvedTargets.size > 0) {
439
546
  this.contextsBySource.set(filePath, contextsByTarget);
@@ -450,6 +557,7 @@ export class BacklinkIndex {
450
557
 
451
558
  _removeForwardLinks(filePath) {
452
559
  this.contextsBySource.delete(filePath);
560
+ this._removeRawTargetSourceContributions(filePath);
453
561
 
454
562
  const oldTargets = this.forward.get(filePath);
455
563
  if (!oldTargets) return;
@@ -478,6 +586,70 @@ export class BacklinkIndex {
478
586
  _refreshWikiTargetIndex() {
479
587
  this._wikiTargetIndex = createWikiTargetIndex(this._fileList);
480
588
  }
589
+
590
+ _removeRawTargetSourceContributions(filePath) {
591
+ const rawTargetKeys = this.rawTargetKeysBySource.get(filePath);
592
+ if (!rawTargetKeys) {
593
+ return;
594
+ }
595
+
596
+ rawTargetKeys.forEach((rawTargetKey) => {
597
+ const sources = this.rawTargetSources.get(rawTargetKey);
598
+ if (!sources) {
599
+ return;
600
+ }
601
+
602
+ sources.delete(filePath);
603
+ if (sources.size === 0) {
604
+ this.rawTargetSources.delete(rawTargetKey);
605
+ }
606
+ });
607
+ this.rawTargetKeysBySource.delete(filePath);
608
+ }
609
+
610
+ _collectImpactedSourcesForMembershipChanges(pathValues = []) {
611
+ const affectedTargetKeys = new Set();
612
+ pathValues.forEach((pathValue) => {
613
+ collectWikiTargetKeysForFilePath(pathValue).forEach((targetKey) => {
614
+ affectedTargetKeys.add(targetKey);
615
+ });
616
+ });
617
+
618
+ const impactedSources = new Set();
619
+ affectedTargetKeys.forEach((targetKey) => {
620
+ this.rawTargetSources.get(targetKey)?.forEach((sourcePath) => {
621
+ impactedSources.add(sourcePath);
622
+ });
623
+ });
624
+ return impactedSources;
625
+ }
626
+
627
+ async _refreshImpactedSources(impactedSources = new Set(), {
628
+ nextEntries = new Map(),
629
+ renameMap = new Map(),
630
+ } = {}) {
631
+ const refreshedSources = new Set();
632
+
633
+ for (const sourcePath of impactedSources) {
634
+ const livePath = renameMap.get(sourcePath) ?? sourcePath;
635
+ if (
636
+ refreshedSources.has(livePath)
637
+ || !livePath
638
+ || !isMarkdownFilePath(livePath)
639
+ || !nextEntries.has(livePath)
640
+ ) {
641
+ continue;
642
+ }
643
+
644
+ const content = await this.vaultFileStore.readMarkdownFile(livePath);
645
+ if (content === null) {
646
+ continue;
647
+ }
648
+
649
+ refreshedSources.add(livePath);
650
+ this.updateFile(livePath, content, { refreshIndex: false });
651
+ }
652
+ }
481
653
  }
482
654
 
483
655
  /** Flatten a vault tree into an array of file paths. */
@@ -0,0 +1,382 @@
1
+ import yaml from 'js-yaml';
2
+
3
+ const SUPPORTED_VIEW_TYPES = new Set(['cards', 'list', 'table']);
4
+
5
+ function isPlainObject(value) {
6
+ return Object.prototype.toString.call(value) === '[object Object]';
7
+ }
8
+
9
+ function capitalize(value = '') {
10
+ const normalized = String(value ?? '').trim();
11
+ if (!normalized) {
12
+ return '';
13
+ }
14
+
15
+ return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`;
16
+ }
17
+
18
+ function normalizeDirection(value, fallback = 'asc') {
19
+ return String(value ?? fallback).toLowerCase() === 'desc' ? 'desc' : 'asc';
20
+ }
21
+
22
+ function normalizeFormulaPropertyId(value = '') {
23
+ const normalized = String(value ?? '').trim();
24
+ if (!normalized) {
25
+ return '';
26
+ }
27
+
28
+ return normalized.startsWith('formula.')
29
+ ? normalized
30
+ : `formula.${normalized}`;
31
+ }
32
+
33
+ function bareFormulaName(value = '') {
34
+ return normalizeFormulaPropertyId(value).replace(/^formula\./u, '');
35
+ }
36
+
37
+ function normalizeBaseView(rawView, index) {
38
+ const view = isPlainObject(rawView) ? rawView : {};
39
+ const type = typeof view.type === 'string' ? view.type : 'table';
40
+ const name = typeof view.name === 'string' && view.name.trim()
41
+ ? view.name.trim()
42
+ : `${capitalize(type)} ${index + 1}`;
43
+ return {
44
+ extra: Object.entries(view).reduce((acc, [key, value]) => {
45
+ if ([
46
+ 'filters',
47
+ 'groupBy',
48
+ 'group_by',
49
+ 'image',
50
+ 'limit',
51
+ 'name',
52
+ 'order',
53
+ 'properties',
54
+ 'sort',
55
+ 'sorts',
56
+ 'summaries',
57
+ 'type',
58
+ ].includes(key)) {
59
+ return acc;
60
+ }
61
+ acc[key] = value;
62
+ return acc;
63
+ }, {}),
64
+ filters: view.filters ?? null,
65
+ groupBy: normalizeViewGroupBy(view.groupBy ?? view.group_by),
66
+ id: `view-${index}`,
67
+ image: typeof view.image === 'string' ? view.image : null,
68
+ limit: Number.isFinite(Number(view.limit)) ? Math.max(0, Number(view.limit)) : null,
69
+ name,
70
+ order: normalizeViewOrder(view.order ?? view.properties),
71
+ sort: normalizeViewSort(view.sort ?? view.sorts),
72
+ summaries: normalizeSummaries(view.summaries),
73
+ supported: SUPPORTED_VIEW_TYPES.has(type),
74
+ type,
75
+ };
76
+ }
77
+
78
+ function normalizeViewOrder(value) {
79
+ if (Array.isArray(value)) {
80
+ return value
81
+ .map((entry) => {
82
+ if (typeof entry === 'string') {
83
+ return entry;
84
+ }
85
+ if (isPlainObject(entry)) {
86
+ return entry.id ?? entry.property ?? entry.name ?? null;
87
+ }
88
+ return null;
89
+ })
90
+ .filter(Boolean);
91
+ }
92
+
93
+ if (typeof value === 'string' && value.trim()) {
94
+ return [value.trim()];
95
+ }
96
+
97
+ return [];
98
+ }
99
+
100
+ function normalizeViewGroupBy(value) {
101
+ if (typeof value === 'string' && value.trim()) {
102
+ return {
103
+ direction: 'asc',
104
+ explicitDirection: false,
105
+ property: value.trim(),
106
+ };
107
+ }
108
+
109
+ if (!isPlainObject(value)) {
110
+ return null;
111
+ }
112
+
113
+ const property = value.property ?? value.id ?? value.name ?? null;
114
+ if (typeof property !== 'string' || !property.trim()) {
115
+ return null;
116
+ }
117
+
118
+ return {
119
+ direction: normalizeDirection(value.direction ?? value.order, 'asc'),
120
+ explicitDirection: Object.hasOwn(value, 'direction') || Object.hasOwn(value, 'order'),
121
+ property: property.trim(),
122
+ };
123
+ }
124
+
125
+ function normalizeViewSort(value) {
126
+ if (typeof value === 'string' && value.trim()) {
127
+ return [{ direction: 'asc', property: value.trim() }];
128
+ }
129
+
130
+ if (!Array.isArray(value)) {
131
+ return [];
132
+ }
133
+
134
+ return value
135
+ .map((entry) => {
136
+ if (typeof entry === 'string') {
137
+ return { direction: 'asc', property: entry };
138
+ }
139
+ if (!isPlainObject(entry)) {
140
+ return null;
141
+ }
142
+
143
+ const property = entry.property ?? entry.id ?? entry.name ?? null;
144
+ if (typeof property !== 'string' || !property.trim()) {
145
+ return null;
146
+ }
147
+
148
+ const direction = normalizeDirection(entry.direction ?? entry.order, 'asc');
149
+ return { direction, property: property.trim() };
150
+ })
151
+ .filter(Boolean);
152
+ }
153
+
154
+ function normalizeSummaries(value) {
155
+ if (!value) {
156
+ return [];
157
+ }
158
+
159
+ if (Array.isArray(value)) {
160
+ return value
161
+ .map((entry, index) => normalizeSummary(entry, index))
162
+ .filter(Boolean);
163
+ }
164
+
165
+ if (isPlainObject(value)) {
166
+ const entries = [];
167
+ Object.entries(value).forEach(([property, propertySummaries]) => {
168
+ if (Array.isArray(propertySummaries)) {
169
+ propertySummaries.forEach((entry, index) => {
170
+ const normalized = normalizeSummary(entry, index, property);
171
+ if (normalized) {
172
+ entries.push(normalized);
173
+ }
174
+ });
175
+ return;
176
+ }
177
+
178
+ const normalized = normalizeSummary(propertySummaries, entries.length, property);
179
+ if (normalized) {
180
+ entries.push(normalized);
181
+ }
182
+ });
183
+ return entries;
184
+ }
185
+
186
+ return [];
187
+ }
188
+
189
+ function normalizeSummary(entry, index, property = null) {
190
+ if (typeof entry === 'string') {
191
+ return {
192
+ id: `summary-${index}`,
193
+ label: capitalize(entry),
194
+ property,
195
+ type: entry.toLowerCase(),
196
+ };
197
+ }
198
+
199
+ if (!isPlainObject(entry)) {
200
+ return null;
201
+ }
202
+
203
+ const summaryProperty = typeof (entry.property ?? property) === 'string'
204
+ ? String(entry.property ?? property).trim()
205
+ : null;
206
+ const formula = typeof entry.formula === 'string' ? entry.formula : null;
207
+ const type = typeof entry.type === 'string' ? entry.type.toLowerCase() : (formula ? 'custom' : null);
208
+ if (!summaryProperty) {
209
+ return null;
210
+ }
211
+
212
+ return {
213
+ formula,
214
+ id: `summary-${index}`,
215
+ label: typeof entry.name === 'string' ? entry.name : capitalize(type || 'Summary'),
216
+ property: summaryProperty,
217
+ type,
218
+ };
219
+ }
220
+
221
+ function resolvePropertyLabel(propertyId, definition) {
222
+ const config = definition.properties[propertyId];
223
+ if (config?.displayName) {
224
+ return config.displayName;
225
+ }
226
+
227
+ if (propertyId.startsWith('formula.')) {
228
+ return bareFormulaName(propertyId);
229
+ }
230
+
231
+ return propertyId.startsWith('file.')
232
+ ? propertyId.slice(5)
233
+ : propertyId.replace(/^note\./u, '');
234
+ }
235
+
236
+ function normalizeFormulaEntries(rawFormulas, rawProperties) {
237
+ const formulas = {};
238
+ const appendFormula = (propertyId, formula, { legacy = false } = {}) => {
239
+ const normalizedId = normalizeFormulaPropertyId(propertyId);
240
+ if (!normalizedId || typeof formula !== 'string') {
241
+ return;
242
+ }
243
+
244
+ formulas[normalizedId] = {
245
+ formula,
246
+ id: normalizedId,
247
+ legacy,
248
+ name: bareFormulaName(normalizedId),
249
+ };
250
+ };
251
+
252
+ if (isPlainObject(rawFormulas)) {
253
+ Object.entries(rawFormulas).forEach(([name, formula]) => {
254
+ appendFormula(name, formula);
255
+ });
256
+ }
257
+
258
+ Object.entries(rawProperties).forEach(([id, config]) => {
259
+ if (typeof config?.formula === 'string') {
260
+ appendFormula(id, config.formula, { legacy: true });
261
+ }
262
+ });
263
+
264
+ return formulas;
265
+ }
266
+
267
+ function createFormulaLookup(formulas = {}) {
268
+ const lookup = new Map();
269
+
270
+ Object.keys(formulas).forEach((propertyId) => {
271
+ const bareName = bareFormulaName(propertyId);
272
+ if (bareName) {
273
+ lookup.set(bareName, propertyId);
274
+ }
275
+ lookup.set(propertyId, propertyId);
276
+ });
277
+
278
+ return lookup;
279
+ }
280
+
281
+ export function normalizeBaseDefinition(source = '') {
282
+ const raw = yaml.load(String(source ?? '')) ?? {};
283
+ const rawObject = isPlainObject(raw) ? raw : {};
284
+ const rawProperties = isPlainObject(rawObject.properties) ? rawObject.properties : {};
285
+ const formulas = normalizeFormulaEntries(rawObject.formulas, rawProperties);
286
+ const properties = Object.entries(rawProperties).reduce((acc, [id, config]) => {
287
+ const normalizedConfig = isPlainObject(config) ? { ...config } : {};
288
+ acc[id] = {
289
+ displayName: typeof normalizedConfig.displayName === 'string' ? normalizedConfig.displayName : null,
290
+ id,
291
+ raw: normalizedConfig,
292
+ };
293
+ return acc;
294
+ }, {});
295
+
296
+ const rawViews = Array.isArray(rawObject.views) ? rawObject.views : [];
297
+ const views = rawViews.map((rawView, index) => normalizeBaseView(rawView, index));
298
+ if (views.length === 0) {
299
+ views.push(normalizeBaseView({ name: 'Table', type: 'table' }, 0));
300
+ }
301
+
302
+ return {
303
+ filters: rawObject.filters ?? null,
304
+ formulas,
305
+ formulaLookup: createFormulaLookup(formulas),
306
+ properties,
307
+ raw: rawObject,
308
+ views,
309
+ };
310
+ }
311
+
312
+ export function buildColumns(definition, view) {
313
+ const order = view.order.length > 0
314
+ ? view.order
315
+ : ['file.name', ...new Set([
316
+ ...Object.keys(definition.properties),
317
+ ...Object.keys(definition.formulas),
318
+ ])];
319
+ return order.map((propertyId) => ({
320
+ id: propertyId,
321
+ label: resolvePropertyLabel(propertyId, definition),
322
+ }));
323
+ }
324
+
325
+ export function collectEvaluatedPropertyIds(columns, view) {
326
+ const propertyIds = new Set(columns.map((column) => column.id));
327
+
328
+ if (view.groupBy?.property) {
329
+ propertyIds.add(view.groupBy.property);
330
+ }
331
+
332
+ view.sort.forEach((sortConfig) => {
333
+ if (sortConfig?.property) {
334
+ propertyIds.add(sortConfig.property);
335
+ }
336
+ });
337
+
338
+ view.summaries.forEach((summary) => {
339
+ if (summary?.property) {
340
+ propertyIds.add(summary.property);
341
+ }
342
+ });
343
+
344
+ return [...propertyIds];
345
+ }
346
+
347
+ export function findView(definition, requestedView = '') {
348
+ return definition.views.find((entry) => entry.name === requestedView || entry.id === requestedView) ?? definition.views[0];
349
+ }
350
+
351
+ export function normalizeRawDefinitionForWrite(definition) {
352
+ const rawDefinition = isPlainObject(definition?.raw)
353
+ ? structuredClone(definition.raw)
354
+ : structuredClone(isPlainObject(definition) ? definition : {});
355
+ const rawProperties = isPlainObject(rawDefinition.properties) ? rawDefinition.properties : {};
356
+ const formulas = isPlainObject(rawDefinition.formulas)
357
+ ? { ...rawDefinition.formulas }
358
+ : {};
359
+
360
+ Object.entries(rawProperties).forEach(([propertyId, config]) => {
361
+ if (!isPlainObject(config) || typeof config.formula !== 'string') {
362
+ return;
363
+ }
364
+
365
+ formulas[bareFormulaName(propertyId)] = config.formula;
366
+ delete config.formula;
367
+ });
368
+
369
+ rawDefinition.properties = rawProperties;
370
+ if (Object.keys(formulas).length > 0) {
371
+ rawDefinition.formulas = formulas;
372
+ } else {
373
+ delete rawDefinition.formulas;
374
+ }
375
+
376
+ return rawDefinition;
377
+ }
378
+
379
+ export function serializeBaseDefinition(definition) {
380
+ const raw = normalizeRawDefinitionForWrite(definition);
381
+ return `${yaml.dump(raw, { lineWidth: -1, noRefs: true }).trim()}\n`;
382
+ }