feat: consolidate legacy parity migration baseline

This commit is contained in:
2026-07-20 22:06:12 +09:00
parent 5eb4054120
commit 4a54977341
66 changed files with 5391 additions and 460 deletions

View File

@@ -454,10 +454,8 @@ public sealed class LegacyNamedForeignStockRestoreService
var punctuationIssue = character switch
{
'+' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPlus,
'&' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAmpersand,
'\'' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedApostrophe,
',' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedComma,
'(' or ')' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedParenthesis,
'#' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedHash,
'%' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPercent,
'*' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsterisk,
@@ -470,7 +468,11 @@ public sealed class LegacyNamedForeignStockRestoreService
return punctuationIssue.Value;
}
if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' '))
// Current Oracle identities include ampersands and parenthesized
// provider symbols. They are safe bound values and cannot collide
// with the '|' or '^' serialization delimiters guarded above.
if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' ' or
'&' or '(' or ')'))
{
return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation;
}

View File

@@ -169,8 +169,18 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
private static string ReadCanonical(DataRow row, int ordinal, int maximumLength)
{
if (row[ordinal] is not string value || value.Length < 1 || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) || !IsSafeText(value))
if (row[ordinal] is not string rawValue || rawValue.Length < 1 ||
rawValue.Length > maximumLength || !IsSafeText(rawValue))
{
throw InvalidData("value");
}
// The current legacy master contains one US/TW row whose name and symbol
// are padded with an outer ASCII space. The original UC5 accepted those
// values. Normalize only that closed padding form; the identity set below
// still rejects any collision created by normalization.
var value = rawValue.Trim(' ');
if (value.Length < 1 || value.Length > maximumLength)
{
throw InvalidData("value");
}

View File

@@ -616,10 +616,18 @@ internal static class ThemeQueries
LEFT JOIN (
SELECT i.SB_M_CODE, i.SB_I_NAME, i.SB_I_CODE, i.SB_I_INDEX
FROM SB_ITEM i
JOIN v_all_stock s
ON SUBSTRING(i.SB_I_CODE, 2, 12) = s.F_STOCK_CODE
WHERE s.F_STOP_GUBUN = 'N'
) a ON l.SB_CODE = a.SB_M_CODE
JOIN (
SELECT CONCAT('X', s.F_STOCK_CODE) ITEM_CODE
FROM N_STOCK s
WHERE s.F_STOP_GUBUN = 'N'
UNION ALL
SELECT CONCAT('T', s.F_STOCK_CODE) ITEM_CODE
FROM N_KOSDAQ_STOCK s
WHERE s.F_STOP_GUBUN = 'N'
) current_nxt
ON BINARY i.SB_I_CODE = BINARY current_nxt.ITEM_CODE
) a ON BINARY l.SB_CODE = BINARY a.SB_M_CODE
AND l.SB_CODE REGEXP BINARY '^[0-9]{8}$'
WHERE l.SB_CODE = @theme_code
AND l.SB_TITLE = @theme_title
AND l.SB_MARKET = 'NXT'

View File

@@ -70,6 +70,23 @@ public static class CandleCutAliasMetadata
CandleCutAlias.Cut8056 => 1464d,
_ => throw new LegacySceneDataException("Unknown candle cut alias.")
};
internal static int GetAssetObjectCapacity(CandleCutAlias alias) => alias switch
{
CandleCutAlias.Cut8035 => 78,
CandleCutAlias.Cut8061 => 5,
CandleCutAlias.Cut8040 => 20,
CandleCutAlias.Cut8046 => 60,
CandleCutAlias.Cut8051 => 120,
CandleCutAlias.Cut8056 => 240,
_ => throw new LegacySceneDataException("Unknown candle cut alias.")
};
internal static bool SupportsMovingAveragePaths(CandleCutAlias alias) =>
alias != CandleCutAlias.Cut8035;
internal static bool SupportsGuide(CandleCutAlias alias) => alias is not
(CandleCutAlias.Cut8035 or CandleCutAlias.Cut8061);
}
public sealed record CandleQuoteData(
@@ -101,7 +118,6 @@ public sealed record S8010SceneData(
public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S8010SceneData>
{
private const int MaximumCandleCount = 240;
private const double GraphStartX = -730.57d;
private const double GraphEndX = 738.65d;
private const double TagMinimumX = -657.57d;
@@ -122,13 +138,25 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
var period = CandleCutAliasMetadata.GetPeriod(data.CutAlias);
var lineWidth = CandleCutAliasMetadata.GetMovingAverageWidth(data.CutAlias);
var format = UsesIntegerPrices(data.MarketTarget) ? "#,##0" : "#,##0.00";
var mutations = new List<PlayoutMutation>(540 + (rows.Count * 12))
var mutations = new List<PlayoutMutation>(
(CandleCutAliasMetadata.GetAssetObjectCapacity(data.CutAlias) * 2) +
60 + (rows.Count * 12))
{
new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode)),
// A path mutation with no points reproduces Begin/Clear/End from the legacy setup.
new PlayoutSetPathPoints("ma5", Array.Empty<PlayoutPoint>()),
new PlayoutSetPathPoints("ma20", Array.Empty<PlayoutPoint>())
new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode))
};
if (CandleCutAliasMetadata.SupportsMovingAveragePaths(data.CutAlias))
{
// A path mutation with no points reproduces Begin/Clear/End from the legacy setup.
mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty<PlayoutPoint>()));
mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty<PlayoutPoint>()));
}
else
{
// The intraday asset exposes ma5/ma20 as ordinary visible objects,
// not editable paths. Hide the baked defaults without PATH_POINT.
mutations.Add(new PlayoutSetVisible("ma5", false));
mutations.Add(new PlayoutSetVisible("ma20", false));
}
AddResolvedTitleAndUnit(
mutations,
@@ -138,7 +166,7 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
AddQuote(mutations, quote, format);
var range = CreateRange(rows, data);
AddInitialVisibility(mutations, data.ChartMode);
AddInitialVisibility(mutations, data.CutAlias, data.ChartMode);
var paths = AddCandles(
mutations,
rows,
@@ -187,7 +215,7 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
var values = LegacySceneBuilderGuard.Range(
rows,
1,
MaximumCandleCount,
CandleCutAliasMetadata.GetAssetObjectCapacity(alias),
nameof(rows));
var requiresTime = CandleCutAliasMetadata.GetPeriod(alias) == CandlePeriod.Intraday;
return values.Select((row, index) =>
@@ -379,17 +407,19 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
private static void AddInitialVisibility(
ICollection<PlayoutMutation> mutations,
CandleCutAlias alias,
CandleChartMode mode)
{
mutations.Add(new PlayoutSetVisible(
"volrumeG",
mode == CandleChartMode.PriceWithVolume));
for (var index = 1; index <= MaximumCandleCount; index++)
var capacity = CandleCutAliasMetadata.GetAssetObjectCapacity(alias);
for (var index = 1; index <= capacity; index++)
{
mutations.Add(new PlayoutSetVisible("volume" + index, false));
}
for (var index = 1; index <= MaximumCandleCount; index++)
for (var index = 1; index <= capacity; index++)
{
mutations.Add(new PlayoutSetVisible("candle" + index, false));
}
@@ -617,17 +647,28 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S801
CandlePaths paths,
S8010SceneData data)
{
if (!CandleCutAliasMetadata.SupportsMovingAveragePaths(data.CutAlias))
{
return;
}
mutations.Add(new PlayoutSetPathPoints(
"ma5",
data.ShowMovingAverage5 ? paths.MovingAverage5 : Array.Empty<PlayoutPoint>()));
mutations.Add(new PlayoutSetPathPoints(
"ma20",
data.ShowMovingAverage20 ? paths.MovingAverage20 : Array.Empty<PlayoutPoint>()));
mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20));
if (CandleCutAliasMetadata.SupportsGuide(data.CutAlias))
{
mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20));
}
if (!SupportsMovingAverages(data.MarketTarget))
{
mutations.Add(new PlayoutSetVisible("guide", false));
if (CandleCutAliasMetadata.SupportsGuide(data.CutAlias))
{
mutations.Add(new PlayoutSetVisible("guide", false));
}
mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty<PlayoutPoint>()));
mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty<PlayoutPoint>()));
}

