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>
199 lines
8.2 KiB
C#
199 lines
8.2 KiB
C#
using System.IO.Compression;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Xml.Linq;
|
|
using DocumentFormat.OpenXml.Packaging;
|
|
using DocumentFormat.OpenXml.Spreadsheet;
|
|
using UglyToad.PdfPig;
|
|
using YoutubeSummarizer.Models;
|
|
|
|
namespace YoutubeSummarizer.Services;
|
|
|
|
/// <summary>
|
|
/// Reads a local file — text, Markdown, CSV, PDF, Word, OpenDocument text, or
|
|
/// Excel — and adapts it into the same <see cref="VideoMetadata"/> /
|
|
/// <see cref="VideoTranscript"/> shapes the YouTube pipeline uses, so the rest
|
|
/// of the app (summarization, rendering, saving) doesn't need to know or care
|
|
/// where the text came from.
|
|
/// </summary>
|
|
public static class DocumentService
|
|
{
|
|
/// <summary>File extensions this service knows how to read (lowercase, with leading dot).</summary>
|
|
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
".txt", ".md", ".csv", ".pdf", ".docx", ".odt", ".xlsx"
|
|
};
|
|
|
|
/// <summary>True when <paramref name="path"/> has an extension this service can extract text from.</summary>
|
|
public static bool IsSupportedFile(string path) =>
|
|
SupportedExtensions.Contains(Path.GetExtension(path));
|
|
|
|
/// <summary>A human-readable label for the file type, shown in place of "Channel" for documents.</summary>
|
|
public static string FriendlyTypeName(string path) => Path.GetExtension(path).ToLowerInvariant() switch
|
|
{
|
|
".txt" => "Plain text",
|
|
".md" => "Markdown document",
|
|
".csv" => "Spreadsheet (CSV)",
|
|
".xlsx" => "Spreadsheet",
|
|
".pdf" => "PDF document",
|
|
".docx" => "Word document",
|
|
".odt" => "OpenDocument text",
|
|
_ => "Document"
|
|
};
|
|
|
|
/// <summary>Builds synthetic video-shaped metadata describing a local file.</summary>
|
|
public static VideoMetadata BuildMetadata(string path)
|
|
{
|
|
var fullPath = Path.GetFullPath(path);
|
|
|
|
return new VideoMetadata
|
|
{
|
|
VideoId = ShortHash(fullPath),
|
|
Title = Path.GetFileName(fullPath),
|
|
ChannelTitle = FriendlyTypeName(fullPath),
|
|
PublishedAt = File.GetLastWriteTimeUtc(fullPath),
|
|
Duration = null,
|
|
Description = null,
|
|
SourcePath = fullPath
|
|
};
|
|
}
|
|
|
|
/// <summary>Extracts plain text from the file and wraps it as a transcript.</summary>
|
|
public static async Task<VideoTranscript> ExtractTextAsync(string path, CancellationToken ct = default)
|
|
{
|
|
var extension = Path.GetExtension(path).ToLowerInvariant();
|
|
|
|
var text = extension switch
|
|
{
|
|
".txt" or ".md" or ".csv" => await File.ReadAllTextAsync(path, ct),
|
|
".pdf" => ExtractPdfText(path),
|
|
".docx" => ExtractDocxText(path),
|
|
".xlsx" => ExtractXlsxText(path),
|
|
".odt" => ExtractOdtText(path),
|
|
_ => throw new NotSupportedException($"Unsupported file type: {extension}")
|
|
};
|
|
|
|
return new VideoTranscript
|
|
{
|
|
VideoId = ShortHash(Path.GetFullPath(path)),
|
|
Text = text,
|
|
SourceTrack = null,
|
|
Source = TranscriptSource.LocalDocument
|
|
};
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Extraction helpers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Joins the words of every page of a PDF with spaces (rather than using
|
|
/// <c>Page.Text</c> directly, which concatenates line/column breaks with no
|
|
/// separator and jams words together, e.g. "...StatusThis week...").
|
|
/// </summary>
|
|
private static string ExtractPdfText(string path)
|
|
{
|
|
using var document = PdfDocument.Open(path);
|
|
var pages = document.GetPages().Select(p => string.Join(" ", p.GetWords().Select(w => w.Text)));
|
|
return string.Join("\n\n", pages);
|
|
}
|
|
|
|
/// <summary>Walks the body paragraphs of a .docx and joins their text with newlines.</summary>
|
|
private static string ExtractDocxText(string path)
|
|
{
|
|
using var doc = WordprocessingDocument.Open(path, isEditable: false);
|
|
var body = doc.MainDocumentPart?.Document?.Body;
|
|
if (body is null) return string.Empty;
|
|
|
|
// Join per paragraph rather than Body.InnerText, which flattens every
|
|
// descendant text node with no separator and jams paragraphs together.
|
|
var paragraphs = body.Elements<DocumentFormat.OpenXml.Wordprocessing.Paragraph>()
|
|
.Select(p => p.InnerText);
|
|
return string.Join("\n", paragraphs);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serializes every worksheet of an .xlsx into plain comma-separated text,
|
|
/// one sheet header + one line per row, so the LLM can reason over tabular
|
|
/// data the same way it does over transcript text.
|
|
/// </summary>
|
|
private static string ExtractXlsxText(string path)
|
|
{
|
|
using var doc = SpreadsheetDocument.Open(path, isEditable: false);
|
|
if (doc.WorkbookPart?.Workbook is not { } workbook) return string.Empty;
|
|
var workbookPart = doc.WorkbookPart;
|
|
|
|
var sharedStrings = workbookPart.SharedStringTablePart?.SharedStringTable?
|
|
.Elements<SharedStringItem>()
|
|
.Select(s => s.InnerText)
|
|
.ToList() ?? new List<string>();
|
|
|
|
var sb = new StringBuilder();
|
|
|
|
foreach (var sheet in workbook.Descendants<DocumentFormat.OpenXml.Spreadsheet.Sheet>())
|
|
{
|
|
if (sheet.Id?.Value is not { } relId) continue;
|
|
if (workbookPart.GetPartById(relId) is not WorksheetPart worksheetPart) continue;
|
|
if (worksheetPart.Worksheet is not { } worksheet) continue;
|
|
|
|
sb.AppendLine($"## Sheet: {sheet.Name}");
|
|
|
|
foreach (var row in worksheet.Descendants<Row>())
|
|
{
|
|
var cells = row.Elements<Cell>().Select(cell => GetCellText(cell, sharedStrings));
|
|
sb.AppendLine(string.Join(",", cells));
|
|
}
|
|
|
|
sb.AppendLine();
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Resolves a cell's display text, following the shared-strings table when needed.</summary>
|
|
private static string GetCellText(Cell cell, List<string> sharedStrings)
|
|
{
|
|
var value = cell.CellValue?.InnerText ?? string.Empty;
|
|
|
|
if (cell.DataType?.Value == CellValues.SharedString &&
|
|
int.TryParse(value, out var index) &&
|
|
index >= 0 && index < sharedStrings.Count)
|
|
{
|
|
return sharedStrings[index];
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// An .odt is a zip archive; the visible text lives in content.xml as a
|
|
/// sequence of ODF text:p / text:h elements. No OpenDocument NuGet package
|
|
/// is pulled in for this — the format is simple enough to read directly,
|
|
/// the same way YouTubeService hand-parses VTT/timedtext captions.
|
|
/// </summary>
|
|
private static string ExtractOdtText(string path)
|
|
{
|
|
const string textNamespace = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";
|
|
|
|
using var archive = ZipFile.OpenRead(path);
|
|
var contentEntry = archive.GetEntry("content.xml")
|
|
?? throw new InvalidDataException("Not a valid .odt file (missing content.xml).");
|
|
|
|
using var stream = contentEntry.Open();
|
|
var doc = XDocument.Load(stream);
|
|
|
|
var paragraphs = doc.Descendants()
|
|
.Where(el => el.Name == XName.Get("p", textNamespace) || el.Name == XName.Get("h", textNamespace))
|
|
.Select(el => el.Value);
|
|
|
|
return string.Join("\n", paragraphs);
|
|
}
|
|
|
|
/// <summary>Short, stable, filesystem-safe identifier derived from a full path (mirrors a YouTube video ID's shape).</summary>
|
|
private static string ShortHash(string fullPath)
|
|
{
|
|
var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(fullPath));
|
|
return Convert.ToHexString(bytes)[..8].ToLowerInvariant();
|
|
}
|
|
}
|