Compare commits

..

2 Commits

12 changed files with 190 additions and 154 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@
[Ll]ogs/ [Ll]ogs/
[Pp]ublish/ [Pp]ublish/
data/ data/
config.dtsod
# IDE files # IDE files
.vs/ .vs/

View File

@ -23,7 +23,7 @@ namespace ParadoxSaveParser.Lib;
/// ///
/// Console.Write($"| {cur.Value} |"); /// Console.Write($"| {cur.Value} |");
/// ///
/// for (var next = cur.Next; next != null; next = next.Next) /// for (var next = cur.Next; next is not null; next = next.Next)
/// Console.Write($" {next.Value}"); /// Console.Write($" {next.Value}");
/// Console.WriteLine(); /// Console.WriteLine();
/// } /// }
@ -64,8 +64,8 @@ public class BufferedEnumerator<T> : IEnumerator<LinkedListNode<T>>
return false; return false;
_currentNodeIndex++; _currentNodeIndex++;
_currentNode = _currentNode == null ? _llist.First : _currentNode.Next; _currentNode = _currentNode is null ? _llist.First : _currentNode.Next;
return _currentNode != null; return _currentNode is not null;
} }
public void Reset() public void Reset()

View File

@ -1,7 +1,7 @@
global using System; global using System;
global using System.Collections.Generic;
global using System.IO; global using System.IO;
global using System.Text; global using System.Text;
global using System.Collections.Generic;
namespace ParadoxSaveParser.Lib; namespace ParadoxSaveParser.Lib;
@ -217,7 +217,7 @@ public class Parser
if(!_tokens.MoveNext()) if(!_tokens.MoveNext())
throw new Exception("Unexpected end of file"); throw new Exception("Unexpected end of file");
object? value = ParseValue(); object? value = ParseValue();
if (value == null) if (value is null)
break; break;
list.Add(value); list.Add(value);
} }
@ -277,7 +277,7 @@ public class Parser
throw new UnexpectedTokenException(tok); throw new UnexpectedTokenException(tok);
object? value = ParseValue(); object? value = ParseValue();
if (value == null) if (value is null)
throw new UnexpectedTokenException(_tokens.Current.Value); throw new UnexpectedTokenException(_tokens.Current.Value);
if(!dict.TryGetValue(key, out List<object>? list)) if(!dict.TryGetValue(key, out List<object>? list))

View File

@ -0,0 +1,34 @@
using DTLib.Dtsod;
namespace ParadoxSaveParser.WebAPI;
public class Config
{
public const int ActualVersion = 1;
public int Version = ActualVersion;
public string BaseUrl = "http://127.0.0.1:5226/";
public static Config FromDtsod(DtsodV23 d)
{
var cfg = new Config
{
Version = d["version"],
BaseUrl = d["baseUrl"]
};
if (cfg.Version < ActualVersion)
throw new Exception($"config is obsolete (config v{cfg.Version} < program v{ActualVersion})");
if(cfg.Version > ActualVersion)
throw new Exception($"program is obsolete (config v{cfg.Version} > program v{ActualVersion})");
return cfg;
}
public DtsodV23 ToDtsod() =>
new()
{
{ "version", Version },
{ "baseUrl", BaseUrl },
};
public override string ToString() => ToDtsod().ToString();
}

View File

@ -1,6 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
@ -11,7 +13,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DTLib.Demystifier" Version="1.1.0" /> <PackageReference Include="DTLib.Web" Version="1.2.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -28,5 +30,6 @@
<ItemGroup> <ItemGroup>
<None Remove="data\**" /> <None Remove="data\**" />
<None Remove="Properties\launchSettings.json" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,12 +1,10 @@
using System.IO; namespace ParadoxSaveParser.WebAPI;
namespace ParadoxSaveParser.WebAPI;
public static class PathHelper public static class PathHelper
{ {
public const string DATA_DIR = "data"; public static readonly IOPath DATA_DIR = "data";
public static string SAVES_DIR = Path.Join(DATA_DIR, "saves"); public static readonly IOPath SAVES_DIR = Path.Concat(DATA_DIR, "saves");
public static string GetMetaFilePath(string save_id) => Path.Join(SAVES_DIR, save_id + ".meta.json"); public static IOPath GetMetaFilePath(string save_id) => Path.Concat(SAVES_DIR, save_id + ".meta.json");
public static string GetSaveFilePath(string save_id) => Path.Join(SAVES_DIR, save_id + ".eu4"); public static IOPath GetSaveFilePath(string save_id) => Path.Concat(SAVES_DIR, save_id + ".eu4");
public static string GetParsedSaveFilePath(string save_id) => Path.Join(SAVES_DIR, save_id + ".parsed.json"); public static IOPath GetParsedSaveFilePath(string save_id) => Path.Concat(SAVES_DIR, save_id + ".parsed.json");
} }