View File

@@ -348,8 +348,21 @@ public sealed class S5087SceneMutationBuilder : ILegacySceneMutationBuilder<S508
data.Series,
ComparisonInitialHideStyle.S5087,
maximumX: 700f,
baselineOffset: -170f);
baselineOffset: -170f)
.Where(IsAuthoritativeAssetMutation)
.ToArray();
}
private static bool IsAuthoritativeAssetMutation(PlayoutMutation mutation) => mutation switch
{
PlayoutSetValue value when value.ObjectName is
"date1_2" or "date1_4" or "date2_2" or "date2_4" or
"highPrice1" or "lowPrice1" or "highPrice2" or "lowPrice2" => false,
PlayoutSetPosition position when position.ObjectName is
"baseline1" or "baseline2" or
"highTag1" or "lowTag1" or "highTag2" or "lowTag2" => false,
_ => true
};
}
public enum YieldGraphPeriod

View File

@@ -136,7 +136,7 @@ public sealed class LegacyParameterizedSceneRequestResolver
// MainForm selects the special s5032 builder whenever the combined subject
// contains the Korean futures label. s5032 then normalizes futures to side two.
if (subject.Contains("선물", StringComparison.Ordinal) ||
subject.Contains("FUTURES", StringComparison.Ordinal))
subject.Contains("FUTURES", StringComparison.OrdinalIgnoreCase))
{
var values = SplitPair(subject, ',');
// Keep this branch closed to the market targets that s5032 renders with

View File

@@ -59,7 +59,13 @@ public sealed class S5074SceneMutationBuilder : ILegacySceneMutationBuilder<Page
public string BuilderKey => "s5074";
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
PagedQuoteMutations.Build(data, ScenePageSize.Five, useWideRateVariants: false);
PagedQuoteMutations.Build(
data,
ScenePageSize.Five,
useWideRateVariants: false,
includeTitle: true,
includeUnit: true,
includeColumns: true);
}
public sealed class S5077SceneMutationBuilder : ILegacySceneMutationBuilder<PagedQuoteSceneData>
@@ -67,7 +73,13 @@ public sealed class S5077SceneMutationBuilder : ILegacySceneMutationBuilder<Page
public string BuilderKey => "s5077";
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
PagedQuoteMutations.Build(data, ScenePageSize.Six, useWideRateVariants: true);
PagedQuoteMutations.Build(
data,
ScenePageSize.Six,
useWideRateVariants: true,
includeTitle: false,
includeUnit: false,
includeColumns: false);
}
public sealed class S5088SceneMutationBuilder : ILegacySceneMutationBuilder<PagedQuoteSceneData>
@@ -75,7 +87,13 @@ public sealed class S5088SceneMutationBuilder : ILegacySceneMutationBuilder<Page
public string BuilderKey => "s5088";
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
PagedQuoteMutations.Build(data, ScenePageSize.Twelve, useWideRateVariants: true);
PagedQuoteMutations.Build(
data,
ScenePageSize.Twelve,
useWideRateVariants: true,
includeTitle: true,
includeUnit: true,
includeColumns: false);
}
internal static class PagedQuoteMutations
@@ -86,7 +104,10 @@ internal static class PagedQuoteMutations
public static IReadOnlyList<PlayoutMutation> Build(
PagedQuoteSceneData data,
ScenePageSize pageSize,
bool useWideRateVariants)
bool useWideRateVariants,
bool includeTitle,
bool includeUnit,
bool includeColumns)
{
ArgumentNullException.ThrowIfNull(data);
var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
@@ -144,9 +165,16 @@ internal static class PagedQuoteMutations
mutations.Add(new PlayoutSetVisible("row" + slot, false));
}
mutations.Add(new PlayoutSetValue("title", BuildTitle(title, data.TitleStyle)));
// The authoritative 5077.t2s has no global title/header objects, while
// 5088.t2s has title/unit but no column1..3. Keep the shared row logic,
// but never send mutations for objects absent from the selected asset.
if (includeTitle)
{
mutations.Add(new PlayoutSetValue("title", BuildTitle(title, data.TitleStyle)));
}
AddMarketMark(mutations, data.Branch, data.NxtSession);
AddHeader(mutations, data.HeaderStyle);
AddHeader(mutations, data.HeaderStyle, includeUnit, includeColumns);
for (var slot = 1; slot <= size; slot++)
{
@@ -228,7 +256,9 @@ internal static class PagedQuoteMutations
private static void AddHeader(
ICollection<PlayoutMutation> mutations,
PagedQuoteHeaderStyle style)
PagedQuoteHeaderStyle style,
bool includeUnit,
bool includeColumns)
{
var values = style switch
{
@@ -240,10 +270,17 @@ internal static class PagedQuoteMutations
(Unit: "\uB2E8\uC704 : \uC6D0", Column1: "\uB9E4\uC218\uAC00", Column2: "\uD604\uC7AC\uAC00", Column3: "\uC218\uC775\uB960"),
_ => throw new LegacySceneDataException("Unknown paged quote header style.")
};
mutations.Add(new PlayoutSetValue("unit", values.Unit));
mutations.Add(new PlayoutSetValue("column1", values.Column1));
mutations.Add(new PlayoutSetValue("column2", values.Column2));
mutations.Add(new PlayoutSetValue("column3", values.Column3));
if (includeUnit)
{
mutations.Add(new PlayoutSetValue("unit", values.Unit));
}
if (includeColumns)
{
mutations.Add(new PlayoutSetValue("column1", values.Column1));
mutations.Add(new PlayoutSetValue("column2", values.Column2));
mutations.Add(new PlayoutSetValue("column3", values.Column3));
}
}
private static void AddPopulatedRow(

View File

@@ -1154,7 +1154,7 @@ public enum S8001Sector
TextilesAndApparel,
ElectricalAndElectronics,
Chemicals,
NonMetallicMinerals,
Pharmaceuticals,
MachineryAndEquipment,
ItComponents,
Transportation,
@@ -1242,7 +1242,7 @@ public sealed class S8001SceneMutationBuilder : ILegacySceneMutationBuilder<S800
S8001Sector.FoodAndBeverage or S8001Sector.SteelAndMetal or
S8001Sector.MedicalPrecision or S8001Sector.TextilesAndApparel or
S8001Sector.ElectricalAndElectronics or S8001Sector.Chemicals or
S8001Sector.NonMetallicMinerals,
S8001Sector.Pharmaceuticals,
S8001Market.Kosdaq => sector is
S8001Sector.MachineryAndEquipment or S8001Sector.ItComponents or
S8001Sector.Transportation or S8001Sector.PaperAndWood or
@@ -1266,7 +1266,7 @@ public sealed class S8001SceneMutationBuilder : ILegacySceneMutationBuilder<S800
S8001Sector.TextilesAndApparel => "섬유의복",
S8001Sector.ElectricalAndElectronics => "전기전자",
S8001Sector.Chemicals => "화학",
S8001Sector.NonMetallicMinerals => "비금속광물",
S8001Sector.Pharmaceuticals => "의약품",
S8001Sector.MachineryAndEquipment => "기계장비",
S8001Sector.ItComponents => "IT부품",
S8001Sector.Transportation => "운송",

View File

@@ -913,7 +913,9 @@ public sealed class S8001SceneDataLoader
"006" => S8001Sector.TextilesAndApparel,
"007" => S8001Sector.PaperAndWood,
"008" => S8001Sector.Chemicals,
"009" => S8001Sector.NonMetallicMinerals,
// The live taxonomy now names code 009 "제약" while the authoritative
// 8001 cut keeps the legacy on-air object name "의약품".
"009" => S8001Sector.Pharmaceuticals,
"011" => S8001Sector.SteelAndMetal,
"012" => S8001Sector.Machinery,
"013" => S8001Sector.ElectricalAndElectronics,

View File

@@ -148,11 +148,34 @@ public sealed class S5011SceneMutationBuilder : ILegacySceneMutationBuilder<S501
mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatInteger(data.NetChange)));
mutations.Add(new PlayoutSetValue("rate" + variant, FormatRate(data.Rate)));
ClosedSceneMappings.AddDirectionHides(mutations, string.Empty);
mutations.AddRange(LegacyPriceDirectionMutations.Build(direction, 0));
AddAssetSupportedDirectionMutations(mutations, direction);
mutations.Add(new PlayoutSetVisible("mart", ClosedSceneMappings.IsNxt(market)));
return mutations;
}
private static void AddAssetSupportedDirectionMutations(
ICollection<PlayoutMutation> mutations,
ScenePriceDirection direction)
{
foreach (var mutation in LegacyPriceDirectionMutations.Build(direction, 0))
{
// The authoritative 5011.t2s has the bg/changePrice/rate/percent
// variants but no wonText variants or pattern asset object.
if (mutation is PlayoutSetVisible { ObjectName: var objectName } &&
objectName.StartsWith("wonText", StringComparison.Ordinal))
{
continue;
}
if (mutation is PlayoutSetAssetValue)
{
continue;
}
mutations.Add(mutation);
}
}
private static void AddDetailMutations(
ICollection<PlayoutMutation> mutations,
S5011DetailData detail)

