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; /// /// Reads a local file — text, Markdown, CSV, PDF, Word, OpenDocument text, or /// Excel — and adapts it into the same / /// 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. /// public static class DocumentService { /// File extensions this service knows how to read (lowercase, with leading dot). private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".txt", ".md", ".csv", ".pdf", ".docx", ".odt", ".xlsx" }; /// True when has an extension this service can extract text from. public static bool IsSupportedFile(string path) => SupportedExtensions.Contains(Path.GetExtension(path)); /// A human-readable label for the file type, shown in place of "Channel" for documents. 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" }; /// Builds synthetic video-shaped metadata describing a local file. 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 }; } /// Extracts plain text from the file and wraps it as a transcript. public static async Task 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 // ───────────────────────────────────────────────────────────────────────── /// /// Joins the words of every page of a PDF with spaces (rather than using /// Page.Text directly, which concatenates line/column breaks with no /// separator and jams words together, e.g. "...StatusThis week..."). /// 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); } /// Walks the body paragraphs of a .docx and joins their text with newlines. 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() .Select(p => p.InnerText); return string.Join("\n", paragraphs); } /// /// 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. /// 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() .Select(s => s.InnerText) .ToList() ?? new List(); var sb = new StringBuilder(); foreach (var sheet in workbook.Descendants()) { 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()) { var cells = row.Elements().Select(cell => GetCellText(cell, sharedStrings)); sb.AppendLine(string.Join(",", cells)); } sb.AppendLine(); } return sb.ToString(); } /// Resolves a cell's display text, following the shared-strings table when needed. private static string GetCellText(Cell cell, List 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; } /// /// 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. /// 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); } /// Short, stable, filesystem-safe identifier derived from a full path (mirrors a YouTube video ID's shape). private static string ShortHash(string fullPath) { var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(fullPath)); return Convert.ToHexString(bytes)[..8].ToLowerInvariant(); } }