sqlite-hub 0.16.0 → 0.17.2

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 (53) hide show
  1. package/README.md +106 -19
  2. package/bin/sqlite-hub.js +1041 -224
  3. package/frontend/index.html +1 -0
  4. package/frontend/js/api.js +34 -0
  5. package/frontend/js/app.js +481 -32
  6. package/frontend/js/components/modal.js +270 -50
  7. package/frontend/js/components/pageHeader.js +14 -16
  8. package/frontend/js/components/queryHistoryPanel.js +126 -131
  9. package/frontend/js/components/queryResults.js +18 -1
  10. package/frontend/js/components/rowEditorPanel.js +42 -34
  11. package/frontend/js/components/sidebar.js +1 -0
  12. package/frontend/js/router.js +8 -0
  13. package/frontend/js/store.js +655 -2
  14. package/frontend/js/utils/jsonPreview.js +31 -0
  15. package/frontend/js/utils/markdownDocuments.js +248 -0
  16. package/frontend/js/utils/rowEditorValues.js +41 -0
  17. package/frontend/js/utils/tableScrollState.js +39 -0
  18. package/frontend/js/views/charts.js +8 -0
  19. package/frontend/js/views/data.js +4 -1
  20. package/frontend/js/views/documents.js +300 -0
  21. package/frontend/js/views/editor.js +2 -0
  22. package/frontend/js/views/settings.js +39 -3
  23. package/frontend/styles/components.css +19 -0
  24. package/frontend/styles/tailwind.generated.css +1 -1
  25. package/frontend/styles/views.css +422 -0
  26. package/package.json +2 -1
  27. package/server/middleware/localRequestSecurity.js +84 -0
  28. package/server/routes/connections.js +18 -1
  29. package/server/routes/documents.js +163 -0
  30. package/server/routes/settings.js +22 -6
  31. package/server/server.js +18 -1
  32. package/server/services/nativeFileDialogService.js +168 -0
  33. package/server/services/sqlite/dataBrowserService.js +0 -4
  34. package/server/services/sqlite/exportService.js +5 -1
  35. package/server/services/sqlite/sqlExecutor.js +51 -2
  36. package/server/services/storage/appStateStore.js +313 -0
  37. package/server/utils/sqliteTypes.js +17 -8
  38. package/tests/cli-args.test.js +100 -0
  39. package/tests/connections-file-dialog-route.test.js +46 -0
  40. package/tests/copy-column-modal.test.js +97 -0
  41. package/tests/database-documents.test.js +85 -0
  42. package/tests/export-blob.test.js +46 -0
  43. package/tests/json-preview.test.js +49 -0
  44. package/tests/local-request-security.test.js +85 -0
  45. package/tests/markdown-documents.test.js +79 -0
  46. package/tests/native-file-dialog.test.js +78 -0
  47. package/tests/query-results-truncation.test.js +20 -0
  48. package/tests/row-editor-null-values.test.js +131 -0
  49. package/tests/settings-metadata.test.js +16 -0
  50. package/tests/settings-view.test.js +32 -0
  51. package/tests/sql-identifier-safety.test.js +9 -3
  52. package/tests/sql-result-limit.test.js +66 -0
  53. package/tests/table-scroll-state.test.js +70 -0
@@ -24,6 +24,9 @@ import { renderTopNav } from './components/topNav.js';
24
24
  import { createRouter } from './router.js';
