feat: establish legacy parity migration baseline

This commit is contained in:
2026-07-15 07:10:01 +09:00
parent e468f43886
commit f14800656b
36 changed files with 3166 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacySearchTrigger
{
Button,
Enter
}
public enum LegacyOperatorStatusKind
{
Neutral,
Information,
Warning,
Error
}
public sealed record LegacyStockResultRow(int Index, string DisplayName);
public sealed record LegacyCutViewRow(
int PhysicalIndex,
int? DisplayOrdinal,
string RawLabel,
bool IsSeparator,
bool IsSelected);
public sealed record LegacyDialogState(string Message);
public sealed record LegacyOperatorSnapshot(
long Revision,
string SearchText,
IReadOnlyList<LegacyStockResultRow> SearchResults,
int? SelectedStockIndex,
IReadOnlyList<LegacyCutViewRow> CutRows,
IReadOnlyList<LegacyOperatorPlaylistRow> Playlist,
LegacyDialogState? Dialog,
string StatusMessage,
LegacyOperatorStatusKind StatusKind);
/// <summary>
/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends
/// input intent only; it never derives stock identity, cut aliases or playlist fields.
/// </summary>
public sealed class LegacyOperatorController
{
private const long DoubleClickWindowMilliseconds = 500;
private readonly ILegacyStockLookup _stockLookup;
private readonly SortedSet<int> _legacyPointerRows = [];
private readonly SortedSet<int> _nativeSelectedCutRows = [];
private readonly List<LegacyOperatorPlaylistRow> _playlist = [];
private Queue<int>? _pendingDropCutRows;
private LegacyStockSearchData _searchData = new(string.Empty, [], []);
private int? _selectedStockIndex;
private int? _cutAnchorIndex;
private int _nativeSelectionAnchorIndex;
private int? _previousPointerRow;
private int _previousPointerX;
private int _previousPointerY;
private long? _previousPointerTimestamp;
private long _revision;
private long _playlistSequence;
private string _searchText = string.Empty;
private string _statusMessage = string.Empty;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
private LegacyDialogState? _dialog;
public LegacyOperatorController(ILegacyStockLookup stockLookup)
{
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
}
public LegacyOperatorSnapshot Current => CreateSnapshot();
public async Task<LegacyOperatorSnapshot> SearchStocksAsync(
string text,
LegacySearchTrigger trigger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(text);
_searchText = text;
// button9_Click returned before calling searchStock only for an exactly empty string.
// txtSearch_KeyPress did not have that guard, so blank Enter still queries all rows.
if (trigger == LegacySearchTrigger.Button && text.Length == 0)
{
Touch();
return CreateSnapshot();
}
_searchData = await _stockLookup.SearchAsync(text, cancellationToken).ConfigureAwait(false);
_selectedStockIndex = null;
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
_dialog = null;
_pendingDropCutRows = null;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectStock(int resultIndex)
{
if (resultIndex < 0 || resultIndex >= _searchData.DisplayNames.Count)
{
return CreateSnapshot();
}
_selectedStockIndex = resultIndex;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CutPointerDown(
int physicalIndex,
bool control,
bool shift,
int x,
int y,
long timestampMilliseconds)
{
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
{
return CreateSnapshot();
}
var gap = _previousPointerTimestamp is long previousTimestamp
? timestampMilliseconds - previousTimestamp
: long.MaxValue;
if (_previousPointerTimestamp is not null &&
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
_previousPointerRow == physicalIndex &&
_previousPointerX == x &&
_previousPointerY == y &&
_legacyPointerRows.Count == 1)
{
TryAppendCut(_legacyPointerRows.Min);
_previousPointerTimestamp = null;
}
else
{
_previousPointerTimestamp = timestampMilliseconds;
}
_previousPointerRow = physicalIndex;
_previousPointerX = x;
_previousPointerY = y;
ApplyLegacyPointerSelection(physicalIndex, control, shift);
ApplyNativeListSelection(physicalIndex, control, shift);
_cutAnchorIndex = physicalIndex;
_nativeSelectionAnchorIndex = physicalIndex;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DropSelectedCutsOnPlaylist()
{
if (_selectedStockIndex is null || _dialog is not null)
{
return CreateSnapshot();
}
_pendingDropCutRows = new Queue<int>(_nativeSelectedCutRows);
ContinuePendingDrop();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = _playlist.FindIndex(row =>
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
if (index >= 0)
{
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot DismissDialog()
{
if (_dialog is not null)
{
_dialog = null;
ContinuePendingDrop();
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReportError(string message)
{
_statusMessage = message ?? string.Empty;
_statusKind = LegacyOperatorStatusKind.Error;
Touch();
return CreateSnapshot();
}
private void ContinuePendingDrop()
{
while (_pendingDropCutRows is { Count: > 0 })
{
var physicalIndex = _pendingDropCutRows.Dequeue();
if (!TryAppendCut(physicalIndex))
{
return;
}
}
_pendingDropCutRows = null;
}
private bool TryAppendCut(int physicalIndex)
{
if (_selectedStockIndex is not int stockIndex ||
stockIndex < 0 || stockIndex >= _searchData.DisplayNames.Count ||
!IsCutIndex(physicalIndex))
{
return true;
}
var cut = LegacyStockCutCatalog.Rows[physicalIndex];
var stockName = _searchData.DisplayNames[stockIndex];
var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal);
if (isNxt && !string.Equals(
cut.RawLabel,
LegacyStockCutCatalog.NxtAllowedLabel,
StringComparison.Ordinal))
{
_dialog = new LegacyDialogState("NXT는 제공하지 않습니다.");
return false;
}
if (cut.IsSeparator)
{
return true;
}
var identity = _searchData.ResolveLastByDisplayName(stockName);
var rowId = $"legacy-stock-{++_playlistSequence:D8}";
_playlist.Add(LegacyStockCutCatalog.CreatePlaylistRow(
rowId,
stockName,
identity,
cut));
return true;
}
private void ApplyLegacyPointerSelection(int physicalIndex, bool control, bool shift)
{
if (control && shift)
{
AddLegacyRangeFromAnchor(physicalIndex);
}
else if (control)
{
if (!_legacyPointerRows.Add(physicalIndex))
{
_legacyPointerRows.Remove(physicalIndex);
}
}
else if (shift)
{
_legacyPointerRows.Clear();
AddLegacyRangeFromAnchor(physicalIndex);
}
else
{
_legacyPointerRows.Clear();
_legacyPointerRows.Add(physicalIndex);
}
}
private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift)
{
if (control && shift)
{
AddInclusiveRange(
_nativeSelectedCutRows,
_nativeSelectionAnchorIndex,
physicalIndex);
}
else if (control)
{
if (!_nativeSelectedCutRows.Add(physicalIndex))
{
_nativeSelectedCutRows.Remove(physicalIndex);
}
}
else if (shift)
{
_nativeSelectedCutRows.Clear();
AddInclusiveRange(
_nativeSelectedCutRows,
_nativeSelectionAnchorIndex,
physicalIndex);
}
else
{
_nativeSelectedCutRows.Clear();
_nativeSelectedCutRows.Add(physicalIndex);
}
}
private void AddLegacyRangeFromAnchor(int physicalIndex)
{
// MainForm used beforeRow = -1 and skipped the range when the previous
// and current rows were equal. The sentinel also affected its double-click count.
var anchor = _cutAnchorIndex ?? -1;
if (anchor == physicalIndex)
{
return;
}
var first = Math.Min(anchor, physicalIndex);
var last = Math.Max(anchor, physicalIndex);
for (var index = first; index <= last; index++)
{
_legacyPointerRows.Add(index);
}
}
private static void AddInclusiveRange(
ISet<int> target,
int anchor,
int physicalIndex)
{
var first = Math.Min(anchor, physicalIndex);
var last = Math.Max(anchor, physicalIndex);
for (var index = first; index <= last; index++)
{
target.Add(index);
}
}
private LegacyOperatorSnapshot CreateSnapshot()
{
var searchRows = _searchData.DisplayNames
.Select((name, index) => new LegacyStockResultRow(index, name))
.ToArray();
var cutRows = LegacyStockCutCatalog.Rows
.Select(row => new LegacyCutViewRow(
row.PhysicalIndex,
row.DisplayOrdinal,
row.RawLabel,
row.IsSeparator,
_nativeSelectedCutRows.Contains(row.PhysicalIndex)))
.ToArray();
return new LegacyOperatorSnapshot(
_revision,
_searchText,
Array.AsReadOnly(searchRows),
_selectedStockIndex,
Array.AsReadOnly(cutRows),
Array.AsReadOnly(_playlist.ToArray()),
_dialog,
_statusMessage,
_statusKind);
}
private static bool IsCutIndex(int physicalIndex) =>
physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count;
private void Touch() => _revision++;
}

View File

@@ -0,0 +1,164 @@
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
/// <summary>
/// Compatibility search for the operator screen. Unlike the modern lookup it intentionally
/// keeps blank-enter, LIKE wildcard, provider ordering and unlimited-result semantics.
/// Values remain bound parameters so parity does not reintroduce SQL concatenation.
/// </summary>
public sealed class LegacyParityStockSearchService : ILegacyStockLookup
{
public const int LegacyTextBoxMaximumLength = 32_767;
private static readonly SearchProfile[] Profiles =
[
new(
LegacyDomesticStockMarket.Kospi,
DataSourceKind.Oracle,
"LEGACY_PARITY_KOSPI",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'"),
new(
LegacyDomesticStockMarket.Kosdaq,
DataSourceKind.Oracle,
"LEGACY_PARITY_KOSDAQ",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N'"),
new(
LegacyDomesticStockMarket.NxtKospi,
DataSourceKind.MariaDb,
"LEGACY_PARITY_NXT_KOSPI",
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_STOCK a, N_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'"),
new(
LegacyDomesticStockMarket.NxtKosdaq,
DataSourceKind.MariaDb,
"LEGACY_PARITY_NXT_KOSDAQ",
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK a, N_KOSDAQ_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK WHERE F_STOP_GUBUN = 'N'")
];
private readonly IDataQueryExecutor _executor;
private readonly SemaphoreSlim _masterLoadGate = new(1, 1);
private IReadOnlyList<LegacyStockIdentity>? _masterCatalog;
public LegacyParityStockSearchService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (query.Length > LegacyTextBoxMaximumLength)
{
throw new ArgumentOutOfRangeException(
nameof(query),
$"The legacy search text cannot exceed {LegacyTextBoxMaximumLength} characters.");
}
var masterCatalog = await GetMasterCatalogAsync(cancellationToken).ConfigureAwait(false);
var searchTerm = query.ToUpper(CultureInfo.CurrentCulture);
var names = new List<string>();
foreach (var profile in Profiles)
{
cancellationToken.ThrowIfCancellationRequested();
var parameter = new DataQueryParameter("search_term", searchTerm, DbType.String);
var spec = new DataQuerySpec(profile.SearchSql, [parameter]);
var table = await _executor
.ExecuteAsync(profile.Source, profile.SearchTableName, spec, cancellationToken)
.ConfigureAwait(false);
AppendNames(table, profile.SearchTableName, names);
}
return new LegacyStockSearchData(query, names, masterCatalog);
}
private async Task<IReadOnlyList<LegacyStockIdentity>> GetMasterCatalogAsync(
CancellationToken cancellationToken)
{
var cached = Volatile.Read(ref _masterCatalog);
if (cached is not null)
{
return cached;
}
await _masterLoadGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cached = Volatile.Read(ref _masterCatalog);
if (cached is not null)
{
return cached;
}
var items = new List<LegacyStockIdentity>();
foreach (var profile in Profiles)
{
cancellationToken.ThrowIfCancellationRequested();
var table = await _executor
.ExecuteAsync(
profile.Source,
$"{profile.SearchTableName}_MASTER",
new DataQuerySpec(profile.MasterSql),
cancellationToken)
.ConfigureAwait(false);
AppendMaster(table, profile, items);
}
cached = Array.AsReadOnly(items.ToArray());
Volatile.Write(ref _masterCatalog, cached);
return cached;
}
finally
{
_masterLoadGate.Release();
}
}
private static void AppendNames(DataTable? table, string sourceName, ICollection<string> target)
{
ValidateTable(table, sourceName);
foreach (DataRow row in table!.Rows)
{
target.Add(Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty);
}
}
private static void AppendMaster(
DataTable? table,
SearchProfile profile,
ICollection<LegacyStockIdentity> target)
{
ValidateTable(table, $"{profile.SearchTableName}_MASTER");
foreach (DataRow row in table!.Rows)
{
target.Add(new LegacyStockIdentity(
profile.Market,
Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty,
Convert.ToString(row[1], CultureInfo.CurrentCulture) ?? string.Empty));
}
}
private static void ValidateTable(DataTable? table, string sourceName)
{
if (table is null || table.Columns.Count < 2)
{
throw new InvalidOperationException(
$"Legacy stock data from {sourceName} does not contain two columns.");
}
}
private sealed record SearchProfile(
LegacyDomesticStockMarket Market,
DataSourceKind Source,
string SearchTableName,
string SearchSql,
string MasterSql);
}

View File

@@ -0,0 +1,197 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public sealed record LegacyCutCatalogRow(
int PhysicalIndex,
int? DisplayOrdinal,
string RawLabel,
bool IsSeparator);
public sealed record LegacyOperatorPlaylistRow(
string RowId,
bool IsEnabled,
string MarketText,
string StockName,
string GraphicType,
string Subtype,
string PageText,
string StockCode,
string RawCutLabel,
int FadeDuration)
{
public LegacyPlaylistEntry ToPlayoutEntry()
{
var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal);
var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt);
return new LegacyPlaylistEntry(
RowId,
cutCode,
IsEnabled,
FadeDuration,
new LegacySceneSelection(
MarketText,
StockName,
GraphicType,
Subtype,
StockCode));
}
}
public static class LegacyStockCutCatalog
{
public const string RuntimeAssetSha256 =
"45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20";
public const string NxtAllowedLabel = "1열판기본_현재가";
private static readonly string?[] RuntimeRows =
[
"1열판기본_예상체결가",
"1열판기본_현재가",
"1열판기본_시간외단일가",
null,
"1열판상세_시가",
"1열판상세_액면가",
"1열판상세_PBR",
"1열판상세_거래량",
null,
"수익률그래프_5일",
"수익률그래프_20일",
"수익률그래프_60일",
"수익률그래프_120일",
"수익률그래프_240일",
"캔들그래프_일봉",
"캔들그래프_5일",
"캔들그래프_20일",
"캔들그래프_60일",
"캔들그래프_120일",
"캔들그래프_240일",
"캔들그래프(거래량)_일봉",
"캔들그래프(거래량)_5일",
"캔들그래프(거래량)_20일",
"캔들그래프(거래량)_60일",
"캔들그래프(거래량)_120일",
"캔들그래프(거래량)_240일",
"캔들그래프(예상체결가)_5일",
"캔들그래프(예상체결가)_20일",
"캔들그래프(예상체결가)_60일",
"캔들그래프(예상체결가)_120일",
"캔들그래프(예상체결가)_240일",
"호가창_표그래프",
"거래원_표그래프"
];
public static IReadOnlyList<LegacyCutCatalogRow> Rows { get; } = CreateRows();
public static string ResolveCutAlias(string rawLabel, bool isNxt)
{
ArgumentException.ThrowIfNullOrEmpty(rawLabel);
if (isNxt)
{
if (!string.Equals(rawLabel, NxtAllowedLabel, StringComparison.Ordinal))
{
throw new InvalidOperationException("NXT는 1열판기본_현재가만 지원합니다.");
}
return "N5001";
}
if (rawLabel.StartsWith("1열판기본_", StringComparison.Ordinal))
{
return "5001";
}
if (string.Equals(rawLabel, "1열판상세_시가", StringComparison.Ordinal))
{
return "5006";
}
if (rawLabel.StartsWith("1열판상세_", StringComparison.Ordinal))
{
return "5011";
}
if (rawLabel.StartsWith("수익률그래프_", StringComparison.Ordinal))
{
return "5086";
}
if (rawLabel.StartsWith("캔들그래프", StringComparison.Ordinal))
{
var split = rawLabel.LastIndexOf('_');
var period = split >= 0 ? rawLabel[(split + 1)..] : string.Empty;
return period switch
{
"일봉" => "8035",
"5일" => "8061",
"20일" => "8040",
"60일" => "8046",
"120일" => "8051",
"240일" => "8056",
_ => throw UnknownCut(rawLabel)
};
}
return rawLabel switch
{
"호가창_표그래프" => "8003",
"거래원_표그래프" => "5037",
_ => throw UnknownCut(rawLabel)
};
}
public static LegacyOperatorPlaylistRow CreatePlaylistRow(
string rowId,
string stockName,
LegacyStockIdentity? identity,
LegacyCutCatalogRow cut,
int fadeDuration = 6)
{
ArgumentException.ThrowIfNullOrEmpty(rowId);
ArgumentNullException.ThrowIfNull(stockName);
ArgumentNullException.ThrowIfNull(cut);
if (cut.IsSeparator || string.IsNullOrEmpty(cut.RawLabel))
{
throw new ArgumentException("A separator cannot become a playlist row.", nameof(cut));
}
var split = cut.RawLabel.IndexOf('_');
if (split <= 0 || split == cut.RawLabel.Length - 1)
{
throw UnknownCut(cut.RawLabel);
}
return new LegacyOperatorPlaylistRow(
rowId,
true,
identity?.MarketText ?? string.Empty,
stockName,
cut.RawLabel[..split],
cut.RawLabel[(split + 1)..],
"1/1",
identity?.StockCode ?? string.Empty,
cut.RawLabel,
fadeDuration);
}
private static IReadOnlyList<LegacyCutCatalogRow> CreateRows()
{
var displayOrdinal = 0;
var rows = new LegacyCutCatalogRow[RuntimeRows.Length];
for (var index = 0; index < RuntimeRows.Length; index++)
{
var label = RuntimeRows[index];
var separator = label is null;
rows[index] = new LegacyCutCatalogRow(
index,
separator ? null : ++displayOrdinal,
label ?? string.Empty,
separator);
}
return Array.AsReadOnly(rows);
}
private static InvalidOperationException UnknownCut(string label) =>
new($"The legacy stock cut is unknown: {label}");
}

View File

@@ -0,0 +1,77 @@
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyDomesticStockMarket
{
Kospi,
Kosdaq,
NxtKospi,
NxtKosdaq
}
public sealed record LegacyStockIdentity(
LegacyDomesticStockMarket Market,
string DisplayName,
string StockCode)
{
public bool IsNxt => Market is
LegacyDomesticStockMarket.NxtKospi or LegacyDomesticStockMarket.NxtKosdaq;
public string MarketText => Market switch
{
LegacyDomesticStockMarket.Kospi => "코스피",
LegacyDomesticStockMarket.Kosdaq => "코스닥",
LegacyDomesticStockMarket.NxtKospi => "코스피_NXT",
LegacyDomesticStockMarket.NxtKosdaq => "코스닥_NXT",
_ => string.Empty
};
}
public sealed class LegacyStockSearchData
{
private readonly LegacyStockIdentity[] _masterCatalog;
public LegacyStockSearchData(
string query,
IEnumerable<string> displayNames,
IEnumerable<LegacyStockIdentity> masterCatalog)
{
Query = query ?? throw new ArgumentNullException(nameof(query));
ArgumentNullException.ThrowIfNull(displayNames);
ArgumentNullException.ThrowIfNull(masterCatalog);
DisplayNames = Array.AsReadOnly(displayNames.ToArray());
_masterCatalog = masterCatalog.ToArray();
MasterCatalog = Array.AsReadOnly(_masterCatalog);
}
public string Query { get; }
public IReadOnlyList<string> DisplayNames { get; }
public IReadOnlyList<LegacyStockIdentity> MasterCatalog { get; }
/// <summary>
/// MainForm.jongmokCode scanned all four cached master tables without breaking.
/// Keeping the final match therefore reproduces its duplicate-name behaviour.
/// </summary>
public LegacyStockIdentity? ResolveLastByDisplayName(string displayName)
{
LegacyStockIdentity? match = null;
foreach (var item in _masterCatalog)
{
if (string.Equals(item.DisplayName, displayName, StringComparison.Ordinal))
{
match = item;
}
}
return match;
}
}
public interface ILegacyStockLookup
{
Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>MBN_STOCK_WEBVIEW.LegacyApplication</RootNamespace>
<AssemblyName>MBN_STOCK_WEBVIEW.LegacyApplication</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,299 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.LegacyApplication;
namespace MBN_STOCK_WEBVIEW.LegacyBridge;
public abstract record LegacyUiIntent;
public sealed record LegacyReadyIntent : LegacyUiIntent;
public sealed record LegacySearchStocksIntent(string Text, LegacySearchTrigger Trigger)
: LegacyUiIntent;
public sealed record LegacySelectStockIntent(int ResultIndex) : LegacyUiIntent;
public sealed record LegacyCutPointerDownIntent(
int PhysicalIndex,
bool Control,
bool Shift,
int X,
int Y,
long TimestampMilliseconds) : LegacyUiIntent;
public sealed record LegacyDropSelectedCutsIntent : LegacyUiIntent;
public sealed record LegacySetPlaylistEnabledIntent(string RowId, bool Enabled)
: LegacyUiIntent;
public sealed record LegacyDismissDialogIntent : LegacyUiIntent;
public static class LegacyBridgeProtocol
{
public const string TrustedHost = "legacy-parity.mbn.local";
private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions();
public static bool IsTrustedSource(string? source)
{
if (!Uri.TryCreate(source, UriKind.Absolute, out var uri))
{
return false;
}
return string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
string.Equals(uri.Host, TrustedHost, StringComparison.OrdinalIgnoreCase) &&
uri.IsDefaultPort;
}
public static bool TryParseIntent(
string json,
out LegacyUiIntent? intent,
out string? error)
{
intent = null;
error = null;
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 8
});
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(root, "type", "payload") ||
!TryString(root, "type", 64, out var type) ||
!root.TryGetProperty("payload", out var payload) ||
payload.ValueKind != JsonValueKind.Object)
{
error = "올바르지 않은 UI 요청입니다.";
return false;
}
intent = type switch
{
"ready" => ParseReady(payload),
"search-stocks" => ParseSearch(payload),
"select-stock" => ParseStockSelection(payload),
"cut-pointer-down" => ParseCutPointer(payload),
"drop-selected-cuts" => ParseDrop(payload),
"set-playlist-enabled" => ParseEnabled(payload),
"dismiss-dialog" => ParseDismiss(payload),
_ => null
};
if (intent is null)
{
error = "지원하지 않는 UI 요청입니다.";
return false;
}
return true;
}
catch (JsonException)
{
error = "UI 요청 JSON을 읽을 수 없습니다.";
return false;
}
}
public static string SerializeState(
LegacyOperatorSnapshot snapshot,
bool isBusy = false)
{
ArgumentNullException.ThrowIfNull(snapshot);
var payload = new
{
snapshot.Revision,
snapshot.SearchText,
snapshot.SearchResults,
snapshot.SelectedStockIndex,
snapshot.CutRows,
playlist = snapshot.Playlist.Select(row => new
{
row.RowId,
row.IsEnabled,
row.MarketText,
row.StockName,
row.GraphicType,
row.Subtype,
row.PageText
}),
snapshot.Dialog,
snapshot.StatusMessage,
snapshot.StatusKind,
isBusy
};
return JsonSerializer.Serialize(new { type = "state", payload }, SerializerOptions);
}
private static LegacyUiIntent? ParseReady(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyReadyIntent() : null;
private static LegacyUiIntent? ParseSearch(JsonElement payload)
{
if (!HasOnlyProperties(payload, "text", "trigger") ||
!TryString(
payload,
"text",
LegacyParityStockSearchService.LegacyTextBoxMaximumLength,
out var text) ||
!TryString(payload, "trigger", 16, out var triggerText))
{
return null;
}
var trigger = triggerText switch
{
"button" => LegacySearchTrigger.Button,
"enter" => LegacySearchTrigger.Enter,
_ => (LegacySearchTrigger?)null
};
return trigger.HasValue ? new LegacySearchStocksIntent(text, trigger.Value) : null;
}
private static LegacyUiIntent? ParseStockSelection(JsonElement payload) =>
HasOnlyProperties(payload, "resultIndex") &&
TryInt32(payload, "resultIndex", 0, int.MaxValue, out var resultIndex)
? new LegacySelectStockIntent(resultIndex)
: null;
private static LegacyUiIntent? ParseCutPointer(JsonElement payload)
{
if (!HasOnlyProperties(
payload,
"physicalIndex",
"control",
"shift",
"x",
"y",
"timestampMilliseconds") ||
!TryInt32(payload, "physicalIndex", 0, 32, out var physicalIndex) ||
!TryBoolean(payload, "control", out var control) ||
!TryBoolean(payload, "shift", out var shift) ||
!TryInt32(payload, "x", 0, 16_384, out var x) ||
!TryInt32(payload, "y", 0, 16_384, out var y) ||
!TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp))
{
return null;
}
return new LegacyCutPointerDownIntent(
physicalIndex,
control,
shift,
x,
y,
timestamp);
}
private static LegacyUiIntent? ParseDrop(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyDropSelectedCutsIntent() : null;
private static LegacyUiIntent? ParseEnabled(JsonElement payload)
{
if (!HasOnlyProperties(payload, "rowId", "enabled") ||
!TryString(payload, "rowId", 128, out var rowId) ||
!IsSafeIdentifier(rowId) ||
!TryBoolean(payload, "enabled", out var enabled))
{
return null;
}
return new LegacySetPlaylistEnabledIntent(rowId, enabled);
}
private static LegacyUiIntent? ParseDismiss(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyDismissDialogIntent() : null;
private static bool HasOnlyProperties(JsonElement element, params string[] names)
{
var expected = new HashSet<string>(names, StringComparer.Ordinal);
var count = 0;
foreach (var property in element.EnumerateObject())
{
count++;
if (!expected.Contains(property.Name))
{
return false;
}
}
return count == expected.Count;
}
private static bool TryString(
JsonElement element,
string propertyName,
int maximumLength,
out string value)
{
value = string.Empty;
return element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String &&
(value = property.GetString() ?? string.Empty).Length <= maximumLength;
}
private static bool TryBoolean(JsonElement element, string name, out bool value)
{
value = false;
if (!element.TryGetProperty(name, out var property) ||
(property.ValueKind != JsonValueKind.True && property.ValueKind != JsonValueKind.False))
{
return false;
}
value = property.GetBoolean();
return true;
}
private static bool TryInt32(
JsonElement element,
string name,
int minimum,
int maximum,
out int value)
{
value = 0;
return element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.Number &&
property.TryGetInt32(out value) &&
value >= minimum && value <= maximum;
}
private static bool TryInt64(
JsonElement element,
string name,
long minimum,
long maximum,
out long value)
{
value = 0;
return element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.Number &&
property.TryGetInt64(out value) &&
value >= minimum && value <= maximum;
}
private static bool IsSafeIdentifier(string value)
{
if (value.Length == 0)
{
return false;
}
return value.All(character =>
char.IsAsciiLetterOrDigit(character) || character is '-' or '_');
}
private static JsonSerializerOptions CreateSerializerOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>MBN_STOCK_WEBVIEW.LegacyBridge</RootNamespace>
<AssemblyName>MBN_STOCK_WEBVIEW.LegacyBridge</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Application
x:Class="MBN_STOCK_WEBVIEW.LegacyParityApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,48 @@
using Microsoft.Windows.AppLifecycle;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public partial class App : Application
{
private const string MainInstanceKey = "Wickedness.MBNStockWebView.LegacyParity.Main";
private Window? _window;
private AppInstance? _mainInstance;
public App()
{
InitializeComponent();
}
protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
var registeredInstance = AppInstance.FindOrRegisterForKey(MainInstanceKey);
if (!registeredInstance.IsCurrent)
{
try
{
var activation = AppInstance.GetCurrent().GetActivatedEventArgs();
if (activation is not null)
{
await registeredInstance.RedirectActivationToAsync(activation);
}
}
finally
{
Exit();
}
return;
}
_mainInstance = registeredInstance;
_mainInstance.Activated += OnMainInstanceActivated;
_window = new MainWindow();
_window.Activate();
}
private void OnMainInstanceActivated(object? sender, AppActivationArguments args)
{
_window?.DispatcherQueue.TryEnqueue(_window.Activate);
}
}

View File

@@ -0,0 +1,2 @@
global using Microsoft.UI.Xaml;
global using Microsoft.UI.Xaml.Controls;

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<OutputType>WinExe</OutputType>
<UseWinUI>true</UseWinUI>
<EnableMsixTooling>true</EnableMsixTooling>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MBN_STOCK_WEBVIEW.LegacyParityApp</RootNamespace>
<AssemblyName>MBN_STOCK_WEBVIEW.LegacyParityApp</AssemblyName>
<UseRidGraph>true</UseRidGraph>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<ApplicationManifest>app.manifest</ApplicationManifest>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<Version>0.1.0</Version>
</PropertyGroup>
<ItemGroup>
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
<Content Include="..\..\Assets\SplashScreen.scale-200.png" Link="Assets\SplashScreen.scale-200.png" />
<Content Include="..\..\Assets\Square150x150Logo.scale-200.png" Link="Assets\Square150x150Logo.scale-200.png" />
<Content Include="..\..\Assets\Square44x44Logo.scale-200.png" Link="Assets\Square44x44Logo.scale-200.png" />
<Content Include="..\..\Assets\Square44x44Logo.targetsize-24_altform-unplated.png" Link="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="..\..\Assets\StoreLogo.png" Link="Assets\StoreLogo.png" />
<Content Include="..\..\Assets\Wide310x150Logo.scale-200.png" Link="Assets\Wide310x150Logo.scale-200.png" />
<Content Include="Web\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" />
</ItemGroup>
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj" />
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.LegacyBridge\MBN_STOCK_WEBVIEW.LegacyBridge.csproj" />
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<Window
x:Class="MBN_STOCK_WEBVIEW.LegacyParityApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)">
<Grid x:Name="Root" Background="#FFF0F0F0">
<WebView2 x:Name="Browser" />
<Grid x:Name="LoadingOverlay" Background="#FFF0F0F0">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<ProgressRing Width="32" Height="32" IsActive="True" />
<TextBlock Text="원본 호환 화면을 준비하고 있습니다." />
</StackPanel>
</Grid>
<InfoBar
x:Name="ErrorBar"
Margin="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
IsClosable="True"
IsOpen="False"
Severity="Error"
Title="화면을 초기화하지 못했습니다." />
</Grid>
</Window>

View File

@@ -0,0 +1,353 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using Microsoft.UI.Windowing;
using Microsoft.Web.WebView2.Core;
using MMoneyCoderSharp.Data;
using Windows.Graphics;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow : Window
{
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly LegacyOperatorController _controller;
private DatabaseRuntime? _databaseRuntime;
private bool _closing;
private bool _intentBusy;
private bool _webViewReady;
public MainWindow()
{
InitializeComponent();
ILegacyStockLookup stockLookup;
string? initializationError = null;
try
{
_databaseRuntime = DatabaseRuntime.CreateDefault();
DataQueryExecutor.Configure(_databaseRuntime.Executor);
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
}
catch
{
initializationError =
"데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
stockLookup = new UnavailableStockLookup(initializationError);
DataQueryExecutor.Reset();
}
_controller = new LegacyOperatorController(stockLookup);
if (initializationError is not null)
{
_controller.ReportError(initializationError);
}
Root.Loaded += OnRootLoaded;
Closed += OnClosed;
}
private async void OnRootLoaded(object sender, RoutedEventArgs e)
{
Root.Loaded -= OnRootLoaded;
if (_closing)
{
return;
}
ConfigureWindow();
await InitializeWebViewAsync();
}
private void ConfigureWindow()
{
var windowHandle = WindowNative.GetWindowHandle(this);
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Title = "V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)";
if (appWindow.Presenter is OverlappedPresenter presenter)
{
presenter.IsMaximizable = false;
presenter.IsResizable = false;
}
// WinForms stored 1905 x 1015 as ClientSize. AppWindow.Resize would include
// the caption and borders and silently make the WebView viewport smaller.
appWindow.ResizeClient(new SizeInt32(1905, 1015));
var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary);
appWindow.Move(new PointInt32(displayArea.WorkArea.X, displayArea.WorkArea.Y));
}
private async Task InitializeWebViewAsync()
{
try
{
var webRoot = Path.Combine(AppContext.BaseDirectory, "Web");
var startPage = Path.Combine(webRoot, "index.html");
if (!File.Exists(startPage))
{
throw new FileNotFoundException("The parity Web UI was not found.", startPage);
}
await Browser.EnsureCoreWebView2Async();
if (_closing)
{
return;
}
var coreWebView = Browser.CoreWebView2 ??
throw new InvalidOperationException("WebView2 was not initialized.");
coreWebView.SetVirtualHostNameToFolderMapping(
LegacyBridgeProtocol.TrustedHost,
webRoot,
CoreWebView2HostResourceAccessKind.DenyCors);
coreWebView.Settings.AreDevToolsEnabled =
System.Diagnostics.Debugger.IsAttached;
coreWebView.Settings.AreDefaultContextMenusEnabled = false;
coreWebView.Settings.IsStatusBarEnabled = false;
coreWebView.NavigationStarting += OnNavigationStarting;
coreWebView.NavigationCompleted += OnNavigationCompleted;
coreWebView.NewWindowRequested += OnNewWindowRequested;
coreWebView.WebMessageReceived += OnWebMessageReceived;
coreWebView.ProcessFailed += OnProcessFailed;
_webViewReady = true;
Browser.Source = new Uri(
$"https://{LegacyBridgeProtocol.TrustedHost}/index.html");
}
catch (Exception exception)
{
if (!_closing)
{
ShowError("WebView2 초기화에 실패했습니다.", exception.Message);
}
}
}
private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args)
{
if (!LegacyBridgeProtocol.IsTrustedSource(args.Uri) &&
!string.Equals(args.Uri, "about:blank", StringComparison.OrdinalIgnoreCase))
{
args.Cancel = true;
}
}
private void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args)
{
if (_closing)
{
return;
}
LoadingOverlay.Visibility = Visibility.Collapsed;
if (!args.IsSuccess)
{
ShowError("원본 호환 화면을 불러오지 못했습니다.", args.WebErrorStatus.ToString());
}
}
private static void OnNewWindowRequested(
object? sender,
CoreWebView2NewWindowRequestedEventArgs args)
{
args.Handled = true;
}
private void OnWebMessageReceived(
object? sender,
CoreWebView2WebMessageReceivedEventArgs args)
{
if (_closing)
{
return;
}
LegacyUiIntent? intent = null;
string? parseError = null;
if (!LegacyBridgeProtocol.IsTrustedSource(args.Source) ||
!LegacyBridgeProtocol.TryParseIntent(
args.WebMessageAsJson,
out intent,
out parseError) ||
intent is null)
{
if (!string.IsNullOrEmpty(parseError))
{
PostState(_controller.ReportError(parseError));
}
return;
}
_ = ProcessIntentAsync(intent);
}
private async Task ProcessIntentAsync(LegacyUiIntent intent)
{
var gateEntered = false;
try
{
// MainForm performed database search synchronously. The busy snapshot
// freezes the Web controls, while this zero-timeout gate prevents an
// already-posted duplicate from growing an unbounded DB request queue.
gateEntered = await _intentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
if (!gateEntered)
{
PostState(_controller.Current);
return;
}
if (_closing)
{
return;
}
if (intent is LegacySearchStocksIntent searchIntent &&
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
searchIntent.Text.Length == 0))
{
_intentBusy = true;
PostState(_controller.Current);
}
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
if (!_closing)
{
_intentBusy = false;
PostState(state);
}
}
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
{
}
catch when (_closing)
{
}
catch
{
_intentBusy = false;
PostState(_controller.ReportError(
"요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요."));
}
finally
{
if (_closing)
{
_intentBusy = false;
}
if (gateEntered)
{
_intentGate.Release();
}
}
}
private async Task<LegacyOperatorSnapshot> HandleIntentAsync(
LegacyUiIntent intent,
CancellationToken cancellationToken)
{
return intent switch
{
LegacyReadyIntent => _controller.Current,
LegacySearchStocksIntent search => await _controller.SearchStocksAsync(
search.Text,
search.Trigger,
cancellationToken),
LegacySelectStockIntent selection =>
_controller.SelectStock(selection.ResultIndex),
LegacyCutPointerDownIntent pointer => _controller.CutPointerDown(
pointer.PhysicalIndex,
pointer.Control,
pointer.Shift,
pointer.X,
pointer.Y,
pointer.TimestampMilliseconds),
LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(),
LegacySetPlaylistEnabledIntent enabled =>
_controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled),
LegacyDismissDialogIntent => _controller.DismissDialog(),
_ => _controller.ReportError("지원하지 않는 화면 요청입니다.")
};
}
private void PostState(LegacyOperatorSnapshot snapshot)
{
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
{
Browser.CoreWebView2.PostWebMessageAsJson(
LegacyBridgeProtocol.SerializeState(snapshot, _intentBusy));
}
}
private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
{
if (!_closing)
{
ShowError("WebView2 프로세스가 중단되었습니다.", args.ProcessFailedKind.ToString());
}
}
private void ShowError(string title, string message)
{
if (_closing)
{
return;
}
ErrorBar.Title = title;
ErrorBar.Message = message;
ErrorBar.IsOpen = true;
}
private void OnClosed(object sender, WindowEventArgs args)
{
if (_closing)
{
return;
}
_closing = true;
Root.Loaded -= OnRootLoaded;
Closed -= OnClosed;
_lifetimeCancellation.Cancel();
DataQueryExecutor.Reset();
if (Browser.CoreWebView2 is not null)
{
Browser.CoreWebView2.NavigationStarting -= OnNavigationStarting;
Browser.CoreWebView2.NavigationCompleted -= OnNavigationCompleted;
Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested;
Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
Browser.CoreWebView2.ProcessFailed -= OnProcessFailed;
}
_webViewReady = false;
// These process-lifetime guards deliberately remain undisposed. An in-flight
// provider call may resume after Closed and must still be able to observe
// cancellation and release the gate without racing disposed objects.
}
private sealed class UnavailableStockLookup : ILegacyStockLookup
{
private readonly string _message;
public UnavailableStockLookup(string message)
{
_message = message;
}
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromException<LegacyStockSearchData>(
new InvalidOperationException(_message));
}
}

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="Wickedness.MBNStockWebView.LegacyParity"
Publisher="CN=Comtrophy"
Version="0.1.0.0" />
<mp:PhoneIdentity PhoneProductId="8f62f17b-a9a4-42cc-a696-a1987b5588f1" PhonePublisherId="00000000-0000-0000-0000-000000000000" />
<Properties>
<DisplayName>MBN Stock WebView (Legacy Parity)</DisplayName>
<PublisherDisplayName>Wickedness</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="LegacyParityApp" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="MBN Stock WebView (Legacy Parity)"
Description="매일경제TV 증권정보송출시스템 원본 호환 이관판"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<Capability Name="internetClient" />
<Capability Name="privateNetworkClientServer" />
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,7 @@
{
"profiles": {
"MBN_STOCK_WEBVIEW.LegacyParityApp (Package)": {
"commandName": "MsixPackage"
}
}
}