View File

@@ -173,9 +173,10 @@ public sealed class RegisteredLegacySceneCueProvider :
}
var builderMutations = _builders.Build(definition.BuilderKey, loaded.Data);
var mutations = new List<PlayoutMutation>(builderMutations.Count + 2);
var assetMutations = ApplyAssetContract(entry.CutCode, builderMutations);
var mutations = new List<PlayoutMutation>(assetMutations.Count + 2);
AddBackgroundMutations(mutations, options);
mutations.AddRange(builderMutations);
mutations.AddRange(assetMutations);
return new LegacySceneCuePage(
new PlayoutCue(
@@ -250,6 +251,47 @@ public sealed class RegisteredLegacySceneCueProvider :
_ => throw new LegacySceneDataException("The scene catalog contains an invalid page size.")
};
private static IReadOnlyList<PlayoutMutation> ApplyAssetContract(
string cutCode,
IReadOnlyList<PlayoutMutation> mutations)
{
if (string.Equals(cutCode, "5032", StringComparison.Ordinal))
{
return mutations.Where(static mutation => mutation switch
{
PlayoutSetAssetValue { ObjectName: "pattern" } => false,
PlayoutSetVisible visible when IsAbsent5032Decoration(visible.ObjectName) => false,
_ => true
}).ToArray();
}
if (!string.Equals(cutCode, "8032", StringComparison.Ordinal))
{
return mutations;
}
return mutations.Where(static mutation => mutation switch
{
PlayoutSetAssetValue { ObjectName: "pattern1" or "pattern2" } => false,
PlayoutSetValue { ObjectName: "wonText1" or "wonText2" } => false,
PlayoutSetVisible visible when IsAbsent8032Variant(visible.ObjectName) => false,
_ => true
}).ToArray();
}
private static bool IsAbsent8032Variant(string objectName) => objectName is
"percent1_1" or "percent2_1" or "percent3_1" or
"percent1_2" or "percent2_2" or "percent3_2" or
"wonText1_1" or "wonText2_1" or "wonText3_1" or
"wonText1_2" or "wonText2_2" or "wonText3_2";
private static bool IsAbsent5032Decoration(string objectName) => objectName is
"bg1_" or "changePrice1_" or "wonText1_" or "rate1_" or "percent1_" or
"bg2_" or "changePrice2_" or "wonText2_" or "rate2_" or "percent2_" or
"bg3_" or "changePrice3_" or "wonText3_" or "rate3_" or "percent3_" or
"unit1_" or "unit2_" or "unit3_" or
"price1_" or "price2_" or "price3_";
internal static LegacySceneCueCompositionOptions ValidateOptions(
LegacySceneCueCompositionOptions options)
{

View File

@@ -354,10 +354,11 @@ public sealed class S5037SceneMutationBuilder : ILegacySceneMutationBuilder<S503
var buys = NormalizeDealers(data.BuyDealers, nameof(data.BuyDealers));
var sells = NormalizeDealers(data.SellDealers, nameof(data.SellDealers));
// The authoritative 5037.t2s bakes the unit label into the artwork and
// does not expose the legacy code's "unit" object.
var mutations = new List<PlayoutMutation>(44)
{
new PlayoutSetValue("title", stockName),
new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"),
new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)),
new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)),
new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture))
@@ -457,10 +458,11 @@ public sealed class S8003SceneMutationBuilder : ILegacySceneMutationBuilder<S800
var buys = NormalizeLevels(data.BuyLevels, nameof(data.BuyLevels));
var sells = NormalizeLevels(data.SellLevels, nameof(data.SellLevels));
// The authoritative 8003.t2s bakes the unit label into the artwork and
// does not expose the legacy code's "unit" object.
var mutations = new List<PlayoutMutation>(40)
{
new PlayoutSetValue("title", stockName),
new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"),
new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)),
new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)),
new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture))

View File

