Cambio a Reflection porque la libreria DocFX no es compatible
This commit is contained in:
parent
93b0f39a24
commit
641bb7baf1
11
App.xaml
11
App.xaml
|
@ -1,13 +1,10 @@
|
|||
<Application x:Class="NetDocsForLLM.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:NetDocsForLLM"
|
||||
xmlns:converters="clr-namespace:NetDocsForLLM.Converters">
|
||||
<Application x:Class="NetDocsForLLM.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:NetDocsForLLM"
|
||||
xmlns:converters="clr-namespace:NetDocsForLLM.Converters">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<!-- Converters -->
|
||||
<converters:EnumBooleanConverter x:Key="EnumBooleanConverter"/>
|
||||
|
||||
<converters:EnumBooleanConverter x:Key="EnumBooleanConverter" />
|
||||
<!-- Other resources -->
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NetDocsForLLM.Services;
|
||||
using NetDocsForLLM.ViewModels;
|
||||
using System;
|
||||
|
@ -20,7 +20,7 @@ namespace NetDocsForLLM
|
|||
private void ConfigureServices(ServiceCollection services)
|
||||
{
|
||||
// Register services
|
||||
services.AddSingleton<IDocFxService, DocFxService>();
|
||||
services.AddSingleton<IDocFxService, ReflectionAnalyzerService>(); // <- Cambio aquí
|
||||
services.AddSingleton<IDocumentationGenerator, DocumentationGenerator>();
|
||||
services.AddSingleton<IAssemblyAnalyzer, AssemblyAnalyzer>();
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
|
@ -11,31 +11,25 @@ namespace NetDocsForLLM.Converters
|
|||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value == null || parameter == null)
|
||||
if (parameter == null || value == null)
|
||||
return false;
|
||||
|
||||
string checkValue = parameter.ToString();
|
||||
string currentValue = value.ToString();
|
||||
string parameterString = parameter.ToString();
|
||||
if (Enum.IsDefined(value.GetType(), value))
|
||||
{
|
||||
return value.ToString().Equals(parameterString, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return checkValue.Equals(currentValue, StringComparison.InvariantCultureIgnoreCase);
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value == null || parameter == null)
|
||||
if (parameter == null || !(bool)value)
|
||||
return null;
|
||||
|
||||
bool isChecked = (bool)value;
|
||||
if (isChecked)
|
||||
{
|
||||
if (parameter is string parameterString)
|
||||
{
|
||||
return Enum.Parse(targetType, parameterString);
|
||||
}
|
||||
return parameter;
|
||||
}
|
||||
|
||||
return Binding.DoNothing;
|
||||
string parameterString = parameter.ToString();
|
||||
return Enum.Parse(targetType, parameterString);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
using System.Linq;
|
||||
|
||||
namespace NetDocsForLLM.Helpers
|
||||
{
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using NetDocsForLLM.Models;
|
||||
using NetDocsForLLM.Models;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
|
|
@ -4,9 +4,15 @@
|
|||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:NetDocsForLLM"
|
||||
xmlns:conv="clr-namespace:NetDocsForLLM.Converters"
|
||||
xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
|
||||
mc:Ignorable="d"
|
||||
Title="NetDocs para LLMs" Height="650" Width="800">
|
||||
|
||||
<Window.Resources>
|
||||
<conv:EnumBooleanConverter x:Key="EnumBooleanConverter"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
|
@ -199,4 +205,4 @@
|
|||
</StatusBarItem>
|
||||
</StatusBar>
|
||||
</Grid>
|
||||
</Window>
|
||||
</Window>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System.Windows;
|
||||
using System.Windows;
|
||||
|
||||
namespace NetDocsForLLM
|
||||
{
|
||||
|
@ -9,4 +9,4 @@ namespace NetDocsForLLM
|
|||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
|
@ -48,4 +48,4 @@ namespace NetDocsForLLM.Models
|
|||
XmlDocPath = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NetDocsForLLM.Models
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace NetDocsForLLM.Models
|
||||
{
|
||||
|
|
|
@ -0,0 +1,343 @@
|
|||
using NetDocsForLLM.Models;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace NetDocsForLLM.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Implementación del servicio de análisis de ensamblados basado en Reflection
|
||||
/// </summary>
|
||||
public class ReflectionAnalyzerService : IDocFxService
|
||||
{
|
||||
private readonly string _workingDirectory;
|
||||
|
||||
public ReflectionAnalyzerService()
|
||||
{
|
||||
// Crear un directorio temporal para trabajar
|
||||
_workingDirectory = Path.Combine(Path.GetTempPath(), "NetDocsForLLM_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_workingDirectory);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateMetadataAsync(IEnumerable<AssemblyModel> assemblies)
|
||||
{
|
||||
// Crear directorio para metadatos
|
||||
var metadataPath = Path.Combine(_workingDirectory, "metadata");
|
||||
Directory.CreateDirectory(metadataPath);
|
||||
|
||||
// Para cada ensamblado, generar archivo de metadatos
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
await Task.Run(() => ProcessAssembly(assembly, metadataPath));
|
||||
}
|
||||
|
||||
return metadataPath;
|
||||
}
|
||||
|
||||
private void ProcessAssembly(AssemblyModel assemblyModel, string outputPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = assemblyModel.LoadedAssembly;
|
||||
if (assembly == null)
|
||||
throw new ArgumentException("El ensamblado no está cargado", nameof(assemblyModel));
|
||||
|
||||
// Cargar comentarios XML si existen
|
||||
XDocument xmlDoc = null;
|
||||
if (assemblyModel.HasXmlDocumentation && File.Exists(assemblyModel.XmlDocPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
xmlDoc = XDocument.Load(assemblyModel.XmlDocPath);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Si hay error al cargar XML, continuar sin comentarios
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar todos los tipos exportados
|
||||
foreach (var type in assembly.GetExportedTypes())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Generar archivo de metadatos para cada tipo
|
||||
var typeInfo = new
|
||||
{
|
||||
Name = type.Name,
|
||||
FullName = type.FullName,
|
||||
Namespace = type.Namespace,
|
||||
IsClass = type.IsClass,
|
||||
IsInterface = type.IsInterface,
|
||||
IsEnum = type.IsEnum,
|
||||
IsAbstract = type.IsAbstract,
|
||||
IsSealed = type.IsSealed,
|
||||
IsPublic = type.IsPublic,
|
||||
BaseType = type.BaseType?.FullName,
|
||||
Interfaces = type.GetInterfaces().Select(i => i.FullName).ToArray(),
|
||||
XmlDocumentation = GetXmlDocumentation(xmlDoc, GetMemberXmlId(type)),
|
||||
Members = GetMembers(type, xmlDoc)
|
||||
};
|
||||
|
||||
// Guardar en archivo JSON
|
||||
string typePath = Path.Combine(outputPath, $"{type.FullName.Replace('+', '_')}.json");
|
||||
File.WriteAllText(typePath, JsonConvert.SerializeObject(typeInfo, Formatting.Indented));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Registrar error para este tipo pero continuar con otros
|
||||
File.WriteAllText(
|
||||
Path.Combine(outputPath, $"error_{type.FullName.Replace('+', '_')}.txt"),
|
||||
$"Error procesando tipo {type.FullName}: {ex.Message}\n{ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Registrar el error pero continuar con otros ensamblados
|
||||
File.WriteAllText(
|
||||
Path.Combine(outputPath, $"error_{Path.GetFileNameWithoutExtension(assemblyModel.FilePath)}.txt"),
|
||||
$"Error procesando ensamblado: {ex.Message}\n{ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
private object[] GetMembers(Type type, XDocument xmlDoc)
|
||||
{
|
||||
var result = new List<object>();
|
||||
|
||||
// Obtener métodos
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (method.IsSpecialName) // Excluir getters/setters de propiedades
|
||||
continue;
|
||||
|
||||
var parameters = method.GetParameters().Select(p => new
|
||||
{
|
||||
Name = p.Name,
|
||||
Type = GetFriendlyTypeName(p.ParameterType),
|
||||
IsOptional = p.IsOptional,
|
||||
DefaultValue = p.IsOptional ? ConvertDefaultValueToString(p.DefaultValue) : null
|
||||
}).ToArray();
|
||||
|
||||
var methodInfo = new
|
||||
{
|
||||
Name = method.Name,
|
||||
MemberType = "Method",
|
||||
ReturnType = GetFriendlyTypeName(method.ReturnType),
|
||||
IsStatic = method.IsStatic,
|
||||
IsPublic = method.IsPublic,
|
||||
Parameters = parameters,
|
||||
XmlDocumentation = GetXmlDocumentation(xmlDoc, GetMemberXmlId(method))
|
||||
};
|
||||
|
||||
result.Add(methodInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignorar métodos con problemas
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener propiedades
|
||||
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var propertyInfo = new
|
||||
{
|
||||
Name = property.Name,
|
||||
MemberType = "Property",
|
||||
Type = GetFriendlyTypeName(property.PropertyType),
|
||||
CanRead = property.CanRead,
|
||||
CanWrite = property.CanWrite,
|
||||
IsStatic = property.GetAccessors(true).FirstOrDefault()?.IsStatic ?? false,
|
||||
XmlDocumentation = GetXmlDocumentation(xmlDoc, GetMemberXmlId(property))
|
||||
};
|
||||
|
||||
result.Add(propertyInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignorar propiedades con problemas
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener eventos
|
||||
foreach (var eventInfo in type.GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var eventData = new
|
||||
{
|
||||
Name = eventInfo.Name,
|
||||
MemberType = "Event",
|
||||
EventHandlerType = GetFriendlyTypeName(eventInfo.EventHandlerType),
|
||||
IsStatic = eventInfo.GetAddMethod()?.IsStatic ?? false,
|
||||
XmlDocumentation = GetXmlDocumentation(xmlDoc, GetMemberXmlId(eventInfo))
|
||||
};
|
||||
|
||||
result.Add(eventData);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignorar eventos con problemas
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener campos
|
||||
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fieldInfo = new
|
||||
{
|
||||
Name = field.Name,
|
||||
MemberType = "Field",
|
||||
Type = GetFriendlyTypeName(field.FieldType),
|
||||
IsStatic = field.IsStatic,
|
||||
IsConstant = field.IsLiteral && !field.IsInitOnly,
|
||||
Value = field.IsLiteral ? ConvertDefaultValueToString(field.GetValue(null)) : null,
|
||||
XmlDocumentation = GetXmlDocumentation(xmlDoc, GetMemberXmlId(field))
|
||||
};
|
||||
|
||||
result.Add(fieldInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignorar campos con problemas
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private string GetFriendlyTypeName(Type type)
|
||||
{
|
||||
if (type == null) return "void";
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var genericArgs = string.Join(", ", type.GetGenericArguments().Select(GetFriendlyTypeName));
|
||||
return $"{type.Name.Split('`')[0]}<{genericArgs}>";
|
||||
}
|
||||
|
||||
// Usar nombres simplificados para tipos comunes
|
||||
if (type == typeof(void)) return "void";
|
||||
if (type == typeof(int)) return "int";
|
||||
if (type == typeof(string)) return "string";
|
||||
if (type == typeof(bool)) return "bool";
|
||||
if (type == typeof(double)) return "double";
|
||||
if (type == typeof(float)) return "float";
|
||||
if (type == typeof(decimal)) return "decimal";
|
||||
if (type == typeof(long)) return "long";
|
||||
if (type == typeof(short)) return "short";
|
||||
if (type == typeof(byte)) return "byte";
|
||||
if (type == typeof(char)) return "char";
|
||||
if (type == typeof(object)) return "object";
|
||||
|
||||
return type.FullName ?? type.Name;
|
||||
}
|
||||
|
||||
private string ConvertDefaultValueToString(object defaultValue)
|
||||
{
|
||||
if (defaultValue == null) return "null";
|
||||
|
||||
// Para strings, agregar comillas
|
||||
if (defaultValue is string stringValue)
|
||||
return $"\"{stringValue}\"";
|
||||
|
||||
return defaultValue.ToString();
|
||||
}
|
||||
|
||||
private string GetMemberXmlId(MemberInfo member)
|
||||
{
|
||||
// Generar ID XML según el formato estándar de documentación XML
|
||||
string prefix = "";
|
||||
if (member is MethodInfo)
|
||||
prefix = "M:";
|
||||
else if (member is PropertyInfo)
|
||||
prefix = "P:";
|
||||
else if (member is EventInfo)
|
||||
prefix = "E:";
|
||||
else if (member is FieldInfo)
|
||||
prefix = "F:";
|
||||
else if (member is Type)
|
||||
prefix = "T:";
|
||||
|
||||
// Manejar tipos anidados
|
||||
string declaringFullName;
|
||||
if (member.DeclaringType != null)
|
||||
{
|
||||
declaringFullName = member.DeclaringType.FullName;
|
||||
}
|
||||
else if (member is Type typeInfo)
|
||||
{
|
||||
declaringFullName = typeInfo.FullName;
|
||||
}
|
||||
else
|
||||
{
|
||||
declaringFullName = "Unknown";
|
||||
}
|
||||
|
||||
return prefix + declaringFullName + "." + member.Name;
|
||||
}
|
||||
|
||||
private object GetXmlDocumentation(XDocument xmlDoc, string memberId)
|
||||
{
|
||||
if (xmlDoc == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Buscar nodo de miembro por ID
|
||||
var memberNode = xmlDoc.Descendants("member")
|
||||
.FirstOrDefault(m => m.Attribute("name")?.Value == memberId);
|
||||
|
||||
if (memberNode == null)
|
||||
return null;
|
||||
|
||||
// Extraer elementos de documentación
|
||||
var summary = memberNode.Element("summary")?.Value.Trim();
|
||||
var remarks = memberNode.Element("remarks")?.Value.Trim();
|
||||
var returns = memberNode.Element("returns")?.Value.Trim();
|
||||
|
||||
var parameters = memberNode.Elements("param")
|
||||
.Select(p => new
|
||||
{
|
||||
Name = p.Attribute("name")?.Value,
|
||||
Description = p.Value.Trim()
|
||||
})
|
||||
.Where(p => !string.IsNullOrEmpty(p.Name))
|
||||
.ToArray();
|
||||
|
||||
var examples = memberNode.Elements("example")
|
||||
.Select(e => e.Value.Trim())
|
||||
.ToArray();
|
||||
|
||||
return new
|
||||
{
|
||||
Summary = summary,
|
||||
Remarks = remarks,
|
||||
Returns = returns,
|
||||
Parameters = parameters,
|
||||
Examples = examples
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0-windows7.0</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using NetDocsForLLM.Models;
|
||||
using NetDocsForLLM.Models;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
|
|
|
@ -1,203 +0,0 @@
|
|||
using NetDocsForLLM.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NetDocsForLLM.Services
|
||||
{
|
||||
public interface IDocFxService
|
||||
{
|
||||
Task<string> GenerateMetadataAsync(IEnumerable<AssemblyModel> assemblies);
|
||||
}
|
||||
|
||||
public class DocFxService : IDocFxService
|
||||
{
|
||||
private readonly string _workingDirectory;
|
||||
private readonly string _docfxPath;
|
||||
|
||||
public DocFxService()
|
||||
{
|
||||
// Create a temporary working directory
|
||||
_workingDirectory = Path.Combine(Path.GetTempPath(), "NetDocsForLLM_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_workingDirectory);
|
||||
|
||||
// Locate DocFX executable in the packages directory
|
||||
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
_docfxPath = Path.Combine(baseDir, "docfx", "docfx.exe");
|
||||
|
||||
// If not found in the default location, try to locate it in the packages directory
|
||||
if (!File.Exists(_docfxPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var packagesDir = Path.Combine(baseDir, "..", "..", "..", "packages");
|
||||
|
||||
// Check if packages directory exists before attempting to search it
|
||||
if (Directory.Exists(packagesDir))
|
||||
{
|
||||
var docfxPaths = Directory.GetFiles(packagesDir, "docfx.exe", SearchOption.AllDirectories);
|
||||
if (docfxPaths.Length > 0)
|
||||
{
|
||||
_docfxPath = docfxPaths[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Check embedded docfx in dotnet tools
|
||||
if (!File.Exists(_docfxPath))
|
||||
{
|
||||
var toolsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet", "tools");
|
||||
if (Directory.Exists(toolsDir))
|
||||
{
|
||||
var docfxToolPath = Path.Combine(toolsDir, "docfx.exe");
|
||||
if (File.Exists(docfxToolPath))
|
||||
{
|
||||
_docfxPath = docfxToolPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Directory not found, continue to the check below
|
||||
}
|
||||
|
||||
// If still not found, throw an exception
|
||||
if (!File.Exists(_docfxPath))
|
||||
{
|
||||
throw new FileNotFoundException("No se pudo encontrar docfx.exe. Asegúrese de que el paquete docfx.console esté instalado.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GenerateMetadataAsync(IEnumerable<AssemblyModel> assemblies)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create DocFX configuration
|
||||
var configPath = Path.Combine(_workingDirectory, "docfx.json");
|
||||
var config = CreateDocFxConfig(assemblies);
|
||||
File.WriteAllText(configPath, config);
|
||||
|
||||
// Run DocFX metadata
|
||||
var result = await RunDocFxMetadataAsync(configPath);
|
||||
|
||||
// Return path to the generated metadata
|
||||
var apiPath = Path.Combine(_workingDirectory, "obj", "api");
|
||||
return apiPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Error al generar metadatos con DocFX: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateDocFxConfig(IEnumerable<AssemblyModel> assemblies)
|
||||
{
|
||||
var assemblyPaths = new List<string>();
|
||||
var xmlPaths = new List<string>();
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
assemblyPaths.Add(assembly.FilePath);
|
||||
if (assembly.HasXmlDocumentation)
|
||||
{
|
||||
xmlPaths.Add(assembly.XmlDocPath);
|
||||
}
|
||||
}
|
||||
|
||||
return $@"
|
||||
{{
|
||||
""metadata"": [
|
||||
{{
|
||||
""src"": [
|
||||
{{
|
||||
""files"": [
|
||||
""{string.Join("\",\n \"", assemblyPaths.Select(p => p.Replace("\\", "\\\\")))}""
|
||||
],
|
||||
""src"": "".""
|
||||
}}
|
||||
],
|
||||
""dest"": ""obj/api"",
|
||||
""properties"": {{
|
||||
""TargetFramework"": ""net6.0""
|
||||
}},
|
||||
""disableGitFeatures"": true,
|
||||
""disableDefaultFilter"": false
|
||||
}}
|
||||
],
|
||||
""build"": {{
|
||||
""content"": [
|
||||
{{
|
||||
""files"": [""*.yml""],
|
||||
""src"": ""obj/api"",
|
||||
""dest"": ""api""
|
||||
}}
|
||||
],
|
||||
""resource"": [
|
||||
{{
|
||||
""files"": [""images/**""],
|
||||
""exclude"": [""obj/**"", ""_site/**""]
|
||||
}}
|
||||
],
|
||||
""dest"": ""_site"",
|
||||
""globalMetadataFiles"": [],
|
||||
""fileMetadataFiles"": [],
|
||||
""template"": [""default""],
|
||||
""postProcessors"": [],
|
||||
""markdownEngineName"": ""markdig"",
|
||||
""noLangKeyword"": false,
|
||||
""keepFileLink"": false,
|
||||
""cleanupCacheHistory"": false,
|
||||
""disableGitFeatures"": false
|
||||
}}
|
||||
}}";
|
||||
}
|
||||
|
||||
private async Task<string> RunDocFxMetadataAsync(string configPath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _docfxPath,
|
||||
Arguments = $"metadata \"{configPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = _workingDirectory
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
var outputBuilder = new System.Text.StringBuilder();
|
||||
var errorBuilder = new System.Text.StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"DocFX metadata falló con código de salida {process.ExitCode}. Error: {errorBuilder}");
|
||||
}
|
||||
|
||||
return outputBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
using NetDocsForLLM.Models;
|
||||
using NetDocsForLLM.Models;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
|
||||
|
@ -16,29 +17,32 @@ namespace NetDocsForLLM.Services
|
|||
|
||||
public class DocumentationGenerator : IDocumentationGenerator
|
||||
{
|
||||
private readonly IDocFxService _docFxService;
|
||||
private readonly IDocFxService _metadataService;
|
||||
|
||||
public DocumentationGenerator(IDocFxService docFxService)
|
||||
public DocumentationGenerator(IDocFxService metadataService)
|
||||
{
|
||||
_docFxService = docFxService ?? throw new ArgumentNullException(nameof(docFxService));
|
||||
_metadataService = metadataService ?? throw new ArgumentNullException(nameof(metadataService));
|
||||
}
|
||||
|
||||
public async Task<DocumentationModel> GenerateDocumentation(IEnumerable<AssemblyModel> assemblies, ExportSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Generate metadata with DocFX
|
||||
var metadataPath = await _docFxService.GenerateMetadataAsync(assemblies);
|
||||
// Generate metadata using reflection service
|
||||
var metadataPath = await _metadataService.GenerateMetadataAsync(assemblies);
|
||||
|
||||
// Process metadata files
|
||||
var documentation = new DocumentationModel();
|
||||
var namespaces = new Dictionary<string, NamespaceDocumentation>();
|
||||
|
||||
// Read YAML files produced by DocFX
|
||||
var ymlFiles = Directory.GetFiles(metadataPath, "*.yml", SearchOption.AllDirectories);
|
||||
foreach (var ymlFile in ymlFiles)
|
||||
// Read JSON files produced by our reflection service
|
||||
var jsonFiles = Directory.GetFiles(metadataPath, "*.json", SearchOption.AllDirectories);
|
||||
foreach (var jsonFile in jsonFiles)
|
||||
{
|
||||
ProcessYamlMetadata(ymlFile, documentation, namespaces, settings);
|
||||
if (Path.GetFileName(jsonFile).StartsWith("error_"))
|
||||
continue; // Skip error files
|
||||
|
||||
ProcessJsonMetadata(jsonFile, documentation, namespaces, settings);
|
||||
}
|
||||
|
||||
return documentation;
|
||||
|
@ -49,62 +53,211 @@ namespace NetDocsForLLM.Services
|
|||
}
|
||||
}
|
||||
|
||||
private void ProcessYamlMetadata(string ymlFile, DocumentationModel documentation,
|
||||
Dictionary<string, NamespaceDocumentation> namespaces,
|
||||
private void ProcessJsonMetadata(string jsonFile, DocumentationModel documentation,
|
||||
Dictionary<string, NamespaceDocumentation> namespaces,
|
||||
ExportSettings settings)
|
||||
{
|
||||
// This is a simplified implementation. In a real application,
|
||||
// you would need to use a YAML parser to read DocFX output
|
||||
|
||||
// For this example, we'll create sample documentation data
|
||||
var typeDoc = new TypeDocumentation
|
||||
try
|
||||
{
|
||||
Name = Path.GetFileNameWithoutExtension(ymlFile),
|
||||
FullName = $"ExampleNamespace.{Path.GetFileNameWithoutExtension(ymlFile)}",
|
||||
Description = "Descripción del tipo extraída de comentarios XML",
|
||||
TypeKind = "Class"
|
||||
};
|
||||
// Leer el archivo JSON
|
||||
var jsonContent = File.ReadAllText(jsonFile);
|
||||
var typeData = JsonConvert.DeserializeObject<dynamic>(jsonContent);
|
||||
|
||||
// Add some members
|
||||
typeDoc.Members.Add(new MemberDocumentation
|
||||
{
|
||||
Name = "ExampleMethod",
|
||||
Description = "Un método de ejemplo con documentación",
|
||||
MemberType = "Method",
|
||||
Signature = "public void ExampleMethod(string parameter1, int parameter2)",
|
||||
ReturnType = "void",
|
||||
ReturnDescription = "Este método no devuelve ningún valor"
|
||||
});
|
||||
if (typeData == null)
|
||||
return;
|
||||
|
||||
// Add parameters to the method
|
||||
typeDoc.Members[0].Parameters.Add(new ParameterDocumentation
|
||||
{
|
||||
Name = "parameter1",
|
||||
Type = "string",
|
||||
Description = "Descripción del primer parámetro"
|
||||
});
|
||||
// Obtener namespace
|
||||
string namespaceName = typeData.Namespace?.ToString() ?? "Global";
|
||||
|
||||
typeDoc.Members[0].Parameters.Add(new ParameterDocumentation
|
||||
{
|
||||
Name = "parameter2",
|
||||
Type = "int",
|
||||
Description = "Descripción del segundo parámetro"
|
||||
});
|
||||
|
||||
// Add to namespace
|
||||
var namespaceName = "ExampleNamespace";
|
||||
if (!namespaces.TryGetValue(namespaceName, out var namespaceDoc))
|
||||
{
|
||||
namespaceDoc = new NamespaceDocumentation
|
||||
// Buscar o crear el namespace en nuestra documentación
|
||||
if (!namespaces.TryGetValue(namespaceName, out var namespaceDoc))
|
||||
{
|
||||
Name = namespaceName,
|
||||
Description = "Descripción del namespace"
|
||||
namespaceDoc = new NamespaceDocumentation
|
||||
{
|
||||
Name = namespaceName,
|
||||
Description = $"Contiene tipos del ensamblado"
|
||||
};
|
||||
namespaces[namespaceName] = namespaceDoc;
|
||||
documentation.Namespaces.Add(namespaceDoc);
|
||||
}
|
||||
|
||||
// Crear documentación de tipo
|
||||
var typeDoc = new TypeDocumentation
|
||||
{
|
||||
Name = typeData.Name,
|
||||
FullName = typeData.FullName,
|
||||
Description = typeData.XmlDocumentation?.Summary ?? "Sin documentación disponible",
|
||||
TypeKind = GetTypeKind(typeData)
|
||||
};
|
||||
namespaces[namespaceName] = namespaceDoc;
|
||||
documentation.Namespaces.Add(namespaceDoc);
|
||||
|
||||
// Agregar base types e interfaces si están disponibles
|
||||
if (typeData.BaseType != null && typeData.BaseType.ToString() != "System.Object")
|
||||
{
|
||||
typeDoc.BaseTypes.Add(typeData.BaseType.ToString());
|
||||
}
|
||||
|
||||
// Agregar interfaces
|
||||
if (typeData.Interfaces != null)
|
||||
{
|
||||
foreach (var interfaceType in typeData.Interfaces)
|
||||
{
|
||||
typeDoc.Interfaces.Add(interfaceType.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar miembros
|
||||
if (typeData.Members != null)
|
||||
{
|
||||
foreach (var member in typeData.Members)
|
||||
{
|
||||
// Filtrar miembros privados si la configuración lo indica
|
||||
if (!settings.IncludePrivateMembers &&
|
||||
member.IsPublic != null && !(bool)member.IsPublic)
|
||||
continue;
|
||||
|
||||
var memberDoc = new MemberDocumentation
|
||||
{
|
||||
Name = member.Name,
|
||||
Description = member.XmlDocumentation?.Summary ?? "Sin documentación disponible",
|
||||
MemberType = member.MemberType,
|
||||
Signature = member.Signature ?? GenerateSignature(member),
|
||||
ReturnType = GetReturnType(member),
|
||||
ReturnDescription = member.XmlDocumentation?.Returns ?? ""
|
||||
};
|
||||
|
||||
// Agregar parámetros si es un método
|
||||
if (member.MemberType?.ToString() == "Method" && member.Parameters != null)
|
||||
{
|
||||
foreach (var param in member.Parameters)
|
||||
{
|
||||
var paramDoc = new ParameterDocumentation
|
||||
{
|
||||
Name = param.Name,
|
||||
Type = param.Type,
|
||||
IsOptional = param.IsOptional != null && (bool)param.IsOptional,
|
||||
DefaultValue = param.DefaultValue?.ToString() ?? "",
|
||||
Description = GetParameterDescription(member.XmlDocumentation, param.Name?.ToString())
|
||||
};
|
||||
|
||||
memberDoc.Parameters.Add(paramDoc);
|
||||
}
|
||||
}
|
||||
|
||||
// Agregar ejemplos si están disponibles y configurados
|
||||
if (settings.IncludeExamples &&
|
||||
member.XmlDocumentation?.Examples != null)
|
||||
{
|
||||
foreach (var example in member.XmlDocumentation.Examples)
|
||||
{
|
||||
if (example != null && !string.IsNullOrWhiteSpace(example.ToString()))
|
||||
memberDoc.Examples.Add(example.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
typeDoc.Members.Add(memberDoc);
|
||||
}
|
||||
}
|
||||
|
||||
namespaceDoc.Types.Add(typeDoc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Si hay error al procesar, lo registramos pero continuamos
|
||||
Console.WriteLine($"Error al procesar {jsonFile}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetTypeKind(dynamic typeData)
|
||||
{
|
||||
if (typeData.IsClass != null && (bool)typeData.IsClass)
|
||||
return "Class";
|
||||
if (typeData.IsInterface != null && (bool)typeData.IsInterface)
|
||||
return "Interface";
|
||||
if (typeData.IsEnum != null && (bool)typeData.IsEnum)
|
||||
return "Enum";
|
||||
return "Type";
|
||||
}
|
||||
|
||||
private string GenerateSignature(dynamic member)
|
||||
{
|
||||
// Generar una firma simple basada en el tipo de miembro
|
||||
string memberType = member.MemberType?.ToString();
|
||||
string name = member.Name?.ToString() ?? "Unknown";
|
||||
|
||||
if (memberType == "Method")
|
||||
{
|
||||
string returnType = member.ReturnType?.ToString() ?? "void";
|
||||
string parameters = "";
|
||||
|
||||
if (member.Parameters != null)
|
||||
{
|
||||
var paramList = new List<string>();
|
||||
foreach (var param in member.Parameters)
|
||||
{
|
||||
string paramType = param.Type?.ToString() ?? "object";
|
||||
string paramName = param.Name?.ToString() ?? "param";
|
||||
paramList.Add($"{paramType} {paramName}");
|
||||
}
|
||||
parameters = string.Join(", ", paramList);
|
||||
}
|
||||
|
||||
return $"{returnType} {name}({parameters})";
|
||||
}
|
||||
else if (memberType == "Property")
|
||||
{
|
||||
string propType = member.Type?.ToString() ?? "object";
|
||||
string accessors = "";
|
||||
|
||||
bool canRead = member.CanRead != null && (bool)member.CanRead;
|
||||
bool canWrite = member.CanWrite != null && (bool)member.CanWrite;
|
||||
|
||||
if (canRead && canWrite)
|
||||
accessors = " { get; set; }";
|
||||
else if (canRead)
|
||||
accessors = " { get; }";
|
||||
else if (canWrite)
|
||||
accessors = " { set; }";
|
||||
|
||||
return $"{propType} {name}{accessors}";
|
||||
}
|
||||
else if (memberType == "Field")
|
||||
{
|
||||
string fieldType = member.Type?.ToString() ?? "object";
|
||||
return $"{fieldType} {name}";
|
||||
}
|
||||
else if (memberType == "Event")
|
||||
{
|
||||
string eventType = member.EventHandlerType?.ToString() ?? "EventHandler";
|
||||
return $"event {eventType} {name}";
|
||||
}
|
||||
|
||||
namespaceDoc.Types.Add(typeDoc);
|
||||
return name;
|
||||
}
|
||||
|
||||
private string GetReturnType(dynamic member)
|
||||
{
|
||||
string memberType = member.MemberType?.ToString();
|
||||
|
||||
if (memberType == "Method")
|
||||
return member.ReturnType?.ToString() ?? "void";
|
||||
else if (memberType == "Property")
|
||||
return member.Type?.ToString() ?? "object";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private string GetParameterDescription(dynamic xmlDocumentation, string paramName)
|
||||
{
|
||||
if (xmlDocumentation?.Parameters == null || paramName == null)
|
||||
return "";
|
||||
|
||||
foreach (var param in xmlDocumentation.Parameters)
|
||||
{
|
||||
if (param.Name?.ToString() == paramName)
|
||||
return param.Description?.ToString() ?? "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
public string GenerateDocumentationPreview(DocumentationModel documentation, ExportSettings settings)
|
||||
|
@ -113,24 +266,24 @@ namespace NetDocsForLLM.Services
|
|||
{
|
||||
if (settings.OutputFormat == OutputFormat.Json)
|
||||
{
|
||||
return JsonConvert.SerializeObject(documentation, Formatting.Indented,
|
||||
return JsonConvert.SerializeObject(documentation, Formatting.Indented,
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
}
|
||||
else // YAML
|
||||
{
|
||||
// Convert to JSON first, then to YAML (simplified)
|
||||
var json = JsonConvert.SerializeObject(documentation, Formatting.None,
|
||||
// En un escenario real, usaríamos YamlDotNet
|
||||
// Para esta implementación, usaremos una conversión manual simplificada
|
||||
return ConvertJsonToSimpleYaml(
|
||||
JsonConvert.SerializeObject(documentation, Formatting.None,
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
|
||||
});
|
||||
|
||||
// In a real application, you would use a YAML serializer
|
||||
// For this example, we'll return a simple YAML representation
|
||||
return ConvertJsonToSimpleYaml(json);
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -141,22 +294,19 @@ namespace NetDocsForLLM.Services
|
|||
|
||||
private string ConvertJsonToSimpleYaml(string json)
|
||||
{
|
||||
// This is a simplified conversion for demonstration purposes
|
||||
// In a real application, you would use a YAML serializer library
|
||||
|
||||
// Deserialize JSON
|
||||
var obj = JsonConvert.DeserializeObject<dynamic>(json);
|
||||
|
||||
|
||||
// Basic YAML builder
|
||||
var yaml = new System.Text.StringBuilder();
|
||||
yaml.AppendLine("namespaces:");
|
||||
|
||||
|
||||
foreach (var ns in obj.Namespaces)
|
||||
{
|
||||
yaml.AppendLine($" - name: {ns.Name}");
|
||||
yaml.AppendLine($" description: {ns.Description}");
|
||||
yaml.AppendLine(" types:");
|
||||
|
||||
|
||||
foreach (var type in ns.Types)
|
||||
{
|
||||
yaml.AppendLine($" - name: {type.Name}");
|
||||
|
@ -164,35 +314,61 @@ namespace NetDocsForLLM.Services
|
|||
yaml.AppendLine($" typeKind: {type.TypeKind}");
|
||||
yaml.AppendLine($" description: {type.Description}");
|
||||
yaml.AppendLine(" members:");
|
||||
|
||||
|
||||
foreach (var member in type.Members)
|
||||
{
|
||||
yaml.AppendLine($" - name: {member.Name}");
|
||||
yaml.AppendLine($" memberType: {member.MemberType}");
|
||||
yaml.AppendLine($" signature: {member.Signature}");
|
||||
yaml.AppendLine($" description: {member.Description}");
|
||||
|
||||
if (member.Parameters.Count > 0)
|
||||
yaml.AppendLine($" signature: \"{EscapeYamlString(member.Signature)}\"");
|
||||
yaml.AppendLine($" description: \"{EscapeYamlString(member.Description)}\"");
|
||||
|
||||
if (member.Parameters != null && member.Parameters.Count > 0)
|
||||
{
|
||||
yaml.AppendLine(" parameters:");
|
||||
foreach (var param in member.Parameters)
|
||||
{
|
||||
yaml.AppendLine($" - name: {param.Name}");
|
||||
yaml.AppendLine($" type: {param.Type}");
|
||||
yaml.AppendLine($" description: {param.Description}");
|
||||
yaml.AppendLine($" description: \"{EscapeYamlString(param.Description)}\"");
|
||||
yaml.AppendLine($" isOptional: {param.IsOptional.ToString().ToLower()}");
|
||||
if (!string.IsNullOrEmpty(param.DefaultValue))
|
||||
yaml.AppendLine($" defaultValue: \"{EscapeYamlString(param.DefaultValue)}\"");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(member.ReturnType))
|
||||
{
|
||||
yaml.AppendLine($" returnType: {member.ReturnType}");
|
||||
yaml.AppendLine($" returnDescription: {member.ReturnDescription}");
|
||||
if (!string.IsNullOrEmpty(member.ReturnDescription))
|
||||
yaml.AppendLine($" returnDescription: \"{EscapeYamlString(member.ReturnDescription)}\"");
|
||||
}
|
||||
|
||||
if (member.Examples != null && member.Examples.Count > 0)
|
||||
{
|
||||
yaml.AppendLine(" examples:");
|
||||
foreach (var example in member.Examples)
|
||||
{
|
||||
yaml.AppendLine($" - |\n {example.Replace("\n", "\n ")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return yaml.ToString();
|
||||
}
|
||||
|
||||
private string EscapeYamlString(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return "";
|
||||
|
||||
return input
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"")
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
using NetDocsForLLM.Models;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NetDocsForLLM.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Interfaz para servicios de extracción de metadatos de ensamblados.
|
||||
/// El nombre se mantiene por compatibilidad, aunque ya no usemos DocFx.
|
||||
/// </summary>
|
||||
public interface IDocFxService
|
||||
{
|
||||
/// <summary>
|
||||
/// Genera metadatos de los ensamblados proporcionados
|
||||
/// </summary>
|
||||
/// <param name="assemblies">Ensamblados para analizar</param>
|
||||
/// <returns>Ruta al directorio con los metadatos generados</returns>
|
||||
Task<string> GenerateMetadataAsync(IEnumerable<AssemblyModel> assemblies);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NetDocsForLLM.Models;
|
||||
using NetDocsForLLM.Services;
|
||||
|
|
Loading…
Reference in New Issue