Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs

6844 lines
241 KiB
C#

using S5025ManualAudience =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience;
using CoreMovingAverageSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection;
using CoreMovingAverageSource =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource;
using CorePagePlanProvider =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.ILegacyScenePagePlanProvider;
using CorePlaylistEntry =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry;
using CoreScenePaging =
MBN_STOCK_WEBVIEW.Core.Playout.ScenePaging;
using CoreScenePageSize =
MBN_STOCK_WEBVIEW.Core.Playout.ScenePageSize;
using CoreSceneSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection;
using S5011DetailBranch =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacySearchTrigger
{
Button,
Enter
}
public enum LegacyOperatorStatusKind
{
Neutral,
Information,
Warning,
Error
}
public enum LegacyPlaylistMoveDirection
{
Up,
Down
}
public enum LegacyDragDropPosition
{
Before,
After
}
public enum LegacyCutKeyboardKey
{
ArrowUp,
ArrowDown,
Home,
End,
Space
}
public enum LegacyOperatorTabId
{
Overseas,
Exchange,
Index,
KospiIndustry,
KosdaqIndustry,
Comparison,
Theme,
OverseasStocks,
Expert,
TradingHalt
}
public sealed record LegacyStockResultRow(int Index, string DisplayName);
public sealed record LegacyCutViewRow(
int PhysicalIndex,
int? DisplayOrdinal,
string RawLabel,
bool IsSeparator,
bool IsSelected,
bool IsFocused = false);
public sealed record LegacyDialogState(
string Message,
string Caption = "MmoneyCoder",
bool IsConfirmation = false);
public sealed record LegacyOperatorCommandResult(
long Sequence,
string Command,
string TargetId,
bool Succeeded);
public sealed record LegacyOperatorTabState(
LegacyOperatorTabId Id,
string Label,
bool IsActive);
public sealed record LegacyFixedActionView(
string Id,
string Label,
string Detail,
bool IsAvailable,
string Prerequisite);
public sealed record LegacyFixedSectionView(
int Index,
string Name,
IReadOnlyList<LegacyFixedActionView> Actions);
public sealed record LegacyFixedCatalogView(
LegacyFixedMarket Market,
IReadOnlyList<LegacyFixedSectionView> Sections);
/// <summary>
/// A fixed UC1 parent-node activation is staged until every modal manual child has
/// been confirmed. The playlist remains unchanged while this view is present.
/// </summary>
public sealed record LegacyFixedSectionBatchView(
int SectionIndex,
string SectionName,
int TotalActionCount,
int StagedActionCount,
int ManualStepNumber,
int ManualStepCount,
string PendingActionId,
string PendingActionLabel);
public sealed record LegacyOperatorSnapshot(
long Revision,
string SearchText,
IReadOnlyList<LegacyStockResultRow> SearchResults,
int? SelectedStockIndex,
IReadOnlyList<LegacyCutViewRow> CutRows,
IReadOnlyList<LegacyOperatorPlaylistRow> Playlist,
LegacyDialogState? Dialog,
string StatusMessage,
LegacyOperatorStatusKind StatusKind,
IReadOnlyList<LegacyOperatorTabState>? Tabs = null,
LegacyMovingAverageSelection MovingAverages = default,
LegacyFixedCatalogView? FixedCatalog = null,
LegacyIndustrySelectionSnapshot? Industry = null,
LegacyThemeWorkflowSnapshot? Theme = null,
LegacyExpertWorkflowSnapshot? Expert = null,
LegacyTradingHaltWorkflowSnapshot? TradingHalt = null,
LegacyComparisonWorkflowSnapshot? Comparison = null,
LegacyManualFinancialWorkflowSnapshot? ManualFinancial = null,
LegacyNamedPlaylistWorkflowSnapshot? NamedPlaylist = null,
LegacyOverseasWorkflowSnapshot? Overseas = null,
LegacyManualListsSnapshot? ManualLists = null,
LegacyOperatorCatalogWorkflowSnapshot? OperatorCatalog = null,
LegacyOperatorCommandResult? CommandResult = null,
LegacyFixedSectionBatchView? FixedSectionBatch = null);
/// <summary>
/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends
/// input intent only; it never derives stock identity, cut aliases or playlist fields.
/// </summary>
public sealed class LegacyOperatorController
{
private const long DoubleClickWindowMilliseconds = 500;
private readonly ILegacyStockLookup _stockLookup;
private readonly IReadOnlyList<LegacyCutCatalogRow> _cutRows;
private readonly LegacyFixedActionCatalog _fixedCatalog;
private readonly TimeProvider _timeProvider;
private readonly LegacyIndustrySelectionWorkflow? _industryWorkflow;
private readonly LegacyThemeWorkflow? _themeWorkflow;
private readonly LegacyExpertWorkflowController? _expertWorkflow;
private readonly LegacyTradingHaltWorkflowController? _tradingHaltWorkflow;
private readonly LegacyComparisonWorkflowController? _comparisonWorkflow;
private readonly LegacyManualFinancialWorkflow? _manualFinancialWorkflow;
private readonly LegacyNamedPlaylistWorkflowController? _namedPlaylistWorkflow;
private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow;
private readonly LegacyManualListsWorkflow? _manualListsWorkflow;
private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow;
private readonly CorePagePlanProvider? _fixedPagePlanProvider;
private readonly CoreMovingAverageSource _movingAverageSource = new(
new CoreMovingAverageSelection(true, true));
private readonly SemaphoreSlim _tabActivationGate = new(1, 1);
private readonly SortedSet<int> _legacyPointerRows = [];
private readonly SortedSet<int> _nativeSelectedCutRows = [];
private readonly List<LegacyOperatorPlaylistRow> _playlist = [];
private readonly HashSet<string> _selectedPlaylistRowIds = new(StringComparer.Ordinal);
private readonly List<LegacyOperatorTabId> _tabOrder =
[
LegacyOperatorTabId.Overseas,
LegacyOperatorTabId.Exchange,
LegacyOperatorTabId.Index,
LegacyOperatorTabId.KospiIndustry,
LegacyOperatorTabId.KosdaqIndustry,
LegacyOperatorTabId.Comparison,
LegacyOperatorTabId.Theme,
LegacyOperatorTabId.OverseasStocks,
LegacyOperatorTabId.Expert,
LegacyOperatorTabId.TradingHalt
];
private Queue<int>? _pendingDropCutRows;
private LegacyStockSearchData _searchData = new(string.Empty, [], []);
private int? _selectedStockIndex;
private int? _cutAnchorIndex;
private int _nativeSelectionAnchorIndex;
private int _nativeFocusedCutIndex = -1;
private int? _previousPointerRow;
private int _previousPointerX;
private int _previousPointerY;
private long? _previousPointerTimestamp;
private string? _activePlaylistRowId;
private int _playlistCursorIndex;
private int _playlistPriorCursorIndex;
private long _revision;
private long _playlistSequence;
private string _searchText = string.Empty;
private string _statusMessage = string.Empty;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
private LegacyDialogState? _dialog;
private PendingNativeConfirmation? _pendingNativeConfirmation;
private bool _closeOperatorCatalogAfterDialog;
private LegacyOperatorTabId _activeTab = LegacyOperatorTabId.Overseas;
private LegacyMovingAverageSelection _movingAverages = new(true, true);
private LegacyThemeWorkflowSnapshot? _themeSnapshot;
private LegacyManualFinancialWorkflowSnapshot? _manualFinancialSnapshot;
private bool _industryActivationListLoaded;
private bool _comparisonActivationListLoaded;
private bool _themeActivationListLoaded;
private bool _expertActivationListLoaded;
private long _tabActivationRequest;
private long _commandSequence;
private LegacyOperatorCommandResult? _commandResult;
private PendingFixedSectionBatch? _pendingFixedSectionBatch;
public LegacyOperatorController(
ILegacyStockLookup stockLookup,
LegacyFixedActionCatalog? fixedCatalog = null,
TimeProvider? timeProvider = null,
LegacyIndustrySelectionWorkflow? industryWorkflow = null,
LegacyThemeWorkflow? themeWorkflow = null,
LegacyExpertWorkflowController? expertWorkflow = null,
LegacyTradingHaltWorkflowController? tradingHaltWorkflow = null,
LegacyComparisonWorkflowController? comparisonWorkflow = null,
LegacyManualFinancialWorkflow? manualFinancialWorkflow = null,
LegacyNamedPlaylistWorkflowController? namedPlaylistWorkflow = null,
LegacyOverseasSelectionWorkflow? overseasWorkflow = null,
LegacyManualListsWorkflow? manualListsWorkflow = null,
LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null,
IReadOnlyList<LegacyCutCatalogRow>? cutRows = null,
CorePagePlanProvider? fixedPagePlanProvider = null)
{
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
_cutRows = CreateValidatedCutRows(cutRows);
_fixedCatalog = fixedCatalog ?? LegacyFixedActionCatalog.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
_industryWorkflow = industryWorkflow;
_themeWorkflow = themeWorkflow;
_themeSnapshot = themeWorkflow?.CreateInitial(CurrentThemeNxtSession());
_expertWorkflow = expertWorkflow;
_tradingHaltWorkflow = tradingHaltWorkflow;
_comparisonWorkflow = comparisonWorkflow;
_manualFinancialWorkflow = manualFinancialWorkflow;
_namedPlaylistWorkflow = namedPlaylistWorkflow;
_overseasWorkflow = overseasWorkflow;
_manualListsWorkflow = manualListsWorkflow;
_operatorCatalogWorkflow = operatorCatalogWorkflow;
_fixedPagePlanProvider = fixedPagePlanProvider;
_tradingHaltWorkflow?.SetMovingAverages(
_movingAverages.Ma5,
_movingAverages.Ma20);
_overseasWorkflow?.SetMovingAverages(
_movingAverages.Ma5,
_movingAverages.Ma20);
}
public LegacyOperatorSnapshot Current => CreateSnapshot();
public LegacyOperatorPlayoutRequest CreatePlayoutRequest() =>
CreatePlayoutRequestCore(globalFadeDuration: null);
public LegacyOperatorPlayoutRequest CreatePlayoutRequest(int globalFadeDuration)
{
if (globalFadeDuration is < 0 or > 19)
{
throw new ArgumentOutOfRangeException(
nameof(globalFadeDuration),
"The original DissolveTime index must be between 0 and 19.");
}
return CreatePlayoutRequestCore(globalFadeDuration);
}
private LegacyOperatorPlayoutRequest CreatePlayoutRequestCore(
int? globalFadeDuration)
{
if (_playlist.Count == 0)
{
throw new InvalidOperationException("플레이리스트에 송출할 항목이 없습니다.");
}
var selectedIndex = _activePlaylistRowId is null
? -1
: FindPlaylistIndex(_activePlaylistRowId);
if (selectedIndex < 0)
{
throw new InvalidOperationException(
"플레이리스트의 F8 대상 행이 선택되지 않았습니다.");
}
var entries = _playlist.Select(row =>
{
var entry = row.ToPlayoutEntry();
if (MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
.Supports(entry.CutCode))
{
// Keep the row's seven persisted fields unchanged. The retained source
// is sampled again by the workflow whenever s8010 is rebuilt.
entry = entry with { MovingAverageSelectionSource = _movingAverageSource };
entry = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
.ApplyCurrent(entry);
}
return globalFadeDuration.HasValue
? entry with { FadeDuration = globalFadeDuration.Value }
: entry;
}).ToArray();
return new LegacyOperatorPlayoutRequest(
Array.AsReadOnly(entries),
selectedIndex);
}
/// <summary>
/// Reflects a successfully prepared or played cue back into the operator grid.
/// MainForm stored the current PageN value in column 5 instead of keeping it only
/// in the Tornado workflow. TAKE/PREPARE may move the F8 candidate when a disabled
/// row is skipped, but never rewrites bufdel/m_delcnt or the Shift anchors. NEXT
/// deliberately leaves even that independent candidate alone.
/// </summary>
public LegacyOperatorSnapshot ReflectSuccessfulPlayout(
string entryId,
int pageIndexZeroBased,
int pageCount,
bool synchronizeOperatorSelection)
{
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
if (pageCount <= 0 || pageIndexZeroBased < 0 || pageIndexZeroBased >= pageCount)
{
throw new ArgumentOutOfRangeException(
nameof(pageIndexZeroBased),
"The playout page position is outside the playlist page range.");
}
var index = FindPlaylistIndex(entryId);
if (index < 0)
{
return CreateSnapshot();
}
var changed = false;
var pageText = $"{pageIndexZeroBased + 1}/{pageCount}";
if (!string.Equals(_playlist[index].PageText, pageText, StringComparison.Ordinal))
{
_playlist[index] = _playlist[index] with { PageText = pageText };
changed = true;
}
if (synchronizeOperatorSelection)
{
var rowId = _playlist[index].RowId;
changed |= !string.Equals(_activePlaylistRowId, rowId, StringComparison.Ordinal);
_activePlaylistRowId = rowId;
}
if (changed)
{
Touch();
}
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchStocksAsync(
string text,
LegacySearchTrigger trigger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(text);
_searchText = text;
// button9_Click returned before calling searchStock only for an exactly empty string.
// txtSearch_KeyPress did not have that guard, so blank Enter still queries all rows.
if (trigger == LegacySearchTrigger.Button && text.Length == 0)
{
Touch();
return CreateSnapshot();
}
_searchData = await _stockLookup.SearchAsync(text, cancellationToken).ConfigureAwait(false);
_selectedStockIndex = null;
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
_dialog = null;
_pendingDropCutRows = null;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectStock(int resultIndex)
{
if (resultIndex < 0 || resultIndex >= _searchData.DisplayNames.Count)
{
return CreateSnapshot();
}
_selectedStockIndex = resultIndex;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CutPointerDown(
int physicalIndex,
bool control,
bool shift,
int x,
int y,
long timestampMilliseconds,
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();
}
var gap = _previousPointerTimestamp is long previousTimestamp
? timestampMilliseconds - previousTimestamp
: long.MaxValue;
if (allowAppend &&
_previousPointerTimestamp is not null &&
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
_previousPointerRow == physicalIndex &&
_previousPointerX == x &&
_previousPointerY == y &&
_legacyPointerRows.Count == 1)
{
TryAppendCut(_legacyPointerRows.Min);
_previousPointerTimestamp = null;
}
else
{
_previousPointerTimestamp = allowAppend ? timestampMilliseconds : null;
}
_previousPointerRow = physicalIndex;
_previousPointerX = x;
_previousPointerY = y;
ApplyLegacyPointerSelection(physicalIndex, control, shift);
ApplyNativeListSelection(physicalIndex, control, shift);
_cutAnchorIndex = physicalIndex;
_nativeFocusedCutIndex = physicalIndex;
// Win32 ListView's selection mark remains the fixed anchor across
// consecutive Shift/Ctrl+Shift gestures. Only a non-Shift click moves it.
if (!shift)
{
_nativeSelectionAnchorIndex = physicalIndex;
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CutKeyDown(
LegacyCutKeyboardKey key,
bool control,
bool shift)
{
if (_selectedStockIndex is null || _cutRows.Count == 0)
{
return CreateSnapshot();
}
var focusedIndex = IsCutIndex(_nativeFocusedCutIndex)
? _nativeFocusedCutIndex
: 0;
var targetIndex = key switch
{
LegacyCutKeyboardKey.ArrowUp => Math.Max(0, focusedIndex - 1),
LegacyCutKeyboardKey.ArrowDown => Math.Min(_cutRows.Count - 1, focusedIndex + 1),
LegacyCutKeyboardKey.Home => 0,
LegacyCutKeyboardKey.End => _cutRows.Count - 1,
LegacyCutKeyboardKey.Space => focusedIndex,
_ => throw new ArgumentOutOfRangeException(nameof(key), key, null)
};
_nativeFocusedCutIndex = targetIndex;
if (key == LegacyCutKeyboardKey.Space)
{
ApplyNativeSpaceSelection(targetIndex, control, shift);
}
else
{
ApplyNativeKeyboardNavigationSelection(targetIndex, control, shift);
}
// MainForm's selectedRowsList was populated only by listView1_MouseDown.
// Keyboard selection belongs exclusively to the native ListView state used
// by owner drawing and PlayList_DragDrop, so do not alter the legacy list.
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DropSelectedCutsOnPlaylist()
{
if (_selectedStockIndex is null || _dialog is not null)
{
return CreateSnapshot();
}
_pendingDropCutRows = new Queue<int>(_nativeSelectedCutRows);
ContinuePendingDrop();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ClearCutSelection()
{
if (_selectedStockIndex is null)
{
return CreateSnapshot();
}
var changed = _legacyPointerRows.Count != 0 ||
_nativeSelectedCutRows.Count != 0 ||
_cutAnchorIndex != -1 ||
_previousPointerTimestamp is not null;
_legacyPointerRows.Clear();
_nativeSelectedCutRows.Clear();
_cutAnchorIndex = -1;
// A Win32 ListView blank-area click clears Selected items but preserves
// its selection mark, so a following Shift gesture keeps the old anchor.
_previousPointerTimestamp = null;
_previousPointerRow = -1;
if (changed)
{
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = _playlist.FindIndex(row =>
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
if (index >= 0)
{
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot TogglePlaylistRowEnabled(string rowId)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = FindPlaylistIndex(rowId);
if (index >= 0)
{
_playlist[index] = _playlist[index] with
{
IsEnabled = !_playlist[index].IsEnabled
};
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetAllPlaylistRowsEnabled(bool enabled)
{
if (_playlist.Count == 0)
{
return CreateSnapshot();
}
for (var index = 0; index < _playlist.Count; index++)
{
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectTab(LegacyOperatorTabId tabId)
{
if (_pendingFixedSectionBatch is not null && _activeTab != tabId)
{
return ReportError(
"고정 컷 섹션의 수동 입력은 원본 모달 창과 같이 완료하거나 취소하기 전까지 탭을 바꿀 수 없습니다.");
}
if (!_tabOrder.Contains(tabId) || _activeTab == tabId)
{
return CreateSnapshot();
}
BeginTabActivation(tabId);
_activeTab = tabId;
Interlocked.Increment(ref _tabActivationRequest);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SelectTabAsync(
LegacyOperatorTabId tabId,
CancellationToken cancellationToken = default)
{
SelectTab(tabId);
var activationRequest = Volatile.Read(ref _tabActivationRequest);
await _tabActivationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
var market = GetActiveIndustryMarket();
var touched = false;
if (_industryWorkflow is not null && market is not null &&
!_industryActivationListLoaded)
{
await _industryWorkflow.LoadMarketAsync(market.Value, cancellationToken)
.ConfigureAwait(false);
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
_industryActivationListLoaded = true;
touched = true;
}
if (IsComparisonActive() && _comparisonWorkflow is not null &&
!_comparisonActivationListLoaded)
{
await _comparisonWorkflow.LoadSavedPairsAsync(cancellationToken)
.ConfigureAwait(false);
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
_comparisonActivationListLoaded = true;
touched = true;
}
// Legacy UC4_Load starts a one-shot timer whose first tick calls
// GetThemeByKeyword with the initial empty query. Keep that load in
// native authority so Web code cannot decide when or what to query.
if (IsThemeActive() && _themeWorkflow is not null &&
_themeSnapshot is not null && !_themeActivationListLoaded)
{
var loaded = await _themeWorkflow.SearchAsync(
_themeSnapshot,
string.Empty,
CurrentThemeNxtSession(),
cancellationToken).ConfigureAwait(false);
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
_themeSnapshot = loaded;
_themeActivationListLoaded = true;
touched = true;
}
// Legacy UC6_Load calls Data_Load immediately, which loads the full
// EXPERT_LIST. An empty native search is its parameterized equivalent.
if (IsExpertActive() && _expertWorkflow is not null &&
!_expertActivationListLoaded)
{
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
.ConfigureAwait(false);
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
_expertActivationListLoaded = true;
touched = true;
}
if (touched)
{
Touch();
}
return CreateSnapshot();
}
finally
{
_tabActivationGate.Release();
}
}
public LegacyOperatorSnapshot SwapTabs(
LegacyOperatorTabId source,
LegacyOperatorTabId target)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션의 수동 입력을 완료하거나 취소하기 전에는 탭 순서를 바꿀 수 없습니다.");
}
var sourceIndex = _tabOrder.IndexOf(source);
var targetIndex = _tabOrder.IndexOf(target);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex)
{
return CreateSnapshot();
}
(_tabOrder[sourceIndex], _tabOrder[targetIndex]) =
(_tabOrder[targetIndex], _tabOrder[sourceIndex]);
if (_activeTab != source)
{
Interlocked.Increment(ref _tabActivationRequest);
BeginTabActivation(source);
}
_activeTab = source;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SwapTabsAsync(
LegacyOperatorTabId source,
LegacyOperatorTabId target,
CancellationToken cancellationToken = default)
{
if (!_tabOrder.Contains(source) || !_tabOrder.Contains(target) || source == target)
{
return SwapTabs(source, target);
}
SwapTabs(source, target);
return await SelectTabAsync(source, cancellationToken).ConfigureAwait(false);
}
public Task<LegacyOperatorSnapshot> SwapTabsAtExpectedPositionsAsync(
LegacyOperatorTabId source,
LegacyOperatorTabId target,
int expectedSourceIndex,
int expectedTargetIndex,
CancellationToken cancellationToken = default)
{
if (source == target ||
expectedSourceIndex < 0 || expectedSourceIndex >= _tabOrder.Count ||
expectedTargetIndex < 0 || expectedTargetIndex >= _tabOrder.Count ||
expectedSourceIndex == expectedTargetIndex ||
_tabOrder[expectedSourceIndex] != source ||
_tabOrder[expectedTargetIndex] != target)
{
return Task.FromResult(CreateSnapshot());
}
// The expected positions make a repeated dragover message idempotent. Once
// the first message swaps the two tabs, an identical delivery no longer
// matches the C#-owned order and therefore cannot swap them back.
return SwapTabsAsync(source, target, cancellationToken);
}
public LegacyOperatorSnapshot SetMovingAverages(bool ma5, bool ma20)
{
var next = new LegacyMovingAverageSelection(ma5, ma20);
if (_movingAverages == next)
{
return CreateSnapshot();
}
_movingAverageSource.Update(new CoreMovingAverageSelection(ma5, ma20));
_movingAverages = next;
_tradingHaltWorkflow?.SetMovingAverages(ma5, ma20);
_overseasWorkflow?.SetMovingAverages(ma5, ma20);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> OpenOperatorCatalogAsync(
LegacyOperatorCatalogEntity entity,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenAsync(entity, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> BeginThemeCatalogFromUc4Async(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenAsync(
LegacyOperatorCatalogEntity.Theme,
cancellationToken).ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> EditSelectedThemeCatalogAsync(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
var identity = _themeSnapshot?.SelectedIdentity;
if (identity is null)
{
return ReportError("편집할 테마를 먼저 선택하세요.");
}
await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public Task<LegacyOperatorSnapshot> DeleteSelectedThemeCatalogAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_operatorCatalogWorkflow is null)
{
return Task.FromResult(ReportError("ThemeA 관리 기능이 구성되지 않았습니다."));
}
var identity = _themeSnapshot?.SelectedIdentity;
if (identity is null)
{
return Task.FromResult(ReportError("삭제할 테마를 먼저 선택하세요."));
}
if (_dialog is not null)
{
return Task.FromResult(CreateSnapshot());
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForTheme(identity);
_dialog = new LegacyDialogState(
"선택한 테마를 삭제하겠습니까?",
IsConfirmation: true);
Touch();
return Task.FromResult(CreateSnapshot());
}
public async Task<LegacyOperatorSnapshot> BeginExpertCatalogFromUc6Async(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenAsync(
LegacyOperatorCatalogEntity.Expert,
cancellationToken).ConfigureAwait(false);
if (_operatorCatalogWorkflow.Current.CanBeginCreate)
{
await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken)
.ConfigureAwait(false);
}
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> EditSelectedExpertCatalogAsync(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
}
var selected = _expertWorkflow?.Current.SelectedExpert;
if (selected is null)
{
return ReportError("편집할 전문가를 먼저 선택하세요.");
}
await _operatorCatalogWorkflow.OpenExpertForEditAsync(
new MMoneyCoderSharp.Data.ExpertSelectionIdentity(
selected.ExpertCode,
selected.ExpertName),
cancellationToken).ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public Task<LegacyOperatorSnapshot> DeleteSelectedExpertCatalogAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_operatorCatalogWorkflow is null)
{
return Task.FromResult(ReportError("EList 관리 기능이 구성되지 않았습니다."));
}
var selected = _expertWorkflow?.Current.SelectedExpert;
if (selected is null)
{
return Task.FromResult(ReportError("삭제할 전문가를 먼저 선택하세요."));
}
if (_dialog is not null)
{
return Task.FromResult(CreateSnapshot());
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForExpert(
new MMoneyCoderSharp.Data.ExpertSelectionIdentity(
selected.ExpertCode,
selected.ExpertName));
_dialog = new LegacyDialogState(
"선택한 전문가를 삭제하겠습니까?",
IsConfirmation: true);
Touch();
return Task.FromResult(CreateSnapshot());
}
public LegacyOperatorSnapshot CloseOperatorCatalog()
{
if (_operatorCatalogWorkflow is null)
{
return CreateSnapshot();
}
_operatorCatalogWorkflow.Close();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> CloseOperatorCatalogAsync(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return CreateSnapshot();
}
var closing = _operatorCatalogWorkflow.Current;
_operatorCatalogWorkflow.Close();
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
if (closing.IsOpen &&
closing.Entity == LegacyOperatorCatalogEntity.Expert &&
closing.Mode == LegacyOperatorCatalogMode.Create &&
_expertWorkflow is not null)
{
try
{
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
_statusMessage = "전문가 목록을 새로 읽지 못했습니다. 다시 조회하세요.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchOperatorCatalogAsync(
string query,
MMoneyCoderSharp.Data.ThemeSession nxtSession,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.SearchAsync(query, nxtSession, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SelectOperatorCatalogResultAsync(
string resultId,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.SelectAsync(resultId, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> BeginCreateThemeCatalogAsync(
MMoneyCoderSharp.Data.ThemeMarket market,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.BeginCreateThemeAsync(market, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> ChangeCreateThemeCatalogMarketAsync(
MMoneyCoderSharp.Data.ThemeMarket market,
string editorName,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.ChangeCreateThemeMarketAsync(
market,
editorName,
cancellationToken).ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> BeginCreateExpertCatalogAsync(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchOperatorCatalogStocksAsync(
string query,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.SearchStocksAsync(query, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectOperatorCatalogStock(string resultId)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.SelectStock(resultId);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot AddOperatorCatalogStock(decimal? buyAmount)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.AddSelectedStock(buyAmount);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot MoveOperatorCatalogDraftRow(
string rowId,
LegacyOperatorCatalogMoveDirection direction)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.MoveDraftRow(rowId, direction);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReorderOperatorCatalogDraftRow(
string sourceRowId,
string targetRowId,
LegacyDragDropPosition position)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
var beforeRevision = _operatorCatalogWorkflow.Current.Revision;
var reordered = _operatorCatalogWorkflow.ReorderDraftRow(
sourceRowId,
targetRowId,
position);
if (reordered.Revision == beforeRevision)
{
return CreateSnapshot();
}
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount(
string rowId,
decimal? buyAmount)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.SetDraftBuyAmount(rowId, buyAmount);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateOperatorCatalogStock(
string resultId,
decimal? buyAmount)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.SelectStock(resultId);
if (string.Equals(
_operatorCatalogWorkflow.Current.SelectedStockResultId,
resultId,
StringComparison.Ordinal))
{
_operatorCatalogWorkflow.AddSelectedStock(buyAmount);
}
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot RemoveOperatorCatalogDraftRow(string rowId)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.RemoveDraftRow(rowId);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectOperatorCatalogDraftRow(string rowId)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.SelectDraftRow(rowId);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot RemoveActiveOperatorCatalogDraftRow()
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.RemoveActiveDraftRow();
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ClearOperatorCatalogDraftRows()
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
var catalog = _operatorCatalogWorkflow.Current;
if (!catalog.IsOpen || catalog.Entity != LegacyOperatorCatalogEntity.Theme ||
catalog.Mode is not (LegacyOperatorCatalogMode.Create or
LegacyOperatorCatalogMode.Edit))
{
return ReportError("ThemeA 편집 목록이 열려 있지 않습니다.");
}
if (catalog.DraftRows.Count == 0 || _dialog is not null)
{
return CreateSnapshot();
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForThemeDraftClear(
catalog.Revision);
_dialog = new LegacyDialogState(
"모두 삭제하겠습니까?",
IsConfirmation: true);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> CheckOperatorCatalogThemeNameAsync(
string editorName,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.CheckThemeNameAvailabilityAsync(
editorName,
cancellationToken).ConfigureAwait(false);
ApplyOperatorCatalogStatus();
var availability = _operatorCatalogWorkflow.Current.ThemeNameAvailability;
if (availability is LegacyThemeNameAvailability.Available or
LegacyThemeNameAvailability.Duplicate)
{
_dialog = new LegacyDialogState(_operatorCatalogWorkflow.Current.StatusMessage);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveOperatorCatalogAsync(
string editorName,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
var before = _operatorCatalogWorkflow.Current;
var selectedExpertId = before.Entity == LegacyOperatorCatalogEntity.Expert &&
before.Mode == LegacyOperatorCatalogMode.Edit
? _expertWorkflow?.Current.SelectedExpert?.SelectionId
: null;
if (before.Entity == LegacyOperatorCatalogEntity.Expert &&
before.Mode == LegacyOperatorCatalogMode.Create &&
string.IsNullOrWhiteSpace(editorName))
{
_dialog = new LegacyDialogState("이름을 입력하세요");
Touch();
return CreateSnapshot();
}
await _operatorCatalogWorkflow.SaveAsync(editorName, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
var after = _operatorCatalogWorkflow.Current;
if (before.Entity == LegacyOperatorCatalogEntity.Theme)
{
if (after.Mode == LegacyOperatorCatalogMode.List)
{
_operatorCatalogWorkflow.Close();
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
}
else if (string.Equals(
after.StatusMessage,
LegacyOperatorCatalogWorkflowController.ThemeNameDuplicateMessage,
StringComparison.Ordinal))
{
_dialog = new LegacyDialogState(
LegacyOperatorCatalogWorkflowController.ThemeNameDuplicateMessage);
}
}
else if (after.Mode == LegacyOperatorCatalogMode.List)
{
var refreshFailed = false;
if (before.Mode == LegacyOperatorCatalogMode.Create)
{
_operatorCatalogWorkflow.Close();
try
{
if (_expertWorkflow is not null)
{
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
.ConfigureAwait(false);
}
}
catch
{
// The mutation receipt is already known-committed. A UC6 list refresh
// failure must not be reclassified as an unknown DB-write outcome.
_statusMessage = "전문가 목록을 새로 읽지 못했습니다. 다시 조회하세요.";
_statusKind = LegacyOperatorStatusKind.Warning;
refreshFailed = true;
}
}
else if (before.Mode == LegacyOperatorCatalogMode.Edit)
{
try
{
if (_expertWorkflow is not null && selectedExpertId is not null)
{
await _expertWorkflow.SelectExpertAsync(
selectedExpertId,
cancellationToken)
.ConfigureAwait(false);
}
}
catch
{
// The expert transaction is known-committed. Preview refresh failure
// is a read failure and must never turn into an unknown write outcome.
refreshFailed = true;
_statusMessage = "저장된 추천 종목을 다시 읽지 못했습니다. 전문가를 다시 선택하세요.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
_dialog = new LegacyDialogState("저장되었습니다");
_closeOperatorCatalogAfterDialog = true;
}
if (!refreshFailed)
{
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
}
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot InvalidateOperatorCatalogBrowserCorrelation()
{
if (_operatorCatalogWorkflow is null)
{
return CreateSnapshot();
}
_operatorCatalogWorkflow.InvalidateBrowserCorrelation();
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> OpenManualFinancialAsync(
MMoneyCoderSharp.Data.ManualFinancialScreenKind screen,
CancellationToken cancellationToken = default)
{
if (_manualFinancialWorkflow is null)
{
return ReportError("수동 재무 입력 기능이 구성되지 않았습니다.");
}
_manualFinancialSnapshot = _manualFinancialWorkflow.CreateInitial(screen);
_manualFinancialSnapshot = await _manualFinancialWorkflow.SearchAsync(
_manualFinancialSnapshot,
string.Empty,
cancellationToken).ConfigureAwait(false);
ApplyManualFinancialListStatus(_manualFinancialSnapshot);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CloseManualFinancial()
{
if (_manualFinancialSnapshot is null)
{
return CreateSnapshot();
}
_manualFinancialSnapshot = null;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchManualFinancialAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
_manualFinancialSnapshot = await workflow.SearchAsync(
snapshot,
query,
cancellationToken).ConfigureAwait(false);
ApplyManualFinancialListStatus(_manualFinancialSnapshot);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ToggleManualFinancialNameSort(long expectedRevision)
{
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("Open a manual-financial screen before sorting its list.");
}
try
{
_manualFinancialSnapshot = workflow.ToggleNameSort(
snapshot,
expectedRevision);
_statusMessage = "The GraphE stock-name order was changed.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException)
{
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> FindManualFinancialAsync(
string query,
LegacyManualFinancialFindDirection direction,
long expectedRevision,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("Open a manual-financial screen before finding a stock.");
}
try
{
_manualFinancialSnapshot = workflow.FindByName(
snapshot,
query,
direction,
expectedRevision);
if (_manualFinancialSnapshot.LastMessage is not null)
{
_statusMessage = _manualFinancialSnapshot.LastMessage;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
return await LoadManualFinancialAsync(
_manualFinancialSnapshot.SelectedRowId!,
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException or KeyNotFoundException or
LegacyManualFinancialWorkflowDataException)
{
// A stale browser generation or a failed fresh detail read leaves the
// GraphE modal open. The operator may inspect the failure; no read is retried.
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> LoadManualFinancialAsync(
string resultId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resultId);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
// Commit the attempted row selection and clear any previous detail before
// the strict exact read. If a malformed historical row fails, an earlier
// valid record can never remain eligible for save, delete, or playout.
_manualFinancialSnapshot = workflow.BeginLoad(snapshot, resultId);
try
{
_manualFinancialSnapshot = await workflow.LoadAsync(
_manualFinancialSnapshot,
resultId,
cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is ManualFinancialDataException or
ArgumentException or InvalidOperationException or KeyNotFoundException)
{
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
// GraphE identities contain STOCK_NAME only. Resolve the exact stock master
// immediately after the fresh detail read when it is unambiguous; otherwise
// leave the candidates visible so the operator can resolve them explicitly.
var selectedIdentity = _manualFinancialSnapshot.SelectedRawRecord?.Identity ??
_manualFinancialSnapshot.SelectedRecord?.Identity;
if (selectedIdentity is null)
{
_dialog = new LegacyDialogState(
"선택한 GraphE 행의 종목 식별정보를 읽을 수 없습니다.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
var stockName = selectedIdentity.StockName;
_manualFinancialSnapshot = await workflow.SearchStocksAsync(
_manualFinancialSnapshot,
stockName,
cancellationToken).ConfigureAwait(false);
var exact = _manualFinancialSnapshot.StockCandidates
.Where(row => string.Equals(row.Name, stockName, StringComparison.Ordinal))
.ToArray();
if (exact.Length == 1)
{
try
{
_manualFinancialSnapshot = workflow.SelectStock(
_manualFinancialSnapshot,
exact[0].ResultId);
}
catch (ArgumentException)
{
// Ambiguous provider identities remain unverified and visible.
}
}
if (_manualFinancialSnapshot.WritesQuarantined)
{
_statusMessage = _manualFinancialSnapshot.LastMessage ??
"GraphE 저장 작업은 현재 차단되어 있습니다.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
else if (!_manualFinancialSnapshot.IsPlayoutReady)
{
_statusMessage =
"기존 GraphE 저장값을 원문 그대로 불러왔습니다. 편집·삭제와 스케줄 추가는 가능하며 실제 송출 전 장면 검증에서 다시 확인합니다.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
else
{
_statusMessage =
$"{stockName} {_manualFinancialSnapshot.Definition.Label} 데이터를 불러왔습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> ActivateManualFinancialResultAsync(
string resultId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resultId);
const string command = "activate-manual-financial-result";
if (!TryGetManualFinancial(out _, out _))
{
CompleteCommand(command, resultId, succeeded: false);
return ReportError("Open a manual-financial screen before activating a result.");
}
try
{
// A GraphE list identity is only an opaque handle. Re-read and validate the
// exact record and stock-master identity before deriving the playlist row.
await LoadManualFinancialAsync(resultId, cancellationToken).ConfigureAwait(false);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
throw new InvalidOperationException(
"The manual-financial result is no longer available.");
}
if ((!snapshot.IsRawDetailFresh && !snapshot.IsDetailFresh) ||
snapshot.SelectedIdentity is null ||
!string.Equals(snapshot.SelectedRowId, resultId, StringComparison.Ordinal))
{
throw new LegacyManualFinancialWorkflowDataException(
"선택한 기존 GraphE 행의 저장값을 읽을 수 없습니다.");
}
var draft = workflow.MaterializePlaylistDraft(
snapshot,
snapshot.Definition.ActionId);
AppendManualFinancialRow(draft);
_manualFinancialSnapshot = null;
_statusMessage = $"{draft.Title} {draft.Detail} cut was added.";
_statusKind = LegacyOperatorStatusKind.Information;
CompleteCommand(command, resultId, succeeded: true);
Touch();
return CreateSnapshot();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException or KeyNotFoundException or
LegacyManualFinancialWorkflowDataException)
{
// Keep the GraphE modal open and never append a partial row.
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
CompleteCommand(command, resultId, succeeded: false);
Touch();
return CreateSnapshot();
}
}
public LegacyOperatorSnapshot BeginManualFinancialCreate()
{
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
_manualFinancialSnapshot = workflow.BeginCreate(snapshot);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchManualFinancialStocksAsync(
string stockName,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stockName);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
// GraphE's search button and Enter handler both returned without any
// state or focus change when the stock-name box was empty.
if (string.IsNullOrWhiteSpace(stockName))
{
return CreateSnapshot();
}
_manualFinancialSnapshot = await workflow.SearchStocksAsync(
snapshot,
stockName,
cancellationToken).ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectManualFinancialStock(string resultId)
{
ArgumentNullException.ThrowIfNull(resultId);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
if (string.IsNullOrWhiteSpace(resultId))
{
return CreateSnapshot();
}
try
{
_manualFinancialSnapshot = workflow.SelectStock(snapshot, resultId);
Touch();
return CreateSnapshot();
}
catch (ArgumentException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> SaveManualFinancialAsync(
MMoneyCoderSharp.Data.ManualFinancialRecord record,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
try
{
LegacyManualFinancialWriteRequest request = snapshot.SelectedRecord is null
? workflow.CreateCreateRequest(snapshot, record)
: workflow.CreateUpdateRequest(snapshot, record);
_manualFinancialSnapshot = await workflow.ExecuteAsync(
snapshot,
request,
cancellationToken).ConfigureAwait(false);
ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장");
ShowManualFinancialSaveDialog(_manualFinancialSnapshot);
Touch();
return CreateSnapshot();
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> SaveManualFinancialRawAsync(
MMoneyCoderSharp.Data.ManualFinancialRawRecord record,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
try
{
var request = workflow.CreateRawUpdateRequest(snapshot, record);
_manualFinancialSnapshot = await workflow.ExecuteAsync(
snapshot,
request,
cancellationToken).ConfigureAwait(false);
ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장");
ShowManualFinancialSaveDialog(_manualFinancialSnapshot);
Touch();
return CreateSnapshot();
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public Task<LegacyOperatorSnapshot> DeleteManualFinancialAsync(
bool all,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return Task.FromResult(ReportError("수동 재무 입력 화면을 먼저 여세요."));
}
if (_dialog is not null)
{
return Task.FromResult(CreateSnapshot());
}
// GraphE returned immediately when no row had been selected; it did not
// show either the Yes/No question or a follow-up error MessageBox.
if (!all && snapshot.SelectedRowId is null)
{
return Task.FromResult(CreateSnapshot());
}
try
{
// Validate the current selection/write authority before showing the
// native-compatible question, but do not cross the write boundary.
_ = all
? workflow.CreateDeleteAllRequest(
snapshot,
snapshot.Definition.DeleteAllConfirmationToken)
: workflow.CreateDeleteRequest(snapshot);
_pendingNativeConfirmation = PendingNativeConfirmation.ForManualFinancialDelete(
all,
snapshot.Revision);
_dialog = new LegacyDialogState(
all ? "모든 종목을 삭제하겠습니까?" : "선택한 종목을 삭제하겠습니까?",
IsConfirmation: true);
Touch();
return Task.FromResult(CreateSnapshot());
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return Task.FromResult(CreateSnapshot());
}
}
public LegacyOperatorSnapshot ActivateManualFinancialAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
try
{
var draft = workflow.MaterializePlaylistDraft(snapshot, actionId);
AppendManualFinancialRow(draft);
_statusMessage = $"{draft.Title} {draft.Detail} 컷을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> OpenManualNetSellAsync(
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience,
CancellationToken cancellationToken = default)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요.");
}
return await OpenManualNetSellCoreAsync(audience, cancellationToken)
.ConfigureAwait(false);
}
private async Task<LegacyOperatorSnapshot> OpenManualNetSellCoreAsync(
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience,
CancellationToken cancellationToken)
{
if (_manualListsWorkflow is null)
{
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.OpenNetSellAsync(audience, cancellationToken)
.ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> OpenManualViAsync(
CancellationToken cancellationToken = default)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요.");
}
return await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<LegacyOperatorSnapshot> OpenManualViCoreAsync(
CancellationToken cancellationToken)
{
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.OpenViAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CloseManualLists()
{
_manualListsWorkflow?.Close();
if (_pendingFixedSectionBatch is { } pending)
{
_pendingFixedSectionBatch = null;
_statusMessage =
$"{pending.SectionName} 섹션 추가를 취소했습니다. 편성표에는 아무 항목도 추가하지 않았습니다. 이미 확인한 수동 저장 내용은 유지됩니다.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> RefreshManualListsAsync(
CancellationToken cancellationToken = default)
{
if (_manualListsWorkflow is null)
{
return ReportError("수동 목록 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.RefreshAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetManualNetSellCell(
string rowId,
LegacyManualNetSellField field,
string value)
{
if (_manualListsWorkflow is null)
{
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
}
_manualListsWorkflow.SetNetSellCell(rowId, field, value);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveManualNetSellAsync(
CancellationToken cancellationToken = default)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다.");
}
if (_manualListsWorkflow is null)
{
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.SaveNetSellAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot AddManualNetSellPlaylistRow()
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다.");
}
if (_manualListsWorkflow is null)
{
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
}
try
{
AppendManualListRow(_manualListsWorkflow.CreateNetSellPlaylistDraft());
_statusMessage = "검증된 수동 순매도 컷을 편성표에 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchManualViAsync(
string query,
CancellationToken cancellationToken = default)
{
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.SearchViAsync(query, cancellationToken)
.ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectManualViSearchResult(string resultId)
{
if (_manualListsWorkflow is null)
{
return ReportError("The manual VI list feature is unavailable.");
}
_manualListsWorkflow.SelectViSearchResult(resultId);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot AddManualViSearchResult(string resultId)
{
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
_manualListsWorkflow.AddViSearchResult(resultId);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot MoveManualViItem(
string itemId,
LegacyManualListMoveDirection direction)
{
_manualListsWorkflow?.MoveViItem(itemId, direction);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DeleteManualViItem(string itemId)
{
_manualListsWorkflow?.DeleteViItem(itemId);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DeleteAllManualViItems()
{
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
var manualLists = _manualListsWorkflow.Current;
if (!manualLists.IsOpen || manualLists.Screen != LegacyManualListScreen.Vi)
{
return ReportError("VI 수동 목록 화면을 먼저 여세요.");
}
if (_dialog is not null)
{
return CreateSnapshot();
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForManualViDeleteAll(
manualLists.ViItems.Select(static item => item.ItemId).ToArray());
_dialog = new LegacyDialogState("모두 삭제하겠습니까?", IsConfirmation: true);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveManualViAsync(
CancellationToken cancellationToken = default)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다.");
}
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.SaveViAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot AddManualViPlaylistRow()
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다.");
}
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
try
{
AppendManualListRow(_manualListsWorkflow.CreateViPlaylistDraft());
_statusMessage = "검증된 VI 발동 컷을 편성표에 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
}
Touch();
return CreateSnapshot();
}
/// <summary>
/// Reproduces the original FSell/VI OK button: persist once, verify the fresh
/// readback, append the corresponding cut, and close the editor as one operator
/// action. A failed or outcome-unknown save leaves the editor open and never
/// attempts playlist materialization or an automatic retry.
/// </summary>
public async Task<LegacyOperatorSnapshot> ConfirmManualListsAsync(
CancellationToken cancellationToken = default)
{
if (_manualListsWorkflow is null)
{
return ReportError("수동 목록 기능을 사용할 수 없습니다.");
}
var screen = _manualListsWorkflow.Current.Screen;
if (screen is null)
{
return ReportError("열려 있는 수동 목록 입력창이 없습니다.");
}
if (_pendingFixedSectionBatch is { } pending &&
(GetActiveFixedMarket() != pending.Market ||
!pending.IsCurrentManual(
screen.Value,
_manualListsWorkflow.Current.Audience)))
{
return ReportError(
"고정 컷 섹션의 현재 수동 입력 단계와 열린 입력창이 일치하지 않아 저장하지 않았습니다.");
}
if (screen == LegacyManualListScreen.NetSell)
{
await _manualListsWorkflow.SaveNetSellAsync(cancellationToken)
.ConfigureAwait(false);
}
else
{
await _manualListsWorkflow.SaveViAsync(cancellationToken)
.ConfigureAwait(false);
}
var saved = _manualListsWorkflow.Current;
if (!saved.CanMaterializePlaylist)
{
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
try
{
var draft = screen == LegacyManualListScreen.NetSell
? _manualListsWorkflow.CreateNetSellPlaylistDraft()
: _manualListsWorkflow.CreateViPlaylistDraft();
if (_pendingFixedSectionBatch is not null)
{
_pendingFixedSectionBatch.StageCurrentManual(draft);
_manualListsWorkflow.Close();
return await ContinuePendingFixedSectionAsync(cancellationToken)
.ConfigureAwait(false);
}
AppendManualListRow(draft);
_manualListsWorkflow.Close();
_statusMessage = screen == LegacyManualListScreen.NetSell
? "수동 순매도 목록을 저장하고 편성표에 추가했습니다."
: "VI 발동 목록을 저장하고 편성표에 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> ImportLegacyManualListsAsync(
CancellationToken cancellationToken = default)
{
if (_manualListsWorkflow is null)
{
return ReportError("원본 수동 데이터 가져오기 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.ImportAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateFixedAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요.");
}
var market = GetActiveFixedMarket();
if (market is null)
{
return ReportError("현재 탭에서는 고정 컷을 선택할 수 없습니다.");
}
try
{
var materialized = _fixedCatalog.Materialize(
actionId,
_timeProvider.GetLocalNow());
if (materialized.Action.Market != market.Value)
{
return ReportError("현재 탭에 속하지 않는 항목입니다.");
}
var initialPage = CreateFixedInitialPageFallback(materialized.Action);
AppendFixedRow(materialized, initialPage.PageText);
ApplyFixedActionAddedStatus(materialized.Action, initialPage.PageCountUnavailable);
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("등록되지 않은 고정 컷 항목입니다.");
}
}
public async Task<LegacyOperatorSnapshot> ActivateFixedActionAsync(
string actionId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(actionId);
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요.");
}
var manualTarget = GetFixedManualTarget(actionId);
if (manualTarget is null)
{
var fixedMarket = GetActiveFixedMarket();
if (fixedMarket is null)
{
return ReportError("현재 탭에서는 고정 컷을 선택할 수 없습니다.");
}
try
{
var materialized = _fixedCatalog.Materialize(
actionId,
_timeProvider.GetLocalNow());
if (materialized.Action.Market != fixedMarket.Value)
{
return ReportError("현재 탭에 속하지 않는 항목입니다.");
}
var initialPage = await ResolveFixedInitialPageAsync(
materialized,
cancellationToken).ConfigureAwait(false);
AppendFixedRow(materialized, initialPage.PageText);
ApplyFixedActionAddedStatus(
materialized.Action,
initialPage.PageCountUnavailable);
Touch();
return CreateSnapshot();
}
catch (OperationCanceledException)
{
throw;
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("등록되지 않은 고정 컷 항목입니다.");
}
}
var market = GetActiveFixedMarket();
if (market != LegacyFixedMarket.Index)
{
return ReportError("현재 탭에 속하지 않는 수동 입력 항목입니다.");
}
return manualTarget.Value.Screen == LegacyManualListScreen.NetSell
? await OpenManualNetSellAsync(
manualTarget.Value.Audience,
cancellationToken).ConfigureAwait(false)
: await OpenManualViAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Preserves UC1 parent-node direct-child order while closing its manual-child gap:
/// the original parent path removed FSell rows and returned at VI, because the
/// ShowDialog calls lived only in the leaf path. Automatic children are materialized
/// into a native staging area first; FSell/VI children are opened one at a time and
/// the playlist is committed only after every manual child has been confirmed.
/// </summary>
public async Task<LegacyOperatorSnapshot> ActivateFixedSection(
int sectionIndex,
CancellationToken cancellationToken = default)
{
var market = GetActiveFixedMarket();
if (market is null)
{
return ReportError("현재 탭에서는 고정 컷 섹션을 선택할 수 없습니다.");
}
var sections = _fixedCatalog.GetSections(market.Value);
if (sectionIndex < 0 || sectionIndex >= sections.Count)
{
return ReportError("등록되지 않은 고정 컷 섹션입니다.");
}
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"이미 고정 컷 섹션의 수동 입력을 진행하고 있습니다. 먼저 완료하거나 취소하세요.");
}
if (_manualListsWorkflow?.Current.IsOpen == true)
{
return ReportError("열려 있는 수동 입력창을 먼저 완료하거나 닫으세요.");
}
var section = sections[sectionIndex];
try
{
var localNow = _timeProvider.GetLocalNow();
var items = new List<PendingFixedSectionItem>(section.Actions.Count);
foreach (var action in section.Actions)
{
var manualTarget = GetFixedManualTarget(action.Id);
if (manualTarget is not null)
{
if (_manualListsWorkflow is null)
{
return ReportError(
"이 섹션에는 수동 입력 항목이 있지만 수동 목록 기능을 사용할 수 없습니다.");
}
items.Add(new PendingFixedSectionItem(action, manualTarget.Value));
continue;
}
// Every non-manual child is validated before the first playlist row
// is appended or the first modal editor is opened.
var materialized = _fixedCatalog.Materialize(
action.Id,
localNow);
var initialPage = await ResolveFixedInitialPageAsync(
materialized,
cancellationToken).ConfigureAwait(false);
items.Add(new PendingFixedSectionItem(
action,
materialized,
initialPage));
}
var manualCount = items.Count(item => item.ManualTarget is not null);
if (manualCount == 0)
{
CommitFixedSectionBatch(items);
ApplyFixedSectionAddedStatus(section.Name, items);
Touch();
return CreateSnapshot();
}
_pendingFixedSectionBatch = new PendingFixedSectionBatch(
market.Value,
sectionIndex,
section.Name,
items,
manualCount);
return await OpenPendingFixedSectionManualAsync(cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (InvalidOperationException)
{
_pendingFixedSectionBatch = null;
return ReportError("섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
}
}
public LegacyOperatorSnapshot SelectIndustry(int index, bool doubleClick)
{
if (!IsActiveIndustryReady())
{
return ReportError("업종 목록을 먼저 불러오세요.");
}
if (doubleClick)
{
_industryWorkflow!.DoubleClickIndustry(index);
}
else
{
_industryWorkflow!.SelectIndustry(index);
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SwapIndustries()
{
if (!IsActiveIndustryReady())
{
return ReportError("업종 목록을 먼저 불러오세요.");
}
_industryWorkflow!.SwapIndustries();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetIndustryComparisonText(
LegacyIndustryComparisonSlot slot,
string text)
{
ArgumentNullException.ThrowIfNull(text);
if (!IsActiveIndustryReady())
{
return ReportError("업종 목록을 먼저 불러오세요.");
}
try
{
_industryWorkflow!.SetComparisonText(slot, text);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 업종 비교 입력칸입니다.");
}
catch (LegacyIndustryWorkflowException exception)
{
return ReportError(exception.Message);
}
}
public LegacyOperatorSnapshot ActivateIndustryAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsActiveIndustryReady())
{
return ReportError("업종 목록을 먼저 불러오세요.");
}
try
{
var draft = _industryWorkflow!.MaterializePlaylistDraft(actionId);
AppendIndustryRow(draft, _industryWorkflow.Current.Industries.Count);
_statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (LegacyIndustryWorkflowException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public LegacyOperatorSnapshot ActivateIndustrySection(int sectionIndex)
{
if (!IsActiveIndustryReady())
{
return ReportError("업종 목록을 먼저 불러오세요.");
}
// MainForm constructs both industry UC1 instances with UC3 == null. The
// original root double-click path dereferences UC3 before its 22-child loop,
// then its outer catch silently consumes the exception. Preserve that
// observable no-op; only child double-clicks add industry playlist rows.
return sectionIndex == 0
? CreateSnapshot()
: ReportError("등록되지 않은 업종 컷 섹션입니다.");
}
public LegacyOperatorSnapshot SelectOverseasFixedIndex(string targetId)
{
ArgumentNullException.ThrowIfNull(targetId);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
try
{
var selected = _overseasWorkflow.SelectFixedIndex(targetId).SelectedFixedIndex!;
_statusMessage = $"{selected.DisplayName} 지수를 선택했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("등록되지 않은 해외지수입니다.");
}
}
public async Task<LegacyOperatorSnapshot> SearchOverseasIndustriesAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
var overseas = await _overseasWorkflow.SearchIndustriesAsync(
query,
cancellationToken: cancellationToken).ConfigureAwait(false);
_statusMessage = $"해외업종 {overseas.Industry.ResultCount}건을 찾았습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchOverseasStocksAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
var overseas = await _overseasWorkflow.SearchStocksAsync(
query,
cancellationToken: cancellationToken).ConfigureAwait(false);
_statusMessage = $"해외종목 {overseas.Stock.ResultCount}건을 찾았습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectOverseasIndustry(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
try
{
var selected = _overseasWorkflow.SelectIndustry(selectionId).Industry.Selected!;
_statusMessage = $"{selected.KoreanName} 업종을 선택했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("현재 검색 결과에 없는 해외업종입니다.");
}
}
public LegacyOperatorSnapshot SelectOverseasStock(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
try
{
var selected = _overseasWorkflow.SelectStock(selectionId).Stock.Selected!;
_statusMessage = $"{selected.InputName} 종목을 선택했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("현재 검색 결과에 없는 해외종목입니다.");
}
}
public LegacyOperatorSnapshot ActivateOverseasAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
try
{
var draft = _overseasWorkflow.MaterializePlaylistDraft(actionId);
AppendOverseasRow(draft);
_statusMessage = $"{draft.Title} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (LegacyOverseasWorkflowException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> SearchComparisonDomesticAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
await _comparisonWorkflow.SearchDomesticAsync(query, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchComparisonWorldAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
await _comparisonWorkflow.SearchWorldAsync(query, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectComparisonDomesticResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("Select the comparison tab first.");
}
_comparisonWorkflow.SelectDomesticResult(selectionId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectComparisonWorldResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("Select the comparison tab first.");
}
_comparisonWorkflow.SelectWorldResult(selectionId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectComparisonFixedTarget(string targetId)
{
ArgumentNullException.ThrowIfNull(targetId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("Select the comparison tab first.");
}
_comparisonWorkflow.SelectFixedTarget(targetId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ChooseComparisonDomesticResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.ChooseDomesticResult(selectionId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ChooseComparisonWorldResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.ChooseWorldResult(selectionId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ChooseComparisonFixedTarget(string targetId)
{
ArgumentNullException.ThrowIfNull(targetId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.ChooseFixedTarget(targetId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SwapComparisonTargets()
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.SwapDraftTargets();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ClearComparisonTargets()
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.ClearDraftTargets();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> AddComparisonPairAsync(
CancellationToken cancellationToken = default)
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
try
{
await _comparisonWorkflow.AddDraftPairAsync(cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public LegacyOperatorSnapshot SelectComparisonPair(string rowId)
{
ArgumentNullException.ThrowIfNull(rowId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.SelectSavedPair(rowId);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> MoveComparisonPairAsync(
LegacyComparisonPairMoveDirection direction,
CancellationToken cancellationToken = default)
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
await _comparisonWorkflow.MoveSelectedPairAsync(direction, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> DeleteComparisonPairAsync(
bool all,
CancellationToken cancellationToken = default)
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
if (all)
{
if (_dialog is not null)
{
return CreateSnapshot();
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForComparisonDeleteAll(
_comparisonWorkflow.Current.Revision);
_dialog = new LegacyDialogState(
"모두 삭제하겠습니까?",
"MmoneyCoder",
IsConfirmation: true);
}
else
{
await _comparisonWorkflow.DeleteSelectedPairAsync(cancellationToken)
.ConfigureAwait(false);
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot RequestLegacyComparisonPairImport()
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 메뉴를 먼저 선택하세요.");
}
if (!_comparisonWorkflow.CanImportLegacyPairs)
{
_dialog = new LegacyDialogState(
"원본 비교쌍 가져오기는 현재 데이터베이스 구성에서 사용할 수 없습니다.");
Touch();
return CreateSnapshot();
}
if (_dialog is not null)
{
return CreateSnapshot();
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForComparisonImport(
_comparisonWorkflow.Current.Revision);
_dialog = new LegacyDialogState(
"현재 저장된 비교쌍의 순서는 그대로 유지하고, 원본 프로그램의 비교쌍 중 없는 항목만 뒤에 추가할까요?",
"MmoneyCoder",
IsConfirmation: true);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateComparisonAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
try
{
var draft = _comparisonWorkflow.MaterializeSelectedAction(actionId);
AppendComparisonRow(draft);
_statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 비교 컷 항목입니다.");
}
}
public LegacyOperatorSnapshot ActivateComparisonSection(int sectionIndex)
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
var snapshot = _comparisonWorkflow.Current;
var sections = snapshot.Actions
.Select(static action => action.Section)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (sectionIndex < 0 || sectionIndex >= sections.Length)
{
return ReportError("등록되지 않은 비교 컷 섹션입니다.");
}
var section = sections[sectionIndex];
var selectedPair = snapshot.SavedPairs.FirstOrDefault(static pair => pair.IsSelected);
if (string.Equals(section, "종목별 수익률 비교", StringComparison.Ordinal) &&
selectedPair is not null &&
(selectedPair.First.Kind == LegacyComparisonTargetKind.NxtStock ||
selectedPair.Second.Kind == LegacyComparisonTargetKind.NxtStock))
{
_dialog = new LegacyDialogState(
"NXT 종목이 포함된 비교쌍에는 종목별 수익률 비교 섹션을 추가할 수 없습니다.");
Touch();
return CreateSnapshot();
}
var actions = snapshot.Actions
.Where(action => string.Equals(
action.Section,
section,
StringComparison.Ordinal))
.ToArray();
if (actions.Length == 0 || actions.Any(static action => !action.IsAvailable))
{
_dialog = new LegacyDialogState(
"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
Touch();
return CreateSnapshot();
}
try
{
var drafts = actions
.Select(action => _comparisonWorkflow.MaterializeSelectedAction(action.ActionId))
.ToArray();
foreach (var draft in drafts)
{
AppendComparisonRow(draft);
}
_statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(
$"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}");
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 비교 컷 항목입니다.");
}
}
public Task<LegacyOperatorSnapshot> SearchThemesAsync(
string query,
CancellationToken cancellationToken = default) =>
SearchThemesCoreAsync(query, CurrentThemeNxtSession(), cancellationToken);
// Compatibility overload for native callers. UC4 does not allow a WebView
// payload to select the NXT session; TimeProvider remains authoritative.
public Task<LegacyOperatorSnapshot> SearchThemesAsync(
string query,
MMoneyCoderSharp.Data.ThemeSession ignoredNxtSession,
CancellationToken cancellationToken = default) =>
SearchThemesAsync(query, cancellationToken);
private async Task<LegacyOperatorSnapshot> SearchThemesCoreAsync(
string query,
MMoneyCoderSharp.Data.ThemeSession nxtSession,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
return ReportError("테마 탭을 먼저 선택하세요.");
}
_themeSnapshot = await _themeWorkflow.SearchAsync(
_themeSnapshot,
query,
nxtSession,
cancellationToken).ConfigureAwait(false);
_themeActivationListLoaded = true;
_statusMessage = _themeSnapshot.SearchIsTruncated
? $"테마 검색 결과가 최대 {LegacyThemeWorkflow.MaximumSearchResults:N0}건으로 제한되었습니다. 검색어를 좁혀 주세요."
: $"테마 {_themeSnapshot.SearchResults.Count:N0}건을 찾았습니다.";
_statusKind = _themeSnapshot.SearchIsTruncated
? LegacyOperatorStatusKind.Warning
: LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SelectThemeAsync(
string resultId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resultId);
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
return ReportError("테마 탭을 먼저 선택하세요.");
}
try
{
_themeSnapshot = await _themeWorkflow.SelectAsync(
_themeSnapshot,
resultId,
cancellationToken).ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("등록되지 않은 테마 검색 결과입니다.");
}
}
public async Task<LegacyOperatorSnapshot> ActivateThemeResultAsync(
string resultId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resultId);
const string command = "activate-theme-result";
const string originalDoubleClickAction = "five-row-current";
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
CompleteCommand(command, resultId, succeeded: false);
return ReportError("Select the theme tab before activating a result.");
}
try
{
// The original row double-click first selected the row, then emitted the
// fixed five-row current-price cut. Await preview loading so those stages
// cannot race each other.
_themeSnapshot = await _themeWorkflow.SelectAsync(
_themeSnapshot,
resultId,
cancellationToken).ConfigureAwait(false);
var draft = _themeWorkflow.MaterializePlaylistDraft(
_themeSnapshot,
originalDoubleClickAction);
AppendThemeRow(draft);
_statusMessage = $"{draft.Detail} item was added.";
_statusKind = LegacyOperatorStatusKind.Information;
CompleteCommand(command, resultId, succeeded: true);
Touch();
return CreateSnapshot();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException or KeyNotFoundException or
LegacyThemeWorkflowDataException)
{
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
CompleteCommand(command, resultId, succeeded: false);
Touch();
return CreateSnapshot();
}
}
public LegacyOperatorSnapshot SetThemeSort(string sortId)
{
ArgumentNullException.ThrowIfNull(sortId);
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
return ReportError("테마 탭을 먼저 선택하세요.");
}
_themeSnapshot = _themeWorkflow.SelectSort(_themeSnapshot, sortId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateThemeAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
return ReportError("테마 탭을 먼저 선택하세요.");
}
try
{
var draft = _themeWorkflow.MaterializePlaylistDraft(_themeSnapshot, actionId);
AppendThemeRow(draft);
_statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("등록되지 않은 테마 컷 항목입니다.");
}
}
public async Task<LegacyOperatorSnapshot> SearchExpertsAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsExpertActive() || _expertWorkflow is null)
{
return ReportError("전문가 탭을 먼저 선택하세요.");
}
await _expertWorkflow.SearchAsync(query, cancellationToken).ConfigureAwait(false);
_expertActivationListLoaded = true;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SelectExpertAsync(
string selectionId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsExpertActive() || _expertWorkflow is null)
{
return ReportError("전문가 탭을 먼저 선택하세요.");
}
try
{
await _expertWorkflow.SelectExpertAsync(selectionId, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 전문가 검색 결과입니다.");
}
}
public LegacyOperatorSnapshot ActivateExpertAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsExpertActive() || _expertWorkflow is null)
{
return ReportError("전문가 탭을 먼저 선택하세요.");
}
try
{
var draft = _expertWorkflow.MaterializeSelectedAction(actionId);
AppendExpertRow(draft);
_statusMessage = $"{draft.Title} 추천 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 전문가 컷 항목입니다.");
}
}
public async Task<LegacyOperatorSnapshot> SearchTradingHaltsAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
{
return ReportError("정지 탭을 먼저 선택하세요.");
}
await _tradingHaltWorkflow.SearchAsync(query, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectTradingHalt(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
{
return ReportError("정지 탭을 먼저 선택하세요.");
}
try
{
_tradingHaltWorkflow.SelectResult(selectionId);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 거래정지 검색 결과입니다.");
}
}
public LegacyOperatorSnapshot ToggleTradingHaltSort()
{
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
{
return ReportError("정지 탭을 먼저 선택하세요.");
}
_tradingHaltWorkflow.ToggleNameSort();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateTradingHaltAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
{
return ReportError("정지 탭을 먼저 선택하세요.");
}
try
{
var draft = _tradingHaltWorkflow.MaterializeSelectedAction(actionId);
AppendTradingHaltRow(draft);
_statusMessage = $"{draft.Title} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 거래정지 컷 항목입니다.");
}
}
public LegacyOperatorSnapshot SelectPlaylistRow(
string rowId,
bool control,
bool shift)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = FindPlaylistIndex(rowId);
if (index < 0)
{
return CreateSnapshot();
}
_activePlaylistRowId = rowId;
// MainForm handled Control before Shift. Consequently Ctrl+Shift added only
// the clicked row, and clicking an already selected row did not remove it.
if (control)
{
_selectedPlaylistRowIds.Add(rowId);
}
else
{
if (_playlistCursorIndex != index)
{
_playlistPriorCursorIndex = _playlistCursorIndex;
_playlistCursorIndex = index;
}
_selectedPlaylistRowIds.Clear();
if (shift)
{
AddPlaylistRange(_playlistPriorCursorIndex, _playlistCursorIndex);
}
else
{
_selectedPlaylistRowIds.Add(rowId);
}
}
Touch();
return CreateSnapshot();
}
/// <summary>
/// Moves only MainForm's m_rowidx equivalent. A click in the checkbox column
/// selected the next F8 candidate before its separate enabled-state handling,
/// but did not replace the rows accumulated for Ctrl/Shift deletion.
/// </summary>
public LegacyOperatorSnapshot ActivatePlaylistRow(string rowId)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = FindPlaylistIndex(rowId);
if (index < 0 || string.Equals(
_activePlaylistRowId,
rowId,
StringComparison.Ordinal))
{
return CreateSnapshot();
}
_activePlaylistRowId = rowId;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ToggleActivePlaylistRowEnabled()
{
return _activePlaylistRowId is null
? CreateSnapshot()
: TogglePlaylistRowEnabled(_activePlaylistRowId);
}
public LegacyOperatorSnapshot SelectPlaylistBoundary(bool last)
{
if (_playlist.Count == 0)
{
return CreateSnapshot();
}
var index = last ? _playlist.Count - 1 : 0;
var rowId = _playlist[index].RowId;
_activePlaylistRowId = rowId;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DeleteSelectedPlaylistRows()
{
if (_selectedPlaylistRowIds.Count == 0)
{
return CreateSnapshot();
}
var originalRows = _playlist.ToArray();
var removedIds = new HashSet<string>(
_selectedPlaylistRowIds,
StringComparer.Ordinal);
var activeIndex = FindOriginalPlaylistIndex(originalRows, _activePlaylistRowId);
var cursorIndex = Math.Clamp(_playlistCursorIndex, 0, originalRows.Length - 1);
var priorCursorIndex = Math.Clamp(
_playlistPriorCursorIndex,
0,
originalRows.Length - 1);
var cursorRowId = originalRows[cursorIndex].RowId;
var priorCursorRowId = originalRows[priorCursorIndex].RowId;
_playlist.RemoveAll(row => removedIds.Contains(row.RowId));
_selectedPlaylistRowIds.Clear();
if (_playlist.Count == 0)
{
ResetPlaylistSelection();
}
else
{
_activePlaylistRowId = ResolveSurvivingPlaylistRowId(
originalRows,
removedIds,
_activePlaylistRowId,
activeIndex);
_playlistCursorIndex = ResolveSurvivingPlaylistIndex(
originalRows,
removedIds,
cursorRowId,
cursorIndex);
_playlistPriorCursorIndex = ResolveSurvivingPlaylistIndex(
originalRows,
removedIds,
priorCursorRowId,
priorCursorIndex);
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DeleteAllPlaylistRows()
{
if (_dialog is not null)
{
return CreateSnapshot();
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForPlaylistDeleteAll(
_playlist.Select(static row => row.RowId).ToArray());
_dialog = new LegacyDialogState(
"송출 리스트를 모두 삭제하겠습니까?",
IsConfirmation: true);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot MoveSelectedPlaylistRows(
LegacyPlaylistMoveDirection direction)
{
var activeIndex = _activePlaylistRowId is null
? -1
: FindPlaylistIndex(_activePlaylistRowId);
if (activeIndex < 0)
{
return CreateSnapshot();
}
var targetIndex = direction switch
{
LegacyPlaylistMoveDirection.Up => activeIndex - 1,
LegacyPlaylistMoveDirection.Down => activeIndex + 1,
_ => throw new ArgumentOutOfRangeException(nameof(direction), direction, null)
};
if (targetIndex < 0 || targetIndex >= _playlist.Count)
{
return CreateSnapshot();
}
// Spd_UpDown exchanged all seven cell values for the active row only. It did
// not move an entire multi-selection as a block.
(_playlist[activeIndex], _playlist[targetIndex]) =
(_playlist[targetIndex], _playlist[activeIndex]);
_selectedPlaylistRowIds.Clear();
_selectedPlaylistRowIds.Add(_activePlaylistRowId!);
_playlistPriorCursorIndex = targetIndex;
_playlistCursorIndex = targetIndex;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReorderPlaylistRows(
string sourceRowId,
string targetRowId,
LegacyDragDropPosition position)
{
ArgumentNullException.ThrowIfNull(sourceRowId);
ArgumentNullException.ThrowIfNull(targetRowId);
var sourceIndex = FindPlaylistIndex(sourceRowId);
var targetIndex = FindPlaylistIndex(targetRowId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex)
{
return CreateSnapshot();
}
// FarPoint's native row move uses the row being dragged as its source.
// MainForm's manually painted Ctrl/Shift deletion selection was not a
// native multi-row drag selection, so it must never turn this into a
// block move.
var movingRow = _playlist[sourceIndex];
var remainingRows = _playlist
.Where(row => !string.Equals(
row.RowId,
sourceRowId,
StringComparison.Ordinal))
.ToList();
var remainingTargetIndex = remainingRows.FindIndex(row =>
string.Equals(row.RowId, targetRowId, StringComparison.Ordinal));
if (remainingTargetIndex < 0)
{
return CreateSnapshot();
}
var insertionIndex = position switch
{
LegacyDragDropPosition.Before => remainingTargetIndex,
LegacyDragDropPosition.After => remainingTargetIndex + 1,
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null)
};
remainingRows.Insert(insertionIndex, movingRow);
var orderChanged = !_playlist
.Select(static row => row.RowId)
.SequenceEqual(
remainingRows.Select(static row => row.RowId),
StringComparer.Ordinal);
if (!orderChanged)
{
return CreateSnapshot();
}
_playlist.Clear();
_playlist.AddRange(remainingRows);
_selectedPlaylistRowIds.Clear();
_selectedPlaylistRowIds.Add(sourceRowId);
_activePlaylistRowId = sourceRowId;
_playlistCursorIndex = FindPlaylistIndex(sourceRowId);
_playlistPriorCursorIndex = _playlistCursorIndex;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> RefreshNamedPlaylistsAsync(
CancellationToken cancellationToken = default)
{
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
var namedPlaylist = await _namedPlaylistWorkflow.RefreshAsync(
cancellationToken: cancellationToken).ConfigureAwait(false);
ApplyNamedPlaylistStatus(namedPlaylist);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
ApplyNamedPlaylistStatus(
_namedPlaylistWorkflow.Current,
"DB 재생목록 목록을 불러오지 못했습니다.",
LegacyOperatorStatusKind.Error);
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectNamedPlaylist(string definitionId)
{
ArgumentNullException.ThrowIfNull(definitionId);
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
ApplyNamedPlaylistStatus(
_namedPlaylistWorkflow.SelectDefinition(definitionId));
}
catch (Exception exception) when (exception is KeyNotFoundException or
InvalidOperationException or ArgumentException)
{
_dialog = new LegacyDialogState(
"선택한 DB 재생목록이 더 이상 유효하지 않습니다. 목록을 새로 고치세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> LoadSelectedNamedPlaylistAsync(
CancellationToken cancellationToken = default)
{
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync(
cancellationToken).ConfigureAwait(false);
var loadedRows = namedPlaylist.Rows
.Select(CreateNamedPlaylistOperatorRow)
.ToArray();
_playlist.Clear();
_playlist.AddRange(loadedRows);
ResetPlaylistSelection();
SelectInitialPlaylistCandidate();
_dialog = null;
ApplyNamedPlaylistStatus(namedPlaylist);
var unresolvedCount = loadedRows.Count(row => !row.IsSceneResolved);
if (unresolvedCount > 0)
{
_statusMessage =
$"DB 재생목록을 불러왔습니다. 장면 이름을 찾지 못한 {unresolvedCount}개 행은 해당 행을 송출할 때만 차단됩니다.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (InvalidDataException)
{
_dialog = new LegacyDialogState(
"DB 재생목록의 저장 형식을 읽을 수 없어 기존 송출 목록을 유지했습니다.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Error;
}
catch (Exception exception) when (exception is KeyNotFoundException or
InvalidOperationException or ArgumentException)
{
_dialog = new LegacyDialogState("불러올 DB 재생목록을 먼저 선택하세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
catch
{
ApplyNamedPlaylistStatus(
_namedPlaylistWorkflow.Current,
"선택한 DB 재생목록을 불러오지 못했습니다.",
LegacyOperatorStatusKind.Error);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> LoadNamedPlaylistByIdAsync(
string definitionId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(definitionId);
const string command = "load-named-playlist-by-id";
var selected = SelectNamedPlaylist(definitionId);
if (!string.Equals(
selected.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal))
{
CompleteCommand(command, definitionId, succeeded: false);
Touch();
return CreateSnapshot();
}
var loaded = await LoadSelectedNamedPlaylistAsync(cancellationToken)
.ConfigureAwait(false);
var succeeded =
string.Equals(
loaded.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal) &&
loaded.NamedPlaylist?.ReadStatus == LegacyNamedPlaylistReadStatus.Ready &&
loaded.NamedPlaylist?.DocumentFreshness == LegacyNamedPlaylistFreshness.Fresh &&
(loaded.StatusKind is LegacyOperatorStatusKind.Information or
LegacyOperatorStatusKind.Warning) &&
loaded.Dialog is null;
CompleteCommand(command, definitionId, succeeded);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> CreateNamedPlaylistAsync(
string title,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(title);
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
var draft = _namedPlaylistWorkflow.CreateProgramDraft(title);
var result = await _namedPlaylistWorkflow.CreateProgramAsync(
draft,
cancellationToken).ConfigureAwait(false);
ApplyNamedPlaylistStatus(result.Snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException)
{
_dialog = new LegacyDialogState(
"새 DB 재생목록을 만들 수 없습니다. 목록을 새로 고친 뒤 이름을 확인하세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveCurrentNamedPlaylistAsync(
CancellationToken cancellationToken = default)
{
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
if (_playlist.Count > LegacyNamedPlaylistWorkflowController.MaximumItems)
{
throw new InvalidOperationException("The playlist is too large to save.");
}
// DC_LIST/PLAY_LIST compatibility remains the original seven fields.
// Closed native theme rows project their exact DB title into the legacy
// subject field because ExactSubject is intentionally not an eighth field.
var rows = _playlist.Select(row =>
{
var persistedSelection = CreateNamedPlaylistStoredSelection(row);
var entry = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry(
row.RowId,
row.SceneAlias,
row.IsEnabled,
row.FadeDuration,
persistedSelection);
return _namedPlaylistWorkflow.CreateRowDraft(
entry,
ParseNamedPlaylistPage(row.PageText));
}).ToArray();
var result = await _namedPlaylistWorkflow.SaveCurrentAsync(
rows,
cancellationToken).ConfigureAwait(false);
ApplyNamedPlaylistStatus(result.Snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException or FormatException or OverflowException)
{
_dialog = new LegacyDialogState(
"현재 송출 목록을 저장할 수 없습니다. DB 재생목록 선택과 Page 값을 확인하세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveCurrentNamedPlaylistToAsync(
string definitionId,
bool showSavedAlert = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(definitionId);
const string command = "save-current-named-playlist-to";
var selected = SelectNamedPlaylist(definitionId);
if (!string.Equals(
selected.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal))
{
CompleteCommand(command, definitionId, succeeded: false);
Touch();
return CreateSnapshot();
}
var selectedRevision = selected.NamedPlaylist?.Revision ?? -1;
var saved = await SaveCurrentNamedPlaylistAsync(cancellationToken)
.ConfigureAwait(false);
var succeeded =
(saved.NamedPlaylist?.Revision ?? -1) > selectedRevision &&
string.Equals(
saved.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal) &&
saved.NamedPlaylist?.LastMutationOutcome is
LegacyNamedPlaylistMutationOutcome.CommittedFresh or
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
if (succeeded && showSavedAlert)
{
// PList displayed this one-button MessageBox after the confirmed save.
// Keep it in authoritative state so WebView focus/modal behavior is the
// same as every other MmoneyCoder dialog.
_dialog = new LegacyDialogState("저장되었습니다");
}
CompleteCommand(command, definitionId, succeeded);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> DeleteSelectedNamedPlaylistAsync(
CancellationToken cancellationToken = default)
{
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
var result = await _namedPlaylistWorkflow.DeleteSelectedAsync(
cancellationToken).ConfigureAwait(false);
ApplyNamedPlaylistStatus(result.Snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException)
{
_dialog = new LegacyDialogState("삭제할 DB 재생목록을 먼저 선택하세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> ConfirmDialogAsync(
CancellationToken cancellationToken = default)
{
var pending = _pendingNativeConfirmation;
if (_dialog?.IsConfirmation != true || pending is null)
{
return CreateSnapshot();
}
// Consume the confirmation before crossing a database-write boundary. A
// timeout or unknown outcome must never leave a replayable Yes action.
_pendingNativeConfirmation = null;
_dialog = null;
return pending.Kind switch
{
PendingNativeConfirmationKind.DeleteTheme =>
await ExecuteConfirmedThemeDeleteAsync(
pending.ThemeIdentity!,
cancellationToken).ConfigureAwait(false),
PendingNativeConfirmationKind.DeleteExpert =>
await ExecuteConfirmedExpertDeleteAsync(
pending.ExpertIdentity!,
cancellationToken).ConfigureAwait(false),
PendingNativeConfirmationKind.ClearThemeDraft =>
ExecuteConfirmedThemeDraftClear(pending.CatalogRevision),
PendingNativeConfirmationKind.DeleteAllComparisonPairs =>
await ExecuteConfirmedComparisonDeleteAllAsync(
pending.ComparisonRevision,
cancellationToken)
.ConfigureAwait(false),
PendingNativeConfirmationKind.ImportLegacyComparisonPairs =>
await ExecuteConfirmedComparisonImportAsync(
pending.ComparisonRevision,
cancellationToken)
.ConfigureAwait(false),
PendingNativeConfirmationKind.DeleteAllPlaylistRows =>
ExecuteConfirmedPlaylistDeleteAll(pending.PlaylistRowIds!),
PendingNativeConfirmationKind.DeleteManualFinancial =>
await ExecuteConfirmedManualFinancialDeleteAsync(
all: false,
pending.ManualFinancialRevision,
cancellationToken).ConfigureAwait(false),
PendingNativeConfirmationKind.DeleteAllManualFinancial =>
await ExecuteConfirmedManualFinancialDeleteAsync(
all: true,
pending.ManualFinancialRevision,
cancellationToken).ConfigureAwait(false),
PendingNativeConfirmationKind.DeleteAllManualViItems =>
ExecuteConfirmedManualViDeleteAll(pending.ManualViItemIds!),
_ => throw new InvalidOperationException("Unknown native confirmation kind.")
};
}
public LegacyOperatorSnapshot CancelDialog()
{
if (_dialog?.IsConfirmation == true)
{
_pendingNativeConfirmation = null;
_dialog = null;
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot DismissDialog()
{
if (_dialog is not null && !_dialog.IsConfirmation)
{
_dialog = null;
if (_closeOperatorCatalogAfterDialog)
{
_operatorCatalogWorkflow?.Close();
_closeOperatorCatalogAfterDialog = false;
}
ContinuePendingDrop();
Touch();
}
return CreateSnapshot();
}
private LegacyOperatorSnapshot ExecuteConfirmedPlaylistDeleteAll(
IReadOnlyList<string> expectedRowIds)
{
var currentRowIds = _playlist.Select(static row => row.RowId).ToArray();
if (!currentRowIds.SequenceEqual(expectedRowIds, StringComparer.Ordinal))
{
return ReportError(
"송출 리스트가 변경되어 전체 삭제를 시작하지 않았습니다.");
}
_playlist.Clear();
ResetPlaylistSelection();
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedManualFinancialDeleteAsync(
bool all,
long expectedRevision,
CancellationToken cancellationToken)
{
if (!TryGetManualFinancial(out var workflow, out var snapshot) ||
snapshot.Revision != expectedRevision)
{
return ReportError(
"수동 재무 삭제 대상이 변경되어 DB 삭제를 시작하지 않았습니다.");
}
try
{
LegacyManualFinancialWriteRequest request = all
? workflow.CreateDeleteAllRequest(
snapshot,
snapshot.Definition.DeleteAllConfirmationToken)
: workflow.CreateDeleteRequest(snapshot);
_manualFinancialSnapshot = await workflow.ExecuteAsync(
snapshot,
request,
cancellationToken).ConfigureAwait(false);
ApplyManualFinancialResultStatus(
_manualFinancialSnapshot,
all ? "전체 삭제" : "삭제");
Touch();
return CreateSnapshot();
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
private LegacyOperatorSnapshot ExecuteConfirmedManualViDeleteAll(
IReadOnlyList<string> expectedItemIds)
{
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
var current = _manualListsWorkflow.Current;
var currentItemIds = current.ViItems.Select(static item => item.ItemId).ToArray();
if (!current.IsOpen || current.Screen != LegacyManualListScreen.Vi ||
!currentItemIds.SequenceEqual(expectedItemIds, StringComparer.Ordinal))
{
return ReportError(
"VI 목록이 변경되어 전체 삭제를 시작하지 않았습니다.");
}
_manualListsWorkflow.DeleteAllViItems();
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedThemeDeleteAsync(
MMoneyCoderSharp.Data.ThemeSelectionIdentity identity,
CancellationToken cancellationToken)
{
if (_operatorCatalogWorkflow is null || _themeWorkflow is null ||
_themeSnapshot?.SelectedIdentity != identity)
{
return ReportError("삭제 대상 테마가 변경되어 DB 삭제를 시작하지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken)
.ConfigureAwait(false);
if (!_operatorCatalogWorkflow.Current.CanDelete)
{
ApplyOperatorCatalogStatus();
_operatorCatalogWorkflow.Close();
Touch();
return CreateSnapshot();
}
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
if (_operatorCatalogWorkflow.Current.Mode == LegacyOperatorCatalogMode.List)
{
_themeSnapshot = _themeWorkflow.RemoveSelectedResult(_themeSnapshot);
_operatorCatalogWorkflow.Close();
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
}
else
{
ApplyOperatorCatalogStatus();
}
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedExpertDeleteAsync(
MMoneyCoderSharp.Data.ExpertSelectionIdentity identity,
CancellationToken cancellationToken)
{
var selected = _expertWorkflow?.Current.SelectedExpert;
if (_operatorCatalogWorkflow is null || selected is null ||
!string.Equals(selected.ExpertCode, identity.ExpertCode, StringComparison.Ordinal) ||
!string.Equals(selected.ExpertName, identity.ExpertName, StringComparison.Ordinal))
{
return ReportError("삭제 대상 전문가가 변경되어 DB 삭제를 시작하지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenExpertForEditAsync(identity, cancellationToken)
.ConfigureAwait(false);
if (!_operatorCatalogWorkflow.Current.CanDelete)
{
ApplyOperatorCatalogStatus();
_operatorCatalogWorkflow.Close();
Touch();
return CreateSnapshot();
}
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
if (_operatorCatalogWorkflow.Current.Mode == LegacyOperatorCatalogMode.List)
{
_expertWorkflow?.RemoveSelectedResultPreservingPreview();
_operatorCatalogWorkflow.Close();
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
}
else
{
ApplyOperatorCatalogStatus();
}
Touch();
return CreateSnapshot();
}
private LegacyOperatorSnapshot ExecuteConfirmedThemeDraftClear(long catalogRevision)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
var catalog = _operatorCatalogWorkflow.Current;
if (catalog.Revision != catalogRevision || !catalog.IsOpen ||
catalog.Entity != LegacyOperatorCatalogEntity.Theme ||
catalog.Mode is not (LegacyOperatorCatalogMode.Create or
LegacyOperatorCatalogMode.Edit))
{
return ReportError("ThemeA 편집 목록이 변경되어 전체 삭제를 실행하지 않았습니다.");
}
_operatorCatalogWorkflow.ClearThemeDraftRows();
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedComparisonDeleteAllAsync(
long comparisonRevision,
CancellationToken cancellationToken)
{
if (!IsComparisonActive() || _comparisonWorkflow is null ||
_comparisonWorkflow.Current.Revision != comparisonRevision)
{
return ReportError(
"삭제 대상 종목 비교 목록이 변경되어 전체 삭제를 실행하지 않았습니다.");
}
await _comparisonWorkflow.DeleteAllPairsAsync(cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedComparisonImportAsync(
long comparisonRevision,
CancellationToken cancellationToken)
{
if (!IsComparisonActive() || _comparisonWorkflow is null ||
_comparisonWorkflow.Current.Revision != comparisonRevision)
{
return ReportError(
"비교쌍 목록이 바뀌어 원본 비교쌍 가져오기를 시작하지 않았습니다.");
}
try
{
await _comparisonWorkflow.ImportLegacyPairsAsync(cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
catch (LegacyComparisonImportException exception)
{
_dialog = new LegacyDialogState(exception.Failure switch
{
LegacyComparisonImportFailure.SourceUnavailable =>
"원본 프로그램의 고정된 Data\\종목비교.dat 파일을 읽을 수 없습니다.",
LegacyComparisonImportFailure.SourceInvalid =>
"원본 종목비교.dat 파일 형식이 올바르지 않아 가져오지 않았습니다.",
LegacyComparisonImportFailure.IdentityNotFound =>
"원본 비교쌍 중 현재 종목 DB에서 정확히 찾을 수 없는 항목이 있어 아무것도 가져오지 않았습니다.",
LegacyComparisonImportFailure.IdentityAmbiguous =>
"원본 비교쌍 중 현재 종목 DB에서 하나로 확정할 수 없는 항목이 있어 아무것도 가져오지 않았습니다.",
LegacyComparisonImportFailure.DatabaseDataInvalid =>
"현재 종목 DB 조회 결과가 안전한 식별 형식과 일치하지 않아 아무것도 가져오지 않았습니다.",
_ => "원본 비교쌍을 안전하게 가져오지 못했습니다."
});
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public LegacyOperatorSnapshot ReportError(string message)
{
_statusMessage = message ?? string.Empty;
_statusKind = LegacyOperatorStatusKind.Error;
Touch();
return CreateSnapshot();
}
/// <summary>
/// Completes a correlated named-playlist command when the native dispatch
/// boundary rejects the request or encounters an unexpected exception.
/// The terminal receipt prevents the WebView modal from waiting indefinitely;
/// the named-playlist workflow remains the authority for any write quarantine
/// that was already latched before the exception escaped.
/// </summary>
public LegacyOperatorSnapshot ReportNamedPlaylistCommandFailure(
string command,
string targetId,
string message)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command);
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
if (command is not "refresh-named-playlists" and
not "select-named-playlist" and
not "create-named-playlist" and
not "delete-selected-named-playlist" and
not "load-named-playlist-by-id" and
not "save-current-named-playlist-to")
{
throw new ArgumentOutOfRangeException(
nameof(command),
command,
"Only correlated named-playlist commands can be completed here.");
}
CompleteCommand(command, targetId, succeeded: false);
return ReportError(message);
}
public LegacyOperatorSnapshot CompleteNamedPlaylistDispatch(
string command,
string requestId,
bool succeeded)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command);
ArgumentException.ThrowIfNullOrWhiteSpace(requestId);
if (command is not "refresh-named-playlists" and
not "select-named-playlist" and
not "create-named-playlist" and
not "delete-selected-named-playlist")
{
throw new ArgumentOutOfRangeException(
nameof(command),
command,
"The command does not use a named-playlist dispatch id.");
}
CompleteCommand(command, requestId, succeeded);
Touch();
return CreateSnapshot();
}
/// <summary>
/// Terminates an accepted save command without making its database outcome
/// retryable. This is intentionally separate from a known pre-dispatch
/// rejection: an escaped post-dispatch exception permanently quarantines every
/// later named-playlist write in this process.
/// </summary>
public LegacyOperatorSnapshot ReportNamedPlaylistWriteOutcomeUnknown(
string command,
string targetId,
string message)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command);
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
if (command is not "create-named-playlist" and
not "delete-selected-named-playlist" and
not "save-current-named-playlist-to")
{
throw new ArgumentOutOfRangeException(
nameof(command),
command,
"The command is not a named-playlist write.");
}
_namedPlaylistWorkflow?.QuarantineAmbiguousWriteOutcome();
CompleteCommand(command, targetId, succeeded: false);
return ReportError(message);
}
public LegacyOperatorSnapshot ReportInformation(string message)
{
_statusMessage = message ?? string.Empty;
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReportWarning(string message)
{
_statusMessage = message ?? string.Empty;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
private void ContinuePendingDrop()
{
while (_pendingDropCutRows is { Count: > 0 })
{
var physicalIndex = _pendingDropCutRows.Dequeue();
if (!TryAppendCut(physicalIndex))
{
return;
}
}
_pendingDropCutRows = null;
}
private bool TryAppendCut(int physicalIndex)
{
if (_selectedStockIndex is not int stockIndex ||
stockIndex < 0 || stockIndex >= _searchData.DisplayNames.Count ||
!IsCutIndex(physicalIndex))
{
return true;
}
var cut = _cutRows[physicalIndex];
var stockName = _searchData.DisplayNames[stockIndex];
var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal);
if (isNxt && !string.Equals(
cut.RawLabel,
LegacyStockCutCatalog.NxtAllowedLabel,
StringComparison.Ordinal))
{
_dialog = new LegacyDialogState("NXT는 제공하지 않습니다.");
return false;
}
if (cut.IsSeparator)
{
return true;
}
var identity = _searchData.ResolveLastByDisplayName(stockName);
var rowId = $"legacy-stock-{++_playlistSequence:D8}";
AppendPlaylistRow(LegacyStockCutCatalog.CreatePlaylistRow(
rowId,
stockName,
identity,
cut));
return true;
}
private void ApplyLegacyPointerSelection(int physicalIndex, bool control, bool shift)
{
if (control && shift)
{
AddLegacyRangeFromAnchor(physicalIndex);
}
else if (control)
{
if (!_legacyPointerRows.Add(physicalIndex))
{
_legacyPointerRows.Remove(physicalIndex);
}
}
else if (shift)
{
_legacyPointerRows.Clear();
AddLegacyRangeFromAnchor(physicalIndex);
}
else
{
_legacyPointerRows.Clear();
_legacyPointerRows.Add(physicalIndex);
}
}
private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift)
{
var anchor = _nativeSelectionAnchorIndex >= 0
? _nativeSelectionAnchorIndex
: physicalIndex;
if (control && shift)
{
AddInclusiveRange(
_nativeSelectedCutRows,
anchor,
physicalIndex);
}
else if (control)
{
if (!_nativeSelectedCutRows.Add(physicalIndex))
{
_nativeSelectedCutRows.Remove(physicalIndex);
}
}
else if (shift)
{
_nativeSelectedCutRows.Clear();
AddInclusiveRange(
_nativeSelectedCutRows,
anchor,
physicalIndex);
}
else
{
_nativeSelectedCutRows.Clear();
_nativeSelectedCutRows.Add(physicalIndex);
}
}
private void ApplyNativeKeyboardNavigationSelection(
int physicalIndex,
bool control,
bool shift)
{
var anchor = IsCutIndex(_nativeSelectionAnchorIndex)
? _nativeSelectionAnchorIndex
: physicalIndex;
if (control && !shift)
{
// Ctrl+navigation moves the focus rectangle without changing either
// selection or the Win32 ListView selection mark.
return;
}
if (control && shift)
{
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
return;
}
_nativeSelectedCutRows.Clear();
if (shift)
{
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
return;
}
_nativeSelectedCutRows.Add(physicalIndex);
_nativeSelectionAnchorIndex = physicalIndex;
}
private void ApplyNativeSpaceSelection(int physicalIndex, bool control, bool shift)
{
var anchor = IsCutIndex(_nativeSelectionAnchorIndex)
? _nativeSelectionAnchorIndex
: physicalIndex;
if (control && shift)
{
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
return;
}
if (control)
{
if (!_nativeSelectedCutRows.Add(physicalIndex))
{
_nativeSelectedCutRows.Remove(physicalIndex);
}
_nativeSelectionAnchorIndex = physicalIndex;
return;
}
_nativeSelectedCutRows.Clear();
if (shift)
{
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
return;
}
_nativeSelectedCutRows.Add(physicalIndex);
_nativeSelectionAnchorIndex = physicalIndex;
}
private void AddLegacyRangeFromAnchor(int physicalIndex)
{
// MainForm used beforeRow = -1 and skipped the range when the previous
// and current rows were equal. The sentinel also affected its double-click count.
var anchor = _cutAnchorIndex ?? -1;
if (anchor == physicalIndex)
{
return;
}
var first = Math.Min(anchor, physicalIndex);
var last = Math.Max(anchor, physicalIndex);
for (var index = first; index <= last; index++)
{
_legacyPointerRows.Add(index);
}
}
private static void AddInclusiveRange(
ISet<int> target,
int anchor,
int physicalIndex)
{
var first = Math.Min(anchor, physicalIndex);
var last = Math.Max(anchor, physicalIndex);
for (var index = first; index <= last; index++)
{
target.Add(index);
}
}
private void ApplyNamedPlaylistStatus(
LegacyNamedPlaylistWorkflowSnapshot snapshot,
string? fallbackMessage = null,
LegacyOperatorStatusKind? fallbackKind = null)
{
_statusMessage = string.IsNullOrWhiteSpace(snapshot.StatusMessage)
? fallbackMessage ?? string.Empty
: snapshot.StatusMessage;
_statusKind = fallbackKind ?? snapshot.StatusKind;
}
private static MMoneyCoderSharp.Data.NamedPlaylistPageState? ParseNamedPlaylistPage(
string pageText)
{
ArgumentNullException.ThrowIfNull(pageText);
if (pageText.Length == 0)
{
return null;
}
if (!string.Equals(pageText, pageText.Trim(), StringComparison.Ordinal))
{
throw new FormatException("A named-playlist page must be canonical.");
}
var parts = pageText.Split('/');
if (parts.Length != 2 ||
!int.TryParse(
parts[0],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out var currentPage) ||
!int.TryParse(
parts[1],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out var totalPages))
{
throw new FormatException("A named-playlist page must use current/total form.");
}
return new MMoneyCoderSharp.Data.NamedPlaylistPageState(currentPage, totalPages);
}
private static CoreSceneSelection CreateNamedPlaylistStoredSelection(
LegacyOperatorPlaylistRow row)
{
var native = row.PlayoutSelection;
if (native?.ExactSubject is { } exactTitle &&
native.GroupCode is "PAGED_THEME" or "PAGED_NXT_THEME")
{
var isNxt = native.GroupCode == "PAGED_NXT_THEME";
var expectedMarket = isNxt ? "테마_NXT" : "테마";
if (!string.Equals(row.MarketText, expectedMarket, StringComparison.Ordinal) ||
!string.Equals(native.DataCode, row.StockCode, StringComparison.Ordinal) ||
native.DataCode.Length is < 3 or > 8 ||
native.DataCode.Any(character => !char.IsAsciiDigit(character)) ||
!IsSafeStoredThemeTitle(exactTitle, 128))
{
throw new InvalidOperationException(
"The native theme row has an invalid exact stored identity.");
}
return new CoreSceneSelection(
row.MarketText,
isNxt ? exactTitle + "(NXT)" : exactTitle,
row.GraphicType,
row.Subtype,
row.StockCode);
}
return new CoreSceneSelection(
row.MarketText,
row.StockName,
row.GraphicType,
row.Subtype,
row.StockCode);
}
private LegacyOperatorPlaylistRow CreateNamedPlaylistOperatorRow(
LegacyNamedPlaylistRowView row)
{
var selection = row.Selection;
var sceneAlias = ResolveNamedPlaylistSceneAlias(selection) ??
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry.UnresolvedCutCode;
var playoutSelection = TryCreateNamedFixedSelection(
selection,
out var fixedSelection,
out _)
? fixedSelection
: TryCreateNamedStockCandleSelection(selection, out var stockCandleSelection)
? stockCandleSelection
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
? stockDetailSelection
: TryCreateNamedLegacyIndustryFixedSelection(
selection,
out var legacyIndustryFixedSelection)
? legacyIndustryFixedSelection
: TryCreateNamedIndustrySelection(selection, out var industrySelection)
? industrySelection
: TryCreateNamedLegacyComparisonSelection(
selection,
out var legacyComparisonSelection)
? legacyComparisonSelection
: TryCreateNamedComparisonSelection(selection, out var comparisonSelection)
? comparisonSelection
: TryCreateNamedThemeSelection(selection, out var themeSelection)
? themeSelection
: TryCreateNamedExpertSelection(selection, out var expertSelection)
? expertSelection
: 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}";
return NormalizeMovingAverageIdentity(new LegacyOperatorPlaylistRow(
$"legacy-named-{++_playlistSequence:D8}",
row.IsEnabled,
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
row.Page?.PageText ?? string.Empty,
selection.DataCode,
rawCutLabel,
6,
SceneAlias: sceneAlias,
PlayoutSelection: playoutSelection));
}
private string? ResolveNamedPlaylistSceneAlias(
LegacyNamedPlaylistSelectionView selection)
{
var rawLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
// An original fixed-index row can use a stock-market value such as 코스피.
// Resolve the exact fixed signature before the broader stock-cut label map.
if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction))
{
return fixedAction!.Code;
}
// Stock and trading-halt rows retain the original stock-market and cut-label
// columns, so the closed stock catalog remains the authority for their alias.
if (selection.GroupCode is "코스피" or "코스닥" or
"코스피_NXT" or "코스닥_NXT")
{
try
{
return LegacyStockCutCatalog.ResolveCutAlias(
rawLabel,
selection.GroupCode.EndsWith("_NXT", StringComparison.Ordinal) ||
selection.Subject.Contains("(NXT)", StringComparison.Ordinal));
}
catch (InvalidOperationException)
{
// Continue through the other closed registries. This also permits
// manual-financial rows whose market column resembles a stock market.
}
}
var industryMarket = selection.GroupCode switch
{
"업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
"업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
_ => (MMoneyCoderSharp.Data.IndustryMarket?)null
};
if (industryMarket is { } industry)
{
var actions = LegacyIndustryActionCatalog.GetActions(industry)
.Where(action => string.Equals(
action.Label,
rawLabel,
StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length == 1)
{
return actions[0].SceneAlias;
}
}
if (selection.GroupCode is "테마" or "테마_NXT")
{
var detail = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType} · {selection.Subtype}";
var actions = LegacyThemeWorkflow.Actions
.Where(action => LegacyThemeWorkflow.Sorts.Any(sort => string.Equals(
$"{action.Label} · {sort.Label}",
detail,
StringComparison.Ordinal)))
.Take(2)
.ToArray();
if (actions.Length == 1)
{
return actions[0].Code;
}
}
if (selection.GroupCode is "전문가" or "전문가 추천" &&
string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal))
{
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
{
"s5032" => "5032",
"s8018" => "8032",
"s5029" => "5029",
"s5026" => "5026",
"s5087" => "5087",
_ => null
};
if (alias is not null)
{
return alias;
}
}
var manualFinancial = LegacyManualFinancialWorkflow.Screens
.Where(definition =>
string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
(selection.GroupCode is "KOSPI" or "KOSDAQ" or
"NXT_KOSPI" or "NXT_KOSDAQ"))
.Take(2)
.ToArray();
if (manualFinancial.Length == 1)
{
return manualFinancial[0].Code;
}
return LegacyCutInfoSceneAliasResolver.TryResolve(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
out var legacyAlias)
? legacyAlias
: null;
}
private bool TryCreateNamedLegacyIndustryFixedSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
var market = selection.GroupCode switch
{
"업종_코스피" when selection.Subject == "코스피" =>
MMoneyCoderSharp.Data.IndustryMarket.Kospi,
"업종_코스닥" when selection.Subject == "코스닥" =>
MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
_ => (MMoneyCoderSharp.Data.IndustryMarket?)null
};
if (market is not { } exactMarket || selection.Subtype.Length != 0 ||
selection.DataCode.Length != 0)
{
return false;
}
var marketGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "INDUSTRY_KOSPI"
: "INDUSTRY_KOSDAQ";
var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "KOSPI"
: "KOSDAQ";
normalized = selection.GraphicType switch
{
"5단 표그래프" => new CoreSceneSelection(
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "PAGED_DOMESTIC_KOSPI"
: "PAGED_DOMESTIC_KOSDAQ",
"INDEX",
string.Empty,
"INDUSTRY",
_timeProvider.GetLocalNow().ToString(
"yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture)),
"네모그래프" => new CoreSceneSelection(
marketGroup,
marketName,
"네모그래프",
string.Empty,
string.Empty),
"섹터지수" => new CoreSceneSelection(
marketGroup,
marketName,
"SECTOR_INDEX",
string.Empty,
string.Empty),
_ => null!
};
return normalized is not null;
}
private static bool TryCreateNamedLegacyComparisonSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (!string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) ||
!string.Equals(selection.GraphicType, "2열판", StringComparison.Ordinal) ||
selection.DataCode.Length != 0)
{
return false;
}
var actionId = selection.Subtype switch
{
"" => "two-column-current",
"예상체결" => "two-column-expected",
_ => null
};
var names = selection.Subject.Split(',', StringSplitOptions.None);
if (actionId is null || names.Length != 2)
{
return false;
}
var first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
new LegacyComparisonStoredTargetReference(names[0], string.Empty));
var second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
new LegacyComparisonStoredTargetReference(names[1], string.Empty));
if (first is null || second is null)
{
return false;
}
try
{
normalized = LegacyComparisonWorkflowCatalog.Materialize(
actionId,
first,
second,
"legacy-named-restore").Selection;
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
private bool TryCreateNamedThemeSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
var isNxt = string.Equals(selection.GroupCode, "테마_NXT", StringComparison.Ordinal);
if (!isNxt && !string.Equals(selection.GroupCode, "테마", StringComparison.Ordinal) ||
selection.DataCode.Length is < 3 or > 8 ||
selection.DataCode.Any(character => !char.IsAsciiDigit(character)))
{
return false;
}
var subtype = selection.Subtype switch
{
"테마-현재가(입력순)" => (Value: "CURRENT", Sort: "INPUT_ORDER"),
"테마-현재가(상승률순)" => (Value: "CURRENT", Sort: "GAIN_DESC"),
"테마-현재가(하락률순)" => (Value: "CURRENT", Sort: "GAIN_ASC"),
"테마-현재가" => (Value: "CURRENT", Sort: "GAIN_ASC"),
"테마-예상체결가(입력순)" => (Value: "EXPECTED", Sort: "INPUT_ORDER"),
"테마-예상체결가(상승률순)" => (Value: "EXPECTED", Sort: "GAIN_DESC"),
"테마-예상체결가(하락률순)" => (Value: "EXPECTED", Sort: "GAIN_ASC"),
"테마-예상체결가" => (Value: "EXPECTED", Sort: "GAIN_ASC"),
_ => default
};
if (subtype.Value is null || isNxt && subtype.Value != "CURRENT" ||
selection.GraphicType switch
{
"5단 표그래프" => false,
"6종목 현재가" or "12종목 현재가" => subtype.Value != "CURRENT",
_ => true
})
{
return false;
}
if (isNxt)
{
const string suffix = "(NXT)";
if (!selection.Subject.EndsWith(suffix, StringComparison.Ordinal) ||
selection.Subject.Length == suffix.Length)
{
return false;
}
var exactTitle = selection.Subject[..^suffix.Length];
if (!IsSafeStoredThemeTitle(exactTitle, 128))
{
return false;
}
normalized = new CoreSceneSelection(
"PAGED_NXT_THEME",
selection.Subject,
_timeProvider.GetLocalNow().Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET",
subtype.Sort,
selection.DataCode,
exactTitle);
return true;
}
if (!IsSafeStoredThemeTitle(selection.Subject, 128))
{
return false;
}
normalized = new CoreSceneSelection(
"PAGED_THEME",
selection.Subject,
subtype.Sort,
subtype.Value,
selection.DataCode,
selection.Subject);
return true;
}
private static bool IsSafeStoredThemeTitle(string value, int maximumLength) =>
value.Length is >= 1 &&
value.Length <= maximumLength &&
!string.IsNullOrWhiteSpace(value) &&
!value.Contains('^') &&
!value.Any(character =>
char.IsControl(character) ||
char.IsSurrogate(character) ||
character == '\uFFFD' ||
System.Globalization.CharUnicodeInfo.GetUnicodeCategory(character) is
System.Globalization.UnicodeCategory.Format or
System.Globalization.UnicodeCategory.LineSeparator or
System.Globalization.UnicodeCategory.ParagraphSeparator);
private static bool TryCreateNamedExpertSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.GroupCode is not ("전문가" or "전문가 추천") ||
!string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) ||
!string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) ||
selection.DataCode.Length != 4 ||
selection.DataCode.Any(character => !char.IsAsciiDigit(character)))
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
128,
nameof(selection.Subject));
}
catch (ArgumentException)
{
return false;
}
normalized = new CoreSceneSelection(
"PAGED_EXPERT",
selection.Subject,
"INPUT_ORDER",
"CURRENT",
selection.DataCode);
return true;
}
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,
_ when selection.DataCode.Length == 0 &&
string.Equals(selection.Subject, "지수", StringComparison.Ordinal) =>
LegacyFixedMarket.Index,
_ => (LegacyFixedMarket?)null
};
if (fixedMarket is not { } market)
{
return false;
}
var matches = _fixedCatalog.GetActions(market)
.Where(candidate =>
MatchesCurrentNamedFixedSelection(candidate, selection) ||
MatchesLegacyNamedFixedSelection(candidate, selection))
.Take(2)
.ToArray();
if (matches.Length != 1 || !matches[0].Available)
{
return false;
}
action = matches[0];
var isCurrentShape = MatchesCurrentNamedFixedSelection(action, selection);
var isLegacyShape = MatchesLegacyNamedFixedSelection(action, selection);
var materialized = _fixedCatalog.Materialize(
action.Id,
_timeProvider.GetLocalNow()).Selection;
normalized = new CoreSceneSelection(
materialized.GroupCode,
materialized.Subject,
materialized.GraphicType,
materialized.Subtype,
isLegacyShape && !isCurrentShape
? materialized.DataCode
: selection.DataCode);
return true;
}
private static bool MatchesCurrentNamedFixedSelection(
LegacyFixedAction action,
LegacyNamedPlaylistSelectionView selection) =>
string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) &&
string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal);
private static bool MatchesLegacyNamedFixedSelection(
LegacyFixedAction action,
LegacyNamedPlaylistSelectionView selection)
{
var visible = ProjectLegacyFixedVisibleSelection(action);
return string.Equals(visible.GroupCode, selection.GroupCode, StringComparison.Ordinal) &&
string.Equals(visible.Subject, selection.Subject, StringComparison.Ordinal) &&
string.Equals(visible.GraphicType, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(visible.Subtype, selection.Subtype, StringComparison.Ordinal) &&
string.Equals(visible.DataCode, selection.DataCode, StringComparison.Ordinal);
}
private static LegacyFixedSelection ProjectLegacyFixedVisibleSelection(
LegacyFixedAction action)
{
var split = action.Label.IndexOf('_');
var first = split < 0 ? action.Label : action.Label[..split];
var remainder = split < 0 ? string.Empty : action.Label[(split + 1)..];
if (action.Market is LegacyFixedMarket.Overseas or LegacyFixedMarket.Exchange)
{
return new LegacyFixedSelection(
action.Market == LegacyFixedMarket.Overseas ? "해외지수" : "환율",
first,
action.Section,
remainder,
string.Empty);
}
if (action.Section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
{
return new LegacyFixedSelection(
LegacyFixedIndexGroup(action.Label),
"지수",
action.Section,
action.Label,
string.Empty);
}
if (string.Equals(action.Section, "매매동향", StringComparison.Ordinal))
{
return new LegacyFixedSelection(
LegacyFixedIndexGroup(action.Label),
"지수",
first,
remainder,
string.Empty);
}
return new LegacyFixedSelection(
action.Section,
"지수",
first,
remainder,
string.Empty);
}
private static string LegacyFixedIndexGroup(string label) =>
label.Contains("코스피_NXT", StringComparison.Ordinal) ? "코스피_NXT" :
label.Contains("코스닥_NXT", StringComparison.Ordinal) ? "코스닥_NXT" :
label.Contains("코스피", StringComparison.Ordinal) ? "코스피" :
label.Contains("코스닥", StringComparison.Ordinal) ? "코스닥" :
"전체";
private static bool TryCreateNamedStockCandleSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
200,
nameof(selection.Subject));
LegacyComparisonValueGuard.RequireStockCode(
selection.DataCode,
nameof(selection.DataCode));
}
catch (ArgumentException)
{
return false;
}
return LegacyStockCutCatalog.TryCreateDomesticStockCandleSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode,
out normalized);
}
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
.Select((name, index) => new LegacyStockResultRow(index, name))
.ToArray();
var cutRows = _cutRows
.Select(row => new LegacyCutViewRow(
row.PhysicalIndex,
row.DisplayOrdinal,
row.RawLabel,
row.IsSeparator,
_nativeSelectedCutRows.Contains(row.PhysicalIndex),
row.PhysicalIndex == _nativeFocusedCutIndex))
.ToArray();
var playlistRows = _playlist
.Select(row => row with
{
IsSelected = _selectedPlaylistRowIds.Contains(row.RowId),
IsActive = string.Equals(
_activePlaylistRowId,
row.RowId,
StringComparison.Ordinal)
})
.ToArray();
var tabs = _tabOrder
.Select(tab => new LegacyOperatorTabState(
tab,
GetTabLabel(tab),
tab == _activeTab))
.ToArray();
var fixedCatalog = CreateFixedCatalogView();
var operatorCatalog = _operatorCatalogWorkflow?.Current;
return new LegacyOperatorSnapshot(
_revision,
_searchText,
Array.AsReadOnly(searchRows),
_selectedStockIndex,
Array.AsReadOnly(cutRows),
Array.AsReadOnly(playlistRows),
_dialog,
_statusMessage,
_statusKind,
Array.AsReadOnly(tabs),
_movingAverages,
fixedCatalog,
GetActiveIndustrySnapshot(),
IsThemeActive() ? _themeSnapshot : null,
IsExpertActive() ? _expertWorkflow?.Current : null,
IsTradingHaltActive() ? _tradingHaltWorkflow?.Current : null,
IsComparisonActive() ? _comparisonWorkflow?.Current : null,
_manualFinancialSnapshot,
_namedPlaylistWorkflow?.Current,
IsOverseasActive() ? _overseasWorkflow?.Current : null,
_manualListsWorkflow?.Current,
operatorCatalog?.IsOpen == true ? operatorCatalog : null,
_commandResult,
_pendingFixedSectionBatch?.CreateView());
}
private LegacyFixedCatalogView? CreateFixedCatalogView()
{
var market = GetActiveFixedMarket();
if (market is null)
{
return null;
}
var sections = _fixedCatalog.GetSections(market.Value)
.Select((section, index) => new LegacyFixedSectionView(
index,
section.Name,
Array.AsReadOnly(section.Actions
.Select(action => new LegacyFixedActionView(
action.Id,
action.Label,
action.Detail,
action.Available,
action.Prerequisite))
.ToArray())))
.ToArray();
return new LegacyFixedCatalogView(market.Value, Array.AsReadOnly(sections));
}
private LegacyFixedMarket? GetActiveFixedMarket() => _activeTab switch
{
LegacyOperatorTabId.Overseas => LegacyFixedMarket.Overseas,
LegacyOperatorTabId.Exchange => LegacyFixedMarket.Exchange,
LegacyOperatorTabId.Index => LegacyFixedMarket.Index,
_ => null
};
private MMoneyCoderSharp.Data.IndustryMarket? GetActiveIndustryMarket() => _activeTab switch
{
LegacyOperatorTabId.KospiIndustry => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
LegacyOperatorTabId.KosdaqIndustry => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
_ => null
};
private LegacyIndustrySelectionSnapshot? GetActiveIndustrySnapshot() =>
GetActiveIndustryMarket() is { } market &&
_industryWorkflow?.Current.Market == market
? _industryWorkflow.Current
: null;
private bool IsActiveIndustryReady() => GetActiveIndustrySnapshot() is not null;
private bool IsThemeActive() => _activeTab == LegacyOperatorTabId.Theme;
private MMoneyCoderSharp.Data.ThemeSession CurrentThemeNxtSession() =>
_timeProvider.GetLocalNow().Hour <= 12
? MMoneyCoderSharp.Data.ThemeSession.PreMarket
: MMoneyCoderSharp.Data.ThemeSession.AfterMarket;
private void BeginTabActivation(LegacyOperatorTabId tabId)
{
if (tabId == LegacyOperatorTabId.KospiIndustry)
{
_industryActivationListLoaded = false;
_industryWorkflow?.ResetForTabActivation(
MMoneyCoderSharp.Data.IndustryMarket.Kospi);
}
else if (tabId == LegacyOperatorTabId.KosdaqIndustry)
{
_industryActivationListLoaded = false;
_industryWorkflow?.ResetForTabActivation(
MMoneyCoderSharp.Data.IndustryMarket.Kosdaq);
}
else if (tabId == LegacyOperatorTabId.Comparison)
{
_comparisonActivationListLoaded = false;
_comparisonWorkflow?.ResetForTabActivation();
}
else if (tabId == LegacyOperatorTabId.Theme)
{
_themeActivationListLoaded = false;
if (_themeWorkflow is not null && _themeSnapshot is not null)
{
_themeSnapshot = _themeWorkflow.ResetForTabActivation(
_themeSnapshot,
CurrentThemeNxtSession());
}
}
else if (tabId == LegacyOperatorTabId.OverseasStocks)
{
_overseasWorkflow?.ResetForTabActivation();
}
else if (tabId == LegacyOperatorTabId.Expert)
{
_expertActivationListLoaded = false;
}
else if (tabId == LegacyOperatorTabId.TradingHalt)
{
_tradingHaltWorkflow?.ResetForTabActivation();
}
}
private bool IsCurrentTabActivation(
LegacyOperatorTabId tabId,
long activationRequest) =>
_activeTab == tabId &&
Volatile.Read(ref _tabActivationRequest) == activationRequest;
private bool IsComparisonActive() => _activeTab == LegacyOperatorTabId.Comparison;
private bool IsExpertActive() => _activeTab == LegacyOperatorTabId.Expert;
private bool IsTradingHaltActive() => _activeTab == LegacyOperatorTabId.TradingHalt;
private bool IsOverseasActive() => _activeTab == LegacyOperatorTabId.OverseasStocks;
private bool TryGetManualFinancial(
out LegacyManualFinancialWorkflow workflow,
out LegacyManualFinancialWorkflowSnapshot snapshot)
{
workflow = _manualFinancialWorkflow!;
snapshot = _manualFinancialSnapshot!;
return workflow is not null && snapshot is not null;
}
private void ApplyManualFinancialListStatus(
LegacyManualFinancialWorkflowSnapshot snapshot)
{
var warnings = new List<string>();
if (snapshot.ListRejectedItemCount > 0)
{
var displayedLegacyRawCount = snapshot.Rows.Count(row =>
!row.IsPlayoutReady);
var hiddenUnreadableCount =
snapshot.ListRejectedItemCount - displayedLegacyRawCount;
warnings.Add(hiddenUnreadableCount == 0
? $"기존 원문 형식 데이터 {displayedLegacyRawCount}행도 " +
"목록에 표시되며 선택·편집할 수 있습니다."
: $"기존 원문 형식 데이터 {displayedLegacyRawCount}행은 표시했고, " +
$"이름을 읽을 수 없는 {hiddenUnreadableCount}행은 제외했습니다.");
}
if (snapshot.ListIsTruncated)
{
warnings.Add(
$"검색 결과가 {LegacyManualFinancialWorkflow.MaximumListResults}행을 " +
"초과해 일부만 표시됩니다.");
}
_statusMessage = $"{snapshot.Definition.Label} 목록을 불러왔습니다." +
(warnings.Count == 0 ? string.Empty : " " + string.Join(' ', warnings));
_statusKind = warnings.Count == 0
? LegacyOperatorStatusKind.Information
: LegacyOperatorStatusKind.Warning;
}
private void ApplyManualFinancialResultStatus(
LegacyManualFinancialWorkflowSnapshot snapshot,
string operation)
{
_statusMessage = snapshot.Status switch
{
LegacyManualFinancialWorkflowStatus.WriteCommitted =>
$"수동 재무 {operation} 작업이 완료되었습니다.",
LegacyManualFinancialWorkflowStatus.WriteRejected =>
snapshot.LastMessage ?? $"수동 재무 {operation} 작업이 거부되었습니다.",
LegacyManualFinancialWorkflowStatus.ReadBackRequired =>
snapshot.LastMessage ?? "저장은 완료됐지만 새로 읽기가 필요합니다.",
LegacyManualFinancialWorkflowStatus.OutcomeUnknown =>
snapshot.LastMessage ?? "결과가 불명확하여 수동 재무 쓰기를 차단했습니다.",
_ => snapshot.LastMessage ?? string.Empty
};
_statusKind = snapshot.Status switch
{
LegacyManualFinancialWorkflowStatus.WriteCommitted =>
LegacyOperatorStatusKind.Information,
LegacyManualFinancialWorkflowStatus.WriteRejected =>
LegacyOperatorStatusKind.Warning,
LegacyManualFinancialWorkflowStatus.ReadBackRequired or
LegacyManualFinancialWorkflowStatus.OutcomeUnknown =>
LegacyOperatorStatusKind.Error,
_ => LegacyOperatorStatusKind.Neutral
};
}
private void ShowManualFinancialSaveDialog(
LegacyManualFinancialWorkflowSnapshot snapshot)
{
// GraphE used a one-button MmoneyCoder MessageBox after every explicit
// save attempt. Only a correlated write plus fresh read-back receives
// the legacy success wording. Known rollback/read-back/unknown outcomes
// retain their already-sanitized, specific status instead of being
// mislabeled as a generic database outage.
_dialog = snapshot.Status switch
{
LegacyManualFinancialWorkflowStatus.WriteCommitted =>
new LegacyDialogState("저장되었습니다"),
LegacyManualFinancialWorkflowStatus.WriteRejected or
LegacyManualFinancialWorkflowStatus.ReadBackRequired or
LegacyManualFinancialWorkflowStatus.OutcomeUnknown =>
new LegacyDialogState(_statusMessage),
_ => null
};
}
private void ApplyManualListsStatus()
{
if (_manualListsWorkflow is null)
{
return;
}
_statusMessage = _manualListsWorkflow.Current.StatusMessage;
_statusKind = _manualListsWorkflow.Current.StatusKind;
}
private async Task<LegacyOperatorSnapshot> OpenPendingFixedSectionManualAsync(
CancellationToken cancellationToken)
{
var pending = _pendingFixedSectionBatch
?? throw new InvalidOperationException("A fixed-section batch is not active.");
var item = pending.CurrentManualItem
?? throw new InvalidOperationException("The fixed-section batch has no pending manual item.");
var target = item.ManualTarget!.Value;
if (target.Screen == LegacyManualListScreen.NetSell)
{
await OpenManualNetSellCoreAsync(target.Audience, cancellationToken)
.ConfigureAwait(false);
}
else
{
await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false);
}
var manual = _manualListsWorkflow!.Current;
var view = pending.CreateView();
var progress =
$"{pending.SectionName} 섹션 수동 입력 {view.ManualStepNumber}/{view.ManualStepCount}: {item.Action.Label}. 확인하면 다음 직계 자식으로 계속합니다.";
_statusMessage = string.IsNullOrWhiteSpace(manual.StatusMessage)
? progress
: $"{progress} {manual.StatusMessage}";
_statusKind = manual.StatusKind == LegacyOperatorStatusKind.Neutral
? LegacyOperatorStatusKind.Information
: manual.StatusKind;
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ContinuePendingFixedSectionAsync(
CancellationToken cancellationToken)
{
var pending = _pendingFixedSectionBatch
?? throw new InvalidOperationException("A fixed-section batch is not active.");
if (pending.CurrentManualItem is not null)
{
return await OpenPendingFixedSectionManualAsync(cancellationToken)
.ConfigureAwait(false);
}
try
{
CommitFixedSectionBatch(pending.Items);
}
catch
{
_pendingFixedSectionBatch = null;
throw;
}
_pendingFixedSectionBatch = null;
ApplyFixedSectionAddedStatus(pending.SectionName, pending.Items);
Touch();
return CreateSnapshot();
}
private void CommitFixedSectionBatch(IReadOnlyList<PendingFixedSectionItem> items)
{
var originalCount = _playlist.Count;
var originalSequence = _playlistSequence;
var originalActiveRowId = _activePlaylistRowId;
var originalSelectedRowIds = _selectedPlaylistRowIds.ToArray();
var originalCursorIndex = _playlistCursorIndex;
var originalPriorCursorIndex = _playlistPriorCursorIndex;
try
{
foreach (var item in items)
{
if (item.Materialized is not null)
{
AppendFixedRow(
item.Materialized,
item.InitialPage?.PageText ?? throw new InvalidOperationException(
"A materialized fixed-section item has no initial page state."));
}
else if (item.ManualDraft is not null)
{
AppendManualListRow(item.ManualDraft);
}
else
{
throw new InvalidOperationException(
"A fixed-section batch item was not materialized.");
}
}
}
catch
{
if (_playlist.Count > originalCount)
{
_playlist.RemoveRange(originalCount, _playlist.Count - originalCount);
}
_playlistSequence = originalSequence;
_activePlaylistRowId = originalActiveRowId;
_selectedPlaylistRowIds.Clear();
_selectedPlaylistRowIds.UnionWith(originalSelectedRowIds);
_playlistCursorIndex = originalCursorIndex;
_playlistPriorCursorIndex = originalPriorCursorIndex;
throw;
}
}
private static FixedManualTarget? GetFixedManualTarget(string actionId) => actionId switch
{
"fixed-245" => new FixedManualTarget(
LegacyManualListScreen.NetSell,
S5025ManualAudience.Individual),
"fixed-246" => new FixedManualTarget(
LegacyManualListScreen.NetSell,
S5025ManualAudience.Foreign),
"fixed-247" => new FixedManualTarget(
LegacyManualListScreen.NetSell,
S5025ManualAudience.Institution),
"fixed-262" or "fixed-293" or "fixed-320" => new FixedManualTarget(
LegacyManualListScreen.Vi,
S5025ManualAudience.Individual),
_ => null
};
private async Task<FixedInitialPage> ResolveFixedInitialPageAsync(
LegacyMaterializedFixedAction materialized,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var pageSize = GetFixedPageSize(materialized.Action);
if (pageSize is null)
{
return FixedInitialPage.SinglePage;
}
if (_fixedPagePlanProvider is null)
{
return FixedInitialPage.Unavailable;
}
var selection = materialized.Selection;
var entry = new CorePlaylistEntry(
materialized.Action.Id,
materialized.Action.Code,
IsEnabled: true,
FadeDuration: 6,
Selection: new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode));
try
{
var pagePlan = await _fixedPagePlanProvider.CreatePagePlanAsync(
entry,
cancellationToken).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(pagePlan);
var expected = CoreScenePaging.CreatePlan(pagePlan.ItemCount, pageSize.Value);
if (!expected.IsValid ||
expected.Plan is null ||
!string.Equals(
pagePlan.BuilderKey,
materialized.Action.BuilderKey,
StringComparison.Ordinal) ||
pagePlan.PageSize != pageSize.Value ||
pagePlan.PageCount != expected.Plan.TotalPages ||
pagePlan.AccessibleItemCount != expected.Plan.AccessibleItemCount ||
pagePlan.IsTruncated != (expected.Plan.ExcludedItemCount > 0))
{
throw new InvalidOperationException(
"The fixed-action page preflight returned an invalid paging plan.");
}
return new FixedInitialPage($"1/{pagePlan.PageCount}", false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
// PageN/Nxt_PageN in UC1 caught data-read failures and still appended
// the row as 1/0. Keep that exact failure representation; never guess N.
return FixedInitialPage.Unavailable;
}
}
private static FixedInitialPage CreateFixedInitialPageFallback(
LegacyFixedAction action) => GetFixedPageSize(action) is null
? FixedInitialPage.SinglePage
: FixedInitialPage.Unavailable;
private static CoreScenePageSize? GetFixedPageSize(LegacyFixedAction action)
{
ArgumentNullException.ThrowIfNull(action);
var exact = (action.Code, action.BuilderKey) switch
{
("5074", "s5074") => CoreScenePageSize.Five,
("5077", "s5077") => CoreScenePageSize.Six,
("5088", "s5088") => CoreScenePageSize.Twelve,
_ => (CoreScenePageSize?)null
};
if (exact is not null)
{
return exact;
}
if (action.Code is "5074" or "5077" or "5088" ||
action.BuilderKey is "s5074" or "s5077" or "s5088")
{
throw new InvalidOperationException(
"The fixed-action paging alias and builder do not match.");
}
return null;
}
private void ApplyFixedActionAddedStatus(
LegacyFixedAction action,
bool pageCountUnavailable)
{
_statusMessage = pageCountUnavailable
? $"{action.Label} 항목을 추가했지만 현재 데이터 건수를 읽지 못해 페이지를 1/0으로 표시합니다."
: $"{action.Label} 항목을 추가했습니다.";
_statusKind = pageCountUnavailable
? LegacyOperatorStatusKind.Warning
: LegacyOperatorStatusKind.Information;
}
private void ApplyFixedSectionAddedStatus(
string sectionName,
IReadOnlyList<PendingFixedSectionItem> items)
{
var unavailableCount = items.Count(item => item.PageCountUnavailable);
_statusMessage = unavailableCount == 0
? $"{sectionName} 항목 {items.Count}개를 원본 순서로 추가했습니다."
: $"{sectionName} 항목 {items.Count}개를 원본 순서로 추가했습니다. 데이터 건수를 읽지 못한 {unavailableCount}개 항목은 페이지를 1/0으로 표시합니다.";
_statusKind = unavailableCount == 0
? LegacyOperatorStatusKind.Information
: LegacyOperatorStatusKind.Warning;
}
private void ApplyOperatorCatalogStatus()
{
if (_operatorCatalogWorkflow is null)
{
return;
}
var snapshot = _operatorCatalogWorkflow.Current;
_statusMessage = snapshot.StatusMessage;
_statusKind = snapshot.StatusKind switch
{
LegacyOperatorCatalogStatusKind.Information => LegacyOperatorStatusKind.Information,
LegacyOperatorCatalogStatusKind.Warning => LegacyOperatorStatusKind.Warning,
LegacyOperatorCatalogStatusKind.Error => LegacyOperatorStatusKind.Error,
_ => LegacyOperatorStatusKind.Neutral
};
}
private void AppendManualListRow(LegacyManualListPlaylistDraft draft)
{
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-manual-list-{++_playlistSequence:D8}",
true,
draft.MarketText,
draft.StockName,
draft.GraphicType,
draft.Subtype,
draft.PageText,
draft.Selection.DataCode,
draft.RawCutLabel,
draft.FadeDuration,
SceneAlias: draft.SceneAlias,
PlayoutSelection: draft.Selection));
}
private void AppendFixedRow(
LegacyMaterializedFixedAction materialized,
string pageText)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pageText);
var action = materialized.Action;
var selection = materialized.Selection;
var visible = ProjectLegacyFixedVisibleSelection(action);
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-fixed-{++_playlistSequence:D8}",
true,
visible.GroupCode,
visible.Subject,
visible.GraphicType,
visible.Subtype,
pageText,
visible.DataCode,
action.Label,
6,
SceneAlias: action.Code,
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode)));
}
private void AppendIndustryRow(LegacyIndustryPlaylistDraft draft, int itemCount)
{
var split = draft.Detail.IndexOf('_');
var graphicType = split > 0 ? draft.Detail[..split] : draft.Detail;
var subtype = split > 0 && split < draft.Detail.Length - 1
? draft.Detail[(split + 1)..]
: string.Empty;
var pageCount = draft.PageSize > 0
? Math.Clamp(
(int)Math.Ceiling(itemCount / (double)draft.PageSize),
1,
20)
: 1;
var marketText = draft.Market == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "업종_코스피"
: "업종_코스닥";
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-industry-{++_playlistSequence:D8}",
draft.IsEnabled,
marketText,
draft.Title,
graphicType,
subtype,
$"1/{pageCount}",
draft.Selection.DataCode,
draft.Detail,
draft.FadeDuration,
SceneAlias: draft.SceneAlias,
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
draft.Selection.GroupCode,
draft.Selection.Subject,
draft.Selection.GraphicType,
draft.Selection.Subtype,
draft.Selection.DataCode)));
}
private void AppendOverseasRow(LegacyOverseasPlaylistDraft draft)
{
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-overseas-{++_playlistSequence:D8}",
draft.IsEnabled,
draft.VisibleFields.Market,
draft.VisibleFields.StockName,
draft.VisibleFields.GraphicType,
draft.VisibleFields.Subtype,
draft.VisibleFields.PageText,
draft.Selection.DataCode,
draft.Detail,
draft.FadeDuration,
SceneAlias: draft.SceneAlias,
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
draft.Selection.GroupCode,
draft.Selection.Subject,
draft.Selection.GraphicType,
draft.Selection.Subtype,
draft.Selection.DataCode)));
}
private void AppendThemeRow(LegacyThemePlaylistDraft draft)
{
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-theme-{++_playlistSequence:D8}",
true,
draft.Selection.GroupCode == "PAGED_THEME" ? "테마" : "테마_NXT",
draft.Title,
draft.PlaylistGraphicType,
draft.PlaylistSubtype,
draft.PagePlan.InitialPageText,
draft.Selection.DataCode,
draft.Detail,
6,
SceneAlias: draft.Code,
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
draft.Selection.GroupCode,
draft.Selection.Subject,
draft.Selection.GraphicType,
draft.Selection.Subtype,
draft.Selection.DataCode,
draft.Selection.ExactSubject)));
}
private void AppendComparisonRow(LegacyGenericPlaylistDraft draft)
{
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-comparison-{++_playlistSequence:D8}",
draft.IsEnabled,
"비교",
draft.Title,
draft.BuilderKey,
draft.Detail,
"1/1",
draft.Selection.DataCode,
draft.Source,
draft.FadeDuration,
SceneAlias: draft.CutCode,
PlayoutSelection: draft.Selection));
}
private void AppendExpertRow(LegacyExpertPlaylistDraft draft)
{
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-expert-{++_playlistSequence:D8}",
draft.IsEnabled,
"전문가",
draft.Title,
"5단 표그래프",
"현재가",
draft.PageText,
draft.ExpertCode,
draft.Detail,
draft.FadeDuration,
SceneAlias: draft.CutCode,
PlayoutSelection: draft.Selection));
}
private void AppendTradingHaltRow(LegacyTradingHaltPlaylistDraft draft)
{
var isCandle = draft.CandlePeriodDays.HasValue;
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-halt-{++_playlistSequence:D8}",
draft.IsEnabled,
draft.Market == "kospi" ? "코스피" : "코스닥",
draft.Title,
isCandle ? "캔들그래프" : "1열판기본",
isCandle ? "거래정지_120일" : "현재가",
draft.PageText,
draft.StockCode,
draft.Detail,
draft.FadeDuration,
SceneAlias: draft.CutCode,
PlayoutSelection: draft.Selection));
}
private void AppendManualFinancialRow(LegacyManualFinancialPlaylistDraft draft)
{
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-manual-financial-{++_playlistSequence:D8}",
true,
draft.Market,
draft.Title,
draft.Detail,
draft.CutSelection.Selection.GraphicType,
"1/1",
draft.StockCode,
draft.Detail,
6,
SceneAlias: draft.Code,
PlayoutSelection: draft.CutSelection.Selection));
}
private static string GetTabLabel(LegacyOperatorTabId tabId) => tabId switch
{
LegacyOperatorTabId.Overseas => "해외",
LegacyOperatorTabId.Exchange => "환율",
LegacyOperatorTabId.Index => "지수",
LegacyOperatorTabId.KospiIndustry => "코스피",
LegacyOperatorTabId.KosdaqIndustry => "코스닥",
LegacyOperatorTabId.Comparison => "비교",
LegacyOperatorTabId.Theme => "테마",
LegacyOperatorTabId.OverseasStocks => "해외시장",
LegacyOperatorTabId.Expert => "전문가",
LegacyOperatorTabId.TradingHalt => "거래정지",
_ => throw new ArgumentOutOfRangeException(nameof(tabId), tabId, null)
};
private bool IsCutIndex(int physicalIndex) =>
physicalIndex >= 0 && physicalIndex < _cutRows.Count;
private static IReadOnlyList<LegacyCutCatalogRow> CreateValidatedCutRows(
IReadOnlyList<LegacyCutCatalogRow>? cutRows)
{
var source = cutRows ?? LegacyStockCutCatalog.Rows;
if (source.Count is < 1 or > LegacyRuntimeStockCutMenuLoader.MaximumRows)
{
throw new ArgumentException(
"The stock cut menu must contain from 1 through 50 physical rows.",
nameof(cutRows));
}
var copiedRows = new LegacyCutCatalogRow[source.Count];
var displayOrdinal = 0;
for (var index = 0; index < source.Count; index++)
{
var row = source[index] ?? throw new ArgumentException(
"The stock cut menu cannot contain a null row.",
nameof(cutRows));
if (row.PhysicalIndex != index)
{
throw new ArgumentException(
"Stock cut physical indices must be contiguous and zero based.",
nameof(cutRows));
}
if (row.IsSeparator)
{
if (row.DisplayOrdinal is not null ||
!string.IsNullOrEmpty(row.RawLabel))
{
throw new ArgumentException(
"A stock cut separator must have no display ordinal or label.",
nameof(cutRows));
}
}
else
{
displayOrdinal++;
if (row.DisplayOrdinal != displayOrdinal)
{
throw new ArgumentException(
"Stock cut display ordinals must be contiguous across active rows.",
nameof(cutRows));
}
var isKnown = LegacyStockCutCatalog.Rows.Any(candidate =>
!candidate.IsSeparator && string.Equals(
candidate.RawLabel,
row.RawLabel,
StringComparison.Ordinal));
if (!isKnown)
{
throw new ArgumentException(
"The stock cut menu contains an unknown cut label.",
nameof(cutRows));
}
}
copiedRows[index] = row;
}
if (displayOrdinal == 0)
{
throw new ArgumentException(
"The stock cut menu must contain at least one active cut.",
nameof(cutRows));
}
return Array.AsReadOnly(copiedRows);
}
private int FindPlaylistIndex(string rowId) => _playlist.FindIndex(row =>
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
private static int FindOriginalPlaylistIndex(
IReadOnlyList<LegacyOperatorPlaylistRow> rows,
string? rowId)
{
if (rowId is null)
{
return -1;
}
for (var index = 0; index < rows.Count; index++)
{
if (string.Equals(rows[index].RowId, rowId, StringComparison.Ordinal))
{
return index;
}
}
return -1;
}
private void AppendPlaylistRow(LegacyOperatorPlaylistRow row)
{
ArgumentNullException.ThrowIfNull(row);
row = NormalizeMovingAverageIdentity(row);
var wasEmpty = _playlist.Count == 0;
_playlist.Add(row);
if (wasEmpty)
{
SelectInitialPlaylistCandidate();
}
}
private static LegacyOperatorPlaylistRow NormalizeMovingAverageIdentity(
LegacyOperatorPlaylistRow row)
{
var cutCode = row.SceneAlias.Length > 0
? row.SceneAlias
: LegacyStockCutCatalog.ResolveCutAlias(
row.RawCutLabel,
row.StockName.Contains("(NXT)", StringComparison.Ordinal));
if (!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
.Supports(cutCode))
{
return row;
}
var graphicType = RemoveCapturedMovingAverageFlags(row.GraphicType);
var selection = row.PlayoutSelection;
var normalizedSelection = selection is null
? null
: selection with
{
GraphicType = RemoveCapturedMovingAverageFlags(selection.GraphicType)
};
return string.Equals(graphicType, row.GraphicType, StringComparison.Ordinal) &&
Equals(normalizedSelection, selection)
? row
: row with
{
GraphicType = graphicType,
PlayoutSelection = normalizedSelection
};
}
private static string RemoveCapturedMovingAverageFlags(string graphicType) =>
graphicType is "MA5" or "MA20" or "MA5,MA20"
? string.Empty
: graphicType;
private void SelectInitialPlaylistCandidate()
{
if (_playlist.Count == 0)
{
return;
}
_activePlaylistRowId = _playlist[0].RowId;
// MainForm corrected m_rowidx to zero for the first F8 candidate, but it
// did not populate bufdel until an actual playlist row click occurred.
_selectedPlaylistRowIds.Clear();
_playlistCursorIndex = 0;
_playlistPriorCursorIndex = 0;
}
private string ResolveSurvivingPlaylistRowId(
IReadOnlyList<LegacyOperatorPlaylistRow> originalRows,
ISet<string> removedIds,
string? preferredRowId,
int preferredIndex)
{
if (preferredRowId is not null && FindPlaylistIndex(preferredRowId) >= 0)
{
return preferredRowId;
}
var resolvedOriginalIndex = FindNextThenPreviousSurvivor(
originalRows,
removedIds,
preferredIndex);
return originalRows[resolvedOriginalIndex].RowId;
}
private int ResolveSurvivingPlaylistIndex(
IReadOnlyList<LegacyOperatorPlaylistRow> originalRows,
ISet<string> removedIds,
string preferredRowId,
int preferredIndex)
{
var survivingIndex = FindPlaylistIndex(preferredRowId);
if (survivingIndex >= 0)
{
return survivingIndex;
}
var resolvedOriginalIndex = FindNextThenPreviousSurvivor(
originalRows,
removedIds,
preferredIndex);
return FindPlaylistIndex(originalRows[resolvedOriginalIndex].RowId);
}
private static int FindNextThenPreviousSurvivor(
IReadOnlyList<LegacyOperatorPlaylistRow> originalRows,
ISet<string> removedIds,
int preferredIndex)
{
if (preferredIndex < 0)
{
for (var index = 0; index < originalRows.Count; index++)
{
if (!removedIds.Contains(originalRows[index].RowId))
{
return index;
}
}
}
var start = Math.Clamp(preferredIndex, 0, originalRows.Count - 1);
for (var index = start + 1; index < originalRows.Count; index++)
{
if (!removedIds.Contains(originalRows[index].RowId))
{
return index;
}
}
for (var index = start - 1; index >= 0; index--)
{
if (!removedIds.Contains(originalRows[index].RowId))
{
return index;
}
}
throw new InvalidOperationException("삭제 후 남은 플레이리스트 행을 찾지 못했습니다.");
}
private void AddPlaylistRange(int firstIndex, int lastIndex)
{
var first = Math.Max(0, Math.Min(firstIndex, lastIndex));
var last = Math.Min(_playlist.Count - 1, Math.Max(firstIndex, lastIndex));
for (var index = first; index <= last; index++)
{
_selectedPlaylistRowIds.Add(_playlist[index].RowId);
}
}
private void ResetPlaylistSelection()
{
_selectedPlaylistRowIds.Clear();
_activePlaylistRowId = null;
_playlistCursorIndex = 0;
_playlistPriorCursorIndex = 0;
}
private void CompleteCommand(string command, string targetId, bool succeeded)
{
_commandResult = new LegacyOperatorCommandResult(
checked(++_commandSequence),
command,
targetId,
succeeded);
}
private readonly record struct FixedManualTarget(
LegacyManualListScreen Screen,
S5025ManualAudience Audience);
private readonly record struct FixedInitialPage(
string PageText,
bool PageCountUnavailable)
{
internal static FixedInitialPage SinglePage { get; } = new("1/1", false);
internal static FixedInitialPage Unavailable { get; } = new("1/0", true);
}
private sealed class PendingFixedSectionItem
{
internal PendingFixedSectionItem(
LegacyFixedAction action,
LegacyMaterializedFixedAction materialized,
FixedInitialPage initialPage)
{
Action = action;
Materialized = materialized;
InitialPage = initialPage;
}
internal PendingFixedSectionItem(
LegacyFixedAction action,
FixedManualTarget manualTarget)
{
Action = action;
ManualTarget = manualTarget;
}
internal LegacyFixedAction Action { get; }
internal LegacyMaterializedFixedAction? Materialized { get; }
internal FixedInitialPage? InitialPage { get; }
internal FixedManualTarget? ManualTarget { get; }
internal LegacyManualListPlaylistDraft? ManualDraft { get; private set; }
internal bool PageCountUnavailable =>
InitialPage?.PageCountUnavailable == true;
internal bool IsStaged => Materialized is not null || ManualDraft is not null;
internal void StageManual(LegacyManualListPlaylistDraft draft)
{
ArgumentNullException.ThrowIfNull(draft);
if (ManualTarget is null || ManualDraft is not null)
{
throw new InvalidOperationException(
"The fixed-section manual item cannot be staged in its current state.");
}
ManualDraft = draft;
}
}
private sealed class PendingFixedSectionBatch
{
internal PendingFixedSectionBatch(
LegacyFixedMarket market,
int sectionIndex,
string sectionName,
IReadOnlyList<PendingFixedSectionItem> items,
int manualCount)
{
Market = market;
SectionIndex = sectionIndex;
SectionName = sectionName;
Items = items;
ManualCount = manualCount;
}
internal LegacyFixedMarket Market { get; }
internal int SectionIndex { get; }
internal string SectionName { get; }
internal IReadOnlyList<PendingFixedSectionItem> Items { get; }
internal int ManualCount { get; }
internal PendingFixedSectionItem? CurrentManualItem => Items.FirstOrDefault(item =>
item.ManualTarget is not null && item.ManualDraft is null);
internal bool IsCurrentManual(
LegacyManualListScreen screen,
S5025ManualAudience audience)
{
var target = CurrentManualItem?.ManualTarget;
return target is not null && target.Value.Screen == screen &&
(screen == LegacyManualListScreen.Vi || target.Value.Audience == audience);
}
internal void StageCurrentManual(LegacyManualListPlaylistDraft draft)
{
var current = CurrentManualItem
?? throw new InvalidOperationException(
"The fixed-section batch has no pending manual item.");
current.StageManual(draft);
}
internal LegacyFixedSectionBatchView CreateView()
{
var current = CurrentManualItem
?? throw new InvalidOperationException(
"A completed fixed-section batch cannot be projected as pending.");
var manualStep = Items
.Where(item => item.ManualTarget is not null)
.TakeWhile(item => !ReferenceEquals(item, current))
.Count() + 1;
return new LegacyFixedSectionBatchView(
SectionIndex,
SectionName,
Items.Count,
Items.Count(item => item.IsStaged),
manualStep,
ManualCount,
current.Action.Id,
current.Action.Label);
}
}
private enum PendingNativeConfirmationKind
{
DeleteTheme,
DeleteExpert,
ClearThemeDraft,
DeleteAllComparisonPairs,
ImportLegacyComparisonPairs,
DeleteAllPlaylistRows,
DeleteManualFinancial,
DeleteAllManualFinancial,
DeleteAllManualViItems
}
private sealed record PendingNativeConfirmation(PendingNativeConfirmationKind Kind)
{
internal MMoneyCoderSharp.Data.ThemeSelectionIdentity? ThemeIdentity { get; init; }
internal MMoneyCoderSharp.Data.ExpertSelectionIdentity? ExpertIdentity { get; init; }
internal long CatalogRevision { get; init; }
internal long ComparisonRevision { get; init; }
internal IReadOnlyList<string>? PlaylistRowIds { get; init; }
internal long ManualFinancialRevision { get; init; }
internal IReadOnlyList<string>? ManualViItemIds { get; init; }
internal static PendingNativeConfirmation ForTheme(
MMoneyCoderSharp.Data.ThemeSelectionIdentity identity) =>
new(PendingNativeConfirmationKind.DeleteTheme)
{
ThemeIdentity = identity
};
internal static PendingNativeConfirmation ForExpert(
MMoneyCoderSharp.Data.ExpertSelectionIdentity identity) =>
new(PendingNativeConfirmationKind.DeleteExpert)
{
ExpertIdentity = identity
};
internal static PendingNativeConfirmation ForThemeDraftClear(long revision) =>
new(PendingNativeConfirmationKind.ClearThemeDraft)
{
CatalogRevision = revision
};
internal static PendingNativeConfirmation ForComparisonDeleteAll(long revision) =>
new(PendingNativeConfirmationKind.DeleteAllComparisonPairs)
{
ComparisonRevision = revision
};
internal static PendingNativeConfirmation ForComparisonImport(long revision) =>
new(PendingNativeConfirmationKind.ImportLegacyComparisonPairs)
{
ComparisonRevision = revision
};
internal static PendingNativeConfirmation ForPlaylistDeleteAll(
IReadOnlyList<string> rowIds) =>
new(PendingNativeConfirmationKind.DeleteAllPlaylistRows)
{
PlaylistRowIds = rowIds
};
internal static PendingNativeConfirmation ForManualFinancialDelete(
bool all,
long revision) =>
new(all
? PendingNativeConfirmationKind.DeleteAllManualFinancial
: PendingNativeConfirmationKind.DeleteManualFinancial)
{
ManualFinancialRevision = revision
};
internal static PendingNativeConfirmation ForManualViDeleteAll(
IReadOnlyList<string> itemIds) =>
new(PendingNativeConfirmationKind.DeleteAllManualViItems)
{
ManualViItemIds = itemIds
};
}
private void Touch() => _revision++;
}