import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import YAML from 'yaml'; import { z } from 'zod'; const outputSchema = z.object({ name: z.string().min(1), sheetsApi: z .object({ enabled: z.boolean().default(false), credentials: z.string().optional(), token: z.string().optional(), spreadsheetId: z.string().optional() }) .optional(), excel: z .object({ enabled: z.boolean().default(false), path: z.string().default('./output/newsletter-catalog.xlsx') }) .optional() }); const configSchema = z .object({ gmail: z.object({ folder: z.string().min(1), credentials: z.string().default('~/.nlc/gmail-credentials.json'), token: z.string().default('~/.nlc/gmail-token.json') }), output: outputSchema, newsletters: z.record(z.string(), z.any()).default({}), links: z .object({ unwrapRedirects: z.boolean().default(true), stripUtm: z.boolean().default(true), trackingParams: z .array(z.string()) .default(['utm_*', 'fbclid', 'gclid', 'mc_cid', 'mc_eid']), redirectLimit: z.number().int().positive().default(5), readMorePattern: z.string().default('(?i)^(read more|continue reading|learn more)$'), sharePatterns: z.array(z.string()).default(['(?i)share', '(?i)forward to a friend']), sponsorMarkers: z .array(z.string()) .default(['(?i)sponsor', '(?i)sponsored', '(?i)advertisement', '(?i)partner']), filterUnsubscribe: z.boolean().default(true), filterSocialFooter: z.boolean().default(true), filterShareLinks: z.boolean().default(true), mergeReadMore: z.boolean().default(true) }) .default({}), categories: z .object({ custom: z.array(z.string()).default([]), llm: z .object({ provider: z .enum(['anthropic', 'openai', 'local', 'openai-compatible']) .default('anthropic'), model: z.string().default('claude-sonnet-4-6'), apiKeyEnv: z.string().default('ANTHROPIC_API_KEY'), baseUrl: z.string().nullable().optional(), failureCategory: z.string().default('Uncategorized') }) .default({}) }) .default({}), enrichment: z .object({ enabled: z.boolean().default(true), concurrency: z.number().int().positive().default(3), delayMs: z.number().int().nonnegative().default(1500), retries: z.number().int().nonnegative().default(2), timeoutMs: z.number().int().positive().default(10000) }) .default({}), rateLimit: z .object({ gmailQps: z.number().positive().default(5), linkConcurrency: z.number().int().positive().default(3) }) .default({}), database: z .object({ enabled: z.boolean().default(true), path: z.string().default('./data/newsletter-catalog.sqlite') }) .default({}), stateFile: z.string().default('~/.nlc/state.json'), plugins: z.record(z.string(), z.any()).default({}) }) .transform((config) => ({ ...config, output: { ...config.output, sheetsApi: config.output.sheetsApi ?? { enabled: false }, excel: config.output.excel ?? { enabled: false, path: './output/newsletter-catalog.xlsx' } } })); export type AppConfig = z.infer; export type PartialConfig = Record; function camelize(value: unknown): unknown { if (Array.isArray(value)) { return value.map(camelize); } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value as Record).map(([key, entry]) => [ key.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()), camelize(entry) ]) ); } return value; } function objectFromNull(value: unknown): unknown { return value === null ? {} : value; } export function expandHome(path: string): string { return path.startsWith('~/') ? resolve(homedir(), path.slice(2)) : path; } export function loadConfigFromString(source: string): AppConfig { const parsed = camelize(YAML.parse(source) ?? {}) as Record; parsed.newsletters = objectFromNull(parsed.newsletters); parsed.categories = objectFromNull(parsed.categories) as Record; if (parsed.categories && typeof parsed.categories === 'object') { parsed.categories.llm = objectFromNull(parsed.categories.llm); } return configSchema.parse(parsed); } export async function loadConfig(path: string): Promise { return loadConfigFromString(await readFile(expandHome(path), 'utf8')); } export function normalizeConfig(config: PartialConfig): AppConfig { return configSchema.parse(camelize(config)); }