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 Actions); public sealed record LegacyFixedCatalogView( LegacyFixedMarket Market, IReadOnlyList Sections); /// /// 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. /// 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 SearchResults, int? SelectedStockIndex, IReadOnlyList CutRows, IReadOnlyList Playlist, LegacyDialogState? Dialog, string StatusMessage, LegacyOperatorStatusKind StatusKind, IReadOnlyList? 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); /// /// 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. /// public sealed class LegacyOperatorController { private const long DoubleClickWindowMilliseconds = 500; private readonly ILegacyStockLookup _stockLookup; private readonly IReadOnlyList _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 _legacyPointerRows = []; private readonly SortedSet _nativeSelectedCutRows = []; private readonly List _playlist = []; private readonly HashSet _selectedPlaylistRowIds = new(StringComparer.Ordinal); private readonly List _tabOrder = [ LegacyOperatorTabId.Overseas, LegacyOperatorTabId.Exchange, LegacyOperatorTabId.Index, LegacyOperatorTabId.KospiIndustry, LegacyOperatorTabId.KosdaqIndustry, LegacyOperatorTabId.Comparison, LegacyOperatorTabId.Theme, LegacyOperatorTabId.OverseasStocks, LegacyOperatorTabId.Expert, LegacyOperatorTabId.TradingHalt ]; private Queue? _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? 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); } /// /// 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. /// 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 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(_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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 BeginCreateExpertCatalogAsync( CancellationToken cancellationToken = default) { if (_operatorCatalogWorkflow is null) { return ReportError("EList 관리 기능이 구성되지 않았습니다."); } await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken) .ConfigureAwait(false); ApplyOperatorCatalogStatus(); Touch(); return CreateSnapshot(); } public async Task 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 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 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 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 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 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 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 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 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 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 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 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 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 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 OpenManualViAsync( CancellationToken cancellationToken = default) { if (_pendingFixedSectionBatch is not null) { return ReportError( "고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요."); } return await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false); } private async Task 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 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 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 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 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(); } /// /// 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. /// public async Task 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 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 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); } /// /// 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. /// public async Task 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(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 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 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 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 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 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 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 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 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 SearchThemesAsync( string query, MMoneyCoderSharp.Data.ThemeSession ignoredNxtSession, CancellationToken cancellationToken = default) => SearchThemesAsync(query, cancellationToken); private async Task 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 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 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 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 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 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(); } /// /// 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. /// 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( _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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(); } /// /// 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. /// 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(); } /// /// 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. /// 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 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 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(); 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 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 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 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 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 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 CreateValidatedCutRows( IReadOnlyList? 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 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 originalRows, ISet 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 originalRows, ISet 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 originalRows, ISet 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 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 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? PlaylistRowIds { get; init; } internal long ManualFinancialRevision { get; init; } internal IReadOnlyList? 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 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 itemIds) => new(PendingNativeConfirmationKind.DeleteAllManualViItems) { ManualViItemIds = itemIds }; } private void Touch() => _revision++; }