198 lines
8.5 KiB
C#
198 lines
8.5 KiB
C#
using YoutubeSummarizer.Models;
|
||
|
||
namespace YoutubeSummarizer.Services;
|
||
|
||
/// <summary>
|
||
/// Handles all console output formatting.
|
||
/// Keeping display logic separate from business logic makes it easy to
|
||
/// later add output modes (JSON, Markdown file, HTML report) without
|
||
/// touching the service layer.
|
||
/// </summary>
|
||
public static class ConsoleRenderer
|
||
{
|
||
// ANSI color codes. These render correctly in most Linux terminals.
|
||
// If you pipe output to a file, the escape codes will appear as-is —
|
||
// run with --no-color if that's a concern (not implemented here, left
|
||
// as an exercise).
|
||
private const string Reset = "\x1b[0m";
|
||
private const string Bold = "\x1b[1m";
|
||
private const string Cyan = "\x1b[36m";
|
||
private const string Yellow = "\x1b[33m";
|
||
private const string Green = "\x1b[32m";
|
||
private const string Red = "\x1b[31m";
|
||
private const string Dim = "\x1b[2m";
|
||
|
||
/// <summary>Prints the application banner on startup.</summary>
|
||
public static void PrintBanner()
|
||
{
|
||
Console.WriteLine();
|
||
Console.WriteLine($"{Bold}{Cyan}╔════════════════════════════════════════╗{Reset}");
|
||
Console.WriteLine($"{Bold}{Cyan}║ YouTube Video Summarizer ║{Reset}");
|
||
Console.WriteLine($"{Bold}{Cyan}╚════════════════════════════════════════╝{Reset}");
|
||
Console.WriteLine();
|
||
}
|
||
|
||
/// <summary>Prompts the user for a URL and reads input.</summary>
|
||
public static string PromptForUrl()
|
||
{
|
||
Console.Write($"{Bold}Enter YouTube URL (or 'q' to quit):{Reset} ");
|
||
return Console.ReadLine()?.Trim() ?? string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Asks the user whether they want to save the transcript to a text file.
|
||
/// Returns true if the user answers yes.
|
||
/// </summary>
|
||
public static bool PromptSaveTranscript()
|
||
{
|
||
Console.Write($"{Bold}Save transcript to file? (y/n):{Reset} ");
|
||
var answer = Console.ReadLine()?.Trim() ?? string.Empty;
|
||
return answer.Equals("y", StringComparison.OrdinalIgnoreCase)
|
||
|| answer.Equals("yes", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
/// <summary>Prints a success message with the saved file path.</summary>
|
||
public static void PrintFileSaved(string filePath)
|
||
{
|
||
Console.WriteLine($" {Green}✓ Transcript saved to:{Reset} {filePath}");
|
||
Console.WriteLine();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prompts the user to choose a summary mode.
|
||
/// Returns the selected <see cref="SummaryMode"/>.
|
||
/// </summary>
|
||
public static SummaryMode PromptSummaryMode()
|
||
{
|
||
Console.WriteLine($" {Dim}Summary modes:{Reset}");
|
||
Console.WriteLine($" {Bold}1{Reset} – Standard (detailed bullet-point summary)");
|
||
Console.WriteLine($" {Bold}2{Reset} – Personal Filter (relevance verdict: ACT / MONITOR / IGNORE)");
|
||
Console.Write($"{Bold}Choose summary mode [1]:{Reset} ");
|
||
var choice = Console.ReadLine()?.Trim() ?? string.Empty;
|
||
return choice == "2" ? SummaryMode.PersonalFilter : SummaryMode.Standard;
|
||
}
|
||
|
||
/// <summary>Displays a spinner-style "working" indicator while async work runs.</summary>
|
||
public static void PrintWorking(string message)
|
||
{
|
||
Console.WriteLine($" {Dim}→ {message}...{Reset}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the full summary result to the console in a structured,
|
||
/// readable format. Includes metadata header, quality warning, and
|
||
/// the summary body.
|
||
/// </summary>
|
||
public static void PrintSummary(VideoSummary summary, bool showTranscriptSource)
|
||
{
|
||
Console.WriteLine();
|
||
PrintDivider();
|
||
|
||
// ── Metadata header ──────────────────────────────────────────────────
|
||
Console.WriteLine($"{Bold}{Green} {summary.Metadata.Title}{Reset}");
|
||
Console.WriteLine($" {Dim}Channel:{Reset} {summary.Metadata.ChannelTitle}");
|
||
Console.WriteLine($" {Dim}Published:{Reset} {summary.Metadata.PublishedAt:MMMM d, yyyy}");
|
||
Console.WriteLine($" {Dim}Duration:{Reset} {summary.Metadata.FormattedDuration}");
|
||
Console.WriteLine($" {Dim}URL:{Reset} https://youtu.be/{summary.Metadata.VideoId}");
|
||
|
||
// ── Transcript source badge ──────────────────────────────────────────
|
||
if (showTranscriptSource)
|
||
{
|
||
var (badge, color) = summary.TranscriptSource switch
|
||
{
|
||
TranscriptSource.OwnerPublished => ("✓ Owner-published captions", Green),
|
||
TranscriptSource.CommunityContributed=> ("✓ Community captions", Green),
|
||
TranscriptSource.AutoGenerated => ("~ Auto-generated (ASR)", Yellow),
|
||
TranscriptSource.MetadataOnly => ("✗ Metadata only", Red),
|
||
_ => ("? Unknown", Dim)
|
||
};
|
||
Console.WriteLine($" {Dim}Transcript:{Reset} {color}{badge}{Reset}");
|
||
}
|
||
|
||
Console.WriteLine($" {Dim}Model:{Reset} {summary.ModelUsed}");
|
||
Console.WriteLine($" {Dim}Generated:{Reset} {summary.GeneratedAt:yyyy-MM-dd HH:mm} UTC");
|
||
|
||
PrintDivider();
|
||
|
||
// ── Quality warning ──────────────────────────────────────────────────
|
||
if (summary.QualityWarning is not null)
|
||
{
|
||
Console.WriteLine();
|
||
Console.WriteLine($" {Yellow}{summary.QualityWarning}{Reset}");
|
||
}
|
||
|
||
// ── Summary body ─────────────────────────────────────────────────────
|
||
Console.WriteLine();
|
||
Console.WriteLine($"{Bold} SUMMARY{Reset}");
|
||
Console.WriteLine();
|
||
|
||
// Word-wrap the summary body at 80 characters so it's readable in
|
||
// standard terminal widths without horizontal scrolling.
|
||
foreach (var line in WordWrap(summary.SummaryText, maxWidth: 78))
|
||
{
|
||
Console.WriteLine($" {line}");
|
||
}
|
||
|
||
Console.WriteLine();
|
||
PrintDivider();
|
||
Console.WriteLine();
|
||
}
|
||
|
||
/// <summary>Prints a styled error message.</summary>
|
||
public static void PrintError(string message)
|
||
{
|
||
Console.WriteLine();
|
||
Console.WriteLine($" {Red}✗ Error: {message}{Reset}");
|
||
Console.WriteLine();
|
||
}
|
||
|
||
/// <summary>Prints a styled warning (non-fatal).</summary>
|
||
public static void PrintWarning(string message)
|
||
{
|
||
Console.WriteLine($" {Yellow}⚠ {message}{Reset}");
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Private helpers
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
private static void PrintDivider()
|
||
{
|
||
Console.WriteLine($" {Dim}{"─".PadRight(74, '─')}{Reset}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Splits text into lines no wider than <paramref name="maxWidth"/> characters,
|
||
/// breaking only at word boundaries. Respects existing newlines in the input.
|
||
/// </summary>
|
||
private static IEnumerable<string> WordWrap(string text, int maxWidth)
|
||
{
|
||
foreach (var paragraph in text.Split('\n'))
|
||
{
|
||
if (string.IsNullOrWhiteSpace(paragraph))
|
||
{
|
||
yield return string.Empty;
|
||
continue;
|
||
}
|
||
|
||
var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
var current = new System.Text.StringBuilder();
|
||
|
||
foreach (var word in words)
|
||
{
|
||
if (current.Length + word.Length + 1 > maxWidth)
|
||
{
|
||
yield return current.ToString();
|
||
current.Clear();
|
||
}
|
||
|
||
if (current.Length > 0) current.Append(' ');
|
||
current.Append(word);
|
||
}
|
||
|
||
if (current.Length > 0)
|
||
yield return current.ToString();
|
||
}
|
||
}
|
||
}
|