View File

@@ -0,0 +1,232 @@
(function () {
"use strict";
const webview = window.chrome && window.chrome.webview;
const searchInput = document.getElementById("stock-search");
const searchButton = document.getElementById("stock-search-button");
const stockResults = document.getElementById("stock-results");
const cutList = document.getElementById("cut-list");
const playlistRows = document.getElementById("playlist-rows");
const playlistDropZone = document.getElementById("playlist-drop-zone");
const status = document.getElementById("operator-status");
const dialog = document.getElementById("legacy-dialog");
const dialogMessage = document.getElementById("legacy-dialog-message");
const dialogOk = document.getElementById("legacy-dialog-ok");
let uiBusy = false;
function send(type, payload) {
if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
}
function createStockResultElement(row) {
const button = document.createElement("button");
button.type = "button";
button.dataset.resultIndex = String(row.index);
button.setAttribute("role", "option");
button.addEventListener("click", function () {
send("select-stock", { resultIndex: Number(button.dataset.resultIndex) });
});
return button;
}
function renderStockResults(state) {
state.searchResults.forEach(function (row, position) {
let button = stockResults.children.item(position);
if (!button || button.dataset.resultIndex !== String(row.index)) {
button = createStockResultElement(row);
if (position < stockResults.children.length) {
stockResults.replaceChild(button, stockResults.children.item(position));
} else {
stockResults.appendChild(button);
}
}
button.className = "stock-result" +
(state.selectedStockIndex === row.index ? " selected" : "");
button.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
button.textContent = row.displayName;
button.setAttribute("aria-selected", state.selectedStockIndex === row.index ? "true" : "false");
});
while (stockResults.children.length > state.searchResults.length) {
stockResults.lastElementChild.remove();
}
}
function pointerPosition(event) {
const bounds = cutList.getBoundingClientRect();
return {
x: Math.max(0, Math.round(event.clientX - bounds.left)),
y: Math.max(0, Math.round(event.clientY - bounds.top))
};
}
function createCutElement(row) {
const element = document.createElement("div");
element.setAttribute("role", "option");
element.dataset.physicalIndex = String(row.physicalIndex);
element.draggable = true;
const number = document.createElement("span");
number.className = "cut-number";
number.textContent = row.displayOrdinal === null ? "" : String(row.displayOrdinal);
const label = document.createElement("span");
label.textContent = row.rawLabel;
element.append(number, label);
element.addEventListener("pointerdown", function (event) {
const position = pointerPosition(event);
send("cut-pointer-down", {
physicalIndex: row.physicalIndex,
control: event.ctrlKey,
shift: event.shiftKey,
x: position.x,
y: position.y,
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
});
});
element.addEventListener("dragstart", function (event) {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", "legacy-selected-cuts");
}
});
return element;
}
function renderCuts(state) {
state.cutRows.forEach(function (row, index) {
let element = cutList.children.item(index);
if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) {
element = createCutElement(row);
if (index < cutList.children.length) {
cutList.replaceChild(element, cutList.children.item(index));
} else {
cutList.appendChild(element);
}
}
element.className = "cut-row" +
(row.isSelected ? " selected" : "") +
(row.isSeparator ? " separator" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
element.draggable = state.isBusy !== true;
});
while (cutList.children.length > state.cutRows.length) {
cutList.lastElementChild.remove();
}
}
function createPlaylistElement(row) {
const element = document.createElement("div");
element.className = "playlist-row playlist-data-row";
element.dataset.rowId = row.rowId;
const enabledCell = document.createElement("div");
enabledCell.className = "enabled-cell";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.addEventListener("change", function () {
send("set-playlist-enabled", {
rowId: element.dataset.rowId,
enabled: enabled.checked
});
});
enabledCell.appendChild(enabled);
element.appendChild(enabledCell);
for (let index = 0; index < 5; index += 1) {
element.appendChild(document.createElement("div"));
}
return element;
}
function renderPlaylist(state) {
state.playlist.forEach(function (row, position) {
let element = playlistRows.children.item(position);
if (!element || element.dataset.rowId !== row.rowId) {
element = createPlaylistElement(row);
if (position < playlistRows.children.length) {
playlistRows.replaceChild(element, playlistRows.children.item(position));
} else {
playlistRows.appendChild(element);
}
}
const enabled = element.querySelector("input");
enabled.checked = row.isEnabled;
enabled.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
[row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText]
.forEach(function (text, cellIndex) {
const cell = element.children.item(cellIndex + 1);
cell.textContent = text;
cell.title = text;
});
});
while (playlistRows.children.length > state.playlist.length) {
playlistRows.lastElementChild.remove();
}
}
function renderDialog(state) {
if (state.dialog && state.dialog.message) {
dialogMessage.textContent = state.dialog.message;
dialog.hidden = false;
dialogOk.focus();
} else {
dialog.hidden = true;
}
}
function render(state) {
const isBusy = state.isBusy === true;
uiBusy = isBusy;
document.body.classList.toggle("busy", isBusy);
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
searchInput.readOnly = isBusy;
searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false");
if (document.activeElement !== searchInput) searchInput.value = state.searchText || "";
renderStockResults(state);
renderCuts(state);
renderPlaylist(state);
renderDialog(state);
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
}
searchButton.addEventListener("click", function () {
send("search-stocks", { text: searchInput.value, trigger: "button" });
});
searchInput.addEventListener("keypress", function (event) {
if (event.key === "Enter") {
event.preventDefault();
send("search-stocks", { text: searchInput.value, trigger: "enter" });
}
});
playlistDropZone.addEventListener("dragover", function (event) {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
});
playlistDropZone.addEventListener("drop", function (event) {
event.preventDefault();
send("drop-selected-cuts", {});
});
dialogOk.addEventListener("click", function () {
send("dismiss-dialog", {});
});
if (webview) {
webview.addEventListener("message", function (event) {
if (event.data && event.data.type === "state" && event.data.payload) {
render(event.data.payload);
}
});
send("ready", {});
} else {
status.textContent = "WebView2 브리지를 사용할 수 없습니다.";
}
}());

