Summarize local PDF, Word, and plain text files via a new DocumentService (PdfPig + DocumentFormat.OpenXml), and add a Custom summary mode that uses the user's own instructions as the system prompt. Update the console UI, transcript saving, and README to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
220 lines
8.9 KiB
C#
220 lines
8.9 KiB
C#
using Spectre.Console;
|
||
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.
|
||
///
|
||
/// Rendering is delegated to Spectre.Console, which handles colour,
|
||
/// word-wrap to the current terminal width, and graceful degradation
|
||
/// when output is redirected to a file or a non-ANSI terminal.
|
||
/// </summary>
|
||
public static class ConsoleRenderer
|
||
{
|
||
/// <summary>
|
||
/// Sets the terminal window/tab title. Silently no-ops on terminals that
|
||
/// don't support the escape sequence.
|
||
/// </summary>
|
||
public static void SetTitle(string title)
|
||
{
|
||
try { Console.Title = title; }
|
||
catch { /* unsupported terminal — ignore */ }
|
||
}
|
||
|
||
/// <summary>Prints the application banner on startup.</summary>
|
||
public static void PrintBanner()
|
||
{
|
||
SetTitle("Summarize");
|
||
AnsiConsole.WriteLine();
|
||
AnsiConsole.Write(new FigletText("YT Summary").Centered().Color(Color.Red));
|
||
AnsiConsole.Write(
|
||
new Rule("[grey]YouTube Video Summarizer[/]")
|
||
.RuleStyle("grey")
|
||
.Centered());
|
||
AnsiConsole.WriteLine();
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when the terminal can drive Spectre's interactive prompts. When output
|
||
/// is redirected or piped we fall back to plain reads / default answers so the
|
||
/// app still works in scripts and CI.
|
||
/// </summary>
|
||
private static bool Interactive => AnsiConsole.Profile.Capabilities.Interactive;
|
||
|
||
/// <summary>Prompts the user for a URL and reads input.</summary>
|
||
public static string PromptForUrl()
|
||
{
|
||
// Revert the title while waiting for the next video.
|
||
SetTitle("Summarize");
|
||
|
||
const string label = "[bold]Enter a YouTube URL or a local file path[/] [grey](or 'q' to quit)[/]";
|
||
|
||
if (!Interactive)
|
||
{
|
||
AnsiConsole.Markup($"{label}: ");
|
||
return Console.ReadLine()?.Trim() ?? string.Empty;
|
||
}
|
||
|
||
var input = AnsiConsole.Prompt(new TextPrompt<string>($"{label}:").AllowEmpty());
|
||
return input.Trim();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Asks the user whether they want to save the transcript to a text file.
|
||
/// Defaults to <c>true</c> — pressing Enter accepts.
|
||
/// </summary>
|
||
public static bool PromptSaveTranscript()
|
||
{
|
||
if (!Interactive) return true;
|
||
return AnsiConsole.Confirm("[bold]Save transcript to file?[/]", defaultValue: true);
|
||
}
|
||
|
||
/// <summary>Prints a success message with the saved file path.</summary>
|
||
public static void PrintFileSaved(string filePath)
|
||
{
|
||
AnsiConsole.MarkupLine($"[green]✓[/] Transcript saved to: [blue]{Markup.Escape(filePath)}[/]");
|
||
AnsiConsole.WriteLine();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prompts the user to choose a summary mode via an arrow-key selection list.
|
||
/// <see cref="SummaryMode.Standard"/> is listed first so it is the highlighted
|
||
/// default — pressing Enter accepts it.
|
||
/// </summary>
|
||
public static SummaryMode PromptSummaryMode()
|
||
{
|
||
if (!Interactive) return SummaryMode.Standard;
|
||
return AnsiConsole.Prompt(
|
||
new SelectionPrompt<SummaryMode>()
|
||
.Title("[bold]Choose summary mode:[/]")
|
||
.AddChoices(SummaryMode.Standard, SummaryMode.PersonalFilter, SummaryMode.Custom)
|
||
.UseConverter(m => m switch
|
||
{
|
||
SummaryMode.Standard => "Standard – detailed bullet-point summary",
|
||
SummaryMode.PersonalFilter => "Personal Filter – relevance verdict (ACT / MONITOR / IGNORE)",
|
||
SummaryMode.Custom => "Custom – write your own instructions",
|
||
_ => m.ToString()
|
||
}));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads multi-line custom summarization instructions from the user,
|
||
/// terminated by a blank line. Spectre has no built-in multi-line text
|
||
/// prompt, so this reads raw lines directly (still styled with a Spectre
|
||
/// header) and joins them with newlines.
|
||
/// </summary>
|
||
public static string PromptCustomInstructions()
|
||
{
|
||
AnsiConsole.MarkupLine("[bold]Enter your custom instructions[/] [grey](finish with an empty line):[/]");
|
||
|
||
var lines = new List<string>();
|
||
string? line;
|
||
while (!string.IsNullOrEmpty(line = Console.ReadLine()))
|
||
{
|
||
lines.Add(line);
|
||
}
|
||
|
||
return string.Join('\n', lines).Trim();
|
||
}
|
||
|
||
/// <summary>Prints a dim "working" indicator before an async step.</summary>
|
||
public static void PrintWorking(string message)
|
||
{
|
||
AnsiConsole.MarkupLine($"[grey]→ {Markup.Escape(message)}...[/]");
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
AnsiConsole.WriteLine();
|
||
|
||
var isDocument = summary.TranscriptSource == TranscriptSource.LocalDocument;
|
||
|
||
// ── Metadata header ──────────────────────────────────────────────────
|
||
var grid = new Grid();
|
||
grid.AddColumn(new GridColumn().PadRight(2));
|
||
grid.AddColumn();
|
||
|
||
grid.AddRow("[bold green]Title[/]", $"[bold]{Markup.Escape(summary.Metadata.Title)}[/]");
|
||
grid.AddRow(isDocument ? "[grey]Type[/]" : "[grey]Channel[/]", Markup.Escape(summary.Metadata.ChannelTitle));
|
||
grid.AddRow(
|
||
isDocument ? "[grey]Modified[/]" : "[grey]Published[/]",
|
||
Markup.Escape(summary.Metadata.PublishedAt.ToString("MMMM d, yyyy")));
|
||
|
||
if (summary.Metadata.Duration is not null)
|
||
grid.AddRow("[grey]Duration[/]", Markup.Escape(summary.Metadata.FormattedDuration));
|
||
|
||
grid.AddRow(
|
||
isDocument ? "[grey]Location[/]" : "[grey]URL[/]",
|
||
isDocument
|
||
? Markup.Escape(summary.Metadata.SourcePath ?? summary.Metadata.Title)
|
||
: $"https://youtu.be/{Markup.Escape(summary.Metadata.VideoId)}");
|
||
|
||
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"),
|
||
TranscriptSource.LocalDocument => ("✓ Local document", "green"),
|
||
_ => ("? Unknown", "grey")
|
||
};
|
||
grid.AddRow("[grey]Transcript[/]", $"[{color}]{Markup.Escape(badge)}[/]");
|
||
}
|
||
|
||
grid.AddRow("[grey]Model[/]", Markup.Escape(summary.ModelUsed));
|
||
grid.AddRow("[grey]Generated[/]", Markup.Escape(summary.GeneratedAt.ToString("yyyy-MM-dd HH:mm")) + " UTC");
|
||
|
||
AnsiConsole.Write(grid);
|
||
AnsiConsole.WriteLine();
|
||
|
||
// ── Quality warning ──────────────────────────────────────────────────
|
||
if (summary.QualityWarning is not null)
|
||
{
|
||
AnsiConsole.Write(
|
||
new Panel($"[yellow]{Markup.Escape(summary.QualityWarning)}[/]")
|
||
{
|
||
Header = new PanelHeader("Quality warning"),
|
||
Border = BoxBorder.Rounded,
|
||
BorderStyle = new Style(Color.Yellow)
|
||
});
|
||
AnsiConsole.WriteLine();
|
||
}
|
||
|
||
// ── Summary body ─────────────────────────────────────────────────────
|
||
AnsiConsole.Write(
|
||
new Panel(Markup.Escape(summary.SummaryText))
|
||
{
|
||
Header = new PanelHeader("Summary"),
|
||
Border = BoxBorder.Rounded,
|
||
Padding = new Padding(2, 1, 2, 1)
|
||
}.Expand());
|
||
|
||
AnsiConsole.WriteLine();
|
||
}
|
||
|
||
/// <summary>Prints a styled error message.</summary>
|
||
public static void PrintError(string message)
|
||
{
|
||
AnsiConsole.WriteLine();
|
||
AnsiConsole.MarkupLine($"[red]✗ Error:[/] {Markup.Escape(message)}");
|
||
AnsiConsole.WriteLine();
|
||
}
|
||
|
||
/// <summary>Prints a styled warning (non-fatal).</summary>
|
||
public static void PrintWarning(string message)
|
||
{
|
||
AnsiConsole.MarkupLine($"[yellow]⚠ {Markup.Escape(message)}[/]");
|
||
}
|
||
}
|