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; 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 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) { _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; _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) { if (_selectedStockIndex is null || !IsCutIndex(physicalIndex)) { return CreateSnapshot(); } var gap = _previousPointerTimestamp is long previousTimestamp ? timestampMilliseconds - previousTimestamp : long.MaxValue; if (_previousPointerTimestamp is not null && gap >= 0 && gap < DoubleClickWindowMilliseconds && _previousPointerRow == physicalIndex && _previousPointerX == x && _previousPointerY == y && _legacyPointerRows.Count == 1) { TryAppendCut(_legacyPointerRows.Min); _previousPointerTimestamp = null; } else { _previousPointerTimestamp = timestampMilliseconds; } _previousPointerRow = physicalIndex; _previousPointerX = x; _previousPointerY = y; ApplyLegacyPointerSelection(physicalIndex, control, shift); ApplyNativeListSelection(physicalIndex, control, shift); _cutAnchorIndex = physicalIndex; _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 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); _statusMessage = $"{_manualFinancialSnapshot.Definition.Label} 목록을 불러왔습니다."; _statusKind = LegacyOperatorStatusKind.Information; 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); 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("수동 재무 입력 화면을 먼저 여세요."); } _manualFinancialSnapshot = await workflow.LoadAsync( snapshot, resultId, cancellationToken).ConfigureAwait(false); // 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 stockName = _manualFinancialSnapshot.SelectedRecord!.Identity.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. } } 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."); } 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("수동 재무 입력 화면을 먼저 여세요."); } _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("수동 재무 입력 화면을 먼저 여세요."); } 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, "저장"); Touch(); return CreateSnapshot(); } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) { _dialog = new LegacyDialogState(exception.Message); Touch(); return CreateSnapshot(); } } public async Task DeleteManualFinancialAsync( bool all, CancellationToken cancellationToken = default) { if (!TryGetManualFinancial(out var workflow, out var snapshot)) { return ReportError("수동 재무 입력 화면을 먼저 여세요."); } 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(); } } 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() { _manualListsWorkflow?.DeleteAllViItems(); ApplyManualListsStatus(); 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("현재 탭에 속하지 않는 항목입니다."); } AppendFixedRow(materialized); _statusMessage = $"{materialized.Action.Label} 항목을 추가했습니다."; _statusKind = LegacyOperatorStatusKind.Information; 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) { return ActivateFixedAction(actionId); } 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. items.Add(new PendingFixedSectionItem( action, _fixedCatalog.Materialize( action.Id, localNow))); } var manualCount = items.Count(item => item.ManualTarget is not null); if (manualCount == 0) { CommitFixedSectionBatch(items); _statusMessage = $"{section.Name} 항목 {items.Count}개를 추가했습니다."; _statusKind = LegacyOperatorStatusKind.Information; Touch(); return CreateSnapshot(); } _pendingFixedSectionBatch = new PendingFixedSectionBatch( market.Value, sectionIndex, section.Name, items, manualCount); return await OpenPendingFixedSectionManualAsync(cancellationToken) .ConfigureAwait(false); } 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 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; 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 (_playlist.Count == 0) { return CreateSnapshot(); } _playlist.Clear(); ResetPlaylistSelection(); 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(); ApplyNamedPlaylistStatus(namedPlaylist); } 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 == LegacyOperatorStatusKind.Information; 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 is exactly the original seven visible/hidden // fields. Do not persist the newer internal scene selection or scene alias. var rows = _playlist.Select(row => { var persistedSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( row.MarketText, row.StockName, row.GraphicType, row.Subtype, row.StockCode); 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, 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; 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), _ => 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 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(); } 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 LegacyOperatorPlaylistRow CreateNamedPlaylistOperatorRow( LegacyNamedPlaylistRowView row) { var selection = row.Selection; var sceneAlias = ResolveNamedPlaylistSceneAlias(selection); var playoutSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( selection.GroupCode, selection.Subject, selection.GraphicType, selection.Subtype, selection.DataCode); var 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}"; // 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 fixedMarket = selection.GroupCode switch { "해외지수" => LegacyFixedMarket.Overseas, "환율" => LegacyFixedMarket.Exchange, "주요지수" => LegacyFixedMarket.Index, _ => (LegacyFixedMarket?)null }; if (fixedMarket is { } market) { var actions = _fixedCatalog.GetActions(market) .Where(action => string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) && string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) && string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal)) .Take(2) .ToArray(); if (actions.Length == 1) { return actions[0].Code; } } 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 (string.Equals(selection.GroupCode, "전문가", StringComparison.Ordinal) && string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) && string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal)) { 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; } throw new InvalidDataException( "The named-playlist row does not map to a registered scene alias."); } 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 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 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; _statusMessage = $"{pending.SectionName} 항목 {pending.Items.Count}개를 원본 순서로 추가했습니다."; _statusKind = LegacyOperatorStatusKind.Information; 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); } 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 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) { var action = materialized.Action; var selection = materialized.Selection; var marketText = action.Market switch { LegacyFixedMarket.Overseas => "해외지수", LegacyFixedMarket.Exchange => "환율", LegacyFixedMarket.Index => "주요지수", _ => throw new ArgumentOutOfRangeException() }; AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-fixed-{++_playlistSequence:D8}", true, marketText, action.Label, action.Section, action.Detail, "1/1", selection.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))); } 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 entry = row.ToPlayoutEntry(); if (!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay .Supports(entry.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 sealed class PendingFixedSectionItem { internal PendingFixedSectionItem( LegacyFixedAction action, LegacyMaterializedFixedAction materialized) { Action = action; Materialized = materialized; } internal PendingFixedSectionItem( LegacyFixedAction action, FixedManualTarget manualTarget) { Action = action; ManualTarget = manualTarget; } internal LegacyFixedAction Action { get; } internal LegacyMaterializedFixedAction? Materialized { get; } internal FixedManualTarget? ManualTarget { get; } internal LegacyManualListPlaylistDraft? ManualDraft { get; private set; } 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 } private sealed record PendingNativeConfirmation( PendingNativeConfirmationKind Kind, MMoneyCoderSharp.Data.ThemeSelectionIdentity? ThemeIdentity, MMoneyCoderSharp.Data.ExpertSelectionIdentity? ExpertIdentity, long CatalogRevision, long ComparisonRevision) { internal static PendingNativeConfirmation ForTheme( MMoneyCoderSharp.Data.ThemeSelectionIdentity identity) => new( PendingNativeConfirmationKind.DeleteTheme, identity, null, 0, 0); internal static PendingNativeConfirmation ForExpert( MMoneyCoderSharp.Data.ExpertSelectionIdentity identity) => new( PendingNativeConfirmationKind.DeleteExpert, null, identity, 0, 0); internal static PendingNativeConfirmation ForThemeDraftClear(long revision) => new( PendingNativeConfirmationKind.ClearThemeDraft, null, null, revision, 0); internal static PendingNativeConfirmation ForComparisonDeleteAll(long revision) => new( PendingNativeConfirmationKind.DeleteAllComparisonPairs, null, null, 0, revision); } private void Touch() => _revision++; }