v0.17.0 — Doc tool auto-deploy + revert (mirror of Report)
Adds the same deploy + backup/revert mechanism as Report, but for the local Documentation web tool : PROSERVE-source/_doc/ → C:\xampp\htdocs\ProserveDoc\ http://localhost/ProserveDoc/ Default DocumentationUrl in the launcher set to http://localhost/ProserveDoc/ so the Documentation sidebar tab works out of the box on a fresh install without any manual config. Implementation - LocalConfig.DocToolConfig (HtdocsRoot/FolderName/AutoDeploy/MaxBackups, defaults to ProserveDoc folder, 3 backups kept). - IDocToolDeployer + DocToolDeployer : near-duplicate of the Report deployer with SourceSubdir = "_doc". Reuses ReportTool's BackupInfo / DeployStatus / DeployResult / DeployProgress types so we don't fork the data model. - MainViewModel : new install step 7 right after the Report deploy step, mirrors its progress reporting + error dialogs (silent on XamppNotFound since the Report step already warned for the same root). - SettingsViewModel : DocBackupViewModel + Doc properties + RedeployDoc + Revert commands. Loads the doc backups list in background like the Report ones. - SettingsDialog.xaml : new « OUTIL DOCUMENTATION » card under the « OUTIL REPORT » one in Avancés, with the same fields and backup table. - Strings.cs : doc-specific status / progress / error labels in 5 langs ; reuses some labels from Report where the wording is identical. Versions bumped to 0.17.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
211
src/PSLauncher.Core/DocTool/DocToolDeployer.cs
Normal file
211
src/PSLauncher.Core/DocTool/DocToolDeployer.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.ReportTool;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.DocTool;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation : copy récursif + atomic rename + backups horodatés.
|
||||
/// Strictement parallèle à <see cref="ReportToolDeployer"/> mais avec
|
||||
/// <c>_doc/</c> comme source au lieu de <c>_report/</c>. Voir le commentaire
|
||||
/// de cette dernière classe pour les détails du mécanisme.
|
||||
/// </summary>
|
||||
public sealed class DocToolDeployer : IDocToolDeployer
|
||||
{
|
||||
private const string SourceSubdir = "_doc";
|
||||
private const string BackupSuffix = ".backup-";
|
||||
private const string TimestampFormat = "yyyyMMdd-HHmmss";
|
||||
|
||||
private readonly Func<DocToolConfig> _configProvider;
|
||||
private readonly ILogger<DocToolDeployer> _logger;
|
||||
|
||||
public DocToolDeployer(
|
||||
Func<DocToolConfig> configProvider,
|
||||
ILogger<DocToolDeployer> logger)
|
||||
{
|
||||
_configProvider = configProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DeployResult> DeployAsync(
|
||||
string installFolder,
|
||||
IProgress<DeployProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cfg = _configProvider();
|
||||
var source = Path.Combine(installFolder, SourceSubdir);
|
||||
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
|
||||
|
||||
if (!Directory.Exists(source))
|
||||
{
|
||||
_logger.LogInformation("No {Sub}/ directory in {Folder}, skipping doc deploy", SourceSubdir, installFolder);
|
||||
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||
{
|
||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
||||
}
|
||||
|
||||
var stagingTarget = target + ".new";
|
||||
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
|
||||
|
||||
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
|
||||
int total = allFiles.Count;
|
||||
long bytesTotal = 0;
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Directory.CreateDirectory(stagingTarget);
|
||||
int done = 0;
|
||||
foreach (var srcFile in allFiles)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var rel = Path.GetRelativePath(source, srcFile);
|
||||
var dstFile = Path.Combine(stagingTarget, rel);
|
||||
var dstDir = Path.GetDirectoryName(dstFile);
|
||||
if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir))
|
||||
Directory.CreateDirectory(dstDir);
|
||||
File.Copy(srcFile, dstFile, overwrite: true);
|
||||
bytesTotal += new FileInfo(srcFile).Length;
|
||||
done++;
|
||||
progress?.Report(new DeployProgress(done, total, rel));
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
if (Directory.Exists(target))
|
||||
Directory.Move(target, backupTarget);
|
||||
Directory.Move(stagingTarget, target);
|
||||
|
||||
PruneOldBackups(cfg);
|
||||
|
||||
_logger.LogInformation("Doc tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
|
||||
target, total, bytesTotal, backupTarget);
|
||||
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deploy doc tool to {Target}", target);
|
||||
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
|
||||
catch { /* ignore */ }
|
||||
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
|
||||
{
|
||||
var cfg = _configProvider();
|
||||
var prefix = cfg.FolderName + BackupSuffix;
|
||||
|
||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
|
||||
|
||||
var list = new List<BackupInfo>();
|
||||
foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var name = Path.GetFileName(dir);
|
||||
var tsPart = name.Substring(prefix.Length);
|
||||
if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
long size = 0;
|
||||
try
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
size += new FileInfo(f).Length;
|
||||
}
|
||||
catch { /* size best-effort */ }
|
||||
list.Add(new BackupInfo(name, ts, size));
|
||||
}
|
||||
list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc));
|
||||
return Task.FromResult<IReadOnlyList<BackupInfo>>(list);
|
||||
}
|
||||
|
||||
public async Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct)
|
||||
{
|
||||
var cfg = _configProvider();
|
||||
var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName);
|
||||
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
|
||||
|
||||
if (!Directory.Exists(backupPath))
|
||||
{
|
||||
return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target,
|
||||
$"Le backup {backupFolderName} n'existe plus.");
|
||||
}
|
||||
|
||||
var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (Directory.Exists(target))
|
||||
Directory.Move(target, newBackupForCurrent);
|
||||
Directory.Move(backupPath, target);
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
int files = 0;
|
||||
long bytes = 0;
|
||||
try
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
files++;
|
||||
bytes += new FileInfo(f).Length;
|
||||
}
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
|
||||
PruneOldBackups(cfg);
|
||||
_logger.LogInformation("Reverted doc to backup {Backup} ({Files} files), previous current saved as {New}",
|
||||
backupFolderName, files, newBackupForCurrent);
|
||||
return new DeployResult(DeployStatus.Deployed, files, bytes, target);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Doc revert to {Backup} failed", backupFolderName);
|
||||
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void PruneOldBackups(DocToolConfig cfg)
|
||||
{
|
||||
try
|
||||
{
|
||||
var prefix = cfg.FolderName + BackupSuffix;
|
||||
var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)
|
||||
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
|
||||
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
int keep = Math.Max(0, cfg.MaxBackups);
|
||||
for (int i = keep; i < dirs.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dirs[i].Path, recursive: true);
|
||||
_logger.LogDebug("Pruned old doc backup {Dir}", dirs[i].Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Could not prune doc backup {Dir} (will retry next deploy)", dirs[i].Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Doc backup pruning skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user