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

5842 lines
201 KiB
C#

using S5025ManualAudience =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience;
using CoreMovingAverageSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection;
using CoreMovingAverageSource =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource;
using CoreSceneSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection;
using S5011DetailBranch =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacySearchTrigger
{
Button,
Enter
}
public enum LegacyOperatorStatusKind
{
Neutral,
Information,
Warning,
Error
}
public enum LegacyPlaylistMoveDirection
{
Up,
Down
}
public enum LegacyDragDropPosition
{
Before,
After
}
public enum LegacyCutKeyboardKey
{
ArrowUp,
ArrowDown,
Home,
End,
Space
}
public enum LegacyOperatorTabId
{
Overseas,
Exchange,
Index,
KospiIndustry,
KosdaqIndustry,
Comparison,
Theme,
OverseasStocks,
Expert,
TradingHalt
}
public sealed record LegacyStockResultRow(int Index, string DisplayName);
public sealed record LegacyCutViewRow(
int PhysicalIndex,
int? DisplayOrdinal,
string RawLabel,
bool IsSeparator,
bool IsSelected,
bool IsFocused = false);
public sealed record LegacyDialogState(
string Message,
string Caption = "MmoneyCoder",
bool IsConfirmation = false);
public sealed record LegacyOperatorCommandResult(
long Sequence,
string Command,
string TargetId,
bool Succeeded);
public sealed record LegacyOperatorTabState(
LegacyOperatorTabId Id,
string Label,
bool IsActive);
public sealed record LegacyFixedActionView(
string Id,
string Label,
string Detail,
bool IsAvailable,
string Prerequisite);
public sealed record LegacyFixedSectionView(
int Index,
string Name,
IReadOnlyList<LegacyFixedActionView> Actions);
public sealed record LegacyFixedCatalogView(
LegacyFixedMarket Market,
IReadOnlyList<LegacyFixedSectionView> Sections);
/// <summary>
/// A fixed UC1 parent-node activation is staged until every modal manual child has
/// been confirmed. The playlist remains unchanged while this view is present.
/// </summary>
public sealed record LegacyFixedSectionBatchView(
int SectionIndex,
string SectionName,
int TotalActionCount,
int StagedActionCount,
int ManualStepNumber,
int ManualStepCount,
string PendingActionId,
string PendingActionLabel);
public sealed record LegacyOperatorSnapshot(
long Revision,
string SearchText,
IReadOnlyList<LegacyStockResultRow> SearchResults,
int? SelectedStockIndex,
IReadOnlyList<LegacyCutViewRow> CutRows,
IReadOnlyList<LegacyOperatorPlaylistRow> Playlist,
LegacyDialogState? Dialog,
string StatusMessage,
LegacyOperatorStatusKind StatusKind,
IReadOnlyList<LegacyOperatorTabState>? Tabs = null,
LegacyMovingAverageSelection MovingAverages = default,
LegacyFixedCatalogView? FixedCatalog = null,
LegacyIndustrySelectionSnapshot? Industry = null,
LegacyThemeWorkflowSnapshot? Theme = null,
LegacyExpertWorkflowSnapshot? Expert = null,
LegacyTradingHaltWorkflowSnapshot? TradingHalt = null,
LegacyComparisonWorkflowSnapshot? Comparison = null,
LegacyManualFinancialWorkflowSnapshot? ManualFinancial = null,
LegacyNamedPlaylistWorkflowSnapshot? NamedPlaylist = null,
LegacyOverseasWorkflowSnapshot? Overseas = null,
LegacyManualListsSnapshot? ManualLists = null,
LegacyOperatorCatalogWorkflowSnapshot? OperatorCatalog = null,
LegacyOperatorCommandResult? CommandResult = null,
LegacyFixedSectionBatchView? FixedSectionBatch = null);
/// <summary>
/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends
/// input intent only; it never derives stock identity, cut aliases or playlist fields.
/// </summary>
public sealed class LegacyOperatorController
{
private const long DoubleClickWindowMilliseconds = 500;
private readonly ILegacyStockLookup _stockLookup;
private readonly IReadOnlyList<LegacyCutCatalogRow> _cutRows;
private readonly LegacyFixedActionCatalog _fixedCatalog;
private readonly TimeProvider _timeProvider;
private readonly LegacyIndustrySelectionWorkflow? _industryWorkflow;
private readonly LegacyThemeWorkflow? _themeWorkflow;
private readonly LegacyExpertWorkflowController? _expertWorkflow;
private readonly LegacyTradingHaltWorkflowController? _tradingHaltWorkflow;
private readonly LegacyComparisonWorkflowController? _comparisonWorkflow;
private readonly LegacyManualFinancialWorkflow? _manualFinancialWorkflow;
private readonly LegacyNamedPlaylistWorkflowController? _namedPlaylistWorkflow;
private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow;
private readonly LegacyManualListsWorkflow? _manualListsWorkflow;
private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow;
private readonly CoreMovingAverageSource _movingAverageSource = new(
new CoreMovingAverageSelection(true, true));
private readonly SemaphoreSlim _tabActivationGate = new(1, 1);
private readonly SortedSet<int> _legacyPointerRows = [];
private readonly SortedSet<int> _nativeSelectedCutRows = [];
private readonly List<LegacyOperatorPlaylistRow> _playlist = [];
private readonly HashSet<string> _selectedPlaylistRowIds = new(StringComparer.Ordinal);
private readonly List<LegacyOperatorTabId> _tabOrder =
[
LegacyOperatorTabId.Overseas,
LegacyOperatorTabId.Exchange,
LegacyOperatorTabId.Index,
LegacyOperatorTabId.KospiIndustry,
LegacyOperatorTabId.KosdaqIndustry,
LegacyOperatorTabId.Comparison,
LegacyOperatorTabId.Theme,
LegacyOperatorTabId.OverseasStocks,
LegacyOperatorTabId.Expert,
LegacyOperatorTabId.TradingHalt
];
private Queue<int>? _pendingDropCutRows;
private LegacyStockSearchData _searchData = new(string.Empty, [], []);
private int? _selectedStockIndex;
private int? _cutAnchorIndex;
private int _nativeSelectionAnchorIndex;
private int _nativeFocusedCutIndex = -1;
private int? _previousPointerRow;
private int _previousPointerX;
private int _previousPointerY;
private long? _previousPointerTimestamp;
private string? _activePlaylistRowId;
private int _playlistCursorIndex;
private int _playlistPriorCursorIndex;
private long _revision;
private long _playlistSequence;
private string _searchText = string.Empty;
private string _statusMessage = string.Empty;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
private LegacyDialogState? _dialog;
private PendingNativeConfirmation? _pendingNativeConfirmation;
private bool _closeOperatorCatalogAfterDialog;
private LegacyOperatorTabId _activeTab = LegacyOperatorTabId.Overseas;
private LegacyMovingAverageSelection _movingAverages = new(true, true);
private LegacyThemeWorkflowSnapshot? _themeSnapshot;
private LegacyManualFinancialWorkflowSnapshot? _manualFinancialSnapshot;
private bool _industryActivationListLoaded;
private bool _comparisonActivationListLoaded;
private bool _themeActivationListLoaded;
private bool _expertActivationListLoaded;
private long _tabActivationRequest;
private long _commandSequence;
private LegacyOperatorCommandResult? _commandResult;
private PendingFixedSectionBatch? _pendingFixedSectionBatch;
public LegacyOperatorController(
ILegacyStockLookup stockLookup,
LegacyFixedActionCatalog? fixedCatalog = null,
TimeProvider? timeProvider = null,
LegacyIndustrySelectionWorkflow? industryWorkflow = null,
LegacyThemeWorkflow? themeWorkflow = null,
LegacyExpertWorkflowController? expertWorkflow = null,
LegacyTradingHaltWorkflowController? tradingHaltWorkflow = null,
LegacyComparisonWorkflowController? comparisonWorkflow = null,
LegacyManualFinancialWorkflow? manualFinancialWorkflow = null,
LegacyNamedPlaylistWorkflowController? namedPlaylistWorkflow = null,
LegacyOverseasSelectionWorkflow? overseasWorkflow = null,
LegacyManualListsWorkflow? manualListsWorkflow = null,
LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null,
IReadOnlyList<LegacyCutCatalogRow>? cutRows = null)
{
_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);
}
/// <summary>
/// Reflects a successfully prepared or played cue back into the operator grid.
/// MainForm stored the current PageN value in column 5 instead of keeping it only
/// in the Tornado workflow. TAKE/PREPARE may move the F8 candidate when a disabled
/// row is skipped, but never rewrites bufdel/m_delcnt or the Shift anchors. NEXT
/// deliberately leaves even that independent candidate alone.
/// </summary>
public LegacyOperatorSnapshot ReflectSuccessfulPlayout(
string entryId,
int pageIndexZeroBased,
int pageCount,
bool synchronizeOperatorSelection)
{
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
if (pageCount <= 0 || pageIndexZeroBased < 0 || pageIndexZeroBased >= pageCount)
{
throw new ArgumentOutOfRangeException(
nameof(pageIndexZeroBased),
"The playout page position is outside the playlist page range.");
}
var index = FindPlaylistIndex(entryId);
if (index < 0)
{
return CreateSnapshot();
}
var changed = false;
var pageText = $"{pageIndexZeroBased + 1}/{pageCount}";
if (!string.Equals(_playlist[index].PageText, pageText, StringComparison.Ordinal))
{
_playlist[index] = _playlist[index] with { PageText = pageText };
changed = true;
}
if (synchronizeOperatorSelection)
{
var rowId = _playlist[index].RowId;
changed |= !string.Equals(_activePlaylistRowId, rowId, StringComparison.Ordinal);
_activePlaylistRowId = rowId;
}
if (changed)
{
Touch();
}
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchStocksAsync(
string text,
LegacySearchTrigger trigger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(text);
_searchText = text;
// button9_Click returned before calling searchStock only for an exactly empty string.
// txtSearch_KeyPress did not have that guard, so blank Enter still queries all rows.
if (trigger == LegacySearchTrigger.Button && text.Length == 0)
{
Touch();
return CreateSnapshot();
}
_searchData = await _stockLookup.SearchAsync(text, cancellationToken).ConfigureAwait(false);
_selectedStockIndex = null;
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
_dialog = null;
_pendingDropCutRows = null;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectStock(int resultIndex)
{
if (resultIndex < 0 || resultIndex >= _searchData.DisplayNames.Count)
{
return CreateSnapshot();
}
_selectedStockIndex = resultIndex;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CutPointerDown(
int physicalIndex,
bool control,
bool shift,
int x,
int y,
long timestampMilliseconds,
bool allowAppend = true)
{
if (!allowAppend)
{
// Selection remains available while a scene is prepared or on air, but
// an on-air click must neither activate a cut nor prime the next IDLE
// click as the second half of a legacy pointer double-click.
_previousPointerTimestamp = null;
}
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
{
return CreateSnapshot();
}
var gap = _previousPointerTimestamp is long previousTimestamp
? timestampMilliseconds - previousTimestamp
: long.MaxValue;
if (allowAppend &&
_previousPointerTimestamp is not null &&
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
_previousPointerRow == physicalIndex &&
_previousPointerX == x &&
_previousPointerY == y &&
_legacyPointerRows.Count == 1)
{
TryAppendCut(_legacyPointerRows.Min);
_previousPointerTimestamp = null;
}
else
{
_previousPointerTimestamp = allowAppend ? timestampMilliseconds : null;
}
_previousPointerRow = physicalIndex;
_previousPointerX = x;
_previousPointerY = y;
ApplyLegacyPointerSelection(physicalIndex, control, shift);
ApplyNativeListSelection(physicalIndex, control, shift);
_cutAnchorIndex = physicalIndex;
_nativeFocusedCutIndex = physicalIndex;
// Win32 ListView's selection mark remains the fixed anchor across
// consecutive Shift/Ctrl+Shift gestures. Only a non-Shift click moves it.
if (!shift)
{
_nativeSelectionAnchorIndex = physicalIndex;
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CutKeyDown(
LegacyCutKeyboardKey key,
bool control,
bool shift)
{
if (_selectedStockIndex is null || _cutRows.Count == 0)
{
return CreateSnapshot();
}
var focusedIndex = IsCutIndex(_nativeFocusedCutIndex)
? _nativeFocusedCutIndex
: 0;
var targetIndex = key switch
{
LegacyCutKeyboardKey.ArrowUp => Math.Max(0, focusedIndex - 1),
LegacyCutKeyboardKey.ArrowDown => Math.Min(_cutRows.Count - 1, focusedIndex + 1),
LegacyCutKeyboardKey.Home => 0,
LegacyCutKeyboardKey.End => _cutRows.Count - 1,
LegacyCutKeyboardKey.Space => focusedIndex,
_ => throw new ArgumentOutOfRangeException(nameof(key), key, null)
};
_nativeFocusedCutIndex = targetIndex;
if (key == LegacyCutKeyboardKey.Space)
{
ApplyNativeSpaceSelection(targetIndex, control, shift);
}
else
{
ApplyNativeKeyboardNavigationSelection(targetIndex, control, shift);
}
// MainForm's selectedRowsList was populated only by listView1_MouseDown.
// Keyboard selection belongs exclusively to the native ListView state used
// by owner drawing and PlayList_DragDrop, so do not alter the legacy list.
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DropSelectedCutsOnPlaylist()
{
if (_selectedStockIndex is null || _dialog is not null)
{
return CreateSnapshot();
}
_pendingDropCutRows = new Queue<int>(_nativeSelectedCutRows);
ContinuePendingDrop();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ClearCutSelection()
{
if (_selectedStockIndex is null)
{
return CreateSnapshot();
}
var changed = _legacyPointerRows.Count != 0 ||
_nativeSelectedCutRows.Count != 0 ||
_cutAnchorIndex != -1 ||
_previousPointerTimestamp is not null;
_legacyPointerRows.Clear();
_nativeSelectedCutRows.Clear();
_cutAnchorIndex = -1;
// A Win32 ListView blank-area click clears Selected items but preserves
// its selection mark, so a following Shift gesture keeps the old anchor.
_previousPointerTimestamp = null;
_previousPointerRow = -1;
if (changed)
{
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = _playlist.FindIndex(row =>
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
if (index >= 0)
{
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot TogglePlaylistRowEnabled(string rowId)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = FindPlaylistIndex(rowId);
if (index >= 0)
{
_playlist[index] = _playlist[index] with
{
IsEnabled = !_playlist[index].IsEnabled
};
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetAllPlaylistRowsEnabled(bool enabled)
{
if (_playlist.Count == 0)
{
return CreateSnapshot();
}
for (var index = 0; index < _playlist.Count; index++)
{
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectTab(LegacyOperatorTabId tabId)
{
if (_pendingFixedSectionBatch is not null && _activeTab != tabId)
{
return ReportError(
"고정 컷 섹션의 수동 입력은 원본 모달 창과 같이 완료하거나 취소하기 전까지 탭을 바꿀 수 없습니다.");
}
if (!_tabOrder.Contains(tabId) || _activeTab == tabId)
{
return CreateSnapshot();
}
BeginTabActivation(tabId);
_activeTab = tabId;
Interlocked.Increment(ref _tabActivationRequest);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SelectTabAsync(
LegacyOperatorTabId tabId,
CancellationToken cancellationToken = default)
{
SelectTab(tabId);
var activationRequest = Volatile.Read(ref _tabActivationRequest);
await _tabActivationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
var market = GetActiveIndustryMarket();
var touched = false;
if (_industryWorkflow is not null && market is not null &&
!_industryActivationListLoaded)
{
await _industryWorkflow.LoadMarketAsync(market.Value, cancellationToken)
.ConfigureAwait(false);
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
_industryActivationListLoaded = true;
touched = true;
}
if (IsComparisonActive() && _comparisonWorkflow is not null &&
!_comparisonActivationListLoaded)
{
await _comparisonWorkflow.LoadSavedPairsAsync(cancellationToken)
.ConfigureAwait(false);
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
_comparisonActivationListLoaded = true;
touched = true;
}
// Legacy UC4_Load starts a one-shot timer whose first tick calls
// GetThemeByKeyword with the initial empty query. Keep that load in
// native authority so Web code cannot decide when or what to query.
if (IsThemeActive() && _themeWorkflow is not null &&
_themeSnapshot is not null && !_themeActivationListLoaded)
{
var loaded = await _themeWorkflow.SearchAsync(
_themeSnapshot,
string.Empty,
CurrentThemeNxtSession(),
cancellationToken).ConfigureAwait(false);
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
_themeSnapshot = loaded;
_themeActivationListLoaded = true;
touched = true;
}
// Legacy UC6_Load calls Data_Load immediately, which loads the full
// EXPERT_LIST. An empty native search is its parameterized equivalent.
if (IsExpertActive() && _expertWorkflow is not null &&
!_expertActivationListLoaded)
{
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
.ConfigureAwait(false);
if (!IsCurrentTabActivation(tabId, activationRequest))
{
return CreateSnapshot();
}
_expertActivationListLoaded = true;
touched = true;
}
if (touched)
{
Touch();
}
return CreateSnapshot();
}
finally
{
_tabActivationGate.Release();
}
}
public LegacyOperatorSnapshot SwapTabs(
LegacyOperatorTabId source,
LegacyOperatorTabId target)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션의 수동 입력을 완료하거나 취소하기 전에는 탭 순서를 바꿀 수 없습니다.");
}
var sourceIndex = _tabOrder.IndexOf(source);
var targetIndex = _tabOrder.IndexOf(target);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex)
{
return CreateSnapshot();
}
(_tabOrder[sourceIndex], _tabOrder[targetIndex]) =
(_tabOrder[targetIndex], _tabOrder[sourceIndex]);
if (_activeTab != source)
{
Interlocked.Increment(ref _tabActivationRequest);
BeginTabActivation(source);
}
_activeTab = source;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SwapTabsAsync(
LegacyOperatorTabId source,
LegacyOperatorTabId target,
CancellationToken cancellationToken = default)
{
if (!_tabOrder.Contains(source) || !_tabOrder.Contains(target) || source == target)
{
return SwapTabs(source, target);
}
SwapTabs(source, target);
return await SelectTabAsync(source, cancellationToken).ConfigureAwait(false);
}
public Task<LegacyOperatorSnapshot> SwapTabsAtExpectedPositionsAsync(
LegacyOperatorTabId source,
LegacyOperatorTabId target,
int expectedSourceIndex,
int expectedTargetIndex,
CancellationToken cancellationToken = default)
{
if (source == target ||
expectedSourceIndex < 0 || expectedSourceIndex >= _tabOrder.Count ||
expectedTargetIndex < 0 || expectedTargetIndex >= _tabOrder.Count ||
expectedSourceIndex == expectedTargetIndex ||
_tabOrder[expectedSourceIndex] != source ||
_tabOrder[expectedTargetIndex] != target)
{
return Task.FromResult(CreateSnapshot());
}
// The expected positions make a repeated dragover message idempotent. Once
// the first message swaps the two tabs, an identical delivery no longer
// matches the C#-owned order and therefore cannot swap them back.
return SwapTabsAsync(source, target, cancellationToken);
}
public LegacyOperatorSnapshot SetMovingAverages(bool ma5, bool ma20)
{
var next = new LegacyMovingAverageSelection(ma5, ma20);
if (_movingAverages == next)
{
return CreateSnapshot();
}
_movingAverageSource.Update(new CoreMovingAverageSelection(ma5, ma20));
_movingAverages = next;
_tradingHaltWorkflow?.SetMovingAverages(ma5, ma20);
_overseasWorkflow?.SetMovingAverages(ma5, ma20);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> OpenOperatorCatalogAsync(
LegacyOperatorCatalogEntity entity,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenAsync(entity, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> BeginThemeCatalogFromUc4Async(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenAsync(
LegacyOperatorCatalogEntity.Theme,
cancellationToken).ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> EditSelectedThemeCatalogAsync(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
var identity = _themeSnapshot?.SelectedIdentity;
if (identity is null)
{
return ReportError("편집할 테마를 먼저 선택하세요.");
}
await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public Task<LegacyOperatorSnapshot> DeleteSelectedThemeCatalogAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_operatorCatalogWorkflow is null)
{
return Task.FromResult(ReportError("ThemeA 관리 기능이 구성되지 않았습니다."));
}
var identity = _themeSnapshot?.SelectedIdentity;
if (identity is null)
{
return Task.FromResult(ReportError("삭제할 테마를 먼저 선택하세요."));
}
if (_dialog is not null)
{
return Task.FromResult(CreateSnapshot());
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForTheme(identity);
_dialog = new LegacyDialogState(
"선택한 테마를 삭제하겠습니까?",
IsConfirmation: true);
Touch();
return Task.FromResult(CreateSnapshot());
}
public async Task<LegacyOperatorSnapshot> BeginExpertCatalogFromUc6Async(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenAsync(
LegacyOperatorCatalogEntity.Expert,
cancellationToken).ConfigureAwait(false);
if (_operatorCatalogWorkflow.Current.CanBeginCreate)
{
await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken)
.ConfigureAwait(false);
}
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> EditSelectedExpertCatalogAsync(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
}
var selected = _expertWorkflow?.Current.SelectedExpert;
if (selected is null)
{
return ReportError("편집할 전문가를 먼저 선택하세요.");
}
await _operatorCatalogWorkflow.OpenExpertForEditAsync(
new MMoneyCoderSharp.Data.ExpertSelectionIdentity(
selected.ExpertCode,
selected.ExpertName),
cancellationToken).ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public Task<LegacyOperatorSnapshot> DeleteSelectedExpertCatalogAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_operatorCatalogWorkflow is null)
{
return Task.FromResult(ReportError("EList 관리 기능이 구성되지 않았습니다."));
}
var selected = _expertWorkflow?.Current.SelectedExpert;
if (selected is null)
{
return Task.FromResult(ReportError("삭제할 전문가를 먼저 선택하세요."));
}
if (_dialog is not null)
{
return Task.FromResult(CreateSnapshot());
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForExpert(
new MMoneyCoderSharp.Data.ExpertSelectionIdentity(
selected.ExpertCode,
selected.ExpertName));
_dialog = new LegacyDialogState(
"선택한 전문가를 삭제하겠습니까?",
IsConfirmation: true);
Touch();
return Task.FromResult(CreateSnapshot());
}
public LegacyOperatorSnapshot CloseOperatorCatalog()
{
if (_operatorCatalogWorkflow is null)
{
return CreateSnapshot();
}
_operatorCatalogWorkflow.Close();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> CloseOperatorCatalogAsync(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return CreateSnapshot();
}
var closing = _operatorCatalogWorkflow.Current;
_operatorCatalogWorkflow.Close();
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
if (closing.IsOpen &&
closing.Entity == LegacyOperatorCatalogEntity.Expert &&
closing.Mode == LegacyOperatorCatalogMode.Create &&
_expertWorkflow is not null)
{
try
{
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
_statusMessage = "전문가 목록을 새로 읽지 못했습니다. 다시 조회하세요.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchOperatorCatalogAsync(
string query,
MMoneyCoderSharp.Data.ThemeSession nxtSession,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.SearchAsync(query, nxtSession, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SelectOperatorCatalogResultAsync(
string resultId,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.SelectAsync(resultId, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> BeginCreateThemeCatalogAsync(
MMoneyCoderSharp.Data.ThemeMarket market,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.BeginCreateThemeAsync(market, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> BeginCreateExpertCatalogAsync(
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchOperatorCatalogStocksAsync(
string query,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.SearchStocksAsync(query, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectOperatorCatalogStock(string resultId)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.SelectStock(resultId);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot AddOperatorCatalogStock(decimal? buyAmount)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.AddSelectedStock(buyAmount);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot MoveOperatorCatalogDraftRow(
string rowId,
LegacyOperatorCatalogMoveDirection direction)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.MoveDraftRow(rowId, direction);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReorderOperatorCatalogDraftRow(
string sourceRowId,
string targetRowId,
LegacyDragDropPosition position)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
var beforeRevision = _operatorCatalogWorkflow.Current.Revision;
var reordered = _operatorCatalogWorkflow.ReorderDraftRow(
sourceRowId,
targetRowId,
position);
if (reordered.Revision == beforeRevision)
{
return CreateSnapshot();
}
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount(
string rowId,
decimal? buyAmount)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.SetDraftBuyAmount(rowId, buyAmount);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateOperatorCatalogStock(
string resultId,
decimal? buyAmount)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.SelectStock(resultId);
if (string.Equals(
_operatorCatalogWorkflow.Current.SelectedStockResultId,
resultId,
StringComparison.Ordinal))
{
_operatorCatalogWorkflow.AddSelectedStock(buyAmount);
}
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot RemoveOperatorCatalogDraftRow(string rowId)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.RemoveDraftRow(rowId);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectOperatorCatalogDraftRow(string rowId)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.SelectDraftRow(rowId);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot RemoveActiveOperatorCatalogDraftRow()
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
_operatorCatalogWorkflow.RemoveActiveDraftRow();
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ClearOperatorCatalogDraftRows()
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
var catalog = _operatorCatalogWorkflow.Current;
if (!catalog.IsOpen || catalog.Entity != LegacyOperatorCatalogEntity.Theme ||
catalog.Mode is not (LegacyOperatorCatalogMode.Create or
LegacyOperatorCatalogMode.Edit))
{
return ReportError("ThemeA 편집 목록이 열려 있지 않습니다.");
}
if (catalog.DraftRows.Count == 0 || _dialog is not null)
{
return CreateSnapshot();
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForThemeDraftClear(
catalog.Revision);
_dialog = new LegacyDialogState(
"모두 삭제하겠습니까?",
IsConfirmation: true);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> CheckOperatorCatalogThemeNameAsync(
string editorName,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.CheckThemeNameAvailabilityAsync(
editorName,
cancellationToken).ConfigureAwait(false);
ApplyOperatorCatalogStatus();
var availability = _operatorCatalogWorkflow.Current.ThemeNameAvailability;
if (availability is LegacyThemeNameAvailability.Available or
LegacyThemeNameAvailability.Duplicate)
{
_dialog = new LegacyDialogState(_operatorCatalogWorkflow.Current.StatusMessage);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveOperatorCatalogAsync(
string editorName,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
var before = _operatorCatalogWorkflow.Current;
var selectedExpertId = before.Entity == LegacyOperatorCatalogEntity.Expert &&
before.Mode == LegacyOperatorCatalogMode.Edit
? _expertWorkflow?.Current.SelectedExpert?.SelectionId
: null;
if (before.Entity == LegacyOperatorCatalogEntity.Expert &&
before.Mode == LegacyOperatorCatalogMode.Create &&
string.IsNullOrWhiteSpace(editorName))
{
_dialog = new LegacyDialogState("이름을 입력하세요");
Touch();
return CreateSnapshot();
}
await _operatorCatalogWorkflow.SaveAsync(editorName, cancellationToken)
.ConfigureAwait(false);
ApplyOperatorCatalogStatus();
var after = _operatorCatalogWorkflow.Current;
if (before.Entity == LegacyOperatorCatalogEntity.Theme)
{
if (after.Mode == LegacyOperatorCatalogMode.List)
{
_operatorCatalogWorkflow.Close();
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
}
else if (string.Equals(
after.StatusMessage,
LegacyOperatorCatalogWorkflowController.ThemeNameDuplicateMessage,
StringComparison.Ordinal))
{
_dialog = new LegacyDialogState(
LegacyOperatorCatalogWorkflowController.ThemeNameDuplicateMessage);
}
}
else if (after.Mode == LegacyOperatorCatalogMode.List)
{
var refreshFailed = false;
if (before.Mode == LegacyOperatorCatalogMode.Create)
{
_operatorCatalogWorkflow.Close();
try
{
if (_expertWorkflow is not null)
{
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
.ConfigureAwait(false);
}
}
catch
{
// The mutation receipt is already known-committed. A UC6 list refresh
// failure must not be reclassified as an unknown DB-write outcome.
_statusMessage = "전문가 목록을 새로 읽지 못했습니다. 다시 조회하세요.";
_statusKind = LegacyOperatorStatusKind.Warning;
refreshFailed = true;
}
}
else if (before.Mode == LegacyOperatorCatalogMode.Edit)
{
try
{
if (_expertWorkflow is not null && selectedExpertId is not null)
{
await _expertWorkflow.SelectExpertAsync(
selectedExpertId,
cancellationToken)
.ConfigureAwait(false);
}
}
catch
{
// The expert transaction is known-committed. Preview refresh failure
// is a read failure and must never turn into an unknown write outcome.
refreshFailed = true;
_statusMessage = "저장된 추천 종목을 다시 읽지 못했습니다. 전문가를 다시 선택하세요.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
_dialog = new LegacyDialogState("저장되었습니다");
_closeOperatorCatalogAfterDialog = true;
}
if (!refreshFailed)
{
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
}
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot InvalidateOperatorCatalogBrowserCorrelation()
{
if (_operatorCatalogWorkflow is null)
{
return CreateSnapshot();
}
_operatorCatalogWorkflow.InvalidateBrowserCorrelation();
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> OpenManualFinancialAsync(
MMoneyCoderSharp.Data.ManualFinancialScreenKind screen,
CancellationToken cancellationToken = default)
{
if (_manualFinancialWorkflow is null)
{
return ReportError("수동 재무 입력 기능이 구성되지 않았습니다.");
}
_manualFinancialSnapshot = _manualFinancialWorkflow.CreateInitial(screen);
_manualFinancialSnapshot = await _manualFinancialWorkflow.SearchAsync(
_manualFinancialSnapshot,
string.Empty,
cancellationToken).ConfigureAwait(false);
_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<LegacyOperatorSnapshot> SearchManualFinancialAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
_manualFinancialSnapshot = await workflow.SearchAsync(
snapshot,
query,
cancellationToken).ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ToggleManualFinancialNameSort(long expectedRevision)
{
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("Open a manual-financial screen before sorting its list.");
}
try
{
_manualFinancialSnapshot = workflow.ToggleNameSort(
snapshot,
expectedRevision);
_statusMessage = "The GraphE stock-name order was changed.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException)
{
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> FindManualFinancialAsync(
string query,
LegacyManualFinancialFindDirection direction,
long expectedRevision,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("Open a manual-financial screen before finding a stock.");
}
try
{
_manualFinancialSnapshot = workflow.FindByName(
snapshot,
query,
direction,
expectedRevision);
if (_manualFinancialSnapshot.LastMessage is not null)
{
_statusMessage = _manualFinancialSnapshot.LastMessage;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
return await LoadManualFinancialAsync(
_manualFinancialSnapshot.SelectedRowId!,
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException or KeyNotFoundException or
LegacyManualFinancialWorkflowDataException)
{
// A stale browser generation or a failed fresh detail read leaves the
// GraphE modal open. The operator may inspect the failure; no read is retried.
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> LoadManualFinancialAsync(
string resultId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resultId);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
_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<LegacyOperatorSnapshot> ActivateManualFinancialResultAsync(
string resultId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resultId);
const string command = "activate-manual-financial-result";
if (!TryGetManualFinancial(out _, out _))
{
CompleteCommand(command, resultId, succeeded: false);
return ReportError("Open a manual-financial screen before activating a result.");
}
try
{
// A GraphE list identity is only an opaque handle. Re-read and validate the
// exact record and stock-master identity before deriving the playlist row.
await LoadManualFinancialAsync(resultId, cancellationToken).ConfigureAwait(false);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
throw new InvalidOperationException(
"The manual-financial result is no longer available.");
}
var draft = workflow.MaterializePlaylistDraft(
snapshot,
snapshot.Definition.ActionId);
AppendManualFinancialRow(draft);
_manualFinancialSnapshot = null;
_statusMessage = $"{draft.Title} {draft.Detail} cut was added.";
_statusKind = LegacyOperatorStatusKind.Information;
CompleteCommand(command, resultId, succeeded: true);
Touch();
return CreateSnapshot();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException or KeyNotFoundException or
LegacyManualFinancialWorkflowDataException)
{
// Keep the GraphE modal open and never append a partial row.
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
CompleteCommand(command, resultId, succeeded: false);
Touch();
return CreateSnapshot();
}
}
public LegacyOperatorSnapshot BeginManualFinancialCreate()
{
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
_manualFinancialSnapshot = workflow.BeginCreate(snapshot);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchManualFinancialStocksAsync(
string stockName,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stockName);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
_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<LegacyOperatorSnapshot> SaveManualFinancialAsync(
MMoneyCoderSharp.Data.ManualFinancialRecord record,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
try
{
LegacyManualFinancialWriteRequest request = snapshot.SelectedRecord is null
? workflow.CreateCreateRequest(snapshot, record)
: workflow.CreateUpdateRequest(snapshot, record);
_manualFinancialSnapshot = await workflow.ExecuteAsync(
snapshot,
request,
cancellationToken).ConfigureAwait(false);
ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장");
Touch();
return CreateSnapshot();
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> 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<LegacyOperatorSnapshot> OpenManualNetSellAsync(
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience,
CancellationToken cancellationToken = default)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요.");
}
return await OpenManualNetSellCoreAsync(audience, cancellationToken)
.ConfigureAwait(false);
}
private async Task<LegacyOperatorSnapshot> OpenManualNetSellCoreAsync(
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience,
CancellationToken cancellationToken)
{
if (_manualListsWorkflow is null)
{
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.OpenNetSellAsync(audience, cancellationToken)
.ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> OpenManualViAsync(
CancellationToken cancellationToken = default)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요.");
}
return await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<LegacyOperatorSnapshot> OpenManualViCoreAsync(
CancellationToken cancellationToken)
{
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.OpenViAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CloseManualLists()
{
_manualListsWorkflow?.Close();
if (_pendingFixedSectionBatch is { } pending)
{
_pendingFixedSectionBatch = null;
_statusMessage =
$"{pending.SectionName} 섹션 추가를 취소했습니다. 편성표에는 아무 항목도 추가하지 않았습니다. 이미 확인한 수동 저장 내용은 유지됩니다.";
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> RefreshManualListsAsync(
CancellationToken cancellationToken = default)
{
if (_manualListsWorkflow is null)
{
return ReportError("수동 목록 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.RefreshAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetManualNetSellCell(
string rowId,
LegacyManualNetSellField field,
string value)
{
if (_manualListsWorkflow is null)
{
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
}
_manualListsWorkflow.SetNetSellCell(rowId, field, value);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveManualNetSellAsync(
CancellationToken cancellationToken = default)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다.");
}
if (_manualListsWorkflow is null)
{
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.SaveNetSellAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot AddManualNetSellPlaylistRow()
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다.");
}
if (_manualListsWorkflow is null)
{
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
}
try
{
AppendManualListRow(_manualListsWorkflow.CreateNetSellPlaylistDraft());
_statusMessage = "검증된 수동 순매도 컷을 편성표에 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchManualViAsync(
string query,
CancellationToken cancellationToken = default)
{
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.SearchViAsync(query, cancellationToken)
.ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectManualViSearchResult(string resultId)
{
if (_manualListsWorkflow is null)
{
return ReportError("The manual VI list feature is unavailable.");
}
_manualListsWorkflow.SelectViSearchResult(resultId);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot AddManualViSearchResult(string resultId)
{
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
_manualListsWorkflow.AddViSearchResult(resultId);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot MoveManualViItem(
string itemId,
LegacyManualListMoveDirection direction)
{
_manualListsWorkflow?.MoveViItem(itemId, direction);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DeleteManualViItem(string itemId)
{
_manualListsWorkflow?.DeleteViItem(itemId);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DeleteAllManualViItems()
{
_manualListsWorkflow?.DeleteAllViItems();
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveManualViAsync(
CancellationToken cancellationToken = default)
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다.");
}
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.SaveViAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot AddManualViPlaylistRow()
{
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다.");
}
if (_manualListsWorkflow is null)
{
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
}
try
{
AppendManualListRow(_manualListsWorkflow.CreateViPlaylistDraft());
_statusMessage = "검증된 VI 발동 컷을 편성표에 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
}
Touch();
return CreateSnapshot();
}
/// <summary>
/// Reproduces the original FSell/VI OK button: persist once, verify the fresh
/// readback, append the corresponding cut, and close the editor as one operator
/// action. A failed or outcome-unknown save leaves the editor open and never
/// attempts playlist materialization or an automatic retry.
/// </summary>
public async Task<LegacyOperatorSnapshot> ConfirmManualListsAsync(
CancellationToken cancellationToken = default)
{
if (_manualListsWorkflow is null)
{
return ReportError("수동 목록 기능을 사용할 수 없습니다.");
}
var screen = _manualListsWorkflow.Current.Screen;
if (screen is null)
{
return ReportError("열려 있는 수동 목록 입력창이 없습니다.");
}
if (_pendingFixedSectionBatch is { } pending &&
(GetActiveFixedMarket() != pending.Market ||
!pending.IsCurrentManual(
screen.Value,
_manualListsWorkflow.Current.Audience)))
{
return ReportError(
"고정 컷 섹션의 현재 수동 입력 단계와 열린 입력창이 일치하지 않아 저장하지 않았습니다.");
}
if (screen == LegacyManualListScreen.NetSell)
{
await _manualListsWorkflow.SaveNetSellAsync(cancellationToken)
.ConfigureAwait(false);
}
else
{
await _manualListsWorkflow.SaveViAsync(cancellationToken)
.ConfigureAwait(false);
}
var saved = _manualListsWorkflow.Current;
if (!saved.CanMaterializePlaylist)
{
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
try
{
var draft = screen == LegacyManualListScreen.NetSell
? _manualListsWorkflow.CreateNetSellPlaylistDraft()
: _manualListsWorkflow.CreateViPlaylistDraft();
if (_pendingFixedSectionBatch is not null)
{
_pendingFixedSectionBatch.StageCurrentManual(draft);
_manualListsWorkflow.Close();
return await ContinuePendingFixedSectionAsync(cancellationToken)
.ConfigureAwait(false);
}
AppendManualListRow(draft);
_manualListsWorkflow.Close();
_statusMessage = screen == LegacyManualListScreen.NetSell
? "수동 순매도 목록을 저장하고 편성표에 추가했습니다."
: "VI 발동 목록을 저장하고 편성표에 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> ImportLegacyManualListsAsync(
CancellationToken cancellationToken = default)
{
if (_manualListsWorkflow is null)
{
return ReportError("원본 수동 데이터 가져오기 기능을 사용할 수 없습니다.");
}
await _manualListsWorkflow.ImportAsync(cancellationToken).ConfigureAwait(false);
ApplyManualListsStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateFixedAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요.");
}
var market = GetActiveFixedMarket();
if (market is null)
{
return ReportError("현재 탭에서는 고정 컷을 선택할 수 없습니다.");
}
try
{
var materialized = _fixedCatalog.Materialize(
actionId,
_timeProvider.GetLocalNow());
if (materialized.Action.Market != market.Value)
{
return ReportError("현재 탭에 속하지 않는 항목입니다.");
}
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<LegacyOperatorSnapshot> ActivateFixedActionAsync(
string actionId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(actionId);
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요.");
}
var manualTarget = GetFixedManualTarget(actionId);
if (manualTarget is null)
{
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);
}
/// <summary>
/// Preserves UC1 parent-node direct-child order while closing its manual-child gap:
/// the original parent path removed FSell rows and returned at VI, because the
/// ShowDialog calls lived only in the leaf path. Automatic children are materialized
/// into a native staging area first; FSell/VI children are opened one at a time and
/// the playlist is committed only after every manual child has been confirmed.
/// </summary>
public async Task<LegacyOperatorSnapshot> ActivateFixedSection(
int sectionIndex,
CancellationToken cancellationToken = default)
{
var market = GetActiveFixedMarket();
if (market is null)
{
return ReportError("현재 탭에서는 고정 컷 섹션을 선택할 수 없습니다.");
}
var sections = _fixedCatalog.GetSections(market.Value);
if (sectionIndex < 0 || sectionIndex >= sections.Count)
{
return ReportError("등록되지 않은 고정 컷 섹션입니다.");
}
if (_pendingFixedSectionBatch is not null)
{
return ReportError(
"이미 고정 컷 섹션의 수동 입력을 진행하고 있습니다. 먼저 완료하거나 취소하세요.");
}
if (_manualListsWorkflow?.Current.IsOpen == true)
{
return ReportError("열려 있는 수동 입력창을 먼저 완료하거나 닫으세요.");
}
var section = sections[sectionIndex];
try
{
var localNow = _timeProvider.GetLocalNow();
var items = new List<PendingFixedSectionItem>(section.Actions.Count);
foreach (var action in section.Actions)
{
var manualTarget = GetFixedManualTarget(action.Id);
if (manualTarget is not null)
{
if (_manualListsWorkflow is null)
{
return ReportError(
"이 섹션에는 수동 입력 항목이 있지만 수동 목록 기능을 사용할 수 없습니다.");
}
items.Add(new PendingFixedSectionItem(action, manualTarget.Value));
continue;
}
// Every non-manual child is validated before the first playlist row
// is appended or the first modal editor is opened.
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<LegacyOperatorSnapshot> SearchOverseasIndustriesAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
var overseas = await _overseasWorkflow.SearchIndustriesAsync(
query,
cancellationToken: cancellationToken).ConfigureAwait(false);
_statusMessage = $"해외업종 {overseas.Industry.ResultCount}건을 찾았습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchOverseasStocksAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
var overseas = await _overseasWorkflow.SearchStocksAsync(
query,
cancellationToken: cancellationToken).ConfigureAwait(false);
_statusMessage = $"해외종목 {overseas.Stock.ResultCount}건을 찾았습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectOverseasIndustry(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
try
{
var selected = _overseasWorkflow.SelectIndustry(selectionId).Industry.Selected!;
_statusMessage = $"{selected.KoreanName} 업종을 선택했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("현재 검색 결과에 없는 해외업종입니다.");
}
}
public LegacyOperatorSnapshot SelectOverseasStock(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
try
{
var selected = _overseasWorkflow.SelectStock(selectionId).Stock.Selected!;
_statusMessage = $"{selected.InputName} 종목을 선택했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("현재 검색 결과에 없는 해외종목입니다.");
}
}
public LegacyOperatorSnapshot ActivateOverseasAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsOverseasActive() || _overseasWorkflow is null)
{
return ReportError("해외종목 탭을 먼저 선택하세요.");
}
try
{
var draft = _overseasWorkflow.MaterializePlaylistDraft(actionId);
AppendOverseasRow(draft);
_statusMessage = $"{draft.Title} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (LegacyOverseasWorkflowException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public async Task<LegacyOperatorSnapshot> SearchComparisonDomesticAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
await _comparisonWorkflow.SearchDomesticAsync(query, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SearchComparisonWorldAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
await _comparisonWorkflow.SearchWorldAsync(query, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectComparisonDomesticResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("Select the comparison tab first.");
}
_comparisonWorkflow.SelectDomesticResult(selectionId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectComparisonWorldResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("Select the comparison tab first.");
}
_comparisonWorkflow.SelectWorldResult(selectionId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectComparisonFixedTarget(string targetId)
{
ArgumentNullException.ThrowIfNull(targetId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("Select the comparison tab first.");
}
_comparisonWorkflow.SelectFixedTarget(targetId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ChooseComparisonDomesticResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.ChooseDomesticResult(selectionId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ChooseComparisonWorldResult(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.ChooseWorldResult(selectionId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ChooseComparisonFixedTarget(string targetId)
{
ArgumentNullException.ThrowIfNull(targetId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.ChooseFixedTarget(targetId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SwapComparisonTargets()
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.SwapDraftTargets();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ClearComparisonTargets()
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.ClearDraftTargets();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> AddComparisonPairAsync(
CancellationToken cancellationToken = default)
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
try
{
await _comparisonWorkflow.AddDraftPairAsync(cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
}
public LegacyOperatorSnapshot SelectComparisonPair(string rowId)
{
ArgumentNullException.ThrowIfNull(rowId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
_comparisonWorkflow.SelectSavedPair(rowId);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> MoveComparisonPairAsync(
LegacyComparisonPairMoveDirection direction,
CancellationToken cancellationToken = default)
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
await _comparisonWorkflow.MoveSelectedPairAsync(direction, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> DeleteComparisonPairAsync(
bool all,
CancellationToken cancellationToken = default)
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
if (all)
{
if (_dialog is not null)
{
return CreateSnapshot();
}
_pendingNativeConfirmation = PendingNativeConfirmation.ForComparisonDeleteAll(
_comparisonWorkflow.Current.Revision);
_dialog = new LegacyDialogState(
"모두 삭제하겠습니까?",
"MmoneyCoder",
IsConfirmation: true);
}
else
{
await _comparisonWorkflow.DeleteSelectedPairAsync(cancellationToken)
.ConfigureAwait(false);
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateComparisonAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
try
{
var draft = _comparisonWorkflow.MaterializeSelectedAction(actionId);
AppendComparisonRow(draft);
_statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 비교 컷 항목입니다.");
}
}
public LegacyOperatorSnapshot ActivateComparisonSection(int sectionIndex)
{
if (!IsComparisonActive() || _comparisonWorkflow is null)
{
return ReportError("비교 탭을 먼저 선택하세요.");
}
var snapshot = _comparisonWorkflow.Current;
var sections = snapshot.Actions
.Select(static action => action.Section)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (sectionIndex < 0 || sectionIndex >= sections.Length)
{
return ReportError("등록되지 않은 비교 컷 섹션입니다.");
}
var section = sections[sectionIndex];
var selectedPair = snapshot.SavedPairs.FirstOrDefault(static pair => pair.IsSelected);
if (string.Equals(section, "종목별 수익률 비교", StringComparison.Ordinal) &&
selectedPair is not null &&
(selectedPair.First.Kind == LegacyComparisonTargetKind.NxtStock ||
selectedPair.Second.Kind == LegacyComparisonTargetKind.NxtStock))
{
_dialog = new LegacyDialogState(
"NXT 종목이 포함된 비교쌍에는 종목별 수익률 비교 섹션을 추가할 수 없습니다.");
Touch();
return CreateSnapshot();
}
var actions = snapshot.Actions
.Where(action => string.Equals(
action.Section,
section,
StringComparison.Ordinal))
.ToArray();
if (actions.Length == 0 || actions.Any(static action => !action.IsAvailable))
{
_dialog = new LegacyDialogState(
"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
Touch();
return CreateSnapshot();
}
try
{
var drafts = actions
.Select(action => _comparisonWorkflow.MaterializeSelectedAction(action.ActionId))
.ToArray();
foreach (var draft in drafts)
{
AppendComparisonRow(draft);
}
_statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(
$"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}");
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 비교 컷 항목입니다.");
}
}
public Task<LegacyOperatorSnapshot> SearchThemesAsync(
string query,
CancellationToken cancellationToken = default) =>
SearchThemesCoreAsync(query, CurrentThemeNxtSession(), cancellationToken);
// Compatibility overload for native callers. UC4 does not allow a WebView
// payload to select the NXT session; TimeProvider remains authoritative.
public Task<LegacyOperatorSnapshot> SearchThemesAsync(
string query,
MMoneyCoderSharp.Data.ThemeSession ignoredNxtSession,
CancellationToken cancellationToken = default) =>
SearchThemesAsync(query, cancellationToken);
private async Task<LegacyOperatorSnapshot> SearchThemesCoreAsync(
string query,
MMoneyCoderSharp.Data.ThemeSession nxtSession,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
return ReportError("테마 탭을 먼저 선택하세요.");
}
_themeSnapshot = await _themeWorkflow.SearchAsync(
_themeSnapshot,
query,
nxtSession,
cancellationToken).ConfigureAwait(false);
_themeActivationListLoaded = true;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SelectThemeAsync(
string resultId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resultId);
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
return ReportError("테마 탭을 먼저 선택하세요.");
}
try
{
_themeSnapshot = await _themeWorkflow.SelectAsync(
_themeSnapshot,
resultId,
cancellationToken).ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("등록되지 않은 테마 검색 결과입니다.");
}
}
public async Task<LegacyOperatorSnapshot> ActivateThemeResultAsync(
string resultId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resultId);
const string command = "activate-theme-result";
const string originalDoubleClickAction = "five-row-current";
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
CompleteCommand(command, resultId, succeeded: false);
return ReportError("Select the theme tab before activating a result.");
}
try
{
// The original row double-click first selected the row, then emitted the
// fixed five-row current-price cut. Await preview loading so those stages
// cannot race each other.
_themeSnapshot = await _themeWorkflow.SelectAsync(
_themeSnapshot,
resultId,
cancellationToken).ConfigureAwait(false);
var draft = _themeWorkflow.MaterializePlaylistDraft(
_themeSnapshot,
originalDoubleClickAction);
AppendThemeRow(draft);
_statusMessage = $"{draft.Detail} item was added.";
_statusKind = LegacyOperatorStatusKind.Information;
CompleteCommand(command, resultId, succeeded: true);
Touch();
return CreateSnapshot();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException or KeyNotFoundException or
LegacyThemeWorkflowDataException)
{
_dialog = new LegacyDialogState(exception.Message);
_statusMessage = exception.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
CompleteCommand(command, resultId, succeeded: false);
Touch();
return CreateSnapshot();
}
}
public LegacyOperatorSnapshot SetThemeSort(string sortId)
{
ArgumentNullException.ThrowIfNull(sortId);
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
return ReportError("테마 탭을 먼저 선택하세요.");
}
_themeSnapshot = _themeWorkflow.SelectSort(_themeSnapshot, sortId);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateThemeAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
{
return ReportError("테마 탭을 먼저 선택하세요.");
}
try
{
var draft = _themeWorkflow.MaterializePlaylistDraft(_themeSnapshot, actionId);
AppendThemeRow(draft);
_statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (KeyNotFoundException)
{
return ReportError("등록되지 않은 테마 컷 항목입니다.");
}
}
public async Task<LegacyOperatorSnapshot> SearchExpertsAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsExpertActive() || _expertWorkflow is null)
{
return ReportError("전문가 탭을 먼저 선택하세요.");
}
await _expertWorkflow.SearchAsync(query, cancellationToken).ConfigureAwait(false);
_expertActivationListLoaded = true;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SelectExpertAsync(
string selectionId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsExpertActive() || _expertWorkflow is null)
{
return ReportError("전문가 탭을 먼저 선택하세요.");
}
try
{
await _expertWorkflow.SelectExpertAsync(selectionId, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 전문가 검색 결과입니다.");
}
}
public LegacyOperatorSnapshot ActivateExpertAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsExpertActive() || _expertWorkflow is null)
{
return ReportError("전문가 탭을 먼저 선택하세요.");
}
try
{
var draft = _expertWorkflow.MaterializeSelectedAction(actionId);
AppendExpertRow(draft);
_statusMessage = $"{draft.Title} 추천 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 전문가 컷 항목입니다.");
}
}
public async Task<LegacyOperatorSnapshot> SearchTradingHaltsAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
{
return ReportError("정지 탭을 먼저 선택하세요.");
}
await _tradingHaltWorkflow.SearchAsync(query, cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectTradingHalt(string selectionId)
{
ArgumentNullException.ThrowIfNull(selectionId);
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
{
return ReportError("정지 탭을 먼저 선택하세요.");
}
try
{
_tradingHaltWorkflow.SelectResult(selectionId);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 거래정지 검색 결과입니다.");
}
}
public LegacyOperatorSnapshot ToggleTradingHaltSort()
{
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
{
return ReportError("정지 탭을 먼저 선택하세요.");
}
_tradingHaltWorkflow.ToggleNameSort();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ActivateTradingHaltAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
{
return ReportError("정지 탭을 먼저 선택하세요.");
}
try
{
var draft = _tradingHaltWorkflow.MaterializeSelectedAction(actionId);
AppendTradingHaltRow(draft);
_statusMessage = $"{draft.Title} 항목을 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
catch (InvalidOperationException exception)
{
_dialog = new LegacyDialogState(exception.Message);
Touch();
return CreateSnapshot();
}
catch (ArgumentOutOfRangeException)
{
return ReportError("등록되지 않은 거래정지 컷 항목입니다.");
}
}
public LegacyOperatorSnapshot SelectPlaylistRow(
string rowId,
bool control,
bool shift)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = FindPlaylistIndex(rowId);
if (index < 0)
{
return CreateSnapshot();
}
_activePlaylistRowId = rowId;
// MainForm handled Control before Shift. Consequently Ctrl+Shift added only
// the clicked row, and clicking an already selected row did not remove it.
if (control)
{
_selectedPlaylistRowIds.Add(rowId);
}
else
{
if (_playlistCursorIndex != index)
{
_playlistPriorCursorIndex = _playlistCursorIndex;
_playlistCursorIndex = index;
}
_selectedPlaylistRowIds.Clear();
if (shift)
{
AddPlaylistRange(_playlistPriorCursorIndex, _playlistCursorIndex);
}
else
{
_selectedPlaylistRowIds.Add(rowId);
}
}
Touch();
return CreateSnapshot();
}
/// <summary>
/// Moves only MainForm's m_rowidx equivalent. A click in the checkbox column
/// selected the next F8 candidate before its separate enabled-state handling,
/// but did not replace the rows accumulated for Ctrl/Shift deletion.
/// </summary>
public LegacyOperatorSnapshot ActivatePlaylistRow(string rowId)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = FindPlaylistIndex(rowId);
if (index < 0 || string.Equals(
_activePlaylistRowId,
rowId,
StringComparison.Ordinal))
{
return CreateSnapshot();
}
_activePlaylistRowId = rowId;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ToggleActivePlaylistRowEnabled()
{
return _activePlaylistRowId is null
? CreateSnapshot()
: TogglePlaylistRowEnabled(_activePlaylistRowId);
}
public LegacyOperatorSnapshot SelectPlaylistBoundary(bool last)
{
if (_playlist.Count == 0)
{
return CreateSnapshot();
}
var index = last ? _playlist.Count - 1 : 0;
var rowId = _playlist[index].RowId;
_activePlaylistRowId = rowId;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DeleteSelectedPlaylistRows()
{
if (_selectedPlaylistRowIds.Count == 0)
{
return CreateSnapshot();
}
var originalRows = _playlist.ToArray();
var removedIds = new HashSet<string>(
_selectedPlaylistRowIds,
StringComparer.Ordinal);
var activeIndex = FindOriginalPlaylistIndex(originalRows, _activePlaylistRowId);
var cursorIndex = Math.Clamp(_playlistCursorIndex, 0, originalRows.Length - 1);
var priorCursorIndex = Math.Clamp(
_playlistPriorCursorIndex,
0,
originalRows.Length - 1);
var cursorRowId = originalRows[cursorIndex].RowId;
var priorCursorRowId = originalRows[priorCursorIndex].RowId;
_playlist.RemoveAll(row => removedIds.Contains(row.RowId));
_selectedPlaylistRowIds.Clear();
if (_playlist.Count == 0)
{
ResetPlaylistSelection();
}
else
{
_activePlaylistRowId = ResolveSurvivingPlaylistRowId(
originalRows,
removedIds,
_activePlaylistRowId,
activeIndex);
_playlistCursorIndex = ResolveSurvivingPlaylistIndex(
originalRows,
removedIds,
cursorRowId,
cursorIndex);
_playlistPriorCursorIndex = ResolveSurvivingPlaylistIndex(
originalRows,
removedIds,
priorCursorRowId,
priorCursorIndex);
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DeleteAllPlaylistRows()
{
if (_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<LegacyOperatorSnapshot> RefreshNamedPlaylistsAsync(
CancellationToken cancellationToken = default)
{
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
var namedPlaylist = await _namedPlaylistWorkflow.RefreshAsync(
cancellationToken: cancellationToken).ConfigureAwait(false);
ApplyNamedPlaylistStatus(namedPlaylist);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
ApplyNamedPlaylistStatus(
_namedPlaylistWorkflow.Current,
"DB 재생목록 목록을 불러오지 못했습니다.",
LegacyOperatorStatusKind.Error);
}
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectNamedPlaylist(string definitionId)
{
ArgumentNullException.ThrowIfNull(definitionId);
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
ApplyNamedPlaylistStatus(
_namedPlaylistWorkflow.SelectDefinition(definitionId));
}
catch (Exception exception) when (exception is KeyNotFoundException or
InvalidOperationException or ArgumentException)
{
_dialog = new LegacyDialogState(
"선택한 DB 재생목록이 더 이상 유효하지 않습니다. 목록을 새로 고치세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> LoadSelectedNamedPlaylistAsync(
CancellationToken cancellationToken = default)
{
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync(
cancellationToken).ConfigureAwait(false);
var loadedRows = namedPlaylist.Rows
.Select(row =>
{
try
{
return CreateNamedPlaylistOperatorRow(row);
}
catch (InvalidDataException exception)
{
exception.Data["NamedPlaylistItemIndex"] = row.ItemIndex;
throw;
}
})
.ToArray();
_playlist.Clear();
_playlist.AddRange(loadedRows);
ResetPlaylistSelection();
SelectInitialPlaylistCandidate();
ApplyNamedPlaylistStatus(namedPlaylist);
}
catch (OperationCanceledException)
{
throw;
}
catch (InvalidDataException exception)
{
var rowNumber = exception.Data["NamedPlaylistItemIndex"] is int itemIndex
? $" {itemIndex + 1}행에"
: string.Empty;
_dialog = new LegacyDialogState(
$"DB 재생목록{rowNumber} 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Error;
}
catch (Exception exception) when (exception is KeyNotFoundException or
InvalidOperationException or ArgumentException)
{
_dialog = new LegacyDialogState("불러올 DB 재생목록을 먼저 선택하세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
catch
{
ApplyNamedPlaylistStatus(
_namedPlaylistWorkflow.Current,
"선택한 DB 재생목록을 불러오지 못했습니다.",
LegacyOperatorStatusKind.Error);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> LoadNamedPlaylistByIdAsync(
string definitionId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(definitionId);
const string command = "load-named-playlist-by-id";
var selected = SelectNamedPlaylist(definitionId);
if (!string.Equals(
selected.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal))
{
CompleteCommand(command, definitionId, succeeded: false);
Touch();
return CreateSnapshot();
}
var loaded = await LoadSelectedNamedPlaylistAsync(cancellationToken)
.ConfigureAwait(false);
var succeeded =
string.Equals(
loaded.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal) &&
loaded.NamedPlaylist?.ReadStatus == LegacyNamedPlaylistReadStatus.Ready &&
loaded.NamedPlaylist?.DocumentFreshness == LegacyNamedPlaylistFreshness.Fresh &&
loaded.StatusKind == LegacyOperatorStatusKind.Information;
CompleteCommand(command, definitionId, succeeded);
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> CreateNamedPlaylistAsync(
string title,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(title);
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
var draft = _namedPlaylistWorkflow.CreateProgramDraft(title);
var result = await _namedPlaylistWorkflow.CreateProgramAsync(
draft,
cancellationToken).ConfigureAwait(false);
ApplyNamedPlaylistStatus(result.Snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException)
{
_dialog = new LegacyDialogState(
"새 DB 재생목록을 만들 수 없습니다. 목록을 새로 고친 뒤 이름을 확인하세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveCurrentNamedPlaylistAsync(
CancellationToken cancellationToken = default)
{
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
if (_playlist.Count > LegacyNamedPlaylistWorkflowController.MaximumItems)
{
throw new InvalidOperationException("The playlist is too large to save.");
}
// DC_LIST/PLAY_LIST compatibility 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<LegacyOperatorSnapshot> 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<LegacyOperatorSnapshot> DeleteSelectedNamedPlaylistAsync(
CancellationToken cancellationToken = default)
{
if (_namedPlaylistWorkflow is null)
{
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
}
try
{
var result = await _namedPlaylistWorkflow.DeleteSelectedAsync(
cancellationToken).ConfigureAwait(false);
ApplyNamedPlaylistStatus(result.Snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is ArgumentException or
InvalidOperationException)
{
_dialog = new LegacyDialogState("삭제할 DB 재생목록을 먼저 선택하세요.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Warning;
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> ConfirmDialogAsync(
CancellationToken cancellationToken = default)
{
var pending = _pendingNativeConfirmation;
if (_dialog?.IsConfirmation != true || pending is null)
{
return CreateSnapshot();
}
// Consume the confirmation before crossing a database-write boundary. A
// timeout or unknown outcome must never leave a replayable Yes action.
_pendingNativeConfirmation = null;
_dialog = null;
return pending.Kind switch
{
PendingNativeConfirmationKind.DeleteTheme =>
await ExecuteConfirmedThemeDeleteAsync(
pending.ThemeIdentity!,
cancellationToken).ConfigureAwait(false),
PendingNativeConfirmationKind.DeleteExpert =>
await ExecuteConfirmedExpertDeleteAsync(
pending.ExpertIdentity!,
cancellationToken).ConfigureAwait(false),
PendingNativeConfirmationKind.ClearThemeDraft =>
ExecuteConfirmedThemeDraftClear(pending.CatalogRevision),
PendingNativeConfirmationKind.DeleteAllComparisonPairs =>
await ExecuteConfirmedComparisonDeleteAllAsync(
pending.ComparisonRevision,
cancellationToken)
.ConfigureAwait(false),
_ => 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<LegacyOperatorSnapshot> ExecuteConfirmedThemeDeleteAsync(
MMoneyCoderSharp.Data.ThemeSelectionIdentity identity,
CancellationToken cancellationToken)
{
if (_operatorCatalogWorkflow is null || _themeWorkflow is null ||
_themeSnapshot?.SelectedIdentity != identity)
{
return ReportError("삭제 대상 테마가 변경되어 DB 삭제를 시작하지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken)
.ConfigureAwait(false);
if (!_operatorCatalogWorkflow.Current.CanDelete)
{
ApplyOperatorCatalogStatus();
_operatorCatalogWorkflow.Close();
Touch();
return CreateSnapshot();
}
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
if (_operatorCatalogWorkflow.Current.Mode == LegacyOperatorCatalogMode.List)
{
_themeSnapshot = _themeWorkflow.RemoveSelectedResult(_themeSnapshot);
_operatorCatalogWorkflow.Close();
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
}
else
{
ApplyOperatorCatalogStatus();
}
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedExpertDeleteAsync(
MMoneyCoderSharp.Data.ExpertSelectionIdentity identity,
CancellationToken cancellationToken)
{
var selected = _expertWorkflow?.Current.SelectedExpert;
if (_operatorCatalogWorkflow is null || selected is null ||
!string.Equals(selected.ExpertCode, identity.ExpertCode, StringComparison.Ordinal) ||
!string.Equals(selected.ExpertName, identity.ExpertName, StringComparison.Ordinal))
{
return ReportError("삭제 대상 전문가가 변경되어 DB 삭제를 시작하지 않았습니다.");
}
await _operatorCatalogWorkflow.OpenExpertForEditAsync(identity, cancellationToken)
.ConfigureAwait(false);
if (!_operatorCatalogWorkflow.Current.CanDelete)
{
ApplyOperatorCatalogStatus();
_operatorCatalogWorkflow.Close();
Touch();
return CreateSnapshot();
}
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
if (_operatorCatalogWorkflow.Current.Mode == LegacyOperatorCatalogMode.List)
{
_expertWorkflow?.RemoveSelectedResultPreservingPreview();
_operatorCatalogWorkflow.Close();
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
}
else
{
ApplyOperatorCatalogStatus();
}
Touch();
return CreateSnapshot();
}
private LegacyOperatorSnapshot ExecuteConfirmedThemeDraftClear(long catalogRevision)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
var catalog = _operatorCatalogWorkflow.Current;
if (catalog.Revision != catalogRevision || !catalog.IsOpen ||
catalog.Entity != LegacyOperatorCatalogEntity.Theme ||
catalog.Mode is not (LegacyOperatorCatalogMode.Create or
LegacyOperatorCatalogMode.Edit))
{
return ReportError("ThemeA 편집 목록이 변경되어 전체 삭제를 실행하지 않았습니다.");
}
_operatorCatalogWorkflow.ClearThemeDraftRows();
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedComparisonDeleteAllAsync(
long comparisonRevision,
CancellationToken cancellationToken)
{
if (!IsComparisonActive() || _comparisonWorkflow is null ||
_comparisonWorkflow.Current.Revision != comparisonRevision)
{
return ReportError(
"삭제 대상 종목 비교 목록이 변경되어 전체 삭제를 실행하지 않았습니다.");
}
await _comparisonWorkflow.DeleteAllPairsAsync(cancellationToken)
.ConfigureAwait(false);
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReportError(string message)
{
_statusMessage = message ?? string.Empty;
_statusKind = LegacyOperatorStatusKind.Error;
Touch();
return CreateSnapshot();
}
/// <summary>
/// Completes a correlated named-playlist command when the native dispatch
/// boundary rejects the request or encounters an unexpected exception.
/// The terminal receipt prevents the WebView modal from waiting indefinitely;
/// the named-playlist workflow remains the authority for any write quarantine
/// that was already latched before the exception escaped.
/// </summary>
public LegacyOperatorSnapshot ReportNamedPlaylistCommandFailure(
string command,
string targetId,
string message)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command);
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
if (command is not "refresh-named-playlists" and
not "select-named-playlist" and
not "create-named-playlist" and
not "delete-selected-named-playlist" and
not "load-named-playlist-by-id" and
not "save-current-named-playlist-to")
{
throw new ArgumentOutOfRangeException(
nameof(command),
command,
"Only correlated named-playlist commands can be completed here.");
}
CompleteCommand(command, targetId, succeeded: false);
return ReportError(message);
}
public LegacyOperatorSnapshot CompleteNamedPlaylistDispatch(
string command,
string requestId,
bool succeeded)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command);
ArgumentException.ThrowIfNullOrWhiteSpace(requestId);
if (command is not "refresh-named-playlists" and
not "select-named-playlist" and
not "create-named-playlist" and
not "delete-selected-named-playlist")
{
throw new ArgumentOutOfRangeException(
nameof(command),
command,
"The command does not use a named-playlist dispatch id.");
}
CompleteCommand(command, requestId, succeeded);
Touch();
return CreateSnapshot();
}
/// <summary>
/// Terminates an accepted save command without making its database outcome
/// retryable. This is intentionally separate from a known pre-dispatch
/// rejection: an escaped post-dispatch exception permanently quarantines every
/// later named-playlist write in this process.
/// </summary>
public LegacyOperatorSnapshot ReportNamedPlaylistWriteOutcomeUnknown(
string command,
string targetId,
string message)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command);
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
if (command is not "create-named-playlist" and
not "delete-selected-named-playlist" and
not "save-current-named-playlist-to")
{
throw new ArgumentOutOfRangeException(
nameof(command),
command,
"The command is not a named-playlist write.");
}
_namedPlaylistWorkflow?.QuarantineAmbiguousWriteOutcome();
CompleteCommand(command, targetId, succeeded: false);
return ReportError(message);
}
public LegacyOperatorSnapshot ReportInformation(string message)
{
_statusMessage = message ?? string.Empty;
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReportWarning(string message)
{
_statusMessage = message ?? string.Empty;
_statusKind = LegacyOperatorStatusKind.Warning;
Touch();
return CreateSnapshot();
}
private void ContinuePendingDrop()
{
while (_pendingDropCutRows is { Count: > 0 })
{
var physicalIndex = _pendingDropCutRows.Dequeue();
if (!TryAppendCut(physicalIndex))
{
return;
}
}
_pendingDropCutRows = null;
}
private bool TryAppendCut(int physicalIndex)
{
if (_selectedStockIndex is not int stockIndex ||
stockIndex < 0 || stockIndex >= _searchData.DisplayNames.Count ||
!IsCutIndex(physicalIndex))
{
return true;
}
var cut = _cutRows[physicalIndex];
var stockName = _searchData.DisplayNames[stockIndex];
var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal);
if (isNxt && !string.Equals(
cut.RawLabel,
LegacyStockCutCatalog.NxtAllowedLabel,
StringComparison.Ordinal))
{
_dialog = new LegacyDialogState("NXT는 제공하지 않습니다.");
return false;
}
if (cut.IsSeparator)
{
return true;
}
var identity = _searchData.ResolveLastByDisplayName(stockName);
var rowId = $"legacy-stock-{++_playlistSequence:D8}";
AppendPlaylistRow(LegacyStockCutCatalog.CreatePlaylistRow(
rowId,
stockName,
identity,
cut));
return true;
}
private void ApplyLegacyPointerSelection(int physicalIndex, bool control, bool shift)
{
if (control && shift)
{
AddLegacyRangeFromAnchor(physicalIndex);
}
else if (control)
{
if (!_legacyPointerRows.Add(physicalIndex))
{
_legacyPointerRows.Remove(physicalIndex);
}
}
else if (shift)
{
_legacyPointerRows.Clear();
AddLegacyRangeFromAnchor(physicalIndex);
}
else
{
_legacyPointerRows.Clear();
_legacyPointerRows.Add(physicalIndex);
}
}
private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift)
{
var anchor = _nativeSelectionAnchorIndex >= 0
? _nativeSelectionAnchorIndex
: physicalIndex;
if (control && shift)
{
AddInclusiveRange(
_nativeSelectedCutRows,
anchor,
physicalIndex);
}
else if (control)
{
if (!_nativeSelectedCutRows.Add(physicalIndex))
{
_nativeSelectedCutRows.Remove(physicalIndex);
}
}
else if (shift)
{
_nativeSelectedCutRows.Clear();
AddInclusiveRange(
_nativeSelectedCutRows,
anchor,
physicalIndex);
}
else
{
_nativeSelectedCutRows.Clear();
_nativeSelectedCutRows.Add(physicalIndex);
}
}
private void ApplyNativeKeyboardNavigationSelection(
int physicalIndex,
bool control,
bool shift)
{
var anchor = IsCutIndex(_nativeSelectionAnchorIndex)
? _nativeSelectionAnchorIndex
: physicalIndex;
if (control && !shift)
{
// Ctrl+navigation moves the focus rectangle without changing either
// selection or the Win32 ListView selection mark.
return;
}
if (control && shift)
{
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
return;
}
_nativeSelectedCutRows.Clear();
if (shift)
{
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
return;
}
_nativeSelectedCutRows.Add(physicalIndex);
_nativeSelectionAnchorIndex = physicalIndex;
}
private void ApplyNativeSpaceSelection(int physicalIndex, bool control, bool shift)
{
var anchor = IsCutIndex(_nativeSelectionAnchorIndex)
? _nativeSelectionAnchorIndex
: physicalIndex;
if (control && shift)
{
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
return;
}
if (control)
{
if (!_nativeSelectedCutRows.Add(physicalIndex))
{
_nativeSelectedCutRows.Remove(physicalIndex);
}
_nativeSelectionAnchorIndex = physicalIndex;
return;
}
_nativeSelectedCutRows.Clear();
if (shift)
{
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
return;
}
_nativeSelectedCutRows.Add(physicalIndex);
_nativeSelectionAnchorIndex = physicalIndex;
}
private void AddLegacyRangeFromAnchor(int physicalIndex)
{
// MainForm used beforeRow = -1 and skipped the range when the previous
// and current rows were equal. The sentinel also affected its double-click count.
var anchor = _cutAnchorIndex ?? -1;
if (anchor == physicalIndex)
{
return;
}
var first = Math.Min(anchor, physicalIndex);
var last = Math.Max(anchor, physicalIndex);
for (var index = first; index <= last; index++)
{
_legacyPointerRows.Add(index);
}
}
private static void AddInclusiveRange(
ISet<int> target,
int anchor,
int physicalIndex)
{
var first = Math.Min(anchor, physicalIndex);
var last = Math.Max(anchor, physicalIndex);
for (var index = first; index <= last; index++)
{
target.Add(index);
}
}
private void ApplyNamedPlaylistStatus(
LegacyNamedPlaylistWorkflowSnapshot snapshot,
string? fallbackMessage = null,
LegacyOperatorStatusKind? fallbackKind = null)
{
_statusMessage = string.IsNullOrWhiteSpace(snapshot.StatusMessage)
? fallbackMessage ?? string.Empty
: snapshot.StatusMessage;
_statusKind = fallbackKind ?? snapshot.StatusKind;
}
private static MMoneyCoderSharp.Data.NamedPlaylistPageState? ParseNamedPlaylistPage(
string pageText)
{
ArgumentNullException.ThrowIfNull(pageText);
if (pageText.Length == 0)
{
return null;
}
if (!string.Equals(pageText, pageText.Trim(), StringComparison.Ordinal))
{
throw new FormatException("A named-playlist page must be canonical.");
}
var parts = pageText.Split('/');
if (parts.Length != 2 ||
!int.TryParse(
parts[0],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out var currentPage) ||
!int.TryParse(
parts[1],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out var totalPages))
{
throw new FormatException("A named-playlist page must use current/total form.");
}
return new MMoneyCoderSharp.Data.NamedPlaylistPageState(currentPage, totalPages);
}
private LegacyOperatorPlaylistRow CreateNamedPlaylistOperatorRow(
LegacyNamedPlaylistRowView row)
{
var selection = row.Selection;
var sceneAlias = ResolveNamedPlaylistSceneAlias(selection);
var playoutSelection = TryCreateNamedFixedSelection(
selection,
out var fixedSelection,
out _)
? fixedSelection
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
? stockDetailSelection
: TryCreateNamedIndustrySelection(selection, out var industrySelection)
? industrySelection
: TryCreateNamedComparisonSelection(selection, out var comparisonSelection)
? comparisonSelection
: TryCreateNamedManualFinancialSelection(
selection,
out var manualFinancialSelection)
? manualFinancialSelection
: TryCreateNamedManualNetSellSelection(
selection,
out var manualNetSellSelection)
? manualNetSellSelection
: TryCreateNamedVersionedViSelection(selection, out var viSelection)
? viSelection
: new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode);
var rawCutLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
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.
}
}
if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction))
{
return fixedAction!.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";
}
// FSell rows are saved with the closed canonical selection emitted by the
// native manual-list workflow. Older PList rows use the original visible
// seven-field identity; the scene resolver accepts both shapes and performs
// the fresh trusted-file read again before any cue can reach Tornado2.
if (TryCreateNamedManualNetSellSelection(selection, out _))
{
return "5025";
}
// A versioned VI row carries no path or operator-controlled scene name. The
// native cue provider reopens the trusted snapshot and requires this exact
// version at PREPARE, so alias restoration remains fail-closed.
if (TryCreateNamedVersionedViSelection(selection, out _))
{
return "5074";
}
if (string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
{
var alias = selection.GraphicType switch
{
"s5032" => "5032",
"s8018" => "8032",
"s5029" => "5029",
"s5026" => "5026",
"s5087" => "5087",
_ => null
};
if (alias is not null)
{
return alias;
}
}
var manualFinancial = LegacyManualFinancialWorkflow.Screens
.Where(definition =>
string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
(selection.GroupCode is "KOSPI" or "KOSDAQ" or
"NXT_KOSPI" or "NXT_KOSDAQ"))
.Take(2)
.ToArray();
if (manualFinancial.Length == 1)
{
return manualFinancial[0].Code;
}
throw new InvalidDataException(
"The named-playlist row does not map to a registered scene alias.");
}
private bool TryCreateNamedFixedSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized,
out LegacyFixedAction? action)
{
normalized = null!;
action = null;
var fixedMarket = selection.GroupCode switch
{
"해외지수" => LegacyFixedMarket.Overseas,
"환율" => LegacyFixedMarket.Exchange,
"주요지수" => LegacyFixedMarket.Index,
_ => (LegacyFixedMarket?)null
};
if (fixedMarket is not { } market)
{
return false;
}
var matches = _fixedCatalog.GetActions(market)
.Where(candidate =>
string.Equals(candidate.Label, selection.Subject, StringComparison.Ordinal) &&
string.Equals(candidate.Section, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(candidate.Detail, selection.Subtype, StringComparison.Ordinal))
.Take(2)
.ToArray();
if (matches.Length != 1 || !matches[0].Available)
{
return false;
}
action = matches[0];
var materialized = _fixedCatalog.Materialize(action.Id, DateTimeOffset.Now).Selection;
normalized = new CoreSceneSelection(
materialized.GroupCode,
materialized.Subject,
materialized.GraphicType,
materialized.Subtype,
selection.DataCode);
return true;
}
private static bool TryCreateNamedStockDetailSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.GroupCode is not ("코스피" or "코스닥") ||
!string.Equals(selection.GraphicType, "1열판상세", StringComparison.Ordinal))
{
return false;
}
var branch = selection.Subtype switch
{
"액면가" => nameof(S5011DetailBranch.FaceValue),
"PBR" => nameof(S5011DetailBranch.Valuation),
"거래량" => nameof(S5011DetailBranch.Volume),
_ => null
};
if (branch is null)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
200,
nameof(selection.Subject));
LegacyComparisonValueGuard.RequireStockCode(
selection.DataCode,
nameof(selection.DataCode));
}
catch (ArgumentException)
{
return false;
}
normalized = new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
branch,
selection.DataCode);
return true;
}
private static bool TryCreateNamedIndustrySelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
var market = selection.GroupCode switch
{
"업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
"업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
_ => (MMoneyCoderSharp.Data.IndustryMarket?)null
};
if (market is not { } exactMarket)
{
return false;
}
var rawLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
var actions = LegacyIndustryActionCatalog.GetActions(exactMarket)
.Where(action => string.Equals(action.Label, rawLabel, StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length != 1)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
256,
nameof(selection.Subject));
}
catch (ArgumentException)
{
return false;
}
var action = actions[0];
var industryGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "INDUSTRY_KOSPI"
: "INDUSTRY_KOSDAQ";
var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "KOSPI"
: "KOSDAQ";
normalized = action.Kind switch
{
LegacyIndustryActionKind.Single => new CoreSceneSelection(
industryGroup,
selection.Subject,
"1열판",
"CURRENT",
string.Empty),
LegacyIndustryActionKind.Pair => new CoreSceneSelection(
industryGroup,
selection.Subject,
"2열판",
string.Empty,
string.Empty),
LegacyIndustryActionKind.Yield => new CoreSceneSelection(
industryGroup,
selection.Subject,
"YIELD_GRAPH",
action.YieldPeriod!,
string.Empty),
LegacyIndustryActionKind.Candle => new CoreSceneSelection(
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "KOSPI_INDUSTRY"
: "KOSDAQ_INDUSTRY",
selection.Subject,
string.Empty,
action.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE",
string.Empty),
LegacyIndustryActionKind.FiveRow when
DateOnly.TryParseExact(
selection.DataCode,
"yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out _) => new CoreSceneSelection(
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "PAGED_DOMESTIC_KOSPI"
: "PAGED_DOMESTIC_KOSDAQ",
"INDEX",
string.Empty,
"INDUSTRY",
selection.DataCode),
LegacyIndustryActionKind.Square => new CoreSceneSelection(
industryGroup,
marketName,
"네모그래프",
string.Empty,
string.Empty),
LegacyIndustryActionKind.Sector => new CoreSceneSelection(
industryGroup,
marketName,
"SECTOR_INDEX",
string.Empty,
string.Empty),
_ => null!
};
return normalized is not null;
}
private static bool TryCreateNamedComparisonSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (!string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
{
return false;
}
var names = selection.Subject.Split(" ↔ ", StringSplitOptions.None);
if (names.Length != 2 || names.Any(string.IsNullOrWhiteSpace) ||
names.Any(name => !string.Equals(name, name.Trim(), StringComparison.Ordinal)))
{
return false;
}
LegacyComparisonTarget first;
LegacyComparisonTarget second;
if (selection.DataCode.Length == 0)
{
first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
new LegacyComparisonStoredTargetReference(names[0], string.Empty))!;
second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
new LegacyComparisonStoredTargetReference(names[1], string.Empty))!;
if (first is null || second is null)
{
return false;
}
}
else if (!TryCreateNamedComparisonStocks(
names,
selection.DataCode,
out first,
out second))
{
return false;
}
var actions = LegacyComparisonWorkflowCatalog.Actions
.Where(action => string.Equals(
action.Label,
selection.Subtype,
StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length != 1)
{
return false;
}
try
{
var draft = LegacyComparisonWorkflowCatalog.Materialize(
actions[0].ActionId,
first,
second,
"named-restore");
if (!string.Equals(draft.BuilderKey, selection.GraphicType, StringComparison.Ordinal) ||
!string.Equals(draft.Detail, selection.Subtype, StringComparison.Ordinal) ||
!string.Equals(
draft.Selection.DataCode,
selection.DataCode,
StringComparison.Ordinal))
{
return false;
}
normalized = draft.Selection;
return true;
}
catch (Exception exception) when (exception is
ArgumentException or InvalidOperationException)
{
return false;
}
}
private static bool TryCreateNamedComparisonStocks(
IReadOnlyList<string> names,
string dataCode,
out LegacyComparisonTarget first,
out LegacyComparisonTarget second)
{
first = null!;
second = null!;
var identities = dataCode.Split('|', StringSplitOptions.None);
if (identities.Length != 2)
{
return false;
}
try
{
var targets = new LegacyComparisonTarget[2];
for (var index = 0; index < targets.Length; index++)
{
var separator = identities[index].IndexOf(':');
if (separator <= 0 ||
separator != identities[index].LastIndexOf(':') ||
separator == identities[index].Length - 1)
{
return false;
}
var market = identities[index][..separator] switch
{
"KOSPI" => LegacyComparisonDomesticMarket.Kospi,
"KOSDAQ" => LegacyComparisonDomesticMarket.Kosdaq,
_ => (LegacyComparisonDomesticMarket?)null
};
if (market is not { } exactMarket)
{
return false;
}
targets[index] = LegacyComparisonTarget.CreateKrxStock(
exactMarket,
names[index],
identities[index][(separator + 1)..]);
}
first = targets[0];
second = targets[1];
return true;
}
catch (ArgumentException)
{
return false;
}
}
private static bool TryCreateNamedManualFinancialSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.GroupCode is not
("KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ"))
{
return false;
}
var definitions = LegacyManualFinancialWorkflow.Screens
.Where(definition =>
string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(
definition.CanonicalGraphicType,
selection.Subtype,
StringComparison.Ordinal))
.Take(2)
.ToArray();
if (definitions.Length != 1)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
200,
nameof(selection.Subject));
LegacyComparisonValueGuard.RequireStockCode(
selection.DataCode,
nameof(selection.DataCode));
}
catch (ArgumentException)
{
return false;
}
normalized = new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
definitions[0].CanonicalGraphicType,
string.Empty,
string.Empty);
return true;
}
private static bool TryCreateNamedManualNetSellSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.DataCode.Length != 0)
{
return false;
}
string? audience = null;
if (selection.GroupCode is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION" &&
string.Equals(selection.Subject, "INDEX", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "MANUAL_NET_SELL", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "MANUAL_NET_SELL", StringComparison.Ordinal))
{
audience = selection.GroupCode;
}
else if (string.Equals(selection.Subject, "지수", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
{
audience = selection.GroupCode switch
{
"개인 순매도 상위(수동)" => "INDIVIDUAL",
"외국인 순매도 상위(수동)" => "FOREIGN",
"기관 순매도 상위(수동)" => "INSTITUTION",
_ => null
};
}
else if (string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
{
audience = selection.Subject switch
{
"개인 순매도 상위(수동)" => "INDIVIDUAL",
"외국인 순매도 상위(수동)" => "FOREIGN",
"기관 순매도 상위(수동)" => "INSTITUTION",
_ => null
};
}
if (audience is null)
{
return false;
}
normalized = new CoreSceneSelection(
audience,
"INDEX",
"MANUAL_NET_SELL",
"MANUAL_NET_SELL",
string.Empty);
return true;
}
private static bool TryCreateNamedVersionedViSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
var canonical = string.Equals(selection.GroupCode, "PAGED_VI", StringComparison.Ordinal) &&
string.Equals(
selection.Subject,
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "INPUT_ORDER", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "CURRENT", StringComparison.Ordinal);
var savedVisibleFields = string.Equals(
selection.GroupCode,
"지수",
StringComparison.Ordinal) &&
selection.Subject.Length > 0 &&
string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "VI 발동(수동)", StringComparison.Ordinal);
if ((!canonical && !savedVisibleFields) ||
!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.IsRowVersion(
selection.DataCode))
{
return false;
}
normalized = new CoreSceneSelection(
"PAGED_VI",
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
"INPUT_ORDER",
"CURRENT",
selection.DataCode);
return true;
}
private LegacyOperatorSnapshot CreateSnapshot()
{
var searchRows = _searchData.DisplayNames
.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<LegacyOperatorSnapshot> OpenPendingFixedSectionManualAsync(
CancellationToken cancellationToken)
{
var pending = _pendingFixedSectionBatch
?? throw new InvalidOperationException("A fixed-section batch is not active.");
var item = pending.CurrentManualItem
?? throw new InvalidOperationException("The fixed-section batch has no pending manual item.");
var target = item.ManualTarget!.Value;
if (target.Screen == LegacyManualListScreen.NetSell)
{
await OpenManualNetSellCoreAsync(target.Audience, cancellationToken)
.ConfigureAwait(false);
}
else
{
await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false);
}
var manual = _manualListsWorkflow!.Current;
var view = pending.CreateView();
var progress =
$"{pending.SectionName} 섹션 수동 입력 {view.ManualStepNumber}/{view.ManualStepCount}: {item.Action.Label}. 확인하면 다음 직계 자식으로 계속합니다.";
_statusMessage = string.IsNullOrWhiteSpace(manual.StatusMessage)
? progress
: $"{progress} {manual.StatusMessage}";
_statusKind = manual.StatusKind == LegacyOperatorStatusKind.Neutral
? LegacyOperatorStatusKind.Information
: manual.StatusKind;
Touch();
return CreateSnapshot();
}
private async Task<LegacyOperatorSnapshot> ContinuePendingFixedSectionAsync(
CancellationToken cancellationToken)
{
var pending = _pendingFixedSectionBatch
?? throw new InvalidOperationException("A fixed-section batch is not active.");
if (pending.CurrentManualItem is not null)
{
return await OpenPendingFixedSectionManualAsync(cancellationToken)
.ConfigureAwait(false);
}
try
{
CommitFixedSectionBatch(pending.Items);
}
catch
{
_pendingFixedSectionBatch = null;
throw;
}
_pendingFixedSectionBatch = null;
_statusMessage =
$"{pending.SectionName} 항목 {pending.Items.Count}개를 원본 순서로 추가했습니다.";
_statusKind = LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
private void CommitFixedSectionBatch(IReadOnlyList<PendingFixedSectionItem> items)
{
var originalCount = _playlist.Count;
var originalSequence = _playlistSequence;
var originalActiveRowId = _activePlaylistRowId;
var originalSelectedRowIds = _selectedPlaylistRowIds.ToArray();
var originalCursorIndex = _playlistCursorIndex;
var originalPriorCursorIndex = _playlistPriorCursorIndex;
try
{
foreach (var item in items)
{
if (item.Materialized is not null)
{
AppendFixedRow(item.Materialized);
}
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<LegacyCutCatalogRow> CreateValidatedCutRows(
IReadOnlyList<LegacyCutCatalogRow>? cutRows)
{
var source = cutRows ?? LegacyStockCutCatalog.Rows;
if (source.Count is < 1 or > LegacyRuntimeStockCutMenuLoader.MaximumRows)
{
throw new ArgumentException(
"The stock cut menu must contain from 1 through 50 physical rows.",
nameof(cutRows));
}
var copiedRows = new LegacyCutCatalogRow[source.Count];
var displayOrdinal = 0;
for (var index = 0; index < source.Count; index++)
{
var row = source[index] ?? throw new ArgumentException(
"The stock cut menu cannot contain a null row.",
nameof(cutRows));
if (row.PhysicalIndex != index)
{
throw new ArgumentException(
"Stock cut physical indices must be contiguous and zero based.",
nameof(cutRows));
}
if (row.IsSeparator)
{
if (row.DisplayOrdinal is not null ||
!string.IsNullOrEmpty(row.RawLabel))
{
throw new ArgumentException(
"A stock cut separator must have no display ordinal or label.",
nameof(cutRows));
}
}
else
{
displayOrdinal++;
if (row.DisplayOrdinal != displayOrdinal)
{
throw new ArgumentException(
"Stock cut display ordinals must be contiguous across active rows.",
nameof(cutRows));
}
var isKnown = LegacyStockCutCatalog.Rows.Any(candidate =>
!candidate.IsSeparator && string.Equals(
candidate.RawLabel,
row.RawLabel,
StringComparison.Ordinal));
if (!isKnown)
{
throw new ArgumentException(
"The stock cut menu contains an unknown cut label.",
nameof(cutRows));
}
}
copiedRows[index] = row;
}
if (displayOrdinal == 0)
{
throw new ArgumentException(
"The stock cut menu must contain at least one active cut.",
nameof(cutRows));
}
return Array.AsReadOnly(copiedRows);
}
private int FindPlaylistIndex(string rowId) => _playlist.FindIndex(row =>
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
private static int FindOriginalPlaylistIndex(
IReadOnlyList<LegacyOperatorPlaylistRow> rows,
string? rowId)
{
if (rowId is null)
{
return -1;
}
for (var index = 0; index < rows.Count; index++)
{
if (string.Equals(rows[index].RowId, rowId, StringComparison.Ordinal))
{
return index;
}
}
return -1;
}
private void AppendPlaylistRow(LegacyOperatorPlaylistRow row)
{
ArgumentNullException.ThrowIfNull(row);
row = NormalizeMovingAverageIdentity(row);
var wasEmpty = _playlist.Count == 0;
_playlist.Add(row);
if (wasEmpty)
{
SelectInitialPlaylistCandidate();
}
}
private static LegacyOperatorPlaylistRow NormalizeMovingAverageIdentity(
LegacyOperatorPlaylistRow row)
{
var 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<LegacyOperatorPlaylistRow> originalRows,
ISet<string> removedIds,
string? preferredRowId,
int preferredIndex)
{
if (preferredRowId is not null && FindPlaylistIndex(preferredRowId) >= 0)
{
return preferredRowId;
}
var resolvedOriginalIndex = FindNextThenPreviousSurvivor(
originalRows,
removedIds,
preferredIndex);
return originalRows[resolvedOriginalIndex].RowId;
}
private int ResolveSurvivingPlaylistIndex(
IReadOnlyList<LegacyOperatorPlaylistRow> originalRows,
ISet<string> removedIds,
string preferredRowId,
int preferredIndex)
{
var survivingIndex = FindPlaylistIndex(preferredRowId);
if (survivingIndex >= 0)
{
return survivingIndex;
}
var resolvedOriginalIndex = FindNextThenPreviousSurvivor(
originalRows,
removedIds,
preferredIndex);
return FindPlaylistIndex(originalRows[resolvedOriginalIndex].RowId);
}
private static int FindNextThenPreviousSurvivor(
IReadOnlyList<LegacyOperatorPlaylistRow> originalRows,
ISet<string> removedIds,
int preferredIndex)
{
if (preferredIndex < 0)
{
for (var index = 0; index < originalRows.Count; index++)
{
if (!removedIds.Contains(originalRows[index].RowId))
{
return index;
}
}
}
var start = Math.Clamp(preferredIndex, 0, originalRows.Count - 1);
for (var index = start + 1; index < originalRows.Count; index++)
{
if (!removedIds.Contains(originalRows[index].RowId))
{
return index;
}
}
for (var index = start - 1; index >= 0; index--)
{
if (!removedIds.Contains(originalRows[index].RowId))
{
return index;
}
}
throw new InvalidOperationException("삭제 후 남은 플레이리스트 행을 찾지 못했습니다.");
}
private void AddPlaylistRange(int firstIndex, int lastIndex)
{
var first = Math.Max(0, Math.Min(firstIndex, lastIndex));
var last = Math.Min(_playlist.Count - 1, Math.Max(firstIndex, lastIndex));
for (var index = first; index <= last; index++)
{
_selectedPlaylistRowIds.Add(_playlist[index].RowId);
}
}
private void ResetPlaylistSelection()
{
_selectedPlaylistRowIds.Clear();
_activePlaylistRowId = null;
_playlistCursorIndex = 0;
_playlistPriorCursorIndex = 0;
}
private void CompleteCommand(string command, string targetId, bool succeeded)
{
_commandResult = new LegacyOperatorCommandResult(
checked(++_commandSequence),
command,
targetId,
succeeded);
}
private readonly record struct FixedManualTarget(
LegacyManualListScreen Screen,
S5025ManualAudience Audience);
private 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<PendingFixedSectionItem> items,
int manualCount)
{
Market = market;
SectionIndex = sectionIndex;
SectionName = sectionName;
Items = items;
ManualCount = manualCount;
}
internal LegacyFixedMarket Market { get; }
internal int SectionIndex { get; }
internal string SectionName { get; }
internal IReadOnlyList<PendingFixedSectionItem> Items { get; }
internal int ManualCount { get; }
internal PendingFixedSectionItem? CurrentManualItem => Items.FirstOrDefault(item =>
item.ManualTarget is not null && item.ManualDraft is null);
internal bool IsCurrentManual(
LegacyManualListScreen screen,
S5025ManualAudience audience)
{
var target = CurrentManualItem?.ManualTarget;
return target is not null && target.Value.Screen == screen &&
(screen == LegacyManualListScreen.Vi || target.Value.Audience == audience);
}
internal void StageCurrentManual(LegacyManualListPlaylistDraft draft)
{
var current = CurrentManualItem
?? throw new InvalidOperationException(
"The fixed-section batch has no pending manual item.");
current.StageManual(draft);
}
internal LegacyFixedSectionBatchView CreateView()
{
var current = CurrentManualItem
?? throw new InvalidOperationException(
"A completed fixed-section batch cannot be projected as pending.");
var manualStep = Items
.Where(item => item.ManualTarget is not null)
.TakeWhile(item => !ReferenceEquals(item, current))
.Count() + 1;
return new LegacyFixedSectionBatchView(
SectionIndex,
SectionName,
Items.Count,
Items.Count(item => item.IsStaged),
manualStep,
ManualCount,
current.Action.Id,
current.Action.Label);
}
}
private enum PendingNativeConfirmationKind
{
DeleteTheme,
DeleteExpert,
ClearThemeDraft,
DeleteAllComparisonPairs
}
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++;
}