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.
Files changed (83) hide show
  1. package/README.md +101 -19
  2. package/bin/sqlite-hub.js +83 -401
  3. package/examples/api/queries.js +31 -0
  4. package/examples/api/rows.js +27 -0
  5. package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
  6. package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
  7. package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
  8. package/frontend/assets/mockups/connections_1200.webp +0 -0
  9. package/frontend/assets/mockups/data_1_1200.webp +0 -0
  10. package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
  11. package/frontend/assets/mockups/documents_1200.webp +0 -0
  12. package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
  13. package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
  14. package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
  15. package/frontend/assets/mockups/overview_1200.webp +0 -0
  16. package/frontend/assets/mockups/settings_1200.webp +0 -0
  17. package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
  18. package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
  19. package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
  20. package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
  21. package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
  22. package/frontend/assets/mockups/structure_1_1200.webp +0 -0
  23. package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
  24. package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
  25. package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
  26. package/frontend/js/api.js +25 -0
  27. package/frontend/js/app.js +183 -26
  28. package/frontend/js/components/formControls.js +38 -0
  29. package/frontend/js/components/modal.js +79 -19
  30. package/frontend/js/components/queryResults.js +18 -1
  31. package/frontend/js/components/rowEditorPanel.js +42 -34
  32. package/frontend/js/store.js +129 -2
  33. package/frontend/js/utils/jsonPreview.js +31 -0
  34. package/frontend/js/utils/rowEditorValues.js +41 -0
  35. package/frontend/js/utils/tableScrollState.js +39 -0
  36. package/frontend/js/views/charts.js +8 -0
  37. package/frontend/js/views/data.js +4 -1
  38. package/frontend/js/views/editor.js +2 -0
  39. package/frontend/js/views/settings.js +141 -5
  40. package/frontend/styles/components.css +6 -0
  41. package/frontend/styles/tailwind.generated.css +2 -2
  42. package/package.json +6 -6
  43. package/server/middleware/apiTokenAuth.js +30 -0
  44. package/server/middleware/localRequestSecurity.js +84 -0
  45. package/server/routes/connections.js +35 -1
  46. package/server/routes/externalApi.js +222 -0
  47. package/server/routes/settings.js +64 -4
  48. package/server/server.js +34 -2
  49. package/server/services/apiTokenService.js +101 -0
  50. package/server/services/databaseCommandService.js +399 -0
  51. package/server/services/nativeFileDialogService.js +260 -0
  52. package/server/services/sqlite/dataBrowserService.js +0 -4
  53. package/server/services/sqlite/exportService.js +5 -1
  54. package/server/services/sqlite/sqlExecutor.js +51 -2
  55. package/server/services/storage/appStateStore.js +113 -0
  56. package/server/utils/errors.js +7 -0
  57. package/server/utils/sqliteTypes.js +17 -8
  58. package/tests/api-token-auth.test.js +127 -0
  59. package/tests/cli-service-delegation.test.js +43 -0
  60. package/tests/connections-file-dialog-route.test.js +89 -0
  61. package/tests/copy-column-modal.test.js +48 -0
  62. package/tests/database-command-service.test.js +102 -0
  63. package/tests/export-blob.test.js +46 -0
  64. package/tests/form-controls.test.js +34 -0
  65. package/tests/json-preview.test.js +49 -0
  66. package/tests/local-request-security.test.js +85 -0
  67. package/tests/native-file-dialog.test.js +105 -0
  68. package/tests/query-results-truncation.test.js +20 -0
  69. package/tests/row-editor-null-values.test.js +131 -0
  70. package/tests/settings-api-tokens-route.test.js +76 -0
  71. package/tests/settings-view.test.js +47 -0
  72. package/tests/sql-identifier-safety.test.js +9 -3
  73. package/tests/sql-result-limit.test.js +66 -0
  74. package/tests/table-scroll-state.test.js +70 -0
  75. package/frontend/assets/mockups/connections.png +0 -0
  76. package/frontend/assets/mockups/data.png +0 -0
  77. package/frontend/assets/mockups/data_row_editor.png +0 -0
  78. package/frontend/assets/mockups/home.png +0 -0
  79. package/frontend/assets/mockups/sql_editor.png +0 -0
  80. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  81. package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
  82. package/frontend/assets/mockups/structure.png +0 -0
  83. package/frontend/assets/mockups/structure_inspector.png +0 -0
