profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

T15: export spec pack as zip, transcript copy/download

shared/transcript.ts renders segments as readable Markdown, shared by
the download route and the client's clipboard copy. server/exporter/zip.ts
is a dependency-free ZIP writer (STORE method) validated against a real
unzip binary byte-for-byte. GET /api/sessions/:id/export/spec-pack.zip
404s with a clear message before generate, then serves the six files as
one download; GET .../export/transcript.md works at any point in the
session. Client: a "Copy transcript" / "Download transcript" toolbar in
the interview header, and a "Download spec pack (.zip)" link once a
generate result exists.

Manually verified in a headless browser: clipboard copy contains the
answer, transcript.md downloads with correct content, and the zip
downloads and unzips to the same six files as the on-disk spec pack.
This completes T13-T15, closing out the non-technical-user product
review (plain-language onboarding, folder browser, export/share).
commit 4e12fd8

12 changed files with +420 and −10

Jump to a changed file
  1. client/src/App.css +40 −1
  2. client/src/App.tsx +12 −8
  3. client/src/components/GeneratePanel.tsx +7 −0
  4. client/src/components/TranscriptExport.tsx +30 −0
  5. server/app.ts +2 −0
  6. server/exporter/zip.test.ts +43 −0
  7. server/exporter/zip.ts +100 −0
  8. server/routes/export.test.ts +86 −0
  9. server/routes/export.ts +62 −0
  10. shared/transcript.test.ts +25 −0
  11. shared/transcript.ts +12 −0
  12. 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.