Skip to content

Commit 4262565

Browse files
ndbroadbentclaude
andcommitted
Add export subcommands and fix PDF layout
Export subcommands (export pdf, export json, export csv, export excel, export map): - Wire up subcommands in cli.ts to call new cmdExport handler - Create src/cli/commands/export.ts for single-format export - Use outputDir default (./chat-to-map/output/) instead of hardcoded paths - Export buildPdfConfig from steps/export.ts to avoid duplication PDF layout fixes: - Fix heading colors (reset to #000000 after gray activity details) - Add divider lines between country sections with proper spacing - Fix thumbnail layout breaking heading indentation (reset doc.x = 50) - Add proper spacing for flat list and country-only grouping modes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 96c1b34 commit 4262565

5 files changed

Lines changed: 213 additions & 39 deletions

File tree

src/cli.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { cmdAnalyze } from './cli/commands/analyze'
1313
import { cmdClassify } from './cli/commands/classify'
1414
import { cmdConfig } from './cli/commands/config'
1515
import { cmdEmbed } from './cli/commands/embed'
16+
import { cmdExport } from './cli/commands/export'
1617
import { cmdFetchImageUrls } from './cli/commands/fetch-image-urls'
1718
import { cmdFetchImages } from './cli/commands/fetch-images'
1819
import { cmdFilter } from './cli/commands/filter'
@@ -31,9 +32,18 @@ async function main(): Promise<void> {
3132
try {
3233
switch (args.command) {
3334
case 'analyze':
35+
case 'export':
3436
await cmdAnalyze(args, logger)
3537
break
3638

39+
case 'export-pdf':
40+
case 'export-json':
41+
case 'export-csv':
42+
case 'export-excel':
43+
case 'export-map':
44+
await cmdExport(args, logger)
45+
break
46+
3747
case 'parse':
3848
await cmdParse(args, logger)
3949
break

src/cli/commands.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ function addExportCommand(program: Command): void {
133133
.command('pdf')
134134
.description('Export activities to PDF')
135135
.argument('<input>', 'Chat export (.zip, directory, or .txt file)')
136-
.option('-o, --output <file>', 'Output file path', './output/activities.pdf')
136+
.option('-o, --output <file>', 'Output file path')
137137
)
138138
.option('--thumbnails', 'Include thumbnails in PDF')
139139
.option('--include-score', 'Show score in PDF output')
@@ -151,7 +151,7 @@ function addExportCommand(program: Command): void {
151151
.command('json')
152152
.description('Export activities to JSON')
153153
.argument('<input>', 'Chat export (.zip, directory, or .txt file)')
154-
.option('-o, --output <file>', 'Output file path', './output/activities.json')
154+
.option('-o, --output <file>', 'Output file path')
155155
)
156156

157157
// export csv
@@ -160,7 +160,7 @@ function addExportCommand(program: Command): void {
160160
.command('csv')
161161
.description('Export activities to CSV')
162162
.argument('<input>', 'Chat export (.zip, directory, or .txt file)')
163-
.option('-o, --output <file>', 'Output file path', './output/activities.csv')
163+
.option('-o, --output <file>', 'Output file path')
164164
)
165165

166166
// export excel
@@ -169,7 +169,7 @@ function addExportCommand(program: Command): void {
169169
.command('excel')
170170
.description('Export activities to Excel')
171171
.argument('<input>', 'Chat export (.zip, directory, or .txt file)')
172-
.option('-o, --output <file>', 'Output file path', './output/activities.xlsx')
172+
.option('-o, --output <file>', 'Output file path')
173173
)
174174

175175
// export map - with map-specific options
@@ -178,7 +178,7 @@ function addExportCommand(program: Command): void {
178178
.command('map')
179179
.description('Export activities to interactive HTML map')
180180
.argument('<input>', 'Chat export (.zip, directory, or .txt file)')
181-
.option('-o, --output <file>', 'Output file path', './output/map.html')
181+
.option('-o, --output <file>', 'Output file path')
182182
).option('--default-style <style>', 'Default map tile style (e.g. osm, satellite, terrain)')
183183
}
184184

src/cli/commands/export.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Export Command
3+
*
4+
* Runs the full pipeline and exports to a single format.
5+
* Used by: export pdf, export json, export csv, export excel, export map
6+
*/
7+
8+
import { writeFile } from 'node:fs/promises'
9+
import { dirname, join } from 'node:path'
10+
import {
11+
exportToCSV,
12+
exportToExcel,
13+
exportToJSON,
14+
exportToMapHTML,
15+
exportToPDF,
16+
filterActivitiesForExport,
17+
VERSION
18+
} from '../../index'
19+
import type { GeocodedActivity } from '../../types'
20+
import type { CLIArgs } from '../args'
21+
import { buildFilterOptions } from '../filter-options'
22+
import { initCommandContext } from '../helpers'
23+
import { ensureDir } from '../io'
24+
import type { Logger } from '../logger'
25+
import { buildPdfConfig } from '../steps/export'
26+
import { StepRunner } from '../steps/runner'
27+
28+
function getDefaultFilename(format: string): string {
29+
switch (format) {
30+
case 'pdf':
31+
return 'activities.pdf'
32+
case 'json':
33+
return 'activities.json'
34+
case 'csv':
35+
return 'activities.csv'
36+
case 'excel':
37+
return 'activities.xlsx'
38+
case 'map':
39+
return 'map.html'
40+
default:
41+
return `activities.${format}`
42+
}
43+
}
44+
45+
export async function cmdExport(args: CLIArgs, logger: Logger): Promise<void> {
46+
const format = args.exportFormat
47+
if (!format) {
48+
logger.error('No export format specified')
49+
process.exit(1)
50+
}
51+
52+
const { ctx, config } = await initCommandContext(`Export ${format.toUpperCase()}`, args, logger)
53+
54+
const runner = new StepRunner(ctx, args, config, logger)
55+
56+
// Run pipeline through fetch-images to get thumbnails for PDF
57+
const needsThumbnails = format === 'pdf' && args.pdfThumbnails
58+
let thumbnails: Map<string, Buffer> | undefined
59+
60+
let geocoded: readonly GeocodedActivity[]
61+
62+
if (needsThumbnails) {
63+
const result = await runner.run('fetchImages')
64+
thumbnails = result.thumbnails
65+
// Get geocoded activities from the runner
66+
const geocodeResult = await runner.run('geocode')
67+
geocoded = geocodeResult.activities
68+
} else {
69+
// Just run through geocode step
70+
const result = await runner.run('geocode')
71+
geocoded = result.activities
72+
}
73+
74+
if (geocoded.length === 0) {
75+
logger.error('No activities to export')
76+
process.exit(1)
77+
}
78+
79+
// Build filter options for this format
80+
const filterOptions = buildFilterOptions(
81+
format as 'pdf' | 'json' | 'csv' | 'excel' | 'map',
82+
args,
83+
config
84+
)
85+
const filtered = filterActivitiesForExport(geocoded, filterOptions)
86+
87+
if (filtered.length === 0) {
88+
logger.log('⚠️ No activities match the filter criteria')
89+
}
90+
91+
// Determine output path (use --output if provided, otherwise outputDir + default filename)
92+
const outputPath = args.exportOutput ?? join(args.outputDir, getDefaultFilename(format))
93+
94+
// Ensure output directory exists
95+
await ensureDir(dirname(outputPath))
96+
97+
// Export based on format
98+
switch (format) {
99+
case 'pdf': {
100+
const pdfConfig = buildPdfConfig(args, config, args.input, thumbnails)
101+
const pdfData = await exportToPDF(filtered, pdfConfig)
102+
await writeFile(outputPath, pdfData)
103+
logger.success(`Exported ${filtered.length} activities to ${outputPath}`)
104+
break
105+
}
106+
107+
case 'json': {
108+
const metadata = { inputFile: args.input, messageCount: 0, version: VERSION }
109+
const json = exportToJSON(filtered, metadata)
110+
await writeFile(outputPath, json)
111+
logger.success(`Exported ${filtered.length} activities to ${outputPath}`)
112+
break
113+
}
114+
115+
case 'csv': {
116+
const csv = exportToCSV(filtered)
117+
await writeFile(outputPath, csv)
118+
logger.success(`Exported ${filtered.length} activities to ${outputPath}`)
119+
break
120+
}
121+
122+
case 'excel': {
123+
const excel = await exportToExcel(filtered)
124+
await writeFile(outputPath, excel)
125+
logger.success(`Exported ${filtered.length} activities to ${outputPath}`)
126+
break
127+
}
128+
129+
case 'map': {
130+
const html = exportToMapHTML(filtered, { title: 'Things To Do' })
131+
await writeFile(outputPath, html)
132+
logger.success(`Exported ${filtered.length} activities to ${outputPath}`)
133+
break
134+
}
135+
136+
default:
137+
logger.error(`Unknown export format: ${format}`)
138+
process.exit(1)
139+
}
140+
}

src/cli/steps/export.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ interface ExportOptions {
4141
* Build PDFConfig from CLI args and config.
4242
* Filtering is handled separately by buildFilterOptions.
4343
*/
44-
function buildPdfConfig(
44+
export function buildPdfConfig(
4545
args: CLIArgs,
4646
config: Config | null,
4747
inputFile: string,

src/export/pdf.ts

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function groupByCountry(activities: readonly GeocodedActivity[]): Map<string, Ge
5252
const groups = new Map<string, GeocodedActivity[]>()
5353

5454
for (const a of activities) {
55-
const country = a.country ?? 'Unknown'
55+
const country = a.country ?? 'General'
5656
const existing = groups.get(country) ?? []
5757
existing.push(a)
5858
groups.set(country, existing)
@@ -250,6 +250,7 @@ function renderFlatList(
250250
config: PDFConfig,
251251
renderOptions: RenderOptions
252252
): void {
253+
doc.moveDown(2)
253254
renderActivityList(doc, activities, config, renderOptions, 'score')
254255
}
255256

@@ -267,9 +268,11 @@ function renderCategorySection(
267268
if (doc.y > 700) doc.addPage()
268269

269270
doc.moveDown(options.moveDown)
271+
doc.x = 50 // Reset to left margin (thumbnails shift X position)
270272
doc
271273
.fontSize(options.fontSize)
272274
.font('Helvetica-Bold')
275+
.fillColor('#000000')
273276
.text(`${CATEGORY_NAMES[category]} (${items.length})`)
274277
doc.moveDown(0.5)
275278

@@ -299,49 +302,61 @@ function renderGroupedByCategory(
299302
}
300303

301304
/**
302-
* Render activities grouped by country only.
305+
* Render a horizontal divider line.
303306
*/
304-
function renderGroupedByCountry(
307+
function renderDivider(doc: PDFKit.PDFDocument): void {
308+
const y = doc.y + 32
309+
doc.strokeColor('#cccccc').lineWidth(0.5).moveTo(50, y).lineTo(545, y).stroke()
310+
doc.y = y + 8
311+
}
312+
313+
/**
314+
* Render a country section with heading and divider.
315+
*/
316+
function renderCountrySection(
305317
doc: PDFKit.PDFDocument,
306-
activities: readonly GeocodedActivity[],
307-
config: PDFConfig,
308-
renderOptions: RenderOptions
318+
country: string,
319+
items: readonly GeocodedActivity[],
320+
_config: PDFConfig,
321+
_renderOptions: RenderOptions,
322+
options: { fontSize: number; renderContent: () => void }
309323
): void {
310-
const grouped = groupByCountry(activities)
311-
const countries = [...grouped.keys()].sort()
324+
if (doc.y > 680) doc.addPage()
312325

313-
for (const country of countries) {
314-
const items = grouped.get(country)
315-
if (!items || items.length === 0) continue
326+
renderDivider(doc)
316327

317-
if (doc.y > 700) doc.addPage()
318-
319-
doc.moveDown(1.5)
320-
doc.fontSize(16).font('Helvetica-Bold').text(`${country} (${items.length})`)
321-
doc.moveDown(0.5)
328+
doc.moveDown(0.5)
329+
doc.x = 50 // Reset to left margin (thumbnails shift X position)
330+
doc
331+
.fontSize(options.fontSize)
332+
.font('Helvetica-Bold')
333+
.fillColor('#000000')
334+
.text(`${country} (${items.length})`)
322335

323-
renderActivityList(doc, items, config, renderOptions, 'score')
324-
}
336+
options.renderContent()
325337
}
326338

327339
/**
328-
* Render categories within a country section.
340+
* Render activities grouped by country only.
329341
*/
330-
function renderCategoriesInCountry(
342+
function renderGroupedByCountry(
331343
doc: PDFKit.PDFDocument,
332-
countryItems: readonly GeocodedActivity[],
344+
activities: readonly GeocodedActivity[],
333345
config: PDFConfig,
334346
renderOptions: RenderOptions
335347
): void {
336-
const byCategory = groupByCategory(countryItems)
348+
const grouped = groupByCountry(activities)
337349

338-
for (const category of VALID_CATEGORIES) {
339-
const items = byCategory.get(category)
350+
for (const country of [...grouped.keys()].sort()) {
351+
const items = grouped.get(country)
340352
if (!items || items.length === 0) continue
341353

342-
renderCategorySection(doc, category, items, config, renderOptions, {
343-
moveDown: 1,
344-
fontSize: 14
354+
renderCountrySection(doc, country, items, config, renderOptions, {
355+
fontSize: 16,
356+
renderContent: () => {
357+
doc.moveDown(0.5)
358+
renderActivityList(doc, items, config, renderOptions, 'score')
359+
}
345360
})
346361
}
347362
}
@@ -356,18 +371,27 @@ function renderGroupedByCountryAndCategory(
356371
renderOptions: RenderOptions
357372
): void {
358373
const byCountry = groupByCountry(activities)
359-
const countries = [...byCountry.keys()].sort()
360374

361-
for (const country of countries) {
375+
for (const country of [...byCountry.keys()].sort()) {
362376
const countryItems = byCountry.get(country)
363377
if (!countryItems || countryItems.length === 0) continue
364378

365-
if (doc.y > 680) doc.addPage()
379+
const byCategory = groupByCategory(countryItems)
366380

367-
doc.moveDown(1.5)
368-
doc.fontSize(18).font('Helvetica-Bold').text(`${country} (${countryItems.length})`)
381+
renderCountrySection(doc, country, countryItems, config, renderOptions, {
382+
fontSize: 18,
383+
renderContent: () => {
384+
for (const category of VALID_CATEGORIES) {
385+
const items = byCategory.get(category)
386+
if (!items || items.length === 0) continue
369387

370-
renderCategoriesInCountry(doc, countryItems, config, renderOptions)
388+
renderCategorySection(doc, category, items, config, renderOptions, {
389+
moveDown: 1,
390+
fontSize: 14
391+
})
392+
}
393+
}
394+
})
371395
}
372396
}
373397

0 commit comments

Comments
 (0)