heyiam 0.3.7 → 0.3.10
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/archive.js +28 -0
- package/dist/db.js +9 -0
- package/dist/export.js +3 -0
- package/dist/index.js +27 -10
- package/dist/mount.js +43 -18
- package/dist/public/assets/index-DU5On5Al.js +37 -0
- package/dist/public/index.html +1 -1
- package/dist/render/build-render-data.js +1 -0
- package/dist/render/liquid.js +14 -1
- package/dist/render/templates/bauhaus/project.liquid +2 -2
- package/dist/render/templates/editorial/project.liquid +1 -1
- package/dist/render/templates/glacier/project.liquid +3 -3
- package/dist/render/templates/kinetic/project.liquid +1 -1
- package/dist/render/templates/minimal/project.liquid +1 -1
- package/dist/render/templates/paper/project.liquid +2 -2
- package/dist/render/templates/partials/_work-timeline.liquid +1 -1
- package/dist/render/templates/project.liquid +1 -1
- package/dist/render/templates/radar/project.liquid +1 -1
- package/dist/render/templates/showcase/project.liquid +1 -1
- package/dist/render/templates/terminal/project.liquid +1 -1
- package/dist/routes/context.js +26 -3
- package/dist/routes/delete.js +18 -1
- package/dist/routes/enhance.js +10 -3
- package/dist/routes/preview.js +16 -0
- package/dist/routes/project-session-upload.js +63 -1
- package/dist/routes/projects.js +8 -1
- package/dist/routes/publish.js +34 -5
- package/dist/routes/sessions.js +36 -11
- package/dist/settings.js +2 -0
- package/package.json +1 -1
- package/dist/public/assets/index-BDh4ne9u.js +0 -37
package/dist/public/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>app</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DU5On5Al.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-ByoBtx7P.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/dist/render/liquid.js
CHANGED
|
@@ -110,7 +110,20 @@ export function renderProject(data, extras, templateName) {
|
|
|
110
110
|
const slug = typeof existingSlug === 'string' && existingSlug !== ''
|
|
111
111
|
? existingSlug
|
|
112
112
|
: (slugByToken.get(id) ?? '');
|
|
113
|
-
|
|
113
|
+
const merged = { ...rest, slug, rawLog: [] };
|
|
114
|
+
// When dates are hidden, strip every timestamp from the session so
|
|
115
|
+
// nothing leaks through the page source. The chart and overlay treat
|
|
116
|
+
// a missing `date` as "ordinal mode" — no axis labels, no gap markers.
|
|
117
|
+
if (data.hideSessionDates) {
|
|
118
|
+
delete merged.date;
|
|
119
|
+
delete merged.endTime;
|
|
120
|
+
const children = merged.children;
|
|
121
|
+
if (Array.isArray(children)) {
|
|
122
|
+
for (const c of children)
|
|
123
|
+
delete c.date;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return merged;
|
|
114
127
|
});
|
|
115
128
|
// Encode JSON safe for single-quoted HTML attributes
|
|
116
129
|
const sessionsJson = JSON.stringify(chartSessions).replace(/'/g, ''');
|
|
@@ -218,7 +218,7 @@
|
|
|
218
218
|
<tr>
|
|
219
219
|
<th scope="col">#</th>
|
|
220
220
|
<th scope="col">Session</th>
|
|
221
|
-
<th scope="col">Date</th>
|
|
221
|
+
{% unless hideSessionDates %}<th scope="col">Date</th>{% endunless %}
|
|
222
222
|
<th scope="col">Duration</th>
|
|
223
223
|
<th scope="col">LOC</th>
|
|
224
224
|
<th scope="col">Source</th>
|
|
@@ -230,7 +230,7 @@
|
|
|
230
230
|
<tr>
|
|
231
231
|
<td><span class="session-num">{{ forloop.index }}</span></td>
|
|
232
232
|
<td><a href="{{ sessionBaseUrl }}/{{ s.slug }}{{ sessionSuffix }}" class="session-title-link">{{ s.title }}</a></td>
|
|
233
|
-
<td>{{ s.recordedAt | formatDateShort }}</td>
|
|
233
|
+
{% unless hideSessionDates %}<td>{{ s.recordedAt | formatDateShort }}</td>{% endunless %}
|
|
234
234
|
<td>{{ s.durationMinutes | formatDuration }}</td>
|
|
235
235
|
<td>{{ s.locChanged | localeNumber }}</td>
|
|
236
236
|
<td>{% if s.sourceTool %}<span class="session-source-badge badge-{{ s.sourceTool | downcase | replace: ' ', '-' }}">{{ s.sourceTool }}</span>{% endif %}</td>
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
{% if sessionsJson %}
|
|
91
91
|
<section class="ed-card-section" aria-label="Agent work timeline">
|
|
92
92
|
<h2 class="ed-section-title">Work Timeline</h2>
|
|
93
|
-
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
|
|
93
|
+
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
|
|
94
94
|
</section>
|
|
95
95
|
{% endif %}
|
|
96
96
|
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
{% assign labelY = barY | minus: 7 %}
|
|
132
132
|
<rect x="{{ barX }}" y="{{ barY }}" width="{{ barWidth }}" height="{{ barH }}" class="chart-bar" aria-label="{{ s.title }}: {{ s.locChanged }} LOC"/>
|
|
133
133
|
<text x="{{ labelX }}" y="{{ labelY }}" class="chart-value" text-anchor="middle">{{ s.locChanged }}</text>
|
|
134
|
-
<text x="{{ labelX }}" y="180" class="chart-label" text-anchor="middle">{{ s.recordedAt | formatDateShort }}</text>
|
|
134
|
+
<text x="{{ labelX }}" y="180" class="chart-label" text-anchor="middle">{% if hideSessionDates %}#{{ forloop.index }}{% else %}{{ s.recordedAt | formatDateShort }}{% endif %}</text>
|
|
135
135
|
{% endfor %}
|
|
136
136
|
</svg>
|
|
137
137
|
</div>
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
<tr>
|
|
224
224
|
<th scope="col">#</th>
|
|
225
225
|
<th scope="col">Title</th>
|
|
226
|
-
<th scope="col">Date</th>
|
|
226
|
+
{% unless hideSessionDates %}<th scope="col">Date</th>{% endunless %}
|
|
227
227
|
<th scope="col">Duration</th>
|
|
228
228
|
<th scope="col">LOC</th>
|
|
229
229
|
<th scope="col">Source</th>
|
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
<tr>
|
|
236
236
|
<td>{{ forloop.index }}</td>
|
|
237
237
|
<td><a href="{{ sessionBaseUrl }}/{{ s.slug }}{{ sessionSuffix }}" class="session-title-link">{{ s.title }}</a></td>
|
|
238
|
-
<td>{{ s.recordedAt | formatDateShort }}</td>
|
|
238
|
+
{% unless hideSessionDates %}<td>{{ s.recordedAt | formatDateShort }}</td>{% endunless %}
|
|
239
239
|
<td>{{ s.durationMinutes | formatDuration }}</td>
|
|
240
240
|
<td>{{ s.locChanged | localeNumber }}</td>
|
|
241
241
|
<td>{% if s.sourceTool %}<span class="session-source-badge">{{ s.sourceTool }}</span>{% endif %}</td>
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
<div class="section-tag">Agent work</div>
|
|
108
108
|
<h2 class="section-title">Work timeline</h2>
|
|
109
109
|
<div class="timeline-container">
|
|
110
|
-
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
|
|
110
|
+
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
|
|
111
111
|
</div>
|
|
112
112
|
</section>
|
|
113
113
|
{% endif %}
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
{% if sessionsJson %}
|
|
56
56
|
<div class="mn-section">
|
|
57
57
|
<h2 class="mn-section-heading">Work Timeline</h2>
|
|
58
|
-
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
|
|
58
|
+
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
|
|
59
59
|
</div>
|
|
60
60
|
<hr class="mn-rule" />
|
|
61
61
|
{% endif %}
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
<tr>
|
|
101
101
|
<th scope="col">#</th>
|
|
102
102
|
<th scope="col">Title</th>
|
|
103
|
-
<th scope="col">Date</th>
|
|
103
|
+
{% unless hideSessionDates %}<th scope="col">Date</th>{% endunless %}
|
|
104
104
|
<th scope="col" class="text-right">Duration</th>
|
|
105
105
|
<th scope="col" class="text-right">LOC</th>
|
|
106
106
|
<th scope="col">Source</th>
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
<tr>
|
|
115
115
|
<td>{{ forloop.index }}</td>
|
|
116
116
|
<td>{{ s.title }}</td>
|
|
117
|
-
<td>{{ s.recordedAt | formatDateShort }}</td>
|
|
117
|
+
{% unless hideSessionDates %}<td>{{ s.recordedAt | formatDateShort }}</td>{% endunless %}
|
|
118
118
|
<td class="text-right">{{ s.durationMinutes | formatDuration }}</td>
|
|
119
119
|
<td class="text-right">{{ s.locChanged | localeNumber }}</td>
|
|
120
120
|
<td>{{ s.sourceTool }}</td>
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
<h3 class="section-header__title">Work timeline</h3>
|
|
4
4
|
<span class="section-header__meta">sessions over time</span>
|
|
5
5
|
</div>
|
|
6
|
-
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
|
|
6
|
+
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
|
|
7
7
|
</div>
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
<h3 class="section-header__title">Work timeline</h3>
|
|
95
95
|
<span class="section-header__meta">sessions over time</span>
|
|
96
96
|
</div>
|
|
97
|
-
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
|
|
97
|
+
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
|
|
98
98
|
</div>
|
|
99
99
|
|
|
100
100
|
{%- comment -%} Growth Chart mount point {%- endcomment -%}
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
<section class="radar-section" aria-label="Work timeline">
|
|
104
104
|
<div class="radar-label">Work Timeline</div>
|
|
105
105
|
<div class="chart-card">
|
|
106
|
-
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
|
|
106
|
+
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
|
|
107
107
|
</div>
|
|
108
108
|
</section>
|
|
109
109
|
{% endif %}
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
<h3>Work Timeline</h3>
|
|
91
91
|
<span class="sc-section-label">sessions over time</span>
|
|
92
92
|
</div>
|
|
93
|
-
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
|
|
93
|
+
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
|
|
94
94
|
</div>
|
|
95
95
|
</section>
|
|
96
96
|
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
{% if sessionsJson %}
|
|
70
70
|
<div class="term-section">
|
|
71
71
|
<div class="term-comment"># work timeline</div>
|
|
72
|
-
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
|
|
72
|
+
<div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
|
|
73
73
|
</div>
|
|
74
74
|
{% endif %}
|
|
75
75
|
|
package/dist/routes/context.js
CHANGED
|
@@ -9,8 +9,8 @@ import { bridgeToAnalyzer, mergeActiveIntervals, sumIntervalMs } from '../bridge
|
|
|
9
9
|
import { analyzeSession } from '../analyzer.js';
|
|
10
10
|
import { loadEnhancedData, loadProjectEnhanceResult, getUploadedState, } from '../settings.js';
|
|
11
11
|
import { getTemplateCss } from '../render/templates.js';
|
|
12
|
-
import { archiveSessionFiles } from '../archive.js';
|
|
13
|
-
import { getDatabase, openDatabase, getSessionStats as dbGetSessionStats, getSessionCount, getAllSessionMetas, getAllProjectStats, getSessionsByProject, getProjectUuid, } from '../db.js';
|
|
12
|
+
import { archiveSessionFiles, findReadableSessionPath } from '../archive.js';
|
|
13
|
+
import { getDatabase, openDatabase, getSessionStats as dbGetSessionStats, getSessionCount, getAllSessionMetas, getAllProjectStats, getSessionsByProject, getProjectUuid, getSessionRow, updateSessionPath, } from '../db.js';
|
|
14
14
|
import { ensureSessionIndexed, displayNameFromDir } from '../sync.js';
|
|
15
15
|
export { displayNameFromDir };
|
|
16
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -298,11 +298,34 @@ export function createRouteContext(sessionsBasePath, dbPath) {
|
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
async function loadSession(sessionPath, projectName, sessionId) {
|
|
301
|
-
const
|
|
301
|
+
const readablePath = await resolveSessionReadPath(sessionPath, sessionId);
|
|
302
|
+
const parsed = await parseSession(readablePath);
|
|
302
303
|
const analyzerInput = bridgeToAnalyzer(parsed, { sessionId, projectName });
|
|
303
304
|
const session = analyzeSession(analyzerInput);
|
|
304
305
|
return mergeEnhancedData(session);
|
|
305
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Returns a path to a readable session file. If the originally-indexed
|
|
309
|
+
* path still exists, returns it unchanged. Otherwise falls back to the
|
|
310
|
+
* archive copy and heals the DB cache so future reads skip the lookup.
|
|
311
|
+
*
|
|
312
|
+
* This compensates for source tools (notably Claude Code's 30-day
|
|
313
|
+
* cleanup) deleting session files after we've indexed them.
|
|
314
|
+
*/
|
|
315
|
+
async function resolveSessionReadPath(originalPath, sessionId) {
|
|
316
|
+
const row = getSessionRow(db, sessionId);
|
|
317
|
+
const projectDir = row?.project_dir;
|
|
318
|
+
if (!projectDir)
|
|
319
|
+
return originalPath;
|
|
320
|
+
const readable = await findReadableSessionPath(originalPath, projectDir);
|
|
321
|
+
if (!readable)
|
|
322
|
+
return originalPath;
|
|
323
|
+
if (readable !== originalPath) {
|
|
324
|
+
updateSessionPath(db, sessionId, readable);
|
|
325
|
+
console.log(`[loadSession] Healed stale path for ${sessionId} → archive copy`);
|
|
326
|
+
}
|
|
327
|
+
return readable;
|
|
328
|
+
}
|
|
306
329
|
// ── getSessionStats ──────────────────────────────────────
|
|
307
330
|
async function getSessionStats(meta, projectName) {
|
|
308
331
|
try {
|
package/dist/routes/delete.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|
|
2
2
|
import { getAuthToken } from '../auth.js';
|
|
3
3
|
import { API_URL, warnIfNonDefaultApiUrl } from '../config.js';
|
|
4
4
|
import { getUploadedState, clearUploadedState, saveUploadedState, loadEnhancedData, saveEnhancedData, } from '../settings.js';
|
|
5
|
+
import { toSlug } from '../format-utils.js';
|
|
5
6
|
function sendError(res, status, error) {
|
|
6
7
|
res.status(status).json({ error });
|
|
7
8
|
}
|
|
@@ -139,7 +140,23 @@ export function createDeleteRouter(_ctx) {
|
|
|
139
140
|
return;
|
|
140
141
|
}
|
|
141
142
|
try {
|
|
142
|
-
|
|
143
|
+
// Phoenix DELETE /api/sessions/:id expects an integer share ID,
|
|
144
|
+
// which the CLI never has — POST /api/sessions returns a token,
|
|
145
|
+
// not the DB row id. So we send the Claude UUID in the path
|
|
146
|
+
// (Phoenix accepts it as an opaque marker) and the real lookup
|
|
147
|
+
// key — (project_id, slug) — in query params. The server resolves
|
|
148
|
+
// by (project_id, slug) when the path :id isn't a valid integer.
|
|
149
|
+
const uploadedState = getUploadedState(project);
|
|
150
|
+
const enhanced = loadEnhancedData(sessionId);
|
|
151
|
+
const slug = toSlug(enhanced?.title ?? sessionId, 80);
|
|
152
|
+
const serverProjectId = uploadedState?.projectId;
|
|
153
|
+
const query = new URLSearchParams();
|
|
154
|
+
if (serverProjectId !== undefined)
|
|
155
|
+
query.set('project_id', String(serverProjectId));
|
|
156
|
+
if (slug)
|
|
157
|
+
query.set('slug', slug);
|
|
158
|
+
const qs = query.toString();
|
|
159
|
+
const phoenixRes = await fetch(`${API_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}`, {
|
|
143
160
|
method: 'DELETE',
|
|
144
161
|
headers: { Authorization: `Bearer ${auth.token}` },
|
|
145
162
|
});
|
package/dist/routes/enhance.js
CHANGED
|
@@ -5,7 +5,7 @@ import { enhanceProject, refineNarrative } from '../llm/project-enhance.js';
|
|
|
5
5
|
import { getAnthropicApiKey, saveEnhancedData, loadEnhancedData, deleteEnhancedData, loadFreshProjectEnhanceResult, saveProjectEnhanceResult, loadProjectEnhanceResult, buildProjectFingerprint, getUploadedState, } from '../settings.js';
|
|
6
6
|
import { requireProject } from './context.js';
|
|
7
7
|
import { startSSE } from './sse.js';
|
|
8
|
-
import { invalidatePortfolioPreviewCache } from './preview.js';
|
|
8
|
+
import { invalidatePortfolioPreviewCache, invalidateProjectPreviewCache } from './preview.js';
|
|
9
9
|
export function createEnhanceRouter(ctx) {
|
|
10
10
|
const router = Router();
|
|
11
11
|
// Triage endpoint -- AI selects which sessions are worth showcasing (SSE stream)
|
|
@@ -259,7 +259,11 @@ export function createEnhanceRouter(ctx) {
|
|
|
259
259
|
// Save project enhance result explicitly
|
|
260
260
|
router.post('/api/projects/:project/enhance-save', async (req, res) => {
|
|
261
261
|
const project = String(req.params.project);
|
|
262
|
-
|
|
262
|
+
// FIXME(security): screenshotBase64 is accepted with no size cap and no
|
|
263
|
+
// `data:image/(png|jpeg|jpg|webp);base64,...` shape check. Local-only CLI
|
|
264
|
+
// so not a privilege issue today, but worth a regex + ~4 MB cap for
|
|
265
|
+
// defense-in-depth and to keep the cache JSON from ballooning.
|
|
266
|
+
const { selectedSessionIds, result, title, repoUrl, projectUrl, screenshotBase64, hideSessionDates } = req.body;
|
|
263
267
|
if (!Array.isArray(selectedSessionIds) || !result?.narrative) {
|
|
264
268
|
res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds and result are required' } });
|
|
265
269
|
return;
|
|
@@ -272,9 +276,12 @@ export function createEnhanceRouter(ctx) {
|
|
|
272
276
|
const proj = await requireProject(ctx, project, res);
|
|
273
277
|
if (!proj)
|
|
274
278
|
return;
|
|
275
|
-
saveProjectEnhanceResult(proj.dirName, selectedSessionIds, result, undefined, { title, repoUrl, projectUrl, screenshotBase64 });
|
|
279
|
+
saveProjectEnhanceResult(proj.dirName, selectedSessionIds, result, undefined, { title, repoUrl, projectUrl, screenshotBase64, hideSessionDates });
|
|
276
280
|
// Project title/narrative/skills appear in portfolio listing — bust cache.
|
|
277
281
|
invalidatePortfolioPreviewCache();
|
|
282
|
+
// Per-project render cache is keyed by the URL param the client passed.
|
|
283
|
+
invalidateProjectPreviewCache(project);
|
|
284
|
+
invalidateProjectPreviewCache(proj.dirName);
|
|
278
285
|
res.json({ saved: true, enhancedAt: new Date().toISOString() });
|
|
279
286
|
}
|
|
280
287
|
catch (err) {
|
package/dist/routes/preview.js
CHANGED
|
@@ -54,6 +54,15 @@ function portfolioCacheKey(templateName) {
|
|
|
54
54
|
export function invalidatePortfolioPreviewCache() {
|
|
55
55
|
portfolioPreviewCache.clear();
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Drop the per-project render-data cache for `projectParam`. Call after any
|
|
59
|
+
* mutation that affects the rendered project page (title, URLs, narrative,
|
|
60
|
+
* screenshot, skills, timeline). Without this, the 30s TTL on
|
|
61
|
+
* previewDataCache masks the change in the React UI.
|
|
62
|
+
*/
|
|
63
|
+
export function invalidateProjectPreviewCache(projectParam) {
|
|
64
|
+
previewDataCache.delete(projectParam);
|
|
65
|
+
}
|
|
57
66
|
/** Test helper: read current cache entry without mutating. */
|
|
58
67
|
export function _getPortfolioPreviewCacheEntry(templateName = 'editorial') {
|
|
59
68
|
return portfolioPreviewCache.get(portfolioCacheKey(templateName));
|
|
@@ -177,6 +186,12 @@ async function buildProjectPreviewData(ctx, projectParam, queryOverrides) {
|
|
|
177
186
|
repoUrl: metaRepoUrl,
|
|
178
187
|
projectUrl: metaProjectUrl,
|
|
179
188
|
screenshotUrl: (() => {
|
|
189
|
+
// Manual uploads land only in the enhance-cache JSON (no disk write),
|
|
190
|
+
// so check the cache first — mirrors export.ts:resolveScreenshotDataUri.
|
|
191
|
+
const b64 = cachedAny?.screenshotBase64;
|
|
192
|
+
if (b64) {
|
|
193
|
+
return b64.startsWith('data:') ? b64 : `data:image/png;base64,${b64}`;
|
|
194
|
+
}
|
|
180
195
|
return existsSync(path.join(SCREENSHOTS_DIR, `${slug}.png`))
|
|
181
196
|
? `/screenshots/${slug}.png`
|
|
182
197
|
: undefined;
|
|
@@ -193,6 +208,7 @@ async function buildProjectPreviewData(ctx, projectParam, queryOverrides) {
|
|
|
193
208
|
allSessionCards,
|
|
194
209
|
sessionBaseUrl: `/preview/project/${encodeURIComponent(projectParam)}/session`,
|
|
195
210
|
sessionSuffix: '.html',
|
|
211
|
+
hideSessionDates: cachedAny?.hideSessionDates,
|
|
196
212
|
});
|
|
197
213
|
const result = { renderData, enhanceResult, projName: projAny.name };
|
|
198
214
|
// Cache the result (template-agnostic data, re-rendered cheaply per template)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { API_URL } from '../config.js';
|
|
7
|
-
import { loadEnhancedData, saveEnhancedData, getDefaultTemplate, isTranscriptIncluded, } from '../settings.js';
|
|
7
|
+
import { loadEnhancedData, saveEnhancedData, getDefaultTemplate, isTranscriptIncluded, getUploadedState, } from '../settings.js';
|
|
8
8
|
import { redactSession, redactText, scanTextSync, formatFindings, stripHomePathsInText } from '../redact.js';
|
|
9
9
|
import { renderSessionHtml } from '../render/index.js';
|
|
10
10
|
import { buildSessionRenderData, buildSessionCard } from '../render/build-render-data.js';
|
|
@@ -222,3 +222,65 @@ export async function uploadSelectedSessions(ctx, auth, options) {
|
|
|
222
222
|
}
|
|
223
223
|
return { uploadedCount, failedSessions, uploadedSessionCards };
|
|
224
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Delete previously-uploaded sessions that are no longer in the user's
|
|
227
|
+
* selected set. Called before re-uploading so the published portfolio
|
|
228
|
+
* reflects deselections made through the Manage Sessions modal.
|
|
229
|
+
*
|
|
230
|
+
* Identifies each session on the server by (project_id, slug) — the same
|
|
231
|
+
* fallback contract used by the single-session trash button. Failures
|
|
232
|
+
* are logged but non-fatal: the upload continues so partial cleanup
|
|
233
|
+
* doesn't block the user's primary action.
|
|
234
|
+
*/
|
|
235
|
+
export async function demoteRemovedSessions(auth, options) {
|
|
236
|
+
const { projectDirName, selectedSessionIds, send } = options;
|
|
237
|
+
const notify = send ?? (() => { });
|
|
238
|
+
const uploaded = getUploadedState(projectDirName);
|
|
239
|
+
if (!uploaded || !uploaded.projectId) {
|
|
240
|
+
return { demotedCount: 0, failed: [] };
|
|
241
|
+
}
|
|
242
|
+
const selected = new Set(selectedSessionIds);
|
|
243
|
+
const removed = (uploaded.uploadedSessions ?? []).filter((id) => !selected.has(id));
|
|
244
|
+
if (removed.length === 0) {
|
|
245
|
+
return { demotedCount: 0, failed: [] };
|
|
246
|
+
}
|
|
247
|
+
let demotedCount = 0;
|
|
248
|
+
const failed = [];
|
|
249
|
+
for (const sessionId of removed) {
|
|
250
|
+
const enhanced = loadEnhancedData(sessionId);
|
|
251
|
+
const slug = toSlug(enhanced?.title ?? sessionId, 80);
|
|
252
|
+
notify({ type: 'session', sessionId, status: 'demoting' });
|
|
253
|
+
try {
|
|
254
|
+
const query = new URLSearchParams({
|
|
255
|
+
project_id: String(uploaded.projectId),
|
|
256
|
+
slug,
|
|
257
|
+
});
|
|
258
|
+
const res = await fetch(`${API_URL}/api/sessions/${encodeURIComponent(sessionId)}?${query.toString()}`, {
|
|
259
|
+
method: 'DELETE',
|
|
260
|
+
headers: { Authorization: `Bearer ${auth.token}` },
|
|
261
|
+
});
|
|
262
|
+
if (res.status === 204 || res.status === 404) {
|
|
263
|
+
// 404 = already gone server-side; either way, the local state
|
|
264
|
+
// should reflect "not uploaded" so the next re-publish doesn't
|
|
265
|
+
// try to demote it again.
|
|
266
|
+
if (enhanced?.uploaded) {
|
|
267
|
+
saveEnhancedData(sessionId, { ...enhanced, uploaded: false });
|
|
268
|
+
}
|
|
269
|
+
demotedCount++;
|
|
270
|
+
notify({ type: 'session', sessionId, status: 'demoted' });
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
const body = await res.text().catch(() => '');
|
|
274
|
+
const error = `HTTP ${res.status}: ${body.slice(0, 200)}`;
|
|
275
|
+
failed.push({ sessionId, error });
|
|
276
|
+
notify({ type: 'session', sessionId, status: 'demote_failed', error });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
const error = err.message;
|
|
281
|
+
failed.push({ sessionId, error });
|
|
282
|
+
notify({ type: 'session', sessionId, status: 'demote_failed', error });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { demotedCount, failed };
|
|
286
|
+
}
|
package/dist/routes/projects.js
CHANGED
|
@@ -217,7 +217,14 @@ export function createProjectsRouter(ctx) {
|
|
|
217
217
|
res.status(400).json({ error: { code: 'NO_CACHE', message: 'Project must be enhanced before managing sessions' } });
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
220
|
-
saveProjectEnhanceResult(proj.dirName, selectedSessionIds, cache.result, undefined, {
|
|
220
|
+
saveProjectEnhanceResult(proj.dirName, selectedSessionIds, cache.result, undefined, {
|
|
221
|
+
title: cache.title,
|
|
222
|
+
repoUrl: cache.repoUrl,
|
|
223
|
+
projectUrl: cache.projectUrl,
|
|
224
|
+
screenshotBase64: cache.screenshotBase64,
|
|
225
|
+
template: cache.template,
|
|
226
|
+
hideSessionDates: cache.hideSessionDates,
|
|
227
|
+
});
|
|
221
228
|
invalidatePortfolioPreviewCache();
|
|
222
229
|
res.json({ ok: true, selectedSessionIds });
|
|
223
230
|
}
|
package/dist/routes/publish.js
CHANGED
|
@@ -12,7 +12,7 @@ import { buildProjectDetail } from './context.js';
|
|
|
12
12
|
import { captureScreenshot } from '../screenshot.js';
|
|
13
13
|
import { renderProjectHtml } from '../render/index.js';
|
|
14
14
|
import { buildProjectRenderData } from '../render/build-render-data.js';
|
|
15
|
-
import { uploadSelectedSessions } from './project-session-upload.js';
|
|
15
|
+
import { uploadSelectedSessions, demoteRemovedSessions } from './project-session-upload.js';
|
|
16
16
|
import { invalidatePortfolioPreviewCache } from './preview.js';
|
|
17
17
|
import { startSSE } from './sse.js';
|
|
18
18
|
import { displayNameFromDir } from '../sync.js';
|
|
@@ -55,7 +55,7 @@ export function createPublishRouter(ctx) {
|
|
|
55
55
|
// Render project preview HTML
|
|
56
56
|
router.post('/api/projects/:project/render-preview', async (req, res) => {
|
|
57
57
|
try {
|
|
58
|
-
const { username, slug, title, narrative, repoUrl, projectUrl, screenshotUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, totalTokens, sessionCards, } = req.body;
|
|
58
|
+
const { username, slug, title, narrative, repoUrl, projectUrl, screenshotUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, totalTokens, sessionCards, hideSessionDates, } = req.body;
|
|
59
59
|
const renderData = buildProjectRenderData({
|
|
60
60
|
username: username || 'preview',
|
|
61
61
|
slug, title, narrative,
|
|
@@ -69,6 +69,7 @@ export function createPublishRouter(ctx) {
|
|
|
69
69
|
totalFilesChanged: totalFilesChanged || 0,
|
|
70
70
|
totalTokens,
|
|
71
71
|
sessionCards: sessionCards || [],
|
|
72
|
+
hideSessionDates,
|
|
72
73
|
});
|
|
73
74
|
const templateName = getDefaultTemplate() || 'editorial';
|
|
74
75
|
const html = renderProjectHtml(renderData, undefined, templateName);
|
|
@@ -104,7 +105,7 @@ export function createPublishRouter(ctx) {
|
|
|
104
105
|
});
|
|
105
106
|
// Publish project -- SSE stream with per-session progress
|
|
106
107
|
router.post('/api/projects/:project/upload', async (req, res) => {
|
|
107
|
-
const
|
|
108
|
+
const project = String(req.params.project);
|
|
108
109
|
const auth = getAuthToken();
|
|
109
110
|
warnIfNonDefaultApiUrl();
|
|
110
111
|
if (!auth) {
|
|
@@ -113,11 +114,11 @@ export function createPublishRouter(ctx) {
|
|
|
113
114
|
}
|
|
114
115
|
const { title: rawTitle, slug: rawSlug, narrative, repoUrl, projectUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, skippedSessions, selectedSessionIds, screenshotBase64, } = req.body;
|
|
115
116
|
// Ensure slug is the short project name, not the full encoded directory path
|
|
116
|
-
const shortName = displayNameFromDir(
|
|
117
|
+
const shortName = displayNameFromDir(project);
|
|
117
118
|
const baseSlug = toSlug(shortName);
|
|
118
119
|
const title = rawTitle === rawSlug ? shortName : rawTitle;
|
|
119
120
|
// Get stable project UUID from CLI database
|
|
120
|
-
const clientProjectId = getProjectUuid(ctx.db,
|
|
121
|
+
const clientProjectId = getProjectUuid(ctx.db, project);
|
|
121
122
|
const send = startSSE(res);
|
|
122
123
|
try {
|
|
123
124
|
// Step 1: Upsert project on Phoenix (with slug conflict retry)
|
|
@@ -181,6 +182,11 @@ export function createPublishRouter(ctx) {
|
|
|
181
182
|
send({ type: 'screenshot', status: 'capturing' });
|
|
182
183
|
const raw = screenshotBase64.includes(',') ? screenshotBase64.split(',')[1] : screenshotBase64;
|
|
183
184
|
imageBuffer = Buffer.from(raw, 'base64');
|
|
185
|
+
// FIXME(security): ext detection only handles png/jpg, so an SVG
|
|
186
|
+
// (allowed by the file picker's `accept="image/*"`) gets uploaded
|
|
187
|
+
// to S3 with `Content-Type: image/png` while the bytes are SVG.
|
|
188
|
+
// CSP on heyi.am mitigates script execution, but reject SVG here
|
|
189
|
+
// (or convert it) instead of relying on downstream defenses.
|
|
184
190
|
ext = screenshotBase64.startsWith('data:image/jpeg') || screenshotBase64.startsWith('data:image/jpg') ? 'jpg' : 'png';
|
|
185
191
|
}
|
|
186
192
|
else if (projectUrl) {
|
|
@@ -242,6 +248,17 @@ export function createPublishRouter(ctx) {
|
|
|
242
248
|
const failedSessions = [];
|
|
243
249
|
let uploadedSessionCards = [];
|
|
244
250
|
if (proj) {
|
|
251
|
+
// Honor Manage Sessions deselections: remove server-side sessions
|
|
252
|
+
// that were previously uploaded but are no longer in the
|
|
253
|
+
// selection. Failures are non-fatal — proceed with the upload.
|
|
254
|
+
const demoteResult = await demoteRemovedSessions(auth, {
|
|
255
|
+
projectDirName: project,
|
|
256
|
+
selectedSessionIds,
|
|
257
|
+
send,
|
|
258
|
+
});
|
|
259
|
+
if (demoteResult.failed.length > 0) {
|
|
260
|
+
console.warn(`[upload] ${demoteResult.failed.length} session(s) could not be removed from heyi.am`);
|
|
261
|
+
}
|
|
245
262
|
const sessionResult = await uploadSelectedSessions(ctx, auth, {
|
|
246
263
|
proj,
|
|
247
264
|
projectData,
|
|
@@ -524,6 +541,18 @@ export function createPublishRouter(ctx) {
|
|
|
524
541
|
slugMap.set(baseSlug, projectData.slug);
|
|
525
542
|
}
|
|
526
543
|
send({ type: 'project', project: title, index: projectIndex, total: filteredProjects.length, status: 'created' });
|
|
544
|
+
// Honor Manage Sessions deselections before re-uploading: remove
|
|
545
|
+
// server-side sessions that were previously uploaded but aren't
|
|
546
|
+
// in the current `selectedSessionIds`. Without this step, the
|
|
547
|
+
// bulk-status PATCH below silently re-lists everything.
|
|
548
|
+
const demoteResult = await demoteRemovedSessions(auth, {
|
|
549
|
+
projectDirName: rawProj.dirName,
|
|
550
|
+
selectedSessionIds,
|
|
551
|
+
send: (evt) => send({ ...evt, project: title }),
|
|
552
|
+
});
|
|
553
|
+
if (demoteResult.failed.length > 0) {
|
|
554
|
+
console.warn(`[portfolio-upload] ${title}: ${demoteResult.failed.length} session(s) could not be removed from heyi.am`);
|
|
555
|
+
}
|
|
527
556
|
send({ type: 'progress', message: `Uploading ${selectedSessionIds.length} session${selectedSessionIds.length === 1 ? '' : 's'} for ${title}…` });
|
|
528
557
|
const { uploadedSessionCards } = await uploadSelectedSessions(ctx, auth, {
|
|
529
558
|
proj: projInfo,
|
package/dist/routes/sessions.js
CHANGED
|
@@ -2,23 +2,48 @@ import { Router } from 'express';
|
|
|
2
2
|
import { listSessions, parseSession } from '../parsers/index.js';
|
|
3
3
|
import { bridgeToAnalyzer } from '../bridge.js';
|
|
4
4
|
import { analyzeSession } from '../analyzer.js';
|
|
5
|
-
import { getSessionRow } from '../db.js';
|
|
5
|
+
import { getSessionRow, getAllSessionMetas } from '../db.js';
|
|
6
6
|
import { ensureSessionIndexed } from '../sync.js';
|
|
7
7
|
import { exportSessionContext } from '../context-export.js';
|
|
8
8
|
import { buildTranscriptResponse } from '../transcript.js';
|
|
9
9
|
import { loadEnhancedData } from '../settings.js';
|
|
10
|
+
import { toSlug } from '../format-utils.js';
|
|
10
11
|
import { displayNameFromDir } from './context.js';
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Resolve an :id param to a real session. Accepts either:
|
|
14
|
+
* - A canonical UUID (the path used by SPA links built from session.id)
|
|
15
|
+
* - A title-derived slug, optionally with a ".html" suffix (the path
|
|
16
|
+
* baked into rendered Liquid HTML — used when a user lands on a
|
|
17
|
+
* session URL directly via refresh, bookmark, or modifier-click that
|
|
18
|
+
* slipped past the SPA's click interceptor)
|
|
19
|
+
*
|
|
20
|
+
* Falls back to live discovery on a UUID miss, mirroring the original
|
|
21
|
+
* behavior. Slug resolution is DB-only because the slug source-of-truth
|
|
22
|
+
* is the enhanced title, which lives in the local-state store.
|
|
14
23
|
*/
|
|
15
|
-
async function resolveSession(ctx,
|
|
16
|
-
|
|
24
|
+
async function resolveSession(ctx, rawId) {
|
|
25
|
+
const id = rawId.replace(/\.html$/, '');
|
|
26
|
+
// 1. Try DB by UUID
|
|
17
27
|
const row = getSessionRow(ctx.db, id);
|
|
18
28
|
if (row?.file_path) {
|
|
19
|
-
return { filePath: row.file_path, projectName: displayNameFromDir(row.project_dir) };
|
|
29
|
+
return { filePath: row.file_path, projectName: displayNameFromDir(row.project_dir), sessionId: id };
|
|
20
30
|
}
|
|
21
|
-
//
|
|
31
|
+
// 2. Try DB by title-derived slug
|
|
32
|
+
const slugTarget = toSlug(id, 80);
|
|
33
|
+
if (slugTarget) {
|
|
34
|
+
const metas = getAllSessionMetas(ctx.db);
|
|
35
|
+
for (const meta of metas) {
|
|
36
|
+
if (meta.isSubagent)
|
|
37
|
+
continue;
|
|
38
|
+
const enhanced = loadEnhancedData(meta.sessionId);
|
|
39
|
+
const titleRow = getSessionRow(ctx.db, meta.sessionId);
|
|
40
|
+
const title = enhanced?.title ?? titleRow?.title ?? meta.sessionId;
|
|
41
|
+
if (toSlug(title, 80) === slugTarget) {
|
|
42
|
+
return { filePath: meta.path, projectName: displayNameFromDir(meta.projectDir), sessionId: meta.sessionId };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// 3. Fallback: discover live sessions and index the match (UUID only)
|
|
22
47
|
const allSessions = await listSessions(ctx.sessionsBasePath);
|
|
23
48
|
const meta = allSessions.find((s) => s.sessionId === id);
|
|
24
49
|
if (!meta)
|
|
@@ -28,7 +53,7 @@ async function resolveSession(ctx, id) {
|
|
|
28
53
|
await ensureSessionIndexed(ctx.db, meta, projectName);
|
|
29
54
|
}
|
|
30
55
|
catch { /* best effort */ }
|
|
31
|
-
return { filePath: meta.path, projectName };
|
|
56
|
+
return { filePath: meta.path, projectName, sessionId: id };
|
|
32
57
|
}
|
|
33
58
|
export function createSessionsRouter(ctx) {
|
|
34
59
|
const router = Router();
|
|
@@ -41,7 +66,7 @@ export function createSessionsRouter(ctx) {
|
|
|
41
66
|
res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
|
|
42
67
|
return;
|
|
43
68
|
}
|
|
44
|
-
const session = await ctx.loadSession(resolved.filePath, resolved.projectName,
|
|
69
|
+
const session = await ctx.loadSession(resolved.filePath, resolved.projectName, resolved.sessionId);
|
|
45
70
|
res.json({ session });
|
|
46
71
|
}
|
|
47
72
|
catch (err) {
|
|
@@ -59,12 +84,12 @@ export function createSessionsRouter(ctx) {
|
|
|
59
84
|
return;
|
|
60
85
|
}
|
|
61
86
|
const parsed = await parseSession(resolved.filePath);
|
|
62
|
-
const analyzerInput = bridgeToAnalyzer(parsed, { sessionId:
|
|
87
|
+
const analyzerInput = bridgeToAnalyzer(parsed, { sessionId: resolved.sessionId, projectName: resolved.projectName });
|
|
63
88
|
let session = analyzeSession(analyzerInput);
|
|
64
89
|
const turns = analyzerInput.turns;
|
|
65
90
|
// Merge enhanced data (LLM-generated title, context, developer take,
|
|
66
91
|
// execution steps, Q&A) when available — hybrid output
|
|
67
|
-
const enhanced = loadEnhancedData(
|
|
92
|
+
const enhanced = loadEnhancedData(resolved.sessionId);
|
|
68
93
|
if (enhanced) {
|
|
69
94
|
session = {
|
|
70
95
|
...session,
|
package/dist/settings.js
CHANGED
|
@@ -163,6 +163,8 @@ export function saveProjectEnhanceResult(projectDirName, selectedSessionIds, res
|
|
|
163
163
|
...(extras?.repoUrl ? { repoUrl: extras.repoUrl } : {}),
|
|
164
164
|
...(extras?.projectUrl ? { projectUrl: extras.projectUrl } : {}),
|
|
165
165
|
...(extras?.screenshotBase64 ? { screenshotBase64: extras.screenshotBase64 } : {}),
|
|
166
|
+
...(extras?.template ? { template: extras.template } : {}),
|
|
167
|
+
...(extras?.hideSessionDates ? { hideSessionDates: true } : {}),
|
|
166
168
|
result,
|
|
167
169
|
};
|
|
168
170
|
writeFileSync(projectEnhancePath(projectDirName, configDir), JSON.stringify(cache, null, 2), { mode: 0o600 });
|