sqlite-hub 0.11.1 → 0.16.0

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 (52) hide show
  1. package/README.md +52 -13
  2. package/database.sqlite +0 -0
  3. package/frontend/js/api.js +64 -12
  4. package/frontend/js/app.js +723 -37
  5. package/frontend/js/components/emptyState.js +42 -46
  6. package/frontend/js/components/modal.js +526 -2
  7. package/frontend/js/components/queryEditor.js +12 -3
  8. package/frontend/js/components/queryResults.js +79 -17
  9. package/frontend/js/components/rowEditorPanel.js +345 -12
  10. package/frontend/js/components/sidebar.js +106 -23
  11. package/frontend/js/components/tableDesignerEditor.js +69 -11
  12. package/frontend/js/store.js +437 -26
  13. package/frontend/js/utils/copyColumnExport.js +117 -0
  14. package/frontend/js/utils/exportFilenames.js +32 -0
  15. package/frontend/js/utils/filePathPreview.js +315 -0
  16. package/frontend/js/utils/format.js +1 -1
  17. package/frontend/js/utils/rowEditorJson.js +65 -0
  18. package/frontend/js/utils/sqlFormatter.js +691 -0
  19. package/frontend/js/utils/tableDesigner.js +178 -6
  20. package/frontend/js/utils/textCellStats.js +20 -0
  21. package/frontend/js/utils/timestampPreview.js +264 -0
  22. package/frontend/js/views/charts.js +3 -1
  23. package/frontend/js/views/data.js +39 -4
  24. package/frontend/js/views/editor.js +50 -1
  25. package/frontend/js/views/settings.js +1 -4
  26. package/frontend/js/views/structure.js +154 -212
  27. package/frontend/styles/base.css +6 -0
  28. package/frontend/styles/components.css +463 -2
  29. package/frontend/styles/structure-graph.css +0 -3
  30. package/frontend/styles/tailwind.generated.css +1 -1
  31. package/frontend/styles/tokens.css +95 -95
  32. package/package.json +2 -3
  33. package/server/routes/export.js +97 -12
  34. package/server/services/sqlite/dataBrowserService.js +2 -68
  35. package/server/services/sqlite/exportService.js +74 -15
  36. package/server/services/sqlite/introspection.js +209 -1
  37. package/server/services/sqlite/sqlExecutor.js +31 -0
  38. package/server/services/sqlite/tableDesigner/changeAnalysis.js +25 -1
  39. package/server/services/sqlite/tableDesigner/schemaMapping.js +105 -10
  40. package/server/services/sqlite/tableDesigner/validation.js +60 -2
  41. package/server/services/sqlite/tableDesignerService.js +1 -1
  42. package/server/services/sqlite/tableFilter.js +75 -0
  43. package/server/utils/csv.js +30 -4
  44. package/tests/check-constraint-options.test.js +90 -0
  45. package/tests/export-filenames.test.js +34 -0
  46. package/tests/file-path-preview.test.js +165 -0
  47. package/tests/row-editor-json.test.js +82 -0
  48. package/tests/row-editor-timestamp-preview.test.js +192 -0
  49. package/tests/sql-formatter.test.js +173 -0
  50. package/tests/sql-highlight.test.js +38 -0
  51. package/tests/table-designer-v2-unique-constraints.test.js +78 -0
  52. package/tests/text-cell-stats.test.js +38 -0
