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();
}
}
|