View File

@ -1,25 +1,36 @@
global using System; global using System;
global using System.IO;
global using System.Collections.Generic; global using System.Collections.Generic;
global using System.Text; global using System.Text;
global using System.Text.Json; global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks; global using System.Threading.Tasks;
global using DTLib.Demystifier; global using DTLib.Demystifier;
global using DTLib.Filesystem;
global using DTLib.Logging;
global using ParadoxSaveParser.Lib; global using ParadoxSaveParser.Lib;
global using Directory = DTLib.Filesystem.Directory;
global using File = DTLib.Filesystem.File;
global using Path = DTLib.Filesystem.Path;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Net;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Builder; using DTLib.Dtsod;
using Microsoft.AspNetCore.Http; using DTLib.Extensions;
using Microsoft.Extensions.Logging; using DTLib.Web;
using DTLib.Web.Routes;
namespace ParadoxSaveParser.WebAPI; namespace ParadoxSaveParser.WebAPI;
public class Program public class Program
{ {
private static readonly IOPath _configPath = "./config.dtsod";
private static Config _config = new();
private static readonly ILogger _loggerRoot = new ConsoleLogger();
private static readonly CancellationTokenSource _mainCancel = new();
private static ConcurrentDictionary<string, SaveFileMetadata> _saveMetadataStorage = new(); private static ConcurrentDictionary<string, SaveFileMetadata> _saveMetadataStorage = new();
private static WebApplication _app = null!;
private static JsonSerializerOptions _saveSerializerOptions = new() private static JsonSerializerOptions _saveSerializerOptions = new()
{ {
@ -28,134 +39,176 @@ public class Program
MaxDepth = 1024, MaxDepth = 1024,
}; };
public static void Main(string[] args) public static void Main(string[] args)
{ {
var builder = WebApplication.CreateBuilder(args); Console.InputEncoding = Encoding.UTF8;
_app = builder.Build(); Console.OutputEncoding = Encoding.UTF8;
ContextLogger logger = new ContextLogger(nameof(Main), _loggerRoot);
Directory.CreateDirectory(PathHelper.DATA_DIR); Console.CancelKeyPress += (_, _) =>
Directory.CreateDirectory(PathHelper.SAVES_DIR);
foreach (var metaFilePath in Directory.GetFiles(PathHelper.SAVES_DIR, "*.meta.json", SearchOption.TopDirectoryOnly))
{ {
using var metaFile = File.Open(metaFilePath, FileMode.Open, FileAccess.Read); logger.LogInfo("Ctrl+C Pressed");
var meta = JsonSerializer.Deserialize<SaveFileMetadata>(metaFile) ?? throw new NullReferenceException(metaFilePath); _mainCancel.Cancel();
};
try
{
// config
if (!File.Exists(_configPath))
{
logger.LogWarn("config file not found.");
File.WriteAllText(_configPath, _config.ToString());
logger.LogWarn($"created default at {_configPath}.");
}
else _config = Config.FromDtsod(new DtsodV23(File.ReadAllText(_configPath)));
PrepareLocalFiles();
// http server
var router = new SimpleRouter(_loggerRoot);
router.DefaultRoute = new ServeFilesRouteHandler("public");
router.MapRoute("/getSaveStatus", HttpMethod.GET, GetSaveStatusHandler);
router.MapRoute("/uploadSave/eu4", HttpMethod.POST, UploadSaveHandler);
router.MapRoute("/parseSave/eu4", HttpMethod.POST, ParseSaveEU4Handler);
var app = new WebApp(_config.BaseUrl, _loggerRoot, router, _mainCancel.Token);
app.Run().GetAwaiter().GetResult();
}
catch (OperationCanceledException ex)
{
logger.LogWarn($"catched OperationCanceledException from {ex.Source}");
}
catch (Exception ex)
{
logger.LogError(ex.ToStringDemystified());
}
}
public static void PrepareLocalFiles()
{
Directory.Create(PathHelper.DATA_DIR);
Directory.Create(PathHelper.SAVES_DIR);
foreach (var metaFilePath in System.IO.Directory.GetFiles(
PathHelper.SAVES_DIR.Str, "*.meta.json",
SearchOption.TopDirectoryOnly))
{
using var metaFile = File.OpenRead(metaFilePath);
var meta = JsonSerializer.Deserialize<SaveFileMetadata>(metaFile) ??
throw new NullReferenceException(metaFilePath);
if (meta.status != SaveFileProcessingStatus.Done) if (meta.status != SaveFileProcessingStatus.Done)
{ {
_app.Logger.Log(LogLevel.Warning, "metadata file '{metaFilePath}' status has invalid status {status}", metaFilePath, meta.status); _loggerRoot.LogWarn(nameof(PrepareLocalFiles), $"metadata file '{metaFilePath}' status has invalid status {meta.status}");
} }
if(!_saveMetadataStorage.TryAdd(meta.id, meta)) if(!_saveMetadataStorage.TryAdd(meta.id, meta))
throw new Exception("Guid collision!"); throw new Exception("Guid collision!");
} }
_app.UseHttpsRedirection();
_app.MapGet("/getSaveStatus", GetSaveStatusHandler);
_app.MapPost("/uploadSave/eu4", UploadSaveHandler);
_app.MapPost("/parseSave/eu4", ParseSaveEU4Handler);
_app.Run();
} }
private static async Task UploadSaveHandler(HttpContext httpContext) public record ErrorMessage(string errorMessage);
private static async Task<HttpStatusCode>ReturnResponse(HttpListenerContext ctx, HttpStatusCode statusCode, object response)
{ {
var remoteFile = httpContext.Request.Form.Files.FirstOrDefault(); await JsonSerializer.SerializeAsync(ctx.Response.OutputStream, response, response.GetType(),
if (remoteFile is null || !remoteFile.FileName.EndsWith(".eu4")) JsonSerializerOptions.Default, _mainCancel.Token);
{ ctx.Response.StatusCode = (int)statusCode;
throw new BadHttpRequestException($"Invalid file format: {remoteFile?.FileName}", return statusCode;
StatusCodes.Status400BadRequest);
} }
private static async Task<HttpStatusCode>ReturnResponse(HttpListenerContext ctx, HttpStatusCode statusCode, string response)
{
await ctx.Response.OutputStream.WriteAsync(response.ToBytes(), _mainCancel.Token);
ctx.Response.StatusCode = (int)statusCode;
return statusCode;
}
private static async Task<HttpStatusCode>UploadSaveHandler(HttpListenerContext ctx)
{
string? contentType = ctx.Request.Headers.GetValues("Content-Type")?.FirstOrDefault();
if (contentType != "application/octet-stream")
return await ReturnResponse(ctx, HttpStatusCode.BadRequest,
new ErrorMessage($"Invalid request Content-Type: '{contentType}'"));
string saveId = Guid.NewGuid().ToString(); string saveId = Guid.NewGuid().ToString();
string metaFilePath = PathHelper.GetMetaFilePath(saveId); IOPath metaFilePath = PathHelper.GetMetaFilePath(saveId);
if (File.Exists(metaFilePath)) if (File.Exists(metaFilePath))
{ return await ReturnResponse(ctx, HttpStatusCode.InternalServerError,
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; new ErrorMessage($"Guid collision! file' {metaFilePath}' already exists."));
throw new BadHttpRequestException($"Guid collision! file {metaFilePath} already exists.", StatusCodes.Status500InternalServerError);
}
var meta = new SaveFileMetadata { id = saveId, game = Game.EU4, status = SaveFileProcessingStatus.Initialized, }; var meta = new SaveFileMetadata { id = saveId, game = Game.EU4, status = SaveFileProcessingStatus.Initialized, };
if (!_saveMetadataStorage.TryAdd(saveId, meta)) if (!_saveMetadataStorage.TryAdd(saveId, meta))
{ return await ReturnResponse(ctx, HttpStatusCode.InternalServerError,
throw new BadHttpRequestException($"Guid collision! Can't create metadata with id {saveId}", StatusCodes.Status500InternalServerError); new ErrorMessage($"Guid collision! Can't create metadata with id {saveId}"));
}
meta.status = SaveFileProcessingStatus.Uploading; meta.status = SaveFileProcessingStatus.Uploading;
string saveFilePath = PathHelper.GetSaveFilePath(meta.id); IOPath saveFilePath = PathHelper.GetSaveFilePath(meta.id);
await using var saveFile = File.Open(saveFilePath, FileMode.CreateNew, FileAccess.ReadWrite); await using var saveFile = File.OpenWrite(saveFilePath);
await using var remoteStream = remoteFile.OpenReadStream(); await using var remoteStream = ctx.Request.InputStream;
await remoteStream.CopyToAsync(saveFile); await remoteStream.CopyToAsync(saveFile, _mainCancel.Token);
meta.status = SaveFileProcessingStatus.Uploaded; meta.status = SaveFileProcessingStatus.Uploaded;
await httpContext.Response.WriteAsJsonAsync(meta); return await ReturnResponse(ctx, HttpStatusCode.OK, saveId);
} }
private static SaveFileMetadata GetMetaFromRequestId(HttpContext httpContext, string requestParamName) private static (SaveFileMetadata? meta, ErrorMessage? errorMesage) GetMetaFromRequestId(HttpListenerContext ctx, string requestParamName)
{ {
httpContext.Request.Query.TryGetValue(requestParamName, out var ids); var ids = ctx.Request.QueryString.GetValues(requestParamName);
string? id = ids.FirstOrDefault(); string? id = ids?.FirstOrDefault();
if (string.IsNullOrEmpty(id)) if (string.IsNullOrEmpty(id))
{ return (null, new ErrorMessage($"No request parameter '{requestParamName}' provided"));
throw new BadHttpRequestException($"No request parameter '{requestParamName}' provided",
StatusCodes.Status400BadRequest);
}
if (!_saveMetadataStorage.TryGetValue(id, out var meta)) if (!_saveMetadataStorage.TryGetValue(id, out var meta))
{ return (null,new ErrorMessage($"Save with {id} not found"));
throw new BadHttpRequestException($"Save with {id} not found",
StatusCodes.Status400BadRequest); return (meta, null);
} }
return meta; private static async Task<HttpStatusCode>GetSaveStatusHandler(HttpListenerContext ctx)
{
var (meta, errorMessage) = GetMetaFromRequestId(ctx, "id");
if(errorMessage is not null)
return await ReturnResponse(ctx, HttpStatusCode.InternalServerError, errorMessage);
return await ReturnResponse(ctx, HttpStatusCode.OK, meta!);
} }
private static async Task GetSaveStatusHandler(HttpContext httpContext) private static async Task<HttpStatusCode>ParseSaveEU4Handler(HttpListenerContext ctx)
{ {
var meta = GetMetaFromRequestId(httpContext, "id"); var (meta, errorMessage) = GetMetaFromRequestId(ctx, "id");
await httpContext.Response.WriteAsJsonAsync(meta); if(errorMessage is not null)
} return await ReturnResponse(ctx, HttpStatusCode.InternalServerError, errorMessage);
private static async Task ParseSaveEU4Handler(HttpContext httpContext)
{
var meta = GetMetaFromRequestId(httpContext, "id");
if (meta.status == SaveFileProcessingStatus.Error)
{
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(meta);
return;
}
try try
{ {
if (meta.status != SaveFileProcessingStatus.Uploaded) using var zipArchive = ZipFile.Open(PathHelper.GetSaveFilePath(meta!.id).Str, ZipArchiveMode.Read);
throw new Exception($"Invalid save processing status: {meta.status}");
using var zipArchive = ZipFile.Open(PathHelper.GetSaveFilePath(meta.id), ZipArchiveMode.Read);
var zipEntry = zipArchive.Entries.FirstOrDefault(e => e.Name == "gamestate"); var zipEntry = zipArchive.Entries.FirstOrDefault(e => e.Name == "gamestate");
if (zipEntry is null) if (zipEntry is null)
throw new Exception("Invalid save format: no gamestate file found"); return await ReturnResponse(ctx, HttpStatusCode.BadRequest,
new ErrorMessage("Invalid save format: no 'gamestate' file found"));
string extractedGamestatePath = PathHelper.GetSaveFilePath(meta.id) + ".gamestate"; string extractedGamestatePath = PathHelper.GetSaveFilePath(meta.id) + ".gamestate";
zipEntry.ExtractToFile(extractedGamestatePath); zipEntry.ExtractToFile(extractedGamestatePath);
var gamestateStream = File.Open(extractedGamestatePath, FileMode.Open, FileAccess.Read); var gamestateStream = File.OpenRead(extractedGamestatePath);
meta.status = SaveFileProcessingStatus.Parsing; meta.status = SaveFileProcessingStatus.Parsing;
var parser = new Parser(gamestateStream); var parser = new Parser(gamestateStream);
var result = parser.Parse(); var result = parser.Parse();
meta.status = SaveFileProcessingStatus.SavingResults; meta.status = SaveFileProcessingStatus.SavingResults;
string resultFilePath = PathHelper.GetParsedSaveFilePath(meta.id); IOPath resultFilePath = PathHelper.GetParsedSaveFilePath(meta.id);
await using var resultFile = File.Open(resultFilePath, FileMode.CreateNew, FileAccess.Write); await using var resultFile = File.OpenWrite(resultFilePath);
await JsonSerializer.SerializeAsync(resultFile, result, _saveSerializerOptions); await JsonSerializer.SerializeAsync(resultFile, result, _saveSerializerOptions, _mainCancel.Token);
meta.status = SaveFileProcessingStatus.Done; meta.status = SaveFileProcessingStatus.Done;
meta.SaveToFile(); meta.SaveToFile();
} }
catch (Exception ex) catch (Exception ex)
{ {
meta.status = SaveFileProcessingStatus.Error;
string errorMesage = ex.ToStringDemystified(); string errorMesage = ex.ToStringDemystified();
meta.errorMesage = errorMesage; _loggerRoot.LogWarn(nameof(ParseSaveEU4Handler), errorMesage);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; return await ReturnResponse(ctx, HttpStatusCode.BadRequest,
_app.Logger.Log(LogLevel.Error, "ParseSaveEU4 Error: {errorMesage}", errorMesage); new ErrorMessage(errorMesage));
} }
await httpContext.Response.WriteAsJsonAsync(meta); return await ReturnResponse(ctx, HttpStatusCode.OK, meta);
} }
} }

