Skip to content

Commit 96c1b34

Browse files
ndbroadbentclaude
andcommitted
Implement PDF export grouping options and shared export filtering
PDF Export Options: - Add groupByCountry and groupByCategory support (default: both enabled) - Add pageSize support (A4 or Letter, default A4) - Add includeThumbnails option (default: false to save ink) - Add includeScore option to show score in activity details - Add filterByCountry support for PDF exports - Wire all PDF options from CLI args and config Shared Export Filtering Module (src/export/filter.ts): - New FilterOptions interface for filtering, sorting, and limiting activities - Country name normalization using i18n-iso-countries - Sender name matching with word-boundary support - Date range filtering based on first message timestamp - Score filtering using interestingScore*2 + funScore (range 0-3) - Sort by score (default), oldest, or newest - Max activities limiting with mutually exclusive location filters - 48 comprehensive unit tests CLI Enhancements: - Add export subcommands structure in args.ts - Add filter-options.ts for building FilterOptions from CLI args/config - Add commands.ts for export command with subcommands - Update Config interface with all export and PDF options - Wire filtering through steps/export.ts Test Coverage: - 28 PDF export tests (14 new for grouping/options) - 48 filter module tests - All 855 unit tests + 90 E2E tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent dd2662a commit 96c1b34

19 files changed

Lines changed: 2489 additions & 300 deletions

src/cli/args.ts

Lines changed: 264 additions & 229 deletions
Large diffs are not rendered by default.

src/cli/commands.ts

Lines changed: 355 additions & 0 deletions
Large diffs are not rendered by default.

src/cli/commands/analyze.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import type { Logger } from '../logger'
1212
import { StepRunner } from '../steps/runner'
1313