25
25
  import {
26
26
  createActiveConnectionBackup,
27
+ chooseCreateDatabasePath,
28
+ createDocument,
29
+ createDocumentFromMarkdownExport,
27
30
  clearCurrentQuery,
28
31
  clearDataRowSelection,
29
32
  clearEditorRowSelection,
@@ -48,12 +51,15 @@ import {
48
51
  openDeleteQueryHistoryModal,
49
52
  openDeleteQueryChartModal,
50
53
  openDataExportModal,
54
+ openDeleteDocumentModal,
51
55
  openQueryExportModal,
52
56
  openDataRowByIdentity,
53
57
  openEditConnectionModal,
54
58
  openCreateQueryChartModal,
55
59
  openCopyColumnModal,
56
60
  openEditQueryChartModal,
61
+ openDocumentInsertNoteModal,
62
+ openDocumentInsertTableModal,
57
63
  preserveCurrentDataRowSelectionForReload,
58
64
  openDataRowUpdatePreview,
59
65
  openEditorRowUpdatePreview,
@@ -66,6 +72,7 @@ import {
66
72
  runQueryHistoryItem,
67
73
  skipCurrentMediaTaggingItem,
68
74
  saveCurrentQueryChartDraft,
75
+ saveCurrentDocument,
69
76
  saveCurrentMediaTaggingConfig,
70
77
  selectDataRow,
71
78
  selectEditorRow,
@@ -90,6 +97,9 @@ import {
90
97
  setEditorPanelVisibility,
91
98
  setEditorTab,
92
99
  submitDeleteChartConfirmation,
100
+ submitDeleteDocumentConfirmation,
101
+ submitDocumentInsertNote,
102
+ submitDocumentInsertTable,
93
103
  submitCreateMediaTaggingTagTable,
94
104
  submitCreateMediaTaggingMappingTable,
95
105
  submitDeleteQueryHistoryConfirmation,
@@ -100,6 +110,7 @@ import {
100
110
  setQueryHistorySearchInput,
101
111
  setQueryHistoryTab,
102
112
  setCopyColumnModalError,
113
+ setCopyColumnModalEditedText,
103
114
  setCopyColumnModalSubmitting,
104
115
  setRoute,
105
116
  saveQueryHistoryNotes,
@@ -107,6 +118,8 @@ import {
107
118
  saveCurrentTableDesignerDraft,
108
119
  toggleChartsResultsPanel,
109
120
  toggleChartsSqlPanel,
121
+ toggleCurrentDocumentTodo,
122
+ toggleDocumentsPane,
110
123
  setMediaTaggingWorkflowMediaDetailsVisible,
111
124
  setMediaTaggingWorkflowMediaRotationDegrees,
112
125
  queueTableDesignerCsvImport,
@@ -124,6 +137,8 @@ import {
124
137
  updateCurrentMediaTaggingField,
125
138
  updateCurrentMediaTaggingTagFormField,
126
139
  updateCopyColumnModalFormatField,
140
+ updateCurrentDocumentDraftField,
141
+ updateDocumentInsertQuerySelection,
127
142
  updateCurrentQueryChartDraftConfigField,
128
143
  updateCurrentQueryChartDraftField,
129
144
  updateCurrentTableDesignerColumnField,
@@ -136,6 +151,7 @@ import {
136
151
  import { renderChartsDetail, renderChartsView } from './views/charts.js';
137
152
  import { renderConnectionsView } from './views/connections.js';
138
153
  import { renderDataRowEditorPanel, renderDataView } from './views/data.js';
154
+ import { renderDocumentsView } from './views/documents.js';
139
155
  import { renderEditorView } from './views/editor.js';
140
156
  import { renderLandingView } from './views/landing.js';
141
157
  import { renderMediaTaggingView } from './views/mediaTagging.js';
@@ -163,10 +179,19 @@ import {
163
179
  stringifyRowEditorJson,
164
180
  } from './utils/rowEditorJson.js';
165
181
  import { getTimestampPreviewForField } from './utils/timestampPreview.js';
182
+ import {
183
+ buildRowEditorSubmittedValues,
184
+ getRowEditorValueState,
185
+ getRowEditorValueStateLabel,
186
+ } from './utils/rowEditorValues.js';
166
187
  import {
167
188
  formatTextCellCharacterCount,
168
189
  getTextCellCharacterCount,
169
190
  } from './utils/textCellStats.js';
191
+ import {
192
+ captureTableHorizontalScrollState,
193
+ restoreTableHorizontalScrollState,
194
+ } from './utils/tableScrollState.js';
170
195
 
171
196
  const appRoot = document.querySelector('#app');
172
197
 
@@ -197,6 +222,10 @@ let lastRenderedLockedRoute = false;
197
222
  let pendingNewTableDesignerAutofocus = false;
198
223
  let pendingQueryEditorFocus = false;
199
224
  let pendingMediaTaggingTagSearchFocus = false;
225
+ let documentAutosaveTimer = null;
226
+ let pendingDocumentAutosaveId = null;
227
+
228
+ const DOCUMENT_AUTOSAVE_DELAY_MS = 5000;
200
229
 
201
230
  const APP_TITLE = 'SQLite Hub';
202
231
  const ROUTE_TITLE_SEGMENTS = {
@@ -207,6 +236,7 @@ const ROUTE_TITLE_SEGMENTS = {
207
236
  editor: 'SQL Editor',
208
237
  editorResults: 'SQL Editor',
209
238
  charts: 'Charts',
239
+ documents: 'Documents',
210
240
  tableDesigner: 'Table Designer',
211
241
  mediaTaggingSetup: 'Media Tagging',
212
242
  mediaTaggingQueue: 'Tagging Queue',
@@ -797,13 +827,58 @@ function readFileAsText(file) {
797
827
  resolve(String(reader.result ?? ''));
798
828
  };
799
829
  reader.onerror = () => {
800
- reject(reader.error ?? new Error('The selected CSV file could not be read.'));
830
+ reject(reader.error ?? new Error('The selected file could not be read.'));
801
831
  };
802
832
 
803
833
  reader.readAsText(file);
804
834
  });
805
835
  }
806
836
 
837
+ function clearDocumentAutosaveTimer() {
838
+ if (documentAutosaveTimer !== null) {
839
+ window.clearTimeout(documentAutosaveTimer);
840
+ }
841
+
842
+ documentAutosaveTimer = null;
843
+ pendingDocumentAutosaveId = null;
844
+ }
845
+
846
+ function scheduleDocumentAutosave(documentId) {
847
+ clearDocumentAutosaveTimer();
848
+
849
+ if (!documentId) {
850
+ return;
851
+ }
852
+
853
+ pendingDocumentAutosaveId = String(documentId);
854
+ documentAutosaveTimer = window.setTimeout(async () => {
855
+ const state = getState();
856
+ const scheduledDocumentId = pendingDocumentAutosaveId;
857
+
858
+ documentAutosaveTimer = null;
859
+ pendingDocumentAutosaveId = null;
860
+
861
+ if (
862
+ state.route.name !== 'documents' ||
863
+ String(state.documents.selectedId ?? '') !== scheduledDocumentId ||
864
+ !state.documents.dirty
865
+ ) {
866
+ return;
867
+ }
868
+
869
+ if (state.documents.saving) {
870
+ scheduleDocumentAutosave(scheduledDocumentId);
871
+ return;
872
+ }
873
+
874
+ if (state.documents.deleting) {
875
+ return;
876
+ }
877
+
878
+ await saveCurrentDocument({ toast: false });
879
+ }, DOCUMENT_AUTOSAVE_DELAY_MS);
880
+ }
881
+
807
882
  async function buildConnectionLogoUpload(file) {
808
883
  if (!(file instanceof File) || !file.size) {
809
884
  return null;
@@ -852,6 +927,8 @@ function resolveView(state) {
852
927
  return renderOverviewView(state);
853
928
  case 'charts':
854
929
  return renderChartsView(state);
930
+ case 'documents':
931
+ return renderDocumentsView(state);
855
932
  case 'data':
856
933
  return renderDataView(state);
857
934
  case 'editor':
@@ -1101,6 +1178,39 @@ async function handleTableDesignerCsvImport(fileInput) {
1101
1178
  }
1102
1179
  }
1103
1180
 
1181
+ async function handleDocumentMarkdownImport(fileInput) {
1182
+ if (!(fileInput instanceof HTMLInputElement)) {
1183
+ return;
1184
+ }
1185
+
1186
+ const file = fileInput.files?.[0];
1187
+
1188
+ if (!(file instanceof File)) {
1189
+ return;
1190
+ }
1191
+
1192
+ try {
1193
+ const content = await readFileAsText(file);
1194
+ const document = await createDocumentFromMarkdownExport({
1195
+ filename: file.name,
1196
+ content,
1197
+ });
1198
+
1199
+ if (!document?.id) {
1200
+ showToast('Markdown document could not be imported.', 'alert');
1201
+ return;
1202
+ }
1203
+
1204
+ clearDocumentAutosaveTimer();
1205
+ showToast(`Document "${document.filename}" imported.`, 'success');
1206
+ router.navigate(`/documents/${encodeURIComponent(document.id)}`);
1207
+ } catch (error) {
1208
+ showToast(error?.message || 'Markdown import failed.', 'alert');
1209
+ } finally {
1210
+ fileInput.value = '';
1211
+ }
1212
+ }
1213
+
1104
1214
  function renderApp(state) {
1105
1215
  syncDocumentTitle(state);
1106
1216
 
@@ -1118,6 +1228,7 @@ function renderApp(state) {
1118
1228
  'editorResults',
1119
1229
  'data',
1120
1230
  'charts',
1231
+ 'documents',
1121
1232
  'structure',
1122
1233
  'tableDesigner',
1123
1234
  'mediaTaggingSetup',
@@ -1156,6 +1267,10 @@ function renderApp(state) {
1156
1267
 
1157
1268
  const focusedInput = captureFocusedInputState();
1158
1269
  const queryHistoryScrollState = captureQueryHistoryScrollState();
1270
+ const tableHorizontalScrollState = captureTableHorizontalScrollState({
1271
+ routeName: lastRenderedRouteName,
1272
+ scrollNodes: shellRefs.view.querySelectorAll('[data-table-horizontal-scroll]'),
1273
+ });
1159
1274
  const isEnteringNewTableDesignerRoute =
1160
1275
  state.route.name === 'tableDesigner' && state.route.params?.isNew && previousRoutePath !== state.route.path;
1161
1276
 
@@ -1228,6 +1343,14 @@ function renderApp(state) {
1228
1343
  restoreQueryHistoryScrollState(queryHistoryScrollState);
1229
1344
  }
1230
1345
 
1346
+ if (mainChanged && !mainPatched) {
1347
+ restoreTableHorizontalScrollState({
1348
+ snapshot: tableHorizontalScrollState,
1349
+ routeName: state.route.name,
1350
+ scrollNodes: shellRefs.view.querySelectorAll('[data-table-horizontal-scroll]'),
1351
+ });
1352
+ }
1353
+
1231
1354
  if (pendingQueryEditorFocus && (state.route.name === 'editor' || state.route.name === 'editorResults')) {
1232
1355
  if (focusQueryEditorInput()) {
1233
1356
  pendingQueryEditorFocus = false;
@@ -1471,6 +1594,53 @@ function syncRowEditorFilePathPreview(inputNode) {
1471
1594
  previewNode.hidden = false;
1472
1595
  }
1473
1596
 
1597
+ function getRowEditorValueStateClassName(state) {
1598
+ if (state === 'null') {
1599
+ return 'border px-2 py-1 text-[9px] border-primary-container/35 bg-primary-container/15 text-primary-container';
1600
+ }
1601
+
1602
+ if (state === 'empty') {
1603
+ return 'border px-2 py-1 text-[9px] border-outline-variant/35 bg-surface-container-high text-on-surface-variant';
1604
+ }
1605
+
1606
+ return 'border px-2 py-1 text-[9px] border-outline-variant/20 bg-surface-container text-on-surface-variant';
1607
+ }
1608
+
1609
+ function syncRowEditorValueState(controlNode) {
1610
+ const fieldNode = controlNode.closest('[data-row-editor-field]');
1611
+ const valueInput = fieldNode?.querySelector('[data-row-editor-value-source]');
1612
+ const stateBadge = fieldNode?.querySelector('[data-row-editor-value-state]');
1613
+
1614
+ if (!fieldNode || !valueInput || !stateBadge) {
1615
+ return;
1616
+ }
1617
+
1618
+ const valueState = getRowEditorValueState(valueInput.value);
1619
+
1620
+ valueInput.dataset.rowEditorDirty = 'true';
1621
+ stateBadge.dataset.valueState = valueState;
1622
+ stateBadge.className = getRowEditorValueStateClassName(valueState);
1623
+ stateBadge.textContent = getRowEditorValueStateLabel(valueState);
1624
+
1625
+ syncRowEditorTimestampPreview(valueInput);
1626
+ syncRowEditorFilePathPreview(valueInput);
1627
+ syncRowEditorCharacterCount(valueInput);
1628
+ }
1629
+
1630
+ function getRowEditorFieldMetadata(form) {
1631
+ return Object.fromEntries(
1632
+ Array.from(form.elements)
1633
+ .filter(element => element.name?.startsWith('field:'))
1634
+ .map(element => [
1635
+ element.name.slice('field:'.length),
1636
+ {
1637
+ initialState: element.closest('[data-row-editor-field]')?.dataset?.rowEditorInitialState,
1638
+ dirty: element.dataset.rowEditorDirty === 'true',
1639
+ },
1640
+ ]),
1641
+ );
1642
+ }
1643
+
1474
1644
  function closeCopyColumnMenus(exceptMenu = null) {
1475
1645
  document.querySelectorAll('[data-copy-column-menu][open]').forEach(menu => {
1476
1646
  if (menu !== exceptMenu && menu instanceof HTMLDetailsElement) {
@@ -1520,6 +1690,69 @@ function buildCopyColumnExportFilename(columnName, copyMode) {
1520
1690
  return `${columnSlug}-${metadata.suffix}.${metadata.extension}`;
1521
1691
  }
1522
1692
 
1693
+ function normalizeMarkdownDownloadFilename(value) {
1694
+ let filename = String(value ?? '')
1695
+ .trim()
1696
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
1697
+ .replace(/[\\/]+/g, ' ')
1698
+ .replace(/\s+/g, ' ')
1699
+ .replace(/^\.+/, '')
1700
+ .trim();
1701
+
1702
+ if (!filename) {
1703
+ filename = 'document.md';
1704
+ }
1705
+
1706
+ if (!/\.md$/i.test(filename)) {
1707
+ filename = `${filename}.md`;
1708
+ }
1709
+
1710
+ return filename;
1711
+ }
1712
+
1713
+ function countEditedMarkdownTodoItems(text) {
1714
+ const lines = String(text ?? '')
1715
+ .split(/\r\n|\r|\n/g)
1716
+ .filter(line => line.trim());
1717
+
1718
+ return lines.length;
1719
+ }
1720
+
1721
+ function buildDocumentTimestampSlug(date = new Date()) {
1722
+ const pad = value => String(value).padStart(2, '0');
1723
+
1724
+ return [
1725
+ date.getFullYear(),
1726
+ pad(date.getMonth() + 1),
1727
+ pad(date.getDate()),
1728
+ pad(date.getHours()),
1729
+ pad(date.getMinutes()),
1730
+ ].join('-');
1731
+ }
1732
+
1733
+ function buildCopyColumnDocumentFilename(columnName) {
1734
+ const columnSlug = slugifyExportFilenamePart(columnName);
1735
+
1736
+ return `${columnSlug}-todos-${buildDocumentTimestampSlug()}.md`;
1737
+ }
1738
+
1739
+ function buildCopyColumnDocumentContent({ state, columnName, text, valueCount }) {
1740
+ const activeConnection = state.connections.active;
1741
+ const lines = [
1742
+ `# ${columnName || 'Column'} todos`,
1743
+ '',
1744
+ `- Database: ${activeConnection?.label || 'Active database'}`,
1745
+ `- Column: ${columnName || 'N/A'}`,
1746
+ `- Items: ${valueCount}`,
1747
+ `- Generated: ${new Date().toLocaleString()}`,
1748
+ '',
1749
+ ];
1750
+
1751
+ lines.push('## Todos', '', String(text ?? '').trim(), '');
1752
+
1753
+ return lines.join('\n');
1754
+ }
1755
+
1523
1756
  function downloadTextFile({ text, filename, mimeType }) {
1524
1757
  const blob = new Blob([text], { type: mimeType });
1525
1758
  const url = URL.createObjectURL(blob);
@@ -1568,21 +1801,24 @@ function applyChangedRowEditorFormValues(rowObject) {
1568
1801
 
1569
1802
  const nextRowObject = { ...rowObject };
1570
1803
  const formData = new FormData(form);
1804
+ const submittedValues = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
1571
1805
 
1572
- for (const [key, value] of formData.entries()) {
1573
- if (!key.startsWith('field:')) {
1574
- continue;
1575
- }
1576
-
1806
+ for (const [fieldName, value] of Object.entries(submittedValues)) {
1807
+ const key = `field:${fieldName}`;
1577
1808
  const control = Array.from(form.elements).find(element => element.name === key);
1578
1809
  const initialValue = control?.dataset?.rowEditorInitialValue;
1579
- const currentValue = String(value ?? '');
1810
+ const initialState = control?.closest('[data-row-editor-field]')?.dataset?.rowEditorInitialState;
1811
+ const currentState = getRowEditorValueState(value);
1812
+ const currentValue = value === null ? null : String(value);
1580
1813
 
1581
- if (initialValue !== undefined && currentValue === initialValue) {
1814
+ if (
1815
+ initialState === currentState &&
1816
+ (currentState === 'null' || (initialValue !== undefined && currentValue === initialValue))
1817
+ ) {
1582
1818
  continue;
1583
1819
  }
1584
1820
 
1585
- nextRowObject[key.slice('field:'.length)] = currentValue;
1821
+ nextRowObject[fieldName] = currentValue;
1586
1822
  }
1587
1823
 
1588
1824
  return nextRowObject;
@@ -1677,6 +1913,37 @@ function exportRowEditorJson() {
1677
1913
  showToast(`Row from "${payload.label}" exported as JSON.`, 'success');
1678
1914
  }
1679
1915
 
1916
+ function exportCurrentDocumentMarkdown() {
1917
+ const documents = getState().documents;
1918
+
1919
+ if (!documents.selectedId) {
1920
+ showToast('No document is selected.', 'alert');
1921
+ return;
1922
+ }
1923
+
1924
+ const filename = normalizeMarkdownDownloadFilename(documents.draftFilename || documents.selected?.filename);
1925
+
1926
+ downloadTextFile({
1927
+ text: documents.draftContent ?? '',
1928
+ filename,
1929
+ mimeType: 'text/markdown;charset=utf-8',
1930
+ });
1931
+ showToast(`Document "${filename}" exported.`, 'success');
1932
+ }
1933
+
1934
+ function getCurrentDocumentEditorInsertionRange() {
1935
+ const textarea = document.querySelector('.documents-editor-input');
1936
+
1937
+ if (!(textarea instanceof HTMLTextAreaElement)) {
1938
+ return null;
1939
+ }
1940
+
1941
+ return {
1942
+ start: textarea.selectionStart,
1943
+ end: textarea.selectionEnd,
1944
+ };
1945
+ }
1946
+
1680
1947
  function preserveDataRowEditorSelectionForReload() {
1681
1948
  preserveCurrentDataRowSelectionForReload();
1682
1949
  }
@@ -1698,6 +1965,9 @@ async function submitCopyColumnModal(formData) {
1698
1965
  const outputSeparator = lineBreaks ? '\n' : separator;
1699
1966
  const intent = String(formData.get('intent') ?? 'copy');
1700
1967
  const isExportIntent = intent === 'export';
1968
+ const isDocumentIntent = intent === 'document';
1969
+ const isMarkdownTodo = isMarkdownTodoCopyColumnMode(copyMode);
1970
+ const editedText = formData.has('editedText') ? String(formData.get('editedText') ?? '') : null;
1701
1971
  const result = getCopyColumnResult(state, scope);
1702
1972
  const hasColumn = (result?.columns ?? []).some(column => String(column) === columnName);
1703
1973
 
@@ -1710,20 +1980,57 @@ async function submitCopyColumnModal(formData) {
1710
1980
  return;
1711
1981
  }
1712
1982
 
1713
- if (!isMarkdownTodoCopyColumnMode(copyMode)) {
1983
+ if (!isMarkdownTodo) {
1714
1984
  storeCopyColumnPreferences({ separator, wrapper, lineBreaks });
1985
+ } else if (editedText !== null) {
1986
+ setCopyColumnModalEditedText(editedText);
1715
1987
  }
1716
1988
  setCopyColumnModalSubmitting(true);
1717
1989
 
1718
- const { text, valueCount } = buildCopyColumnText({
1990
+ const generatedOutput = buildCopyColumnText({
1719
1991
  result,
1720
1992
  columnName,
1721
1993
  copyMode,
1722
1994
  separator: outputSeparator,
1723
1995
  wrapper,
1724
1996
  });
1997
+ const text = isMarkdownTodo && editedText !== null ? editedText : generatedOutput.text;
1998
+ const valueCount = isMarkdownTodo && editedText !== null
1999
+ ? countEditedMarkdownTodoItems(editedText)
2000
+ : generatedOutput.valueCount;
1725
2001
 
1726
2002
  try {
2003
+ if (isDocumentIntent) {
2004
+ if (!isMarkdownTodo) {
2005
+ throw new Error('Document export is only available for Markdown Todo columns.');
2006
+ }
2007
+
2008
+ const document = await createDocumentFromMarkdownExport({
2009
+ filename: buildCopyColumnDocumentFilename(columnName),
2010
+ title: `${columnName || 'Column'} todos`,
2011
+ content: buildCopyColumnDocumentContent({
2012
+ state,
2013
+ columnName,
2014
+ text,
2015
+ valueCount,
2016
+ }),
2017
+ });
2018
+
2019
+ if (!document) {
2020
+ throw new Error('Document could not be created.');
2021
+ }
2022
+
2023
+ closeModal();
2024
+ showToast(
2025
+ `Document "${document.filename}" created · ${formatNumber(valueCount)} ${
2026
+ valueCount === 1 ? 'item' : 'items'
2027
+ }`,
2028
+ 'success',
2029
+ );
2030
+ router.navigate(`/documents/${encodeURIComponent(document.id)}`);
2031
+ return;
2032
+ }
2033
+
1727
2034
  if (isExportIntent) {
1728
2035
  const metadata = getCopyColumnExportMetadata(copyMode);
1729
2036
 
@@ -1754,10 +2061,23 @@ async function submitCopyColumnModal(formData) {
1754
2061
  );
1755
2062
  } catch (error) {
1756
2063
  setCopyColumnModalError({
1757
- code: isExportIntent ? 'COPY_COLUMN_EXPORT_FAILED' : 'CLIPBOARD_ACCESS_FAILED',
1758
- message: error?.message || (isExportIntent ? 'Column export failed.' : 'Clipboard access failed.'),
2064
+ code: isDocumentIntent
2065
+ ? 'COPY_COLUMN_DOCUMENT_EXPORT_FAILED'
2066
+ : isExportIntent
2067
+ ? 'COPY_COLUMN_EXPORT_FAILED'
2068
+ : 'CLIPBOARD_ACCESS_FAILED',
2069
+ message:
2070
+ error?.message ||
2071
+ (isDocumentIntent
2072
+ ? 'Document export failed.'
2073
+ : isExportIntent
2074
+ ? 'Column export failed.'
2075
+ : 'Clipboard access failed.'),
1759
2076
  });
1760
- showToast(isExportIntent ? 'Column export failed.' : 'Clipboard access failed.', 'alert');
2077
+ showToast(
2078
+ isDocumentIntent ? 'Document export failed.' : isExportIntent ? 'Column export failed.' : 'Clipboard access failed.',
2079
+ 'alert',
2080
+ );
1761
2081
  }
1762
2082
  }
1763
2083
 
@@ -1794,6 +2114,58 @@ async function handleAction(actionNode) {
1794
2114
  mode: actionNode.dataset.copyMode,
1795
2115
  });
1796
2116
  return;
2117
+ case 'create-document': {
2118
+ const document = await createDocument();
2119
+
2120
+ if (document?.id) {
2121
+ router.navigate(`/documents/${encodeURIComponent(document.id)}`);
2122
+ }
2123
+ return;
2124
+ }
2125
+ case 'select-document': {
2126
+ const documentId = String(actionNode.dataset.documentId ?? '').trim();
2127
+
2128
+ if (documentId) {
2129
+ clearDocumentAutosaveTimer();
2130
+ router.navigate(`/documents/${encodeURIComponent(documentId)}`);
2131
+ }
2132
+ return;
2133
+ }
2134
+ case 'save-document':
2135
+ clearDocumentAutosaveTimer();
2136
+ await saveCurrentDocument();
2137
+ return;
2138
+ case 'export-document-markdown':
2139
+ exportCurrentDocumentMarkdown();
2140
+ return;
2141
+ case 'open-document-insert-table-modal':
2142
+ await openDocumentInsertTableModal(getCurrentDocumentEditorInsertionRange());
2143
+ return;
2144
+ case 'open-document-insert-note-modal':
2145
+ await openDocumentInsertNoteModal(getCurrentDocumentEditorInsertionRange());
2146
+ return;
2147
+ case 'import-document-markdown': {
2148
+ const fileInput = document.querySelector('[data-bind="document-import-file"]');
2149
+
2150
+ if (!(fileInput instanceof HTMLInputElement)) {
2151
+ return;
2152
+ }
2153
+
2154
+ fileInput.value = '';
2155
+ fileInput.click();
2156
+ return;
2157
+ }
2158
+ case 'delete-document':
2159
+ clearDocumentAutosaveTimer();
2160
+ openDeleteDocumentModal();
2161
+ return;
2162
+ case 'toggle-document-pane':
2163
+ toggleDocumentsPane(actionNode.dataset.pane);
2164
+ return;
2165
+ case 'toggle-document-todo':
2166
+ clearDocumentAutosaveTimer();
2167
+ await toggleCurrentDocumentTodo(actionNode.dataset.lineIndex);
2168
+ return;
1797
2169
  case 'open-create-query-chart-modal':
1798
2170
  openCreateQueryChartModal();
1799
2171
  return;
@@ -1809,6 +2181,33 @@ async function handleAction(actionNode) {
1809
2181
  case 'edit-connection':
1810
2182
  openEditConnectionModal(actionNode.dataset.connectionId);
1811
2183
  return;
2184
+ case 'choose-create-database-path': {
2185
+ const labelNode = actionNode.querySelector('[data-create-database-path-button-label]');
2186
+
2187
+ actionNode.setAttribute('disabled', '');
2188
+ if (labelNode) {
2189
+ labelNode.textContent = 'Choosing...';
2190
+ }
2191
+
2192
+ const selectedPath = await chooseCreateDatabasePath();
2193
+ const pathInput = document.querySelector(
2194
+ '[data-form="create-connection"] [data-create-database-path]',
2195
+ );
2196
+
2197
+ if (selectedPath && pathInput instanceof HTMLInputElement) {
2198
+ pathInput.value = selectedPath;
2199
+ pathInput.focus({ preventScroll: true });
2200
+ pathInput.setSelectionRange(pathInput.value.length, pathInput.value.length);
2201
+ }
2202
+
2203
+ if (actionNode.isConnected) {
2204
+ actionNode.removeAttribute('disabled');
2205
+ if (labelNode) {
2206
+ labelNode.textContent = 'Browse...';
2207
+ }
2208
+ }
2209
+ return;
2210
+ }
1812
2211
  case 'close-modal':
1813
2212
  closeModal();
1814
2213
  return;
@@ -2284,6 +2683,20 @@ document.addEventListener('keydown', event => {
2284
2683
  const target = event.target;
2285
2684
  const state = getState();
2286
2685
 
2686
+ if (
2687
+ (event.key === 's' || event.key === 'S') &&
2688
+ (event.ctrlKey || event.metaKey) &&
2689
+ !event.altKey &&
2690
+ !event.shiftKey &&
2691
+ !event.defaultPrevented &&
2692
+ state.route.name === 'documents'
2693
+ ) {
2694
+ event.preventDefault();
2695
+ clearDocumentAutosaveTimer();
2696
+ void saveCurrentDocument();
2697
+ return;
2698
+ }
2699
+
2287
2700
  // Handle Enter key in tag form fields to trigger create tag
2288
2701
  if (
2289
2702
  event.key === 'Enter' &&
@@ -2375,6 +2788,7 @@ document.addEventListener('keydown', event => {
2375
2788
 
2376
2789
  document.addEventListener('input', event => {
2377
2790
  const target = event.target instanceof Element ? event.target : null;
2791
+ const valueInput = target?.closest('[data-row-editor-value-source]');
2378
2792
  const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
2379
2793
  const textCellInput = target?.closest('[data-row-editor-text-source]');
2380
2794
 
@@ -2387,6 +2801,10 @@ document.addEventListener('input', event => {
2387
2801
  syncRowEditorCharacterCount(textCellInput);
2388
2802
  }
2389
2803
 
2804
+ if (valueInput) {
2805
+ syncRowEditorValueState(valueInput);
2806
+ }
2807
+
2390
2808
  const bindNode = event.target.closest('[data-bind]');
2391
2809
 
2392
2810
  if (!bindNode) {
@@ -2409,6 +2827,12 @@ document.addEventListener('input', event => {
2409
2827
  return;
2410
2828
  }
2411
2829
 
2830
+ if (bindNode.dataset.bind === 'document-field') {
2831
+ updateCurrentDocumentDraftField(bindNode.dataset.field, bindNode.value);
2832
+ scheduleDocumentAutosave(getState().documents.selectedId);
2833
+ return;
2834
+ }
2835
+
2412
2836
  if (bindNode.dataset.bind === 'data-search-query') {
2413
2837
  void setDataSearchQuery(bindNode.value);
2414
2838
  return;
@@ -2525,6 +2949,7 @@ document.addEventListener(
2525
2949
 
2526
2950
  document.addEventListener('change', event => {
2527
2951
  const target = event.target instanceof Element ? event.target : null;
2952
+ const valueControl = target?.closest('[data-row-editor-value-source]');
2528
2953
  const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
2529
2954
  const textCellInput = target?.closest('[data-row-editor-text-source]');
2530
2955
 
@@ -2537,6 +2962,10 @@ document.addEventListener('change', event => {
2537
2962
  syncRowEditorCharacterCount(textCellInput);
2538
2963
  }
2539
2964
 
2965
+ if (valueControl) {
2966
+ syncRowEditorValueState(valueControl);
2967
+ }
2968
+
2540
2969
  const bindNode = event.target.closest('[data-bind]');
2541
2970
 
2542
2971
  if (!bindNode) {
@@ -2579,6 +3008,16 @@ document.addEventListener('change', event => {
2579
3008
  return;
2580
3009
  }
2581
3010
 
3011
+ if (bindNode.dataset.bind === 'document-import-file') {
3012
+ void handleDocumentMarkdownImport(bindNode);
3013
+ return;
3014
+ }
3015
+
3016
+ if (bindNode.dataset.bind === 'document-insert-query-select') {
3017
+ updateDocumentInsertQuerySelection(bindNode.value);
3018
+ return;
3019
+ }
3020
+
2582
3021
  if (bindNode.dataset.bind === 'table-designer-field') {
2583
3022
  if (bindNode instanceof HTMLInputElement && bindNode.type === 'checkbox') {
2584
3023
  updateCurrentTableDesignerField(bindNode.dataset.field, bindNode.checked);
@@ -2757,6 +3196,32 @@ document.addEventListener('submit', async event => {
2757
3196
  case 'delete-query-history-confirm':
2758
3197
  await submitDeleteQueryHistoryConfirmation();
2759
3198
  return;
3199
+ case 'delete-document-confirm': {
3200
+ const result = await submitDeleteDocumentConfirmation();
3201
+
3202
+ if (result.deleted) {
3203
+ router.navigate(
3204
+ result.nextDocumentId ? `/documents/${encodeURIComponent(result.nextDocumentId)}` : '/documents',
3205
+ );
3206
+ }
3207
+ return;
3208
+ }
3209
+ case 'document-insert-table': {
3210
+ const inserted = await submitDocumentInsertTable();
3211
+
3212
+ if (inserted) {
3213
+ scheduleDocumentAutosave(getState().documents.selectedId);
3214
+ }
3215
+ return;
3216
+ }
3217
+ case 'document-insert-note': {
3218
+ const inserted = submitDocumentInsertNote();
3219
+
3220
+ if (inserted) {
3221
+ scheduleDocumentAutosave(getState().documents.selectedId);
3222
+ }
3223
+ return;
3224
+ }
2760
3225
  case 'apply-row-update-preview':
2761
3226
  await submitRowUpdatePreviewConfirmation();
2762
3227
  return;
@@ -2770,15 +3235,7 @@ document.addEventListener('submit', async event => {
2770
3235
  await submitCopyColumnModal(formData);
2771
3236
  return;
2772
3237
  case 'save-data-row': {
2773
- const values = {};
2774
-
2775
- for (const [key, value] of formData.entries()) {
2776
- if (!key.startsWith('field:')) {
2777
- continue;
2778
- }
2779
-
2780
- values[key.slice('field:'.length)] = String(value ?? '');
2781
- }
3238
+ const values = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
2782
3239
 
2783
3240
  let rowIdentity = null;
2784
3241
  const rawRowIdentity = String(formData.get('rowIdentity') ?? '').trim();
@@ -2795,15 +3252,7 @@ document.addEventListener('submit', async event => {
2795
3252
  return;
2796
3253
  }
2797
3254
  case 'save-editor-row': {
2798
- const values = {};
2799
-
2800
- for (const [key, value] of formData.entries()) {
2801
- if (!key.startsWith('field:')) {
2802
- continue;
2803
- }
2804
-
2805
- values[key.slice('field:'.length)] = String(value ?? '');
2806
- }
3255
+ const values = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
2807
3256
 
2808
3257
  await openEditorRowUpdatePreview(String(formData.get('rowIndex') ?? ''), values);
2809
3258
  return;