Compare commits

..

No commits in common. "5d88ad49c0b1c5cdad87fdbde8e6c1da4a01249e" and "52d5320899d303627bc63a3cbe3bd3b28b15f723" have entirely different histories.

12 changed files with 153 additions and 189 deletions

1
.gitignore vendored
View File

@ -10,7 +10,6 @@
[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 is not null; next = next.Next) /// for (var next = cur.Next; next != 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 is null ? _llist.First : _currentNode.Next; _currentNode = _currentNode == null ? _llist.First : _currentNode.Next;
return _currentNode is not null; return _currentNode != 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 is null) if (value == 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 is null) if (value == 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

@ -1,34 +0,0 @@
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,8 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk.Web">
<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>
@ -13,7 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DTLib.Web" Version="1.2.1" /> <PackageReference Include="DTLib.Demystifier" Version="1.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -30,6 +28,5 @@
<ItemGroup> <ItemGroup>
<None Remove="data\**" /> <None Remove="data\**" />
<None Remove="Properties\launchSettings.json" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

@ -1,36 +1,25 @@
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 DTLib.Dtsod; using Microsoft.AspNetCore.Builder;
using DTLib.Extensions; using Microsoft.AspNetCore.Http;
using DTLib.Web; using Microsoft.Extensions.Logging;
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()
{ {
@ -39,176 +28,134 @@ public class Program
MaxDepth = 1024, MaxDepth = 1024,
}; };
public static void Main(string[] args) public static void Main(string[] args)
{ {
Console.InputEncoding = Encoding.UTF8; var builder = WebApplication.CreateBuilder(args);
Console.OutputEncoding = Encoding.UTF8; _app = builder.Build();
ContextLogger logger = new ContextLogger(nameof(Main), _loggerRoot);
Console.CancelKeyPress += (_, _) =>
{
logger.LogInfo("Ctrl+C Pressed");
_mainCancel.Cancel();
};
try Directory.CreateDirectory(PathHelper.DATA_DIR);
Directory.CreateDirectory(PathHelper.SAVES_DIR);
foreach (var metaFilePath in Directory.GetFiles(PathHelper.SAVES_DIR, "*.meta.json", SearchOption.TopDirectoryOnly))
{ {
// config using var metaFile = File.Open(metaFilePath, FileMode.Open, FileAccess.Read);
if (!File.Exists(_configPath)) var meta = JsonSerializer.Deserialize<SaveFileMetadata>(metaFile) ?? throw new NullReferenceException(metaFilePath);
{
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)
{ {
_loggerRoot.LogWarn(nameof(PrepareLocalFiles), $"metadata file '{metaFilePath}' status has invalid status {meta.status}"); _app.Logger.Log(LogLevel.Warning, "metadata file '{metaFilePath}' status has invalid status {status}", metaFilePath, 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();
} }
public record ErrorMessage(string errorMessage); private static async Task UploadSaveHandler(HttpContext httpContext)
private static async Task<HttpStatusCode>ReturnResponse(HttpListenerContext ctx, HttpStatusCode statusCode, object response)
{ {
await JsonSerializer.SerializeAsync(ctx.Response.OutputStream, response, response.GetType(), var remoteFile = httpContext.Request.Form.Files.FirstOrDefault();
JsonSerializerOptions.Default, _mainCancel.Token); if (remoteFile is null || !remoteFile.FileName.EndsWith(".eu4"))
ctx.Response.StatusCode = (int)statusCode; {
return statusCode; throw new BadHttpRequestException($"Invalid file format: {remoteFile?.FileName}",
} 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();
IOPath metaFilePath = PathHelper.GetMetaFilePath(saveId); string metaFilePath = PathHelper.GetMetaFilePath(saveId);
if (File.Exists(metaFilePath)) if (File.Exists(metaFilePath))
return await ReturnResponse(ctx, HttpStatusCode.InternalServerError, {
new ErrorMessage($"Guid collision! file' {metaFilePath}' already exists.")); httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
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, {
new ErrorMessage($"Guid collision! Can't create metadata with id {saveId}")); throw new BadHttpRequestException($"Guid collision! Can't create metadata with id {saveId}", StatusCodes.Status500InternalServerError);
}
meta.status = SaveFileProcessingStatus.Uploading; meta.status = SaveFileProcessingStatus.Uploading;
IOPath saveFilePath = PathHelper.GetSaveFilePath(meta.id); string saveFilePath = PathHelper.GetSaveFilePath(meta.id);
await using var saveFile = File.OpenWrite(saveFilePath); await using var saveFile = File.Open(saveFilePath, FileMode.CreateNew, FileAccess.ReadWrite);
await using var remoteStream = ctx.Request.InputStream; await using var remoteStream = remoteFile.OpenReadStream();
await remoteStream.CopyToAsync(saveFile, _mainCancel.Token); await remoteStream.CopyToAsync(saveFile);
meta.status = SaveFileProcessingStatus.Uploaded; meta.status = SaveFileProcessingStatus.Uploaded;
return await ReturnResponse(ctx, HttpStatusCode.OK, saveId); await httpContext.Response.WriteAsJsonAsync(meta);
} }
private static (SaveFileMetadata? meta, ErrorMessage? errorMesage) GetMetaFromRequestId(HttpListenerContext ctx, string requestParamName) private static SaveFileMetadata GetMetaFromRequestId(HttpContext httpContext, string requestParamName)
{ {
var ids = ctx.Request.QueryString.GetValues(requestParamName); httpContext.Request.Query.TryGetValue(requestParamName, out var ids);
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) private static async Task GetSaveStatusHandler(HttpContext httpContext)
{ {
var (meta, errorMessage) = GetMetaFromRequestId(ctx, "id"); var meta = GetMetaFromRequestId(httpContext, "id");
if(errorMessage is not null) await httpContext.Response.WriteAsJsonAsync(meta);
return await ReturnResponse(ctx, HttpStatusCode.InternalServerError, errorMessage);
return await ReturnResponse(ctx, HttpStatusCode.OK, meta!);
} }
private static async Task<HttpStatusCode>ParseSaveEU4Handler(HttpListenerContext ctx) private static async Task ParseSaveEU4Handler(HttpContext httpContext)
{ {
var (meta, errorMessage) = GetMetaFromRequestId(ctx, "id"); var meta = GetMetaFromRequestId(httpContext, "id");
if(errorMessage is not null) if (meta.status == SaveFileProcessingStatus.Error)
return await ReturnResponse(ctx, HttpStatusCode.InternalServerError, errorMessage); {
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(meta);
return;
}
try try
{ {
using var zipArchive = ZipFile.Open(PathHelper.GetSaveFilePath(meta!.id).Str, ZipArchiveMode.Read); if (meta.status != SaveFileProcessingStatus.Uploaded)
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)
return await ReturnResponse(ctx, HttpStatusCode.BadRequest, throw new Exception("Invalid save format: no gamestate file found");
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.OpenRead(extractedGamestatePath); var gamestateStream = File.Open(extractedGamestatePath, FileMode.Open, FileAccess.Read);
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;
IOPath resultFilePath = PathHelper.GetParsedSaveFilePath(meta.id); string resultFilePath = PathHelper.GetParsedSaveFilePath(meta.id);
await using var resultFile = File.OpenWrite(resultFilePath); await using var resultFile = File.Open(resultFilePath, FileMode.CreateNew, FileAccess.Write);
await JsonSerializer.SerializeAsync(resultFile, result, _saveSerializerOptions, _mainCancel.Token); await JsonSerializer.SerializeAsync(resultFile, result, _saveSerializerOptions);
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();
_loggerRoot.LogWarn(nameof(ParseSaveEU4Handler), errorMesage); meta.errorMesage = errorMesage;
return await ReturnResponse(ctx, HttpStatusCode.BadRequest, httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
new ErrorMessage(errorMesage)); _app.Logger.Log(LogLevel.Error, "ParseSaveEU4 Error: {errorMesage}", errorMesage);
} }
return await ReturnResponse(ctx, HttpStatusCode.OK, meta); await httpContext.Response.WriteAsJsonAsync(meta);
} }
} }

View File

@ -0,0 +1,35 @@
{
"$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,10 +1,12 @@
using System.Text.Json.Serialization; using System.IO;
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 Initialized, Uploading, Uploaded, Parsing, SavingResults, Done, Error
} }
public enum Game public enum Game
@ -22,11 +24,12 @@ 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.OpenWrite(PathHelper.GetMetaFilePath(id)); using var metaFile = File.Open(PathHelper.GetMetaFilePath(id), FileMode.CreateNew, FileAccess.Write);
JsonSerializer.Serialize(metaFile, this, _jsonOptions); JsonSerializer.Serialize(metaFile, this, _jsonOptions);
} }
} }

View File

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

View File

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

View File

@ -1,12 +1,10 @@
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