collabmd 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/client/assets/{drawio-editor-B4sHHwWJ.js → drawio-editor-BMTwP5mD.js} +1 -1
  2. package/dist/client/assets/drawioEditor-D6clB5OH.js +2 -0
  3. package/dist/client/assets/{editor-session-BKHEYkbY.js → editor-session-Dzy_85Wp.js} +2 -2
  4. package/dist/client/assets/embedded-editor-base-CgEkjNYn.css +1 -0
  5. package/dist/client/assets/{excalidraw-editor-CHbeNZu6.js → excalidraw-editor-CAP9F8CB.js} +3 -3
  6. package/dist/client/assets/{excalidrawEditor-BhaTX2Ko.js → excalidrawEditor-DD4AvzYP.js} +2 -2
  7. package/dist/client/assets/exportDocument-BbkN85c4.css +1 -0
  8. package/dist/client/assets/index-BIt5EZXz.css +1 -0
  9. package/dist/client/assets/{index-kPgEYvEX.js → index-Czg_KL-i.js} +2 -2
  10. package/dist/client/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/client/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/client/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/client/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/client/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/client/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  18. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  19. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  20. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  21. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  22. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  23. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  24. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  25. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  26. package/dist/client/assets/main-DeowJOSp.js +1368 -0
  27. package/dist/client/assets/vault-api-client-D7Ck0CvR.css +1 -0
  28. package/dist/client/assets/vault-api-client-p7h7cXKM.js +1 -0
  29. package/dist/client/drawio-editor.html +1 -1
  30. package/dist/client/excalidraw-editor.html +1 -1
  31. package/dist/client/export-document.html +2 -2
  32. package/dist/client/index.html +2 -2
  33. package/package.json +8 -5
  34. package/src/client/app/main-entry.js +2 -0
  35. package/src/client/application/app-shell/git-feature.js +28 -36
  36. package/src/client/application/app-shell/presence-feature.js +3 -1
  37. package/src/client/application/diagram-preview-hydrator.js +24 -15
  38. package/src/client/application/mermaid-preview-hydrator.js +121 -4
  39. package/src/client/application/plantuml-preview-hydrator.js +6 -16
  40. package/src/client/application/preview-renderer.js +11 -2
  41. package/src/client/bootstrap/collabmd-app-shell.js +182 -50
  42. package/src/client/domain/excalidraw-document-switch.js +3 -0
  43. package/src/client/excalidraw-editor.js +187 -84
  44. package/src/client/infrastructure/auth-client.js +4 -4
  45. package/src/client/infrastructure/backlinks-api-client.js +18 -0
  46. package/src/client/infrastructure/comment-thread-store.js +4 -0
  47. package/src/client/infrastructure/editor-session.js +6 -0
  48. package/src/client/infrastructure/editor-view-adapter.js +23 -0
  49. package/src/client/infrastructure/git-api-client.js +134 -0
  50. package/src/client/infrastructure/plantuml-api-client.js +21 -0
  51. package/src/client/infrastructure/vault-api-client.js +115 -0
  52. package/src/client/presentation/backlinks-panel.js +6 -9
  53. package/src/client/presentation/bases-preview-controller.js +1413 -34
  54. package/src/client/presentation/comments-panel.js +50 -5
  55. package/src/client/presentation/excalidraw-embed-controller.js +251 -15
  56. package/src/client/presentation/excalidraw-embed-reconciler.js +0 -1
  57. package/src/client/presentation/file-action-controller.js +97 -0
  58. package/src/client/presentation/file-explorer-controller.js +2 -0
  59. package/src/client/presentation/file-explorer-view.js +368 -0
  60. package/src/client/presentation/file-history-view-controller.js +41 -40
  61. package/src/client/presentation/git-diff-view-controller.js +70 -53
  62. package/src/client/presentation/git-panel-controller.js +12 -30
  63. package/src/client/styles/components/scrollbars.css +5 -0
  64. package/src/client/styles/export-document.css +45 -2
  65. package/src/client/styles/features/auth-gate.css +13 -24
  66. package/src/client/styles/features/comment-card.css +6 -0
  67. package/src/client/styles/features/comments-drawer.css +38 -18
  68. package/src/client/styles/features/diagram-preview.css +33 -0
  69. package/src/client/styles/features/embedded-preview.css +37 -11
  70. package/src/client/styles/features/preview-markdown.css +559 -39
  71. package/src/client/styles/foundation/themes.css +4 -0
  72. package/src/client/styles/foundation/tokens.css +4 -3
  73. package/src/client/styles/layout/sidebar.css +72 -0
  74. package/src/server/auth/auth-common.js +224 -0
  75. package/src/server/auth/auth-constants.js +30 -0
  76. package/src/server/auth/auth-strategies.js +320 -0
  77. package/src/server/auth/create-auth-service.js +24 -545
  78. package/src/server/domain/backlink-index.js +177 -5
  79. package/src/server/domain/bases/base-definition.js +382 -0
  80. package/src/server/domain/bases/base-expression-runtime.js +1196 -0
  81. package/src/server/domain/bases/base-index-snapshot-store.js +565 -0
  82. package/src/server/domain/bases/base-query-metadata.js +242 -0
  83. package/src/server/domain/bases/base-query-results.js +215 -0
  84. package/src/server/domain/bases/base-query-service.js +406 -1992
  85. package/src/server/domain/bases/base-transform.js +116 -0
  86. package/src/server/domain/collaboration/collaboration-room.js +36 -1
  87. package/src/server/domain/collaboration/room-client-state-store.js +1 -0
  88. package/src/server/domain/collaboration/room-persistence-controller.js +1 -1
  89. package/src/server/infrastructure/http/create-vault-api-query-handler.js +161 -1
  90. package/src/server/infrastructure/http/http-response.js +56 -0
  91. package/src/server/infrastructure/persistence/vault-file-store.js +110 -0
  92. package/dist/client/assets/drawioEditor-DGpo2hcx.js +0 -2
  93. package/dist/client/assets/embedded-editor-base-DgmRcsR8.css +0 -1
  94. package/dist/client/assets/exportDocument-DmV7Gngb.css +0 -1
  95. package/dist/client/assets/index-D69HOZHr.css +0 -1
  96. package/dist/client/assets/main-DSxNTtD-.js +0 -1243
  97. package/dist/client/assets/vault-api-client-BzHQseMN.js +0 -1
  98. package/dist/client/assets/vault-api-client-De7kMvrE.css +0 -1
  99. /package/dist/client/assets/{exportDocument-CtZKWE2c.js → exportDocument-Zfwq1XVx.js} +0 -0