@@ -4,6 +4,10 @@ using CoreMovingAverageSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection;
using CoreMovingAverageSource =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource;
using CoreSceneSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection;
using S5011DetailBranch =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -403,8 +407,17 @@ public sealed class LegacyOperatorController
bool shift,
int x,
int y,
long timestampMilliseconds)
long timestampMilliseconds,
bool allowAppend = true)
{
if (!allowAppend)
{
// Selection remains available while a scene is prepared or on air, but
// an on-air click must neither activate a cut nor prime the next IDLE
// click as the second half of a legacy pointer double-click.
_previousPointerTimestamp = null;
}
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
{
return CreateSnapshot();
@@ -413,7 +426,8 @@ public sealed class LegacyOperatorController
var gap = _previousPointerTimestamp is long previousTimestamp
? timestampMilliseconds - previousTimestamp
: long.MaxValue;
if (_previousPointerTimestamp is not null &&
if (allowAppend &&
_previousPointerTimestamp is not null &&
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
_previousPointerRow == physicalIndex &&
_previousPointerX == x &&
@@ -425,7 +439,7 @@ public sealed class LegacyOperatorController
}
else
{
_previousPointerTimestamp = timestampMilliseconds;
_previousPointerTimestamp = allowAppend ? timestampMilliseconds : null;
}
_previousPointerRow = physicalIndex;
@@ -3409,7 +3423,18 @@ public sealed class LegacyOperatorController
var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync(
cancellationToken).ConfigureAwait(false);
var loadedRows = namedPlaylist.Rows
.Select(CreateNamedPlaylistOperatorRow)
.Select(row =>
{
try
{
return CreateNamedPlaylistOperatorRow(row);
}
catch (InvalidDataException exception)
{
exception.Data["NamedPlaylistItemIndex"] = row.ItemIndex;
throw;
}
})
.ToArray();
_playlist.Clear();
@@ -3422,10 +3447,13 @@ public sealed class LegacyOperatorController
{
throw;
}
catch (InvalidDataException)
catch (InvalidDataException exception)
{
var rowNumber = exception.Data["NamedPlaylistItemIndex"] is int itemIndex
? $" {itemIndex + 1}행에"
: string.Empty;
_dialog = new LegacyDialogState(
"DB 재생목록 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
$"DB 재생목록{rowNumber} 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Error;
}
@@ -4176,12 +4204,33 @@ public sealed class LegacyOperatorController
{
var selection = row.Selection;
var sceneAlias = ResolveNamedPlaylistSceneAlias(selection);
var playoutSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode);
var playoutSelection = TryCreateNamedFixedSelection(
selection,
out var fixedSelection,
out _)
? fixedSelection
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
? stockDetailSelection
: TryCreateNamedIndustrySelection(selection, out var industrySelection)
? industrySelection
: TryCreateNamedComparisonSelection(selection, out var comparisonSelection)
? comparisonSelection
: TryCreateNamedManualFinancialSelection(
selection,
out var manualFinancialSelection)
? manualFinancialSelection
: TryCreateNamedManualNetSellSelection(
selection,
out var manualNetSellSelection)
? manualNetSellSelection
: TryCreateNamedVersionedViSelection(selection, out var viSelection)
? viSelection
: new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode);
var rawCutLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
@@ -4226,26 +4275,9 @@ public sealed class LegacyOperatorController
}
}
var fixedMarket = selection.GroupCode switch
if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction))
{
"해외지수" => LegacyFixedMarket.Overseas,
"환율" => LegacyFixedMarket.Exchange,
"주요지수" => LegacyFixedMarket.Index,
_ => (LegacyFixedMarket?)null
};
if (fixedMarket is { } market)
{
var actions = _fixedCatalog.GetActions(market)
.Where(action =>
string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) &&
string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length == 1)
{
return actions[0].Code;
}
return fixedAction!.Code;
}
var industryMarket = selection.GroupCode switch
@@ -4294,6 +4326,23 @@ public sealed class LegacyOperatorController
return "5074";
}
// FSell rows are saved with the closed canonical selection emitted by the
// native manual-list workflow. Older PList rows use the original visible
// seven-field identity; the scene resolver accepts both shapes and performs
// the fresh trusted-file read again before any cue can reach Tornado2.
if (TryCreateNamedManualNetSellSelection(selection, out _))
{
return "5025";
}
// A versioned VI row carries no path or operator-controlled scene name. The
// native cue provider reopens the trusted snapshot and requires this exact
// version at PREPARE, so alias restoration remains fail-closed.
if (TryCreateNamedVersionedViSelection(selection, out _))
{
return "5074";
}
if (string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
{
var alias = selection.GraphicType switch
@@ -4327,6 +4376,474 @@ public sealed class LegacyOperatorController
"The named-playlist row does not map to a registered scene alias.");
}
private bool TryCreateNamedFixedSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized,
out LegacyFixedAction? action)
{
normalized = null!;
action = null;
var fixedMarket = selection.GroupCode switch
{
"해외지수" => LegacyFixedMarket.Overseas,
"환율" => LegacyFixedMarket.Exchange,
"주요지수" => LegacyFixedMarket.Index,
_ => (LegacyFixedMarket?)null
};
if (fixedMarket is not { } market)
{
return false;
}
var matches = _fixedCatalog.GetActions(market)
.Where(candidate =>
string.Equals(candidate.Label, selection.Subject, StringComparison.Ordinal) &&
string.Equals(candidate.Section, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(candidate.Detail, selection.Subtype, StringComparison.Ordinal))
.Take(2)
.ToArray();
if (matches.Length != 1 || !matches[0].Available)
{
return false;
}
action = matches[0];
var materialized = _fixedCatalog.Materialize(action.Id, DateTimeOffset.Now).Selection;
normalized = new CoreSceneSelection(
materialized.GroupCode,
materialized.Subject,
materialized.GraphicType,
materialized.Subtype,
selection.DataCode);
return true;
}
private static bool TryCreateNamedStockDetailSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.GroupCode is not ("코스피" or "코스닥") ||
!string.Equals(selection.GraphicType, "1열판상세", StringComparison.Ordinal))
{
return false;
}
var branch = selection.Subtype switch
{
"액면가" => nameof(S5011DetailBranch.FaceValue),
"PBR" => nameof(S5011DetailBranch.Valuation),
"거래량" => nameof(S5011DetailBranch.Volume),
_ => null
};
if (branch is null)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
200,
nameof(selection.Subject));
LegacyComparisonValueGuard.RequireStockCode(
selection.DataCode,
nameof(selection.DataCode));
}
catch (ArgumentException)
{
return false;
}
normalized = new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
branch,
selection.DataCode);
return true;
}
private static bool TryCreateNamedIndustrySelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
var market = selection.GroupCode switch
{
"업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
"업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
_ => (MMoneyCoderSharp.Data.IndustryMarket?)null
};
if (market is not { } exactMarket)
{
return false;
}
var rawLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
var actions = LegacyIndustryActionCatalog.GetActions(exactMarket)
.Where(action => string.Equals(action.Label, rawLabel, StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length != 1)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
256,
nameof(selection.Subject));
}
catch (ArgumentException)
{
return false;
}
var action = actions[0];
var industryGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "INDUSTRY_KOSPI"
: "INDUSTRY_KOSDAQ";
var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "KOSPI"
: "KOSDAQ";
normalized = action.Kind switch
{
LegacyIndustryActionKind.Single => new CoreSceneSelection(
industryGroup,
selection.Subject,
"1열판",
"CURRENT",
string.Empty),
LegacyIndustryActionKind.Pair => new CoreSceneSelection(
industryGroup,
selection.Subject,
"2열판",
string.Empty,
string.Empty),
LegacyIndustryActionKind.Yield => new CoreSceneSelection(
industryGroup,
selection.Subject,
"YIELD_GRAPH",
action.YieldPeriod!,
string.Empty),
LegacyIndustryActionKind.Candle => new CoreSceneSelection(
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "KOSPI_INDUSTRY"
: "KOSDAQ_INDUSTRY",
selection.Subject,
string.Empty,
action.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE",
string.Empty),
LegacyIndustryActionKind.FiveRow when
DateOnly.TryParseExact(
selection.DataCode,
"yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out _) => new CoreSceneSelection(
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "PAGED_DOMESTIC_KOSPI"
: "PAGED_DOMESTIC_KOSDAQ",
"INDEX",
string.Empty,
"INDUSTRY",
selection.DataCode),
LegacyIndustryActionKind.Square => new CoreSceneSelection(
industryGroup,
marketName,
"네모그래프",
string.Empty,
string.Empty),
LegacyIndustryActionKind.Sector => new CoreSceneSelection(
industryGroup,
marketName,
"SECTOR_INDEX",
string.Empty,
string.Empty),
_ => null!
};
return normalized is not null;
}
private static bool TryCreateNamedComparisonSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (!string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
{
return false;
}
var names = selection.Subject.Split(" ↔ ", StringSplitOptions.None);
if (names.Length != 2 || names.Any(string.IsNullOrWhiteSpace) ||
names.Any(name => !string.Equals(name, name.Trim(), StringComparison.Ordinal)))
{
return false;
}
LegacyComparisonTarget first;
LegacyComparisonTarget second;
if (selection.DataCode.Length == 0)
{
first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
new LegacyComparisonStoredTargetReference(names[0], string.Empty))!;
second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
new LegacyComparisonStoredTargetReference(names[1], string.Empty))!;
if (first is null || second is null)
{
return false;
}
}
else if (!TryCreateNamedComparisonStocks(
names,
selection.DataCode,
out first,
out second))
{
return false;
}
var actions = LegacyComparisonWorkflowCatalog.Actions
.Where(action => string.Equals(
action.Label,
selection.Subtype,
StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length != 1)
{
return false;
}
try
{
var draft = LegacyComparisonWorkflowCatalog.Materialize(
actions[0].ActionId,
first,
second,
"named-restore");
if (!string.Equals(draft.BuilderKey, selection.GraphicType, StringComparison.Ordinal) ||
!string.Equals(draft.Detail, selection.Subtype, StringComparison.Ordinal) ||
!string.Equals(
draft.Selection.DataCode,
selection.DataCode,
StringComparison.Ordinal))
{
return false;
}
normalized = draft.Selection;
return true;
}
catch (Exception exception) when (exception is
ArgumentException or InvalidOperationException)
{
return false;
}
}
private static bool TryCreateNamedComparisonStocks(
IReadOnlyList<string> names,
string dataCode,
out LegacyComparisonTarget first,
out LegacyComparisonTarget second)
{
first = null!;
second = null!;
var identities = dataCode.Split('|', StringSplitOptions.None);
if (identities.Length != 2)
{
return false;
}
try
{
var targets = new LegacyComparisonTarget[2];
for (var index = 0; index < targets.Length; index++)
{
var separator = identities[index].IndexOf(':');
if (separator <= 0 ||
separator != identities[index].LastIndexOf(':') ||
separator == identities[index].Length - 1)
{
return false;
}
var market = identities[index][..separator] switch
{
"KOSPI" => LegacyComparisonDomesticMarket.Kospi,
"KOSDAQ" => LegacyComparisonDomesticMarket.Kosdaq,
_ => (LegacyComparisonDomesticMarket?)null
};
if (market is not { } exactMarket)
{
return false;
}
targets[index] = LegacyComparisonTarget.CreateKrxStock(
exactMarket,
names[index],
identities[index][(separator + 1)..]);
}
first = targets[0];
second = targets[1];
return true;
}
catch (ArgumentException)
{
return false;
}
}
private static bool TryCreateNamedManualFinancialSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.GroupCode is not
("KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ"))
{
return false;
}
var definitions = LegacyManualFinancialWorkflow.Screens
.Where(definition =>
string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(
definition.CanonicalGraphicType,
selection.Subtype,
StringComparison.Ordinal))
.Take(2)
.ToArray();
if (definitions.Length != 1)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
200,
nameof(selection.Subject));
LegacyComparisonValueGuard.RequireStockCode(
selection.DataCode,
nameof(selection.DataCode));
}
catch (ArgumentException)
{
return false;
}
normalized = new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
definitions[0].CanonicalGraphicType,
string.Empty,
string.Empty);
return true;
}
private static bool TryCreateNamedManualNetSellSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.DataCode.Length != 0)
{
return false;
}
string? audience = null;
if (selection.GroupCode is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION" &&
string.Equals(selection.Subject, "INDEX", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "MANUAL_NET_SELL", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "MANUAL_NET_SELL", StringComparison.Ordinal))
{
audience = selection.GroupCode;
}
else if (string.Equals(selection.Subject, "지수", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
{
audience = selection.GroupCode switch
{
"개인 순매도 상위(수동)" => "INDIVIDUAL",
"외국인 순매도 상위(수동)" => "FOREIGN",
"기관 순매도 상위(수동)" => "INSTITUTION",
_ => null
};
}
else if (string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
{
audience = selection.Subject switch
{
"개인 순매도 상위(수동)" => "INDIVIDUAL",
"외국인 순매도 상위(수동)" => "FOREIGN",
"기관 순매도 상위(수동)" => "INSTITUTION",
_ => null
};
}
if (audience is null)
{
return false;
}
normalized = new CoreSceneSelection(
audience,
"INDEX",
"MANUAL_NET_SELL",
"MANUAL_NET_SELL",
string.Empty);
return true;
}
private static bool TryCreateNamedVersionedViSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
var canonical = string.Equals(selection.GroupCode, "PAGED_VI", StringComparison.Ordinal) &&
string.Equals(
selection.Subject,
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "INPUT_ORDER", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "CURRENT", StringComparison.Ordinal);
var savedVisibleFields = string.Equals(
selection.GroupCode,
"지수",
StringComparison.Ordinal) &&
selection.Subject.Length > 0 &&
string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "VI 발동(수동)", StringComparison.Ordinal);
if ((!canonical && !savedVisibleFields) ||
!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.IsRowVersion(
selection.DataCode))
{
return false;
}
normalized = new CoreSceneSelection(
"PAGED_VI",
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
"INPUT_ORDER",
"CURRENT",
selection.DataCode);
return true;
}
private LegacyOperatorSnapshot CreateSnapshot()
{
var searchRows = _searchData.DisplayNames

View File

@@ -957,11 +957,7 @@ public sealed class LegacyOverseasSelectionWorkflow
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 '-');
LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(value);
private static bool IsSafeText(string value)
{

View File

@@ -209,17 +209,38 @@ public static class LegacyStockCutCatalog
throw UnknownCut(cut.RawLabel);
}
var marketText = identity?.MarketText ?? string.Empty;
var graphicType = cut.RawLabel[..split];
var subtype = cut.RawLabel[(split + 1)..];
var stockCode = identity?.StockCode ?? string.Empty;
var runtimeDetailBranch = cut.RawLabel switch
{
"1열판상세_액면가" => nameof(S5011DetailBranch.FaceValue),
"1열판상세_PBR" => nameof(S5011DetailBranch.Valuation),
"1열판상세_거래량" => nameof(S5011DetailBranch.Volume),
_ => null
};
var playoutSelection = runtimeDetailBranch is null
? null
: new LegacySceneSelection(
marketText,
stockName,
graphicType,
runtimeDetailBranch,
stockCode);
return new LegacyOperatorPlaylistRow(
rowId,
true,
identity?.MarketText ?? string.Empty,
marketText,
stockName,
cut.RawLabel[..split],
cut.RawLabel[(split + 1)..],
graphicType,
subtype,
"1/1",
identity?.StockCode ?? string.Empty,
stockCode,
cut.RawLabel,
fadeDuration);
fadeDuration,
PlayoutSelection: playoutSelection);
}
private static IReadOnlyList<LegacyCutCatalogRow> CreateRows()