View File

@@ -0,0 +1,114 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>V-Stock 증권정보송출시스템</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="operator-shell" aria-label="V-Stock 증권정보송출시스템">
<section class="left-panel" aria-label="종목 및 컷 선택">
<header class="brand-line">
<div class="brand"><span>매일경제TV</span><strong>VRi</strong></div>
<div class="connection-state" aria-label="Tornado2 연결 상태">미연결</div>
</header>
<div class="search-line">
<label for="stock-search">종목</label>
<input id="stock-search" maxlength="32767" autocomplete="off" spellcheck="false">
<button id="stock-search-button" type="button"><span aria-hidden="true"></span> 검색</button>
</div>
<div id="stock-results" class="stock-results" role="listbox" aria-label="종목명"></div>
<div class="manual-actions" aria-label="수동 입력">
<button type="button" disabled>주요매출 구성</button>
<button type="button" disabled>성장성 지표</button>
<button type="button" disabled>매출액</button>
<button type="button" disabled>영업이익</button>
</div>
<div id="cut-list" class="cut-list" role="listbox" aria-label="종목 컷" aria-multiselectable="true"></div>
<div id="operator-status" class="operator-status" aria-live="polite"></div>
</section>
<section class="catalog-panel" aria-label="분류별 그래픽">
<nav class="market-tabs" aria-label="시장 분류">
<button class="active" type="button" disabled>해외</button>
<button type="button" disabled>환율</button>
<button type="button" disabled>지수</button>
<button type="button" disabled>코스피</button>
<button type="button" disabled>코스닥</button>
<button type="button" disabled>비교</button>
<button type="button" disabled>테마</button>
<button type="button" disabled>해외종목</button>
<button type="button" disabled>전문가</button>
<button type="button" disabled>정지</button>
</nav>
<label class="expand-line"><input type="checkbox" checked disabled> Expand</label>
<div class="category-title">Category</div>
<div class="category-tree" aria-label="원본 카테고리">
<div class="tree-root"><strong>[1열판기본]</strong></div>
<div class="tree-item">▸ 다우</div>
<div class="tree-item">▸ 나스닥</div>
<div class="tree-item">▸ S&amp;P</div>
<div class="tree-item">▸ 독일</div>
<div class="tree-item">▸ 영국</div>
<div class="tree-item">▸ 프랑스</div>
<div class="tree-item">▸ 니케이</div>
<div class="tree-item">▸ 중국 상해</div>
<div class="tree-item">▸ 홍콩 항셍</div>
<div class="tree-item">▸ 대만 가권</div>
<div class="tree-item">▸ 싱가포르 지수</div>
<div class="tree-item">▸ 필리핀 지수</div>
<div class="tree-item">▸ 말레이시아 지수</div>
<div class="tree-root second"><strong>[2열판-]</strong></div>
<div class="tree-item">▸ 공산품 [원면, 목화(면화)]</div>
<div class="tree-item">▸ 원자재 [국제 금, 국제 은]</div>
</div>
</section>
<section class="playlist-panel" aria-label="플레이리스트">
<div class="playlist-toolbar">
<label><input type="checkbox" checked disabled> 전체</label>
<label><input type="checkbox" checked disabled> 5일선</label>
<label><input type="checkbox" checked disabled> 20일선</label>
<button type="button" disabled></button>
<button type="button" disabled></button>
<span class="toolbar-spacer"></span>
<label><input type="checkbox" disabled> 배경없음(F3)</label>
<button type="button" disabled>배경파일(F2)</button>
<input class="background-name" value="기본.vrv" readonly>
<button type="button" disabled>DB 저장</button>
<button type="button" disabled>DB 불러오기</button>
<button type="button" disabled>선택삭제</button>
<button type="button" disabled>전체삭제</button>
</div>
<div id="playlist-drop-zone" class="playlist-grid">
<div class="playlist-header playlist-row">
<div>송출</div><div>종류</div><div>종목</div><div>컷파일</div><div>세부사항</div><div>Page</div>
</div>
<div id="playlist-rows" class="playlist-rows"></div>
</div>
<div class="playout-actions">
<button class="take-in" type="button" disabled> TAKE IN [F8]</button>
<button class="next" type="button" disabled> NEXT</button>
<button class="take-out" type="button" disabled>✖ TAKE OUT[Esc]</button>
</div>
<div class="playout-status">원본 호환 UI 기준선 · 송출 연결 안 함</div>
</section>
</main>
<div id="legacy-dialog" class="dialog-backdrop" hidden>
<div class="legacy-dialog" role="alertdialog" aria-modal="true" aria-labelledby="legacy-dialog-message">
<div id="legacy-dialog-message"></div>
<button id="legacy-dialog-ok" type="button">확인</button>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,92 @@
:root {
color-scheme: light;
font-family: "NanumGothic", "맑은 고딕", sans-serif;
font-size: 13px;
background: #efefef;
color: #111;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body.busy { cursor: wait; }
body.busy .operator-shell { pointer-events: none; }
button, input { font: inherit; }
button { border: 1px solid #a8a8a8; background: linear-gradient(#fff, #e9e9e9); color: #111; }
button:not(:disabled) { cursor: pointer; }
button:disabled { color: #555; opacity: 1; }
.operator-shell {
display: grid;
grid-template-columns: 430px 500px minmax(760px, 1fr);
width: 100%;
min-width: 1690px;
height: 100vh;
background: #efefef;
}
.left-panel, .catalog-panel, .playlist-panel { min-height: 0; border-right: 1px solid #bbb; }
.left-panel { display: flex; flex-direction: column; padding: 6px 15px 4px 20px; background: #f5f5f5; }
.brand-line { height: 68px; display: flex; align-items: center; justify-content: space-between; }
.brand { display: flex; align-items: center; gap: 12px; white-space: nowrap; }
.brand span { color: #ed671f; font-size: 28px; font-weight: 800; letter-spacing: -2px; }
.brand strong { font: italic 900 38px Arial, sans-serif; letter-spacing: -5px; }
.connection-state { padding: 10px 12px; background: #c40000; color: white; font: 700 18px Consolas, monospace; }
.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; }
.search-line label { font-weight: 700; }
.search-line input { grid-column: 2; height: 27px; padding: 2px 7px; border: 1px solid #888; font-size: 16px; }
.search-line button { grid-column: 4; height: 31px; font-size: 14px; }
.search-line button span { font-size: 21px; margin-right: 20px; }
.stock-results { height: 174px; border: 1px solid #999; background: #fff; overflow-y: auto; }
.stock-result { display: block; width: 100%; height: 20px; padding: 1px 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 18px; }
.stock-result.selected { background: #696969; color: #fff; }
.manual-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 9px 0; }
.manual-actions button { height: 34px; font-size: 12px; font-weight: 700; }
.cut-list { flex: 1; min-height: 0; border: 1px solid #999; background: #fff; overflow-y: auto; user-select: none; }
.cut-row { display: grid; grid-template-columns: 62px 1fr; min-height: 23px; border-bottom: 1px solid #ddd; background: #fff; font-size: 15px; }
.cut-row > span { padding: 2px 7px; pointer-events: none; }
.cut-row .cut-number { border-right: 1px solid #ddd; text-align: center; color: #145ab4; }
.cut-row.selected { background: #dcdcdc; }
.cut-row.separator { background: #fff; }
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; }
.market-tabs { display: flex; height: 29px; border-bottom: 1px solid #999; }
.market-tabs button { min-width: 48px; padding: 3px 8px; border-width: 0 1px 0 0; background: #efefef; }
.market-tabs button.active { background: #fff; border-top: 2px solid #e8781d; }
.expand-line { height: 31px; padding: 7px 8px 0; }
.category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; }
.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; }
.tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; }
.tree-root.second { margin-top: 15px; }
.tree-item { padding: 3px 0 3px 29px; font-size: 15px; }
.playlist-panel { display: grid; grid-template-rows: 58px minmax(0, 1fr) 76px 42px; padding: 0 8px; background: #efefef; }
.playlist-toolbar { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.playlist-toolbar button { height: 25px; padding: 2px 9px; }
.playlist-toolbar .toolbar-spacer { flex: 1; }
.background-name { width: 95px; height: 25px; border: 1px solid #888; background: #fff; }
.playlist-grid { min-height: 0; border: 1px solid #888; background: #d5d5d5; overflow: auto; }
.playlist-row { display: grid; grid-template-columns: 40px 100px 260px 200px minmax(255px, 1fr) 52px; min-width: 907px; }
.playlist-header { position: sticky; top: 0; z-index: 2; height: 29px; background: #efefef; border-bottom: 1px solid #999; }
.playlist-header > div { display: flex; align-items: center; justify-content: center; border-right: 1px solid #aaa; }
.playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; }
.playlist-data-row > div { padding: 3px 6px; border-right: 1px solid #ddd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.playlist-data-row .enabled-cell { text-align: center; }
.playout-actions { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; }
.take-in { width: 215px; }
.next { width: 180px; }
.take-out { width: 212px; }
.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; }
.dialog-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .25); }
.dialog-backdrop[hidden] { display: none; }
.legacy-dialog { position: absolute; left: 50%; top: 50%; width: 320px; padding: 24px; transform: translate(-50%, -50%); border: 1px solid #777; background: #fff; box-shadow: 0 8px 30px rgba(0, 0, 0, .3); text-align: center; }
.legacy-dialog button { min-width: 80px; margin-top: 24px; padding: 6px 15px; }

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MBN_STOCK_WEBVIEW.LegacyParityApp" />
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>