@veolab/discoverylab 1.4.3 → 1.6.3

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.
@@ -0,0 +1,254 @@
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.0">
6
+ <title>__TITLE__</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root { --accent: #6366f1; --bg: #0f0f17; --bg2: #16161f; --bg3: #1e1e2a; --text: #e4e4e7; --muted: #71717a; --ok: #22c55e; --warn: #eab308; }
10
+ body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; min-height: 100vh; overflow-x: hidden; }
11
+
12
+ /* Header */
13
+ .header { display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; border-bottom: 1px solid rgba(255,255,255,0.05); }
14
+ .header-left { display: flex; align-items: center; gap: 12px; }
15
+ .header-title { font-size: 16px; font-weight: 700; }
16
+ .header-platform { font-size: 10px; padding: 2px 8px; background: rgba(99,102,241,0.12); color: var(--accent); border-radius: 10px; }
17
+ .header-date { font-size: 11px; color: var(--muted); }
18
+
19
+ /* Step chips */
20
+ .chips { display: flex; gap: 4px; padding: 10px 24px; overflow-x: auto; scrollbar-width: none; }
21
+ .chips::-webkit-scrollbar { display: none; }
22
+ .chip { font-size: 10px; padding: 4px 12px; border-radius: 16px; background: var(--bg2); border: 1px solid rgba(255,255,255,0.06); cursor: pointer; white-space: nowrap; transition: all 0.25s; color: var(--muted); font-weight: 500; }
23
+ .chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
24
+ .chip:hover:not(.active) { border-color: rgba(255,255,255,0.15); }
25
+
26
+ /* Main layout */
27
+ .main { display: grid; grid-template-columns: 1fr 300px; height: calc(100vh - 100px); overflow: hidden; }
28
+ @media (max-width: 768px) { .main { grid-template-columns: 1fr; } .panel { border-left: none; border-top: 1px solid rgba(255,255,255,0.05); max-height: 200px; } }
29
+
30
+ /* Viewer */
31
+ .viewer { display: flex; align-items: center; justify-content: center; position: relative; padding: 20px; }
32
+ .device-area { position: relative; display: flex; align-items: center; }
33
+ .phone { width: 320px; background: #000; border-radius: 28px; overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,0.5); border: 1.5px solid rgba(255,255,255,0.06); position: relative; }
34
+ .phone img { width: 100%; display: block; transition: opacity 0.35s ease; }
35
+ .phone img.out { opacity: 0; position: absolute; top: 0; left: 0; }
36
+ .phone img.in { opacity: 1; position: relative; }
37
+
38
+ /* Side annotations (appear when paused) */
39
+ .side-annots { position: absolute; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 8px; opacity: 0; transition: opacity 0.35s; pointer-events: none; width: 160px; }
40
+ .side-annots.show { opacity: 1; pointer-events: auto; }
41
+ .side-annots.left { right: calc(100% + 16px); }
42
+ .side-annots.right { left: calc(100% + 16px); }
43
+ .side-annot { padding: 8px 10px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; position: relative; }
44
+ .side-annot::before { content: ''; position: absolute; top: 50%; width: 12px; height: 1px; background: rgba(255,255,255,0.15); }
45
+ .side-annots.left .side-annot::before { right: -13px; }
46
+ .side-annots.right .side-annot::before { left: -13px; }
47
+ .side-annot-title { font-size: 10px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 5px; }
48
+ .side-annot-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
49
+ .side-annot-desc { font-size: 9px; color: var(--muted); margin-top: 2px; line-height: 1.4; }
50
+
51
+ /* Controls */
52
+ .controls { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.6); backdrop-filter: blur(8px); padding: 6px 14px; border-radius: 20px; }
53
+ .play-btn { width: 28px; height: 28px; border-radius: 50%; background: transparent; border: 1.5px solid rgba(255,255,255,0.3); color: var(--text); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; }
54
+ .play-btn:hover { border-color: var(--accent); color: var(--accent); }
55
+ .dots { display: flex; gap: 5px; }
56
+ .dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,0.15); cursor: pointer; transition: all 0.25s; }
57
+ .dot.active { background: var(--accent); width: 16px; border-radius: 3px; }
58
+ .status-text { font-size: 9px; color: rgba(255,255,255,0.35); }
59
+
60
+ /* Panel */
61
+ .panel { padding: 16px; border-left: 1px solid rgba(255,255,255,0.05); overflow-y: auto; background: var(--bg2); display: flex; flex-direction: column; gap: 16px; }
62
+ .panel-section { }
63
+ .panel-label { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: var(--muted); margin-bottom: 6px; }
64
+ .step-number { font-size: 11px; color: var(--accent); font-weight: 700; margin-bottom: 2px; }
65
+ .step-title { font-size: 14px; font-weight: 600; line-height: 1.3; }
66
+ .step-desc { font-size: 12px; color: var(--muted); line-height: 1.5; margin-top: 4px; }
67
+
68
+ /* Annotation cards in panel */
69
+ .annot-cards { display: flex; flex-direction: column; gap: 6px; }
70
+ .annot-card { padding: 8px 10px; background: var(--bg3); border-radius: 8px; border-left: 3px solid var(--accent); cursor: pointer; transition: all 0.2s; }
71
+ .annot-card:hover { background: rgba(99,102,241,0.08); }
72
+ .annot-card-title { font-size: 11px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
73
+ .annot-card-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
74
+ .annot-card-desc { font-size: 10px; color: var(--muted); margin-top: 2px; line-height: 1.4; }
75
+
76
+ .baseline-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; padding: 3px 10px; border-radius: 12px; background: rgba(113,113,122,0.1); color: var(--muted); }
77
+ .baseline-badge.ok { background: rgba(34,197,94,0.1); color: var(--ok); }
78
+ .baseline-badge.warn { background: rgba(234,179,8,0.1); color: var(--warn); }
79
+ .bl-dot { width: 5px; height: 5px; border-radius: 50%; }
80
+
81
+ .footer { text-align: center; padding: 6px; font-size: 8px; color: rgba(255,255,255,0.12); }
82
+
83
+ /* Transitions */
84
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
85
+ .fade-in { animation: fadeIn 0.3s ease forwards; }
86
+ </style>
87
+ </head>
88
+ <body>
89
+ <div class="header">
90
+ <div class="header-left">
91
+ <div class="header-title" id="title"></div>
92
+ <div class="header-platform" id="platform"></div>
93
+ </div>
94
+ <div class="header-date" id="date"></div>
95
+ </div>
96
+ <div class="chips" id="chips"></div>
97
+ <div class="main">
98
+ <div class="viewer">
99
+ <div class="device-area" id="deviceArea" style="position: relative;">
100
+ <div class="side-annots left" id="annotsLeft"></div>
101
+ <div class="phone" id="phone"></div>
102
+ <div class="side-annots right" id="annotsRight"></div>
103
+ </div>
104
+ <div class="controls">
105
+ <button class="play-btn" id="playBtn">
106
+ <svg id="playIco" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="6 3 20 12 6 21"/></svg>
107
+ <svg id="pauseIco" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="display:none"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
108
+ </button>
109
+ <div class="dots" id="dots"></div>
110
+ <div class="status-text" id="statusText">playing</div>
111
+ </div>
112
+ </div>
113
+ <div class="panel">
114
+ <div class="panel-section">
115
+ <div class="panel-label">Step</div>
116
+ <div class="step-number" id="stepNum"></div>
117
+ <div class="step-title" id="stepTitle"></div>
118
+ <div class="step-desc" id="stepDesc"></div>
119
+ </div>
120
+ <div class="panel-section">
121
+ <div class="panel-label">Annotations</div>
122
+ <div class="annot-cards" id="annotCards"></div>
123
+ </div>
124
+ <div class="panel-section">
125
+ <div class="panel-label">Baseline</div>
126
+ <div class="baseline-badge" id="blBadge"><div class="bl-dot"></div><span></span></div>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ <div class="footer">Generated by DiscoveryLab</div>
131
+
132
+ <script>
133
+ const D = window.FLOW_DATA || { name: 'Flow', frames: [] };
134
+ const F = D.frames || [];
135
+ let cur = 0, playing = true, timer = null;
136
+
137
+ document.getElementById('title').textContent = D.name || 'App Flow';
138
+ document.getElementById('platform').textContent = D.platform || '';
139
+ document.getElementById('date').textContent = D.recorded_at ? new Date(D.recorded_at).toLocaleDateString() : '';
140
+
141
+ // Create images
142
+ const phone = document.getElementById('phone');
143
+ F.forEach((f, i) => {
144
+ const img = document.createElement('img');
145
+ img.src = f.base64;
146
+ img.className = i === 0 ? 'in' : 'out';
147
+ img.dataset.i = i;
148
+ phone.appendChild(img);
149
+ });
150
+
151
+ // Chips
152
+ const chips = document.getElementById('chips');
153
+ F.forEach((f, i) => {
154
+ const c = document.createElement('div');
155
+ c.className = `chip ${i===0?'active':''}`;
156
+ c.textContent = `${i+1}. ${f.step_name}`;
157
+ c.onclick = () => { go(i); stop(); };
158
+ chips.appendChild(c);
159
+ });
160
+
161
+ // Dots
162
+ const dots = document.getElementById('dots');
163
+ F.forEach((_, i) => {
164
+ const d = document.createElement('div');
165
+ d.className = `dot ${i===0?'active':''}`;
166
+ d.onclick = () => { go(i); stop(); };
167
+ dots.appendChild(d);
168
+ });
169
+
170
+ function go(i) {
171
+ cur = i;
172
+ phone.querySelectorAll('img').forEach((img, j) => { img.className = j===i?'in':'out'; });
173
+ chips.querySelectorAll('.chip').forEach((c, j) => c.classList.toggle('active', j===i));
174
+ dots.querySelectorAll('.dot').forEach((d, j) => d.classList.toggle('active', j===i));
175
+ updatePanel();
176
+ updateAnnotations();
177
+ }
178
+
179
+ function updatePanel() {
180
+ const f = F[cur]; if (!f) return;
181
+ document.getElementById('stepNum').textContent = `${cur+1} of ${F.length}`;
182
+ document.getElementById('stepTitle').textContent = f.step_name;
183
+ document.getElementById('stepDesc').textContent = f.description;
184
+
185
+ // Annotation cards
186
+ const cards = document.getElementById('annotCards');
187
+ cards.innerHTML = '';
188
+ (f.hotspots || []).forEach(h => {
189
+ const card = document.createElement('div');
190
+ card.className = 'annot-card fade-in';
191
+ card.style.borderLeftColor = h.color;
192
+ card.innerHTML = `<div class="annot-card-title"><div class="annot-card-dot" style="background:${h.color}"></div>${h.title}</div><div class="annot-card-desc">${h.description}</div>`;
193
+ cards.appendChild(card);
194
+ });
195
+
196
+ // Baseline
197
+ const bl = document.getElementById('blBadge');
198
+ const s = f.baseline_status || 'not_validated';
199
+ bl.className = `baseline-badge ${s==='ok'?'ok':s==='changed'?'warn':''}`;
200
+ bl.querySelector('.bl-dot').style.background = s==='ok'?'var(--ok)':s==='changed'?'var(--warn)':'var(--muted)';
201
+ bl.querySelector('span').textContent = s==='ok'?'Validated':s==='changed'?'Changed':'No baseline';
202
+ }
203
+
204
+ function updateAnnotations() {
205
+ const left = document.getElementById('annotsLeft');
206
+ const right = document.getElementById('annotsRight');
207
+ left.innerHTML = ''; right.innerHTML = '';
208
+
209
+ if (playing) {
210
+ left.classList.remove('show'); right.classList.remove('show');
211
+ return;
212
+ }
213
+ left.classList.add('show'); right.classList.add('show');
214
+
215
+ const f = F[cur]; if (!f?.hotspots?.length) return;
216
+
217
+ f.hotspots.forEach((h, j) => {
218
+ const container = j % 2 === 0 ? left : right;
219
+ const el = document.createElement('div');
220
+ el.className = 'side-annot fade-in';
221
+ el.style.animationDelay = (j * 0.1) + 's';
222
+ el.innerHTML = `
223
+ <div class="side-annot-title"><div class="side-annot-dot" style="background:${h.color}"></div>${h.label}</div>
224
+ ${h.description !== h.label ? `<div class="side-annot-desc">${h.description.slice(0, 60)}</div>` : ''}
225
+ `;
226
+ container.appendChild(el);
227
+ });
228
+ }
229
+
230
+ function play() {
231
+ playing = true;
232
+ document.getElementById('playIco').style.display = 'none';
233
+ document.getElementById('pauseIco').style.display = '';
234
+ document.getElementById('statusText').textContent = 'playing';
235
+ document.getElementById('annotsLeft').classList.remove('show');
236
+ document.getElementById('annotsRight').classList.remove('show');
237
+ timer = setInterval(() => { cur = (cur+1)%F.length; go(cur); }, 2200);
238
+ }
239
+
240
+ function stop() {
241
+ playing = false; clearInterval(timer);
242
+ document.getElementById('playIco').style.display = '';
243
+ document.getElementById('pauseIco').style.display = 'none';
244
+ document.getElementById('statusText').textContent = 'paused';
245
+ updateAnnotations();
246
+ }
247
+
248
+ document.getElementById('playBtn').onclick = () => playing ? stop() : play();
249
+
250
+ updatePanel();
251
+ play();
252
+ </script>
253
+ </body>
254
+ </html>
@@ -0,0 +1,180 @@
1
+ import "./chunk-R5U7XKVJ.js";
2
+
3
+ // src/core/export/import.ts
4
+ import { existsSync, mkdirSync, readFileSync, cpSync, rmSync, readdirSync, statSync } from "fs";
5
+ import { join, basename } from "path";
6
+ import { execSync } from "child_process";
7
+ import { randomUUID } from "crypto";
8
+ function extractZip(zipPath, targetDir) {
9
+ mkdirSync(targetDir, { recursive: true });
10
+ if (process.platform === "darwin") {
11
+ try {
12
+ execSync(`ditto -xk "${zipPath}" "${targetDir}"`, { stdio: "pipe" });
13
+ return;
14
+ } catch {
15
+ }
16
+ }
17
+ execSync(`unzip -qo "${zipPath}" -d "${targetDir}"`, { stdio: "pipe" });
18
+ }
19
+ function findBundleRoot(extractDir) {
20
+ const entries = readdirSync(extractDir);
21
+ if (entries.length === 1) {
22
+ const candidate = join(extractDir, entries[0]);
23
+ if (statSync(candidate).isDirectory()) return candidate;
24
+ }
25
+ if (entries.includes("manifest.json")) return extractDir;
26
+ for (const entry of entries) {
27
+ const dir = join(extractDir, entry);
28
+ if (statSync(dir).isDirectory() && existsSync(join(dir, "manifest.json"))) {
29
+ return dir;
30
+ }
31
+ }
32
+ return extractDir;
33
+ }
34
+ async function importApplabBundle(zipPath, db, schema, paths) {
35
+ if (!existsSync(zipPath)) {
36
+ return { success: false, error: `File not found: ${zipPath}` };
37
+ }
38
+ const tempDir = join(paths.dataDir, ".import-temp-" + Date.now());
39
+ try {
40
+ extractZip(zipPath, tempDir);
41
+ const bundleRoot = findBundleRoot(tempDir);
42
+ const manifestPath = join(bundleRoot, "manifest.json");
43
+ if (!existsSync(manifestPath)) {
44
+ return { success: false, error: "Invalid .applab file: manifest.json not found" };
45
+ }
46
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
47
+ let projectData = {};
48
+ const projectJsonPath = join(bundleRoot, "metadata", "project.json");
49
+ if (existsSync(projectJsonPath)) {
50
+ projectData = JSON.parse(readFileSync(projectJsonPath, "utf-8"));
51
+ }
52
+ const projectId = manifest.id || projectData.id || randomUUID();
53
+ const projectName = manifest.name || projectData.name || basename(zipPath, ".applab");
54
+ const { eq } = await import("drizzle-orm");
55
+ const existing = await db.select().from(schema.projects).where(eq(schema.projects.id, projectId)).limit(1);
56
+ if (existing.length > 0) {
57
+ return { success: false, error: `Project already exists: ${projectName} (${projectId}). Delete it first to re-import.` };
58
+ }
59
+ let frameCount = 0;
60
+ const framesSourceDir = join(bundleRoot, "frames");
61
+ const framesTargetDir = join(paths.framesDir, projectId);
62
+ if (existsSync(framesSourceDir)) {
63
+ mkdirSync(framesTargetDir, { recursive: true });
64
+ cpSync(framesSourceDir, framesTargetDir, { recursive: true });
65
+ frameCount = readdirSync(framesTargetDir).filter((f) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)).length;
66
+ }
67
+ const projectTargetDir = join(paths.projectsDir, projectId);
68
+ mkdirSync(projectTargetDir, { recursive: true });
69
+ const mediaDir = join(bundleRoot, "media");
70
+ if (existsSync(mediaDir)) {
71
+ cpSync(mediaDir, projectTargetDir, { recursive: true });
72
+ }
73
+ const recordingDir = join(bundleRoot, "recording");
74
+ if (existsSync(recordingDir)) {
75
+ cpSync(recordingDir, projectTargetDir, { recursive: true });
76
+ }
77
+ let aiSummary = null;
78
+ let ocrText = null;
79
+ const aiPath = join(bundleRoot, "analysis", "app-intelligence.md");
80
+ const ocrPath = join(bundleRoot, "analysis", "ocr.txt");
81
+ if (existsSync(aiPath)) aiSummary = readFileSync(aiPath, "utf-8");
82
+ if (existsSync(ocrPath)) ocrText = readFileSync(ocrPath, "utf-8");
83
+ let taskHubLinks = null;
84
+ const linksPath = join(bundleRoot, "taskhub", "links.json");
85
+ if (existsSync(linksPath)) {
86
+ taskHubLinks = readFileSync(linksPath, "utf-8");
87
+ }
88
+ let videoPath = null;
89
+ const videoExts = [".mp4", ".mov", ".webm"];
90
+ for (const ext of videoExts) {
91
+ const files = readdirSync(projectTargetDir).filter((f) => f.endsWith(ext));
92
+ if (files.length > 0) {
93
+ videoPath = join(projectTargetDir, files[0]);
94
+ break;
95
+ }
96
+ }
97
+ if (!videoPath) videoPath = projectTargetDir;
98
+ let thumbnailPath = null;
99
+ if (frameCount > 0) {
100
+ const firstFrame = readdirSync(framesTargetDir).filter((f) => /\.(png|jpg|jpeg|webp)$/i.test(f)).sort()[0];
101
+ if (firstFrame) thumbnailPath = join(framesTargetDir, firstFrame);
102
+ }
103
+ const now = /* @__PURE__ */ new Date();
104
+ await db.insert(schema.projects).values({
105
+ id: projectId,
106
+ name: projectName,
107
+ marketingTitle: projectData.marketingTitle || projectName,
108
+ marketingDescription: projectData.marketingDescription || null,
109
+ videoPath,
110
+ thumbnailPath,
111
+ platform: manifest.platform || projectData.platform || null,
112
+ aiSummary,
113
+ ocrText,
114
+ ocrEngine: projectData.ocrEngine || null,
115
+ ocrConfidence: projectData.ocrConfidence || null,
116
+ frameCount,
117
+ duration: projectData.duration || null,
118
+ manualNotes: projectData.manualNotes || null,
119
+ tags: projectData.tags || null,
120
+ linkedTicket: projectData.linkedTicket || null,
121
+ linkedJiraUrl: projectData.linkedJiraUrl || null,
122
+ linkedNotionUrl: projectData.linkedNotionUrl || null,
123
+ linkedFigmaUrl: projectData.linkedFigmaUrl || null,
124
+ taskHubLinks,
125
+ taskRequirements: projectData.taskRequirements || null,
126
+ taskTestMap: projectData.taskTestMap || null,
127
+ status: aiSummary ? "analyzed" : "draft",
128
+ createdAt: projectData.createdAt ? new Date(projectData.createdAt) : now,
129
+ updatedAt: now
130
+ });
131
+ if (frameCount > 0) {
132
+ const frameFiles = readdirSync(framesTargetDir).filter((f) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)).sort();
133
+ let frameAnalysis = {};
134
+ const framesJsonPath = join(bundleRoot, "analysis", "frames.json");
135
+ if (existsSync(framesJsonPath)) {
136
+ try {
137
+ frameAnalysis = JSON.parse(readFileSync(framesJsonPath, "utf-8"));
138
+ } catch {
139
+ }
140
+ }
141
+ for (let i = 0; i < frameFiles.length; i++) {
142
+ const frameId = `${projectId}-frame-${i}`;
143
+ const framePath = join(framesTargetDir, frameFiles[i]);
144
+ const frameOcr = frameAnalysis[`frame-${i}`]?.ocrText || null;
145
+ await db.insert(schema.frames).values({
146
+ id: frameId,
147
+ projectId,
148
+ frameNumber: i,
149
+ timestamp: i,
150
+ // approximate
151
+ imagePath: framePath,
152
+ ocrText: frameOcr,
153
+ isKeyFrame: i === 0,
154
+ createdAt: now
155
+ });
156
+ }
157
+ }
158
+ return {
159
+ success: true,
160
+ projectId,
161
+ projectName,
162
+ frameCount
163
+ };
164
+ } catch (error) {
165
+ return {
166
+ success: false,
167
+ error: error instanceof Error ? error.message : String(error)
168
+ };
169
+ } finally {
170
+ if (existsSync(tempDir)) {
171
+ try {
172
+ rmSync(tempDir, { recursive: true, force: true });
173
+ } catch {
174
+ }
175
+ }
176
+ }
177
+ }
178
+ export {
179
+ importApplabBundle
180
+ };