Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ jobs:
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
- run: dotnet test MarkItDownWeb.slnx --configuration Release
- run: dotnet build MarkItDownWeb.slnx --configuration Release --warnaserror
- run: dotnet test MarkItDownWeb.slnx --configuration Release --no-build
110 changes: 51 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,94 +1,86 @@
# FileToMarkdown

Aplicación web local para convertir documentos a Markdown. Selecciona un archivo, revisa el resultado en el navegador y descarga un `.md` con el nombre del documento original.
Biblioteca local para convertir documentos a Markdown, buscar su contenido y consultar fragmentos con referencias a su archivo y sección de origen.

## Captura
**Primer uso en escritorio**

![Conversión de un documento y su vista previa Markdown con historial local](docs/screenshots/local-conversion-flow.png)
![Biblioteca local en escritorio](docs/screenshots/local-document-library.png)

**Primer uso en móvil**

![Biblioteca local en móvil](docs/screenshots/local-document-library-mobile.png)

## Funciones

- Interfaz web responsive con vista previa, copia y descarga del Markdown.
- Historial local de conversiones con búsqueda y reapertura de resultados.
- Conversión de PDF, DOCX, PPTX, XLSX, HTML y CSV mediante [Microsoft MarkItDown](https://github.com/microsoft/markitdown).
- Base de datos SQLite y archivos de resultado guardados en el equipo.
- Validación de extensión, tamaño máximo de 100 MiB y firma de archivos PDF/Office.
- Importa hasta 10 archivos por lote; cada archivo puede ocupar hasta 100 MiB. El estado de conversión se muestra por archivo y un error no detiene los demás.
- Convierte PDF, DOCX, PPTX, XLSX, HTML y CSV con [Microsoft MarkItDown](https://github.com/microsoft/markitdown).
- Previsualiza, copia y descarga Markdown. El historial local permite reabrir o borrar conversiones.
- Indexa el Markdown en fragmentos de hasta 1.200 caracteres y busca nombres de archivo y contenido en SQLite, sin distinguir mayúsculas ni acentos.
- Consulta la biblioteca con respuestas extractivas: muestra el fragmento coincidente y cita el archivo y la sección. Si no encuentra evidencia, lo indica explícitamente.

La aplicación se ejecuta en tu equipo. Los archivos se envían al proceso web local y a MarkItDown; esta configuración no proporciona autenticación, así que mantenla accesible solo desde tu propio equipo y no la expongas a una red pública.
La búsqueda y las consultas son locales y no necesitan claves ni conexión a un proveedor de IA. `IAnswerGenerator` permite añadir otro generador, pero esta versión solo registra el generador extractivo local y no envía documentos fuera del equipo.

## Requisitos

- [.NET SDK 10](https://dotnet.microsoft.com/download/dotnet/10.0)
- Python y `pip`
- Python 3.12 o posterior y `pip` para Microsoft MarkItDown

## Ejecutar en local
## Instalación y ejecución

Desde la raíz del repositorio:
Desde la raíz del repositorio, crea un entorno e instala MarkItDown con sus dependencias opcionales:

```bash
python -m venv .venv
```powershell
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install 'markitdown[pdf,docx,pptx,xlsx]'
```

Activa el entorno virtual y luego instala MarkItDown:
Antes de iniciar la aplicación, configura el ejecutable de Python del entorno virtual. Así MarkItDown y sus dependencias quedan aislados del Python global:

```bash
# Windows PowerShell
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install 'markitdown[all]'
```powershell
$env:MarkItDown__PythonPath = (Resolve-Path .venv\Scripts\python.exe).Path
dotnet run --project src/Web/MarkItDownWeb.Web.csproj --launch-profile http
```

```bash
# Linux / macOS
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install 'markitdown[all]'
```
Abre [http://localhost:5241](http://localhost:5241). Si prefieres usar una instalación global, establece `MarkItDown__PythonPath` con la ruta completa a ese ejecutable antes de iniciar la app.

Inicia la interfaz:
## Uso

```bash
dotnet run --project src/Web/MarkItDownWeb.Web.csproj --launch-profile http
```
1. Arrastra archivos a la zona de carga o pulsa **Elegir documentos**. Se procesan de uno en uno; el estado o error aparece junto al nombre de cada archivo.
2. Busca por nombre o contenido desde la barra superior. La lista central muestra tipo, fecha y estado.
3. Selecciona un documento para abrir su Markdown en el panel de detalle. Desde ahí puedes copiar, descargar o borrar.
4. Usa **Pregunta a la biblioteca** para consultar fragmentos relacionados con sus citas. Sin coincidencias, la app informa que no encontró respaldo. En móvil, el detalle queda debajo de la lista.

Abre [http://localhost:5241](http://localhost:5241). La ruta del ejecutable de Python se puede configurar con `MarkItDown__PythonPath`; por defecto se usa `python` del entorno activo. Por ejemplo, en PowerShell:
## Almacenamiento y privacidad

```powershell
$env:MarkItDown__PythonPath = (Get-Command python).Source
dotnet run --project src/Web/MarkItDownWeb.Web.csproj --launch-profile http
```
SQLite conserva los metadatos y los fragmentos indexados. Los resultados Markdown se guardan localmente en `Storage/Markdown`; el documento original solo existe en un archivo temporal durante la conversión y se elimina al terminar. Al borrar una conversión también se borran sus fragmentos por clave foránea y su archivo Markdown. Al iniciar, la app indexa conversiones existentes que todavía no tengan fragmentos.

## Comprobar cambios
Las rutas se configuran en `src/Web/appsettings.json` (`ConnectionStrings:DefaultConnection`, `Storage:UploadsFolder` y `Storage:MarkdownFolder`). La aplicación no incluye autenticación; mantenla accesible solo desde tu equipo y no la expongas a una red pública.

```bash
dotnet test MarkItDownWeb.slnx --configuration Release
```
## Arquitectura

GitHub Actions ejecuta la misma comprobación al abrir o actualizar un pull request y al hacer push a `master`.
```text
src/
Domain/ Conversiones y fragmentos indexados
Application/ Conversión, división Markdown, búsqueda y respuesta extractiva
Infrastructure/ MarkItDown, almacenamiento local y persistencia SQLite
Web/ Interfaz y endpoints ASP.NET Core MVC
tests/ Pruebas de conversión, búsqueda, citas, validación y borrado
```

## Uso
La tabla `DocumentChunks` se crea al iniciar. Esto conserva las bases SQLite existentes sin modificar su tabla de historial; los fragmentos se eliminan en cascada al borrar una conversión.

1. Elige un PDF, DOCX, PPTX, XLSX, HTML o CSV de hasta 100 MiB.
2. Pulsa **Convertir a Markdown** y revisa el contenido generado.
3. Cópialo o descárgalo como `<nombre-del-documento>.md`.
4. Abre una conversión anterior desde el historial. Los datos permanecen en el equipo.
## Formatos y límites verificados

## Datos locales
La aplicación acepta `.pdf`, `.docx`, `.pptx`, `.xlsx`, `.html` y `.csv`, limita cada archivo a 100 MiB y rechaza extensiones no permitidas. También comprueba las firmas PDF/ZIP de PDF y documentos Office; HTML y CSV no tienen una comprobación de firma. MarkItDown y sus dependencias instaladas determinan qué contenido puede extraerse de cada archivo.

La base de datos SQLite se crea automáticamente. Los archivos temporales se eliminan al terminar la conversión; el historial conserva los resultados Markdown y los metadatos. Las carpetas se pueden cambiar desde `src/Web/appsettings.json` (`Storage:UploadsFolder`, `Storage:MarkdownFolder` y `ConnectionStrings:DefaultConnection`). No se conserva el documento original después de convertirlo.
Las pruebas del repositorio verifican validación, indexación, búsqueda, citas, abstención y eliminación con un conversor simulado. También se comprobó manualmente la conversión e indexación de archivos PDF, DOCX, PPTX, XLSX, HTML y CSV. La extracción depende de MarkItDown; los PDF no reciben número de página de forma uniforme. La app cita los marcadores de página que existan en el Markdown y, en su ausencia, cita encabezados/secciones o `Contenido`. La búsqueda es léxica; no interpreta sinónimos ni genera resúmenes. No incluye OCR ni cuentas multiusuario.

## Estructura
## Verificación

```text
src/
Domain/ Entidades y tipos de conversión
Application/ Flujo de conversión e interfaces
Infrastructure/ MarkItDown, almacenamiento y SQLite
Web/ Interfaz ASP.NET Core MVC
```powershell
dotnet build MarkItDownWeb.slnx --configuration Release --warnaserror
dotnet test MarkItDownWeb.slnx --configuration Release
```

## Limitaciones conocidas

- La calidad del Markdown depende del formato y del contenido del documento original.
- MarkItDown puede necesitar dependencias adicionales para ciertos formatos; consulta su documentación si la instalación completa no está disponible en tu plataforma.
- Esta versión está pensada para uso local individual; no incluye cuentas, aislamiento entre usuarios ni almacenamiento en la nube.
GitHub Actions ejecuta build con warnings como errores y las pruebas al abrir o actualizar un pull request y al hacer push a `master`.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/local-document-library.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions src/Application/DTOs/LibraryAnswerDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace MarkItDownWeb.Application.DTOs;

public sealed class LibraryAnswerDto
{
public string Answer { get; init; } = string.Empty;
public bool HasEvidence { get; init; }
public IReadOnlyList<LibrarySearchHit> Citations { get; init; } = [];
}
10 changes: 10 additions & 0 deletions src/Application/DTOs/LibrarySearchHit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace MarkItDownWeb.Application.DTOs;

public sealed class LibrarySearchHit
{
public Guid ConversionId { get; init; }
public string FileName { get; init; } = string.Empty;
public string Section { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public int MatchCount { get; init; }
}
8 changes: 8 additions & 0 deletions src/Application/Interfaces/IAnswerGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using MarkItDownWeb.Application.DTOs;

namespace MarkItDownWeb.Application.Interfaces;

public interface IAnswerGenerator
{
LibraryAnswerDto CreateAnswer(IReadOnlyList<LibrarySearchHit> evidence);
}
5 changes: 5 additions & 0 deletions src/Application/Interfaces/IConversionRepository.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using MarkItDownWeb.Application.DTOs;
using MarkItDownWeb.Domain.Entities;

namespace MarkItDownWeb.Application.Interfaces;
Expand All @@ -7,6 +8,10 @@ public interface IConversionRepository
Task AddAsync(Conversion conversion, CancellationToken ct = default);
Task<Conversion?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<List<Conversion>> GetHistoryAsync(string? search, DateTime? date, CancellationToken ct = default);
Task<List<Conversion>> GetCompletedWithoutIndexAsync(CancellationToken ct = default);
Task AddChunksAsync(IReadOnlyCollection<DocumentChunk> chunks, CancellationToken ct = default);
Task BackfillSearchIndexesAsync(CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
Task<List<LibrarySearchHit>> SearchContentAsync(IReadOnlyCollection<string> terms, int limit, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
1 change: 1 addition & 0 deletions src/Application/Interfaces/IFileStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ public interface IFileStorageService
Task<string> SaveMarkdownAsync(Guid conversionId, string markdown, CancellationToken ct = default);
Task<string> ReadMarkdownAsync(string markdownFilePath, CancellationToken ct = default);
void DeleteTemporary(string filePath);
void DeleteMarkdown(string filePath);
}
38 changes: 37 additions & 1 deletion src/Application/Services/ConversionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public async Task<ConversionResultDto> ConvertAsync(Stream fileStream, string fi

var markdownPath = await _fileStorageService.SaveMarkdownAsync(conversion.Id, markdown, ct);
conversion.MarkAsCompleted(markdownPath, stopwatch.Elapsed.TotalSeconds);
conversion.Chunks.AddRange(MarkdownChunker.Create(conversion.Id, markdown, conversion.FileName));

await _repository.AddAsync(conversion, ct);
await _repository.SaveChangesAsync(ct);
Expand Down Expand Up @@ -112,7 +113,42 @@ public Task<List<ConversionListItemDto>> GetHistoryAsync(string? search, DateTim
Status = c.Status.ToString()
}).ToList(), ct);

public Task DeleteAsync(Guid id, CancellationToken ct = default) => _repository.DeleteAsync(id, ct);
public async Task IndexExistingAsync(CancellationToken ct = default)
{
var conversions = await _repository.GetCompletedWithoutIndexAsync(ct);
foreach (var conversion in conversions)
{
if (string.IsNullOrWhiteSpace(conversion.MarkdownFilePath))
continue;

try
{
var markdown = await _fileStorageService.ReadMarkdownAsync(conversion.MarkdownFilePath, ct);
var chunks = MarkdownChunker.Create(conversion.Id, markdown, conversion.FileName);
await _repository.AddChunksAsync(chunks, ct);
}
catch (FileNotFoundException)
{
// A missing Markdown file must not stop the local app from starting.
}
}

await _repository.BackfillSearchIndexesAsync(ct);
await _repository.SaveChangesAsync(ct);
}

public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var conversion = await _repository.GetByIdAsync(id, ct);
if (conversion is null)
return;

await _repository.DeleteAsync(id, ct);
await _repository.SaveChangesAsync(ct);

if (!string.IsNullOrWhiteSpace(conversion.MarkdownFilePath))
_fileStorageService.DeleteMarkdown(conversion.MarkdownFilePath);
}

private static void ValidateFile(string fileName, long fileSize)
{
Expand Down
58 changes: 58 additions & 0 deletions src/Application/Services/LibraryService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Text.RegularExpressions;
using MarkItDownWeb.Application.DTOs;
using MarkItDownWeb.Application.Interfaces;

namespace MarkItDownWeb.Application.Services;

public sealed partial class LibraryService
{
private const int MaxQueryLength = 300;
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
{
"a", "an", "and", "are", "as", "at", "be", "by", "do", "does", "for", "from", "how", "in", "is", "it", "of", "on", "or", "the", "to", "was", "were", "what", "when", "where", "which", "who", "why",
"con", "cuál", "cuáles", "cual", "cuales", "cómo", "como", "de", "del", "el", "ella", "en", "es", "esta", "este", "la", "las", "lo", "los", "más", "mas", "para", "por", "qué", "que", "se", "sobre", "son", "un", "una", "y"
};
private readonly IConversionRepository _repository;
private readonly IAnswerGenerator _answerGenerator;

public LibraryService(IConversionRepository repository, IAnswerGenerator answerGenerator)
{
_repository = repository;
_answerGenerator = answerGenerator;
}

[GeneratedRegex(@"[\p{L}\p{N}]{2,}", RegexOptions.CultureInvariant)]
private static partial Regex TermRegex();

public async Task<IReadOnlyList<LibrarySearchHit>> SearchAsync(string? query, CancellationToken ct = default)
{
var terms = GetTerms(query);
return terms.Length == 0
? []
: await _repository.SearchContentAsync(terms, 20, ct);
}

public async Task<LibraryAnswerDto> AskAsync(string? question, CancellationToken ct = default)
{
var terms = GetTerms(question);
if (terms.Length == 0)
return _answerGenerator.CreateAnswer([]);

var evidence = await _repository.SearchContentAsync(terms, 8, ct);
return _answerGenerator.CreateAnswer(evidence);
}

private static string[] GetTerms(string? query)
{
if (string.IsNullOrWhiteSpace(query))
return [];

var normalizedQuery = SearchTextNormalizer.NormalizeTerm(query[..Math.Min(query.Length, MaxQueryLength)]);
return TermRegex().Matches(normalizedQuery)
.Select(match => match.Value)
.Where(term => !StopWords.Contains(term))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(8)
.ToArray();
}
}
27 changes: 27 additions & 0 deletions src/Application/Services/LocalExtractiveAnswerGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using MarkItDownWeb.Application.DTOs;
using MarkItDownWeb.Application.Interfaces;

namespace MarkItDownWeb.Application.Services;

public sealed class LocalExtractiveAnswerGenerator : IAnswerGenerator
{
public LibraryAnswerDto CreateAnswer(IReadOnlyList<LibrarySearchHit> evidence)
{
if (evidence.Count == 0)
{
return new LibraryAnswerDto
{
Answer = "No encontré contenido que respalde una respuesta en los documentos de la biblioteca.",
HasEvidence = false
};
}

var citations = evidence.Take(4).ToArray();
return new LibraryAnswerDto
{
Answer = $"El fragmento más relevante dice: «{citations[0].Content}»",
HasEvidence = true,
Citations = citations
};
}
}
Loading
Loading