View File

@ -1,35 +0,0 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:38396",
"sslPort": 44312
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5226",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7032;http://localhost:5226",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -1,12 +1,10 @@
using System.IO; using System.Text.Json.Serialization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ParadoxSaveParser.WebAPI; namespace ParadoxSaveParser.WebAPI;
public enum SaveFileProcessingStatus public enum SaveFileProcessingStatus
{ {
Initialized, Uploading, Uploaded, Parsing, SavingResults, Done, Error Initialized, Uploading, Uploaded, Parsing, SavingResults, Done
} }
public enum Game public enum Game
@ -24,12 +22,11 @@ public class SaveFileMetadata
[JsonConverter(typeof(JsonStringEnumConverter))] [JsonConverter(typeof(JsonStringEnumConverter))]
public required SaveFileProcessingStatus status { get; set; } public required SaveFileProcessingStatus status { get; set; }
public string? errorMesage { get; set; }
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
public void SaveToFile() public void SaveToFile()
{ {
using var metaFile = File.Open(PathHelper.GetMetaFilePath(id), FileMode.CreateNew, FileAccess.Write); using var metaFile = File.OpenWrite(PathHelper.GetMetaFilePath(id));
JsonSerializer.Serialize(metaFile, this, _jsonOptions); JsonSerializer.Serialize(metaFile, this, _jsonOptions);
} }
} }

View File

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -1,10 +1,12 @@
DTLib.Web:
Add elapsed time to response status log message: `responded 200 (OK) in 0.03 s`
Main: Main:
Move from asp.net to my own http server
Add temporary files deletion Add temporary files deletion
ParseSaveHandler: ParseSaveHandler:
Separate status and error message from metadata
Make this method run as background task instead of POST query Make this method run as background task instead of POST query
Add debug log
Parser: Parser:
Add query support to parse only needed information Add query support to parse only needed information