@@ -1,6 +1,9 @@
1
1
  import { isImageAttachmentFilePath } from '../../domain/file-kind.js';
2
2
  import { resolveApiUrl } from '../domain/runtime-paths.js';
3
3
  import { escapeHtml } from '../domain/vault-utils.js';
4
+ import { buttonClassNames } from './components/ui/button.js';
5
+ import { inputClassNames } from './components/ui/input.js';
6
+ import { segmentedButtonClassNames, segmentedControlClassNames } from './components/ui/segmented-control.js';
4
7
 
5
8
  function createShellKey() {
6
9
  return `base-${Math.random().toString(36).slice(2, 10)}`;
@@ -14,6 +17,18 @@ function looksLikeExternalImageUrl(value = '') {
14
17
  return /^https?:\/\/.+\.(?:png|jpe?g|webp|gif|svg)(?:[?#].*)?$/iu.test(String(value ?? '').trim());
15
18
  }
16
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
+
17
32
  function getCardImageCell(view, columns, row) {
18
33
  const preferred = view.image
19
34
  || columns.find((column) => ['cover', 'image'].includes(String(column.id ?? '').toLowerCase()))?.id
@@ -73,11 +88,11 @@ function renderValue(cell = {}) {
73
88
  return `<a class="bases-link" href="${escapeHtml(cell.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(formatCellText(cell))}</a>`;
74
89
  }
75
90
  if (cell.path) {
76
- return `<button type="button" class="bases-link bases-link-button" data-base-open-file="${escapeHtml(cell.path)}">${escapeHtml(formatCellText(cell))}</button>`;
91
+ return `<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact', pill: true, extra: ['bases-link', 'bases-link-button'] }))}" data-base-open-file="${escapeHtml(cell.path)}">${escapeHtml(formatCellText(cell))}</button>`;
77
92
  }
78
93
  }
79
94
  if (type === 'file' && cell.path) {
80
- return `<button type="button" class="bases-link bases-link-button" data-base-open-file="${escapeHtml(cell.path)}">${escapeHtml(formatCellText(cell))}</button>`;
95
+ return `<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact', pill: true, extra: ['bases-link', 'bases-link-button'] }))}" data-base-open-file="${escapeHtml(cell.path)}">${escapeHtml(formatCellText(cell))}</button>`;
81
96
  }
82
97
 
83
98
  const text = formatCellText(cell);
@@ -92,7 +107,7 @@ function renderRowValue(column, row) {
92
107
  && cell.type !== 'empty'
93
108
  && (column.id === 'file.name' || column.id === 'file.basename')
94
109
  ) {
95
- return `<button type="button" class="bases-link bases-link-button" data-base-open-file="${escapeHtml(row.path)}">${escapeHtml(formatCellText(cell))}</button>`;
110
+ return `<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact', pill: true, extra: ['bases-link', 'bases-link-button'] }))}" data-base-open-file="${escapeHtml(row.path)}">${escapeHtml(formatCellText(cell))}</button>`;
96
111
  }
97
112
 
98
113
  return renderValue(cell);
@@ -171,25 +186,876 @@ function renderViewBody(result) {
171
186
 
172
187
  function renderViewTabs(result) {
173
188
  return (result.views ?? []).map((view) => (
174
- `<button type="button" class="bases-view-tab${view.id === result.view.id ? ' is-active' : ''}" data-base-view="${escapeHtml(view.id)}">${escapeHtml(view.name)}</button>`
189
+ `<button type="button" class="${escapeHtml(segmentedButtonClassNames({ active: view.id === result.view.id, extra: 'bases-view-tab' }))}" data-base-view="${escapeHtml(view.id)}">${escapeHtml(view.name)}</button>`
175
190
  )).join('');
176
191
  }
177
192
 
178
- 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
+
179
323
  return `
180
- <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)}">
181
1039
  <header class="bases-toolbar">
182
1040
  <div class="bases-toolbar-main">
183
- <div class="bases-tabs" data-base-tabs>
1041
+ <div class="${escapeHtml(segmentedControlClassNames({ pill: true, extra: 'bases-tabs' }))}" data-base-tabs>
184
1042
  ${renderViewTabs(result)}
185
1043
  </div>
186
1044
  <div class="bases-meta" data-base-meta>${escapeHtml(String(result.totalRows ?? 0))} results</div>
187
1045
  </div>
188
1046
  <div class="bases-toolbar-actions">
189
- <input class="bases-search-input" type="search" value="${escapeHtml(state.search)}" placeholder="Search this base">
190
- <button type="button" class="bases-export-btn">Export CSV</button>
1047
+ <div class="bases-toolbar-edit-actions" data-base-action-slot>${renderActionButtons(result, entry)}</div>
1048
+ <label class="bases-search-shell" aria-label="Search this base">
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">
1050
+ <circle cx="11" cy="11" r="7"></circle>
1051
+ <path d="m20 20-3.5-3.5"></path>
1052
+ </svg>
1053
+ <input class="${escapeHtml(inputClassNames({ extra: 'bases-search-input' }))}" type="search" value="${escapeHtml(entry.search)}" placeholder="Search this base" aria-label="Search this base">
1054
+ </label>
1055
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', pill: true, extra: 'bases-export-btn' }))}">Export CSV</button>
191
1056
  </div>
192
1057
  </header>
1058
+ <div class="bases-panels" data-base-panel-slot>${renderPanel(result, entry)}</div>
193
1059
  <div data-base-summary-slot>${renderSummaryBar(result.summaries)}</div>
194
1060
  <div class="bases-content" data-base-content>${renderViewBody(result)}</div>
195
1061
  </section>
@@ -205,10 +1071,20 @@ function findShellElement(entry) {
205
1071
  ?? entry.placeholder.querySelector('.bases-shell');
206
1072
  }
207
1073
 
1074
+ function markPlaceholderHydrated(placeholder) {
1075
+ if (!placeholder?.classList) {
1076
+ return;
1077
+ }
1078
+
1079
+ placeholder.classList.add('is-hydrated');
1080
+ placeholder.classList.remove('diagram-preview-shell');
1081
+ }
1082
+
208
1083
  function updateShellContent(entry, result) {
209
1084
  const shell = findShellElement(entry);
210
1085
  if (!shell?.querySelector) {
211
1086
  entry.placeholder.innerHTML = renderShellHtml(result, entry);
1087
+ markPlaceholderHydrated(entry.placeholder);
212
1088
  return;
213
1089
  }
214
1090
 
@@ -222,6 +1098,18 @@ function updateShellContent(entry, result) {
222
1098
  meta.textContent = `${String(result.totalRows ?? 0)} results`;
223
1099
  }
224
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
+
225
1113
  const summarySlot = shell.querySelector('[data-base-summary-slot]');
226
1114
  if (summarySlot) {
227
1115
  summarySlot.innerHTML = renderSummaryBar(result.summaries);
@@ -261,19 +1149,57 @@ function parseDownloadFileName(contentDisposition = '') {
261
1149
  export class BasesPreviewController {
262
1150
  constructor({
263
1151
  getActiveFilePath = () => '',
1152
+ getSession = () => null,
264
1153
  onOpenFile,
265
1154
  previewElement,
1155
+ replaceBaseSource = null,
266
1156
  toastController,
267
1157
  vaultApiClient,
268
1158
  }) {
269
1159
  this.getActiveFilePath = getActiveFilePath;
1160
+ this.getSession = getSession;
270
1161
  this.onOpenFile = onOpenFile;
271
1162
  this.previewElement = previewElement;
1163
+ this.replaceBaseSource = replaceBaseSource;
272
1164
  this.toastController = toastController;
273
1165
  this.vaultApiClient = vaultApiClient;
274
1166
  this.entries = new Map();
275
1167
  this.searchTimers = new Map();
276
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
+
277
1203
  this.handleClick = (event) => {
278
1204
  const openButton = event.target.closest('[data-base-open-file]');
279
1205
  if (openButton) {
@@ -282,58 +1208,378 @@ export class BasesPreviewController {
282
1208
  return;
283
1209
  }
284
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
+
285
1217
  const tab = event.target.closest('.bases-view-tab');
286
1218
  if (tab) {
287
- 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]');
288
1343
  const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
289
1344
  if (!entry) {
290
1345
  return;
291
1346
  }
292
1347
 
293
- entry.payload.view = tab.dataset.baseView || '';
294
- 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));
295
1354
  return;
296
1355
  }
297
1356
 
298
- const exportButton = event.target.closest('.bases-export-btn');
299
- if (exportButton) {
300
- 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]');
301
1360
  const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
302
1361
  if (!entry) {
303
1362
  return;
304
1363
  }
305
1364
 
306
- 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);
307
1399
  }
308
1400
  };
309
1401
 
310
- this.handleInput = (event) => {
311
- const input = event.target.closest('.bases-search-input');
312
- 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
+ });
1462
+ return;
1463
+ }
1464
+
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) {
313
1533
  return;
314
1534
  }
315
1535
 
316
- const shell = input.closest('[data-base-shell-key]');
1536
+ const shell = filterValue.closest('[data-base-shell-key]');
317
1537
  const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
318
1538
  if (!entry) {
319
1539
  return;
320
1540
  }
321
1541
 
322
- entry.search = input.value || '';
323
- clearTimeout(this.searchTimers.get(entry.key));
324
- this.searchTimers.set(entry.key, setTimeout(() => {
325
- entry.payload.search = entry.search;
326
- void this.renderEntry(entry);
327
- }, 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
+ }
328
1548
  };
329
1549
 
1550
+ this.previewElement?.addEventListener('pointerdown', this.handlePointerDown);
330
1551
  this.previewElement?.addEventListener('click', this.handleClick);
331
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
+ }
332
1575
  }
333
1576
 
334
1577
  destroy() {
1578
+ this.previewElement?.removeEventListener('pointerdown', this.handlePointerDown);
335
1579
  this.previewElement?.removeEventListener('click', this.handleClick);
336
1580
  this.previewElement?.removeEventListener('input', this.handleInput);
1581
+ this.previewElement?.removeEventListener('change', this.handleChange);
1582
+ this.previewElement?.removeEventListener('focusin', this.handleFocusIn);
337
1583
  this.searchTimers.forEach((timer) => clearTimeout(timer));
338
1584
  this.searchTimers.clear();
339
1585
  this.entries.clear();
@@ -354,14 +1600,22 @@ export class BasesPreviewController {
354
1600
  sourcePath: placeholder.dataset.baseSourcePath || '',
355
1601
  view: placeholder.dataset.baseView || '',
356
1602
  };
357
- const entry = existing ?? {
358
- key,
359
- payload,
360
- placeholder,
361
- requestVersion: 0,
362
- result: null,
363
- search: '',
364
- };
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
+ };
365
1619
  entry.payload = payload;
366
1620
  entry.placeholder = placeholder;
367
1621
  this.entries.set(key, entry);
@@ -381,9 +1635,17 @@ export class BasesPreviewController {
381
1635
  view: '',
382
1636
  },
383
1637
  placeholder: renderHost,
1638
+ propertyValueOptions: new Map(),
384
1639
  requestVersion: 0,
385
1640
  result: null,
386
1641
  search: '',
1642
+ ui: {
1643
+ builderFilter: null,
1644
+ filterMode: 'builder',
1645
+ openPanel: '',
1646
+ propertySearch: '',
1647
+ rawFilterText: '',
1648
+ },
387
1649
  };
388
1650
  entry.placeholder = renderHost;
389
1651
  entry.payload.path = filePath;
@@ -394,6 +1656,7 @@ export class BasesPreviewController {
394
1656
  }
395
1657
 
396
1658
  async renderEntry(entry) {
1659
+ this.ensureEntryState(entry);
397
1660
  if (!entry?.placeholder?.isConnected) {
398
1661
  return;
399
1662
  }
@@ -413,6 +1676,10 @@ export class BasesPreviewController {
413
1676
  return;
414
1677
  }
415
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
+ }
416
1683
  updateShellContent(entry, entry.result);
417
1684
  } catch (error) {
418
1685
  if (requestVersion !== entry.requestVersion || !entry?.placeholder?.isConnected) {
@@ -431,6 +1698,7 @@ export class BasesPreviewController {
431
1698
  }
432
1699
 
433
1700
  async exportEntry(entry) {
1701
+ this.ensureEntryState(entry);
434
1702
  try {
435
1703
  const response = await this.vaultApiClient.exportBaseCsv({
436
1704
  activeFilePath: this.getActiveFilePath?.() ?? '',
@@ -441,4 +1709,115 @@ export class BasesPreviewController {
441
1709
  this.toastController?.show?.(error.message || 'Failed to export base CSV');
442
1710
  }
443
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
+ }
444
1823
  }