feature: First push to git

This commit is contained in:
Keith Solomon
2026-05-16 14:02:49 -05:00
commit 265f69d95a
46 changed files with 11551 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
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({}),
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<typeof configSchema>;
export type PartialConfig = Record<string, unknown>;
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<string, unknown>).map(([key, entry]) => [
key.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()),
camelize(entry)
])
);
}
return 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) ?? {});
return configSchema.parse(parsed);
}
export async function loadConfig(path: string): Promise<AppConfig> {
return loadConfigFromString(await readFile(expandHome(path), 'utf8'));
}
export function normalizeConfig(config: PartialConfig): AppConfig {
return configSchema.parse(camelize(config));
}