@duckcodeailabs/dql-cli 1.5.0 → 1.5.2
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/apps-api.d.ts +71 -4
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +540 -19
- package/dist/apps-api.js.map +1 -1
- package/dist/apps-api.test.js +45 -2
- package/dist/apps-api.test.js.map +1 -1
- package/dist/assets/dql-notebook/assets/index-BZX1UCr2.js +863 -0
- package/dist/assets/dql-notebook/index.html +1 -1
- package/dist/block-studio-import.d.ts +53 -2
- package/dist/block-studio-import.d.ts.map +1 -1
- package/dist/block-studio-import.js +165 -22
- package/dist/block-studio-import.js.map +1 -1
- package/dist/block-studio-import.test.js +64 -2
- package/dist/block-studio-import.test.js.map +1 -1
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +6 -0
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -5
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +84 -24
- package/dist/commands/init.test.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +587 -89
- package/dist/local-runtime.js.map +1 -1
- package/package.json +11 -11
- package/dist/assets/dql-notebook/assets/index-mlfOQ2me.js +0 -857
package/dist/apps-api.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* dispatcher — returns `true` if the request was handled, `false` otherwise.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { join, dirname, relative } from 'node:path';
|
|
7
|
+
import { join, dirname, relative, basename } from 'node:path';
|
|
8
8
|
import { loadAppDocument, findAppDocuments, loadDashboardDocument, findDashboardsForApp, parseAppDocument, parseDashboardDocument, suggestAppId, } from '@duckcodeailabs/dql-core';
|
|
9
9
|
import { defaultPersonaRegistry, defaultLocalAppsDbPath, LocalAppStorage, personaFromMember, } from '@duckcodeailabs/dql-project';
|
|
10
10
|
export async function handleAppsApi(ctx) {
|
|
@@ -83,6 +83,163 @@ export async function handleAppsApi(ctx) {
|
|
|
83
83
|
}
|
|
84
84
|
return true;
|
|
85
85
|
}
|
|
86
|
+
m = path.match(/^\/api\/apps\/([^/]+)\/notebook-candidates$/);
|
|
87
|
+
if (m && req.method === 'GET') {
|
|
88
|
+
const appId = decodeURIComponent(m[1]);
|
|
89
|
+
const loaded = loadAppById(projectRoot, appId);
|
|
90
|
+
if (!loaded) {
|
|
91
|
+
sendJson(res, 404, { error: `App "${appId}" not found` });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
sendJson(res, 200, { notebooks: listNotebookCandidates(projectRoot, loaded.app, join(projectRoot, 'apps', appId)) });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
m = path.match(/^\/api\/apps\/([^/]+)\/notebooks\/create$/);
|
|
98
|
+
if (m && req.method === 'POST') {
|
|
99
|
+
const appId = decodeURIComponent(m[1]);
|
|
100
|
+
try {
|
|
101
|
+
const body = await readJson(req);
|
|
102
|
+
const result = createNotebookForApp(projectRoot, appId, body);
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
sendJson(res, 400, { error: result.error });
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
sendJson(res, 201, result);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
sendJson(res, 500, { error: err.message });
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
m = path.match(/^\/api\/apps\/([^/]+)\/notebooks\/preview$/);
|
|
115
|
+
if (m && req.method === 'GET') {
|
|
116
|
+
const appId = decodeURIComponent(m[1]);
|
|
117
|
+
const notebookPath = ctx.url.searchParams.get('path') ?? '';
|
|
118
|
+
const result = previewNotebookForApp(projectRoot, appId, notebookPath);
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
sendJson(res, result.status, { error: result.error });
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
sendJson(res, 200, result.preview);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
m = path.match(/^\/api\/apps\/([^/]+)\/notebooks\/run$/);
|
|
127
|
+
if (m && req.method === 'POST') {
|
|
128
|
+
const appId = decodeURIComponent(m[1]);
|
|
129
|
+
try {
|
|
130
|
+
const body = await readJson(req);
|
|
131
|
+
const notebookPath = cleanString(body.path);
|
|
132
|
+
if (!notebookPath) {
|
|
133
|
+
sendJson(res, 400, { error: 'path is required' });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (!ctx.runNotebook) {
|
|
137
|
+
sendJson(res, 400, { error: 'Notebook run is unavailable in this host.' });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
await ctx.runNotebook(appId, notebookPath);
|
|
141
|
+
const preview = previewNotebookForApp(projectRoot, appId, notebookPath);
|
|
142
|
+
if (!preview.ok) {
|
|
143
|
+
sendJson(res, preview.status, { error: preview.error });
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
sendJson(res, 200, { ok: true, preview: preview.preview });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
m = path.match(/^\/api\/apps\/([^/]+)\/notebooks$/);
|
|
154
|
+
if (m && req.method === 'POST') {
|
|
155
|
+
const appId = decodeURIComponent(m[1]);
|
|
156
|
+
try {
|
|
157
|
+
const body = await readJson(req);
|
|
158
|
+
const result = attachNotebookToApp(projectRoot, appId, body);
|
|
159
|
+
if (!result.ok) {
|
|
160
|
+
sendJson(res, 400, { error: result.error });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
sendJson(res, 200, loadAppById(projectRoot, appId) ?? result);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
sendJson(res, 500, { error: err.message });
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
m = path.match(/^\/api\/apps\/([^/]+)\/conversations$/);
|
|
171
|
+
if (m) {
|
|
172
|
+
const appId = decodeURIComponent(m[1]);
|
|
173
|
+
if (!loadAppById(projectRoot, appId)) {
|
|
174
|
+
sendJson(res, 404, { error: `App "${appId}" not found` });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
178
|
+
try {
|
|
179
|
+
if (req.method === 'GET') {
|
|
180
|
+
sendJson(res, 200, { conversations: storage.listAppConversations(appId) });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (req.method === 'POST') {
|
|
184
|
+
const body = await readJson(req);
|
|
185
|
+
const conversation = storage.createAppConversation({
|
|
186
|
+
appId,
|
|
187
|
+
title: body.title,
|
|
188
|
+
dashboardId: body.dashboardId,
|
|
189
|
+
notebookPath: body.notebookPath,
|
|
190
|
+
messages: normalizeConversationMessages(body.messages),
|
|
191
|
+
});
|
|
192
|
+
sendJson(res, 201, { ok: true, conversation });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
sendJson(res, 500, { error: err.message });
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
storage.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
m = path.match(/^\/api\/apps\/([^/]+)\/conversations\/([^/]+)$/);
|
|
205
|
+
if (m) {
|
|
206
|
+
const appId = decodeURIComponent(m[1]);
|
|
207
|
+
const conversationId = decodeURIComponent(m[2]);
|
|
208
|
+
const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
|
|
209
|
+
try {
|
|
210
|
+
const conversation = storage.getAppConversation(conversationId);
|
|
211
|
+
if (!conversation || conversation.appId !== appId) {
|
|
212
|
+
sendJson(res, 404, { error: `Conversation "${conversationId}" not found` });
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (req.method === 'GET') {
|
|
216
|
+
sendJson(res, 200, { conversation });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (req.method === 'PATCH') {
|
|
220
|
+
const body = await readJson(req);
|
|
221
|
+
const updated = storage.updateAppConversation(conversationId, {
|
|
222
|
+
title: body.title,
|
|
223
|
+
dashboardId: body.dashboardId,
|
|
224
|
+
notebookPath: body.notebookPath,
|
|
225
|
+
messages: body.messages ? normalizeConversationMessages(body.messages) : undefined,
|
|
226
|
+
});
|
|
227
|
+
sendJson(res, 200, { ok: true, conversation: updated });
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (req.method === 'DELETE') {
|
|
231
|
+
sendJson(res, 200, { ok: storage.deleteAppConversation(conversationId) });
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
sendJson(res, 500, { error: err.message });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
storage.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
86
243
|
m = path.match(/^\/api\/apps\/([^/]+)\/ai-pins$/);
|
|
87
244
|
if (m) {
|
|
88
245
|
const appId = decodeURIComponent(m[1]);
|
|
@@ -270,7 +427,6 @@ export async function handleAppsApi(ctx) {
|
|
|
270
427
|
}
|
|
271
428
|
return false;
|
|
272
429
|
}
|
|
273
|
-
// ---- Helpers ----
|
|
274
430
|
function collectAppsList(projectRoot) {
|
|
275
431
|
const out = [];
|
|
276
432
|
for (const p of findAppDocuments(projectRoot)) {
|
|
@@ -288,10 +444,15 @@ function collectAppsList(projectRoot) {
|
|
|
288
444
|
id: document.id,
|
|
289
445
|
name: document.name,
|
|
290
446
|
domain: document.domain,
|
|
447
|
+
subdomain: document.subdomain,
|
|
448
|
+
groups: document.groups ?? [],
|
|
291
449
|
description: document.description,
|
|
292
|
-
audience: audienceFromTags(document.tags ?? []),
|
|
293
|
-
|
|
294
|
-
|
|
450
|
+
audience: document.audience ?? audienceFromTags(document.tags ?? []),
|
|
451
|
+
lifecycle: document.lifecycle ?? 'draft',
|
|
452
|
+
certification: document.lifecycle === 'certified' ? 'certified' : 'uncertified',
|
|
453
|
+
status: document.lifecycle === 'review' ? 'review' : dashboards.length > 0 ? 'ready' : 'empty',
|
|
454
|
+
storage: document.visibility === 'private' ? 'mine' : document.visibility === 'template' ? 'template' : 'shared',
|
|
455
|
+
visibility: document.visibility ?? 'shared',
|
|
295
456
|
owners: document.owners,
|
|
296
457
|
tags: document.tags ?? [],
|
|
297
458
|
members: document.members.length,
|
|
@@ -299,6 +460,9 @@ function collectAppsList(projectRoot) {
|
|
|
299
460
|
policies: document.policies.length,
|
|
300
461
|
schedules: (document.schedules ?? []).length,
|
|
301
462
|
dashboards,
|
|
463
|
+
notebooks: listAppNotebookRefs(projectRoot, document, appDir),
|
|
464
|
+
drafts: listAppDrafts(projectRoot, appDir),
|
|
465
|
+
aiPins: countAiPins(projectRoot, document.id),
|
|
302
466
|
homepage: document.homepage,
|
|
303
467
|
});
|
|
304
468
|
}
|
|
@@ -364,8 +528,16 @@ export function createAppPackage(projectRoot, input) {
|
|
|
364
528
|
const appDir = join(projectRoot, 'apps', id);
|
|
365
529
|
if (existsSync(appDir))
|
|
366
530
|
return { ok: false, error: `App already exists: ${id}` };
|
|
531
|
+
const dashboardTitle = cleanString(input.dashboardTitle) || 'Overview';
|
|
532
|
+
const dashboardId = slugify(dashboardTitle) || 'overview';
|
|
367
533
|
const owner = cleanString(input.owners?.[0]) || `${process.env.USER ?? 'owner'}@local`;
|
|
368
534
|
const audience = cleanString(input.audience);
|
|
535
|
+
const subdomain = cleanString(input.subdomain);
|
|
536
|
+
const groups = normalizeTags(input.groups ?? []);
|
|
537
|
+
const visibility = input.visibility === 'private' || input.visibility === 'template' ? input.visibility : 'shared';
|
|
538
|
+
const lifecycle = input.lifecycle === 'certified' || input.lifecycle === 'review' || input.lifecycle === 'deprecated'
|
|
539
|
+
? input.lifecycle
|
|
540
|
+
: 'draft';
|
|
369
541
|
const tags = normalizeTags([...(input.tags ?? []), audience ? `audience:${slugify(audience)}` : '']);
|
|
370
542
|
const selectedIds = Array.from(new Set((input.selectedBlockIds ?? []).map(cleanString).filter(Boolean)));
|
|
371
543
|
const blocks = collectBlockCandidates(projectRoot);
|
|
@@ -377,7 +549,12 @@ export function createAppPackage(projectRoot, input) {
|
|
|
377
549
|
id,
|
|
378
550
|
name,
|
|
379
551
|
description: cleanString(input.purpose) || `${name} consumption surface for ${domain}`,
|
|
552
|
+
visibility,
|
|
380
553
|
domain,
|
|
554
|
+
subdomain: subdomain || undefined,
|
|
555
|
+
groups,
|
|
556
|
+
audience: audience || undefined,
|
|
557
|
+
lifecycle,
|
|
381
558
|
owners: [owner],
|
|
382
559
|
tags,
|
|
383
560
|
members: [
|
|
@@ -416,15 +593,20 @@ export function createAppPackage(projectRoot, input) {
|
|
|
416
593
|
],
|
|
417
594
|
rlsBindings: [],
|
|
418
595
|
schedules: [],
|
|
419
|
-
homepage: { type: 'dashboard', id:
|
|
596
|
+
homepage: { type: 'dashboard', id: dashboardId },
|
|
420
597
|
};
|
|
421
598
|
const dashboard = {
|
|
422
599
|
version: 1,
|
|
423
|
-
id:
|
|
600
|
+
id: dashboardId,
|
|
424
601
|
metadata: {
|
|
425
|
-
title:
|
|
602
|
+
title: dashboardTitle,
|
|
426
603
|
description: cleanString(input.purpose) || `Starter dashboard for ${name}`,
|
|
427
604
|
domain,
|
|
605
|
+
subdomain: subdomain || undefined,
|
|
606
|
+
groups,
|
|
607
|
+
audience: audience || undefined,
|
|
608
|
+
visibility,
|
|
609
|
+
lifecycle,
|
|
428
610
|
tags,
|
|
429
611
|
},
|
|
430
612
|
layout: {
|
|
@@ -437,7 +619,7 @@ export function createAppPackage(projectRoot, input) {
|
|
|
437
619
|
const paths = [
|
|
438
620
|
join(appDir, 'dql.app.json'),
|
|
439
621
|
join(appDir, 'README.md'),
|
|
440
|
-
join(appDir, 'dashboards',
|
|
622
|
+
join(appDir, 'dashboards', `${dashboardId}.dqld`),
|
|
441
623
|
join(appDir, 'notebooks'),
|
|
442
624
|
join(appDir, 'drafts'),
|
|
443
625
|
];
|
|
@@ -445,8 +627,8 @@ export function createAppPackage(projectRoot, input) {
|
|
|
445
627
|
mkdirSync(join(appDir, 'notebooks'), { recursive: true });
|
|
446
628
|
mkdirSync(join(appDir, 'drafts'), { recursive: true });
|
|
447
629
|
writeFileSync(join(appDir, 'dql.app.json'), JSON.stringify(app, null, 2) + '\n', 'utf-8');
|
|
448
|
-
writeFileSync(join(appDir, 'dashboards',
|
|
449
|
-
writeFileSync(join(appDir, 'README.md'), appReadme(app, audience, selectedBlocks), 'utf-8');
|
|
630
|
+
writeFileSync(join(appDir, 'dashboards', `${dashboardId}.dqld`), JSON.stringify(dashboard, null, 2) + '\n', 'utf-8');
|
|
631
|
+
writeFileSync(join(appDir, 'README.md'), appReadme(app, audience, selectedBlocks, dashboardId), 'utf-8');
|
|
450
632
|
const created = collectAppsList(projectRoot).find((entry) => entry.id === id);
|
|
451
633
|
if (!created)
|
|
452
634
|
return { ok: false, error: `App was written but could not be reloaded: ${id}` };
|
|
@@ -454,7 +636,7 @@ export function createAppPackage(projectRoot, input) {
|
|
|
454
636
|
ok: true,
|
|
455
637
|
app: created,
|
|
456
638
|
paths: paths.map((path) => path.startsWith(projectRoot) ? path.slice(projectRoot.length + 1) : path),
|
|
457
|
-
dashboardId
|
|
639
|
+
dashboardId,
|
|
458
640
|
};
|
|
459
641
|
}
|
|
460
642
|
function buildDashboardItems(blocks) {
|
|
@@ -516,10 +698,26 @@ function normalizeVizType(chartType) {
|
|
|
516
698
|
return 'line';
|
|
517
699
|
if (normalized === 'bar')
|
|
518
700
|
return 'bar';
|
|
701
|
+
if (normalized === 'grouped_bar')
|
|
702
|
+
return 'grouped_bar';
|
|
703
|
+
if (normalized === 'stacked_bar')
|
|
704
|
+
return 'stacked_bar';
|
|
519
705
|
if (normalized === 'area')
|
|
520
706
|
return 'area';
|
|
521
707
|
if (normalized === 'pie')
|
|
522
708
|
return 'pie';
|
|
709
|
+
if (normalized === 'donut')
|
|
710
|
+
return 'donut';
|
|
711
|
+
if (normalized === 'scatter')
|
|
712
|
+
return 'scatter';
|
|
713
|
+
if (normalized === 'heatmap')
|
|
714
|
+
return 'heatmap';
|
|
715
|
+
if (normalized === 'histogram')
|
|
716
|
+
return 'histogram';
|
|
717
|
+
if (normalized === 'waterfall')
|
|
718
|
+
return 'waterfall';
|
|
719
|
+
if (normalized === 'gauge')
|
|
720
|
+
return 'gauge';
|
|
523
721
|
if (normalized === 'pivot')
|
|
524
722
|
return 'pivot';
|
|
525
723
|
if (normalized === 'map')
|
|
@@ -528,16 +726,22 @@ function normalizeVizType(chartType) {
|
|
|
528
726
|
return 'funnel';
|
|
529
727
|
return 'table';
|
|
530
728
|
}
|
|
531
|
-
function appReadme(app, audience, blocks) {
|
|
729
|
+
function appReadme(app, audience, blocks, dashboardId = 'overview') {
|
|
532
730
|
return [
|
|
533
731
|
`# ${app.name}`,
|
|
534
732
|
'',
|
|
535
733
|
app.description ?? '',
|
|
536
734
|
'',
|
|
537
735
|
`- Domain: ${app.domain}`,
|
|
736
|
+
...(app.subdomain ? [`- Subdomain: ${app.subdomain}`] : []),
|
|
737
|
+
...(app.groups?.length ? [`- Groups: ${app.groups.join(', ')}`] : []),
|
|
538
738
|
`- Audience: ${audience || 'not specified'}`,
|
|
739
|
+
`- Visibility: ${app.visibility}`,
|
|
740
|
+
`- Lifecycle: ${app.lifecycle}`,
|
|
539
741
|
`- Owners: ${app.owners.join(', ')}`,
|
|
540
|
-
`- Starter dashboard: dashboards
|
|
742
|
+
`- Starter dashboard: dashboards/${dashboardId}.dqld`,
|
|
743
|
+
`- Supporting notebooks: notebooks/`,
|
|
744
|
+
`- Draft blocks: drafts/`,
|
|
541
745
|
'',
|
|
542
746
|
'## Selected Certified Blocks',
|
|
543
747
|
'',
|
|
@@ -622,6 +826,17 @@ function matchArray(source, regex) {
|
|
|
622
826
|
.map((item) => item.trim().replace(/^"|"$/g, ''))
|
|
623
827
|
.filter(Boolean);
|
|
624
828
|
}
|
|
829
|
+
function normalizeConversationMessages(messages) {
|
|
830
|
+
return (messages ?? [])
|
|
831
|
+
.map((message) => ({
|
|
832
|
+
id: cleanString(message.id) || undefined,
|
|
833
|
+
role: message.role === 'assistant' ? 'assistant' : 'user',
|
|
834
|
+
content: cleanString(message.content),
|
|
835
|
+
events: Array.isArray(message.events) ? message.events : [],
|
|
836
|
+
createdAt: cleanString(message.createdAt) || undefined,
|
|
837
|
+
}))
|
|
838
|
+
.filter((message) => message.content.length > 0);
|
|
839
|
+
}
|
|
625
840
|
function cleanString(value) {
|
|
626
841
|
return typeof value === 'string' ? value.trim() : '';
|
|
627
842
|
}
|
|
@@ -637,6 +852,14 @@ function slugify(value) {
|
|
|
637
852
|
.replace(/[^a-z0-9]+/g, '-')
|
|
638
853
|
.replace(/^-+|-+$/g, '');
|
|
639
854
|
}
|
|
855
|
+
function titleFromPath(path) {
|
|
856
|
+
return basename(path)
|
|
857
|
+
.replace(/\.(dqlnb|dql)$/i, '')
|
|
858
|
+
.replace(/[_-]+/g, ' ')
|
|
859
|
+
.replace(/\s+/g, ' ')
|
|
860
|
+
.trim()
|
|
861
|
+
.replace(/\b\w/g, (char) => char.toUpperCase()) || path;
|
|
862
|
+
}
|
|
640
863
|
function audienceFromTags(tags) {
|
|
641
864
|
const tag = tags.find((value) => value.startsWith('audience:'));
|
|
642
865
|
if (!tag)
|
|
@@ -656,8 +879,8 @@ function createDashboardForApp(projectRoot, appId, input) {
|
|
|
656
879
|
const loaded = loadAppById(projectRoot, appId);
|
|
657
880
|
if (!loaded)
|
|
658
881
|
return { ok: false, error: `App "${appId}" not found` };
|
|
659
|
-
const title = cleanString(input.title) || 'New
|
|
660
|
-
const id = slugify(cleanString(input.id) || title) || `
|
|
882
|
+
const title = cleanString(input.title) || 'New page';
|
|
883
|
+
const id = slugify(cleanString(input.id) || title) || `page-${Date.now()}`;
|
|
661
884
|
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(id))
|
|
662
885
|
return { ok: false, error: 'dashboard id must be folder-safe' };
|
|
663
886
|
const appDir = join(projectRoot, 'apps', appId);
|
|
@@ -669,8 +892,13 @@ function createDashboardForApp(projectRoot, appId, input) {
|
|
|
669
892
|
id,
|
|
670
893
|
metadata: {
|
|
671
894
|
title,
|
|
672
|
-
description: cleanString(input.description) || `${title} dashboard
|
|
895
|
+
description: cleanString(input.description) || `${title} dashboard page`,
|
|
673
896
|
domain: loaded.app.domain,
|
|
897
|
+
subdomain: loaded.app.subdomain,
|
|
898
|
+
groups: loaded.app.groups ?? [],
|
|
899
|
+
audience: loaded.app.audience,
|
|
900
|
+
visibility: loaded.app.visibility ?? 'shared',
|
|
901
|
+
lifecycle: loaded.app.lifecycle ?? 'draft',
|
|
674
902
|
tags: loaded.app.tags ?? [],
|
|
675
903
|
},
|
|
676
904
|
layout: {
|
|
@@ -812,7 +1040,9 @@ function nextTileId(dashboard, base) {
|
|
|
812
1040
|
function normalizeVizTypeFromChart(chartConfig) {
|
|
813
1041
|
const chart = String(chartConfig?.chart ?? '').toLowerCase().replace(/-/g, '_');
|
|
814
1042
|
if (chart === 'single_value' || chart === 'kpi' || chart === 'line' || chart === 'bar' || chart === 'area'
|
|
815
|
-
|| chart === '
|
|
1043
|
+
|| chart === 'grouped_bar' || chart === 'stacked_bar' || chart === 'pie' || chart === 'donut'
|
|
1044
|
+
|| chart === 'scatter' || chart === 'heatmap' || chart === 'histogram' || chart === 'waterfall'
|
|
1045
|
+
|| chart === 'gauge' || chart === 'pivot' || chart === 'map' || chart === 'funnel') {
|
|
816
1046
|
return chart;
|
|
817
1047
|
}
|
|
818
1048
|
return 'table';
|
|
@@ -838,10 +1068,301 @@ function loadAppById(projectRoot, id) {
|
|
|
838
1068
|
});
|
|
839
1069
|
}
|
|
840
1070
|
}
|
|
841
|
-
return {
|
|
1071
|
+
return {
|
|
1072
|
+
app: document,
|
|
1073
|
+
dashboards,
|
|
1074
|
+
notebooks: listAppNotebookRefs(projectRoot, document, appDir),
|
|
1075
|
+
drafts: listAppDrafts(projectRoot, appDir),
|
|
1076
|
+
aiPins: listAiPins(projectRoot, document.id),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
export function listNotebookCandidates(projectRoot, app, appDir) {
|
|
1082
|
+
const attached = new Map(listAppNotebookRefs(projectRoot, app, appDir).map((notebook) => [notebook.path, notebook]));
|
|
1083
|
+
const files = new Map();
|
|
1084
|
+
for (const root of ['notebooks', 'workbooks', 'apps']) {
|
|
1085
|
+
for (const file of scanFiles(join(projectRoot, root), '.dqlnb')) {
|
|
1086
|
+
const rel = relative(projectRoot, file).replaceAll('\\', '/');
|
|
1087
|
+
files.set(rel, file);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
for (const notebook of attached.values()) {
|
|
1091
|
+
const abs = join(projectRoot, notebook.path);
|
|
1092
|
+
if (existsSync(abs))
|
|
1093
|
+
files.set(notebook.path, abs);
|
|
1094
|
+
}
|
|
1095
|
+
return Array.from(files.entries())
|
|
1096
|
+
.map(([path, abs]) => {
|
|
1097
|
+
const ref = attached.get(path);
|
|
1098
|
+
const stat = statSyncSafe(abs);
|
|
1099
|
+
return {
|
|
1100
|
+
path,
|
|
1101
|
+
title: ref?.title ?? notebookTitleFromFile(abs) ?? titleFromPath(path),
|
|
1102
|
+
attached: Boolean(ref),
|
|
1103
|
+
role: ref?.role,
|
|
1104
|
+
visibility: ref?.visibility,
|
|
1105
|
+
lastModified: stat?.mtime.toISOString(),
|
|
1106
|
+
};
|
|
1107
|
+
})
|
|
1108
|
+
.sort((a, b) => Number(b.attached) - Number(a.attached) || a.title.localeCompare(b.title));
|
|
1109
|
+
}
|
|
1110
|
+
export function createNotebookForApp(projectRoot, appId, input) {
|
|
1111
|
+
const loaded = loadAppById(projectRoot, appId);
|
|
1112
|
+
if (!loaded)
|
|
1113
|
+
return { ok: false, error: `App "${appId}" not found` };
|
|
1114
|
+
const title = cleanString(input.title) || cleanString(input.name) || 'App analysis';
|
|
1115
|
+
const slug = slugify(cleanString(input.name) || title) || `notebook-${Date.now()}`;
|
|
1116
|
+
const appDir = join(projectRoot, 'apps', appId);
|
|
1117
|
+
const relPath = `apps/${appId}/notebooks/${slug}.dqlnb`;
|
|
1118
|
+
const absPath = join(projectRoot, relPath);
|
|
1119
|
+
if (existsSync(absPath))
|
|
1120
|
+
return { ok: false, error: `Notebook already exists: ${relPath}` };
|
|
1121
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
1122
|
+
writeFileSync(absPath, buildAppNotebookTemplate(title, loaded.app, input.template), 'utf-8');
|
|
1123
|
+
const attached = attachNotebookToApp(projectRoot, appId, {
|
|
1124
|
+
path: relPath,
|
|
1125
|
+
title,
|
|
1126
|
+
role: normalizeNotebookRole(input.role),
|
|
1127
|
+
visibility: input.visibility,
|
|
1128
|
+
});
|
|
1129
|
+
if (!attached.ok)
|
|
1130
|
+
return { ok: false, error: attached.error };
|
|
1131
|
+
const preview = previewNotebookForApp(projectRoot, appId, relPath);
|
|
1132
|
+
return {
|
|
1133
|
+
ok: true,
|
|
1134
|
+
path: relPath,
|
|
1135
|
+
app: loadAppById(projectRoot, appId),
|
|
1136
|
+
preview: preview.ok ? preview.preview : null,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
export function previewNotebookForApp(projectRoot, appId, notebookPath) {
|
|
1140
|
+
if (!loadAppById(projectRoot, appId))
|
|
1141
|
+
return { ok: false, status: 404, error: `App "${appId}" not found` };
|
|
1142
|
+
const rel = cleanString(notebookPath).replaceAll('\\', '/');
|
|
1143
|
+
if (!rel || rel.startsWith('/') || rel.includes('..') || !rel.endsWith('.dqlnb')) {
|
|
1144
|
+
return { ok: false, status: 400, error: 'notebook path must be a project-relative .dqlnb path' };
|
|
1145
|
+
}
|
|
1146
|
+
const abs = join(projectRoot, rel);
|
|
1147
|
+
if (!existsSync(abs))
|
|
1148
|
+
return { ok: false, status: 404, error: `Notebook not found: ${rel}` };
|
|
1149
|
+
try {
|
|
1150
|
+
const raw = readFileSync(abs, 'utf-8');
|
|
1151
|
+
const parsed = JSON.parse(raw);
|
|
1152
|
+
const snapshot = readNotebookRunSnapshot(abs);
|
|
1153
|
+
const snapshotByCell = new Map();
|
|
1154
|
+
for (const entry of snapshot?.cells ?? []) {
|
|
1155
|
+
if (entry && typeof entry === 'object' && typeof entry.cellId === 'string') {
|
|
1156
|
+
snapshotByCell.set(String(entry.cellId), entry);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const cells = (parsed.cells ?? []).map((cell, index) => {
|
|
1160
|
+
const id = typeof cell.id === 'string' ? cell.id : `cell-${index + 1}`;
|
|
1161
|
+
const snap = snapshotByCell.get(id);
|
|
1162
|
+
return {
|
|
1163
|
+
id,
|
|
1164
|
+
type: typeof cell.type === 'string' ? cell.type : 'sql',
|
|
1165
|
+
name: typeof cell.name === 'string' ? cell.name : typeof cell.title === 'string' ? cell.title : undefined,
|
|
1166
|
+
content: typeof cell.content === 'string' ? cell.content : typeof cell.source === 'string' ? cell.source : '',
|
|
1167
|
+
upstream: typeof cell.upstream === 'string' ? cell.upstream : undefined,
|
|
1168
|
+
chartConfig: cell.chartConfig ?? cell.config,
|
|
1169
|
+
tableConfig: cell.tableConfig,
|
|
1170
|
+
singleValueConfig: cell.singleValueConfig,
|
|
1171
|
+
pivotConfig: cell.pivotConfig,
|
|
1172
|
+
status: snap?.status ?? 'idle',
|
|
1173
|
+
result: snap?.result,
|
|
1174
|
+
error: snap?.error,
|
|
1175
|
+
executionCount: snap?.executionCount,
|
|
1176
|
+
executedAt: snap?.executedAt,
|
|
1177
|
+
};
|
|
1178
|
+
});
|
|
1179
|
+
return {
|
|
1180
|
+
ok: true,
|
|
1181
|
+
preview: {
|
|
1182
|
+
path: rel,
|
|
1183
|
+
title: parsed.title ?? parsed.metadata?.title ?? titleFromPath(rel),
|
|
1184
|
+
metadata: parsed.metadata ?? {},
|
|
1185
|
+
cells,
|
|
1186
|
+
snapshotFound: Boolean(snapshot),
|
|
1187
|
+
capturedAt: typeof snapshot?.capturedAt === 'string' ? snapshot.capturedAt : undefined,
|
|
1188
|
+
},
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
catch (err) {
|
|
1192
|
+
return { ok: false, status: 400, error: err instanceof Error ? err.message : String(err) };
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
function attachNotebookToApp(projectRoot, appId, input) {
|
|
1196
|
+
const notebookPath = cleanString(input.path).replaceAll('\\', '/');
|
|
1197
|
+
if (!notebookPath)
|
|
1198
|
+
return { ok: false, error: 'path is required' };
|
|
1199
|
+
if (notebookPath.startsWith('/') || notebookPath.includes('..')) {
|
|
1200
|
+
return { ok: false, error: 'notebook path must be project-relative' };
|
|
1201
|
+
}
|
|
1202
|
+
if (!existsSync(join(projectRoot, notebookPath))) {
|
|
1203
|
+
return { ok: false, error: `Notebook not found: ${notebookPath}` };
|
|
1204
|
+
}
|
|
1205
|
+
for (const appJsonPath of findAppDocuments(projectRoot)) {
|
|
1206
|
+
const { document } = loadAppDocument(appJsonPath);
|
|
1207
|
+
if (!document || document.id !== appId)
|
|
1208
|
+
continue;
|
|
1209
|
+
const role = input.role === 'source' || input.role === 'analysis' ? input.role : 'supporting';
|
|
1210
|
+
const visibility = input.visibility === 'private' || input.visibility === 'template' ? input.visibility : 'shared';
|
|
1211
|
+
const next = {
|
|
1212
|
+
...document,
|
|
1213
|
+
notebooks: [
|
|
1214
|
+
...(document.notebooks ?? []).filter((notebook) => notebook.path !== notebookPath),
|
|
1215
|
+
{
|
|
1216
|
+
path: notebookPath,
|
|
1217
|
+
title: cleanString(input.title) || titleFromPath(notebookPath),
|
|
1218
|
+
role,
|
|
1219
|
+
visibility,
|
|
1220
|
+
},
|
|
1221
|
+
],
|
|
1222
|
+
};
|
|
1223
|
+
const { document: validated, errors } = parseAppDocument(JSON.stringify(next), appJsonPath);
|
|
1224
|
+
if (!validated)
|
|
1225
|
+
return { ok: false, error: errors.map((e) => e.message).join('; ') };
|
|
1226
|
+
writeFileSync(appJsonPath, JSON.stringify(validated, null, 2) + '\n', 'utf-8');
|
|
1227
|
+
return { ok: true, path: relative(projectRoot, appJsonPath) };
|
|
1228
|
+
}
|
|
1229
|
+
return { ok: false, error: `App "${appId}" not found` };
|
|
1230
|
+
}
|
|
1231
|
+
function listAppNotebookRefs(projectRoot, app, appDir) {
|
|
1232
|
+
const byPath = new Map();
|
|
1233
|
+
for (const notebook of app.notebooks ?? []) {
|
|
1234
|
+
byPath.set(notebook.path, {
|
|
1235
|
+
path: notebook.path,
|
|
1236
|
+
title: notebook.title,
|
|
1237
|
+
role: notebook.role,
|
|
1238
|
+
visibility: notebook.visibility ?? 'shared',
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
const notebooksDir = join(appDir, 'notebooks');
|
|
1242
|
+
for (const file of scanFiles(notebooksDir, '.dqlnb')) {
|
|
1243
|
+
const rel = relative(projectRoot, file).replaceAll('\\', '/');
|
|
1244
|
+
if (byPath.has(rel))
|
|
1245
|
+
continue;
|
|
1246
|
+
byPath.set(rel, {
|
|
1247
|
+
path: rel,
|
|
1248
|
+
title: titleFromPath(rel),
|
|
1249
|
+
role: 'supporting',
|
|
1250
|
+
visibility: app.visibility ?? 'shared',
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
return Array.from(byPath.values()).sort((a, b) => (a.title ?? a.path).localeCompare(b.title ?? b.path));
|
|
1254
|
+
}
|
|
1255
|
+
function buildAppNotebookTemplate(title, app, template) {
|
|
1256
|
+
const normalizedTemplate = cleanString(template) || 'blank';
|
|
1257
|
+
const cellId = (base) => `${slugify(base) || 'cell'}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1258
|
+
const intro = [
|
|
1259
|
+
`# ${title}`,
|
|
1260
|
+
'',
|
|
1261
|
+
`App: ${app.name}`,
|
|
1262
|
+
`Domain: ${[app.domain, app.subdomain, ...(app.groups ?? [])].filter(Boolean).join(' / ')}`,
|
|
1263
|
+
'',
|
|
1264
|
+
'Use this notebook for analysis that supports the App dashboard pages.',
|
|
1265
|
+
].join('\n');
|
|
1266
|
+
const cells = [
|
|
1267
|
+
{
|
|
1268
|
+
id: cellId('intro'),
|
|
1269
|
+
type: 'markdown',
|
|
1270
|
+
content: intro,
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
id: cellId('starter-sql'),
|
|
1274
|
+
type: 'sql',
|
|
1275
|
+
name: 'starter_query',
|
|
1276
|
+
content: '-- Write supporting SQL for this App here\nSELECT 1 AS value;',
|
|
1277
|
+
},
|
|
1278
|
+
];
|
|
1279
|
+
if (normalizedTemplate === 'summary') {
|
|
1280
|
+
cells.push({
|
|
1281
|
+
id: cellId('summary'),
|
|
1282
|
+
type: 'markdown',
|
|
1283
|
+
content: '## Notes\n\nAdd observations, assumptions, and follow-up questions here.',
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
return JSON.stringify({
|
|
1287
|
+
dqlnbVersion: 1,
|
|
1288
|
+
version: 1,
|
|
1289
|
+
title,
|
|
1290
|
+
metadata: {
|
|
1291
|
+
description: `Supporting notebook for ${app.name}`,
|
|
1292
|
+
status: 'draft',
|
|
1293
|
+
categories: [app.domain, app.subdomain, ...(app.groups ?? [])].filter(Boolean),
|
|
1294
|
+
createdAt: new Date().toISOString(),
|
|
1295
|
+
modifiedAt: new Date().toISOString(),
|
|
1296
|
+
},
|
|
1297
|
+
cells,
|
|
1298
|
+
}, null, 2) + '\n';
|
|
1299
|
+
}
|
|
1300
|
+
function notebookTitleFromFile(absPath) {
|
|
1301
|
+
try {
|
|
1302
|
+
const parsed = JSON.parse(readFileSync(absPath, 'utf-8'));
|
|
1303
|
+
if (typeof parsed.title === 'string' && parsed.title.trim())
|
|
1304
|
+
return parsed.title.trim();
|
|
1305
|
+
if (typeof parsed.metadata?.title === 'string' && parsed.metadata.title.trim())
|
|
1306
|
+
return parsed.metadata.title.trim();
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
// fall back to path-derived title
|
|
842
1310
|
}
|
|
843
1311
|
return null;
|
|
844
1312
|
}
|
|
1313
|
+
function readNotebookRunSnapshot(absNotebookPath) {
|
|
1314
|
+
const snapshotPath = absNotebookPath.replace(/\.dqlnb$/i, '.run.json');
|
|
1315
|
+
if (!existsSync(snapshotPath))
|
|
1316
|
+
return null;
|
|
1317
|
+
try {
|
|
1318
|
+
return JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
return null;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
function normalizeNotebookRole(value) {
|
|
1325
|
+
return value === 'source' || value === 'analysis' ? value : 'supporting';
|
|
1326
|
+
}
|
|
1327
|
+
function listAppDrafts(projectRoot, appDir) {
|
|
1328
|
+
return scanFiles(join(appDir, 'drafts'), '.dql').map((file) => {
|
|
1329
|
+
const source = readFileSync(file, 'utf-8');
|
|
1330
|
+
const path = relative(projectRoot, file).replaceAll('\\', '/');
|
|
1331
|
+
return {
|
|
1332
|
+
path,
|
|
1333
|
+
name: matchString(source, /block\s+"([^"]+)"/) ?? titleFromPath(path),
|
|
1334
|
+
reviewStatus: matchString(source, /status\s*=\s*"([^"]+)"/) ?? 'review',
|
|
1335
|
+
};
|
|
1336
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
1337
|
+
}
|
|
1338
|
+
function scanFiles(root, extension) {
|
|
1339
|
+
if (!existsSync(root))
|
|
1340
|
+
return [];
|
|
1341
|
+
const out = [];
|
|
1342
|
+
for (const entry of readdirSyncSafe(root)) {
|
|
1343
|
+
const full = join(root, entry.name);
|
|
1344
|
+
if (entry.isDirectory())
|
|
1345
|
+
out.push(...scanFiles(full, extension));
|
|
1346
|
+
else if (entry.isFile() && entry.name.endsWith(extension))
|
|
1347
|
+
out.push(full);
|
|
1348
|
+
}
|
|
1349
|
+
return out.sort();
|
|
1350
|
+
}
|
|
1351
|
+
function countAiPins(projectRoot, appId) {
|
|
1352
|
+
return listAiPins(projectRoot, appId).length;
|
|
1353
|
+
}
|
|
1354
|
+
function listAiPins(projectRoot, appId) {
|
|
1355
|
+
const dbPath = defaultLocalAppsDbPath(projectRoot);
|
|
1356
|
+
if (!existsSync(dbPath))
|
|
1357
|
+
return [];
|
|
1358
|
+
const storage = new LocalAppStorage(dbPath);
|
|
1359
|
+
try {
|
|
1360
|
+
return storage.listAiPins(appId);
|
|
1361
|
+
}
|
|
1362
|
+
finally {
|
|
1363
|
+
storage.close();
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
845
1366
|
function listDashboardsFor(projectRoot, id) {
|
|
846
1367
|
const result = loadAppById(projectRoot, id);
|
|
847
1368
|
return result?.dashboards ?? null;
|