feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -1054,13 +1054,14 @@ internal static class ManualFinancialValidation
|
||||
new ManualRevenueCompositionRecord(
|
||||
identity,
|
||||
Text(
|
||||
RequiredString(row, "BASE_DATE"),
|
||||
OptionalOracleString(row, "BASE_DATE"),
|
||||
MaximumTextLength,
|
||||
allowEmpty: true,
|
||||
allowUnderscore: true,
|
||||
nameof(row)),
|
||||
Enumerable.Range(1, 5)
|
||||
.Select(index => ParseSlice(RequiredString(row, "GUSUNG_" + index)))
|
||||
.Select(index => ParseSlice(
|
||||
OptionalOracleString(row, "GUSUNG_" + index)))
|
||||
.ToArray()),
|
||||
ManualFinancialScreenKind.GrowthMetrics => ParseGrowth(identity, row),
|
||||
ManualFinancialScreenKind.Sales =>
|
||||
@@ -1324,6 +1325,23 @@ internal static class ManualFinancialValidation
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string OptionalOracleString(DataRow row, string columnName)
|
||||
{
|
||||
if (!row.Table.Columns.Contains(columnName))
|
||||
{
|
||||
throw new ArgumentException("A manual-financial database value is invalid.", nameof(row));
|
||||
}
|
||||
|
||||
return row[columnName] switch
|
||||
{
|
||||
string value => value,
|
||||
DBNull => string.Empty,
|
||||
_ => throw new ArgumentException(
|
||||
"A manual-financial database value is invalid.",
|
||||
nameof(row))
|
||||
};
|
||||
}
|
||||
|
||||
private static string RowVersion(string value, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
|
||||
@@ -293,6 +293,72 @@ public sealed class LegacyPlayoutWorkflow
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduces the visible MainForm TAKE IN/F8 path from idle: ONAirMode builds and
|
||||
/// prepares the selected scene, then Play starts that exact prepared instance. The
|
||||
/// page DTO is therefore loaded once and the workflow gate covers both engine calls.
|
||||
/// This is intentionally separate from <see cref="TakeInAsync"/>, which preserves the
|
||||
/// legacy explicit-PREPARE path and refreshes its page before play.
|
||||
/// </summary>
|
||||
public async Task<PlayoutResult> PrepareAndTakeInAsync(
|
||||
IReadOnlyList<LegacyPlaylistEntry> playlist,
|
||||
int cueIndexZeroBased,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_prepared is not null || _onAir is not null)
|
||||
{
|
||||
return Rejected(
|
||||
PlayoutOperation.TakeIn,
|
||||
"One-click TAKE IN can start only while the playout workflow is idle.");
|
||||
}
|
||||
|
||||
var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased);
|
||||
var selectedIndex = FindNextEnabledCue(snapshot, cueIndexZeroBased);
|
||||
if (selectedIndex is null)
|
||||
{
|
||||
return Rejected(
|
||||
PlayoutOperation.Prepare,
|
||||
"There is no enabled playlist entry at or after the selection.");
|
||||
}
|
||||
|
||||
var page = await CreatePageAsync(
|
||||
snapshot,
|
||||
selectedIndex.Value,
|
||||
pageIndexZeroBased: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var prepare = await _engine.PrepareAsync(page.Page.Cue, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!prepare.IsSuccess)
|
||||
{
|
||||
return prepare;
|
||||
}
|
||||
|
||||
// Once PREPARE succeeds the engine owns this scene. Keep that prepared
|
||||
// state if TAKE IN fails or its outcome is unknown; never reload or retry.
|
||||
_playlist = snapshot;
|
||||
_prepared = page;
|
||||
PublishState();
|
||||
|
||||
var takeIn = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (takeIn.IsSuccess)
|
||||
{
|
||||
_onAir = page;
|
||||
_prepared = null;
|
||||
PublishState();
|
||||
}
|
||||
|
||||
return takeIn;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ApplyRequestedEngineClear();
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlayoutResult> TakeInAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the already parameterized domestic/world lookup services to the closed UC3
|
||||
/// comparison identity model. Database identities never cross the WebView boundary.
|
||||
/// </summary>
|
||||
public sealed class LegacyComparisonDataService : ILegacyComparisonDataService
|
||||
{
|
||||
private readonly ILegacyStockLookup _domesticLookup;
|
||||
private readonly IWorldStockSearchService _worldLookup;
|
||||
|
||||
public LegacyComparisonDataService(
|
||||
ILegacyStockLookup domesticLookup,
|
||||
IWorldStockSearchService worldLookup)
|
||||
{
|
||||
_domesticLookup = domesticLookup ?? throw new ArgumentNullException(nameof(domesticLookup));
|
||||
_worldLookup = worldLookup ?? throw new ArgumentNullException(nameof(worldLookup));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.DomesticMaximumResults);
|
||||
var result = await _domesticLookup.SearchAsync(query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var targets = new List<LegacyComparisonTarget>(
|
||||
Math.Min(result.DisplayNames.Count, maximumResults));
|
||||
var identities = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var displayName in result.DisplayNames)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var identity = result.ResolveLastByDisplayName(displayName);
|
||||
if (identity is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = ToComparisonTarget(identity);
|
||||
if (identities.Add(target.TargetId))
|
||||
{
|
||||
targets.Add(target);
|
||||
if (targets.Count == maximumResults)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets.AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.WorldMaximumResults);
|
||||
var result = await _worldLookup.SearchAsync(query, maximumResults, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return Array.AsReadOnly(result.Items
|
||||
.Select(item => LegacyComparisonTarget.CreateWorldStock(
|
||||
item.InputName,
|
||||
item.Symbol,
|
||||
item.NationCode))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
|
||||
LegacyComparisonStoredTargetReference target,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (target.Market.Length == 0)
|
||||
{
|
||||
var world = await SearchWorldAsync(
|
||||
target.Name,
|
||||
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return world.FirstOrDefault(item =>
|
||||
LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target));
|
||||
}
|
||||
|
||||
var domestic = await SearchDomesticAsync(
|
||||
RemoveNxtSuffix(target.Name),
|
||||
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return domestic.FirstOrDefault(item =>
|
||||
LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target));
|
||||
}
|
||||
|
||||
private static LegacyComparisonTarget ToComparisonTarget(LegacyStockIdentity identity)
|
||||
{
|
||||
var name = RemoveNxtSuffix(identity.DisplayName);
|
||||
return identity.Market switch
|
||||
{
|
||||
LegacyDomesticStockMarket.Kospi => LegacyComparisonTarget.CreateKrxStock(
|
||||
LegacyComparisonDomesticMarket.Kospi,
|
||||
name,
|
||||
identity.StockCode),
|
||||
LegacyDomesticStockMarket.Kosdaq => LegacyComparisonTarget.CreateKrxStock(
|
||||
LegacyComparisonDomesticMarket.Kosdaq,
|
||||
name,
|
||||
identity.StockCode),
|
||||
LegacyDomesticStockMarket.NxtKospi => LegacyComparisonTarget.CreateNxtStock(
|
||||
LegacyComparisonDomesticMarket.Kospi,
|
||||
name,
|
||||
identity.StockCode),
|
||||
LegacyDomesticStockMarket.NxtKosdaq => LegacyComparisonTarget.CreateNxtStock(
|
||||
LegacyComparisonDomesticMarket.Kosdaq,
|
||||
name,
|
||||
identity.StockCode),
|
||||
_ => throw new InvalidOperationException("The domestic stock market is invalid.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string RemoveNxtSuffix(string value) =>
|
||||
value.EndsWith("(NXT)", StringComparison.Ordinal)
|
||||
? value[..^5]
|
||||
: value;
|
||||
|
||||
private static void ValidateLimit(int value, int maximum)
|
||||
{
|
||||
if (value is < 1 || value > maximum)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trusted, fixed-path CP949 store for the original 종목비교.dat shape. Reads may fall
|
||||
/// back to a read-only seed, but all writes are atomic and confined to the local path.
|
||||
/// </summary>
|
||||
public sealed class LegacyComparisonPairFileStore : ILegacyComparisonPairStore
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly string? _readOnlySeedPath;
|
||||
|
||||
public LegacyComparisonPairFileStore(string path, string? readOnlySeedPath = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
_path = Path.GetFullPath(path);
|
||||
_readOnlySeedPath = string.IsNullOrWhiteSpace(readOnlySeedPath)
|
||||
? null
|
||||
: Path.GetFullPath(readOnlySeedPath);
|
||||
}
|
||||
|
||||
public async Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = File.Exists(_path)
|
||||
? _path
|
||||
: _readOnlySeedPath is not null && File.Exists(_readOnlySeedPath)
|
||||
? _readOnlySeedPath
|
||||
: null;
|
||||
return source is null
|
||||
? null
|
||||
: await File.ReadAllBytesAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task WriteCp949Async(
|
||||
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
if (rows.Count > MMoneyCoderSharp.Data.LegacyComparisonFileParser.MaximumRows)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(rows));
|
||||
}
|
||||
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
var cp949 = Encoding.GetEncoding(
|
||||
949,
|
||||
EncoderFallback.ExceptionFallback,
|
||||
DecoderFallback.ExceptionFallback);
|
||||
var text = string.Join(
|
||||
'\n',
|
||||
rows.Select((row, index) => SerializeRow(row, index + 1)));
|
||||
var bytes = cp949.GetBytes(text);
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
_ = MMoneyCoderSharp.Data.LegacyComparisonFileParser.Parse(bytes);
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(_path) ??
|
||||
throw new InvalidOperationException("The comparison data path has no directory.");
|
||||
Directory.CreateDirectory(directory);
|
||||
var temporaryPath = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(_path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(temporaryPath, bytes, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
File.Move(temporaryPath, _path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeRow(LegacyComparisonPairWriteRow row, int expectedNumber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
if (row.RowNumber != expectedNumber)
|
||||
{
|
||||
throw new InvalidOperationException("Comparison row numbers must be contiguous.");
|
||||
}
|
||||
|
||||
return string.Join(
|
||||
'^',
|
||||
row.RowNumber.ToString(CultureInfo.InvariantCulture),
|
||||
$"{row.FirstName},{row.SecondName}",
|
||||
row.FirstMarket,
|
||||
row.SecondMarket,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
internal enum LegacyComparisonActionKind
|
||||
{
|
||||
TwoColumn,
|
||||
Comparison,
|
||||
Return
|
||||
}
|
||||
|
||||
internal sealed record LegacyComparisonActionDefinition(
|
||||
string ActionId,
|
||||
string Label,
|
||||
string Section,
|
||||
LegacyComparisonActionKind Kind,
|
||||
string? Mode = null,
|
||||
string? BuilderKey = null,
|
||||
string? CutCode = null,
|
||||
string? GraphicType = null,
|
||||
string? Period = null);
|
||||
|
||||
internal sealed record LegacyComparisonCompatibility(
|
||||
bool IsAvailable,
|
||||
string? Reason,
|
||||
string? EffectiveMode,
|
||||
string? Warning);
|
||||
|
||||
internal static class LegacyComparisonWorkflowCatalog
|
||||
{
|
||||
public const int DefaultFadeDuration = 6;
|
||||
public const int DomesticMaximumResults = 200;
|
||||
public const int WorldMaximumResults = 100;
|
||||
|
||||
private static readonly ReadOnlyCollection<LegacyComparisonTarget> FixedTargetList =
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
Market("kospi", "코스피 지수", "Kospi"),
|
||||
Market("kosdaq", "코스닥 지수", "Kosdaq"),
|
||||
Market("kospi200", "코스피200 지수", "Kospi200"),
|
||||
Market("futures", "선물 지수", "Futures"),
|
||||
Market("krx100", "KRX100지수", "Krx100"),
|
||||
Market("won-dollar", "환율-원달러", "WonDollar"),
|
||||
Market("won-yen", "환율-원엔", "WonYen"),
|
||||
Market("won-yuan", "환율-원위엔", "WonYuan"),
|
||||
Market("won-euro", "환율-원유로", "WonEuro"),
|
||||
Market("dow", "해외지수-다우", "Dow"),
|
||||
Market("nasdaq", "해외지수-나스닥", "Nasdaq"),
|
||||
Market("sp500", "해외지수-S&P", "Sp500"),
|
||||
Market("germany-dax", "해외지수-독일", "GermanyDax"),
|
||||
Market("united-kingdom-ftse", "해외지수-영국", "UnitedKingdomFtse"),
|
||||
Market("france-cac", "해외지수-프랑스", "FranceCac"),
|
||||
Market("nikkei", "해외지수-일본", "Nikkei"),
|
||||
Market("hang-seng", "해외지수-홍콩", "HangSeng"),
|
||||
Market("taiwan-weighted", "해외지수-대만", "TaiwanWeighted"),
|
||||
Market("singapore-straits-times", "해외지수-싱가포르", "SingaporeStraitsTimes"),
|
||||
Market("thailand-set", "해외지수-태국", "ThailandSet"),
|
||||
Market("philippines-composite", "해외지수-필리핀", "PhilippinesComposite"),
|
||||
Market("malaysia-klse", "해외지수-말레이시아", "MalaysiaKlse"),
|
||||
Market("indonesia-composite", "해외지수-인도네시아", "IndonesiaComposite"),
|
||||
Market("shanghai-composite", "해외지수-중국", "ShanghaiComposite")
|
||||
});
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyComparisonTarget> FixedById =
|
||||
FixedTargetList.ToDictionary(static target => target.TargetId, StringComparer.Ordinal);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyComparisonTarget> FixedByDisplayName =
|
||||
FixedTargetList.ToDictionary(static target => target.DisplayName, StringComparer.Ordinal);
|
||||
|
||||
private static readonly ReadOnlyCollection<LegacyComparisonActionDefinition> ActionList =
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
new LegacyComparisonActionDefinition(
|
||||
"two-column-expected", "2열판 예상체결", "2열판",
|
||||
LegacyComparisonActionKind.TwoColumn, Mode: "EXPECTED"),
|
||||
new LegacyComparisonActionDefinition(
|
||||
"two-column-current", "2열판", "2열판",
|
||||
LegacyComparisonActionKind.TwoColumn, Mode: "CURRENT"),
|
||||
new LegacyComparisonActionDefinition(
|
||||
"comparison-candle", "종목별 비교분석_캔들 그래프", "종목별 비교분석",
|
||||
LegacyComparisonActionKind.Comparison,
|
||||
BuilderKey: "s5026", CutCode: "5026",
|
||||
GraphicType: "COMPARISON_CANDLE", Period: "FiveDays"),
|
||||
new LegacyComparisonActionDefinition(
|
||||
"comparison-line", "종목별 비교분석_라인 그래프", "종목별 비교분석",
|
||||
LegacyComparisonActionKind.Comparison,
|
||||
BuilderKey: "s5087", CutCode: "5087",
|
||||
GraphicType: "COMPARISON_LINE", Period: "FiveDays"),
|
||||
Return("return-5d", "5일", "FiveDays"),
|
||||
Return("return-1m", "1개월", "OneMonth"),
|
||||
Return("return-3m", "3개월", "ThreeMonths"),
|
||||
Return("return-6m", "6개월", "SixMonths"),
|
||||
Return("return-12m", "12개월", "TwelveMonths")
|
||||
});
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyComparisonActionDefinition> ActionsById =
|
||||
ActionList.ToDictionary(static action => action.ActionId, StringComparer.Ordinal);
|
||||
|
||||
private static readonly HashSet<string> ExpectedMarketTargets =
|
||||
new(StringComparer.Ordinal) { "Kospi", "Kosdaq", "Kospi200", "Krx100" };
|
||||
|
||||
static LegacyComparisonWorkflowCatalog()
|
||||
{
|
||||
if (FixedTargetList.Count != 24 || FixedById.Count != 24 ||
|
||||
ActionList.Count != 9 || ActionsById.Count != 9)
|
||||
{
|
||||
throw new InvalidOperationException("The closed UC3 comparison catalog is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList;
|
||||
|
||||
public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
|
||||
targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
|
||||
|
||||
public static LegacyComparisonTarget RequireCanonicalTarget(LegacyComparisonTarget target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (target.Kind != LegacyComparisonTargetKind.MarketTarget)
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
if (!FixedById.TryGetValue(target.TargetId, out var canonical) || canonical != target)
|
||||
{
|
||||
throw new InvalidOperationException("The data service returned an unknown market target.");
|
||||
}
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
public static LegacyComparisonTargetView ToView(
|
||||
LegacyComparisonTarget target,
|
||||
string selectionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
LegacyComparisonValueGuard.RequireIdentifier(selectionId, 128, nameof(selectionId));
|
||||
var metadata = target.Kind switch
|
||||
{
|
||||
LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!,
|
||||
LegacyComparisonTargetKind.WorldStock => $"{target.Nation} · {target.Symbol}",
|
||||
LegacyComparisonTargetKind.KrxStock =>
|
||||
$"{MarketText(target.DomesticMarket)} KRX · {target.StockCode}",
|
||||
LegacyComparisonTargetKind.NxtStock =>
|
||||
$"{MarketText(target.DomesticMarket)} NXT · {target.StockCode}",
|
||||
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
|
||||
};
|
||||
return new LegacyComparisonTargetView(
|
||||
selectionId,
|
||||
target.DisplayName,
|
||||
metadata,
|
||||
target.Kind);
|
||||
}
|
||||
|
||||
public static LegacyComparisonStoredTargetReference ToStoredReference(
|
||||
LegacyComparisonTarget target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
return target.Kind switch
|
||||
{
|
||||
LegacyComparisonTargetKind.MarketTarget =>
|
||||
new LegacyComparisonStoredTargetReference(target.DisplayName, string.Empty),
|
||||
LegacyComparisonTargetKind.KrxStock =>
|
||||
new LegacyComparisonStoredTargetReference(
|
||||
target.StockName!,
|
||||
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi
|
||||
? "코스피"
|
||||
: "코스닥"),
|
||||
LegacyComparisonTargetKind.NxtStock =>
|
||||
new LegacyComparisonStoredTargetReference(
|
||||
target.DisplayName,
|
||||
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi
|
||||
? "코스피_NXT"
|
||||
: "코스닥_NXT"),
|
||||
LegacyComparisonTargetKind.WorldStock =>
|
||||
new LegacyComparisonStoredTargetReference(target.InputName!, string.Empty),
|
||||
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
|
||||
};
|
||||
}
|
||||
|
||||
public static bool MatchesStoredReference(
|
||||
LegacyComparisonTarget target,
|
||||
LegacyComparisonStoredTargetReference stored) =>
|
||||
ToStoredReference(target) == stored;
|
||||
|
||||
public static LegacyComparisonTarget? TryResolveFixedStoredTarget(
|
||||
LegacyComparisonStoredTargetReference stored) =>
|
||||
stored.Market.Length == 0 &&
|
||||
FixedByDisplayName.TryGetValue(stored.Name, out var target)
|
||||
? target
|
||||
: null;
|
||||
|
||||
public static IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
|
||||
LegacyComparisonTarget? first,
|
||||
LegacyComparisonTarget? second)
|
||||
{
|
||||
var views = ActionList.Select(action =>
|
||||
{
|
||||
var compatibility = GetCompatibility(action, first, second);
|
||||
return new LegacyComparisonActionView(
|
||||
action.ActionId,
|
||||
action.Label,
|
||||
action.Section,
|
||||
compatibility.IsAvailable,
|
||||
compatibility.Reason);
|
||||
}).ToArray();
|
||||
return Array.AsReadOnly(views);
|
||||
}
|
||||
|
||||
public static LegacyGenericPlaylistDraft Materialize(
|
||||
string actionId,
|
||||
LegacyComparisonTarget first,
|
||||
LegacyComparisonTarget second,
|
||||
string draftId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (!ActionsById.TryGetValue(actionId, out var action))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(actionId), "The comparison action is unknown.");
|
||||
}
|
||||
|
||||
var compatibility = GetCompatibility(action, first, second);
|
||||
if (!compatibility.IsAvailable)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The selected pair cannot use this comparison action: {compatibility.Reason}.");
|
||||
}
|
||||
|
||||
LegacyComparisonValueGuard.RequireIdentifier(draftId, 128, nameof(draftId));
|
||||
var subject = $"{SubjectToken(first)},{SubjectToken(second)}";
|
||||
string builderKey;
|
||||
string cutCode;
|
||||
string graphicType;
|
||||
string subtype;
|
||||
string dataCode;
|
||||
string category;
|
||||
if (action.Kind == LegacyComparisonActionKind.TwoColumn)
|
||||
{
|
||||
var hasFutures = IsFutures(first) || IsFutures(second);
|
||||
builderKey = hasFutures ? "s5032" : "s8018";
|
||||
cutCode = hasFutures ? "5032" : "8032";
|
||||
graphicType = "2열판";
|
||||
subtype = action.Mode!;
|
||||
dataCode = string.Empty;
|
||||
category = "plate";
|
||||
}
|
||||
else
|
||||
{
|
||||
builderKey = action.Kind == LegacyComparisonActionKind.Return
|
||||
? "s5029"
|
||||
: action.BuilderKey!;
|
||||
cutCode = action.Kind == LegacyComparisonActionKind.Return
|
||||
? "5029"
|
||||
: action.CutCode!;
|
||||
graphicType = action.Kind == LegacyComparisonActionKind.Return
|
||||
? "RETURN_COMPARISON"
|
||||
: action.GraphicType!;
|
||||
subtype = action.Period!;
|
||||
dataCode = ExactKrxIdentityEnvelope(first, second);
|
||||
category = "chart";
|
||||
}
|
||||
|
||||
var selection = new LegacySceneSelection(
|
||||
first.Kind == LegacyComparisonTargetKind.MarketTarget ||
|
||||
second.Kind == LegacyComparisonTargetKind.MarketTarget
|
||||
? "INDEX"
|
||||
: "STOCK",
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
dataCode);
|
||||
return new LegacyGenericPlaylistDraft(
|
||||
draftId,
|
||||
builderKey,
|
||||
cutCode,
|
||||
Array.AsReadOnly(new[] { cutCode }),
|
||||
$"{first.DisplayName} ↔ {second.DisplayName}",
|
||||
action.Label,
|
||||
category,
|
||||
"compare",
|
||||
selection,
|
||||
true,
|
||||
DefaultFadeDuration,
|
||||
"legacy-comparison-workflow",
|
||||
action.ActionId,
|
||||
compatibility.EffectiveMode,
|
||||
compatibility.Warning);
|
||||
}
|
||||
|
||||
public static string PairSignature(
|
||||
LegacyComparisonTarget first,
|
||||
LegacyComparisonTarget second)
|
||||
{
|
||||
var firstStored = ToStoredReference(first);
|
||||
var secondStored = ToStoredReference(second);
|
||||
return string.Join(
|
||||
'\u001f',
|
||||
firstStored.Name,
|
||||
firstStored.Market,
|
||||
secondStored.Name,
|
||||
secondStored.Market);
|
||||
}
|
||||
|
||||
private static LegacyComparisonCompatibility GetCompatibility(
|
||||
LegacyComparisonActionDefinition action,
|
||||
LegacyComparisonTarget? first,
|
||||
LegacyComparisonTarget? second)
|
||||
{
|
||||
if (first is null || second is null)
|
||||
{
|
||||
return Unavailable("pair-incomplete-or-invalid");
|
||||
}
|
||||
|
||||
if (action.Kind is LegacyComparisonActionKind.Comparison or LegacyComparisonActionKind.Return)
|
||||
{
|
||||
return first.Kind == LegacyComparisonTargetKind.KrxStock &&
|
||||
second.Kind == LegacyComparisonTargetKind.KrxStock
|
||||
? Available("GRAPH")
|
||||
: Unavailable("comparison-graphs-require-two-krx-stocks");
|
||||
}
|
||||
|
||||
var futuresCount = (IsFutures(first) ? 1 : 0) + (IsFutures(second) ? 1 : 0);
|
||||
if (futuresCount > 1)
|
||||
{
|
||||
return Unavailable("two-futures-targets-are-unsupported");
|
||||
}
|
||||
|
||||
if (futuresCount == 1)
|
||||
{
|
||||
var counterpart = IsFutures(first) ? second : first;
|
||||
if (counterpart.Kind != LegacyComparisonTargetKind.MarketTarget)
|
||||
{
|
||||
return Unavailable("futures-requires-a-market-target-counterpart");
|
||||
}
|
||||
|
||||
return action.Mode == "EXPECTED"
|
||||
? Available(
|
||||
"CURRENT",
|
||||
"The current Core routes a futures expected action through the current s5032 branch.")
|
||||
: Available("CURRENT");
|
||||
}
|
||||
|
||||
if (action.Mode == "EXPECTED")
|
||||
{
|
||||
var supported = IsExpectedTarget(first) && IsExpectedTarget(second);
|
||||
return supported
|
||||
? Available("EXPECTED")
|
||||
: Unavailable(
|
||||
"expected-mode-supports-only-krx-stocks-and-four-domestic-indices");
|
||||
}
|
||||
|
||||
var hasMarket = first.Kind == LegacyComparisonTargetKind.MarketTarget ||
|
||||
second.Kind == LegacyComparisonTargetKind.MarketTarget;
|
||||
var hasWorld = first.Kind == LegacyComparisonTargetKind.WorldStock ||
|
||||
second.Kind == LegacyComparisonTargetKind.WorldStock;
|
||||
return hasMarket && hasWorld
|
||||
? Unavailable("mixed-market-and-world-is-unsupported")
|
||||
: Available("CURRENT");
|
||||
}
|
||||
|
||||
private static bool IsExpectedTarget(LegacyComparisonTarget target) =>
|
||||
target.Kind == LegacyComparisonTargetKind.KrxStock ||
|
||||
target.Kind == LegacyComparisonTargetKind.MarketTarget &&
|
||||
ExpectedMarketTargets.Contains(target.MarketTarget!);
|
||||
|
||||
private static bool IsFutures(LegacyComparisonTarget target) =>
|
||||
target.Kind == LegacyComparisonTargetKind.MarketTarget &&
|
||||
string.Equals(target.MarketTarget, "Futures", StringComparison.Ordinal);
|
||||
|
||||
private static string SubjectToken(LegacyComparisonTarget target) => target.Kind switch
|
||||
{
|
||||
LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!,
|
||||
LegacyComparisonTargetKind.KrxStock => target.StockName!,
|
||||
LegacyComparisonTargetKind.NxtStock => target.DisplayName,
|
||||
LegacyComparisonTargetKind.WorldStock => target.InputName!,
|
||||
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
|
||||
};
|
||||
|
||||
private static string ExactKrxIdentityEnvelope(
|
||||
LegacyComparisonTarget first,
|
||||
LegacyComparisonTarget second)
|
||||
{
|
||||
if (first.Kind != LegacyComparisonTargetKind.KrxStock ||
|
||||
second.Kind != LegacyComparisonTargetKind.KrxStock)
|
||||
{
|
||||
throw new InvalidOperationException("Comparison graphs require two KRX stocks.");
|
||||
}
|
||||
|
||||
return $"{MarketIdentity(first)}:{first.StockCode}|{MarketIdentity(second)}:{second.StockCode}";
|
||||
}
|
||||
|
||||
private static string MarketIdentity(LegacyComparisonTarget target) =>
|
||||
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi ? "KOSPI" : "KOSDAQ";
|
||||
|
||||
private static string MarketText(LegacyComparisonDomesticMarket? market) => market switch
|
||||
{
|
||||
LegacyComparisonDomesticMarket.Kospi => "KOSPI",
|
||||
LegacyComparisonDomesticMarket.Kosdaq => "KOSDAQ",
|
||||
_ => throw new InvalidOperationException("The domestic comparison market is missing.")
|
||||
};
|
||||
|
||||
private static LegacyComparisonCompatibility Available(
|
||||
string effectiveMode,
|
||||
string? warning = null) =>
|
||||
new(true, null, effectiveMode, warning);
|
||||
|
||||
private static LegacyComparisonCompatibility Unavailable(string reason) =>
|
||||
new(false, reason, null, null);
|
||||
|
||||
private static LegacyComparisonTarget Market(
|
||||
string id,
|
||||
string displayName,
|
||||
string target) =>
|
||||
LegacyComparisonTarget.CreateMarketTarget(id, displayName, target);
|
||||
|
||||
private static LegacyComparisonActionDefinition Return(
|
||||
string id,
|
||||
string label,
|
||||
string period) =>
|
||||
new(
|
||||
id,
|
||||
label,
|
||||
"종목별 수익률 비교",
|
||||
LegacyComparisonActionKind.Return,
|
||||
Period: period);
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
/// <summary>
|
||||
/// C# authority for the original UC3 comparison workflow. Search results and fixed targets
|
||||
/// are selected by opaque ids, saved bytes are parsed by the strict Core CP949 parser, and
|
||||
/// an action id is the only caller-supplied value used to materialize a playlist draft.
|
||||
/// </summary>
|
||||
public sealed class LegacyComparisonWorkflowController
|
||||
{
|
||||
private readonly ILegacyComparisonDataService _dataService;
|
||||
private readonly ILegacyComparisonPairStore _pairStore;
|
||||
private List<SearchResultState> _domesticResults = [];
|
||||
private List<SearchResultState> _worldResults = [];
|
||||
private List<SavedPairState> _savedPairs = [];
|
||||
private LegacyComparisonTarget? _firstTarget;
|
||||
private LegacyComparisonTarget? _secondTarget;
|
||||
private string? _selectedDomesticResultId;
|
||||
private string? _selectedWorldResultId;
|
||||
private string? _selectedFixedTargetId;
|
||||
private string? _selectedPairId;
|
||||
private string _domesticQuery = string.Empty;
|
||||
private string _worldQuery = string.Empty;
|
||||
private string _domesticError = string.Empty;
|
||||
private string _worldError = string.Empty;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyComparisonSearchStatus _domesticStatus = LegacyComparisonSearchStatus.Idle;
|
||||
private LegacyComparisonSearchStatus _worldStatus = LegacyComparisonSearchStatus.Idle;
|
||||
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
private long _revision;
|
||||
private long _pairSequence;
|
||||
private long _draftSequence;
|
||||
private int _domesticSearchVersion;
|
||||
private int _worldSearchVersion;
|
||||
private bool _isBusy;
|
||||
|
||||
public LegacyComparisonWorkflowController(
|
||||
ILegacyComparisonDataService dataService,
|
||||
ILegacyComparisonPairStore pairStore)
|
||||
{
|
||||
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
|
||||
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> SearchDomesticAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeSearchQuery(query);
|
||||
var version = ++_domesticSearchVersion;
|
||||
_domesticQuery = normalizedQuery;
|
||||
_domesticStatus = LegacyComparisonSearchStatus.Loading;
|
||||
_domesticError = string.Empty;
|
||||
_domesticResults = [];
|
||||
_selectedDomesticResultId = null;
|
||||
Touch();
|
||||
|
||||
try
|
||||
{
|
||||
var results = await _dataService.SearchDomesticAsync(
|
||||
normalizedQuery,
|
||||
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (version != _domesticSearchVersion)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_domesticResults = ValidateSearchResults(
|
||||
results,
|
||||
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
|
||||
domestic: true,
|
||||
version);
|
||||
_domesticStatus = LegacyComparisonSearchStatus.Ready;
|
||||
_domesticError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (version == _domesticSearchVersion)
|
||||
{
|
||||
_domesticStatus = LegacyComparisonSearchStatus.Idle;
|
||||
_domesticError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (version == _domesticSearchVersion)
|
||||
{
|
||||
_domesticResults = [];
|
||||
_domesticStatus = LegacyComparisonSearchStatus.Error;
|
||||
_domesticError = "국내·NXT 종목 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> SearchWorldAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// UC3's blank world query loaded the complete US/TW universe, so blank remains valid.
|
||||
var normalizedQuery = NormalizeSearchQuery(query);
|
||||
var version = ++_worldSearchVersion;
|
||||
_worldQuery = normalizedQuery;
|
||||
_worldStatus = LegacyComparisonSearchStatus.Loading;
|
||||
_worldError = string.Empty;
|
||||
_worldResults = [];
|
||||
_selectedWorldResultId = null;
|
||||
Touch();
|
||||
|
||||
try
|
||||
{
|
||||
var results = await _dataService.SearchWorldAsync(
|
||||
normalizedQuery,
|
||||
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (version != _worldSearchVersion)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_worldResults = ValidateSearchResults(
|
||||
results,
|
||||
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
|
||||
domestic: false,
|
||||
version);
|
||||
_worldStatus = LegacyComparisonSearchStatus.Ready;
|
||||
_worldError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (version == _worldSearchVersion)
|
||||
{
|
||||
_worldStatus = LegacyComparisonSearchStatus.Idle;
|
||||
_worldError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (version == _worldSearchVersion)
|
||||
{
|
||||
_worldResults = [];
|
||||
_worldStatus = LegacyComparisonSearchStatus.Error;
|
||||
_worldError = "미국·대만 종목 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SelectDomesticResult(string selectionId) =>
|
||||
SelectSearchResult(_domesticResults, selectionId, domestic: true);
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SelectWorldResult(string selectionId) =>
|
||||
SelectSearchResult(_worldResults, selectionId, domestic: false);
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SelectFixedTarget(string targetId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetId);
|
||||
if (LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId) is not null)
|
||||
{
|
||||
_selectedFixedTargetId = targetId;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot ChooseDomesticResult(string selectionId) =>
|
||||
ChooseSearchResult(_domesticResults, selectionId, domestic: true);
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot ChooseWorldResult(string selectionId) =>
|
||||
ChooseSearchResult(_worldResults, selectionId, domestic: false);
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot ChooseFixedTarget(string targetId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetId);
|
||||
var target = LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId);
|
||||
if (target is not null)
|
||||
{
|
||||
_selectedFixedTargetId = targetId;
|
||||
RotateDraft(target);
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SwapDraftTargets()
|
||||
{
|
||||
(_firstTarget, _secondTarget) = (_secondTarget, _firstTarget);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot ClearDraftTargets()
|
||||
{
|
||||
_firstTarget = null;
|
||||
_secondTarget = null;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> LoadSavedPairsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BeginBusy();
|
||||
try
|
||||
{
|
||||
var source = await _pairStore.ReadCp949Async(cancellationToken).ConfigureAwait(false);
|
||||
var candidate = new List<SavedPairState>();
|
||||
if (source is { Length: > 0 })
|
||||
{
|
||||
var bytes = source.ToArray();
|
||||
var rows = LegacyComparisonFileParser.Parse(bytes);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var firstReference = new LegacyComparisonStoredTargetReference(
|
||||
row.First.Name,
|
||||
row.First.Market);
|
||||
var secondReference = new LegacyComparisonStoredTargetReference(
|
||||
row.Second.Name,
|
||||
row.Second.Market);
|
||||
var first = await ResolveStoredTargetAsync(
|
||||
firstReference,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var second = await ResolveStoredTargetAsync(
|
||||
secondReference,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
candidate.Add(new SavedPairState(
|
||||
$"comparison-pair-{row.RowNumber:D8}",
|
||||
first,
|
||||
second));
|
||||
}
|
||||
}
|
||||
|
||||
_savedPairs = candidate;
|
||||
_pairSequence = Math.Max(_pairSequence, candidate.Count);
|
||||
_selectedPairId = candidate.FirstOrDefault()?.RowId;
|
||||
_statusMessage = candidate.Count == 0
|
||||
? "저장된 종목 비교쌍이 없습니다."
|
||||
: $"저장된 종목 비교쌍 {candidate.Count}개를 불러왔습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndBusy();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> AddDraftPairAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_firstTarget is null || _secondTarget is null)
|
||||
{
|
||||
throw new InvalidOperationException("Select two comparison targets first.");
|
||||
}
|
||||
|
||||
if (_savedPairs.Count >= LegacyComparisonFileParser.MaximumRows)
|
||||
{
|
||||
throw new InvalidOperationException("The saved comparison pair limit was reached.");
|
||||
}
|
||||
|
||||
var signature = LegacyComparisonWorkflowCatalog.PairSignature(
|
||||
_firstTarget,
|
||||
_secondTarget);
|
||||
if (_savedPairs.Any(pair => string.Equals(
|
||||
LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second),
|
||||
signature,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException("The exact comparison pair is already saved.");
|
||||
}
|
||||
|
||||
var rowId = $"comparison-pair-{++_pairSequence:D8}";
|
||||
var candidate = new List<SavedPairState>(_savedPairs)
|
||||
{
|
||||
new(rowId, _firstTarget, _secondTarget)
|
||||
};
|
||||
return await CommitPairsAsync(
|
||||
candidate,
|
||||
rowId,
|
||||
"종목 비교쌍을 저장했습니다.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SelectSavedPair(string rowId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rowId);
|
||||
if (_savedPairs.Any(pair => string.Equals(pair.RowId, rowId, StringComparison.Ordinal)))
|
||||
{
|
||||
_selectedPairId = rowId;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> MoveSelectedPairAsync(
|
||||
LegacyComparisonPairMoveDirection direction,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Enum.IsDefined(direction))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(direction));
|
||||
}
|
||||
|
||||
var index = SelectedPairIndex();
|
||||
var destination = index + (direction == LegacyComparisonPairMoveDirection.Up ? -1 : 1);
|
||||
if (index < 0 || destination < 0 || destination >= _savedPairs.Count)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var candidate = new List<SavedPairState>(_savedPairs);
|
||||
(candidate[index], candidate[destination]) = (candidate[destination], candidate[index]);
|
||||
return await CommitPairsAsync(
|
||||
candidate,
|
||||
_selectedPairId,
|
||||
"종목 비교쌍 순서를 변경했습니다.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> DeleteSelectedPairAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var index = SelectedPairIndex();
|
||||
if (index < 0)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var candidate = new List<SavedPairState>(_savedPairs);
|
||||
candidate.RemoveAt(index);
|
||||
var nextSelection = candidate.Count == 0
|
||||
? null
|
||||
: candidate[Math.Min(index, candidate.Count - 1)].RowId;
|
||||
return await CommitPairsAsync(
|
||||
candidate,
|
||||
nextSelection,
|
||||
"선택한 종목 비교쌍을 삭제했습니다.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<LegacyComparisonWorkflowSnapshot> DeleteAllPairsAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_savedPairs.Count == 0
|
||||
? Task.FromResult(CreateSnapshot())
|
||||
: CommitPairsAsync(
|
||||
[],
|
||||
null,
|
||||
"종목 비교쌍을 모두 삭제했습니다.",
|
||||
cancellationToken);
|
||||
|
||||
public LegacyGenericPlaylistDraft MaterializeSelectedAction(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (_isBusy)
|
||||
{
|
||||
throw new InvalidOperationException("The comparison workflow is busy.");
|
||||
}
|
||||
|
||||
var index = SelectedPairIndex();
|
||||
if (index < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Select a saved comparison pair first.");
|
||||
}
|
||||
|
||||
var pair = _savedPairs[index];
|
||||
var draftId = $"comparison-draft-{++_draftSequence:D8}";
|
||||
return LegacyComparisonWorkflowCatalog.Materialize(
|
||||
actionId,
|
||||
pair.First,
|
||||
pair.Second,
|
||||
draftId);
|
||||
}
|
||||
|
||||
private LegacyComparisonWorkflowSnapshot ChooseSearchResult(
|
||||
IReadOnlyList<SearchResultState> results,
|
||||
string selectionId,
|
||||
bool domestic)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
var result = results.FirstOrDefault(item =>
|
||||
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal));
|
||||
if (result is not null)
|
||||
{
|
||||
if (domestic)
|
||||
{
|
||||
_selectedDomesticResultId = selectionId;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedWorldResultId = selectionId;
|
||||
}
|
||||
|
||||
RotateDraft(result.Target);
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
private LegacyComparisonWorkflowSnapshot SelectSearchResult(
|
||||
IReadOnlyList<SearchResultState> results,
|
||||
string selectionId,
|
||||
bool domestic)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
if (results.Any(item => string.Equals(
|
||||
item.SelectionId,
|
||||
selectionId,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
if (domestic)
|
||||
{
|
||||
_selectedDomesticResultId = selectionId;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedWorldResultId = selectionId;
|
||||
}
|
||||
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
private void RotateDraft(LegacyComparisonTarget selected)
|
||||
{
|
||||
_secondTarget = _firstTarget;
|
||||
_firstTarget = selected;
|
||||
}
|
||||
|
||||
private async Task<LegacyComparisonTarget> ResolveStoredTargetAsync(
|
||||
LegacyComparisonStoredTargetReference stored,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fixedTarget = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(stored);
|
||||
var resolved = fixedTarget ?? await _dataService.ResolveStoredTargetAsync(
|
||||
stored,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (resolved is null)
|
||||
{
|
||||
throw new InvalidOperationException("A stored comparison target no longer resolves.");
|
||||
}
|
||||
|
||||
resolved = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(resolved);
|
||||
if (!LegacyComparisonWorkflowCatalog.MatchesStoredReference(resolved, stored))
|
||||
{
|
||||
throw new InvalidOperationException("A stored comparison target resolved to a different identity.");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async Task<LegacyComparisonWorkflowSnapshot> CommitPairsAsync(
|
||||
IReadOnlyList<SavedPairState> candidate,
|
||||
string? nextSelection,
|
||||
string successMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
BeginBusy();
|
||||
try
|
||||
{
|
||||
var writeRows = candidate.Select((pair, index) =>
|
||||
{
|
||||
var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First);
|
||||
var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second);
|
||||
return new LegacyComparisonPairWriteRow(
|
||||
index + 1,
|
||||
first.Name,
|
||||
first.Market,
|
||||
second.Name,
|
||||
second.Market);
|
||||
}).ToArray();
|
||||
await _pairStore.WriteCp949Async(
|
||||
Array.AsReadOnly(writeRows),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_savedPairs = candidate.ToList();
|
||||
_selectedPairId = nextSelection;
|
||||
_statusMessage = successMessage;
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_statusMessage = "종목 비교쌍 파일 쓰기를 완료하지 못해 변경을 적용하지 않았습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndBusy();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
private static List<SearchResultState> ValidateSearchResults(
|
||||
IReadOnlyList<LegacyComparisonTarget>? results,
|
||||
int maximumResults,
|
||||
bool domestic,
|
||||
int version)
|
||||
{
|
||||
if (results is null || results.Count > maximumResults)
|
||||
{
|
||||
throw new InvalidOperationException("The comparison search result count is invalid.");
|
||||
}
|
||||
|
||||
var validated = new List<SearchResultState>(results.Count);
|
||||
var targetIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < results.Count; index++)
|
||||
{
|
||||
var target = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(results[index]);
|
||||
var allowedKind = domestic
|
||||
? target.Kind is LegacyComparisonTargetKind.KrxStock or LegacyComparisonTargetKind.NxtStock
|
||||
: target.Kind == LegacyComparisonTargetKind.WorldStock;
|
||||
if (!allowedKind || !targetIds.Add(target.TargetId))
|
||||
{
|
||||
throw new InvalidOperationException("The comparison search result identity is invalid.");
|
||||
}
|
||||
|
||||
validated.Add(new SearchResultState(
|
||||
$"comparison-{(domestic ? "domestic" : "world")}-{version:D4}-{index + 1:D4}",
|
||||
target));
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
private LegacyComparisonWorkflowSnapshot CreateSnapshot()
|
||||
{
|
||||
var selectedPair = SelectedPairIndex() is var selectedIndex && selectedIndex >= 0
|
||||
? _savedPairs[selectedIndex]
|
||||
: null;
|
||||
var domesticResults = _domesticResults
|
||||
.Select(result => LegacyComparisonWorkflowCatalog.ToView(
|
||||
result.Target,
|
||||
result.SelectionId))
|
||||
.ToArray();
|
||||
var worldResults = _worldResults
|
||||
.Select(result => LegacyComparisonWorkflowCatalog.ToView(
|
||||
result.Target,
|
||||
result.SelectionId))
|
||||
.ToArray();
|
||||
var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets
|
||||
.Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
|
||||
.ToArray();
|
||||
var savedPairs = _savedPairs.Select((pair, index) =>
|
||||
new LegacyComparisonSavedPairView(
|
||||
pair.RowId,
|
||||
index + 1,
|
||||
$"{pair.First.DisplayName} ↔ {pair.Second.DisplayName}",
|
||||
LegacyComparisonWorkflowCatalog.ToView(pair.First, $"{pair.RowId}-first"),
|
||||
LegacyComparisonWorkflowCatalog.ToView(pair.Second, $"{pair.RowId}-second"),
|
||||
string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal)))
|
||||
.ToArray();
|
||||
return new LegacyComparisonWorkflowSnapshot(
|
||||
_revision,
|
||||
_isBusy,
|
||||
new LegacyComparisonSearchSnapshot(
|
||||
_domesticQuery,
|
||||
_domesticStatus,
|
||||
Array.AsReadOnly(domesticResults),
|
||||
_selectedDomesticResultId,
|
||||
domesticResults.Length,
|
||||
_domesticError),
|
||||
new LegacyComparisonSearchSnapshot(
|
||||
_worldQuery,
|
||||
_worldStatus,
|
||||
Array.AsReadOnly(worldResults),
|
||||
_selectedWorldResultId,
|
||||
worldResults.Length,
|
||||
_worldError),
|
||||
Array.AsReadOnly(fixedTargets),
|
||||
_selectedFixedTargetId,
|
||||
_firstTarget is null
|
||||
? null
|
||||
: LegacyComparisonWorkflowCatalog.ToView(_firstTarget, "comparison-draft-first"),
|
||||
_secondTarget is null
|
||||
? null
|
||||
: LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
|
||||
Array.AsReadOnly(savedPairs),
|
||||
_selectedPairId,
|
||||
LegacyComparisonWorkflowCatalog.CreateActionViews(
|
||||
selectedPair?.First,
|
||||
selectedPair?.Second),
|
||||
_statusMessage,
|
||||
_statusKind);
|
||||
}
|
||||
|
||||
private int SelectedPairIndex() => _selectedPairId is null
|
||||
? -1
|
||||
: _savedPairs.FindIndex(pair =>
|
||||
string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal));
|
||||
|
||||
private static string NormalizeSearchQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > 64 ||
|
||||
normalized.Any(static character => char.IsControl(character) || char.IsSurrogate(character)))
|
||||
{
|
||||
throw new ArgumentException("A comparison query must be at most 64 safe characters.", nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void BeginBusy()
|
||||
{
|
||||
if (_isBusy)
|
||||
{
|
||||
throw new InvalidOperationException("The comparison workflow is already busy.");
|
||||
}
|
||||
|
||||
_isBusy = true;
|
||||
Touch();
|
||||
}
|
||||
|
||||
private void EndBusy()
|
||||
{
|
||||
_isBusy = false;
|
||||
Touch();
|
||||
}
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record SearchResultState(
|
||||
string SelectionId,
|
||||
LegacyComparisonTarget Target);
|
||||
|
||||
private sealed record SavedPairState(
|
||||
string RowId,
|
||||
LegacyComparisonTarget First,
|
||||
LegacyComparisonTarget Second);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyComparisonTargetKind
|
||||
{
|
||||
MarketTarget,
|
||||
KrxStock,
|
||||
NxtStock,
|
||||
WorldStock
|
||||
}
|
||||
|
||||
public enum LegacyComparisonDomesticMarket
|
||||
{
|
||||
Kospi,
|
||||
Kosdaq
|
||||
}
|
||||
|
||||
public enum LegacyComparisonSearchStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public enum LegacyComparisonPairMoveDirection
|
||||
{
|
||||
Up,
|
||||
Down
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validated native comparison identity. Web code never constructs this type; adapters
|
||||
/// create it from trusted DB results and the controller only exposes a display projection.
|
||||
/// </summary>
|
||||
public sealed record LegacyComparisonTarget
|
||||
{
|
||||
private LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind kind,
|
||||
string targetId,
|
||||
string displayName,
|
||||
string? marketTarget = null,
|
||||
LegacyComparisonDomesticMarket? domesticMarket = null,
|
||||
string? stockName = null,
|
||||
string? stockCode = null,
|
||||
string? inputName = null,
|
||||
string? symbol = null,
|
||||
string? nation = null)
|
||||
{
|
||||
Kind = kind;
|
||||
TargetId = targetId;
|
||||
DisplayName = displayName;
|
||||
MarketTarget = marketTarget;
|
||||
DomesticMarket = domesticMarket;
|
||||
StockName = stockName;
|
||||
StockCode = stockCode;
|
||||
InputName = inputName;
|
||||
Symbol = symbol;
|
||||
Nation = nation;
|
||||
}
|
||||
|
||||
public LegacyComparisonTargetKind Kind { get; }
|
||||
|
||||
public string TargetId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? MarketTarget { get; }
|
||||
|
||||
public LegacyComparisonDomesticMarket? DomesticMarket { get; }
|
||||
|
||||
public string? StockName { get; }
|
||||
|
||||
public string? StockCode { get; }
|
||||
|
||||
public string? InputName { get; }
|
||||
|
||||
public string? Symbol { get; }
|
||||
|
||||
public string? Nation { get; }
|
||||
|
||||
public static LegacyComparisonTarget CreateMarketTarget(
|
||||
string targetId,
|
||||
string displayName,
|
||||
string marketTarget)
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireIdentifier(targetId, 64, nameof(targetId));
|
||||
LegacyComparisonValueGuard.RequireText(displayName, 200, nameof(displayName));
|
||||
LegacyComparisonValueGuard.RequireIdentifier(marketTarget, 64, nameof(marketTarget));
|
||||
return new LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind.MarketTarget,
|
||||
targetId,
|
||||
displayName,
|
||||
marketTarget: marketTarget);
|
||||
}
|
||||
|
||||
public static LegacyComparisonTarget CreateKrxStock(
|
||||
LegacyComparisonDomesticMarket market,
|
||||
string stockName,
|
||||
string stockCode)
|
||||
{
|
||||
ValidateDomesticStock(stockName, stockCode);
|
||||
var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq";
|
||||
return new LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind.KrxStock,
|
||||
$"krx:{marketToken}:{stockCode}",
|
||||
stockName,
|
||||
domesticMarket: market,
|
||||
stockName: stockName,
|
||||
stockCode: stockCode);
|
||||
}
|
||||
|
||||
public static LegacyComparisonTarget CreateNxtStock(
|
||||
LegacyComparisonDomesticMarket market,
|
||||
string stockName,
|
||||
string stockCode)
|
||||
{
|
||||
ValidateDomesticStock(stockName, stockCode);
|
||||
var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq";
|
||||
return new LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind.NxtStock,
|
||||
$"nxt:{marketToken}:{stockCode}",
|
||||
$"{stockName}(NXT)",
|
||||
domesticMarket: market,
|
||||
stockName: stockName,
|
||||
stockCode: stockCode);
|
||||
}
|
||||
|
||||
public static LegacyComparisonTarget CreateWorldStock(
|
||||
string inputName,
|
||||
string symbol,
|
||||
string nation)
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireText(inputName, 200, nameof(inputName));
|
||||
if (inputName.Contains(',', StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("A world stock input name cannot contain a comma.", nameof(inputName));
|
||||
}
|
||||
|
||||
LegacyComparisonValueGuard.RequireSymbol(symbol, nameof(symbol));
|
||||
var normalizedNation = LegacyComparisonValueGuard.RequireNation(nation, nameof(nation));
|
||||
return new LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind.WorldStock,
|
||||
$"world:{normalizedNation}:{symbol}",
|
||||
inputName,
|
||||
inputName: inputName,
|
||||
symbol: symbol,
|
||||
nation: normalizedNation);
|
||||
}
|
||||
|
||||
private static void ValidateDomesticStock(string stockName, string stockCode)
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireText(stockName, 200, nameof(stockName));
|
||||
if (stockName.Contains(',', StringComparison.Ordinal) ||
|
||||
stockName.Contains("(NXT)", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A domestic stock name cannot contain a comma or the NXT display suffix.",
|
||||
nameof(stockName));
|
||||
}
|
||||
|
||||
LegacyComparisonValueGuard.RequireStockCode(stockCode, nameof(stockCode));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LegacyComparisonStoredTargetReference(
|
||||
string Name,
|
||||
string Market);
|
||||
|
||||
public interface ILegacyComparisonDataService
|
||||
{
|
||||
Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
|
||||
LegacyComparisonStoredTargetReference target,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File IO stays behind this boundary. A production adapter owns the fixed path and CP949
|
||||
/// encoding; no UI request can provide a path or raw serialized row.
|
||||
/// </summary>
|
||||
public interface ILegacyComparisonPairStore
|
||||
{
|
||||
Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default);
|
||||
|
||||
Task WriteCp949Async(
|
||||
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record LegacyComparisonPairWriteRow(
|
||||
int RowNumber,
|
||||
string FirstName,
|
||||
string FirstMarket,
|
||||
string SecondName,
|
||||
string SecondMarket);
|
||||
|
||||
public sealed record LegacyComparisonTargetView(
|
||||
string SelectionId,
|
||||
string DisplayName,
|
||||
string Metadata,
|
||||
LegacyComparisonTargetKind Kind);
|
||||
|
||||
public sealed record LegacyComparisonSearchSnapshot(
|
||||
string Query,
|
||||
LegacyComparisonSearchStatus Status,
|
||||
IReadOnlyList<LegacyComparisonTargetView> Results,
|
||||
string? SelectedResultId,
|
||||
int TotalRowCount,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed record LegacyComparisonSavedPairView(
|
||||
string RowId,
|
||||
int RowNumber,
|
||||
string DisplayLabel,
|
||||
LegacyComparisonTargetView First,
|
||||
LegacyComparisonTargetView Second,
|
||||
bool IsSelected);
|
||||
|
||||
public sealed record LegacyComparisonActionView(
|
||||
string ActionId,
|
||||
string Label,
|
||||
string Section,
|
||||
bool IsAvailable,
|
||||
string? UnavailableReason);
|
||||
|
||||
public sealed record LegacyComparisonWorkflowSnapshot(
|
||||
long Revision,
|
||||
bool IsBusy,
|
||||
LegacyComparisonSearchSnapshot DomesticSearch,
|
||||
LegacyComparisonSearchSnapshot WorldSearch,
|
||||
IReadOnlyList<LegacyComparisonTargetView> FixedTargets,
|
||||
string? SelectedFixedTargetId,
|
||||
LegacyComparisonTargetView? FirstTarget,
|
||||
LegacyComparisonTargetView? SecondTarget,
|
||||
IReadOnlyList<LegacyComparisonSavedPairView> SavedPairs,
|
||||
string? SelectedPairId,
|
||||
IReadOnlyList<LegacyComparisonActionView> Actions,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind);
|
||||
|
||||
/// <summary>
|
||||
/// Generic, already validated playlist materialization. Only an action id crosses the UI
|
||||
/// command boundary; builder, alias, scene selection and hidden identities are produced here.
|
||||
/// </summary>
|
||||
public sealed record LegacyGenericPlaylistDraft(
|
||||
string DraftId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
LegacySceneSelection Selection,
|
||||
bool IsEnabled,
|
||||
int FadeDuration,
|
||||
string Source,
|
||||
string ActionId,
|
||||
string? EffectiveMode,
|
||||
string? Warning)
|
||||
{
|
||||
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
|
||||
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
|
||||
}
|
||||
|
||||
internal static class LegacyComparisonValueGuard
|
||||
{
|
||||
public static void RequireText(string value, int maximumLength, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
if (value.Length is <= 0 || value.Length > maximumLength ||
|
||||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
|
||||
value.Contains('^') ||
|
||||
value.Any(static character =>
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
return char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator;
|
||||
}))
|
||||
{
|
||||
throw new ArgumentException("A canonical comparison text value is required.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequireIdentifier(string value, int maximumLength, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
if (value.Length is <= 0 || value.Length > maximumLength ||
|
||||
value.Any(static character =>
|
||||
!char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_'))
|
||||
{
|
||||
throw new ArgumentException("A safe comparison identifier is required.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequireStockCode(string value, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
if (value.Length is <= 0 or > 32 ||
|
||||
value.Any(static character =>
|
||||
!char.IsAsciiLetterOrDigit(character) && character is not '.' and not '-' and not '_'))
|
||||
{
|
||||
throw new ArgumentException("A safe stock code is required.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequireSymbol(string value, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
if (value.Length is <= 0 or > 64 ||
|
||||
value.Any(static character =>
|
||||
!char.IsAsciiLetterOrDigit(character) &&
|
||||
character is not '@' and not '.' and not '_' and not ':' and not '-'))
|
||||
{
|
||||
throw new ArgumentException("A safe world symbol is required.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
public static string RequireNation(string value, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
var normalized = value.ToUpperInvariant();
|
||||
if (normalized is not ("US" or "TW"))
|
||||
{
|
||||
throw new ArgumentException("Only the original US and TW universe is supported.", parameterName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyExpertRequestStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record LegacyExpertResultView(
|
||||
string SelectionId,
|
||||
string ExpertCode,
|
||||
string ExpertName);
|
||||
|
||||
public sealed record LegacyExpertRecommendationView(
|
||||
int PlayIndex,
|
||||
string StockCode,
|
||||
string StockName,
|
||||
decimal BuyAmount);
|
||||
|
||||
public sealed record LegacyExpertSearchSnapshot(
|
||||
string Query,
|
||||
LegacyExpertRequestStatus Status,
|
||||
IReadOnlyList<LegacyExpertResultView> Results,
|
||||
bool IsTruncated,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed record LegacyExpertPreviewSnapshot(
|
||||
LegacyExpertRequestStatus Status,
|
||||
IReadOnlyList<LegacyExpertRecommendationView> Items,
|
||||
bool IsTruncated,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed record LegacyExpertActionView(
|
||||
string ActionId,
|
||||
string Label,
|
||||
bool IsAvailable);
|
||||
|
||||
public sealed record LegacyExpertWorkflowSnapshot(
|
||||
long Revision,
|
||||
LegacyExpertSearchSnapshot Search,
|
||||
LegacyExpertResultView? SelectedExpert,
|
||||
LegacyExpertPreviewSnapshot Preview,
|
||||
IReadOnlyList<LegacyExpertActionView> Actions,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind);
|
||||
|
||||
public sealed record LegacyExpertPlaylistDraft(
|
||||
string DraftId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Source,
|
||||
string ExpertCode,
|
||||
string ExpertName,
|
||||
int ItemCount,
|
||||
bool PreviewTruncated,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
string PageText,
|
||||
LegacySceneSelection Selection,
|
||||
bool IsEnabled,
|
||||
int FadeDuration,
|
||||
string ActionId)
|
||||
{
|
||||
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
|
||||
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Native authority for UC6's read-only expert selector and recommendation preview.
|
||||
/// EList CRUD is intentionally outside this workflow.
|
||||
/// </summary>
|
||||
public sealed class LegacyExpertWorkflowController
|
||||
{
|
||||
public const string ActionId = "five-row-current";
|
||||
public const int PageSize = 5;
|
||||
public const int MaximumPageCount = 20;
|
||||
public const int MaximumAccessibleItems = PageSize * MaximumPageCount;
|
||||
|
||||
private const int DefaultFadeDuration = 6;
|
||||
private const string ActionLabel = "전문가 추천 · 5단 표그래프 · 현재가";
|
||||
|
||||
private static readonly ReadOnlyCollection<string> Aliases =
|
||||
Array.AsReadOnly(new[] { "5074" });
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly IExpertSelectionService _service;
|
||||
private List<ExpertState> _searchResults = [];
|
||||
private ExpertState? _selected;
|
||||
private List<ExpertRecommendationPreview> _previewItems = [];
|
||||
private string _query = string.Empty;
|
||||
private string _searchError = string.Empty;
|
||||
private string _previewError = string.Empty;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyExpertRequestStatus _searchStatus = LegacyExpertRequestStatus.Idle;
|
||||
private LegacyExpertRequestStatus _previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
private bool _searchTruncated;
|
||||
private bool _previewTruncated;
|
||||
private int _searchGeneration;
|
||||
private int _previewGeneration;
|
||||
private long _revision;
|
||||
private long _draftSequence;
|
||||
|
||||
public LegacyExpertWorkflowController(IExpertSelectionService service)
|
||||
{
|
||||
_service = service ?? throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
public LegacyExpertWorkflowSnapshot Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyExpertWorkflowSnapshot> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
int generation;
|
||||
lock (_gate)
|
||||
{
|
||||
generation = ++_searchGeneration;
|
||||
_previewGeneration++;
|
||||
_query = normalizedQuery;
|
||||
_searchResults = [];
|
||||
_selected = null;
|
||||
ResetPreview();
|
||||
_searchStatus = LegacyExpertRequestStatus.Loading;
|
||||
_searchError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.SearchAsync(
|
||||
normalizedQuery,
|
||||
LegacyExpertSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation != _searchGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_searchResults = ValidateSearchResult(result, normalizedQuery, generation);
|
||||
_searchTruncated = result.IsTruncated;
|
||||
_searchStatus = LegacyExpertRequestStatus.Ready;
|
||||
_searchError = string.Empty;
|
||||
_statusMessage = $"전문가 {_searchResults.Count}명을 조회했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_searchStatus = LegacyExpertRequestStatus.Idle;
|
||||
_searchError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_searchResults = [];
|
||||
_searchStatus = LegacyExpertRequestStatus.Error;
|
||||
_searchError = "전문가 조회 결과를 안전하게 확인하지 못했습니다.";
|
||||
_statusMessage = _searchError;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyExpertWorkflowSnapshot> SelectExpertAsync(
|
||||
string selectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
ExpertState selected;
|
||||
int generation;
|
||||
lock (_gate)
|
||||
{
|
||||
selected = _searchResults.FirstOrDefault(item =>
|
||||
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ??
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(selectionId),
|
||||
"The expert selection id is not in the current result set.");
|
||||
_selected = selected;
|
||||
generation = ++_previewGeneration;
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Loading;
|
||||
_previewError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.PreviewAsync(
|
||||
selected.Identity,
|
||||
LegacyExpertSelectionService.MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation != _previewGeneration ||
|
||||
_selected?.Identity != selected.Identity)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_previewItems = ValidatePreviewResult(result, selected.Identity);
|
||||
_previewTruncated = result.IsTruncated;
|
||||
_previewStatus = LegacyExpertRequestStatus.Ready;
|
||||
_previewError = string.Empty;
|
||||
_statusMessage = $"{selected.Identity.ExpertName} 추천 종목 {_previewItems.Count}개를 조회했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _previewGeneration)
|
||||
{
|
||||
_previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
_previewError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _previewGeneration)
|
||||
{
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Error;
|
||||
_previewError = "추천 종목 preview를 안전하게 확인하지 못했습니다.";
|
||||
_statusMessage = _previewError;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyExpertPlaylistDraft MaterializeSelectedAction(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!string.Equals(actionId, ActionId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(actionId));
|
||||
}
|
||||
|
||||
if (_selected is null || _previewStatus != LegacyExpertRequestStatus.Ready)
|
||||
{
|
||||
throw new InvalidOperationException("Select and preview an expert first.");
|
||||
}
|
||||
|
||||
var itemCount = _previewItems.Count;
|
||||
if (itemCount is <= 0 or > MaximumAccessibleItems)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"An expert cut requires one through 100 accessible recommendation rows.");
|
||||
}
|
||||
|
||||
var pageCount = checked((itemCount + PageSize - 1) / PageSize);
|
||||
if (pageCount is <= 0 or > MaximumPageCount)
|
||||
{
|
||||
throw new InvalidOperationException("The expert PageN plan exceeds 20 pages.");
|
||||
}
|
||||
|
||||
var identity = _selected.Identity;
|
||||
var selection = new LegacySceneSelection(
|
||||
"PAGED_EXPERT",
|
||||
identity.ExpertName,
|
||||
"INPUT_ORDER",
|
||||
"CURRENT",
|
||||
identity.ExpertCode);
|
||||
return new LegacyExpertPlaylistDraft(
|
||||
$"expert-draft-{++_draftSequence:D8}",
|
||||
"s5074",
|
||||
"5074",
|
||||
Aliases,
|
||||
identity.ExpertName,
|
||||
ActionLabel,
|
||||
"expert",
|
||||
"legacy-expert-workflow",
|
||||
identity.ExpertCode,
|
||||
identity.ExpertName,
|
||||
itemCount,
|
||||
_previewTruncated,
|
||||
PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
$"1/{pageCount}",
|
||||
selection,
|
||||
true,
|
||||
DefaultFadeDuration,
|
||||
ActionId);
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyExpertWorkflowSnapshot CreateSnapshot()
|
||||
{
|
||||
var searchRows = _searchResults.Select(ToView).ToArray();
|
||||
var previewRows = _previewItems.Select(item =>
|
||||
new LegacyExpertRecommendationView(
|
||||
item.PlayIndex,
|
||||
item.StockCode,
|
||||
item.StockName,
|
||||
item.BuyAmount)).ToArray();
|
||||
var pageCount = previewRows.Length == 0
|
||||
? 0
|
||||
: (previewRows.Length + PageSize - 1) / PageSize;
|
||||
var actionAvailable = _selected is not null &&
|
||||
_previewStatus == LegacyExpertRequestStatus.Ready &&
|
||||
previewRows.Length is > 0 and <= MaximumAccessibleItems &&
|
||||
pageCount <= MaximumPageCount;
|
||||
return new LegacyExpertWorkflowSnapshot(
|
||||
_revision,
|
||||
new LegacyExpertSearchSnapshot(
|
||||
_query,
|
||||
_searchStatus,
|
||||
Array.AsReadOnly(searchRows),
|
||||
_searchTruncated,
|
||||
_searchError),
|
||||
_selected is null ? null : ToView(_selected),
|
||||
new LegacyExpertPreviewSnapshot(
|
||||
_previewStatus,
|
||||
Array.AsReadOnly(previewRows),
|
||||
_previewTruncated,
|
||||
PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
_previewError),
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
new LegacyExpertActionView(ActionId, ActionLabel, actionAvailable)
|
||||
}),
|
||||
_statusMessage,
|
||||
_statusKind);
|
||||
}
|
||||
|
||||
private static List<ExpertState> ValidateSearchResult(
|
||||
ExpertSearchResult result,
|
||||
string expectedQuery,
|
||||
int generation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
|
||||
result.Items is null ||
|
||||
result.Items.Count > LegacyExpertSelectionService.MaximumResults ||
|
||||
result.IsTruncated &&
|
||||
result.Items.Count != LegacyExpertSelectionService.MaximumResults)
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert search response correlation is invalid.");
|
||||
}
|
||||
|
||||
var states = new List<ExpertState>(result.Items.Count);
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
ExpertSelectionIdentity? previous = null;
|
||||
for (var index = 0; index < result.Items.Count; index++)
|
||||
{
|
||||
var item = result.Items[index] ??
|
||||
throw new ExpertSelectionDataException("The expert search row is missing.");
|
||||
if (item.Source != DataSourceKind.Oracle)
|
||||
{
|
||||
throw new ExpertSelectionDataException("Only Oracle expert identities are selectable.");
|
||||
}
|
||||
|
||||
ValidateExpertIdentity(item.Identity);
|
||||
if (!codes.Add(item.Identity.ExpertCode) || !names.Add(item.Identity.ExpertName) ||
|
||||
previous is not null && CompareIdentities(previous, item.Identity) > 0)
|
||||
{
|
||||
throw new ExpertSelectionDataException(
|
||||
"Expert identities must be unique and sorted by name then code.");
|
||||
}
|
||||
|
||||
states.Add(new ExpertState(
|
||||
$"expert-result-{generation:D4}-{index + 1:D4}",
|
||||
item.Identity));
|
||||
previous = item.Identity;
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
private static List<ExpertRecommendationPreview> ValidatePreviewResult(
|
||||
ExpertPreviewResult result,
|
||||
ExpertSelectionIdentity expectedIdentity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ValidateExpertIdentity(result.Identity);
|
||||
if (result.Identity != expectedIdentity || result.Items is null ||
|
||||
result.Items.Count > MaximumAccessibleItems ||
|
||||
result.IsTruncated && result.Items.Count != MaximumAccessibleItems)
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert preview response correlation is invalid.");
|
||||
}
|
||||
|
||||
var items = new List<ExpertRecommendationPreview>(result.Items.Count);
|
||||
var indexes = new HashSet<int>();
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var previousIndex = -1;
|
||||
foreach (var item in result.Items)
|
||||
{
|
||||
if (item is null || item.PlayIndex is < 0 or > 9_999 ||
|
||||
item.PlayIndex <= previousIndex ||
|
||||
!IsAsciiAlphaNumeric(item.StockCode, 32) ||
|
||||
!IsCanonicalText(item.StockName, 200) ||
|
||||
item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m ||
|
||||
decimal.Truncate(item.BuyAmount) != item.BuyAmount ||
|
||||
!indexes.Add(item.PlayIndex) ||
|
||||
!codes.Add(item.StockCode) ||
|
||||
!names.Add(item.StockName))
|
||||
{
|
||||
throw new ExpertSelectionDataException(
|
||||
"The expert preview contains an invalid or duplicate recommendation.");
|
||||
}
|
||||
|
||||
items.Add(item);
|
||||
previousIndex = item.PlayIndex;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static void ValidateExpertIdentity(ExpertSelectionIdentity identity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(identity);
|
||||
if (identity.ExpertCode.Length != 4 ||
|
||||
identity.ExpertCode.Any(static character => !char.IsAsciiDigit(character)) ||
|
||||
!IsCanonicalText(identity.ExpertName, 128))
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert identity is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static int CompareIdentities(
|
||||
ExpertSelectionIdentity left,
|
||||
ExpertSelectionIdentity right)
|
||||
{
|
||||
var name = string.Compare(left.ExpertName, right.ExpertName, StringComparison.Ordinal);
|
||||
return name != 0
|
||||
? name
|
||||
: string.Compare(left.ExpertCode, right.ExpertCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LegacyExpertResultView ToView(ExpertState state) =>
|
||||
new(state.SelectionId, state.Identity.ExpertCode, state.Identity.ExpertName);
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsSafeText(query))
|
||||
{
|
||||
throw new ArgumentException("The expert query contains an unsafe character.", nameof(query));
|
||||
}
|
||||
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > LegacyExpertSelectionService.MaximumQueryLength ||
|
||||
!IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException("The expert query exceeds 64 safe characters.", nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
value.All(static character => char.IsAsciiLetterOrDigit(character));
|
||||
|
||||
private static bool IsCanonicalText(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ResetPreview()
|
||||
{
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
_previewError = string.Empty;
|
||||
}
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record ExpertState(
|
||||
string SelectionId,
|
||||
ExpertSelectionIdentity Identity);
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyFixedMarket
|
||||
{
|
||||
Overseas,
|
||||
Exchange,
|
||||
Index
|
||||
}
|
||||
|
||||
public enum LegacyFixedCategory
|
||||
{
|
||||
Plate,
|
||||
Market,
|
||||
Chart
|
||||
}
|
||||
|
||||
public sealed record LegacyFixedSelection(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
public readonly record struct LegacyMovingAverageSelection(bool Ma5, bool Ma20);
|
||||
|
||||
public sealed record LegacyFixedRuntimeAsset(
|
||||
LegacyFixedMarket Market,
|
||||
string RelativePath,
|
||||
string Sha256);
|
||||
|
||||
public sealed class LegacyFixedAction
|
||||
{
|
||||
internal LegacyFixedAction(
|
||||
string id,
|
||||
LegacyFixedMarket market,
|
||||
string section,
|
||||
string label,
|
||||
string detail,
|
||||
LegacyFixedCategory category,
|
||||
string builderKey,
|
||||
string code,
|
||||
IReadOnlyList<string> aliases,
|
||||
bool available,
|
||||
string prerequisite,
|
||||
LegacyFixedSelection selection,
|
||||
bool dynamicDate,
|
||||
bool dynamicNxtSession)
|
||||
{
|
||||
Id = id;
|
||||
Market = market;
|
||||
Section = section;
|
||||
Label = label;
|
||||
Detail = detail;
|
||||
Category = category;
|
||||
BuilderKey = builderKey;
|
||||
Code = code;
|
||||
Aliases = aliases;
|
||||
Available = available;
|
||||
Prerequisite = prerequisite;
|
||||
Selection = selection;
|
||||
DynamicDate = dynamicDate;
|
||||
DynamicNxtSession = dynamicNxtSession;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public LegacyFixedMarket Market { get; }
|
||||
|
||||
public string Section { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string Detail { get; }
|
||||
|
||||
public LegacyFixedCategory Category { get; }
|
||||
|
||||
public string BuilderKey { get; }
|
||||
|
||||
public string Code { get; }
|
||||
|
||||
public IReadOnlyList<string> Aliases { get; }
|
||||
|
||||
public bool Available { get; }
|
||||
|
||||
public string Prerequisite { get; }
|
||||
|
||||
public LegacyFixedSelection Selection { get; }
|
||||
|
||||
public bool DynamicDate { get; }
|
||||
|
||||
public bool DynamicNxtSession { get; }
|
||||
|
||||
public bool SupportsMovingAverages =>
|
||||
string.Equals(BuilderKey, "s8010", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record LegacyMaterializedFixedAction(
|
||||
LegacyFixedAction Action,
|
||||
LegacyFixedSelection Selection,
|
||||
LegacyMovingAverageSelection MovingAverages,
|
||||
LegacyFixedRuntimeAsset RuntimeAsset);
|
||||
|
||||
public sealed class LegacyFixedSection
|
||||
{
|
||||
internal LegacyFixedSection(
|
||||
LegacyFixedMarket market,
|
||||
string name,
|
||||
IReadOnlyList<LegacyFixedAction> actions)
|
||||
{
|
||||
Market = market;
|
||||
Name = name;
|
||||
Actions = actions;
|
||||
}
|
||||
|
||||
public LegacyFixedMarket Market { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public IReadOnlyList<LegacyFixedAction> Actions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C#-authoritative catalog for the three fixed legacy UC1 tabs. The embedded JSON is a
|
||||
/// generated build asset; callers can select only by the opaque action id and cannot provide
|
||||
/// builder, cut alias, or scene-selection values.
|
||||
/// </summary>
|
||||
public sealed class LegacyFixedActionCatalog
|
||||
{
|
||||
public const int ExpectedActionCount = 328;
|
||||
public const int ExpectedOverseasCount = 78;
|
||||
public const int ExpectedExchangeCount = 46;
|
||||
public const int ExpectedIndexCount = 204;
|
||||
public const int ExpectedAvailableCount = 322;
|
||||
public const string ExpectedDocumentSha256 =
|
||||
"E56FB9DE8247672022EE2F324AF4C37179EC58E399758425AD4C29DAF1BDDC56";
|
||||
public const string ExpectedGeneratorSourceSha256 =
|
||||
"31D1B0F81A53FF47700D179E45BEFCA55342D11EAD3A58E67F6A926607A874B5";
|
||||
|
||||
private const string ResourceName =
|
||||
"MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json";
|
||||
private const string ExpectedGeneratedFrom = "Web/legacy-fixed-workflow.js";
|
||||
|
||||
private static readonly JsonSerializerOptions StrictJsonOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
|
||||
};
|
||||
|
||||
private static readonly Lazy<LegacyFixedActionCatalog> EmbeddedCatalog =
|
||||
new(LoadEmbedded, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
private readonly IReadOnlyDictionary<string, LegacyFixedAction> _byId;
|
||||
private readonly IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>>
|
||||
_byMarket;
|
||||
private readonly IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>
|
||||
_sectionsByMarket;
|
||||
private readonly IReadOnlyDictionary<(LegacyFixedMarket Market, string Section),
|
||||
IReadOnlyList<LegacyFixedAction>> _bySection;
|
||||
|
||||
private LegacyFixedActionCatalog(
|
||||
IReadOnlyList<LegacyFixedAction> actions,
|
||||
IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset> runtimeAssets,
|
||||
string documentSha256,
|
||||
string generatorSourceSha256)
|
||||
{
|
||||
Actions = actions;
|
||||
RuntimeAssets = runtimeAssets;
|
||||
DocumentSha256 = documentSha256;
|
||||
GeneratorSourceSha256 = generatorSourceSha256;
|
||||
|
||||
_byId = new ReadOnlyDictionary<string, LegacyFixedAction>(
|
||||
actions.ToDictionary(action => action.Id, StringComparer.Ordinal));
|
||||
|
||||
_byMarket = new ReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>>(
|
||||
Enum.GetValues<LegacyFixedMarket>().ToDictionary(
|
||||
market => market,
|
||||
market => ReadOnly(actions.Where(action => action.Market == market))));
|
||||
|
||||
var sectionsByMarket = new Dictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>();
|
||||
var bySection = new Dictionary<(LegacyFixedMarket Market, string Section),
|
||||
IReadOnlyList<LegacyFixedAction>>();
|
||||
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
|
||||
{
|
||||
var sections = _byMarket[market]
|
||||
.GroupBy(action => action.Section, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var sectionActions = ReadOnly(group);
|
||||
bySection.Add((market, group.Key), sectionActions);
|
||||
return new LegacyFixedSection(market, group.Key, sectionActions);
|
||||
});
|
||||
sectionsByMarket.Add(market, ReadOnly(sections));
|
||||
}
|
||||
|
||||
_sectionsByMarket =
|
||||
new ReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>(
|
||||
sectionsByMarket);
|
||||
_bySection = new ReadOnlyDictionary<(LegacyFixedMarket Market, string Section),
|
||||
IReadOnlyList<LegacyFixedAction>>(bySection);
|
||||
}
|
||||
|
||||
public static LegacyFixedActionCatalog Default => EmbeddedCatalog.Value;
|
||||
|
||||
public IReadOnlyList<LegacyFixedAction> Actions { get; }
|
||||
|
||||
public IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset> RuntimeAssets { get; }
|
||||
|
||||
public string DocumentSha256 { get; }
|
||||
|
||||
public string GeneratorSourceSha256 { get; }
|
||||
|
||||
public IReadOnlyList<LegacyFixedAction> GetActions(LegacyFixedMarket market)
|
||||
{
|
||||
RequireMarket(market);
|
||||
return _byMarket[market];
|
||||
}
|
||||
|
||||
public IReadOnlyList<LegacyFixedSection> GetSections(LegacyFixedMarket market)
|
||||
{
|
||||
RequireMarket(market);
|
||||
return _sectionsByMarket[market];
|
||||
}
|
||||
|
||||
public IReadOnlyList<LegacyFixedAction> GetActions(
|
||||
LegacyFixedMarket market,
|
||||
string section)
|
||||
{
|
||||
RequireMarket(market);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(section);
|
||||
return _bySection.TryGetValue((market, section), out var actions)
|
||||
? actions
|
||||
: Array.Empty<LegacyFixedAction>();
|
||||
}
|
||||
|
||||
public LegacyFixedAction? FindAction(string? actionId) =>
|
||||
actionId is not null && _byId.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: null;
|
||||
|
||||
public LegacyFixedAction GetAction(string actionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
return _byId.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: throw new KeyNotFoundException($"Unknown fixed legacy action id '{actionId}'.");
|
||||
}
|
||||
|
||||
public LegacyFixedRuntimeAsset GetRuntimeAsset(LegacyFixedMarket market)
|
||||
{
|
||||
RequireMarket(market);
|
||||
return RuntimeAssets[market];
|
||||
}
|
||||
|
||||
public LegacyMaterializedFixedAction Materialize(
|
||||
string actionId,
|
||||
DateTimeOffset localNow,
|
||||
LegacyMovingAverageSelection movingAverages = default)
|
||||
{
|
||||
var action = GetAction(actionId);
|
||||
if (!action.Available)
|
||||
{
|
||||
throw new InvalidOperationException(action.Prerequisite);
|
||||
}
|
||||
|
||||
var trustedMovingAverages = action.SupportsMovingAverages
|
||||
? movingAverages
|
||||
: default;
|
||||
var graphicType = action.DynamicNxtSession
|
||||
? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET"
|
||||
: action.SupportsMovingAverages
|
||||
? MovingAverageText(trustedMovingAverages)
|
||||
: action.Selection.GraphicType;
|
||||
var dataCode = action.DynamicDate
|
||||
? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
: action.Selection.DataCode;
|
||||
|
||||
var selection = action.Selection with
|
||||
{
|
||||
GraphicType = graphicType,
|
||||
DataCode = dataCode
|
||||
};
|
||||
return new LegacyMaterializedFixedAction(
|
||||
action,
|
||||
selection,
|
||||
trustedMovingAverages,
|
||||
RuntimeAssets[action.Market]);
|
||||
}
|
||||
|
||||
internal static LegacyFixedActionCatalog LoadForValidation(
|
||||
ReadOnlyMemory<byte> utf8Json,
|
||||
string? expectedDocumentSha256 = null) =>
|
||||
Load(utf8Json.ToArray(), expectedDocumentSha256);
|
||||
|
||||
private static LegacyFixedActionCatalog LoadEmbedded()
|
||||
{
|
||||
using var stream = typeof(LegacyFixedActionCatalog).Assembly
|
||||
.GetManifestResourceStream(ResourceName)
|
||||
?? throw new InvalidDataException(
|
||||
$"The embedded fixed legacy catalog '{ResourceName}' is missing.");
|
||||
using var buffer = new MemoryStream();
|
||||
stream.CopyTo(buffer);
|
||||
return Load(buffer.ToArray(), ExpectedDocumentSha256);
|
||||
}
|
||||
|
||||
private static LegacyFixedActionCatalog Load(byte[] utf8Json, string? expectedDocumentSha256)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(utf8Json);
|
||||
if (utf8Json.Length == 0)
|
||||
{
|
||||
throw new InvalidDataException("The fixed legacy catalog is empty.");
|
||||
}
|
||||
|
||||
CatalogDocumentDto document;
|
||||
try
|
||||
{
|
||||
using var parsed = JsonDocument.Parse(utf8Json, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow
|
||||
});
|
||||
RejectDuplicateProperties(parsed.RootElement, "$root");
|
||||
document = JsonSerializer.Deserialize<CatalogDocumentDto>(utf8Json, StrictJsonOptions)
|
||||
?? throw new InvalidDataException("The fixed legacy catalog root is null.");
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new InvalidDataException("The fixed legacy catalog JSON schema is invalid.", exception);
|
||||
}
|
||||
|
||||
var documentHash = Convert.ToHexString(SHA256.HashData(utf8Json));
|
||||
if (expectedDocumentSha256 is not null &&
|
||||
!string.Equals(documentHash, expectedDocumentSha256, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The fixed legacy catalog hash is invalid. Expected {expectedDocumentSha256}; actual {documentHash}.");
|
||||
}
|
||||
|
||||
ValidateDocumentMetadata(document);
|
||||
var runtimeAssets = CreateRuntimeAssets(document.RuntimeAssets);
|
||||
var actions = CreateActions(document.Actions);
|
||||
ValidateCatalogInvariants(actions);
|
||||
return new LegacyFixedActionCatalog(
|
||||
ReadOnly(actions),
|
||||
runtimeAssets,
|
||||
documentHash,
|
||||
document.SourceSha256);
|
||||
}
|
||||
|
||||
private static void ValidateDocumentMetadata(CatalogDocumentDto document)
|
||||
{
|
||||
if (document.SchemaVersion != 1)
|
||||
{
|
||||
throw Invalid("schemaVersion must be 1");
|
||||
}
|
||||
|
||||
if (!string.Equals(document.GeneratedFrom, ExpectedGeneratedFrom, StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"generatedFrom must be '{ExpectedGeneratedFrom}'");
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
document.SourceSha256,
|
||||
ExpectedGeneratorSourceSha256,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid("the generator source hash is not approved");
|
||||
}
|
||||
|
||||
if (document.RuntimeAssets is null)
|
||||
{
|
||||
throw Invalid("runtimeAssets is required");
|
||||
}
|
||||
|
||||
if (document.Actions is null)
|
||||
{
|
||||
throw Invalid("actions is required");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>
|
||||
CreateRuntimeAssets(RuntimeAssetsDto runtimeAssets)
|
||||
{
|
||||
var assets = new Dictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>
|
||||
{
|
||||
[LegacyFixedMarket.Overseas] = ValidateRuntimeAsset(
|
||||
LegacyFixedMarket.Overseas,
|
||||
runtimeAssets.Overseas,
|
||||
"bin/Debug/Res/해외.ini",
|
||||
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
|
||||
[LegacyFixedMarket.Exchange] = ValidateRuntimeAsset(
|
||||
LegacyFixedMarket.Exchange,
|
||||
runtimeAssets.Exchange,
|
||||
"bin/Debug/Res/환율.ini",
|
||||
"7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
|
||||
[LegacyFixedMarket.Index] = ValidateRuntimeAsset(
|
||||
LegacyFixedMarket.Index,
|
||||
runtimeAssets.Index,
|
||||
"bin/Debug/Res/지수.ini",
|
||||
"E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
|
||||
};
|
||||
return new ReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>(assets);
|
||||
}
|
||||
|
||||
private static LegacyFixedRuntimeAsset ValidateRuntimeAsset(
|
||||
LegacyFixedMarket market,
|
||||
RuntimeAssetDto asset,
|
||||
string expectedPath,
|
||||
string expectedSha256)
|
||||
{
|
||||
if (asset is null ||
|
||||
!string.Equals(asset.RelativePath, expectedPath, StringComparison.Ordinal) ||
|
||||
!string.Equals(asset.Sha256, expectedSha256, StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"runtime asset metadata for '{MarketText(market)}' is invalid");
|
||||
}
|
||||
|
||||
RequireSha256(asset.Sha256, $"runtimeAssets.{MarketText(market)}.sha256");
|
||||
return new LegacyFixedRuntimeAsset(market, asset.RelativePath, asset.Sha256);
|
||||
}
|
||||
|
||||
private static LegacyFixedAction[] CreateActions(IReadOnlyList<ActionDto> source)
|
||||
{
|
||||
if (source.Count != ExpectedActionCount)
|
||||
{
|
||||
throw Invalid($"exactly {ExpectedActionCount} actions are required");
|
||||
}
|
||||
|
||||
var actions = new LegacyFixedAction[source.Count];
|
||||
for (var index = 0; index < source.Count; index++)
|
||||
{
|
||||
var dto = source[index]
|
||||
?? throw Invalid($"action at index {index} is null");
|
||||
var expectedId = $"fixed-{index + 1:000}";
|
||||
if (!string.Equals(dto.Id, expectedId, StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"action id at index {index} must be '{expectedId}'");
|
||||
}
|
||||
|
||||
var market = ParseMarket(dto.Market);
|
||||
var category = ParseCategory(dto.Category);
|
||||
var section = RequireText(dto.Section, $"{expectedId}.section");
|
||||
var label = RequireText(dto.Label, $"{expectedId}.label");
|
||||
var detail = RequireText(dto.Detail, $"{expectedId}.detail");
|
||||
var builderKey = RequireText(dto.BuilderKey, $"{expectedId}.builderKey");
|
||||
var code = RequireText(dto.Code, $"{expectedId}.code");
|
||||
ValidateSceneIdentity(expectedId, builderKey, code, dto.Aliases);
|
||||
var selection = ValidateSelection(expectedId, dto);
|
||||
|
||||
var prerequisite = RequireText(
|
||||
dto.Prerequisite,
|
||||
$"{expectedId}.prerequisite",
|
||||
allowEmpty: true,
|
||||
maximumLength: 512);
|
||||
if (dto.Available != string.IsNullOrEmpty(prerequisite))
|
||||
{
|
||||
throw Invalid($"{expectedId} availability and prerequisite do not agree");
|
||||
}
|
||||
|
||||
actions[index] = new LegacyFixedAction(
|
||||
expectedId,
|
||||
market,
|
||||
section,
|
||||
label,
|
||||
detail,
|
||||
category,
|
||||
builderKey,
|
||||
code,
|
||||
Array.AsReadOnly(dto.Aliases.ToArray()),
|
||||
dto.Available,
|
||||
prerequisite,
|
||||
selection,
|
||||
dto.DynamicDate,
|
||||
dto.DynamicNxtSession);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static void ValidateSceneIdentity(
|
||||
string actionId,
|
||||
string builderKey,
|
||||
string code,
|
||||
IReadOnlyList<string> aliases)
|
||||
{
|
||||
if (aliases is null)
|
||||
{
|
||||
throw Invalid($"{actionId}.aliases is required");
|
||||
}
|
||||
var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate =>
|
||||
string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal))
|
||||
?? throw Invalid($"{actionId} has unknown builder '{builderKey}'");
|
||||
|
||||
if (!definition.CutAliases.Contains(code, StringComparer.Ordinal))
|
||||
{
|
||||
throw Invalid($"{actionId} code '{code}' is not an alias of '{builderKey}'");
|
||||
}
|
||||
|
||||
if (aliases.Count != 1 || !string.Equals(aliases[0], code, StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"{actionId} must declare exactly its code as its sole alias");
|
||||
}
|
||||
|
||||
var aliasMatches = LegacySceneCatalog.FindByCutAlias(code);
|
||||
if (!aliasMatches.Any(candidate =>
|
||||
string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal)))
|
||||
{
|
||||
throw Invalid($"{actionId} alias '{code}' does not resolve to '{builderKey}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyFixedSelection ValidateSelection(string actionId, ActionDto action)
|
||||
{
|
||||
var source = action.Selection
|
||||
?? throw Invalid($"{actionId}.selection is required");
|
||||
var selection = new LegacyFixedSelection(
|
||||
RequireText(source.GroupCode, $"{actionId}.selection.groupCode"),
|
||||
RequireText(source.Subject, $"{actionId}.selection.subject", allowEmpty: true),
|
||||
RequireText(source.GraphicType, $"{actionId}.selection.graphicType", allowEmpty: true),
|
||||
RequireText(source.Subtype, $"{actionId}.selection.subtype", allowEmpty: true),
|
||||
RequireText(source.DataCode, $"{actionId}.selection.dataCode", allowEmpty: true));
|
||||
|
||||
if (action.DynamicDate && action.DynamicNxtSession)
|
||||
{
|
||||
throw Invalid($"{actionId} cannot have two dynamic selection fields");
|
||||
}
|
||||
|
||||
if (action.DynamicDate !=
|
||||
string.Equals(selection.DataCode, "$TODAY", StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"{actionId} has an invalid dynamic-date declaration");
|
||||
}
|
||||
|
||||
if (action.DynamicNxtSession !=
|
||||
string.Equals(selection.GraphicType, "$NXT_SESSION", StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"{actionId} has an invalid NXT-session declaration");
|
||||
}
|
||||
|
||||
if (string.Equals(action.BuilderKey, "s8010", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrEmpty(selection.GraphicType))
|
||||
{
|
||||
throw Invalid($"{actionId} moving-average flags must be materialized by C#");
|
||||
}
|
||||
|
||||
var values = new[]
|
||||
{
|
||||
selection.GroupCode,
|
||||
selection.Subject,
|
||||
selection.GraphicType,
|
||||
selection.Subtype,
|
||||
selection.DataCode
|
||||
};
|
||||
if (values.Any(value => value.Contains('$', StringComparison.Ordinal)) &&
|
||||
!action.DynamicDate && !action.DynamicNxtSession)
|
||||
{
|
||||
throw Invalid($"{actionId} contains an unapproved selection placeholder");
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
private static void ValidateCatalogInvariants(IReadOnlyList<LegacyFixedAction> actions)
|
||||
{
|
||||
RequireCount(actions, LegacyFixedMarket.Overseas, ExpectedOverseasCount);
|
||||
RequireCount(actions, LegacyFixedMarket.Exchange, ExpectedExchangeCount);
|
||||
RequireCount(actions, LegacyFixedMarket.Index, ExpectedIndexCount);
|
||||
if (actions.Count(action => action.Available) != ExpectedAvailableCount)
|
||||
{
|
||||
throw Invalid($"exactly {ExpectedAvailableCount} actions must be available");
|
||||
}
|
||||
|
||||
if (actions.Take(ExpectedOverseasCount).Any(action =>
|
||||
action.Market != LegacyFixedMarket.Overseas) ||
|
||||
actions.Skip(ExpectedOverseasCount).Take(ExpectedExchangeCount).Any(action =>
|
||||
action.Market != LegacyFixedMarket.Exchange) ||
|
||||
actions.Skip(ExpectedOverseasCount + ExpectedExchangeCount).Any(action =>
|
||||
action.Market != LegacyFixedMarket.Index))
|
||||
{
|
||||
throw Invalid("market action ranges are not in the approved legacy order");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireCount(
|
||||
IEnumerable<LegacyFixedAction> actions,
|
||||
LegacyFixedMarket market,
|
||||
int expected)
|
||||
{
|
||||
var actual = actions.Count(action => action.Market == market);
|
||||
if (actual != expected)
|
||||
{
|
||||
throw Invalid(
|
||||
$"market '{MarketText(market)}' must contain {expected} actions, not {actual}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string MovingAverageText(LegacyMovingAverageSelection movingAverages)
|
||||
{
|
||||
if (movingAverages.Ma5 && movingAverages.Ma20)
|
||||
{
|
||||
return "MA5,MA20";
|
||||
}
|
||||
|
||||
if (movingAverages.Ma5)
|
||||
{
|
||||
return "MA5";
|
||||
}
|
||||
|
||||
return movingAverages.Ma20 ? "MA20" : string.Empty;
|
||||
}
|
||||
|
||||
private static LegacyFixedMarket ParseMarket(string value) => value switch
|
||||
{
|
||||
"overseas" => LegacyFixedMarket.Overseas,
|
||||
"exchange" => LegacyFixedMarket.Exchange,
|
||||
"index" => LegacyFixedMarket.Index,
|
||||
_ => throw Invalid($"unknown market '{value}'")
|
||||
};
|
||||
|
||||
private static LegacyFixedCategory ParseCategory(string value) => value switch
|
||||
{
|
||||
"plate" => LegacyFixedCategory.Plate,
|
||||
"market" => LegacyFixedCategory.Market,
|
||||
"chart" => LegacyFixedCategory.Chart,
|
||||
_ => throw Invalid($"unknown category '{value}'")
|
||||
};
|
||||
|
||||
private static string MarketText(LegacyFixedMarket market) => market switch
|
||||
{
|
||||
LegacyFixedMarket.Overseas => "overseas",
|
||||
LegacyFixedMarket.Exchange => "exchange",
|
||||
LegacyFixedMarket.Index => "index",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.")
|
||||
};
|
||||
|
||||
private static void RequireMarket(LegacyFixedMarket market)
|
||||
{
|
||||
if (!Enum.IsDefined(market))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequireText(
|
||||
string value,
|
||||
string field,
|
||||
bool allowEmpty = false,
|
||||
int maximumLength = 256)
|
||||
{
|
||||
if (value is null || (!allowEmpty && value.Length == 0) ||
|
||||
value.Length > maximumLength || value.Any(char.IsControl) ||
|
||||
!string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"{field} is invalid");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void RequireSha256(string value, string field)
|
||||
{
|
||||
if (value is null || value.Length != 64 || value.Any(character =>
|
||||
!(char.IsAsciiDigit(character) || character is >= 'A' and <= 'F')))
|
||||
{
|
||||
throw Invalid($"{field} is not an uppercase SHA-256 value");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RejectDuplicateProperties(JsonElement element, string path)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (!names.Add(property.Name))
|
||||
{
|
||||
throw new JsonException($"Duplicate property '{property.Name}' at {path}.");
|
||||
}
|
||||
|
||||
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
RejectDuplicateProperties(item, $"{path}[{index++}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> source) =>
|
||||
Array.AsReadOnly(source.ToArray());
|
||||
|
||||
private static InvalidDataException Invalid(string message) =>
|
||||
new($"The fixed legacy catalog is invalid: {message}.");
|
||||
|
||||
private sealed class CatalogDocumentDto
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public required int SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedFrom")]
|
||||
public required string GeneratedFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceSha256")]
|
||||
public required string SourceSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("runtimeAssets")]
|
||||
public required RuntimeAssetsDto RuntimeAssets { get; init; }
|
||||
|
||||
[JsonPropertyName("actions")]
|
||||
public required List<ActionDto> Actions { get; init; }
|
||||
}
|
||||
|
||||
private sealed class RuntimeAssetsDto
|
||||
{
|
||||
[JsonPropertyName("overseas")]
|
||||
public required RuntimeAssetDto Overseas { get; init; }
|
||||
|
||||
[JsonPropertyName("exchange")]
|
||||
public required RuntimeAssetDto Exchange { get; init; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public required RuntimeAssetDto Index { get; init; }
|
||||
}
|
||||
|
||||
private sealed class RuntimeAssetDto
|
||||
{
|
||||
[JsonPropertyName("relativePath")]
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ActionDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("market")]
|
||||
public required string Market { get; init; }
|
||||
|
||||
[JsonPropertyName("section")]
|
||||
public required string Section { get; init; }
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public required string Label { get; init; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public required string Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public required string Category { get; init; }
|
||||
|
||||
[JsonPropertyName("builderKey")]
|
||||
public required string BuilderKey { get; init; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public required string Code { get; init; }
|
||||
|
||||
[JsonPropertyName("aliases")]
|
||||
public required List<string> Aliases { get; init; }
|
||||
|
||||
[JsonPropertyName("available")]
|
||||
public required bool Available { get; init; }
|
||||
|
||||
[JsonPropertyName("prerequisite")]
|
||||
public required string Prerequisite { get; init; }
|
||||
|
||||
[JsonPropertyName("selection")]
|
||||
public required SelectionDto Selection { get; init; }
|
||||
|
||||
[JsonPropertyName("dynamicDate")]
|
||||
public required bool DynamicDate { get; init; }
|
||||
|
||||
[JsonPropertyName("dynamicNxtSession")]
|
||||
public required bool DynamicNxtSession { get; init; }
|
||||
}
|
||||
|
||||
private sealed class SelectionDto
|
||||
{
|
||||
[JsonPropertyName("groupCode")]
|
||||
public required string GroupCode { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required string Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("graphicType")]
|
||||
public required string GraphicType { get; init; }
|
||||
|
||||
[JsonPropertyName("subtype")]
|
||||
public required string Subtype { get; init; }
|
||||
|
||||
[JsonPropertyName("dataCode")]
|
||||
public required string DataCode { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyIndustryActionKind
|
||||
{
|
||||
Single,
|
||||
Pair,
|
||||
Yield,
|
||||
Candle,
|
||||
FiveRow,
|
||||
Square,
|
||||
Sector
|
||||
}
|
||||
|
||||
public enum LegacyIndustryCandleMode
|
||||
{
|
||||
Price,
|
||||
Volume
|
||||
}
|
||||
|
||||
public sealed record LegacyIndustryRuntimeAsset(
|
||||
IndustryMarket Market,
|
||||
string RelativePath,
|
||||
string Sha256);
|
||||
|
||||
public sealed record LegacyIndustryActionDefinition(
|
||||
IndustryMarket Market,
|
||||
string ActionId,
|
||||
string Label,
|
||||
string Section,
|
||||
string Category,
|
||||
LegacyIndustryActionKind Kind,
|
||||
string BuilderKey,
|
||||
string SceneAlias,
|
||||
int PageSize,
|
||||
string? YieldPeriod = null,
|
||||
LegacyIndustryCandleMode? CandleMode = null);
|
||||
|
||||
/// <summary>
|
||||
/// Closed projection of the two runtime industry INI files. The WebView supplies an
|
||||
/// action id only; market-specific aliases and page sizes remain authoritative in C#.
|
||||
/// </summary>
|
||||
public static class LegacyIndustryActionCatalog
|
||||
{
|
||||
public const int ActionsPerMarket = 22;
|
||||
|
||||
private static readonly LegacyIndustryRuntimeAsset KospiAsset = new(
|
||||
IndustryMarket.Kospi,
|
||||
"bin/Debug/Res/업종_코스피.ini",
|
||||
"F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52");
|
||||
|
||||
private static readonly LegacyIndustryRuntimeAsset KosdaqAsset = new(
|
||||
IndustryMarket.Kosdaq,
|
||||
"bin/Debug/Res/업종_코스닥.ini",
|
||||
"F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4");
|
||||
|
||||
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> KospiActions =
|
||||
CreateActions(IndustryMarket.Kospi);
|
||||
|
||||
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> KosdaqActions =
|
||||
CreateActions(IndustryMarket.Kosdaq);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyIndustryActionDefinition>
|
||||
KospiActionsById = CreateLookup(KospiActions);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyIndustryActionDefinition>
|
||||
KosdaqActionsById = CreateLookup(KosdaqActions);
|
||||
|
||||
public static LegacyIndustryRuntimeAsset GetRuntimeAsset(IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => KospiAsset,
|
||||
IndustryMarket.Kosdaq => KosdaqAsset,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
|
||||
};
|
||||
|
||||
public static IReadOnlyList<LegacyIndustryActionDefinition> GetActions(
|
||||
IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => KospiActions,
|
||||
IndustryMarket.Kosdaq => KosdaqActions,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
|
||||
};
|
||||
|
||||
public static bool TryGetAction(
|
||||
IndustryMarket market,
|
||||
string actionId,
|
||||
out LegacyIndustryActionDefinition? definition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (!IsSafeActionId(actionId))
|
||||
{
|
||||
definition = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return LookupFor(market).TryGetValue(actionId, out definition);
|
||||
}
|
||||
|
||||
public static LegacyIndustryActionDefinition GetRequiredAction(
|
||||
IndustryMarket market,
|
||||
string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
return IsSafeActionId(actionId) &&
|
||||
LookupFor(market).TryGetValue(actionId, out var definition)
|
||||
? definition
|
||||
: throw new KeyNotFoundException("The industry action id is not registered.");
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, LegacyIndustryActionDefinition> LookupFor(
|
||||
IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => KospiActionsById,
|
||||
IndustryMarket.Kosdaq => KosdaqActionsById,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
|
||||
};
|
||||
|
||||
private static bool IsSafeActionId(string actionId) =>
|
||||
actionId.Length is > 0 and <= 64 &&
|
||||
!actionId.Any(char.IsControl) &&
|
||||
string.Equals(actionId, actionId.Trim(), StringComparison.Ordinal);
|
||||
|
||||
private static IReadOnlyList<LegacyIndustryActionDefinition> CreateActions(
|
||||
IndustryMarket market)
|
||||
{
|
||||
var pairAlias = market == IndustryMarket.Kospi ? "8018" : "8032";
|
||||
var squareAlias = market == IndustryMarket.Kospi ? "8001" : "8002";
|
||||
var actions = new List<LegacyIndustryActionDefinition>(ActionsPerMarket)
|
||||
{
|
||||
Action(market, "one-column", "1열판", "업종 판", "plate",
|
||||
LegacyIndustryActionKind.Single, "s5001", "5001"),
|
||||
Action(market, "two-column", "2열판", "업종 판", "plate",
|
||||
LegacyIndustryActionKind.Pair, "s8018", pairAlias)
|
||||
};
|
||||
|
||||
AddYield(actions, market, "5일", "FiveDays");
|
||||
AddYield(actions, market, "20일", "TwentyDays");
|
||||
AddYield(actions, market, "60일", "SixtyDays");
|
||||
AddYield(actions, market, "120일", "OneHundredTwentyDays");
|
||||
AddYield(actions, market, "240일", "TwoHundredFortyDays");
|
||||
|
||||
AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Price);
|
||||
|
||||
AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Volume);
|
||||
|
||||
actions.Add(Action(market, "five-row", "5단 표그래프", "시장 전체", "plate",
|
||||
LegacyIndustryActionKind.FiveRow, "s5074", "5074", pageSize: 5));
|
||||
actions.Add(Action(market, "square", "네모그래프", "시장 전체", "plate",
|
||||
LegacyIndustryActionKind.Square, "s8001", squareAlias));
|
||||
actions.Add(Action(market, "sector", "섹터지수", "시장 전체", "plate",
|
||||
LegacyIndustryActionKind.Sector, "s5078", "5078"));
|
||||
|
||||
Validate(actions, market);
|
||||
return Array.AsReadOnly(actions.ToArray());
|
||||
}
|
||||
|
||||
private static void AddYield(
|
||||
ICollection<LegacyIndustryActionDefinition> actions,
|
||||
IndustryMarket market,
|
||||
string periodLabel,
|
||||
string period)
|
||||
{
|
||||
actions.Add(Action(
|
||||
market,
|
||||
$"yield-{periodLabel}",
|
||||
$"수익률그래프_{periodLabel}",
|
||||
"수익률 그래프",
|
||||
"chart",
|
||||
LegacyIndustryActionKind.Yield,
|
||||
"s5086",
|
||||
"5086",
|
||||
yieldPeriod: period));
|
||||
}
|
||||
|
||||
private static void AddCandle(
|
||||
ICollection<LegacyIndustryActionDefinition> actions,
|
||||
IndustryMarket market,
|
||||
string periodLabel,
|
||||
string sceneAlias,
|
||||
LegacyIndustryCandleMode mode)
|
||||
{
|
||||
var volume = mode == LegacyIndustryCandleMode.Volume;
|
||||
actions.Add(Action(
|
||||
market,
|
||||
$"candle-{(volume ? "volume-" : string.Empty)}{periodLabel}",
|
||||
$"캔들그래프{(volume ? "(거래량)" : string.Empty)}_{periodLabel}",
|
||||
volume ? "캔들 그래프 · 거래량" : "캔들 그래프",
|
||||
"chart",
|
||||
LegacyIndustryActionKind.Candle,
|
||||
"s8010",
|
||||
sceneAlias,
|
||||
candleMode: mode));
|
||||
}
|
||||
|
||||
private static LegacyIndustryActionDefinition Action(
|
||||
IndustryMarket market,
|
||||
string actionId,
|
||||
string label,
|
||||
string section,
|
||||
string category,
|
||||
LegacyIndustryActionKind kind,
|
||||
string builderKey,
|
||||
string sceneAlias,
|
||||
int pageSize = 0,
|
||||
string? yieldPeriod = null,
|
||||
LegacyIndustryCandleMode? candleMode = null) => new(
|
||||
market,
|
||||
actionId,
|
||||
label,
|
||||
section,
|
||||
category,
|
||||
kind,
|
||||
builderKey,
|
||||
sceneAlias,
|
||||
pageSize,
|
||||
yieldPeriod,
|
||||
candleMode);
|
||||
|
||||
private static IReadOnlyDictionary<string, LegacyIndustryActionDefinition> CreateLookup(
|
||||
IReadOnlyList<LegacyIndustryActionDefinition> actions) =>
|
||||
new System.Collections.ObjectModel.ReadOnlyDictionary<
|
||||
string,
|
||||
LegacyIndustryActionDefinition>(actions.ToDictionary(
|
||||
action => action.ActionId,
|
||||
StringComparer.Ordinal));
|
||||
|
||||
private static void Validate(
|
||||
IReadOnlyCollection<LegacyIndustryActionDefinition> actions,
|
||||
IndustryMarket market)
|
||||
{
|
||||
if (actions.Count != ActionsPerMarket ||
|
||||
actions.Select(action => action.ActionId).Distinct(StringComparer.Ordinal).Count() !=
|
||||
ActionsPerMarket ||
|
||||
actions.Any(action => action.Market != market) ||
|
||||
actions.Any(action => action.PageSize is not (0 or 5)) ||
|
||||
actions.Any(action =>
|
||||
(action.Kind == LegacyIndustryActionKind.FiveRow) != (action.PageSize == 5)) ||
|
||||
actions.Any(action => action.Kind == LegacyIndustryActionKind.Yield &&
|
||||
string.IsNullOrEmpty(action.YieldPeriod)) ||
|
||||
actions.Any(action => action.Kind != LegacyIndustryActionKind.Yield &&
|
||||
action.YieldPeriod is not null) ||
|
||||
actions.Any(action => action.Kind == LegacyIndustryActionKind.Candle &&
|
||||
action.CandleMode is null) ||
|
||||
actions.Any(action => action.Kind != LegacyIndustryActionKind.Candle &&
|
||||
action.CandleMode is not null) ||
|
||||
actions.Any(action => !IsSafeActionId(action.ActionId)) ||
|
||||
actions.Any(action => !LegacySceneCatalog.FindByCutAlias(action.SceneAlias)
|
||||
.Any(scene =>
|
||||
string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) &&
|
||||
(int)scene.PageSize == action.PageSize)))
|
||||
{
|
||||
throw new InvalidOperationException("The legacy industry action catalog is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Globalization;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public sealed record LegacyIndustrySelectionSnapshot(
|
||||
long Revision,
|
||||
IndustryMarket? Market,
|
||||
IReadOnlyList<IndustrySelection> Industries,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition> Actions,
|
||||
int? SelectedIndex,
|
||||
IndustrySelection? SelectedIndustry,
|
||||
IndustrySelection? FirstIndustry,
|
||||
IndustrySelection? SecondIndustry);
|
||||
|
||||
public sealed record LegacyIndustrySceneSelectionDraft(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
/// <summary>
|
||||
/// Safe, generic playlist fields produced entirely from the C# catalog and the
|
||||
/// authoritative UC2 selection state. It intentionally has no caller-supplied row id.
|
||||
/// </summary>
|
||||
public sealed record LegacyIndustryPlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string SceneAlias,
|
||||
int PageSize,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
IndustryMarket Market,
|
||||
LegacyIndustrySceneSelectionDraft Selection,
|
||||
string RuntimeAssetRelativePath,
|
||||
string RuntimeAssetSha256,
|
||||
bool IsEnabled,
|
||||
int FadeDuration);
|
||||
|
||||
public sealed class LegacyIndustryWorkflowException : Exception
|
||||
{
|
||||
public LegacyIndustryWorkflowException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C# authority for the legacy UC2 selector and the UC1 industry actions.
|
||||
/// </summary>
|
||||
public sealed class LegacyIndustrySelectionWorkflow
|
||||
{
|
||||
private const int DefaultFadeDuration = 6;
|
||||
|
||||
private static readonly IReadOnlyList<IndustrySelection> NoIndustries =
|
||||
Array.AsReadOnly(Array.Empty<IndustrySelection>());
|
||||
|
||||
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> NoActions =
|
||||
Array.AsReadOnly(Array.Empty<LegacyIndustryActionDefinition>());
|
||||
|
||||
private readonly IIndustrySelectionService _selectionService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
|
||||
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
|
||||
private IndustryMarket? _market;
|
||||
private int? _selectedIndex;
|
||||
private IndustrySelection? _selectedIndustry;
|
||||
private IndustrySelection? _firstIndustry;
|
||||
private IndustrySelection? _secondIndustry;
|
||||
private long _revision;
|
||||
private long _loadGeneration;
|
||||
|
||||
public LegacyIndustrySelectionWorkflow(
|
||||
IIndustrySelectionService selectionService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_selectionService = selectionService ??
|
||||
throw new ArgumentNullException(nameof(selectionService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
|
||||
|
||||
public async Task<LegacyIndustrySelectionSnapshot> LoadMarketAsync(
|
||||
IndustryMarket market,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateMarket(market);
|
||||
var generation = Interlocked.Increment(ref _loadGeneration);
|
||||
var serviceRows = await _selectionService.GetAsync(market, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// A slower previous request must never replace a newer market request.
|
||||
if (generation != Volatile.Read(ref _loadGeneration))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var normalizedRows = NormalizeRows(serviceRows, market);
|
||||
if (generation != Volatile.Read(ref _loadGeneration))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var changingMarket = _market != market;
|
||||
var previousSelected = changingMarket ? null : _selectedIndustry;
|
||||
var previousFirst = changingMarket ? null : _firstIndustry;
|
||||
var previousSecond = changingMarket ? null : _secondIndustry;
|
||||
|
||||
_market = market;
|
||||
_industries = normalizedRows;
|
||||
_actions = LegacyIndustryActionCatalog.GetActions(market);
|
||||
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
|
||||
_selectedIndex = _selectedIndustry is null
|
||||
? null
|
||||
: FindIdentityIndex(normalizedRows, _selectedIndustry);
|
||||
_firstIndustry = Reconcile(previousFirst, normalizedRows);
|
||||
_secondIndustry = Reconcile(previousSecond, normalizedRows);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot SelectIndustry(int index)
|
||||
{
|
||||
if (!TryGetIndustry(index, out var industry))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = index;
|
||||
_selectedIndustry = industry;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot DoubleClickIndustry(int index)
|
||||
{
|
||||
if (!TryGetIndustry(index, out var industry))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = index;
|
||||
_selectedIndustry = industry;
|
||||
|
||||
// UC2.listbox_DoubleClick copied txt1 to txt2 before assigning the newly
|
||||
// double-clicked item to txt1.
|
||||
_secondIndustry = _firstIndustry;
|
||||
_firstIndustry = industry;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot SwapIndustries()
|
||||
{
|
||||
if (_firstIndustry is null && _secondIndustry is null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
(_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot ClearSelections()
|
||||
{
|
||||
if (_selectedIndustry is null && _firstIndustry is null && _secondIndustry is null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = null;
|
||||
_selectedIndustry = null;
|
||||
_firstIndustry = null;
|
||||
_secondIndustry = null;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustryPlaylistDraft MaterializePlaylistDraft(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (_market is not { } market)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("Select an industry market first.");
|
||||
}
|
||||
|
||||
if (!LegacyIndustryActionCatalog.TryGetAction(market, actionId, out var definition) ||
|
||||
definition is null)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("The industry action is not registered.");
|
||||
}
|
||||
|
||||
var projection = ProjectSelection(definition, market);
|
||||
var asset = LegacyIndustryActionCatalog.GetRuntimeAsset(market);
|
||||
return new LegacyIndustryPlaylistDraft(
|
||||
definition.ActionId,
|
||||
definition.BuilderKey,
|
||||
definition.SceneAlias,
|
||||
definition.PageSize,
|
||||
projection.Title,
|
||||
definition.Label,
|
||||
definition.Category,
|
||||
market,
|
||||
projection.Selection,
|
||||
asset.RelativePath,
|
||||
asset.Sha256,
|
||||
IsEnabled: true,
|
||||
FadeDuration: DefaultFadeDuration);
|
||||
}
|
||||
|
||||
private IndustryProjection ProjectSelection(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
IndustryMarket market)
|
||||
{
|
||||
var industryGroup = market == IndustryMarket.Kospi
|
||||
? "INDUSTRY_KOSPI"
|
||||
: "INDUSTRY_KOSDAQ";
|
||||
var marketName = market == IndustryMarket.Kospi ? "KOSPI" : "KOSDAQ";
|
||||
|
||||
return definition.Kind switch
|
||||
{
|
||||
LegacyIndustryActionKind.Single => ForSelected(
|
||||
definition,
|
||||
industryGroup,
|
||||
"1열판",
|
||||
"CURRENT"),
|
||||
LegacyIndustryActionKind.Pair => ForPair(definition, industryGroup),
|
||||
LegacyIndustryActionKind.Yield => ForSelected(
|
||||
definition,
|
||||
industryGroup,
|
||||
"YIELD_GRAPH",
|
||||
definition.YieldPeriod!),
|
||||
LegacyIndustryActionKind.Candle => ForSelected(
|
||||
definition,
|
||||
market == IndustryMarket.Kospi ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY",
|
||||
string.Empty,
|
||||
definition.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE"),
|
||||
LegacyIndustryActionKind.FiveRow => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
market == IndustryMarket.Kospi
|
||||
? "PAGED_DOMESTIC_KOSPI"
|
||||
: "PAGED_DOMESTIC_KOSDAQ",
|
||||
"INDEX",
|
||||
string.Empty,
|
||||
"INDUSTRY",
|
||||
CurrentLocalDate())),
|
||||
LegacyIndustryActionKind.Square => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
industryGroup,
|
||||
marketName,
|
||||
"네모그래프",
|
||||
string.Empty,
|
||||
string.Empty)),
|
||||
LegacyIndustryActionKind.Sector => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
industryGroup,
|
||||
marketName,
|
||||
"SECTOR_INDEX",
|
||||
string.Empty,
|
||||
string.Empty)),
|
||||
_ => throw new LegacyIndustryWorkflowException("The industry action kind is unsupported.")
|
||||
};
|
||||
}
|
||||
|
||||
private IndustryProjection ForSelected(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
string groupCode,
|
||||
string graphicType,
|
||||
string subtype)
|
||||
{
|
||||
if (_selectedIndustry is not { } selected)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
$"The {definition.ActionId} action requires one selected industry.");
|
||||
}
|
||||
|
||||
return new IndustryProjection(
|
||||
selected.IndustryName,
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
groupCode,
|
||||
selected.IndustryName,
|
||||
graphicType,
|
||||
subtype,
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
private IndustryProjection ForPair(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
string groupCode)
|
||||
{
|
||||
if (_firstIndustry is not { } first || _secondIndustry is not { } second ||
|
||||
first.IndustryName.Contains('-', StringComparison.Ordinal) ||
|
||||
second.IndustryName.Contains('-', StringComparison.Ordinal))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
$"The {definition.ActionId} action requires two delimiter-safe industries.");
|
||||
}
|
||||
|
||||
var subject = $"{first.IndustryName}-{second.IndustryName}";
|
||||
return new IndustryProjection(
|
||||
subject,
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
groupCode,
|
||||
subject,
|
||||
"2열판",
|
||||
string.Empty,
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
private string CurrentLocalDate()
|
||||
{
|
||||
var local = _timeProvider.GetLocalNow();
|
||||
return local.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private bool TryGetIndustry(int index, out IndustrySelection industry)
|
||||
{
|
||||
if (index < 0 || index >= _industries.Count)
|
||||
{
|
||||
industry = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
industry = _industries[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IndustrySelection> NormalizeRows(
|
||||
IReadOnlyList<IndustrySelection>? rows,
|
||||
IndustryMarket market)
|
||||
{
|
||||
if (rows is null || rows.Count > LegacyIndustrySelectionService.MaximumResults)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("The industry list is invalid.");
|
||||
}
|
||||
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var normalized = new IndustrySelection[rows.Count];
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
var row = rows[index];
|
||||
if (row is null || row.Market != market)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
"The industry list contains a cross-market row.");
|
||||
}
|
||||
|
||||
var name = row.IndustryName?.Trim();
|
||||
var code = row.IndustryCode?.Trim();
|
||||
if (string.IsNullOrEmpty(name) ||
|
||||
name.Length > LegacyIndustrySelectionService.MaximumNameLength ||
|
||||
name.Any(char.IsControl) ||
|
||||
string.IsNullOrEmpty(code) ||
|
||||
code.Length > 32 ||
|
||||
code.Any(character => !char.IsAsciiLetterOrDigit(character)) ||
|
||||
!names.Add(name) ||
|
||||
!codes.Add(code))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
"The industry list contains an unsafe or ambiguous row.");
|
||||
}
|
||||
|
||||
normalized[index] = new IndustrySelection(market, name, code);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(normalized);
|
||||
}
|
||||
|
||||
private static IndustrySelection? Reconcile(
|
||||
IndustrySelection? previous,
|
||||
IReadOnlyList<IndustrySelection> rows)
|
||||
{
|
||||
if (previous is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rows.FirstOrDefault(row => SameIdentity(row, previous));
|
||||
}
|
||||
|
||||
private static int? FindIdentityIndex(
|
||||
IReadOnlyList<IndustrySelection> rows,
|
||||
IndustrySelection selected)
|
||||
{
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
if (SameIdentity(rows[index], selected))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool SameIdentity(IndustrySelection left, IndustrySelection right) =>
|
||||
left.Market == right.Market &&
|
||||
string.Equals(left.IndustryName, right.IndustryName, StringComparison.Ordinal) &&
|
||||
string.Equals(left.IndustryCode, right.IndustryCode, StringComparison.Ordinal);
|
||||
|
||||
private static void ValidateMarket(IndustryMarket market)
|
||||
{
|
||||
if (market is not (IndustryMarket.Kospi or IndustryMarket.Kosdaq))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(market),
|
||||
market,
|
||||
"Unsupported industry market.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string MarketLabel(IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => "코스피",
|
||||
IndustryMarket.Kosdaq => "코스닥",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
|
||||
};
|
||||
|
||||
private LegacyIndustrySelectionSnapshot CreateSnapshot() => new(
|
||||
_revision,
|
||||
_market,
|
||||
_industries,
|
||||
_actions,
|
||||
_selectedIndex,
|
||||
_selectedIndustry,
|
||||
_firstIndustry,
|
||||
_secondIndustry);
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record IndustryProjection(
|
||||
string Title,
|
||||
LegacyIndustrySceneSelectionDraft Selection);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyOperatorPlayoutPhase
|
||||
{
|
||||
Idle,
|
||||
Prepared,
|
||||
Program
|
||||
}
|
||||
|
||||
public enum LegacyOperatorPlayoutCommand
|
||||
{
|
||||
Prepare,
|
||||
TakeIn,
|
||||
Next,
|
||||
TakeOut
|
||||
}
|
||||
|
||||
public sealed record LegacyOperatorPlayoutRequest(
|
||||
IReadOnlyList<LegacyPlaylistEntry> Playlist,
|
||||
int SelectedIndexZeroBased);
|
||||
|
||||
public sealed record LegacyOperatorPlayoutSnapshot(
|
||||
PlayoutMode Mode,
|
||||
PlayoutConnectionState ConnectionState,
|
||||
LegacyOperatorPlayoutPhase Phase,
|
||||
bool IsProcessRunning,
|
||||
bool IsConnected,
|
||||
bool IsCommandAvailable,
|
||||
bool LiveTakeInAllowed,
|
||||
bool IsPlayCompletionPending,
|
||||
bool OutcomeUnknown,
|
||||
string? PreparedCode,
|
||||
string? OnAirCode,
|
||||
int CurrentCueIndexZeroBased,
|
||||
string? CurrentEntryId,
|
||||
string? BuilderKey,
|
||||
int PageIndexZeroBased,
|
||||
int PageCount,
|
||||
int PageSize,
|
||||
int ItemCount,
|
||||
int CurrentPageItemCount,
|
||||
bool IsLastPage,
|
||||
LegacyWorkflowNextKind NextKind,
|
||||
bool IsBusy,
|
||||
bool RefreshActive,
|
||||
int RefreshCompletedCount,
|
||||
int? RefreshMaximumCount,
|
||||
bool RefreshLimitReached,
|
||||
int FadeDuration,
|
||||
bool BackgroundEnabled,
|
||||
string BackgroundFileName,
|
||||
bool CanChangeBackground,
|
||||
string Message);
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyOverseasActionSource
|
||||
{
|
||||
FixedIndex,
|
||||
Industry,
|
||||
Stock
|
||||
}
|
||||
|
||||
public enum LegacyOverseasActionKind
|
||||
{
|
||||
Current,
|
||||
Candle
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasFixedIndexTarget
|
||||
{
|
||||
internal LegacyOverseasFixedIndexTarget(
|
||||
string targetId,
|
||||
string displayName,
|
||||
string inputName,
|
||||
string symbol,
|
||||
string nationCode)
|
||||
{
|
||||
TargetId = targetId;
|
||||
DisplayName = displayName;
|
||||
InputName = inputName;
|
||||
Symbol = symbol;
|
||||
NationCode = nationCode;
|
||||
}
|
||||
|
||||
public string TargetId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string InputName { get; }
|
||||
|
||||
public string Symbol { get; }
|
||||
|
||||
public string NationCode { get; }
|
||||
|
||||
public string ForeignDomesticCode => "0";
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasActionDefinition
|
||||
{
|
||||
internal LegacyOverseasActionDefinition(
|
||||
string actionId,
|
||||
string label,
|
||||
LegacyOverseasActionSource source,
|
||||
LegacyOverseasActionKind kind,
|
||||
string builderKey,
|
||||
string sceneAlias,
|
||||
int? periodDays)
|
||||
{
|
||||
ActionId = actionId;
|
||||
Label = label;
|
||||
Source = source;
|
||||
Kind = kind;
|
||||
BuilderKey = builderKey;
|
||||
SceneAlias = sceneAlias;
|
||||
Aliases = Array.AsReadOnly([sceneAlias]);
|
||||
PeriodDays = periodDays;
|
||||
}
|
||||
|
||||
public string ActionId { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public LegacyOverseasActionSource Source { get; }
|
||||
|
||||
public LegacyOverseasActionKind Kind { get; }
|
||||
|
||||
public string BuilderKey { get; }
|
||||
|
||||
public string SceneAlias { get; }
|
||||
|
||||
public IReadOnlyList<string> Aliases { get; }
|
||||
|
||||
public int PageSize => 0;
|
||||
|
||||
public int? PeriodDays { get; }
|
||||
|
||||
public bool SupportsMovingAverages => Kind == LegacyOverseasActionKind.Candle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed UC5 metadata. The original control exposed ten fixed world indices and twelve
|
||||
/// buttons: five index candles, one US-industry current plate, and six US/TW stock actions.
|
||||
/// Scene identities never come from Web input.
|
||||
/// </summary>
|
||||
public static class LegacyOverseasActionCatalog
|
||||
{
|
||||
public const int FixedIndexTargetCount = 10;
|
||||
public const int ActionCount = 12;
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasFixedIndexTarget> RegisteredTargets =
|
||||
Array.AsReadOnly<LegacyOverseasFixedIndexTarget>(
|
||||
[
|
||||
Target("dow-jones", "다우존스", "DJI@DJI", "US"),
|
||||
Target("nasdaq", "나스닥", "NAS@IXIC", "US"),
|
||||
Target("sp500", "S&P500", "SPI@SPX", "US"),
|
||||
Target("germany-dax-30", "독일 DAX 30", "XTR@DAX30", "DE"),
|
||||
Target("united-kingdom-ftse-100", "영국 FTSE 100", "LNS@FTSE100", "GB"),
|
||||
Target("france-cac-40", "프랑스 CAC 40", "PAS@CAC40", "FR"),
|
||||
Target("japan-nikkei", "일본 니케이", "NII@NI225", "JP"),
|
||||
Target("china-shanghai-composite", "중국 상해종합", "SHS@000001", "CN"),
|
||||
Target("hong-kong-hang-seng", "홍콩 항셍", "HSI@HSI", "HK"),
|
||||
Target("taiwan-weighted", "대만 가권", "TWS@TI01", "TW")
|
||||
]);
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasActionDefinition> RegisteredActions =
|
||||
Array.AsReadOnly(CreateActions());
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyOverseasFixedIndexTarget>
|
||||
TargetsById = new ReadOnlyDictionary<string, LegacyOverseasFixedIndexTarget>(
|
||||
RegisteredTargets.ToDictionary(target => target.TargetId, StringComparer.Ordinal));
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyOverseasActionDefinition>
|
||||
ActionsById = new ReadOnlyDictionary<string, LegacyOverseasActionDefinition>(
|
||||
RegisteredActions.ToDictionary(action => action.ActionId, StringComparer.Ordinal));
|
||||
|
||||
static LegacyOverseasActionCatalog()
|
||||
{
|
||||
ValidateTargets();
|
||||
ValidateActions();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LegacyOverseasFixedIndexTarget> FixedIndexTargets =>
|
||||
RegisteredTargets;
|
||||
|
||||
public static IReadOnlyList<LegacyOverseasActionDefinition> Actions => RegisteredActions;
|
||||
|
||||
public static IReadOnlyList<LegacyOverseasActionDefinition> GetActions(
|
||||
LegacyOverseasActionSource source)
|
||||
{
|
||||
if (!Enum.IsDefined(source))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(source), source, "Unknown overseas source.");
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(
|
||||
RegisteredActions.Where(action => action.Source == source).ToArray());
|
||||
}
|
||||
|
||||
public static LegacyOverseasFixedIndexTarget? FindFixedIndexTarget(string? targetId) =>
|
||||
targetId is not null && TargetsById.TryGetValue(targetId, out var target)
|
||||
? target
|
||||
: null;
|
||||
|
||||
public static LegacyOverseasFixedIndexTarget GetFixedIndexTarget(string targetId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
|
||||
return TargetsById.TryGetValue(targetId, out var target)
|
||||
? target
|
||||
: throw new KeyNotFoundException("The overseas fixed-index target is not registered.");
|
||||
}
|
||||
|
||||
public static LegacyOverseasActionDefinition? FindAction(string? actionId) =>
|
||||
actionId is not null && ActionsById.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: null;
|
||||
|
||||
public static LegacyOverseasActionDefinition GetAction(string actionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
return ActionsById.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: throw new KeyNotFoundException("The overseas action is not registered.");
|
||||
}
|
||||
|
||||
private static LegacyOverseasFixedIndexTarget Target(
|
||||
string targetId,
|
||||
string inputName,
|
||||
string symbol,
|
||||
string nationCode) =>
|
||||
new(targetId, inputName, inputName, symbol, nationCode);
|
||||
|
||||
private static LegacyOverseasActionDefinition[] CreateActions() =>
|
||||
[
|
||||
Candle("index-candle-5d", "해외지수 캔들 5일", LegacyOverseasActionSource.FixedIndex, 5, "8061"),
|
||||
Candle("index-candle-20d", "해외지수 캔들 20일", LegacyOverseasActionSource.FixedIndex, 20, "8040"),
|
||||
Candle("index-candle-60d", "해외지수 캔들 60일", LegacyOverseasActionSource.FixedIndex, 60, "8046"),
|
||||
Candle("index-candle-120d", "해외지수 캔들 120일", LegacyOverseasActionSource.FixedIndex, 120, "8051"),
|
||||
Candle("index-candle-240d", "해외지수 캔들 240일", LegacyOverseasActionSource.FixedIndex, 240, "8056"),
|
||||
new(
|
||||
"industry-current",
|
||||
"미국 해외업종 현재가 1열판",
|
||||
LegacyOverseasActionSource.Industry,
|
||||
LegacyOverseasActionKind.Current,
|
||||
"s5001",
|
||||
"5001",
|
||||
periodDays: null),
|
||||
new(
|
||||
"stock-current",
|
||||
"해외종목 현재가 1열판",
|
||||
LegacyOverseasActionSource.Stock,
|
||||
LegacyOverseasActionKind.Current,
|
||||
"s5001",
|
||||
"5001",
|
||||
periodDays: null),
|
||||
Candle("stock-candle-5d", "해외종목 캔들 5일", LegacyOverseasActionSource.Stock, 5, "8061"),
|
||||
Candle("stock-candle-20d", "해외종목 캔들 20일", LegacyOverseasActionSource.Stock, 20, "8040"),
|
||||
Candle("stock-candle-60d", "해외종목 캔들 60일", LegacyOverseasActionSource.Stock, 60, "8046"),
|
||||
Candle("stock-candle-120d", "해외종목 캔들 120일", LegacyOverseasActionSource.Stock, 120, "8051"),
|
||||
Candle("stock-candle-240d", "해외종목 캔들 240일", LegacyOverseasActionSource.Stock, 240, "8056")
|
||||
];
|
||||
|
||||
private static LegacyOverseasActionDefinition Candle(
|
||||
string actionId,
|
||||
string label,
|
||||
LegacyOverseasActionSource source,
|
||||
int periodDays,
|
||||
string sceneAlias) =>
|
||||
new(
|
||||
actionId,
|
||||
label,
|
||||
source,
|
||||
LegacyOverseasActionKind.Candle,
|
||||
"s8010",
|
||||
sceneAlias,
|
||||
periodDays);
|
||||
|
||||
private static void ValidateTargets()
|
||||
{
|
||||
if (RegisteredTargets.Count != FixedIndexTargetCount ||
|
||||
RegisteredTargets.Select(target => target.TargetId)
|
||||
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
|
||||
RegisteredTargets.Select(target => target.DisplayName)
|
||||
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
|
||||
RegisteredTargets.Select(target => target.Symbol)
|
||||
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
|
||||
RegisteredTargets.Any(target =>
|
||||
!IsSafeId(target.TargetId) ||
|
||||
!IsSafeCanonicalText(target.DisplayName, 200) ||
|
||||
!string.Equals(target.DisplayName, target.InputName, StringComparison.Ordinal) ||
|
||||
!IsSafeSymbol(target.Symbol) ||
|
||||
target.NationCode.Length != 2 ||
|
||||
target.NationCode.Any(character => character is < 'A' or > 'Z')))
|
||||
{
|
||||
throw new InvalidOperationException("The legacy overseas fixed-index catalog is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateActions()
|
||||
{
|
||||
if (RegisteredActions.Count != ActionCount ||
|
||||
RegisteredActions.Select(action => action.ActionId)
|
||||
.Distinct(StringComparer.Ordinal).Count() != ActionCount ||
|
||||
RegisteredActions.Any(action => !IsSafeId(action.ActionId)) ||
|
||||
RegisteredActions.Any(action => action.PageSize != 0) ||
|
||||
RegisteredActions.Any(action =>
|
||||
action.Kind == LegacyOverseasActionKind.Candle != action.PeriodDays.HasValue) ||
|
||||
RegisteredActions.Any(action =>
|
||||
action.Source == LegacyOverseasActionSource.Industry &&
|
||||
action.Kind != LegacyOverseasActionKind.Current) ||
|
||||
RegisteredActions.Any(action =>
|
||||
!LegacySceneCatalog.FindByCutAlias(action.SceneAlias).Any(scene =>
|
||||
string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) &&
|
||||
(int)scene.PageSize == action.PageSize)))
|
||||
{
|
||||
throw new InvalidOperationException("The legacy overseas action catalog is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSafeId(string value) =>
|
||||
value.Length is > 0 and <= 64 &&
|
||||
value.All(character => char.IsAsciiLetterOrDigit(character) || character == '-');
|
||||
|
||||
private static bool IsSafeCanonicalText(string value, int maximumLength) =>
|
||||
value.Length is > 0 &&
|
||||
value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
!value.Any(character => char.IsControl(character) || char.IsSurrogate(character));
|
||||
|
||||
private static bool IsSafeSymbol(string value) =>
|
||||
IsSafeCanonicalText(value, 64) &&
|
||||
value.All(character =>
|
||||
char.IsAsciiLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-');
|
||||
}
|
||||
@@ -0,0 +1,897 @@
|
||||
using System.Globalization;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyOverseasSearchStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public readonly record struct LegacyOverseasMovingAverageSelection(bool Ma5, bool Ma20);
|
||||
|
||||
public sealed class LegacyOverseasIndustrySearchRow
|
||||
{
|
||||
internal LegacyOverseasIndustrySearchRow(
|
||||
string selectionId,
|
||||
OverseasIndustryIndexItem identity)
|
||||
{
|
||||
SelectionId = selectionId;
|
||||
Identity = identity;
|
||||
}
|
||||
|
||||
public string SelectionId { get; }
|
||||
|
||||
public string KoreanName => Identity.KoreanName;
|
||||
|
||||
public string InputName => Identity.InputName;
|
||||
|
||||
public string Symbol => Identity.Symbol;
|
||||
|
||||
public string NationCode => "US";
|
||||
|
||||
public string ForeignDomesticCode => "0";
|
||||
|
||||
internal OverseasIndustryIndexItem Identity { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasStockSearchRow
|
||||
{
|
||||
internal LegacyOverseasStockSearchRow(
|
||||
string selectionId,
|
||||
WorldStockSearchItem identity)
|
||||
{
|
||||
SelectionId = selectionId;
|
||||
Identity = identity;
|
||||
}
|
||||
|
||||
public string SelectionId { get; }
|
||||
|
||||
public string InputName => Identity.InputName;
|
||||
|
||||
public string Symbol => Identity.Symbol;
|
||||
|
||||
public string NationCode => Identity.NationCode;
|
||||
|
||||
public string ForeignDomesticCode => "1";
|
||||
|
||||
internal WorldStockSearchItem Identity { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasIndustrySearchSnapshot
|
||||
{
|
||||
internal LegacyOverseasIndustrySearchSnapshot(
|
||||
LegacyOverseasSearchStatus status,
|
||||
string query,
|
||||
DateTimeOffset? retrievedAt,
|
||||
IReadOnlyList<LegacyOverseasIndustrySearchRow> results,
|
||||
bool isTruncated,
|
||||
LegacyOverseasIndustrySearchRow? selected,
|
||||
string error)
|
||||
{
|
||||
Status = status;
|
||||
Query = query;
|
||||
RetrievedAt = retrievedAt;
|
||||
Results = results;
|
||||
IsTruncated = isTruncated;
|
||||
Selected = selected;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public LegacyOverseasSearchStatus Status { get; }
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public DateTimeOffset? RetrievedAt { get; }
|
||||
|
||||
public IReadOnlyList<LegacyOverseasIndustrySearchRow> Results { get; }
|
||||
|
||||
public int ResultCount => Results.Count;
|
||||
|
||||
public bool IsTruncated { get; }
|
||||
|
||||
public LegacyOverseasIndustrySearchRow? Selected { get; }
|
||||
|
||||
public string? SelectedId => Selected?.SelectionId;
|
||||
|
||||
public string Error { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasStockSearchSnapshot
|
||||
{
|
||||
internal LegacyOverseasStockSearchSnapshot(
|
||||
LegacyOverseasSearchStatus status,
|
||||
string query,
|
||||
DateTimeOffset? retrievedAt,
|
||||
IReadOnlyList<LegacyOverseasStockSearchRow> results,
|
||||
bool isTruncated,
|
||||
LegacyOverseasStockSearchRow? selected,
|
||||
string error)
|
||||
{
|
||||
Status = status;
|
||||
Query = query;
|
||||
RetrievedAt = retrievedAt;
|
||||
Results = results;
|
||||
IsTruncated = isTruncated;
|
||||
Selected = selected;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public LegacyOverseasSearchStatus Status { get; }
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public DateTimeOffset? RetrievedAt { get; }
|
||||
|
||||
public IReadOnlyList<LegacyOverseasStockSearchRow> Results { get; }
|
||||
|
||||
public int ResultCount => Results.Count;
|
||||
|
||||
public bool IsTruncated { get; }
|
||||
|
||||
public LegacyOverseasStockSearchRow? Selected { get; }
|
||||
|
||||
public string? SelectedId => Selected?.SelectionId;
|
||||
|
||||
public string Error { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasWorkflowSnapshot
|
||||
{
|
||||
internal LegacyOverseasWorkflowSnapshot(
|
||||
long revision,
|
||||
IReadOnlyList<LegacyOverseasFixedIndexTarget> fixedIndexTargets,
|
||||
LegacyOverseasFixedIndexTarget? selectedFixedIndex,
|
||||
LegacyOverseasIndustrySearchSnapshot industry,
|
||||
LegacyOverseasStockSearchSnapshot stock,
|
||||
LegacyOverseasMovingAverageSelection movingAverages,
|
||||
IReadOnlyList<LegacyOverseasActionDefinition> actions)
|
||||
{
|
||||
Revision = revision;
|
||||
FixedIndexTargets = fixedIndexTargets;
|
||||
SelectedFixedIndex = selectedFixedIndex;
|
||||
Industry = industry;
|
||||
Stock = stock;
|
||||
MovingAverages = movingAverages;
|
||||
Actions = actions;
|
||||
}
|
||||
|
||||
public long Revision { get; }
|
||||
|
||||
public IReadOnlyList<LegacyOverseasFixedIndexTarget> FixedIndexTargets { get; }
|
||||
|
||||
public LegacyOverseasFixedIndexTarget? SelectedFixedIndex { get; }
|
||||
|
||||
public LegacyOverseasIndustrySearchSnapshot Industry { get; }
|
||||
|
||||
public LegacyOverseasStockSearchSnapshot Stock { get; }
|
||||
|
||||
public LegacyOverseasMovingAverageSelection MovingAverages { get; }
|
||||
|
||||
public IReadOnlyList<LegacyOverseasActionDefinition> Actions { get; }
|
||||
}
|
||||
|
||||
public sealed record LegacyOverseasPlaylistSelection(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
public sealed record LegacyOverseasPlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string SceneAlias,
|
||||
IReadOnlyList<string> Aliases,
|
||||
int PageSize,
|
||||
string InitialPageText,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
LegacyOverseasActionSource Source,
|
||||
LegacyOverseasPlaylistSelection Selection,
|
||||
LegacyOverseasMovingAverageSelection MovingAverages,
|
||||
bool IsEnabled,
|
||||
int FadeDuration);
|
||||
|
||||
public sealed class LegacyOverseasWorkflowException : Exception
|
||||
{
|
||||
public LegacyOverseasWorkflowException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C# authority for the UC5 overseas selector. Search services return identities, while Web
|
||||
/// receives generation-bound selection ids and closed action ids only. A slower response can
|
||||
/// never replace the state established by a newer request of the same source.
|
||||
/// </summary>
|
||||
public sealed class LegacyOverseasSelectionWorkflow
|
||||
{
|
||||
public const int DefaultMaximumResults = 100;
|
||||
public const int MaximumResults = 500;
|
||||
public const int MaximumQueryLength = 64;
|
||||
|
||||
private const int DefaultFadeDuration = 6;
|
||||
private const string InitialPageText = "1/1";
|
||||
private const string IndustryError = "미국 해외업종 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
private const string StockError = "미국·대만 해외종목 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasIndustrySearchRow> NoIndustryResults =
|
||||
Array.AsReadOnly(Array.Empty<LegacyOverseasIndustrySearchRow>());
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasStockSearchRow> NoStockResults =
|
||||
Array.AsReadOnly(Array.Empty<LegacyOverseasStockSearchRow>());
|
||||
|
||||
private readonly IOverseasIndustryIndexSearchService _industrySearchService;
|
||||
private readonly IWorldStockSearchService _stockSearchService;
|
||||
private readonly object _sync = new();
|
||||
|
||||
private LegacyOverseasFixedIndexTarget? _selectedFixedIndex;
|
||||
private LegacyOverseasSearchStatus _industryStatus;
|
||||
private string _industryQuery = string.Empty;
|
||||
private DateTimeOffset? _industryRetrievedAt;
|
||||
private IReadOnlyList<LegacyOverseasIndustrySearchRow> _industryResults = NoIndustryResults;
|
||||
private bool _industryIsTruncated;
|
||||
private LegacyOverseasIndustrySearchRow? _selectedIndustry;
|
||||
private string _industryError = string.Empty;
|
||||
private long _industryGeneration;
|
||||
private LegacyOverseasSearchStatus _stockStatus;
|
||||
private string _stockQuery = string.Empty;
|
||||
private DateTimeOffset? _stockRetrievedAt;
|
||||
private IReadOnlyList<LegacyOverseasStockSearchRow> _stockResults = NoStockResults;
|
||||
private bool _stockIsTruncated;
|
||||
private LegacyOverseasStockSearchRow? _selectedStock;
|
||||
private string _stockError = string.Empty;
|
||||
private long _stockGeneration;
|
||||
private LegacyOverseasMovingAverageSelection _movingAverages;
|
||||
private long _revision;
|
||||
|
||||
public LegacyOverseasSelectionWorkflow(
|
||||
IOverseasIndustryIndexSearchService industrySearchService,
|
||||
IWorldStockSearchService stockSearchService)
|
||||
{
|
||||
_industrySearchService = industrySearchService ??
|
||||
throw new ArgumentNullException(nameof(industrySearchService));
|
||||
_stockSearchService = stockSearchService ??
|
||||
throw new ArgumentNullException(nameof(stockSearchService));
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId)
|
||||
{
|
||||
var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId);
|
||||
lock (_sync)
|
||||
{
|
||||
if (!ReferenceEquals(_selectedFixedIndex, target))
|
||||
{
|
||||
_selectedFixedIndex = target;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyOverseasWorkflowSnapshot> SearchIndustriesAsync(
|
||||
string query,
|
||||
int maximumResults = DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
ValidateMaximumResults(maximumResults);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
long generation;
|
||||
lock (_sync)
|
||||
{
|
||||
generation = checked(++_industryGeneration);
|
||||
_industryStatus = LegacyOverseasSearchStatus.Loading;
|
||||
_industryQuery = normalizedQuery;
|
||||
_industryRetrievedAt = null;
|
||||
_industryResults = NoIndustryResults;
|
||||
_industryIsTruncated = false;
|
||||
_selectedIndustry = null;
|
||||
_industryError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _industrySearchService.SearchAsync(
|
||||
normalizedQuery,
|
||||
maximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _industryGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
var validated = ValidateIndustryResult(
|
||||
result,
|
||||
normalizedQuery,
|
||||
maximumResults,
|
||||
generation);
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _industryGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_industryStatus = LegacyOverseasSearchStatus.Ready;
|
||||
_industryRetrievedAt = result.RetrievedAt;
|
||||
_industryResults = validated;
|
||||
_industryIsTruncated = result.IsTruncated;
|
||||
_industryError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation == _industryGeneration)
|
||||
{
|
||||
_industryStatus = LegacyOverseasSearchStatus.Idle;
|
||||
_industryError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _industryGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_industryStatus = LegacyOverseasSearchStatus.Error;
|
||||
_industryRetrievedAt = null;
|
||||
_industryResults = NoIndustryResults;
|
||||
_industryIsTruncated = false;
|
||||
_selectedIndustry = null;
|
||||
_industryError = IndustryError;
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyOverseasWorkflowSnapshot> SearchStocksAsync(
|
||||
string query,
|
||||
int maximumResults = DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
ValidateMaximumResults(maximumResults);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
long generation;
|
||||
lock (_sync)
|
||||
{
|
||||
generation = checked(++_stockGeneration);
|
||||
_stockStatus = LegacyOverseasSearchStatus.Loading;
|
||||
_stockQuery = normalizedQuery;
|
||||
_stockRetrievedAt = null;
|
||||
_stockResults = NoStockResults;
|
||||
_stockIsTruncated = false;
|
||||
_selectedStock = null;
|
||||
_stockError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _stockSearchService.SearchAsync(
|
||||
normalizedQuery,
|
||||
maximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _stockGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
var validated = ValidateStockResult(
|
||||
result,
|
||||
normalizedQuery,
|
||||
maximumResults,
|
||||
generation);
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _stockGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_stockStatus = LegacyOverseasSearchStatus.Ready;
|
||||
_stockRetrievedAt = result.RetrievedAt;
|
||||
_stockResults = validated;
|
||||
_stockIsTruncated = result.IsTruncated;
|
||||
_stockError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation == _stockGeneration)
|
||||
{
|
||||
_stockStatus = LegacyOverseasSearchStatus.Idle;
|
||||
_stockError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _stockGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_stockStatus = LegacyOverseasSearchStatus.Error;
|
||||
_stockRetrievedAt = null;
|
||||
_stockResults = NoStockResults;
|
||||
_stockIsTruncated = false;
|
||||
_selectedStock = null;
|
||||
_stockError = StockError;
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot SelectIndustry(string selectionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
|
||||
lock (_sync)
|
||||
{
|
||||
var selected = _industryResults.FirstOrDefault(row =>
|
||||
string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException("The overseas-industry result is no longer current.");
|
||||
if (!ReferenceEquals(_selectedIndustry, selected))
|
||||
{
|
||||
_selectedIndustry = selected;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot SelectStock(string selectionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
|
||||
lock (_sync)
|
||||
{
|
||||
var selected = _stockResults.FirstOrDefault(row =>
|
||||
string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException("The overseas-stock result is no longer current.");
|
||||
if (!ReferenceEquals(_selectedStock, selected))
|
||||
{
|
||||
_selectedStock = selected;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot ClearFixedIndexSelection()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_selectedFixedIndex is not null)
|
||||
{
|
||||
_selectedFixedIndex = null;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot ClearIndustrySelection()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_selectedIndustry is not null)
|
||||
{
|
||||
_selectedIndustry = null;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot ClearStockSelection()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_selectedStock is not null)
|
||||
{
|
||||
_selectedStock = null;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20)
|
||||
{
|
||||
var value = new LegacyOverseasMovingAverageSelection(ma5, ma20);
|
||||
lock (_sync)
|
||||
{
|
||||
if (_movingAverages != value)
|
||||
{
|
||||
_movingAverages = value;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds all playlist and scene-selection fields from current native state. The caller can
|
||||
/// select only a closed action id; it cannot supply aliases, data-code identities, paging,
|
||||
/// moving-average text, or fade values.
|
||||
/// </summary>
|
||||
public LegacyOverseasPlaylistDraft MaterializePlaylistDraft(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
var action = LegacyOverseasActionCatalog.FindAction(actionId)
|
||||
?? throw new LegacyOverseasWorkflowException("The overseas action is not registered.");
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
return action.Source switch
|
||||
{
|
||||
LegacyOverseasActionSource.FixedIndex => MaterializeFixedIndex(action),
|
||||
LegacyOverseasActionSource.Industry => MaterializeIndustry(action),
|
||||
LegacyOverseasActionSource.Stock => MaterializeStock(action),
|
||||
_ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyOverseasPlaylistDraft MaterializeFixedIndex(
|
||||
LegacyOverseasActionDefinition action)
|
||||
{
|
||||
var target = _selectedFixedIndex ??
|
||||
throw new LegacyOverseasWorkflowException("Select a fixed overseas index first.");
|
||||
var movingAverages = MovingAveragesFor(action);
|
||||
return CreateDraft(
|
||||
action,
|
||||
$"{target.DisplayName} 캔들 {action.PeriodDays}일",
|
||||
"chart",
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_INDEX",
|
||||
target.InputName,
|
||||
MovingAverageText(movingAverages),
|
||||
"PRICE",
|
||||
$"{target.Symbol}|{target.NationCode}|{target.ForeignDomesticCode}"),
|
||||
movingAverages);
|
||||
}
|
||||
|
||||
private LegacyOverseasPlaylistDraft MaterializeIndustry(
|
||||
LegacyOverseasActionDefinition action)
|
||||
{
|
||||
var industry = _selectedIndustry ??
|
||||
throw new LegacyOverseasWorkflowException("Select a US overseas industry first.");
|
||||
return CreateDraft(
|
||||
action,
|
||||
industry.KoreanName,
|
||||
"plate",
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_INDUSTRY",
|
||||
industry.KoreanName,
|
||||
"1열판기본",
|
||||
"CURRENT",
|
||||
$"{industry.InputName}|{industry.Symbol}|{industry.NationCode}|{industry.ForeignDomesticCode}"),
|
||||
default);
|
||||
}
|
||||
|
||||
private LegacyOverseasPlaylistDraft MaterializeStock(
|
||||
LegacyOverseasActionDefinition action)
|
||||
{
|
||||
var stock = _selectedStock ??
|
||||
throw new LegacyOverseasWorkflowException("Select a US/TW overseas stock first.");
|
||||
var movingAverages = MovingAveragesFor(action);
|
||||
var current = action.Kind == LegacyOverseasActionKind.Current;
|
||||
return CreateDraft(
|
||||
action,
|
||||
stock.InputName,
|
||||
current ? "stock" : "chart",
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_STOCK",
|
||||
stock.InputName,
|
||||
current ? "1열판기본" : MovingAverageText(movingAverages),
|
||||
current ? "CURRENT" : "PRICE",
|
||||
$"{stock.Symbol}|{stock.NationCode}|{stock.ForeignDomesticCode}"),
|
||||
movingAverages);
|
||||
}
|
||||
|
||||
private static LegacyOverseasPlaylistDraft CreateDraft(
|
||||
LegacyOverseasActionDefinition action,
|
||||
string title,
|
||||
string category,
|
||||
LegacyOverseasPlaylistSelection selection,
|
||||
LegacyOverseasMovingAverageSelection movingAverages) =>
|
||||
new(
|
||||
action.ActionId,
|
||||
action.BuilderKey,
|
||||
action.SceneAlias,
|
||||
action.Aliases,
|
||||
action.PageSize,
|
||||
InitialPageText,
|
||||
title,
|
||||
action.Label,
|
||||
category,
|
||||
"overseas",
|
||||
action.Source,
|
||||
selection,
|
||||
movingAverages,
|
||||
IsEnabled: true,
|
||||
FadeDuration: DefaultFadeDuration);
|
||||
|
||||
private LegacyOverseasMovingAverageSelection MovingAveragesFor(
|
||||
LegacyOverseasActionDefinition action) =>
|
||||
action.SupportsMovingAverages ? _movingAverages : default;
|
||||
|
||||
private static string MovingAverageText(LegacyOverseasMovingAverageSelection selection) =>
|
||||
selection switch
|
||||
{
|
||||
{ Ma5: true, Ma20: true } => "MA5,MA20",
|
||||
{ Ma5: true } => "MA5",
|
||||
{ Ma20: true } => "MA20",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
private LegacyOverseasWorkflowSnapshot CreateSnapshot() =>
|
||||
new(
|
||||
_revision,
|
||||
LegacyOverseasActionCatalog.FixedIndexTargets,
|
||||
_selectedFixedIndex,
|
||||
new LegacyOverseasIndustrySearchSnapshot(
|
||||
_industryStatus,
|
||||
_industryQuery,
|
||||
_industryRetrievedAt,
|
||||
_industryResults,
|
||||
_industryIsTruncated,
|
||||
_selectedIndustry,
|
||||
_industryError),
|
||||
new LegacyOverseasStockSearchSnapshot(
|
||||
_stockStatus,
|
||||
_stockQuery,
|
||||
_stockRetrievedAt,
|
||||
_stockResults,
|
||||
_stockIsTruncated,
|
||||
_selectedStock,
|
||||
_stockError),
|
||||
_movingAverages,
|
||||
LegacyOverseasActionCatalog.Actions);
|
||||
|
||||
private static IReadOnlyList<LegacyOverseasIndustrySearchRow> ValidateIndustryResult(
|
||||
OverseasIndustryIndexSearchResult? result,
|
||||
string expectedQuery,
|
||||
int maximumResults,
|
||||
long generation)
|
||||
{
|
||||
if (result is null || result.Items is null || result.RetrievedAt == default ||
|
||||
!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidData("industry search envelope");
|
||||
}
|
||||
|
||||
var items = result.Items.ToArray();
|
||||
if (items.Length > maximumResults)
|
||||
{
|
||||
throw InvalidData("industry result bound");
|
||||
}
|
||||
|
||||
var koreanNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var inputNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var symbols = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < items.Length; index++)
|
||||
{
|
||||
var item = items[index] ?? throw InvalidData("industry identity");
|
||||
if (!IsSafeName(item.KoreanName) ||
|
||||
!IsSafeName(item.InputName) ||
|
||||
!IsSafeSymbol(item.Symbol) ||
|
||||
!koreanNames.Add(item.KoreanName) ||
|
||||
!inputNames.Add(item.InputName) ||
|
||||
!symbols.Add(item.Symbol) ||
|
||||
index > 0 && CompareIndustry(items[index - 1], item) > 0)
|
||||
{
|
||||
throw InvalidData("industry identity or ordering");
|
||||
}
|
||||
}
|
||||
|
||||
var rows = items.Select((item, index) =>
|
||||
new LegacyOverseasIndustrySearchRow(
|
||||
$"overseas-industry-{generation:D8}-{index + 1:D4}",
|
||||
item));
|
||||
return Array.AsReadOnly(rows.ToArray());
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyOverseasStockSearchRow> ValidateStockResult(
|
||||
WorldStockSearchResult? result,
|
||||
string expectedQuery,
|
||||
int maximumResults,
|
||||
long generation)
|
||||
{
|
||||
if (result is null || result.Items is null || result.RetrievedAt == default ||
|
||||
!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidData("stock search envelope");
|
||||
}
|
||||
|
||||
var items = result.Items.ToArray();
|
||||
if (items.Length > maximumResults)
|
||||
{
|
||||
throw InvalidData("stock result bound");
|
||||
}
|
||||
|
||||
var inputNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var identities = new HashSet<WorldStockSearchItem>();
|
||||
for (var index = 0; index < items.Length; index++)
|
||||
{
|
||||
var item = items[index] ?? throw InvalidData("stock identity");
|
||||
if (!IsSafeName(item.InputName) ||
|
||||
!IsSafeSymbol(item.Symbol) ||
|
||||
item.NationCode is not ("US" or "TW") ||
|
||||
!inputNames.Add(item.InputName) ||
|
||||
!identities.Add(item) ||
|
||||
index > 0 && CompareStock(items[index - 1], item) > 0)
|
||||
{
|
||||
throw InvalidData("stock identity or ordering");
|
||||
}
|
||||
}
|
||||
|
||||
var rows = items.Select((item, index) =>
|
||||
new LegacyOverseasStockSearchRow(
|
||||
$"overseas-stock-{generation:D8}-{index + 1:D4}",
|
||||
item));
|
||||
return Array.AsReadOnly(rows.ToArray());
|
||||
}
|
||||
|
||||
private static int CompareIndustry(
|
||||
OverseasIndustryIndexItem left,
|
||||
OverseasIndustryIndexItem right)
|
||||
{
|
||||
var comparison = CompareUpper(left.KoreanName, right.KoreanName);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
comparison = string.Compare(left.InputName, right.InputName, StringComparison.Ordinal);
|
||||
return comparison != 0
|
||||
? comparison
|
||||
: string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareStock(WorldStockSearchItem left, WorldStockSearchItem right)
|
||||
{
|
||||
var comparison = CompareUpper(left.InputName, right.InputName);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
comparison = string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
|
||||
return comparison != 0
|
||||
? comparison
|
||||
: string.Compare(left.NationCode, right.NationCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareUpper(string left, string right) =>
|
||||
string.Compare(left.ToUpperInvariant(), right.ToUpperInvariant(), StringComparison.Ordinal);
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsSafeText(query))
|
||||
{
|
||||
throw new ArgumentException("The overseas search query is invalid.", nameof(query));
|
||||
}
|
||||
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The overseas search query must contain at most {MaximumQueryLength} safe characters.",
|
||||
nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static void ValidateMaximumResults(int maximumResults)
|
||||
{
|
||||
if (maximumResults is < 1 or > MaximumResults)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maximumResults),
|
||||
$"Overseas results must be limited from 1 through {MaximumResults}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSafeName(string value) =>
|
||||
value.Length is > 0 and <= 200 &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
!value.Contains('|', StringComparison.Ordinal) &&
|
||||
IsSafeText(value);
|
||||
|
||||
private static bool IsSafeSymbol(string value) =>
|
||||
value.Length is > 0 and <= 64 &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
IsSafeText(value) &&
|
||||
value.All(character =>
|
||||
char.IsLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-');
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static LegacyOverseasWorkflowException InvalidData(string detail) =>
|
||||
new($"The overseas selector returned an invalid {detail}.");
|
||||
|
||||
private void Touch() => _revision = checked(_revision + 1);
|
||||
}
|
||||
@@ -18,18 +18,24 @@ public sealed record LegacyOperatorPlaylistRow(
|
||||
string PageText,
|
||||
string StockCode,
|
||||
string RawCutLabel,
|
||||
int FadeDuration)
|
||||
int FadeDuration,
|
||||
bool IsSelected = false,
|
||||
string SceneAlias = "",
|
||||
LegacySceneSelection? PlayoutSelection = null)
|
||||
{
|
||||
public LegacyPlaylistEntry ToPlayoutEntry()
|
||||
{
|
||||
var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal);
|
||||
var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt);
|
||||
var cutCode = SceneAlias.Length > 0
|
||||
? SceneAlias
|
||||
: LegacyStockCutCatalog.ResolveCutAlias(
|
||||
RawCutLabel,
|
||||
StockName.Contains("(NXT)", StringComparison.Ordinal));
|
||||
return new LegacyPlaylistEntry(
|
||||
RowId,
|
||||
cutCode,
|
||||
IsEnabled,
|
||||
FadeDuration,
|
||||
new LegacySceneSelection(
|
||||
PlayoutSelection ?? new LegacySceneSelection(
|
||||
MarketText,
|
||||
StockName,
|
||||
GraphicType,
|
||||
|
||||
822
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
Normal file
822
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
Normal file
@@ -0,0 +1,822 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyThemeSort
|
||||
{
|
||||
InputOrder,
|
||||
GainRateDescending,
|
||||
GainRateAscending
|
||||
}
|
||||
|
||||
public enum LegacyThemeValueKind
|
||||
{
|
||||
Current,
|
||||
Expected
|
||||
}
|
||||
|
||||
public sealed record LegacyThemeSortDefinition(
|
||||
string Id,
|
||||
string Label,
|
||||
LegacyThemeSort Value);
|
||||
|
||||
public sealed record LegacyThemeActionState(
|
||||
string ActionId,
|
||||
bool IsAvailable,
|
||||
string? UnavailableReason);
|
||||
|
||||
public sealed class LegacyThemeActionDefinition
|
||||
{
|
||||
internal LegacyThemeActionDefinition(
|
||||
string id,
|
||||
string label,
|
||||
string builderKey,
|
||||
string code,
|
||||
int pageSize,
|
||||
LegacyThemeValueKind valueKind)
|
||||
{
|
||||
Id = id;
|
||||
Label = label;
|
||||
BuilderKey = builderKey;
|
||||
Code = code;
|
||||
Aliases = Array.AsReadOnly([code]);
|
||||
PageSize = pageSize;
|
||||
ValueKind = valueKind;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string BuilderKey { get; }
|
||||
|
||||
public string Code { get; }
|
||||
|
||||
public IReadOnlyList<string> Aliases { get; }
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
public LegacyThemeValueKind ValueKind { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyThemeSearchRow
|
||||
{
|
||||
internal LegacyThemeSearchRow(
|
||||
string resultId,
|
||||
ThemeSelectionIdentity identity,
|
||||
DataSourceKind source)
|
||||
{
|
||||
ResultId = resultId;
|
||||
Identity = identity;
|
||||
Market = identity.Market;
|
||||
Session = identity.Session;
|
||||
DisplayTitle = identity.Market == ThemeMarket.Nxt
|
||||
? $"{identity.ThemeTitle}(NXT)"
|
||||
: identity.ThemeTitle;
|
||||
Source = source;
|
||||
}
|
||||
|
||||
public string ResultId { get; }
|
||||
|
||||
public ThemeMarket Market { get; }
|
||||
|
||||
public ThemeSession Session { get; }
|
||||
|
||||
public string DisplayTitle { get; }
|
||||
|
||||
public DataSourceKind Source { get; }
|
||||
|
||||
internal ThemeSelectionIdentity Identity { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyThemePreviewRow
|
||||
{
|
||||
internal LegacyThemePreviewRow(ThemeItemPreview item)
|
||||
{
|
||||
InputIndex = item.InputIndex;
|
||||
ItemName = item.ItemName;
|
||||
ItemCode = item.ItemCode;
|
||||
}
|
||||
|
||||
public int InputIndex { get; }
|
||||
|
||||
public string ItemName { get; }
|
||||
|
||||
internal string ItemCode { get; }
|
||||
}
|
||||
|
||||
public sealed record LegacySelectedThemeSummary(
|
||||
ThemeMarket Market,
|
||||
ThemeSession Session,
|
||||
string DisplayTitle,
|
||||
int PreviewItemCount,
|
||||
bool PreviewIsTruncated);
|
||||
|
||||
public sealed class LegacyThemeWorkflowSnapshot
|
||||
{
|
||||
internal LegacyThemeWorkflowSnapshot(
|
||||
object authority,
|
||||
long revision,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
LegacyThemeSort sort,
|
||||
IEnumerable<LegacyThemeSearchRow> searchResults,
|
||||
bool searchIsTruncated,
|
||||
string? selectedResultId,
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
IEnumerable<LegacyThemePreviewRow> previewItems,
|
||||
bool previewIsTruncated)
|
||||
{
|
||||
Authority = authority;
|
||||
Revision = revision;
|
||||
Query = query;
|
||||
NxtSession = nxtSession;
|
||||
Sort = sort;
|
||||
SearchResults = ReadOnly(searchResults);
|
||||
SearchIsTruncated = searchIsTruncated;
|
||||
SelectedResultId = selectedResultId;
|
||||
SelectedIdentity = selectedIdentity;
|
||||
PreviewItems = ReadOnly(previewItems);
|
||||
PreviewIsTruncated = previewIsTruncated;
|
||||
SelectedTheme = selectedIdentity is null
|
||||
? null
|
||||
: new LegacySelectedThemeSummary(
|
||||
selectedIdentity.Market,
|
||||
selectedIdentity.Session,
|
||||
selectedIdentity.Market == ThemeMarket.Nxt
|
||||
? $"{selectedIdentity.ThemeTitle}(NXT)"
|
||||
: selectedIdentity.ThemeTitle,
|
||||
PreviewItems.Count,
|
||||
previewIsTruncated);
|
||||
ActionStates = LegacyThemeWorkflow.CreateActionStates(
|
||||
selectedIdentity,
|
||||
PreviewItems.Count);
|
||||
}
|
||||
|
||||
public long Revision { get; }
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public ThemeSession NxtSession { get; }
|
||||
|
||||
public LegacyThemeSort Sort { get; }
|
||||
|
||||
public string SortId => LegacyThemeWorkflow.SortId(Sort);
|
||||
|
||||
public IReadOnlyList<LegacyThemeSearchRow> SearchResults { get; }
|
||||
|
||||
public bool SearchIsTruncated { get; }
|
||||
|
||||
public string? SelectedResultId { get; }
|
||||
|
||||
public LegacySelectedThemeSummary? SelectedTheme { get; }
|
||||
|
||||
public IReadOnlyList<LegacyThemePreviewRow> PreviewItems { get; }
|
||||
|
||||
public bool PreviewIsTruncated { get; }
|
||||
|
||||
public IReadOnlyList<LegacyThemeActionState> ActionStates { get; }
|
||||
|
||||
internal object Authority { get; }
|
||||
|
||||
internal ThemeSelectionIdentity? SelectedIdentity { get; }
|
||||
|
||||
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> source) =>
|
||||
Array.AsReadOnly(source.ToArray());
|
||||
}
|
||||
|
||||
public sealed record LegacyThemePlaylistSelection(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
public sealed record LegacyThemePagePlan(
|
||||
int PreviewItemCount,
|
||||
int AccessibleItemCount,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
bool IsTruncated,
|
||||
string InitialPageText);
|
||||
|
||||
public sealed record LegacyThemePlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string Code,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
LegacyThemePlaylistSelection Selection,
|
||||
LegacyThemeValueKind ValueKind,
|
||||
LegacyThemeSort Sort,
|
||||
int PageSize,
|
||||
LegacyThemePagePlan PagePlan);
|
||||
|
||||
public sealed class LegacyThemeWorkflowDataException : Exception
|
||||
{
|
||||
public LegacyThemeWorkflowDataException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C#-authoritative migration of the read-only UC4 theme selector. Search and preview data
|
||||
/// come only from <see cref="IThemeSelectionService"/>; Web callers retain opaque result and
|
||||
/// action ids and never provide scene codes, resolver groups, theme identities, or PageN data.
|
||||
/// </summary>
|
||||
public sealed class LegacyThemeWorkflow
|
||||
{
|
||||
public const int MaximumSearchResults = 100;
|
||||
public const int MaximumPreviewItems = 240;
|
||||
public const int MaximumPageCount = 20;
|
||||
|
||||
private static readonly IReadOnlyList<LegacyThemeActionDefinition> RegisteredActions =
|
||||
CreateActions();
|
||||
private static readonly IReadOnlyList<LegacyThemeSortDefinition> RegisteredSorts =
|
||||
Array.AsReadOnly<LegacyThemeSortDefinition>(
|
||||
[
|
||||
new("INPUT_ORDER", "입력순", LegacyThemeSort.InputOrder),
|
||||
new("GAIN_DESC", "상승률순", LegacyThemeSort.GainRateDescending),
|
||||
new("GAIN_ASC", "하락률순", LegacyThemeSort.GainRateAscending)
|
||||
]);
|
||||
private static readonly IReadOnlyDictionary<string, LegacyThemeActionDefinition> ActionsById =
|
||||
new ReadOnlyDictionary<string, LegacyThemeActionDefinition>(
|
||||
RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal));
|
||||
|
||||
private readonly IThemeSelectionService _selectionService;
|
||||
private readonly object _authority = new();
|
||||
|
||||
static LegacyThemeWorkflow()
|
||||
{
|
||||
ValidateActions(RegisteredActions);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflow(IThemeSelectionService selectionService)
|
||||
{
|
||||
_selectionService = selectionService ??
|
||||
throw new ArgumentNullException(nameof(selectionService));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LegacyThemeActionDefinition> Actions => RegisteredActions;
|
||||
|
||||
public static IReadOnlyList<LegacyThemeSortDefinition> Sorts => RegisteredSorts;
|
||||
|
||||
public LegacyThemeWorkflowSnapshot CreateInitial(
|
||||
ThemeSession nxtSession = ThemeSession.PreMarket)
|
||||
{
|
||||
RequireNxtSession(nxtSession);
|
||||
return NewSnapshot(
|
||||
revision: 0,
|
||||
query: string.Empty,
|
||||
nxtSession,
|
||||
LegacyThemeSort.InputOrder,
|
||||
[],
|
||||
searchIsTruncated: false,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public async Task<LegacyThemeWorkflowSnapshot> SearchAsync(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
RequireNxtSession(nxtSession);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await _selectionService.SearchAsync(
|
||||
normalizedQuery,
|
||||
nxtSession,
|
||||
MaximumSearchResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var rows = ValidateSearchResult(result, normalizedQuery, nxtSession);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
normalizedQuery,
|
||||
nxtSession,
|
||||
snapshot.Sort,
|
||||
rows,
|
||||
result.IsTruncated,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public async Task<LegacyThemeWorkflowSnapshot> SelectAsync(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string resultId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resultId);
|
||||
var selected = snapshot.SearchResults.FirstOrDefault(row =>
|
||||
string.Equals(row.ResultId, resultId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException($"Unknown theme result id '{resultId}'.");
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var preview = await _selectionService.PreviewAsync(
|
||||
selected.Identity,
|
||||
MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var previewRows = ValidatePreviewResult(preview, selected.Identity);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
snapshot.Query,
|
||||
snapshot.NxtSession,
|
||||
snapshot.Sort,
|
||||
snapshot.SearchResults,
|
||||
snapshot.SearchIsTruncated,
|
||||
selected.ResultId,
|
||||
selected.Identity,
|
||||
previewRows,
|
||||
preview.IsTruncated);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflowSnapshot SelectSort(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string sortId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
var sort = ParseSort(sortId);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
snapshot.Query,
|
||||
snapshot.NxtSession,
|
||||
sort,
|
||||
snapshot.SearchResults,
|
||||
snapshot.SearchIsTruncated,
|
||||
snapshot.SelectedResultId,
|
||||
snapshot.SelectedIdentity,
|
||||
snapshot.PreviewItems,
|
||||
snapshot.PreviewIsTruncated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes from a trusted snapshot and a closed action id only. All K3D-facing values,
|
||||
/// theme identity fields, sort values, and PageN calculations are rebuilt in this method.
|
||||
/// </summary>
|
||||
public LegacyThemePlaylistDraft MaterializePlaylistDraft(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string actionId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
var action = ActionsById.TryGetValue(actionId, out var found)
|
||||
? found
|
||||
: throw new KeyNotFoundException($"Unknown theme action id '{actionId}'.");
|
||||
var identity = snapshot.SelectedIdentity
|
||||
?? throw new InvalidOperationException("A theme must be selected and previewed first.");
|
||||
if (snapshot.PreviewItems.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("An empty theme preview cannot be added to the playlist.");
|
||||
}
|
||||
|
||||
if (action.ValueKind == LegacyThemeValueKind.Expected &&
|
||||
identity.Market == ThemeMarket.Nxt)
|
||||
{
|
||||
throw new InvalidOperationException("NXT themes do not provide expected-price data.");
|
||||
}
|
||||
|
||||
var sortId = SortId(snapshot.Sort);
|
||||
var valueKind = ValueKindId(action.ValueKind);
|
||||
var displayTitle = identity.Market == ThemeMarket.Nxt
|
||||
? $"{identity.ThemeTitle}(NXT)"
|
||||
: identity.ThemeTitle;
|
||||
var selection = identity.Market == ThemeMarket.Krx
|
||||
? new LegacyThemePlaylistSelection(
|
||||
"PAGED_THEME",
|
||||
identity.ThemeTitle,
|
||||
sortId,
|
||||
valueKind,
|
||||
identity.ThemeCode)
|
||||
: new LegacyThemePlaylistSelection(
|
||||
"PAGED_NXT_THEME",
|
||||
displayTitle,
|
||||
SessionId(identity.Session),
|
||||
sortId,
|
||||
identity.ThemeCode);
|
||||
|
||||
var maximumItems = checked(action.PageSize * MaximumPageCount);
|
||||
var accessibleItems = Math.Min(snapshot.PreviewItems.Count, maximumItems);
|
||||
var pageCount = (accessibleItems + action.PageSize - 1) / action.PageSize;
|
||||
var pagePlan = new LegacyThemePagePlan(
|
||||
snapshot.PreviewItems.Count,
|
||||
accessibleItems,
|
||||
action.PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
snapshot.PreviewIsTruncated || snapshot.PreviewItems.Count > maximumItems,
|
||||
$"1/{pageCount.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
return new LegacyThemePlaylistDraft(
|
||||
action.Id,
|
||||
action.BuilderKey,
|
||||
action.Code,
|
||||
action.Aliases,
|
||||
displayTitle,
|
||||
$"{action.Label} · {SortLabel(snapshot.Sort)}",
|
||||
"plate",
|
||||
"theme",
|
||||
selection,
|
||||
action.ValueKind,
|
||||
snapshot.Sort,
|
||||
action.PageSize,
|
||||
pagePlan);
|
||||
}
|
||||
|
||||
public static LegacyThemeActionDefinition? FindAction(string? actionId) =>
|
||||
actionId is not null && ActionsById.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: null;
|
||||
|
||||
public LegacyThemeActionState GetActionState(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string actionId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
return snapshot.ActionStates.FirstOrDefault(state =>
|
||||
string.Equals(state.ActionId, actionId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException($"Unknown theme action id '{actionId}'.");
|
||||
}
|
||||
|
||||
public static string SortId(LegacyThemeSort sort) => sort switch
|
||||
{
|
||||
LegacyThemeSort.InputOrder => "INPUT_ORDER",
|
||||
LegacyThemeSort.GainRateDescending => "GAIN_DESC",
|
||||
LegacyThemeSort.GainRateAscending => "GAIN_ASC",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.")
|
||||
};
|
||||
|
||||
public static string SortLabel(LegacyThemeSort sort) => sort switch
|
||||
{
|
||||
LegacyThemeSort.InputOrder => "입력순",
|
||||
LegacyThemeSort.GainRateDescending => "상승률순",
|
||||
LegacyThemeSort.GainRateAscending => "하락률순",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.")
|
||||
};
|
||||
|
||||
private LegacyThemeWorkflowSnapshot NewSnapshot(
|
||||
long revision,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
LegacyThemeSort sort,
|
||||
IEnumerable<LegacyThemeSearchRow> searchResults,
|
||||
bool searchIsTruncated,
|
||||
string? selectedResultId,
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
IEnumerable<LegacyThemePreviewRow> previewItems,
|
||||
bool previewIsTruncated) =>
|
||||
new(
|
||||
_authority,
|
||||
revision,
|
||||
query,
|
||||
nxtSession,
|
||||
sort,
|
||||
searchResults,
|
||||
searchIsTruncated,
|
||||
selectedResultId,
|
||||
selectedIdentity,
|
||||
previewItems,
|
||||
previewIsTruncated);
|
||||
|
||||
private void RequireSnapshot(LegacyThemeWorkflowSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
if (!ReferenceEquals(snapshot.Authority, _authority))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The theme snapshot belongs to another workflow instance.",
|
||||
nameof(snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemeSearchRow> ValidateSearchResult(
|
||||
ThemeSearchResult result,
|
||||
string query,
|
||||
ThemeSession nxtSession)
|
||||
{
|
||||
if (result is null || result.Items is null ||
|
||||
!string.Equals(result.Query, query, StringComparison.Ordinal) ||
|
||||
result.NxtSession != nxtSession)
|
||||
{
|
||||
throw InvalidData("search envelope");
|
||||
}
|
||||
|
||||
var serviceItems = result.Items.ToArray();
|
||||
if (serviceItems.Length > MaximumSearchResults ||
|
||||
result.IsTruncated && serviceItems.Length != MaximumSearchResults)
|
||||
{
|
||||
throw InvalidData("search envelope");
|
||||
}
|
||||
|
||||
var titles = new Dictionary<ThemeMarket, HashSet<string>>
|
||||
{
|
||||
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
|
||||
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
|
||||
};
|
||||
var codes = new Dictionary<ThemeMarket, HashSet<string>>
|
||||
{
|
||||
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
|
||||
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
|
||||
};
|
||||
foreach (var item in serviceItems)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
throw InvalidData("search item");
|
||||
}
|
||||
|
||||
ValidateIdentity(item.Identity, nxtSession);
|
||||
var expectedSource = item.Identity.Market == ThemeMarket.Krx
|
||||
? DataSourceKind.Oracle
|
||||
: DataSourceKind.MariaDb;
|
||||
if (item.Source != expectedSource ||
|
||||
!titles[item.Identity.Market].Add(item.Identity.ThemeTitle) ||
|
||||
!codes[item.Identity.Market].Add(item.Identity.ThemeCode))
|
||||
{
|
||||
throw InvalidData("search identity or provider");
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = serviceItems
|
||||
.OrderBy(item => item.Identity.Market)
|
||||
.ThenBy(item => item.Identity.ThemeTitle, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Identity.ThemeCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var rows = new LegacyThemeSearchRow[ordered.Length];
|
||||
for (var index = 0; index < ordered.Length; index++)
|
||||
{
|
||||
rows[index] = new LegacyThemeSearchRow(
|
||||
$"theme-result-{index + 1:000}",
|
||||
ordered[index].Identity,
|
||||
ordered[index].Source);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(rows);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemePreviewRow> ValidatePreviewResult(
|
||||
ThemePreviewResult result,
|
||||
ThemeSelectionIdentity identity)
|
||||
{
|
||||
if (result is null || result.Items is null || result.Identity != identity)
|
||||
{
|
||||
throw InvalidData("preview envelope");
|
||||
}
|
||||
|
||||
var serviceItems = result.Items.ToArray();
|
||||
if (serviceItems.Length > MaximumPreviewItems ||
|
||||
result.IsTruncated && serviceItems.Length != MaximumPreviewItems)
|
||||
{
|
||||
throw InvalidData("preview envelope");
|
||||
}
|
||||
|
||||
var itemCodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var indexes = new HashSet<int>();
|
||||
var previousIndex = -1;
|
||||
var rows = new LegacyThemePreviewRow[serviceItems.Length];
|
||||
for (var index = 0; index < serviceItems.Length; index++)
|
||||
{
|
||||
var item = serviceItems[index]
|
||||
?? throw InvalidData("preview item");
|
||||
if (item.InputIndex is < 0 or > 9_999 || item.InputIndex <= previousIndex ||
|
||||
!indexes.Add(item.InputIndex) || !itemCodes.Add(item.ItemCode) ||
|
||||
!IsItemCodeForMarket(item.ItemCode, identity.Market) ||
|
||||
!IsCanonicalText(item.ItemName, 200))
|
||||
{
|
||||
throw InvalidData("preview item identity");
|
||||
}
|
||||
|
||||
previousIndex = item.InputIndex;
|
||||
rows[index] = new LegacyThemePreviewRow(item);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(rows);
|
||||
}
|
||||
|
||||
private static void ValidateIdentity(
|
||||
ThemeSelectionIdentity identity,
|
||||
ThemeSession requestedNxtSession)
|
||||
{
|
||||
if (identity is null || !Enum.IsDefined(identity.Market) ||
|
||||
!Enum.IsDefined(identity.Session) ||
|
||||
identity.Market == ThemeMarket.Krx &&
|
||||
identity.Session != ThemeSession.NotApplicable ||
|
||||
identity.Market == ThemeMarket.Nxt && identity.Session != requestedNxtSession ||
|
||||
!IsThemeCode(identity.ThemeCode) ||
|
||||
!IsCanonicalText(identity.ThemeTitle, 128) ||
|
||||
identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidData("theme identity");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsSafeText(query))
|
||||
{
|
||||
throw new ArgumentException("The theme search query is invalid.", nameof(query));
|
||||
}
|
||||
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > LegacyThemeSelectionService.MaximumQueryLength ||
|
||||
!IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"A theme search query must contain at most {LegacyThemeSelectionService.MaximumQueryLength} visible characters.",
|
||||
nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static LegacyThemeSort ParseSort(string sortId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sortId);
|
||||
return sortId switch
|
||||
{
|
||||
"INPUT_ORDER" => LegacyThemeSort.InputOrder,
|
||||
"GAIN_DESC" => LegacyThemeSort.GainRateDescending,
|
||||
"GAIN_ASC" => LegacyThemeSort.GainRateAscending,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(sortId),
|
||||
sortId,
|
||||
"Unknown theme sort id.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string SessionId(ThemeSession session) => session switch
|
||||
{
|
||||
ThemeSession.PreMarket => "PRE_MARKET",
|
||||
ThemeSession.AfterMarket => "AFTER_MARKET",
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(session),
|
||||
session,
|
||||
"NXT requires an explicit session.")
|
||||
};
|
||||
|
||||
private static string ValueKindId(LegacyThemeValueKind valueKind) => valueKind switch
|
||||
{
|
||||
LegacyThemeValueKind.Current => "CURRENT",
|
||||
LegacyThemeValueKind.Expected => "EXPECTED",
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(valueKind),
|
||||
valueKind,
|
||||
"Unknown theme value kind.")
|
||||
};
|
||||
|
||||
private static void RequireNxtSession(ThemeSession nxtSession)
|
||||
{
|
||||
if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(nxtSession),
|
||||
nxtSession,
|
||||
"An explicit NXT session is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsThemeCode(string value) =>
|
||||
value is not null && value.Length is >= 3 and <= 8 && value.All(char.IsAsciiDigit);
|
||||
|
||||
private static bool IsItemCodeForMarket(string value, ThemeMarket market)
|
||||
{
|
||||
if (value is null || value.Length is < 2 or > 13 || !value.All(char.IsAsciiLetterOrDigit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return market switch
|
||||
{
|
||||
ThemeMarket.Krx => value[0] is 'P' or 'D',
|
||||
ThemeMarket.Nxt => value[0] is 'X' or 'T',
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsCanonicalText(string value, int maximumLength) =>
|
||||
value is not null && value.Length is >= 1 &&
|
||||
value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
IsSafeText(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
var category = Rune.GetUnicodeCategory(rune);
|
||||
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemeActionDefinition> CreateActions() =>
|
||||
Array.AsReadOnly(
|
||||
[
|
||||
new LegacyThemeActionDefinition(
|
||||
"five-row-current",
|
||||
"5단 표그래프 · 현재가",
|
||||
"s5074",
|
||||
"5074",
|
||||
5,
|
||||
LegacyThemeValueKind.Current),
|
||||
new LegacyThemeActionDefinition(
|
||||
"five-row-expected",
|
||||
"5단 표그래프 · 예상체결가",
|
||||
"s5074",
|
||||
"5074",
|
||||
5,
|
||||
LegacyThemeValueKind.Expected),
|
||||
new LegacyThemeActionDefinition(
|
||||
"six-row-current",
|
||||
"6종목 현재가",
|
||||
"s5077",
|
||||
"5077",
|
||||
6,
|
||||
LegacyThemeValueKind.Current),
|
||||
new LegacyThemeActionDefinition(
|
||||
"twelve-row-current",
|
||||
"12종목 현재가",
|
||||
"s5088",
|
||||
"5088",
|
||||
12,
|
||||
LegacyThemeValueKind.Current)
|
||||
]);
|
||||
|
||||
internal static IReadOnlyList<LegacyThemeActionState> CreateActionStates(
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
int previewItemCount)
|
||||
{
|
||||
var states = new LegacyThemeActionState[RegisteredActions.Count];
|
||||
for (var index = 0; index < RegisteredActions.Count; index++)
|
||||
{
|
||||
var action = RegisteredActions[index];
|
||||
var reason = selectedIdentity is null
|
||||
? "theme-not-selected"
|
||||
: previewItemCount == 0
|
||||
? "theme-preview-is-empty"
|
||||
: action.ValueKind == LegacyThemeValueKind.Expected &&
|
||||
selectedIdentity.Market == ThemeMarket.Nxt
|
||||
? "expected-theme-is-krx-only"
|
||||
: null;
|
||||
states[index] = new LegacyThemeActionState(
|
||||
action.Id,
|
||||
reason is null,
|
||||
reason);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(states);
|
||||
}
|
||||
|
||||
private static void ValidateActions(
|
||||
IReadOnlyCollection<LegacyThemeActionDefinition> actions)
|
||||
{
|
||||
if (actions.Count != 4 ||
|
||||
actions.Select(action => action.Id).Distinct(StringComparer.Ordinal).Count() != 4)
|
||||
{
|
||||
throw new InvalidOperationException("The UC4 action catalog must contain four unique actions.");
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var scene = LegacySceneCatalog.Definitions.Single(definition =>
|
||||
string.Equals(definition.BuilderKey, action.BuilderKey, StringComparison.Ordinal));
|
||||
if (!scene.CutAliases.Contains(action.Code, StringComparer.Ordinal) ||
|
||||
action.Aliases.Count != 1 ||
|
||||
!string.Equals(action.Aliases[0], action.Code, StringComparison.Ordinal) ||
|
||||
(int)scene.PageSize != action.PageSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Theme action '{action.Id}' does not match the Core scene catalog.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyThemeWorkflowDataException InvalidData(string detail) =>
|
||||
new($"The injected theme selection service returned invalid {detail} data.");
|
||||
}
|
||||
@@ -14,4 +14,9 @@
|
||||
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Catalog\LegacyFixedActions.json"
|
||||
LogicalName="MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,974 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyManualListScreen
|
||||
{
|
||||
NetSell,
|
||||
Vi
|
||||
}
|
||||
|
||||
public enum LegacyManualNetSellField
|
||||
{
|
||||
LeftName,
|
||||
LeftAmount,
|
||||
RightName,
|
||||
RightAmount
|
||||
}
|
||||
|
||||
public enum LegacyManualListMoveDirection
|
||||
{
|
||||
Up,
|
||||
Down
|
||||
}
|
||||
|
||||
public sealed record LegacyManualListStoreAvailability(
|
||||
bool IsAvailable,
|
||||
string Code,
|
||||
string? Message);
|
||||
|
||||
public sealed record LegacyManualViStoredItem(string Code, string Name);
|
||||
|
||||
public sealed record LegacyManualViStoreSnapshot(
|
||||
IReadOnlyList<LegacyManualViStoredItem> Items,
|
||||
string RowVersion);
|
||||
|
||||
public sealed record LegacyManualViWriteReceipt(
|
||||
LegacyManualViStoreSnapshot Snapshot,
|
||||
bool Persisted);
|
||||
|
||||
public sealed record LegacyManualImportReceipt(
|
||||
int MarkerVersion,
|
||||
string SourceSha256,
|
||||
DateTimeOffset ImportedAt,
|
||||
int NetSellRowCount,
|
||||
int ViItemCount);
|
||||
|
||||
public sealed class LegacyManualImportStoreException : Exception
|
||||
{
|
||||
public LegacyManualImportStoreException(string code, bool outcomeUnknown)
|
||||
: base("The legacy manual-data import did not complete.")
|
||||
{
|
||||
Code = code;
|
||||
OutcomeUnknown = outcomeUnknown;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
|
||||
public bool OutcomeUnknown { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Native-only boundary for the operator-owned CP949 files. Implementations choose
|
||||
/// every path and file name; the WebView can only select a closed screen or opaque row.
|
||||
/// </summary>
|
||||
public interface ILegacyManualListStore
|
||||
{
|
||||
LegacyManualListStoreAvailability Availability { get; }
|
||||
|
||||
Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task WriteNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
IReadOnlyList<S5025TrustedManualRow> rows,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LegacyManualViStoreSnapshot> ReadViAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LegacyManualViWriteReceipt> WriteViAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> items,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LegacyManualImportReceipt> ImportAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record LegacyManualNetSellRowView(
|
||||
string RowId,
|
||||
string LeftName,
|
||||
string LeftAmount,
|
||||
string RightName,
|
||||
string RightAmount);
|
||||
|
||||
public sealed record LegacyManualViItemView(
|
||||
string ItemId,
|
||||
int Index,
|
||||
string Name);
|
||||
|
||||
public sealed record LegacyManualViSearchResultView(
|
||||
string ResultId,
|
||||
string DisplayName,
|
||||
string MarketText);
|
||||
|
||||
public sealed record LegacyManualListsSnapshot(
|
||||
bool IsOpen,
|
||||
LegacyManualListScreen? Screen,
|
||||
S5025ManualAudience Audience,
|
||||
bool IsAvailable,
|
||||
string AvailabilityCode,
|
||||
string? AvailabilityMessage,
|
||||
bool WritesQuarantined,
|
||||
IReadOnlyList<LegacyManualNetSellRowView> NetRows,
|
||||
bool NetRowsAreFresh,
|
||||
IReadOnlyList<LegacyManualViItemView> ViItems,
|
||||
int ViPageCount,
|
||||
bool ViItemsAreFresh,
|
||||
string SearchQuery,
|
||||
IReadOnlyList<LegacyManualViSearchResultView> SearchResults,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind,
|
||||
bool CanSave,
|
||||
bool CanMaterializePlaylist)
|
||||
{
|
||||
public string? SelectedSearchResultId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LegacyManualListPlaylistDraft(
|
||||
string SceneAlias,
|
||||
string MarketText,
|
||||
string StockName,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string PageText,
|
||||
string RawCutLabel,
|
||||
int FadeDuration,
|
||||
LegacySceneSelection Selection);
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative FSell/VIList workflow. Draft rows, stock identities, file versions,
|
||||
/// save verification and playlist materialization remain in C#; browser messages are
|
||||
/// limited to input intent and opaque identifiers.
|
||||
/// </summary>
|
||||
public sealed class LegacyManualListsWorkflow
|
||||
{
|
||||
public const int NetSellRowCount = 5;
|
||||
public const int MaximumViItems = 100;
|
||||
public const int ViPageSize = 5;
|
||||
public const int MaximumPageCount = 20;
|
||||
public const int MaximumNetSellValueLength = 256;
|
||||
public const int MaximumSearchLength = 100;
|
||||
|
||||
private const string ViSnapshotSubject = "@MBN_VI_SNAPSHOT_V1";
|
||||
private const string OutcomeUnknownMessage =
|
||||
"저장 결과를 확인할 수 없어 수동 목록 쓰기를 격리했습니다. 앱을 다시 시작하고 파일을 확인하세요.";
|
||||
|
||||
private readonly ILegacyManualListStore _store;
|
||||
private readonly ILegacyStockLookup _stockLookup;
|
||||
private readonly List<S5025TrustedManualRow> _netRows = CreateEmptyNetRows();
|
||||
private readonly List<ViDraftItem> _viItems = [];
|
||||
private readonly List<ViSearchResult> _searchResults = [];
|
||||
private LegacyManualListScreen? _screen;
|
||||
private S5025ManualAudience _audience = S5025ManualAudience.Individual;
|
||||
private bool _isOpen;
|
||||
private bool _netRowsAreFresh;
|
||||
private bool _viItemsAreFresh;
|
||||
private bool _writesQuarantined;
|
||||
private string? _viVersion;
|
||||
private string _searchQuery = string.Empty;
|
||||
private string? _selectedSearchResultId;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyOperatorStatusKind _statusKind;
|
||||
private long _opaqueSequence;
|
||||
|
||||
public LegacyManualListsWorkflow(
|
||||
ILegacyManualListStore store,
|
||||
ILegacyStockLookup stockLookup)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot Current => CreateSnapshot();
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> OpenNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Enum.IsDefined(audience))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(audience));
|
||||
}
|
||||
|
||||
_isOpen = true;
|
||||
_screen = LegacyManualListScreen.NetSell;
|
||||
_audience = audience;
|
||||
_searchResults.Clear();
|
||||
_searchQuery = string.Empty;
|
||||
_selectedSearchResultId = null;
|
||||
await ReadNetSellAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> OpenViAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_isOpen = true;
|
||||
_screen = LegacyManualListScreen.Vi;
|
||||
_searchResults.Clear();
|
||||
_searchQuery = string.Empty;
|
||||
_selectedSearchResultId = null;
|
||||
await ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot Close()
|
||||
{
|
||||
_isOpen = false;
|
||||
_screen = null;
|
||||
_searchResults.Clear();
|
||||
_searchQuery = string.Empty;
|
||||
_selectedSearchResultId = null;
|
||||
SetStatus(string.Empty, LegacyOperatorStatusKind.Neutral);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> RefreshAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_screen == LegacyManualListScreen.NetSell)
|
||||
{
|
||||
await ReadNetSellAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (_screen == LegacyManualListScreen.Vi)
|
||||
{
|
||||
await ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot SetNetSellCell(
|
||||
string rowId,
|
||||
LegacyManualNetSellField field,
|
||||
string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rowId);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
if (_screen != LegacyManualListScreen.NetSell ||
|
||||
!TryParseNetRowId(rowId, out var index) ||
|
||||
!IsSafeNetValue(value) ||
|
||||
!Enum.IsDefined(field))
|
||||
{
|
||||
SetStatus("수동 순매도 입력값을 확인하세요.", LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
var row = _netRows[index];
|
||||
_netRows[index] = field switch
|
||||
{
|
||||
LegacyManualNetSellField.LeftName => row with { LeftName = value },
|
||||
LegacyManualNetSellField.LeftAmount => row with { LeftAmount = value },
|
||||
LegacyManualNetSellField.RightName => row with { RightName = value },
|
||||
LegacyManualNetSellField.RightAmount => row with { RightAmount = value },
|
||||
_ => row
|
||||
};
|
||||
_netRowsAreFresh = false;
|
||||
SetStatus("변경 내용을 저장한 뒤 재조회해야 컷을 추가할 수 있습니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> SaveNetSellAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!CanWrite(LegacyManualListScreen.NetSell))
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
var expected = _netRows.ToArray();
|
||||
try
|
||||
{
|
||||
await _store.WriteNetSellAsync(_audience, expected, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return await RecoverNetSellWriteAsync(expected, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await VerifyNetSellWriteAsync(expected, recovered: false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> SearchViAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
_searchQuery = query.Trim();
|
||||
_searchResults.Clear();
|
||||
_selectedSearchResultId = null;
|
||||
if (_screen != LegacyManualListScreen.Vi ||
|
||||
_searchQuery.Length == 0 ||
|
||||
_searchQuery.Length > MaximumSearchLength)
|
||||
{
|
||||
SetStatus("검색할 종목명을 입력하세요.", LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = await _stockLookup.SearchAsync(_searchQuery, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var displayName in data.DisplayNames.Take(200))
|
||||
{
|
||||
var identity = data.ResolveLastByDisplayName(displayName);
|
||||
if (identity is not null)
|
||||
{
|
||||
_searchResults.Add(new ViSearchResult(
|
||||
NextOpaqueId("manual-vi-result"),
|
||||
identity));
|
||||
}
|
||||
}
|
||||
|
||||
SetStatus(
|
||||
_searchResults.Count == 0
|
||||
? "검색 결과가 없습니다."
|
||||
: $"{_searchResults.Count}개 종목을 찾았습니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
SetStatus("종목 검색을 완료하지 못했습니다.", LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot SelectViSearchResult(string resultId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resultId);
|
||||
if (_screen == LegacyManualListScreen.Vi && _searchResults.Any(item =>
|
||||
string.Equals(item.ResultId, resultId, StringComparison.Ordinal)))
|
||||
{
|
||||
_selectedSearchResultId = resultId;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus(
|
||||
"The selected stock is no longer in the current VI search generation.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot AddViSearchResult(string resultId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resultId);
|
||||
var result = _searchResults.SingleOrDefault(item =>
|
||||
string.Equals(item.ResultId, resultId, StringComparison.Ordinal));
|
||||
if (_screen != LegacyManualListScreen.Vi ||
|
||||
result is null ||
|
||||
_viItems.Count >= MaximumViItems)
|
||||
{
|
||||
SetStatus("선택한 종목을 VI 목록에 추가할 수 없습니다.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
_selectedSearchResultId = resultId;
|
||||
|
||||
var prefix = result.Identity.Market is
|
||||
LegacyDomesticStockMarket.Kospi or LegacyDomesticStockMarket.NxtKospi
|
||||
? "P"
|
||||
: "D";
|
||||
var item = new LegacyManualViStoredItem(
|
||||
prefix + result.Identity.StockCode,
|
||||
result.Identity.DisplayName);
|
||||
if (!IsValidViItem(item))
|
||||
{
|
||||
SetStatus("선택한 종목 정보가 VI 파일 형식에 맞지 않습니다.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
_viItems.Add(new ViDraftItem(NextOpaqueId("manual-vi-item"), item));
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus("VI 목록을 저장한 뒤 재조회해야 컷을 추가할 수 있습니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot MoveViItem(
|
||||
string itemId,
|
||||
LegacyManualListMoveDirection direction)
|
||||
{
|
||||
var index = FindViItem(itemId);
|
||||
var target = direction == LegacyManualListMoveDirection.Up ? index - 1 : index + 1;
|
||||
if (_screen != LegacyManualListScreen.Vi ||
|
||||
index < 0 || target < 0 || target >= _viItems.Count ||
|
||||
!Enum.IsDefined(direction))
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
(_viItems[index], _viItems[target]) = (_viItems[target], _viItems[index]);
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus("VI 순서를 변경했습니다. 저장 후 재조회하세요.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot DeleteViItem(string itemId)
|
||||
{
|
||||
var index = FindViItem(itemId);
|
||||
if (_screen == LegacyManualListScreen.Vi && index >= 0)
|
||||
{
|
||||
_viItems.RemoveAt(index);
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus("VI 종목을 삭제했습니다. 저장 후 재조회하세요.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot DeleteAllViItems()
|
||||
{
|
||||
if (_screen == LegacyManualListScreen.Vi && _viItems.Count > 0)
|
||||
{
|
||||
_viItems.Clear();
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus("VI 목록을 비웠습니다. 원본과 같이 빈 목록 저장은 기존 파일을 유지합니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> SaveViAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!CanWrite(LegacyManualListScreen.Vi) ||
|
||||
!TrustedViManualReference.IsRowVersion(_viVersion))
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
var expectedVersion = _viVersion;
|
||||
var expectedItems = _viItems.Select(item => item.Stored).ToArray();
|
||||
LegacyManualViStoreSnapshot preflight;
|
||||
try
|
||||
{
|
||||
preflight = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
SetStatus("VI 저장 전 파일 버전을 확인하지 못했습니다. 저장하지 않았습니다.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
return Current;
|
||||
}
|
||||
|
||||
if (!string.Equals(preflight.RowVersion, expectedVersion, StringComparison.Ordinal))
|
||||
{
|
||||
SetStatus("VI 파일이 다른 작업에서 변경되었습니다. 재조회 후 다시 편집하세요.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
LegacyManualViWriteReceipt receipt;
|
||||
try
|
||||
{
|
||||
receipt = await _store.WriteViAsync(expectedItems, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return await RecoverViWriteAsync(expectedItems, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await VerifyViWriteAsync(
|
||||
expectedItems,
|
||||
receipt,
|
||||
recovered: false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> ImportAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.Availability.IsAvailable || _writesQuarantined)
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var receipt = await _store.ImportAsync(cancellationToken).ConfigureAwait(false);
|
||||
SetStatus(
|
||||
$"원본 수동 데이터를 가져왔습니다. 순매도 {receipt.NetSellRowCount}행, VI {receipt.ViItemCount}종목.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return await RefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LegacyManualImportStoreException exception)
|
||||
{
|
||||
if (exception.OutcomeUnknown)
|
||||
{
|
||||
QuarantineWrites();
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus(ImportFailureMessage(exception.Code), LegacyOperatorStatusKind.Warning);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
SetStatus("원본 수동 데이터 가져오기를 완료하지 못했습니다.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListPlaylistDraft CreateNetSellPlaylistDraft()
|
||||
{
|
||||
if (_screen != LegacyManualListScreen.NetSell || !_netRowsAreFresh)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The net-sell rows must be saved and re-read before materialization.");
|
||||
}
|
||||
|
||||
var (groupCode, label) = _audience switch
|
||||
{
|
||||
S5025ManualAudience.Individual => ("INDIVIDUAL", "개인"),
|
||||
S5025ManualAudience.Foreign => ("FOREIGN", "외국인"),
|
||||
S5025ManualAudience.Institution => ("INSTITUTION", "기관"),
|
||||
_ => throw new InvalidOperationException("The manual audience is invalid.")
|
||||
};
|
||||
return new LegacyManualListPlaylistDraft(
|
||||
"5025",
|
||||
"지수",
|
||||
$"{label} 순매도 상위(수동)",
|
||||
"순매도 상위",
|
||||
"순매도 상위",
|
||||
"1/1",
|
||||
$"{label} 순매도 상위(수동)",
|
||||
6,
|
||||
new LegacySceneSelection(
|
||||
groupCode,
|
||||
"INDEX",
|
||||
"MANUAL_NET_SELL",
|
||||
"MANUAL_NET_SELL",
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
public LegacyManualListPlaylistDraft CreateViPlaylistDraft()
|
||||
{
|
||||
if (_screen != LegacyManualListScreen.Vi ||
|
||||
!_viItemsAreFresh ||
|
||||
_viItems.Count == 0 ||
|
||||
!TrustedViManualReference.IsRowVersion(_viVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The VI rows must be saved and re-read before materialization.");
|
||||
}
|
||||
|
||||
var names = string.Join(",", _viItems.Select(item => item.Stored.Name));
|
||||
var pageCount = (_viItems.Count + ViPageSize - 1) / ViPageSize;
|
||||
var rowVersion = _viVersion ?? throw new InvalidOperationException(
|
||||
"The trusted VI row version is unavailable.");
|
||||
return new LegacyManualListPlaylistDraft(
|
||||
"5074",
|
||||
"지수",
|
||||
names,
|
||||
"5단 표그래프",
|
||||
"VI 발동(수동)",
|
||||
$"1/{pageCount}",
|
||||
"5단 표그래프_VI 발동(수동)",
|
||||
6,
|
||||
new LegacySceneSelection(
|
||||
"PAGED_VI",
|
||||
ViSnapshotSubject,
|
||||
"INPUT_ORDER",
|
||||
"CURRENT",
|
||||
rowVersion));
|
||||
}
|
||||
|
||||
private async Task ReadNetSellAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_netRowsAreFresh = false;
|
||||
if (!EnsureAvailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rows = await _store.ReadNetSellAsync(_audience, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (rows.Count != NetSellRowCount || rows.Any(row => !IsValidNetRow(row)))
|
||||
{
|
||||
throw new InvalidDataException("The FSell file shape is invalid.");
|
||||
}
|
||||
|
||||
_netRows.Clear();
|
||||
_netRows.AddRange(rows);
|
||||
_netRowsAreFresh = true;
|
||||
SetStatus("수동 순매도 5행을 조회했습니다.", LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_netRows.Clear();
|
||||
_netRows.AddRange(CreateEmptyNetRows());
|
||||
SetStatus("수동 순매도 파일을 읽지 못했습니다. 필요하면 원본 가져오기를 실행하세요.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReadViAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_viItemsAreFresh = false;
|
||||
_viVersion = null;
|
||||
if (!EnsureAvailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
SetViSnapshot(snapshot);
|
||||
SetStatus($"VI 수동 목록 {snapshot.Items.Count}종목을 조회했습니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_viItems.Clear();
|
||||
SetStatus("VI 수동 목록 파일을 읽지 못했습니다. 필요하면 원본 가져오기를 실행하세요.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyManualListsSnapshot> RecoverNetSellWriteAsync(
|
||||
IReadOnlyList<S5025TrustedManualRow> expected,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actual = await _store.ReadNetSellAsync(_audience, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (actual.SequenceEqual(expected))
|
||||
{
|
||||
SetNetRows(actual);
|
||||
SetStatus("저장 응답은 실패했지만 재조회로 동일한 5행을 확인했습니다.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
QuarantineWrites();
|
||||
return Current;
|
||||
}
|
||||
|
||||
private async Task<LegacyManualListsSnapshot> VerifyNetSellWriteAsync(
|
||||
IReadOnlyList<S5025TrustedManualRow> expected,
|
||||
bool recovered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actual = await _store.ReadNetSellAsync(_audience, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (actual.SequenceEqual(expected))
|
||||
{
|
||||
SetNetRows(actual);
|
||||
SetStatus(
|
||||
recovered
|
||||
? "재조회로 수동 순매도 저장을 확인했습니다."
|
||||
: "저장 후 재조회한 5행이 입력값과 일치합니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
QuarantineWrites();
|
||||
return Current;
|
||||
}
|
||||
|
||||
private async Task<LegacyManualListsSnapshot> RecoverViWriteAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> expected,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actual = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (expected.Count > 0 && actual.Items.SequenceEqual(expected))
|
||||
{
|
||||
SetViSnapshot(actual);
|
||||
SetStatus("저장 응답은 실패했지만 재조회로 같은 VI 목록을 확인했습니다.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
QuarantineWrites();
|
||||
return Current;
|
||||
}
|
||||
|
||||
private async Task<LegacyManualListsSnapshot> VerifyViWriteAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> expected,
|
||||
LegacyManualViWriteReceipt receipt,
|
||||
bool recovered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actual = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
var receiptMatches = actual.Items.SequenceEqual(receipt.Snapshot.Items) &&
|
||||
string.Equals(actual.RowVersion, receipt.Snapshot.RowVersion,
|
||||
StringComparison.Ordinal);
|
||||
var expectedMatches = !receipt.Persisted || actual.Items.SequenceEqual(expected);
|
||||
if (receiptMatches && expectedMatches &&
|
||||
TrustedViManualReference.IsRowVersion(actual.RowVersion))
|
||||
{
|
||||
SetViSnapshot(actual);
|
||||
SetStatus(
|
||||
receipt.Persisted
|
||||
? recovered
|
||||
? "재조회로 VI 저장을 확인했습니다."
|
||||
: "VI 저장 후 재조회한 목록과 버전이 일치합니다."
|
||||
: "빈 목록은 원본 동작과 같이 파일을 덮어쓰지 않아 기존 목록을 다시 표시합니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
QuarantineWrites();
|
||||
return Current;
|
||||
}
|
||||
|
||||
private bool CanWrite(LegacyManualListScreen requiredScreen)
|
||||
{
|
||||
if (_screen != requiredScreen || !_isOpen || !EnsureAvailable() || _writesQuarantined)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool EnsureAvailable()
|
||||
{
|
||||
if (_store.Availability.IsAvailable)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
SetStatus(
|
||||
_store.Availability.Message ?? "수동 목록 저장소를 사용할 수 없습니다.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetNetRows(IEnumerable<S5025TrustedManualRow> rows)
|
||||
{
|
||||
_netRows.Clear();
|
||||
_netRows.AddRange(rows);
|
||||
_netRowsAreFresh = true;
|
||||
}
|
||||
|
||||
private void SetViSnapshot(LegacyManualViStoreSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.Items.Count > MaximumViItems ||
|
||||
snapshot.Items.Any(item => !IsValidViItem(item)) ||
|
||||
!TrustedViManualReference.IsRowVersion(snapshot.RowVersion))
|
||||
{
|
||||
throw new InvalidDataException("The VI snapshot is invalid.");
|
||||
}
|
||||
|
||||
_viItems.Clear();
|
||||
_viItems.AddRange(snapshot.Items.Select(item =>
|
||||
new ViDraftItem(NextOpaqueId("manual-vi-item"), item)));
|
||||
_viVersion = snapshot.RowVersion;
|
||||
_viItemsAreFresh = true;
|
||||
}
|
||||
|
||||
private LegacyManualListsSnapshot CreateSnapshot()
|
||||
{
|
||||
var availability = _store.Availability;
|
||||
var netRows = _netRows.Select((row, index) => new LegacyManualNetSellRowView(
|
||||
NetRowId(index),
|
||||
row.LeftName,
|
||||
row.LeftAmount,
|
||||
row.RightName,
|
||||
row.RightAmount)).ToArray();
|
||||
var viItems = _viItems.Select((item, index) => new LegacyManualViItemView(
|
||||
item.ItemId,
|
||||
index + 1,
|
||||
item.Stored.Name)).ToArray();
|
||||
var search = _searchResults.Select(item => new LegacyManualViSearchResultView(
|
||||
item.ResultId,
|
||||
item.Identity.DisplayName,
|
||||
item.Identity.MarketText)).ToArray();
|
||||
var canSave = _isOpen && availability.IsAvailable && !_writesQuarantined &&
|
||||
(_screen == LegacyManualListScreen.NetSell ||
|
||||
(_screen == LegacyManualListScreen.Vi &&
|
||||
TrustedViManualReference.IsRowVersion(_viVersion)));
|
||||
var canMaterialize = _screen switch
|
||||
{
|
||||
LegacyManualListScreen.NetSell => _netRowsAreFresh,
|
||||
LegacyManualListScreen.Vi => _viItemsAreFresh && _viItems.Count > 0 &&
|
||||
TrustedViManualReference.IsRowVersion(_viVersion),
|
||||
_ => false
|
||||
};
|
||||
return new LegacyManualListsSnapshot(
|
||||
_isOpen,
|
||||
_screen,
|
||||
_audience,
|
||||
availability.IsAvailable,
|
||||
availability.Code,
|
||||
availability.Message,
|
||||
_writesQuarantined,
|
||||
Array.AsReadOnly(netRows),
|
||||
_netRowsAreFresh,
|
||||
Array.AsReadOnly(viItems),
|
||||
(_viItems.Count + ViPageSize - 1) / ViPageSize,
|
||||
_viItemsAreFresh,
|
||||
_searchQuery,
|
||||
Array.AsReadOnly(search),
|
||||
_statusMessage,
|
||||
_statusKind,
|
||||
canSave,
|
||||
canMaterialize)
|
||||
{
|
||||
SelectedSearchResultId = _selectedSearchResultId
|
||||
};
|
||||
}
|
||||
|
||||
private static List<S5025TrustedManualRow> CreateEmptyNetRows() =>
|
||||
Enumerable.Range(0, NetSellRowCount)
|
||||
.Select(_ => new S5025TrustedManualRow(string.Empty, string.Empty, string.Empty, string.Empty))
|
||||
.ToList();
|
||||
|
||||
private static bool IsValidNetRow(S5025TrustedManualRow row) =>
|
||||
row is not null &&
|
||||
IsSafeNetValue(row.LeftName) &&
|
||||
IsSafeNetValue(row.LeftAmount) &&
|
||||
IsSafeNetValue(row.RightName) &&
|
||||
IsSafeNetValue(row.RightAmount);
|
||||
|
||||
private static bool IsSafeNetValue(string? value) =>
|
||||
value is not null &&
|
||||
value.Length <= MaximumNetSellValueLength &&
|
||||
!value.Contains('^') &&
|
||||
!value.Any(character => char.IsControl(character) || character == '\uFFFD');
|
||||
|
||||
private static bool IsValidViItem(LegacyManualViStoredItem item) =>
|
||||
item is not null &&
|
||||
IsSafeViText(item.Code, 64) &&
|
||||
IsSafeViText(item.Name, 200) &&
|
||||
!item.Name.Contains(',');
|
||||
|
||||
private static bool IsSafeViText(string? value, int maximumLength) =>
|
||||
value is { Length: > 0 } &&
|
||||
value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
!value.Contains('^') &&
|
||||
!value.Any(character => char.IsControl(character) || character == '\uFFFD');
|
||||
|
||||
private static string NetRowId(int index) => $"manual-net-row-{index + 1}";
|
||||
|
||||
private static bool TryParseNetRowId(string rowId, out int index)
|
||||
{
|
||||
index = Array.FindIndex(
|
||||
Enumerable.Range(0, NetSellRowCount).Select(NetRowId).ToArray(),
|
||||
value => string.Equals(value, rowId, StringComparison.Ordinal));
|
||||
return index >= 0;
|
||||
}
|
||||
|
||||
private int FindViItem(string itemId) => _viItems.FindIndex(item =>
|
||||
string.Equals(item.ItemId, itemId, StringComparison.Ordinal));
|
||||
|
||||
private string NextOpaqueId(string prefix) => $"{prefix}-{++_opaqueSequence:D8}";
|
||||
|
||||
private void SetStatus(string message, LegacyOperatorStatusKind kind)
|
||||
{
|
||||
_statusMessage = message;
|
||||
_statusKind = kind;
|
||||
}
|
||||
|
||||
private void QuarantineWrites()
|
||||
{
|
||||
_writesQuarantined = true;
|
||||
_netRowsAreFresh = false;
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus(OutcomeUnknownMessage, LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
|
||||
private static string ImportFailureMessage(string code) => code switch
|
||||
{
|
||||
"SOURCE_UNAVAILABLE" => "원본 수동 데이터 파일을 찾을 수 없습니다.",
|
||||
"SOURCE_INVALID" => "원본 수동 데이터 파일 형식이 올바르지 않습니다.",
|
||||
"ALREADY_IMPORTED" => "원본 수동 데이터는 이미 한 번 가져왔습니다.",
|
||||
"DESTINATION_NOT_EMPTY" => "현재 저장소에 파일이 있어 원본 데이터를 덮어쓰지 않았습니다.",
|
||||
"INTERRUPTED_IMPORT" => "이전 가져오기 작업이 중단된 상태입니다. 파일을 먼저 확인하세요.",
|
||||
_ => "원본 수동 데이터 가져오기를 완료하지 못했습니다."
|
||||
};
|
||||
|
||||
private sealed record ViDraftItem(string ItemId, LegacyManualViStoredItem Stored);
|
||||
|
||||
private sealed record ViSearchResult(string ResultId, LegacyStockIdentity Identity);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.LegacyApplication.Tests")]
|
||||
@@ -0,0 +1,458 @@
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyTradingHaltRequestStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public enum LegacyTradingHaltNameSort
|
||||
{
|
||||
None,
|
||||
Descending,
|
||||
Ascending
|
||||
}
|
||||
|
||||
public sealed record LegacyTradingHaltResultView(
|
||||
string SelectionId,
|
||||
string Market,
|
||||
string StockCode,
|
||||
string DisplayName,
|
||||
bool IsSelected);
|
||||
|
||||
public sealed record LegacyTradingHaltActionView(
|
||||
string ActionId,
|
||||
string Label,
|
||||
bool IsAvailable);
|
||||
|
||||
public sealed record LegacyTradingHaltWorkflowSnapshot(
|
||||
long Revision,
|
||||
string Query,
|
||||
LegacyTradingHaltRequestStatus Status,
|
||||
IReadOnlyList<LegacyTradingHaltResultView> Results,
|
||||
bool IsTruncated,
|
||||
LegacyTradingHaltResultView? Selected,
|
||||
LegacyTradingHaltNameSort NameSort,
|
||||
bool Ma5,
|
||||
bool Ma20,
|
||||
IReadOnlyList<LegacyTradingHaltActionView> Actions,
|
||||
string ErrorMessage,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind);
|
||||
|
||||
public sealed record LegacyTradingHaltPlaylistDraft(
|
||||
string DraftId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
string StockCode,
|
||||
string DisplayName,
|
||||
LegacySceneSelection Selection,
|
||||
bool Ma5,
|
||||
bool Ma20,
|
||||
int? CandlePeriodDays,
|
||||
string PageText,
|
||||
bool IsEnabled,
|
||||
int FadeDuration,
|
||||
string Source,
|
||||
string ActionId)
|
||||
{
|
||||
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
|
||||
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Native authority for UC7's combined KOSPI/KOSDAQ trading-halt selector.
|
||||
/// The Web selects an opaque row and a closed action id; stock identity and scene fields
|
||||
/// always come from the validated Core service result.
|
||||
/// </summary>
|
||||
public sealed class LegacyTradingHaltWorkflowController
|
||||
{
|
||||
public const string CurrentActionId = "current";
|
||||
public const string CandleActionId = "candle-120d";
|
||||
public const int CandlePeriodDays = 120;
|
||||
|
||||
private const int DefaultFadeDuration = 6;
|
||||
private const string CurrentLabel = "거래정지-현재가";
|
||||
private const string CandleLabel = "캔들그래프-거래정지 · 120일";
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly ITradingHaltSelectionService _service;
|
||||
private List<HaltState> _results = [];
|
||||
private HaltState? _selected;
|
||||
private string _query = string.Empty;
|
||||
private string _errorMessage = string.Empty;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyTradingHaltRequestStatus _status = LegacyTradingHaltRequestStatus.Idle;
|
||||
private LegacyTradingHaltNameSort _nameSort;
|
||||
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
private bool _isTruncated;
|
||||
private bool _ma5;
|
||||
private bool _ma20;
|
||||
private int _searchGeneration;
|
||||
private long _revision;
|
||||
private long _draftSequence;
|
||||
|
||||
public LegacyTradingHaltWorkflowController(ITradingHaltSelectionService service)
|
||||
{
|
||||
_service = service ?? throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
public LegacyTradingHaltWorkflowSnapshot Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyTradingHaltWorkflowSnapshot> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
int generation;
|
||||
lock (_gate)
|
||||
{
|
||||
generation = ++_searchGeneration;
|
||||
_query = normalizedQuery;
|
||||
_results = [];
|
||||
_selected = null;
|
||||
_nameSort = LegacyTradingHaltNameSort.None;
|
||||
_status = LegacyTradingHaltRequestStatus.Loading;
|
||||
_isTruncated = false;
|
||||
_errorMessage = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.SearchAsync(
|
||||
normalizedQuery,
|
||||
LegacyTradingHaltSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation != _searchGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_results = ValidateResult(result, normalizedQuery, generation);
|
||||
_isTruncated = result.IsTruncated;
|
||||
_status = LegacyTradingHaltRequestStatus.Ready;
|
||||
_errorMessage = string.Empty;
|
||||
_statusMessage = $"KOSPI·KOSDAQ 거래정지 종목 {_results.Count}개를 조회했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_status = LegacyTradingHaltRequestStatus.Idle;
|
||||
_errorMessage = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_results = [];
|
||||
_selected = null;
|
||||
_status = LegacyTradingHaltRequestStatus.Error;
|
||||
_errorMessage = "거래정지 종목 조회 결과를 안전하게 확인하지 못했습니다.";
|
||||
_statusMessage = _errorMessage;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyTradingHaltWorkflowSnapshot SelectResult(string selectionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
lock (_gate)
|
||||
{
|
||||
_selected = _results.FirstOrDefault(item =>
|
||||
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ??
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(selectionId),
|
||||
"The trading-halt selection id is not in the current result set.");
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyTradingHaltWorkflowSnapshot ToggleNameSort()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_nameSort = _nameSort switch
|
||||
{
|
||||
LegacyTradingHaltNameSort.Descending => LegacyTradingHaltNameSort.Ascending,
|
||||
_ => LegacyTradingHaltNameSort.Descending
|
||||
};
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyTradingHaltWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_ma5 = ma5;
|
||||
_ma20 = ma20;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyTradingHaltPlaylistDraft MaterializeSelectedAction(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
lock (_gate)
|
||||
{
|
||||
var selected = _selected ??
|
||||
throw new InvalidOperationException("Select a trading-halt stock first.");
|
||||
var isCurrent = string.Equals(actionId, CurrentActionId, StringComparison.Ordinal);
|
||||
var isCandle = string.Equals(actionId, CandleActionId, StringComparison.Ordinal);
|
||||
if (!isCurrent && !isCandle)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(actionId));
|
||||
}
|
||||
|
||||
var item = selected.Selection;
|
||||
var market = item.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq";
|
||||
var builderKey = isCurrent ? "s5001" : "s8010";
|
||||
var cutCode = isCurrent ? "5001" : "8051";
|
||||
var graphicType = isCurrent
|
||||
? "1열판기본"
|
||||
: string.Join(
|
||||
',',
|
||||
new[]
|
||||
{
|
||||
_ma5 ? "MA5" : null,
|
||||
_ma20 ? "MA20" : null
|
||||
}.Where(static value => value is not null));
|
||||
var selection = new LegacySceneSelection(
|
||||
isCurrent
|
||||
? item.Market == TradingHaltMarket.Kospi ? "KOSPI" : "KOSDAQ"
|
||||
: item.Market == TradingHaltMarket.Kospi
|
||||
? "KOSPI_STOCK"
|
||||
: "KOSDAQ_STOCK",
|
||||
item.DisplayName,
|
||||
graphicType,
|
||||
isCurrent ? "HALTED" : "TRADING_HALT",
|
||||
item.StockCode);
|
||||
return new LegacyTradingHaltPlaylistDraft(
|
||||
$"trading-halt-draft-{++_draftSequence:D8}",
|
||||
builderKey,
|
||||
cutCode,
|
||||
Array.AsReadOnly(new[] { cutCode }),
|
||||
item.DisplayName,
|
||||
isCurrent ? CurrentLabel : CandleLabel,
|
||||
isCurrent ? "stock" : "chart",
|
||||
market,
|
||||
item.StockCode,
|
||||
item.DisplayName,
|
||||
selection,
|
||||
isCandle && _ma5,
|
||||
isCandle && _ma20,
|
||||
isCandle ? CandlePeriodDays : null,
|
||||
"1/1",
|
||||
true,
|
||||
DefaultFadeDuration,
|
||||
"legacy-trading-halt-workflow",
|
||||
actionId);
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyTradingHaltWorkflowSnapshot CreateSnapshot()
|
||||
{
|
||||
var display = _results.ToArray();
|
||||
if (_nameSort != LegacyTradingHaltNameSort.None)
|
||||
{
|
||||
var multiplier = _nameSort == LegacyTradingHaltNameSort.Ascending ? 1 : -1;
|
||||
display = display
|
||||
.Select((item, sourceIndex) => (item, sourceIndex))
|
||||
.OrderBy(pair => pair.item.Selection.DisplayName, new DirectionComparer(multiplier))
|
||||
.ThenBy(pair => pair.item.Selection.Market)
|
||||
.ThenBy(pair => pair.item.Selection.StockCode, StringComparer.Ordinal)
|
||||
.ThenBy(pair => pair.sourceIndex)
|
||||
.Select(pair => pair.item)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var rows = display.Select(ToView).ToArray();
|
||||
var selected = _selected is null ? null : ToView(_selected);
|
||||
var canAct = _selected is not null;
|
||||
return new LegacyTradingHaltWorkflowSnapshot(
|
||||
_revision,
|
||||
_query,
|
||||
_status,
|
||||
Array.AsReadOnly(rows),
|
||||
_isTruncated,
|
||||
selected,
|
||||
_nameSort,
|
||||
_ma5,
|
||||
_ma20,
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
new LegacyTradingHaltActionView(CurrentActionId, CurrentLabel, canAct),
|
||||
new LegacyTradingHaltActionView(CandleActionId, CandleLabel, canAct)
|
||||
}),
|
||||
_errorMessage,
|
||||
_statusMessage,
|
||||
_statusKind);
|
||||
}
|
||||
|
||||
private LegacyTradingHaltResultView ToView(HaltState state) =>
|
||||
new(
|
||||
state.SelectionId,
|
||||
state.Selection.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq",
|
||||
state.Selection.StockCode,
|
||||
state.Selection.DisplayName,
|
||||
_selected?.Selection == state.Selection);
|
||||
|
||||
private static List<HaltState> ValidateResult(
|
||||
TradingHaltSelectionResult result,
|
||||
string expectedQuery,
|
||||
int generation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
|
||||
result.Items is null ||
|
||||
result.Items.Count > LegacyTradingHaltSelectionService.MaximumResults ||
|
||||
result.IsTruncated &&
|
||||
result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults)
|
||||
{
|
||||
throw new TradingHaltSelectionDataException(
|
||||
"The trading-halt search response correlation is invalid.");
|
||||
}
|
||||
|
||||
var states = new List<HaltState>(result.Items.Count);
|
||||
var identities = new HashSet<(TradingHaltMarket Market, string Code)>();
|
||||
var names = new HashSet<(TradingHaltMarket Market, string Name)>();
|
||||
TradingHaltSelection? previous = null;
|
||||
for (var index = 0; index < result.Items.Count; index++)
|
||||
{
|
||||
var item = result.Items[index] ??
|
||||
throw new TradingHaltSelectionDataException("A trading-halt row is missing.");
|
||||
if (!Enum.IsDefined(item.Market) ||
|
||||
!IsAsciiAlphaNumeric(item.StockCode, 32) ||
|
||||
!IsCanonicalText(item.DisplayName, 200) ||
|
||||
!identities.Add((item.Market, item.StockCode)) ||
|
||||
!names.Add((item.Market, item.DisplayName)) ||
|
||||
previous is not null && CompareSourceOrder(previous, item) > 0)
|
||||
{
|
||||
throw new TradingHaltSelectionDataException(
|
||||
"Trading-halt identities must be canonical, unique, and KOSPI/KOSDAQ ordered.");
|
||||
}
|
||||
|
||||
states.Add(new HaltState(
|
||||
$"trading-halt-result-{generation:D4}-{index + 1:D4}",
|
||||
item));
|
||||
previous = item;
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
private static int CompareSourceOrder(
|
||||
TradingHaltSelection left,
|
||||
TradingHaltSelection right)
|
||||
{
|
||||
var market = left.Market.CompareTo(right.Market);
|
||||
if (market != 0)
|
||||
{
|
||||
return market;
|
||||
}
|
||||
|
||||
var name = string.Compare(left.DisplayName, right.DisplayName, StringComparison.Ordinal);
|
||||
return name != 0
|
||||
? name
|
||||
: string.Compare(left.StockCode, right.StockCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
var normalized = query.Trim();
|
||||
if (query.Length > LegacyTradingHaltSelectionService.MaximumQueryLength ||
|
||||
normalized.Length > LegacyTradingHaltSelectionService.MaximumQueryLength ||
|
||||
!IsSafeText(query) || !IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A trading-halt query must contain at most 64 safe characters.",
|
||||
nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
value.All(static character => char.IsAsciiLetterOrDigit(character));
|
||||
|
||||
private static bool IsCanonicalText(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record HaltState(
|
||||
string SelectionId,
|
||||
TradingHaltSelection Selection);
|
||||
|
||||
private sealed class DirectionComparer(int multiplier) : IComparer<string>
|
||||
{
|
||||
public int Compare(string? left, string? right) =>
|
||||
multiplier * string.Compare(left, right, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
69
src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs
Normal file
69
src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
|
||||
public sealed record LegacyExecutePlayoutIntent(LegacyOperatorPlayoutCommand Command)
|
||||
: LegacyUiIntent;
|
||||
|
||||
public sealed record LegacySetFadeDurationIntent(int Duration)
|
||||
: LegacyUiIntent;
|
||||
|
||||
public sealed record LegacyToggleBackgroundIntent : LegacyUiIntent;
|
||||
|
||||
public sealed record LegacyChooseBackgroundIntent : LegacyUiIntent;
|
||||
|
||||
internal static class LegacyPlayoutUiIntentParser
|
||||
{
|
||||
public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch
|
||||
{
|
||||
"prepare-playout" => ParseEmpty(
|
||||
payload,
|
||||
static () => new LegacyExecutePlayoutIntent(
|
||||
LegacyOperatorPlayoutCommand.Prepare)),
|
||||
"take-in" => ParseEmpty(
|
||||
payload,
|
||||
static () => new LegacyExecutePlayoutIntent(
|
||||
LegacyOperatorPlayoutCommand.TakeIn)),
|
||||
"next-playout" => ParseEmpty(
|
||||
payload,
|
||||
static () => new LegacyExecutePlayoutIntent(
|
||||
LegacyOperatorPlayoutCommand.Next)),
|
||||
"take-out" => ParseEmpty(
|
||||
payload,
|
||||
static () => new LegacyExecutePlayoutIntent(
|
||||
LegacyOperatorPlayoutCommand.TakeOut)),
|
||||
"set-fade-duration" => ParseFadeDuration(payload),
|
||||
"toggle-background" => ParseEmpty(
|
||||
payload,
|
||||
static () => new LegacyToggleBackgroundIntent()),
|
||||
"choose-background" => ParseEmpty(
|
||||
payload,
|
||||
static () => new LegacyChooseBackgroundIntent()),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static LegacyUiIntent? ParseFadeDuration(JsonElement payload)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
payload.GetRawText().Length > 64 ||
|
||||
payload.EnumerateObject().Count() != 1 ||
|
||||
!payload.TryGetProperty("duration", out var durationElement) ||
|
||||
durationElement.ValueKind != JsonValueKind.Number ||
|
||||
!durationElement.TryGetInt32(out var duration) ||
|
||||
duration is < 0 or > 60)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LegacySetFadeDurationIntent(duration);
|
||||
}
|
||||
|
||||
private static LegacyUiIntent? ParseEmpty(
|
||||
JsonElement payload,
|
||||
Func<LegacyUiIntent> factory) =>
|
||||
payload.ValueKind == JsonValueKind.Object &&
|
||||
!payload.EnumerateObject().Any()
|
||||
? factory()
|
||||
: null;
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\LegacySceneRuntimeFactory.cs"
|
||||
Link="Runtime\LegacySceneRuntimeFactory.cs" />
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using WinRT.Interop;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private AppWindow? _closeConfirmationAppWindow;
|
||||
private int _closeConfirmationPending;
|
||||
private int _closeConfirmed;
|
||||
|
||||
private void EnsureCloseConfirmationAttached()
|
||||
{
|
||||
if (_closeConfirmationAppWindow is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var windowHandle = WindowNative.GetWindowHandle(this);
|
||||
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
|
||||
var appWindow = AppWindow.GetFromWindowId(windowId);
|
||||
appWindow.Closing += OnAppWindowClosing;
|
||||
_closeConfirmationAppWindow = appWindow;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The constructor can run before the native window exists. Root.Loaded
|
||||
// retries the attachment after WinUI has created it.
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args)
|
||||
{
|
||||
if (Volatile.Read(ref _closeConfirmed) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// AppWindow.Closing has no deferral. Cancel synchronously and close a second
|
||||
// time only after the operator explicitly confirms. The default is Cancel,
|
||||
// matching MainForm_FormClosing's default No choice.
|
||||
args.Cancel = true;
|
||||
if (Interlocked.CompareExchange(ref _closeConfirmationPending, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Root.XamlRoot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = Root.XamlRoot,
|
||||
Title = "프로그램 종료",
|
||||
Content = BuildCloseConfirmationMessage(),
|
||||
PrimaryButtonText = "종료",
|
||||
CloseButtonText = "취소",
|
||||
DefaultButton = ContentDialogButton.Close
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync();
|
||||
if (result != ContentDialogResult.Primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _closeConfirmed, 1);
|
||||
Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If the confirmation surface cannot be shown, keep the application open.
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _closeConfirmationPending, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildCloseConfirmationMessage()
|
||||
{
|
||||
var status = _playoutEngine?.Status;
|
||||
var workflow = _playoutWorkflow?.State;
|
||||
var hasPreparedScene = !string.IsNullOrWhiteSpace(status?.PreparedSceneName) ||
|
||||
!string.IsNullOrWhiteSpace(workflow?.PreparedCutCode);
|
||||
var hasOnAirScene = !string.IsNullOrWhiteSpace(status?.OnAirSceneName) ||
|
||||
!string.IsNullOrWhiteSpace(workflow?.OnAirCutCode);
|
||||
var commandPending = Volatile.Read(ref _playoutBusy) != 0 ||
|
||||
status?.IsPlayCompletionPending == true;
|
||||
var outcomeUnknown = Volatile.Read(ref _playoutQuarantined) != 0 ||
|
||||
status?.State == PlayoutConnectionState.OutcomeUnknown;
|
||||
|
||||
if (outcomeUnknown)
|
||||
{
|
||||
return "송출 결과를 확정할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 종료하세요. " +
|
||||
"종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?";
|
||||
}
|
||||
|
||||
if (commandPending)
|
||||
{
|
||||
return "송출 명령 또는 callback 처리가 진행 중입니다. 결과를 확인한 뒤 종료하는 것이 안전합니다. " +
|
||||
"그래도 종료하시겠습니까?";
|
||||
}
|
||||
|
||||
if (hasOnAirScene || hasPreparedScene)
|
||||
{
|
||||
return "PREPARED 또는 PROGRAM 장면이 남아 있습니다. 필요한 경우 먼저 TAKE OUT을 실행하고 " +
|
||||
"PGM을 확인하세요. 종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?";
|
||||
}
|
||||
|
||||
return "프로그램을 종료하시겠습니까?";
|
||||
}
|
||||
|
||||
private void DetachCloseConfirmation()
|
||||
{
|
||||
var appWindow = Interlocked.Exchange(ref _closeConfirmationAppWindow, null);
|
||||
if (appWindow is not null)
|
||||
{
|
||||
appWindow.Closing -= OnAppWindowClosing;
|
||||
}
|
||||
}
|
||||
}
|
||||
232
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.ManualLists.cs
Normal file
232
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.ManualLists.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private const string OperatorDataDirectoryEnvironmentVariable =
|
||||
"MBN_STOCK_OPERATOR_DATA_DIRECTORY";
|
||||
|
||||
private S5025TrustedManualFileStore? _manualNetSellStore;
|
||||
private ViTrustedManualFileStore? _manualViStore;
|
||||
private LegacyManualOperatorDataImporter? _manualDataImporter;
|
||||
|
||||
private LegacyManualListsWorkflow CreateManualListsWorkflow(
|
||||
ILegacyStockLookup stockLookup)
|
||||
{
|
||||
ILegacyManualListStore store;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(
|
||||
OperatorDataDirectoryEnvironmentVariable)))
|
||||
{
|
||||
store = new UnavailableManualListStore(
|
||||
"UNSAFE_CONFIGURATION",
|
||||
"외부 경로 설정은 사용하지 않습니다. 앱 전용 로컬 저장소만 사용할 수 있습니다.");
|
||||
return new LegacyManualListsWorkflow(store, stockLookup);
|
||||
}
|
||||
|
||||
var directory = TrustedManualDirectory.PreparePrivateWritableDirectory(
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"OperatorData"));
|
||||
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(directory);
|
||||
if (!inspection.IsReady)
|
||||
{
|
||||
var interrupted = inspection.State ==
|
||||
LegacyManualOperatorDataRuntimeState.InterruptedImport;
|
||||
store = new UnavailableManualListStore(
|
||||
interrupted ? "IMPORT_INTERRUPTED" : "IMPORTED_STATE_INVALID",
|
||||
interrupted
|
||||
? "이전 원본 데이터 가져오기가 중단되어 수동 파일을 열지 않았습니다."
|
||||
: "수동 데이터 가져오기 표식과 파일 상태가 일치하지 않습니다.");
|
||||
return new LegacyManualListsWorkflow(store, stockLookup);
|
||||
}
|
||||
|
||||
_manualNetSellStore = new S5025TrustedManualFileStore(directory);
|
||||
_manualViStore = new ViTrustedManualFileStore(directory);
|
||||
_manualDataImporter = new LegacyManualOperatorDataImporter(
|
||||
directory,
|
||||
new FixedLegacyManualOperatorDataSource());
|
||||
store = new TrustedManualListStore(
|
||||
_manualNetSellStore,
|
||||
_manualViStore,
|
||||
_manualDataImporter);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_manualDataImporter?.Dispose();
|
||||
_manualViStore?.Dispose();
|
||||
_manualNetSellStore?.Dispose();
|
||||
_manualDataImporter = null;
|
||||
_manualViStore = null;
|
||||
_manualNetSellStore = null;
|
||||
store = new UnavailableManualListStore(
|
||||
"INITIALIZATION_FAILED",
|
||||
"수동 순매도/VI 로컬 저장소를 초기화하지 못했습니다.");
|
||||
}
|
||||
|
||||
return new LegacyManualListsWorkflow(store, stockLookup);
|
||||
}
|
||||
|
||||
private void ShutdownManualListsRuntime()
|
||||
{
|
||||
var importer = Interlocked.Exchange(ref _manualDataImporter, null);
|
||||
var viStore = Interlocked.Exchange(ref _manualViStore, null);
|
||||
var netStore = Interlocked.Exchange(ref _manualNetSellStore, null);
|
||||
if (importer is null && viStore is null && netStore is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await _intentGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
importer?.Dispose();
|
||||
viStore?.Dispose();
|
||||
netStore?.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_intentGate.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TrustedManualListStore : ILegacyManualListStore
|
||||
{
|
||||
private readonly S5025TrustedManualFileStore _netStore;
|
||||
private readonly ViTrustedManualFileStore _viStore;
|
||||
private readonly LegacyManualOperatorDataImporter _importer;
|
||||
|
||||
public TrustedManualListStore(
|
||||
S5025TrustedManualFileStore netStore,
|
||||
ViTrustedManualFileStore viStore,
|
||||
LegacyManualOperatorDataImporter importer)
|
||||
{
|
||||
_netStore = netStore;
|
||||
_viStore = viStore;
|
||||
_importer = importer;
|
||||
}
|
||||
|
||||
public LegacyManualListStoreAvailability Availability { get; } =
|
||||
new(true, "READY", null);
|
||||
|
||||
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_netStore.ReadAsync(audience, cancellationToken);
|
||||
|
||||
public Task WriteNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
IReadOnlyList<S5025TrustedManualRow> rows,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_netStore.WriteAsync(audience, rows, cancellationToken);
|
||||
|
||||
public async Task<LegacyManualViStoreSnapshot> ReadViAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await _viStore.ReadSnapshotAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return Map(snapshot);
|
||||
}
|
||||
|
||||
public async Task<LegacyManualViWriteReceipt> WriteViAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _viStore.WriteAsync(
|
||||
items.Select(item => new ViTrustedManualItem(item.Code, item.Name)).ToArray(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return new LegacyManualViWriteReceipt(Map(result.Snapshot), result.Persisted);
|
||||
}
|
||||
|
||||
public async Task<LegacyManualImportReceipt> ImportAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _importer.ImportAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new LegacyManualImportReceipt(
|
||||
result.MarkerVersion,
|
||||
result.SourceSha256,
|
||||
result.ImportedAt,
|
||||
result.NetSellRowCount,
|
||||
result.ViItemCount);
|
||||
}
|
||||
catch (LegacyManualOperatorImportException exception)
|
||||
{
|
||||
throw new LegacyManualImportStoreException(
|
||||
ImportFailureCode(exception.Failure),
|
||||
exception.OutcomeUnknown);
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyManualViStoreSnapshot Map(ViTrustedManualSnapshot snapshot) =>
|
||||
new(
|
||||
Array.AsReadOnly(snapshot.Items.Select(item =>
|
||||
new LegacyManualViStoredItem(item.Code, item.Name)).ToArray()),
|
||||
snapshot.RowVersion);
|
||||
|
||||
private static string ImportFailureCode(LegacyManualOperatorImportFailure failure) =>
|
||||
failure switch
|
||||
{
|
||||
LegacyManualOperatorImportFailure.SourceUnavailable => "SOURCE_UNAVAILABLE",
|
||||
LegacyManualOperatorImportFailure.SourceInvalid => "SOURCE_INVALID",
|
||||
LegacyManualOperatorImportFailure.AlreadyImported => "ALREADY_IMPORTED",
|
||||
LegacyManualOperatorImportFailure.DestinationNotEmpty =>
|
||||
"DESTINATION_NOT_EMPTY",
|
||||
LegacyManualOperatorImportFailure.InterruptedImport => "INTERRUPTED_IMPORT",
|
||||
LegacyManualOperatorImportFailure.ImportFailed => "IMPORT_FAILED",
|
||||
LegacyManualOperatorImportFailure.RollbackFailed => "ROLLBACK_FAILED",
|
||||
_ => "IMPORT_FAILED"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class UnavailableManualListStore : ILegacyManualListStore
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableManualListStore(string code, string message)
|
||||
{
|
||||
_message = message;
|
||||
Availability = new LegacyManualListStoreAvailability(false, code, message);
|
||||
}
|
||||
|
||||
public LegacyManualListStoreAvailability Availability { get; }
|
||||
|
||||
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<IReadOnlyList<S5025TrustedManualRow>>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task WriteNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
IReadOnlyList<S5025TrustedManualRow> rows,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException(new InvalidOperationException(_message));
|
||||
|
||||
public Task<LegacyManualViStoreSnapshot> ReadViAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<LegacyManualViStoreSnapshot>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<LegacyManualViWriteReceipt> WriteViAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> items,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<LegacyManualViWriteReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<LegacyManualImportReceipt> ImportAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<LegacyManualImportReceipt>(
|
||||
new LegacyManualImportStoreException("UNAVAILABLE", false));
|
||||
}
|
||||
}
|
||||
836
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
Normal file
836
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
Normal file
@@ -0,0 +1,836 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
using MBN_STOCK_WEBVIEW.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Windows.Storage.Pickers;
|
||||
using WinRT.Interop;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
|
||||
private IPlayoutEngine? _playoutEngine;
|
||||
private LegacyPlayoutWorkflow? _playoutWorkflow;
|
||||
private PlayoutOptions? _playoutOptions;
|
||||
private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions;
|
||||
private string? _playoutInitializationError;
|
||||
private string? _playoutInitializationWarning;
|
||||
private CancellationTokenSource? _refreshCancellation;
|
||||
private Task? _refreshTask;
|
||||
private int _playoutBusy;
|
||||
private int _playoutQuarantined;
|
||||
private int _playoutShutdownStarted;
|
||||
private int _operatorMutationQuarantined;
|
||||
private int _refreshCompletedCount;
|
||||
private bool _refreshLimitReached;
|
||||
private string _refreshMessage = string.Empty;
|
||||
|
||||
private void InitializePlayoutRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
var explicitLocalConfigurationExists = File.Exists(
|
||||
PlayoutOptionsLoader.DefaultPath);
|
||||
_playoutOptions = PlayoutOptionsLoader.Load();
|
||||
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
|
||||
_playoutOptions,
|
||||
explicitLocalConfigurationExists);
|
||||
var initialComposition = startupComposition.Composition;
|
||||
_playoutInitializationWarning = startupComposition.HasWarning
|
||||
? startupComposition.WarningMessage
|
||||
: null;
|
||||
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
|
||||
initialComposition);
|
||||
_playoutEngine = PlayoutEngineFactory.Create(_playoutOptions);
|
||||
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
|
||||
if (_databaseRuntime is not null)
|
||||
{
|
||||
_playoutWorkflow = global::MBN_STOCK_WEBVIEW.LegacySceneRuntimeFactory.Create(
|
||||
_playoutEngine,
|
||||
_databaseRuntime.Executor,
|
||||
compositionOptionsSource: _compositionOptions,
|
||||
trustedManualSource: _manualNetSellStore,
|
||||
trustedViSource: _manualViStore);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_playoutInitializationWarning = null;
|
||||
_playoutInitializationError = exception is PlayoutConfigurationException
|
||||
? exception.Message
|
||||
: "Tornado 송출 어댑터를 초기화하지 못했습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var engine = _playoutEngine;
|
||||
if (engine is null)
|
||||
{
|
||||
QueueOperatorState();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_playoutInitializationWarning = null;
|
||||
_playoutInitializationError = result.Message;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
_playoutInitializationWarning = null;
|
||||
_playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
|
||||
}
|
||||
|
||||
QueueOperatorState();
|
||||
}
|
||||
|
||||
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
|
||||
{
|
||||
_playoutWorkflow?.ObserveEngineStatus(args.Current);
|
||||
if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
|
||||
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
|
||||
{
|
||||
StopRefreshLoop();
|
||||
}
|
||||
|
||||
QueueOperatorState();
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(
|
||||
LegacyOperatorPlayoutCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var engine = _playoutEngine;
|
||||
var workflow = _playoutWorkflow;
|
||||
if (engine is null || workflow is null)
|
||||
{
|
||||
return _controller.ReportError(
|
||||
_playoutInitializationError ?? "Tornado 송출 기능을 사용할 수 없습니다.");
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _playoutQuarantined) != 0 ||
|
||||
engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"송출 결과를 확인할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 앱을 다시 시작하세요.");
|
||||
}
|
||||
|
||||
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return _controller.ReportError("다른 송출 명령을 처리하고 있습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutBusy, 1);
|
||||
StopRefreshLoop();
|
||||
var nextKindBefore = workflow.State.NextKind;
|
||||
if (command == LegacyOperatorPlayoutCommand.Next &&
|
||||
engine.Status.IsPlayCompletionPending)
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"Tornado 재생 완료 이벤트를 기다리는 중입니다. NEXT를 반복하지 마세요.");
|
||||
}
|
||||
|
||||
PlayoutResult result;
|
||||
switch (command)
|
||||
{
|
||||
case LegacyOperatorPlayoutCommand.Prepare:
|
||||
var request = _controller.CreatePlayoutRequest();
|
||||
result = await workflow.PrepareAsync(
|
||||
request.Playlist,
|
||||
request.SelectedIndexZeroBased,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case LegacyOperatorPlayoutCommand.TakeIn:
|
||||
// MainForm.btnTake_Click performed ONAirMode (load/prepare) and
|
||||
// Play in one visible F8 action. Keep the explicit PREPARE control
|
||||
// for engineering checks, but preserve the original one-click path
|
||||
// whenever no scene is already prepared.
|
||||
if (string.IsNullOrWhiteSpace(workflow.State.PreparedCutCode))
|
||||
{
|
||||
var takeInRequest = _controller.CreatePlayoutRequest();
|
||||
result = await workflow.PrepareAndTakeInAsync(
|
||||
takeInRequest.Playlist,
|
||||
takeInRequest.SelectedIndexZeroBased,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await workflow.TakeInAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
case LegacyOperatorPlayoutCommand.Next:
|
||||
result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case LegacyOperatorPlayoutCommand.TakeOut:
|
||||
result = await workflow.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(command));
|
||||
}
|
||||
|
||||
if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut)
|
||||
{
|
||||
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
||||
return _controller.ReportError(
|
||||
"송출 명령 결과가 불명확합니다. 명령을 반복하지 말고 PGM과 Network Monitoring을 확인하세요.");
|
||||
}
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
return _controller.ReportError(result.Message);
|
||||
}
|
||||
|
||||
if (command == LegacyOperatorPlayoutCommand.TakeIn ||
|
||||
command == LegacyOperatorPlayoutCommand.Next &&
|
||||
nextKindBefore == LegacyWorkflowNextKind.PlaylistNext)
|
||||
{
|
||||
StartRefreshLoop(workflow);
|
||||
}
|
||||
|
||||
return _controller.ReportInformation(result.Message);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return _controller.ReportError("송출 요청이 취소되었습니다.");
|
||||
}
|
||||
catch (DatabaseOperationException exception)
|
||||
{
|
||||
return _controller.ReportError(exception.Message);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is LegacySceneDataException or ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return _controller.ReportError(exception.Message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
||||
return _controller.ReportError(
|
||||
"송출 엔진 명령의 결과를 확인할 수 없습니다. 명령을 반복하지 마세요.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutBusy, 0);
|
||||
_playoutCommandGate.Release();
|
||||
QueueOperatorState();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(
|
||||
int fadeDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (fadeDuration is < 0 or > 60)
|
||||
{
|
||||
return _controller.ReportError("Fade 값이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return _controller.ReportError("송출 명령 처리 중에는 Fade를 변경할 수 없습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!CanChangeComposition())
|
||||
{
|
||||
return _controller.ReportError("Fade는 IDLE 상태에서만 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
var source = _compositionOptions!;
|
||||
source.Update(source.Current with { FadeDuration = fadeDuration });
|
||||
return _controller.ReportInformation(
|
||||
$"Fade {fadeDuration}을 다음 PREPARE부터 적용합니다.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_playoutCommandGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!CanChangeComposition())
|
||||
{
|
||||
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
var source = _compositionOptions!;
|
||||
if (source.Current.BackgroundKind == LegacySceneBackgroundKind.None)
|
||||
{
|
||||
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
|
||||
_playoutOptions!,
|
||||
source.Current.FadeDuration));
|
||||
_playoutInitializationWarning = null;
|
||||
return _controller.ReportInformation(
|
||||
"기본 배경을 다음 PREPARE부터 적용합니다.");
|
||||
}
|
||||
|
||||
source.Update(new LegacySceneCueCompositionOptions(
|
||||
source.Current.FadeDuration,
|
||||
LegacySceneBackgroundKind.None));
|
||||
return _controller.ReportInformation("배경 사용을 해제했습니다.");
|
||||
}
|
||||
catch (PlayoutConfigurationException exception)
|
||||
{
|
||||
return _controller.ReportError(exception.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_playoutCommandGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!CanChangeComposition())
|
||||
{
|
||||
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
// MainForm.btnback_Click assigns 배경\기본.vrv before opening the
|
||||
// picker. Preserve that observable behavior when the trusted default is
|
||||
// present, while still allowing the operator to choose another asset if it
|
||||
// is not.
|
||||
var source = _compositionOptions!;
|
||||
try
|
||||
{
|
||||
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
|
||||
_playoutOptions!,
|
||||
source.Current.FadeDuration));
|
||||
_playoutInitializationWarning = null;
|
||||
}
|
||||
catch (PlayoutConfigurationException)
|
||||
{
|
||||
}
|
||||
|
||||
var picker = new FileOpenPicker
|
||||
{
|
||||
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
|
||||
ViewMode = PickerViewMode.List
|
||||
};
|
||||
foreach (var extension in new[] { ".vrv", ".jpg", ".jpeg", ".png" })
|
||||
{
|
||||
picker.FileTypeFilter.Add(extension);
|
||||
}
|
||||
|
||||
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
|
||||
var selected = await picker.PickSingleFileAsync();
|
||||
if (selected is null)
|
||||
{
|
||||
return _controller.Current;
|
||||
}
|
||||
|
||||
if (!CanChangeComposition())
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"파일 선택 중 송출 상태가 바뀌어 배경 변경을 취소했습니다.");
|
||||
}
|
||||
|
||||
source.Update(PlayoutSceneCompositionFactory.CreateOperatorSelection(
|
||||
_playoutOptions!,
|
||||
source.Current.FadeDuration,
|
||||
selected.Path));
|
||||
_playoutInitializationWarning = null;
|
||||
return _controller.ReportInformation(
|
||||
"선택한 배경을 다음 PREPARE부터 적용합니다.");
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or IOException or UnauthorizedAccessException or
|
||||
PlayoutConfigurationException)
|
||||
{
|
||||
return _controller.ReportError(
|
||||
"신뢰 배경 폴더 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_playoutCommandGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanChangeComposition()
|
||||
{
|
||||
var status = _playoutEngine?.Status;
|
||||
var workflow = _playoutWorkflow?.State;
|
||||
return _playoutOptions is not null && _compositionOptions is not null &&
|
||||
status is not null && workflow is not null &&
|
||||
Volatile.Read(ref _playoutBusy) == 0 &&
|
||||
Volatile.Read(ref _playoutQuarantined) == 0 &&
|
||||
!status.IsPlayCompletionPending &&
|
||||
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
|
||||
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
|
||||
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
|
||||
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
|
||||
(_refreshTask is null || _refreshTask.IsCompleted);
|
||||
}
|
||||
|
||||
private bool CanAcceptOperatorMutation()
|
||||
{
|
||||
var status = _playoutEngine?.Status;
|
||||
var workflow = _playoutWorkflow?.State;
|
||||
if (status is null || workflow is null)
|
||||
{
|
||||
// A disabled or unavailable playout runtime must not prevent the operator
|
||||
// from composing a playlist. PREPARE will still fail closed later.
|
||||
return true;
|
||||
}
|
||||
|
||||
return Volatile.Read(ref _playoutBusy) == 0 &&
|
||||
Volatile.Read(ref _playoutQuarantined) == 0 &&
|
||||
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
|
||||
status.State != PlayoutConnectionState.OutcomeUnknown &&
|
||||
!status.IsPlayCompletionPending &&
|
||||
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
|
||||
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
|
||||
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
|
||||
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
|
||||
(_refreshTask is null || _refreshTask.IsCompleted);
|
||||
}
|
||||
|
||||
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyDropSelectedCutsIntent or
|
||||
LegacySetPlaylistEnabledIntent or
|
||||
LegacySetAllPlaylistEnabledIntent or
|
||||
LegacyMoveSelectedPlaylistRowsIntent or
|
||||
LegacyDeleteSelectedPlaylistRowsIntent or
|
||||
LegacyDeleteAllPlaylistRowsIntent or
|
||||
LegacyToggleActivePlaylistEnabledIntent or
|
||||
LegacyActivateFixedActionIntent or
|
||||
LegacyActivateFixedSectionIntent or
|
||||
LegacyActivateIndustryActionIntent or
|
||||
LegacyActivateIndustrySectionIntent or
|
||||
LegacyActivateOverseasActionIntent or
|
||||
LegacyAddComparisonPairIntent or
|
||||
LegacyMoveComparisonPairIntent or
|
||||
LegacyDeleteSelectedComparisonPairIntent or
|
||||
LegacyDeleteAllComparisonPairsIntent or
|
||||
LegacyActivateComparisonActionIntent or
|
||||
LegacyActivateComparisonSectionIntent or
|
||||
LegacyActivateThemeActionIntent or
|
||||
LegacyActivateThemeResultIntent or
|
||||
LegacyActivateExpertActionIntent or
|
||||
LegacyActivateTradingHaltActionIntent or
|
||||
LegacyBeginUc4ThemeCatalogIntent or
|
||||
LegacyEditUc4SelectedThemeIntent or
|
||||
LegacyDeleteUc4SelectedThemeIntent or
|
||||
LegacyBeginUc6ExpertCatalogIntent or
|
||||
LegacyEditUc6SelectedExpertIntent or
|
||||
LegacyDeleteUc6SelectedExpertIntent or
|
||||
LegacyBeginCreateThemeCatalogIntent or
|
||||
LegacyBeginCreateExpertCatalogIntent or
|
||||
LegacyAddOperatorCatalogStockIntent or
|
||||
LegacyActivateOperatorCatalogStockIntent or
|
||||
LegacyMoveOperatorCatalogDraftRowIntent or
|
||||
LegacyRemoveOperatorCatalogDraftRowIntent or
|
||||
LegacySetOperatorCatalogDraftBuyAmountIntent or
|
||||
LegacySaveOperatorCatalogIntent or
|
||||
LegacyDeleteOperatorCatalogIntent or
|
||||
LegacyLoadSelectedNamedPlaylistIntent or
|
||||
LegacyLoadNamedPlaylistByIdIntent or
|
||||
LegacyCreateNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistToIntent or
|
||||
LegacyDeleteSelectedNamedPlaylistIntent or
|
||||
LegacySaveManualFinancialIntent or
|
||||
LegacyDeleteManualFinancialIntent or
|
||||
LegacyDeleteAllManualFinancialIntent or
|
||||
LegacyActivateManualFinancialActionIntent or
|
||||
LegacyActivateManualFinancialResultIntent or
|
||||
LegacySetManualNetSellCellIntent or
|
||||
LegacySaveManualNetSellIntent or
|
||||
LegacyAddManualNetSellCutIntent or
|
||||
LegacyAddManualViResultIntent or
|
||||
LegacyMoveManualViItemIntent or
|
||||
LegacyDeleteManualViItemIntent or
|
||||
LegacyDeleteAllManualViItemsIntent or
|
||||
LegacySaveManualViIntent or
|
||||
LegacyAddManualViCutIntent or
|
||||
LegacyConfirmManualListIntent or
|
||||
LegacyImportManualListsIntent;
|
||||
|
||||
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
|
||||
LegacySetManualNetSellCellIntent or
|
||||
LegacyAddManualViResultIntent or
|
||||
LegacyMoveManualViItemIntent or
|
||||
LegacyDeleteManualViItemIntent or
|
||||
LegacyDeleteAllManualViItemsIntent or
|
||||
LegacyConfirmManualListIntent or
|
||||
LegacyImportManualListsIntent;
|
||||
|
||||
private void ObserveOperatorMutationQuarantine(LegacyOperatorSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.ManualFinancial?.WritesQuarantined == true ||
|
||||
snapshot.NamedPlaylist?.IsWriteQuarantined == true ||
|
||||
snapshot.ManualLists?.WritesQuarantined == true ||
|
||||
snapshot.OperatorCatalog?.WritesQuarantined == true)
|
||||
{
|
||||
Interlocked.Exchange(ref _operatorMutationQuarantined, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsOperatorMutationQuarantined() =>
|
||||
Volatile.Read(ref _operatorMutationQuarantined) != 0;
|
||||
|
||||
private LegacyOperatorPlayoutSnapshot CreatePlayoutSnapshot()
|
||||
{
|
||||
var status = _playoutEngine?.Status;
|
||||
var workflow = _playoutWorkflow?.State;
|
||||
var composition = _compositionOptions?.Current ??
|
||||
LegacySceneCueCompositionOptions.Default;
|
||||
var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 ||
|
||||
status?.State == PlayoutConnectionState.OutcomeUnknown;
|
||||
var phase = !string.IsNullOrWhiteSpace(status?.OnAirSceneName)
|
||||
? LegacyOperatorPlayoutPhase.Program
|
||||
: !string.IsNullOrWhiteSpace(status?.PreparedSceneName)
|
||||
? LegacyOperatorPlayoutPhase.Prepared
|
||||
: LegacyOperatorPlayoutPhase.Idle;
|
||||
var backgroundEnabled =
|
||||
composition.BackgroundKind != LegacySceneBackgroundKind.None;
|
||||
return new LegacyOperatorPlayoutSnapshot(
|
||||
status?.Mode ?? PlayoutMode.Disabled,
|
||||
quarantined
|
||||
? PlayoutConnectionState.OutcomeUnknown
|
||||
: status?.State ?? PlayoutConnectionState.Disabled,
|
||||
phase,
|
||||
status?.IsProcessRunning ?? false,
|
||||
status?.IsConnected ?? false,
|
||||
(status?.IsCommandAvailable ?? false) && !quarantined,
|
||||
(status?.LiveTakeInAllowed ?? false) && !quarantined,
|
||||
status?.IsPlayCompletionPending ?? false,
|
||||
quarantined,
|
||||
status?.PreparedSceneName,
|
||||
status?.OnAirSceneName,
|
||||
workflow?.CurrentCueIndexZeroBased ?? -1,
|
||||
workflow?.CurrentEntryId,
|
||||
workflow?.BuilderKey,
|
||||
workflow?.PageIndexZeroBased ?? 0,
|
||||
workflow?.PageCount ?? 0,
|
||||
workflow?.PageSize ?? 0,
|
||||
workflow?.ItemCount ?? 0,
|
||||
workflow?.CurrentPageItemCount ?? 0,
|
||||
workflow?.IsLastPage ?? true,
|
||||
workflow?.NextKind ?? LegacyWorkflowNextKind.None,
|
||||
Volatile.Read(ref _playoutBusy) != 0,
|
||||
_refreshTask is { IsCompleted: false },
|
||||
Volatile.Read(ref _refreshCompletedCount),
|
||||
_playoutOptions?.MaximumAutomaticRefreshesPerTakeIn,
|
||||
_refreshLimitReached,
|
||||
composition.FadeDuration,
|
||||
backgroundEnabled,
|
||||
backgroundEnabled
|
||||
? Path.GetFileName(composition.BackgroundAssetPath) ?? string.Empty
|
||||
: string.Empty,
|
||||
CanChangeComposition(),
|
||||
quarantined
|
||||
? "결과 불명확 상태입니다. 명령을 반복하지 마세요."
|
||||
: _refreshMessage.Length > 0
|
||||
? _refreshMessage
|
||||
: _playoutInitializationWarning ?? status?.Message ??
|
||||
_playoutInitializationError ??
|
||||
"송출 어댑터를 사용할 수 없습니다.");
|
||||
}
|
||||
|
||||
private void StartRefreshLoop(LegacyPlayoutWorkflow workflow)
|
||||
{
|
||||
StopRefreshLoop();
|
||||
Interlocked.Exchange(ref _refreshCompletedCount, 0);
|
||||
_refreshLimitReached = false;
|
||||
_refreshMessage = string.Empty;
|
||||
var cutCode = workflow.State.OnAirCutCode;
|
||||
if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var firstInterval) ||
|
||||
firstInterval <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
_lifetimeCancellation.Token);
|
||||
_refreshCancellation = cancellation;
|
||||
_refreshTask = RunRefreshLoopAsync(workflow, firstInterval, cancellation);
|
||||
}
|
||||
|
||||
private void StopRefreshLoop()
|
||||
{
|
||||
var cancellation = Interlocked.Exchange(ref _refreshCancellation, null);
|
||||
if (cancellation is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellation.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunRefreshLoopAsync(
|
||||
LegacyPlayoutWorkflow workflow,
|
||||
TimeSpan firstInterval,
|
||||
CancellationTokenSource cancellation)
|
||||
{
|
||||
var token = cancellation.Token;
|
||||
var delay = firstInterval;
|
||||
var maximum = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn;
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var engine = _playoutEngine;
|
||||
if (engine is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (maximum is { } cap && Volatile.Read(ref _refreshCompletedCount) >= cap)
|
||||
{
|
||||
if (!await WaitForPlayCompletionAsync(engine, token)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
_refreshMessage =
|
||||
"자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. PGM을 확인하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshLimitReached = true;
|
||||
_refreshMessage = $"자동 갱신 상한 {cap}회에 도달했습니다.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await WaitForPlayCompletionAsync(engine, token)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
_refreshMessage = "재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다.";
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(delay, token).ConfigureAwait(false);
|
||||
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutBusy, 1);
|
||||
var result = await workflow.RefreshOnAirAsync(token).ConfigureAwait(false);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
if (result.Code is PlayoutResultCode.OutcomeUnknown or
|
||||
PlayoutResultCode.TimedOut)
|
||||
{
|
||||
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_refreshMessage =
|
||||
"자동 장면 갱신을 중단했습니다. TAKE OUT 전 PGM을 확인하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _refreshCompletedCount);
|
||||
delay = LegacySceneRefreshPolicy.RecurringInterval;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutBusy, 0);
|
||||
_playoutCommandGate.Release();
|
||||
QueueOperatorState();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
||||
_refreshMessage = "자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCancellation, cancellation))
|
||||
{
|
||||
Interlocked.CompareExchange(ref _refreshCancellation, null, cancellation);
|
||||
}
|
||||
|
||||
cancellation.Dispose();
|
||||
QueueOperatorState();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForPlayCompletionAsync(
|
||||
IPlayoutEngine engine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!engine.Status.IsPlayCompletionPending)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var timeout = Math.Clamp(
|
||||
engine.Status.OperationTimeoutMilliseconds,
|
||||
100,
|
||||
300_000);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
linked.CancelAfter(timeout);
|
||||
while (engine.Status.IsPlayCompletionPending)
|
||||
{
|
||||
var completion = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void OnStatus(object? sender, PlayoutStatusChangedEventArgs args)
|
||||
{
|
||||
if (!args.Current.IsPlayCompletionPending)
|
||||
{
|
||||
completion.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
engine.StatusChanged += OnStatus;
|
||||
try
|
||||
{
|
||||
if (!engine.Status.IsPlayCompletionPending)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
await completion.Task.WaitAsync(linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
engine.StatusChanged -= OnStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ValueTask QuarantinePlayoutAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _playoutQuarantined, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopRefreshLoop();
|
||||
if (_playoutEngine is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _playoutEngine.QuarantineAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueOperatorState()
|
||||
{
|
||||
if (_closing || !_webViewReady)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (!_closing)
|
||||
{
|
||||
PostState(_controller.Current);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ShutdownPlayoutRuntimeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopRefreshLoop();
|
||||
var refresh = _refreshTask;
|
||||
if (refresh is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await refresh.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
var engine = _playoutEngine;
|
||||
_playoutEngine = null;
|
||||
_playoutWorkflow = null;
|
||||
if (engine is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
engine.StatusChanged -= OnPlayoutStatusChanged;
|
||||
try
|
||||
{
|
||||
if (Volatile.Read(ref _playoutQuarantined) == 0 &&
|
||||
engine.Status.State != PlayoutConnectionState.OutcomeUnknown &&
|
||||
string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) &&
|
||||
!engine.Status.IsPlayCompletionPending)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
await engine.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ShutdownPlayoutRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
ShutdownPlayoutRuntimeAsync()
|
||||
.Wait(TimeSpan.FromSeconds(8));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Window shutdown is bounded and best-effort. The engine itself keeps an
|
||||
// outcome-unknown session quarantined and will not send a duplicate BYE.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public sealed partial class MainWindow : Window
|
||||
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
||||
private readonly SemaphoreSlim _intentGate = new(1, 1);
|
||||
private readonly LegacyOperatorController _controller;
|
||||
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
|
||||
private DatabaseRuntime? _databaseRuntime;
|
||||
private bool _closing;
|
||||
private bool _intentBusy;
|
||||
@@ -22,29 +23,144 @@ public sealed partial class MainWindow : Window
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
EnsureCloseConfirmationAttached();
|
||||
|
||||
ILegacyStockLookup stockLookup;
|
||||
IIndustrySelectionService industrySelectionService;
|
||||
IThemeSelectionService themeSelectionService;
|
||||
IExpertSelectionService expertSelectionService;
|
||||
ITradingHaltSelectionService tradingHaltSelectionService;
|
||||
IOverseasIndustryIndexSearchService overseasIndustrySearchService;
|
||||
IWorldStockSearchService worldStockSearchService;
|
||||
IManualFinancialScreenService manualFinancialService;
|
||||
IStockSearchService manualFinancialStockSearchService;
|
||||
INamedPlaylistPersistenceService namedPlaylistPersistenceService;
|
||||
IThemeCatalogPersistenceService themeCatalogPersistenceService;
|
||||
IExpertCatalogPersistenceService expertCatalogPersistenceService;
|
||||
IStockMasterIdentityValidationService stockMasterIdentityValidationService;
|
||||
IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService;
|
||||
string? initializationError = null;
|
||||
try
|
||||
{
|
||||
_databaseRuntime = DatabaseRuntime.CreateDefault();
|
||||
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
||||
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
|
||||
industrySelectionService = new LegacyIndustrySelectionService(
|
||||
_databaseRuntime.Executor);
|
||||
themeSelectionService = new LegacyThemeSelectionService(
|
||||
_databaseRuntime.Executor);
|
||||
expertSelectionService = new LegacyExpertSelectionService(
|
||||
_databaseRuntime.Executor);
|
||||
tradingHaltSelectionService = new LegacyTradingHaltSelectionService(
|
||||
_databaseRuntime.Executor);
|
||||
overseasIndustrySearchService = new LegacyOverseasIndustryIndexSearchService(
|
||||
_databaseRuntime.Executor);
|
||||
worldStockSearchService = new LegacyWorldStockSearchService(
|
||||
_databaseRuntime.Executor);
|
||||
manualFinancialService = new LegacyManualFinancialScreenService(
|
||||
_databaseRuntime.Executor,
|
||||
new OracleManualFinancialMutationExecutor(
|
||||
_databaseRuntime.ConnectionFactory,
|
||||
_databaseRuntime.Options.Resilience));
|
||||
manualFinancialStockSearchService = new LegacyStockSearchService(
|
||||
_databaseRuntime.Executor);
|
||||
namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
|
||||
_databaseRuntime.Executor,
|
||||
new OracleNamedPlaylistMutationExecutor(
|
||||
_databaseRuntime.ConnectionFactory,
|
||||
_databaseRuntime.Options.Resilience));
|
||||
var operatorCatalogMutationExecutor = new OperatorCatalogMutationExecutor(
|
||||
_databaseRuntime.ConnectionFactory,
|
||||
_databaseRuntime.Options.Resilience);
|
||||
themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService(
|
||||
_databaseRuntime.Executor,
|
||||
operatorCatalogMutationExecutor);
|
||||
expertCatalogPersistenceService = new LegacyExpertCatalogPersistenceService(
|
||||
_databaseRuntime.Executor,
|
||||
operatorCatalogMutationExecutor);
|
||||
stockMasterIdentityValidationService =
|
||||
new LegacyStockMasterIdentityValidationService(_databaseRuntime.Executor);
|
||||
operatorCatalogSchemaValidationService =
|
||||
new LegacyOperatorCatalogSchemaValidationService(_databaseRuntime.Executor);
|
||||
}
|
||||
catch
|
||||
{
|
||||
initializationError =
|
||||
"데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
|
||||
stockLookup = new UnavailableStockLookup(initializationError);
|
||||
industrySelectionService = new UnavailableIndustrySelectionService(
|
||||
initializationError);
|
||||
themeSelectionService = new UnavailableThemeSelectionService(
|
||||
initializationError);
|
||||
expertSelectionService = new UnavailableExpertSelectionService(
|
||||
initializationError);
|
||||
tradingHaltSelectionService = new UnavailableTradingHaltSelectionService(
|
||||
initializationError);
|
||||
overseasIndustrySearchService = new UnavailableOverseasIndustryIndexSearchService(
|
||||
initializationError);
|
||||
worldStockSearchService = new UnavailableWorldStockSearchService(
|
||||
initializationError);
|
||||
manualFinancialService = new UnavailableManualFinancialScreenService(
|
||||
initializationError);
|
||||
manualFinancialStockSearchService = new UnavailableStockSearchService(
|
||||
initializationError);
|
||||
namedPlaylistPersistenceService = new UnavailableNamedPlaylistPersistenceService(
|
||||
initializationError);
|
||||
themeCatalogPersistenceService = new UnavailableThemeCatalogPersistenceService(
|
||||
initializationError);
|
||||
expertCatalogPersistenceService = new UnavailableExpertCatalogPersistenceService(
|
||||
initializationError);
|
||||
stockMasterIdentityValidationService =
|
||||
new UnavailableStockMasterIdentityValidationService(initializationError);
|
||||
operatorCatalogSchemaValidationService =
|
||||
new UnavailableOperatorCatalogSchemaValidationService(initializationError);
|
||||
DataQueryExecutor.Reset();
|
||||
}
|
||||
|
||||
_controller = new LegacyOperatorController(stockLookup);
|
||||
_industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService);
|
||||
var overseasWorkflow = new LegacyOverseasSelectionWorkflow(
|
||||
overseasIndustrySearchService,
|
||||
worldStockSearchService);
|
||||
var comparisonStore = new LegacyComparisonPairFileStore(
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"Data",
|
||||
"종목비교.dat"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Data", "종목비교.dat"));
|
||||
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
|
||||
_controller = new LegacyOperatorController(
|
||||
stockLookup,
|
||||
industryWorkflow: _industryWorkflow,
|
||||
themeWorkflow: new LegacyThemeWorkflow(themeSelectionService),
|
||||
expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService),
|
||||
tradingHaltWorkflow: new LegacyTradingHaltWorkflowController(
|
||||
tradingHaltSelectionService),
|
||||
comparisonWorkflow: new LegacyComparisonWorkflowController(
|
||||
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
|
||||
comparisonStore),
|
||||
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
|
||||
manualFinancialService,
|
||||
manualFinancialStockSearchService),
|
||||
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(
|
||||
namedPlaylistPersistenceService),
|
||||
overseasWorkflow: overseasWorkflow,
|
||||
manualListsWorkflow: manualListsWorkflow,
|
||||
operatorCatalogWorkflow: new LegacyOperatorCatalogWorkflowController(
|
||||
themeSelectionService,
|
||||
expertSelectionService,
|
||||
themeCatalogPersistenceService,
|
||||
expertCatalogPersistenceService,
|
||||
manualFinancialStockSearchService,
|
||||
stockMasterIdentityValidationService,
|
||||
operatorCatalogSchemaValidationService));
|
||||
if (initializationError is not null)
|
||||
{
|
||||
_controller.ReportError(initializationError);
|
||||
}
|
||||
|
||||
InitializePlayoutRuntime();
|
||||
|
||||
Root.Loaded += OnRootLoaded;
|
||||
Closed += OnClosed;
|
||||
}
|
||||
@@ -59,10 +175,15 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
ConfigureWindow();
|
||||
await InitializeWebViewAsync();
|
||||
if (!_closing)
|
||||
{
|
||||
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureWindow()
|
||||
{
|
||||
EnsureCloseConfirmationAttached();
|
||||
var windowHandle = WindowNative.GetWindowHandle(this);
|
||||
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
|
||||
var appWindow = AppWindow.GetFromWindowId(windowId);
|
||||
@@ -208,15 +329,94 @@ public sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent is LegacySearchStocksIntent searchIntent &&
|
||||
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
|
||||
searchIntent.Text.Length == 0))
|
||||
if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined())
|
||||
{
|
||||
PostState(_controller.ReportError(
|
||||
"이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_controller.Current.FixedSectionBatch is not null &&
|
||||
IsOperatorMutationIntent(intent) &&
|
||||
!IsFixedSectionBatchMutationIntent(intent))
|
||||
{
|
||||
PostState(_controller.ReportError(
|
||||
"고정 컷 섹션의 수동 입력 중에는 현재 입력 단계만 변경할 수 있습니다. 완료하거나 취소한 뒤 다른 편성 작업을 실행하세요."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation())
|
||||
{
|
||||
PostState(_controller.ReportError(
|
||||
"PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요."));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((intent is LegacySearchStocksIntent searchIntent &&
|
||||
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
|
||||
searchIntent.Text.Length == 0)) ||
|
||||
intent is LegacySelectTabIntent or
|
||||
LegacySearchThemesIntent or LegacySelectThemeIntent or
|
||||
LegacyActivateThemeResultIntent or
|
||||
LegacySearchExpertsIntent or LegacySelectExpertIntent or
|
||||
LegacySearchTradingHaltsIntent or
|
||||
LegacySearchComparisonDomesticIntent or
|
||||
LegacySearchComparisonWorldIntent or
|
||||
LegacySearchOverseasIndustriesIntent or
|
||||
LegacySearchOverseasStocksIntent or
|
||||
LegacyOpenOperatorCatalogIntent or
|
||||
LegacyBeginUc4ThemeCatalogIntent or
|
||||
LegacyEditUc4SelectedThemeIntent or
|
||||
LegacyDeleteUc4SelectedThemeIntent or
|
||||
LegacyBeginUc6ExpertCatalogIntent or
|
||||
LegacyEditUc6SelectedExpertIntent or
|
||||
LegacyDeleteUc6SelectedExpertIntent or
|
||||
LegacySearchOperatorCatalogIntent or
|
||||
LegacySelectOperatorCatalogResultIntent or
|
||||
LegacyBeginCreateThemeCatalogIntent or
|
||||
LegacyBeginCreateExpertCatalogIntent or
|
||||
LegacySearchOperatorCatalogStocksIntent or
|
||||
LegacyActivateOperatorCatalogStockIntent or
|
||||
LegacySetOperatorCatalogDraftBuyAmountIntent or
|
||||
LegacySaveOperatorCatalogIntent or
|
||||
LegacyDeleteOperatorCatalogIntent or
|
||||
LegacyAddComparisonPairIntent or
|
||||
LegacyMoveComparisonPairIntent or
|
||||
LegacyDeleteSelectedComparisonPairIntent or
|
||||
LegacyDeleteAllComparisonPairsIntent or
|
||||
LegacyOpenManualFinancialIntent or
|
||||
LegacySearchManualFinancialIntent or
|
||||
LegacyFindManualFinancialIntent or
|
||||
LegacyLoadManualFinancialIntent or
|
||||
LegacyActivateManualFinancialResultIntent or
|
||||
LegacySearchManualFinancialStocksIntent or
|
||||
LegacySaveManualFinancialIntent or
|
||||
LegacyDeleteManualFinancialIntent or
|
||||
LegacyDeleteAllManualFinancialIntent or
|
||||
LegacyOpenManualListIntent or
|
||||
LegacyRefreshManualListIntent or
|
||||
LegacySaveManualNetSellIntent or
|
||||
LegacySearchManualViIntent or
|
||||
LegacySaveManualViIntent or
|
||||
LegacyImportManualListsIntent or
|
||||
LegacyRefreshNamedPlaylistsIntent or
|
||||
LegacyLoadSelectedNamedPlaylistIntent or
|
||||
LegacyLoadNamedPlaylistByIdIntent or
|
||||
LegacyCreateNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistToIntent or
|
||||
LegacyDeleteSelectedNamedPlaylistIntent or
|
||||
LegacyExecutePlayoutIntent or
|
||||
LegacySetFadeDurationIntent or
|
||||
LegacyToggleBackgroundIntent or
|
||||
LegacyChooseBackgroundIntent)
|
||||
{
|
||||
_intentBusy = true;
|
||||
PostState(_controller.Current);
|
||||
}
|
||||
|
||||
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
|
||||
ObserveOperatorMutationQuarantine(state);
|
||||
if (!_closing)
|
||||
{
|
||||
_intentBusy = false;
|
||||
@@ -272,6 +472,318 @@ public sealed partial class MainWindow : Window
|
||||
LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(),
|
||||
LegacySetPlaylistEnabledIntent enabled =>
|
||||
_controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled),
|
||||
LegacySetAllPlaylistEnabledIntent enabled =>
|
||||
_controller.SetAllPlaylistRowsEnabled(enabled.Enabled),
|
||||
LegacySelectPlaylistRowIntent selection =>
|
||||
_controller.SelectPlaylistRow(
|
||||
selection.RowId,
|
||||
selection.Control,
|
||||
selection.Shift),
|
||||
LegacyMoveSelectedPlaylistRowsIntent move =>
|
||||
_controller.MoveSelectedPlaylistRows(move.Direction),
|
||||
LegacyDeleteSelectedPlaylistRowsIntent =>
|
||||
_controller.DeleteSelectedPlaylistRows(),
|
||||
LegacyDeleteAllPlaylistRowsIntent =>
|
||||
_controller.DeleteAllPlaylistRows(),
|
||||
LegacyToggleActivePlaylistEnabledIntent =>
|
||||
_controller.ToggleActivePlaylistRowEnabled(),
|
||||
LegacySelectPlaylistBoundaryIntent boundary =>
|
||||
_controller.SelectPlaylistBoundary(boundary.Last),
|
||||
LegacySelectTabIntent tab =>
|
||||
await _controller.SelectTabAsync(tab.TabId, cancellationToken),
|
||||
LegacySwapTabsIntent tabs =>
|
||||
await _controller.SwapTabsAsync(
|
||||
tabs.Source,
|
||||
tabs.Target,
|
||||
cancellationToken),
|
||||
LegacySetMovingAveragesIntent movingAverages =>
|
||||
_controller.SetMovingAverages(movingAverages.Ma5, movingAverages.Ma20),
|
||||
LegacyActivateFixedActionIntent action =>
|
||||
await _controller.ActivateFixedActionAsync(
|
||||
action.ActionId,
|
||||
cancellationToken),
|
||||
LegacyActivateFixedSectionIntent section =>
|
||||
await _controller.ActivateFixedSection(
|
||||
section.SectionIndex,
|
||||
cancellationToken),
|
||||
LegacySelectIndustryIntent industry =>
|
||||
_controller.SelectIndustry(industry.Index, industry.DoubleClick),
|
||||
LegacySwapIndustriesIntent =>
|
||||
_controller.SwapIndustries(),
|
||||
LegacyActivateIndustryActionIntent action =>
|
||||
_controller.ActivateIndustryAction(action.ActionId),
|
||||
LegacyActivateIndustrySectionIntent section =>
|
||||
_controller.ActivateIndustrySection(section.SectionIndex),
|
||||
LegacySelectOverseasFixedIndexIntent selection =>
|
||||
_controller.SelectOverseasFixedIndex(selection.TargetId),
|
||||
LegacySearchOverseasIndustriesIntent search =>
|
||||
await _controller.SearchOverseasIndustriesAsync(
|
||||
search.Query,
|
||||
cancellationToken),
|
||||
LegacySearchOverseasStocksIntent search =>
|
||||
await _controller.SearchOverseasStocksAsync(
|
||||
search.Query,
|
||||
cancellationToken),
|
||||
LegacySelectOverseasIndustryIntent selection =>
|
||||
_controller.SelectOverseasIndustry(selection.SelectionId),
|
||||
LegacySelectOverseasStockIntent selection =>
|
||||
_controller.SelectOverseasStock(selection.SelectionId),
|
||||
LegacyActivateOverseasActionIntent action =>
|
||||
_controller.ActivateOverseasAction(action.ActionId),
|
||||
LegacySearchComparisonDomesticIntent search =>
|
||||
await _controller.SearchComparisonDomesticAsync(
|
||||
search.Query,
|
||||
cancellationToken),
|
||||
LegacySearchComparisonWorldIntent search =>
|
||||
await _controller.SearchComparisonWorldAsync(
|
||||
search.Query,
|
||||
cancellationToken),
|
||||
LegacySelectComparisonDomesticIntent selection =>
|
||||
_controller.SelectComparisonDomesticResult(selection.SelectionId),
|
||||
LegacySelectComparisonWorldIntent selection =>
|
||||
_controller.SelectComparisonWorldResult(selection.SelectionId),
|
||||
LegacySelectComparisonFixedIntent selection =>
|
||||
_controller.SelectComparisonFixedTarget(selection.TargetId),
|
||||
LegacyChooseComparisonDomesticIntent selection =>
|
||||
_controller.ChooseComparisonDomesticResult(selection.SelectionId),
|
||||
LegacyChooseComparisonWorldIntent selection =>
|
||||
_controller.ChooseComparisonWorldResult(selection.SelectionId),
|
||||
LegacyChooseComparisonFixedIntent selection =>
|
||||
_controller.ChooseComparisonFixedTarget(selection.TargetId),
|
||||
LegacySwapComparisonTargetsIntent =>
|
||||
_controller.SwapComparisonTargets(),
|
||||
LegacyClearComparisonTargetsIntent =>
|
||||
_controller.ClearComparisonTargets(),
|
||||
LegacyAddComparisonPairIntent =>
|
||||
await _controller.AddComparisonPairAsync(cancellationToken),
|
||||
LegacySelectComparisonPairIntent selection =>
|
||||
_controller.SelectComparisonPair(selection.RowId),
|
||||
LegacyMoveComparisonPairIntent move =>
|
||||
await _controller.MoveComparisonPairAsync(
|
||||
move.Direction,
|
||||
cancellationToken),
|
||||
LegacyDeleteSelectedComparisonPairIntent =>
|
||||
await _controller.DeleteComparisonPairAsync(
|
||||
all: false,
|
||||
cancellationToken),
|
||||
LegacyDeleteAllComparisonPairsIntent =>
|
||||
await _controller.DeleteComparisonPairAsync(
|
||||
all: true,
|
||||
cancellationToken),
|
||||
LegacyActivateComparisonActionIntent action =>
|
||||
_controller.ActivateComparisonAction(action.ActionId),
|
||||
LegacyActivateComparisonSectionIntent section =>
|
||||
_controller.ActivateComparisonSection(section.SectionIndex),
|
||||
LegacySearchThemesIntent search =>
|
||||
await _controller.SearchThemesAsync(
|
||||
search.Query,
|
||||
search.NxtSession,
|
||||
cancellationToken),
|
||||
LegacySelectThemeIntent selection =>
|
||||
await _controller.SelectThemeAsync(
|
||||
selection.ResultId,
|
||||
cancellationToken),
|
||||
LegacyActivateThemeResultIntent activation =>
|
||||
await _controller.ActivateThemeResultAsync(
|
||||
activation.ResultId,
|
||||
cancellationToken),
|
||||
LegacySetThemeSortIntent sort =>
|
||||
_controller.SetThemeSort(sort.SortId),
|
||||
LegacyActivateThemeActionIntent action =>
|
||||
_controller.ActivateThemeAction(action.ActionId),
|
||||
LegacySearchExpertsIntent search =>
|
||||
await _controller.SearchExpertsAsync(search.Query, cancellationToken),
|
||||
LegacySelectExpertIntent selection =>
|
||||
await _controller.SelectExpertAsync(
|
||||
selection.SelectionId,
|
||||
cancellationToken),
|
||||
LegacyActivateExpertActionIntent action =>
|
||||
_controller.ActivateExpertAction(action.ActionId),
|
||||
LegacySearchTradingHaltsIntent search =>
|
||||
await _controller.SearchTradingHaltsAsync(
|
||||
search.Query,
|
||||
cancellationToken),
|
||||
LegacySelectTradingHaltIntent selection =>
|
||||
_controller.SelectTradingHalt(selection.SelectionId),
|
||||
LegacyToggleTradingHaltSortIntent =>
|
||||
_controller.ToggleTradingHaltSort(),
|
||||
LegacyActivateTradingHaltActionIntent action =>
|
||||
_controller.ActivateTradingHaltAction(action.ActionId),
|
||||
LegacyOpenOperatorCatalogIntent catalog =>
|
||||
await _controller.OpenOperatorCatalogAsync(
|
||||
catalog.Entity,
|
||||
cancellationToken),
|
||||
LegacyBeginUc4ThemeCatalogIntent =>
|
||||
await _controller.BeginThemeCatalogFromUc4Async(cancellationToken),
|
||||
LegacyEditUc4SelectedThemeIntent =>
|
||||
await _controller.EditSelectedThemeCatalogAsync(cancellationToken),
|
||||
LegacyDeleteUc4SelectedThemeIntent =>
|
||||
await _controller.DeleteSelectedThemeCatalogAsync(cancellationToken),
|
||||
LegacyBeginUc6ExpertCatalogIntent =>
|
||||
await _controller.BeginExpertCatalogFromUc6Async(cancellationToken),
|
||||
LegacyEditUc6SelectedExpertIntent =>
|
||||
await _controller.EditSelectedExpertCatalogAsync(cancellationToken),
|
||||
LegacyDeleteUc6SelectedExpertIntent =>
|
||||
await _controller.DeleteSelectedExpertCatalogAsync(cancellationToken),
|
||||
LegacyCloseOperatorCatalogIntent =>
|
||||
_controller.CloseOperatorCatalog(),
|
||||
LegacySearchOperatorCatalogIntent search =>
|
||||
await _controller.SearchOperatorCatalogAsync(
|
||||
search.Query,
|
||||
search.NxtSession,
|
||||
cancellationToken),
|
||||
LegacySelectOperatorCatalogResultIntent selection =>
|
||||
await _controller.SelectOperatorCatalogResultAsync(
|
||||
selection.ResultId,
|
||||
cancellationToken),
|
||||
LegacyBeginCreateThemeCatalogIntent create =>
|
||||
await _controller.BeginCreateThemeCatalogAsync(
|
||||
create.Market,
|
||||
cancellationToken),
|
||||
LegacyBeginCreateExpertCatalogIntent =>
|
||||
await _controller.BeginCreateExpertCatalogAsync(cancellationToken),
|
||||
LegacySearchOperatorCatalogStocksIntent search =>
|
||||
await _controller.SearchOperatorCatalogStocksAsync(
|
||||
search.Query,
|
||||
cancellationToken),
|
||||
LegacySelectOperatorCatalogStockIntent selection =>
|
||||
_controller.SelectOperatorCatalogStock(selection.ResultId),
|
||||
LegacyAddOperatorCatalogStockIntent add =>
|
||||
_controller.AddOperatorCatalogStock(add.BuyAmount),
|
||||
LegacyActivateOperatorCatalogStockIntent activation =>
|
||||
_controller.ActivateOperatorCatalogStock(
|
||||
activation.ResultId,
|
||||
activation.BuyAmount),
|
||||
LegacyMoveOperatorCatalogDraftRowIntent move =>
|
||||
_controller.MoveOperatorCatalogDraftRow(move.RowId, move.Direction),
|
||||
LegacyRemoveOperatorCatalogDraftRowIntent remove =>
|
||||
_controller.RemoveOperatorCatalogDraftRow(remove.RowId),
|
||||
LegacySetOperatorCatalogDraftBuyAmountIntent amount =>
|
||||
_controller.SetOperatorCatalogDraftBuyAmount(
|
||||
amount.RowId,
|
||||
amount.BuyAmount),
|
||||
LegacySaveOperatorCatalogIntent save =>
|
||||
await _controller.SaveOperatorCatalogAsync(
|
||||
save.Name,
|
||||
cancellationToken),
|
||||
LegacyDeleteOperatorCatalogIntent =>
|
||||
await _controller.DeleteOperatorCatalogAsync(cancellationToken),
|
||||
LegacyRefreshNamedPlaylistsIntent =>
|
||||
await _controller.RefreshNamedPlaylistsAsync(cancellationToken),
|
||||
LegacySelectNamedPlaylistIntent selection =>
|
||||
_controller.SelectNamedPlaylist(selection.DefinitionId),
|
||||
LegacyLoadSelectedNamedPlaylistIntent =>
|
||||
await _controller.LoadSelectedNamedPlaylistAsync(cancellationToken),
|
||||
LegacyLoadNamedPlaylistByIdIntent load =>
|
||||
await _controller.LoadNamedPlaylistByIdAsync(
|
||||
load.DefinitionId,
|
||||
cancellationToken),
|
||||
LegacyCreateNamedPlaylistIntent create =>
|
||||
await _controller.CreateNamedPlaylistAsync(
|
||||
create.Title,
|
||||
cancellationToken),
|
||||
LegacySaveCurrentNamedPlaylistIntent =>
|
||||
await _controller.SaveCurrentNamedPlaylistAsync(cancellationToken),
|
||||
LegacySaveCurrentNamedPlaylistToIntent save =>
|
||||
await _controller.SaveCurrentNamedPlaylistToAsync(
|
||||
save.DefinitionId,
|
||||
cancellationToken),
|
||||
LegacyDeleteSelectedNamedPlaylistIntent =>
|
||||
await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken),
|
||||
LegacyOpenManualFinancialIntent manual =>
|
||||
await _controller.OpenManualFinancialAsync(
|
||||
manual.Screen,
|
||||
cancellationToken),
|
||||
LegacyCloseManualFinancialIntent =>
|
||||
_controller.CloseManualFinancial(),
|
||||
LegacySearchManualFinancialIntent search =>
|
||||
await _controller.SearchManualFinancialAsync(
|
||||
search.Query,
|
||||
cancellationToken),
|
||||
LegacyFindManualFinancialIntent find =>
|
||||
await _controller.FindManualFinancialAsync(
|
||||
find.Query,
|
||||
find.Direction,
|
||||
find.Generation,
|
||||
cancellationToken),
|
||||
LegacyToggleManualFinancialNameSortIntent sort =>
|
||||
_controller.ToggleManualFinancialNameSort(sort.Generation),
|
||||
LegacyLoadManualFinancialIntent load =>
|
||||
await _controller.LoadManualFinancialAsync(
|
||||
load.ResultId,
|
||||
cancellationToken),
|
||||
LegacyActivateManualFinancialResultIntent activation =>
|
||||
await _controller.ActivateManualFinancialResultAsync(
|
||||
activation.ResultId,
|
||||
cancellationToken),
|
||||
LegacyBeginManualFinancialCreateIntent =>
|
||||
_controller.BeginManualFinancialCreate(),
|
||||
LegacySearchManualFinancialStocksIntent search =>
|
||||
await _controller.SearchManualFinancialStocksAsync(
|
||||
search.StockName,
|
||||
cancellationToken),
|
||||
LegacySelectManualFinancialStockIntent selection =>
|
||||
_controller.SelectManualFinancialStock(selection.ResultId),
|
||||
LegacySaveManualFinancialIntent save =>
|
||||
await _controller.SaveManualFinancialAsync(
|
||||
save.Record,
|
||||
cancellationToken),
|
||||
LegacyDeleteManualFinancialIntent =>
|
||||
await _controller.DeleteManualFinancialAsync(
|
||||
all: false,
|
||||
cancellationToken),
|
||||
LegacyDeleteAllManualFinancialIntent =>
|
||||
await _controller.DeleteManualFinancialAsync(
|
||||
all: true,
|
||||
cancellationToken),
|
||||
LegacyActivateManualFinancialActionIntent action =>
|
||||
_controller.ActivateManualFinancialAction(action.ActionId),
|
||||
LegacyOpenManualListIntent manual =>
|
||||
manual.Screen == LegacyManualListScreen.NetSell
|
||||
? await _controller.OpenManualNetSellAsync(
|
||||
manual.Audience,
|
||||
cancellationToken)
|
||||
: await _controller.OpenManualViAsync(cancellationToken),
|
||||
LegacyCloseManualListIntent =>
|
||||
_controller.CloseManualLists(),
|
||||
LegacyRefreshManualListIntent =>
|
||||
await _controller.RefreshManualListsAsync(cancellationToken),
|
||||
LegacySetManualNetSellCellIntent cell =>
|
||||
_controller.SetManualNetSellCell(cell.RowId, cell.Field, cell.Value),
|
||||
LegacySaveManualNetSellIntent =>
|
||||
await _controller.SaveManualNetSellAsync(cancellationToken),
|
||||
LegacyAddManualNetSellCutIntent =>
|
||||
_controller.AddManualNetSellPlaylistRow(),
|
||||
LegacySearchManualViIntent search =>
|
||||
await _controller.SearchManualViAsync(search.Query, cancellationToken),
|
||||
LegacySelectManualViResultIntent result =>
|
||||
_controller.SelectManualViSearchResult(result.ResultId),
|
||||
LegacyAddManualViResultIntent result =>
|
||||
_controller.AddManualViSearchResult(result.ResultId),
|
||||
LegacyMoveManualViItemIntent move =>
|
||||
_controller.MoveManualViItem(move.ItemId, move.Direction),
|
||||
LegacyDeleteManualViItemIntent item =>
|
||||
_controller.DeleteManualViItem(item.ItemId),
|
||||
LegacyDeleteAllManualViItemsIntent =>
|
||||
_controller.DeleteAllManualViItems(),
|
||||
LegacySaveManualViIntent =>
|
||||
await _controller.SaveManualViAsync(cancellationToken),
|
||||
LegacyAddManualViCutIntent =>
|
||||
_controller.AddManualViPlaylistRow(),
|
||||
LegacyConfirmManualListIntent =>
|
||||
await _controller.ConfirmManualListsAsync(cancellationToken),
|
||||
LegacyImportManualListsIntent =>
|
||||
await _controller.ImportLegacyManualListsAsync(cancellationToken),
|
||||
LegacyExecutePlayoutIntent playout =>
|
||||
await ExecuteOperatorPlayoutAsync(
|
||||
playout.Command,
|
||||
cancellationToken),
|
||||
LegacySetFadeDurationIntent fade =>
|
||||
await SetFadeDurationAsync(fade.Duration, cancellationToken),
|
||||
LegacyToggleBackgroundIntent =>
|
||||
await ToggleBackgroundAsync(cancellationToken),
|
||||
LegacyChooseBackgroundIntent =>
|
||||
await ChooseBackgroundAsync(cancellationToken),
|
||||
LegacyDismissDialogIntent => _controller.DismissDialog(),
|
||||
_ => _controller.ReportError("지원하지 않는 화면 요청입니다.")
|
||||
};
|
||||
@@ -282,12 +794,17 @@ public sealed partial class MainWindow : Window
|
||||
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
|
||||
{
|
||||
Browser.CoreWebView2.PostWebMessageAsJson(
|
||||
LegacyBridgeProtocol.SerializeState(snapshot, _intentBusy));
|
||||
LegacyBridgeProtocol.SerializeState(
|
||||
snapshot,
|
||||
_intentBusy,
|
||||
CreatePlayoutSnapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
|
||||
{
|
||||
ObserveOperatorMutationQuarantine(
|
||||
_controller.InvalidateOperatorCatalogBrowserCorrelation());
|
||||
if (!_closing)
|
||||
{
|
||||
ShowError("WebView2 프로세스가 중단되었습니다.", args.ProcessFailedKind.ToString());
|
||||
@@ -314,9 +831,14 @@ public sealed partial class MainWindow : Window
|
||||
}
|
||||
|
||||
_closing = true;
|
||||
ObserveOperatorMutationQuarantine(
|
||||
_controller.InvalidateOperatorCatalogBrowserCorrelation());
|
||||
Root.Loaded -= OnRootLoaded;
|
||||
Closed -= OnClosed;
|
||||
DetachCloseConfirmation();
|
||||
_lifetimeCancellation.Cancel();
|
||||
ShutdownPlayoutRuntime();
|
||||
ShutdownManualListsRuntime();
|
||||
DataQueryExecutor.Reset();
|
||||
|
||||
if (Browser.CoreWebView2 is not null)
|
||||
@@ -335,6 +857,166 @@ public sealed partial class MainWindow : Window
|
||||
// cancellation and release the gate without racing disposed objects.
|
||||
}
|
||||
|
||||
private sealed class UnavailableThemeCatalogPersistenceService
|
||||
: IThemeCatalogPersistenceService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableThemeCatalogPersistenceService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public bool CanMutate => false;
|
||||
|
||||
public Task<string> SuggestNextCodeAsync(
|
||||
ThemeMarket market,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<string>(new InvalidOperationException(_message));
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> CreateAsync(
|
||||
ThemeCatalogDefinition definition,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<OperatorCatalogMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
|
||||
ThemeSelectionIdentity expectedIdentity,
|
||||
string newTitle,
|
||||
IReadOnlyList<ThemeCatalogItem> items,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<OperatorCatalogMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
|
||||
ThemeSelectionIdentity expectedIdentity,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<OperatorCatalogMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableExpertCatalogPersistenceService
|
||||
: IExpertCatalogPersistenceService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableExpertCatalogPersistenceService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public bool CanMutate => false;
|
||||
|
||||
public Task<string> SuggestNextCodeAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<string>(new InvalidOperationException(_message));
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> CreateAsync(
|
||||
ExpertCatalogDefinition definition,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<OperatorCatalogMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
|
||||
ExpertSelectionIdentity expectedIdentity,
|
||||
string newName,
|
||||
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<OperatorCatalogMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<OperatorCatalogMutationReceipt> DeleteAsync(
|
||||
ExpertSelectionIdentity expectedIdentity,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<OperatorCatalogMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableStockMasterIdentityValidationService
|
||||
: IStockMasterIdentityValidationService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableStockMasterIdentityValidationService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StockMasterIdentity>> ValidateThemeItemsAsync(
|
||||
IReadOnlyList<ThemeCatalogItem> items,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<IReadOnlyList<StockMasterIdentity>>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<IReadOnlyList<StockMasterIdentity>> ValidateExpertRecommendationsAsync(
|
||||
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<IReadOnlyList<StockMasterIdentity>>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableOperatorCatalogSchemaValidationService
|
||||
: IOperatorCatalogSchemaValidationService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableOperatorCatalogSchemaValidationService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<OperatorCatalogSchemaValidationResult> ValidateAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<OperatorCatalogSchemaValidationResult>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableNamedPlaylistPersistenceService
|
||||
: INamedPlaylistPersistenceService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableNamedPlaylistPersistenceService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public bool CanMutate => false;
|
||||
|
||||
public Task<NamedPlaylistListResult> ListAsync(
|
||||
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<NamedPlaylistListResult>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<string> SuggestNextProgramCodeAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<string>(new InvalidOperationException(_message));
|
||||
|
||||
public Task<NamedPlaylistDocument> LoadAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<NamedPlaylistDocument>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task CreateDefinitionAsync(
|
||||
string programCode,
|
||||
string title,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException(new InvalidOperationException(_message));
|
||||
|
||||
public Task ReplaceItemsAsync(
|
||||
string programCode,
|
||||
IReadOnlyList<NamedPlaylistStoredItem> items,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException(new InvalidOperationException(_message));
|
||||
|
||||
public Task DeleteAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException(new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableStockLookup : ILegacyStockLookup
|
||||
{
|
||||
private readonly string _message;
|
||||
@@ -350,4 +1032,185 @@ public sealed partial class MainWindow : Window
|
||||
Task.FromException<LegacyStockSearchData>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableIndustrySelectionService : IIndustrySelectionService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableIndustrySelectionService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IndustrySelection>> GetAsync(
|
||||
IndustryMarket market,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<IReadOnlyList<IndustrySelection>>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableThemeSelectionService : IThemeSelectionService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableThemeSelectionService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ThemeSearchResult>(new InvalidOperationException(_message));
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ThemePreviewResult>(new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableExpertSelectionService : IExpertSelectionService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableExpertSelectionService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<ExpertSearchResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ExpertSearchResult>(new InvalidOperationException(_message));
|
||||
|
||||
public Task<ExpertPreviewResult> PreviewAsync(
|
||||
ExpertSelectionIdentity identity,
|
||||
int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ExpertPreviewResult>(new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableTradingHaltSelectionService : ITradingHaltSelectionService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableTradingHaltSelectionService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<TradingHaltSelectionResult> SearchAsync(
|
||||
string query = "",
|
||||
int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<TradingHaltSelectionResult>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableManualFinancialScreenService
|
||||
: IManualFinancialScreenService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableManualFinancialScreenService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public bool CanMutate => false;
|
||||
|
||||
public Task<ManualFinancialSearchResult> SearchAsync(
|
||||
ManualFinancialScreenKind screen,
|
||||
string query = "",
|
||||
int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ManualFinancialSearchResult>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<ManualFinancialSnapshot> GetAsync(
|
||||
ManualFinancialIdentity identity,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ManualFinancialSnapshot>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> CreateAsync(
|
||||
ManualFinancialRecord record,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ManualFinancialMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> UpdateAsync(
|
||||
ManualFinancialSnapshot expected,
|
||||
ManualFinancialRecord replacement,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ManualFinancialMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> DeleteAsync(
|
||||
ManualFinancialSnapshot expected,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ManualFinancialMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<ManualFinancialMutationReceipt> DeleteAllAsync(
|
||||
ManualFinancialScreenKind screen,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ManualFinancialMutationReceipt>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableStockSearchService : IStockSearchService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableStockSearchService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<StockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableWorldStockSearchService : IWorldStockSearchService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableWorldStockSearchService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<WorldStockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<WorldStockSearchResult>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableOverseasIndustryIndexSearchService
|
||||
: IOverseasIndustryIndexSearchService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableOverseasIndustryIndexSearchService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<OverseasIndustryIndexSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<OverseasIndustryIndexSearchResult>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,8 @@
|
||||
<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>
|
||||
<div class="connection-state" id="playout-connection-state"
|
||||
aria-label="Tornado2 연결 상태">미연결</div>
|
||||
</header>
|
||||
|
||||
<div class="search-line">
|
||||
@@ -23,10 +24,16 @@
|
||||
<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>
|
||||
<button type="button" data-manual-screen="revenue-composition">주요매출 구성</button>
|
||||
<button type="button" data-manual-screen="growth-metrics">성장성 지표</button>
|
||||
<button type="button" data-manual-screen="sales">매출액</button>
|
||||
<button type="button" data-manual-screen="operating-profit">영업이익</button>
|
||||
</div>
|
||||
<div class="manual-list-actions" aria-label="수동 송출 목록" hidden>
|
||||
<button type="button" data-manual-list-screen="individual">개인 순매도</button>
|
||||
<button type="button" data-manual-list-screen="foreign">외국인 순매도</button>
|
||||
<button type="button" data-manual-list-screen="institution">기관 순매도</button>
|
||||
<button type="button" data-manual-list-screen="vi">VI 발동</button>
|
||||
</div>
|
||||
|
||||
<div id="cut-list" class="cut-list" role="listbox" aria-label="종목 컷" aria-multiselectable="true"></div>
|
||||
@@ -34,74 +41,195 @@
|
||||
</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 id="market-tabs" class="market-tabs" aria-label="시장 분류">
|
||||
<button class="active" type="button" draggable="true" data-tab-id="overseas">해외</button>
|
||||
<button type="button" draggable="true" data-tab-id="exchange">환율</button>
|
||||
<button type="button" draggable="true" data-tab-id="index">지수</button>
|
||||
<button type="button" draggable="true" data-tab-id="kospiIndustry">코스피</button>
|
||||
<button type="button" draggable="true" data-tab-id="kosdaqIndustry">코스닥</button>
|
||||
<button type="button" draggable="true" data-tab-id="comparison">비교</button>
|
||||
<button type="button" draggable="true" data-tab-id="theme">테마</button>
|
||||
<button type="button" draggable="true" data-tab-id="overseasStocks">해외종목</button>
|
||||
<button type="button" draggable="true" data-tab-id="expert">전문가</button>
|
||||
<button type="button" draggable="true" data-tab-id="tradingHalt">정지</button>
|
||||
</nav>
|
||||
<label class="expand-line"><input type="checkbox" checked disabled> Expand</label>
|
||||
<label class="expand-line"><input id="catalog-expand-all" type="checkbox" checked> 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&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>
|
||||
<div id="category-tree" class="category-tree" aria-label="원본 카테고리"></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>
|
||||
<label><input id="playlist-enable-all" type="checkbox" checked disabled> 전체</label>
|
||||
<label><input id="moving-average-5" type="checkbox" checked> 5일선</label>
|
||||
<label><input id="moving-average-20" type="checkbox" checked> 20일선</label>
|
||||
<button id="playlist-move-up" type="button" disabled aria-label="선택 행 위로 이동">↑</button>
|
||||
<button id="playlist-move-down" type="button" disabled aria-label="선택 행 아래로 이동">↓</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>
|
||||
<label><input id="playout-background-none" type="checkbox" disabled> 배경없음(F3)</label>
|
||||
<button id="playout-background-file" type="button" disabled>배경파일(F2)</button>
|
||||
<input id="playout-background-name" class="background-name" value="배경없음" readonly>
|
||||
<label class="fade-control" for="playout-fade-duration" hidden>효과</label>
|
||||
<select id="playout-fade-duration" disabled aria-label="장면 효과 시간" hidden>
|
||||
<option value="0">1</option><option value="1">2</option>
|
||||
<option value="2">3</option><option value="3">4</option>
|
||||
<option value="4">5</option><option value="5">6</option>
|
||||
<option value="6">7</option><option value="7">8</option>
|
||||
<option value="8">9</option><option value="9">10</option>
|
||||
<option value="10">11</option><option value="11">12</option>
|
||||
<option value="12">13</option><option value="13">14</option>
|
||||
<option value="14">15</option><option value="15">16</option>
|
||||
<option value="16">17</option><option value="17">18</option>
|
||||
<option value="18">19</option><option value="19">20</option>
|
||||
</select>
|
||||
<button id="db-save-button" type="button" disabled>DB 저장</button>
|
||||
<button id="db-load-button" type="button" disabled>DB 불러오기</button>
|
||||
<button id="playlist-delete-selected" type="button" disabled>선택삭제(DEL)</button>
|
||||
<button id="playlist-delete-all" 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 id="playlist-rows" class="playlist-rows" role="listbox" aria-label="송출 목록" aria-multiselectable="true"></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>
|
||||
<button id="playout-prepare" class="prepare" type="button" disabled hidden
|
||||
aria-hidden="true">PREPARE</button>
|
||||
<button id="playout-take-in" class="take-in" type="button" disabled>● TAKE IN [F8]</button>
|
||||
<button id="playout-next" class="next" type="button" disabled>▶ NEXT</button>
|
||||
<button id="playout-take-out" class="take-out" type="button" disabled>✖ TAKE OUT[Esc]</button>
|
||||
</div>
|
||||
<div class="playout-status">원본 호환 UI 기준선 · 송출 연결 안 함</div>
|
||||
<div id="playout-status" class="playout-status" aria-live="polite">송출 상태를 확인하고 있습니다.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="named-playlist-modal" class="named-playlist-backdrop" hidden>
|
||||
<section class="named-playlist-dialog" role="dialog" aria-modal="true"
|
||||
aria-labelledby="named-playlist-title">
|
||||
<header>
|
||||
<h2 id="named-playlist-title">DB 재생목록</h2>
|
||||
<button id="named-playlist-close" type="button" aria-label="닫기">×</button>
|
||||
</header>
|
||||
<div id="named-playlist-status" class="named-playlist-status" aria-live="polite"></div>
|
||||
<div class="named-playlist-body">
|
||||
<div id="named-playlist-definitions" class="named-playlist-definitions"
|
||||
role="listbox" aria-label="저장된 DB 재생목록"></div>
|
||||
<section class="named-playlist-create" aria-labelledby="named-playlist-create-title">
|
||||
<h3 id="named-playlist-create-title">새 재생목록</h3>
|
||||
<label for="named-playlist-new-title">이름</label>
|
||||
<input id="named-playlist-new-title" maxlength="128" autocomplete="off"
|
||||
spellcheck="false">
|
||||
<button id="named-playlist-create-button" type="button" disabled>새로 만들기</button>
|
||||
<p>DB 쓰기는 이 버튼 또는 저장·삭제 버튼을 누른 경우에만 한 번 실행됩니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<button id="named-playlist-delete-button" type="button" disabled>선택 삭제</button>
|
||||
<span class="named-playlist-footer-spacer"></span>
|
||||
<button id="named-playlist-cancel-button" type="button">취소</button>
|
||||
<button id="named-playlist-confirm-button" type="button" disabled>확인</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="manual-financial-modal" class="manual-financial-backdrop" hidden>
|
||||
<section class="manual-financial-dialog" role="dialog" aria-modal="true"
|
||||
aria-labelledby="manual-financial-title">
|
||||
<header>
|
||||
<h2 id="manual-financial-title">수동 재무 입력</h2>
|
||||
<button id="manual-financial-close" type="button" aria-label="닫기">×</button>
|
||||
</header>
|
||||
<div id="manual-financial-workspace" class="manual-financial-workspace"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="manual-list-modal" class="manual-list-backdrop" hidden>
|
||||
<section class="manual-list-dialog" role="dialog" aria-modal="true"
|
||||
aria-labelledby="manual-list-title">
|
||||
<header>
|
||||
<h2 id="manual-list-title">수동 송출 목록</h2>
|
||||
<button id="manual-list-close" type="button" aria-label="닫기">×</button>
|
||||
</header>
|
||||
<div id="manual-list-workspace" class="manual-list-workspace"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="operator-catalog-modal" class="operator-catalog-backdrop" hidden>
|
||||
<section class="operator-catalog-dialog" role="dialog" aria-modal="true"
|
||||
aria-labelledby="operator-catalog-title">
|
||||
<header>
|
||||
<h2 id="operator-catalog-title">ThemeA / EList 관리</h2>
|
||||
<button id="operator-catalog-close" type="button" aria-label="닫기">×</button>
|
||||
</header>
|
||||
<div id="operator-catalog-status" class="operator-catalog-status"
|
||||
aria-live="polite"></div>
|
||||
<div class="operator-catalog-toolbar">
|
||||
<button id="operator-catalog-theme-tab" type="button">ThemeA</button>
|
||||
<button id="operator-catalog-expert-tab" type="button">EList</button>
|
||||
<input id="operator-catalog-query" type="search" maxlength="64"
|
||||
autocomplete="off" placeholder="이름 검색">
|
||||
<select id="operator-catalog-session" aria-label="NXT 세션">
|
||||
<option value="preMarket">NXT 장전</option>
|
||||
<option value="afterMarket">NXT 시간외</option>
|
||||
</select>
|
||||
<button id="operator-catalog-search" type="button">검색</button>
|
||||
</div>
|
||||
<div class="operator-catalog-body">
|
||||
<section class="operator-catalog-list-panel" aria-labelledby="operator-catalog-list-title">
|
||||
<div class="operator-catalog-panel-header">
|
||||
<h3 id="operator-catalog-list-title">DB 목록</h3>
|
||||
<select id="operator-catalog-new-market" aria-label="새 ThemeA 시장">
|
||||
<option value="krx">KRX</option>
|
||||
<option value="nxt">NXT</option>
|
||||
</select>
|
||||
<button id="operator-catalog-new" type="button">새 항목</button>
|
||||
</div>
|
||||
<div id="operator-catalog-results" class="operator-catalog-results"
|
||||
role="listbox" aria-label="ThemeA EList 목록"></div>
|
||||
</section>
|
||||
<section id="operator-catalog-editor" class="operator-catalog-editor"
|
||||
aria-labelledby="operator-catalog-editor-title">
|
||||
<div class="operator-catalog-panel-header">
|
||||
<h3 id="operator-catalog-editor-title">추가·편집</h3>
|
||||
<span id="operator-catalog-code" class="operator-catalog-code"></span>
|
||||
</div>
|
||||
<label class="operator-catalog-name-field">
|
||||
<span id="operator-catalog-name-label">이름</span>
|
||||
<input id="operator-catalog-name" maxlength="128" autocomplete="off">
|
||||
</label>
|
||||
<div class="operator-catalog-stock-search">
|
||||
<input id="operator-catalog-stock-query" type="search" maxlength="64"
|
||||
autocomplete="off" placeholder="종목명 검색">
|
||||
<button id="operator-catalog-stock-search" type="button">종목 검색</button>
|
||||
</div>
|
||||
<div id="operator-catalog-stock-results" class="operator-catalog-stock-results"
|
||||
role="listbox" aria-label="stock master 검색 결과"></div>
|
||||
<div class="operator-catalog-add-line">
|
||||
<label id="operator-catalog-amount-label" for="operator-catalog-amount">매수가</label>
|
||||
<input id="operator-catalog-amount" type="number" min="1" step="1"
|
||||
inputmode="numeric">
|
||||
<button id="operator-catalog-add-stock" type="button">선택 종목 추가</button>
|
||||
</div>
|
||||
<div class="operator-catalog-draft-header">
|
||||
<strong>구성 종목</strong>
|
||||
<span>위·아래 버튼으로 송출 순서를 변경합니다.</span>
|
||||
</div>
|
||||
<div id="operator-catalog-draft-rows" class="operator-catalog-draft-rows"
|
||||
aria-label="ThemeA EList 구성 종목"></div>
|
||||
<footer class="operator-catalog-editor-actions">
|
||||
<button id="operator-catalog-delete" type="button">선택 삭제</button>
|
||||
<span></span>
|
||||
<button id="operator-catalog-cancel" type="button">편집 취소</button>
|
||||
<button id="operator-catalog-save" type="button">저장</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -110,5 +238,6 @@
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="playout-ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
172
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js
Normal file
172
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js
Normal file
@@ -0,0 +1,172 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const webview = window.chrome && window.chrome.webview;
|
||||
const connection = document.getElementById("playout-connection-state");
|
||||
const prepare = document.getElementById("playout-prepare");
|
||||
const takeIn = document.getElementById("playout-take-in");
|
||||
const next = document.getElementById("playout-next");
|
||||
const takeOut = document.getElementById("playout-take-out");
|
||||
const backgroundNone = document.getElementById("playout-background-none");
|
||||
const backgroundFile = document.getElementById("playout-background-file");
|
||||
const backgroundName = document.getElementById("playout-background-name");
|
||||
const fadeDuration = document.getElementById("playout-fade-duration");
|
||||
const status = document.getElementById("playout-status");
|
||||
const controls = [prepare, takeIn, next, takeOut, backgroundNone,
|
||||
backgroundFile, fadeDuration];
|
||||
let currentState = null;
|
||||
let localBusy = false;
|
||||
|
||||
function hasOpenDialog() {
|
||||
return Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']"))
|
||||
.some(function (dialog) {
|
||||
const container = dialog.closest("[hidden]");
|
||||
return !container && !dialog.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (!webview || localBusy || hasOpenDialog()) return;
|
||||
localBusy = true;
|
||||
render(currentState);
|
||||
webview.postMessage({ type: type, payload: payload || {} });
|
||||
}
|
||||
|
||||
function setAllDisabled(disabled) {
|
||||
controls.forEach(function (control) {
|
||||
if (control) control.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function phaseLabel(phase) {
|
||||
if (phase === "prepared") return "PREPARED";
|
||||
if (phase === "program") return "PROGRAM";
|
||||
return "IDLE";
|
||||
}
|
||||
|
||||
function connectionLabel(playout) {
|
||||
if (!playout) return "미연결";
|
||||
if (playout.outcomeUnknown) return "결과 불명확";
|
||||
if (playout.mode === "disabled") return "송출 비활성";
|
||||
if (playout.mode === "dryRun") return "DRY RUN";
|
||||
if (playout.isConnected) return "Tornado2 연결됨";
|
||||
if (playout.isProcessRunning) return "Tornado2 감지됨";
|
||||
return "미연결";
|
||||
}
|
||||
|
||||
function pageLabel(playout) {
|
||||
if (!playout || playout.pageCount <= 0) return "";
|
||||
return " · Page " + String(playout.pageIndexZeroBased + 1) +
|
||||
"/" + String(playout.pageCount);
|
||||
}
|
||||
|
||||
function nextLabel(playout) {
|
||||
if (!playout || playout.nextKind === "none") return "";
|
||||
return playout.nextKind === "pageNext" ? " · 다음 페이지" : " · 다음 항목";
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
currentState = state || currentState;
|
||||
const playout = currentState && currentState.playout;
|
||||
const nativeBusy = !!(currentState && currentState.isBusy) ||
|
||||
!!(playout && playout.isBusy);
|
||||
const busy = localBusy || nativeBusy;
|
||||
if (!playout) {
|
||||
connection.textContent = "미연결";
|
||||
status.textContent = "송출 엔진 상태를 사용할 수 없습니다.";
|
||||
setAllDisabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const commandAvailable = playout.isCommandAvailable === true &&
|
||||
playout.outcomeUnknown !== true;
|
||||
const hasPlaylist = Array.isArray(currentState.playlist) &&
|
||||
currentState.playlist.some(function (row) { return row.isEnabled === true; });
|
||||
const liveTakeInAllowed = playout.mode !== "live" ||
|
||||
playout.liveTakeInAllowed === true;
|
||||
const canPrepareToggle = playout.phase === "idle" ? hasPlaylist :
|
||||
playout.phase === "prepared" || playout.phase === "program";
|
||||
|
||||
connection.textContent = connectionLabel(playout);
|
||||
connection.classList.toggle("connected", playout.isConnected === true);
|
||||
connection.classList.toggle("outcome-unknown", playout.outcomeUnknown === true);
|
||||
prepare.classList.toggle("active", playout.phase !== "idle");
|
||||
prepare.disabled = busy || !commandAvailable || !canPrepareToggle ||
|
||||
playout.isPlayCompletionPending === true;
|
||||
const canTakeIn = playout.phase === "prepared" ||
|
||||
(playout.phase === "idle" && hasPlaylist);
|
||||
takeIn.disabled = busy || !commandAvailable || !liveTakeInAllowed ||
|
||||
!canTakeIn || playout.isPlayCompletionPending === true;
|
||||
next.disabled = busy || !commandAvailable ||
|
||||
playout.phase !== "program" || playout.nextKind === "none" ||
|
||||
playout.isPlayCompletionPending === true;
|
||||
takeOut.disabled = busy || !commandAvailable ||
|
||||
(playout.phase !== "prepared" && playout.phase !== "program");
|
||||
backgroundNone.disabled = busy || playout.canChangeBackground !== true;
|
||||
backgroundFile.disabled = busy || playout.canChangeBackground !== true;
|
||||
fadeDuration.disabled = busy || playout.canChangeBackground !== true;
|
||||
backgroundNone.checked = playout.backgroundEnabled !== true;
|
||||
backgroundName.value = playout.backgroundEnabled === true
|
||||
? (playout.backgroundFileName || "선택한 배경") : "배경없음";
|
||||
fadeDuration.value = String(playout.fadeDuration);
|
||||
|
||||
const scene = playout.onAirCode || playout.preparedCode;
|
||||
const sceneLabel = scene ? " · " + scene : "";
|
||||
const refresh = playout.refreshActive
|
||||
? " · 자동 갱신 " + String(playout.refreshCompletedCount) + "회" : "";
|
||||
status.textContent = phaseLabel(playout.phase) + sceneLabel +
|
||||
pageLabel(playout) + nextLabel(playout) + refresh +
|
||||
(playout.message ? " · " + playout.message : "");
|
||||
}
|
||||
|
||||
prepare.addEventListener("click", function () {
|
||||
post("prepare-playout", {});
|
||||
});
|
||||
takeIn.addEventListener("click", function () {
|
||||
post("take-in", {});
|
||||
});
|
||||
next.addEventListener("click", function () {
|
||||
post("next-playout", {});
|
||||
});
|
||||
takeOut.addEventListener("click", function () {
|
||||
post("take-out", {});
|
||||
});
|
||||
backgroundFile.addEventListener("click", function () {
|
||||
post("choose-background", {});
|
||||
});
|
||||
backgroundNone.addEventListener("change", function () {
|
||||
post("toggle-background", {});
|
||||
});
|
||||
fadeDuration.addEventListener("change", function () {
|
||||
post("set-fade-duration", { duration: Number(fadeDuration.value) });
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.repeat || hasOpenDialog()) return;
|
||||
if (event.key === "F2") {
|
||||
event.preventDefault();
|
||||
if (!backgroundFile.disabled) backgroundFile.click();
|
||||
} else if (event.key === "F3") {
|
||||
event.preventDefault();
|
||||
if (!backgroundNone.disabled) backgroundNone.click();
|
||||
} else if (event.key === "F8") {
|
||||
event.preventDefault();
|
||||
if (!takeIn.disabled) takeIn.click();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (!takeOut.disabled) takeOut.click();
|
||||
}
|
||||
});
|
||||
|
||||
if (webview) {
|
||||
webview.addEventListener("message", function (event) {
|
||||
if (event.data && event.data.type === "state" && event.data.payload) {
|
||||
localBusy = false;
|
||||
render(event.data.payload);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setAllDisabled(true);
|
||||
status.textContent = "WebView2 송출 브리지를 사용할 수 없습니다.";
|
||||
}
|
||||
}());
|
||||
@@ -7,6 +7,7 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
*[hidden] { display: none !important; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
body.busy { cursor: wait; }
|
||||
body.busy .operator-shell { pointer-events: none; }
|
||||
@@ -32,6 +33,8 @@ button:disabled { color: #555; opacity: 1; }
|
||||
.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; }
|
||||
.connection-state.connected { background: #176c32; }
|
||||
.connection-state.outcome-unknown { background: #8a4d00; }
|
||||
|
||||
.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; }
|
||||
.search-line label { font-weight: 700; }
|
||||
@@ -45,6 +48,85 @@ button:disabled { color: #555; opacity: 1; }
|
||||
|
||||
.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; }
|
||||
.manual-list-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 0 0 9px; }
|
||||
.manual-list-actions button { height: 31px; padding: 2px 3px; font-size: 11px; font-weight: 700; }
|
||||
|
||||
.named-playlist-backdrop { position: fixed; inset: 0; z-index: 19; background: rgba(0, 0, 0, .3); }
|
||||
.named-playlist-backdrop[hidden] { display: none; }
|
||||
.named-playlist-dialog { position: absolute; left: 50%; top: 50%; width: 650px; height: 500px; transform: translate(-50%, -50%); display: grid; grid-template-rows: 45px auto minmax(0, 1fr) 52px; border: 1px solid #666; background: #f2f2f2; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
|
||||
.named-playlist-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; }
|
||||
.named-playlist-dialog h2 { margin: 0; font-size: 18px; }
|
||||
.named-playlist-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
|
||||
.named-playlist-status { min-height: 38px; padding: 8px 12px; border-bottom: 1px solid #bbb; color: #444; }
|
||||
.named-playlist-status.error { background: #fff1f3; color: #9b001b; font-weight: 700; }
|
||||
.named-playlist-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 9px; padding: 10px; }
|
||||
.named-playlist-definitions { min-height: 0; overflow: auto; border: 1px solid #888; background: #fff; }
|
||||
.named-playlist-definitions button { display: block; width: 100%; min-height: 28px; padding: 4px 8px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
|
||||
.named-playlist-definitions button.selected { background: #d9e8f9; font-weight: 700; }
|
||||
.named-playlist-create { align-self: start; display: grid; grid-template-columns: 1fr; gap: 7px; padding: 10px; border: 1px solid #aaa; background: #fff; }
|
||||
.named-playlist-create h3 { margin: 0 0 5px; font-size: 15px; }
|
||||
.named-playlist-create input, .named-playlist-create button { min-width: 0; height: 30px; }
|
||||
.named-playlist-create p { margin: 5px 0 0; color: #555; font-size: 11px; line-height: 1.45; }
|
||||
.named-playlist-dialog > footer { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border-top: 1px solid #aaa; }
|
||||
.named-playlist-dialog > footer button { min-width: 96px; height: 32px; }
|
||||
.named-playlist-footer-spacer { flex: 1; }
|
||||
.named-playlist-dialog > footer #named-playlist-confirm-button { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
|
||||
|
||||
.manual-financial-backdrop { position: fixed; inset: 0; z-index: 18; background: rgba(0, 0, 0, .3); }
|
||||
.manual-financial-backdrop[hidden] { display: none; }
|
||||
.manual-financial-dialog { position: absolute; left: 50%; top: 50%; width: min(1120px, 92vw); height: min(800px, 90vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr); border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
|
||||
.manual-financial-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; }
|
||||
.manual-financial-dialog h2 { margin: 0; font-size: 18px; }
|
||||
.manual-financial-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
|
||||
.manual-financial-workspace { min-height: 0; display: grid; grid-template-rows: 38px minmax(0, 1fr); gap: 7px; padding: 8px; }
|
||||
.manual-financial-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; }
|
||||
.manual-financial-toolbar input, .manual-financial-toolbar button { min-width: 0; height: 30px; }
|
||||
.manual-financial-body { min-height: 0; display: grid; grid-template-columns: 270px minmax(0, 1fr); gap: 8px; }
|
||||
.manual-financial-list { min-height: 0; overflow: auto; border: 1px solid #999; background: #fff; }
|
||||
.manual-financial-list button { display: block; width: 100%; min-height: 25px; padding: 3px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
|
||||
.manual-financial-list button.selected { background: #dcdcdc; }
|
||||
.manual-financial-list button.manual-financial-list-header { position: sticky; top: 0; z-index: 1; background: #ececec; border-bottom: 1px solid #888; font-weight: 700; }
|
||||
.manual-financial-editor { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 7px; padding: 8px; border: 1px solid #999; background: #fff; }
|
||||
.manual-financial-stock-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; }
|
||||
.manual-financial-stock-line input, .manual-financial-stock-line button { height: 30px; }
|
||||
.manual-financial-stock-results { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; max-height: 84px; overflow: auto; padding: 3px; border: 1px solid #ccc; }
|
||||
.manual-financial-stock-results button { padding: 3px 7px; }
|
||||
.manual-financial-stock-results button.selected { border-color: #145ab4; background: #dceaff; }
|
||||
.manual-financial-verification { min-height: 22px; color: #145ab4; }
|
||||
.manual-financial-fields { display: grid; gap: 7px; }
|
||||
.manual-financial-field { display: grid; grid-template-columns: 100px minmax(0, 1fr); align-items: center; gap: 6px; }
|
||||
.manual-financial-field input { min-width: 0; height: 28px; }
|
||||
.manual-financial-grid { display: grid; gap: 4px; align-items: center; }
|
||||
.manual-financial-grid input { min-width: 0; height: 27px; }
|
||||
.manual-revenue-grid, .manual-quarter-grid { grid-template-columns: minmax(0, 1fr) minmax(120px, .45fr); }
|
||||
.manual-growth-grid { grid-template-columns: 130px repeat(4, minmax(90px, 1fr)); }
|
||||
.manual-financial-actions { margin-top: auto; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
||||
.manual-financial-actions button { min-height: 34px; }
|
||||
.manual-financial-actions .manual-primary { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
|
||||
.manual-financial-message { color: #444; }
|
||||
.manual-financial-warning { padding: 7px; border: 1px solid #b00020; background: #fff1f3; color: #9b001b; font-weight: 700; }
|
||||
.manual-list-backdrop { position: fixed; inset: 0; z-index: 19; background: rgba(0, 0, 0, .32); }
|
||||
.manual-list-backdrop[hidden] { display: none; }
|
||||
.manual-list-dialog { position: absolute; left: 50%; top: 50%; width: min(980px, 94vw); height: min(760px, 91vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr); border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
|
||||
.manual-list-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; }
|
||||
.manual-list-dialog h2 { margin: 0; font-size: 18px; }
|
||||
.manual-list-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
|
||||
.manual-list-workspace { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 8px; padding: 10px; }
|
||||
.manual-list-tabs, .manual-list-actions-row, .manual-list-search { display: flex; gap: 6px; align-items: center; }
|
||||
.manual-list-tabs button.active { border-color: #145ab4; background: #dceaff; font-weight: 700; }
|
||||
.manual-list-table-wrap { min-height: 0; overflow: auto; border: 1px solid #999; background: #fff; }
|
||||
.manual-list-table { width: 100%; border-collapse: collapse; }
|
||||
.manual-list-table th, .manual-list-table td { padding: 4px; border-right: 1px solid #ddd; border-bottom: 1px solid #ddd; }
|
||||
.manual-list-table input { width: 100%; min-width: 0; height: 27px; }
|
||||
.manual-list-table .manual-list-row-actions { width: 170px; white-space: nowrap; }
|
||||
.manual-list-search input { flex: 1; min-width: 0; height: 30px; }
|
||||
.manual-list-search-results { display: flex; flex-wrap: wrap; gap: 4px; max-height: 110px; overflow: auto; padding: 5px; border: 1px solid #aaa; background: #fff; }
|
||||
.manual-list-search-results button.selected { background: #dcdcdc; font-weight: 600; }
|
||||
.manual-list-status { padding: 7px; border: 1px solid #bbb; background: #fff; }
|
||||
.manual-list-status.error { border-color: #b00020; background: #fff1f3; color: #9b001b; font-weight: 700; }
|
||||
.manual-list-actions-row { margin-top: auto; justify-content: flex-end; }
|
||||
.manual-list-actions-row button { min-width: 100px; height: 34px; }
|
||||
.manual-list-actions-row .primary { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); 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; }
|
||||
@@ -63,13 +145,123 @@ button:disabled { color: #555; opacity: 1; }
|
||||
.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; }
|
||||
.catalog-section > summary { margin: 5px 0 2px; color: #168000; font-size: 16px; cursor: default; user-select: none; }
|
||||
.catalog-actions { display: grid; }
|
||||
.tree-item { width: 100%; padding: 3px 0 3px 29px; border: 0; background: transparent; color: #111; font: inherit; font-size: 15px; text-align: left; cursor: default; }
|
||||
.tree-item:hover, .tree-item:focus-visible { background: #dcdcdc; outline: none; }
|
||||
.tree-item.unavailable { color: #777; }
|
||||
.industry-workspace { display: grid; grid-template-rows: minmax(170px, 42%) minmax(0, 1fr); height: 100%; gap: 7px; }
|
||||
.industry-selector { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
|
||||
.industry-slots { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 4px; margin-bottom: 5px; }
|
||||
.industry-slots input { min-width: 0; }
|
||||
.industry-list { overflow: auto; border: 1px solid #aaa; background: #fff; }
|
||||
.industry-list button { display: block; width: 100%; padding: 2px 7px; border: 0; background: #fff; text-align: left; }
|
||||
.industry-list button.selected { background: #dcdcdc; }
|
||||
.industry-action-tree { min-height: 0; overflow: auto; border-top: 1px solid #aaa; padding-top: 4px; }
|
||||
.industry-action-tree summary { color: #168000; font-size: 15px; cursor: default; }
|
||||
.overseas-workspace { display: grid; grid-template-rows: minmax(92px, 20%) minmax(170px, 42%) minmax(0, 1fr); height: 100%; gap: 7px; }
|
||||
.overseas-panel { min-height: 0; }
|
||||
.overseas-panel h3 { margin: 0 0 4px; color: #168000; font-size: 14px; font-weight: 600; }
|
||||
.overseas-fixed-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.overseas-fixed-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: auto; border: 1px solid #aaa; background: #fff; }
|
||||
.overseas-fixed-list button { min-width: 0; padding: 2px 5px; border-width: 0 1px 1px 0; background: #fff; text-align: left; }
|
||||
.overseas-fixed-list button.selected { background: #dcdcdc; font-weight: 600; }
|
||||
.overseas-searches { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; gap: 7px; }
|
||||
.overseas-search-panel { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; }
|
||||
.overseas-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; }
|
||||
.overseas-search-line input { min-width: 0; }
|
||||
.overseas-selected, .overseas-note { min-height: 18px; padding: 2px 3px; color: #444; font-size: 11px; }
|
||||
.overseas-note.error { color: #a40000; }
|
||||
.overseas-results { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
|
||||
.overseas-results button { display: block; width: 100%; padding: 2px 5px; border: 0; background: #fff; text-align: left; }
|
||||
.overseas-results button.selected { background: #dcdcdc; }
|
||||
.overseas-action-panel { overflow: auto; border-top: 1px solid #aaa; padding-top: 4px; }
|
||||
.overseas-action-panel summary { color: #168000; font-size: 15px; cursor: default; }
|
||||
.comparison-workspace { display: grid; grid-template-rows: minmax(110px, 22%) minmax(70px, 16%) minmax(150px, 1fr) auto; height: 100%; gap: 6px; }
|
||||
.comparison-searches { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; gap: 6px; }
|
||||
.comparison-search-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
|
||||
.comparison-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; }
|
||||
.comparison-results, .comparison-saved { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
|
||||
.comparison-results button, .comparison-saved button { display: block; width: 100%; padding: 2px 5px; border: 0; background: #fff; text-align: left; }
|
||||
.comparison-results button:hover, .comparison-results button.selected, .comparison-saved button.selected { background: #dcdcdc; }
|
||||
.comparison-fixed { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
|
||||
.comparison-fixed-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: auto; border: 1px solid #aaa; }
|
||||
.comparison-fixed-list button { padding: 2px 4px; text-align: left; }
|
||||
.comparison-fixed-list button.selected { background: #dcdcdc; font-weight: 600; }
|
||||
.comparison-pair-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-height: 0; gap: 4px; }
|
||||
.comparison-draft { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto auto; gap: 4px; }
|
||||
.comparison-draft input { min-width: 0; }
|
||||
.comparison-saved-tools { display: flex; gap: 4px; justify-content: flex-end; }
|
||||
.comparison-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
|
||||
.theme-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: 100%; gap: 7px; }
|
||||
.theme-search-line { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; }
|
||||
.theme-search-line #theme-search { grid-column: span 2; min-width: 0; }
|
||||
.theme-search-line button[data-uc4-theme-catalog="create"] { grid-column: 2; }
|
||||
.theme-body { display: grid; grid-template-columns: minmax(150px, 42%) minmax(0, 1fr); min-height: 0; gap: 7px; }
|
||||
.theme-results, .theme-preview-panel { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
|
||||
.theme-results button { display: block; width: 100%; padding: 3px 6px; border: 0; background: #fff; text-align: left; }
|
||||
.theme-results button.selected { background: #dcdcdc; }
|
||||
.theme-preview-panel { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 4px; padding: 5px; }
|
||||
.theme-preview-list { overflow: auto; margin: 0; padding-left: 28px; }
|
||||
.theme-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
|
||||
.expert-workspace, .halt-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: 100%; gap: 7px; }
|
||||
.expert-search-line { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, auto); gap: 5px; }
|
||||
.expert-body { display: grid; grid-template-columns: minmax(150px, 40%) minmax(0, 1fr); min-height: 0; gap: 7px; }
|
||||
.expert-results, .expert-preview, .halt-results { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
|
||||
.expert-results button, .halt-results button { display: block; width: 100%; padding: 3px 6px; border: 0; background: #fff; text-align: left; }
|
||||
.expert-results button.selected, .halt-results button.selected { background: #dcdcdc; }
|
||||
.expert-preview { padding: 6px; }
|
||||
.expert-preview ol { margin: 5px 0 0; padding-left: 28px; }
|
||||
.expert-actions, .halt-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
|
||||
.halt-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; }
|
||||
|
||||
.operator-catalog-backdrop { position: fixed; inset: 0; z-index: 24; display: grid; place-items: center; background: rgba(0, 0, 0, .36); }
|
||||
.operator-catalog-backdrop[hidden] { display: none; }
|
||||
.operator-catalog-dialog { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); width: min(1160px, calc(100vw - 70px)); height: min(820px, calc(100vh - 60px)); overflow: hidden; border: 1px solid #555; background: #efefef; box-shadow: 0 12px 42px rgba(0, 0, 0, .38); }
|
||||
.operator-catalog-dialog > header { display: flex; align-items: center; min-height: 48px; padding: 7px 12px; border-bottom: 1px solid #888; background: #fff; }
|
||||
.operator-catalog-dialog > header h2 { flex: 1; margin: 0; font-size: 20px; }
|
||||
.operator-catalog-dialog > header button { width: 38px; height: 32px; font-size: 22px; }
|
||||
.operator-catalog-status { min-height: 34px; padding: 8px 12px; border-bottom: 1px solid #bbb; color: #333; background: #f8f8f8; }
|
||||
.operator-catalog-status.error { color: #a40000; background: #fff0f0; font-weight: 600; }
|
||||
.operator-catalog-toolbar { display: grid; grid-template-columns: 94px 94px minmax(240px, 1fr) 120px 80px; gap: 6px; padding: 8px 12px; border-bottom: 1px solid #aaa; }
|
||||
.operator-catalog-toolbar button.selected { color: #fff; background: #176b2d; font-weight: 700; }
|
||||
.operator-catalog-body { display: grid; grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); min-height: 0; gap: 10px; padding: 10px 12px 12px; }
|
||||
.operator-catalog-list-panel, .operator-catalog-editor { display: grid; min-height: 0; border: 1px solid #999; background: #fff; }
|
||||
.operator-catalog-list-panel { grid-template-rows: auto minmax(0, 1fr); }
|
||||
.operator-catalog-panel-header { display: flex; align-items: center; min-height: 40px; gap: 6px; padding: 6px 8px; border-bottom: 1px solid #bbb; background: #f3f3f3; }
|
||||
.operator-catalog-panel-header h3 { flex: 1; margin: 0; font-size: 15px; }
|
||||
.operator-catalog-code { color: #555; font-family: Consolas, monospace; }
|
||||
.operator-catalog-results, .operator-catalog-stock-results, .operator-catalog-draft-rows { min-height: 0; overflow: auto; }
|
||||
.operator-catalog-results button { display: grid; grid-template-columns: 84px minmax(0, 1fr); width: 100%; gap: 2px 8px; padding: 6px 8px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
|
||||
.operator-catalog-results button small { grid-column: 2; color: #666; }
|
||||
.operator-catalog-results button.selected { background: #dcebdc; }
|
||||
.operator-catalog-editor { grid-template-rows: auto auto auto minmax(92px, 20%) auto auto minmax(120px, 1fr) auto; gap: 7px; padding-bottom: 8px; }
|
||||
.operator-catalog-editor[hidden] { display: none; }
|
||||
.operator-catalog-name-field { display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 6px; padding: 0 9px; }
|
||||
.operator-catalog-stock-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 0 9px; }
|
||||
.operator-catalog-stock-results { margin: 0 9px; border: 1px solid #aaa; }
|
||||
.operator-catalog-stock-results button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; padding: 4px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
|
||||
.operator-catalog-stock-results button.selected { background: #dcebdc; }
|
||||
.operator-catalog-stock-results button:disabled { color: #999; }
|
||||
.operator-catalog-add-line { display: grid; grid-template-columns: auto 150px minmax(130px, auto); justify-content: end; align-items: center; gap: 6px; padding: 0 9px; }
|
||||
.operator-catalog-add-line [hidden] { display: none; }
|
||||
.operator-catalog-draft-header { display: flex; justify-content: space-between; gap: 8px; padding: 2px 9px; color: #555; }
|
||||
.operator-catalog-draft-header span { font-size: 11px; }
|
||||
.operator-catalog-draft-rows { margin: 0 9px; border: 1px solid #aaa; }
|
||||
.operator-catalog-draft-row { display: grid; grid-template-columns: 34px minmax(140px, 1fr) minmax(110px, auto) 36px 36px 58px; align-items: center; gap: 4px; min-height: 34px; padding: 3px 5px; border-bottom: 1px solid #ddd; }
|
||||
.operator-catalog-draft-row > span { text-align: center; }
|
||||
.operator-catalog-draft-row > small { color: #555; text-align: right; }
|
||||
.operator-catalog-draft-row > input { min-width: 90px; width: 100%; height: 27px; text-align: right; }
|
||||
.operator-catalog-editor-actions { display: grid; grid-template-columns: auto 1fr auto auto; gap: 7px; padding: 2px 9px 0; }
|
||||
.operator-catalog-editor-actions button:last-child { min-width: 92px; color: #fff; background: #176b2d; font-weight: 700; }
|
||||
|
||||
.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 { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.playlist-toolbar button { height: 25px; padding: 2px 7px; }
|
||||
.playlist-toolbar .toolbar-spacer { flex: 1; }
|
||||
.background-name { width: 95px; height: 25px; border: 1px solid #888; background: #fff; }
|
||||
.background-name { width: 82px; height: 25px; border: 1px solid #888; background: #fff; }
|
||||
.fade-control { margin-left: 2px; }
|
||||
#playout-fade-duration { width: 48px; height: 25px; }
|
||||
|
||||
.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; }
|
||||
@@ -78,12 +270,14 @@ button:disabled { color: #555; opacity: 1; }
|
||||
.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; }
|
||||
.playlist-data-row.selected { background: #dcdcdc; }
|
||||
.playlist-data-row:focus { outline: 1px dotted #333; outline-offset: -2px; }
|
||||
|
||||
.playout-actions { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.playout-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: 12px; }
|
||||
.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-actions button:disabled { color: #888; background: #f7f7f7; }
|
||||
.playout-actions .prepare { font-size: 21px; }
|
||||
.playout-actions .prepare.active { color: #fff; background: #1a7700; }
|
||||
.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); }
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
public sealed record LegacyParityStartupCompositionResolution(
|
||||
LegacySceneCueCompositionOptions Composition,
|
||||
string WarningMessage)
|
||||
{
|
||||
public bool HasWarning => WarningMessage.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the startup-only MainForm background behavior used by LegacyParityApp.
|
||||
/// An explicit local file remains authoritative; only an absent file enables the
|
||||
/// validated legacy 기본.vrv fallback.
|
||||
/// </summary>
|
||||
public static class LegacyParityStartupCompositionResolver
|
||||
{
|
||||
public const string MissingLegacyDefaultWarning =
|
||||
"신뢰 배경 폴더에서 기본.vrv를 확인할 수 없어 배경없음으로 시작했습니다.";
|
||||
|
||||
public static LegacyParityStartupCompositionResolution Resolve(
|
||||
PlayoutOptions options,
|
||||
bool explicitLocalConfigurationExists)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var configured = PlayoutSceneCompositionFactory.Create(options);
|
||||
if (explicitLocalConfigurationExists ||
|
||||
configured.BackgroundKind != LegacySceneBackgroundKind.None)
|
||||
{
|
||||
return new LegacyParityStartupCompositionResolution(configured, string.Empty);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new LegacyParityStartupCompositionResolution(
|
||||
PlayoutSceneCompositionFactory.CreateLegacyDefault(
|
||||
options,
|
||||
configured.FadeDuration),
|
||||
string.Empty);
|
||||
}
|
||||
catch (PlayoutConfigurationException)
|
||||
{
|
||||
// Missing/invalid external media is not an application-startup failure.
|
||||
// Keep the already validated None composition and expose one bounded,
|
||||
// path-free warning through the native state projection.
|
||||
return new LegacyParityStartupCompositionResolution(
|
||||
configured,
|
||||
MissingLegacyDefaultWarning);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user