433 lines
24 KiB
C#
433 lines
24 KiB
C#
using System;
|
||
using System.IO;
|
||
using System.IO.Compression; // Добавлено (или уже было) для ZipFile
|
||
using System.Linq;
|
||
using System.Net.Http;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Threading.Tasks;
|
||
using System.Windows.Forms;
|
||
using DocumentFormat.OpenXml.Packaging;
|
||
using DocumentFormat.OpenXml.Wordprocessing;
|
||
using Markdig;
|
||
using HtmlToOpenXml; // Для конвертера HTML в DOCX
|
||
using System.Collections.Generic; // Для IList<>
|
||
using DocumentFormat.OpenXml; // Для OpenXmlCompositeElement
|
||
using System.Diagnostics; // <--- ДОБАВЛЕНО для Debug.WriteLine
|
||
|
||
namespace GiteaDocGenerator
|
||
{
|
||
public partial class Form1 : Form
|
||
{
|
||
private string _tempDir = string.Empty; // Для хранения скачанного кода
|
||
|
||
public Form1()
|
||
{
|
||
InitializeComponent();
|
||
}
|
||
|
||
// --- Обработчики UI ---
|
||
|
||
private void btnSelectSample_Click(object sender, EventArgs e)
|
||
{
|
||
using (OpenFileDialog ofd = new OpenFileDialog())
|
||
{
|
||
ofd.Filter = "Word Documents (*.docx)|*.docx";
|
||
ofd.Title = "Выберите файл-пример DOCX";
|
||
if (ofd.ShowDialog() == DialogResult.OK)
|
||
{
|
||
txtSampleDocx.Text = ofd.FileName;
|
||
}
|
||
}
|
||
}
|
||
|
||
private async void btnGenerate_Click(object sender, EventArgs e)
|
||
{
|
||
string repoUrl = txtRepoUrl.Text.Trim();
|
||
string sampleDocxPath = txtSampleDocx.Text;
|
||
string apiKey = txtApiKey.Text.Trim(); // **НЕБЕЗОПАСНО!**
|
||
|
||
if (string.IsNullOrWhiteSpace(repoUrl) || !Uri.IsWellFormedUriString(repoUrl, UriKind.Absolute))
|
||
{
|
||
MessageBox.Show("Пожалуйста, введите корректный URL репозитория Gitea.", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return;
|
||
}
|
||
if (string.IsNullOrWhiteSpace(sampleDocxPath) || !File.Exists(sampleDocxPath))
|
||
{
|
||
MessageBox.Show("Пожалуйста, выберите существующий файл-пример DOCX.", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return;
|
||
}
|
||
if (string.IsNullOrWhiteSpace(apiKey))
|
||
{
|
||
MessageBox.Show("Пожалуйста, введите ваш Google AI API Key.", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return;
|
||
}
|
||
|
||
// Блокируем UI на время работы
|
||
SetUiEnabled(false);
|
||
lblStatus.Text = "Статус: Скачивание репозитория...";
|
||
|
||
try
|
||
{
|
||
// 1. Скачивание и распаковка репозитория
|
||
_tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||
Directory.CreateDirectory(_tempDir);
|
||
string zipPath = await DownloadRepoAsync(repoUrl, _tempDir);
|
||
if (string.IsNullOrEmpty(zipPath)) return; // Ошибка уже показана в DownloadRepoAsync
|
||
|
||
lblStatus.Text = "Статус: Распаковка архива...";
|
||
string extractedPath = UnzipRepo(zipPath, _tempDir);
|
||
File.Delete(zipPath); // Удаляем zip после распаковки
|
||
|
||
// Находим папку с кодом внутри (обычно имя_репо-ветка)
|
||
string repoSourcePath = Directory.GetDirectories(extractedPath).FirstOrDefault();
|
||
if (repoSourcePath == null)
|
||
{
|
||
throw new DirectoryNotFoundException("Не удалось найти папку с исходным кодом после распаковки.");
|
||
}
|
||
|
||
|
||
// 2. Подготовка данных для ИИ
|
||
lblStatus.Text = "Статус: Чтение кода и структуры примера...";
|
||
string codeContent = ReadCodeFiles(repoSourcePath); // Читаем код
|
||
string sampleStructure = ExtractStructureFromDocx(sampleDocxPath); // Читаем структуру DOCX
|
||
|
||
if (string.IsNullOrWhiteSpace(codeContent))
|
||
{
|
||
MessageBox.Show("Не удалось прочитать файлы кода из репозитория. Возможно, репозиторий пуст или содержит неподдерживаемые файлы.", "Предупреждение", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
}
|
||
|
||
// 3. Взаимодействие с Gemini API
|
||
lblStatus.Text = "Статус: Обращение к Google Gemini API...";
|
||
string markdownResult = await CallGeminiApiAsync(apiKey, sampleStructure, codeContent);
|
||
|
||
if (string.IsNullOrWhiteSpace(markdownResult))
|
||
{
|
||
lblStatus.Text = "Статус: Ошибка API.";
|
||
MessageBox.Show("Получен пустой ответ от Gemini API.", "Ошибка API", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
return;
|
||
}
|
||
|
||
// 4. Создание DOCX из Markdown
|
||
lblStatus.Text = "Статус: Создание DOCX файла...";
|
||
string outputDocxPath = ShowSaveDialog();
|
||
if (!string.IsNullOrEmpty(outputDocxPath))
|
||
{
|
||
await CreateDocxFromMarkdown(markdownResult, outputDocxPath, sampleDocxPath);
|
||
lblStatus.Text = $"Статус: Готово! Файл сохранен в {outputDocxPath}";
|
||
MessageBox.Show($"Документация успешно сгенерирована и сохранена:\n{outputDocxPath}", "Успех", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||
}
|
||
else
|
||
{
|
||
lblStatus.Text = "Статус: Генерация отменена.";
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
lblStatus.Text = "Статус: Ошибка!";
|
||
string errorMessage = $"Произошла ошибка: {ex.Message}";
|
||
if (ex.InnerException != null)
|
||
{
|
||
errorMessage += $"\n\nВнутренняя ошибка: {ex.InnerException.Message}\n\n{ex.InnerException.StackTrace}";
|
||
}
|
||
errorMessage += $"\n\nStackTrace:\n{ex.StackTrace}";
|
||
// Если в сообщении уже есть лог Markdown/HTML (добавленный в CreateDocxFromMarkdown),
|
||
// он будет включен здесь автоматически.
|
||
MessageBox.Show(errorMessage, "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
}
|
||
finally
|
||
{
|
||
SetUiEnabled(true);
|
||
CleanUpTempDir();
|
||
}
|
||
}
|
||
|
||
private void SetUiEnabled(bool enabled)
|
||
{
|
||
txtRepoUrl.Enabled = enabled;
|
||
txtSampleDocx.Enabled = enabled;
|
||
btnSelectSample.Enabled = enabled;
|
||
btnGenerate.Enabled = enabled;
|
||
txtApiKey.Enabled = enabled;
|
||
}
|
||
|
||
private void CleanUpTempDir()
|
||
{
|
||
if (!string.IsNullOrEmpty(_tempDir) && Directory.Exists(_tempDir))
|
||
{
|
||
try { Directory.Delete(_tempDir, true); }
|
||
catch (Exception ex) { Console.WriteLine($"Не удалось удалить временную папку {_tempDir}: {ex.Message}"); }
|
||
_tempDir = string.Empty;
|
||
}
|
||
}
|
||
|
||
// --- Логика работы с Gitea ---
|
||
|
||
private async Task<string> DownloadRepoAsync(string repoUrl, string downloadDir)
|
||
{
|
||
Uri repoUri = new Uri(repoUrl);
|
||
string zipUrlMaster = $"{repoUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/archive/master.zip";
|
||
string zipUrlMain = $"{repoUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/archive/main.zip";
|
||
string zipPath = Path.Combine(downloadDir, "repo.zip");
|
||
|
||
using (HttpClient client = new HttpClient())
|
||
{
|
||
client.DefaultRequestHeaders.Add("User-Agent", "GiteaDocGeneratorApp");
|
||
HttpResponseMessage response = null;
|
||
try
|
||
{
|
||
response = await client.GetAsync(zipUrlMain);
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
lblStatus.Text = "Статус: Не удалось скачать main.zip, пробую master.zip...";
|
||
response.Dispose();
|
||
response = await client.GetAsync(zipUrlMaster);
|
||
}
|
||
response.EnsureSuccessStatusCode();
|
||
using (var fs = new FileStream(zipPath, FileMode.CreateNew))
|
||
{
|
||
await response.Content.CopyToAsync(fs);
|
||
}
|
||
return zipPath;
|
||
}
|
||
catch (HttpRequestException httpEx)
|
||
{
|
||
string message = $"Ошибка скачивания репозитория: {httpEx.Message}";
|
||
if (response?.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||
{
|
||
message += "\nУбедитесь, что URL верный, репозиторий публичный и содержит ветку 'main' или 'master'.";
|
||
}
|
||
else if (response != null)
|
||
{
|
||
message += $"\nКод ответа: {response.StatusCode}";
|
||
}
|
||
MessageBox.Show(message, "Ошибка скачивания", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||
return null;
|
||
}
|
||
finally
|
||
{
|
||
response?.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
private string UnzipRepo(string zipPath, string extractPath)
|
||
{
|
||
ZipFile.ExtractToDirectory(zipPath, extractPath);
|
||
return extractPath;
|
||
}
|
||
|
||
// --- Подготовка данных ---
|
||
|
||
private string ReadCodeFiles(string sourcePath, int maxChars = 15000)
|
||
{
|
||
StringBuilder content = new StringBuilder();
|
||
string[] extensions = { ".cs", ".md", ".txt", ".py", ".js", ".html", ".css", ".java", ".go", ".php", "Dockerfile", ".yaml", ".yml", ".json" };
|
||
string basePathWithTrailingSlash = sourcePath.EndsWith(Path.DirectorySeparatorChar.ToString()) ? sourcePath : sourcePath + Path.DirectorySeparatorChar;
|
||
Uri sourceUri = new Uri(basePathWithTrailingSlash);
|
||
|
||
var readmeFiles = Directory.GetFiles(sourcePath, "README.md", SearchOption.AllDirectories).Concat(Directory.GetFiles(sourcePath, "README.txt", SearchOption.AllDirectories));
|
||
foreach (var file in readmeFiles)
|
||
{
|
||
if (content.Length >= maxChars) break;
|
||
try
|
||
{
|
||
Uri fileUri = new Uri(file);
|
||
string relativePath = Uri.UnescapeDataString(sourceUri.MakeRelativeUri(fileUri).ToString()).Replace('/', Path.DirectorySeparatorChar);
|
||
content.AppendLine($"--- Файл: {relativePath} ---");
|
||
content.AppendLine(File.ReadAllText(file));
|
||
content.AppendLine("--- Конец файла ---");
|
||
content.AppendLine();
|
||
}
|
||
catch (Exception ex) { Console.WriteLine($"Ошибка чтения файла {file}: {ex.Message}"); }
|
||
}
|
||
|
||
var codeFiles = Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories).Where(f => extensions.Contains(Path.GetExtension(f).ToLowerInvariant()) && !Path.GetFileName(f).StartsWith("README.", StringComparison.OrdinalIgnoreCase));
|
||
foreach (var file in codeFiles)
|
||
{
|
||
if (content.Length >= maxChars) { content.AppendLine("\n[...Остальные файлы кода пропущены из-за ограничения размера...]"); break; }
|
||
try
|
||
{
|
||
string fileContent = File.ReadAllText(file);
|
||
if (!string.IsNullOrWhiteSpace(fileContent))
|
||
{
|
||
Uri fileUri = new Uri(file);
|
||
string relativePath = Uri.UnescapeDataString(sourceUri.MakeRelativeUri(fileUri).ToString()).Replace('/', Path.DirectorySeparatorChar);
|
||
content.AppendLine($"--- Файл: {relativePath} ---");
|
||
content.AppendLine(fileContent);
|
||
content.AppendLine("--- Конец файла ---");
|
||
content.AppendLine();
|
||
}
|
||
}
|
||
catch (Exception ex) { Console.WriteLine($"Ошибка чтения файла {file}: {ex.Message}"); }
|
||
}
|
||
return content.ToString();
|
||
}
|
||
|
||
private string ExtractStructureFromDocx(string docxPath)
|
||
{
|
||
StringBuilder structure = new StringBuilder();
|
||
try
|
||
{
|
||
using (WordprocessingDocument wordDoc = WordprocessingDocument.Open(docxPath, false))
|
||
{
|
||
Body body = wordDoc.MainDocumentPart.Document.Body;
|
||
foreach (var element in body.Elements<Paragraph>())
|
||
{
|
||
string styleId = element.ParagraphProperties?.ParagraphStyleId?.Val?.Value;
|
||
bool isHeading = styleId != null && styleId.StartsWith("Heading", StringComparison.OrdinalIgnoreCase) || styleId == "Заголовок";
|
||
if (isHeading) { structure.AppendLine($"{(isHeading ? "## " : "")}{element.InnerText}"); }
|
||
else if (!string.IsNullOrWhiteSpace(element.InnerText)) { if (structure.Length < 2000) { structure.AppendLine(element.InnerText); } }
|
||
}
|
||
if (structure.Length >= 2000) { structure.AppendLine("\n[...Структура примера обрезана...]"); }
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
MessageBox.Show($"Не удалось прочитать структуру из файла DOCX: {ex.Message}. Будет использован стандартный промпт.", "Ошибка чтения DOCX", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||
return "Заголовок 1\n\nВведение\n\nОсновная часть\n\nЗаключение";
|
||
}
|
||
if (string.IsNullOrWhiteSpace(structure.ToString())) { return "Заголовок 1\n\nВведение\n\nОсновная часть\n\nЗаключение"; }
|
||
return structure.ToString();
|
||
}
|
||
|
||
// --- Взаимодействие с Gemini API ---
|
||
|
||
private async Task<string> CallGeminiApiAsync(string apiKey, string sampleStructure, string codeContent)
|
||
{
|
||
string apiUrl = $"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro-latest:generateContent?key={apiKey}";
|
||
string prompt = $@"Сгенерируй документацию для программного продукта, исходный код которого приведен ниже.
|
||
Документация должна строго следовать структуре, представленной в следующем примере (используй заголовки и порядок разделов как в примере):
|
||
|
||
--- НАЧАЛО СТРУКТУРЫ ПРИМЕРА ---
|
||
{sampleStructure}
|
||
--- КОНЕЦ СТРУКТУРЫ ПРИМЕРА ---
|
||
|
||
Содержимое документации должно описывать код продукта. Если кода нет, напиши общую документацию по указанной структуре.
|
||
ВЕСЬ ТВОЙ ОТВЕТ ДОЛЖЕН БЫТЬ ТОЛЬКО В ФОРМАТЕ MARKDOWN. Не добавляй никаких пояснений до или после markdown.
|
||
|
||
--- НАЧАЛО КОДА ПРОДУКТА ---
|
||
{codeContent}
|
||
--- КОНЕЦ КОДА ПРОДУКТА ---
|
||
|
||
Еще раз: ВЕСЬ ТВОЙ ОТВЕТ должен быть строго в формате Markdown.";
|
||
|
||
var requestBody = new { contents = new[] { new { parts = new[] { new { text = prompt } } } }, generationConfig = new { temperature = 0.6 } };
|
||
string jsonPayload = JsonSerializer.Serialize(requestBody);
|
||
|
||
using (HttpClient client = new HttpClient())
|
||
{
|
||
try
|
||
{
|
||
var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
|
||
HttpResponseMessage response = await client.PostAsync(apiUrl, content);
|
||
string responseBody = await response.Content.ReadAsStringAsync();
|
||
|
||
if (!response.IsSuccessStatusCode) { string errorDetails = TryExtractErrorFromJson(responseBody); throw new Exception($"Ошибка API Gemini: {response.StatusCode}. {errorDetails}"); }
|
||
|
||
using (JsonDocument document = JsonDocument.Parse(responseBody))
|
||
{
|
||
if (document.RootElement.TryGetProperty("candidates", out JsonElement candidates) && candidates.GetArrayLength() > 0)
|
||
{
|
||
if (candidates[0].TryGetProperty("content", out JsonElement responseContent) && responseContent.TryGetProperty("parts", out JsonElement parts) && parts.GetArrayLength() > 0)
|
||
{
|
||
if (parts[0].TryGetProperty("text", out JsonElement textElement)) { return textElement.GetString(); }
|
||
}
|
||
if (candidates[0].TryGetProperty("finishReason", out JsonElement reason))
|
||
{
|
||
string reasonStr = reason.GetString(); string safetyInfo = "";
|
||
if (reasonStr == "SAFETY" && candidates[0].TryGetProperty("safetyRatings", out JsonElement ratings)) { safetyInfo = $" Safety Ratings: {ratings.ToString()}"; }
|
||
throw new Exception($"Ответ API Gemini не содержит текст. Причина завершения: {reasonStr}.{safetyInfo}");
|
||
}
|
||
}
|
||
throw new Exception($"Не удалось извлечь текст из ответа API Gemini. Ответ:\n{responseBody}");
|
||
}
|
||
}
|
||
catch (JsonException jsonEx) { throw new Exception($"Ошибка парсинга JSON ответа от API Gemini: {jsonEx.Message}. Ответ:\n{jsonPayload}", jsonEx); }
|
||
catch (Exception ex) { throw new Exception($"Ошибка при вызове Gemini API: {ex.Message}", ex); }
|
||
}
|
||
}
|
||
|
||
private string TryExtractErrorFromJson(string jsonResponse)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(jsonResponse)) return "(пустой ответ)";
|
||
try { using (JsonDocument document = JsonDocument.Parse(jsonResponse)) { if (document.RootElement.TryGetProperty("error", out JsonElement errorElement)) { if (errorElement.TryGetProperty("message", out JsonElement messageElement)) { return messageElement.GetString(); } return errorElement.ToString(); } } }
|
||
catch (JsonException) { }
|
||
return jsonResponse;
|
||
}
|
||
|
||
// --- Создание DOCX ---
|
||
|
||
private string ShowSaveDialog()
|
||
{
|
||
using (SaveFileDialog sfd = new SaveFileDialog())
|
||
{
|
||
sfd.Filter = "Word Document (*.docx)|*.docx"; sfd.Title = "Сохранить документацию как..."; sfd.FileName = "Generated_Documentation.docx";
|
||
if (sfd.ShowDialog() == DialogResult.OK) { return sfd.FileName; }
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Метод остается асинхронным (async Task)
|
||
// Метод остается асинхронным (async Task)
|
||
private async Task CreateDocxFromMarkdown(string markdown, string outputPath, string sampleDocxPath)
|
||
{
|
||
// --- Логирование Markdown ---
|
||
Debug.WriteLine("--- ИСХОДНЫЙ MARKDOWN ---");
|
||
Debug.WriteLine(markdown ?? "[NULL]");
|
||
Debug.WriteLine("--- КОНЕЦ MARKDOWN ---");
|
||
// ---------------------------
|
||
|
||
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
||
string html = "[NULL]";
|
||
if (markdown != null)
|
||
{
|
||
html = Markdown.ToHtml(markdown, pipeline);
|
||
}
|
||
|
||
// --- Логирование HTML ---
|
||
Debug.WriteLine("--- ПОЛУЧЕННЫЙ HTML ---");
|
||
Debug.WriteLine(html);
|
||
Debug.WriteLine("--- КОНЕЦ HTML ---");
|
||
// ------------------------
|
||
|
||
try
|
||
{
|
||
// Шаг 1: Клонирование шаблона. Шаблон будет закрыт сразу после клонирования.
|
||
using (WordprocessingDocument templateDoc = WordprocessingDocument.Open(sampleDocxPath, false))
|
||
{
|
||
// Клонируем шаблон в ВЫХОДНОЙ файл и открываем для редактирования
|
||
using (WordprocessingDocument wordDoc = (WordprocessingDocument)templateDoc.Clone(outputPath, true))
|
||
{
|
||
// Шаг 2: Работа с клонированным документом (wordDoc)
|
||
MainDocumentPart mainPart = wordDoc.MainDocumentPart;
|
||
Body body = mainPart.Document.Body;
|
||
|
||
// !!! ОЧИЩАЕМ ТЕЛО ДОКУМЕНТА !!!
|
||
body.RemoveAllChildren<Paragraph>();
|
||
body.RemoveAllChildren<Table>();
|
||
|
||
// Шаг 3: Используем HtmlConverter для преобразования HTML и добавления в пустое тело
|
||
HtmlConverter converter = new HtmlConverter(mainPart);
|
||
await converter.ParseHtml(html); // ParseHtml добавляет элементы в mainPart.Document.Body
|
||
|
||
// Шаг 4: Плейсхолдер НЕ НУЖЕН и его поиск/удаление НЕ НУЖНЫ
|
||
|
||
// Сохранение происходит автоматически при выходе из using (wordDoc)
|
||
} // wordDoc закрывается и сохраняется здесь
|
||
} // templateDoc закрывается здесь
|
||
}
|
||
catch (IOException ioEx)
|
||
{
|
||
string logInfo = $"\n\n--- Markdown Log ---\n{markdown ?? "[NULL]"}\n--- HTML Log ---\n{html}";
|
||
throw new IOException($"Ошибка доступа к файлу при клонировании/создании DOCX '{outputPath}': {ioEx.Message}. Убедитесь, что файл не открыт в другой программе.{logInfo}", ioEx);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Убираем проверку на InvalidOperationException, так как плейсхолдера нет
|
||
string logInfo = $"\n\n--- Markdown Log ---\n{markdown ?? "[NULL]"}\n--- HTML Log ---\n{html}";
|
||
throw new Exception($"Ошибка при создании DOCX файла из Markdown/HTML:\n{ex.Message}{logInfo}", ex);
|
||
}
|
||
}
|
||
}
|
||
} |