cssitegen/Program.cs
Robert Morrison da24073f14
feat: Add support for removing deleted source files
Detects files removed from the source directory, deletes the file from
destination, removes it from the metadata cache.
2025-03-12 02:31:54 +00:00

331 lines
11 KiB
C#

/*
* csSiteGen - A static site generator written in c#
* Copyright © 2022 Robert Morrison<sherlock5512>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using Serilog;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using System.CommandLine;
using System.Diagnostics;
using System.Reflection;
using Spectre.Console;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace csSiteGen;
class Program
{
static int Main(string[] args)
{
// Get the current version number
string? version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
// Only log to file
// Logging to the console is BAD practice as it tends to be messy.
// TODO: Log to a known location using environment to find the correct location.
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File("log.log")
.CreateLogger();
Log.Information("Starting New Instance of csSiteGen");
if (version is not null)
{
Log.Information("Version: {ver}", version);
}
else
{
Log.Warning("Cannot get Version Information");
}
// It is very likely that this program will only work on linux. As such it is worth warning the user about this.
// With further testing and handling of any edge cases it _may_ be possible to have this work anywhere.
if (!OperatingSystem.IsLinux())
{
Log.Warning("This program has only been tested on linux and cannot be assumed to work on other Operating Systems");
// AnsiConsole.MarkupLine("[[[yellow]Warning[/]]] This program is only tested on linux systems, it may not work on this Operating System.");
}
Stopwatch TotalExecutionTime = Stopwatch.StartNew();
/* !! IMPORTANT !!
WARN:
This code uses system.commandline which is still in pre-release
the following section of code will contain comments to explain the intent of the programmer
which may be useful if system.commandline has breaking changes
*/
// First the option for the project directory is created
var ProjectDirectoryOption = new Option<DirectoryInfo>(
name: "--project",
description: "The Directory for the project");
ProjectDirectoryOption.IsRequired = false; // it is not required as not providing it infers that the current directory is the project directory
// If the option is used then the input is validated before control passes to any of the actual code.
ProjectDirectoryOption.AddValidator(result =>
{
if (!result.GetValueForOption(ProjectDirectoryOption)!.Exists)
{
result.ErrorMessage = $"Project directory {result.GetValueForOption(ProjectDirectoryOption)} does not exist.";
}
}
);
// The root command is the entry point for commandline but otherwise does nothing.
var rootCommand = new RootCommand("csSiteGen");
// TODO: Verify if the use of async in these functions is necessary
// This creates the command for cleaning a projects output directory.
var cleanCommand = new Command("clean", "Clean the projects output directory");
cleanCommand.AddOption(ProjectDirectoryOption); // This command can use the project directory option we created earlier
cleanCommand.SetHandler(async (ProjectDirectory) =>
{
await Task.Run(() =>
{
Clean(ProjectDirectory);
});
},ProjectDirectoryOption);
// This creates the command for actually converting the project.
var convertCommand = new Command("convert", "Convert the projects input directory and place the files in the output directory.");
convertCommand.AddOption(ProjectDirectoryOption); // This command can use the project directory option
convertCommand.SetHandler(async (ProjectDirectory) =>
{
await Task.Run(() =>
{
Convert(ProjectDirectory);
});
},ProjectDirectoryOption);
// Adding the commands to the root command makes them actually callable on the commandline
rootCommand.AddCommand(cleanCommand);
rootCommand.AddCommand(convertCommand);
// The parser is what actually handles the arguments and dispatches them to the appropriate commands.
// This is used instead of the simpler method of just Invoking the root command as it automatically creates usage statements.
// It also makes a user aware that a subcommand needs to be used.
var parser = new CommandLineBuilder(rootCommand)
.UseDefaults()
.Build();
parser.Invoke(args);
TotalExecutionTime.Stop();
Log.Information("TotalExecutionTime {time:000}ms", TotalExecutionTime.ElapsedMilliseconds);
// Providing there were no early exits it is best to properly close the log before we exit.
Log.CloseAndFlush();
return 0;
}
static int Convert(DirectoryInfo? ProjectDirectory)
{
Log.Information("Convert command was called, beginning conversion.");
// WARN: This is only temporary as Spectre.Console does not recognise some Linux terminals
// A better solution that checks the terminal value and sets this option should be added in the future.
AnsiConsole.Console.Profile.Capabilities.Ansi = true;
ProjectSettings settings = GetProjectSettings(ProjectDirectory);
List<SiteFile> siteFiles = new();
Utils.GetFiles(settings.InputDirectory).ForEach(x => {
siteFiles.Add(new SiteFile(x));
Log.Debug("Found file {file}",x.FullName);
});
Log.Information("SiteFiles Found {count}", siteFiles.Count);
Console.WriteLine($"Converting {siteFiles.Count} files from {settings.InputDirectory.FullName} to {settings.OutputDirectory.FullName}");
Dictionary<string,bool> fileStatus = new();
AnsiConsole.Progress()
.AutoRefresh(true)
.Columns(new ProgressColumn[]
{
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new PercentageColumn(),
new RemainingTimeColumn(),
new SpinnerColumn()
})
.Start(ctx =>
{
var tasks = siteFiles.Select(x => ctx.AddTask($"Converting {x.Name}")).ToList();
var overallTask = ctx.AddTask("[bold]Converting Files[/]");
overallTask.MaxValue = siteFiles.Count();
for (int i = 0; i < siteFiles.Count; i++)
{
tasks[i].MaxValue = 1;
Log.Information("Converting file {name} {i}/{count}",siteFiles[i].Name,i+1,siteFiles.Count());
bool res = siteFiles[i].Convert(settings);
tasks[i].Increment(1);
tasks[i].StopTask();
if (!res)
{
Log.Warning("{name} Failed...",siteFiles[i].FullName);
tasks[i].Description += " [red]FAILED[/]";
}
Log.Information("adding {siteFile} conversion status to fileStatus", siteFiles[i].FullName);
fileStatus.Add(siteFiles[i].FullName,res);
overallTask.Increment(1);
}
});
var Failed = fileStatus.Where(x => x.Value == false);
if ( Failed.Count() > 0)
{
foreach (var fail in Failed)
{
AnsiConsole.MarkupLineInterpolated(
$"[red]File [blue]\"{fail.Key}\"[/] failed to convert[/]");
AnsiConsole.MarkupLine("[yellow]See log for more details[/]");
}
}
EnforceConsistency(settings);
return 0;
}
static int Clean(DirectoryInfo? ProjectDirectory)
{
Log.Information("Clean command was called, Beginning cleaning");
ProjectSettings settings = GetProjectSettings(ProjectDirectory);
if (!settings.OutputDirectory.Exists)
{
Log.Warning("Not deleting {dir} as it doesn't exist",settings.OutputDirectory.FullName);
AnsiConsole.MarkupLineInterpolated($"[bold][[[yellow]Warning[/]]][/] Not cleaning [blue]\"{settings.OutputDirectory.FullName}\"[/] as it does not exist.");
return 0; // success because it doesn't exist.
}
try
{
Log.Information("Cleaning {dir}",settings.OutputDirectory.FullName);
AnsiConsole.MarkupInterpolated($"Cleaning [blue]\"{settings.OutputDirectory.FullName}\"[/]");
settings.OutputDirectory.Delete(recursive: true);
settings.OutputDirectory.Create();
AnsiConsole.MarkupLine(" [bold][[[green]OK[/]]][/]");
AnsiConsole.MarkupLineInterpolated($"\t[grey]>>[/] [green]All files in {settings.OutputDirectory.FullName} purged successfully[/]");
return 0;
}
catch (System.Security.SecurityException e)
{
AnsiConsole.MarkupLine(" [bold][[[red]Fail[/]]][/]");
AnsiConsole.MarkupLine("[orangered1]See log for more details about what went wrong.[/]");
Log.Error(e, "Failed to delete directory {dir} due to permission error.", settings.OutputDirectory.FullName);
return 1;
}
catch (Exception e)
{
AnsiConsole.MarkupLine(" [red][[[bold]Fail[/]]][/]");
AnsiConsole.MarkupLine("[orangered1]See log for more details about what went wrong.[/]");
Log.Error(e, "Failed to delete/create directory {dir}", settings.OutputDirectory.FullName);
return 1;
}
}
static void EnforceConsistency(ProjectSettings projectSettings)
{
AnsiConsole.MarkupLine("[blue]Checking for stale files[/]");
// Grab the metadata.
List<string> staleMetadataFiles = new();
var metadata = SiteFile.getMetadata(projectSettings);
// Read the metadata.
foreach (var entry in metadata)
{
var file = entry.Key;
Log.Information("Checking if {f} has been deleted", file);
if (!File.Exists(file))
{
Log.Information("{f} HAS been deleted", file);
// what should that files new name be.
var newFileName = Conversions.GetNewName(new FileInfo(file),projectSettings);
try{
// Delete that file and mark the metadata for removal.
File.Delete(newFileName);
AnsiConsole.MarkupLineInterpolated($"[red]Deleted [blue]{newFileName}[/][/]");
Log.Information("Deleted {f}",newFileName);
staleMetadataFiles.Add(file);
}
catch (Exception e)
{
Log.Error(e,"Could not delete {f}",newFileName);
}
}
SiteFile.invalidateMetadata(staleMetadataFiles,projectSettings);
}
}
static ProjectSettings GetProjectSettings(DirectoryInfo? ProjectDirectory)
{
// TODO: implement proper error handling where file access is performed.
if (ProjectDirectory is null)
{
// use the current directory if no project directory is passed.
ProjectDirectory = new DirectoryInfo(".");
}
Log.Information("{projectdir} => fullname {pdfn}",ProjectDirectory, ProjectDirectory.FullName);
FileInfo projectFile = new (Path.Combine(ProjectDirectory.FullName,"cssitegen.json"));
if (!projectFile.Exists)
{
Log.Fatal("Cannot locate project file {pf} in {dir}",projectFile,ProjectDirectory);
Environment.Exit(1);
}
Log.Information("Located Project File {pf}",projectFile.FullName);
ProjectSettings? projectSettings = JsonSerializer
.Deserialize<ProjectSettings>(
projectFile
.OpenText()
.ReadToEnd(),
SourceGenerationContext.Default.ProjectSettings
);
if (projectSettings is null)
{
Log.Fatal("Cannot deserialize projectFile");
Environment.Exit(1);
}
projectSettings.setProjectRoot(ProjectDirectory);
return projectSettings;
}
}
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(ProjectSettings))]
internal partial class SourceGenerationContext : JsonSerializerContext
{
}