import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import type { AnalyticsBatch, WeeklyReport, MonthlyReport, PostAnalytics } from "../models/types.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); /** * Get the analytics root directory from environment or default location * Default is assets/analytics relative to the plugin root */ export function getAnalyticsRoot(): string { if (process.env.ANALYTICS_ROOT) { return resolve(process.env.ANALYTICS_ROOT); } // Build output is at: scripts/analytics/build/utils/storage.js // Plugin root is 4 levels up: ../../../../ // Then assets/analytics from there const pluginRoot = resolve(__dirname, "../../../../"); return join(pluginRoot, "assets", "analytics"); } /** * Ensure required subdirectories exist under analytics root */ export function ensureDirectories(root: string): void { const directories = ["exports", "posts", "weekly-reports", "monthly-reports"]; if (!existsSync(root)) { mkdirSync(root, { recursive: true }); } for (const dir of directories) { const path = join(root, dir); if (!existsSync(path)) { mkdirSync(path, { recursive: true }); } } } /** * List all CSV export files in the exports directory */ export function listExports(root: string): string[] { const exportsDir = join(root, "exports"); if (!existsSync(exportsDir)) { return []; } return readdirSync(exportsDir) .filter(file => file.endsWith(".csv")) .sort(); } /** * Sanitize date string to only allow YYYY-MM-DD format */ function sanitizeDate(date: string): string { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`); } return date; } /** * Sanitize ID string to only allow alphanumeric and hyphens */ function sanitizeId(id: string): string { if (!/^[a-zA-Z0-9-]+$/.test(id)) { throw new Error(`Invalid ID format: ${id}. Only alphanumeric and hyphens allowed`); } return id; } /** * Verify that the resolved path is within the expected directory */ function verifyPathWithinDirectory(filepath: string, expectedDir: string): void { const resolvedPath = resolve(filepath); const resolvedDir = resolve(expectedDir); if (!resolvedPath.startsWith(resolvedDir + "/") && resolvedPath !== resolvedDir) { throw new Error(`Path traversal detected: ${filepath} is not within ${expectedDir}`); } } /** * Save an analytics batch to disk * Returns the filename that was created */ export function saveBatch(root: string, batch: AnalyticsBatch): string { ensureDirectories(root); const postsDir = join(root, "posts"); // Sanitize inputs to prevent path traversal const date = sanitizeDate(batch.dateRange.from); const shortId = sanitizeId(batch.batchId.substring(0, 8)); const filename = `${date}-${shortId}.json`; const filepath = join(postsDir, filename); // Verify the resolved filepath is within postsDir verifyPathWithinDirectory(filepath, postsDir); writeFileSync(filepath, JSON.stringify(batch, null, 2), "utf-8"); return filename; } /** * Load all analytics batches from disk * Returns batches sorted by importedAt timestamp */ export function loadAllBatches(root: string): AnalyticsBatch[] { const postsDir = join(root, "posts"); if (!existsSync(postsDir)) { return []; } const batches: AnalyticsBatch[] = []; for (const file of readdirSync(postsDir)) { if (!file.endsWith(".json")) { continue; } const filepath = join(postsDir, file); const content = readFileSync(filepath, "utf-8"); try { const batch = JSON.parse(content) as AnalyticsBatch; batches.push(batch); } catch { // Skip corrupt batch file continue; } } return batches.sort((a, b) => a.importedAt.localeCompare(b.importedAt) ); } /** * Load all posts from all batches, deduplicated by post ID * Latest import wins. Sorted by publishedDate descending. */ export function loadAllPosts(root: string): PostAnalytics[] { const batches = loadAllBatches(root); // Use Map to deduplicate - key is post ID, value is { post, importedAt } const postMap = new Map(); for (const batch of batches) { for (const post of batch.posts) { const existing = postMap.get(post.id); // Keep post with latest importedAt timestamp if (!existing || batch.importedAt > existing.importedAt) { postMap.set(post.id, { post, importedAt: batch.importedAt }); } } } // Extract posts and sort by publishedDate descending const posts = Array.from(postMap.values()).map(({ post }) => post); return posts.sort((a, b) => b.publishedDate.localeCompare(a.publishedDate) ); } /** * Sanitize week string to only allow ISO week format (YYYY-WXX) */ function sanitizeWeek(week: string): string { if (!/^\d{4}-W\d{2}$/.test(week)) { throw new Error(`Invalid week format: ${week}. Expected YYYY-WXX`); } return week; } /** * Save a weekly report to disk * Returns the filename that was created */ export function saveWeeklyReport(root: string, report: WeeklyReport): string { ensureDirectories(root); const reportsDir = join(root, "weekly-reports"); // Sanitize week to prevent path traversal const week = sanitizeWeek(report.week); const filename = `${week}.json`; const filepath = join(reportsDir, filename); // Verify the resolved filepath is within reportsDir verifyPathWithinDirectory(filepath, reportsDir); writeFileSync(filepath, JSON.stringify(report, null, 2), "utf-8"); return filename; } /** * Load a specific weekly report by week identifier * Returns null if not found */ export function loadWeeklyReport(root: string, week: string): WeeklyReport | null { week = sanitizeWeek(week); const reportsDir = join(root, "weekly-reports"); const filepath = join(reportsDir, `${week}.json`); if (!existsSync(filepath)) { return null; } const content = readFileSync(filepath, "utf-8"); return JSON.parse(content) as WeeklyReport; } /** * Load all weekly reports from disk * Returns reports sorted by week descending (newest first) */ export function loadAllWeeklyReports(root: string): WeeklyReport[] { const reportsDir = join(root, "weekly-reports"); if (!existsSync(reportsDir)) { return []; } const reports: WeeklyReport[] = []; for (const file of readdirSync(reportsDir)) { if (!file.endsWith(".json")) { continue; } const filepath = join(reportsDir, file); const content = readFileSync(filepath, "utf-8"); const report = JSON.parse(content) as WeeklyReport; reports.push(report); } return reports.sort((a, b) => b.week.localeCompare(a.week) ); } /** * Sanitize month string to only allow YYYY-MM format */ function sanitizeMonth(month: string): string { if (!/^\d{4}-\d{2}$/.test(month)) { throw new Error(`Invalid month format: ${month}. Expected YYYY-MM`); } return month; } /** * Save a monthly report to disk */ export function saveMonthlyReport(root: string, report: MonthlyReport): string { ensureDirectories(root); const reportsDir = join(root, "monthly-reports"); const month = sanitizeMonth(report.month); const filename = `${month}.json`; const filepath = join(reportsDir, filename); verifyPathWithinDirectory(filepath, reportsDir); writeFileSync(filepath, JSON.stringify(report, null, 2), "utf-8"); return filename; } /** * Load a specific monthly report by month identifier */ export function loadMonthlyReport(root: string, month: string): MonthlyReport | null { month = sanitizeMonth(month); const reportsDir = join(root, "monthly-reports"); const filepath = join(reportsDir, `${month}.json`); if (!existsSync(filepath)) return null; const content = readFileSync(filepath, "utf-8"); return JSON.parse(content) as MonthlyReport; }