feat: load legacy Cuts and Res runtime assets
This commit is contained in:
@@ -44,10 +44,7 @@ public sealed class DatabaseOptionsLoader
|
||||
{
|
||||
var path = jsonPath ?? DefaultConfigurationPath;
|
||||
var options = LoadJson(path);
|
||||
ApplyEnvironmentOverrides(options);
|
||||
options.Normalize();
|
||||
options.ValidateRuntimeSettings();
|
||||
return options;
|
||||
return ApplyEnvironmentOverridesAndValidate(options);
|
||||
}
|
||||
|
||||
public async Task<DatabaseOptions> LoadAsync(
|
||||
@@ -56,6 +53,12 @@ public sealed class DatabaseOptionsLoader
|
||||
{
|
||||
var path = jsonPath ?? DefaultConfigurationPath;
|
||||
var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return ApplyEnvironmentOverridesAndValidate(options);
|
||||
}
|
||||
|
||||
internal DatabaseOptions ApplyEnvironmentOverridesAndValidate(DatabaseOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ApplyEnvironmentOverrides(options);
|
||||
options.Normalize();
|
||||
options.ValidateRuntimeSettings();
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the legacy executable-relative Res\MmoneyCoder.ini contract without
|
||||
/// exposing any of its values through diagnostics or exception messages.
|
||||
/// </summary>
|
||||
public sealed class LegacyIniDatabaseOptionsLoader
|
||||
{
|
||||
public const int MaximumFileBytes = 64 * 1024;
|
||||
|
||||
private static readonly UTF8Encoding StrictUtf8 =
|
||||
new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
private static readonly Encoding StrictCp949 = CreateCp949Encoding();
|
||||
|
||||
private readonly DatabaseOptionsLoader _optionsLoader;
|
||||
|
||||
public LegacyIniDatabaseOptionsLoader(
|
||||
Func<string, string?>? readEnvironmentVariable = null)
|
||||
{
|
||||
_optionsLoader = new DatabaseOptionsLoader(readEnvironmentVariable);
|
||||
}
|
||||
|
||||
public DatabaseOptions Load(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var attributes = File.GetAttributes(fullPath);
|
||||
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
|
||||
using var stream = new FileStream(
|
||||
fullPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
FileOptions.SequentialScan);
|
||||
if (stream.Length is <= 0 or > MaximumFileBytes)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
|
||||
bytes = new byte[checked((int)stream.Length)];
|
||||
stream.ReadExactly(bytes);
|
||||
}
|
||||
catch (Exception exception) when (IsExpectedFileFailure(exception))
|
||||
{
|
||||
throw InvalidConfiguration();
|
||||
}
|
||||
|
||||
var text = Decode(bytes);
|
||||
var values = Parse(text);
|
||||
var options = CreateOptions(values);
|
||||
options = _optionsLoader.ApplyEnvironmentOverridesAndValidate(options);
|
||||
if (!options.IsConfigured(DataSourceKind.Oracle) ||
|
||||
!options.IsConfigured(DataSourceKind.MariaDb))
|
||||
{
|
||||
throw InvalidConfiguration();
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static string Decode(byte[] bytes)
|
||||
{
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = StrictUtf8.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
try
|
||||
{
|
||||
text = StrictCp949.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
throw InvalidConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
return text.Length > 0 && text[0] == '\uFEFF'
|
||||
? text[1..]
|
||||
: text;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Parse(
|
||||
string text)
|
||||
{
|
||||
var sections = new Dictionary<string, Dictionary<string, string>>(
|
||||
StringComparer.Ordinal);
|
||||
Dictionary<string, string>? current = null;
|
||||
|
||||
using var reader = new StringReader(text);
|
||||
while (reader.ReadLine() is { } sourceLine)
|
||||
{
|
||||
var line = sourceLine.Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
if (line.Length < 3 || line[^1] != ']')
|
||||
{
|
||||
throw InvalidConfiguration();
|
||||
}
|
||||
|
||||
var sectionName = line[1..^1];
|
||||
if (!sections.TryGetValue(sectionName, out current))
|
||||
{
|
||||
current = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
sections.Add(sectionName, current);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
throw InvalidConfiguration();
|
||||
}
|
||||
|
||||
var separatorIndex = line.IndexOf('=');
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line[..separatorIndex].Trim();
|
||||
if (key is not ("ConnectionName" or "ID" or "Pass"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = line[(separatorIndex + 1)..].Trim();
|
||||
if (value.Length == 0 || value.Any(char.IsControl))
|
||||
{
|
||||
throw InvalidConfiguration();
|
||||
}
|
||||
|
||||
// The original reader keeps the last occurrence in file order.
|
||||
current[key] = value;
|
||||
}
|
||||
|
||||
return sections.ToDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => (IReadOnlyDictionary<string, string>)pair.Value,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static DatabaseOptions CreateOptions(
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> sections)
|
||||
{
|
||||
if (!sections.TryGetValue("Oracle", out var oracle) ||
|
||||
!sections.TryGetValue("Maria", out var maria) ||
|
||||
!TryGetRequired(oracle, "ConnectionName", out var oracleConnection) ||
|
||||
!TryGetRequired(oracle, "ID", out var oracleId) ||
|
||||
!TryGetRequired(oracle, "Pass", out var oraclePassword) ||
|
||||
!TryGetRequired(maria, "ConnectionName", out var mariaConnection) ||
|
||||
!TryGetRequired(maria, "ID", out var mariaId) ||
|
||||
!TryGetRequired(maria, "Pass", out var mariaPassword) ||
|
||||
!TryParseConnectionName(
|
||||
oracleConnection,
|
||||
out var oracleHost,
|
||||
out var oraclePort,
|
||||
out var oracleSid) ||
|
||||
!TryParseConnectionName(
|
||||
mariaConnection,
|
||||
out var mariaHost,
|
||||
out var mariaPort,
|
||||
out var mariaDatabase))
|
||||
{
|
||||
throw InvalidConfiguration();
|
||||
}
|
||||
|
||||
return new DatabaseOptions
|
||||
{
|
||||
Oracle = new OracleDatabaseOptions
|
||||
{
|
||||
Host = oracleHost,
|
||||
Port = oraclePort,
|
||||
Sid = oracleSid,
|
||||
ServiceName = null,
|
||||
UserName = oracleId,
|
||||
Password = oraclePassword
|
||||
},
|
||||
MariaDb = new MariaDbDatabaseOptions
|
||||
{
|
||||
Host = mariaHost,
|
||||
Port = mariaPort,
|
||||
Database = mariaDatabase,
|
||||
UserName = mariaId,
|
||||
Password = mariaPassword,
|
||||
// The legacy MySQL connection string did not request TLS.
|
||||
TlsMode = MariaDbTlsMode.Disabled
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetRequired(
|
||||
IReadOnlyDictionary<string, string> section,
|
||||
string key,
|
||||
out string value)
|
||||
{
|
||||
if (section.TryGetValue(key, out var candidate) &&
|
||||
!string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
value = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseConnectionName(
|
||||
string value,
|
||||
out string host,
|
||||
out int port,
|
||||
out string database)
|
||||
{
|
||||
host = string.Empty;
|
||||
port = 0;
|
||||
database = string.Empty;
|
||||
|
||||
var colonIndex = value.IndexOf(':');
|
||||
var slashIndex = value.IndexOf('/', colonIndex + 1);
|
||||
if (colonIndex <= 0 || slashIndex <= colonIndex + 1 ||
|
||||
slashIndex == value.Length - 1 ||
|
||||
value.IndexOf(':', colonIndex + 1) >= 0 ||
|
||||
value.IndexOf('/', slashIndex + 1) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsedHost = value[..colonIndex].Trim();
|
||||
var portText = value[(colonIndex + 1)..slashIndex].Trim();
|
||||
var parsedDatabase = value[(slashIndex + 1)..].Trim();
|
||||
if (parsedHost.Length == 0 || parsedDatabase.Length == 0 ||
|
||||
parsedHost.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)) ||
|
||||
parsedDatabase.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)) ||
|
||||
!int.TryParse(portText, out var parsedPort) ||
|
||||
parsedPort is < 1 or > 65_535)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
host = parsedHost;
|
||||
port = parsedPort;
|
||||
database = parsedDatabase;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Encoding CreateCp949Encoding()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
return Encoding.GetEncoding(
|
||||
949,
|
||||
EncoderFallback.ExceptionFallback,
|
||||
DecoderFallback.ExceptionFallback);
|
||||
}
|
||||
|
||||
private static bool IsExpectedFileFailure(Exception exception) =>
|
||||
exception is IOException or UnauthorizedAccessException or SecurityException or
|
||||
ArgumentException or NotSupportedException;
|
||||
|
||||
private static DatabaseConfigurationException InvalidConfiguration() =>
|
||||
new("실행 폴더의 레거시 데이터베이스 설정을 읽을 수 없습니다.");
|
||||
}
|
||||
@@ -30,6 +30,43 @@ public sealed class DatabaseRuntime
|
||||
return Create(options);
|
||||
}
|
||||
|
||||
public static DatabaseRuntime CreateLegacyExecutableDefault(
|
||||
string baseDirectory,
|
||||
string? fallbackConfigurationPath = null,
|
||||
string? legacyOverridePath = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
|
||||
var legacyIniPath = Path.Combine(
|
||||
Path.GetFullPath(baseDirectory),
|
||||
"Res",
|
||||
"MmoneyCoder.ini");
|
||||
if (File.Exists(legacyIniPath))
|
||||
{
|
||||
return Create(new LegacyIniDatabaseOptionsLoader().Load(legacyIniPath));
|
||||
}
|
||||
|
||||
var localOverride = legacyOverridePath ?? ResolveLegacyOverridePath();
|
||||
if (!string.IsNullOrWhiteSpace(localOverride) && File.Exists(localOverride))
|
||||
{
|
||||
return Create(new LegacyIniDatabaseOptionsLoader().Load(localOverride));
|
||||
}
|
||||
|
||||
return CreateDefault(fallbackConfigurationPath);
|
||||
}
|
||||
|
||||
private static string? ResolveLegacyOverridePath()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.LocalApplicationData);
|
||||
return string.IsNullOrWhiteSpace(localAppData)
|
||||
? null
|
||||
: Path.Combine(
|
||||
localAppData,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"Res",
|
||||
"MmoneyCoder.ini");
|
||||
}
|
||||
|
||||
public static DatabaseRuntime Create(DatabaseOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
@@ -111,6 +111,8 @@ internal static class LegacyComparisonWorkflowCatalog
|
||||
|
||||
public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList;
|
||||
|
||||
internal static IReadOnlyList<LegacyComparisonActionDefinition> Actions => ActionList;
|
||||
|
||||
public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
|
||||
targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
|
||||
|
||||
@@ -195,13 +197,31 @@ internal static class LegacyComparisonWorkflowCatalog
|
||||
LegacyComparisonTarget? first,
|
||||
LegacyComparisonTarget? second)
|
||||
{
|
||||
var views = ActionList.Select(action =>
|
||||
var placements = ActionList
|
||||
.Select(action => new LegacyComparisonActionPlacement(action.ActionId, action.Section))
|
||||
.ToArray();
|
||||
return CreateActionViews(first, second, placements);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
|
||||
LegacyComparisonTarget? first,
|
||||
LegacyComparisonTarget? second,
|
||||
IReadOnlyList<LegacyComparisonActionPlacement> placements)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(placements);
|
||||
var views = placements.Select(placement =>
|
||||
{
|
||||
if (!ActionsById.TryGetValue(placement.ActionId, out var action))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The runtime comparison layout contains an unknown action.");
|
||||
}
|
||||
|
||||
var compatibility = GetCompatibility(action, first, second);
|
||||
return new LegacyComparisonActionView(
|
||||
action.ActionId,
|
||||
action.Label,
|
||||
action.Section,
|
||||
placement.Section,
|
||||
compatibility.IsAvailable,
|
||||
compatibility.Reason);
|
||||
}).ToArray();
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class LegacyComparisonWorkflowController
|
||||
{
|
||||
private readonly ILegacyComparisonDataService _dataService;
|
||||
private readonly ILegacyComparisonPairStore _pairStore;
|
||||
private readonly LegacyComparisonUiLayout _uiLayout;
|
||||
private List<SearchResultState> _domesticResults = [];
|
||||
private List<SearchResultState> _worldResults = [];
|
||||
private List<SavedPairState> _savedPairs = [];
|
||||
@@ -38,10 +39,12 @@ public sealed class LegacyComparisonWorkflowController
|
||||
|
||||
public LegacyComparisonWorkflowController(
|
||||
ILegacyComparisonDataService dataService,
|
||||
ILegacyComparisonPairStore pairStore)
|
||||
ILegacyComparisonPairStore pairStore,
|
||||
LegacyComparisonUiLayout? uiLayout = null)
|
||||
{
|
||||
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
|
||||
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
|
||||
_uiLayout = uiLayout ?? LegacyComparisonUiLayout.Default;
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
|
||||
@@ -606,7 +609,7 @@ public sealed class LegacyComparisonWorkflowController
|
||||
result.Target,
|
||||
result.SelectionId))
|
||||
.ToArray();
|
||||
var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets
|
||||
var fixedTargets = _uiLayout.FixedTargets
|
||||
.Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
|
||||
.ToArray();
|
||||
var savedPairs = _savedPairs.Select((pair, index) =>
|
||||
@@ -645,7 +648,7 @@ public sealed class LegacyComparisonWorkflowController
|
||||
: LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
|
||||
Array.AsReadOnly(savedPairs),
|
||||
_selectedPairId,
|
||||
LegacyComparisonWorkflowCatalog.CreateActionViews(
|
||||
_uiLayout.CreateActionViews(
|
||||
selectedPair?.First,
|
||||
selectedPair?.Second),
|
||||
_statusMessage,
|
||||
|
||||
@@ -259,6 +259,35 @@ public sealed class LegacyFixedActionCatalog
|
||||
return RuntimeAssets[market];
|
||||
}
|
||||
|
||||
internal LegacyFixedActionCatalog WithRuntimeOrder(
|
||||
IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>> actionsByMarket)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionsByMarket);
|
||||
var ordered = new List<LegacyFixedAction>(Actions.Count);
|
||||
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
|
||||
{
|
||||
var source = actionsByMarket.TryGetValue(market, out var runtimeActions)
|
||||
? runtimeActions
|
||||
: _byMarket[market];
|
||||
if (source is null || source.Count != _byMarket[market].Count ||
|
||||
source.Any(action => action is null || action.Market != market) ||
|
||||
!source.Select(action => action.Id).ToHashSet(StringComparer.Ordinal)
|
||||
.SetEquals(_byMarket[market].Select(action => action.Id)))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The runtime fixed-action order for '{MarketText(market)}' is invalid.");
|
||||
}
|
||||
|
||||
ordered.AddRange(source);
|
||||
}
|
||||
|
||||
return new LegacyFixedActionCatalog(
|
||||
ReadOnly(ordered),
|
||||
RuntimeAssets,
|
||||
DocumentSha256,
|
||||
GeneratorSourceSha256);
|
||||
}
|
||||
|
||||
public LegacyMaterializedFixedAction Materialize(
|
||||
string actionId,
|
||||
DateTimeOffset localNow)
|
||||
|
||||
@@ -70,6 +70,7 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
|
||||
private readonly IIndustrySelectionService _selectionService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly LegacyIndustryActionLayout _actionLayout;
|
||||
private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
|
||||
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
|
||||
private IndustryMarket? _market;
|
||||
@@ -84,11 +85,13 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
|
||||
public LegacyIndustrySelectionWorkflow(
|
||||
IIndustrySelectionService selectionService,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
LegacyIndustryActionLayout? actionLayout = null)
|
||||
{
|
||||
_selectionService = selectionService ??
|
||||
throw new ArgumentNullException(nameof(selectionService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_actionLayout = actionLayout ?? LegacyIndustryActionLayout.Default;
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
|
||||
@@ -104,7 +107,7 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
Interlocked.Increment(ref _loadGeneration);
|
||||
_market = market;
|
||||
_industries = NoIndustries;
|
||||
_actions = LegacyIndustryActionCatalog.GetActions(market);
|
||||
_actions = _actionLayout.GetActions(market);
|
||||
_selectedIndex = null;
|
||||
_selectedIndustry = null;
|
||||
_firstIndustry = null;
|
||||
@@ -146,7 +149,7 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
|
||||
_market = market;
|
||||
_industries = normalizedRows;
|
||||
_actions = LegacyIndustryActionCatalog.GetActions(market);
|
||||
_actions = _actionLayout.GetActions(market);
|
||||
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
|
||||
_selectedIndex = _selectedIndustry is null
|
||||
? null
|
||||
|
||||
@@ -0,0 +1,745 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyRuntimeUiCatalogFailure
|
||||
{
|
||||
None,
|
||||
InvalidBaseDirectory,
|
||||
FileNotFound,
|
||||
FileUnavailable,
|
||||
FileTooLarge,
|
||||
InvalidEncoding,
|
||||
InvalidFormat,
|
||||
TooManySections,
|
||||
TooManyRows,
|
||||
UnknownEntry,
|
||||
DuplicateEntry,
|
||||
MissingEntry
|
||||
}
|
||||
|
||||
public sealed record LegacyRuntimeUiCatalogWarning(
|
||||
string FileName,
|
||||
LegacyRuntimeUiCatalogFailure Failure);
|
||||
|
||||
public sealed class LegacyIndustryActionLayout
|
||||
{
|
||||
private readonly IReadOnlyDictionary<IndustryMarket,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition>> _actions;
|
||||
|
||||
private LegacyIndustryActionLayout(
|
||||
IReadOnlyDictionary<IndustryMarket,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition>> actions)
|
||||
{
|
||||
_actions = actions;
|
||||
}
|
||||
|
||||
public static LegacyIndustryActionLayout Default { get; } = Create(
|
||||
LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi),
|
||||
LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq));
|
||||
|
||||
public IReadOnlyList<LegacyIndustryActionDefinition> GetActions(IndustryMarket market) =>
|
||||
_actions.TryGetValue(market, out var actions)
|
||||
? actions
|
||||
: throw new ArgumentOutOfRangeException(
|
||||
nameof(market), market, "Unsupported industry market.");
|
||||
|
||||
internal static LegacyIndustryActionLayout Create(
|
||||
IReadOnlyList<LegacyIndustryActionDefinition> kospi,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition> kosdaq)
|
||||
{
|
||||
Validate(IndustryMarket.Kospi, kospi);
|
||||
Validate(IndustryMarket.Kosdaq, kosdaq);
|
||||
return new LegacyIndustryActionLayout(
|
||||
new ReadOnlyDictionary<IndustryMarket,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition>>(
|
||||
new Dictionary<IndustryMarket,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition>>
|
||||
{
|
||||
[IndustryMarket.Kospi] = Array.AsReadOnly(kospi.ToArray()),
|
||||
[IndustryMarket.Kosdaq] = Array.AsReadOnly(kosdaq.ToArray())
|
||||
}));
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
IndustryMarket market,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition> actions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actions);
|
||||
var expected = LegacyIndustryActionCatalog.GetActions(market);
|
||||
if (actions.Count != expected.Count ||
|
||||
actions.Any(action => action is null || action.Market != market) ||
|
||||
!actions.Select(action => action.ActionId).ToHashSet(StringComparer.Ordinal)
|
||||
.SetEquals(expected.Select(action => action.ActionId)))
|
||||
{
|
||||
throw new InvalidDataException("The runtime industry action layout is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record LegacyComparisonActionPlacement(string ActionId, string Section);
|
||||
|
||||
public sealed class LegacyComparisonUiLayout
|
||||
{
|
||||
private readonly IReadOnlyList<string> _fixedTargetIds;
|
||||
private readonly IReadOnlyList<LegacyComparisonActionPlacement> _actionPlacements;
|
||||
|
||||
private LegacyComparisonUiLayout(
|
||||
IReadOnlyList<string> fixedTargetIds,
|
||||
IReadOnlyList<LegacyComparisonActionPlacement> actionPlacements)
|
||||
{
|
||||
_fixedTargetIds = fixedTargetIds;
|
||||
_actionPlacements = actionPlacements;
|
||||
}
|
||||
|
||||
public static LegacyComparisonUiLayout Default { get; } = Create(
|
||||
LegacyComparisonWorkflowCatalog.FixedTargets.Select(target => target.TargetId),
|
||||
LegacyComparisonWorkflowCatalog.Actions.Select(action =>
|
||||
new LegacyComparisonActionPlacement(action.ActionId, action.Section)));
|
||||
|
||||
internal IReadOnlyList<LegacyComparisonTarget> FixedTargets => _fixedTargetIds
|
||||
.Select(id => LegacyComparisonWorkflowCatalog.GetFixedTarget(id) ??
|
||||
throw new InvalidOperationException(
|
||||
"The runtime comparison layout contains an unknown target."))
|
||||
.ToArray();
|
||||
|
||||
internal IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
|
||||
LegacyComparisonTarget? first,
|
||||
LegacyComparisonTarget? second) =>
|
||||
LegacyComparisonWorkflowCatalog.CreateActionViews(
|
||||
first,
|
||||
second,
|
||||
_actionPlacements);
|
||||
|
||||
internal static LegacyComparisonUiLayout Create(
|
||||
IEnumerable<string> fixedTargetIds,
|
||||
IEnumerable<LegacyComparisonActionPlacement> actionPlacements)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fixedTargetIds);
|
||||
ArgumentNullException.ThrowIfNull(actionPlacements);
|
||||
var targetIds = fixedTargetIds.ToArray();
|
||||
var placements = actionPlacements.ToArray();
|
||||
var expectedTargetIds = LegacyComparisonWorkflowCatalog.FixedTargets
|
||||
.Select(target => target.TargetId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var expectedActionIds = LegacyComparisonWorkflowCatalog.Actions
|
||||
.Select(action => action.ActionId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (targetIds.Length != expectedTargetIds.Count ||
|
||||
targetIds.Distinct(StringComparer.Ordinal).Count() != targetIds.Length ||
|
||||
!targetIds.ToHashSet(StringComparer.Ordinal).SetEquals(expectedTargetIds) ||
|
||||
placements.Length != expectedActionIds.Count ||
|
||||
placements.Any(placement =>
|
||||
placement is null ||
|
||||
string.IsNullOrWhiteSpace(placement.Section) ||
|
||||
placement.Section.Any(char.IsControl)) ||
|
||||
placements.Select(placement => placement.ActionId)
|
||||
.Distinct(StringComparer.Ordinal).Count() != placements.Length ||
|
||||
!placements.Select(placement => placement.ActionId)
|
||||
.ToHashSet(StringComparer.Ordinal).SetEquals(expectedActionIds))
|
||||
{
|
||||
throw new InvalidDataException("The runtime comparison UI layout is invalid.");
|
||||
}
|
||||
|
||||
return new LegacyComparisonUiLayout(
|
||||
Array.AsReadOnly(targetIds),
|
||||
Array.AsReadOnly(placements));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LegacyRuntimeUiCatalogComposition(
|
||||
LegacyFixedActionCatalog FixedCatalog,
|
||||
LegacyIndustryActionLayout IndustryLayout,
|
||||
LegacyComparisonUiLayout ComparisonLayout,
|
||||
IReadOnlyList<LegacyRuntimeUiCatalogWarning> Warnings)
|
||||
{
|
||||
public string? WarningMessage => Warnings.Count == 0
|
||||
? null
|
||||
: "런타임 UI INI 일부를 읽지 못해 검증된 내장 목록을 사용합니다: " +
|
||||
string.Join(", ", Warnings.Select(warning =>
|
||||
$"{warning.FileName} ({warning.Failure})"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads only the six non-secret CP949 UI catalogs next to the executable. Raw INI text
|
||||
/// controls section and row order, while every executable action remains joined to one
|
||||
/// pre-registered C# identity. A malformed file can therefore change neither scene aliases
|
||||
/// nor mutation data and falls back independently to the embedded catalog.
|
||||
/// </summary>
|
||||
public static class LegacyRuntimeUiCatalogLoader
|
||||
{
|
||||
public const int MaximumFileBytes = 64 * 1024;
|
||||
public const int MaximumSections = 20;
|
||||
public const int MaximumRowsPerSection = 50;
|
||||
public const int MaximumLineLength = 256;
|
||||
|
||||
private static readonly Encoding Cp949 = CreateCp949Encoding();
|
||||
|
||||
public static LegacyRuntimeUiCatalogComposition ResolveFromBaseDirectory(
|
||||
string? baseDirectory)
|
||||
{
|
||||
var warnings = new List<LegacyRuntimeUiCatalogWarning>();
|
||||
if (!TryCreateResourceDirectory(baseDirectory, out var resourceDirectory))
|
||||
{
|
||||
warnings.Add(new LegacyRuntimeUiCatalogWarning(
|
||||
"Res",
|
||||
LegacyRuntimeUiCatalogFailure.InvalidBaseDirectory));
|
||||
return CreateFallback(warnings);
|
||||
}
|
||||
|
||||
var fixedCatalog = LegacyFixedActionCatalog.Default;
|
||||
var fixedOrders = new Dictionary<LegacyFixedMarket,
|
||||
IReadOnlyList<LegacyFixedAction>>();
|
||||
LoadFixed(
|
||||
resourceDirectory,
|
||||
"해외.ini",
|
||||
LegacyFixedMarket.Overseas,
|
||||
fixedCatalog,
|
||||
fixedOrders,
|
||||
warnings);
|
||||
LoadFixed(
|
||||
resourceDirectory,
|
||||
"환율.ini",
|
||||
LegacyFixedMarket.Exchange,
|
||||
fixedCatalog,
|
||||
fixedOrders,
|
||||
warnings);
|
||||
LoadFixed(
|
||||
resourceDirectory,
|
||||
"지수.ini",
|
||||
LegacyFixedMarket.Index,
|
||||
fixedCatalog,
|
||||
fixedOrders,
|
||||
warnings);
|
||||
fixedCatalog = fixedCatalog.WithRuntimeOrder(fixedOrders);
|
||||
|
||||
var kospi = LoadIndustry(
|
||||
resourceDirectory,
|
||||
"업종_코스피.ini",
|
||||
IndustryMarket.Kospi,
|
||||
warnings);
|
||||
var kosdaq = LoadIndustry(
|
||||
resourceDirectory,
|
||||
"업종_코스닥.ini",
|
||||
IndustryMarket.Kosdaq,
|
||||
warnings);
|
||||
var industryLayout = LegacyIndustryActionLayout.Create(kospi, kosdaq);
|
||||
|
||||
var comparisonLayout = LoadComparison(
|
||||
resourceDirectory,
|
||||
"종목비교.ini",
|
||||
warnings);
|
||||
return new LegacyRuntimeUiCatalogComposition(
|
||||
fixedCatalog,
|
||||
industryLayout,
|
||||
comparisonLayout,
|
||||
Array.AsReadOnly(warnings.ToArray()));
|
||||
}
|
||||
|
||||
private static LegacyRuntimeUiCatalogComposition CreateFallback(
|
||||
IReadOnlyList<LegacyRuntimeUiCatalogWarning> warnings) => new(
|
||||
LegacyFixedActionCatalog.Default,
|
||||
LegacyIndustryActionLayout.Default,
|
||||
LegacyComparisonUiLayout.Default,
|
||||
warnings);
|
||||
|
||||
private static void LoadFixed(
|
||||
string resourceDirectory,
|
||||
string fileName,
|
||||
LegacyFixedMarket market,
|
||||
LegacyFixedActionCatalog catalog,
|
||||
IDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>> orders,
|
||||
ICollection<LegacyRuntimeUiCatalogWarning> warnings)
|
||||
{
|
||||
if (TryReadDocument(
|
||||
Path.Combine(resourceDirectory, fileName),
|
||||
out var document,
|
||||
out var failure) &&
|
||||
TryJoinRows(
|
||||
document,
|
||||
catalog.GetActions(market),
|
||||
action => action.Section,
|
||||
action => action.Label,
|
||||
out var actions,
|
||||
out failure))
|
||||
{
|
||||
orders.Add(market, actions);
|
||||
return;
|
||||
}
|
||||
|
||||
orders.Add(market, catalog.GetActions(market));
|
||||
warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyIndustryActionDefinition> LoadIndustry(
|
||||
string resourceDirectory,
|
||||
string fileName,
|
||||
IndustryMarket market,
|
||||
ICollection<LegacyRuntimeUiCatalogWarning> warnings)
|
||||
{
|
||||
var embedded = LegacyIndustryActionCatalog.GetActions(market);
|
||||
if (!TryReadDocument(
|
||||
Path.Combine(resourceDirectory, fileName),
|
||||
out var document,
|
||||
out var failure))
|
||||
{
|
||||
warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure));
|
||||
return embedded;
|
||||
}
|
||||
|
||||
var expectedSection = market == IndustryMarket.Kospi ? "코스피" : "코스닥";
|
||||
if (document.Sections.Count != 1 ||
|
||||
!string.Equals(
|
||||
document.Sections[0].Name,
|
||||
expectedSection,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
warnings.Add(new LegacyRuntimeUiCatalogWarning(
|
||||
fileName,
|
||||
LegacyRuntimeUiCatalogFailure.InvalidFormat));
|
||||
return embedded;
|
||||
}
|
||||
|
||||
var expected = embedded.Select(action => new IndustryJoinRow(
|
||||
expectedSection,
|
||||
action.Label,
|
||||
action)).ToArray();
|
||||
if (!TryJoinRows(
|
||||
document,
|
||||
expected,
|
||||
row => row.Section,
|
||||
row => row.Label,
|
||||
out var ordered,
|
||||
out failure))
|
||||
{
|
||||
warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure));
|
||||
return embedded;
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(ordered
|
||||
.Select(row => row.Action with { Section = expectedSection })
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static LegacyComparisonUiLayout LoadComparison(
|
||||
string resourceDirectory,
|
||||
string fileName,
|
||||
ICollection<LegacyRuntimeUiCatalogWarning> warnings)
|
||||
{
|
||||
if (!TryReadDocument(
|
||||
Path.Combine(resourceDirectory, fileName),
|
||||
out var document,
|
||||
out var failure) ||
|
||||
!TryCreateComparisonLayout(document, out var layout, out failure))
|
||||
{
|
||||
warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure));
|
||||
return LegacyComparisonUiLayout.Default;
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
private static bool TryCreateComparisonLayout(
|
||||
IniDocument document,
|
||||
out LegacyComparisonUiLayout layout,
|
||||
out LegacyRuntimeUiCatalogFailure failure)
|
||||
{
|
||||
layout = LegacyComparisonUiLayout.Default;
|
||||
if (document.Sections.Count != 6 ||
|
||||
!string.Equals(document.Sections[0].Name, "지수", StringComparison.Ordinal))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetByLabel = LegacyComparisonWorkflowCatalog.FixedTargets
|
||||
.ToDictionary(target => target.DisplayName, StringComparer.Ordinal);
|
||||
var targetIds = new List<string>(targetByLabel.Count);
|
||||
var targetLabels = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var label in document.Sections[0].Rows)
|
||||
{
|
||||
if (!targetLabels.Add(label))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.DuplicateEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!targetByLabel.TryGetValue(label, out var target))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.UnknownEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
targetIds.Add(target.TargetId);
|
||||
}
|
||||
|
||||
if (targetIds.Count != targetByLabel.Count)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.MissingEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
var actions = LegacyComparisonWorkflowCatalog.Actions;
|
||||
var directActions = actions
|
||||
.Where(action => action.Kind != LegacyComparisonActionKind.Return)
|
||||
.ToDictionary(action => action.Label, StringComparer.Ordinal);
|
||||
var returnActions = actions
|
||||
.Where(action => action.Kind == LegacyComparisonActionKind.Return)
|
||||
.ToArray();
|
||||
var returnSectionNames = returnActions
|
||||
.Select(action => action.Section)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (returnSectionNames.Length != 1)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSectionNames = directActions.Keys
|
||||
.Append(returnSectionNames[0])
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var actualSectionNames = document.Sections.Skip(1)
|
||||
.Select(section => section.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (actualSectionNames.Count != expectedSectionNames.Count ||
|
||||
!actualSectionNames.SetEquals(expectedSectionNames))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.UnknownEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
var placements = new List<LegacyComparisonActionPlacement>(actions.Count);
|
||||
var actionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var section in document.Sections.Skip(1))
|
||||
{
|
||||
if (directActions.TryGetValue(section.Name, out var directAction))
|
||||
{
|
||||
if (section.Rows.Count != 0)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!actionIds.Add(directAction.ActionId))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.DuplicateEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
placements.Add(new LegacyComparisonActionPlacement(
|
||||
directAction.ActionId,
|
||||
section.Name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
section.Name,
|
||||
returnSectionNames[0],
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.UnknownEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedReturnRows = returnActions.Select(action =>
|
||||
new ComparisonJoinRow(section.Name, action.Label, action)).ToArray();
|
||||
var returnDocument = new IniDocument(
|
||||
Array.AsReadOnly(new[] { section }));
|
||||
if (!TryJoinRows(
|
||||
returnDocument,
|
||||
expectedReturnRows,
|
||||
row => row.Section,
|
||||
row => row.Label,
|
||||
out var orderedReturns,
|
||||
out failure))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var row in orderedReturns)
|
||||
{
|
||||
actionIds.Add(row.Action.ActionId);
|
||||
placements.Add(new LegacyComparisonActionPlacement(
|
||||
row.Action.ActionId,
|
||||
section.Name));
|
||||
}
|
||||
}
|
||||
|
||||
if (placements.Count != actions.Count || actionIds.Count != actions.Count)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.MissingEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
layout = LegacyComparisonUiLayout.Create(targetIds, placements);
|
||||
failure = LegacyRuntimeUiCatalogFailure.None;
|
||||
return true;
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryJoinRows<T>(
|
||||
IniDocument document,
|
||||
IReadOnlyList<T> expected,
|
||||
Func<T, string> sectionSelector,
|
||||
Func<T, string> labelSelector,
|
||||
out IReadOnlyList<T> ordered,
|
||||
out LegacyRuntimeUiCatalogFailure failure)
|
||||
{
|
||||
ordered = Array.Empty<T>();
|
||||
var expectedSections = expected.Select(sectionSelector)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var actualSections = document.Sections.Select(section => section.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (document.Sections.Count != expectedSections.Count ||
|
||||
!actualSections.SetEquals(expectedSections))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedOccurrences = new Dictionary<EntryKey, T>();
|
||||
var expectedCounts = new Dictionary<EntryPair, int>();
|
||||
foreach (var value in expected)
|
||||
{
|
||||
var pair = new EntryPair(sectionSelector(value), labelSelector(value));
|
||||
expectedCounts.TryGetValue(pair, out var occurrence);
|
||||
expectedCounts[pair] = occurrence + 1;
|
||||
expectedOccurrences.Add(
|
||||
new EntryKey(pair.Section, pair.Label, occurrence),
|
||||
value);
|
||||
}
|
||||
|
||||
var result = new List<T>(expected.Count);
|
||||
var actualCounts = new Dictionary<EntryPair, int>();
|
||||
foreach (var section in document.Sections)
|
||||
{
|
||||
foreach (var label in section.Rows)
|
||||
{
|
||||
var pair = new EntryPair(section.Name, label);
|
||||
actualCounts.TryGetValue(pair, out var occurrence);
|
||||
actualCounts[pair] = occurrence + 1;
|
||||
if (!expectedOccurrences.TryGetValue(
|
||||
new EntryKey(section.Name, label, occurrence),
|
||||
out var value))
|
||||
{
|
||||
failure = expectedCounts.ContainsKey(pair)
|
||||
? LegacyRuntimeUiCatalogFailure.DuplicateEntry
|
||||
: LegacyRuntimeUiCatalogFailure.UnknownEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
result.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Count != expected.Count)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.MissingEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
ordered = Array.AsReadOnly(result.ToArray());
|
||||
failure = LegacyRuntimeUiCatalogFailure.None;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadDocument(
|
||||
string path,
|
||||
out IniDocument document,
|
||||
out LegacyRuntimeUiCatalogFailure failure)
|
||||
{
|
||||
document = IniDocument.Empty;
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
4096,
|
||||
FileOptions.SequentialScan);
|
||||
if (stream.Length > MaximumFileBytes)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.FileTooLarge;
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes = new byte[(int)stream.Length];
|
||||
stream.ReadExactly(bytes);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.FileNotFound;
|
||||
return false;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or UnauthorizedAccessException or
|
||||
ArgumentException or NotSupportedException or
|
||||
System.Security.SecurityException)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.FileUnavailable;
|
||||
return false;
|
||||
}
|
||||
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = Cp949.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidEncoding;
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryParse(text, out document, out failure);
|
||||
}
|
||||
|
||||
private static bool TryParse(
|
||||
string text,
|
||||
out IniDocument document,
|
||||
out LegacyRuntimeUiCatalogFailure failure)
|
||||
{
|
||||
document = IniDocument.Empty;
|
||||
var sections = new List<IniSection>();
|
||||
var sectionNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
List<string>? currentRows = null;
|
||||
using var reader = new StringReader(text);
|
||||
while (reader.ReadLine() is { } sourceLine)
|
||||
{
|
||||
var line = sourceLine.Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.Length > MaximumLineLength ||
|
||||
line.Any(character => char.IsControl(character) || char.IsSurrogate(character)))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (line[0] == '[')
|
||||
{
|
||||
if (line.Length < 3 || line[^1] != ']' ||
|
||||
line.AsSpan(1, line.Length - 2).Contains('[') ||
|
||||
line.AsSpan(1, line.Length - 2).Contains(']'))
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
|
||||
var name = line[1..^1].Trim();
|
||||
if (name.Length == 0 || !sectionNames.Add(name))
|
||||
{
|
||||
failure = name.Length == 0
|
||||
? LegacyRuntimeUiCatalogFailure.InvalidFormat
|
||||
: LegacyRuntimeUiCatalogFailure.DuplicateEntry;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sections.Count >= MaximumSections)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.TooManySections;
|
||||
return false;
|
||||
}
|
||||
|
||||
currentRows = [];
|
||||
sections.Add(new IniSection(name, currentRows));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentRows is null)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentRows.Count >= MaximumRowsPerSection)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.TooManyRows;
|
||||
return false;
|
||||
}
|
||||
|
||||
currentRows.Add(line);
|
||||
}
|
||||
|
||||
if (sections.Count == 0)
|
||||
{
|
||||
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new IniDocument(Array.AsReadOnly(sections.ToArray()));
|
||||
failure = LegacyRuntimeUiCatalogFailure.None;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryCreateResourceDirectory(
|
||||
string? baseDirectory,
|
||||
out string resourceDirectory)
|
||||
{
|
||||
resourceDirectory = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(baseDirectory) ||
|
||||
baseDirectory.Any(char.IsControl) ||
|
||||
!Path.IsPathFullyQualified(baseDirectory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
resourceDirectory = Path.Combine(Path.GetFullPath(baseDirectory), "Res");
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or IOException or NotSupportedException or
|
||||
System.Security.SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Encoding CreateCp949Encoding()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
return Encoding.GetEncoding(
|
||||
949,
|
||||
EncoderFallback.ExceptionFallback,
|
||||
DecoderFallback.ExceptionFallback);
|
||||
}
|
||||
|
||||
private sealed record IniSection(string Name, IReadOnlyList<string> Rows);
|
||||
|
||||
private sealed record IniDocument(IReadOnlyList<IniSection> Sections)
|
||||
{
|
||||
public static IniDocument Empty { get; } = new(Array.Empty<IniSection>());
|
||||
}
|
||||
|
||||
private sealed record IndustryJoinRow(
|
||||
string Section,
|
||||
string Label,
|
||||
LegacyIndustryActionDefinition Action);
|
||||
|
||||
private sealed record ComparisonJoinRow(
|
||||
string Section,
|
||||
string Label,
|
||||
LegacyComparisonActionDefinition Action);
|
||||
|
||||
private readonly record struct EntryPair(string Section, string Label);
|
||||
|
||||
private readonly record struct EntryKey(string Section, string Label, int Occurrence);
|
||||
}
|
||||
@@ -23,9 +23,55 @@
|
||||
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<!--
|
||||
Keep the legacy runtime assets outside this repository while reproducing the
|
||||
original executable-folder layout. Override this property at build time when
|
||||
the read-only MBN_STOCK_N checkout is in a different location:
|
||||
/p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\bin\Debug"
|
||||
-->
|
||||
<LegacyRuntimeSourceRoot Condition="'$(LegacyRuntimeSourceRoot)' == ''">$(MSBuildProjectDirectory)\..\..\..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug</LegacyRuntimeSourceRoot>
|
||||
<LegacyRuntimeSourceRoot>$([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)'))</LegacyRuntimeSourceRoot>
|
||||
<LegacyCutsSourceRoot>$(LegacyRuntimeSourceRoot)\Cuts</LegacyCutsSourceRoot>
|
||||
<LegacyResSourceRoot>$(LegacyRuntimeSourceRoot)\Res</LegacyResSourceRoot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Explicit allowlist: these legacy Res assets contain UI/menu data only. -->
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\a.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\aa.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\dot.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Find_16x16.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Find_32x32.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\green.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Grow.bmp" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\logo.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MmoneyCoder.ico" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MoveDown_16x16.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MoveUp_16x16.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\P.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\pie.bmp" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Preview.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\pro#00083.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\profit.bmp" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\red.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\sample_1.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\sell.bmp" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\setup.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\t.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\VRIPNG.png" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\기타.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종_코스닥.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종_코스피.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목_0111.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목비교.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수_0318.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수_250401.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\해외.ini" />
|
||||
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\환율.ini" />
|
||||
|
||||
<Compile Include="..\..\LegacySceneRuntimeFactory.cs"
|
||||
Link="Runtime\LegacySceneRuntimeFactory.cs" />
|
||||
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
|
||||
@@ -39,9 +85,99 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="$(LegacyCutsSourceRoot)\**\*">
|
||||
<Link>Cuts\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<TargetPath>Cuts\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="@(LegacyPackagedResAsset)">
|
||||
<Link>Res\%(Filename)%(Extension)</Link>
|
||||
<TargetPath>Res\%(Filename)%(Extension)</TargetPath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<!--
|
||||
The active legacy DB INI contains credentials. It is available beside the
|
||||
executable for local F5/unpackaged development only and is never MSIX Content.
|
||||
Historical copies, backups, archives and afiedt.buf.txt are not included at all.
|
||||
-->
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="ValidateLegacyRuntimeAssets" BeforeTargets="PrepareForBuild">
|
||||
<Error Condition="!Exists('$(LegacyRuntimeSourceRoot)')"
|
||||
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Set /p:LegacyRuntimeSourceRoot to the read-only MBN_STOCK_N bin\Debug directory." />
|
||||
<Error Condition="!Exists('$(LegacyCutsSourceRoot)')"
|
||||
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />
|
||||
<Error Condition="!Exists('$(LegacyResSourceRoot)')"
|
||||
Text="Legacy Res source directory was not found: $(LegacyResSourceRoot)." />
|
||||
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\5001.t2s')"
|
||||
Text="The required legacy scene file Cuts\5001.t2s is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
<Error Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and
|
||||
'$(PublishAppxPackage)' != 'true' and
|
||||
!Exists('$(LegacyResSourceRoot)\MmoneyCoder.ini')"
|
||||
Text="The required legacy database settings file Res\MmoneyCoder.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
<Error Condition="!Exists('$(LegacyResSourceRoot)\종목.ini')"
|
||||
Text="The required legacy stock menu file Res\종목.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스피.ini')"
|
||||
Text="The required legacy KOSPI industry menu file Res\업종_코스피.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스닥.ini')"
|
||||
Text="The required legacy KOSDAQ industry menu file Res\업종_코스닥.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
<Error Condition="!Exists('$(LegacyResSourceRoot)\해외.ini')"
|
||||
Text="The required legacy overseas menu file Res\해외.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
<Error Condition="!Exists('$(LegacyResSourceRoot)\환율.ini')"
|
||||
Text="The required legacy exchange menu file Res\환율.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
<Error Condition="!Exists('$(LegacyResSourceRoot)\지수.ini')"
|
||||
Text="The required legacy index menu file Res\지수.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
</Target>
|
||||
|
||||
<!--
|
||||
Do not declare the credential-bearing INI as Content, None, or another item
|
||||
harvested by PackagingOutputs. The MSIX tooling harvests every
|
||||
CopyToOutputDirectory item into its loose-package recipe, even when
|
||||
GenerateAppxPackageOnBuild is false. This post-build copy provides the original
|
||||
executable-relative layout only for ordinary local output. The CPS-only
|
||||
up-to-date items below are not package payload candidates.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<!-- These CPS-only items participate in Visual Studio's fast up-to-date check. -->
|
||||
<UpToDateCheckInput Include="$(LegacyResSourceRoot)\MmoneyCoder.ini" />
|
||||
<UpToDateCheckBuilt Include="$(TargetDir)Res\MmoneyCoder.ini" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyLegacyDatabaseIniToLocalOutput"
|
||||
AfterTargets="Build"
|
||||
Inputs="$(LegacyResSourceRoot)\MmoneyCoder.ini"
|
||||
Outputs="$(TargetDir)Res\MmoneyCoder.ini"
|
||||
Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and '$(PublishAppxPackage)' != 'true'">
|
||||
<MakeDir Directories="$(TargetDir)Res" />
|
||||
<Copy SourceFiles="$(LegacyResSourceRoot)\MmoneyCoder.ini"
|
||||
DestinationFiles="$(TargetDir)Res\MmoneyCoder.ini"
|
||||
SkipUnchangedFiles="true" />
|
||||
<ItemGroup>
|
||||
<FileWrites Include="$(TargetDir)Res\MmoneyCoder.ini" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- The credential-bearing local development copy must not survive Clean. -->
|
||||
<Target Name="RemoveLegacyDatabaseIniFromLocalOutput"
|
||||
BeforeTargets="Clean">
|
||||
<Delete Files="$(TargetDir)Res\MmoneyCoder.ini"
|
||||
Condition="Exists('$(TargetDir)Res\MmoneyCoder.ini')" />
|
||||
</Target>
|
||||
|
||||
<Target Name="RejectLegacyDatabaseIniFromPackagePayload"
|
||||
BeforeTargets="_ComputeAppxPackagePayload">
|
||||
<ItemGroup>
|
||||
<_ForbiddenLegacyDatabasePackagePayload Include="@(PackagingOutputs)"
|
||||
Condition="'%(PackagingOutputs.TargetPath)' == 'Res\MmoneyCoder.ini' or
|
||||
'%(PackagingOutputs.TargetPath)' == 'Res/MmoneyCoder.ini'" />
|
||||
</ItemGroup>
|
||||
<Error Condition="'@(_ForbiddenLegacyDatabasePackagePayload)' != ''"
|
||||
Text="Credential-bearing Res\MmoneyCoder.ini must never enter an MSIX payload or package recipe." />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
|
||||
|
||||
@@ -57,6 +57,8 @@ public sealed partial class MainWindow : Window
|
||||
var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
|
||||
runtimeCutMenuOverridePath,
|
||||
AppContext.BaseDirectory);
|
||||
var runtimeUiCatalogs = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
|
||||
AppContext.BaseDirectory);
|
||||
|
||||
ILegacyStockLookup stockLookup;
|
||||
IIndustrySelectionService industrySelectionService;
|
||||
@@ -76,7 +78,8 @@ public sealed partial class MainWindow : Window
|
||||
string? initializationError = null;
|
||||
try
|
||||
{
|
||||
_databaseRuntime = DatabaseRuntime.CreateDefault();
|
||||
_databaseRuntime = DatabaseRuntime.CreateLegacyExecutableDefault(
|
||||
AppContext.BaseDirectory);
|
||||
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
||||
var databaseMutationAuthorization =
|
||||
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
|
||||
@@ -163,7 +166,9 @@ public sealed partial class MainWindow : Window
|
||||
DataQueryExecutor.Reset();
|
||||
}
|
||||
|
||||
_industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService);
|
||||
_industryWorkflow = new LegacyIndustrySelectionWorkflow(
|
||||
industrySelectionService,
|
||||
actionLayout: runtimeUiCatalogs.IndustryLayout);
|
||||
var overseasWorkflow = new LegacyOverseasSelectionWorkflow(
|
||||
overseasIndustrySearchService,
|
||||
overseasStockSearchService);
|
||||
@@ -177,6 +182,7 @@ public sealed partial class MainWindow : Window
|
||||
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
|
||||
_controller = new LegacyOperatorController(
|
||||
stockLookup,
|
||||
fixedCatalog: runtimeUiCatalogs.FixedCatalog,
|
||||
industryWorkflow: _industryWorkflow,
|
||||
themeWorkflow: new LegacyThemeWorkflow(themeSelectionService),
|
||||
expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService),
|
||||
@@ -184,7 +190,8 @@ public sealed partial class MainWindow : Window
|
||||
tradingHaltSelectionService),
|
||||
comparisonWorkflow: new LegacyComparisonWorkflowController(
|
||||
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
|
||||
comparisonStore),
|
||||
comparisonStore,
|
||||
runtimeUiCatalogs.ComparisonLayout),
|
||||
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
|
||||
manualFinancialService,
|
||||
manualFinancialStockSearchService),
|
||||
@@ -205,9 +212,15 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
_controller.ReportError(initializationError);
|
||||
}
|
||||
else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)
|
||||
else if (new[]
|
||||
{
|
||||
cutMenuComposition.WarningMessage,
|
||||
runtimeUiCatalogs.WarningMessage
|
||||
}
|
||||
.Where(message => message is not null)
|
||||
.ToArray() is { Length: > 0 } catalogWarnings)
|
||||
{
|
||||
_controller.ReportWarning(cutMenuWarning);
|
||||
_controller.ReportWarning(string.Join(" ", catalogWarnings));
|
||||
}
|
||||
|
||||
InitializePlayoutRuntime();
|
||||
|
||||
@@ -23,6 +23,7 @@ public static class PlayoutOptionsLoader
|
||||
{
|
||||
var options = LoadJson(path ?? DefaultPath);
|
||||
ApplyEnvironment(options);
|
||||
ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,36 @@ public static class PlayoutOptionsLoader
|
||||
RequiredLiveAuthorizationValue,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
internal static void ApplyExecutableAssetDefaults(
|
||||
PlayoutOptions options,
|
||||
string? baseDirectory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!string.IsNullOrWhiteSpace(options.SceneDirectory) ||
|
||||
string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cutsDirectory = Path.Combine(
|
||||
Path.GetFullPath(baseDirectory),
|
||||
"Cuts");
|
||||
if (Directory.Exists(cutsDirectory))
|
||||
{
|
||||
options.SceneDirectory = cutsDirectory;
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or IOException or NotSupportedException or
|
||||
System.Security.SecurityException)
|
||||
{
|
||||
// A missing or invalid executable asset root leaves the existing safe
|
||||
// configuration unchanged; validation reports it if a command needs it.
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayoutOptions LoadJson(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
|
||||
Reference in New Issue
Block a user