admindb 1.0.1
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/LICENSE +21 -0
- package/README.md +325 -0
- package/dist/app.d.ts +36 -0
- package/dist/app.js +186 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -0
- package/dist/csv.d.ts +15 -0
- package/dist/csv.js +80 -0
- package/dist/csv.js.map +1 -0
- package/dist/db/database.d.ts +225 -0
- package/dist/db/database.js +497 -0
- package/dist/db/database.js.map +1 -0
- package/dist/db/export.d.ts +12 -0
- package/dist/db/export.js +37 -0
- package/dist/db/export.js.map +1 -0
- package/dist/db/manager.d.ts +62 -0
- package/dist/db/manager.js +206 -0
- package/dist/db/manager.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +25 -0
- package/dist/logger.js.map +1 -0
- package/dist/public/css/app.css +1 -0
- package/dist/public/css/input.css +179 -0
- package/dist/public/js/api.js +47 -0
- package/dist/public/js/app.js +120 -0
- package/dist/public/js/browse.js +123 -0
- package/dist/public/js/databases.js +45 -0
- package/dist/public/js/designer.js +205 -0
- package/dist/public/js/forms.js +244 -0
- package/dist/public/js/query.js +232 -0
- package/dist/public/js/schema.js +314 -0
- package/dist/public/js/triggers.js +155 -0
- package/dist/public/js/views.js +176 -0
- package/dist/routes/api.d.ts +9 -0
- package/dist/routes/api.js +622 -0
- package/dist/routes/api.js.map +1 -0
- package/dist/routes/databases.d.ts +19 -0
- package/dist/routes/databases.js +99 -0
- package/dist/routes/databases.js.map +1 -0
- package/dist/routes/pages.d.ts +9 -0
- package/dist/routes/pages.js +253 -0
- package/dist/routes/pages.js.map +1 -0
- package/dist/sql/classifier.d.ts +19 -0
- package/dist/sql/classifier.js +37 -0
- package/dist/sql/classifier.js.map +1 -0
- package/dist/sql/generator.d.ts +99 -0
- package/dist/sql/generator.js +259 -0
- package/dist/sql/generator.js.map +1 -0
- package/dist/test/classifier.test.d.ts +1 -0
- package/dist/test/classifier.test.js +44 -0
- package/dist/test/classifier.test.js.map +1 -0
- package/dist/test/csv.test.d.ts +1 -0
- package/dist/test/csv.test.js +64 -0
- package/dist/test/csv.test.js.map +1 -0
- package/dist/test/database.test.d.ts +1 -0
- package/dist/test/database.test.js +224 -0
- package/dist/test/database.test.js.map +1 -0
- package/dist/test/generator.test.d.ts +1 -0
- package/dist/test/generator.test.js +164 -0
- package/dist/test/generator.test.js.map +1 -0
- package/dist/test/manager.test.d.ts +1 -0
- package/dist/test/manager.test.js +146 -0
- package/dist/test/manager.test.js.map +1 -0
- package/dist/util.d.ts +29 -0
- package/dist/util.js +127 -0
- package/dist/util.js.map +1 -0
- package/dist/views/layouts/main.hbs +49 -0
- package/dist/views/pages/databases.hbs +102 -0
- package/dist/views/pages/designer.hbs +81 -0
- package/dist/views/pages/error.hbs +12 -0
- package/dist/views/pages/form.hbs +62 -0
- package/dist/views/pages/home.hbs +123 -0
- package/dist/views/pages/query.hbs +94 -0
- package/dist/views/pages/schema.hbs +290 -0
- package/dist/views/pages/table.hbs +203 -0
- package/dist/views/pages/triggers.hbs +162 -0
- package/dist/views/pages/views.hbs +134 -0
- package/dist/views/partials/navbar.hbs +50 -0
- package/dist/views/partials/sidebar.hbs +128 -0
- package/package.json +62 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/* Visual table designer: add column definitions one at a time, validate them,
|
|
2
|
+
* and preview the generated CREATE TABLE live. Create executes; Copy copies. */
|
|
3
|
+
(function () {
|
|
4
|
+
'use strict';
|
|
5
|
+
const DESIGNER_TYPES = ['TEXT', 'INTEGER', 'REAL', 'BLOB', 'BOOLEAN', 'DATE', 'DATETIME'];
|
|
6
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
7
|
+
|
|
8
|
+
const columnsEl = document.getElementById('columns');
|
|
9
|
+
const errorBox = document.getElementById('designer-error');
|
|
10
|
+
const tableNameEl = document.getElementById('table-name');
|
|
11
|
+
const addBtn = document.getElementById('add-column');
|
|
12
|
+
const createBtn = document.getElementById('create-table');
|
|
13
|
+
const copyBtn = document.getElementById('copy-sql');
|
|
14
|
+
const previewBox = document.getElementById('sql-preview');
|
|
15
|
+
const previewCode = document.getElementById('preview-code');
|
|
16
|
+
const previewPlaceholder = document.getElementById('preview-placeholder');
|
|
17
|
+
const previewStatus = document.getElementById('preview-status');
|
|
18
|
+
|
|
19
|
+
const columnCache = {};
|
|
20
|
+
let fkTables = [];
|
|
21
|
+
let previewTimer = null;
|
|
22
|
+
|
|
23
|
+
function escapeHtml(s) {
|
|
24
|
+
return (window.UI && UI.escapeHtml) ? UI.escapeHtml(s) : String(s == null ? '' : s);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function loadColumns(table) {
|
|
28
|
+
if (columnCache[table]) return columnCache[table];
|
|
29
|
+
const info = await Api.get('/api/tables/' + encodeURIComponent(table) + '/info');
|
|
30
|
+
columnCache[table] = info.columns.map((c) => c.name);
|
|
31
|
+
return columnCache[table];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function addColumnRow() {
|
|
35
|
+
const row = document.createElement('div');
|
|
36
|
+
row.className = 'grid grid-cols-2 items-end gap-2.5 rounded-xl border border-base-300 bg-base-100 p-3 shadow-soft sm:grid-cols-12';
|
|
37
|
+
row.innerHTML = [
|
|
38
|
+
'<div class="col-span-2 sm:col-span-2"><label class="field-label">Name</label>',
|
|
39
|
+
'<input class="field-input col-name" placeholder="column_name" autocomplete="off" spellcheck="false"></div>',
|
|
40
|
+
'<div class="col-span-1 sm:col-span-2"><label class="field-label">Type</label>',
|
|
41
|
+
'<select class="field-select col-type">' + DESIGNER_TYPES.map((t) => '<option>' + t + '</option>').join('') + '</select></div>',
|
|
42
|
+
'<div class="col-span-1 sm:col-span-2"><label class="field-label">FK table</label>',
|
|
43
|
+
'<select class="field-select col-fk-table"><option value="">— none —</option>' +
|
|
44
|
+
fkTables.map((t) => '<option>' + escapeHtml(t) + '</option>').join('') + '</select></div>',
|
|
45
|
+
'<div class="col-span-1 sm:col-span-2"><label class="field-label">FK column</label>',
|
|
46
|
+
'<select class="field-select col-fk-col"><option value="">—</option></select></div>',
|
|
47
|
+
'<div class="col-span-1 sm:col-span-2"><label class="field-label">Default</label>',
|
|
48
|
+
'<input class="field-input col-default" placeholder="0, CURRENT_TIMESTAMP" autocomplete="off" spellcheck="false"></div>',
|
|
49
|
+
'<div class="col-span-2 flex items-end justify-between gap-1 pb-0.5 sm:col-span-2">',
|
|
50
|
+
'<label class="flex cursor-pointer flex-col items-center gap-1" title="Primary key"><input type="checkbox" class="checkbox checkbox-primary checkbox-xs col-pk"><span class="text-[10px] font-semibold uppercase text-base-content/50">PK</span></label>',
|
|
51
|
+
'<label class="flex cursor-pointer flex-col items-center gap-1" title="Not null"><input type="checkbox" class="checkbox checkbox-xs col-notnull"><span class="text-[10px] font-semibold uppercase text-base-content/50">NN</span></label>',
|
|
52
|
+
'<label class="flex cursor-pointer flex-col items-center gap-1" title="Unique"><input type="checkbox" class="checkbox checkbox-xs col-unique"><span class="text-[10px] font-semibold uppercase text-base-content/50">UQ</span></label>',
|
|
53
|
+
'<button type="button" class="btn btn-ghost btn-xs text-error remove-col" title="Remove column">×</button>',
|
|
54
|
+
'</div>',
|
|
55
|
+
].join('');
|
|
56
|
+
columnsEl.appendChild(row);
|
|
57
|
+
|
|
58
|
+
const fkTableSel = row.querySelector('.col-fk-table');
|
|
59
|
+
const fkColSel = row.querySelector('.col-fk-col');
|
|
60
|
+
fkTableSel.addEventListener('change', () => {
|
|
61
|
+
fkColSel.innerHTML = '<option value="">—</option>';
|
|
62
|
+
const target = fkTableSel.value;
|
|
63
|
+
if (!target) return;
|
|
64
|
+
loadColumns(target)
|
|
65
|
+
.then((cols) => {
|
|
66
|
+
fkColSel.innerHTML = '<option value="">—</option>' + cols.map((c) => '<option>' + escapeHtml(c) + '</option>').join('');
|
|
67
|
+
})
|
|
68
|
+
.catch(() => {});
|
|
69
|
+
});
|
|
70
|
+
row.querySelector('.remove-col').addEventListener('click', () => row.remove());
|
|
71
|
+
|
|
72
|
+
row.querySelectorAll('input, select').forEach((el) => {
|
|
73
|
+
el.addEventListener('input', schedulePreview);
|
|
74
|
+
el.addEventListener('change', schedulePreview);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collectColumns() {
|
|
79
|
+
const cols = [];
|
|
80
|
+
document.querySelectorAll('#columns > div').forEach((row) => {
|
|
81
|
+
const name = row.querySelector('.col-name').value.trim();
|
|
82
|
+
if (!name) return;
|
|
83
|
+
const fkTable = row.querySelector('.col-fk-table').value;
|
|
84
|
+
const fkCol = row.querySelector('.col-fk-col').value;
|
|
85
|
+
cols.push({
|
|
86
|
+
name,
|
|
87
|
+
type: row.querySelector('.col-type').value,
|
|
88
|
+
primaryKey: row.querySelector('.col-pk').checked,
|
|
89
|
+
notNull: row.querySelector('.col-notnull').checked,
|
|
90
|
+
unique: row.querySelector('.col-unique').checked,
|
|
91
|
+
defaultValue: row.querySelector('.col-default').value.trim() || null,
|
|
92
|
+
foreignKey: fkTable && fkCol ? { table: fkTable, column: fkCol } : null,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return cols;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validate() {
|
|
99
|
+
const name = tableNameEl.value.trim();
|
|
100
|
+
const cols = collectColumns();
|
|
101
|
+
if (!name) return 'Table name is required.';
|
|
102
|
+
if (!NAME_RE.test(name)) return 'Invalid table name: "' + name + '". Use letters, digits and underscores.';
|
|
103
|
+
if (!cols.length) return 'Add at least one column.';
|
|
104
|
+
let pkCount = 0;
|
|
105
|
+
const seen = {};
|
|
106
|
+
for (const c of cols) {
|
|
107
|
+
if (!NAME_RE.test(c.name)) return 'Invalid column name: "' + c.name + '".';
|
|
108
|
+
if (seen[c.name]) return 'Duplicate column name: "' + c.name + '".';
|
|
109
|
+
seen[c.name] = true;
|
|
110
|
+
if (c.primaryKey) pkCount += 1;
|
|
111
|
+
if (c.foreignKey && (!c.foreignKey.table || !c.foreignKey.column)) {
|
|
112
|
+
return 'Column "' + c.name + '" has an incomplete foreign key (choose both table and column).';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (pkCount > 1) return 'At most one column can be a primary key.';
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function showError(msg) {
|
|
120
|
+
errorBox.classList.remove('hidden');
|
|
121
|
+
errorBox.innerHTML = '<div class="alert alert-error shadow-lg"><span>' + escapeHtml(msg) + '</span></div>';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function schedulePreview() {
|
|
125
|
+
clearTimeout(previewTimer);
|
|
126
|
+
previewTimer = setTimeout(runPreview, 250);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runPreview() {
|
|
130
|
+
const err = validate();
|
|
131
|
+
if (err) {
|
|
132
|
+
errorBox.classList.add('hidden');
|
|
133
|
+
previewBox.classList.add('hidden');
|
|
134
|
+
previewPlaceholder.classList.remove('hidden');
|
|
135
|
+
if (previewStatus) previewStatus.textContent = 'fix validation errors…';
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
errorBox.classList.add('hidden');
|
|
139
|
+
try {
|
|
140
|
+
const data = await Api.post('/api/tables/generate', {
|
|
141
|
+
name: tableNameEl.value.trim(),
|
|
142
|
+
columns: collectColumns(),
|
|
143
|
+
});
|
|
144
|
+
previewCode.textContent = data.sql;
|
|
145
|
+
previewBox.classList.remove('hidden');
|
|
146
|
+
previewPlaceholder.classList.add('hidden');
|
|
147
|
+
if (previewStatus) {
|
|
148
|
+
previewStatus.textContent = 'live preview · not executed';
|
|
149
|
+
previewStatus.className = 'text-[11px] font-medium text-accent';
|
|
150
|
+
}
|
|
151
|
+
} catch (e) {
|
|
152
|
+
previewBox.classList.add('hidden');
|
|
153
|
+
previewPlaceholder.classList.remove('hidden');
|
|
154
|
+
showError(e.message);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function submit() {
|
|
159
|
+
errorBox.classList.add('hidden');
|
|
160
|
+
const err = validate();
|
|
161
|
+
if (err) {
|
|
162
|
+
showError(err);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const body = { name: tableNameEl.value.trim(), columns: collectColumns() };
|
|
166
|
+
try {
|
|
167
|
+
const data = await Api.post('/api/tables', body);
|
|
168
|
+
UI.showToast(data.message || 'Table created.', 'success');
|
|
169
|
+
setTimeout(() => {
|
|
170
|
+
window.location.href = Api.basePath + '/tables/' + encodeURIComponent(body.name);
|
|
171
|
+
}, 500);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
showError(e.message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function init() {
|
|
178
|
+
try {
|
|
179
|
+
const tables = await Api.get('/api/tables');
|
|
180
|
+
fkTables = tables.map((t) => t.name).filter((n) => n.charAt(0) !== '_');
|
|
181
|
+
} catch (e) {
|
|
182
|
+
fkTables = [];
|
|
183
|
+
}
|
|
184
|
+
addColumnRow();
|
|
185
|
+
addColumnRow();
|
|
186
|
+
tableNameEl.addEventListener('input', schedulePreview);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (addBtn) addBtn.addEventListener('click', addColumnRow);
|
|
190
|
+
if (createBtn) createBtn.addEventListener('click', submit);
|
|
191
|
+
if (copyBtn) {
|
|
192
|
+
copyBtn.addEventListener('click', () => {
|
|
193
|
+
const text = previewCode.textContent || '';
|
|
194
|
+
if (!text) {
|
|
195
|
+
UI.showError('Nothing to copy yet.');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
navigator.clipboard.writeText(text)
|
|
199
|
+
.then(() => UI.showToast('SQL copied to clipboard.', 'success'))
|
|
200
|
+
.catch(() => UI.showError('Could not copy.'));
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
init();
|
|
205
|
+
})();
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/* Dynamic insert/edit row form.
|
|
2
|
+
* The form is built entirely from column metadata returned by the API, so the
|
|
3
|
+
* same page handles every table. Supports run mode and "get query" preview. */
|
|
4
|
+
(function () {
|
|
5
|
+
'use strict';
|
|
6
|
+
const cfgEl = document.getElementById('form-config');
|
|
7
|
+
if (!cfgEl) return;
|
|
8
|
+
|
|
9
|
+
const cfg = JSON.parse(cfgEl.textContent);
|
|
10
|
+
const form = document.getElementById('row-fields');
|
|
11
|
+
const errorBox = document.getElementById('form-error');
|
|
12
|
+
const sqlBox = document.getElementById('generated-sql');
|
|
13
|
+
const sqlCode = document.getElementById('generated-sql-code');
|
|
14
|
+
const submitBtn = document.getElementById('row-submit');
|
|
15
|
+
const generateBtn = document.getElementById('row-generate');
|
|
16
|
+
const copyBtn = document.getElementById('copy-sql');
|
|
17
|
+
const t = encodeURIComponent(cfg.table);
|
|
18
|
+
|
|
19
|
+
let info = null;
|
|
20
|
+
let record = null;
|
|
21
|
+
let fkOptions = {};
|
|
22
|
+
|
|
23
|
+
function escapeHtml(s) {
|
|
24
|
+
return (window.UI && UI.escapeHtml) ? UI.escapeHtml(s) : String(s == null ? '' : s);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toLocalInput(v) {
|
|
28
|
+
const m = String(v).match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})/);
|
|
29
|
+
if (m) return m[1] + 'T' + m[2] + ':' + m[3];
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toLocalNow() {
|
|
34
|
+
const d = new Date();
|
|
35
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
36
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + 'T' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert a column's raw default expression (dflt_value) into a form-friendly
|
|
41
|
+
* value. Handles literals we can read safely; anything complex returns null
|
|
42
|
+
* (the DB applies the real default when the field is left empty).
|
|
43
|
+
*/
|
|
44
|
+
function defaultToValue(col, dflt) {
|
|
45
|
+
if (dflt === null || dflt === undefined) return null;
|
|
46
|
+
const s = String(dflt).trim();
|
|
47
|
+
if (s === '' || /^null$/i.test(s)) return null;
|
|
48
|
+
|
|
49
|
+
const type = (col.type || '').toUpperCase();
|
|
50
|
+
|
|
51
|
+
// Single-quoted string literal, e.g. 'N/A', 'user'
|
|
52
|
+
const str = s.match(/^'(.*)'$/s);
|
|
53
|
+
if (str) return str[1].replace(/''/g, "'");
|
|
54
|
+
|
|
55
|
+
// Numeric literal, e.g. 0, 42, 3.14
|
|
56
|
+
if (/^[-+]?\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
57
|
+
|
|
58
|
+
// Boolean literal
|
|
59
|
+
if (/^(true|false)$/i.test(s)) return /^true$/i.test(s);
|
|
60
|
+
|
|
61
|
+
// BOOLEAN columns stored as 0/1
|
|
62
|
+
if (type === 'BOOLEAN') {
|
|
63
|
+
if (s === '1' || /^true$/i.test(s)) return 1;
|
|
64
|
+
if (s === '0' || /^false$/i.test(s)) return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// current-timestamp style defaults → "now" for date/datetime columns
|
|
68
|
+
if ((type === 'DATE' || type === 'DATETIME') && /current_timestamp|now\s*\(|datetime\s*\(|date\s*\(/i.test(s)) {
|
|
69
|
+
return toLocalNow();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Value to pre-fill a control with: edit → the record's value; insert → the column default. */
|
|
76
|
+
function initialValue(col) {
|
|
77
|
+
if (cfg.mode === 'edit' && record) return record[col.name];
|
|
78
|
+
if (cfg.mode === 'insert') return defaultToValue(col, col.dflt_value);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTruthy(v) {
|
|
83
|
+
return v === 1 || v === true || v === '1' || v === 'true';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderField(col) {
|
|
87
|
+
const isPk = info.primaryKey.indexOf(col.name) !== -1;
|
|
88
|
+
const wrapper = document.createElement('div');
|
|
89
|
+
wrapper.className = 'space-y-1.5';
|
|
90
|
+
|
|
91
|
+
const label = document.createElement('label');
|
|
92
|
+
label.className = 'field-label';
|
|
93
|
+
label.innerHTML =
|
|
94
|
+
'<span>' + escapeHtml(col.name) + '</span>' +
|
|
95
|
+
(col.notnull ? '<span class="text-error">*</span>' : '') +
|
|
96
|
+
(isPk ? '<span class="chip chip-pk">PK</span>' : '') +
|
|
97
|
+
(col.type ? '<span class="chip chip-type">' + escapeHtml(col.type) + '</span>' : '') +
|
|
98
|
+
(cfg.mode === 'insert' && col.dflt_value ? '<span class="chip chip-type">default</span>' : '');
|
|
99
|
+
wrapper.appendChild(label);
|
|
100
|
+
|
|
101
|
+
const fk = info.foreignKeys.find((f) => f.from === col.name);
|
|
102
|
+
const type = (col.type || '').toUpperCase();
|
|
103
|
+
const isBool = type === 'BOOLEAN';
|
|
104
|
+
const isNum = type.startsWith('INTEGER') || type === 'REAL';
|
|
105
|
+
const isBlob = type === 'BLOB';
|
|
106
|
+
const isDate = type === 'DATE' || type === 'DATETIME';
|
|
107
|
+
const iv = initialValue(col);
|
|
108
|
+
|
|
109
|
+
let control;
|
|
110
|
+
if (isBool) {
|
|
111
|
+
const boolWrap = document.createElement('label');
|
|
112
|
+
boolWrap.className = 'flex cursor-pointer items-center gap-2';
|
|
113
|
+
control = document.createElement('input');
|
|
114
|
+
control.type = 'checkbox';
|
|
115
|
+
control.className = 'field-check';
|
|
116
|
+
control.dataset.column = col.name;
|
|
117
|
+
if (isTruthy(iv)) control.checked = true;
|
|
118
|
+
const span = document.createElement('span');
|
|
119
|
+
span.className = 'text-sm text-base-content/60';
|
|
120
|
+
span.textContent = 'Enabled';
|
|
121
|
+
boolWrap.appendChild(control);
|
|
122
|
+
boolWrap.appendChild(span);
|
|
123
|
+
wrapper.appendChild(boolWrap);
|
|
124
|
+
return wrapper;
|
|
125
|
+
} else if (fk) {
|
|
126
|
+
control = document.createElement('select');
|
|
127
|
+
control.className = 'field-select';
|
|
128
|
+
control.dataset.column = col.name;
|
|
129
|
+
const opts = fkOptions[col.name] || [];
|
|
130
|
+
const empty = document.createElement('option');
|
|
131
|
+
empty.value = '';
|
|
132
|
+
empty.textContent = '(not set)';
|
|
133
|
+
control.appendChild(empty);
|
|
134
|
+
opts.forEach((o) => {
|
|
135
|
+
const opt = document.createElement('option');
|
|
136
|
+
opt.value = String(o.value);
|
|
137
|
+
opt.textContent = o.label === '' ? String(o.value) : o.label;
|
|
138
|
+
if (iv != null && String(iv) === String(o.value)) opt.selected = true;
|
|
139
|
+
control.appendChild(opt);
|
|
140
|
+
});
|
|
141
|
+
} else if (isDate) {
|
|
142
|
+
control = document.createElement('input');
|
|
143
|
+
control.type = 'datetime-local';
|
|
144
|
+
control.className = 'field-input';
|
|
145
|
+
control.dataset.column = col.name;
|
|
146
|
+
if (iv != null) control.value = toLocalInput(String(iv));
|
|
147
|
+
} else if (isNum) {
|
|
148
|
+
control = document.createElement('input');
|
|
149
|
+
control.type = 'number';
|
|
150
|
+
control.step = type === 'REAL' ? 'any' : '1';
|
|
151
|
+
control.className = 'field-input';
|
|
152
|
+
control.dataset.column = col.name;
|
|
153
|
+
if (iv != null) control.value = String(iv);
|
|
154
|
+
} else {
|
|
155
|
+
control = document.createElement('input');
|
|
156
|
+
control.type = 'text';
|
|
157
|
+
control.className = 'field-input';
|
|
158
|
+
control.placeholder = isBlob ? '0x… hex or text' : '';
|
|
159
|
+
control.dataset.column = col.name;
|
|
160
|
+
if (iv != null) control.value = String(iv);
|
|
161
|
+
}
|
|
162
|
+
if (isPk && cfg.mode === 'edit') control.disabled = true;
|
|
163
|
+
wrapper.appendChild(control);
|
|
164
|
+
return wrapper;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function render() {
|
|
168
|
+
form.innerHTML = '';
|
|
169
|
+
info.columns.forEach((col) => form.appendChild(renderField(col)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function collect() {
|
|
173
|
+
const values = {};
|
|
174
|
+
form.querySelectorAll('[data-column]').forEach((el) => {
|
|
175
|
+
const col = el.dataset.column;
|
|
176
|
+
if (el.type === 'checkbox') values[col] = el.checked ? 'true' : '';
|
|
177
|
+
else values[col] = el.value;
|
|
178
|
+
});
|
|
179
|
+
return values;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function showSql(sql) {
|
|
183
|
+
sqlCode.textContent = sql;
|
|
184
|
+
sqlBox.classList.remove('hidden');
|
|
185
|
+
sqlBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function submit(runMode) {
|
|
189
|
+
errorBox.classList.add('hidden');
|
|
190
|
+
const values = collect();
|
|
191
|
+
const edit = cfg.mode === 'edit';
|
|
192
|
+
let path;
|
|
193
|
+
if (edit) {
|
|
194
|
+
path = '/api/tables/' + t + '/row/' + encodeURIComponent(cfg.pk) + (runMode ? '' : '/generate');
|
|
195
|
+
} else {
|
|
196
|
+
path = '/api/tables/' + t + '/rows' + (runMode ? '' : '/generate');
|
|
197
|
+
}
|
|
198
|
+
const method = edit ? 'PUT' : 'POST';
|
|
199
|
+
try {
|
|
200
|
+
const data = await Api.request(method, path, { values });
|
|
201
|
+
if (runMode) {
|
|
202
|
+
UI.showToast(data.message || 'Saved.', 'success');
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
window.location.href = Api.basePath + '/tables/' + encodeURIComponent(cfg.table);
|
|
205
|
+
}, 500);
|
|
206
|
+
} else {
|
|
207
|
+
showSql(data.sql);
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
errorBox.classList.remove('hidden');
|
|
211
|
+
errorBox.innerHTML = '<div class="flex items-start gap-2.5 rounded-xl border border-error/40 bg-error/10 px-4 py-3 text-sm font-medium text-error"><span>' + escapeHtml(e.message) + '</span></div>';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function init() {
|
|
216
|
+
try {
|
|
217
|
+
info = await Api.get('/api/tables/' + t + '/info');
|
|
218
|
+
if (cfg.mode === 'edit') {
|
|
219
|
+
record = await Api.get('/api/tables/' + t + '/row/' + encodeURIComponent(cfg.pk));
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
fkOptions = await Api.get('/api/tables/' + t + '/fk-options');
|
|
223
|
+
} catch (e) {
|
|
224
|
+
fkOptions = {};
|
|
225
|
+
}
|
|
226
|
+
render();
|
|
227
|
+
} catch (e) {
|
|
228
|
+
errorBox.classList.remove('hidden');
|
|
229
|
+
errorBox.innerHTML = '<div class="flex items-start gap-2.5 rounded-xl border border-error/40 bg-error/10 px-4 py-3 text-sm font-medium text-error"><span>' + escapeHtml(e.message) + '</span></div>';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (submitBtn) submitBtn.addEventListener('click', () => submit(true));
|
|
234
|
+
if (generateBtn) generateBtn.addEventListener('click', () => submit(false));
|
|
235
|
+
if (copyBtn) {
|
|
236
|
+
copyBtn.addEventListener('click', () => {
|
|
237
|
+
navigator.clipboard.writeText(sqlCode.textContent)
|
|
238
|
+
.then(() => UI.showToast('Copied to clipboard.', 'success'))
|
|
239
|
+
.catch(() => UI.showError('Could not copy.'));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
init();
|
|
244
|
+
})();
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/* Query editor: run arbitrary SQL, save/load named queries, render results. */
|
|
2
|
+
(function () {
|
|
3
|
+
'use strict';
|
|
4
|
+
const editor = document.getElementById('query-sql');
|
|
5
|
+
const gutter = document.getElementById('query-gutter');
|
|
6
|
+
const runBtn = document.getElementById('run-query');
|
|
7
|
+
const messageEl = document.getElementById('query-message');
|
|
8
|
+
const resultEl = document.getElementById('query-result');
|
|
9
|
+
const headEl = document.getElementById('query-result-head');
|
|
10
|
+
const bodyEl = document.getElementById('query-result-body');
|
|
11
|
+
const resultSummary = document.getElementById('result-summary');
|
|
12
|
+
const saveBtns = Array.prototype.slice.call(document.querySelectorAll('#save-query, #save-query-inline'));
|
|
13
|
+
const saveName = document.getElementById('save-query-name');
|
|
14
|
+
const selectEl = document.getElementById('saved-query-select');
|
|
15
|
+
const deleteBtn = document.getElementById('delete-query');
|
|
16
|
+
const exportCsvBtn = document.getElementById('export-csv');
|
|
17
|
+
const exportJsonBtn = document.getElementById('export-json');
|
|
18
|
+
|
|
19
|
+
let queriesCache = [];
|
|
20
|
+
|
|
21
|
+
function setExportEnabled(enabled) {
|
|
22
|
+
if (exportCsvBtn) exportCsvBtn.disabled = !enabled;
|
|
23
|
+
if (exportJsonBtn) exportJsonBtn.disabled = !enabled;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---- Export results ------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
async function exportResults(format) {
|
|
29
|
+
const sql = editor.value.trim();
|
|
30
|
+
if (!sql) return;
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(Api.url('/api/query/export'), {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ sql, format }),
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
let msg = 'Export failed (' + res.status + ').';
|
|
39
|
+
try {
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
if (data && data.error) msg = data.error;
|
|
42
|
+
} catch (e) { /* ignore */ }
|
|
43
|
+
throw new Error(msg);
|
|
44
|
+
}
|
|
45
|
+
const blob = await res.blob();
|
|
46
|
+
const url = URL.createObjectURL(blob);
|
|
47
|
+
const a = document.createElement('a');
|
|
48
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
49
|
+
a.href = url;
|
|
50
|
+
a.download = 'query-' + stamp + '.' + format;
|
|
51
|
+
document.body.appendChild(a);
|
|
52
|
+
a.click();
|
|
53
|
+
a.remove();
|
|
54
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
55
|
+
UI.showToast('Exported as ' + format.toUpperCase() + '.', 'success');
|
|
56
|
+
} catch (e) {
|
|
57
|
+
setMessage(e.message, 'error');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (exportCsvBtn) exportCsvBtn.addEventListener('click', () => exportResults('csv'));
|
|
62
|
+
if (exportJsonBtn) exportJsonBtn.addEventListener('click', () => exportResults('json'));
|
|
63
|
+
|
|
64
|
+
function escapeHtml(s) {
|
|
65
|
+
return (window.UI && UI.escapeHtml) ? UI.escapeHtml(s) : String(s == null ? '' : s);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setMessage(text, type) {
|
|
69
|
+
if (!text) {
|
|
70
|
+
messageEl.innerHTML = '';
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const palette = {
|
|
74
|
+
error: 'border-error/40 bg-error/10 text-error',
|
|
75
|
+
success: 'border-success/40 bg-success/10 text-success',
|
|
76
|
+
info: 'border-info/40 bg-info/10 text-info',
|
|
77
|
+
warning: 'border-warning/40 bg-warning/10 text-warning',
|
|
78
|
+
};
|
|
79
|
+
messageEl.innerHTML = '<div class="flex items-start gap-2.5 rounded-xl border px-4 py-3 text-sm font-medium ' +
|
|
80
|
+
(palette[type] || palette.info) + '"><span>' + escapeHtml(text) + '</span></div>';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderTable(columns, rows) {
|
|
84
|
+
headEl.innerHTML = '<tr>' + columns.map((c) => '<th class="whitespace-nowrap font-semibold"><span class="font-mono normal-case">' + escapeHtml(c) + '</span></th>').join('') + '</tr>';
|
|
85
|
+
if (!rows.length) {
|
|
86
|
+
bodyEl.innerHTML = '<tr><td colspan="' + Math.max(1, columns.length) + '" class="py-10 text-center text-base-content/45">No rows returned.</td></tr>';
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
bodyEl.innerHTML = rows.map((row) =>
|
|
90
|
+
'<tr class="hover:bg-primary/[0.04]">' + columns.map((c) => {
|
|
91
|
+
const v = row[c];
|
|
92
|
+
if (v === null || v === undefined) {
|
|
93
|
+
return '<td class="align-middle"><span class="chip chip-null">NULL</span></td>';
|
|
94
|
+
}
|
|
95
|
+
const s = String(v);
|
|
96
|
+
const short = s.length > 200 ? s.slice(0, 200) + '…' : s;
|
|
97
|
+
return '<td class="max-w-xs truncate align-middle font-mono text-[13px]" title="' + escapeHtml(s) + '">' + escapeHtml(short) + '</td>';
|
|
98
|
+
}).join('') + '</tr>',
|
|
99
|
+
).join('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderMessageRow(text, kindLabel) {
|
|
103
|
+
headEl.innerHTML = '';
|
|
104
|
+
bodyEl.innerHTML = '<tr><td class="px-4 py-8 text-center"><p class="text-2xl font-extrabold text-primary">' +
|
|
105
|
+
escapeHtml(text) + '</p><p class="mt-1 text-sm text-base-content/45">' + escapeHtml(kindLabel) + '</p></td></tr>';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function setSummary(text) {
|
|
109
|
+
if (resultSummary) resultSummary.textContent = text;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function run() {
|
|
113
|
+
const sql = editor.value.trim();
|
|
114
|
+
if (!sql) {
|
|
115
|
+
setMessage('Enter a SQL statement first.', 'warning');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
messageEl.innerHTML = '';
|
|
119
|
+
resultEl.classList.add('hidden');
|
|
120
|
+
setExportEnabled(false);
|
|
121
|
+
const started = performance.now();
|
|
122
|
+
try {
|
|
123
|
+
const data = await Api.post('/api/query', { sql });
|
|
124
|
+
const elapsed = Math.max(1, Math.round(performance.now() - started));
|
|
125
|
+
resultEl.classList.remove('hidden');
|
|
126
|
+
if (data.kind === 'count') {
|
|
127
|
+
renderMessageRow(String(data.count), 'count');
|
|
128
|
+
setSummary('Count query · ' + elapsed + 'ms');
|
|
129
|
+
} else if (data.kind === 'select') {
|
|
130
|
+
renderTable(data.columns, data.rows);
|
|
131
|
+
setSummary(data.rows.length + ' row(s) · ' + elapsed + 'ms');
|
|
132
|
+
setExportEnabled(true);
|
|
133
|
+
} else {
|
|
134
|
+
renderMessageRow(data.message || 'Statement executed.', 'write');
|
|
135
|
+
setSummary(data.changes != null ? data.changes + ' row(s) affected · ' + elapsed + 'ms' : elapsed + 'ms');
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
resultEl.classList.add('hidden');
|
|
139
|
+
setMessage(e.message, 'error');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- Line numbers ------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function updateGutter() {
|
|
146
|
+
if (!gutter) return;
|
|
147
|
+
const lines = editor.value.split('\n').length;
|
|
148
|
+
let html = '';
|
|
149
|
+
for (let i = 1; i <= lines; i++) html += i + '\n';
|
|
150
|
+
gutter.textContent = html;
|
|
151
|
+
gutter.scrollTop = editor.scrollTop;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (editor && gutter) {
|
|
155
|
+
editor.addEventListener('scroll', () => {
|
|
156
|
+
gutter.scrollTop = editor.scrollTop;
|
|
157
|
+
});
|
|
158
|
+
editor.addEventListener('input', updateGutter);
|
|
159
|
+
editor.addEventListener('keydown', (e) => {
|
|
160
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
run();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
updateGutter();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- Saved queries -----------------------------------------------------
|
|
169
|
+
|
|
170
|
+
async function refreshQueries(selectName) {
|
|
171
|
+
try {
|
|
172
|
+
queriesCache = await Api.get('/api/queries');
|
|
173
|
+
} catch (e) {
|
|
174
|
+
queriesCache = [];
|
|
175
|
+
}
|
|
176
|
+
selectEl.innerHTML = '<option value="">— select a saved query —</option>' + queriesCache.map((q) =>
|
|
177
|
+
'<option value="' + q.id + '"' + (selectName && q.name === selectName ? ' selected' : '') + '>' + escapeHtml(q.name) + '</option>',
|
|
178
|
+
).join('');
|
|
179
|
+
deleteBtn.disabled = true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function saveQuery() {
|
|
183
|
+
const sql = editor.value.trim();
|
|
184
|
+
const name = saveName.value.trim();
|
|
185
|
+
if (!sql) {
|
|
186
|
+
setMessage('Write some SQL before saving.', 'warning');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!name) {
|
|
190
|
+
setMessage('Give the query a name.', 'warning');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
await Api.post('/api/queries', { name, sql });
|
|
195
|
+
UI.showToast('Query saved.', 'success');
|
|
196
|
+
await refreshQueries(name);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
setMessage(e.message, 'error');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function deleteQuery() {
|
|
203
|
+
const id = selectEl.value;
|
|
204
|
+
if (!id) return;
|
|
205
|
+
if (!window.confirm('Delete this saved query?')) return;
|
|
206
|
+
try {
|
|
207
|
+
await Api.del('/api/queries/' + id);
|
|
208
|
+
UI.showToast('Query deleted.', 'success');
|
|
209
|
+
await refreshQueries();
|
|
210
|
+
} catch (e) {
|
|
211
|
+
setMessage(e.message, 'error');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (runBtn) runBtn.addEventListener('click', run);
|
|
216
|
+
saveBtns.forEach((btn) => btn.addEventListener('click', saveQuery));
|
|
217
|
+
if (selectEl) {
|
|
218
|
+
selectEl.addEventListener('change', () => {
|
|
219
|
+
const id = selectEl.value;
|
|
220
|
+
deleteBtn.disabled = !id;
|
|
221
|
+
if (!id) return;
|
|
222
|
+
const q = queriesCache.find((x) => String(x.id) === String(id));
|
|
223
|
+
if (q) {
|
|
224
|
+
editor.value = q.sql;
|
|
225
|
+
saveName.value = q.name;
|
|
226
|
+
updateGutter();
|
|
227
|
+
setMessage('Loaded "' + q.name + '".', 'info');
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (deleteBtn) deleteBtn.addEventListener('click', deleteQuery);
|
|
232
|
+
})();
|