Commit
T15: export spec pack as zip, transcript copy/download
commit
4e12fd8
12 changed files with +420 and −10
Jump to a changed file
- client/src/App.css +40 −1
- client/src/App.tsx +12 −8
- client/src/components/GeneratePanel.tsx +7 −0
- client/src/components/TranscriptExport.tsx +30 −0
- server/app.ts +2 −0
- server/exporter/zip.test.ts +43 −0
- server/exporter/zip.ts +100 −0
- server/routes/export.test.ts +86 −0
- server/routes/export.ts +62 −0
- shared/transcript.test.ts +25 −0
- shared/transcript.ts +12 −0
- spec/TASKS.md +1 −1
modified client/src/App.css +40 −1
| @@ -227,6 +227,14 @@ | ||
| 227 | 227 | justify-content: space-between; |
| 228 | 228 | align-items: center; |
| 229 | 229 | gap: 16px; |
| 230 | + flex-wrap: wrap; | |
| 231 | +} | |
| 232 | + | |
| 233 | +.interview-header-actions { | |
| 234 | + display: flex; | |
| 235 | + align-items: center; | |
| 236 | + gap: 16px; | |
| 237 | + flex-wrap: wrap; | |
| 230 | 238 | } |
| 231 | 239 | |
| 232 | 240 | .tts-toggle { |
| @@ -238,6 +246,37 @@ | ||
| 238 | 246 | white-space: nowrap; |
| 239 | 247 | } |
| 240 | 248 | |
| 249 | +.transcript-export { | |
| 250 | + display: flex; | |
| 251 | + align-items: center; | |
| 252 | + gap: 8px; | |
| 253 | +} | |
| 254 | + | |
| 255 | +.transcript-export button { | |
| 256 | + padding: 6px 10px; | |
| 257 | + border-radius: 6px; | |
| 258 | + border: 1px solid var(--border); | |
| 259 | + background: var(--bg-alt); | |
| 260 | + color: var(--text-h); | |
| 261 | + cursor: pointer; | |
| 262 | + font-size: 13px; | |
| 263 | +} | |
| 264 | + | |
| 265 | +.download-link { | |
| 266 | + padding: 6px 10px; | |
| 267 | + border-radius: 6px; | |
| 268 | + border: 1px solid var(--border); | |
| 269 | + background: var(--bg-alt); | |
| 270 | + color: var(--text-h); | |
| 271 | + font-size: 13px; | |
| 272 | + text-decoration: none; | |
| 273 | + display: inline-block; | |
| 274 | +} | |
| 275 | + | |
| 276 | +.download-link:hover { | |
| 277 | + border-color: var(--accent); | |
| 278 | +} | |
| 279 | + | |
| 241 | 280 | .push-to-talk { |
| 242 | 281 | margin-bottom: 12px; |
| 243 | 282 | } |
| @@ -420,7 +459,7 @@ | ||
| 420 | 459 | } |
| 421 | 460 | |
| 422 | 461 | .generate-result ul { |
| 423 | - margin: 8px 0 0; | |
| 462 | + margin: 8px 0 12px; | |
| 424 | 463 | padding-left: 20px; |
| 425 | 464 | } |
| 426 | 465 |
modified client/src/App.tsx +12 −8
| @@ -9,6 +9,7 @@import { GeneratePanel } from './components/GeneratePanel' | ||
| 9 | 9 | import { PushToTalkButton } from './components/PushToTalkButton' |
| 10 | 10 | import { QuestionCard } from './components/QuestionCard' |
| 11 | 11 | import { Transcript } from './components/Transcript' |
| 12 | +import { TranscriptExport } from './components/TranscriptExport' | |
| 12 | 13 | import { speak } from './tts' |
| 13 | 14 | |
| 14 | 15 | function latestQuestion(session: Session): string | null { |
| @@ -188,14 +189,17 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 188 | 189 | </button> |
| 189 | 190 | <div className="interview-header"> |
| 190 | 191 | <h1>{session.name}</h1> |
| 191 | - <label className="tts-toggle"> | |
| 192 | - <input | |
| 193 | - type="checkbox" | |
| 194 | - checked={ttsEnabled} | |
| 195 | - onChange={(e) => setTtsEnabled(e.target.checked)} | |
| 196 | - /> | |
| 197 | - Read questions aloud | |
| 198 | - </label> | |
| 192 | + <div className="interview-header-actions"> | |
| 193 | + <label className="tts-toggle"> | |
| 194 | + <input | |
| 195 | + type="checkbox" | |
| 196 | + checked={ttsEnabled} | |
| 197 | + onChange={(e) => setTtsEnabled(e.target.checked)} | |
| 198 | + /> | |
| 199 | + Read questions aloud | |
| 200 | + </label> | |
| 201 | + <TranscriptExport sessionId={session.id} projectName={session.name} segments={session.segments} /> | |
| 202 | + </div> | |
| 199 | 203 | </div> |
| 200 | 204 | |
| 201 | 205 | <div className="interview-layout"> |
modified client/src/components/GeneratePanel.tsx +7 −0
| @@ -85,6 +85,13 @@export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) { | ||
| 85 | 85 | <li key={file}>{file}</li> |
| 86 | 86 | ))} |
| 87 | 87 | </ul> |
| 88 | + <a | |
| 89 | + className="download-link" | |
| 90 | + href={`/api/sessions/${sessionId}/export/spec-pack.zip`} | |
| 91 | + download | |
| 92 | + > | |
| 93 | + ⬇ Download spec pack (.zip) | |
| 94 | + </a> | |
| 88 | 95 | {result.warnings.length > 0 && ( |
| 89 | 96 | <div className="generate-warnings"> |
| 90 | 97 | <p>A couple of notes:</p> |
added client/src/components/TranscriptExport.tsx +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +import { useState } from 'react' | |
| 2 | +import { formatTranscript } from 'shared/transcript' | |
| 3 | +import type { Segment } from 'shared/types' | |
| 4 | + | |
| 5 | +interface TranscriptExportProps { | |
| 6 | + sessionId: string | |
| 7 | + projectName: string | |
| 8 | + segments: Segment[] | |
| 9 | +} | |
| 10 | + | |
| 11 | +export function TranscriptExport({ sessionId, projectName, segments }: TranscriptExportProps) { | |
| 12 | + const [copied, setCopied] = useState(false) | |
| 13 | + | |
| 14 | + async function handleCopy() { | |
| 15 | + await navigator.clipboard.writeText(formatTranscript(segments, projectName)) | |
| 16 | + setCopied(true) | |
| 17 | + setTimeout(() => setCopied(false), 2000) | |
| 18 | + } | |
| 19 | + | |
| 20 | + return ( | |
| 21 | + <div className="transcript-export"> | |
| 22 | + <button type="button" onClick={() => void handleCopy()}> | |
| 23 | + {copied ? 'Copied!' : 'Copy transcript'} | |
| 24 | + </button> | |
| 25 | + <a className="download-link" href={`/api/sessions/${sessionId}/export/transcript.md`} download> | |
| 26 | + ⬇ Download transcript | |
| 27 | + </a> | |
| 28 | + </div> | |
| 29 | + ) | |
| 30 | +} |
modified server/app.ts +2 −0
| @@ -5,6 +5,7 @@import { createInterviewLlm, createSttProvider } from './providers/factory' | ||
| 5 | 5 | import type { InterviewLlm, SttProvider } from './providers/types' |
| 6 | 6 | import { registerAudioRoutes } from './routes/audio' |
| 7 | 7 | import { registerBlockerRoutes } from './routes/blockers' |
| 8 | +import { registerExportRoutes } from './routes/export' | |
| 8 | 9 | import { registerFsRoutes } from './routes/fs' |
| 9 | 10 | import { registerGenerateRoutes } from './routes/generate' |
| 10 | 11 | import { registerSessionRoutes } from './routes/sessions' |
| @@ -33,6 +34,7 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance { | ||
| 33 | 34 | registerGenerateRoutes(app, resolved) |
| 34 | 35 | registerBlockerRoutes(app, resolved) |
| 35 | 36 | registerFsRoutes(app) |
| 37 | + registerExportRoutes(app, resolved) | |
| 36 | 38 | |
| 37 | 39 | return app |
| 38 | 40 | } |
added server/exporter/zip.test.ts +43 −0
| @@ -0,0 +1,43 @@ | ||
| 1 | +import { execFileSync } from 'node:child_process' | |
| 2 | +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 3 | +import { tmpdir } from 'node:os' | |
| 4 | +import path from 'node:path' | |
| 5 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 6 | +import { createZip } from './zip' | |
| 7 | + | |
| 8 | +describe('createZip', () => { | |
| 9 | + let workDir: string | |
| 10 | + | |
| 11 | + beforeEach(async () => { | |
| 12 | + workDir = await mkdtemp(path.join(tmpdir(), 'voicetask-zip-')) | |
| 13 | + }) | |
| 14 | + | |
| 15 | + afterEach(async () => { | |
| 16 | + await rm(workDir, { recursive: true, force: true }) | |
| 17 | + }) | |
| 18 | + | |
| 19 | + it('produces a zip that a real unzip tool can extract byte-for-byte', async () => { | |
| 20 | + const entries = [ | |
| 21 | + { name: 'SPEC.md', content: Buffer.from('# SPEC\n\nsome content [S1]\n', 'utf8') }, | |
| 22 | + { name: 'nested/HANDOFF.md', content: Buffer.from('claude "do the thing"\n', 'utf8') }, | |
| 23 | + { name: 'empty.txt', content: Buffer.alloc(0) }, | |
| 24 | + ] | |
| 25 | + | |
| 26 | + const zipBuffer = createZip(entries, new Date('2026-01-15T10:30:00')) | |
| 27 | + const zipPath = path.join(workDir, 'pack.zip') | |
| 28 | + await writeFile(zipPath, zipBuffer) | |
| 29 | + | |
| 30 | + const extractDir = path.join(workDir, 'extracted') | |
| 31 | + execFileSync('unzip', ['-o', zipPath, '-d', extractDir]) | |
| 32 | + | |
| 33 | + for (const entry of entries) { | |
| 34 | + const extracted = await readFile(path.join(extractDir, entry.name)) | |
| 35 | + expect(extracted.equals(entry.content)).toBe(true) | |
| 36 | + } | |
| 37 | + | |
| 38 | + const listing = execFileSync('unzip', ['-l', zipPath]).toString('utf8') | |
| 39 | + for (const entry of entries) { | |
| 40 | + expect(listing).toContain(entry.name) | |
| 41 | + } | |
| 42 | + }) | |
| 43 | +}) |
added server/exporter/zip.ts +100 −0
| @@ -0,0 +1,100 @@ | ||
| 1 | +const CRC_TABLE = buildCrcTable() | |
| 2 | + | |
| 3 | +function buildCrcTable(): Uint32Array { | |
| 4 | + const table = new Uint32Array(256) | |
| 5 | + for (let n = 0; n < 256; n++) { | |
| 6 | + let c = n | |
| 7 | + for (let k = 0; k < 8; k++) { | |
| 8 | + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 | |
| 9 | + } | |
| 10 | + table[n] = c >>> 0 | |
| 11 | + } | |
| 12 | + return table | |
| 13 | +} | |
| 14 | + | |
| 15 | +function crc32(buf: Buffer): number { | |
| 16 | + let crc = 0xffffffff | |
| 17 | + for (const byte of buf) { | |
| 18 | + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8) | |
| 19 | + } | |
| 20 | + return (crc ^ 0xffffffff) >>> 0 | |
| 21 | +} | |
| 22 | + | |
| 23 | +function dosDateTime(date: Date): { time: number; date: number } { | |
| 24 | + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2) | |
| 25 | + const dosDate = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate() | |
| 26 | + return { time, date: dosDate } | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface ZipEntryInput { | |
| 30 | + name: string | |
| 31 | + content: Buffer | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * Minimal ZIP writer (STORE method, no compression) so the project doesn't need a | |
| 36 | + * zip dependency for one contained feature. See spec/PLAN.md "Decisions already made". | |
| 37 | + */ | |
| 38 | +export function createZip(entries: ZipEntryInput[], now: Date = new Date()): Buffer { | |
| 39 | + const { time, date } = dosDateTime(now) | |
| 40 | + const localParts: Buffer[] = [] | |
| 41 | + const centralParts: Buffer[] = [] | |
| 42 | + let offset = 0 | |
| 43 | + | |
| 44 | + for (const entry of entries) { | |
| 45 | + const nameBuf = Buffer.from(entry.name, 'utf8') | |
| 46 | + const crc = crc32(entry.content) | |
| 47 | + const size = entry.content.length | |
| 48 | + | |
| 49 | + const localHeader = Buffer.alloc(30) | |
| 50 | + localHeader.writeUInt32LE(0x04034b50, 0) | |
| 51 | + localHeader.writeUInt16LE(20, 4) | |
| 52 | + localHeader.writeUInt16LE(0, 6) | |
| 53 | + localHeader.writeUInt16LE(0, 8) | |
| 54 | + localHeader.writeUInt16LE(time, 10) | |
| 55 | + localHeader.writeUInt16LE(date, 12) | |
| 56 | + localHeader.writeUInt32LE(crc, 14) | |
| 57 | + localHeader.writeUInt32LE(size, 18) | |
| 58 | + localHeader.writeUInt32LE(size, 22) | |
| 59 | + localHeader.writeUInt16LE(nameBuf.length, 26) | |
| 60 | + localHeader.writeUInt16LE(0, 28) | |
| 61 | + localParts.push(localHeader, nameBuf, entry.content) | |
| 62 | + | |
| 63 | + const centralHeader = Buffer.alloc(46) | |
| 64 | + centralHeader.writeUInt32LE(0x02014b50, 0) | |
| 65 | + centralHeader.writeUInt16LE(20, 4) | |
| 66 | + centralHeader.writeUInt16LE(20, 6) | |
| 67 | + centralHeader.writeUInt16LE(0, 8) | |
| 68 | + centralHeader.writeUInt16LE(0, 10) | |
| 69 | + centralHeader.writeUInt16LE(time, 12) | |
| 70 | + centralHeader.writeUInt16LE(date, 14) | |
| 71 | + centralHeader.writeUInt32LE(crc, 16) | |
| 72 | + centralHeader.writeUInt32LE(size, 20) | |
| 73 | + centralHeader.writeUInt32LE(size, 24) | |
| 74 | + centralHeader.writeUInt16LE(nameBuf.length, 28) | |
| 75 | + centralHeader.writeUInt16LE(0, 30) | |
| 76 | + centralHeader.writeUInt16LE(0, 32) | |
| 77 | + centralHeader.writeUInt16LE(0, 34) | |
| 78 | + centralHeader.writeUInt16LE(0, 36) | |
| 79 | + centralHeader.writeUInt32LE(0, 38) | |
| 80 | + centralHeader.writeUInt32LE(offset, 42) | |
| 81 | + centralParts.push(centralHeader, nameBuf) | |
| 82 | + | |
| 83 | + offset += localHeader.length + nameBuf.length + entry.content.length | |
| 84 | + } | |
| 85 | + | |
| 86 | + const centralDirOffset = offset | |
| 87 | + const centralDirSize = centralParts.reduce((sum, buf) => sum + buf.length, 0) | |
| 88 | + | |
| 89 | + const eocd = Buffer.alloc(22) | |
| 90 | + eocd.writeUInt32LE(0x06054b50, 0) | |
| 91 | + eocd.writeUInt16LE(0, 4) | |
| 92 | + eocd.writeUInt16LE(0, 6) | |
| 93 | + eocd.writeUInt16LE(entries.length, 8) | |
| 94 | + eocd.writeUInt16LE(entries.length, 10) | |
| 95 | + eocd.writeUInt32LE(centralDirSize, 12) | |
| 96 | + eocd.writeUInt32LE(centralDirOffset, 16) | |
| 97 | + eocd.writeUInt16LE(0, 20) | |
| 98 | + | |
| 99 | + return Buffer.concat([...localParts, ...centralParts, eocd]) | |
| 100 | +} |
added server/routes/export.test.ts +86 −0
| @@ -0,0 +1,86 @@ | ||
| 1 | +import { execFileSync } from 'node:child_process' | |
| 2 | +import { mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 3 | +import { tmpdir } from 'node:os' | |
| 4 | +import path from 'node:path' | |
| 5 | +import type { FastifyInstance } from 'fastify' | |
| 6 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 7 | +import type { Session } from '../../shared/types' | |
| 8 | +import { buildApp } from '../app' | |
| 9 | +import { createLlmMock } from '../providers/llmMock' | |
| 10 | +import { SessionStore } from '../store/sessionStore' | |
| 11 | + | |
| 12 | +describe('export routes', () => { | |
| 13 | + let storeDir: string | |
| 14 | + let targetDir: string | |
| 15 | + let workDir: string | |
| 16 | + let app: FastifyInstance | |
| 17 | + | |
| 18 | + beforeEach(async () => { | |
| 19 | + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-')) | |
| 20 | + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-')) | |
| 21 | + workDir = await mkdtemp(path.join(tmpdir(), 'voicetask-workdir-')) | |
| 22 | + app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() }) | |
| 23 | + }) | |
| 24 | + | |
| 25 | + afterEach(async () => { | |
| 26 | + await app.close() | |
| 27 | + await rm(storeDir, { recursive: true, force: true }) | |
| 28 | + await rm(targetDir, { recursive: true, force: true }) | |
| 29 | + await rm(workDir, { recursive: true, force: true }) | |
| 30 | + }) | |
| 31 | + | |
| 32 | + async function createSessionWithAnAnswer(): Promise<Session> { | |
| 33 | + const created = await app | |
| 34 | + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'Family Recipes', targetDir } }) | |
| 35 | + .then((r) => r.json<Session>()) | |
| 36 | + await app.inject({ | |
| 37 | + method: 'POST', | |
| 38 | + url: `/api/sessions/${created.id}/answer`, | |
| 39 | + payload: { text: 'a recipe app for my family' }, | |
| 40 | + }) | |
| 41 | + return app.inject({ method: 'GET', url: `/api/sessions/${created.id}` }).then((r) => r.json<Session>()) | |
| 42 | + } | |
| 43 | + | |
| 44 | + it('404s the zip export before the spec pack has been generated', async () => { | |
| 45 | + const session = await createSessionWithAnAnswer() | |
| 46 | + const res = await app.inject({ method: 'GET', url: `/api/sessions/${session.id}/export/spec-pack.zip` }) | |
| 47 | + expect(res.statusCode).toBe(404) | |
| 48 | + }) | |
| 49 | + | |
| 50 | + it('downloads a valid zip of the spec pack after generating', async () => { | |
| 51 | + const session = await createSessionWithAnAnswer() | |
| 52 | + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 53 | + | |
| 54 | + const res = await app.inject({ method: 'GET', url: `/api/sessions/${session.id}/export/spec-pack.zip` }) | |
| 55 | + expect(res.statusCode).toBe(200) | |
| 56 | + expect(res.headers['content-type']).toBe('application/zip') | |
| 57 | + expect(res.headers['content-disposition']).toContain('family-recipes-spec-pack.zip') | |
| 58 | + | |
| 59 | + const zipPath = path.join(workDir, 'pack.zip') | |
| 60 | + await writeFile(zipPath, res.rawPayload) | |
| 61 | + const listing = execFileSync('unzip', ['-l', zipPath]).toString('utf8') | |
| 62 | + for (const file of ['SPEC.md', 'PLAN.md', 'TASKS.md', 'VERIFICATION.md', 'HANDOFF.md', 'sources.json']) { | |
| 63 | + expect(listing).toContain(file) | |
| 64 | + } | |
| 65 | + }) | |
| 66 | + | |
| 67 | + it('returns 404 for the zip export on an unknown session', async () => { | |
| 68 | + const res = await app.inject({ method: 'GET', url: '/api/sessions/nonexistent/export/spec-pack.zip' }) | |
| 69 | + expect(res.statusCode).toBe(404) | |
| 70 | + }) | |
| 71 | + | |
| 72 | + it('downloads the transcript as markdown at any point in the session', async () => { | |
| 73 | + const session = await createSessionWithAnAnswer() | |
| 74 | + const res = await app.inject({ method: 'GET', url: `/api/sessions/${session.id}/export/transcript.md` }) | |
| 75 | + expect(res.statusCode).toBe(200) | |
| 76 | + expect(res.headers['content-type']).toContain('text/markdown') | |
| 77 | + expect(res.headers['content-disposition']).toContain('family-recipes-transcript.md') | |
| 78 | + expect(res.body).toContain('# Family Recipes — Interview Transcript') | |
| 79 | + expect(res.body).toContain('**You:** a recipe app for my family') | |
| 80 | + }) | |
| 81 | + | |
| 82 | + it('returns 404 for the transcript export on an unknown session', async () => { | |
| 83 | + const res = await app.inject({ method: 'GET', url: '/api/sessions/nonexistent/export/transcript.md' }) | |
| 84 | + expect(res.statusCode).toBe(404) | |
| 85 | + }) | |
| 86 | +}) |
added server/routes/export.ts +62 −0
| @@ -0,0 +1,62 @@ | ||
| 1 | +import { readFile, stat } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { FastifyInstance } from 'fastify' | |
| 4 | +import { formatTranscript } from '../../shared/transcript' | |
| 5 | +import { createZip, type ZipEntryInput } from '../exporter/zip' | |
| 6 | +import { SPEC_PACK_FILES } from '../providers/types' | |
| 7 | +import type { SessionStore } from '../store/sessionStore' | |
| 8 | + | |
| 9 | +const SOURCES_FILE = 'sources.json' | |
| 10 | + | |
| 11 | +export interface ExportRouteDeps { | |
| 12 | + store: SessionStore | |
| 13 | +} | |
| 14 | + | |
| 15 | +function slugify(name: string): string { | |
| 16 | + const slug = name | |
| 17 | + .toLowerCase() | |
| 18 | + .replace(/[^a-z0-9]+/g, '-') | |
| 19 | + .replace(/^-+|-+$/g, '') | |
| 20 | + return slug.length > 0 ? slug : 'voicetask' | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function registerExportRoutes(app: FastifyInstance, deps: ExportRouteDeps): void { | |
| 24 | + const { store } = deps | |
| 25 | + | |
| 26 | + app.get<{ Params: { id: string } }>('/api/sessions/:id/export/spec-pack.zip', async (request, reply) => { | |
| 27 | + const session = await store.getSession(request.params.id) | |
| 28 | + if (!session) return reply.code(404).send({ error: 'session not found' }) | |
| 29 | + | |
| 30 | + const specDir = path.join(session.targetDir, 'spec') | |
| 31 | + const specDirExists = await stat(specDir) | |
| 32 | + .then((s) => s.isDirectory()) | |
| 33 | + .catch(() => false) | |
| 34 | + if (!specDirExists) { | |
| 35 | + return reply.code(404).send({ error: 'no spec pack found yet — generate it first' }) | |
| 36 | + } | |
| 37 | + | |
| 38 | + const entries: ZipEntryInput[] = [] | |
| 39 | + for (const file of [...SPEC_PACK_FILES, SOURCES_FILE]) { | |
| 40 | + const content = await readFile(path.join(specDir, file)).catch(() => null) | |
| 41 | + if (content) entries.push({ name: file, content }) | |
| 42 | + } | |
| 43 | + if (entries.length === 0) { | |
| 44 | + return reply.code(404).send({ error: 'no spec pack found yet — generate it first' }) | |
| 45 | + } | |
| 46 | + | |
| 47 | + const zipBuffer = createZip(entries) | |
| 48 | + reply.header('content-type', 'application/zip') | |
| 49 | + reply.header('content-disposition', `attachment; filename="${slugify(session.name)}-spec-pack.zip"`) | |
| 50 | + return reply.send(zipBuffer) | |
| 51 | + }) | |
| 52 | + | |
| 53 | + app.get<{ Params: { id: string } }>('/api/sessions/:id/export/transcript.md', async (request, reply) => { | |
| 54 | + const session = await store.getSession(request.params.id) | |
| 55 | + if (!session) return reply.code(404).send({ error: 'session not found' }) | |
| 56 | + | |
| 57 | + const text = formatTranscript(session.segments, session.name) | |
| 58 | + reply.header('content-type', 'text/markdown; charset=utf-8') | |
| 59 | + reply.header('content-disposition', `attachment; filename="${slugify(session.name)}-transcript.md"`) | |
| 60 | + return reply.send(text) | |
| 61 | + }) | |
| 62 | +} |
added shared/transcript.test.ts +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { formatTranscript } from './transcript' | |
| 3 | +import type { Segment } from './types' | |
| 4 | + | |
| 5 | +function segment(id: string, speaker: Segment['speaker'], text: string): Segment { | |
| 6 | + return { id, ts: new Date().toISOString(), speaker, text } | |
| 7 | +} | |
| 8 | + | |
| 9 | +describe('formatTranscript', () => { | |
| 10 | + it('renders each segment as a labeled line, in order', () => { | |
| 11 | + const segments = [ | |
| 12 | + segment('S1', 'user', 'a recipe app for my family'), | |
| 13 | + segment('S2', 'interviewer', 'who will use it?'), | |
| 14 | + ] | |
| 15 | + const text = formatTranscript(segments, 'Family Recipes') | |
| 16 | + expect(text).toContain('# Family Recipes — Interview Transcript') | |
| 17 | + expect(text).toContain('**You:** a recipe app for my family') | |
| 18 | + expect(text).toContain('**Interviewer:** who will use it?') | |
| 19 | + expect(text.indexOf('You:')).toBeLessThan(text.indexOf('Interviewer:')) | |
| 20 | + }) | |
| 21 | + | |
| 22 | + it('falls back to a generic title when no project name is given', () => { | |
| 23 | + expect(formatTranscript([])).toContain('# Interview Transcript') | |
| 24 | + }) | |
| 25 | +}) |
added shared/transcript.ts +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +import type { Segment } from './types' | |
| 2 | + | |
| 3 | +export function formatTranscript(segments: Segment[], projectName?: string): string { | |
| 4 | + const lines: string[] = [projectName ? `# ${projectName} — Interview Transcript` : '# Interview Transcript', ''] | |
| 5 | + | |
| 6 | + for (const segment of segments) { | |
| 7 | + const speakerLabel = segment.speaker === 'user' ? 'You' : 'Interviewer' | |
| 8 | + lines.push(`**${speakerLabel}:** ${segment.text}`, '') | |
| 9 | + } | |
| 10 | + | |
| 11 | + return `${lines.join('\n').trimEnd()}\n` | |
| 12 | +} |
modified spec/TASKS.md +1 −1
| @@ -72,7 +72,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 72 | 72 | - Depends: T7 |
| 73 | 73 | - Verify: `npm test` (route tests: browse lists directories and a parent, browsing an unreadable path returns a 4xx, mkdir creates a directory and 4xxs on a duplicate name) and `npm run typecheck`. Manual: browsing, creating a folder, and selecting it fills the target directory field. |
| 74 | 74 | |
| 75 | -- [ ] T15 Export: spec pack zip and transcript | |
| 75 | +- [x] T15 Export: spec pack zip and transcript | |
| 76 | 76 | - `shared/transcript.ts`: `formatTranscript(segments)` plain-text renderer. `server/exporter/zip.ts`: dependency-free ZIP (STORE method) writer. `GET /api/sessions/:id/export/spec-pack.zip` (404 with a clear error if `spec/` doesn't exist yet in the target directory) and `GET /api/sessions/:id/export/transcript.md`. Client: a transcript "Copy" / "Download" control usable at any time, and a "Download spec pack (.zip)" button in `GeneratePanel` that appears once a pack exists. |
| 77 | 77 | - Depends: T9, T10 |
| 78 | 78 | - Verify: `npm test` (zip writer roundtrips through Node's own unzip via a temp-file check or a byte-level structural check; export route: 404 before generate, 200 with correct `Content-Type`/`Content-Disposition` after) and `npm run typecheck`. Manual: after generating, the zip downloads and contains all six files; transcript copy/download works mid-interview. |