View File

@@ -22,7 +22,8 @@ public sealed record LegacyCutPointerDownIntent(
bool Shift,
int X,
int Y,
long TimestampMilliseconds) : LegacyUiIntent;
long TimestampMilliseconds,
bool AllowAppend) : LegacyUiIntent;
public sealed record LegacyCutKeyDownIntent(
LegacyCutKeyboardKey Key,
@@ -1221,7 +1222,8 @@ public static class LegacyBridgeProtocol
"shift",
"x",
"y",
"timestampMilliseconds") ||
"timestampMilliseconds",
"allowAppend") ||
!TryInt32(
payload,
"physicalIndex",
@@ -1232,7 +1234,8 @@ public static class LegacyBridgeProtocol
!TryBoolean(payload, "shift", out var shift) ||
!TryInt32(payload, "x", 0, 16_384, out var x) ||
!TryInt32(payload, "y", 0, 16_384, out var y) ||
!TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp))
!TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp) ||
!TryBoolean(payload, "allowAppend", out var allowAppend))
{
return null;
}
@@ -1243,7 +1246,8 @@ public static class LegacyBridgeProtocol
shift,
x,
y,
timestamp);
timestamp,
allowAppend);
}
private static LegacyUiIntent? ParseDrop(JsonElement payload) =>

