Files
EngineeringSync 04ae8a0aae Initial commit: EngineeringSync v1.0.0
Vollständige Implementierung des EngineeringSync-Middleware-Tools:
- Windows Service (Kestrel :5050) mit FileSystemWatcher + SignalR
- WPF Tray-App mit PendingChanges- und Projektverwaltungs-Fenster
- Setup-Wizard (8-Schritte-Installer)
- SQLite/EF Core Datenschicht (WAL-Modus)
- SHA-256-basiertes Debouncing (2s Fenster)
- Backup-System mit konfigurierbarer Aufbewahrung

Bugfixes & Verbesserungen:
- BUG-1: AppDbContext OnConfiguring invertierte Bedingung behoben
- BUG-2: Event-Handler-Leak in TrayApp (Fenster-Singleton-Pattern)
- BUG-3: ProjectConfigChanged SignalR-Signal in allen CRUD-Endpoints
- BUG-5: Rename-Sync löscht alte Datei im Simulations-Ordner
- BUG-6: Doppeltes Dispose von SignalR verhindert
- BUG-7: Registry-Deinstallation nur EngineeringSync-Eintrag entfernt
- S1: Path-Traversal-Schutz via SafeCombine() im SyncManager
- E1: FSW Buffer 64KB + automatischer Re-Scan bei Overflow
- E2: Retry-Logik (3x) für gesperrte Dateien mit exponentiellem Backoff
- E4: Channel.Writer.TryComplete() beim Shutdown
- C2: HubMethodNames-Konstanten statt Magic Strings
- E3: Pagination in Changes-API (page/pageSize Query-Parameter)
- A1: Fire-and-Forget mit try/catch + Logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 21:52:26 +01:00

174 lines
7.5 KiB
C#

