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
@@ -17,6 +17,18 @@ function looksLikeExternalImageUrl(value = '') {
17
17
  return /^https?:\/\/.+\.(?:png|jpe?g|webp|gif|svg)(?:[?#].*)?$/iu.test(String(value ?? '').trim());
18
18
  }
19
19
 
20
+ function isPlainObject(value) {
21
+ return Object.prototype.toString.call(value) === '[object Object]';
22
+ }
23
+
24
+ function createEmptyFilterGroup(conjunction = 'and') {
25
+ return {
26
+ children: [],
27
+ conjunction,
28
+ type: 'group',
29
+ };
30
+ }
31
+
20
32
  function getCardImageCell(view, columns, row) {
21
33
  const preferred = view.image
22
34
  || columns.find((column) => ['cover', 'image'].includes(String(column.id ?? '').toLowerCase()))?.id
@@ -178,9 +190,852 @@ function renderViewTabs(result) {
178
190
  )).join('');
179
191
  }
180
192
 
181
- function renderShellHtml(result, state) {
193
+ function getMeta(result) {
194
+ return result?.meta ?? {};
195
+ }
196
+
197
+ function getAvailableProperties(result) {
198
+ return getMeta(result).availableProperties ?? [];
199
+ }
200
+
201
+ function getVisiblePropertyIds(result) {
202
+ const explicitOrder = getMeta(result).activeViewConfig?.order;
203
+ if (Array.isArray(explicitOrder) && explicitOrder.length > 0) {
204
+ return [...explicitOrder];
205
+ }
206
+
207
+ return Array.isArray(result?.columns)
208
+ ? result.columns.map((column) => column.id).filter(Boolean)
209
+ : [];
210
+ }
211
+
212
+ function findPropertyMeta(result, propertyId = '') {
213
+ return getAvailableProperties(result).find((entry) => entry.id === propertyId) ?? null;
214
+ }
215
+
216
+ function getPropertyFilterOperators(result, propertyId = '') {
217
+ return findPropertyMeta(result, propertyId)?.filterOperators
218
+ ?? ['is', 'is not', 'contains', 'does not contain', 'is empty', 'is not empty'];
219
+ }
220
+
221
+ function getEditableViewConfig(result) {
222
+ const config = getMeta(result).activeViewConfig ?? {};
223
+ return {
224
+ filters: config.filters ?? null,
225
+ groupBy: config.groupBy ?? null,
226
+ order: Array.isArray(config.order) ? [...config.order] : [],
227
+ sort: Array.isArray(config.sort) ? config.sort.map((entry) => ({ ...entry })) : [],
228
+ };
229
+ }
230
+
231
+ function isStandaloneBaseEntry(entry) {
232
+ return Boolean(
233
+ entry?.payload?.path
234
+ && entry?.payload?.sourcePath
235
+ && entry.payload.path === entry.payload.sourcePath,
236
+ );
237
+ }
238
+
239
+ function buildToolbarButton(label, panel, entry, result) {
240
+ const active = entry.ui?.openPanel === panel;
241
+ const editable = Boolean(getMeta(result).editable);
242
+ return `<button type="button" class="${escapeHtml(buttonClassNames({ variant: active ? 'primary' : 'secondary', pill: true, extra: ['bases-toolbar-btn', active ? 'is-active' : ''] }))}" data-base-panel="${escapeHtml(panel)}"${editable ? '' : ' disabled'}>${escapeHtml(label)}</button>`;
243
+ }
244
+
245
+ function pruneFilterGroupByProperty(group, propertyId = '') {
246
+ if (!group || group.type !== 'group' || !propertyId) {
247
+ return group;
248
+ }
249
+
250
+ const children = (group.children ?? []).flatMap((child) => {
251
+ if (child?.type === 'group') {
252
+ const nextGroup = pruneFilterGroupByProperty(child, propertyId);
253
+ return nextGroup ? [nextGroup] : [];
254
+ }
255
+
256
+ return child?.propertyId === propertyId ? [] : [child];
257
+ });
258
+
259
+ return {
260
+ ...group,
261
+ children,
262
+ };
263
+ }
264
+
265
+ function createPropertyValuesCacheKey(entry, propertyId = '', result = entry?.result) {
266
+ const rawFilters = getMeta(result).activeViewConfig?.filters ?? null;
267
+ const parsedFilters = parseFilterNode(rawFilters);
268
+ const cacheFilters = propertyId && parsedFilters
269
+ ? compileFilterGroup(pruneFilterGroupByProperty(parsedFilters, propertyId), result)
270
+ : rawFilters;
271
+ return JSON.stringify({
272
+ filters: cacheFilters,
273
+ path: entry?.payload?.path ?? '',
274
+ source: entry?.payload?.source ?? null,
275
+ sourcePath: entry?.payload?.sourcePath ?? '',
276
+ view: result?.view?.id ?? entry?.payload?.view ?? '',
277
+ });
278
+ }
279
+
280
+ function getCachedPropertyValueOptions(entry, propertyId = '', result = entry?.result) {
281
+ const cachedEntry = entry?.propertyValueOptions?.get(propertyId) ?? null;
282
+ if (!cachedEntry) {
283
+ return [];
284
+ }
285
+
286
+ return cachedEntry.cacheKey === createPropertyValuesCacheKey(entry, propertyId, result)
287
+ ? (cachedEntry.values ?? [])
288
+ : [];
289
+ }
290
+
291
+ function renderActionButtons(result, entry) {
292
+ return [
293
+ buildToolbarButton('Sort', 'sort', entry, result),
294
+ buildToolbarButton('Filter', 'filter', entry, result),
295
+ buildToolbarButton('Properties', 'properties', entry, result),
296
+ ].join('');
297
+ }
298
+
299
+ function createSelectOptions(result, {
300
+ allowBlank = false,
301
+ selected = '',
302
+ } = {}) {
303
+ const options = [];
304
+ if (allowBlank) {
305
+ options.push(`<option value=""${selected ? '' : ' selected'}>Property</option>`);
306
+ }
307
+
308
+ getAvailableProperties(result).forEach((property) => {
309
+ options.push(`<option value="${escapeHtml(property.id)}"${property.id === selected ? ' selected' : ''}>${escapeHtml(property.label)}</option>`);
310
+ });
311
+ return options.join('');
312
+ }
313
+
314
+ function renderSortPanel(result) {
315
+ const viewConfig = getEditableViewConfig(result);
316
+ const groupDirections = findPropertyMeta(result, viewConfig.groupBy?.property)?.sortDirections
317
+ ?? [
318
+ { id: 'asc', label: 'A → Z' },
319
+ { id: 'desc', label: 'Z → A' },
320
+ ];
321
+ const sorts = viewConfig.sort ?? [];
322
+
182
323
  return `
183
- <section class="bases-shell" data-base-shell-key="${escapeHtml(state.key)}">
324
+ <section class="bases-panel-card">
325
+ <div class="bases-panel-section">
326
+ <div class="bases-panel-title">Group by</div>
327
+ <div class="bases-sort-row">
328
+ <select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-sort-property' }))}" data-base-group-by-property>
329
+ ${createSelectOptions(result, { allowBlank: true, selected: viewConfig.groupBy?.property ?? '' })}
330
+ </select>
331
+ <select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-sort-direction' }))}" data-base-group-by-direction${viewConfig.groupBy?.property ? '' : ' disabled'}>
332
+ ${groupDirections.map((direction) => `<option value="${escapeHtml(direction.id)}"${direction.id === (viewConfig.groupBy?.direction ?? 'asc') ? ' selected' : ''}>${escapeHtml(direction.label)}</option>`).join('')}
333
+ </select>
334
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-clear-group-by${viewConfig.groupBy?.property ? '' : ' disabled'}>Clear</button>
335
+ </div>
336
+ </div>
337
+ <div class="bases-panel-section">
338
+ <div class="bases-panel-title">Sort by</div>
339
+ <div class="bases-sort-list">
340
+ ${sorts.map((sortConfig, index) => {
341
+ const propertyMeta = findPropertyMeta(result, sortConfig.property);
342
+ const directions = propertyMeta?.sortDirections ?? [
343
+ { id: 'asc', label: 'A → Z' },
344
+ { id: 'desc', label: 'Z → A' },
345
+ ];
346
+ return `
347
+ <div class="bases-sort-row">
348
+ <select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-sort-property' }))}" data-base-sort-property="${index}">
349
+ ${createSelectOptions(result, { selected: sortConfig.property })}
350
+ </select>
351
+ <select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-sort-direction' }))}" data-base-sort-direction="${index}">
352
+ ${directions.map((direction) => `<option value="${escapeHtml(direction.id)}"${direction.id === sortConfig.direction ? ' selected' : ''}>${escapeHtml(direction.label)}</option>`).join('')}
353
+ </select>
354
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-sort-move="${index}:up"${index === 0 ? ' disabled' : ''}>↑</button>
355
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-sort-move="${index}:down"${index === sorts.length - 1 ? ' disabled' : ''}>↓</button>
356
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-sort-delete="${index}">Delete</button>
357
+ </div>
358
+ `;
359
+ }).join('')}
360
+ </div>
361
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-sort-add>Add sort</button>
362
+ </div>
363
+ </section>
364
+ `;
365
+ }
366
+
367
+ function encodePath(path = []) {
368
+ return path.join('.');
369
+ }
370
+
371
+ function decodePath(value = '') {
372
+ return String(value ?? '')
373
+ .split('.')
374
+ .filter((segment) => segment !== '')
375
+ .map((segment) => Number.parseInt(segment, 10))
376
+ .filter(Number.isFinite);
377
+ }
378
+
379
+ function escapeExpressionString(value = '') {
380
+ return JSON.stringify(String(value ?? ''));
381
+ }
382
+
383
+ function formatExpressionLiteral(value = '', valueType = 'text', operator = 'is') {
384
+ const normalized = String(value ?? '').trim();
385
+ if (!normalized && operator !== 'is' && operator !== 'is not') {
386
+ return '""';
387
+ }
388
+
389
+ if (valueType === 'boolean') {
390
+ return normalized === 'false' ? 'false' : 'true';
391
+ }
392
+
393
+ if (valueType === 'number') {
394
+ return Number.isFinite(Number(normalized))
395
+ ? String(Number(normalized))
396
+ : escapeExpressionString(normalized);
397
+ }
398
+
399
+ if (valueType === 'date') {
400
+ return escapeExpressionString(normalized);
401
+ }
402
+
403
+ return escapeExpressionString(normalized);
404
+ }
405
+
406
+ function compileFilterRule(rule, propertyMeta = null) {
407
+ if (!rule?.propertyId || !rule?.operator) {
408
+ return '';
409
+ }
410
+
411
+ const propertyId = rule.propertyId;
412
+ const valueType = propertyMeta?.valueType ?? 'text';
413
+
414
+ switch (rule.operator) {
415
+ case 'contains':
416
+ return `${propertyId}.contains(${formatExpressionLiteral(rule.value, valueType, rule.operator)})`;
417
+ case 'does not contain':
418
+ return `!${propertyId}.contains(${formatExpressionLiteral(rule.value, valueType, rule.operator)})`;
419
+ case 'starts with':
420
+ return `${propertyId}.startsWith(${formatExpressionLiteral(rule.value, valueType, rule.operator)})`;
421
+ case 'ends with':
422
+ return `${propertyId}.endsWith(${formatExpressionLiteral(rule.value, valueType, rule.operator)})`;
423
+ case 'is':
424
+ return `${propertyId} == ${formatExpressionLiteral(rule.value, valueType, rule.operator)}`;
425
+ case 'is not':
426
+ return `${propertyId} != ${formatExpressionLiteral(rule.value, valueType, rule.operator)}`;
427
+ case '>':
428
+ case '>=':
429
+ case '<':
430
+ case '<=':
431
+ return `${propertyId} ${rule.operator} ${formatExpressionLiteral(rule.value, valueType, rule.operator)}`;
432
+ case 'is empty':
433
+ return `${propertyId}.isEmpty()`;
434
+ case 'is not empty':
435
+ return `!${propertyId}.isEmpty()`;
436
+ default:
437
+ return '';
438
+ }
439
+ }
440
+
441
+ function compileFilterGroup(group, result) {
442
+ if (!group || group.type !== 'group') {
443
+ return null;
444
+ }
445
+
446
+ const compiledChildren = (group.children ?? [])
447
+ .map((child) => {
448
+ if (child?.type === 'group') {
449
+ return compileFilterGroup(child, result);
450
+ }
451
+
452
+ const propertyMeta = findPropertyMeta(result, child?.propertyId);
453
+ return compileFilterRule(child, propertyMeta);
454
+ })
455
+ .filter(Boolean);
456
+
457
+ if (compiledChildren.length === 0) {
458
+ return null;
459
+ }
460
+
461
+ if (group.conjunction === 'not') {
462
+ return {
463
+ not: compiledChildren,
464
+ };
465
+ }
466
+
467
+ return {
468
+ [group.conjunction]: compiledChildren,
469
+ };
470
+ }
471
+
472
+ function deserializeExpressionValue(value = '') {
473
+ const normalized = String(value ?? '').trim();
474
+ if (!normalized) {
475
+ return '';
476
+ }
477
+
478
+ if (normalized === 'true' || normalized === 'false') {
479
+ return normalized;
480
+ }
481
+
482
+ if (normalized === 'null') {
483
+ return '';
484
+ }
485
+
486
+ if (/^-?\d+(?:\.\d+)?$/u.test(normalized)) {
487
+ return normalized;
488
+ }
489
+
490
+ if ((normalized.startsWith('"') && normalized.endsWith('"')) || (normalized.startsWith('\'') && normalized.endsWith('\''))) {
491
+ return normalized.slice(1, -1).replace(/\\(["'])/gu, '$1');
492
+ }
493
+
494
+ return normalized;
495
+ }
496
+
497
+ function parseFilterRule(expression = '') {
498
+ const text = String(expression ?? '').trim();
499
+ const patterns = [
500
+ [/^!([A-Za-z0-9_.]+)\.isEmpty\(\)$/u, (_, propertyId) => ({ operator: 'is not empty', propertyId, type: 'rule', value: '' })],
501
+ [/^([A-Za-z0-9_.]+)\.isEmpty\(\)$/u, (_, propertyId) => ({ operator: 'is empty', propertyId, type: 'rule', value: '' })],
502
+ [/^!([A-Za-z0-9_.]+)\.contains\((.+)\)$/u, (_, propertyId, value) => ({ operator: 'does not contain', propertyId, type: 'rule', value: deserializeExpressionValue(value) })],
503
+ [/^([A-Za-z0-9_.]+)\.contains\((.+)\)$/u, (_, propertyId, value) => ({ operator: 'contains', propertyId, type: 'rule', value: deserializeExpressionValue(value) })],
504
+ [/^([A-Za-z0-9_.]+)\.startsWith\((.+)\)$/u, (_, propertyId, value) => ({ operator: 'starts with', propertyId, type: 'rule', value: deserializeExpressionValue(value) })],
505
+ [/^([A-Za-z0-9_.]+)\.endsWith\((.+)\)$/u, (_, propertyId, value) => ({ operator: 'ends with', propertyId, type: 'rule', value: deserializeExpressionValue(value) })],
506
+ [/^([A-Za-z0-9_.]+)\s*(==|!=|>=|<=|>|<)\s*(.+)$/u, (_, propertyId, operator, value) => ({
507
+ operator: {
508
+ '!=': 'is not',
509
+ '==': 'is',
510
+ '<': '<',
511
+ '<=': '<=',
512
+ '>': '>',
513
+ '>=': '>=',
514
+ }[operator] ?? operator,
515
+ propertyId,
516
+ type: 'rule',
517
+ value: deserializeExpressionValue(value),
518
+ })],
519
+ ];
520
+
521
+ for (const [pattern, build] of patterns) {
522
+ const match = text.match(pattern);
523
+ if (match) {
524
+ return build(...match);
525
+ }
526
+ }
527
+
528
+ return null;
529
+ }
530
+
531
+ function parseFilterNode(filterNode) {
532
+ if (!filterNode) {
533
+ return createEmptyFilterGroup('and');
534
+ }
535
+
536
+ if (typeof filterNode === 'string') {
537
+ const rule = parseFilterRule(filterNode);
538
+ return rule
539
+ ? {
540
+ children: [rule],
541
+ conjunction: 'and',
542
+ type: 'group',
543
+ }
544
+ : null;
545
+ }
546
+
547
+ if (!isPlainObject(filterNode)) {
548
+ return null;
549
+ }
550
+
551
+ if (Array.isArray(filterNode.and)) {
552
+ const children = filterNode.and.map((child) => parseFilterNode(child)).flatMap((child) => (
553
+ child?.type === 'group' && child.conjunction === 'and' && child.children?.length === 1 && child.children[0]?.type === 'rule'
554
+ ? child.children
555
+ : [child]
556
+ ));
557
+ return children.every(Boolean)
558
+ ? { children, conjunction: 'and', type: 'group' }
559
+ : null;
560
+ }
561
+
562
+ if (Array.isArray(filterNode.or)) {
563
+ const children = filterNode.or.map((child) => parseFilterNode(child)).flatMap((child) => (
564
+ child?.type === 'group' && child.conjunction === 'and' && child.children?.length === 1 && child.children[0]?.type === 'rule'
565
+ ? child.children
566
+ : [child]
567
+ ));
568
+ return children.every(Boolean)
569
+ ? { children, conjunction: 'or', type: 'group' }
570
+ : null;
571
+ }
572
+
573
+ if (filterNode.not != null) {
574
+ const notChildren = Array.isArray(filterNode.not)
575
+ ? filterNode.not
576
+ : (() => {
577
+ const parsed = parseFilterNode(filterNode.not);
578
+ if (!parsed) {
579
+ return [];
580
+ }
581
+ return parsed.type === 'group' && parsed.conjunction === 'or'
582
+ ? (parsed.children ?? [])
583
+ : [parsed];
584
+ })();
585
+ const children = notChildren.flatMap((child) => {
586
+ const parsedChild = child?.type ? child : parseFilterNode(child);
587
+ if (!parsedChild) {
588
+ return [null];
589
+ }
590
+ return parsedChild.type === 'group' && parsedChild.conjunction === 'and' && parsedChild.children?.length === 1 && parsedChild.children[0]?.type === 'rule'
591
+ ? parsedChild.children
592
+ : [parsedChild];
593
+ });
594
+ return children.every(Boolean)
595
+ ? { children, conjunction: 'not', type: 'group' }
596
+ : null;
597
+ }
598
+
599
+ return null;
600
+ }
601
+
602
+ function serializeRawFilterText(filters) {
603
+ if (!filters) {
604
+ return '';
605
+ }
606
+
607
+ return typeof filters === 'string'
608
+ ? filters
609
+ : JSON.stringify(filters, null, 2);
610
+ }
611
+
612
+ function parseRawFilterText(text = '') {
613
+ const normalized = String(text ?? '').trim();
614
+ if (!normalized) {
615
+ return null;
616
+ }
617
+
618
+ if (normalized.startsWith('{') || normalized.startsWith('[')) {
619
+ return JSON.parse(normalized);
620
+ }
621
+
622
+ return normalized;
623
+ }
624
+
625
+ function getNodeAtPath(group, path = []) {
626
+ let current = group;
627
+ for (const index of path) {
628
+ current = current?.children?.[index];
629
+ }
630
+ return current ?? null;
631
+ }
632
+
633
+ function updateNodeAtPath(group, path = [], updater) {
634
+ if (path.length === 0) {
635
+ return updater(group);
636
+ }
637
+
638
+ const [head, ...rest] = path;
639
+ return {
640
+ ...group,
641
+ children: (group.children ?? []).map((child, index) => (
642
+ index === head
643
+ ? updateNodeAtPath(child, rest, updater)
644
+ : child
645
+ )),
646
+ };
647
+ }
648
+
649
+ function removeNodeAtPath(group, path = []) {
650
+ if (path.length === 0) {
651
+ return group;
652
+ }
653
+
654
+ const parentPath = path.slice(0, -1);
655
+ const removeIndex = path[path.length - 1];
656
+ return updateNodeAtPath(group, parentPath, (node) => ({
657
+ ...node,
658
+ children: (node.children ?? []).filter((_, index) => index !== removeIndex),
659
+ }));
660
+ }
661
+
662
+ function addNodeAtPath(group, path = [], node) {
663
+ return updateNodeAtPath(group, path, (target) => ({
664
+ ...target,
665
+ children: [...(target.children ?? []), node],
666
+ }));
667
+ }
668
+
669
+ function getPropertyOptionsMarkup(result, selected = '') {
670
+ return createSelectOptions(result, { allowBlank: true, selected });
671
+ }
672
+
673
+ function getPropertyValueOptionText(option = {}) {
674
+ return String(option?.text ?? option?.value ?? '').trim();
675
+ }
676
+
677
+ function getBuilderFilterState(entry, result) {
678
+ return entry.ui.builderFilter
679
+ ?? parseFilterNode(getEditableViewConfig(result).filters)
680
+ ?? createEmptyFilterGroup('and');
681
+ }
682
+
683
+ function buildFilterValuePresentation(result, rule, path, entry) {
684
+ const encodedPath = encodePath(path);
685
+ const operators = getPropertyFilterOperators(result, rule.propertyId);
686
+ const suggestionQuery = String(rule.value ?? '').trim().toLowerCase();
687
+ const propertyValueOptions = getCachedPropertyValueOptions(entry, rule.propertyId, result)
688
+ .map((option) => ({
689
+ count: option?.count,
690
+ text: getPropertyValueOptionText(option),
691
+ }))
692
+ .filter((option, index, items) => (
693
+ option.text
694
+ && items.findIndex((candidate) => candidate.text === option.text) === index
695
+ ));
696
+ const hasExactSuggestionMatch = propertyValueOptions.some((option) => (
697
+ option.text.toLowerCase() === suggestionQuery
698
+ ));
699
+ const matchingValueSuggestions = propertyValueOptions.filter((option) => (
700
+ !suggestionQuery || option.text.toLowerCase().includes(suggestionQuery)
701
+ ));
702
+ const valueSuggestions = !suggestionQuery || hasExactSuggestionMatch
703
+ ? propertyValueOptions
704
+ : matchingValueSuggestions.length > 0
705
+ ? matchingValueSuggestions
706
+ : propertyValueOptions;
707
+ const suggestionsEnabled = valueSuggestions.length > 0 && !['is empty', 'is not empty'].includes(rule.operator);
708
+ const suggestionListId = `bases-filter-suggestions-${escapeHtml(entry.key)}-${escapeHtml(encodedPath.replace(/\./g, '-'))}`;
709
+ const valueInputClasses = inputClassNames({
710
+ extra: suggestionsEnabled
711
+ ? 'bases-filter-value bases-filter-value--suggestions'
712
+ : 'bases-filter-value',
713
+ });
714
+ return {
715
+ encodedPath,
716
+ operators,
717
+ suggestionListId,
718
+ suggestionsEnabled,
719
+ valueInputClasses,
720
+ valueSuggestions,
721
+ valueDisabled: ['is empty', 'is not empty'].includes(rule.operator),
722
+ };
723
+ }
724
+
725
+ function renderFilterSuggestionList(presentation, currentValue = '') {
726
+ if (!presentation.suggestionsEnabled) {
727
+ return '';
728
+ }
729
+
730
+ return `
731
+ <div class="bases-filter-suggestion-list" id="${presentation.suggestionListId}" role="listbox">
732
+ ${presentation.valueSuggestions.slice(0, 8).map((option) => `
733
+ <button
734
+ type="button"
735
+ class="bases-filter-suggestion"
736
+ data-base-filter-suggestion="${escapeHtml(presentation.encodedPath)}"
737
+ data-base-filter-suggestion-value="${escapeHtml(option.text)}"
738
+ role="option"
739
+ aria-selected="${option.text === String(currentValue ?? '') ? 'true' : 'false'}"
740
+ >
741
+ <span class="bases-filter-suggestion-label">${escapeHtml(option.text)}</span>
742
+ ${option.count == null ? '' : `<span class="bases-filter-suggestion-meta">${escapeHtml(String(option.count))}</span>`}
743
+ </button>
744
+ `).join('')}
745
+ </div>
746
+ `;
747
+ }
748
+
749
+ function renderFilterValueCombobox(result, rule, path, entry) {
750
+ const presentation = buildFilterValuePresentation(result, rule, path, entry);
751
+ return `
752
+ <div class="bases-filter-value-combobox${presentation.suggestionsEnabled ? ' has-suggestions' : ''}">
753
+ <input class="${escapeHtml(presentation.valueInputClasses)}" type="text" value="${escapeHtml(rule.value ?? '')}" data-base-filter-value="${escapeHtml(presentation.encodedPath)}"${presentation.suggestionsEnabled ? ` aria-controls="${presentation.suggestionListId}" aria-haspopup="listbox" aria-autocomplete="list" autocomplete="off"` : ''}${presentation.valueDisabled ? ' disabled' : ''}>
754
+ ${renderFilterSuggestionList(presentation, rule.value ?? '')}
755
+ </div>
756
+ `;
757
+ }
758
+
759
+ function renderFilterRule(result, rule, path, entry) {
760
+ const presentation = buildFilterValuePresentation(result, rule, path, entry);
761
+
762
+ return `
763
+ <div class="bases-filter-row">
764
+ <select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-filter-property' }))}" data-base-filter-property="${escapeHtml(presentation.encodedPath)}">
765
+ ${getPropertyOptionsMarkup(result, rule.propertyId)}
766
+ </select>
767
+ <select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-filter-operator' }))}" data-base-filter-operator="${escapeHtml(presentation.encodedPath)}">
768
+ ${presentation.operators.map((operator) => `<option value="${escapeHtml(operator)}"${operator === rule.operator ? ' selected' : ''}>${escapeHtml(operator)}</option>`).join('')}
769
+ </select>
770
+ ${renderFilterValueCombobox(result, rule, path, entry)}
771
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-filter-remove="${escapeHtml(presentation.encodedPath)}">Delete</button>
772
+ </div>
773
+ `;
774
+ }
775
+
776
+ function syncFilterValueCombobox(entry, result, path, { fallbackToFullRender = true } = {}) {
777
+ const shell = findShellElement(entry);
778
+ const panelSlot = shell?.querySelector?.('[data-base-panel-slot]') ?? null;
779
+ const encodedPath = encodePath(path);
780
+ const input = panelSlot?.querySelector?.(`[data-base-filter-value="${encodedPath}"]`) ?? null;
781
+ const combobox = input?.parentElement?.classList?.contains('bases-filter-value-combobox')
782
+ ? input.parentElement
783
+ : null;
784
+ const rule = getNodeAtPath(getBuilderFilterState(entry, result), path);
785
+
786
+ if (!input || !combobox || !rule || rule.type !== 'rule' || typeof input.insertAdjacentHTML !== 'function') {
787
+ if (fallbackToFullRender) {
788
+ updateShellContent(entry, result);
789
+ }
790
+ return false;
791
+ }
792
+
793
+ const presentation = buildFilterValuePresentation(result, rule, path, entry);
794
+ combobox.classList.toggle('has-suggestions', presentation.suggestionsEnabled);
795
+ input.className = presentation.valueInputClasses;
796
+ input.disabled = presentation.valueDisabled;
797
+ if (input.value !== String(rule.value ?? '')) {
798
+ input.value = String(rule.value ?? '');
799
+ }
800
+
801
+ if (presentation.suggestionsEnabled) {
802
+ input.setAttribute('aria-controls', presentation.suggestionListId);
803
+ input.setAttribute('aria-haspopup', 'listbox');
804
+ input.setAttribute('aria-autocomplete', 'list');
805
+ input.setAttribute('autocomplete', 'off');
806
+ } else {
807
+ input.removeAttribute('aria-controls');
808
+ input.removeAttribute('aria-haspopup');
809
+ input.removeAttribute('aria-autocomplete');
810
+ input.removeAttribute('autocomplete');
811
+ }
812
+
813
+ const suggestionList = combobox.querySelector('.bases-filter-suggestion-list');
814
+ suggestionList?.remove?.();
815
+ if (presentation.suggestionsEnabled) {
816
+ input.insertAdjacentHTML('afterend', renderFilterSuggestionList(presentation, rule.value ?? ''));
817
+ }
818
+
819
+ return true;
820
+ }
821
+
822
+ function forEachFilterRule(node, callback, path = []) {
823
+ if (!node) {
824
+ return;
825
+ }
826
+
827
+ if (node.type === 'rule') {
828
+ callback(node, path);
829
+ return;
830
+ }
831
+
832
+ (node.children ?? []).forEach((child, index) => {
833
+ forEachFilterRule(child, callback, [...path, index]);
834
+ });
835
+ }
836
+
837
+ function renderFilterGroup(result, group, path, entry, { isRoot = false } = {}) {
838
+ const encodedPath = encodePath(path);
839
+ return `
840
+ <div class="bases-filter-group"${isRoot ? ' data-base-filter-root' : ''}>
841
+ <div class="bases-filter-group-header">
842
+ <select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-filter-conjunction' }))}" data-base-filter-conjunction="${escapeHtml(encodedPath)}">
843
+ ${['and', 'or', 'not'].map((conjunction) => `<option value="${conjunction}"${group.conjunction === conjunction ? ' selected' : ''}>${escapeHtml({
844
+ and: 'All the following are true',
845
+ not: 'None of the following are true',
846
+ or: 'Any of the following are true',
847
+ }[conjunction])}</option>`).join('')}
848
+ </select>
849
+ ${isRoot ? '' : `<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-filter-remove="${escapeHtml(encodedPath)}">Delete group</button>`}
850
+ </div>
851
+ <div class="bases-filter-children">
852
+ ${(group.children ?? []).map((child, index) => (
853
+ child?.type === 'group'
854
+ ? renderFilterGroup(result, child, [...path, index], entry)
855
+ : renderFilterRule(result, child, [...path, index], entry)
856
+ )).join('')}
857
+ </div>
858
+ <div class="bases-filter-actions-row">
859
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-filter-add-rule="${escapeHtml(encodedPath)}">Add filter</button>
860
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-filter-add-group="${escapeHtml(encodedPath)}">Add filter group</button>
861
+ </div>
862
+ </div>
863
+ `;
864
+ }
865
+
866
+ function renderFilterPanel(result, entry) {
867
+ const rawFilters = getEditableViewConfig(result).filters;
868
+ const parsed = entry.ui.builderFilter ?? parseFilterNode(rawFilters);
869
+ if (entry.ui.builderFilter == null) {
870
+ entry.ui.builderFilter = parsed;
871
+ }
872
+ const mode = parsed ? (entry.ui.filterMode ?? 'builder') : 'advanced';
873
+ const rawText = entry.ui.rawFilterText ?? serializeRawFilterText(rawFilters);
874
+
875
+ return `
876
+ <section class="bases-panel-card">
877
+ <div class="bases-panel-title-row">
878
+ <div class="bases-panel-title">This view</div>
879
+ ${parsed ? `
880
+ <div class="bases-toggle-row">
881
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: mode === 'builder' ? 'primary' : 'secondary', size: 'compact' }))}" data-base-filter-mode="builder">Builder</button>
882
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: mode === 'advanced' ? 'primary' : 'secondary', size: 'compact' }))}" data-base-filter-mode="advanced">Advanced</button>
883
+ </div>
884
+ ` : ''}
885
+ </div>
886
+ ${mode === 'advanced'
887
+ ? `
888
+ <label class="bases-filter-advanced-label">
889
+ <textarea class="${escapeHtml(inputClassNames({ extra: 'bases-filter-advanced' }))}" data-base-filter-advanced>${escapeHtml(rawText)}</textarea>
890
+ </label>
891
+ <div class="bases-filter-actions-row">
892
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'primary', size: 'compact' }))}" data-base-filter-save-advanced>Save filter</button>
893
+ </div>
894
+ `
895
+ : renderFilterGroup(result, parsed ?? createEmptyFilterGroup('and'), [], entry, { isRoot: true })}
896
+ </section>
897
+ `;
898
+ }
899
+
900
+ function renderPropertiesPanel(result, entry) {
901
+ const propertySearch = String(entry.ui.propertySearch ?? '').trim().toLowerCase();
902
+ const visibleIds = new Set(getVisiblePropertyIds(result));
903
+ const properties = getAvailableProperties(result).filter((property) => (
904
+ !propertySearch
905
+ || property.label.toLowerCase().includes(propertySearch)
906
+ || property.id.toLowerCase().includes(propertySearch)
907
+ ));
908
+
909
+ return `
910
+ <section class="bases-panel-card">
911
+ <label class="bases-properties-search">
912
+ <input class="${escapeHtml(inputClassNames({ extra: 'bases-properties-search-input' }))}" type="search" value="${escapeHtml(entry.ui.propertySearch ?? '')}" placeholder="Find property" data-base-properties-search>
913
+ </label>
914
+ <div class="bases-properties-list" data-base-properties-list>
915
+ ${properties.map((property) => `
916
+ <label class="bases-property-option">
917
+ <input type="checkbox" data-base-property-toggle="${escapeHtml(property.id)}"${visibleIds.has(property.id) ? ' checked' : ''}>
918
+ <span class="bases-property-option-label">${escapeHtml(property.label)}</span>
919
+ <span class="bases-property-option-meta">${escapeHtml(property.kind)}</span>
920
+ </label>
921
+ `).join('')}
922
+ </div>
923
+ </section>
924
+ `;
925
+ }
926
+
927
+ function capturePanelState(panelSlot) {
928
+ const propertiesList = panelSlot?.querySelector?.('[data-base-properties-list]') ?? null;
929
+ const propertiesSearch = panelSlot?.querySelector?.('[data-base-properties-search]') ?? null;
930
+ const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
931
+ let focusedControl = null;
932
+
933
+ if (propertiesSearch && activeElement === propertiesSearch) {
934
+ const selectionStart = typeof propertiesSearch.selectionStart === 'number'
935
+ ? propertiesSearch.selectionStart
936
+ : null;
937
+ const selectionEnd = typeof propertiesSearch.selectionEnd === 'number'
938
+ ? propertiesSearch.selectionEnd
939
+ : selectionStart;
940
+ focusedControl = {
941
+ selectionEnd,
942
+ selectionStart,
943
+ type: 'properties-search',
944
+ };
945
+ } else if (activeElement?.dataset?.baseFilterValue != null) {
946
+ const filterPath = String(activeElement.dataset.baseFilterValue);
947
+ const filterValue = panelSlot?.querySelector?.(`[data-base-filter-value="${filterPath}"]`) ?? null;
948
+ if (filterValue && activeElement === filterValue) {
949
+ const selectionStart = typeof filterValue.selectionStart === 'number'
950
+ ? filterValue.selectionStart
951
+ : null;
952
+ const selectionEnd = typeof filterValue.selectionEnd === 'number'
953
+ ? filterValue.selectionEnd
954
+ : selectionStart;
955
+ focusedControl = {
956
+ path: filterPath,
957
+ selectionEnd,
958
+ selectionStart,
959
+ type: 'filter-value',
960
+ };
961
+ }
962
+ }
963
+
964
+ return {
965
+ focusedControl,
966
+ propertiesListScrollTop: propertiesList ? propertiesList.scrollTop : null,
967
+ };
968
+ }
969
+
970
+ function restorePanelState(panelSlot, panelState) {
971
+ if (!panelState) {
972
+ return;
973
+ }
974
+
975
+ if (panelState.propertiesListScrollTop != null) {
976
+ const propertiesList = panelSlot?.querySelector?.('[data-base-properties-list]') ?? null;
977
+ if (propertiesList) {
978
+ propertiesList.scrollTop = panelState.propertiesListScrollTop;
979
+ }
980
+ }
981
+
982
+ if (panelState.focusedControl?.type === 'properties-search') {
983
+ const propertiesSearch = panelSlot?.querySelector?.('[data-base-properties-search]') ?? null;
984
+ if (propertiesSearch) {
985
+ propertiesSearch.focus?.();
986
+ if (
987
+ typeof propertiesSearch.setSelectionRange === 'function'
988
+ && panelState.focusedControl.selectionStart != null
989
+ ) {
990
+ propertiesSearch.setSelectionRange(
991
+ panelState.focusedControl.selectionStart,
992
+ panelState.focusedControl.selectionEnd ?? panelState.focusedControl.selectionStart,
993
+ );
994
+ }
995
+ }
996
+ return;
997
+ }
998
+
999
+ if (panelState.focusedControl?.type === 'filter-value' && panelState.focusedControl.path != null) {
1000
+ const filterValue = panelSlot?.querySelector?.(`[data-base-filter-value="${panelState.focusedControl.path}"]`) ?? null;
1001
+ if (filterValue) {
1002
+ filterValue.focus?.();
1003
+ if (
1004
+ typeof filterValue.setSelectionRange === 'function'
1005
+ && panelState.focusedControl.selectionStart != null
1006
+ ) {
1007
+ filterValue.setSelectionRange(
1008
+ panelState.focusedControl.selectionStart,
1009
+ panelState.focusedControl.selectionEnd ?? panelState.focusedControl.selectionStart,
1010
+ );
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ function renderPanel(result, entry) {
1017
+ if (!entry.ui?.openPanel) {
1018
+ return '';
1019
+ }
1020
+
1021
+ if (!getMeta(result).editable) {
1022
+ return '<section class="bases-panel-card"><div class="bases-readonly-note">Inline base previews are read-only. Open the .base file to edit its sort, filter, and properties.</div></section>';
1023
+ }
1024
+
1025
+ switch (entry.ui.openPanel) {
1026
+ case 'filter':
1027
+ return renderFilterPanel(result, entry);
1028
+ case 'properties':
1029
+ return renderPropertiesPanel(result, entry);
1030
+ case 'sort':
1031
+ default:
1032
+ return renderSortPanel(result);
1033
+ }
1034
+ }
1035
+
1036
+ function renderShellHtml(result, entry) {
1037
+ return `
1038
+ <section class="bases-shell" data-base-shell-key="${escapeHtml(entry.key)}">
184
1039
  <header class="bases-toolbar">
185
1040
  <div class="bases-toolbar-main">
186
1041
  <div class="${escapeHtml(segmentedControlClassNames({ pill: true, extra: 'bases-tabs' }))}" data-base-tabs>
@@ -189,16 +1044,18 @@ function renderShellHtml(result, state) {
189
1044
  <div class="bases-meta" data-base-meta>${escapeHtml(String(result.totalRows ?? 0))} results</div>
190
1045
  </div>
191
1046
  <div class="bases-toolbar-actions">
1047
+ <div class="bases-toolbar-edit-actions" data-base-action-slot>${renderActionButtons(result, entry)}</div>
192
1048
  <label class="bases-search-shell" aria-label="Search this base">
193
1049
  <svg class="bases-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
194
1050
  <circle cx="11" cy="11" r="7"></circle>
195
1051
  <path d="m20 20-3.5-3.5"></path>
196
1052
  </svg>
197
- <input class="${escapeHtml(inputClassNames({ extra: 'bases-search-input' }))}" type="search" value="${escapeHtml(state.search)}" placeholder="Search this base" aria-label="Search this base">
1053
+ <input class="${escapeHtml(inputClassNames({ extra: 'bases-search-input' }))}" type="search" value="${escapeHtml(entry.search)}" placeholder="Search this base" aria-label="Search this base">
198
1054
  </label>
199
1055
  <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', pill: true, extra: 'bases-export-btn' }))}">Export CSV</button>
200
1056
  </div>
201
1057
  </header>
1058
+ <div class="bases-panels" data-base-panel-slot>${renderPanel(result, entry)}</div>
202
1059
  <div data-base-summary-slot>${renderSummaryBar(result.summaries)}</div>
203
1060
  <div class="bases-content" data-base-content>${renderViewBody(result)}</div>
204
1061
  </section>
@@ -241,6 +1098,18 @@ function updateShellContent(entry, result) {
241
1098
  meta.textContent = `${String(result.totalRows ?? 0)} results`;
242
1099
  }
243
1100
 
1101
+ const actionSlot = shell.querySelector('[data-base-action-slot]');
1102
+ if (actionSlot) {
1103
+ actionSlot.innerHTML = renderActionButtons(result, entry);
1104
+ }
1105
+
1106
+ const panelSlot = shell.querySelector('[data-base-panel-slot]');
1107
+ if (panelSlot) {
1108
+ const panelState = capturePanelState(panelSlot);
1109
+ panelSlot.innerHTML = renderPanel(result, entry);
1110
+ restorePanelState(panelSlot, panelState);
1111
+ }
1112
+
244
1113
  const summarySlot = shell.querySelector('[data-base-summary-slot]');
245
1114
  if (summarySlot) {
246
1115
  summarySlot.innerHTML = renderSummaryBar(result.summaries);
@@ -280,19 +1149,57 @@ function parseDownloadFileName(contentDisposition = '') {
280
1149
  export class BasesPreviewController {
281
1150
  constructor({
282
1151
  getActiveFilePath = () => '',
1152
+ getSession = () => null,
283
1153
  onOpenFile,
284
1154
  previewElement,
1155
+ replaceBaseSource = null,
285
1156
  toastController,
286
1157
  vaultApiClient,
287
1158
  }) {
288
1159
  this.getActiveFilePath = getActiveFilePath;
1160
+ this.getSession = getSession;
289
1161
  this.onOpenFile = onOpenFile;
290
1162
  this.previewElement = previewElement;
1163
+ this.replaceBaseSource = replaceBaseSource;
291
1164
  this.toastController = toastController;
292
1165
  this.vaultApiClient = vaultApiClient;
293
1166
  this.entries = new Map();
294
1167
  this.searchTimers = new Map();
295
1168
 
1169
+ this.applyFilterSuggestion = (entry, path, nextValue) => {
1170
+ void this.updateBuilderFilter(entry, (group) => updateNodeAtPath(group, path, (node) => ({
1171
+ ...node,
1172
+ value: nextValue,
1173
+ })));
1174
+ };
1175
+
1176
+ this.persistBuilderFilter = (entry) => {
1177
+ const nextGroup = getBuilderFilterState(entry, entry.result);
1178
+ entry.ui.builderFilter = nextGroup;
1179
+ void this.updateViewConfig(entry, (config) => {
1180
+ config.filters = compileFilterGroup(nextGroup, entry.result);
1181
+ return config;
1182
+ });
1183
+ };
1184
+
1185
+ this.handlePointerDown = (event) => {
1186
+ const filterSuggestion = event.target.closest('[data-base-filter-suggestion]');
1187
+ if (!filterSuggestion) {
1188
+ return;
1189
+ }
1190
+
1191
+ const shell = event.target.closest('[data-base-shell-key]');
1192
+ const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
1193
+ if (!entry) {
1194
+ return;
1195
+ }
1196
+
1197
+ event.preventDefault();
1198
+ const path = decodePath(filterSuggestion.dataset.baseFilterSuggestion || '');
1199
+ const nextValue = filterSuggestion.dataset.baseFilterSuggestionValue || '';
1200
+ this.applyFilterSuggestion(entry, path, nextValue);
1201
+ };
1202
+
296
1203
  this.handleClick = (event) => {
297
1204
  const openButton = event.target.closest('[data-base-open-file]');
298
1205
  if (openButton) {
@@ -301,58 +1208,378 @@ export class BasesPreviewController {
301
1208
  return;
302
1209
  }
303
1210
 
1211
+ const shell = event.target.closest('[data-base-shell-key]');
1212
+ const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
1213
+ if (!entry) {
1214
+ return;
1215
+ }
1216
+
304
1217
  const tab = event.target.closest('.bases-view-tab');
305
1218
  if (tab) {
306
- const shell = tab.closest('[data-base-shell-key]');
1219
+ entry.payload.view = tab.dataset.baseView || '';
1220
+ entry.ui.openPanel = '';
1221
+ void this.renderEntry(entry);
1222
+ return;
1223
+ }
1224
+
1225
+ const panelButton = event.target.closest('[data-base-panel]');
1226
+ if (panelButton) {
1227
+ const panel = panelButton.dataset.basePanel || '';
1228
+ entry.ui.openPanel = entry.ui.openPanel === panel ? '' : panel;
1229
+ entry.ui.rawFilterText = serializeRawFilterText(getEditableViewConfig(entry.result).filters);
1230
+ updateShellContent(entry, entry.result);
1231
+ return;
1232
+ }
1233
+
1234
+ const exportButton = event.target.closest('.bases-export-btn');
1235
+ if (exportButton) {
1236
+ void this.exportEntry(entry);
1237
+ return;
1238
+ }
1239
+
1240
+ if (event.target.closest('[data-base-sort-add]')) {
1241
+ void this.updateViewConfig(entry, (config) => {
1242
+ const used = new Set((config.sort ?? []).map((sort) => sort.property));
1243
+ const nextProperty = getAvailableProperties(entry.result).find((property) => !used.has(property.id))?.id ?? 'file.name';
1244
+ config.sort = [...(config.sort ?? []), { direction: 'asc', property: nextProperty }];
1245
+ return config;
1246
+ });
1247
+ return;
1248
+ }
1249
+
1250
+ const sortDelete = event.target.closest('[data-base-sort-delete]');
1251
+ if (sortDelete) {
1252
+ const index = Number.parseInt(sortDelete.dataset.baseSortDelete || '', 10);
1253
+ void this.updateViewConfig(entry, (config) => {
1254
+ config.sort = (config.sort ?? []).filter((_, sortIndex) => sortIndex !== index);
1255
+ return config;
1256
+ });
1257
+ return;
1258
+ }
1259
+
1260
+ const sortMove = event.target.closest('[data-base-sort-move]');
1261
+ if (sortMove) {
1262
+ const [indexText, direction] = String(sortMove.dataset.baseSortMove || '').split(':');
1263
+ const index = Number.parseInt(indexText, 10);
1264
+ void this.updateViewConfig(entry, (config) => {
1265
+ const nextSorts = [...(config.sort ?? [])];
1266
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
1267
+ if (targetIndex < 0 || targetIndex >= nextSorts.length) {
1268
+ return config;
1269
+ }
1270
+ [nextSorts[index], nextSorts[targetIndex]] = [nextSorts[targetIndex], nextSorts[index]];
1271
+ config.sort = nextSorts;
1272
+ return config;
1273
+ });
1274
+ return;
1275
+ }
1276
+
1277
+ if (event.target.closest('[data-base-clear-group-by]')) {
1278
+ void this.updateViewConfig(entry, (config) => {
1279
+ config.groupBy = null;
1280
+ return config;
1281
+ });
1282
+ return;
1283
+ }
1284
+
1285
+ const filterModeButton = event.target.closest('[data-base-filter-mode]');
1286
+ if (filterModeButton) {
1287
+ entry.ui.filterMode = filterModeButton.dataset.baseFilterMode || 'builder';
1288
+ if (entry.ui.filterMode === 'advanced') {
1289
+ entry.ui.rawFilterText = serializeRawFilterText(getEditableViewConfig(entry.result).filters);
1290
+ entry.ui.builderFilter = null;
1291
+ }
1292
+ updateShellContent(entry, entry.result);
1293
+ return;
1294
+ }
1295
+
1296
+ if (event.target.closest('[data-base-filter-save-advanced]')) {
1297
+ void this.saveAdvancedFilter(entry);
1298
+ return;
1299
+ }
1300
+
1301
+ const addFilterRule = event.target.closest('[data-base-filter-add-rule]');
1302
+ if (addFilterRule) {
1303
+ const path = decodePath(addFilterRule.dataset.baseFilterAddRule || '');
1304
+ void this.updateBuilderFilter(entry, (group) => addNodeAtPath(group, path, {
1305
+ operator: 'is',
1306
+ propertyId: getAvailableProperties(entry.result)[0]?.id ?? 'file.name',
1307
+ type: 'rule',
1308
+ value: '',
1309
+ }));
1310
+ return;
1311
+ }
1312
+
1313
+ const addFilterGroup = event.target.closest('[data-base-filter-add-group]');
1314
+ if (addFilterGroup) {
1315
+ const path = decodePath(addFilterGroup.dataset.baseFilterAddGroup || '');
1316
+ void this.updateBuilderFilter(entry, (group) => addNodeAtPath(group, path, createEmptyFilterGroup('and')));
1317
+ return;
1318
+ }
1319
+
1320
+ const removeFilter = event.target.closest('[data-base-filter-remove]');
1321
+ if (removeFilter) {
1322
+ const path = decodePath(removeFilter.dataset.baseFilterRemove || '');
1323
+ void this.updateBuilderFilter(entry, (group) => removeNodeAtPath(group, path));
1324
+ return;
1325
+ }
1326
+
1327
+ const filterSuggestion = event.target.closest('[data-base-filter-suggestion]');
1328
+ if (filterSuggestion) {
1329
+ event.preventDefault?.();
1330
+ if (event.detail != null && event.detail !== 0) {
1331
+ return;
1332
+ }
1333
+ const path = decodePath(filterSuggestion.dataset.baseFilterSuggestion || '');
1334
+ const nextValue = filterSuggestion.dataset.baseFilterSuggestionValue || '';
1335
+ this.applyFilterSuggestion(entry, path, nextValue);
1336
+ }
1337
+ };
1338
+
1339
+ this.handleInput = (event) => {
1340
+ const input = event.target.closest('.bases-search-input');
1341
+ if (input) {
1342
+ const shell = input.closest('[data-base-shell-key]');
307
1343
  const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
308
1344
  if (!entry) {
309
1345
  return;
310
1346
  }
311
1347
 
312
- entry.payload.view = tab.dataset.baseView || '';
313
- void this.renderEntry(entry);
1348
+ entry.search = input.value || '';
1349
+ clearTimeout(this.searchTimers.get(entry.key));
1350
+ this.searchTimers.set(entry.key, setTimeout(() => {
1351
+ entry.payload.search = entry.search;
1352
+ void this.renderEntry(entry);
1353
+ }, 180));
314
1354
  return;
315
1355
  }
316
1356
 
317
- const exportButton = event.target.closest('.bases-export-btn');
318
- if (exportButton) {
319
- const shell = exportButton.closest('[data-base-shell-key]');
1357
+ const propertiesSearch = event.target.closest('[data-base-properties-search]');
1358
+ if (propertiesSearch) {
1359
+ const shell = propertiesSearch.closest('[data-base-shell-key]');
320
1360
  const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
321
1361
  if (!entry) {
322
1362
  return;
323
1363
  }
324
1364
 
325
- void this.exportEntry(entry);
1365
+ entry.ui.propertySearch = propertiesSearch.value || '';
1366
+ updateShellContent(entry, entry.result);
1367
+ return;
1368
+ }
1369
+
1370
+ const advancedFilter = event.target.closest('[data-base-filter-advanced]');
1371
+ if (advancedFilter) {
1372
+ const shell = advancedFilter.closest('[data-base-shell-key]');
1373
+ const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
1374
+ if (!entry) {
1375
+ return;
1376
+ }
1377
+
1378
+ entry.ui.rawFilterText = advancedFilter.value || '';
1379
+ return;
1380
+ }
1381
+
1382
+ const filterValue = event.target.closest('[data-base-filter-value]');
1383
+ if (filterValue) {
1384
+ const shell = filterValue.closest('[data-base-shell-key]');
1385
+ const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
1386
+ if (!entry) {
1387
+ return;
1388
+ }
1389
+
1390
+ const path = decodePath(filterValue.dataset.baseFilterValue || '');
1391
+ const parsed = entry.ui.builderFilter
1392
+ ?? parseFilterNode(getEditableViewConfig(entry.result).filters)
1393
+ ?? createEmptyFilterGroup('and');
1394
+ entry.ui.builderFilter = updateNodeAtPath(parsed, path, (node) => ({
1395
+ ...node,
1396
+ value: filterValue.value || '',
1397
+ }));
1398
+ syncFilterValueCombobox(entry, entry.result, path);
326
1399
  }
327
1400
  };
328
1401
 
329
- this.handleInput = (event) => {
330
- const input = event.target.closest('.bases-search-input');
331
- if (!input) {
1402
+ this.handleChange = (event) => {
1403
+ const shell = event.target.closest('[data-base-shell-key]');
1404
+ const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
1405
+ if (!entry) {
1406
+ return;
1407
+ }
1408
+
1409
+ const groupByProperty = event.target.closest('[data-base-group-by-property]');
1410
+ if (groupByProperty) {
1411
+ void this.updateViewConfig(entry, (config) => {
1412
+ config.groupBy = groupByProperty.value
1413
+ ? {
1414
+ direction: config.groupBy?.direction ?? 'asc',
1415
+ property: groupByProperty.value,
1416
+ }
1417
+ : null;
1418
+ return config;
1419
+ });
1420
+ return;
1421
+ }
1422
+
1423
+ const groupByDirection = event.target.closest('[data-base-group-by-direction]');
1424
+ if (groupByDirection) {
1425
+ void this.updateViewConfig(entry, (config) => {
1426
+ config.groupBy = config.groupBy?.property
1427
+ ? {
1428
+ direction: groupByDirection.value || 'asc',
1429
+ property: config.groupBy.property,
1430
+ }
1431
+ : null;
1432
+ return config;
1433
+ });
1434
+ return;
1435
+ }
1436
+
1437
+ const sortProperty = event.target.closest('[data-base-sort-property]');
1438
+ if (sortProperty) {
1439
+ const index = Number.parseInt(sortProperty.dataset.baseSortProperty || '', 10);
1440
+ void this.updateViewConfig(entry, (config) => {
1441
+ config.sort = (config.sort ?? []).map((sortConfig, sortIndex) => (
1442
+ sortIndex === index
1443
+ ? { ...sortConfig, property: sortProperty.value }
1444
+ : sortConfig
1445
+ ));
1446
+ return config;
1447
+ });
1448
+ return;
1449
+ }
1450
+
1451
+ const sortDirection = event.target.closest('[data-base-sort-direction]');
1452
+ if (sortDirection) {
1453
+ const index = Number.parseInt(sortDirection.dataset.baseSortDirection || '', 10);
1454
+ void this.updateViewConfig(entry, (config) => {
1455
+ config.sort = (config.sort ?? []).map((sortConfig, sortIndex) => (
1456
+ sortIndex === index
1457
+ ? { ...sortConfig, direction: sortDirection.value || 'asc' }
1458
+ : sortConfig
1459
+ ));
1460
+ return config;
1461
+ });
332
1462
  return;
333
1463
  }
334
1464
 
335
- const shell = input.closest('[data-base-shell-key]');
1465
+ const propertyToggle = event.target.closest('[data-base-property-toggle]');
1466
+ if (propertyToggle) {
1467
+ const propertyId = propertyToggle.dataset.basePropertyToggle || '';
1468
+ void this.updateViewConfig(entry, (config) => {
1469
+ const nextOrder = config.order?.length > 0
1470
+ ? [...config.order]
1471
+ : getVisiblePropertyIds(entry.result);
1472
+ const existingIndex = nextOrder.indexOf(propertyId);
1473
+ if (propertyToggle.checked && existingIndex === -1) {
1474
+ nextOrder.push(propertyId);
1475
+ }
1476
+ if (!propertyToggle.checked && existingIndex >= 0) {
1477
+ nextOrder.splice(existingIndex, 1);
1478
+ }
1479
+ config.order = nextOrder;
1480
+ return config;
1481
+ });
1482
+ return;
1483
+ }
1484
+
1485
+ const filterConjunction = event.target.closest('[data-base-filter-conjunction]');
1486
+ if (filterConjunction) {
1487
+ const path = decodePath(filterConjunction.dataset.baseFilterConjunction || '');
1488
+ void this.updateBuilderFilter(entry, (group) => updateNodeAtPath(group, path, (node) => ({
1489
+ ...node,
1490
+ conjunction: filterConjunction.value || 'and',
1491
+ })));
1492
+ return;
1493
+ }
1494
+
1495
+ const filterProperty = event.target.closest('[data-base-filter-property]');
1496
+ if (filterProperty) {
1497
+ const path = decodePath(filterProperty.dataset.baseFilterProperty || '');
1498
+ void (async () => {
1499
+ await this.updateBuilderFilter(entry, (group) => updateNodeAtPath(group, path, (node) => {
1500
+ const nextOperators = getPropertyFilterOperators(entry.result, filterProperty.value);
1501
+ const nextOperator = nextOperators[0] ?? 'is';
1502
+ return {
1503
+ ...node,
1504
+ operator: nextOperator,
1505
+ propertyId: filterProperty.value,
1506
+ value: '',
1507
+ };
1508
+ }));
1509
+ await this.ensurePropertyValues(entry, filterProperty.value);
1510
+ })();
1511
+ return;
1512
+ }
1513
+
1514
+ const filterOperator = event.target.closest('[data-base-filter-operator]');
1515
+ if (filterOperator) {
1516
+ const path = decodePath(filterOperator.dataset.baseFilterOperator || '');
1517
+ void this.updateBuilderFilter(entry, (group) => updateNodeAtPath(group, path, (node) => ({
1518
+ ...node,
1519
+ operator: filterOperator.value || 'is',
1520
+ })));
1521
+ return;
1522
+ }
1523
+
1524
+ const filterValue = event.target.closest('[data-base-filter-value]');
1525
+ if (filterValue) {
1526
+ this.persistBuilderFilter(entry);
1527
+ }
1528
+ };
1529
+
1530
+ this.handleFocusIn = (event) => {
1531
+ const filterValue = event.target.closest('[data-base-filter-value]');
1532
+ if (!filterValue) {
1533
+ return;
1534
+ }
1535
+
1536
+ const shell = filterValue.closest('[data-base-shell-key]');
336
1537
  const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
337
1538
  if (!entry) {
338
1539
  return;
339
1540
  }
340
1541
 
341
- entry.search = input.value || '';
342
- clearTimeout(this.searchTimers.get(entry.key));
343
- this.searchTimers.set(entry.key, setTimeout(() => {
344
- entry.payload.search = entry.search;
345
- void this.renderEntry(entry);
346
- }, 180));
1542
+ const path = decodePath(filterValue.dataset.baseFilterValue || '');
1543
+ const parsed = parseFilterNode(getEditableViewConfig(entry.result).filters);
1544
+ const node = parsed ? getNodeAtPath(parsed, path) : null;
1545
+ if (node?.propertyId) {
1546
+ void this.ensurePropertyValues(entry, node.propertyId);
1547
+ }
347
1548
  };
348
1549
 
1550
+ this.previewElement?.addEventListener('pointerdown', this.handlePointerDown);
349
1551
  this.previewElement?.addEventListener('click', this.handleClick);
350
1552
  this.previewElement?.addEventListener('input', this.handleInput);
1553
+ this.previewElement?.addEventListener('change', this.handleChange);
1554
+ this.previewElement?.addEventListener('focusin', this.handleFocusIn);
1555
+ }
1556
+
1557
+ ensureEntryState(entry) {
1558
+ if (!entry.ui) {
1559
+ entry.ui = {
1560
+ builderFilter: null,
1561
+ filterMode: 'builder',
1562
+ openPanel: '',
1563
+ propertySearch: '',
1564
+ rawFilterText: '',
1565
+ };
1566
+ }
1567
+
1568
+ if (!Object.prototype.hasOwnProperty.call(entry.ui, 'builderFilter')) {
1569
+ entry.ui.builderFilter = null;
1570
+ }
1571
+
1572
+ if (!entry.propertyValueOptions) {
1573
+ entry.propertyValueOptions = new Map();
1574
+ }
351
1575
  }
352
1576
 
353
1577
  destroy() {
1578
+ this.previewElement?.removeEventListener('pointerdown', this.handlePointerDown);
354
1579
  this.previewElement?.removeEventListener('click', this.handleClick);
355
1580
  this.previewElement?.removeEventListener('input', this.handleInput);
1581
+ this.previewElement?.removeEventListener('change', this.handleChange);
1582
+ this.previewElement?.removeEventListener('focusin', this.handleFocusIn);
356
1583
  this.searchTimers.forEach((timer) => clearTimeout(timer));
357
1584
  this.searchTimers.clear();
358
1585
  this.entries.clear();
@@ -373,14 +1600,22 @@ export class BasesPreviewController {
373
1600
  sourcePath: placeholder.dataset.baseSourcePath || '',
374
1601
  view: placeholder.dataset.baseView || '',
375
1602
  };
376
- const entry = existing ?? {
377
- key,
378
- payload,
379
- placeholder,
380
- requestVersion: 0,
381
- result: null,
382
- search: '',
383
- };
1603
+ const entry = existing ?? {
1604
+ key,
1605
+ payload,
1606
+ placeholder,
1607
+ propertyValueOptions: new Map(),
1608
+ requestVersion: 0,
1609
+ result: null,
1610
+ search: '',
1611
+ ui: {
1612
+ builderFilter: null,
1613
+ filterMode: 'builder',
1614
+ openPanel: '',
1615
+ propertySearch: '',
1616
+ rawFilterText: '',
1617
+ },
1618
+ };
384
1619
  entry.payload = payload;
385
1620
  entry.placeholder = placeholder;
386
1621
  this.entries.set(key, entry);
@@ -400,9 +1635,17 @@ export class BasesPreviewController {
400
1635
  view: '',
401
1636
  },
402
1637
  placeholder: renderHost,
1638
+ propertyValueOptions: new Map(),
403
1639
  requestVersion: 0,
404
1640
  result: null,
405
1641
  search: '',
1642
+ ui: {
1643
+ builderFilter: null,
1644
+ filterMode: 'builder',
1645
+ openPanel: '',
1646
+ propertySearch: '',
1647
+ rawFilterText: '',
1648
+ },
406
1649
  };
407
1650
  entry.placeholder = renderHost;
408
1651
  entry.payload.path = filePath;
@@ -413,6 +1656,7 @@ export class BasesPreviewController {
413
1656
  }
414
1657
 
415
1658
  async renderEntry(entry) {
1659
+ this.ensureEntryState(entry);
416
1660
  if (!entry?.placeholder?.isConnected) {
417
1661
  return;
418
1662
  }
@@ -432,6 +1676,10 @@ export class BasesPreviewController {
432
1676
  return;
433
1677
  }
434
1678
  entry.result = response.result;
1679
+ entry.ui.builderFilter = parseFilterNode(getEditableViewConfig(entry.result).filters);
1680
+ if (!entry.ui.rawFilterText) {
1681
+ entry.ui.rawFilterText = serializeRawFilterText(getEditableViewConfig(entry.result).filters);
1682
+ }
435
1683
  updateShellContent(entry, entry.result);
436
1684
  } catch (error) {
437
1685
  if (requestVersion !== entry.requestVersion || !entry?.placeholder?.isConnected) {
@@ -450,6 +1698,7 @@ export class BasesPreviewController {
450
1698
  }
451
1699
 
452
1700
  async exportEntry(entry) {
1701
+ this.ensureEntryState(entry);
453
1702
  try {
454
1703
  const response = await this.vaultApiClient.exportBaseCsv({
455
1704
  activeFilePath: this.getActiveFilePath?.() ?? '',
@@ -460,4 +1709,115 @@ export class BasesPreviewController {
460
1709
  this.toastController?.show?.(error.message || 'Failed to export base CSV');
461
1710
  }
462
1711
  }
1712
+
1713
+ async ensurePropertyValues(entry, propertyId = '') {
1714
+ this.ensureEntryState(entry);
1715
+ if (!propertyId) {
1716
+ return;
1717
+ }
1718
+
1719
+ const cacheKey = createPropertyValuesCacheKey(entry, propertyId);
1720
+ const cachedEntry = entry.propertyValueOptions.get(propertyId) ?? null;
1721
+ if (cachedEntry?.cacheKey === cacheKey) {
1722
+ return;
1723
+ }
1724
+
1725
+ try {
1726
+ const response = await this.vaultApiClient.queryBasePropertyValues({
1727
+ activeFilePath: this.getActiveFilePath?.() ?? '',
1728
+ ...entry.payload,
1729
+ propertyId,
1730
+ });
1731
+ entry.propertyValueOptions.set(propertyId, {
1732
+ cacheKey,
1733
+ values: response.result?.values ?? [],
1734
+ });
1735
+ if (entry.result && entry.ui.openPanel === 'filter') {
1736
+ let synced = false;
1737
+ forEachFilterRule(getBuilderFilterState(entry, entry.result), (rule, path) => {
1738
+ if (rule.propertyId === propertyId) {
1739
+ synced = syncFilterValueCombobox(entry, entry.result, path, { fallbackToFullRender: false }) || synced;
1740
+ }
1741
+ });
1742
+ if (!synced) {
1743
+ updateShellContent(entry, entry.result);
1744
+ }
1745
+ }
1746
+ } catch {
1747
+ entry.propertyValueOptions.set(propertyId, {
1748
+ cacheKey,
1749
+ values: [],
1750
+ });
1751
+ }
1752
+ }
1753
+
1754
+ async applyTransformedSource(entry, transformed) {
1755
+ this.ensureEntryState(entry);
1756
+ if (isStandaloneBaseEntry(entry)) {
1757
+ entry.payload.source = transformed.source;
1758
+ this.replaceBaseSource?.({
1759
+ path: entry.payload.path,
1760
+ source: transformed.source,
1761
+ });
1762
+ return;
1763
+ }
1764
+
1765
+ await this.vaultApiClient.writeFile({
1766
+ content: transformed.source,
1767
+ path: entry.payload.path,
1768
+ });
1769
+ entry.payload.source = null;
1770
+ }
1771
+
1772
+ async updateViewConfig(entry, updater) {
1773
+ this.ensureEntryState(entry);
1774
+ if (!entry?.result || !getMeta(entry.result).editable) {
1775
+ return;
1776
+ }
1777
+
1778
+ const nextConfig = updater(getEditableViewConfig(entry.result));
1779
+ try {
1780
+ const response = await this.vaultApiClient.transformBase({
1781
+ activeFilePath: this.getActiveFilePath?.() ?? '',
1782
+ mutation: {
1783
+ config: nextConfig,
1784
+ type: 'set-view-config',
1785
+ view: entry.result.view.id,
1786
+ },
1787
+ ...entry.payload,
1788
+ });
1789
+ const transformed = response.result;
1790
+ await this.applyTransformedSource(entry, transformed);
1791
+ entry.result = transformed.result;
1792
+ entry.ui.rawFilterText = serializeRawFilterText(getEditableViewConfig(entry.result).filters);
1793
+ updateShellContent(entry, entry.result);
1794
+ } catch (error) {
1795
+ this.toastController?.show?.(error.message || 'Failed to update base');
1796
+ }
1797
+ }
1798
+
1799
+ async updateBuilderFilter(entry, updater) {
1800
+ this.ensureEntryState(entry);
1801
+ const parsed = entry.ui.builderFilter ?? parseFilterNode(getEditableViewConfig(entry.result).filters) ?? createEmptyFilterGroup('and');
1802
+ const nextGroup = updater(parsed);
1803
+ entry.ui.builderFilter = nextGroup;
1804
+ await this.updateViewConfig(entry, (config) => {
1805
+ config.filters = compileFilterGroup(nextGroup, entry.result);
1806
+ return config;
1807
+ });
1808
+ }
1809
+
1810
+ async saveAdvancedFilter(entry) {
1811
+ this.ensureEntryState(entry);
1812
+ try {
1813
+ const parsed = parseRawFilterText(entry.ui.rawFilterText ?? '');
1814
+ entry.ui.builderFilter = parseFilterNode(parsed);
1815
+ await this.updateViewConfig(entry, (config) => {
1816
+ config.filters = parsed;
1817
+ return config;
1818
+ });
1819
+ } catch (error) {
1820
+ this.toastController?.show?.(error.message || 'Invalid advanced filter');
1821
+ }
1822
+ }
463
1823
  }