View File

@@ -0,0 +1,191 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow
{
private static readonly TimeSpan DatabaseHealthPollingInterval =
TimeSpan.FromSeconds(10);
private readonly SemaphoreSlim _databaseActivityGate = new(1, 1);
private readonly INativeOperatorLogWriter _databaseHealthLogWriter =
new NativeOperatorLogWriter();
private readonly NativeOperatorLogTransitionTracker _databaseHealthTransitions =
new();
private int _databaseActivityPriorityWaiterCount;
private CancellationTokenSource? _databaseHealthCheckCancellation;
private Task? _databaseHealthMonitorTask;
private void StartDatabaseHealthMonitor()
{
_databaseHealthMonitorTask ??=
MonitorDatabaseHealthAsync(_lifetimeCancellation.Token);
}
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
// WinForms timerAliveChecker first fired after its 10-second interval.
await Task.Delay(DatabaseHealthPollingInterval, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
try
{
await CheckAndLogDatabaseHealthAsync(cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
}
}
private async Task CheckAndLogDatabaseHealthAsync(CancellationToken cancellationToken)
{
if (Volatile.Read(ref _playoutBusy) != 0 ||
Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0 ||
!await _databaseActivityGate.WaitAsync(0, cancellationToken)
.ConfigureAwait(false))
{
return;
}
CancellationTokenSource? healthCancellation = null;
try
{
if (Volatile.Read(ref _playoutBusy) != 0)
{
return;
}
if (_databaseRuntime is null)
{
ObserveDatabaseHealthState(
DataSourceKind.Oracle,
DatabaseHealthState.NotConfigured);
ObserveDatabaseHealthState(
DataSourceKind.MariaDb,
DatabaseHealthState.NotConfigured);
return;
}
healthCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
Interlocked.Exchange(
ref _databaseHealthCheckCancellation,
healthCancellation);
// Close the race where an operator command starts after this monitor
// acquired the activity gate but before the provider probes begin.
if (Volatile.Read(ref _playoutBusy) != 0 ||
Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0)
{
healthCancellation.Cancel();
return;
}
var statuses = await _databaseRuntime.HealthService
.CheckAllAsync(healthCancellation.Token)
.ConfigureAwait(false);
foreach (var status in statuses)
{
ObserveDatabaseHealthState(status.Source, status.State);
}
}
catch (OperationCanceledException) when (
cancellationToken.IsCancellationRequested ||
healthCancellation?.IsCancellationRequested == true)
{
// Shutdown and operator work both have priority over diagnostics.
}
catch
{
ObserveDatabaseHealthState(
DataSourceKind.Oracle,
DatabaseHealthState.Unhealthy);
ObserveDatabaseHealthState(
DataSourceKind.MariaDb,
DatabaseHealthState.Unhealthy);
}
finally
{
if (healthCancellation is not null)
{
Interlocked.CompareExchange(
ref _databaseHealthCheckCancellation,
null,
healthCancellation);
healthCancellation.Dispose();
}
_databaseActivityGate.Release();
}
}
private async Task AcquirePriorityDatabaseActivityAsync(
CancellationToken cancellationToken)
{
// Signal priority before inspecting the active probe. This closes both
// sides of the acquisition/publish race:
// * a probe that has not acquired the gate sees the waiter and yields;
// * a probe that owns the gate either sees the waiter after publishing
// its CTS or is cancelled through that already-published CTS.
Interlocked.Increment(ref _databaseActivityPriorityWaiterCount);
try
{
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(cancellationToken)
.ConfigureAwait(false);
}
finally
{
Interlocked.Decrement(ref _databaseActivityPriorityWaiterCount);
}
}
private void CancelActiveDatabaseHealthCheck()
{
var healthCancellation = Volatile.Read(ref _databaseHealthCheckCancellation);
try
{
healthCancellation?.Cancel();
}
catch (ObjectDisposedException)
{
// Probe completion won the race after the reference was read.
}
}
private void ObserveDatabaseHealthState(
DataSourceKind source,
DatabaseHealthState state)
{
if (!_databaseHealthTransitions.TryObserveDatabaseState(
source,
state,
out var record))
{
return;
}
try
{
_ = _databaseHealthLogWriter.TryWrite(record);
}
catch
{
// Logging is best effort and cannot affect DB or playout work.
}
}
}

View File

@@ -178,8 +178,9 @@ public sealed partial class MainWindow
{
case LegacyOperatorPlayoutCommand.Prepare:
var request = gateAPrepareRequest ??
_controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration);
await CreateFreshPagedPlayoutRequestAsync(
workflow,
cancellationToken).ConfigureAwait(false);
result = await workflow.PrepareAsync(
request.Playlist,
request.SelectedIndexZeroBased,
@@ -190,8 +191,9 @@ public sealed partial class MainWindow
// candidate) to m_crow and ran ONAirMode + Play, even while another
// row was already on air. Re-read the current selection on every F8;
// a stale explicit PREPARE must never override a later row click.
var takeInRequest = _controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration);
var takeInRequest = await CreateFreshPagedPlayoutRequestAsync(
workflow,
cancellationToken).ConfigureAwait(false);
result = await workflow.TakeSelectedAsync(
takeInRequest.Playlist,
takeInRequest.SelectedIndexZeroBased,
@@ -280,6 +282,61 @@ public sealed partial class MainWindow
}
}
private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(
LegacyPlayoutWorkflow workflow,
CancellationToken cancellationToken)
{
var fadeDuration = _compositionOptions!.Current.FadeDuration;
var request = _controller.CreatePlayoutRequest(fadeDuration);
// The first PREPARE/TAKE IN freezes the complete playlist. Resolve every
// operator PageN row from a fresh read-only DB preflight before that snapshot
// is handed to the workflow. A later F8 while PREPARED/PROGRAM reuses the
// already-frozen 1/N values and must not query a second navigation contract.
if (workflow.State.CurrentEntryId is not null)
{
return request;
}
var pagedEntries = request.Playlist
.Where(static entry => entry.PageNavigation?.PageSize.HasValue == true)
.ToArray();
if (pagedEntries.Length == 0)
{
return request;
}
var plans = await workflow.PreflightPagePlansAsync(
Array.AsReadOnly(pagedEntries),
cancellationToken).ConfigureAwait(false);
if (plans.Count != pagedEntries.Length)
{
throw new LegacySceneDataException(
"The playlist page preflight did not return every PageN entry.");
}
foreach (var plan in plans)
{
var entry = pagedEntries.SingleOrDefault(candidate =>
string.Equals(candidate.EntryId, plan.EntryId, StringComparison.Ordinal));
if (entry?.PageNavigation?.PageSize is not { } expectedPageSize ||
plan.PageSize != (int)expectedPageSize ||
plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
{
throw new LegacySceneDataException(
"The playlist page preflight does not match the operator PageN row.");
}
_controller.ReflectSuccessfulPlayout(
plan.EntryId,
pageIndexZeroBased: 0,
pageCount: plan.PageCount,
synchronizeOperatorSelection: false);
}
return _controller.CreatePlayoutRequest(fadeDuration);
}
private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(
string capability,
CancellationToken cancellationToken)
@@ -596,9 +653,7 @@ public sealed partial class MainWindow
}
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
LegacyCutPointerDownIntent or
LegacyCutKeyDownIntent or
LegacyClearCutSelectionIntent or
LegacyCutPointerDownIntent { AllowAppend: true } or
LegacyDropSelectedCutsIntent or
LegacySetPlaylistEnabledIntent or
LegacySetAllPlaylistEnabledIntent or
@@ -948,6 +1003,7 @@ public sealed partial class MainWindow
}
var intentGateEntered = false;
var databaseGateEntered = false;
var playoutGateEntered = false;
try
{
@@ -958,6 +1014,9 @@ public sealed partial class MainWindow
_intentBusy = true;
QueueOperatorState();
await AcquirePriorityDatabaseActivityAsync(token).ConfigureAwait(false);
databaseGateEntered = true;
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
playoutGateEntered = true;
Interlocked.Exchange(ref _playoutBusy, 1);
@@ -1058,6 +1117,11 @@ public sealed partial class MainWindow
_playoutCommandGate.Release();
}
if (databaseGateEntered)
{
_databaseActivityGate.Release();
}
if (intentGateEntered)
{
_intentBusy = false;

View File

@@ -22,6 +22,7 @@ public sealed partial class MainWindow : Window
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorController _controller;
@@ -241,6 +242,7 @@ public sealed partial class MainWindow : Window
await InitializeWebViewAsync();
if (!_closing)
{
StartDatabaseHealthMonitor();
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
}
}
@@ -380,15 +382,35 @@ public sealed partial class MainWindow : Window
private async Task ProcessIntentAsync(LegacyUiIntent intent)
{
var gateEntered = false;
var orderedPlayoutIntentEntered = false;
var databaseGateEntered = false;
var dispatchStarted = false;
try
{
// MainForm performed database search synchronously. The busy snapshot
// freezes the Web controls, while this zero-timeout gate prevents an
// already-posted duplicate from growing an unbounded DB request queue.
gateEntered = await _intentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
// freezes the Web controls. Playout commands must retain their order behind
// a preceding local row-selection intent, but only one may wait or execute
// at a time so a double post can never become a queued vendor command.
if (IsOrderedPlayoutIntent(intent))
{
orderedPlayoutIntentEntered = await _orderedPlayoutIntentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
if (orderedPlayoutIntentEntered)
{
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
}
else
{
// Keep the zero-timeout admission used by database and edit intents;
// those requests must not build an unbounded queue behind slow I/O.
gateEntered = await _intentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
}
if (!gateEntered)
{
PostState(IsNamedPlaylistModalIntent(intent)
@@ -514,6 +536,13 @@ public sealed partial class MainWindow : Window
PostState(_controller.Current);
}
await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token);
databaseGateEntered = true;
if (_closing)
{
return;
}
dispatchStarted = true;
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
ObserveOperatorMutationQuarantine(state);
@@ -546,13 +575,26 @@ public sealed partial class MainWindow : Window
_intentBusy = false;
}
if (databaseGateEntered)
{
_databaseActivityGate.Release();
}
if (gateEntered)
{
_intentGate.Release();
}
if (orderedPlayoutIntentEntered)
{
_orderedPlayoutIntentGate.Release();
}
}
}
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
intent is LegacyRefreshNamedPlaylistsIntent or
LegacySelectNamedPlaylistIntent or
@@ -648,7 +690,8 @@ public sealed partial class MainWindow : Window
pointer.Shift,
pointer.X,
pointer.Y,
pointer.TimestampMilliseconds),
pointer.TimestampMilliseconds,
pointer.AllowAppend),
LegacyCutKeyDownIntent key => _controller.CutKeyDown(
key.Key,
key.Control,
@@ -1347,6 +1390,7 @@ public sealed partial class MainWindow : Window
DetachCloseConfirmation();
DetachInteractionMetricsRefresh();
_lifetimeCancellation.Cancel();
CancelActiveDatabaseHealthCheck();
ShutdownPlayoutRuntime();
ShutdownManualListsRuntime();
DataQueryExecutor.Reset();

View File

@@ -6,6 +6,7 @@
const searchButton = document.getElementById("stock-search-button");
const stockResults = document.getElementById("stock-results");
const cutList = document.getElementById("cut-list");
const cutDragFeedback = document.getElementById("cut-drag-feedback");
const playlistRows = document.getElementById("playlist-rows");
const playlistDropZone = document.getElementById("playlist-drop-zone");
const playlistMoveUp = document.getElementById("playlist-move-up");
@@ -123,6 +124,7 @@
let operatorCatalogDraftKey = "";
let manualResultClickTimer = null;
let comparisonTargetClickTimer = null;
let themeResultClickTimer = null;
let manualViResultClickTimer = null;
let operatorCatalogStockClickTimer = null;
let pendingNamedPlaylistCommand = null;
@@ -357,6 +359,8 @@
treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement);
if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
if (nextTabId !== renderedTreeTabId) {
clearDelayedClick(themeResultClickTimer);
themeResultClickTimer = null;
renderedTreeTabId = nextTabId;
pendingTreeResetTabs.clear();
if (nextTabId) pendingTreeResetTabs.add(nextTabId);
@@ -461,6 +465,14 @@
playout.isTakeOutCompletionPending === true || playout.phase !== "idle"));
}
function isCutSelectionLocked(state) {
const playout = state && state.playout;
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
playout.isPlayCompletionPending === true ||
playout.isTakeOutCompletionPending === true));
}
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after";
@@ -669,10 +681,15 @@
function clearCutDragGesture(releaseCapture) {
const gesture = cutDragGesture;
if (!gesture) return;
cutDragGesture = null;
gesture.element.classList.remove("dragging");
playlistDropZone.classList.remove("cut-drag-over");
cutDragFeedback.hidden = true;
cutDragFeedback.classList.remove("allowed");
delete cutDragFeedback.dataset.dropAllowed;
cutDragFeedback.style.removeProperty("left");
cutDragFeedback.style.removeProperty("top");
if (!gesture) return;
gesture.element.classList.remove("dragging");
if (releaseCapture !== false &&
typeof gesture.element.hasPointerCapture === "function" &&
gesture.element.hasPointerCapture(gesture.pointerId) &&
@@ -685,6 +702,21 @@
}
}
function showCutDragFeedback(event, allowed) {
cutDragFeedback.hidden = false;
cutDragFeedback.classList.toggle("allowed", allowed);
cutDragFeedback.dataset.dropAllowed = allowed ? "true" : "false";
const gap = 14;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const width = cutDragFeedback.offsetWidth;
const height = cutDragFeedback.offsetHeight;
const left = Math.max(4, Math.min(event.clientX + gap, viewportWidth - width - 4));
const top = Math.max(4, Math.min(event.clientY + gap, viewportHeight - height - 4));
cutDragFeedback.style.left = Math.round(left) + "px";
cutDragFeedback.style.top = Math.round(top) + "px";
}
function beginCutDragGesture(element, event, position) {
clearCutDragGesture();
if (event.button !== 0) return;
@@ -719,9 +751,10 @@
}
if (gesture.started) {
event.preventDefault();
playlistDropZone.classList.toggle(
"cut-drag-over",
!playlistMutationLocked && pointInsideElement(playlistDropZone, event));
const dropAllowed = !playlistMutationLocked &&
pointInsideElement(playlistDropZone, event);
playlistDropZone.classList.toggle("cut-drag-over", dropAllowed);
showCutDragFeedback(event, dropAllowed);
}
}
@@ -790,7 +823,6 @@
element.append(number, label);
element.addEventListener("pointerdown", function (event) {
if (playlistMutationLocked) return event.preventDefault();
if (!cutSelectionEnabled) return event.preventDefault();
focusCutElement(element, false);
const position = pointerPosition(event);
@@ -802,9 +834,12 @@
// equality contract. Drag geometry keeps the raw fractional CSS point.
x: Math.round(position.x),
y: Math.round(position.y),
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)),
allowAppend: !playlistMutationLocked
});
beginCutDragGesture(element, event, position);
if (!playlistMutationLocked) {
beginCutDragGesture(element, event, position);
}
});
element.addEventListener("pointermove", onCutPointerMove);
element.addEventListener("pointerup", onCutPointerUp);
@@ -816,7 +851,6 @@
// The native ListView consumes these keys. Home/End still bubble to the
// document-level MainForm KeyPreview equivalent for its playlist side effect.
event.preventDefault();
if (playlistMutationLocked) return;
if (!cutSelectionEnabled) return;
const target = cutKeyboardTarget(element, event.key);
focusCutElement(target, true);
@@ -831,7 +865,8 @@
function renderCuts(state) {
const cutDragLocked = isOperatorMutationLocked(state);
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && !cutDragLocked;
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) &&
!isCutSelectionLocked(state);
const focusedCutIndex = state.cutRows.findIndex(function (row) {
return row.isFocused === true;
});
@@ -864,7 +899,9 @@
? 0
: -1;
element.draggable = false;
element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false";
element.dataset.cutDragEnabled = cutSelectionEnabled && !cutDragLocked
? "true"
: "false";
});
while (cutList.children.length > state.cutRows.length) {
@@ -876,7 +913,7 @@
}
cutList.addEventListener("pointerdown", function (event) {
if (event.target !== cutList || playlistMutationLocked) return;
if (event.target !== cutList || !cutSelectionEnabled) return;
send("clear-cut-selection", {});
});
@@ -4000,6 +4037,8 @@
const themeResult = event.target.closest("button[data-theme-result-id]");
if (themeResult && !themeResult.disabled) {
event.preventDefault();
clearDelayedClick(themeResultClickTimer);
themeResultClickTimer = null;
send("activate-theme-result", {
resultId: themeResult.dataset.themeResultId
});
@@ -4260,7 +4299,12 @@
}
const themeResult = event.target.closest("button[data-theme-result-id]");
if (themeResult) {
send("select-theme", { resultId: themeResult.dataset.themeResultId });
clearDelayedClick(themeResultClickTimer);
const resultId = themeResult.dataset.themeResultId;
themeResultClickTimer = window.setTimeout(function () {
themeResultClickTimer = null;
send("select-theme", { resultId: resultId });
}, 250);
return;
}
if (event.target.closest("button[data-theme-search]")) {

View File

@@ -257,6 +257,11 @@
</div>
</div>
<div id="cut-drag-feedback" class="cut-drag-feedback" aria-hidden="true" hidden>
<span class="cut-drag-feedback-icon"></span>
<span>선택 컷 추가</span>
</div>
<script src="legacy-cut-drag.js"></script>
<script src="legacy-modal-focus.js"></script>
<script src="legacy-named-playlist-selection.js"></script>

View File

@@ -84,7 +84,7 @@ button:disabled { color: #555; opacity: 1; }
.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-workspace { min-height: 0; display: grid; grid-template-rows: auto 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; }
@@ -144,6 +144,13 @@ button:disabled { color: #555; opacity: 1; }
.cut-row[data-cut-drag-enabled="true"] { cursor: grab; }
.cut-row.dragging { opacity: .58; cursor: grabbing; }
.playlist-grid.cut-drag-over { box-shadow: inset 0 0 0 3px #176b2d; }
.cut-drag-feedback { position: fixed; z-index: 10000; display: flex; align-items: center; gap: 7px; max-width: 260px; padding: 7px 10px; border: 1px solid #8a8a8a; border-radius: 4px; background: rgba(255, 255, 255, .97); box-shadow: 0 3px 10px rgba(0, 0, 0, .28); color: #555; font-size: 13px; font-weight: 700; line-height: 1; pointer-events: none; user-select: none; white-space: nowrap; }
.cut-drag-feedback[hidden] { display: none; }
.cut-drag-feedback-icon { display: inline-flex; width: 17px; height: 17px; align-items: center; justify-content: center; border-radius: 50%; background: #777; color: #fff; font-size: 16px; font-weight: 800; }
.cut-drag-feedback-icon::before { content: "×"; }
.cut-drag-feedback.allowed { border-color: #176b2d; color: #125824; }
.cut-drag-feedback.allowed .cut-drag-feedback-icon { background: #176b2d; }
.cut-drag-feedback.allowed .cut-drag-feedback-icon::before { content: "+"; }
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; }