1414
export async function cmdAnalyze(args: CLIArgs, logger: Logger): Promise<void> {
15-
const { ctx } = await initCommandContext('Analyze', args, logger)
15+
const { ctx, config } = await initCommandContext('Analyze', args, logger)
1616

17-
const runner = new StepRunner(ctx, args, logger)
17+
const runner = new StepRunner(ctx, args, config, logger)
1818

1919
// Run full pipeline through export
2020
const { exportedFiles } = await runner.run('export')

src/cli/commands/classify.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ export function buildClassifyOutput(
8787
}
8888

8989
export async function cmdClassify(args: CLIArgs, logger: Logger): Promise<void> {
90-
const { ctx } = await initCommandContext('Classify', args, logger)
90+
const { ctx, config } = await initCommandContext('Classify', args, logger)
9191

9292
// Use StepRunner to handle dependencies: filter → scrapeUrls → classify
93-
const runner = new StepRunner(ctx, args, logger)
93+
const runner = new StepRunner(ctx, args, config, logger)
9494

9595
// Run filter step (which runs parse → scan → embed → filter)
9696
const { candidates } = await runner.run('filter')

src/cli/commands/fetch-image-urls.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ interface FetchImagesOutput {
3939
}
4040

4141
export async function cmdFetchImageUrls(args: CLIArgs, logger: Logger): Promise<void> {
42-
const { ctx } = await initCommandContext('Fetch Image URLs', args, logger)
42+
const { ctx, config } = await initCommandContext('Fetch Image URLs', args, logger)
4343

4444
// Use StepRunner to handle dependencies: geocode → fetch-image-urls
45-
const runner = new StepRunner(ctx, args, logger)
45+
const runner = new StepRunner(ctx, args, config, logger)
4646

4747
// Run geocode step (which runs the full pipeline up to geocode)
4848
const { activities: geocodedActivities } = await runner.run('geocode')

src/cli/commands/fetch-images.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import type { Logger } from '../logger'
1212
import { StepRunner } from '../steps/runner'
1313

1414
export async function cmdFetchImages(args: CLIArgs, logger: Logger): Promise<void> {
15-
const { ctx } = await initCommandContext('Fetch Images', args, logger)
15+
const { ctx, config } = await initCommandContext('Fetch Images', args, logger)
1616

17-
const runner = new StepRunner(ctx, args, logger)
17+
const runner = new StepRunner(ctx, args, config, logger)
1818

1919
// Run fetchImages step (which runs the full pipeline including fetchImageUrls)
2020
const { thumbnails } = await runner.run('fetchImages')

src/cli/commands/geocode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ interface GeocodeOutput {
4545
}
4646

4747
export async function cmdGeocode(args: CLIArgs, logger: Logger): Promise<void> {
48-
const { ctx } = await initCommandContext('Geocode', args, logger)
48+
const { ctx, config } = await initCommandContext('Geocode', args, logger)
4949

5050
// Use StepRunner to handle dependencies: classify → geocode
51-
const runner = new StepRunner(ctx, args, logger)
51+
const runner = new StepRunner(ctx, args, config, logger)
5252

5353
// Run classify step (which runs parse → scan → embed → filter → scrape → classify)
5454
const { activities: classifiedActivities } = await runner.run('classify')

src/cli/config.ts

Lines changed: 166 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,69 @@ export interface Config {
2626
outputDir?: string | undefined
2727
/** Export formats (csv,excel,json,map,pdf) */
2828
formats?: string[] | undefined
29+
30+
// === Common export settings (apply to ALL formats) ===
31+
/** Filter ALL exports by categories */
32+
exportCategories?: string[] | undefined
33+
/** Filter ALL exports by countries */
34+
exportCountries?: string[] | undefined
35+
/** Filter ALL exports by sender names */
36+
exportFrom?: string[] | undefined
37+
/** Filter ALL exports to on/after this date (YYYY-MM-DD) */
38+
exportStartDate?: string | undefined
39+
/** Filter ALL exports to on/before this date (YYYY-MM-DD) */
40+
exportEndDate?: string | undefined
41+
/** Min score threshold for ALL exports (0-3) */
42+
exportMinScore?: number | undefined
43+
/** Only export activities with specific locations */
44+
exportOnlyLocations?: boolean | undefined
45+
/** Only export generic activities without locations */
46+
exportOnlyGeneric?: boolean | undefined
47+
/** Max activities in ALL exports (0 = all) */
48+
exportMaxActivities?: number | undefined
49+
/** Sort order for ALL exports: score, oldest, newest */
50+
exportSort?: string | undefined
51+
52+
// === PDF-specific settings (override export* for PDF only) ===
53+
/** Include thumbnails in PDF exports */
54+
pdfThumbnails?: boolean | undefined
55+
/** Show score in PDF output */
56+
pdfIncludeScore?: boolean | undefined
57+
/** Group by country in PDF (default: true) */
58+
pdfGroupByCountry?: boolean | undefined
59+
/** Group by category in PDF (default: true) */
60+
pdfGroupByCategory?: boolean | undefined
61+
/** PDF page size: A4 or Letter (default: based on home country) */
62+
pdfPageSize?: string | undefined
63+
/** Custom PDF title */
64+
pdfTitle?: string | undefined
65+
/** Custom PDF subtitle */
66+
pdfSubtitle?: string | undefined
67+
/** Filter PDF by categories (overrides exportCategories) */
68+
pdfCategories?: string[] | undefined
69+
/** Filter PDF by countries (overrides exportCountries) */
70+
pdfCountries?: string[] | undefined
71+
/** Filter PDF by sender names (overrides exportFrom) */
72+
pdfFrom?: string[] | undefined
73+
/** Filter PDF to on/after this date (overrides exportStartDate) */
74+
pdfStartDate?: string | undefined
75+
/** Filter PDF to on/before this date (overrides exportEndDate) */
76+
pdfEndDate?: string | undefined
77+
/** Min score for PDF (overrides exportMinScore) */
78+
pdfMinScore?: number | undefined
79+
/** Only locations in PDF (overrides exportOnlyLocations) */
80+
pdfOnlyLocations?: boolean | undefined
81+
/** Only generic in PDF (overrides exportOnlyGeneric) */
82+
pdfOnlyGeneric?: boolean | undefined
83+
/** Max activities in PDF (overrides exportMaxActivities) */
84+
pdfMaxActivities?: number | undefined
85+
/** Sort order for PDF (overrides exportSort) */
86+
pdfSort?: string | undefined
87+
88+
// === Map-specific settings ===
89+
/** Default map tile style */
90+
mapDefaultStyle?: string | undefined
91+
2992
/** When settings were last updated */
3093
updatedAt?: string | undefined
3194
}
@@ -34,20 +97,104 @@ export interface Config {
3497
export type ConfigKey = keyof Omit<Config, 'updatedAt'>
3598

3699
/** Config keys that accept string values */
37-
const STRING_KEYS: ConfigKey[] = ['homeCountry', 'timezone', 'cacheDir', 'outputDir']
100+
const STRING_KEYS: ConfigKey[] = [
101+
'homeCountry',
102+
'timezone',
103+
'cacheDir',
104+
'outputDir',
105+
// Common export
106+
'exportStartDate',
107+
'exportEndDate',
108+
'exportSort',
109+
// PDF-specific
110+
'pdfPageSize',
111+
'pdfTitle',
112+
'pdfSubtitle',
113+
'pdfStartDate',
114+
'pdfEndDate',
115+
'pdfSort',
116+
// Map-specific
117+
'mapDefaultStyle'
118+
]
38119
/** Config keys that accept boolean values */
39-
const BOOLEAN_KEYS: ConfigKey[] = ['fetchImages']
120+
const BOOLEAN_KEYS: ConfigKey[] = [
121+
'fetchImages',
122+
// Common export
123+
'exportOnlyLocations',
124+
'exportOnlyGeneric',
125+
// PDF-specific
126+
'pdfThumbnails',
127+
'pdfIncludeScore',
128+
'pdfGroupByCountry',
129+
'pdfGroupByCategory',
130+
'pdfOnlyLocations',
131+
'pdfOnlyGeneric'
132+
]
133+
/** Config keys that accept number values */
134+
const NUMBER_KEYS: ConfigKey[] = [
135+
// Common export
136+
'exportMinScore',
137+
'exportMaxActivities',
138+
// PDF-specific
139+
'pdfMinScore',
140+
'pdfMaxActivities'
141+
]
40142
/** Config keys that accept array values */
41-
const ARRAY_KEYS: ConfigKey[] = ['formats']
143+
const ARRAY_KEYS: ConfigKey[] = [
144+
'formats',
145+
// Common export
146+
'exportCategories',
147+
'exportCountries',
148+
'exportFrom',
149+
// PDF-specific
150+
'pdfCategories',
151+
'pdfCountries',
152+
'pdfFrom'
153+
]
42154

43155
/** Descriptions for config keys (for help output) */
44156
const CONFIG_DESCRIPTIONS: Record<ConfigKey, string> = {
45-
homeCountry: 'Your home country for location context (default: detected from IP)',
46-
timezone: 'Your timezone (e.g. Pacific/Auckland) (default: detected from system)',
47-
fetchImages: 'Fetch images by default (default: false)',
157+
// General
48158
cacheDir: 'Cache directory path (default: ~/.cache/chat-to-map)',
159+
fetchImages: 'Fetch images by default (default: false)',
160+
formats: 'Export formats (default: csv,excel,json,map,pdf)',
161+
homeCountry: 'Your home country for location context (default: detected from IP)',
49162
outputDir: 'Output directory for exports (default: ./output)',
50-
formats: 'Export formats (default: csv,excel,json,map,pdf)'
163+
timezone: 'Your timezone (e.g. Pacific/Auckland) (default: detected from system)',
164+
165+
// Common export settings
166+
exportCategories: 'Filter ALL exports by categories (comma-separated)',
167+
exportCountries: 'Filter ALL exports by countries (comma-separated)',
168+
exportFrom: 'Filter ALL exports by sender names (comma-separated)',
169+
exportStartDate: 'Filter ALL exports to on/after this date (YYYY-MM-DD)',
170+
exportEndDate: 'Filter ALL exports to on/before this date (YYYY-MM-DD)',
171+
exportMinScore: 'Min score for ALL exports (0-3)',
172+
exportOnlyLocations: 'Only export activities with specific locations',
173+
exportOnlyGeneric: 'Only export generic activities without locations',
174+
exportMaxActivities: 'Max activities in ALL exports, 0 for all (default: 0)',
175+
exportSort: 'Sort order for ALL exports: score, oldest, newest (default: score)',
176+
177+
// PDF-specific settings
178+
pdfThumbnails: 'Include thumbnails in PDF (default: false)',
179+
pdfIncludeScore: 'Show score in PDF output (default: false)',
180+
pdfGroupByCountry: 'Group by country in PDF (default: true)',
181+
pdfGroupByCategory: 'Group by category in PDF (default: true)',
182+
pdfPageSize: 'PDF page size: A4 or Letter (default: based on country)',
183+
pdfTitle: 'Custom PDF title',
184+
pdfSubtitle: 'Custom PDF subtitle',
185+
pdfCategories: 'Filter PDF by categories (overrides exportCategories)',
186+
pdfCountries: 'Filter PDF by countries (overrides exportCountries)',
187+
pdfFrom: 'Filter PDF by sender names (overrides exportFrom)',
188+
pdfStartDate: 'Filter PDF to on/after date (overrides exportStartDate)',
189+
pdfEndDate: 'Filter PDF to on/before date (overrides exportEndDate)',
190+
pdfMinScore: 'Min score for PDF (overrides exportMinScore)',
191+
pdfOnlyLocations: 'Only locations in PDF (overrides exportOnlyLocations)',
192+
pdfOnlyGeneric: 'Only generic in PDF (overrides exportOnlyGeneric)',
193+
pdfMaxActivities: 'Max activities in PDF (overrides exportMaxActivities)',
194+
pdfSort: 'Sort order for PDF (overrides exportSort)',
195+
196+
// Map-specific settings
197+
mapDefaultStyle: 'Default map tile style (e.g. osm, satellite, terrain)'
51198
}
52199

53200
/**
@@ -56,6 +203,7 @@ const CONFIG_DESCRIPTIONS: Record<ConfigKey, string> = {
56203
*/
57204
export function getConfigType(key: ConfigKey): string {
58205
if (BOOLEAN_KEYS.includes(key)) return 'boolean'
206+
if (NUMBER_KEYS.includes(key)) return 'number'
59207
if (ARRAY_KEYS.includes(key)) return 'comma-separated'
60208
return 'string'
61209
}
@@ -123,10 +271,16 @@ export async function saveConfig(config: Config, configFile?: string): Promise<v
123271
/**
124272
* Parse a string value into the appropriate type for a config key.
125273
*/
126-
export function parseConfigValue(key: ConfigKey, value: string): string | boolean | string[] {
274+
export function parseConfigValue(
275+
key: ConfigKey,
276+
value: string
277+
): string | boolean | number | string[] {
127278
if (BOOLEAN_KEYS.includes(key)) {
128279
return value === 'true' || value === '1' || value === 'yes'
129280
}
281+
if (NUMBER_KEYS.includes(key)) {
282+
return Number.parseInt(value, 10)
283+
}
130284
if (ARRAY_KEYS.includes(key)) {
131285
return value.split(',').map((v) => v.trim())
132286
}
@@ -153,23 +307,24 @@ export function isValidConfigKey(key: string): key is ConfigKey {
153307
return (
154308
STRING_KEYS.includes(key as ConfigKey) ||
155309
BOOLEAN_KEYS.includes(key as ConfigKey) ||
310+
NUMBER_KEYS.includes(key as ConfigKey) ||
156311
ARRAY_KEYS.includes(key as ConfigKey)
157312
)
158313
}
159314

160315
/**
161-
* Get all valid config keys.
316+
* Get all valid config keys (sorted alphabetically).
162317
*/
163318
export function getValidConfigKeys(): ConfigKey[] {
164-
return [...STRING_KEYS, ...BOOLEAN_KEYS, ...ARRAY_KEYS]
319+
return [...STRING_KEYS, ...BOOLEAN_KEYS, ...NUMBER_KEYS, ...ARRAY_KEYS].sort()
165320
}
166321

167322
/**
168323
* Set a single config value and save.
169324
*/
170325
export async function setConfigValue(
171326
key: ConfigKey,
172-
value: string | boolean | string[],
327+
value: string | boolean | number | string[],
173328
configFile?: string
174329
): Promise<void> {
175330
const config = (await loadConfig(configFile)) ?? {}

0 commit comments

Comments
 (0)