sqlite-hub 1.2.0 → 1.5.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.
- package/README.md +113 -71
- package/bin/sqlite-hub.js +723 -116
- package/docs/API.md +152 -0
- package/docs/CLI.md +249 -0
- package/docs/CLI_API_PARITY.md +61 -0
- package/docs/changelog.md +149 -0
- package/docs/guidelines/AGENTS.md +32 -0
- package/docs/guidelines/DESIGN.md +55 -0
- package/docs/shortkeys.md +27 -0
- package/docs/todo.md +4 -0
- package/examples/api/generate-types.js +52 -0
- package/frontend/assets/mockups/backups_1_1920.webp +0 -0
- package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_1_1920.webp +0 -0
- package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
- package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
- package/frontend/assets/mockups/connections_1_1920.webp +0 -0
- package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
- package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
- package/frontend/assets/mockups/data_1_1920.webp +0 -0
- package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
- package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
- package/frontend/assets/mockups/documents_1_1920.webp +0 -0
- package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/overview_1_1920.webp +0 -0
- package/frontend/assets/mockups/settings_1_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
- package/frontend/assets/mockups/structure_1_1920.webp +0 -0
- package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
- package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
- package/frontend/js/api.js +63 -0
- package/frontend/js/app.js +310 -10
- package/frontend/js/components/connectionCard.js +4 -2
- package/frontend/js/components/emptyState.js +4 -4
- package/frontend/js/components/generateTypesDropdown.js +33 -0
- package/frontend/js/components/metricCard.js +1 -1
- package/frontend/js/components/modal.js +555 -21
- package/frontend/js/components/pageHeader.js +1 -1
- package/frontend/js/components/queryHistoryDetail.js +3 -3
- package/frontend/js/components/queryHistoryHeader.js +1 -1
- package/frontend/js/components/queryHistoryPanel.js +1 -1
- package/frontend/js/components/rowEditorPanel.js +13 -3
- package/frontend/js/components/sidebar.js +41 -7
- package/frontend/js/components/topNav.js +2 -14
- package/frontend/js/router.js +38 -6
- package/frontend/js/store.js +870 -8
- package/frontend/js/utils/inputClear.js +36 -0
- package/frontend/js/utils/syntheticData.js +240 -0
- package/frontend/js/views/backups.js +581 -37
- package/frontend/js/views/charts.js +9 -9
- package/frontend/js/views/connections.js +2 -2
- package/frontend/js/views/data.js +17 -1
- package/frontend/js/views/documents.js +3 -3
- package/frontend/js/views/editor.js +6 -6
- package/frontend/js/views/logs.js +339 -0
- package/frontend/js/views/mediaTagging.js +7 -5
- package/frontend/js/views/overview.js +3 -3
- package/frontend/js/views/settings.js +80 -30
- package/frontend/js/views/structure.js +12 -1
- package/frontend/js/views/tableAdvisor.js +385 -0
- package/frontend/js/views/tableDesigner.js +6 -0
- package/frontend/styles/base.css +1 -40
- package/frontend/styles/components.css +258 -232
- package/frontend/styles/structure-graph.css +32 -68
- package/frontend/styles/tailwind.css +8 -4
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/tokens.css +28 -21
- package/frontend/styles/views.css +102 -261
- package/package.json +18 -2
- package/server/routes/backups.js +18 -2
- package/server/routes/data.js +45 -0
- package/server/routes/externalApi.js +321 -2
- package/server/routes/logs.js +127 -0
- package/server/routes/structure.js +22 -0
- package/server/server.js +3 -0
- package/server/services/databaseCommandService.js +59 -0
- package/server/services/sqlite/backupDiff.js +914 -0
- package/server/services/sqlite/backupService.js +77 -1
- package/server/services/sqlite/dataBrowserService.js +36 -0
- package/server/services/sqlite/introspection.js +226 -22
- package/server/services/sqlite/structureService.js +7 -0
- package/server/services/sqlite/syntheticDataGenerator.js +728 -0
- package/server/services/sqlite/tableAdvisor.js +790 -0
- package/server/services/storage/appStateStore.js +773 -169
- package/server/services/typeGenerationService.js +663 -0
- package/tailwind.config.cjs +0 -1
- package/.github/funding.yml +0 -2
- package/.github/workflows/ci.yml +0 -36
- package/database.sqlite +0 -0
- package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
- package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
- package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
- package/frontend/assets/mockups/connections_1200.webp +0 -0
- package/frontend/assets/mockups/data_1_1200.webp +0 -0
- package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
- package/frontend/assets/mockups/documents_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
- package/frontend/assets/mockups/overview_1200.webp +0 -0
- package/frontend/assets/mockups/settings_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
- package/frontend/assets/mockups/structure_1_1200.webp +0 -0
- package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
- package/tests/api-token-auth.test.js +0 -279
- package/tests/backup-manager.test.js +0 -140
- package/tests/backups-view.test.js +0 -70
- package/tests/charts-height-preset-storage.test.js +0 -60
- package/tests/charts-route-state.test.js +0 -144
- package/tests/check-constraint-options.test.js +0 -90
- package/tests/cli-args.test.js +0 -113
- package/tests/cli-service-delegation.test.js +0 -140
- package/tests/connection-removal.test.js +0 -52
- package/tests/connections-file-dialog-route.test.js +0 -89
- package/tests/copy-column-modal.test.js +0 -131
- package/tests/database-command-service.test.js +0 -174
- package/tests/database-documents.test.js +0 -85
- package/tests/documents-view.test.js +0 -132
- package/tests/dropdown-button.test.js +0 -101
- package/tests/email-preview.test.js +0 -89
- package/tests/export-blob.test.js +0 -99
- package/tests/export-filenames.test.js +0 -38
- package/tests/file-path-preview.test.js +0 -165
- package/tests/form-controls.test.js +0 -34
- package/tests/json-preview.test.js +0 -49
- package/tests/local-request-security.test.js +0 -85
- package/tests/markdown-documents.test.js +0 -103
- package/tests/native-file-dialog.test.js +0 -105
- package/tests/query-editor.test.js +0 -28
- package/tests/query-history-detail.test.js +0 -37
- package/tests/query-history-header.test.js +0 -30
- package/tests/query-results-truncation.test.js +0 -20
- package/tests/risky-sql.test.js +0 -30
- package/tests/row-editor-json.test.js +0 -82
- package/tests/row-editor-null-values.test.js +0 -184
- package/tests/row-editor-timestamp-preview.test.js +0 -192
- package/tests/security-paths.test.js +0 -84
- package/tests/settings-api-tokens-route.test.js +0 -97
- package/tests/settings-metadata.test.js +0 -114
- package/tests/settings-view.test.js +0 -108
- package/tests/sql-formatter.test.js +0 -173
- package/tests/sql-highlight.test.js +0 -38
- package/tests/sql-identifier-safety.test.js +0 -171
- package/tests/sql-result-limit.test.js +0 -66
- package/tests/structure-view.test.js +0 -60
- package/tests/table-designer-v2-unique-constraints.test.js +0 -78
- package/tests/table-scroll-state.test.js +0 -70
- package/tests/text-cell-stats.test.js +0 -38
package/frontend/js/app.js
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
initializeApp,
|
|
49
49
|
insertMarkdownIntoLastOpenDocument,
|
|
50
50
|
loadMoreQueryHistory,
|
|
51
|
+
loadMoreLogs,
|
|
51
52
|
openModal,
|
|
52
53
|
openOverviewInFinder,
|
|
53
54
|
openQueryHistoryInEditor,
|
|
@@ -56,11 +57,15 @@ import {
|
|
|
56
57
|
openDeleteQueryHistoryModal,
|
|
57
58
|
openDeleteSettingsApiTokenModal,
|
|
58
59
|
openCreateBackupModal,
|
|
59
|
-
|
|
60
|
+
openEditBackupModal,
|
|
61
|
+
openBackupDiffDrawer,
|
|
62
|
+
closeBackupDiffDrawer,
|
|
63
|
+
openGenerateTypesModal,
|
|
60
64
|
openDeleteBackupModal,
|
|
61
65
|
openRestoreBackupModal,
|
|
62
66
|
openDeleteQueryChartModal,
|
|
63
67
|
openDataExportModal,
|
|
68
|
+
openGenerateDataModal,
|
|
64
69
|
openDeleteDocumentModal,
|
|
65
70
|
openQueryExportModal,
|
|
66
71
|
openDataRowByIdentity,
|
|
@@ -74,6 +79,7 @@ import {
|
|
|
74
79
|
openDataRowUpdatePreview,
|
|
75
80
|
openEditorRowUpdatePreview,
|
|
76
81
|
refreshCurrentRoute,
|
|
82
|
+
refreshLogs,
|
|
77
83
|
refreshBackups,
|
|
78
84
|
refreshMediaTaggingPreview,
|
|
79
85
|
removeConnection,
|
|
@@ -91,6 +97,9 @@ import {
|
|
|
91
97
|
selectQueryHistoryItem,
|
|
92
98
|
selectStructureEntry,
|
|
93
99
|
setDocumentsSearchQuery,
|
|
100
|
+
setLogFilter,
|
|
101
|
+
setLogSearchInput,
|
|
102
|
+
applyLogSearch,
|
|
94
103
|
setTableDesignerSearchQuery,
|
|
95
104
|
setTableDesignerSqlPreviewVisibility,
|
|
96
105
|
toggleTableDesignerTablesPanel,
|
|
@@ -122,8 +131,10 @@ import {
|
|
|
122
131
|
submitDeleteSettingsApiTokenConfirmation,
|
|
123
132
|
submitBackupSafetyChoice,
|
|
124
133
|
submitCreateBackupConfirmation,
|
|
125
|
-
|
|
134
|
+
submitEditBackupConfirmation,
|
|
135
|
+
submitGenerateDataRows,
|
|
126
136
|
submitRowUpdatePreviewConfirmation,
|
|
137
|
+
previewGenerateDataRows,
|
|
127
138
|
setQueryHistoryPanelVisibility,
|
|
128
139
|
sortDataTableByColumn,
|
|
129
140
|
sortEditorResultsByColumn,
|
|
@@ -132,6 +143,7 @@ import {
|
|
|
132
143
|
setCopyColumnModalError,
|
|
133
144
|
setCopyColumnModalEditedText,
|
|
134
145
|
setCopyColumnModalSubmitting,
|
|
146
|
+
setBackupDiffTab,
|
|
135
147
|
setRoute,
|
|
136
148
|
setSettingsSection,
|
|
137
149
|
saveQueryHistoryNotes,
|
|
@@ -166,6 +178,9 @@ import {
|
|
|
166
178
|
updateCurrentTableDesignerColumnField,
|
|
167
179
|
updateCurrentTableDesignerConstraintField,
|
|
168
180
|
updateCurrentTableDesignerField,
|
|
181
|
+
updateGenerateDataMapping,
|
|
182
|
+
updateGenerateDataModal,
|
|
183
|
+
updateGenerateTypesModal,
|
|
169
184
|
addCurrentTableDesignerColumn,
|
|
170
185
|
applyCurrentMediaTaggingSelection,
|
|
171
186
|
removeCurrentTableDesignerColumn,
|
|
@@ -177,10 +192,12 @@ import { renderDataRowEditorPanel, renderDataView } from './views/data.js';
|
|
|
177
192
|
import { renderDocumentsView } from './views/documents.js';
|
|
178
193
|
import { renderEditorView } from './views/editor.js';
|
|
179
194
|
import { renderLandingView } from './views/landing.js';
|
|
195
|
+
import { renderLogsView, renderLogTable } from './views/logs.js';
|
|
180
196
|
import { renderMediaTaggingView } from './views/mediaTagging.js';
|
|
181
197
|
import { renderOverviewView } from './views/overview.js';
|
|
182
198
|
import { renderSettingsView } from './views/settings.js';
|
|
183
199
|
import { renderStructureView } from './views/structure.js';
|
|
200
|
+
import { renderTableAdvisorView } from './views/tableAdvisor.js';
|
|
184
201
|
import { renderTableDesignerView } from './views/tableDesigner.js';
|
|
185
202
|
import { replaceChildrenFromRenderedMarkup, replaceElementFromRenderedMarkup } from './utils/dom.js';
|
|
186
203
|
import {
|
|
@@ -195,6 +212,7 @@ import {
|
|
|
195
212
|
detectFilePathValue,
|
|
196
213
|
getPathTypeLabel,
|
|
197
214
|
} from './utils/filePathPreview.js';
|
|
215
|
+
import { clearInputForEscape } from './utils/inputClear.js';
|
|
198
216
|
import { formatSqlQuery } from './utils/sqlFormatter.js';
|
|
199
217
|
import {
|
|
200
218
|
buildDataRowEditorJsonObject,
|
|
@@ -258,6 +276,7 @@ const ROUTE_TITLE_SEGMENTS = {
|
|
|
258
276
|
backups: 'Backups',
|
|
259
277
|
overview: 'Overview',
|
|
260
278
|
data: 'Data',
|
|
279
|
+
tableAdvisor: 'Table Advisor',
|
|
261
280
|
structure: 'Structure',
|
|
262
281
|
editor: 'SQL Editor',
|
|
263
282
|
editorResults: 'SQL Editor',
|
|
@@ -267,6 +286,7 @@ const ROUTE_TITLE_SEGMENTS = {
|
|
|
267
286
|
mediaTaggingSetup: 'Media Tagging',
|
|
268
287
|
mediaTaggingQueue: 'Tagging Queue',
|
|
269
288
|
settings: 'Settings',
|
|
289
|
+
logs: 'Logs',
|
|
270
290
|
notFound: 'Not Found',
|
|
271
291
|
};
|
|
272
292
|
|
|
@@ -968,6 +988,92 @@ function patchChartsDetailUi(state, { preserveCharts = false } = {}) {
|
|
|
968
988
|
return true;
|
|
969
989
|
}
|
|
970
990
|
|
|
991
|
+
function getLogsActiveDatabaseId(state) {
|
|
992
|
+
return String(state.connections?.active?.id ?? state.logs?.metadata?.activeDatabase?.id ?? '');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function syncLogsFilterControls(state) {
|
|
996
|
+
const filters = state.logs?.filters ?? {};
|
|
997
|
+
|
|
998
|
+
for (const button of shellRefs.view.querySelectorAll('[data-action="set-log-filter"][data-field][data-value]')) {
|
|
999
|
+
if (!(button instanceof HTMLElement)) {
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const field = button.dataset.field;
|
|
1004
|
+
const value = button.dataset.value;
|
|
1005
|
+
const active = String(filters[field] ?? '') === String(value ?? '');
|
|
1006
|
+
|
|
1007
|
+
button.classList.toggle('is-active', active);
|
|
1008
|
+
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function syncLogsMetaStrip(state) {
|
|
1013
|
+
const logs = state.logs ?? {};
|
|
1014
|
+
const values = {
|
|
1015
|
+
visible: formatNumber((logs.items ?? []).length),
|
|
1016
|
+
matched: formatNumber(logs.total ?? 0),
|
|
1017
|
+
scope: logs.metadata?.activeDatabase?.label ?? 'Active Database',
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1021
|
+
const node = shellRefs.view.querySelector(`[data-logs-meta="${key}"]`);
|
|
1022
|
+
|
|
1023
|
+
if (node instanceof HTMLElement) {
|
|
1024
|
+
node.textContent = value;
|
|
1025
|
+
node.title = value;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function patchLogsTableUi(state) {
|
|
1031
|
+
const logsView = shellRefs.view.querySelector('[data-logs-view]');
|
|
1032
|
+
|
|
1033
|
+
if (!(logsView instanceof HTMLElement)) {
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const activeDatabaseId = getLogsActiveDatabaseId(state);
|
|
1038
|
+
|
|
1039
|
+
if (String(logsView.dataset.logsActiveDatabaseId ?? '') !== activeDatabaseId) {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const currentTable = logsView.querySelector('[data-logs-table]');
|
|
1044
|
+
|
|
1045
|
+
if (!(currentTable instanceof HTMLElement)) {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const currentScrollNode = currentTable.querySelector('[data-logs-table-scroll]');
|
|
1050
|
+
const scrollState =
|
|
1051
|
+
currentScrollNode instanceof HTMLElement
|
|
1052
|
+
? {
|
|
1053
|
+
left: currentScrollNode.scrollLeft,
|
|
1054
|
+
top: currentScrollNode.scrollTop,
|
|
1055
|
+
}
|
|
1056
|
+
: null;
|
|
1057
|
+
const patched = replaceElementFromRenderedMarkup(currentTable, renderLogTable(state.logs ?? {}));
|
|
1058
|
+
|
|
1059
|
+
if (!patched) {
|
|
1060
|
+
return false;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (scrollState) {
|
|
1064
|
+
const nextScrollNode = logsView.querySelector('[data-logs-table-scroll]');
|
|
1065
|
+
|
|
1066
|
+
if (nextScrollNode instanceof HTMLElement) {
|
|
1067
|
+
nextScrollNode.scrollLeft = scrollState.left;
|
|
1068
|
+
nextScrollNode.scrollTop = scrollState.top;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
syncLogsFilterControls(state);
|
|
1073
|
+
syncLogsMetaStrip(state);
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
971
1077
|
function readFileAsBase64(file) {
|
|
972
1078
|
return new Promise((resolve, reject) => {
|
|
973
1079
|
const reader = new FileReader();
|
|
@@ -1065,11 +1171,11 @@ function renderNotFoundView() {
|
|
|
1065
1171
|
<p class="font-mono text-[10px] uppercase tracking-[0.3em] text-primary-container/40">
|
|
1066
1172
|
ROUTE_LOST // HASH_NOT_RECOGNIZED
|
|
1067
1173
|
</p>
|
|
1068
|
-
<h1 class="mt-4 font-
|
|
1174
|
+
<h1 class="mt-4 font-body text-6xl font-black uppercase tracking-tight text-primary-container">
|
|
1069
1175
|
404_SIGNAL
|
|
1070
1176
|
</h1>
|
|
1071
1177
|
<button
|
|
1072
|
-
class="standard-button mt-8 px-6 font-
|
|
1178
|
+
class="standard-button mt-8 px-6 font-body text-sm"
|
|
1073
1179
|
data-action="navigate"
|
|
1074
1180
|
data-to="/"
|
|
1075
1181
|
type="button"
|
|
@@ -1107,12 +1213,16 @@ function resolveView(state) {
|
|
|
1107
1213
|
return renderStructureView(state);
|
|
1108
1214
|
case 'tableDesigner':
|
|
1109
1215
|
return renderTableDesignerView(state);
|
|
1216
|
+
case 'tableAdvisor':
|
|
1217
|
+
return renderTableAdvisorView(state);
|
|
1110
1218
|
case 'mediaTaggingSetup':
|
|
1111
1219
|
return renderMediaTaggingView(state, { subView: 'setup' });
|
|
1112
1220
|
case 'mediaTaggingQueue':
|
|
1113
1221
|
return renderMediaTaggingView(state, { subView: 'queue' });
|
|
1114
1222
|
case 'settings':
|
|
1115
1223
|
return renderSettingsView(state);
|
|
1224
|
+
case 'logs':
|
|
1225
|
+
return renderLogsView(state);
|
|
1116
1226
|
default:
|
|
1117
1227
|
return renderNotFoundView();
|
|
1118
1228
|
}
|
|
@@ -1398,9 +1508,11 @@ function renderApp(state) {
|
|
|
1398
1508
|
'editorResults',
|
|
1399
1509
|
'data',
|
|
1400
1510
|
'charts',
|
|
1511
|
+
'logs',
|
|
1401
1512
|
'documents',
|
|
1402
1513
|
'structure',
|
|
1403
1514
|
'tableDesigner',
|
|
1515
|
+
'tableAdvisor',
|
|
1404
1516
|
'mediaTaggingSetup',
|
|
1405
1517
|
'mediaTaggingQueue',
|
|
1406
1518
|
].includes(state.route.name);
|
|
@@ -1452,8 +1564,8 @@ function renderApp(state) {
|
|
|
1452
1564
|
pendingNewTableDesignerAutofocus = false;
|
|
1453
1565
|
}
|
|
1454
1566
|
|
|
1455
|
-
const canPatchChartsMain =
|
|
1456
|
-
|
|
1567
|
+
const canPatchChartsMain = mainChanged && previousRouteName === 'charts' && state.route.name === 'charts';
|
|
1568
|
+
const canPatchLogsMain = mainChanged && previousRouteName === 'logs' && state.route.name === 'logs';
|
|
1457
1569
|
let mainPatched = false;
|
|
1458
1570
|
let preservedChartsDom = false;
|
|
1459
1571
|
|
|
@@ -1479,6 +1591,10 @@ function renderApp(state) {
|
|
|
1479
1591
|
}
|
|
1480
1592
|
}
|
|
1481
1593
|
|
|
1594
|
+
if (!mainPatched && canPatchLogsMain) {
|
|
1595
|
+
mainPatched = patchLogsTableUi(state);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1482
1598
|
if (mainChanged) {
|
|
1483
1599
|
if (!mainPatched) {
|
|
1484
1600
|
teardownStructureGraph();
|
|
@@ -2003,6 +2119,68 @@ function downloadTextFile({ text, filename, mimeType }) {
|
|
|
2003
2119
|
URL.revokeObjectURL(url);
|
|
2004
2120
|
}
|
|
2005
2121
|
|
|
2122
|
+
async function copyGeneratedTypes() {
|
|
2123
|
+
const modal = getState().modal;
|
|
2124
|
+
const code = modal?.kind === 'generate-types' ? String(modal.result?.code ?? '') : '';
|
|
2125
|
+
|
|
2126
|
+
if (!code) {
|
|
2127
|
+
showToast('No generated code to copy.', 'alert');
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (!navigator.clipboard?.writeText) {
|
|
2132
|
+
showToast('Clipboard API is not available.', 'alert');
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
try {
|
|
2137
|
+
await navigator.clipboard.writeText(code);
|
|
2138
|
+
showToast(
|
|
2139
|
+
modal.result?.files?.length ? 'Type definitions copied to clipboard.' : 'Type definition copied to clipboard.',
|
|
2140
|
+
'success',
|
|
2141
|
+
);
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
showToast(
|
|
2144
|
+
modal.result?.files?.length
|
|
2145
|
+
? 'Type definitions could not be copied.'
|
|
2146
|
+
: 'Type definition could not be copied.',
|
|
2147
|
+
'alert',
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function downloadGeneratedTypes() {
|
|
2153
|
+
const modal = getState().modal;
|
|
2154
|
+
const result = modal?.kind === 'generate-types' ? modal.result : null;
|
|
2155
|
+
const files = result?.files ?? [];
|
|
2156
|
+
|
|
2157
|
+
if (files.length) {
|
|
2158
|
+
files.forEach((file, index) => {
|
|
2159
|
+
window.setTimeout(() => {
|
|
2160
|
+
downloadTextFile({
|
|
2161
|
+
text: file.code,
|
|
2162
|
+
filename: file.fileName || `${file.tableName || 'types'}.txt`,
|
|
2163
|
+
mimeType: 'text/plain;charset=utf-8',
|
|
2164
|
+
});
|
|
2165
|
+
}, index * 120);
|
|
2166
|
+
});
|
|
2167
|
+
showToast(`${files.length} type definition downloads started.`, 'success');
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
if (!result?.code) {
|
|
2172
|
+
showToast('No generated code to download.', 'alert');
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
downloadTextFile({
|
|
2177
|
+
text: result.code,
|
|
2178
|
+
filename: result.fileName || 'types.txt',
|
|
2179
|
+
mimeType: 'text/plain;charset=utf-8',
|
|
2180
|
+
});
|
|
2181
|
+
showToast('Type definition download started.', 'success');
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2006
2184
|
function getSelectedDataBrowserRowForJson(state) {
|
|
2007
2185
|
if (state.dataBrowser.selectedRow) {
|
|
2008
2186
|
return state.dataBrowser.selectedRow;
|
|
@@ -2361,6 +2539,15 @@ async function handleAction(actionNode) {
|
|
|
2361
2539
|
case 'refresh-view':
|
|
2362
2540
|
await refreshCurrentRoute();
|
|
2363
2541
|
return;
|
|
2542
|
+
case 'refresh-logs':
|
|
2543
|
+
await refreshLogs();
|
|
2544
|
+
return;
|
|
2545
|
+
case 'set-log-filter':
|
|
2546
|
+
await setLogFilter(actionNode.dataset.field, actionNode.dataset.value);
|
|
2547
|
+
return;
|
|
2548
|
+
case 'load-more-logs':
|
|
2549
|
+
await loadMoreLogs();
|
|
2550
|
+
return;
|
|
2364
2551
|
case 'set-settings-section':
|
|
2365
2552
|
setSettingsSection(actionNode.dataset.section);
|
|
2366
2553
|
return;
|
|
@@ -2582,11 +2769,29 @@ async function handleAction(actionNode) {
|
|
|
2582
2769
|
case 'open-table-in-sql-editor':
|
|
2583
2770
|
await openTableInSqlEditor(actionNode.dataset.tableName);
|
|
2584
2771
|
return;
|
|
2772
|
+
case 'open-generate-types-modal':
|
|
2773
|
+
await openGenerateTypesModal(actionNode.dataset.tableName, actionNode.dataset.typeTarget, actionNode.dataset.typeScope);
|
|
2774
|
+
return;
|
|
2775
|
+
case 'copy-generated-types':
|
|
2776
|
+
await copyGeneratedTypes();
|
|
2777
|
+
return;
|
|
2778
|
+
case 'download-generated-types':
|
|
2779
|
+
downloadGeneratedTypes();
|
|
2780
|
+
return;
|
|
2585
2781
|
case 'open-restore-backup-modal':
|
|
2586
2782
|
openRestoreBackupModal(actionNode.dataset.backupId);
|
|
2587
2783
|
return;
|
|
2588
|
-
case 'open-
|
|
2589
|
-
|
|
2784
|
+
case 'open-compare-backup-drawer':
|
|
2785
|
+
await openBackupDiffDrawer(actionNode.dataset.backupId);
|
|
2786
|
+
return;
|
|
2787
|
+
case 'set-backup-diff-tab':
|
|
2788
|
+
setBackupDiffTab(actionNode.dataset.tab);
|
|
2789
|
+
return;
|
|
2790
|
+
case 'close-backup-diff-drawer':
|
|
2791
|
+
closeBackupDiffDrawer();
|
|
2792
|
+
return;
|
|
2793
|
+
case 'open-edit-backup-modal':
|
|
2794
|
+
openEditBackupModal(actionNode.dataset.backupId);
|
|
2590
2795
|
return;
|
|
2591
2796
|
case 'open-delete-backup-modal':
|
|
2592
2797
|
openDeleteBackupModal(actionNode.dataset.backupId);
|
|
@@ -2793,6 +2998,12 @@ async function handleAction(actionNode) {
|
|
|
2793
2998
|
case 'open-data-export-modal':
|
|
2794
2999
|
openDataExportModal();
|
|
2795
3000
|
return;
|
|
3001
|
+
case 'open-generate-data-modal':
|
|
3002
|
+
openGenerateDataModal();
|
|
3003
|
+
return;
|
|
3004
|
+
case 'preview-generate-data':
|
|
3005
|
+
await previewGenerateDataRows();
|
|
3006
|
+
return;
|
|
2796
3007
|
case 'export-data-format': {
|
|
2797
3008
|
const format = actionNode.dataset.exportFormat;
|
|
2798
3009
|
const filename = getExportFilenameFromAction(actionNode);
|
|
@@ -2863,6 +3074,30 @@ async function handleAction(actionNode) {
|
|
|
2863
3074
|
}
|
|
2864
3075
|
return;
|
|
2865
3076
|
}
|
|
3077
|
+
case 'copy-table-advisor-sql': {
|
|
3078
|
+
const issueId = actionNode.dataset.issueId;
|
|
3079
|
+
const advisor = getState().tableAdvisor;
|
|
3080
|
+
const issue = (advisor.result?.issues ?? []).find(item => item.id === issueId);
|
|
3081
|
+
const sql = String(issue?.sql ?? '');
|
|
3082
|
+
|
|
3083
|
+
if (!sql.trim()) {
|
|
3084
|
+
showToast('No advisor SQL to copy.', 'alert');
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
if (!navigator.clipboard?.writeText) {
|
|
3089
|
+
showToast('Clipboard API is not available.', 'alert');
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
try {
|
|
3094
|
+
await navigator.clipboard.writeText(sql);
|
|
3095
|
+
showToast('Advisor SQL copied.', 'success');
|
|
3096
|
+
} catch (error) {
|
|
3097
|
+
showToast('Advisor SQL could not be copied.', 'alert');
|
|
3098
|
+
}
|
|
3099
|
+
return;
|
|
3100
|
+
}
|
|
2866
3101
|
case 'toggle-table-designer-sql-preview':
|
|
2867
3102
|
setTableDesignerSqlPreviewVisibility(
|
|
2868
3103
|
actionNode.dataset.nextValue ? actionNode.dataset.nextValue === 'true' : undefined,
|
|
@@ -3151,6 +3386,11 @@ document.addEventListener('keydown', event => {
|
|
|
3151
3386
|
return;
|
|
3152
3387
|
}
|
|
3153
3388
|
|
|
3389
|
+
if (target instanceof HTMLInputElement && clearInputForEscape(target)) {
|
|
3390
|
+
event.preventDefault();
|
|
3391
|
+
return;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3154
3394
|
if (state.modal) {
|
|
3155
3395
|
event.preventDefault();
|
|
3156
3396
|
closeModal();
|
|
@@ -3181,6 +3421,12 @@ document.addEventListener('keydown', event => {
|
|
|
3181
3421
|
return;
|
|
3182
3422
|
}
|
|
3183
3423
|
|
|
3424
|
+
if (state.route.name === 'backups' && state.backups.diff?.visible) {
|
|
3425
|
+
event.preventDefault();
|
|
3426
|
+
closeBackupDiffDrawer();
|
|
3427
|
+
return;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3184
3430
|
if (state.editor.historySelectedId !== null || state.editor.historyDetail) {
|
|
3185
3431
|
event.preventDefault();
|
|
3186
3432
|
clearQueryHistorySelection();
|
|
@@ -3227,6 +3473,29 @@ document.addEventListener('input', event => {
|
|
|
3227
3473
|
return;
|
|
3228
3474
|
}
|
|
3229
3475
|
|
|
3476
|
+
if (bindNode.dataset.bind === 'type-generation-field') {
|
|
3477
|
+
if (bindNode instanceof HTMLInputElement && bindNode.type === 'checkbox') {
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
void updateGenerateTypesModal(bindNode.dataset.typeGenerationField, bindNode.value);
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
if (bindNode.dataset.bind === 'generate-data-field') {
|
|
3486
|
+
updateGenerateDataModal(bindNode.dataset.field, bindNode.value, { notify: false });
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
if (bindNode.dataset.bind === 'generate-data-mapping') {
|
|
3491
|
+
if (bindNode instanceof HTMLSelectElement) {
|
|
3492
|
+
return;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
updateGenerateDataMapping(bindNode.dataset.columnName, bindNode.dataset.field, bindNode.value, { notify: false });
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3230
3499
|
if (bindNode.dataset.bind === 'copy-column-format-field') {
|
|
3231
3500
|
updateCopyColumnModalFormatField(
|
|
3232
3501
|
bindNode.dataset.field,
|
|
@@ -3254,6 +3523,11 @@ document.addEventListener('input', event => {
|
|
|
3254
3523
|
return;
|
|
3255
3524
|
}
|
|
3256
3525
|
|
|
3526
|
+
if (bindNode.dataset.bind === 'logs-search') {
|
|
3527
|
+
setLogSearchInput(bindNode.value);
|
|
3528
|
+
return;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3257
3531
|
if (bindNode.dataset.bind === 'data-search-query') {
|
|
3258
3532
|
void setDataSearchQuery(bindNode.value);
|
|
3259
3533
|
return;
|
|
@@ -3398,6 +3672,23 @@ document.addEventListener('change', event => {
|
|
|
3398
3672
|
return;
|
|
3399
3673
|
}
|
|
3400
3674
|
|
|
3675
|
+
if (bindNode.dataset.bind === 'type-generation-field') {
|
|
3676
|
+
const nextValue =
|
|
3677
|
+
bindNode instanceof HTMLInputElement && bindNode.type === 'checkbox' ? bindNode.checked : bindNode.value;
|
|
3678
|
+
void updateGenerateTypesModal(bindNode.dataset.typeGenerationField, nextValue);
|
|
3679
|
+
return;
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
if (bindNode.dataset.bind === 'generate-data-field') {
|
|
3683
|
+
updateGenerateDataModal(bindNode.dataset.field, bindNode.value);
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
if (bindNode.dataset.bind === 'generate-data-mapping') {
|
|
3688
|
+
updateGenerateDataMapping(bindNode.dataset.columnName, bindNode.dataset.field, bindNode.value);
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3401
3692
|
if (bindNode.dataset.bind === 'data-search-column') {
|
|
3402
3693
|
void setDataSearchColumn(bindNode.value);
|
|
3403
3694
|
return;
|
|
@@ -3542,6 +3833,9 @@ document.addEventListener('submit', async event => {
|
|
|
3542
3833
|
const formData = submitter ? new FormData(form, submitter) : new FormData(form);
|
|
3543
3834
|
|
|
3544
3835
|
switch (form.dataset.form) {
|
|
3836
|
+
case 'logs-search':
|
|
3837
|
+
await applyLogSearch(String(formData.get('search') ?? ''));
|
|
3838
|
+
return;
|
|
3545
3839
|
case 'create-api-token':
|
|
3546
3840
|
await createSettingsApiToken(String(formData.get('name') ?? ''));
|
|
3547
3841
|
return;
|
|
@@ -3623,8 +3917,11 @@ document.addEventListener('submit', async event => {
|
|
|
3623
3917
|
type: String(formData.get('type') ?? 'manual'),
|
|
3624
3918
|
});
|
|
3625
3919
|
return;
|
|
3626
|
-
case 'edit-backup
|
|
3627
|
-
await
|
|
3920
|
+
case 'edit-backup':
|
|
3921
|
+
await submitEditBackupConfirmation({
|
|
3922
|
+
name: String(formData.get('name') ?? ''),
|
|
3923
|
+
notes: String(formData.get('notes') ?? ''),
|
|
3924
|
+
});
|
|
3628
3925
|
return;
|
|
3629
3926
|
case 'delete-backup-confirm':
|
|
3630
3927
|
await submitDeleteBackupConfirmation();
|
|
@@ -3670,6 +3967,9 @@ document.addEventListener('submit', async event => {
|
|
|
3670
3967
|
case 'apply-row-update-preview':
|
|
3671
3968
|
await submitRowUpdatePreviewConfirmation();
|
|
3672
3969
|
return;
|
|
3970
|
+
case 'generate-data':
|
|
3971
|
+
await submitGenerateDataRows();
|
|
3972
|
+
return;
|
|
3673
3973
|
case 'create-media-tagging-tag-table':
|
|
3674
3974
|
await submitCreateMediaTaggingTagTable();
|
|
3675
3975
|
return;
|
|
@@ -36,7 +36,7 @@ export function renderConnectionCard(connection, activeConnectionId) {
|
|
|
36
36
|
renderStatusBadge(isActive ? "ACTIVE" : "RECENT", isActive ? "primary" : "muted"),
|
|
37
37
|
connection.readOnly ? renderStatusBadge("READ_ONLY", "alert") : "",
|
|
38
38
|
"</div></div>",
|
|
39
|
-
'<h3 class="mb-1 font-
|
|
39
|
+
'<h3 class="mb-1 font-body text-xl font-bold uppercase ',
|
|
40
40
|
isActive ? "text-[#FCE300]" : "text-on-surface",
|
|
41
41
|
'">',
|
|
42
42
|
escapeHtml(connection.label),
|
|
@@ -56,7 +56,9 @@ export function renderConnectionCard(connection, activeConnectionId) {
|
|
|
56
56
|
'<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Last Opened</div><div class="text-xs font-bold text-on-surface">',
|
|
57
57
|
escapeHtml(formatDateTime(connection.lastOpenedAt)),
|
|
58
58
|
"</div></div>",
|
|
59
|
-
'<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Mode</div><div class="text-xs font-bold
|
|
59
|
+
'<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Mode</div><div class="text-xs font-bold ',
|
|
60
|
+
connection.readOnly ? "text-primary-container" : "text-on-surface",
|
|
61
|
+
'">',
|
|
60
62
|
connection.readOnly ? "Read only" : "Read / Write",
|
|
61
63
|
"</div></div></div></div>",
|
|
62
64
|
'<div class="border-t border-outline-variant/10 bg-surface-container-low px-4 py-3">',
|
|
@@ -61,7 +61,7 @@ function renderActiveConnection(activeConnection) {
|
|
|
61
61
|
<p class="text-[10px] font-mono uppercase tracking-[0.24em] text-primary-container/70">
|
|
62
62
|
ACTIVE_DATABASE
|
|
63
63
|
</p>
|
|
64
|
-
<h2 class="mt-2 font-
|
|
64
|
+
<h2 class="mt-2 font-body text-2xl font-black uppercase tracking-tight text-primary-container">
|
|
65
65
|
${escapeHtml(activeConnection.label)}
|
|
66
66
|
</h2>
|
|
67
67
|
<p class="mt-2 font-mono text-[10px] text-on-surface-variant/55">${escapeHtml(
|
|
@@ -93,7 +93,7 @@ export function renderEmptyState({ activeConnection, recentConnections = [] }) {
|
|
|
93
93
|
SYSTEM_READY // ${hasActive ? 'ACTIVE_CONTEXT' : 'IDLE_STATE'}
|
|
94
94
|
</span>
|
|
95
95
|
</div>
|
|
96
|
-
<h1 class="mb-4 font-
|
|
96
|
+
<h1 class="mb-4 font-body text-7xl font-black tracking-tighter text-primary-container opacity-90 md:text-9xl">
|
|
97
97
|
SQLite Hub
|
|
98
98
|
</h1>
|
|
99
99
|
<p class="font-mono text-primary-container/40">The only fucking SQLite Manager you'll ever need.</p>
|
|
@@ -104,7 +104,7 @@ export function renderEmptyState({ activeConnection, recentConnections = [] }) {
|
|
|
104
104
|
</div>
|
|
105
105
|
<div class="mx-auto grid w-full max-w-3xl grid-cols-1 gap-4 px-6 md:grid-cols-2">
|
|
106
106
|
<button
|
|
107
|
-
class="landing-primary-action clipped-btn group flex items-center justify-between bg-primary-container px-8 py-6 font-
|
|
107
|
+
class="landing-primary-action clipped-btn group flex items-center justify-between bg-primary-container px-8 py-6 font-body text-lg font-bold transition-all duration-300 hover:shadow-[0_0_20px_rgba(252,227,0,0.3)]"
|
|
108
108
|
data-action="open-modal"
|
|
109
109
|
data-modal="open-connection"
|
|
110
110
|
style="--clip-path: polygon(0 0, 90% 0, 100% 25%, 100% 100%, 0 100%);"
|
|
@@ -114,7 +114,7 @@ export function renderEmptyState({ activeConnection, recentConnections = [] }) {
|
|
|
114
114
|
<span class="material-symbols-outlined transition-transform group-hover:translate-x-1">add_circle</span>
|
|
115
115
|
</button>
|
|
116
116
|
<button
|
|
117
|
-
class="flex items-center justify-between border-l-2 border-primary-container bg-surface-container-highest px-8 py-6 font-
|
|
117
|
+
class="flex items-center justify-between border-l-2 border-primary-container bg-surface-container-highest px-8 py-6 font-body text-lg font-bold text-primary-container transition-colors duration-150 hover:bg-surface-bright"
|
|
118
118
|
data-action="open-modal"
|
|
119
119
|
data-modal="create-connection"
|
|
120
120
|
type="button"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { renderDropdownButton } from './dropdownButton.js';
|
|
2
|
+
|
|
3
|
+
export function renderGenerateTypesDropdown({ selectedTableName = '', hasTables = false } = {}) {
|
|
4
|
+
const hasSelectedTable = Boolean(String(selectedTableName ?? '').trim());
|
|
5
|
+
|
|
6
|
+
return renderDropdownButton({
|
|
7
|
+
align: 'left',
|
|
8
|
+
icon: 'code',
|
|
9
|
+
label: 'Generate Types',
|
|
10
|
+
title: 'Generate types',
|
|
11
|
+
items: [
|
|
12
|
+
{
|
|
13
|
+
action: 'open-generate-types-modal',
|
|
14
|
+
disabled: !hasSelectedTable,
|
|
15
|
+
icon: 'select_check_box',
|
|
16
|
+
label: 'Selected table',
|
|
17
|
+
dataAttributes: {
|
|
18
|
+
tableName: selectedTableName,
|
|
19
|
+
typeScope: 'selected',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
action: 'open-generate-types-modal',
|
|
24
|
+
disabled: !hasTables,
|
|
25
|
+
icon: 'library_books',
|
|
26
|
+
label: 'All tables',
|
|
27
|
+
dataAttributes: {
|
|
28
|
+
typeScope: 'all',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -10,7 +10,7 @@ export function renderMetricCard({
|
|
|
10
10
|
return `
|
|
11
11
|
<div class="metric-card ${accent ? "metric-card--accent" : ""}">
|
|
12
12
|
<span class="text-[10px] font-mono text-on-surface/40 uppercase">${escapeHtml(label)}</span>
|
|
13
|
-
<span class="text-3xl font-
|
|
13
|
+
<span class="text-3xl font-body font-bold text-on-surface">${escapeHtml(value)}</span>
|
|
14
14
|
${
|
|
15
15
|
progress
|
|
16
16
|
? `
|