sqlite-hub 0.17.0 → 1.0.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 +101 -19
- package/bin/sqlite-hub.js +83 -401
- package/examples/api/queries.js +31 -0
- package/examples/api/rows.js +27 -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/frontend/js/api.js +25 -0
- package/frontend/js/app.js +183 -26
- package/frontend/js/components/formControls.js +38 -0
- package/frontend/js/components/modal.js +79 -19
- package/frontend/js/components/queryResults.js +18 -1
- package/frontend/js/components/rowEditorPanel.js +42 -34
- package/frontend/js/store.js +129 -2
- package/frontend/js/utils/jsonPreview.js +31 -0
- package/frontend/js/utils/rowEditorValues.js +41 -0
- package/frontend/js/utils/tableScrollState.js +39 -0
- package/frontend/js/views/charts.js +8 -0
- package/frontend/js/views/data.js +4 -1
- package/frontend/js/views/editor.js +2 -0
- package/frontend/js/views/settings.js +141 -5
- package/frontend/styles/components.css +6 -0
- package/frontend/styles/tailwind.generated.css +2 -2
- package/package.json +6 -6
- package/server/middleware/apiTokenAuth.js +30 -0
- package/server/middleware/localRequestSecurity.js +84 -0
- package/server/routes/connections.js +35 -1
- package/server/routes/externalApi.js +222 -0
- package/server/routes/settings.js +64 -4
- package/server/server.js +34 -2
- package/server/services/apiTokenService.js +101 -0
- package/server/services/databaseCommandService.js +399 -0
- package/server/services/nativeFileDialogService.js +260 -0
- package/server/services/sqlite/dataBrowserService.js +0 -4
- package/server/services/sqlite/exportService.js +5 -1
- package/server/services/sqlite/sqlExecutor.js +51 -2
- package/server/services/storage/appStateStore.js +113 -0
- package/server/utils/errors.js +7 -0
- package/server/utils/sqliteTypes.js +17 -8
- package/tests/api-token-auth.test.js +127 -0
- package/tests/cli-service-delegation.test.js +43 -0
- package/tests/connections-file-dialog-route.test.js +89 -0
- package/tests/copy-column-modal.test.js +48 -0
- package/tests/database-command-service.test.js +102 -0
- package/tests/export-blob.test.js +46 -0
- package/tests/form-controls.test.js +34 -0
- package/tests/json-preview.test.js +49 -0
- package/tests/local-request-security.test.js +85 -0
- package/tests/native-file-dialog.test.js +105 -0
- package/tests/query-results-truncation.test.js +20 -0
- package/tests/row-editor-null-values.test.js +131 -0
- package/tests/settings-api-tokens-route.test.js +76 -0
- package/tests/settings-view.test.js +47 -0
- package/tests/sql-identifier-safety.test.js +9 -3
- package/tests/sql-result-limit.test.js +66 -0
- package/tests/table-scroll-state.test.js +70 -0
- package/frontend/assets/mockups/connections.png +0 -0
- package/frontend/assets/mockups/data.png +0 -0
- package/frontend/assets/mockups/data_row_editor.png +0 -0
- package/frontend/assets/mockups/home.png +0 -0
- package/frontend/assets/mockups/sql_editor.png +0 -0
- package/frontend/assets/mockups/sql_editor_croped.png +0 -0
- package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
- package/frontend/assets/mockups/structure.png +0 -0
- package/frontend/assets/mockups/structure_inspector.png +0 -0
|
@@ -12,37 +12,14 @@ import {
|
|
|
12
12
|
formatTextCellCharacterCount,
|
|
13
13
|
getTextCellCharacterCount,
|
|
14
14
|
} from "../utils/textCellStats.js";
|
|
15
|
+
import { formatJsonPreview } from "../utils/jsonPreview.js";
|
|
16
|
+
import {
|
|
17
|
+
getRowEditorValueState,
|
|
18
|
+
getRowEditorValueStateLabel,
|
|
19
|
+
} from "../utils/rowEditorValues.js";
|
|
15
20
|
|
|
16
21
|
const URL_PATTERN = /^https?:\/\/[^\s<>"']+$/i;
|
|
17
22
|
|
|
18
|
-
function getJsonPreview(value) {
|
|
19
|
-
if (value === null || value === undefined) {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (typeof value === "object") {
|
|
24
|
-
return JSON.stringify(value, null, 2);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const text = String(value).trim();
|
|
28
|
-
|
|
29
|
-
if (!text || !["{", "["].includes(text[0])) {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
const parsed = JSON.parse(text);
|
|
35
|
-
|
|
36
|
-
if (!parsed || typeof parsed !== "object") {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return JSON.stringify(parsed, null, 2);
|
|
41
|
-
} catch (error) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
23
|
function getUrlValue(value) {
|
|
47
24
|
const text = String(value ?? "").trim();
|
|
48
25
|
|
|
@@ -119,7 +96,7 @@ function renderOpenUrlButton(url) {
|
|
|
119
96
|
}
|
|
120
97
|
|
|
121
98
|
return `
|
|
122
|
-
<div class="mt-2">
|
|
99
|
+
<div class="mt-2" data-row-editor-url-action>
|
|
123
100
|
<button
|
|
124
101
|
class="standard-button"
|
|
125
102
|
data-action="open-row-editor-url"
|
|
@@ -141,7 +118,7 @@ function renderAllowedValuesSelect(field, allowedValues) {
|
|
|
141
118
|
currentValue !== "" && !hasCurrentAllowedValue;
|
|
142
119
|
const options = [
|
|
143
120
|
shouldRenderEmptyOption
|
|
144
|
-
? `<option value="" ${currentValue === "" ? "selected" : ""}>
|
|
121
|
+
? `<option value="" ${currentValue === "" ? "selected" : ""}>Empty string</option>`
|
|
145
122
|
: "",
|
|
146
123
|
shouldRenderCurrentOption
|
|
147
124
|
? `<option value="${escapeHtml(currentValue)}" selected>${escapeHtml(currentValue)}</option>`
|
|
@@ -160,6 +137,7 @@ function renderAllowedValuesSelect(field, allowedValues) {
|
|
|
160
137
|
data-row-editor-initial-value="${escapeHtml(currentValue)}"
|
|
161
138
|
data-row-editor-text-source
|
|
162
139
|
data-row-editor-timestamp-source
|
|
140
|
+
data-row-editor-value-source
|
|
163
141
|
name="field:${escapeHtml(field.name)}"
|
|
164
142
|
>
|
|
165
143
|
${options}
|
|
@@ -284,7 +262,7 @@ function renderJsonViewer(prettyJson, title = "JSON Viewer") {
|
|
|
284
262
|
<div class="text-[10px] font-mono uppercase tracking-[0.18em] text-primary-container/75">
|
|
285
263
|
${escapeHtml(title)}
|
|
286
264
|
</div>
|
|
287
|
-
<pre class="custom-scrollbar mt-3 max-h-[18rem] overflow-auto
|
|
265
|
+
<pre class="row-editor-json-preview custom-scrollbar mt-3 max-h-[18rem] overflow-auto border border-outline-variant/10 bg-surface-container-lowest px-4 py-4 font-mono text-xs leading-6 text-on-surface">${escapeHtml(
|
|
288
266
|
prettyJson
|
|
289
267
|
)}</pre>
|
|
290
268
|
</div>
|
|
@@ -301,7 +279,8 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
|
|
|
301
279
|
const url = getUrlValue(value);
|
|
302
280
|
const badges = withFilePathBadge(withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url), filePathPreview);
|
|
303
281
|
const displayLabel = typeof label === "object" ? label.label : label;
|
|
304
|
-
const jsonPreview =
|
|
282
|
+
const jsonPreview = formatJsonPreview(value);
|
|
283
|
+
const valueState = getRowEditorValueState(rawValue);
|
|
305
284
|
|
|
306
285
|
return `
|
|
307
286
|
<div
|
|
@@ -315,6 +294,7 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
|
|
|
315
294
|
<div class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
|
|
316
295
|
<span>${escapeHtml(displayLabel)}</span>
|
|
317
296
|
${renderFieldBadgesWithCharacterCount(badges, rawValue)}
|
|
297
|
+
${renderValueStateBadge(valueState)}
|
|
318
298
|
</div>
|
|
319
299
|
${
|
|
320
300
|
jsonPreview
|
|
@@ -328,6 +308,28 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
|
|
|
328
308
|
`;
|
|
329
309
|
}
|
|
330
310
|
|
|
311
|
+
function getValueStateBadgeClassName(state) {
|
|
312
|
+
if (state === "null") {
|
|
313
|
+
return "border-primary-container/35 bg-primary-container/15 text-primary-container";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (state === "empty") {
|
|
317
|
+
return "border-outline-variant/35 bg-surface-container-high text-on-surface-variant";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return "border-outline-variant/20 bg-surface-container text-on-surface-variant";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function renderValueStateBadge(state) {
|
|
324
|
+
return `
|
|
325
|
+
<span
|
|
326
|
+
class="border px-2 py-1 text-[9px] ${getValueStateBadgeClassName(state)}"
|
|
327
|
+
data-row-editor-value-state
|
|
328
|
+
data-value-state="${escapeHtml(state)}"
|
|
329
|
+
>${escapeHtml(getRowEditorValueStateLabel(state))}</span>
|
|
330
|
+
`;
|
|
331
|
+
}
|
|
332
|
+
|
|
331
333
|
function renderEditableField(field, tableMeta = {}) {
|
|
332
334
|
const columnName = getFieldColumnName(field);
|
|
333
335
|
const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
|
|
@@ -336,25 +338,28 @@ function renderEditableField(field, tableMeta = {}) {
|
|
|
336
338
|
const filePathPreview = getFieldFilePathPreview(field, tableMeta);
|
|
337
339
|
const baseBadges = withCheckBadge(Array.isArray(field.badges) ? field.badges : [], allowedValues);
|
|
338
340
|
const badges = withFilePathBadge(withUrlBadge(baseBadges, url), filePathPreview);
|
|
339
|
-
const jsonPreview =
|
|
341
|
+
const jsonPreview = formatJsonPreview(field.value);
|
|
340
342
|
const inputType = field.inputType === "number" ? "number" : "text";
|
|
341
343
|
const numberStep = field.numberStep === "1" ? "1" : "any";
|
|
344
|
+
const valueState = getRowEditorValueState(getFieldRawValue(field));
|
|
342
345
|
|
|
343
346
|
return `
|
|
344
347
|
<div
|
|
345
348
|
class="block space-y-2"
|
|
346
349
|
data-row-editor-column-name="${escapeHtml(columnName)}"
|
|
347
350
|
data-row-editor-field
|
|
351
|
+
data-row-editor-initial-state="${escapeHtml(valueState)}"
|
|
348
352
|
data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
|
|
349
353
|
${url ? "data-row-editor-url-field" : ""}
|
|
350
354
|
>
|
|
351
355
|
<span class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
|
|
352
356
|
<span>${escapeHtml(field.label ?? field.name)}</span>
|
|
353
357
|
${renderFieldBadgesWithCharacterCount(badges, inputType === "number" ? null : field.value ?? "")}
|
|
358
|
+
${renderValueStateBadge(valueState)}
|
|
354
359
|
</span>
|
|
355
360
|
${
|
|
356
361
|
jsonPreview
|
|
357
|
-
? renderJsonViewer(jsonPreview, "JSON Preview")
|
|
362
|
+
? `<div data-row-editor-json-preview-container>${renderJsonViewer(jsonPreview, "JSON Preview")}</div>`
|
|
358
363
|
: ""
|
|
359
364
|
}
|
|
360
365
|
${
|
|
@@ -366,6 +371,7 @@ function renderEditableField(field, tableMeta = {}) {
|
|
|
366
371
|
class="w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
|
|
367
372
|
data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
|
|
368
373
|
data-row-editor-timestamp-source
|
|
374
|
+
data-row-editor-value-source
|
|
369
375
|
name="field:${escapeHtml(field.name)}"
|
|
370
376
|
step="${escapeHtml(numberStep)}"
|
|
371
377
|
type="number"
|
|
@@ -379,6 +385,7 @@ function renderEditableField(field, tableMeta = {}) {
|
|
|
379
385
|
data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
|
|
380
386
|
data-row-editor-text-source
|
|
381
387
|
data-row-editor-timestamp-source
|
|
388
|
+
data-row-editor-value-source
|
|
382
389
|
name="field:${escapeHtml(field.name)}"
|
|
383
390
|
data-row-editor-url-input
|
|
384
391
|
spellcheck="false"
|
|
@@ -395,6 +402,7 @@ function renderEditableField(field, tableMeta = {}) {
|
|
|
395
402
|
data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
|
|
396
403
|
data-row-editor-text-source
|
|
397
404
|
data-row-editor-timestamp-source
|
|
405
|
+
data-row-editor-value-source
|
|
398
406
|
name="field:${escapeHtml(field.name)}"
|
|
399
407
|
spellcheck="false"
|
|
400
408
|
>${escapeHtml(field.value ?? "")}</textarea>
|
package/frontend/js/store.js
CHANGED
|
@@ -253,6 +253,11 @@ const state = {
|
|
|
253
253
|
},
|
|
254
254
|
settings: {
|
|
255
255
|
data: { ...DEFAULT_SETTINGS },
|
|
256
|
+
section: 'information',
|
|
257
|
+
apiTokens: [],
|
|
258
|
+
tokenDatabase: null,
|
|
259
|
+
createdApiToken: null,
|
|
260
|
+
tokenSaving: false,
|
|
256
261
|
loading: false,
|
|
257
262
|
error: null,
|
|
258
263
|
appVersion: null,
|
|
@@ -1310,6 +1315,9 @@ async function refreshSettingsState() {
|
|
|
1310
1315
|
};
|
|
1311
1316
|
state.settings.appVersion = response.metadata?.appVersion ?? null;
|
|
1312
1317
|
state.settings.sqliteVersion = response.metadata?.sqliteVersion ?? null;
|
|
1318
|
+
state.settings.apiTokens = response.metadata?.apiTokens ?? [];
|
|
1319
|
+
state.settings.tokenDatabase = response.metadata?.activeDatabase ?? null;
|
|
1320
|
+
state.settings.createdApiToken = null;
|
|
1313
1321
|
} catch (error) {
|
|
1314
1322
|
state.settings.error = normalizeError(error);
|
|
1315
1323
|
} finally {
|
|
@@ -1318,6 +1326,103 @@ async function refreshSettingsState() {
|
|
|
1318
1326
|
}
|
|
1319
1327
|
}
|
|
1320
1328
|
|
|
1329
|
+
export async function createSettingsApiToken(name) {
|
|
1330
|
+
state.settings.tokenSaving = true;
|
|
1331
|
+
state.settings.error = null;
|
|
1332
|
+
emitChange();
|
|
1333
|
+
|
|
1334
|
+
try {
|
|
1335
|
+
const response = await api.createApiToken(name);
|
|
1336
|
+
state.settings.apiTokens = response.metadata?.apiTokens ?? state.settings.apiTokens;
|
|
1337
|
+
state.settings.tokenDatabase = response.metadata?.activeDatabase ?? state.settings.tokenDatabase;
|
|
1338
|
+
state.settings.createdApiToken = response.data ?? null;
|
|
1339
|
+
pushToast('API token created. Store it now; it will not be shown again.', 'success');
|
|
1340
|
+
return response.data ?? null;
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
state.settings.error = normalizeError(error);
|
|
1343
|
+
return null;
|
|
1344
|
+
} finally {
|
|
1345
|
+
state.settings.tokenSaving = false;
|
|
1346
|
+
emitChange();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
export function setSettingsSection(section) {
|
|
1351
|
+
const normalizedSection = section === 'api-tokens' ? 'api-tokens' : 'information';
|
|
1352
|
+
|
|
1353
|
+
if (state.settings.section === normalizedSection) {
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
state.settings.section = normalizedSection;
|
|
1358
|
+
emitChange();
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
export async function deleteSettingsApiToken(tokenId) {
|
|
1362
|
+
state.settings.tokenSaving = true;
|
|
1363
|
+
state.settings.error = null;
|
|
1364
|
+
emitChange();
|
|
1365
|
+
|
|
1366
|
+
try {
|
|
1367
|
+
const response = await api.deleteApiToken(tokenId);
|
|
1368
|
+
state.settings.apiTokens = response.metadata?.apiTokens ?? [];
|
|
1369
|
+
state.settings.tokenDatabase = response.metadata?.activeDatabase ?? state.settings.tokenDatabase;
|
|
1370
|
+
|
|
1371
|
+
if (state.settings.createdApiToken?.id === tokenId) {
|
|
1372
|
+
state.settings.createdApiToken = null;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
pushToast('API token deleted.', 'success');
|
|
1376
|
+
return true;
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
if (state.modal?.kind === 'delete-api-token') {
|
|
1379
|
+
withModalError(error);
|
|
1380
|
+
} else {
|
|
1381
|
+
state.settings.error = normalizeError(error);
|
|
1382
|
+
}
|
|
1383
|
+
return false;
|
|
1384
|
+
} finally {
|
|
1385
|
+
state.settings.tokenSaving = false;
|
|
1386
|
+
emitChange();
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
export function openDeleteSettingsApiTokenModal(tokenId) {
|
|
1391
|
+
const token = state.settings.apiTokens.find(candidate => candidate.id === tokenId);
|
|
1392
|
+
|
|
1393
|
+
if (!token) {
|
|
1394
|
+
pushToast('The selected API token could not be loaded.', 'alert');
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
state.modal = {
|
|
1399
|
+
kind: 'delete-api-token',
|
|
1400
|
+
tokenId: token.id,
|
|
1401
|
+
tokenName: token.name,
|
|
1402
|
+
tokenPrefix: token.tokenPrefix,
|
|
1403
|
+
databaseLabel: state.settings.tokenDatabase?.label ?? 'active database',
|
|
1404
|
+
error: null,
|
|
1405
|
+
submitting: false,
|
|
1406
|
+
};
|
|
1407
|
+
emitChange();
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
export async function submitDeleteSettingsApiTokenConfirmation() {
|
|
1411
|
+
if (state.modal?.kind !== 'delete-api-token') {
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const tokenId = state.modal.tokenId;
|
|
1416
|
+
startModalSubmission();
|
|
1417
|
+
const deleted = await deleteSettingsApiToken(tokenId);
|
|
1418
|
+
|
|
1419
|
+
if (deleted) {
|
|
1420
|
+
closeModalInternal();
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
return deleted;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1321
1426
|
async function loadQueryHistoryDetail(historyId, options = {}) {
|
|
1322
1427
|
const normalizedId = String(historyId ?? '').trim();
|
|
1323
1428
|
const numericId = Number(normalizedId);
|
|
@@ -3490,6 +3595,26 @@ export async function submitCreateConnection(payload) {
|
|
|
3490
3595
|
}
|
|
3491
3596
|
}
|
|
3492
3597
|
|
|
3598
|
+
export async function chooseOpenDatabasePath() {
|
|
3599
|
+
try {
|
|
3600
|
+
const response = await api.chooseOpenDatabasePath();
|
|
3601
|
+
return response.data?.path ?? null;
|
|
3602
|
+
} catch (error) {
|
|
3603
|
+
pushToast(normalizeError(error).message || 'Native file dialog could not be opened.', 'alert');
|
|
3604
|
+
return null;
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
export async function chooseCreateDatabasePath() {
|
|
3609
|
+
try {
|
|
3610
|
+
const response = await api.chooseCreateDatabasePath();
|
|
3611
|
+
return response.data?.path ?? null;
|
|
3612
|
+
} catch (error) {
|
|
3613
|
+
pushToast(normalizeError(error).message || 'Native file dialog could not be opened.', 'alert');
|
|
3614
|
+
return null;
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3493
3618
|
export async function submitImportSql(payload) {
|
|
3494
3619
|
startModalSubmission();
|
|
3495
3620
|
|
|
@@ -5303,11 +5428,13 @@ export function getQueryMessages(snapshot = state) {
|
|
|
5303
5428
|
|
|
5304
5429
|
return [
|
|
5305
5430
|
...snapshot.editor.result.statements.map(statement => ({
|
|
5306
|
-
tone: statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
|
|
5431
|
+
tone: statement.kind === 'resultSet' && statement.truncated ? 'warning' : statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
|
|
5307
5432
|
label: `${statement.keyword} #${statement.index + 1}`,
|
|
5308
5433
|
value:
|
|
5309
5434
|
statement.kind === 'resultSet'
|
|
5310
|
-
?
|
|
5435
|
+
? statement.truncated
|
|
5436
|
+
? `Showing the first ${statement.rowCount} row(s); the result was limited to ${statement.rowLimit}.`
|
|
5437
|
+
: `${statement.rowCount} row(s) returned.`
|
|
5311
5438
|
: `${statement.changes} row(s) affected.`,
|
|
5312
5439
|
})),
|
|
5313
5440
|
...queryMessages,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function formatJsonPreview(value) {
|
|
2
|
+
if (value === null || value === undefined) {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let parsed = value;
|
|
7
|
+
|
|
8
|
+
if (typeof value !== "object") {
|
|
9
|
+
const text = String(value).trim();
|
|
10
|
+
|
|
11
|
+
if (!text || !["{", "["].includes(text[0])) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(text);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!parsed || typeof parsed !== "object") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return JSON.stringify(parsed, null, 2);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function getRowEditorValueState(value) {
|
|
2
|
+
if (value === null || value === undefined) {
|
|
3
|
+
return "null";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return String(value) === "" ? "empty" : "value";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getRowEditorValueStateLabel(state) {
|
|
10
|
+
if (state === "null") {
|
|
11
|
+
return "NULL";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (state === "empty") {
|
|
15
|
+
return "EMPTY STRING";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return "VALUE";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildRowEditorSubmittedValues(formData, fieldMetadata = {}) {
|
|
22
|
+
const fieldNames = new Set();
|
|
23
|
+
|
|
24
|
+
for (const [key] of formData.entries()) {
|
|
25
|
+
if (key.startsWith("field:")) {
|
|
26
|
+
fieldNames.add(key.slice("field:".length));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Object.fromEntries(
|
|
31
|
+
[...fieldNames].map((fieldName) => {
|
|
32
|
+
const valueKey = `field:${fieldName}`;
|
|
33
|
+
const value = String(formData.get(valueKey) ?? "");
|
|
34
|
+
const metadata = fieldMetadata[fieldName] ?? {};
|
|
35
|
+
const keepInitialNull =
|
|
36
|
+
metadata.initialState === "null" && metadata.dirty !== true && value === "";
|
|
37
|
+
|
|
38
|
+
return [fieldName, keepInitialNull ? null : value];
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function captureTableHorizontalScrollState({ routeName = "", scrollNodes = [] } = {}) {
|
|
2
|
+
return {
|
|
3
|
+
routeName: String(routeName ?? ""),
|
|
4
|
+
positions: Array.from(scrollNodes)
|
|
5
|
+
.map((scrollNode) => ({
|
|
6
|
+
key: String(scrollNode?.dataset?.tableScrollKey ?? ""),
|
|
7
|
+
scrollLeft: Number(scrollNode?.scrollLeft) || 0,
|
|
8
|
+
}))
|
|
9
|
+
.filter((position) => position.key),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function restoreTableHorizontalScrollState({
|
|
14
|
+
snapshot,
|
|
15
|
+
routeName = "",
|
|
16
|
+
scrollNodes = [],
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (!snapshot || snapshot.routeName !== String(routeName ?? "")) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const candidates = Array.from(scrollNodes);
|
|
23
|
+
let restored = false;
|
|
24
|
+
|
|
25
|
+
for (const position of snapshot.positions ?? []) {
|
|
26
|
+
const scrollNode = candidates.find(
|
|
27
|
+
(candidate) => String(candidate?.dataset?.tableScrollKey ?? "") === position.key
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (!scrollNode) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
scrollNode.scrollLeft = position.scrollLeft;
|
|
35
|
+
restored = true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return restored;
|
|
39
|
+
}
|
|
@@ -220,6 +220,14 @@ function renderQueryResultState(state, result) {
|
|
|
220
220
|
`;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
if (result.truncated) {
|
|
224
|
+
return `
|
|
225
|
+
<div class="border-b border-primary-container/20 bg-primary-container/10 px-4 py-3 text-sm text-on-surface">
|
|
226
|
+
Charts use the first ${escapeHtml(String(result.rowLimit ?? result.rows?.length ?? 0))} rows. Refine the query for a complete visualization.
|
|
227
|
+
</div>
|
|
228
|
+
`;
|
|
229
|
+
}
|
|
230
|
+
|
|
223
231
|
return '';
|
|
224
232
|
}
|
|
225
233
|
|
|
@@ -461,7 +461,9 @@ function renderTableSurface(state) {
|
|
|
461
461
|
return [
|
|
462
462
|
'<div class="flex flex-1 min-h-0 flex-col bg-surface-container-lowest">',
|
|
463
463
|
renderTableFilterBar(table, state, activeColumn),
|
|
464
|
-
'<div class="custom-scrollbar flex-1 overflow-auto"
|
|
464
|
+
'<div class="custom-scrollbar flex-1 overflow-auto" data-table-horizontal-scroll data-table-scroll-key="data:',
|
|
465
|
+
escapeHtml(table.name),
|
|
466
|
+
'">',
|
|
465
467
|
gridMarkup,
|
|
466
468
|
emptyMarkup,
|
|
467
469
|
'</div>',
|
|
@@ -603,6 +605,7 @@ export function renderDataRowEditorPanel(state) {
|
|
|
603
605
|
...getColumnNumberInputMeta(column),
|
|
604
606
|
allowedValues: column.allowedValues ?? [],
|
|
605
607
|
notNull: Boolean(column.notNull),
|
|
608
|
+
rawValue: value,
|
|
606
609
|
value: value === null || value === undefined ? '' : String(value),
|
|
607
610
|
};
|
|
608
611
|
}),
|
|
@@ -234,6 +234,7 @@ function renderEditorRowPanel(state) {
|
|
|
234
234
|
badges: getEditorColumnBadges(column),
|
|
235
235
|
allowedValues: column.allowedValues ?? [],
|
|
236
236
|
notNull: Boolean(column.notNull),
|
|
237
|
+
rawValue: value,
|
|
237
238
|
value: value === null || value === undefined ? '' : String(value),
|
|
238
239
|
};
|
|
239
240
|
}),
|
|
@@ -280,6 +281,7 @@ function renderResultsSurface(state, isResultsRoute) {
|
|
|
280
281
|
sortDirection: state.editor.resultSortDirection,
|
|
281
282
|
resultScope: 'editor',
|
|
282
283
|
sortAction: 'sort-editor-results-column',
|
|
284
|
+
scrollKey: `editor:${state.editor.result?.historyId ?? 'current'}`,
|
|
283
285
|
})
|
|
284
286
|
: renderMissingDatabase();
|
|
285
287
|
}
|
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
import { renderPageHeader } from '../components/pageHeader.js';
|
|
2
|
+
import { renderTextInput } from '../components/formControls.js';
|
|
2
3
|
import { escapeHtml } from '../utils/format.js';
|
|
3
4
|
|
|
5
|
+
function renderSettingsNavigation(activeSection) {
|
|
6
|
+
const items = [
|
|
7
|
+
{ id: 'information', icon: 'info', label: 'Information' },
|
|
8
|
+
{ id: 'api-tokens', icon: 'key', label: 'API Tokens' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
return `
|
|
12
|
+
<nav class="flex flex-wrap gap-2 border-b border-outline-variant/10 pb-4" aria-label="Settings sections">
|
|
13
|
+
${items
|
|
14
|
+
.map(
|
|
15
|
+
item => `
|
|
16
|
+
<button
|
|
17
|
+
class="query-history-tab ${activeSection === item.id ? 'is-active' : ''}"
|
|
18
|
+
aria-current="${activeSection === item.id ? 'page' : 'false'}"
|
|
19
|
+
data-action="set-settings-section"
|
|
20
|
+
data-section="${item.id}"
|
|
21
|
+
type="button"
|
|
22
|
+
>
|
|
23
|
+
<span class="material-symbols-outlined text-sm mr-[5px]">${item.icon}</span>
|
|
24
|
+
${item.label}
|
|
25
|
+
</button>
|
|
26
|
+
`,
|
|
27
|
+
)
|
|
28
|
+
.join('')}
|
|
29
|
+
</nav>
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
|
|
4
33
|
function renderSettingsContent(state) {
|
|
5
34
|
if (state.settings.loading && !state.settings.appVersion) {
|
|
6
35
|
return `
|
|
@@ -24,11 +53,97 @@ function renderSettingsContent(state) {
|
|
|
24
53
|
`;
|
|
25
54
|
}
|
|
26
55
|
|
|
56
|
+
const activeSection = state.settings.section === 'api-tokens' ? 'api-tokens' : 'information';
|
|
27
57
|
const appVersion = escapeHtml(state.settings.appVersion ?? '0.0.0');
|
|
28
58
|
const sqliteVersion = escapeHtml(state.settings.sqliteVersion ?? 'unknown');
|
|
59
|
+
const tokenDatabase = state.settings.tokenDatabase;
|
|
60
|
+
const apiTokens = state.settings.apiTokens ?? [];
|
|
61
|
+
const createdApiToken = state.settings.createdApiToken;
|
|
62
|
+
const tokenSaving = Boolean(state.settings.tokenSaving);
|
|
63
|
+
const tokenItems = apiTokens.length
|
|
64
|
+
? apiTokens
|
|
65
|
+
.map(
|
|
66
|
+
token => `
|
|
67
|
+
<div class="flex flex-col gap-3 border border-outline-variant/10 bg-surface-container-high px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
68
|
+
<div class="min-w-0">
|
|
69
|
+
<div class="truncate text-sm font-semibold text-on-surface">${escapeHtml(token.name)}</div>
|
|
70
|
+
<div class="mt-1 font-mono text-[10px] text-on-surface-variant/60">
|
|
71
|
+
${escapeHtml(token.tokenPrefix)}... · Created ${escapeHtml(token.createdAt ?? 'unknown')}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<button
|
|
75
|
+
class="delete-button"
|
|
76
|
+
data-action="open-delete-api-token-modal"
|
|
77
|
+
data-token-id="${escapeHtml(token.id)}"
|
|
78
|
+
type="button"
|
|
79
|
+
${tokenSaving ? 'disabled' : ''}
|
|
80
|
+
>
|
|
81
|
+
Delete
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
`,
|
|
85
|
+
)
|
|
86
|
+
.join('')
|
|
87
|
+
: '<div class="text-sm text-on-surface-variant">No API tokens exist for this database.</div>';
|
|
88
|
+
const tokenSection = tokenDatabase
|
|
89
|
+
? `
|
|
90
|
+
<div class="space-y-5">
|
|
91
|
+
<div class="space-y-3">
|
|
92
|
+
<div class="text-sm font-semibold text-on-surface">${escapeHtml(tokenDatabase.label)}</div>
|
|
93
|
+
<div>
|
|
94
|
+
<div class="text-[10px] font-mono uppercase tracking-[0.2em] text-on-surface-variant/60">
|
|
95
|
+
Database ID
|
|
96
|
+
</div>
|
|
97
|
+
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
|
98
|
+
<div
|
|
99
|
+
class="min-w-0 flex-1 break-all border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 font-mono text-sm text-primary-container"
|
|
100
|
+
data-database-id
|
|
101
|
+
>${escapeHtml(tokenDatabase.id)}</div>
|
|
102
|
+
<button class="standard-button w-full self-center justify-center" data-action="copy-database-id" type="button">
|
|
103
|
+
<span class="material-symbols-outlined text-sm">content_copy</span>
|
|
104
|
+
Copy ID
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<form class="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]" data-form="create-api-token">
|
|
110
|
+
${renderTextInput({
|
|
111
|
+
className: 'min-w-0 flex-1',
|
|
112
|
+
dataAttributes: { apiTokenName: true },
|
|
113
|
+
maxlength: 80,
|
|
114
|
+
name: 'name',
|
|
115
|
+
placeholder: 'Token name',
|
|
116
|
+
})}
|
|
117
|
+
<button class="standard-button w-full self-center justify-center" type="submit" ${tokenSaving ? 'disabled' : ''}>
|
|
118
|
+
Create Token
|
|
119
|
+
</button>
|
|
120
|
+
</form>
|
|
121
|
+
${
|
|
122
|
+
createdApiToken?.token
|
|
123
|
+
? `
|
|
124
|
+
<div class="border border-primary-container/20 bg-primary-container/5 p-4">
|
|
125
|
+
<div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">Shown once</div>
|
|
126
|
+
<div class="mt-2 text-sm text-on-surface-variant">Store this token now. SQLite Hub only keeps its hash.</div>
|
|
127
|
+
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
|
|
128
|
+
${renderTextInput({
|
|
129
|
+
className: 'min-w-0 flex-1 font-mono',
|
|
130
|
+
dataAttributes: { createdApiToken: true },
|
|
131
|
+
readonly: true,
|
|
132
|
+
value: createdApiToken.token,
|
|
133
|
+
})}
|
|
134
|
+
<button class="standard-button" data-action="copy-created-api-token" type="button">Copy</button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
`
|
|
138
|
+
: ''
|
|
139
|
+
}
|
|
140
|
+
<div class="space-y-2">${tokenItems}</div>
|
|
141
|
+
</div>
|
|
142
|
+
`
|
|
143
|
+
: '<div class="text-sm leading-6 text-on-surface-variant">Select a database before creating database-specific API tokens.</div>';
|
|
29
144
|
|
|
30
|
-
|
|
31
|
-
|
|
145
|
+
const informationSection = `
|
|
146
|
+
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
|
32
147
|
<section class="shell-section overflow-hidden">
|
|
33
148
|
<div class="flex items-center justify-between border-b border-outline-variant/10 bg-surface-container-highest px-4 py-2">
|
|
34
149
|
<span class="text-[10px] font-bold uppercase tracking-[0.25em]">Application</span>
|
|
@@ -120,8 +235,26 @@ function renderSettingsContent(state) {
|
|
|
120
235
|
</a>
|
|
121
236
|
</div>
|
|
122
237
|
</section>
|
|
123
|
-
|
|
124
|
-
|
|
238
|
+
</div>
|
|
239
|
+
`;
|
|
240
|
+
const apiTokensSection = `
|
|
241
|
+
<section class="shell-section overflow-hidden">
|
|
242
|
+
<div class="flex items-center justify-between border-b border-outline-variant/10 bg-surface-container-highest px-4 py-2">
|
|
243
|
+
<span class="text-[10px] font-bold uppercase tracking-[0.25em] mr-px">API Tokens</span>
|
|
244
|
+
<span class="material-symbols-outlined text-xs text-primary-container">key</span>
|
|
245
|
+
</div>
|
|
246
|
+
<div class="p-6">
|
|
247
|
+
${tokenSection}
|
|
248
|
+
</div>
|
|
249
|
+
</section>
|
|
250
|
+
`;
|
|
251
|
+
|
|
252
|
+
return `
|
|
253
|
+
<div class="space-y-6">
|
|
254
|
+
${renderSettingsNavigation(activeSection)}
|
|
255
|
+
${activeSection === 'api-tokens' ? apiTokensSection : informationSection}
|
|
256
|
+
</div>
|
|
257
|
+
`;
|
|
125
258
|
}
|
|
126
259
|
|
|
127
260
|
export function renderSettingsView(state) {
|
|
@@ -131,7 +264,10 @@ export function renderSettingsView(state) {
|
|
|
131
264
|
<div class="view-frame mx-auto max-w-6xl space-y-8">
|
|
132
265
|
${renderPageHeader({
|
|
133
266
|
title: 'Settings',
|
|
134
|
-
subtitle:
|
|
267
|
+
subtitle:
|
|
268
|
+
state.settings.section === 'api-tokens'
|
|
269
|
+
? 'Security // Database API access'
|
|
270
|
+
: 'Application // Build + Credits',
|
|
135
271
|
})}
|
|
136
272
|
${renderSettingsContent(state)}
|
|
137
273
|
</div>
|