@duckcodeailabs/dql-cli 0.1.1 → 0.1.4
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/dist/assets/notebook-browser/app.js +548 -0
- package/dist/assets/notebook-browser/index.html +83 -0
- package/dist/assets/notebook-browser/styles.css +336 -0
- package/dist/assets/templates/ecommerce/README.md +27 -0
- package/dist/assets/templates/ecommerce/blocks/repeat_rate.dql +26 -0
- package/dist/assets/templates/ecommerce/blocks/revenue_by_segment.dql +24 -0
- package/dist/assets/templates/ecommerce/dashboards/revenue_command_center.dql +25 -0
- package/dist/assets/templates/ecommerce/data/customers.csv +11 -0
- package/dist/assets/templates/ecommerce/data/funnel.csv +6 -0
- package/dist/assets/templates/ecommerce/data/orders.csv +16 -0
- package/dist/assets/templates/ecommerce/dql.config.json +13 -0
- package/dist/assets/templates/ecommerce/semantic-layer/dimensions/channel.yaml +4 -0
- package/dist/assets/templates/ecommerce/semantic-layer/metrics/gmv.yaml +4 -0
- package/dist/assets/templates/ecommerce/workbooks/.gitkeep +1 -0
- package/dist/assets/templates/saas/README.md +20 -0
- package/dist/assets/templates/saas/blocks/churn_pressure.dql +25 -0
- package/dist/assets/templates/saas/blocks/revenue_by_segment.dql +25 -0
- package/dist/assets/templates/saas/dashboards/growth_scorecard.dql +25 -0
- package/dist/assets/templates/saas/data/cohorts.csv +7 -0
- package/dist/assets/templates/saas/data/subscriptions.csv +13 -0
- package/dist/assets/templates/saas/dql.config.json +13 -0
- package/dist/assets/templates/saas/semantic-layer/metrics/mrr.yaml +4 -0
- package/dist/assets/templates/saas/workbooks/.gitkeep +1 -0
- package/dist/assets/templates/starter/README.md +54 -0
- package/dist/assets/templates/starter/blocks/revenue_by_segment.dql +29 -0
- package/dist/assets/templates/starter/blocks/revenue_trend_query_only.dql +20 -0
- package/dist/assets/templates/starter/dashboards/.gitkeep +1 -0
- package/dist/assets/templates/starter/data/revenue.csv +13 -0
- package/dist/assets/templates/starter/dql.config.json +13 -0
- package/dist/assets/templates/starter/semantic-layer/blocks/revenue_by_segment.yaml +11 -0
- package/dist/assets/templates/starter/semantic-layer/dimensions/segment_tier.yaml +6 -0
- package/dist/assets/templates/starter/semantic-layer/hierarchies/revenue_time.yaml +12 -0
- package/dist/assets/templates/starter/semantic-layer/metrics/revenue.yaml +7 -0
- package/dist/assets/templates/starter/workbooks/.gitkeep +1 -0
- package/dist/assets/templates/taxi/README.md +19 -0
- package/dist/assets/templates/taxi/blocks/airport_mix.dql +25 -0
- package/dist/assets/templates/taxi/blocks/revenue_by_segment.dql +24 -0
- package/dist/assets/templates/taxi/dashboards/city_operations.dql +25 -0
- package/dist/assets/templates/taxi/data/trips.csv +13 -0
- package/dist/assets/templates/taxi/dql.config.json +13 -0
- package/dist/assets/templates/taxi/semantic-layer/.gitkeep +1 -0
- package/dist/assets/templates/taxi/workbooks/.gitkeep +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +3 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/notebook.d.ts.map +1 -1
- package/dist/commands/notebook.js +8 -2
- package/dist/commands/notebook.js.map +1 -1
- package/dist/local-runtime.d.ts +6 -0
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +65 -9
- package/dist/local-runtime.js.map +1 -1
- package/dist/local-runtime.test.js +24 -1
- package/dist/local-runtime.test.js.map +1 -1
- package/package.json +8 -8
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
const state = {
|
|
2
|
+
bootstrap: null,
|
|
3
|
+
notebook: null,
|
|
4
|
+
results: new Map(),
|
|
5
|
+
connectionForms: [],
|
|
6
|
+
draftConnection: null,
|
|
7
|
+
activeConnection: null,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const elements = {
|
|
11
|
+
projectName: document.getElementById('project-name'),
|
|
12
|
+
notebookTitle: document.getElementById('notebook-title'),
|
|
13
|
+
fileList: document.getElementById('file-list'),
|
|
14
|
+
cells: document.getElementById('cells'),
|
|
15
|
+
template: document.getElementById('cell-template'),
|
|
16
|
+
driverSelect: document.getElementById('driver-select'),
|
|
17
|
+
connectionFields: document.getElementById('connection-fields'),
|
|
18
|
+
connectionSummary: document.getElementById('connection-summary'),
|
|
19
|
+
connectionStatus: document.getElementById('connection-status'),
|
|
20
|
+
runAll: document.getElementById('run-all'),
|
|
21
|
+
exportNotebook: document.getElementById('export-notebook'),
|
|
22
|
+
saveConnection: document.getElementById('save-connection'),
|
|
23
|
+
testConnection: document.getElementById('test-connection'),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
await bootstrap();
|
|
27
|
+
|
|
28
|
+
async function bootstrap() {
|
|
29
|
+
const response = await fetch('/api/notebook/bootstrap');
|
|
30
|
+
const payload = await response.json();
|
|
31
|
+
state.bootstrap = payload;
|
|
32
|
+
state.notebook = payload.notebook;
|
|
33
|
+
state.connectionForms = payload.connectorForms;
|
|
34
|
+
state.activeConnection = loadStoredConnection() || payload.defaultConnection;
|
|
35
|
+
state.draftConnection = { ...state.activeConnection };
|
|
36
|
+
|
|
37
|
+
elements.projectName.textContent = payload.project;
|
|
38
|
+
elements.notebookTitle.textContent = payload.notebook.metadata.title;
|
|
39
|
+
|
|
40
|
+
renderFiles(payload.files);
|
|
41
|
+
renderConnectionForm();
|
|
42
|
+
renderConnectionSummary();
|
|
43
|
+
renderCells();
|
|
44
|
+
|
|
45
|
+
document.querySelectorAll('[data-add]').forEach((button) => {
|
|
46
|
+
button.addEventListener('click', () => addCell(button.getAttribute('data-add')));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
elements.runAll.addEventListener('click', runAllCells);
|
|
50
|
+
elements.exportNotebook.addEventListener('click', exportNotebook);
|
|
51
|
+
elements.driverSelect.addEventListener('change', onDriverChange);
|
|
52
|
+
elements.saveConnection.addEventListener('click', saveDraftConnection);
|
|
53
|
+
elements.testConnection.addEventListener('click', testDraftConnection);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderFiles(files) {
|
|
57
|
+
elements.fileList.innerHTML = '';
|
|
58
|
+
files.forEach((file) => {
|
|
59
|
+
const li = document.createElement('li');
|
|
60
|
+
const link = document.createElement('a');
|
|
61
|
+
link.href = `/api/notebook/file?path=${encodeURIComponent(file)}`;
|
|
62
|
+
link.target = '_blank';
|
|
63
|
+
link.rel = 'noreferrer';
|
|
64
|
+
link.textContent = file;
|
|
65
|
+
link.title = `Open ${file}`;
|
|
66
|
+
li.appendChild(link);
|
|
67
|
+
elements.fileList.appendChild(li);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderConnectionSummary() {
|
|
72
|
+
const connection = state.activeConnection || {};
|
|
73
|
+
elements.connectionSummary.innerHTML = `
|
|
74
|
+
<strong>${connection.driver || 'file'}</strong>
|
|
75
|
+
<div class="field-help">${connection.host || connection.filepath || connection.database || ':memory:'}</div>
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderConnectionForm() {
|
|
80
|
+
elements.driverSelect.innerHTML = '';
|
|
81
|
+
state.connectionForms.forEach((schema) => {
|
|
82
|
+
const option = document.createElement('option');
|
|
83
|
+
option.value = schema.driver;
|
|
84
|
+
option.textContent = schema.label;
|
|
85
|
+
if (schema.driver === state.draftConnection?.driver) {
|
|
86
|
+
option.selected = true;
|
|
87
|
+
}
|
|
88
|
+
elements.driverSelect.appendChild(option);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const schema = currentSchema();
|
|
92
|
+
if (!schema) return;
|
|
93
|
+
|
|
94
|
+
elements.connectionFields.innerHTML = '';
|
|
95
|
+
schema.fields.forEach((field) => {
|
|
96
|
+
const wrapper = document.createElement('div');
|
|
97
|
+
wrapper.className = 'field';
|
|
98
|
+
|
|
99
|
+
if (field.type === 'checkbox') {
|
|
100
|
+
wrapper.innerHTML = `
|
|
101
|
+
<label class="checkbox">
|
|
102
|
+
<input type="checkbox" data-connection-field="${field.key}" ${state.draftConnection?.[field.key] ? 'checked' : ''} />
|
|
103
|
+
<span>${field.label}</span>
|
|
104
|
+
</label>
|
|
105
|
+
`;
|
|
106
|
+
} else {
|
|
107
|
+
wrapper.innerHTML = `
|
|
108
|
+
<label class="field-label">${field.label}</label>
|
|
109
|
+
<input
|
|
110
|
+
type="${field.type}"
|
|
111
|
+
data-connection-field="${field.key}"
|
|
112
|
+
value="${escapeAttribute(state.draftConnection?.[field.key] ?? '')}"
|
|
113
|
+
placeholder="${escapeAttribute(field.placeholder || '')}"
|
|
114
|
+
/>
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
elements.connectionFields.appendChild(wrapper);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
elements.connectionFields.querySelectorAll('[data-connection-field]').forEach((input) => {
|
|
122
|
+
input.addEventListener('input', collectConnectionDraft);
|
|
123
|
+
input.addEventListener('change', collectConnectionDraft);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function currentSchema() {
|
|
128
|
+
return state.connectionForms.find((schema) => schema.driver === (state.draftConnection?.driver || state.activeConnection?.driver));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onDriverChange(event) {
|
|
132
|
+
state.draftConnection = { driver: event.target.value };
|
|
133
|
+
renderConnectionForm();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function collectConnectionDraft() {
|
|
137
|
+
const driver = elements.driverSelect.value;
|
|
138
|
+
const draft = { driver };
|
|
139
|
+
|
|
140
|
+
elements.connectionFields.querySelectorAll('[data-connection-field]').forEach((input) => {
|
|
141
|
+
const key = input.getAttribute('data-connection-field');
|
|
142
|
+
if (input.type === 'checkbox') {
|
|
143
|
+
draft[key] = input.checked;
|
|
144
|
+
} else if (input.type === 'number') {
|
|
145
|
+
draft[key] = input.value ? Number(input.value) : undefined;
|
|
146
|
+
} else {
|
|
147
|
+
draft[key] = input.value || undefined;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
state.draftConnection = draft;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function saveDraftConnection() {
|
|
155
|
+
collectConnectionDraft();
|
|
156
|
+
state.activeConnection = { ...state.draftConnection };
|
|
157
|
+
localStorage.setItem(connectionStorageKey(), JSON.stringify(state.activeConnection));
|
|
158
|
+
renderConnectionSummary();
|
|
159
|
+
setConnectionStatus('Saved local notebook connection.', 'ok');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function testDraftConnection() {
|
|
163
|
+
collectConnectionDraft();
|
|
164
|
+
setConnectionStatus('Testing connection…');
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetch('/api/test-connection', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({ connection: state.draftConnection }),
|
|
170
|
+
});
|
|
171
|
+
const payload = await response.json();
|
|
172
|
+
if (!response.ok || !payload.ok) {
|
|
173
|
+
throw new Error(payload.error || 'Connection test failed.');
|
|
174
|
+
}
|
|
175
|
+
setConnectionStatus('Connection test passed.', 'ok');
|
|
176
|
+
} catch (error) {
|
|
177
|
+
setConnectionStatus(error.message, 'error');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function setConnectionStatus(message, kind = '') {
|
|
182
|
+
elements.connectionStatus.textContent = message;
|
|
183
|
+
elements.connectionStatus.className = `status ${kind}`.trim();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderCells() {
|
|
187
|
+
elements.cells.innerHTML = '';
|
|
188
|
+
state.notebook.cells.forEach((cell, index) => {
|
|
189
|
+
const fragment = elements.template.content.cloneNode(true);
|
|
190
|
+
const root = fragment.querySelector('.cell');
|
|
191
|
+
const type = fragment.querySelector('.cell-type');
|
|
192
|
+
const title = fragment.querySelector('.cell-title');
|
|
193
|
+
const source = fragment.querySelector('.cell-source');
|
|
194
|
+
const output = fragment.querySelector('.cell-output');
|
|
195
|
+
const status = fragment.querySelector('.cell-status');
|
|
196
|
+
const markdownPreview = fragment.querySelector('.markdown-preview');
|
|
197
|
+
const chartEditor = fragment.querySelector('.chart-editor');
|
|
198
|
+
|
|
199
|
+
root.dataset.cellId = cell.id;
|
|
200
|
+
type.textContent = cell.type;
|
|
201
|
+
title.value = cell.title || '';
|
|
202
|
+
source.value = cell.source || '';
|
|
203
|
+
|
|
204
|
+
title.addEventListener('input', () => {
|
|
205
|
+
cell.title = title.value;
|
|
206
|
+
if (index === 0) {
|
|
207
|
+
state.notebook.metadata.title = title.value || state.notebook.metadata.title;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (cell.type === 'markdown') {
|
|
212
|
+
markdownPreview.classList.remove('hidden');
|
|
213
|
+
markdownPreview.innerHTML = renderMarkdown(cell.source);
|
|
214
|
+
source.addEventListener('input', () => {
|
|
215
|
+
cell.source = source.value;
|
|
216
|
+
markdownPreview.innerHTML = renderMarkdown(cell.source);
|
|
217
|
+
});
|
|
218
|
+
} else if (cell.type === 'chart') {
|
|
219
|
+
source.classList.add('hidden');
|
|
220
|
+
chartEditor.classList.remove('hidden');
|
|
221
|
+
renderChartEditor(chartEditor, cell);
|
|
222
|
+
} else {
|
|
223
|
+
source.addEventListener('input', () => {
|
|
224
|
+
cell.source = source.value;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const cached = state.results.get(cell.id);
|
|
229
|
+
if (cached) {
|
|
230
|
+
renderExecutionOutput(output, status, cell, cached);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fragment.querySelectorAll('[data-action]').forEach((button) => {
|
|
234
|
+
button.addEventListener('click', () => handleCellAction(cell.id, button.dataset.action));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
elements.cells.appendChild(fragment);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderChartEditor(container, cell) {
|
|
242
|
+
const config = cell.config || {};
|
|
243
|
+
const sqlLikeCells = state.notebook.cells.filter((candidate) => candidate.id !== cell.id && candidate.type !== 'markdown' && candidate.type !== 'chart');
|
|
244
|
+
container.innerHTML = `
|
|
245
|
+
<div>
|
|
246
|
+
<label class="field-label">Source cell</label>
|
|
247
|
+
<select data-chart-field="sourceCellId">
|
|
248
|
+
${sqlLikeCells.map((candidate) => `<option value="${candidate.id}" ${candidate.id === config.sourceCellId ? 'selected' : ''}>${escapeHtml(candidate.title || candidate.id)}</option>`).join('')}
|
|
249
|
+
</select>
|
|
250
|
+
</div>
|
|
251
|
+
<div>
|
|
252
|
+
<label class="field-label">Chart</label>
|
|
253
|
+
<select data-chart-field="chart">
|
|
254
|
+
${['bar', 'line', 'table', 'kpi'].map((chart) => `<option value="${chart}" ${chart === (config.chart || 'bar') ? 'selected' : ''}>${chart}</option>`).join('')}
|
|
255
|
+
</select>
|
|
256
|
+
</div>
|
|
257
|
+
<div>
|
|
258
|
+
<label class="field-label">X field</label>
|
|
259
|
+
<input data-chart-field="x" value="${escapeAttribute(config.x || '')}" />
|
|
260
|
+
</div>
|
|
261
|
+
<div>
|
|
262
|
+
<label class="field-label">Y field</label>
|
|
263
|
+
<input data-chart-field="y" value="${escapeAttribute(config.y || '')}" />
|
|
264
|
+
</div>
|
|
265
|
+
<div>
|
|
266
|
+
<label class="field-label">Title</label>
|
|
267
|
+
<input data-chart-field="title" value="${escapeAttribute(config.title || '')}" />
|
|
268
|
+
</div>
|
|
269
|
+
`;
|
|
270
|
+
|
|
271
|
+
container.querySelectorAll('[data-chart-field]').forEach((input) => {
|
|
272
|
+
input.addEventListener('input', () => {
|
|
273
|
+
cell.config = cell.config || {};
|
|
274
|
+
cell.config[input.dataset.chartField] = input.value;
|
|
275
|
+
renderLinkedChartCell(cell.id);
|
|
276
|
+
});
|
|
277
|
+
input.addEventListener('change', () => {
|
|
278
|
+
cell.config = cell.config || {};
|
|
279
|
+
cell.config[input.dataset.chartField] = input.value;
|
|
280
|
+
renderLinkedChartCell(cell.id);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function handleCellAction(cellId, action) {
|
|
286
|
+
const index = state.notebook.cells.findIndex((cell) => cell.id === cellId);
|
|
287
|
+
if (index === -1) return;
|
|
288
|
+
|
|
289
|
+
if (action === 'delete') {
|
|
290
|
+
state.notebook.cells.splice(index, 1);
|
|
291
|
+
} else if (action === 'up' && index > 0) {
|
|
292
|
+
swapCells(index, index - 1);
|
|
293
|
+
} else if (action === 'down' && index < state.notebook.cells.length - 1) {
|
|
294
|
+
swapCells(index, index + 1);
|
|
295
|
+
} else if (action === 'run') {
|
|
296
|
+
runCell(state.notebook.cells[index]);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
renderCells();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function swapCells(a, b) {
|
|
304
|
+
const temp = state.notebook.cells[a];
|
|
305
|
+
state.notebook.cells[a] = state.notebook.cells[b];
|
|
306
|
+
state.notebook.cells[b] = temp;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function addCell(type) {
|
|
310
|
+
const nextIndex = state.notebook.cells.length + 1;
|
|
311
|
+
state.notebook.cells.push({
|
|
312
|
+
id: `cell-${nextIndex}`,
|
|
313
|
+
type,
|
|
314
|
+
title: `${type.toUpperCase()} Cell`,
|
|
315
|
+
source: type === 'markdown' ? '## New note' : type === 'sql' ? 'SELECT 1 AS value' : type === 'dql' ? 'block "New Block" {\n domain = "general"\n type = "custom"\n query = """SELECT 1 AS value"""\n}' : '',
|
|
316
|
+
config: type === 'chart' ? { chart: 'bar' } : undefined,
|
|
317
|
+
});
|
|
318
|
+
renderCells();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function runAllCells() {
|
|
322
|
+
for (const cell of state.notebook.cells) {
|
|
323
|
+
if (cell.type === 'markdown') continue;
|
|
324
|
+
await runCell(cell);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function runCell(cell) {
|
|
329
|
+
if (cell.type === 'chart') {
|
|
330
|
+
renderLinkedChartCell(cell.id);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const card = document.querySelector(`[data-cell-id="${cell.id}"]`);
|
|
335
|
+
const status = card.querySelector('.cell-status');
|
|
336
|
+
const output = card.querySelector('.cell-output');
|
|
337
|
+
status.textContent = 'Running…';
|
|
338
|
+
try {
|
|
339
|
+
const response = await fetch('/api/notebook/execute', {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: { 'Content-Type': 'application/json' },
|
|
342
|
+
body: JSON.stringify({ cell, connection: state.activeConnection }),
|
|
343
|
+
});
|
|
344
|
+
const payload = await response.json();
|
|
345
|
+
if (!response.ok || payload.error) {
|
|
346
|
+
throw new Error(payload.error || 'Notebook execution failed.');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
state.results.set(cell.id, payload);
|
|
350
|
+
renderExecutionOutput(output, status, cell, payload);
|
|
351
|
+
rerenderDependentCharts(cell.id);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
status.textContent = error.message;
|
|
354
|
+
status.className = 'cell-status status error';
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function renderExecutionOutput(container, status, cell, payload) {
|
|
359
|
+
status.textContent = payload.result ? `${payload.result.rowCount} row${payload.result.rowCount === 1 ? '' : 's'}` : 'Ready';
|
|
360
|
+
status.className = 'cell-status status ok';
|
|
361
|
+
container.innerHTML = '';
|
|
362
|
+
|
|
363
|
+
if (!payload.result) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const chartConfig = payload.chartConfig || cell.config || { chart: 'table' };
|
|
368
|
+
if (cell.type === 'dql' && chartConfig.chart && chartConfig.chart !== 'table') {
|
|
369
|
+
container.appendChild(renderChart(payload.result.rows, chartConfig, payload.title));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
container.appendChild(renderTable(payload.result.rows));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function rerenderDependentCharts(sourceCellId) {
|
|
376
|
+
state.notebook.cells
|
|
377
|
+
.filter((cell) => cell.type === 'chart' && cell.config?.sourceCellId === sourceCellId)
|
|
378
|
+
.forEach((cell) => renderLinkedChartCell(cell.id));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function renderLinkedChartCell(cellId) {
|
|
382
|
+
const cell = state.notebook.cells.find((candidate) => candidate.id === cellId);
|
|
383
|
+
if (!cell) return;
|
|
384
|
+
const card = document.querySelector(`[data-cell-id="${cell.id}"]`);
|
|
385
|
+
const status = card.querySelector('.cell-status');
|
|
386
|
+
const output = card.querySelector('.cell-output');
|
|
387
|
+
output.innerHTML = '';
|
|
388
|
+
|
|
389
|
+
const sourceResult = state.results.get(cell.config?.sourceCellId || '');
|
|
390
|
+
if (!sourceResult?.result) {
|
|
391
|
+
status.textContent = 'Run the source SQL/DQL cell first.';
|
|
392
|
+
status.className = 'cell-status status';
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
status.textContent = `Linked to ${cell.config.sourceCellId}`;
|
|
397
|
+
status.className = 'cell-status status ok';
|
|
398
|
+
output.appendChild(renderChart(sourceResult.result.rows, cell.config || {}, cell.title || 'Chart'));
|
|
399
|
+
output.appendChild(renderTable(sourceResult.result.rows));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function renderChart(rows, config, title) {
|
|
403
|
+
const chart = (config.chart || 'table').toLowerCase();
|
|
404
|
+
const shell = document.createElement('div');
|
|
405
|
+
shell.className = 'chart-card';
|
|
406
|
+
shell.innerHTML = `<strong>${escapeHtml(config.title || title || 'Chart')}</strong>`;
|
|
407
|
+
|
|
408
|
+
if (!rows.length) {
|
|
409
|
+
shell.innerHTML += '<p class="field-help">No rows returned.</p>';
|
|
410
|
+
return shell;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (chart === 'kpi' || chart === 'metric') {
|
|
414
|
+
const yField = config.y || Object.keys(rows[0])[0];
|
|
415
|
+
const value = rows[0][yField];
|
|
416
|
+
shell.innerHTML += `<div class="kpi-value">${escapeHtml(String(value ?? '—'))}</div>`;
|
|
417
|
+
return shell;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (chart === 'line') {
|
|
421
|
+
const xField = config.x || Object.keys(rows[0])[0];
|
|
422
|
+
const yField = config.y || Object.keys(rows[0])[1] || xField;
|
|
423
|
+
shell.appendChild(renderLineChart(rows, xField, yField));
|
|
424
|
+
return shell;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (chart === 'table') {
|
|
428
|
+
shell.appendChild(renderTable(rows));
|
|
429
|
+
return shell;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const xField = config.x || Object.keys(rows[0])[0];
|
|
433
|
+
const yField = config.y || Object.keys(rows[0])[1] || xField;
|
|
434
|
+
const max = Math.max(...rows.map((row) => Number(row[yField]) || 0), 1);
|
|
435
|
+
rows.slice(0, 12).forEach((row) => {
|
|
436
|
+
const barRow = document.createElement('div');
|
|
437
|
+
barRow.className = 'bar-row';
|
|
438
|
+
const value = Number(row[yField]) || 0;
|
|
439
|
+
barRow.innerHTML = `
|
|
440
|
+
<span>${escapeHtml(String(row[xField] ?? ''))}</span>
|
|
441
|
+
<div class="bar-track"><div class="bar-fill" style="width:${(value / max) * 100}%"></div></div>
|
|
442
|
+
<strong>${escapeHtml(String(value))}</strong>
|
|
443
|
+
`;
|
|
444
|
+
shell.appendChild(barRow);
|
|
445
|
+
});
|
|
446
|
+
return shell;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderLineChart(rows, xField, yField) {
|
|
450
|
+
const wrapper = document.createElement('div');
|
|
451
|
+
const width = 640;
|
|
452
|
+
const height = 220;
|
|
453
|
+
const maxY = Math.max(...rows.map((row) => Number(row[yField]) || 0), 1);
|
|
454
|
+
const step = rows.length > 1 ? width / (rows.length - 1) : width;
|
|
455
|
+
const points = rows.map((row, index) => {
|
|
456
|
+
const x = index * step;
|
|
457
|
+
const y = height - ((Number(row[yField]) || 0) / maxY) * (height - 24) - 12;
|
|
458
|
+
return `${x},${y}`;
|
|
459
|
+
}).join(' ');
|
|
460
|
+
wrapper.innerHTML = `
|
|
461
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" height="220" role="img" aria-label="${escapeAttribute(yField)} over ${escapeAttribute(xField)}">
|
|
462
|
+
<rect x="0" y="0" width="${width}" height="${height}" fill="transparent"></rect>
|
|
463
|
+
<polyline fill="none" stroke="#59c2ff" stroke-width="3" points="${points}"></polyline>
|
|
464
|
+
${rows.map((row, index) => {
|
|
465
|
+
const x = index * step;
|
|
466
|
+
const y = height - ((Number(row[yField]) || 0) / maxY) * (height - 24) - 12;
|
|
467
|
+
return `<circle cx="${x}" cy="${y}" r="4" fill="#8b5cf6"></circle>`;
|
|
468
|
+
}).join('')}
|
|
469
|
+
</svg>
|
|
470
|
+
`;
|
|
471
|
+
return wrapper;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderTable(rows) {
|
|
475
|
+
if (!rows.length) {
|
|
476
|
+
const empty = document.createElement('div');
|
|
477
|
+
empty.className = 'panel small';
|
|
478
|
+
empty.textContent = 'No rows returned.';
|
|
479
|
+
return empty;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const columns = Object.keys(rows[0]);
|
|
483
|
+
const wrapper = document.createElement('div');
|
|
484
|
+
wrapper.className = 'table-shell';
|
|
485
|
+
wrapper.innerHTML = `
|
|
486
|
+
<table>
|
|
487
|
+
<thead>
|
|
488
|
+
<tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join('')}</tr>
|
|
489
|
+
</thead>
|
|
490
|
+
<tbody>
|
|
491
|
+
${rows.slice(0, 20).map((row) => `<tr>${columns.map((column) => `<td>${escapeHtml(String(row[column] ?? ''))}</td>`).join('')}</tr>`).join('')}
|
|
492
|
+
</tbody>
|
|
493
|
+
</table>
|
|
494
|
+
`;
|
|
495
|
+
return wrapper;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function exportNotebook() {
|
|
499
|
+
const blob = new Blob([JSON.stringify(state.notebook, null, 2)], { type: 'application/json' });
|
|
500
|
+
const url = URL.createObjectURL(blob);
|
|
501
|
+
const anchor = document.createElement('a');
|
|
502
|
+
anchor.href = url;
|
|
503
|
+
anchor.download = `${slugify(state.notebook.metadata.title || 'notebook')}.dqlnb`;
|
|
504
|
+
anchor.click();
|
|
505
|
+
URL.revokeObjectURL(url);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderMarkdown(markdown) {
|
|
509
|
+
return escapeHtml(markdown)
|
|
510
|
+
.replace(/^###\s+(.*)$/gm, '<h3>$1</h3>')
|
|
511
|
+
.replace(/^##\s+(.*)$/gm, '<h2>$1</h2>')
|
|
512
|
+
.replace(/^#\s+(.*)$/gm, '<h1>$1</h1>')
|
|
513
|
+
.replace(/^[-*]\s+(.*)$/gm, '<li>$1</li>')
|
|
514
|
+
.replace(/(?:\n|^)(<li>.*<\/li>)(?:\n|$)/gs, (_match, list) => `\n<ul>${list}</ul>\n`)
|
|
515
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
516
|
+
.replace(/\n\n/g, '</p><p>');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function loadStoredConnection() {
|
|
520
|
+
try {
|
|
521
|
+
const raw = localStorage.getItem(connectionStorageKey());
|
|
522
|
+
return raw ? JSON.parse(raw) : null;
|
|
523
|
+
} catch {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function connectionStorageKey() {
|
|
529
|
+
const projectRoot = state.bootstrap?.projectRoot || state.bootstrap?.project || 'default';
|
|
530
|
+
return `dql-notebook-connection:${projectRoot}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function slugify(value) {
|
|
534
|
+
return String(value || 'notebook').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function escapeHtml(value) {
|
|
538
|
+
return String(value)
|
|
539
|
+
.replaceAll('&', '&')
|
|
540
|
+
.replaceAll('<', '<')
|
|
541
|
+
.replaceAll('>', '>')
|
|
542
|
+
.replaceAll('"', '"')
|
|
543
|
+
.replaceAll("'", ''');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function escapeAttribute(value) {
|
|
547
|
+
return escapeHtml(value).replaceAll('`', '`');
|
|
548
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>DQL Notebook</title>
|
|
7
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app" class="app-shell">
|
|
11
|
+
<aside class="sidebar">
|
|
12
|
+
<div class="brand">
|
|
13
|
+
<div class="brand-mark">DQL</div>
|
|
14
|
+
<div>
|
|
15
|
+
<strong>Notebook</strong>
|
|
16
|
+
<p id="project-name">Loading project…</p>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<section>
|
|
20
|
+
<div class="section-title">Files</div>
|
|
21
|
+
<ul id="file-list" class="file-list"></ul>
|
|
22
|
+
</section>
|
|
23
|
+
<section>
|
|
24
|
+
<div class="section-title">Connections</div>
|
|
25
|
+
<div id="connection-summary" class="panel small"></div>
|
|
26
|
+
<label class="field-label" for="driver-select">Add connection</label>
|
|
27
|
+
<select id="driver-select"></select>
|
|
28
|
+
<div id="connection-fields"></div>
|
|
29
|
+
<div class="row-actions">
|
|
30
|
+
<button id="save-connection" class="secondary">Save draft</button>
|
|
31
|
+
<button id="test-connection" class="secondary">Test</button>
|
|
32
|
+
</div>
|
|
33
|
+
<div id="connection-status" class="status"></div>
|
|
34
|
+
</section>
|
|
35
|
+
</aside>
|
|
36
|
+
|
|
37
|
+
<main class="main">
|
|
38
|
+
<header class="toolbar">
|
|
39
|
+
<div>
|
|
40
|
+
<h1 id="notebook-title">Notebook</h1>
|
|
41
|
+
<p class="subtitle">DQL cells, SQL cells, markdown, and linked charts.</p>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="toolbar-actions">
|
|
44
|
+
<button data-add="dql">Add DQL</button>
|
|
45
|
+
<button data-add="sql">Add SQL</button>
|
|
46
|
+
<button data-add="markdown">Add Markdown</button>
|
|
47
|
+
<button data-add="chart">Add Chart</button>
|
|
48
|
+
<button id="run-all" class="primary">Run all</button>
|
|
49
|
+
<button id="export-notebook" class="primary">Export .dqlnb</button>
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
|
|
53
|
+
<section id="cells" class="cells"></section>
|
|
54
|
+
</main>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<template id="cell-template">
|
|
58
|
+
<article class="cell">
|
|
59
|
+
<div class="cell-header">
|
|
60
|
+
<div>
|
|
61
|
+
<span class="cell-type"></span>
|
|
62
|
+
<input class="cell-title" type="text" />
|
|
63
|
+
</div>
|
|
64
|
+
<div class="cell-actions">
|
|
65
|
+
<button data-action="up">↑</button>
|
|
66
|
+
<button data-action="down">↓</button>
|
|
67
|
+
<button data-action="run">Run</button>
|
|
68
|
+
<button data-action="delete">Delete</button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="cell-body">
|
|
72
|
+
<textarea class="cell-source" spellcheck="false"></textarea>
|
|
73
|
+
<div class="chart-editor hidden"></div>
|
|
74
|
+
<div class="markdown-preview hidden"></div>
|
|
75
|
+
<div class="cell-status"></div>
|
|
76
|
+
<div class="cell-output"></div>
|
|
77
|
+
</div>
|
|
78
|
+
</article>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<script type="module" src="./app.js"></script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|