import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import type { Command } from "commander"; import { PagesCacheManager } from "../cache/PagesCacheManager"; import { ConfigManager } from "../config/ConfigManager"; import { ContentProcessor } from "../content/ContentProcessor"; import { DependencyManager } from "../dependencies/DependencyManager"; import { BidirectionalLinkResolver } from "../links/BidirectionalLinkResolver"; import { LinkResolver } from "../links/LinkResolver"; import type { BrokenLink, FileScanResult } from "../links/types"; import { convertMarkdownToTelegraphNodes } from "../markdownConverter"; import { MetadataManager } from "../metadata/MetadataManager"; import { EnhancedTelegraphPublisher } from "../publisher/EnhancedTelegraphPublisher"; import { TelegraphPublisher } from "../telegraphPublisher"; import { type FileMetadata, PublicationStatus, type TelegraphLink } from "../types/metadata"; import { PathResolver } from "../utils/PathResolver"; import { PublicationWorkflowManager } from "../workflow"; import { ProgressIndicator } from "./ProgressIndicator"; /** * Enhanced CLI commands with metadata management */ export class EnhancedCommands { /** * CLI to configuration parameter mapping (Selective Parameter Mapping pattern) */ private static readonly CLI_TO_CONFIG_MAPPING: Record = { 'author': 'defaultUsername', 'tocTitle': 'customFields.tocTitle', 'withDependencies': 'autoPublishDependencies' }; /** * Extract configuration updates from CLI options * @param options CLI options * @returns Configuration updates to apply */ private static extractConfigUpdatesFromCli(options: any): Partial { const updates: any = {}; for (const [cliKey, configPath] of Object.entries(EnhancedCommands.CLI_TO_CONFIG_MAPPING)) { if (options[cliKey] !== undefined) { if (configPath.includes('.')) { // Handle nested properties like customFields.tocTitle const pathParts = configPath.split('.'); const parent = pathParts[0]; const child = pathParts[1]; if (parent && child) { if (!updates[parent]) updates[parent] = {}; updates[parent][child] = options[cliKey]; } } else { updates[configPath] = options[cliKey]; } } } return updates; } /** * Notify user about configuration changes (Configuration Change Detection pattern) * @param changedFields Fields that were changed * @param configDirectory Directory where config was saved */ private static notifyConfigurationUpdate(changedFields: Record, configDirectory: string): void { const changes = Object.entries(changedFields) .map(([key, value]) => { if (typeof value === 'object' && value !== null) { // Handle nested objects like customFields return Object.entries(value).map(([nestedKey, nestedValue]) => ` ${key}.${nestedKey}: ${nestedValue}`).join('\n'); } return ` ${key}: ${value}`; }) .join('\n'); ProgressIndicator.showStatus( `πŸ’Ύ Configuration auto-saved to .telegraph-publisher-config.json:\n${changes}`, 'success' ); } /** * Add unified publish command (combines pub and edit functionality) * @param program Commander program instance */ static addPublishCommand(program: Command): void { program .command("publish") .alias("pub") .description("Unified publish/edit command: creates, publishes, or updates Markdown files (if no file specified, publishes entire directory)") .option("-f, --file ", "Path to the Markdown file (optional - if not specified, publishes current directory)") .option("-a, --author ", "Author's name (overrides config default)") .option("--title ", "Title of the article (optional, will be extracted from file if not provided)") .option("--author-url <url>", "Author's URL (optional)") .option("--with-dependencies", "Automatically publish linked local files (default: true)") .option("--no-with-dependencies", "Skip automatic dependency publishing") .option("--force-republish", "Force republish even if file is already published") .option("--dry-run", "Preview operations without making changes") .option("--debug", "Save the generated Telegraph JSON to a file (implies --dry-run)") .option("--no-verify", "Skip mandatory local link verification before publishing") .option("--no-auto-repair", "Disable automatic link repair (publication will fail if broken links are found)") .option("--aside", "Automatically generate a Table of Contents (aside block) at the start of the article (default: true)") .option("--no-aside", "Disable automatic generation of the Table of Contents") .option("--toc-title <title>", "Title for the Table of Contents section (default: 'Π‘ΠΎΠ΄Π΅Ρ€ΠΆΠ°Π½ΠΈΠ΅')") .option("--toc-separators", "Add horizontal separators (HR) before and after Table of Contents (default: true)") .option("--no-toc-separators", "Disable horizontal separators around Table of Contents") .option("--force", "Bypass link verification and publish anyway (for debugging)") .option("--token <token>", "Access token (optional, will try to load from config)") .option("-v, --verbose", "Show detailed progress information") .action(async (options) => { try { await EnhancedCommands.handleUnifiedPublishCommand(options); } catch (error) { ProgressIndicator.showStatus( `Operation failed: ${error instanceof Error ? error.message : String(error)}`, "error" ); process.exit(1); } }); } /** * Add dependency analysis command * @param program Commander program instance */ static addAnalyzeCommand(program: Command): void { program .command("analyze") .description("Analyze file dependencies and publication status") .option("-f, --file <path>", "Path to the Markdown file to analyze") .option("--depth <number>", "Maximum dependency depth to analyze", "1") .option("--show-tree", "Show dependency tree visualization") .action(async (options) => { try { await EnhancedCommands.handleAnalyzeCommand(options); } catch (error) { ProgressIndicator.showStatus( `Analysis failed: ${error instanceof Error ? error.message : String(error)}`, "error" ); process.exit(1); } }); } /** * Add configuration management command * @param program Commander program instance */ static addConfigCommand(program: Command): void { program .command("config") .description("Manage configuration settings") .option("--show", "Show current configuration") .option("--set <key=value>", "Set configuration value", []) .option("--reset", "Reset configuration to defaults") .option("--username <name>", "Set default username") .option("--max-depth <number>", "Set maximum dependency depth") .option("--auto-deps <boolean>", "Enable/disable automatic dependency publishing") .action(async (options) => { try { await EnhancedCommands.handleConfigCommand(options); } catch (error) { ProgressIndicator.showStatus( `Configuration failed: ${error instanceof Error ? error.message : String(error)}`, "error" ); process.exit(1); } }); } /** * Add status command * @param program Commander program instance */ static addStatusCommand(program: Command): void { program .command("status") .description("Show publication status of files") .option("-f, --file <path>", "Check specific file") .option("-d, --directory <path>", "Check all markdown files in directory") .option("--recursive", "Check subdirectories recursively") .action(async (options) => { try { await EnhancedCommands.handleStatusCommand(options); } catch (error) { ProgressIndicator.showStatus( `Status check failed: ${error instanceof Error ? error.message : String(error)}`, "error" ); process.exit(1); } }); } /** * Add reset command * @param program Commander program instance */ static addResetCommand(program: Command): void { program .command("reset") .alias("r") .description("Reset publication metadata, preserving only title") .option("-f, --file <path>", "Path to specific file (optional - processes directory if not specified)") .option("--dry-run", "Preview changes without modification") .option("-v, --verbose", "Detailed progress information") .option("--force", "Reset files even without publication metadata") .action(async (options) => { try { await EnhancedCommands.handleResetCommand(options); } catch (error) { ProgressIndicator.showStatus( `Reset failed: ${error instanceof Error ? error.message : String(error)}`, "error" ); process.exit(1); } }); } /** * Add check-links command * @param program Commander program instance */ static addCheckLinksCommand(program: Command): void { program .command("check-links") .alias("cl") .description("Verify and repair local Markdown links") .argument("[path]", "Path to file or directory (default: current directory)") .option("--apply-fixes", "Enable interactive repair mode") .option("--dry-run", "Report only, no changes (default)") .option("-v, --verbose", "Show detailed progress information") .option("-o, --output <file>", "Save report to file") .action(async (path, options) => { try { await EnhancedCommands.handleCheckLinksCommand(path, options); } catch (error) { ProgressIndicator.showStatus( `Link check failed: ${error instanceof Error ? error.message : String(error)}`, "error" ); process.exit(1); } }); } /** * Handle unified publish command (combines pub and edit functionality) * @param options Command options */ static async handleUnifiedPublishCommand(options: any): Promise<void> { const targetPath = options.file || process.cwd(); const fileDirectory = options.file ? dirname(resolve(options.file)) : process.cwd(); // If a specific file is targeted and it doesn't exist, create it (edit functionality) if (options.file && !existsSync(resolve(options.file))) { ProgressIndicator.showStatus("File not found. Creating new file...", "info"); const initialContent = options.title ? `# ${options.title}\n\nContent goes here...` : `# New Article\n\nContent goes here...`; writeFileSync(resolve(options.file), initialContent); ProgressIndicator.showStatus(`Created new file: ${resolve(options.file)}`, "success"); } // STEP 1: Load existing configuration const existingConfig = ConfigManager.getMetadataConfig(fileDirectory); // STEP 2: Extract configuration updates from CLI options const configUpdatesFromCli = EnhancedCommands.extractConfigUpdatesFromCli(options); // STEP 3: Merge configurations with CLI priority (Configuration Cascade pattern) const finalConfig = { ...existingConfig, ...configUpdatesFromCli }; // STEP 4: Persist merged configuration immediately (Immediate Persistence pattern) if (Object.keys(configUpdatesFromCli).length > 0) { ConfigManager.updateMetadataConfig(fileDirectory, finalConfig); EnhancedCommands.notifyConfigurationUpdate(configUpdatesFromCli, fileDirectory); } // STEP 5: Handle access token with same persistence pattern const accessToken = options.token || ConfigManager.loadAccessToken(fileDirectory); if (options.token) { ConfigManager.saveAccessToken(fileDirectory, options.token); } if (!accessToken) { throw new Error("Access token is required. Set it using --token or configure it with 'config' command"); } const workflowManager = new PublicationWorkflowManager(finalConfig, accessToken); // Handle debug mode: debug implies dry-run if (options.debug) { options.dryRun = true; } try { await workflowManager.publish(targetPath, options); } catch (error) { ProgressIndicator.showStatus( `Publication workflow failed: ${error instanceof Error ? error.message : String(error)}`, "error" ); process.exit(1); } } /** * Handle dependency analysis command * @param options Command options */ private static async handleAnalyzeCommand(options: any): Promise<void> { if (!options.file) { throw new Error("File path must be specified using --file"); } const filePath = resolve(options.file); if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const config = ConfigManager.getMetadataConfig(dirname(filePath)); config.maxDependencyDepth = parseInt(options.depth) || config.maxDependencyDepth; const pathResolver = PathResolver.getInstance(); const dependencyManager = new DependencyManager(config, pathResolver); ProgressIndicator.showStatus("Analyzing dependencies...", "info"); const dependencyTree = dependencyManager.buildDependencyTree(filePath); const analysis = dependencyManager.analyzeDependencyTree(dependencyTree); console.log("\nπŸ“Š Dependency Analysis Results:"); console.log("================================"); console.log(`πŸ“ Total files: ${analysis.totalFiles}`); console.log(`βœ… Published files: ${analysis.publishedFiles}`); console.log(`πŸ“ Unpublished files: ${analysis.unpublishedFiles}`); console.log(`πŸ“ Maximum depth: ${analysis.maxDepth}`); if (analysis.circularDependencies.length > 0) { ProgressIndicator.showStatus("Circular dependencies detected!", "warning"); analysis.circularDependencies.forEach((cycle, index) => { console.log(` ${index + 1}. ${cycle.join(" β†’ ")}`); }); } if (analysis.unpublishedFiles > 0) { const filesToPublish = dependencyManager.getFilesToPublish(dependencyTree); ProgressIndicator.showList("Files that need publishing", filesToPublish); if (analysis.publishOrder.length > 0) { ProgressIndicator.showList("Recommended publishing order", analysis.publishOrder, true); } } if (options.showTree) { console.log("\n🌳 Dependency Tree:"); EnhancedCommands.printDependencyTree(dependencyTree, "", true); } } /** * Handle configuration command * @param options Command options */ private static async handleConfigCommand(options: any): Promise<void> { const directory = process.cwd(); if (options.show) { ConfigManager.displayConfig(directory); return; } if (options.reset) { ConfigManager.resetConfig(directory); return; } // Handle individual settings const updates: any = {}; if (options.username) { updates.defaultUsername = options.username; } if (options.maxDepth) { const depth = parseInt(options.maxDepth); if (isNaN(depth) || depth < 1 || depth > 20) { throw new Error("Max depth must be a number between 1 and 20"); } updates.maxDependencyDepth = depth; } if (options.autoDeps !== undefined) { updates.autoPublishDependencies = options.autoDeps === "true"; } if (Object.keys(updates).length > 0) { ConfigManager.updateMetadataConfig(directory, updates); ProgressIndicator.showStatus("Configuration updated successfully", "success"); } else { ConfigManager.displayConfig(directory); } } /** * Handle status command * @param options Command options */ private static async handleStatusCommand(options: any): Promise<void> { if (options.file) { const filePath = resolve(options.file); if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const status = MetadataManager.getPublicationStatus(filePath); const metadata = MetadataManager.getPublicationInfo(filePath); console.log(`\nπŸ“„ File: ${filePath}`); console.log(`πŸ“Š Status: ${status}`); if (metadata) { console.log(`πŸ”— URL: ${metadata.telegraphUrl}`); console.log(`πŸ‘€ Author: ${metadata.username}`); console.log(`πŸ“… Published: ${metadata.publishedAt}`); } } else { ProgressIndicator.showStatus("Directory status checking not implemented yet", "warning"); } } /** * Print dependency tree visualization * @param node Dependency node * @param prefix Tree prefix * @param isLast Whether this is the last node */ private static printDependencyTree(node: any, prefix: string = "", isLast: boolean = true): void { const status = node.status === PublicationStatus.PUBLISHED ? "βœ…" : "πŸ“"; const connector = isLast ? "└── " : "β”œβ”€β”€ "; console.log(`${prefix}${connector}${status} ${node.filePath}`); if (node.dependencies && node.dependencies.length > 0) { const newPrefix = prefix + (isLast ? " " : "β”‚ "); node.dependencies.forEach((dep: any, index: number) => { const isLastDep = index === node.dependencies.length - 1; EnhancedCommands.printDependencyTree(dep, newPrefix, isLastDep); }); } } /** * Handle directory publishing - publish all markdown files in current directory and subdirectories * @param options Command line options */ static async handleDirectoryPublish(options: any): Promise<void> { // This method is now largely replaced by PublicationWorkflowManager.publish // It will simply call the unified publish method with the current directory as target await EnhancedCommands.handleUnifiedPublishCommand(options); } /** * Find all markdown files in directory and subdirectories * @param dir Directory to search * @returns Array of markdown file paths */ static async findMarkdownFiles(dir: string): Promise<string[]> { const { readdirSync, statSync } = await import('fs'); const { join } = await import('path'); const files: string[] = []; try { const entries = readdirSync(dir); for (const entry of entries) { const fullPath = join(dir, entry); try { const stat = statSync(fullPath); if (stat.isDirectory()) { // Skip node_modules, .git, and other common directories if (!entry.startsWith('.') && entry !== 'node_modules' && entry !== 'dist') { const subFiles = await EnhancedCommands.findMarkdownFiles(fullPath); files.push(...subFiles); } } else if (stat.isFile() && entry.toLowerCase().endsWith('.md')) { files.push(fullPath); } } catch (error) { // Skip files/directories that can't be accessed console.warn(`Warning: Could not access ${fullPath}`); } } } catch (error) { console.warn(`Warning: Could not read directory ${dir}`); } return files.sort(); } /** * Handle reset command * @param options Command options */ static async handleResetCommand(options: any): Promise<void> { const { readFileSync, writeFileSync } = await import('fs'); const { resolve } = await import('path'); // If no file specified, process current directory if (!options.file) { await EnhancedCommands.handleDirectoryReset(options); return; } const filePath = resolve(options.file as string); if (options.verbose) { ProgressIndicator.showStatus(`Processing file: ${filePath}`, "info"); } try { // Read file content const content = readFileSync(filePath, 'utf-8'); // Check if file has metadata to reset const hasMetadata = content.trim().startsWith('---'); if (!hasMetadata && !options.force) { ProgressIndicator.showStatus(`Skipped ${filePath}: No front-matter found`, "info"); return; } // Perform reset const resetContent = MetadataManager.resetMetadata(content, filePath); if (options.dryRun) { ProgressIndicator.showStatus("πŸ” Dry-run mode: Preview of changes", "info"); console.log(`\nπŸ“„ File: ${filePath}`); console.log("πŸ“ Before:"); console.log(content.split('\n').slice(0, 10).join('\n') + (content.split('\n').length > 10 ? '\n...' : '')); console.log("\n✨ After:"); console.log(resetContent.split('\n').slice(0, 10).join('\n') + (resetContent.split('\n').length > 10 ? '\n...' : '')); console.log(""); return; } // Write the reset content back to file writeFileSync(filePath, resetContent, 'utf-8'); ProgressIndicator.showStatus(`βœ… Reset completed: ${filePath}`, "success"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); ProgressIndicator.showStatus(`❌ Error processing ${filePath}: ${errorMessage}`, "error"); throw error; } } /** * Handle directory reset * @param options Command options */ static async handleDirectoryReset(options: any): Promise<void> { const currentDir = process.cwd(); if (options.verbose) { ProgressIndicator.showStatus(`Scanning directory: ${currentDir}`, "info"); } // Find all markdown files const markdownFiles = await EnhancedCommands.findMarkdownFiles(currentDir); if (markdownFiles.length === 0) { ProgressIndicator.showStatus("No markdown files found in current directory", "info"); return; } if (options.dryRun) { ProgressIndicator.showStatus("πŸ” Dry-run mode: Found files that would be processed:", "info"); markdownFiles.forEach(file => console.log(` πŸ“„ ${file}`)); console.log(`\nTotal files: ${markdownFiles.length}`); return; } ProgressIndicator.showStatus(`Found ${markdownFiles.length} markdown files. Processing...`, "info"); const spinner = ProgressIndicator.createSpinner("Resetting metadata"); spinner.start(); let successCount = 0; let skipCount = 0; let errorCount = 0; const errors: Array<{ file: string, error: string }> = []; try { for (let i = 0; i < markdownFiles.length; i++) { const file = markdownFiles[i]; if (!file) continue; try { const { readFileSync, writeFileSync } = await import('fs'); const content = readFileSync(file, 'utf-8'); // Check if file has metadata to reset const hasMetadata = content.trim().startsWith('---'); if (!hasMetadata && !options.force) { skipCount++; if (options.verbose) { spinner.stop(); ProgressIndicator.showStatus(`Skipped ${file}: No front-matter`, "info"); spinner.start(); } continue; } // Perform reset const resetContent = MetadataManager.resetMetadata(content, file); writeFileSync(file, resetContent, 'utf-8'); successCount++; if (options.verbose) { spinner.stop(); ProgressIndicator.showStatus(`βœ… Reset: ${file}`, "success"); spinner.start(); } } catch (error) { errorCount++; const errorMessage = error instanceof Error ? error.message : String(error); errors.push({ file, error: errorMessage }); if (options.verbose) { spinner.stop(); ProgressIndicator.showStatus(`❌ Error: ${file} - ${errorMessage}`, "error"); spinner.start(); } } } } finally { spinner.stop(); } // Display summary ProgressIndicator.showStatus( `\nβœ… Reset Operation Complete\n` + `═══════════════════════════\n` + `πŸ“Š Files processed: ${markdownFiles.length}\n` + `βœ… Successfully reset: ${successCount}\n` + `⚠️ Skipped: ${skipCount}\n` + `❌ Errors: ${errorCount}`, "success" ); if (errors.length > 0) { console.log("\n❌ Failed files:"); errors.forEach(({ file, error }) => { console.log(` β€’ ${file}: ${error}`); }); } } /** * Handle check-links command * @param path Path to scan (optional) * @param options Command options */ static async handleCheckLinksCommand(path: string | undefined, options: any): Promise<void> { const { LinkScanner, LinkVerifier, LinkResolver, ReportGenerator, InteractiveRepairer } = await import('../links'); const targetPath = path || process.cwd(); const verbose = options.verbose || false; const applyFixes = options.applyFixes || false; const outputFile = options.output; const reportGenerator = new ReportGenerator(verbose); const scanner = new LinkScanner(); const verifier = new LinkVerifier(PathResolver.getInstance(), targetPath); const resolver = new LinkResolver(); const startTime = Date.now(); try { // Show initial progress if (verbose) { reportGenerator.showInfo(`πŸ”Ž Starting scan: ${targetPath}`); } // Find all markdown files const markdownFiles = await scanner.findMarkdownFiles(targetPath); if (markdownFiles.length === 0) { reportGenerator.showInfo('No markdown files found to scan.'); return; } // Scan all files for links const fileResults: any[] = []; let totalLinks = 0; let totalLocalLinks = 0; for (let i = 0; i < markdownFiles.length; i++) { const filePath = markdownFiles[i]; if (!filePath) continue; if (verbose) { reportGenerator.showVerboseProgress(filePath, i + 1, markdownFiles.length); } const scanResult = await scanner.scanFile(filePath); const verifiedResult = await verifier.verifyLinks(scanResult); fileResults.push(verifiedResult); totalLinks += verifiedResult.allLinks.length; totalLocalLinks += verifiedResult.localLinks.length; if (verbose) { reportGenerator.showVerboseFileDetails(verifiedResult); } } // Resolve suggestions for broken links const resolvedResults = await resolver.resolveBrokenLinks(fileResults); // Create complete scan result const allBrokenLinks: any[] = []; for (const result of resolvedResults) { allBrokenLinks.push(...result.brokenLinks); } const scanResult = { totalFiles: markdownFiles.length, totalLinks, totalLocalLinks, brokenLinks: allBrokenLinks, fileResults: resolvedResults, processingTime: Date.now() - startTime }; // Generate report reportGenerator.generateReport(scanResult, outputFile); // Interactive repair mode if (applyFixes && allBrokenLinks.length > 0) { const repairer = new InteractiveRepairer(reportGenerator); await repairer.performInteractiveRepair(allBrokenLinks); } } catch (error) { reportGenerator.showError(error instanceof Error ? error.message : String(error)); throw error; } } }