@@ -0,0 +1,399 @@
1
+ const path = require("node:path");
2
+ const { NotFoundError, ValidationError } = require("../utils/errors");
3
+ const { ConnectionManager } = require("./sqlite/connectionManager");
4
+ const { DataBrowserService } = require("./sqlite/dataBrowserService");
5
+ const { ExportService } = require("./sqlite/exportService");
6
+ const { getTableDetail } = require("./sqlite/introspection");
7
+ const { SqlExecutor } = require("./sqlite/sqlExecutor");
8
+
9
+ function normalizeLookupValue(value, label) {
10
+ const normalized = String(value ?? "").trim();
11
+
12
+ if (!normalized) {
13
+ throw new ValidationError(`${label} is required.`);
14
+ }
15
+
16
+ return normalized;
17
+ }
18
+
19
+ function getQueryTitle(item) {
20
+ return item?.title || item?.displayTitle || item?.previewSql || item?.rawSql || "(untitled query)";
21
+ }
22
+
23
+ function getDocumentTitle(document) {
24
+ return document?.filename || document?.title || document?.id || "(untitled document)";
25
+ }
26
+
27
+ function sanitizeFilenameBase(value, fallback = "export") {
28
+ const sanitized = String(value ?? "")
29
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ")
30
+ .replace(/\s+/g, " ")
31
+ .trim()
32
+ .replace(/[. ]+$/g, "");
33
+
34
+ return (sanitized || fallback).slice(0, 120);
35
+ }
36
+
37
+ function normalizeMarkdownExportFilename(filename, fallback = "document.md") {
38
+ let normalizedFilename = String(filename ?? "")
39
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
40
+ .replace(/[<>:"/\\|?*]+/g, " ")
41
+ .replace(/\s+/g, " ")
42
+ .trim()
43
+ .replace(/^\.+/, "")
44
+ .replace(/[. ]+$/g, "");
45
+
46
+ if (!normalizedFilename) {
47
+ normalizedFilename = fallback;
48
+ }
49
+
50
+ if (!/\.md$/i.test(normalizedFilename)) {
51
+ normalizedFilename = `${normalizedFilename}.md`;
52
+ }
53
+
54
+ if (normalizedFilename.length > 160) {
55
+ normalizedFilename = `${normalizedFilename.slice(0, 157)}.md`;
56
+ }
57
+
58
+ return normalizedFilename;
59
+ }
60
+
61
+ function coerceIdentityValue(column, value) {
62
+ const text = String(value ?? "");
63
+ const affinity = String(column?.affinity ?? "").toUpperCase();
64
+
65
+ if (["INTEGER", "REAL", "NUMERIC"].includes(affinity) && text.trim() !== "") {
66
+ const numberValue = Number(text);
67
+
68
+ if (Number.isFinite(numberValue)) {
69
+ return numberValue;
70
+ }
71
+ }
72
+
73
+ return value;
74
+ }
75
+
76
+ function parseCompositePrimaryKeyValue(rawValue) {
77
+ if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) {
78
+ return rawValue;
79
+ }
80
+
81
+ try {
82
+ const parsed = JSON.parse(rawValue);
83
+
84
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
85
+ return parsed;
86
+ }
87
+ } catch (error) {
88
+ // Fall through to the validation error below.
89
+ }
90
+
91
+ throw new ValidationError(
92
+ 'Composite primary key export requires a JSON object, for example {"id":1,"locale":"en"}.'
93
+ );
94
+ }
95
+
96
+ function buildIdentityFromExportTarget(tableDetail, exportTarget) {
97
+ if (tableDetail.identityStrategy?.type === "rowid") {
98
+ const numberValue = Number(exportTarget);
99
+
100
+ return {
101
+ kind: "rowid",
102
+ values: {
103
+ rowid: Number.isInteger(numberValue) ? numberValue : exportTarget,
104
+ },
105
+ };
106
+ }
107
+
108
+ if (tableDetail.identityStrategy?.type === "primaryKey") {
109
+ const columns = tableDetail.identityStrategy.columns ?? [];
110
+
111
+ if (columns.length === 1) {
112
+ const columnName = columns[0];
113
+ const column = tableDetail.columns.find((candidate) => candidate.name === columnName);
114
+
115
+ return {
116
+ kind: "primaryKey",
117
+ columns,
118
+ values: {
119
+ [columnName]: coerceIdentityValue(column, exportTarget),
120
+ },
121
+ };
122
+ }
123
+
124
+ const parsed = parseCompositePrimaryKeyValue(exportTarget);
125
+
126
+ return {
127
+ kind: "primaryKey",
128
+ columns,
129
+ values: Object.fromEntries(
130
+ columns.map((columnName) => {
131
+ if (!Object.prototype.hasOwnProperty.call(parsed, columnName)) {
132
+ throw new ValidationError(`Missing primary key value for ${columnName}.`);
133
+ }
134
+
135
+ const column = tableDetail.columns.find((candidate) => candidate.name === columnName);
136
+ return [columnName, coerceIdentityValue(column, parsed[columnName])];
137
+ })
138
+ ),
139
+ };
140
+ }
141
+
142
+ throw new ValidationError(`Table ${tableDetail.name} has no stable row identity.`);
143
+ }
144
+
145
+ function buildRowJsonObject({ row, columns = [] } = {}) {
146
+ const names = columns
147
+ .map((column) => String(typeof column === "object" ? column?.name : column ?? "").trim())
148
+ .filter((name) => name && name !== "__identity");
149
+ const sourceNames = names.length
150
+ ? names
151
+ : Object.keys(row ?? {}).filter((name) => name !== "__identity");
152
+
153
+ return Object.fromEntries(
154
+ sourceNames
155
+ .map((name) => [name, Object.prototype.hasOwnProperty.call(row ?? {}, name) ? row[name] : undefined])
156
+ .filter(([, value]) => value !== undefined)
157
+ );
158
+ }
159
+
160
+ class DatabaseCommandService {
161
+ constructor({ appStateStore, runtimeFactory } = {}) {
162
+ this.appStateStore = appStateStore;
163
+ this.runtimeFactory = runtimeFactory ?? ((connection) => this.createReadOnlyRuntime(connection));
164
+ }
165
+
166
+ listDatabases() {
167
+ return this.appStateStore.getRecentConnections();
168
+ }
169
+
170
+ getDatabase(databaseReference) {
171
+ const normalizedReference = normalizeLookupValue(databaseReference, "Database").toLowerCase();
172
+ const connection = this.listDatabases().find(
173
+ (candidate) =>
174
+ String(candidate.label ?? "").toLowerCase() === normalizedReference ||
175
+ String(candidate.id ?? "").toLowerCase() === normalizedReference
176
+ );
177
+
178
+ if (!connection) {
179
+ throw new NotFoundError(`Database not found: ${databaseReference}`);
180
+ }
181
+
182
+ return connection;
183
+ }
184
+
185
+ createReadOnlyRuntime(connection) {
186
+ const connectionManager = new ConnectionManager({ appStateStore: this.appStateStore });
187
+
188
+ connectionManager.openConnection({
189
+ filePath: connection.path,
190
+ label: connection.label,
191
+ id: connection.id,
192
+ logoPath: connection.logoPath ?? null,
193
+ makeActive: false,
194
+ readOnly: true,
195
+ });
196
+
197
+ const sqlExecutor = new SqlExecutor({
198
+ connectionManager,
199
+ appStateStore: this.appStateStore,
200
+ });
201
+
202
+ return {
203
+ connectionManager,
204
+ dataBrowserService: new DataBrowserService({ connectionManager }),
205
+ db: connectionManager.getActiveDatabase(),
206
+ exportService: new ExportService({
207
+ appStateStore: this.appStateStore,
208
+ connectionManager,
209
+ sqlExecutor,
210
+ }),
211
+ sqlExecutor,
212
+ close() {
213
+ connectionManager.closeCurrent();
214
+ },
215
+ };
216
+ }
217
+
218
+ withDatabase(databaseReference, callback) {
219
+ const connection = this.getDatabase(databaseReference);
220
+ const runtime = this.runtimeFactory(connection);
221
+
222
+ try {
223
+ return callback({ connection, runtime });
224
+ } finally {
225
+ runtime.close?.();
226
+ }
227
+ }
228
+
229
+ listTables(databaseReference) {
230
+ return this.withDatabase(databaseReference, ({ runtime }) => runtime.dataBrowserService.listTables());
231
+ }
232
+
233
+ getTable(databaseReference, tableName) {
234
+ const normalizedTableName = normalizeLookupValue(tableName, "Table name");
235
+ return this.withDatabase(databaseReference, ({ runtime }) =>
236
+ getTableDetail(runtime.db, normalizedTableName)
237
+ );
238
+ }
239
+
240
+ getTableRow(databaseReference, tableName, exportTarget) {
241
+ const normalizedTableName = normalizeLookupValue(tableName, "Table name");
242
+ normalizeLookupValue(exportTarget, "Row key");
243
+
244
+ return this.withDatabase(databaseReference, ({ runtime }) => {
245
+ const tableDetail = getTableDetail(runtime.db, normalizedTableName, {
246
+ includeRowCount: false,
247
+ });
248
+ const identity = buildIdentityFromExportTarget(tableDetail, exportTarget);
249
+ const { row } = runtime.dataBrowserService.getTableRow(normalizedTableName, { identity });
250
+ const data = buildRowJsonObject({
251
+ row,
252
+ columns: tableDetail.columns.filter((column) => column.visible),
253
+ });
254
+
255
+ return {
256
+ data,
257
+ filename: `${sanitizeFilenameBase(
258
+ `${normalizedTableName}-${typeof exportTarget === "object" ? JSON.stringify(exportTarget) : exportTarget}`,
259
+ `${normalizedTableName}-row`
260
+ )}.json`,
261
+ identity,
262
+ table: tableDetail,
263
+ };
264
+ });
265
+ }
266
+
267
+ findQuery(databaseReference, queryName) {
268
+ const connection = this.getDatabase(databaseReference);
269
+ const normalizedQueryName = normalizeLookupValue(queryName, "Query name").toLowerCase();
270
+ const collection = this.appStateStore.buildQueryHistoryCollection({
271
+ databaseKey: connection.id,
272
+ search: queryName,
273
+ onlySaved: false,
274
+ limit: 100,
275
+ });
276
+
277
+ return (
278
+ collection.items.find((item) =>
279
+ [item.id, item.title, item.displayTitle]
280
+ .filter(Boolean)
281
+ .some((candidate) => String(candidate).toLowerCase() === normalizedQueryName)
282
+ ) ??
283
+ collection.items.find((item) =>
284
+ String(item.rawSql ?? "").toLowerCase().includes(normalizedQueryName)
285
+ ) ??
286
+ null
287
+ );
288
+ }
289
+
290
+ requireQuery(databaseReference, queryName) {
291
+ const query = this.findQuery(databaseReference, queryName);
292
+
293
+ if (!query) {
294
+ const available = this.listSavedQueries(databaseReference).items.map(getQueryTitle);
295
+ throw new NotFoundError(`Saved query not found: ${queryName}`, {
296
+ details: { available },
297
+ });
298
+ }
299
+
300
+ return query;
301
+ }
302
+
303
+ listSavedQueries(databaseReference, limit = 100) {
304
+ const connection = this.getDatabase(databaseReference);
305
+ return this.appStateStore.buildQueryHistoryCollection({
306
+ databaseKey: connection.id,
307
+ onlySaved: true,
308
+ limit,
309
+ });
310
+ }
311
+
312
+ getSavedQuery(databaseReference, queryName) {
313
+ return this.requireQuery(databaseReference, queryName);
314
+ }
315
+
316
+ executeSavedQuery(databaseReference, queryName) {
317
+ const query = this.requireQuery(databaseReference, queryName);
318
+ const result = this.withDatabase(databaseReference, ({ runtime }) =>
319
+ runtime.sqlExecutor.execute(query.rawSql, { persistHistory: false })
320
+ );
321
+
322
+ return { query, result };
323
+ }
324
+
325
+ exportSavedQuery(databaseReference, queryName, format = "csv") {
326
+ const query = this.requireQuery(databaseReference, queryName);
327
+ const result = this.withDatabase(databaseReference, ({ runtime }) =>
328
+ runtime.exportService.exportQuery(query.rawSql, { format })
329
+ );
330
+
331
+ return { query, result };
332
+ }
333
+
334
+ listDocuments(databaseReference) {
335
+ const connection = this.getDatabase(databaseReference);
336
+ return this.appStateStore.listDatabaseDocuments(connection.id);
337
+ }
338
+
339
+ findDocument(databaseReference, documentName) {
340
+ const connection = this.getDatabase(databaseReference);
341
+ const normalizedDocumentName = normalizeLookupValue(documentName, "Document name").toLowerCase();
342
+ const documents = this.appStateStore.listDatabaseDocuments(connection.id);
343
+ const exactMatch = documents.find((document) =>
344
+ [document.id, document.filename, document.title]
345
+ .filter(Boolean)
346
+ .some((candidate) => String(candidate).toLowerCase() === normalizedDocumentName)
347
+ );
348
+ const partialMatch = exactMatch ?? documents.find((document) =>
349
+ [document.filename, document.title]
350
+ .filter(Boolean)
351
+ .some((candidate) => String(candidate).toLowerCase().includes(normalizedDocumentName))
352
+ );
353
+
354
+ return partialMatch
355
+ ? this.appStateStore.getDatabaseDocument(connection.id, partialMatch.id)
356
+ : null;
357
+ }
358
+
359
+ requireDocument(databaseReference, documentName) {
360
+ const document = this.findDocument(databaseReference, documentName);
361
+
362
+ if (!document) {
363
+ const available = this.listDocuments(databaseReference).map(getDocumentTitle);
364
+ throw new NotFoundError(`Document not found: ${documentName}`, {
365
+ details: { available },
366
+ });
367
+ }
368
+
369
+ return document;
370
+ }
371
+
372
+ getDocument(databaseReference, documentName) {
373
+ return this.requireDocument(databaseReference, documentName);
374
+ }
375
+
376
+ exportDocument(databaseReference, documentName) {
377
+ const document = this.requireDocument(databaseReference, documentName);
378
+
379
+ return {
380
+ document,
381
+ content: document.content ?? "",
382
+ filename: normalizeMarkdownExportFilename(
383
+ document.filename,
384
+ `${document.title || path.basename(String(documentName)) || "document"}.md`
385
+ ),
386
+ mimeType: "text/markdown; charset=utf-8",
387
+ };
388
+ }
389
+ }
390
+
391
+ module.exports = {
392
+ DatabaseCommandService,
393
+ buildIdentityFromExportTarget,
394
+ buildRowJsonObject,
395
+ getDocumentTitle,
396
+ getQueryTitle,
397
+ normalizeMarkdownExportFilename,
398
+ sanitizeFilenameBase,
399
+ };
@@ -0,0 +1,260 @@
1
+ const path = require("node:path");
2
+ const os = require("node:os");
3
+ const { execFile } = require("node:child_process");
4
+ const { promisify } = require("node:util");
5
+ const { AppError } = require("../utils/errors");
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const DEFAULT_DATABASE_FILENAME = "new-database.sqlite";
9
+
10
+ function escapePowerShellSingleQuotedString(value) {
11
+ return String(value).replaceAll("'", "''");
12
+ }
13
+
14
+ function buildDialogAttempts({ platform = process.platform, homeDirectory = os.homedir() } = {}) {
15
+ const defaultPath = path.join(homeDirectory, DEFAULT_DATABASE_FILENAME);
16
+
17
+ if (platform === "darwin") {
18
+ return [
19
+ {
20
+ command: "osascript",
21
+ args: [
22
+ "-e",
23
+ "on run argv",
24
+ "-e",
25
+ 'set selectedFile to choose file name with prompt "Create SQLite Database" default location POSIX file (item 1 of argv) default name (item 2 of argv)',
26
+ "-e",
27
+ "return POSIX path of selectedFile",
28
+ "-e",
29
+ "end run",
30
+ homeDirectory,
31
+ DEFAULT_DATABASE_FILENAME,
32
+ ],
33
+ cancelledExitCodes: new Set([1]),
34
+ cancelledErrorPattern: /user canceled|-128/i,
35
+ },
36
+ ];
37
+ }
38
+
39
+ if (platform === "win32") {
40
+ const initialDirectory = escapePowerShellSingleQuotedString(homeDirectory);
41
+ const script = [
42
+ "Add-Type -AssemblyName System.Windows.Forms",
43
+ "$dialog = New-Object System.Windows.Forms.SaveFileDialog",
44
+ "$dialog.Title = 'Create SQLite Database'",
45
+ "$dialog.Filter = 'SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*'",
46
+ "$dialog.DefaultExt = 'sqlite'",
47
+ "$dialog.AddExtension = $true",
48
+ `$dialog.InitialDirectory = '${initialDirectory}'`,
49
+ `$dialog.FileName = '${DEFAULT_DATABASE_FILENAME}'`,
50
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
51
+ " [Console]::Out.Write($dialog.FileName)",
52
+ "} else {",
53
+ " exit 2",
54
+ "}",
55
+ ].join("; ");
56
+
57
+ return [
58
+ {
59
+ command: "powershell.exe",
60
+ args: ["-NoProfile", "-STA", "-Command", script],
61
+ cancelledExitCodes: new Set([2]),
62
+ },
63
+ ];
64
+ }
65
+
66
+ return [
67
+ {
68
+ command: "zenity",
69
+ args: [
70
+ "--file-selection",
71
+ "--save",
72
+ "--title=Create SQLite Database",
73
+ `--filename=${defaultPath}`,
74
+ "--file-filter=SQLite databases | *.db *.sqlite *.sqlite3",
75
+ "--file-filter=All files | *",
76
+ ],
77
+ cancelledExitCodes: new Set([1]),
78
+ },
79
+ {
80
+ command: "kdialog",
81
+ args: [
82
+ "--getsavefilename",
83
+ defaultPath,
84
+ "SQLite databases (*.db *.sqlite *.sqlite3)",
85
+ "--title",
86
+ "Create SQLite Database",
87
+ ],
88
+ cancelledExitCodes: new Set([1]),
89
+ },
90
+ ];
91
+ }
92
+
93
+ function buildOpenDialogAttempts({ platform = process.platform, homeDirectory = os.homedir() } = {}) {
94
+ if (platform === "darwin") {
95
+ return [
96
+ {
97
+ command: "osascript",
98
+ args: [
99
+ "-e",
100
+ "on run argv",
101
+ "-e",
102
+ 'set selectedFile to choose file with prompt "Open SQLite Database" default location POSIX file (item 1 of argv)',
103
+ "-e",
104
+ "return POSIX path of selectedFile",
105
+ "-e",
106
+ "end run",
107
+ homeDirectory,
108
+ ],
109
+ cancelledExitCodes: new Set([1]),
110
+ cancelledErrorPattern: /user canceled|-128/i,
111
+ },
112
+ ];
113
+ }
114
+
115
+ if (platform === "win32") {
116
+ const initialDirectory = escapePowerShellSingleQuotedString(homeDirectory);
117
+ const script = [
118
+ "Add-Type -AssemblyName System.Windows.Forms",
119
+ "$dialog = New-Object System.Windows.Forms.OpenFileDialog",
120
+ "$dialog.Title = 'Open SQLite Database'",
121
+ "$dialog.Filter = 'SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*'",
122
+ "$dialog.CheckFileExists = $true",
123
+ `$dialog.InitialDirectory = '${initialDirectory}'`,
124
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
125
+ " [Console]::Out.Write($dialog.FileName)",
126
+ "} else {",
127
+ " exit 2",
128
+ "}",
129
+ ].join("; ");
130
+
131
+ return [
132
+ {
133
+ command: "powershell.exe",
134
+ args: ["-NoProfile", "-STA", "-Command", script],
135
+ cancelledExitCodes: new Set([2]),
136
+ },
137
+ ];
138
+ }
139
+
140
+ return [
141
+ {
142
+ command: "zenity",
143
+ args: [
144
+ "--file-selection",
145
+ "--title=Open SQLite Database",
146
+ `--filename=${homeDirectory}${path.sep}`,
147
+ "--file-filter=SQLite databases | *.db *.sqlite *.sqlite3",
148
+ "--file-filter=All files | *",
149
+ ],
150
+ cancelledExitCodes: new Set([1]),
151
+ },
152
+ {
153
+ command: "kdialog",
154
+ args: [
155
+ "--getopenfilename",
156
+ homeDirectory,
157
+ "SQLite databases (*.db *.sqlite *.sqlite3);;All files (*)",
158
+ "--title",
159
+ "Open SQLite Database",
160
+ ],
161
+ cancelledExitCodes: new Set([1]),
162
+ },
163
+ ];
164
+ }
165
+
166
+ function normalizeSelectedDatabasePath(value) {
167
+ const selectedPath = String(value ?? "").trim();
168
+
169
+ if (!selectedPath) {
170
+ return null;
171
+ }
172
+
173
+ return path.extname(selectedPath) ? selectedPath : `${selectedPath}.sqlite`;
174
+ }
175
+
176
+ function normalizeOpenedDatabasePath(value) {
177
+ return String(value ?? "").trim() || null;
178
+ }
179
+
180
+ function isMissingDialogCommand(error) {
181
+ return error?.code === "ENOENT";
182
+ }
183
+
184
+ function isCancelledDialog(error, attempt) {
185
+ if (!attempt.cancelledExitCodes.has(Number(error?.code))) {
186
+ return false;
187
+ }
188
+
189
+ if (!attempt.cancelledErrorPattern) {
190
+ return true;
191
+ }
192
+
193
+ return attempt.cancelledErrorPattern.test(`${error?.message ?? ""}\n${error?.stderr ?? ""}`);
194
+ }
195
+
196
+ class NativeFileDialogService {
197
+ constructor(options = {}) {
198
+ this.platform = options.platform ?? process.platform;
199
+ this.homeDirectory = options.homeDirectory ?? os.homedir();
200
+ this.executeFile = options.executeFile ?? execFileAsync;
201
+ }
202
+
203
+ async chooseCreateDatabasePath() {
204
+ const attempts = buildDialogAttempts({
205
+ platform: this.platform,
206
+ homeDirectory: this.homeDirectory,
207
+ });
208
+
209
+ return this.runDialogAttempts(attempts, normalizeSelectedDatabasePath);
210
+ }
211
+
212
+ async chooseOpenDatabasePath() {
213
+ const attempts = buildOpenDialogAttempts({
214
+ platform: this.platform,
215
+ homeDirectory: this.homeDirectory,
216
+ });
217
+
218
+ return this.runDialogAttempts(attempts, normalizeOpenedDatabasePath);
219
+ }
220
+
221
+ async runDialogAttempts(attempts, normalizePath) {
222
+ for (const attempt of attempts) {
223
+ try {
224
+ const result = await this.executeFile(attempt.command, attempt.args, {
225
+ maxBuffer: 64 * 1024,
226
+ windowsHide: true,
227
+ });
228
+
229
+ return normalizePath(result?.stdout);
230
+ } catch (error) {
231
+ if (isCancelledDialog(error, attempt)) {
232
+ return null;
233
+ }
234
+
235
+ if (isMissingDialogCommand(error)) {
236
+ continue;
237
+ }
238
+
239
+ throw new AppError("The native file dialog could not be opened.", 500, {
240
+ code: "NATIVE_FILE_DIALOG_FAILED",
241
+ details: { platform: this.platform },
242
+ });
243
+ }
244
+ }
245
+
246
+ throw new AppError("No supported native file dialog is available on this system.", 501, {
247
+ code: "NATIVE_FILE_DIALOG_UNAVAILABLE",
248
+ details: { platform: this.platform },
249
+ });
250
+ }
251
+ }
252
+
253
+ module.exports = {
254
+ DEFAULT_DATABASE_FILENAME,
255
+ NativeFileDialogService,
256
+ buildDialogAttempts,
257
+ buildOpenDialogAttempts,
258
+ normalizeOpenedDatabasePath,
259
+ normalizeSelectedDatabasePath,
260
+ };
@@ -74,10 +74,6 @@ function formatPreviewValue(value) {
74
74
  }
75
75
 
76
76
  function isUnchangedSubmittedValue(currentValue, submittedValue) {
77
- if (currentValue === null && submittedValue === "") {
78
- return true;
79
- }
80
-
81
77
  return formatPreviewValue(currentValue) === formatPreviewValue(submittedValue);
82
78
  }
83
79
 
@@ -91,6 +91,8 @@ class ExportService {
91
91
  })
92
92
  );
93
93
  const result = this.sqlExecutor.execute(sql, {
94
+ blobMode: "full",
95
+ maxRows: null,
94
96
  persistHistory: false,
95
97
  requireReader: true,
96
98
  });
@@ -132,7 +134,9 @@ class ExportService {
132
134
  .filter(Boolean)
133
135
  .join(" ")
134
136
  );
135
- const rows = serializeRows(statement.all(...(filter?.params ?? [])));
137
+ const rows = serializeRows(statement.all(...(filter?.params ?? [])), {
138
+ blobMode: "full",
139
+ });
136
140
  const columns = statement.columns().map((column) => column.name);
137
141
  const content = renderExportContent({
138
142
  columns,