201 lines
9.2 KiB
C#
201 lines
9.2 KiB
C#
using Microsoft.Extensions.Logging;
|
||
using PARR.Core.Services.HelpDocument.Interfaces;
|
||
using PARR.Domain.DTOs.HelpDocument;
|
||
using PARR.Domain.Settings;
|
||
using System.Web;
|
||
|
||
namespace PARR.Core.Services.HelpDocument.Implementations
|
||
{
|
||
/// <summary>
|
||
/// Получение справочной документации из GitLab
|
||
/// </summary>
|
||
internal class GitLabHelpProvider : IHelpDocumentProvider
|
||
{
|
||
private readonly HttpClient _httpClient;
|
||
private readonly HelpDocumentSettings _helpDocumentSettings;
|
||
private readonly ILogger<GitLabHelpProvider> _logger;
|
||
|
||
public GitLabHelpProvider(HttpClient httpClient, HelpDocumentSettings helpDocumentSettings, ILogger<GitLabHelpProvider> logger)
|
||
{
|
||
_httpClient = httpClient;
|
||
_helpDocumentSettings = helpDocumentSettings;
|
||
_logger = logger;
|
||
|
||
// Базовая настройка http
|
||
_httpClient = httpClient;
|
||
|
||
if (_helpDocumentSettings.GitLabSettings == null)
|
||
{
|
||
_logger.LogCritical("Инициализация провайдера справки провалена: не задана секция GitLabSettings");
|
||
throw new ArgumentNullException(nameof(_helpDocumentSettings.GitLabSettings), "Не заданы настройки подключения к GitLab");
|
||
}
|
||
|
||
_httpClient.BaseAddress = new Uri(_helpDocumentSettings.GitLabSettings.BaseUrl);
|
||
_httpClient.DefaultRequestHeaders.Add("PRIVATE-TOKEN", _helpDocumentSettings.GitLabSettings.Token);
|
||
}
|
||
|
||
|
||
public async Task<HelpDocumentResult?> GetDocumentAsync(string helpId)
|
||
{
|
||
//1. Формируем путь к файлу
|
||
var filePath = $"{helpId}.md";
|
||
|
||
// 2. GitLab API требует, чтобы относительный путь к файлу в URL был закодирован (экранирован)
|
||
var urlEncodedPath = HttpUtility.UrlEncode(filePath);
|
||
|
||
// 3. Собираем эндпоинт для получения RAW контента файла
|
||
string relativeRequestUrl = $"projects/{_helpDocumentSettings.GitLabSettings!.ProjectId}/repository/files/{urlEncodedPath}/raw?ref={_helpDocumentSettings.GitLabSettings.DefaultBranch}";
|
||
|
||
|
||
try
|
||
{
|
||
// Создаем URI строго с флагом UriKind.Relative. Это заставит HttpClient отправить %2F в сыром виде!
|
||
var relativeUri = new Uri(relativeRequestUrl, UriKind.Relative);
|
||
|
||
_logger.LogDebug("Отправка HTTP GET запроса к GitLab API. Относительный URI: {RelativeUri}", relativeRequestUrl);
|
||
|
||
var response = await _httpClient.GetAsync(relativeUri);
|
||
|
||
// Если файла нет (404) или токен не подошел (401/403)
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
_logger.LogWarning("Документ справки не найден или недоступен в GitLab. HelpId: {HelpId}, StatusCode: {StatusCode}",
|
||
helpId, response.StatusCode);
|
||
return null;
|
||
}
|
||
|
||
_logger.LogDebug("Файл справки успешно получен из GitLab. Размер ответа: {ContentLength} байт",
|
||
response.Content.Headers.ContentLength);
|
||
|
||
// Получаем весь текст файла (и шапку, и контент)
|
||
string rawFileContent = await response.Content.ReadAsStringAsync();
|
||
|
||
// 4. Парсим файл
|
||
var result = ParseMarkdownFile(helpId, rawFileContent);
|
||
|
||
_logger.LogDebug("Документ справки успешно загружен и распарсен. HelpId: {HelpId}, Title: '{Title}'",
|
||
helpId, result.Title);
|
||
|
||
var editUrl = GenerateEditUrl(helpId);
|
||
|
||
return result with
|
||
{
|
||
EditUrl = editUrl
|
||
};
|
||
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Ошибка при запросе справки {HelpId} из GitLab", helpId);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private string? GenerateEditUrl(string helpId)
|
||
{
|
||
if (_helpDocumentSettings.GitLabSettings == null)
|
||
return null;
|
||
|
||
var repoPath = _helpDocumentSettings.GitLabSettings.RepositoryPath;
|
||
var branch = _helpDocumentSettings.GitLabSettings.DefaultBranch ?? "main";
|
||
|
||
return $"{repoPath}/-/edit/{branch}/{helpId}.md";
|
||
}
|
||
|
||
private HelpDocumentResult ParseMarkdownFile(string helpId, string rawContent)
|
||
{
|
||
_logger.LogDebug("Начало парсинга структуры Markdown для HelpId: {HelpId}", helpId);
|
||
|
||
if (string.IsNullOrWhiteSpace(rawContent))
|
||
{
|
||
_logger.LogWarning("Получен пустой контент для HelpId: {HelpId}", helpId);
|
||
return new HelpDocumentResult { HelpId = helpId };
|
||
}
|
||
|
||
// Стандартное разделение строк с поддержкой разных типов окончаний (Windows/Linux)
|
||
var lines = rawContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
|
||
|
||
var title = string.Empty;
|
||
string? prevId = null;
|
||
string? nextId = null;
|
||
|
||
var contentStartIndex = 0;
|
||
var isInsideFrontMatter = false;
|
||
var frontMatterClosed = false;
|
||
|
||
for (int i = 0; i < lines.Length; i++)
|
||
{
|
||
var line = lines[i].Trim();
|
||
|
||
// Ищем маркеры начала и конца метаданных "---"
|
||
if (line == "---")
|
||
{
|
||
if (!isInsideFrontMatter && !frontMatterClosed)
|
||
{
|
||
isInsideFrontMatter = true;
|
||
_logger.LogDebug("Обнаружено начало блока метаданных (Front Matter) на строке {LineIndex}", i);
|
||
continue;
|
||
}
|
||
else if (isInsideFrontMatter)
|
||
{
|
||
isInsideFrontMatter = false;
|
||
frontMatterClosed = true;
|
||
contentStartIndex = i + 1; // Контент начинается строго со следующей строки
|
||
_logger.LogDebug("Обнаружен конец блока метаданных на строке {LineIndex}", i);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Читаем свойства внутри блока метаданных
|
||
if (isInsideFrontMatter)
|
||
{
|
||
if (line.StartsWith("title:", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
title = line.Substring("title:".Length).Trim();
|
||
_logger.LogDebug("В метаданных найден Title: '{Title}'", title);
|
||
}
|
||
// Парсим Предыдущий шаг
|
||
else if (line.StartsWith("prev_id:", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
prevId = line.Substring("prev_id:".Length).Trim();
|
||
_logger.LogDebug("В метаданных найден PrevId: '{PrevId}'", prevId);
|
||
}
|
||
// Парсим Следующий шаг
|
||
else if (line.StartsWith("next_id:", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
nextId = line.Substring("next_id:".Length).Trim();
|
||
_logger.LogDebug("В метаданных найден NextId: '{NextId}'", nextId);
|
||
}
|
||
|
||
// Тут в будущем можно парсить другие поля, например: icon, min_role и т.д.
|
||
}
|
||
}
|
||
|
||
string cleanContent;
|
||
|
||
if (frontMatterClosed)
|
||
{
|
||
// Собираем обратно чистый контент, исключая строки с шапкой
|
||
cleanContent = string.Join(Environment.NewLine, lines.Skip(contentStartIndex));
|
||
}
|
||
else
|
||
{
|
||
// Если шапки "---" вообще не было в файле, берем весь текст как есть
|
||
_logger.LogDebug("Блок метаданных '---' не найден в файле. Весь текст перенесен в Content.");
|
||
cleanContent = rawContent;
|
||
title = Path.GetFileNameWithoutExtension(helpId); // Дефолтный заголовок из имени файла
|
||
}
|
||
|
||
return new HelpDocumentResult
|
||
{
|
||
HelpId = helpId,
|
||
Title = title,
|
||
Content = cleanContent,
|
||
PrevId = prevId,
|
||
NextId = nextId
|
||
};
|
||
}
|
||
|
||
}
|
||
}
|