using EngineeringSync.Domain.Constants;
using EngineeringSync.Domain.Entities;
using EngineeringSync.Infrastructure;
using EngineeringSync.Service.Hubs;
using EngineeringSync.Service.Models;
using EngineeringSync.Service.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EngineeringSync.Service.Api;
public static class ProjectsApi
{
public static IEndpointRouteBuilder MapProjectsApi(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects");
group.MapGet("/", async (IDbContextFactory<AppDbContext> dbFactory) =>
{
await using var db = await dbFactory.CreateDbContextAsync();
var projects = await db.Projects.OrderBy(p => p.Name).ToListAsync();
return Results.Ok(projects);
});
group.MapGet("/{id:guid}", async (Guid id, IDbContextFactory<AppDbContext> dbFactory) =>
{
await using var db = await dbFactory.CreateDbContextAsync();
var project = await db.Projects.FindAsync(id);
return project is null ? Results.NotFound() : Results.Ok(project);
});
group.MapPost("/", async (CreateProjectRequest req, IDbContextFactory<AppDbContext> dbFactory,
WatcherService watcher, IHubContext<NotificationHub> hubContext, ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("ProjectsApi");
if (!Directory.Exists(req.EngineeringPath))
return Results.BadRequest($"Engineering-Pfad existiert nicht: {req.EngineeringPath}");
if (!Directory.Exists(req.SimulationPath))
return Results.BadRequest($"Simulations-Pfad existiert nicht: {req.SimulationPath}");
await using var db = await dbFactory.CreateDbContextAsync();
var project = new ProjectConfig
{
Name = req.Name,
EngineeringPath = req.EngineeringPath,
SimulationPath = req.SimulationPath,
FileExtensions = req.FileExtensions,
IsActive = req.IsActive,
BackupEnabled = req.BackupEnabled,
BackupPath = req.BackupPath,
MaxBackupsPerFile = req.MaxBackupsPerFile
};
db.Projects.Add(project);
await db.SaveChangesAsync();
await watcher.StartWatchingAsync(project);
_ = Task.Run(async () =>
{
try { await watcher.ScanExistingFilesAsync(project); }
catch (Exception ex) { logger.LogError(ex, "Fehler beim initialen Scan für Projekt {Id}", project.Id); }
});
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
return Results.Created($"/api/projects/{project.Id}", project);
});
group.MapPut("/{id:guid}", async (Guid id, UpdateProjectRequest req,
IDbContextFactory<AppDbContext> dbFactory, WatcherService watcher, IHubContext<NotificationHub> hubContext,
ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("ProjectsApi");
await using var db = await dbFactory.CreateDbContextAsync();
var project = await db.Projects.FindAsync(id);
if (project is null) return Results.NotFound();
if (!Directory.Exists(req.EngineeringPath))
return Results.BadRequest($"Engineering-Pfad existiert nicht: {req.EngineeringPath}");
if (!Directory.Exists(req.SimulationPath))
return Results.BadRequest($"Simulations-Pfad existiert nicht: {req.SimulationPath}");
project.Name = req.Name;
project.EngineeringPath = req.EngineeringPath;
project.SimulationPath = req.SimulationPath;
project.FileExtensions = req.FileExtensions;
project.IsActive = req.IsActive;
project.BackupEnabled = req.BackupEnabled;
project.BackupPath = req.BackupPath;
project.MaxBackupsPerFile = req.MaxBackupsPerFile;
await db.SaveChangesAsync();
// Watcher neu starten (stoppt automatisch den alten)
await watcher.StartWatchingAsync(project);
_ = Task.Run(async () =>
{
try { await watcher.ScanExistingFilesAsync(project); }
catch (Exception ex) { logger.LogError(ex, "Fehler beim initialen Scan für Projekt {Id}", project.Id); }
});
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
return Results.Ok(project);
});
group.MapDelete("/{id:guid}", async (Guid id, IDbContextFactory<AppDbContext> dbFactory,
WatcherService watcher, IHubContext<NotificationHub> hubContext) =>
{
await using var db = await dbFactory.CreateDbContextAsync();
var project = await db.Projects.FindAsync(id);
if (project is null) return Results.NotFound();
await watcher.StopWatchingAsync(id);
db.Projects.Remove(project);
await db.SaveChangesAsync();
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
return Results.NoContent();
});
group.MapGet("/scan", async (string engineeringPath, string simulationPath, string fileExtensions) =>
{
if (!Directory.Exists(engineeringPath))
return Results.BadRequest($"Engineering-Pfad existiert nicht: {engineeringPath}");
if (!Directory.Exists(simulationPath))
return Results.BadRequest($"Simulations-Pfad existiert nicht: {simulationPath}");
var extensions = string.IsNullOrWhiteSpace(fileExtensions) || fileExtensions == "*"
? new HashSet<string>()
: fileExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var scanAll = extensions.Count == 0;
var engFiles = scanAll
? Directory.EnumerateFiles(engineeringPath, "*", SearchOption.AllDirectories).ToList()
: Directory.EnumerateFiles(engineeringPath, "*", SearchOption.AllDirectories)
.Where(f => extensions.Contains(Path.GetExtension(f)))
.ToList();
var results = new List<ScanResultEntry>();
foreach (var engFile in engFiles)
{
var relativePath = Path.GetRelativePath(engineeringPath, engFile);
var simFile = Path.Combine(simulationPath, relativePath);
var engInfo = new FileInfo(engFile);
var engHash = await FileHasher.ComputeAsync(engFile);
var existsInSim = File.Exists(simFile);
var needsSync = !existsInSim;
if (existsInSim)
{
var simHash = await FileHasher.ComputeAsync(simFile);
needsSync = engHash != simHash;
}
if (needsSync)
{
results.Add(new ScanResultEntry(
relativePath,
existsInSim ? "Modified" : "Created",
engInfo.Length,
engInfo.LastWriteTimeUtc
));
}
}
return Results.Ok(results);
});
return app;
}
}