@@ -33,8 +33,10 @@ import {
33
33
  dismissMediaTaggingIssue,
34
34
  dismissToast,
35
35
  executeCurrentQuery,
36
- exportCurrentDataTableCsv,
37
- exportCurrentQueryCsv,
36
+ duplicateCurrentDataTableAsTable,
37
+ exportCurrentDataTableFormat,
38
+ duplicateCurrentQueryAsTable,
39
+ exportCurrentQueryFormat,
38
40
  getState,
39
41
  initializeApp,
40
42
  loadMoreQueryHistory,
@@ -45,10 +47,14 @@ import {
45
47
  openDeleteEditorRowModal,
46
48
  openDeleteQueryHistoryModal,
47
49
  openDeleteQueryChartModal,
50
+ openDataExportModal,
51
+ openQueryExportModal,
48
52
  openDataRowByIdentity,
49
53
  openEditConnectionModal,
50
54
  openCreateQueryChartModal,
55
+ openCopyColumnModal,
51
56
  openEditQueryChartModal,
57
+ preserveCurrentDataRowSelectionForReload,
52
58
  openDataRowUpdatePreview,
53
59
  openEditorRowUpdatePreview,
54
60
  refreshCurrentRoute,
@@ -93,6 +99,8 @@ import {
93
99
  sortEditorResultsByColumn,
94
100
  setQueryHistorySearchInput,
95
101
  setQueryHistoryTab,
102
+ setCopyColumnModalError,
103
+ setCopyColumnModalSubmitting,
96
104
  setRoute,
97
105
  saveQueryHistoryNotes,
98
106
  saveQueryHistoryTitle,
@@ -103,6 +111,7 @@ import {
103
111
  setMediaTaggingWorkflowMediaRotationDegrees,
104
112
  queueTableDesignerCsvImport,
105
113
  showToast,
114
+ storeCopyColumnPreferences,
106
115
  submitCreateConnection,
107
116
  createCurrentMediaTag,
108
117
  submitDeleteRowConfirmation,
@@ -114,9 +123,11 @@ import {
114
123
  toggleQueryHistorySavedState,
115
124
  updateCurrentMediaTaggingField,
116
125
  updateCurrentMediaTaggingTagFormField,
126
+ updateCopyColumnModalFormatField,
117
127
  updateCurrentQueryChartDraftConfigField,
118
128
  updateCurrentQueryChartDraftField,
119
129
  updateCurrentTableDesignerColumnField,
130
+ updateCurrentTableDesignerConstraintField,
120
131
  updateCurrentTableDesignerField,
121
132
  addCurrentTableDesignerColumn,
122
133
  applyCurrentMediaTaggingSelection,
@@ -132,11 +143,30 @@ import { renderOverviewView } from './views/overview.js';
132
143
  import { renderSettingsView } from './views/settings.js';
133
144
  import { renderStructureView } from './views/structure.js';
134
145
  import { renderTableDesignerView } from './views/tableDesigner.js';
146
+ import { replaceChildrenFromRenderedMarkup, replaceElementFromRenderedMarkup } from './utils/dom.js';
135
147
  import {
136
- replaceChildrenFromRenderedMarkup,
137
- replaceElementFromRenderedMarkup,
138
- } from './utils/dom.js';
139
- import { highlightSql } from './utils/format.js';
148
+ buildCopyColumnText,
149
+ getCopyColumnExportMetadata,
150
+ isMarkdownTodoCopyColumnMode,
151
+ normalizeCopyColumnMode,
152
+ } from './utils/copyColumnExport.js';
153
+ import { formatNumber, highlightSql } from './utils/format.js';
154
+ import {
155
+ compactPathForDisplay,
156
+ detectFilePathValue,
157
+ getPathTypeLabel,
158
+ } from './utils/filePathPreview.js';
159
+ import { formatSqlQuery } from './utils/sqlFormatter.js';
160
+ import {
161
+ buildDataRowEditorJsonObject,
162
+ buildEditorRowEditorJsonObject,
163
+ stringifyRowEditorJson,
164
+ } from './utils/rowEditorJson.js';
165
+ import { getTimestampPreviewForField } from './utils/timestampPreview.js';
166
+ import {
167
+ formatTextCellCharacterCount,
168
+ getTextCellCharacterCount,
169
+ } from './utils/textCellStats.js';
140
170
 
141
171
  const appRoot = document.querySelector('#app');
142
172
 
@@ -333,7 +363,7 @@ function normalizeMediaTaggingRotationDegrees(value) {
333
363
  return 0;
334
364
  }
335
365
 
336
- return ((Math.round(numericValue / 90) * 90) % 360 + 360) % 360;
366
+ return (((Math.round(numericValue / 90) * 90) % 360) + 360) % 360;
337
367
  }
338
368
 
339
369
  function syncMediaTaggingMediaRotationUi(node, rotationDegrees) {
@@ -384,7 +414,9 @@ function syncMediaTaggingTagSearchUi(input) {
384
414
  continue;
385
415
  }
386
416
 
387
- const searchText = String(tagOption.dataset.tagSearchText ?? '').trim().toLowerCase();
417
+ const searchText = String(tagOption.dataset.tagSearchText ?? '')
418
+ .trim()
419
+ .toLowerCase();
388
420
  tagOption.hidden = Boolean(normalizedQuery) && !searchText.includes(normalizedQuery);
389
421
  }
390
422
 
@@ -502,23 +534,18 @@ function syncQueryHistoryUi(historyId) {
502
534
  return false;
503
535
  }
504
536
 
505
- const historyItem = state.editor.history.find(entry => Number(entry.id) === numericId) ?? state.editor.historyDetail ?? null;
506
- const listItemNode = shellRefs.view.querySelector(
507
- [
508
- '[data-action="select-query-history-item"][data-history-id="',
509
- String(numericId),
510
- '"]',
511
- ].join(''),
512
- )?.closest('.query-history-item');
537
+ const historyItem =
538
+ state.editor.history.find(entry => Number(entry.id) === numericId) ?? state.editor.historyDetail ?? null;
539
+ const listItemNode = shellRefs.view
540
+ .querySelector(
541
+ ['[data-action="select-query-history-item"][data-history-id="', String(numericId), '"]'].join(''),
542
+ )
543
+ ?.closest('.query-history-item');
513
544
 
514
545
  if (historyItem && listItemNode instanceof HTMLElement) {
515
546
  replaceElementFromRenderedMarkup(
516
547
  listItemNode,
517
- renderQueryHistoryListItem(
518
- historyItem,
519
- state.editor.historyActiveId,
520
- state.editor.historySelectedId,
521
- ),
548
+ renderQueryHistoryListItem(historyItem, state.editor.historyActiveId, state.editor.historySelectedId),
522
549
  );
523
550
  }
524
551
 
@@ -1256,6 +1283,484 @@ async function executeEditorQueryAndNavigate() {
1256
1283
  router.navigate(success && activeTab === 'results' ? '/editor/results' : '/editor');
1257
1284
  }
1258
1285
 
1286
+ function formatCurrentQuery() {
1287
+ const currentQuery = getState().editor.sqlText ?? '';
1288
+ const formattedQuery = formatSqlQuery(currentQuery);
1289
+
1290
+ if (!formattedQuery) {
1291
+ showToast('No SQL query to format.', 'alert');
1292
+ return;
1293
+ }
1294
+
1295
+ if (formattedQuery === currentQuery) {
1296
+ focusQueryEditorInput();
1297
+ showToast('SQL query is already formatted.', 'muted');
1298
+ return;
1299
+ }
1300
+
1301
+ invalidateMainRenderCache();
1302
+ setCurrentQuery(formattedQuery);
1303
+
1304
+ const input = document.querySelector('[data-bind="current-query"]');
1305
+
1306
+ if (input instanceof HTMLTextAreaElement) {
1307
+ input.value = formattedQuery;
1308
+ syncQueryEditorHighlight(input);
1309
+ syncQueryEditorScroll(input);
1310
+ input.focus({ preventScroll: true });
1311
+ input.setSelectionRange(input.value.length, input.value.length);
1312
+ } else {
1313
+ pendingQueryEditorFocus = true;
1314
+ }
1315
+
1316
+ showToast('SQL query formatted.', 'success');
1317
+ }
1318
+
1319
+ const OPENABLE_URL_PATTERN = /^https?:\/\/[^\s<>"']+$/i;
1320
+
1321
+ function getOpenableUrl(value) {
1322
+ const text = String(value ?? '').trim();
1323
+
1324
+ if (!OPENABLE_URL_PATTERN.test(text)) {
1325
+ return null;
1326
+ }
1327
+
1328
+ try {
1329
+ const url = new URL(text);
1330
+ return ['http:', 'https:'].includes(url.protocol) ? url.href : null;
1331
+ } catch (error) {
1332
+ return null;
1333
+ }
1334
+ }
1335
+
1336
+ function openRowEditorUrl(actionNode) {
1337
+ const field = actionNode.closest('[data-row-editor-url-field]');
1338
+ const inputValue = field?.querySelector('[data-row-editor-url-input]')?.value;
1339
+ const url = getOpenableUrl(inputValue ?? actionNode.dataset.url);
1340
+
1341
+ if (!url) {
1342
+ showToast('Field value is not a valid URL.', 'alert');
1343
+ return;
1344
+ }
1345
+
1346
+ const link = document.createElement('a');
1347
+
1348
+ link.href = url;
1349
+ link.target = '_blank';
1350
+ link.rel = 'noopener noreferrer';
1351
+ document.body.appendChild(link);
1352
+ link.click();
1353
+ link.remove();
1354
+ }
1355
+
1356
+ function getExportFilenameFromAction(actionNode) {
1357
+ const input = actionNode.closest('[data-export-modal]')?.querySelector('input[name="filename"]');
1358
+ return input instanceof HTMLInputElement ? input.value : '';
1359
+ }
1360
+
1361
+ function syncRowEditorTimestampPreview(inputNode) {
1362
+ const fieldNode = inputNode.closest('[data-row-editor-field]');
1363
+ const previewNode = fieldNode?.querySelector('[data-row-editor-timestamp-preview]');
1364
+
1365
+ if (!fieldNode || !previewNode) {
1366
+ return;
1367
+ }
1368
+
1369
+ const columnName = fieldNode.dataset.rowEditorColumnName ?? '';
1370
+ const protectedKeyColumn = fieldNode.dataset.rowEditorProtectedKey === 'true';
1371
+ const tableMeta = {
1372
+ columns: [
1373
+ {
1374
+ name: columnName,
1375
+ primaryKeyPosition: protectedKeyColumn ? 1 : 0,
1376
+ foreignKey: protectedKeyColumn,
1377
+ },
1378
+ ],
1379
+ foreignKeys: [],
1380
+ };
1381
+ const preview = getTimestampPreviewForField({
1382
+ columnName,
1383
+ value: inputNode.value,
1384
+ tableMeta,
1385
+ });
1386
+
1387
+ if (preview.kind !== 'timestamp') {
1388
+ previewNode.hidden = true;
1389
+ previewNode.textContent = '';
1390
+ return;
1391
+ }
1392
+
1393
+ previewNode.hidden = false;
1394
+ previewNode.textContent = `Interpretiert als Datum: ${preview.formatted}`;
1395
+ }
1396
+
1397
+ function syncRowEditorCharacterCount(inputNode) {
1398
+ const fieldNode = inputNode.closest('[data-row-editor-field]');
1399
+ const countNode = fieldNode?.querySelector('[data-row-editor-char-count]');
1400
+
1401
+ if (!fieldNode || !countNode) {
1402
+ return;
1403
+ }
1404
+
1405
+ const count = getTextCellCharacterCount(inputNode.value);
1406
+
1407
+ if (count === null) {
1408
+ countNode.hidden = true;
1409
+ countNode.textContent = '';
1410
+ return;
1411
+ }
1412
+
1413
+ countNode.hidden = false;
1414
+ countNode.textContent = formatTextCellCharacterCount(count);
1415
+ }
1416
+
1417
+ function syncRowEditorFilePathPreview(inputNode) {
1418
+ const fieldNode = inputNode.closest('[data-row-editor-field]');
1419
+ const previewNode = fieldNode?.querySelector('[data-row-editor-filepath-preview]');
1420
+
1421
+ if (!fieldNode || !previewNode) {
1422
+ return;
1423
+ }
1424
+
1425
+ const columnName = fieldNode.dataset.rowEditorColumnName ?? '';
1426
+ const protectedKeyColumn = fieldNode.dataset.rowEditorProtectedKey === 'true';
1427
+ const tableMeta = {
1428
+ columns: [
1429
+ {
1430
+ name: columnName,
1431
+ primaryKeyPosition: protectedKeyColumn ? 1 : 0,
1432
+ foreignKey: protectedKeyColumn,
1433
+ },
1434
+ ],
1435
+ foreignKeys: [],
1436
+ };
1437
+ const preview = detectFilePathValue(inputNode.value, columnName, tableMeta);
1438
+
1439
+ if (!preview) {
1440
+ previewNode.hidden = true;
1441
+ return;
1442
+ }
1443
+
1444
+ const filenameNode = previewNode.querySelector('[data-row-editor-filepath-filename]');
1445
+ const directoryRowNode = previewNode.querySelector('[data-row-editor-filepath-directory-row]');
1446
+ const directoryNode = previewNode.querySelector('[data-row-editor-filepath-directory]');
1447
+ const extensionNode = previewNode.querySelector('[data-row-editor-filepath-extension]');
1448
+ const typeNode = previewNode.querySelector('[data-row-editor-filepath-type]');
1449
+
1450
+ if (filenameNode) {
1451
+ filenameNode.textContent = preview.fileName ?? 'N/A';
1452
+ }
1453
+
1454
+ if (directoryRowNode) {
1455
+ directoryRowNode.hidden = !preview.directory;
1456
+ }
1457
+
1458
+ if (directoryNode) {
1459
+ directoryNode.textContent = preview.directory ? compactPathForDisplay(preview.directory, 72) : '';
1460
+ directoryNode.setAttribute('title', preview.directory ?? '');
1461
+ }
1462
+
1463
+ if (extensionNode) {
1464
+ extensionNode.textContent = preview.extension ?? 'N/A';
1465
+ }
1466
+
1467
+ if (typeNode) {
1468
+ typeNode.textContent = getPathTypeLabel(preview.pathType);
1469
+ }
1470
+
1471
+ previewNode.hidden = false;
1472
+ }
1473
+
1474
+ function closeCopyColumnMenus(exceptMenu = null) {
1475
+ document.querySelectorAll('[data-copy-column-menu][open]').forEach(menu => {
1476
+ if (menu !== exceptMenu && menu instanceof HTMLDetailsElement) {
1477
+ menu.open = false;
1478
+ }
1479
+ });
1480
+ }
1481
+
1482
+ function closeSidebarDatabasePickers(exceptPicker = null) {
1483
+ document.querySelectorAll('.sidebar-db-picker[open]').forEach(picker => {
1484
+ if (picker !== exceptPicker && picker instanceof HTMLDetailsElement) {
1485
+ picker.open = false;
1486
+ }
1487
+ });
1488
+ }
1489
+
1490
+ function openCopyColumnMenu(headerNode) {
1491
+ const menu = headerNode?.querySelector('[data-copy-column-menu]');
1492
+
1493
+ if (!(menu instanceof HTMLDetailsElement)) {
1494
+ return;
1495
+ }
1496
+
1497
+ closeCopyColumnMenus(menu);
1498
+ menu.open = true;
1499
+ menu.querySelector('summary')?.focus({ preventScroll: true });
1500
+ }
1501
+
1502
+ function getCopyColumnResult(state, scope) {
1503
+ return scope === 'charts' ? state.charts.result : state.editor.result;
1504
+ }
1505
+
1506
+ function slugifyExportFilenamePart(value, fallback = 'column') {
1507
+ const slug = String(value ?? '')
1508
+ .trim()
1509
+ .toLowerCase()
1510
+ .replace(/[^a-z0-9]+/g, '-')
1511
+ .replace(/^-+|-+$/g, '');
1512
+
1513
+ return slug || fallback;
1514
+ }
1515
+
1516
+ function buildCopyColumnExportFilename(columnName, copyMode) {
1517
+ const metadata = getCopyColumnExportMetadata(copyMode);
1518
+ const columnSlug = slugifyExportFilenamePart(columnName);
1519
+
1520
+ return `${columnSlug}-${metadata.suffix}.${metadata.extension}`;
1521
+ }
1522
+
1523
+ function downloadTextFile({ text, filename, mimeType }) {
1524
+ const blob = new Blob([text], { type: mimeType });
1525
+ const url = URL.createObjectURL(blob);
1526
+ const link = document.createElement('a');
1527
+
1528
+ link.href = url;
1529
+ link.download = filename;
1530
+ document.body.appendChild(link);
1531
+ link.click();
1532
+ link.remove();
1533
+ URL.revokeObjectURL(url);
1534
+ }
1535
+
1536
+ function getSelectedDataBrowserRowForJson(state) {
1537
+ if (state.dataBrowser.selectedRow) {
1538
+ return state.dataBrowser.selectedRow;
1539
+ }
1540
+
1541
+ const rowIndex = state.dataBrowser.selectedRowIndex;
1542
+
1543
+ if (typeof rowIndex !== 'number') {
1544
+ return null;
1545
+ }
1546
+
1547
+ return state.dataBrowser.table?.rows?.[rowIndex] ?? null;
1548
+ }
1549
+
1550
+ function getSelectedEditorResultRowForJson(state) {
1551
+ const rowIndex = state.editor.selectedRowIndex;
1552
+
1553
+ if (typeof rowIndex !== 'number') {
1554
+ return null;
1555
+ }
1556
+
1557
+ return state.editor.result?.rows?.[rowIndex] ?? null;
1558
+ }
1559
+
1560
+ function applyChangedRowEditorFormValues(rowObject) {
1561
+ const form = shellRefs.panel.querySelector(
1562
+ 'form[data-form="save-data-row"], form[data-form="save-editor-row"]',
1563
+ );
1564
+
1565
+ if (!form) {
1566
+ return rowObject;
1567
+ }
1568
+
1569
+ const nextRowObject = { ...rowObject };
1570
+ const formData = new FormData(form);
1571
+
1572
+ for (const [key, value] of formData.entries()) {
1573
+ if (!key.startsWith('field:')) {
1574
+ continue;
1575
+ }
1576
+
1577
+ const control = Array.from(form.elements).find(element => element.name === key);
1578
+ const initialValue = control?.dataset?.rowEditorInitialValue;
1579
+ const currentValue = String(value ?? '');
1580
+
1581
+ if (initialValue !== undefined && currentValue === initialValue) {
1582
+ continue;
1583
+ }
1584
+
1585
+ nextRowObject[key.slice('field:'.length)] = currentValue;
1586
+ }
1587
+
1588
+ return nextRowObject;
1589
+ }
1590
+
1591
+ function buildRowEditorJsonPayload(state) {
1592
+ if (state.route.name === 'data') {
1593
+ const row = getSelectedDataBrowserRowForJson(state);
1594
+ const table = state.dataBrowser.table;
1595
+ const tableName = table?.name ?? state.dataBrowser.selectedTable ?? 'row';
1596
+
1597
+ if (!row || !table) {
1598
+ return null;
1599
+ }
1600
+
1601
+ const rowObject = applyChangedRowEditorFormValues(
1602
+ buildDataRowEditorJsonObject({
1603
+ row,
1604
+ columns: table.columns ?? table.columnMeta ?? [],
1605
+ }),
1606
+ );
1607
+
1608
+ return {
1609
+ filename: `${slugifyExportFilenamePart(tableName, 'row')}-row.json`,
1610
+ label: tableName,
1611
+ text: stringifyRowEditorJson(rowObject),
1612
+ };
1613
+ }
1614
+
1615
+ if (state.route.name === 'editorResults') {
1616
+ const row = getSelectedEditorResultRowForJson(state);
1617
+ const result = state.editor.result;
1618
+ const rowIndex = state.editor.selectedRowIndex;
1619
+ const tableName = result?.editing?.tableName ?? 'query-result';
1620
+
1621
+ if (!row || !result) {
1622
+ return null;
1623
+ }
1624
+
1625
+ const rowObject = applyChangedRowEditorFormValues(
1626
+ buildEditorRowEditorJsonObject({
1627
+ row,
1628
+ editingColumns: result.editing?.columns ?? [],
1629
+ resultColumns: result.columns ?? [],
1630
+ }),
1631
+ );
1632
+
1633
+ return {
1634
+ filename: `${slugifyExportFilenamePart(tableName, 'query-result')}-row-${Number(rowIndex) + 1}.json`,
1635
+ label: tableName,
1636
+ text: stringifyRowEditorJson(rowObject),
1637
+ };
1638
+ }
1639
+
1640
+ return null;
1641
+ }
1642
+
1643
+ async function copyRowEditorJson() {
1644
+ const payload = buildRowEditorJsonPayload(getState());
1645
+
1646
+ if (!payload) {
1647
+ showToast('No row is selected.', 'alert');
1648
+ return;
1649
+ }
1650
+
1651
+ if (!navigator.clipboard?.writeText) {
1652
+ showToast('Clipboard API is not available.', 'alert');
1653
+ return;
1654
+ }
1655
+
1656
+ try {
1657
+ await navigator.clipboard.writeText(payload.text);
1658
+ showToast(`Row from "${payload.label}" copied as JSON.`, 'success');
1659
+ } catch (error) {
1660
+ showToast('Row JSON could not be copied.', 'alert');
1661
+ }
1662
+ }
1663
+
1664
+ function exportRowEditorJson() {
1665
+ const payload = buildRowEditorJsonPayload(getState());
1666
+
1667
+ if (!payload) {
1668
+ showToast('No row is selected.', 'alert');
1669
+ return;
1670
+ }
1671
+
1672
+ downloadTextFile({
1673
+ text: payload.text,
1674
+ filename: payload.filename,
1675
+ mimeType: 'application/json;charset=utf-8',
1676
+ });
1677
+ showToast(`Row from "${payload.label}" exported as JSON.`, 'success');
1678
+ }
1679
+
1680
+ function preserveDataRowEditorSelectionForReload() {
1681
+ preserveCurrentDataRowSelectionForReload();
1682
+ }
1683
+
1684
+ async function submitCopyColumnModal(formData) {
1685
+ const state = getState();
1686
+ const modal = state.modal;
1687
+
1688
+ if (modal?.kind !== 'copy-column') {
1689
+ return;
1690
+ }
1691
+
1692
+ const scope = String(formData.get('scope') ?? modal.scope ?? 'editor');
1693
+ const columnName = String(formData.get('columnName') ?? modal.columnName ?? '');
1694
+ const copyMode = normalizeCopyColumnMode(String(formData.get('copyMode') ?? modal.copyMode ?? 'column'));
1695
+ const separator = String(formData.get('separator') ?? ',');
1696
+ const wrapper = String(formData.get('wrapper') ?? '');
1697
+ const lineBreaks = formData.get('lineBreaks') === 'on';
1698
+ const outputSeparator = lineBreaks ? '\n' : separator;
1699
+ const intent = String(formData.get('intent') ?? 'copy');
1700
+ const isExportIntent = intent === 'export';
1701
+ const result = getCopyColumnResult(state, scope);
1702
+ const hasColumn = (result?.columns ?? []).some(column => String(column) === columnName);
1703
+
1704
+ if (!hasColumn) {
1705
+ setCopyColumnModalError({
1706
+ code: 'COPY_COLUMN_UNAVAILABLE',
1707
+ message: 'Column is no longer available in the current result set.',
1708
+ });
1709
+ showToast('Column could not be copied.', 'alert');
1710
+ return;
1711
+ }
1712
+
1713
+ if (!isMarkdownTodoCopyColumnMode(copyMode)) {
1714
+ storeCopyColumnPreferences({ separator, wrapper, lineBreaks });
1715
+ }
1716
+ setCopyColumnModalSubmitting(true);
1717
+
1718
+ const { text, valueCount } = buildCopyColumnText({
1719
+ result,
1720
+ columnName,
1721
+ copyMode,
1722
+ separator: outputSeparator,
1723
+ wrapper,
1724
+ });
1725
+
1726
+ try {
1727
+ if (isExportIntent) {
1728
+ const metadata = getCopyColumnExportMetadata(copyMode);
1729
+
1730
+ downloadTextFile({
1731
+ text,
1732
+ filename: buildCopyColumnExportFilename(columnName, copyMode),
1733
+ mimeType: metadata.mimeType,
1734
+ });
1735
+ closeModal();
1736
+ showToast(
1737
+ `Column "${columnName}" exported as ${metadata.label} · ${formatNumber(valueCount)} ${
1738
+ valueCount === 1 ? 'value' : 'values'
1739
+ }`,
1740
+ 'success',
1741
+ );
1742
+ return;
1743
+ }
1744
+
1745
+ if (!navigator.clipboard?.writeText) {
1746
+ throw new Error('Clipboard API is not available.');
1747
+ }
1748
+
1749
+ await navigator.clipboard.writeText(text);
1750
+ closeModal();
1751
+ showToast(
1752
+ `Column "${columnName}" copied · ${formatNumber(valueCount)} ${valueCount === 1 ? 'value' : 'values'}`,
1753
+ 'success',
1754
+ );
1755
+ } catch (error) {
1756
+ setCopyColumnModalError({
1757
+ code: isExportIntent ? 'COPY_COLUMN_EXPORT_FAILED' : 'CLIPBOARD_ACCESS_FAILED',
1758
+ message: error?.message || (isExportIntent ? 'Column export failed.' : 'Clipboard access failed.'),
1759
+ });
1760
+ showToast(isExportIntent ? 'Column export failed.' : 'Clipboard access failed.', 'alert');
1761
+ }
1762
+ }
1763
+
1259
1764
  async function handleAction(actionNode) {
1260
1765
  const { action } = actionNode.dataset;
1261
1766
 
@@ -1266,8 +1771,28 @@ async function handleAction(actionNode) {
1266
1771
  case 'refresh-view':
1267
1772
  await refreshCurrentRoute();
1268
1773
  return;
1774
+ case 'open-row-editor-url':
1775
+ openRowEditorUrl(actionNode);
1776
+ return;
1777
+ case 'copy-row-editor-json':
1778
+ await copyRowEditorJson();
1779
+ return;
1780
+ case 'export-row-editor-json':
1781
+ exportRowEditorJson();
1782
+ return;
1269
1783
  case 'open-modal':
1270
- openModal(actionNode.dataset.modal);
1784
+ openModal(actionNode.dataset.modal, {
1785
+ columnId: actionNode.dataset.columnId,
1786
+ columnName: actionNode.dataset.columnName,
1787
+ });
1788
+ return;
1789
+ case 'open-copy-column-modal':
1790
+ closeCopyColumnMenus();
1791
+ openCopyColumnModal({
1792
+ scope: actionNode.dataset.resultScope,
1793
+ columnName: actionNode.dataset.columnName ?? '',
1794
+ mode: actionNode.dataset.copyMode,
1795
+ });
1271
1796
  return;
1272
1797
  case 'open-create-query-chart-modal':
1273
1798
  openCreateQueryChartModal();
@@ -1291,6 +1816,7 @@ async function handleAction(actionNode) {
1291
1816
  dismissToast(actionNode.dataset.toastId);
1292
1817
  return;
1293
1818
  case 'select-connection': {
1819
+ closeSidebarDatabasePickers();
1294
1820
  resetStructureGraphForDatabaseChange();
1295
1821
  const next = await selectConnection(actionNode.dataset.connectionId);
1296
1822
  if (next) {
@@ -1346,6 +1872,9 @@ async function handleAction(actionNode) {
1346
1872
  await executeEditorQueryAndNavigate();
1347
1873
  return;
1348
1874
  }
1875
+ case 'format-current-query':
1876
+ formatCurrentQuery();
1877
+ return;
1349
1878
  case 'delete-data-row':
1350
1879
  openDeleteDataRowModal(actionNode.dataset.rowIndex);
1351
1880
  return;
@@ -1464,12 +1993,46 @@ async function handleAction(actionNode) {
1464
1993
  router.navigate(tab === 'results' ? '/editor/results' : '/editor');
1465
1994
  return;
1466
1995
  }
1467
- case 'export-query-csv':
1468
- await exportCurrentQueryCsv();
1996
+ case 'open-query-export-modal':
1997
+ openQueryExportModal();
1998
+ return;
1999
+ case 'export-query-format': {
2000
+ const format = actionNode.dataset.exportFormat;
2001
+ const filename = getExportFilenameFromAction(actionNode);
2002
+
2003
+ if (format === 'table') {
2004
+ const imported = await duplicateCurrentQueryAsTable(filename);
2005
+
2006
+ if (imported) {
2007
+ router.navigate('/table-designer/new');
2008
+ }
2009
+
2010
+ return;
2011
+ }
2012
+
2013
+ await exportCurrentQueryFormat(format, filename);
2014
+ return;
2015
+ }
2016
+ case 'open-data-export-modal':
2017
+ openDataExportModal();
1469
2018
  return;
1470
- case 'export-data-csv':
1471
- await exportCurrentDataTableCsv();
2019
+ case 'export-data-format': {
2020
+ const format = actionNode.dataset.exportFormat;
2021
+ const filename = getExportFilenameFromAction(actionNode);
2022
+
2023
+ if (format === 'table') {
2024
+ const imported = await duplicateCurrentDataTableAsTable(filename);
2025
+
2026
+ if (imported) {
2027
+ router.navigate('/table-designer/new');
2028
+ }
2029
+
2030
+ return;
2031
+ }
2032
+
2033
+ await exportCurrentDataTableFormat(format, filename);
1472
2034
  return;
2035
+ }
1473
2036
  case 'toggle-data-tables':
1474
2037
  toggleDataTablesPanel();
1475
2038
  return;
@@ -1565,11 +2128,7 @@ async function handleAction(actionNode) {
1565
2128
  const currentRotation = getState().mediaTagging.workflowMediaRotationDegrees ?? 0;
1566
2129
  const command = actionNode.dataset.rotationCommand;
1567
2130
  const nextRotation =
1568
- command === 'left'
1569
- ? currentRotation - 90
1570
- : command === 'right'
1571
- ? currentRotation + 90
1572
- : 0;
2131
+ command === 'left' ? currentRotation - 90 : command === 'right' ? currentRotation + 90 : 0;
1573
2132
 
1574
2133
  syncMediaTaggingMediaRotationUi(actionNode, nextRotation);
1575
2134
  setMediaTaggingWorkflowMediaRotationDegrees(nextRotation, { notify: false });
@@ -1649,6 +2208,7 @@ async function handleAction(actionNode) {
1649
2208
  }
1650
2209
  return;
1651
2210
  case 'reload-data-route':
2211
+ preserveDataRowEditorSelectionForReload();
1652
2212
  await refreshCurrentRoute();
1653
2213
  return;
1654
2214
  default:
@@ -1669,7 +2229,37 @@ function canApplyMediaTaggingShortcut(state) {
1669
2229
  }
1670
2230
 
1671
2231
  document.addEventListener('click', event => {
1672
- const actionNode = event.target.closest('[data-action]');
2232
+ const target = event.target instanceof Element ? event.target : null;
2233
+
2234
+ if (!target) {
2235
+ return;
2236
+ }
2237
+
2238
+ const copyColumnMenu = target.closest('[data-copy-column-menu]');
2239
+
2240
+ if (!copyColumnMenu) {
2241
+ closeCopyColumnMenus();
2242
+ } else if (target.closest('.query-result-column-menu__toggle')) {
2243
+ window.requestAnimationFrame(() => {
2244
+ if (copyColumnMenu instanceof HTMLDetailsElement && copyColumnMenu.open) {
2245
+ closeCopyColumnMenus(copyColumnMenu);
2246
+ }
2247
+ });
2248
+ }
2249
+
2250
+ const sidebarDatabasePicker = target.closest('.sidebar-db-picker');
2251
+
2252
+ if (!sidebarDatabasePicker) {
2253
+ closeSidebarDatabasePickers();
2254
+ } else if (target.closest('.sidebar-footer-card')) {
2255
+ window.requestAnimationFrame(() => {
2256
+ if (sidebarDatabasePicker instanceof HTMLDetailsElement && sidebarDatabasePicker.open) {
2257
+ closeSidebarDatabasePickers(sidebarDatabasePicker);
2258
+ }
2259
+ });
2260
+ }
2261
+
2262
+ const actionNode = target.closest('[data-action]');
1673
2263
 
1674
2264
  if (!actionNode) {
1675
2265
  return;
@@ -1678,6 +2268,18 @@ document.addEventListener('click', event => {
1678
2268
  handleAction(actionNode);
1679
2269
  });
1680
2270
 
2271
+ document.addEventListener('contextmenu', event => {
2272
+ const target = event.target instanceof Element ? event.target : null;
2273
+ const headerNode = target?.closest('[data-result-column-header]');
2274
+
2275
+ if (!headerNode) {
2276
+ return;
2277
+ }
2278
+
2279
+ event.preventDefault();
2280
+ openCopyColumnMenu(headerNode);
2281
+ });
2282
+
1681
2283
  document.addEventListener('keydown', event => {
1682
2284
  const target = event.target;
1683
2285
  const state = getState();
@@ -1744,6 +2346,18 @@ document.addEventListener('keydown', event => {
1744
2346
  return;
1745
2347
  }
1746
2348
 
2349
+ if (document.querySelector('[data-copy-column-menu][open]')) {
2350
+ event.preventDefault();
2351
+ closeCopyColumnMenus();
2352
+ return;
2353
+ }
2354
+
2355
+ if (document.querySelector('.sidebar-db-picker[open]')) {
2356
+ event.preventDefault();
2357
+ closeSidebarDatabasePickers();
2358
+ return;
2359
+ }
2360
+
1747
2361
  if (
1748
2362
  state.route.name === 'data' &&
1749
2363
  (typeof state.dataBrowser.selectedRowIndex === 'number' || Boolean(state.dataBrowser.selectedRow))
@@ -1760,12 +2374,33 @@ document.addEventListener('keydown', event => {
1760
2374
  });
1761
2375
 
1762
2376
  document.addEventListener('input', event => {
2377
+ const target = event.target instanceof Element ? event.target : null;
2378
+ const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
2379
+ const textCellInput = target?.closest('[data-row-editor-text-source]');
2380
+
2381
+ if (timestampInput) {
2382
+ syncRowEditorTimestampPreview(timestampInput);
2383
+ syncRowEditorFilePathPreview(timestampInput);
2384
+ }
2385
+
2386
+ if (textCellInput) {
2387
+ syncRowEditorCharacterCount(textCellInput);
2388
+ }
2389
+
1763
2390
  const bindNode = event.target.closest('[data-bind]');
1764
2391
 
1765
2392
  if (!bindNode) {
1766
2393
  return;
1767
2394
  }
1768
2395
 
2396
+ if (bindNode.dataset.bind === 'copy-column-format-field') {
2397
+ updateCopyColumnModalFormatField(
2398
+ bindNode.dataset.field,
2399
+ bindNode instanceof HTMLInputElement && bindNode.type === 'checkbox' ? bindNode.checked : bindNode.value,
2400
+ );
2401
+ return;
2402
+ }
2403
+
1769
2404
  if (bindNode.dataset.bind === 'current-query') {
1770
2405
  invalidateMainRenderCache();
1771
2406
  syncQueryEditorHighlight(bindNode);
@@ -1807,7 +2442,12 @@ document.addEventListener('input', event => {
1807
2442
  return;
1808
2443
  }
1809
2444
 
1810
- updateCurrentTableDesignerField(bindNode.dataset.field, bindNode.value);
2445
+ updateCurrentTableDesignerField(bindNode.dataset.field, bindNode.value, { notify: false });
2446
+
2447
+ if (!syncTableDesignerDraftUi(bindNode)) {
2448
+ renderApp(getState());
2449
+ }
2450
+
1811
2451
  return;
1812
2452
  }
1813
2453
 
@@ -1816,7 +2456,14 @@ document.addEventListener('input', event => {
1816
2456
  return;
1817
2457
  }
1818
2458
 
1819
- updateCurrentTableDesignerColumnField(bindNode.dataset.columnId, bindNode.dataset.field, bindNode.value);
2459
+ updateCurrentTableDesignerColumnField(bindNode.dataset.columnId, bindNode.dataset.field, bindNode.value, {
2460
+ notify: false,
2461
+ });
2462
+
2463
+ if (!syncTableDesignerDraftUi(bindNode)) {
2464
+ renderApp(getState());
2465
+ }
2466
+
1820
2467
  return;
1821
2468
  }
1822
2469
 
@@ -1877,6 +2524,19 @@ document.addEventListener(
1877
2524
  );
1878
2525
 
1879
2526
  document.addEventListener('change', event => {
2527
+ const target = event.target instanceof Element ? event.target : null;
2528
+ const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
2529
+ const textCellInput = target?.closest('[data-row-editor-text-source]');
2530
+
2531
+ if (timestampInput) {
2532
+ syncRowEditorTimestampPreview(timestampInput);
2533
+ syncRowEditorFilePathPreview(timestampInput);
2534
+ }
2535
+
2536
+ if (textCellInput) {
2537
+ syncRowEditorCharacterCount(textCellInput);
2538
+ }
2539
+
1880
2540
  const bindNode = event.target.closest('[data-bind]');
1881
2541
 
1882
2542
  if (!bindNode) {
@@ -1935,7 +2595,12 @@ document.addEventListener('change', event => {
1935
2595
  return;
1936
2596
  }
1937
2597
 
1938
- updateCurrentTableDesignerField(bindNode.dataset.field, bindNode.value);
2598
+ updateCurrentTableDesignerField(bindNode.dataset.field, bindNode.value, { notify: false });
2599
+
2600
+ if (!syncTableDesignerDraftUi(bindNode)) {
2601
+ renderApp(getState());
2602
+ }
2603
+
1939
2604
  return;
1940
2605
  }
1941
2606
 
@@ -1952,7 +2617,14 @@ document.addEventListener('change', event => {
1952
2617
  return;
1953
2618
  }
1954
2619
 
1955
- updateCurrentTableDesignerColumnField(bindNode.dataset.columnId, bindNode.dataset.field, bindNode.value);
2620
+ updateCurrentTableDesignerColumnField(bindNode.dataset.columnId, bindNode.dataset.field, bindNode.value, {
2621
+ notify: false,
2622
+ });
2623
+
2624
+ if (!syncTableDesignerDraftUi(bindNode)) {
2625
+ renderApp(getState());
2626
+ }
2627
+
1956
2628
  return;
1957
2629
  }
1958
2630
 
@@ -1961,6 +2633,16 @@ document.addEventListener('change', event => {
1961
2633
  return;
1962
2634
  }
1963
2635
 
2636
+ if (bindNode.dataset.bind === 'table-designer-constraint-field') {
2637
+ updateCurrentTableDesignerConstraintField(
2638
+ bindNode.dataset.constraintKind,
2639
+ bindNode.dataset.constraintId,
2640
+ bindNode.dataset.field,
2641
+ bindNode.value,
2642
+ );
2643
+ return;
2644
+ }
2645
+
1964
2646
  if (bindNode.dataset.bind === 'query-chart-draft:chartType') {
1965
2647
  updateCurrentQueryChartDraftField('chartType', bindNode.value);
1966
2648
  return;
@@ -1991,7 +2673,8 @@ document.addEventListener('submit', async event => {
1991
2673
  }
1992
2674
 
1993
2675
  event.preventDefault();
1994
- const formData = new FormData(form);
2676
+ const submitter = event.submitter instanceof HTMLElement ? event.submitter : null;
2677
+ const formData = submitter ? new FormData(form, submitter) : new FormData(form);
1995
2678
 
1996
2679
  switch (form.dataset.form) {
1997
2680
  case 'open-connection': {
@@ -2083,6 +2766,9 @@ document.addEventListener('submit', async event => {
2083
2766
  case 'create-media-tagging-mapping-table':
2084
2767
  await submitCreateMediaTaggingMappingTable();
2085
2768
  return;
2769
+ case 'copy-column':
2770
+ await submitCopyColumnModal(formData);
2771
+ return;
2086
2772
  case 'save-data-row': {
2087
2773
  const values = {};
2088
2774