diff --git a/.gitignore b/.gitignore index fcb46be..3603ce9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ BundleArtifacts/ *.pfx *.p12 *.snk +*.cer +*.crt +*.key +*.pem # Local settings and secrets Config/appsettings.local.json @@ -42,8 +46,12 @@ artifacts/K3DInterop/ **/K3DAsyncEngine.tlb *.t2s *.k3s +*.vrv +*.lic K3D*.lic Tornado*.lic +**/Cuts/ +**/배경/ # Web tooling (if introduced later) node_modules/ diff --git a/App.xaml.cs b/App.xaml.cs index 55accb8..5e132f1 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -1,3 +1,5 @@ +using Microsoft.Windows.AppLifecycle; + namespace MBN_STOCK_WEBVIEW; /// @@ -5,16 +7,53 @@ namespace MBN_STOCK_WEBVIEW; /// public partial class App : Application { + private const string MainInstanceKey = "Wickedness.MBNStockWebView.Main"; + private Window? _window; + private AppInstance? _mainInstance; public App() { InitializeComponent(); } - protected override void OnLaunched(LaunchActivatedEventArgs args) + protected override async void OnLaunched(LaunchActivatedEventArgs args) { + var registeredInstance = AppInstance.FindOrRegisterForKey(MainInstanceKey); + if (!registeredInstance.IsCurrent) + { + try + { + var activation = AppInstance.GetCurrent().GetActivatedEventArgs(); + if (activation is not null) + { + await registeredInstance.RedirectActivationToAsync(activation); + } + } + finally + { + // The secondary process owns no DB, WebView, or playout runtime. End it only + // after the activation has been handed to the registered primary instance. + Exit(); + } + + return; + } + + _mainInstance = registeredInstance; + _mainInstance.Activated += OnMainInstanceActivated; _window = new MainWindow(); _window.Activate(); } + + private void OnMainInstanceActivated(object? sender, AppActivationArguments args) + { + var window = _window; + if (window is null) + { + return; + } + + window.DispatcherQueue.TryEnqueue(window.Activate); + } } diff --git a/Config/playout.example.json b/Config/playout.example.json index 82cfa31..0429548 100644 --- a/Config/playout.example.json +++ b/Config/playout.example.json @@ -12,6 +12,7 @@ "legacySceneBackgroundAssetPath": null, "legacySceneBackgroundVideoLoopCount": 2004, "legacySceneBackgroundVideoLoopInfinite": true, + "legacyBackgroundDirectory": null, "testProcessWindowTitlePattern": null, "testSceneAllowlist": [], "trustedLiveOutputEnabled": false, diff --git a/LegacySceneRuntimeFactory.cs b/LegacySceneRuntimeFactory.cs index c8d4bc1..de0c283 100644 --- a/LegacySceneRuntimeFactory.cs +++ b/LegacySceneRuntimeFactory.cs @@ -567,11 +567,13 @@ internal static class LegacySceneRuntimeFactory ParseNxtSession(selection.GraphicType), page), "PAGED_THEME" => new ThemePagedQuoteLoadRequest( + RequireLookupValue(selection.DataCode, "theme code"), RequireLookupValue(selection.Subject, "theme"), ParseThemeSort(selection.GraphicType), ParseThemeValueKind(selection.Subtype), page), "PAGED_NXT_THEME" => new NxtThemePagedQuoteLoadRequest( + RequireLookupValue(selection.DataCode, "NXT theme code"), RequireLookupValue(selection.Subject, "NXT theme"), ParseThemeSort(selection.Subtype), ParseNxtSession(selection.GraphicType), diff --git a/MBN_STOCK_WEBVIEW.csproj b/MBN_STOCK_WEBVIEW.csproj index 7bbae0d..3a4ce61 100644 --- a/MBN_STOCK_WEBVIEW.csproj +++ b/MBN_STOCK_WEBVIEW.csproj @@ -25,9 +25,9 @@ Properties\PublishProfiles\win-x64.pubxml false false - 1.0.2 - 3 - 1.0.2 + 1.0.3 + 4 + 1.0.3 diff --git a/MainWindow.Background.cs b/MainWindow.Background.cs index 7c3a992..021ac3e 100644 --- a/MainWindow.Background.cs +++ b/MainWindow.Background.cs @@ -9,9 +9,6 @@ namespace MBN_STOCK_WEBVIEW; public sealed partial class MainWindow { - private static readonly IReadOnlySet OperatorBackgroundExtensions = - new HashSet([".vrv", ".jpg", ".jpeg", ".png"], - StringComparer.OrdinalIgnoreCase); private int _backgroundSelectionInFlight; private void HandleBackgroundStatusRequest(JsonElement payload) @@ -50,9 +47,25 @@ public sealed partial class MainWindow return; } + var source = _legacyCompositionOptionsSource ?? + throw new InvalidOperationException("Background composition is unavailable."); + + // Original btnback_Click assigns 배경\기본.vrv before opening the dialog. + // Keep that behavior only when the default passes all trusted-file checks. + try + { + source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault( + _playoutOptions!, + source.Current.FadeDuration)); + } + catch (PlayoutConfigurationException) + { + // F2 may still select another valid file from the same trusted root. + } + var picker = new FileOpenPicker { - SuggestedStartLocation = PickerLocationId.DocumentsLibrary, + SuggestedStartLocation = PickerLocationId.PicturesLibrary, ViewMode = PickerViewMode.List }; picker.FileTypeFilter.Add(".vrv"); @@ -74,14 +87,14 @@ public sealed partial class MainWindow } var composition = CreateOperatorBackgroundComposition(selected.Path); - _legacyCompositionOptionsSource!.Update(composition); - _lastEnabledLegacyComposition = composition; + source.Update(composition); message = "선택한 배경을 다음 PREPARE부터 사용합니다."; } catch (Exception exception) when ( - exception is ArgumentException or IOException or UnauthorizedAccessException) + exception is ArgumentException or IOException or UnauthorizedAccessException or + PlayoutConfigurationException) { - error = "배경 파일은 설정된 컷 루트 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다."; + error = "배경은 신뢰 배경 폴더 안의 일반 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다."; } catch { @@ -133,18 +146,21 @@ public sealed partial class MainWindow var current = source.Current; if (current.BackgroundKind == LegacySceneBackgroundKind.None) { - if (_lastEnabledLegacyComposition is null) + try { - error = "켜 둘 배경 파일이 없습니다. 먼저 F2로 배경 파일을 선택하세요."; + source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault( + _playoutOptions!, + current.FadeDuration)); + message = "기본 배경을 켰습니다. 다음 PREPARE부터 반영됩니다."; + } + catch (PlayoutConfigurationException) + { + error = "신뢰 배경 폴더의 기본.vrv를 확인할 수 없습니다. F2로 유효한 배경을 선택하세요."; return; } - - source.Update(_lastEnabledLegacyComposition); - message = "배경 사용을 켰습니다. 다음 PREPARE부터 반영됩니다."; } else { - _lastEnabledLegacyComposition = current; source.Update(new LegacySceneCueCompositionOptions( current.FadeDuration, LegacySceneBackgroundKind.None)); @@ -178,42 +194,18 @@ public sealed partial class MainWindow throw new InvalidOperationException("Playout options are unavailable."); var source = _legacyCompositionOptionsSource ?? throw new InvalidOperationException("Background composition is unavailable."); - var rootText = options.SceneDirectory?.Trim(); - if (string.IsNullOrWhiteSpace(rootText) || !Path.IsPathFullyQualified(rootText) || - string.IsNullOrWhiteSpace(selectedPath) || !Path.IsPathFullyQualified(selectedPath)) - { - throw new ArgumentException("A trusted scene root and background are required."); - } - - var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText)); - var candidate = Path.GetFullPath(selectedPath); - var rootPrefix = root + Path.DirectorySeparatorChar; - var extension = Path.GetExtension(candidate); - if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) || - !OperatorBackgroundExtensions.Contains(extension)) - { - throw new ArgumentException("The selected background is outside the trusted scene root."); - } - - var kind = string.Equals(extension, ".vrv", StringComparison.OrdinalIgnoreCase) - ? LegacySceneBackgroundKind.Video - : LegacySceneBackgroundKind.Texture; - var validationOptions = new PlayoutOptions - { - SceneDirectory = root, - LegacySceneFadeDuration = source.Current.FadeDuration, - LegacySceneBackgroundKind = kind, - LegacySceneBackgroundAssetPath = Path.GetRelativePath(root, candidate), - LegacySceneBackgroundVideoLoopCount = options.LegacySceneBackgroundVideoLoopCount, - LegacySceneBackgroundVideoLoopInfinite = options.LegacySceneBackgroundVideoLoopInfinite - }; - return PlayoutSceneCompositionFactory.Create(validationOptions); + return PlayoutSceneCompositionFactory.CreateOperatorSelection( + options, + source.Current.FadeDuration, + selectedPath); } private bool CanChangeLegacyBackground(bool allowBackgroundOperation = false) { if (_playoutOptions is null || _legacyCompositionOptionsSource is null || - string.IsNullOrWhiteSpace(_playoutOptions.SceneDirectory) || + !PlayoutSceneCompositionFactory.TryGetTrustedBackgroundDirectory( + _playoutOptions, + out _) || IsBrowserCorrelationQuarantined() || (!allowBackgroundOperation && Volatile.Read(ref _backgroundSelectionInFlight) != 0) || @@ -251,6 +243,26 @@ public sealed partial class MainWindow var current = _legacyCompositionOptionsSource?.Current ?? LegacySceneCueCompositionOptions.Default; var enabled = current.BackgroundKind != LegacySceneBackgroundKind.None; + var rootAvailable = _playoutOptions is not null && + PlayoutSceneCompositionFactory.TryGetTrustedBackgroundDirectory( + _playoutOptions, + out _); + var defaultAvailable = false; + if (rootAvailable && _playoutOptions is not null) + { + try + { + _ = PlayoutSceneCompositionFactory.CreateLegacyDefault( + _playoutOptions, + current.FadeDuration); + defaultAvailable = true; + } + catch (PlayoutConfigurationException) + { + // F2 remains available for another valid file in the trusted root. + } + } + PostMessage("background-status", new { requestId, @@ -263,9 +275,13 @@ public sealed partial class MainWindow }, fileName = enabled ? Path.GetFileName(current.BackgroundAssetPath) : string.Empty, canChange = CanChangeLegacyBackground(), - message = message ?? (enabled - ? "선택한 배경은 다음 PREPARE부터 적용됩니다." - : "배경 사용 안 함") + message = message ?? (!rootAvailable + ? "신뢰 배경 폴더를 찾을 수 없어 F2/F3 배경 기능을 잠갔습니다." + : enabled + ? "선택한 배경은 다음 PREPARE부터 적용됩니다." + : defaultAvailable + ? "배경 사용 안 함" + : "기본.vrv가 없습니다. F2로 신뢰 배경 폴더의 파일을 선택하세요.") }); } diff --git a/MainWindow.CloseConfirmation.cs b/MainWindow.CloseConfirmation.cs new file mode 100644 index 0000000..b849201 --- /dev/null +++ b/MainWindow.CloseConfirmation.cs @@ -0,0 +1,124 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using Microsoft.UI.Windowing; +using WinRT.Interop; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private AppWindow? _closeConfirmationAppWindow; + private int _closeConfirmationPending; + private int _closeConfirmed; + + private void EnsureCloseConfirmationAttached() + { + if (_closeConfirmationAppWindow is not null) + { + return; + } + + try + { + var windowHandle = WindowNative.GetWindowHandle(this); + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle); + var appWindow = AppWindow.GetFromWindowId(windowId); + appWindow.Closing += OnAppWindowClosing; + _closeConfirmationAppWindow = appWindow; + } + catch + { + // Root.Loaded retries the attachment after the native window has been created. + } + } + + private async void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args) + { + if (Volatile.Read(ref _closeConfirmed) != 0) + { + return; + } + + // AppWindow.Closing cannot be deferred. Cancel this close synchronously and issue a + // second, explicitly confirmed close only after the modal result is known. + args.Cancel = true; + if (Interlocked.CompareExchange(ref _closeConfirmationPending, 1, 0) != 0) + { + return; + } + + try + { + if (Root.XamlRoot is null) + { + return; + } + + var dialog = new ContentDialog + { + XamlRoot = Root.XamlRoot, + Title = "프로그램 종료", + Content = BuildCloseConfirmationMessage(), + PrimaryButtonText = "종료", + CloseButtonText = "취소", + DefaultButton = ContentDialogButton.Close + }; + + var result = await dialog.ShowAsync(); + if (result != ContentDialogResult.Primary) + { + return; + } + + Interlocked.Exchange(ref _closeConfirmed, 1); + Close(); + } + catch + { + // If the confirmation surface cannot be shown, fail closed and keep the app open. + } + finally + { + Interlocked.Exchange(ref _closeConfirmationPending, 0); + } + } + + private string BuildCloseConfirmationMessage() + { + var status = _playoutEngine?.Status; + var hasPreparedScene = !string.IsNullOrWhiteSpace(status?.PreparedSceneName); + var hasOnAirScene = !string.IsNullOrWhiteSpace(status?.OnAirSceneName); + var commandPending = Volatile.Read(ref _playoutCommandInFlight) != 0 || + status?.IsPlayCompletionPending == true; + var outcomeUnknown = Volatile.Read(ref _browserCorrelationQuarantined) != 0 || + status?.State == PlayoutConnectionState.OutcomeUnknown; + + if (outcomeUnknown) + { + return "송출 결과를 확정할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 종료하세요. " + + "종료 과정에서 TAKE OUT 또는 다른 송출 명령을 자동으로 반복하지 않습니다. 그래도 종료하시겠습니까?"; + } + + if (commandPending) + { + return "송출 명령 또는 callback 처리가 진행 중입니다. 결과를 확인한 뒤 종료하는 것이 안전합니다. " + + "그래도 종료하시겠습니까?"; + } + + if (hasOnAirScene || hasPreparedScene) + { + return "PREPARED 또는 PROGRAM 장면이 남아 있습니다. 필요한 경우 먼저 TAKE OUT을 실행하고 " + + "PGM을 확인하세요. 종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?"; + } + + return "프로그램을 종료하시겠습니까?"; + } + + private void DetachCloseConfirmation() + { + var appWindow = Interlocked.Exchange(ref _closeConfirmationAppWindow, null); + if (appWindow is not null) + { + appWindow.Closing -= OnAppWindowClosing; + } + } +} diff --git a/MainWindow.LegacyComparisonImport.cs b/MainWindow.LegacyComparisonImport.cs new file mode 100644 index 0000000..9962ce4 --- /dev/null +++ b/MainWindow.LegacyComparisonImport.cs @@ -0,0 +1,307 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private LegacyComparisonPairImportService? _legacyComparisonImportService; + private CancellationTokenSource? _legacyComparisonImportCancellation; + + private bool TryHandleLegacyComparisonImportRequest( + string? requestType, + JsonElement payload) + { + if (!string.Equals( + requestType, + "import-legacy-comparison-pairs", + StringComparison.Ordinal)) + { + return false; + } + + var requestId = string.Empty; + var hasSafeRequestId = payload.ValueKind == JsonValueKind.Object && + TryGetSafeToken( + payload, + "requestId", + MaximumPlayoutRequestIdLength, + out requestId); + if (!hasSafeRequestId || !HasOnlyProperties(payload, "requestId")) + { + PostLegacyComparisonImportError( + hasSafeRequestId ? requestId : string.Empty, + "INVALID_REQUEST", + "원본 비교쌍 가져오기 요청 형식이 올바르지 않습니다."); + return true; + } + + _ = HandleLegacyComparisonImportAsync(requestId); + return true; + } + + private async Task HandleLegacyComparisonImportAsync(string requestId) + { + var runtime = _databaseRuntime; + if (runtime is null) + { + PostLegacyComparisonImportError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 종목 마스터 DB를 사용할 수 없어 원본 비교쌍을 검증할 수 없습니다."); + return; + } + + LegacyComparisonPairImportService service; + try + { + service = _legacyComparisonImportService ??= + new LegacyComparisonPairImportService( + runtime.Executor, + TrustedLegacyComparisonFileSource.CreateDefault()); + } + catch (LegacyComparisonImportException exception) + { + PostLegacyComparisonImportFailure(requestId, exception.Failure); + return; + } + + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + var previous = Interlocked.Exchange( + ref _legacyComparisonImportCancellation, + cancellation); + CancelRequest(previous); + + var gateEntered = false; + try + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + PostLegacyComparisonImportErrorIfCurrent( + requestId, + cancellation, + "PLAYOUT_PRIORITY", + "송출 명령이 우선 처리 중이므로 가져오기를 실행하지 않았습니다."); + return; + } + + await _databaseActivityGate.WaitAsync(cancellation.Token); + gateEntered = true; + cancellation.Token.ThrowIfCancellationRequested(); + if (Volatile.Read(ref _playoutCommandInFlight) != 0) + { + PostLegacyComparisonImportErrorIfCurrent( + requestId, + cancellation, + "PLAYOUT_PRIORITY", + "송출 명령이 우선 처리 중이므로 가져오기를 실행하지 않았습니다."); + return; + } + + var result = await service.ImportAsync(cancellation.Token); + cancellation.Token.ThrowIfCancellationRequested(); + if (!IsCurrentLegacyComparisonImport(cancellation)) + { + return; + } + + PostMessage("legacy-comparison-import-results", new + { + requestId, + result.SourceSha256, + result.SourceRowCount, + result.RetrievedAt, + pairs = result.Pairs.Select(pair => new + { + pair.RowNumber, + first = ToLegacyComparisonImportWireTarget(pair.First), + second = ToLegacyComparisonImportWireTarget(pair.Second) + }) + }); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // A newer explicit import, navigation, playout priority, or shutdown + // owns the DB slot. Never retry or publish a stale result. + PostPlayoutPriorityLegacyComparisonImportCancellationIfCurrent( + requestId, + cancellation); + } + catch (DatabaseOperationException) when (cancellation.IsCancellationRequested) + { + // Providers may surface a native exception after cancellation. + PostPlayoutPriorityLegacyComparisonImportCancellationIfCurrent( + requestId, + cancellation); + } + catch (Exception) when (cancellation.IsCancellationRequested) + { + // Preserve correlation loss and shutdown semantics. + PostPlayoutPriorityLegacyComparisonImportCancellationIfCurrent( + requestId, + cancellation); + } + catch (LegacyComparisonImportException exception) + { + if (IsCurrentLegacyComparisonImport(cancellation)) + { + PostLegacyComparisonImportFailure(requestId, exception.Failure); + } + } + catch (DatabaseOperationException) + { + if (IsCurrentLegacyComparisonImport(cancellation)) + { + PostLegacyComparisonImportError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 종목 마스터 DB에서 원본 비교쌍을 검증하지 못했습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + } + catch + { + if (IsCurrentLegacyComparisonImport(cancellation)) + { + PostLegacyComparisonImportError( + requestId, + "IMPORT_FAILED", + "원본 비교쌍 가져오기를 안전하게 완료하지 못했습니다."); + } + } + finally + { + if (gateEntered) + { + _databaseActivityGate.Release(); + } + + Interlocked.CompareExchange( + ref _legacyComparisonImportCancellation, + null, + cancellation); + cancellation.Dispose(); + } + } + + private bool IsCurrentLegacyComparisonImport(CancellationTokenSource cancellation) => + !_lifetimeCancellation.IsCancellationRequested && + ReferenceEquals( + Volatile.Read(ref _legacyComparisonImportCancellation), + cancellation); + + private void PostLegacyComparisonImportErrorIfCurrent( + string requestId, + CancellationTokenSource cancellation, + string code, + string message) + { + if (IsCurrentLegacyComparisonImport(cancellation)) + { + PostLegacyComparisonImportError(requestId, code, message); + } + } + + private void PostPlayoutPriorityLegacyComparisonImportCancellationIfCurrent( + string requestId, + CancellationTokenSource cancellation) + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0 && + IsCurrentLegacyComparisonImport(cancellation)) + { + PostLegacyComparisonImportError( + requestId, + "PLAYOUT_PRIORITY", + "송출 명령을 우선 처리하기 위해 원본 비교쌍 가져오기를 취소했습니다. 자동 재시도하지 않습니다."); + } + } + + private void PostLegacyComparisonImportFailure( + string requestId, + LegacyComparisonImportFailure failure) + { + var (code, message) = failure switch + { + LegacyComparisonImportFailure.SourceUnavailable => ( + "SOURCE_UNAVAILABLE", + "고정된 원본 위치의 종목비교.dat를 안전하게 읽을 수 없습니다."), + LegacyComparisonImportFailure.SourceInvalid => ( + "SOURCE_INVALID", + "원본 종목비교.dat가 정확한 CP949 9필드 형식과 일치하지 않습니다."), + LegacyComparisonImportFailure.IdentityNotFound => ( + "IDENTITY_NOT_FOUND", + "원본 비교쌍의 종목 또는 시장 식별자를 현재 DB에서 정확히 확인하지 못했습니다."), + LegacyComparisonImportFailure.IdentityAmbiguous => ( + "IDENTITY_AMBIGUOUS", + "원본 비교쌍의 종목 식별자가 현재 DB에서 둘 이상 확인되어 가져오기를 중단했습니다."), + LegacyComparisonImportFailure.DatabaseDataInvalid => ( + "DATABASE_DATA_INVALID", + "현재 종목 마스터 조회 결과가 엄격한 식별자 형식과 일치하지 않습니다."), + _ => ( + "IMPORT_FAILED", + "원본 비교쌍 가져오기를 안전하게 완료하지 못했습니다.") + }; + PostLegacyComparisonImportError(requestId, code, message); + } + + private void PostLegacyComparisonImportError( + string requestId, + string code, + string message) + { + PostMessage("legacy-comparison-import-error", new + { + requestId, + code, + message + }); + } + + private static object ToLegacyComparisonImportWireTarget( + LegacyComparisonImportTarget target) => target.Kind switch + { + LegacyComparisonImportTargetKind.MarketTarget => new + { + kind = "market-target", + target = target.MarketTarget + }, + LegacyComparisonImportTargetKind.KrxStock => new + { + kind = "krx-stock", + market = target.Market, + stockName = target.StockName, + stockCode = target.StockCode + }, + LegacyComparisonImportTargetKind.NxtStock => new + { + kind = "nxt-stock", + market = target.Market, + stockName = target.StockName, + stockCode = target.StockCode + }, + LegacyComparisonImportTargetKind.WorldStock => new + { + kind = "world-stock", + inputName = target.InputName, + symbol = target.Symbol, + nation = target.Nation + }, + _ => throw new InvalidOperationException( + "The comparison import target kind is invalid.") + }; + + private void InvalidateLegacyComparisonImportRequest() + { + CancelRequest(Interlocked.Exchange( + ref _legacyComparisonImportCancellation, + null)); + } + + private void ShutdownLegacyComparisonImportRuntime() + { + InvalidateLegacyComparisonImportRequest(); + _legacyComparisonImportService = null; + } +} diff --git a/MainWindow.ManualLists.cs b/MainWindow.ManualLists.cs index e1be2c3..0c0459d 100644 --- a/MainWindow.ManualLists.cs +++ b/MainWindow.ManualLists.cs @@ -15,7 +15,9 @@ public sealed partial class MainWindow private readonly SemaphoreSlim _manualOperatorDataGate = new(1, 1); private S5025TrustedManualFileStore? _manualNetSellStore; private ViTrustedManualFileStore? _viManualListStore; + private LegacyManualOperatorDataImporter? _legacyManualOperatorDataImporter; private string? _manualOperatorDataInitializationError; + private string? _manualOperatorDataInitializationCode; private int _manualOperatorWriteInFlight; private int _manualOperatorWriteQuarantined; private int _manualOperatorBrowserGeneration; @@ -30,6 +32,7 @@ public sealed partial class MainWindow OperatorDataDirectoryEnvironmentVariable); if (!string.IsNullOrWhiteSpace(configuredDirectory)) { + _manualOperatorDataInitializationCode = "UNSAFE_CONFIGURATION"; _manualOperatorDataInitializationError = "외부 수동 데이터 경로는 TOCTOU 안전성을 보장할 수 없어 비활성화되었습니다. 앱 전용 로컬 운영 데이터 폴더를 사용하세요."; return; @@ -39,15 +42,37 @@ public sealed partial class MainWindow Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MBN_STOCK_WEBVIEW", "OperatorData")); + var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(directory); + if (!inspection.IsReady) + { + (_manualOperatorDataInitializationCode, + _manualOperatorDataInitializationError) = inspection.State switch + { + LegacyManualOperatorDataRuntimeState.InterruptedImport => ( + "IMPORT_INTERRUPTED", + "이전 원본 수동 데이터 가져오기가 중단되었습니다. FSell/VI 파일은 열지 않았습니다. 다시 시작하기 전에 진행 표식, 완료 표식, 데이터 파일 4개를 확인하세요."), + _ => ( + "IMPORTED_STATE_INVALID", + "원본 수동 데이터 완료 표식과 파일 4개가 검증된 한 세트를 이루지 않습니다. FSell/VI 파일은 열지 않았습니다. 다시 시작하기 전에 앱 전용 운영 데이터 폴더를 확인하세요.") + }; + return; + } + _manualNetSellStore = new S5025TrustedManualFileStore(directory); _viManualListStore = new ViTrustedManualFileStore(directory); + _legacyManualOperatorDataImporter = new LegacyManualOperatorDataImporter( + directory, + new FixedLegacyManualOperatorDataSource()); } catch { + _legacyManualOperatorDataImporter?.Dispose(); _viManualListStore?.Dispose(); _manualNetSellStore?.Dispose(); + _legacyManualOperatorDataImporter = null; _viManualListStore = null; _manualNetSellStore = null; + _manualOperatorDataInitializationCode = "INITIALIZATION_FAILED"; _manualOperatorDataInitializationError = "수동 송출 데이터 저장소를 초기화하지 못했습니다. 로컬 운영 데이터 설정을 확인하세요."; } @@ -75,6 +100,7 @@ public sealed partial class MainWindow { requestId, available = _manualNetSellStore is not null && _viManualListStore is not null, + code = _manualOperatorDataInitializationCode ?? "READY", writeQuarantined = IsManualOperatorWriteQuarantined(), message = IsManualOperatorWriteQuarantined() ? GetOperatorMutationQuarantineMessage( @@ -84,6 +110,120 @@ public sealed partial class MainWindow }); } + private async Task HandleLegacyManualOperatorImportAsync(JsonElement payload) + { + const string operation = "import-legacy-manual-data"; + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId") || + !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId)) + { + PostManualOperatorError( + SafeManualOperatorRequestId(payload), + operation, + "INVALID_REQUEST", + "올바르지 않은 원본 수동 데이터 가져오기 요청입니다.", + retryable: false, + outcomeUnknown: false); + return; + } + + var importer = _legacyManualOperatorDataImporter; + if (importer is null || _manualNetSellStore is null || _viManualListStore is null) + { + PostManualOperatorUnavailable(requestId, operation); + return; + } + + if (IsManualOperatorWriteQuarantined()) + { + PostManualOperatorQuarantine(requestId, operation); + return; + } + + if (!await TryEnterManualMutationGatesAsync(requestId, operation)) + { + return; + } + + var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration); + Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1); + try + { + var result = await importer.ImportAsync(_lifetimeCancellation.Token); + if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration)) + { + QuarantineManualOperatorWrites( + "원본 수동 데이터 가져오기 중 화면 상관관계가 사라졌습니다. 가져오기 marker와 4개 파일을 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요."); + return; + } + + PostMessage("legacy-manual-operator-import-result", new + { + requestId, + markerVersion = result.MarkerVersion, + sourceSha256 = result.SourceSha256, + importedAt = result.ImportedAt, + netSellRowCount = result.NetSellRowCount, + viItemCount = result.ViItemCount + }); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + // A pre-commit cancellation creates nothing. Once commit begins the + // importer completes or rolls back without observing cancellation. + } + catch (LegacyManualOperatorImportException exception) + { + if (exception.OutcomeUnknown) + { + QuarantineManualOperatorWrites( + "원본 수동 데이터 가져오기를 안전하게 되돌렸는지 확인할 수 없습니다. 4개 파일과 marker를 직접 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요."); + PostManualOperatorQuarantine(requestId, operation); + return; + } + + var (code, message) = exception.Failure switch + { + LegacyManualOperatorImportFailure.SourceUnavailable => ( + "SOURCE_UNAVAILABLE", + "원본 프로젝트의 FSell/VI 데이터 4개 파일을 찾을 수 없습니다."), + LegacyManualOperatorImportFailure.SourceInvalid => ( + "SOURCE_INVALID", + "원본 FSell/VI 데이터가 CP949 원본 형식 또는 크기 제한을 충족하지 않습니다."), + LegacyManualOperatorImportFailure.AlreadyImported => ( + "ALREADY_IMPORTED", + "이 앱 전용 폴더에는 이미 완료 marker가 있어 다시 가져오지 않습니다."), + LegacyManualOperatorImportFailure.DestinationNotEmpty => ( + "DESTINATION_NOT_EMPTY", + "앱 전용 폴더에 기존 FSell/VI 데이터가 있어 아무 파일도 덮어쓰지 않았습니다."), + LegacyManualOperatorImportFailure.InterruptedImport => ( + "IMPORT_INTERRUPTED", + "이전 가져오기의 진행 표식이 남아 있습니다. 완료 표식과 파일 4개를 확인하기 전에는 다시 시작하거나 반복하지 마세요."), + _ => ( + "IMPORT_FAILED", + "원본 수동 데이터 가져오기에 실패했고 생성된 파일은 되돌렸습니다. 자동으로 재시도하지 않습니다.") + }; + PostManualOperatorError( + requestId, + operation, + code, + message, + retryable: false, + outcomeUnknown: false); + } + catch + { + QuarantineManualOperatorWrites( + "원본 수동 데이터 가져오기 결과를 확인할 수 없습니다. 4개 파일과 marker를 직접 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요."); + PostManualOperatorQuarantine(requestId, operation); + } + finally + { + Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0); + ExitManualMutationGates(); + } + } + private async Task HandleManualNetSellReadAsync(JsonElement payload) { if (!TryParseAudienceRequest(payload, out var requestId, out var audience)) @@ -701,7 +841,7 @@ public sealed partial class MainWindow PostManualOperatorError( requestId, operation, - "DATA_UNAVAILABLE", + _manualOperatorDataInitializationCode ?? "DATA_UNAVAILABLE", _manualOperatorDataInitializationError ?? "수동 송출 데이터 저장소를 사용할 수 없습니다.", retryable: false, @@ -750,21 +890,24 @@ public sealed partial class MainWindow InvalidateManualOperatorBrowserRequests(); var netSellStore = Interlocked.Exchange(ref _manualNetSellStore, null); var viStore = Interlocked.Exchange(ref _viManualListStore, null); - if (netSellStore is null && viStore is null) + var importer = Interlocked.Exchange(ref _legacyManualOperatorDataImporter, null); + if (netSellStore is null && viStore is null && importer is null) { return; } - _ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore); + _ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore, importer); } private async Task DisposeManualOperatorStoresWhenIdleAsync( S5025TrustedManualFileStore? netSellStore, - ViTrustedManualFileStore? viStore) + ViTrustedManualFileStore? viStore, + LegacyManualOperatorDataImporter? importer) { await _manualOperatorDataGate.WaitAsync().ConfigureAwait(false); try { + importer?.Dispose(); viStore?.Dispose(); netSellStore?.Dispose(); } diff --git a/MainWindow.NamedComparisonRestore.cs b/MainWindow.NamedComparisonRestore.cs new file mode 100644 index 0000000..c1f47a5 --- /dev/null +++ b/MainWindow.NamedComparisonRestore.cs @@ -0,0 +1,133 @@ +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private LegacyNamedComparisonRestoreService? _namedComparisonRestoreService; + + private async Task PostNamedComparisonRestoreAsync( + string requestId, + IReadOnlyList items, + CancellationToken cancellationToken) + { + var candidates = items + .Select(static item => new LegacyNamedComparisonRestoreRow( + item.ItemIndex, + item.IsEnabled, + item.Selection.GroupCode, + item.Selection.Subject, + item.Selection.GraphicType, + item.Selection.Subtype, + item.Page?.ToString() ?? string.Empty, + item.Selection.DataCode)) + .Where(LegacyNamedComparisonRestoreService.IsCandidate) + .ToArray(); + if (candidates.Length == 0) + { + return; + } + + try + { + var runtime = _databaseRuntime; + if (runtime is null) + { + PostNamedComparisonRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 종목 마스터 DB를 사용할 수 없어 비교 행을 재검증하지 못했습니다."); + return; + } + + var service = _namedComparisonRestoreService ??= + new LegacyNamedComparisonRestoreService(runtime.Executor); + var result = await service.ResolveAsync(candidates, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PostMessage("named-comparison-restore-results", new + { + requestId, + result.RetrievedAt, + totalRowCount = result.Rows.Count, + rows = result.Rows.Select(ToNamedComparisonRestoreWireRow) + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0 && + !_lifetimeCancellation.IsCancellationRequested) + { + PostNamedComparisonRestoreError( + requestId, + "PLAYOUT_PRIORITY", + "송출 명령 우선 처리로 비교 행 재검증을 취소했습니다. 자동 재시도하지 않습니다."); + } + + throw; + } + catch (DatabaseOperationException) + { + PostNamedComparisonRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 종목 마스터 DB에서 비교 행을 재검증하지 못했습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostNamedComparisonRestoreError( + requestId, + "RESTORE_FAILED", + "저장된 비교 행을 현재 명시적 선택으로 안전하게 복원하지 못했습니다."); + } + } + + private static object ToNamedComparisonRestoreWireRow( + LegacyNamedComparisonRestoreOutcome outcome) + { + if (outcome.IsResolved) + { + return new + { + outcome.ItemIndex, + status = "resolved", + outcome.ActionId, + first = ToLegacyComparisonImportWireTarget(outcome.First!), + second = ToLegacyComparisonImportWireTarget(outcome.Second!) + }; + } + + return new + { + outcome.ItemIndex, + status = "failed", + failure = outcome.Failure switch + { + LegacyNamedComparisonRestoreFailure.InvalidRow => "INVALID_ROW", + LegacyNamedComparisonRestoreFailure.MissingMarketIdentity => + "MISSING_MARKET_IDENTITY", + LegacyNamedComparisonRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND", + LegacyNamedComparisonRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS", + LegacyNamedComparisonRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION", + LegacyNamedComparisonRestoreFailure.DatabaseDataInvalid => + "DATABASE_DATA_INVALID", + _ => "RESTORE_FAILED" + } + }; + } + + private void PostNamedComparisonRestoreError( + string requestId, + string code, + string message) + { + PostMessage("named-comparison-restore-error", new + { + requestId, + code, + message, + retryable = false + }); + } +} diff --git a/MainWindow.NamedForeignIndexCandleRestore.cs b/MainWindow.NamedForeignIndexCandleRestore.cs new file mode 100644 index 0000000..322544a --- /dev/null +++ b/MainWindow.NamedForeignIndexCandleRestore.cs @@ -0,0 +1,137 @@ +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private LegacyForeignIndexCandleRestoreService? _namedForeignIndexCandleRestoreService; + + private async Task PostNamedForeignIndexCandleRestoreAsync( + string requestId, + IReadOnlyList items, + CancellationToken cancellationToken) + { + var candidates = items + .Select(static item => new LegacyForeignIndexCandleRestoreRow( + item.ItemIndex, + item.IsEnabled, + item.Selection.GroupCode, + item.Selection.Subject, + item.Selection.GraphicType, + item.Selection.Subtype, + item.Page?.ToString() ?? string.Empty, + item.Selection.DataCode)) + .Where(LegacyForeignIndexCandleRestoreService.IsCandidate) + .ToArray(); + if (candidates.Length == 0) + { + return; + } + + try + { + var runtime = _databaseRuntime; + if (runtime is null) + { + PostNamedForeignIndexCandleRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 해외지수 master DB를 사용할 수 없어 캔들 행을 재검증하지 못했습니다."); + return; + } + + var service = _namedForeignIndexCandleRestoreService ??= + new LegacyForeignIndexCandleRestoreService(runtime.Executor); + var result = await service.ResolveAsync(candidates, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PostMessage("named-foreign-index-candle-restore-results", new + { + requestId, + result.RetrievedAt, + totalRowCount = result.Rows.Count, + rows = result.Rows.Select(ToNamedForeignIndexCandleRestoreWireRow) + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0 && + !_lifetimeCancellation.IsCancellationRequested) + { + PostNamedForeignIndexCandleRestoreError( + requestId, + "PLAYOUT_PRIORITY", + "송출 명령 우선 처리로 해외지수 캔들 재검증을 취소했습니다. 자동 재시도하지 않습니다."); + } + + throw; + } + catch (DatabaseOperationException) + { + PostNamedForeignIndexCandleRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 해외지수 master DB에서 캔들 행을 재검증하지 못했습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostNamedForeignIndexCandleRestoreError( + requestId, + "RESTORE_FAILED", + "저장된 해외지수 캔들 행을 안전한 s8010 선택으로 복원하지 못했습니다."); + } + } + + private static object ToNamedForeignIndexCandleRestoreWireRow( + LegacyForeignIndexCandleRestoreOutcome outcome) + { + if (outcome.IsResolved) + { + var identity = outcome.Identity!; + return new + { + outcome.ItemIndex, + status = "resolved", + identity.TargetKey, + identity.InputName, + identity.Symbol, + identity.NationCode, + identity.ForeignDomesticCode, + identity.SceneCode, + identity.ValueType, + identity.PeriodDays, + identity.CutCode + }; + } + + return new + { + outcome.ItemIndex, + status = "failed", + failure = outcome.Failure switch + { + LegacyForeignIndexCandleRestoreFailure.InvalidRow => "INVALID_ROW", + LegacyForeignIndexCandleRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND", + LegacyForeignIndexCandleRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS", + LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid => + "DATABASE_DATA_INVALID", + _ => "RESTORE_FAILED" + } + }; + } + + private void PostNamedForeignIndexCandleRestoreError( + string requestId, + string code, + string message) + { + PostMessage("named-foreign-index-candle-restore-error", new + { + requestId, + code, + message, + retryable = false + }); + } +} diff --git a/MainWindow.NamedNxtThemeRestore.cs b/MainWindow.NamedNxtThemeRestore.cs new file mode 100644 index 0000000..65d4be3 --- /dev/null +++ b/MainWindow.NamedNxtThemeRestore.cs @@ -0,0 +1,149 @@ +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private LegacyNamedNxtThemeRestoreService? _namedNxtThemeRestoreService; + + private async Task PostNamedNxtThemeRestoreAsync( + string requestId, + IReadOnlyList items, + CancellationToken cancellationToken) + { + var candidates = items + .Select(static item => new LegacyNamedNxtThemeRestoreRow( + item.ItemIndex, + item.IsEnabled, + item.Selection.GroupCode, + item.Selection.Subject, + item.Selection.GraphicType, + item.Selection.Subtype, + item.Page?.ToString() ?? string.Empty, + item.Selection.DataCode)) + .Where(LegacyNamedNxtThemeRestoreService.IsCandidate) + .ToArray(); + if (candidates.Length == 0) + { + return; + } + + try + { + var runtime = _databaseRuntime; + if (runtime is null) + { + PostNamedNxtThemeRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 MariaDB를 사용할 수 없어 저장된 NXT 테마를 재검증하지 못했습니다."); + return; + } + + var service = _namedNxtThemeRestoreService ??= + new LegacyNamedNxtThemeRestoreService(runtime.Executor); + var result = await service.ResolveAsync(candidates, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PostMessage("named-nxt-theme-restore-results", new + { + requestId, + result.RetrievedAt, + totalRowCount = result.Rows.Count, + rows = result.Rows.Select(ToNamedNxtThemeRestoreWireRow) + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (Volatile.Read(ref _playoutCommandInFlight) != 0 && + !_lifetimeCancellation.IsCancellationRequested) + { + PostNamedNxtThemeRestoreError( + requestId, + "PLAYOUT_PRIORITY", + "송출 명령 우선 처리로 NXT 테마 재검증을 취소했습니다. 자동 재시도하지 않습니다."); + } + + throw; + } + catch (DatabaseOperationException) + { + PostNamedNxtThemeRestoreError( + requestId, + "DATABASE_UNAVAILABLE", + "현재 MariaDB에서 NXT 테마를 재검증하지 못했습니다."); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } + catch + { + PostNamedNxtThemeRestoreError( + requestId, + "RESTORE_FAILED", + "저장된 NXT 테마 행을 안전한 현재 선택으로 복원하지 못했습니다."); + } + } + + private static object ToNamedNxtThemeRestoreWireRow( + LegacyNamedNxtThemeRestoreOutcome outcome) + { + if (outcome.IsResolved) + { + var identity = outcome.Identity!; + return new + { + outcome.ItemIndex, + status = "resolved", + identity.ActionId, + identity.BuilderKey, + identity.CutCode, + identity.PageSize, + identity.Sort, + session = identity.Session switch + { + ThemeSession.PreMarket => "PRE_MARKET", + ThemeSession.AfterMarket => "AFTER_MARKET", + _ => throw new InvalidOperationException("NXT theme session is invalid.") + }, + identity.ThemeTitle, + identity.StoredThemeCode, + identity.CurrentThemeCode, + identity.PreviewItemCount, + identity.LiveItemCount, + identity.PreviewIsTruncated, + identity.CodeWasRemapped + }; + } + + return new + { + outcome.ItemIndex, + status = "failed", + failure = outcome.Failure switch + { + LegacyNamedNxtThemeRestoreFailure.InvalidRow => "INVALID_ROW", + LegacyNamedNxtThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION", + LegacyNamedNxtThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND", + LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS", + LegacyNamedNxtThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY", + LegacyNamedNxtThemeRestoreFailure.LiveItemsEmpty => "LIVE_ITEMS_EMPTY", + LegacyNamedNxtThemeRestoreFailure.DatabaseDataInvalid => + "DATABASE_DATA_INVALID", + _ => "RESTORE_FAILED" + } + }; + } + + private void PostNamedNxtThemeRestoreError( + string requestId, + string code, + string message) + { + PostMessage("named-nxt-theme-restore-error", new + { + requestId, + code, + message, + retryable = false + }); + } +} diff --git a/MainWindow.NamedPlaylists.cs b/MainWindow.NamedPlaylists.cs index 02794fd..1bf3b2c 100644 --- a/MainWindow.NamedPlaylists.cs +++ b/MainWindow.NamedPlaylists.cs @@ -122,6 +122,21 @@ public sealed partial class MainWindow item.Selection.DataCode }) }); + await PostNamedComparisonRestoreAsync( + requestId, + result.Items, + cancellationToken); + await PostNamedForeignIndexCandleRestoreAsync( + requestId, + result.Items, + cancellationToken); + // Keep all named restore batches correlated to this load. NXT + // theme verification runs last because it can perform both an + // exact identity lookup and a bounded item preview per title. + await PostNamedNxtThemeRestoreAsync( + requestId, + result.Items, + cancellationToken); }); } @@ -779,7 +794,7 @@ public sealed partial class MainWindow !TryGetNamedPlaylistText( itemElement, "subject", - 256, + 4000, allowEmpty: true, allowCaret: false, out var subject) || diff --git a/MainWindow.NativeOperatorLog.cs b/MainWindow.NativeOperatorLog.cs new file mode 100644 index 0000000..dbd09cf --- /dev/null +++ b/MainWindow.NativeOperatorLog.cs @@ -0,0 +1,160 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW; + +public sealed partial class MainWindow +{ + private readonly INativeOperatorLogWriter _nativeOperatorLogWriter = + new NativeOperatorLogWriter(); + private readonly NativeOperatorLogTransitionTracker _nativeOperatorTransitionTracker = + new(); + private readonly NativeOperatorCommandLogTracker _nativeOperatorCommandTracker = + new(); + private int _nativeStartupRecorded; + private int _nativeShutdownRecorded; + + private void LogNativeOperatorStartup() + { + if (Interlocked.Exchange(ref _nativeStartupRecorded, 1) == 0) + { + TryWriteNativeOperatorRecord(NativeOperatorLogRecord.ForStartup()); + } + } + + private void LogNativeOperatorShutdown() + { + if (Interlocked.Exchange(ref _nativeShutdownRecorded, 1) == 0) + { + TryWriteNativeOperatorRecord(NativeOperatorLogRecord.ForShutdown()); + } + } + + private void ObserveNativeDatabaseState( + DataSourceKind source, + DatabaseHealthState state) + { + if (_nativeOperatorTransitionTracker.TryObserveDatabaseState( + source, + state, + out var record)) + { + TryWriteNativeOperatorRecord(record); + } + } + + private void ObserveNativeTornadoState(PlayoutConnectionState state) + { + if (_nativeOperatorTransitionTracker.TryObserveTornadoState(state, out var record)) + { + TryWriteNativeOperatorRecord(record); + } + } + + private void LogNativePlayoutCommandRequested(string command) + { + if (TryMapNativePlayoutCommand(command, out var mapped) && + _nativeOperatorCommandTracker.TryBegin(mapped, out var record)) + { + TryWriteNativeOperatorRecord(record); + } + } + + private void LogNativePlayoutCommandCompleted( + string command, + PlayoutResultCode result) + { + if (!TryMapNativePlayoutCommand(command, out var mapped)) + { + return; + } + + if (result is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut || + !TryMapNativePlayoutCompletion(result, out var completion)) + { + if (_nativeOperatorCommandTracker.TryMarkOutcomeUnknown(mapped, out var unknownRecord)) + { + TryWriteNativeOperatorRecord(unknownRecord); + } + return; + } + + if (_nativeOperatorCommandTracker.TryComplete(mapped, completion, out var record)) + { + TryWriteNativeOperatorRecord(record); + } + } + + private void LogNativePlayoutCommandCompleted( + string command, + NativeOperatorPlayoutCompletion completion) + { + if (TryMapNativePlayoutCommand(command, out var mapped) && + _nativeOperatorCommandTracker.TryComplete(mapped, completion, out var record)) + { + TryWriteNativeOperatorRecord(record); + } + } + + private void LogNativePlayoutOutcomeUnknown(string command) + { + if (TryMapNativePlayoutCommand(command, out var mapped) && + _nativeOperatorCommandTracker.TryMarkOutcomeUnknown(mapped, out var record)) + { + TryWriteNativeOperatorRecord(record); + } + } + + private void LogCurrentNativePlayoutOutcomeUnknown() + { + if (_nativeOperatorCommandTracker.TryMarkCurrentOutcomeUnknown(out var record)) + { + TryWriteNativeOperatorRecord(record); + } + } + + private void TryWriteNativeOperatorRecord(NativeOperatorLogRecord record) + { + try + { + _ = _nativeOperatorLogWriter.TryWrite(record); + } + catch + { + // Logging is strictly best effort and cannot affect operator commands. + } + } + + private static bool TryMapNativePlayoutCommand( + string command, + out NativeOperatorPlayoutCommand mapped) + { + mapped = command switch + { + "prepare" => NativeOperatorPlayoutCommand.Prepare, + "take-in" => NativeOperatorPlayoutCommand.TakeIn, + "next" => NativeOperatorPlayoutCommand.Next, + "take-out" => NativeOperatorPlayoutCommand.TakeOut, + _ => default + }; + return mapped != default; + } + + private static bool TryMapNativePlayoutCompletion( + PlayoutResultCode result, + out NativeOperatorPlayoutCompletion mapped) + { + mapped = result switch + { + PlayoutResultCode.Success => NativeOperatorPlayoutCompletion.Succeeded, + PlayoutResultCode.Rejected => NativeOperatorPlayoutCompletion.Rejected, + PlayoutResultCode.Cancelled => NativeOperatorPlayoutCompletion.Cancelled, + PlayoutResultCode.Unavailable => NativeOperatorPlayoutCompletion.Unavailable, + PlayoutResultCode.Failed => NativeOperatorPlayoutCompletion.Failed, + _ => default + }; + return mapped != default; + } +} diff --git a/MainWindow.OperatorCatalogs.cs b/MainWindow.OperatorCatalogs.cs index 5cadd4d..c8c2443 100644 --- a/MainWindow.OperatorCatalogs.cs +++ b/MainWindow.OperatorCatalogs.cs @@ -17,6 +17,7 @@ public sealed partial class MainWindow new(StringComparer.Ordinal); private IThemeCatalogPersistenceService? _themeCatalogPersistenceService; private IExpertCatalogPersistenceService? _expertCatalogPersistenceService; + private IStockMasterIdentityValidationService? _stockMasterIdentityValidationService; private IOperatorCatalogSchemaValidationService? _operatorCatalogSchemaValidationService; private CancellationTokenSource? _operatorCatalogWriteCancellation; private string? _operatorCatalogInitializationError; @@ -56,6 +57,8 @@ public sealed partial class MainWindow _expertCatalogPersistenceService = new LegacyExpertCatalogPersistenceService( runtime.Executor, mutationExecutor); + _stockMasterIdentityValidationService = + new LegacyStockMasterIdentityValidationService(runtime.Executor); _operatorCatalogSchemaValidationService = new LegacyOperatorCatalogSchemaValidationService(runtime.Executor); } @@ -63,6 +66,7 @@ public sealed partial class MainWindow { _themeCatalogPersistenceService = null; _expertCatalogPersistenceService = null; + _stockMasterIdentityValidationService = null; _operatorCatalogSchemaValidationService = null; _operatorCatalogInitializationError = "The theme/expert database boundary could not be initialized."; @@ -376,12 +380,19 @@ public sealed partial class MainWindow InitializeOperatorCatalogRuntime(); var service = _themeCatalogPersistenceService; + var identityValidation = _stockMasterIdentityValidationService; await ExecuteOperatorCatalogWriteAsync( requestId, "theme", "create", definition.ThemeCode, - service?.CanMutate == true, + service?.CanMutate == true && identityValidation is not null, + async cancellationToken => + { + _ = await identityValidation!.ValidateThemeItemsAsync( + definition.Items, + cancellationToken); + }, cancellationToken => service!.CreateAsync(definition, cancellationToken)); } @@ -403,12 +414,19 @@ public sealed partial class MainWindow InitializeOperatorCatalogRuntime(); var service = _themeCatalogPersistenceService; + var identityValidation = _stockMasterIdentityValidationService; await ExecuteOperatorCatalogWriteAsync( requestId, "theme", "replace", expectedIdentity.ThemeCode, - service?.CanMutate == true, + service?.CanMutate == true && identityValidation is not null, + async cancellationToken => + { + _ = await identityValidation!.ValidateThemeItemsAsync( + items, + cancellationToken); + }, cancellationToken => service!.ReplaceAsync( expectedIdentity, newTitle, @@ -438,6 +456,7 @@ public sealed partial class MainWindow "delete", identity.ThemeCode, service?.CanMutate == true, + validationBody: null, cancellationToken => service!.DeleteAsync(identity, cancellationToken)); } @@ -457,12 +476,19 @@ public sealed partial class MainWindow InitializeOperatorCatalogRuntime(); var service = _expertCatalogPersistenceService; + var identityValidation = _stockMasterIdentityValidationService; await ExecuteOperatorCatalogWriteAsync( requestId, "expert", "create", definition.Identity.ExpertCode, - service?.CanMutate == true, + service?.CanMutate == true && identityValidation is not null, + async cancellationToken => + { + _ = await identityValidation!.ValidateExpertRecommendationsAsync( + definition.Recommendations, + cancellationToken); + }, cancellationToken => service!.CreateAsync(definition, cancellationToken)); } @@ -484,12 +510,19 @@ public sealed partial class MainWindow InitializeOperatorCatalogRuntime(); var service = _expertCatalogPersistenceService; + var identityValidation = _stockMasterIdentityValidationService; await ExecuteOperatorCatalogWriteAsync( requestId, "expert", "replace", expectedIdentity.ExpertCode, - service?.CanMutate == true, + service?.CanMutate == true && identityValidation is not null, + async cancellationToken => + { + _ = await identityValidation!.ValidateExpertRecommendationsAsync( + recommendations, + cancellationToken); + }, cancellationToken => service!.ReplaceAsync( expectedIdentity, newName, @@ -519,6 +552,7 @@ public sealed partial class MainWindow "delete", identity.ExpertCode, service?.CanMutate == true, + validationBody: null, cancellationToken => service!.DeleteAsync(identity, cancellationToken)); } @@ -672,6 +706,7 @@ public sealed partial class MainWindow string operation, string identityCode, bool canMutate, + Func? validationBody, Func> operationBody) { if (!canMutate) @@ -764,6 +799,30 @@ public sealed partial class MainWindow return; } + if (validationBody is not null) + { + await validationBody(requestToken); + requestToken.ThrowIfCancellationRequested(); + if (!CanStartOperatorCatalogWrite()) + { + PostOperatorCatalogPlayoutActive( + requestId, + entity, + operation, + identityCode); + return; + } + + if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration) || + !ReferenceEquals( + Volatile.Read(ref _operatorCatalogWriteCancellation), + requestCancellation)) + { + requestCancellation.Cancel(); + return; + } + } + mutationDispatched = true; Interlocked.Exchange(ref _operatorCatalogWriteInFlight, 1); var receipt = await operationBody(requestToken); @@ -806,6 +865,31 @@ public sealed partial class MainWindow outcomeUnknown: false, requestCancellation); } + catch (StockMasterIdentityDataException) when (!mutationDispatched) + { + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + "INVALID_DATA", + "Every catalog stock must be selected from one current, unambiguous stock-master identity.", + outcomeUnknown: false, + requestCancellation); + } + catch (DatabaseOperationException) when (!mutationDispatched) + { + PostOperatorCatalogWriteErrorIfCurrent( + requestId, + entity, + operation, + identityCode, + "DATABASE_ERROR", + "The stock-master identity verification failed before the database transaction began.", + outcomeUnknown: false, + requestCancellation); + _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); + } catch (OperatorCatalogMutationException exception) { if (exception.OutcomeUnknown) @@ -1152,10 +1236,12 @@ public sealed partial class MainWindow private bool CanMutateThemeCatalog() => _themeCatalogPersistenceService?.CanMutate == true && + _stockMasterIdentityValidationService is not null && !IsOperatorCatalogWriteQuarantined(); private bool CanMutateExpertCatalog() => _expertCatalogPersistenceService?.CanMutate == true && + _stockMasterIdentityValidationService is not null && !IsOperatorCatalogWriteQuarantined(); private bool IsOperatorCatalogWriteQuarantined() => @@ -1224,6 +1310,7 @@ public sealed partial class MainWindow CancelRequest(Interlocked.Exchange(ref _operatorCatalogWriteCancellation, null)); _themeCatalogPersistenceService = null; _expertCatalogPersistenceService = null; + _stockMasterIdentityValidationService = null; _operatorCatalogSchemaValidationService = null; } @@ -1378,7 +1465,7 @@ public sealed partial class MainWindow market == ThemeMarket.Krx && session != ThemeSession.NotApplicable || market == ThemeMarket.Nxt && session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket) || - !TryGetOperatorCatalogCode(element, "themeCode", 8, out var code) || + !TryGetExistingThemeCatalogCode(element, "themeCode", out var code) || !TryGetOperatorCatalogText(element, "themeTitle", 128, out var title) || market == ThemeMarket.Nxt && title.Contains("(NXT)", StringComparison.OrdinalIgnoreCase)) { @@ -1612,6 +1699,15 @@ public sealed partial class MainWindow return value.Length == length && value.All(char.IsAsciiDigit); } + private static bool TryGetExistingThemeCatalogCode( + JsonElement payload, + string propertyName, + out string value) + { + value = GetString(payload, propertyName) ?? string.Empty; + return value.Length is >= 3 and <= 8 && value.All(char.IsAsciiDigit); + } + private static bool TryGetOperatorCatalogText( JsonElement payload, string propertyName, diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs index ee73d60..09a05aa 100644 --- a/MainWindow.Playout.cs +++ b/MainWindow.Playout.cs @@ -2,6 +2,7 @@ using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MBN_STOCK_WEBVIEW.Infrastructure; +using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics; using MBN_STOCK_WEBVIEW.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; @@ -18,7 +19,6 @@ public sealed partial class MainWindow private LegacyPlayoutWorkflow? _playoutWorkflow; private PlayoutOptions? _playoutOptions; private MutableLegacySceneCueCompositionOptionsSource? _legacyCompositionOptionsSource; - private LegacySceneCueCompositionOptions? _lastEnabledLegacyComposition; private string? _playoutInitializationError; private int _playoutCommandInFlight; private int _playoutShutdownStarted; @@ -38,12 +38,9 @@ public sealed partial class MainWindow _playoutOptions = options; _legacyCompositionOptionsSource = new MutableLegacySceneCueCompositionOptionsSource(compositionOptions); - _lastEnabledLegacyComposition = compositionOptions.BackgroundKind == - LegacySceneBackgroundKind.None - ? null - : compositionOptions; _playoutEngine = PlayoutEngineFactory.Create(options); _playoutEngine.StatusChanged += OnPlayoutStatusChanged; + ObserveNativeTornadoState(_playoutEngine.Status.State); if (_databaseRuntime is not null) { _playoutWorkflow = LegacySceneRuntimeFactory.Create( @@ -56,6 +53,7 @@ public sealed partial class MainWindow } catch (Exception exception) { + ObserveNativeTornadoState(PlayoutConnectionState.Faulted); _playoutInitializationError = exception is PlayoutConfigurationException ? exception.Message : "Tornado 송출 어댑터를 초기화하지 못했습니다."; @@ -89,6 +87,7 @@ public sealed partial class MainWindow private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args) { + ObserveNativeTornadoState(args.Current.State); _playoutWorkflow?.ObserveEngineStatus(args.Current); if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) && string.IsNullOrWhiteSpace(args.Current.OnAirSceneName)) @@ -186,9 +185,11 @@ public sealed partial class MainWindow var databaseActivityEntered = false; try { + LogNativePlayoutCommandRequested(request.Command); Interlocked.Exchange(ref _playoutCommandInFlight, 1); if (IsBrowserCorrelationQuarantined()) { + LogNativePlayoutOutcomeUnknown(request.Command); PostPlayoutCommandError( request.RequestId, request.Command, @@ -202,6 +203,9 @@ public sealed partial class MainWindow var refreshBeforeCommand = GetLegacyRefreshStatus(); if (refreshBeforeCommand.IsFaulted && request.Command != "take-out") { + LogNativePlayoutCommandCompleted( + request.Command, + NativeOperatorPlayoutCompletion.Rejected); PostPlayoutCommandError( request.RequestId, request.Command, @@ -222,6 +226,9 @@ public sealed partial class MainWindow // gates are released, leaving an emergency TAKE OUT actionable. if (request.Command == "next" && engine.Status.IsPlayCompletionPending) { + LogNativePlayoutCommandCompleted( + request.Command, + NativeOperatorPlayoutCompletion.Rejected); PostPlayoutCommandError( request.RequestId, request.Command, @@ -238,6 +245,7 @@ public sealed partial class MainWindow databaseActivityEntered = true; if (IsBrowserCorrelationQuarantined()) { + LogNativePlayoutOutcomeUnknown(request.Command); PostPlayoutCommandError( request.RequestId, request.Command, @@ -252,6 +260,7 @@ public sealed partial class MainWindow workflow, request, _lifetimeCancellation.Token); + LogNativePlayoutCommandCompleted(request.Command, result.Code); var status = engine.Status; var workflowState = workflow.State; @@ -312,6 +321,9 @@ public sealed partial class MainWindow } catch (OperationCanceledException) { + LogNativePlayoutCommandCompleted( + request.Command, + NativeOperatorPlayoutCompletion.Cancelled); PostPlayoutCommandError( request.RequestId, request.Command, @@ -322,6 +334,9 @@ public sealed partial class MainWindow } catch (DatabaseOperationException exception) { + LogNativePlayoutCommandCompleted( + request.Command, + NativeOperatorPlayoutCompletion.Unavailable); PostPlayoutCommandError( request.RequestId, request.Command, @@ -333,6 +348,9 @@ public sealed partial class MainWindow catch (Exception exception) when ( exception is LegacySceneDataException or ArgumentException) { + LogNativePlayoutCommandCompleted( + request.Command, + NativeOperatorPlayoutCompletion.Rejected); PostPlayoutCommandError( request.RequestId, request.Command, @@ -343,6 +361,7 @@ public sealed partial class MainWindow } catch { + LogNativePlayoutOutcomeUnknown(request.Command); PostPlayoutCommandError( request.RequestId, request.Command, @@ -389,6 +408,7 @@ public sealed partial class MainWindow // Latch before the first await so a reload or a second WebView message can // never race ahead of native quarantine. The latch is process-lifetime only. + LogCurrentNativePlayoutOutcomeUnknown(); StopLegacyRefreshLoop(); QueuePlayoutStatus(); diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index d514395..8c8a2c6 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -54,6 +54,8 @@ public sealed partial class MainWindow : Window public MainWindow() { InitializeComponent(); + LogNativeOperatorStartup(); + EnsureCloseConfirmationAttached(); InitializeManualOperatorDataRuntime(); InitializeDatabaseRuntime(); InitializePlayoutRuntime(); @@ -100,6 +102,7 @@ public sealed partial class MainWindow : Window private async void OnRootLoaded(object sender, RoutedEventArgs e) { Root.Loaded -= OnRootLoaded; + EnsureCloseConfirmationAttached(); ResizeWindow(); await InitializeWebViewAsync(); @@ -230,6 +233,11 @@ public sealed partial class MainWindow : Window { return; } + if (request is not null && + TryHandleLegacyComparisonImportRequest(request.Type, request.Payload)) + { + return; + } switch (request?.Type) { @@ -371,6 +379,9 @@ public sealed partial class MainWindow : Window case "request-manual-operator-data-status": HandleManualOperatorStatusRequest(request.Payload); break; + case "import-legacy-manual-operator-data": + _ = HandleLegacyManualOperatorImportAsync(request.Payload); + break; case "request-manual-net-sell-data": _ = HandleManualNetSellReadAsync(request.Payload); break; @@ -416,16 +427,6 @@ public sealed partial class MainWindow : Window retryable: false, outcomeUnknown: false); break; - case "open-external" when request.Payload.ValueKind == JsonValueKind.Object: - if (request.Payload.TryGetProperty("url", out var value) && - Uri.TryCreate(value.GetString(), UriKind.Absolute, out var uri)) - { - _ = OpenExternalUriAsync(uri); - } - break; - case "reload": - ReloadBrowserSafely(); - break; } } catch (JsonException exception) @@ -2262,6 +2263,8 @@ public sealed partial class MainWindow : Window if (_databaseRuntime is null) { var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."; + ObserveNativeDatabaseState(DataSourceKind.Oracle, DatabaseHealthState.NotConfigured); + ObserveNativeDatabaseState(DataSourceKind.MariaDb, DatabaseHealthState.NotConfigured); PostDatabaseStatus(new[] { CreateUnavailableStatus("oracle", message), @@ -2274,6 +2277,10 @@ public sealed partial class MainWindow : Window { var statuses = await _databaseRuntime.HealthService .CheckAllAsync(cancellationToken); + foreach (var status in statuses) + { + ObserveNativeDatabaseState(status.Source, status.State); + } PostDatabaseStatus(statuses.Select(status => new { @@ -2292,6 +2299,8 @@ public sealed partial class MainWindow : Window } catch { + ObserveNativeDatabaseState(DataSourceKind.Oracle, DatabaseHealthState.Unhealthy); + ObserveNativeDatabaseState(DataSourceKind.MariaDb, DatabaseHealthState.Unhealthy); PostDatabaseStatus(new[] { CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."), @@ -2335,6 +2344,7 @@ public sealed partial class MainWindow : Window CancelRequest(Volatile.Read(ref _namedPlaylistReadCancellation)); CancelRequest(Volatile.Read(ref _namedPlaylistWriteCancellation)); CancelRequest(Volatile.Read(ref _playlistPagePlanCancellation)); + CancelRequest(Volatile.Read(ref _legacyComparisonImportCancellation)); CancelOperatorCatalogReadsForPlayout(); CancelManualFinancialReadsForPlayout(); } @@ -2579,6 +2589,7 @@ public sealed partial class MainWindow : Window InvalidateOperatorCatalogBrowserRequests(); InvalidateManualOperatorBrowserRequests(); InvalidateManualFinancialBrowserRequests(); + InvalidateLegacyComparisonImportRequest(); if (Volatile.Read(ref _playoutCommandInFlight) != 0) { // Navigation or a renderer failure destroys the JavaScript request @@ -2589,6 +2600,8 @@ public sealed partial class MainWindow : Window private void OnClosed(object sender, WindowEventArgs args) { + DetachCloseConfirmation(); + LogNativeOperatorShutdown(); var wasWebViewReady = _webViewReady; _webViewReady = false; _lifetimeCancellation.Cancel(); @@ -2637,6 +2650,7 @@ public sealed partial class MainWindow : Window ShutdownOperatorCatalogRuntime(); ShutdownManualFinancialRuntime(); ShutdownManualOperatorDataRuntime(); + ShutdownLegacyComparisonImportRuntime(); DataQueryExecutor.Reset(); if (!wasWebViewReady) diff --git a/Package.appxmanifest b/Package.appxmanifest index b1d5f9e..51959bb 100644 --- a/Package.appxmanifest +++ b/Package.appxmanifest @@ -10,7 +10,7 @@ + Version="1.0.3.0" /> diff --git a/README.md b/README.md index 4146add..3118c83 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - Windows x64 전용 - 단일 프로젝트 MSIX -## 현재 구현 범위 +## 현재 화면/계약 구현 범위 - WinUI 3 네이티브 창과 WebView2 간 JSON 메시지 브리지 - FarPoint 플레이리스트를 대체하는 Web UI @@ -30,14 +30,14 @@ - 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트 - MSIX 패키지 매니페스트와 x64 게시 프로필 -Oracle/MariaDB 연결 계층과 Tornado/K3D 어댑터는 구현·검증을 완료했습니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. +Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다. -35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 운영 화면은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 운영 DB write와 각 화면의 실제 PGM 확인은 별도 승인 회차 전까지 완료 증거로 간주하지 않습니다. +35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 다만 PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원, GraphE·FSell·VI를 포함한 수동 항목의 named save→fresh restore, ThemeA/EList의 실제 운영 identity, 구현된 `종목비교.dat` importer의 운영 DB 연결 1회 실행, FSell/VI 원본 파일의 one-time import는 아직 운영 동등성 완료로 판정하지 않습니다. 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인도 미검증입니다. ## 최종 검증 기준선 (2026-07-12) - 현재 마이그레이션 소스에서 Core 1,116개, Infrastructure 125개, Playout 374개가 Debug/Release x64에서 각각 모두 통과했다. 구성별 1,615개, 전체 3,230개이며 실패·skip·경고는 0건이다. Web 구문 19개와 테스트 235/235, 원본 scene 기준선 35/35도 통과했다. -- 원본 `Cuts` 기준으로 도달 가능한 builder 34개와 active alias 45개의 컷 커버리지를 다시 검사했고 missing/unsafe는 0건이다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색 63건, 필수 scene 33/33과 전체 query 55/55를 통과했다. `s5025`는 승인된 외부 수동 파일이 필요한 전제조건으로 분리된다. +- 원본 `Cuts` 기준 active alias 45개의 `.t2s` 존재성은 missing/unsafe 0건이었다. 이 결과는 종속 자산 준비 상태를 뜻하지 않는다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색 63건, 필수 loader 33/33과 전체 query 55/55를 통과했지만 DB→DTO→mutation 검증 범위다. `s5025`는 승인된 외부 수동 파일이 필요하고, `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수용 13개 영상은 현재 승인 Cuts에 없어 외부자산 필요 상태다. - Visual Studio 2026 MSBuild `18.7.8.30822`로 Debug x64와 signed Release x64 MSIX 빌드를 완료했다. `1.0.2.0` 패키지 SHA-256은 `4A4ED867D16B75C1C82CB55DA5111E0502573F486B8B897889069843D9DF5072`이며 서명, 281개 패키지 항목, Web 24개 byte-for-byte 일치, 금지 자산·비밀값 0건을 확인했다. - 설치본 `Wickedness.MBNStockWebView_1.0.2.0_x64__qbv3jkvsn3aj0`은 실제 package context에서 WebView2 프로세스 6개와 함께 실행된다. 전체 운영 UI, Oracle/MariaDB 연결, `삼성` 63건 검색, 기본 `DryRun`, 응답 정상과 패키지 생성 이후 오류 이벤트 0건을 확인했다. - 실제 Tornado2 검증은 고정 계획 `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19`의 `MBNWEB-20260711-H` 한 회차로 제한했다. PGM에서 `5001`과 `5074` page 1/page 2를 확인한 뒤 TAKE OUT의 검은 화면을 확인했다. Network Monitoring의 정확한 합계는 `SCENE_PREPARE 5001 = 3`, `SCENE_PREPARE 5074 = 2`, `PLAY = 4`, `SCENE_PLAYED = 4`, `FAILURE = 0`, `ERROR = 0`, retry `0`이며 최종 `OutcomeUnknown = false`다. diff --git a/Web/app.js b/Web/app.js index ffabbf3..2406966 100644 --- a/Web/app.js +++ b/Web/app.js @@ -3,14 +3,20 @@ const playoutSafety = globalThis.MbnPlayoutSafety; if (!playoutSafety) throw new Error("Playout safety module is unavailable."); + const marketNavReorder = globalThis.MbnMarketNavReorder; + if (!marketNavReorder) throw new Error("Market-nav reorder module is unavailable."); const operatorWorkflow = globalThis.MbnOperatorWorkflow; if (!operatorWorkflow) throw new Error("Operator workflow module is unavailable."); + const legacyBatchSelectionWorkflow = globalThis.MbnLegacyBatchSelectionWorkflow; + if (!legacyBatchSelectionWorkflow) throw new Error("Legacy batch-selection workflow module is unavailable."); const fixedWorkflow = globalThis.MbnLegacyFixedWorkflow; if (!fixedWorkflow) throw new Error("Legacy fixed workflow module is unavailable."); const industryWorkflow = globalThis.MbnIndustryWorkflow; if (!industryWorkflow) throw new Error("Legacy industry workflow module is unavailable."); const comparisonWorkflow = globalThis.MbnComparisonWorkflow; if (!comparisonWorkflow) throw new Error("Legacy comparison workflow module is unavailable."); + const comparisonImportWorkflow = globalThis.MbnComparisonImportWorkflow; + if (!comparisonImportWorkflow) throw new Error("Legacy comparison import workflow module is unavailable."); const themeWorkflow = globalThis.MbnThemeWorkflow; if (!themeWorkflow) throw new Error("Legacy theme workflow module is unavailable."); const overseasWorkflow = globalThis.MbnOverseasWorkflow; @@ -27,6 +33,14 @@ if (!manualFinancialWorkflow) throw new Error("Manual financial workflow module is unavailable."); const namedManualRestoreWorkflow = globalThis.MbnNamedManualRestoreWorkflow; if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable."); + const namedComparisonRestoreWorkflow = globalThis.MbnNamedComparisonRestoreWorkflow; + if (!namedComparisonRestoreWorkflow) throw new Error("Named comparison restore workflow module is unavailable."); + const foreignIndexCandleRestoreWorkflow = globalThis.MbnLegacyForeignIndexCandleWorkflow; + if (!foreignIndexCandleRestoreWorkflow) throw new Error("Legacy foreign-index candle restore workflow module is unavailable."); + const namedNxtThemeRestoreWorkflow = globalThis.MbnNamedNxtThemeRestoreWorkflow; + if (!namedNxtThemeRestoreWorkflow) throw new Error("Named NXT-theme restore workflow module is unavailable."); + const legacyNamedRowWorkflow = globalThis.MbnLegacyNamedRowWorkflow; + if (!legacyNamedRowWorkflow) throw new Error("Legacy named-row workflow module is unavailable."); const operatorCatalogWorkflow = globalThis.MbnOperatorCatalogWorkflow; if (!operatorCatalogWorkflow) throw new Error("Operator catalog workflow module is unavailable."); const manualListsUiModule = globalThis.MbnManualListsUi; @@ -86,7 +100,7 @@ .flatMap(sceneAliases)); if (reachableAliases.size !== 45) throw new Error("Legacy scene catalog must expose all 45 active aliases."); const operatorInputBuilderKeys = new Set([ - "s5001", "s5006", "s5011", "s5026", "s5029", "s5037", "s5074", + "s5001", "s5006", "s5011", "s5025", "s5026", "s5029", "s5037", "s5074", "s5076", "s5077", "s5079", "s5080", "s5081", "s5086", "s5087", "s5088", "s8003", "s8010" ]); @@ -166,10 +180,15 @@ const storageKey = "mbn-stock-webview.playlist.v1"; const namedPlaylistBackupStorageKey = "mbn-stock-webview.playlist.before-db-load.v1"; const comparisonStorageKey = "mbn-stock-webview.comparison-pairs.v1"; + // Read only while migrating the former two-key layout. All new commits use + // the single comparisonStorageKey envelope so pair data and import state + // cannot be split by a renderer/process crash. + const comparisonImportMarkerStorageKey = "mbn-stock-webview.comparison-pairs.legacy-import.v1"; const state = { - activeMarket: "dashboard", + activeMarket: "overseas", activeCategory: "all", stockWorkflowCollapsed: false, + stockCutSelection: legacyBatchSelectionWorkflow.createSelection(operatorWorkflow.stockCuts.length), fixedWorkflowCollapsed: false, industryWorkflowCollapsed: false, fixedSearch: "", @@ -203,6 +222,9 @@ loadedDefinition: null, guardByEntryId: new Map(), manualRestoreFinalized: true, + comparisonRestorePending: null, + foreignIndexCandleRestorePending: null, + nxtThemeRestorePending: null, selectAfterRefresh: "" }, playout: { @@ -303,6 +325,14 @@ draftSecond: null, pairList: comparisonWorkflow.deleteAllPairs(), selectedPairId: null, + legacyImport: { + requestId: null, + status: "idle", + marker: null, + recoveryBlocked: false, + error: "", + timeoutId: null + }, domestic: { requestId: null, query: "", @@ -376,6 +406,7 @@ status: "idle", results: [], selected: null, + sortDirection: null, totalRowCount: 0, truncated: false, error: "", @@ -407,6 +438,9 @@ selectedStockMeta: document.querySelector("#selectedStockMeta"), stockMa5: document.querySelector("#stockMa5"), stockMa20: document.querySelector("#stockMa20"), stockCutList: document.querySelector("#stockCutList"), + stockCutSelectionCount: document.querySelector("#stockCutSelectionCount"), + stockCutBatchAdd: document.querySelector("#stockCutBatchAdd"), + stockCutSelectionClear: document.querySelector("#stockCutSelectionClear"), manualStockActions: document.querySelector("#manualStockActions"), legacyFixedWorkflow: document.querySelector("#legacyFixedWorkflow"), legacyFixedWorkflowTitle: document.querySelector("#legacyFixedWorkflowTitle"), @@ -429,6 +463,7 @@ industryClear: document.querySelector("#industryClear"), industryCutList: document.querySelector("#industryCutList"), comparisonWorkflow: document.querySelector("#comparisonWorkflow"), + comparisonImportLegacy: document.querySelector("#comparisonImportLegacy"), comparisonPairCount: document.querySelector("#comparisonPairCount"), comparisonSourceTabs: document.querySelector("#comparisonSourceTabs"), comparisonDomesticPanel: document.querySelector("#comparisonDomesticPanel"), @@ -509,6 +544,7 @@ tradingHaltSearch: document.querySelector("#tradingHaltSearch"), tradingHaltSearchButton: document.querySelector("#tradingHaltSearchButton"), tradingHaltSearchState: document.querySelector("#tradingHaltSearchState"), + tradingHaltNameSort: document.querySelector("#tradingHaltNameSort"), tradingHaltResults: document.querySelector("#tradingHaltResults"), selectedTradingHalt: document.querySelector("#selectedTradingHalt"), selectedTradingHaltName: document.querySelector("#selectedTradingHaltName"), @@ -625,8 +661,9 @@ } const source = entry.operator?.source; if (source === "legacy-manual-lists-workflow") { - if (!manualListsWorkflow.restorePlaylistEntry(entry)) { - throw new TypeError("The manual-list entry failed strict restoration."); + if (!manualListsWorkflow.restorePlaylistEntry(entry) || + !manualListsWorkflow.isPlaylistEntryPlayable(entry)) { + throw new TypeError("The manual-list entry is not fresh-read verified."); } } else if (source === "manual-financial-workflow") { if (!manualFinancialWorkflow.isPlaylistEntryPlayable(entry)) { @@ -660,7 +697,18 @@ }); } - function requirePlaylistEditable() { + function requirePlaylistEditable(allowFailedManualRestore = false) { + const restoreRecords = [...state.manualFinancialRestore.entries.values()]; + const failedRestoreOnly = restoreRecords.length > 0 && + restoreRecords.every(record => record.status === "error"); + if (state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending || + state.namedPlaylist.comparisonRestorePending || + state.namedPlaylist.foreignIndexCandleRestorePending || + state.namedPlaylist.nxtThemeRestorePending || + (restoreRecords.length > 0 && !(allowFailedManualRestore && failedRestoreOnly))) { + showToast("DB 플레이리스트 복원과 현재 identity 재검증이 끝날 때까지 순서를 변경할 수 없습니다."); + return false; + } if (!isPlaylistSnapshotLocked()) return true; showToast("PREPARE 이후에는 TAKE OUT까지 플레이리스트가 잠깁니다."); return false; @@ -773,6 +821,51 @@ } } + function addFixedActions(sectionActions) { + if (!Array.isArray(sectionActions) || !sectionActions.length) { + showToast("일괄 추가할 원본 운영 항목이 없습니다."); + return; + } + if (sectionActions.some(action => action?.available === false)) { + showToast("이 섹션에는 수동 입력이 필요한 항목이 있어 아무것도 추가하지 않았습니다. 수동 항목은 개별 입력 버튼으로 먼저 확정하세요."); + return; + } + if (sectionActions.some(action => !action?.available) || !requirePlaylistEditable()) { + return; + } + + try { + const entries = legacyBatchSelectionWorkflow.materializeAtomicBatch( + sectionActions, + action => { + const created = fixedWorkflow.createFixedPlaylistEntry(action.id, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION, + ma5: state.candleOptions.ma5, + ma20: state.candleOptions.ma20 + }); + const definition = catalog.find(item => + item.builderKey === created.builderKey && + sceneAliases(item).includes(created.code)); + if (!definition) { + throw new Error("A fixed batch item does not resolve to a registered scene alias."); + } + return { ...definition, ...created, enabled: true }; + }); + state.playlist.push(...entries); + state.selectedRows.clear(); + entries.forEach(entry => state.selectedRows.add(entry.id)); + state.currentIndex = state.playlist.length - 1; + state.selectionAnchorIndex = state.currentIndex; + renderPlaylist(); + updatePreview(); + addLog(`${sectionActions[0].section} 섹션 전체 추가 · ${entries.length}개 · 원본 자식 순서`); + showToast(`${sectionActions[0].section} 항목 ${entries.length}개를 순서대로 추가했습니다.`); + } catch { + showToast("섹션의 모든 원본 항목을 검증하지 못해 아무것도 추가하지 않았습니다."); + } + } + function renderFixedWorkflow() { const visible = fixedWorkflow.fixedMarkets.includes(state.activeMarket); elements.legacyFixedWorkflow.hidden = !visible; @@ -814,9 +907,28 @@ const header = document.createElement("header"); const title = document.createElement("strong"); const count = document.createElement("span"); + const tools = document.createElement("div"); + const addAll = document.createElement("button"); title.textContent = sectionName; count.textContent = `${sectionActions.length}`; - header.append(title, count); + tools.className = "legacy-fixed-section-tools"; + addAll.className = "legacy-fixed-batch-add"; + addAll.type = "button"; + addAll.textContent = "전체 추가"; + addAll.disabled = operatorUiLocked(); + addAll.title = sectionActions.some(action => action.available === false) + ? "수동 입력 항목이 포함되어 있으며 전체 배치는 부분 추가 없이 차단됩니다." + : "현재 표시된 섹션 자식을 원본 순서대로 추가합니다."; + addAll.addEventListener("click", event => { + event.stopPropagation(); + addFixedActions(sectionActions); + }); + header.addEventListener("dblclick", event => { + if (event.target.closest("button")) return; + addFixedActions(sectionActions); + }); + tools.append(count, addAll); + header.append(title, tools); section.append(header); for (const action of sectionActions) { @@ -1242,7 +1354,11 @@ function persistComparisonPairList(nextList, successMessage) { try { - localStorage.setItem(comparisonStorageKey, JSON.stringify(nextList)); + const envelope = comparisonImportWorkflow.createStorageEnvelope( + nextList, + state.comparison.legacyImport.marker); + if (!envelope) throw new Error("The comparison storage envelope is invalid."); + localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope)); state.comparison.pairList = nextList; if (successMessage) addLog(successMessage); return true; @@ -1253,16 +1369,167 @@ } function loadComparisonPairList() { + const operation = state.comparison.legacyImport; try { - const raw = JSON.parse(localStorage.getItem(comparisonStorageKey) || "null"); - state.comparison.pairList = comparisonWorkflow.normalizePairList(raw); + const serialized = localStorage.getItem(comparisonStorageKey); + const raw = JSON.parse(serialized || "null"); + let envelope = comparisonImportWorkflow.normalizeStorageEnvelope(raw); + if (!envelope && raw !== null) { + const oldMarker = comparisonImportWorkflow.normalizeMarker(JSON.parse( + localStorage.getItem(comparisonImportMarkerStorageKey) || "null")); + envelope = comparisonImportWorkflow.createStorageEnvelope(raw, oldMarker); + if (!envelope) throw new Error("A split legacy comparison import was detected."); + localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope)); + localStorage.removeItem(comparisonImportMarkerStorageKey); + } + if (!envelope) { + envelope = comparisonImportWorkflow.createStorageEnvelope( + comparisonWorkflow.deleteAllPairs(), + null); + } + if (!envelope) throw new Error("The comparison storage envelope is invalid."); + state.comparison.pairList = envelope.pairList; + operation.marker = envelope.legacyImportMarker; + operation.status = operation.marker ? "imported" : "idle"; + operation.recoveryBlocked = false; + operation.error = ""; state.comparison.selectedPairId = state.comparison.pairList.pairs[0]?.id || null; } catch { state.comparison.pairList = comparisonWorkflow.deleteAllPairs(); state.comparison.selectedPairId = null; + operation.marker = null; + operation.status = "error"; + operation.recoveryBlocked = true; + operation.error = "비교쌍과 1회 가져오기 표식이 분리되어 안전하게 복구할 수 없습니다. 로컬 상태를 확인하기 전에는 가져오기를 다시 실행하지 않습니다."; } } + function clearComparisonImportTimeout() { + const operation = state.comparison.legacyImport; + if (operation.timeoutId !== null) clearTimeout(operation.timeoutId); + operation.timeoutId = null; + } + + function persistComparisonLegacyImport(nextList, marker) { + try { + const envelope = comparisonImportWorkflow.createStorageEnvelope(nextList, marker); + if (!envelope) return false; + localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope)); + } catch { + return false; + } + + state.comparison.pairList = nextList; + state.comparison.legacyImport.marker = marker; + state.comparison.legacyImport.status = "imported"; + state.comparison.legacyImport.recoveryBlocked = false; + return true; + } + + function requestLegacyComparisonImport() { + const operation = state.comparison.legacyImport; + if (isPlaylistSnapshotLocked() || operation.status === "loading" || + operation.recoveryBlocked) return; + if (operation.marker) { + showToast("원본 비교쌍은 이미 한 번 안전하게 가져왔습니다."); + return; + } + if (!nativeBridge) { + operation.status = "error"; + operation.error = "브라우저 미리보기에서는 원본 파일과 실제 DB를 검증할 수 없습니다."; + renderComparisonWorkflow(); + showToast(operation.error); + return; + } + if (!window.confirm( + "원본 종목비교.dat를 한 번 읽고, 모든 종목을 현재 DB에서 재검증한 뒤 로컬 비교쌍으로 가져올까요?")) return; + + clearComparisonImportTimeout(); + const requestId = createId(); + Object.assign(operation, { + requestId, + status: "loading", + error: "" + }); + renderComparisonWorkflow(); + postNative("import-legacy-comparison-pairs", { requestId }); + operation.timeoutId = setTimeout(() => { + if (operation.requestId !== requestId || operation.status !== "loading") return; + operation.status = "error"; + operation.error = "원본 비교쌍 검증 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다."; + renderComparisonWorkflow(); + addLog("원본 비교쌍 가져오기 시간 초과 · 자동 재시도 없음"); + showToast(operation.error); + }, 60000); + } + + function handleLegacyComparisonImportResults(payload) { + const operation = state.comparison.legacyImport; + if (operation.status !== "loading" || payload?.requestId !== operation.requestId) return; + clearComparisonImportTimeout(); + const imported = comparisonImportWorkflow.normalizeImportResponse( + payload, + operation.requestId); + if (!imported) { + operation.status = "error"; + operation.error = "Native가 반환한 원본 비교쌍 형식 또는 상관관계가 올바르지 않습니다."; + renderComparisonWorkflow(); + showToast(operation.error); + return; + } + + let merged; + try { + merged = comparisonImportWorkflow.mergeImport( + state.comparison.pairList, + imported); + } catch { + operation.status = "error"; + operation.error = "기존 비교쌍과 중복되거나 저장 한도를 초과해 전체 가져오기를 취소했습니다."; + renderComparisonWorkflow(); + showToast(operation.error); + return; + } + + const previousCount = state.comparison.pairList.pairs.length; + const marker = comparisonImportWorkflow.createMarker(imported); + if (!marker || !persistComparisonLegacyImport(merged, marker)) { + operation.status = "error"; + operation.error = "비교쌍 로컬 저장소를 갱신하지 못해 전체 가져오기를 취소했습니다."; + renderComparisonWorkflow(); + showToast(operation.error); + return; + } + + try { + state.comparison.selectedPairId = merged.pairs[previousCount]?.id || + merged.pairs[0]?.id || null; + operation.requestId = null; + operation.error = ""; + renderComparisonWorkflow(); + addLog(`원본 비교쌍 가져오기 완료 · ${imported.sourceRowCount.toLocaleString("ko-KR")}쌍 · 현재 DB 재검증`); + showToast(`원본 비교쌍 ${imported.sourceRowCount.toLocaleString("ko-KR")}개를 로컬 저장소로 가져왔습니다.`); + } catch { + // Persistence already completed; rendering/logging failures must not + // roll back or reopen the one-time import gate. + } + } + + function handleLegacyComparisonImportError(payload) { + const operation = state.comparison.legacyImport; + if (operation.status !== "loading" || payload?.requestId !== operation.requestId) return; + clearComparisonImportTimeout(); + const message = typeof payload.message === "string" && payload.message.trim().length <= 512 && + !/[\u0000-\u001f\u007f]/u.test(payload.message) + ? payload.message.trim() + : "원본 비교쌍 가져오기를 완료하지 못했습니다."; + operation.status = "error"; + operation.error = message; + renderComparisonWorkflow(); + addLog(`원본 비교쌍 가져오기 실패 · ${message}`); + showToast(message); + } + function saveComparisonDraftPair() { if (isPlaylistSnapshotLocked()) return; const pair = comparisonWorkflow.normalizePair([ @@ -1450,6 +1717,20 @@ const selectedRecord = selectedComparisonRecord(); const selectedPair = selectedRecord?.pair || null; elements.comparisonPairCount.textContent = `${pairs.length} PAIRS`; + const legacyImport = comparison.legacyImport; + elements.comparisonImportLegacy.classList.toggle("imported", Boolean(legacyImport.marker)); + elements.comparisonImportLegacy.disabled = locked || legacyImport.status === "loading" || + legacyImport.recoveryBlocked || Boolean(legacyImport.marker); + elements.comparisonImportLegacy.textContent = legacyImport.marker + ? `원본 ${legacyImport.marker.importedPairCount}쌍 가져옴` + : (legacyImport.recoveryBlocked + ? "원본 가져오기 복구 필요" + : (legacyImport.status === "loading" + ? "원본 검증 중…" + : (legacyImport.status === "error" ? "원본 가져오기 재시도" : "원본 비교쌍 가져오기"))); + elements.comparisonImportLegacy.title = legacyImport.marker + ? "원본 파일은 다시 참조하지 않으며 현재 로컬 비교쌍만 사용합니다." + : (legacyImport.error || "고정된 원본 종목비교.dat를 한 번만 가져옵니다."); elements.comparisonSourceTabs.querySelectorAll("button[data-comparison-source]").forEach(button => { button.classList.toggle("active", button.dataset.comparisonSource === comparison.source); @@ -2142,7 +2423,9 @@ let invalid = false; state.playlist = state.playlist.map(item => { if (item?.operator?.source !== "legacy-theme-workflow") return item; - const refreshed = themeWorkflow.refreshThemePlaylistEntry(item); + const refreshed = namedNxtThemeRestoreWorkflow.isMaterializedEntry(item) + ? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date()) + : themeWorkflow.refreshThemePlaylistEntry(item); if (!refreshed) { invalid = true; return item; @@ -2153,7 +2436,12 @@ invalid = true; return item; } - return { ...definition, ...refreshed }; + // A native-verified named NXT entry already has the complete theme + // operator shape. Do not clone it here: its in-memory verification mark + // must survive until PREPARE/save validation. + return namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed) + ? refreshed + : { ...definition, ...refreshed }; }); if (invalid) { showToast("저장된 테마 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다."); @@ -2803,8 +3091,14 @@ } function tradingHaltIdentity(value) { - if (!value) return ""; - return [value.market, value.stockCode, value.displayName].join("|"); + return tradingHaltWorkflow.tradingHaltIdentity(value); + } + + function toggleTradingHaltNameSort() { + const workflow = state.tradingHalt; + if (isPlaylistSnapshotLocked() || workflow.status !== "ready" || !workflow.results.length) return; + workflow.sortDirection = tradingHaltWorkflow.nextTradingHaltNameSort(workflow.sortDirection); + renderTradingHaltWorkflow(); } function selectTradingHalt(value) { @@ -2863,6 +3157,16 @@ : `${workflow.totalRowCount.toLocaleString("ko-KR")} HALTED`; elements.tradingHaltSearch.disabled = locked; elements.tradingHaltSearchButton.disabled = locked || workflow.status === "loading"; + const canSort = workflow.status === "ready" && workflow.results.length > 0; + elements.tradingHaltNameSort.disabled = locked || !canSort; + elements.tradingHaltNameSort.dataset.sortDirection = workflow.sortDirection || "none"; + const sortLabel = workflow.sortDirection === "descending" + ? "종목명 정렬: 내림차순, 누르면 오름차순" + : workflow.sortDirection === "ascending" + ? "종목명 정렬: 오름차순, 누르면 내림차순" + : "종목명 정렬: 서버 기본 순서, 누르면 내림차순"; + elements.tradingHaltNameSort.setAttribute("aria-label", sortLabel); + elements.tradingHaltNameSort.title = sortLabel; elements.tradingHaltSearchState.className = `trading-halt-search-state ${workflow.status}`; if (workflow.status === "loading") { elements.tradingHaltSearchState.textContent = "Oracle의 KOSPI·KOSDAQ 거래 정지 종목을 조회하고 있습니다."; @@ -2884,7 +3188,10 @@ elements.tradingHaltResults.append(empty); } else { const selectedIdentity = tradingHaltIdentity(workflow.selected); - for (const item of workflow.results) { + const displayResults = tradingHaltWorkflow.sortTradingHaltsForDisplay( + workflow.results, + workflow.sortDirection); + for (const item of displayResults) { const row = document.createElement("button"); row.type = "button"; row.className = "trading-halt-result"; @@ -2971,6 +3278,7 @@ workflow.status = "loading"; workflow.results = []; workflow.selected = null; + workflow.sortDirection = null; workflow.totalRowCount = 0; workflow.truncated = false; workflow.error = ""; @@ -2995,6 +3303,7 @@ const request = workflow.request; if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return; clearTradingHaltTimeout(); + workflow.sortDirection = null; const response = tradingHaltWorkflow.normalizeTradingHaltSearchResponse(payload, request); if (!response) { workflow.status = "error"; @@ -3064,7 +3373,75 @@ addLog(`종목 선택 · ${stock.displayName} · ${stock.stockCode}`); } + function selectStockCut(index, event) { + try { + state.stockCutSelection = legacyBatchSelectionWorkflow.applyPointerSelection( + state.stockCutSelection, + index, + { + ctrlKey: event?.ctrlKey === true || event?.metaKey === true, + shiftKey: event?.shiftKey === true + }); + renderStockCutSelection(); + } catch { + state.stockCutSelection = legacyBatchSelectionWorkflow.createSelection( + operatorWorkflow.stockCuts.length); + renderStockCutSelection(); + } + } + + function clearStockCutSelection() { + state.stockCutSelection = legacyBatchSelectionWorkflow.createSelection( + operatorWorkflow.stockCuts.length); + renderStockCutSelection(); + } + + function renderStockCutSelection() { + let selection; + try { + selection = legacyBatchSelectionWorkflow.createSelection( + operatorWorkflow.stockCuts.length, + state.stockCutSelection.selectedIndices, + state.stockCutSelection.anchorIndex); + } catch { + selection = legacyBatchSelectionWorkflow.createSelection( + operatorWorkflow.stockCuts.length); + } + state.stockCutSelection = selection; + const selected = new Set(selection.selectedIndices); + const selectedCount = selection.selectedIndices.length; + const locked = operatorUiLocked(); + elements.stockCutSelectionCount.textContent = `${selectedCount}개 선택`; + elements.stockCutBatchAdd.disabled = selectedCount === 0 || + !state.stockSearch.selected || locked; + elements.stockCutBatchAdd.title = selectedCount === 0 + ? "Ctrl 또는 Shift와 함께 컷을 선택하세요." + : (!state.stockSearch.selected + ? "먼저 검색 결과에서 종목을 선택하세요." + : "선택 컷을 원본 목록 순서로 한 번에 추가합니다."); + elements.stockCutSelectionClear.disabled = selectedCount === 0; + for (const row of elements.stockCutList.querySelectorAll("[data-stock-cut-index]")) { + const index = Number(row.dataset.stockCutIndex); + const isSelected = selected.has(index); + row.classList.toggle("selected", isSelected); + row.setAttribute("aria-selected", isSelected ? "true" : "false"); + } + } + + function addSelectedStockCuts() { + const selected = state.stockCutSelection.selectedIndices; + if (!selected.length) { + showToast("추가할 종목 컷을 먼저 선택하세요."); + return; + } + addStockCuts(selected.map(index => operatorWorkflow.stockCuts[index]?.id)); + } + function addStockCut(cutId) { + addStockCuts([cutId]); + } + + function addStockCuts(cutIds) { if (!requirePlaylistEditable()) return; const stock = state.stockSearch.selected; if (!stock) { @@ -3073,38 +3450,51 @@ return; } - if (!operatorWorkflow.isStockCutAllowed(stock, cutId)) { - showToast(stock.isNxt ? "NXT 종목은 1열판기본_현재가만 지원합니다." : "이 종목과 컷 조합은 지원하지 않습니다."); + const cutsById = new Map(operatorWorkflow.stockCuts.map(cut => [cut.id, cut])); + const ids = Array.isArray(cutIds) ? cutIds : []; + const cuts = ids.map(id => cutsById.get(id)); + if (!ids.length || cuts.some(cut => !cut) || new Set(ids).size !== ids.length) { + showToast("선택한 종목 컷 목록이 올바르지 않아 아무것도 추가하지 않았습니다."); + return; + } + if (cuts.some(cut => !operatorWorkflow.isStockCutAllowed(stock, cut.id))) { + showToast(stock.isNxt + ? "선택한 컷 중 NXT가 지원하지 않는 항목이 있어 아무것도 추가하지 않았습니다. NXT는 1열판기본_현재가만 지원합니다." + : "지원하지 않는 종목 컷이 포함되어 아무것도 추가하지 않았습니다."); return; } try { - const created = operatorWorkflow.createStockPlaylistEntry(stock, cutId, { - id: createId(), - fadeDuration: DEFAULT_FADE_DURATION, - ma5: state.candleOptions.ma5, - ma20: state.candleOptions.ma20 + const entries = legacyBatchSelectionWorkflow.materializeAtomicBatch(cuts, cut => { + const created = operatorWorkflow.createStockPlaylistEntry(stock, cut.id, { + id: createId(), + fadeDuration: DEFAULT_FADE_DURATION, + ma5: state.candleOptions.ma5, + ma20: state.candleOptions.ma20 + }); + const definition = catalog.find(item => item.builderKey === created.builderKey); + if (!definition || !sceneAliases(definition).includes(String(created.code || ""))) { + throw new Error("The operator cut does not resolve to a registered scene alias."); + } + return { + ...definition, + ...created, + enabled: true + }; }); - const definition = catalog.find(item => item.builderKey === created.builderKey); - if (!definition || !sceneAliases(definition).includes(String(created.code || ""))) { - throw new Error("The operator cut does not resolve to a registered scene alias."); - } - const entry = { - ...definition, - ...created, - enabled: true - }; - state.playlist.push(entry); + state.playlist.push(...entries); state.selectedRows.clear(); - state.selectedRows.add(entry.id); + entries.forEach(entry => state.selectedRows.add(entry.id)); state.currentIndex = state.playlist.length - 1; state.selectionAnchorIndex = state.currentIndex; renderPlaylist(); updatePreview(); - addLog(`${entry.code} ${stock.displayName} · ${entry.operator?.legacyLabel || entry.title} 추가`); - showToast(`${stock.displayName} 컷을 플레이리스트에 추가했습니다.`); + addLog(`${stock.displayName} 종목 컷 일괄 추가 · ${entries.length}개 · 원본 목록 순서`); + showToast(entries.length === 1 + ? `${stock.displayName} 컷을 플레이리스트에 추가했습니다.` + : `${stock.displayName} 컷 ${entries.length}개를 순서대로 추가했습니다.`); } catch { - showToast("선택한 종목과 컷을 안전한 장면으로 구성하지 못했습니다."); + showToast("선택한 종목 컷 전체를 안전한 장면으로 구성하지 못해 아무것도 추가하지 않았습니다."); } } @@ -3187,9 +3577,10 @@ function renderStockWorkflow() { renderGlobalCandleOptions(); - const visible = ["dashboard", "kospi", "kosdaq"].includes(state.activeMarket); - elements.stockWorkflow.hidden = !visible; - if (!visible) return; + // In MainForm this was a top-level left panel, not content owned by the + // dashboard/KOSPI/KOSDAQ tabs. Keep the same search result and selected + // stock available while the operator moves between every business view. + elements.stockWorkflow.hidden = false; elements.stockWorkflow.classList.toggle("collapsed", state.stockWorkflowCollapsed); elements.stockWorkflowToggle.textContent = state.stockWorkflowCollapsed ? "펼치기" : "접기"; elements.stockWorkflowToggle.setAttribute("aria-expanded", String(!state.stockWorkflowCollapsed)); @@ -3263,6 +3654,9 @@ const allowed = Boolean(search.selected) && operatorWorkflow.isStockCutAllowed(search.selected, cut.id); const row = document.createElement("div"); row.className = "stock-cut-row"; + row.dataset.stockCutIndex = String(index); + row.setAttribute("role", "option"); + row.tabIndex = 0; row.classList.toggle("disabled", !allowed); const number = document.createElement("span"); number.className = "stock-cut-number"; @@ -3282,11 +3676,37 @@ add.title = !search.selected ? "먼저 종목을 선택하세요." : (!allowed ? "이 종목 시장에서는 지원하지 않는 컷입니다." : "플레이리스트에 추가"); - add.addEventListener("click", () => addStockCut(cut.id)); - row.addEventListener("dblclick", () => { if (allowed) addStockCut(cut.id); }); + add.addEventListener("click", event => { + event.stopPropagation(); + addStockCut(cut.id); + }); + row.addEventListener("click", event => { + if (event.target.closest("button")) return; + selectStockCut(index, event); + }); + row.addEventListener("dblclick", event => { + if (event.target.closest("button")) return; + if (allowed && legacyBatchSelectionWorkflow.canActivateSingle( + state.stockCutSelection, + index)) addStockCut(cut.id); + }); + row.addEventListener("keydown", event => { + if (event.repeat) return; + if (event.key === " ") { + event.preventDefault(); + selectStockCut(index, event); + } else if (event.key === "Enter") { + event.preventDefault(); + if (!legacyBatchSelectionWorkflow.canActivateSingle( + state.stockCutSelection, + index)) selectStockCut(index, {}); + if (allowed) addStockCut(cut.id); + } + }); row.append(number, copy, add); elements.stockCutList.append(row); }); + renderStockCutSelection(); elements.manualStockActions.replaceChildren(); for (const action of operatorWorkflow.manualStockActions) { @@ -3379,6 +3799,8 @@ if (operatorInputBuilderKeys.has(item.builderKey)) { if (["s5026", "s5029", "s5087"].includes(item.builderKey)) { showToast("비교 장면은 두 종목을 확정하는 전용 입력 화면에서 추가해야 합니다."); + } else if (item.builderKey === "s5025") { + showToast("수동 순매도 장면은 투자자별 수동 목록 화면에서 추가해야 합니다."); } else if (["s5074", "s5077", "s5088"].includes(item.builderKey)) { showToast("5·6·12행 페이지 장면은 지수·업종·테마 등 원본 전용 입력 화면에서 추가해야 합니다."); } else if (["s5076", "s5079", "s5080", "s5081"].includes(item.builderKey)) { @@ -3745,8 +4167,13 @@ } function deleteSelected() { - if (!requirePlaylistEditable()) return; if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요."); + const restoreRecords = [...state.manualFinancialRestore.entries.values()]; + const deletingFailedRestores = restoreRecords.length > 0 && + restoreRecords.every(record => record.status === "error") && + [...state.selectedRows].every(id => + state.manualFinancialRestore.entries.get(id)?.status === "error"); + if (!requirePlaylistEditable(deletingFailedRestores)) return; const deleted = state.selectedRows.size; for (const id of state.selectedRows) { const restore = state.manualFinancialRestore.entries.get(id); @@ -3758,21 +4185,28 @@ state.currentIndex = Math.min(state.currentIndex, state.playlist.length - 1); state.selectionAnchorIndex = state.currentIndex; renderPlaylist(); + renderNamedPlaylist(); updatePreview(); addLog(`${deleted}개 항목 삭제`); + requestNextManualFinancialRestore(); } function deleteAllPlaylistEntries() { - if (!requirePlaylistEditable()) return; + const restoreRecords = [...state.manualFinancialRestore.entries.values()]; + const failedRestoreOnly = restoreRecords.length > 0 && + restoreRecords.every(record => record.status === "error"); + if (!requirePlaylistEditable(failedRestoreOnly)) return; if (!state.playlist.length) return; if (!window.confirm("플레이리스트의 모든 항목을 삭제할까요?")) return; const deleted = state.playlist.length; installManualFinancialRestores([]); + clearNamedPlaylistLoadState(); state.playlist = []; state.selectedRows.clear(); state.currentIndex = -1; state.selectionAnchorIndex = -1; renderPlaylist(); + renderNamedPlaylist(); updatePreview(); addLog(`플레이리스트 전체 ${deleted}개 항목 삭제`); } @@ -3812,7 +4246,7 @@ function normalizeStoredPlaylist(value, options = {}) { if (!Array.isArray(value)) return []; - return value.slice(0, 1000).flatMap(item => { + return value.slice(0, 1000).flatMap((item, itemIndex) => { if (!item || typeof item !== "object") return []; const restoredFixedEntry = fixedWorkflow.restoreFixedPlaylistEntry(item, { now: options.now }); if (restoredFixedEntry) { @@ -3879,9 +4313,16 @@ } const restoredManualListEntry = manualListsWorkflow.restorePlaylistEntry(item); if (restoredManualListEntry) { + const pendingManualListEntry = namedManualRestoreWorkflow.createStoredManualListPending( + restoredManualListEntry, + itemIndex); + if (!pendingManualListEntry) return []; const manualListDefinition = catalog.find(definition => definition.builderKey === restoredManualListEntry.builderKey && sceneAliases(definition).includes(restoredManualListEntry.code)); + if (manualListDefinition && Array.isArray(options.manualFinancialRestores)) { + options.manualFinancialRestores.push(pendingManualListEntry); + } return manualListDefinition ? [{ ...manualListDefinition, ...restoredManualListEntry }] : []; @@ -3983,7 +4424,7 @@ function requestManualFinancialRestoreLoad(record) { try { - if (record.restoreKind === "named-list") { + if (["named-list", "stored-list"].includes(record.restoreKind)) { const envelope = namedManualRestoreWorkflow.createManualListReadRequest( `named-manual-read-${createId()}`, record.pending); @@ -4033,7 +4474,11 @@ state.manualFinancialRestore.entries.set(pending.entry.id, { pending, restoreKind: namedManualRestoreWorkflow.isPending(pending) - ? (pending.kind === "financial" ? "named-financial" : "named-list") + ? (pending.kind === "financial" + ? "named-financial" + : (namedManualRestoreWorkflow.isStoredManualListPending(pending) + ? "stored-list" + : "named-list")) : "stored-financial", status: nativeBridge ? "queued" : "error", request: null, @@ -4056,9 +4501,25 @@ return true; } record.loadResponse = response; + const expectedMarket = record.restoreKind === "named-financial" + ? record.pending.market + : record.pending.verifiedStock?.market; + let searchQuery = response.snapshot.stockName; + if (String(expectedMarket || "").startsWith("nxt-")) { + const suffix = "(NXT)"; + if (!searchQuery.endsWith(suffix) || searchQuery.length === suffix.length) { + failManualFinancialRestore(record, "NXT 종목의 원본 표시명에서 검색 identity를 복원할 수 없습니다."); + return true; + } + searchQuery = searchQuery.slice(0, -suffix.length); + if (!searchQuery || searchQuery !== searchQuery.trim() || searchQuery.includes(suffix)) { + failManualFinancialRestore(record, "NXT 종목의 원본 표시명에서 검색 identity를 복원할 수 없습니다."); + return true; + } + } const request = { requestId: `manual-restore-stock-${createId()}`, - query: response.snapshot.stockName, + query: searchQuery, maximumResults: 200 }; record.status = "stock"; @@ -4228,6 +4689,9 @@ if (records.some(record => ["queued", "load", "stock", "selection", "manual-list"].includes(record.status))) { return; } + if (state.namedPlaylist.comparisonRestorePending || + state.namedPlaylist.foreignIndexCandleRestorePending || + state.namedPlaylist.nxtThemeRestorePending) return; const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration); const materialized = materializeNamedRestoration(restoration); state.namedPlaylist.restoration = restoration; @@ -4241,7 +4705,7 @@ function handleNamedManualListResults(payload) { const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list"); - if (!record || record.restoreKind !== "named-list") return false; + if (!record || !["named-list", "stored-list"].includes(record.restoreKind)) return false; clearManualFinancialRestoreTimeout(record); let entry = null; try { @@ -4253,8 +4717,12 @@ entry = null; } const index = state.playlist.findIndex(item => item.id === record.pending.entry.id); - if (!entry || index < 0 || isPlaylistSnapshotLocked() || - !trustNamedManualRestoration(record.pending, entry)) { + const current = index >= 0 ? state.playlist[index] : null; + const trusted = entry && index >= 0 && !isPlaylistSnapshotLocked() && + (record.restoreKind === "named-list" + ? trustNamedManualRestoration(record.pending, entry) + : namedManualRestoreWorkflow.matchesMaterializedEntry(record.pending, current)); + if (!trusted) { failManualFinancialRestore(record, "현재 신뢰 저장소 값이 DB raw 행과 정확히 일치하지 않습니다."); return true; } @@ -4270,7 +4738,7 @@ function handleNamedManualListError(payload) { const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list"); - if (!record || record.restoreKind !== "named-list") return false; + if (!record || !["named-list", "stored-list"].includes(record.restoreKind)) return false; const expectedId = record.request?.payload?.requestId; const error = manualListsWorkflow.normalizeError(payload, expectedId); failManualFinancialRestore(record, error?.message || "신뢰 수동 저장소 읽기에 실패했습니다."); @@ -4279,8 +4747,12 @@ function manualFinancialPrepareBlocked() { if (state.manualFinancialRestore.entries.size > 0) return true; - return state.playlist.some(item => item?.operator?.source === "manual-financial-workflow" && - !manualFinancialWorkflow.isPlaylistEntryPlayable(item)); + return state.playlist.some(item => + (item?.operator?.source === "manual-financial-workflow" && + !manualFinancialWorkflow.isPlaylistEntryPlayable(item)) || + (item?.operator?.source === "legacy-manual-lists-workflow" && + (!manualListsWorkflow.restorePlaylistEntry(item) || + !manualListsWorkflow.isPlaylistEntryPlayable(item)))); } function loadPlaylist() { @@ -4344,14 +4816,14 @@ function addFixedNamedCandidates(candidates, rawRow, itemIndex, now) { for (const action of fixedWorkflow.actions) { if (!action.available) continue; - const matches = ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => { + const canonicalMatch = ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => { if (key === "dataCode" && action.dynamicDate) return /^\d{4}-\d{2}-\d{2}$/.test(rawRow.dataCode); if (key === "graphicType" && action.dynamicNxtSession) { return ["PRE_MARKET", "AFTER_MARKET"].includes(rawRow.graphicType); } return action.selection[key] === rawRow[key]; }); - if (!matches) continue; + if (!canonicalMatch && !legacyNamedRowWorkflow.matchesFixedActionRaw(rawRow, action)) continue; addNamedCandidate(candidates, () => fixedWorkflow.createFixedPlaylistEntry(action.id, { id: `db-${itemIndex}-${action.id}`, fadeDuration: DEFAULT_FADE_DURATION, @@ -4383,57 +4855,32 @@ } function addIndustryNamedCandidates(candidates, rawRow, itemIndex, now) { - const upperGroup = String(rawRow.groupCode || "").toLocaleUpperCase("en-US"); - const market = upperGroup.includes("KOSDAQ") ? "kosdaq" : (upperGroup.includes("KOSPI") ? "kospi" : null); - if (!market) return; - industryWorkflow.cuts.forEach((cut, cutIndex) => { - const options = { - id: `db-${itemIndex}-industry-${cutIndex}`, + const descriptor = legacyNamedRowWorkflow.industryFixedDescriptor(rawRow); + if (!descriptor) return; + addNamedCandidate(candidates, () => industryWorkflow.createIndustryPlaylistEntry( + descriptor.market, + descriptor.cutId, + { + id: `db-${itemIndex}-industry-${descriptor.cutId}`, fadeDuration: DEFAULT_FADE_DURATION, now - }; - if (cut.kind === "pair") { - const names = String(rawRow.subject || "").split("-"); - if (names.length !== 2 || names.some(value => !value)) return; - options.pair = names.map((name, index) => ({ market, name, code: `R${itemIndex}${index}` })); - } else if (cut.kind !== "fixed") { - options.selected = { market, name: rawRow.subject, code: `R${itemIndex}` }; - } - addNamedCandidate(candidates, () => - industryWorkflow.createIndustryPlaylistEntry(market, cut.id, options)); - }); - } - - function namedComparisonTarget(token, itemIndex, tokenIndex) { - const marketTarget = comparisonWorkflow.getMarketTarget(token); - if (marketTarget) return marketTarget; - const isNxt = token.endsWith("(NXT)"); - const stockName = token.replace(/\s*\(NXT\)\s*$/, "").trim(); - return stockName ? { - kind: isNxt ? "nxt-stock" : "krx-stock", - market: "kospi", - stockName, - stockCode: `R${itemIndex}${tokenIndex}` - } : null; - } - - function addComparisonNamedCandidates(candidates, rawRow, itemIndex) { - const tokens = String(rawRow.subject || "").split(","); - if (tokens.length !== 2 || tokens.some(value => !value)) return; - const pair = tokens.map((token, tokenIndex) => namedComparisonTarget(token, itemIndex, tokenIndex)); - if (pair.some(value => !value)) return; - for (const action of comparisonWorkflow.actions) { - addNamedCandidate(candidates, () => comparisonWorkflow.createComparisonPlaylistEntry(action.id, pair, { - id: `db-${itemIndex}-comparison-${action.id}`, - fadeDuration: DEFAULT_FADE_DURATION })); - } } - function addThemeNamedCandidates(candidates, rawRow, itemIndex) { + function addThemeNamedCandidates(candidates, rawRow, itemIndex, now) { + // Historical NXT codes are not authoritative. Keep every original + // `테마_NXT` row untrusted until the correlated native MariaDB batch maps + // its exact title to the current unique code and proves a live item. + if (rawRow.groupCode === "테마_NXT") return; let theme; let sort; - if (rawRow.groupCode === "PAGED_THEME") { + let actionIds = themeWorkflow.actions.map(action => action.id); + const legacy = legacyNamedRowWorkflow.themeLegacyDescriptor(rawRow, { now }); + if (legacy) { + theme = legacy.theme; + sort = legacy.sort; + actionIds = [legacy.actionId]; + } else if (rawRow.groupCode === "PAGED_THEME") { theme = { market: "krx", themeCode: rawRow.dataCode, @@ -4454,9 +4901,9 @@ } else { return; } - for (const action of themeWorkflow.actions) { - addNamedCandidate(candidates, () => themeWorkflow.createThemePlaylistEntry(action.id, theme, sort, { - id: `db-${itemIndex}-theme-${action.id}`, + for (const actionId of actionIds) { + addNamedCandidate(candidates, () => themeWorkflow.createThemePlaylistEntry(actionId, theme, sort, { + id: `db-${itemIndex}-theme-${actionId}`, fadeDuration: DEFAULT_FADE_DURATION })); } @@ -4498,14 +4945,17 @@ } function addExpertNamedCandidate(candidates, rawRow, itemIndex) { - if (rawRow.groupCode !== "PAGED_EXPERT") return; - addNamedCandidate(candidates, () => expertWorkflow.createExpertPlaylistEntry({ - expertCode: rawRow.dataCode, - expertName: rawRow.subject, - source: "oracle", - itemCount: 1, - previewTruncated: false - }, { + const expert = rawRow.groupCode === "PAGED_EXPERT" + ? { + expertCode: rawRow.dataCode, + expertName: rawRow.subject, + source: "oracle", + itemCount: 1, + previewTruncated: false + } + : legacyNamedRowWorkflow.expertLegacyDescriptor(rawRow); + if (!expert) return; + addNamedCandidate(candidates, () => expertWorkflow.createExpertPlaylistEntry(expert, { id: `db-${itemIndex}-expert`, fadeDuration: DEFAULT_FADE_DURATION })); @@ -4549,22 +4999,21 @@ addFixedNamedCandidates(candidates, rawRow, itemIndex, now); addStockNamedCandidates(candidates, rawRow, itemIndex); addIndustryNamedCandidates(candidates, rawRow, itemIndex, now); - addComparisonNamedCandidates(candidates, rawRow, itemIndex); - addThemeNamedCandidates(candidates, rawRow, itemIndex); + addThemeNamedCandidates(candidates, rawRow, itemIndex, now); addOverseasNamedCandidates(candidates, rawRow, itemIndex); addExpertNamedCandidate(candidates, rawRow, itemIndex); addTradingHaltNamedCandidates(candidates, rawRow, itemIndex); addDefaultNamedCandidates(candidates, rawRow, itemIndex); - const trusted = new Map(); + const trusted = []; for (const candidate of candidates) { const restored = normalizeStoredPlaylist([{ ...candidate, enabled: rawRow.enabled }], { now }); const entry = restored.length === 1 ? restored[0] : null; - if (!entry || entry.enabled !== rawRow.enabled || !sameNamedSelection(entry.selection, rawRow)) continue; - const key = `${entry.builderKey}|${entry.code}|${JSON.stringify(entry.selection)}`; - if (!trusted.has(key)) trusted.set(key, entry); + if (!entry || entry.enabled !== rawRow.enabled || + !legacyNamedRowWorkflow.matchesCanonicalEntry(entry, rawRow, { now })) continue; + trusted.push(entry); } - return trusted.size === 1 ? [...trusted.values()][0] : null; + return legacyNamedRowWorkflow.selectUniqueCanonicalEntry(trusted, rawRow, { now }); } function prepareNamedPagePreflight(restoration) { @@ -4603,7 +5052,9 @@ const guards = new Map(); for (const row of restoration.rows) { const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" || - row.entry?.operator?.source === "legacy-manual-lists-workflow"; + row.entry?.operator?.source === "legacy-manual-lists-workflow" || + namedNxtThemeRestoreWorkflow.isMaterializedEntry(row.entry) || + foreignIndexCandleRestoreWorkflow.isMaterializedEntry(row.entry); const entry = row.trusted ? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText }) : namedInvalidEntry(restoration.definition, row.rawRow); @@ -4647,6 +5098,9 @@ state.namedPlaylist.pagePlanPending = null; state.namedPlaylist.pagePlanError = null; state.namedPlaylist.manualRestoreFinalized = true; + state.namedPlaylist.comparisonRestorePending = null; + state.namedPlaylist.foreignIndexCandleRestorePending = null; + state.namedPlaylist.nxtThemeRestorePending = null; } function saveNamedPlaylistBackup() { @@ -4711,15 +5165,133 @@ return `1/${pageCount}`; } - function isTrustedNamedPlaylistEntry(entry) { + function namedLegacyFieldsForEntry(entry, itemIndex, pageText) { + const manualFields = namedManualRestoreWorkflow.legacyFieldsForEntry(entry, itemIndex); + if (manualFields) { + if (manualFields[5] !== pageText) { + throw new Error("수동 항목의 원본 페이지 서명을 현재 검증 결과와 일치시킬 수 없습니다."); + } + return manualFields; + } + const selection = namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) || + legacyNamedRowWorkflow.legacySelectionForEntry(entry); + if (!selection) { + throw new Error("이 항목은 원본 PList와 상호 운용되는 7필드 서명이 아직 없습니다."); + } + const fields = namedPlaylistWorkflow.toLegacySevenFields( + { ...entry, selection }, + pageText); + const rawRow = { + itemIndex, + enabled: fields[0] === "1", + groupCode: fields[1], + subject: fields[2], + graphicType: fields[3], + subtype: fields[4], + pageText: fields[5], + dataCode: fields[6] + }; + if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) { + if (!namedNxtThemeRestoreWorkflow.matchesRaw(entry, rawRow)) { + throw new Error("NXT 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다."); + } + return fields; + } + if (entry.operator?.source === "legacy-comparison-workflow") { + const restoredComparison = comparisonWorkflow.restoreComparisonPlaylistEntry(entry); + if (!restoredComparison || restoredComparison.builderKey !== entry.builderKey || + restoredComparison.code !== entry.code || + !legacyNamedRowWorkflow.matchesCanonicalEntry(restoredComparison, rawRow)) { + throw new Error("비교 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다."); + } + return fields; + } + if (entry.operator?.source === "legacy-foreign-index-candle-workflow") { + const restoredForeignIndex = foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry); + if (!restoredForeignIndex || restoredForeignIndex.builderKey !== entry.builderKey || + restoredForeignIndex.code !== entry.code || + !legacyNamedRowWorkflow.matchesCanonicalEntry(restoredForeignIndex, rawRow)) { + throw new Error("해외지수 캔들 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다."); + } + return fields; + } + const roundTrip = resolveNamedPlaylistRawRow(rawRow, itemIndex); + if (!roundTrip || roundTrip.builderKey !== entry.builderKey || roundTrip.code !== entry.code) { + throw new Error("원본 PList 7필드 서명을 현재 항목으로 유일하게 복원할 수 없습니다."); + } + return fields; + } + + function isTrustedNamedPlaylistEntry(entry, itemIndex = 0) { const guard = state.namedPlaylist.guardByEntryId.get(entry?.id); if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false; + if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) { + if (!guard || !guard.trusted) return false; + try { + const legacySelection = namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry); + const fields = namedPlaylistWorkflow.toLegacySevenFields( + { ...entry, selection: legacySelection }, + namedPageTextForEntry(entry)); + return namedNxtThemeRestoreWorkflow.matchesRaw(entry, { + itemIndex, + enabled: fields[0] === "1", + groupCode: fields[1], + subject: fields[2], + graphicType: fields[3], + subtype: fields[4], + pageText: fields[5], + dataCode: fields[6] + }); + } catch { + return false; + } + } + if (entry?.operator?.source === "legacy-foreign-index-candle-workflow" && + (!guard || !guard.trusted)) return false; + if (namedManualRestoreWorkflow.isTrustedEntryForSave(entry, itemIndex)) return true; const selection = entry?.selection; if (!selection) return false; const now = namedNowForRawRow(selection); - const restored = normalizeStoredPlaylist([entry], { now }); + const restored = entry.operator?.source === "legacy-foreign-index-candle-workflow" + ? [foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean) + : normalizeStoredPlaylist([entry], { now }); if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey || restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false; + if ([ + "legacy-comparison-workflow", + "legacy-industry-workflow", + "legacy-foreign-index-candle-workflow" + ] + .includes(entry.operator?.source)) { + const legacySelection = legacyNamedRowWorkflow.legacySelectionForEntry(entry); + if (!legacySelection) return false; + let legacyFields; + try { + legacyFields = namedPlaylistWorkflow.toLegacySevenFields( + { ...entry, selection: legacySelection }, + namedPageTextForEntry(entry)); + } catch { + return false; + } + const rawRow = { + itemIndex: 0, + enabled: legacyFields[0] === "1", + groupCode: legacyFields[1], + subject: legacyFields[2], + graphicType: legacyFields[3], + subtype: legacyFields[4], + pageText: legacyFields[5], + dataCode: legacyFields[6] + }; + const restoredClosedEntry = entry.operator.source === "legacy-comparison-workflow" + ? comparisonWorkflow.restoreComparisonPlaylistEntry(entry) + : entry.operator.source === "legacy-industry-workflow" + ? industryWorkflow.restoreIndustryPlaylistEntry(entry) + : foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry); + return Boolean(restoredClosedEntry && restoredClosedEntry.builderKey === entry.builderKey && + restoredClosedEntry.code === entry.code && + legacyNamedRowWorkflow.matchesCanonicalEntry(restoredClosedEntry, rawRow)); + } let fields; try { fields = namedPlaylistWorkflow.toLegacySevenFields(entry, namedPageTextForEntry(entry)); @@ -4745,18 +5317,27 @@ } function namedPlaylistBusy() { - return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending); + return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending || + state.namedPlaylist.comparisonRestorePending || + state.namedPlaylist.foreignIndexCandleRestorePending || + state.namedPlaylist.nxtThemeRestorePending || + state.manualFinancialRestore.entries.size > 0); } function renderNamedPlaylist() { const workflow = state.namedPlaylist; const locked = isPlaylistSnapshotLocked(); const pending = workflow.pending || workflow.pagePlanPending; + const freshRestorePending = state.manualFinancialRestore.entries.size > 0 || + Boolean(workflow.comparisonRestorePending) || + Boolean(workflow.foreignIndexCandleRestorePending) || + Boolean(workflow.nxtThemeRestorePending); + const busy = Boolean(pending) || freshRestorePending; const selected = selectedNamedPlaylistDefinition(); const blockers = namedPlaylistPrepareBlockers(); const pageRetryAvailable = workflow.pagePlanError?.retryable === true || blockers.some(value => value.reason === "page-result-empty"); - const writeAllowed = Boolean(nativeBridge) && !locked && !pending && + const writeAllowed = Boolean(nativeBridge) && !locked && !busy && workflow.canMutate && !workflow.writeQuarantined; elements.namedPlaylistPanel.hidden = !workflow.open; @@ -4797,6 +5378,9 @@ status = pending.operation === "page-preflight" ? "현재 Oracle 조회 결과로 5·6·12행 장면의 페이지 수를 계산하고 있습니다. 이 조회는 송출 엔진을 호출하지 않습니다." : `${pending.operation.toLocaleUpperCase("en-US")} 요청을 처리하고 있습니다. 완료 전에는 다른 DB 작업이나 PREPARE를 실행하지 않습니다.`; + } else if (freshRestorePending) { + badge = "재검증 중"; + status = "DB 행의 수동·비교 장면 identity를 현재 저장소와 종목 마스터에서 재검증하고 있습니다. 완료 전에는 다른 DB 작업이나 PREPARE를 실행하지 않습니다."; } else if (workflow.loadedDefinition) { badge = blockers.length ? "복원 확인" : "불러옴"; status = blockers.length @@ -4821,13 +5405,13 @@ ? "PREPARED/PROGRAM 상태에서는 DB 플레이리스트 UI가 TAKE OUT까지 잠깁니다." : status; - elements.namedPlaylistRefreshButton.disabled = !nativeBridge || locked || Boolean(pending); - elements.namedPlaylistDefinitions.disabled = locked || Boolean(pending) || !workflow.definitions.length; - elements.namedPlaylistLoadButton.disabled = !nativeBridge || locked || Boolean(pending) || !selected; + elements.namedPlaylistRefreshButton.disabled = !nativeBridge || locked || busy; + elements.namedPlaylistDefinitions.disabled = locked || busy || !workflow.definitions.length; + elements.namedPlaylistLoadButton.disabled = !nativeBridge || locked || busy || !selected; elements.namedPlaylistPageRetryButton.hidden = !pageRetryAvailable; - elements.namedPlaylistPageRetryButton.disabled = !nativeBridge || locked || Boolean(pending) || + elements.namedPlaylistPageRetryButton.disabled = !nativeBridge || locked || busy || !workflow.restoration; - elements.namedPlaylistRestoreBackupButton.disabled = locked || Boolean(pending) || !hasNamedPlaylistBackup(); + elements.namedPlaylistRestoreBackupButton.disabled = locked || busy || !hasNamedPlaylistBackup(); elements.namedPlaylistProgramCode.disabled = !writeAllowed; elements.namedPlaylistProgramTitle.disabled = !writeAllowed; elements.namedPlaylistCreateButton.disabled = !writeAllowed; @@ -4840,7 +5424,7 @@ elements.namedPlaylistCreateButton, elements.namedPlaylistReplaceButton, elements.namedPlaylistDeleteButton - ]) button.setAttribute("aria-busy", String(Boolean(pending))); + ]) button.setAttribute("aria-busy", String(busy)); const unresolved = blockers.filter(value => value.reason === "trusted-restore-required").length; const pagePending = blockers.filter(value => value.reason === "page-recalculation-required").length; @@ -4925,7 +5509,8 @@ state.playlist, { isTrustedEntry: isTrustedNamedPlaylistEntry, - pageTextForEntry: namedPageTextForEntry + pageTextForEntry: namedPageTextForEntry, + fieldsForEntry: namedLegacyFieldsForEntry }); if (!window.confirm(`${definition.title}의 DB 플레이리스트를 현재 ${request.items.length}개 순서로 교체할까요? 이 작업은 한 트랜잭션으로 실행되며 자동 재시도하지 않습니다.`)) return; beginNamedPlaylistOperation("replace", request, definition.programCode); @@ -5014,7 +5599,10 @@ return; } try { - let restoration = namedPlaylistWorkflow.restoreRawRows(load, resolveNamedPlaylistRawRow); + let restoration = namedPlaylistWorkflow.restoreRawRows( + load, + resolveNamedPlaylistRawRow, + legacyNamedRowWorkflow.matchesCanonicalEntry); const pendingNamedManual = restoration.rows.flatMap(row => { if (row.trusted) return []; const pending = namedManualRestoreWorkflow.classify(row.rawRow, { @@ -5023,7 +5611,33 @@ }); return pending ? [pending] : []; }); - state.namedPlaylist.manualRestoreFinalized = pendingNamedManual.length === 0; + const pendingNamedComparison = restoration.rows.flatMap(row => { + if (row.trusted) return []; + const pending = namedComparisonRestoreWorkflow.classify(row.rawRow, { + id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`, + fadeDuration: DEFAULT_FADE_DURATION + }); + return pending ? [pending] : []; + }); + const pendingForeignIndexCandles = restoration.rows.flatMap(row => { + if (row.trusted) return []; + const pending = foreignIndexCandleRestoreWorkflow.classify(row.rawRow, { + id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`, + fadeDuration: DEFAULT_FADE_DURATION + }); + return pending ? [pending] : []; + }); + const pendingNxtThemes = restoration.rows.flatMap(row => { + if (row.trusted) return []; + const pending = namedNxtThemeRestoreWorkflow.classify(row.rawRow, { + id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`, + fadeDuration: DEFAULT_FADE_DURATION + }); + return pending ? [pending] : []; + }); + state.namedPlaylist.manualRestoreFinalized = + pendingNamedManual.length === 0 && pendingNamedComparison.length === 0 && + pendingForeignIndexCandles.length === 0 && pendingNxtThemes.length === 0; if (state.namedPlaylist.manualRestoreFinalized) { restoration = prepareNamedPagePreflight(restoration); } @@ -5032,6 +5646,24 @@ synchronizeGlobalCandleOptions(false); state.namedPlaylist.guardByEntryId = materialized.guards; state.namedPlaylist.restoration = restoration; + state.namedPlaylist.comparisonRestorePending = pendingNamedComparison.length + ? Object.freeze({ + requestId: load.requestId, + rows: Object.freeze(pendingNamedComparison) + }) + : null; + state.namedPlaylist.foreignIndexCandleRestorePending = pendingForeignIndexCandles.length + ? Object.freeze({ + requestId: load.requestId, + rows: Object.freeze(pendingForeignIndexCandles) + }) + : null; + state.namedPlaylist.nxtThemeRestorePending = pendingNxtThemes.length + ? Object.freeze({ + requestId: load.requestId, + rows: Object.freeze(pendingNxtThemes) + }) + : null; state.namedPlaylist.pagePlanError = null; state.namedPlaylist.loadedDefinition = load.definition; state.namedPlaylist.selectedProgramCode = load.definition.programCode; @@ -5057,6 +5689,273 @@ } } + function failNamedComparisonRestore(record, message) { + if (!record || state.namedPlaylist.comparisonRestorePending !== record) return; + state.namedPlaylist.comparisonRestorePending = null; + state.namedPlaylist.error = message; + state.namedPlaylist.status = "error"; + finalizeNamedManualRestores(); + renderPlaylist(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedComparisonRestoreResults(payload) { + const record = state.namedPlaylist.comparisonRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const response = namedComparisonRestoreWorkflow.normalizeResponse( + payload, + record.requestId, + record.rows); + if (!response) { + failNamedComparisonRestore( + record, + "Native Bridge의 비교 행 재검증 응답이 현재 DB 목록과 일치하지 않습니다."); + return; + } + const restoration = state.namedPlaylist.restoration; + if (!restoration || isPlaylistSnapshotLocked()) { + failNamedComparisonRestore( + record, + "비교 행 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다."); + return; + } + const rows = [...restoration.rows]; + let restoredCount = 0; + const failures = new Map(); + for (let index = 0; index < response.rows.length; index += 1) { + const pending = record.rows[index]; + const outcome = response.rows[index]; + if (outcome.status === "failed") { + failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1); + continue; + } + const storedRow = rows[pending.itemIndex]; + const entry = namedComparisonRestoreWorkflow.materialize(pending, outcome); + if (!storedRow || storedRow.trusted || !entry || + state.playlist[pending.itemIndex]?.id !== pending.entry.id || + entry.enabled !== storedRow.rawRow.enabled || + !legacyNamedRowWorkflow.matchesCanonicalEntry(entry, storedRow.rawRow)) { + failures.set("MATERIALIZATION_MISMATCH", + (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1); + continue; + } + rows[pending.itemIndex] = Object.freeze({ + ...storedRow, + entry, + trusted: true + }); + state.playlist[pending.itemIndex] = entry; + restoredCount += 1; + } + state.namedPlaylist.restoration = Object.freeze({ + ...restoration, + rows: Object.freeze(rows) + }); + state.namedPlaylist.comparisonRestorePending = null; + addLog(`DB 비교 행 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}건`); + if (failures.size) { + const summary = [...failures.entries()] + .map(([code, count]) => `${code} ${count}`) + .join(" · "); + addLog(`DB 비교 행 차단 사유 · ${summary}`); + } + finalizeNamedManualRestores(); + renderPlaylist(); + updatePreview(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedComparisonRestoreError(payload) { + const record = state.namedPlaylist.comparisonRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const error = namedComparisonRestoreWorkflow.normalizeError(payload, record.requestId); + failNamedComparisonRestore( + record, + error?.message || "비교 행의 현재 종목 마스터 재검증을 완료하지 못했습니다."); + } + + function failNamedForeignIndexCandleRestore(record, message) { + if (!record || state.namedPlaylist.foreignIndexCandleRestorePending !== record) return; + state.namedPlaylist.foreignIndexCandleRestorePending = null; + state.namedPlaylist.error = message; + state.namedPlaylist.status = "error"; + finalizeNamedManualRestores(); + renderPlaylist(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedForeignIndexCandleRestoreResults(payload) { + const record = state.namedPlaylist.foreignIndexCandleRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const response = foreignIndexCandleRestoreWorkflow.normalizeResponse( + payload, + record.requestId, + record.rows); + if (!response) { + failNamedForeignIndexCandleRestore( + record, + "Native Bridge의 해외지수 캔들 재검증 응답이 현재 DB 목록과 일치하지 않습니다."); + return; + } + const restoration = state.namedPlaylist.restoration; + if (!restoration || isPlaylistSnapshotLocked()) { + failNamedForeignIndexCandleRestore( + record, + "해외지수 캔들 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다."); + return; + } + + const rows = [...restoration.rows]; + let restoredCount = 0; + const failures = new Map(); + for (let index = 0; index < response.rows.length; index += 1) { + const pending = record.rows[index]; + const outcome = response.rows[index]; + if (outcome.status === "failed") { + failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1); + continue; + } + const storedRow = rows[pending.itemIndex]; + const entry = foreignIndexCandleRestoreWorkflow.materialize(pending, outcome); + if (!storedRow || storedRow.trusted || !entry || + !foreignIndexCandleRestoreWorkflow.isMaterializedEntry(entry) || + state.playlist[pending.itemIndex]?.id !== pending.entry.id || + entry.enabled !== storedRow.rawRow.enabled || + !legacyNamedRowWorkflow.matchesCanonicalEntry(entry, storedRow.rawRow)) { + failures.set("MATERIALIZATION_MISMATCH", + (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1); + continue; + } + rows[pending.itemIndex] = Object.freeze({ + ...storedRow, + entry, + trusted: true + }); + state.playlist[pending.itemIndex] = entry; + restoredCount += 1; + } + state.namedPlaylist.restoration = Object.freeze({ + ...restoration, + rows: Object.freeze(rows) + }); + state.namedPlaylist.foreignIndexCandleRestorePending = null; + addLog(`DB 해외지수 캔들 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}건`); + if (failures.size) { + const summary = [...failures.entries()] + .map(([code, count]) => `${code} ${count}`) + .join(" · "); + addLog(`DB 해외지수 캔들 차단 사유 · ${summary}`); + } + finalizeNamedManualRestores(); + renderPlaylist(); + updatePreview(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedForeignIndexCandleRestoreError(payload) { + const record = state.namedPlaylist.foreignIndexCandleRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const error = foreignIndexCandleRestoreWorkflow.normalizeError(payload, record.requestId); + failNamedForeignIndexCandleRestore( + record, + error?.message || "해외지수 캔들의 현재 master 재검증을 완료하지 못했습니다."); + } + + function failNamedNxtThemeRestore(record, message) { + if (!record || state.namedPlaylist.nxtThemeRestorePending !== record) return; + state.namedPlaylist.nxtThemeRestorePending = null; + state.namedPlaylist.error = message; + state.namedPlaylist.status = "error"; + finalizeNamedManualRestores(); + renderPlaylist(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedNxtThemeRestoreResults(payload) { + const record = state.namedPlaylist.nxtThemeRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const response = namedNxtThemeRestoreWorkflow.normalizeResponse( + payload, + record.requestId, + record.rows); + if (!response) { + failNamedNxtThemeRestore( + record, + "Native Bridge의 NXT 테마 재검증 응답이 현재 DB 목록과 일치하지 않습니다."); + return; + } + const restoration = state.namedPlaylist.restoration; + if (!restoration || isPlaylistSnapshotLocked()) { + failNamedNxtThemeRestore( + record, + "NXT 테마 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다."); + return; + } + + const rows = [...restoration.rows]; + let restoredCount = 0; + let remappedCount = 0; + const failures = new Map(); + for (let index = 0; index < response.rows.length; index += 1) { + const pending = record.rows[index]; + const outcome = response.rows[index]; + if (outcome.status === "failed") { + failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1); + continue; + } + const storedRow = rows[pending.itemIndex]; + const entry = namedNxtThemeRestoreWorkflow.materialize(pending, outcome); + if (!storedRow || storedRow.trusted || !entry || + !namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry) || + state.playlist[pending.itemIndex]?.id !== pending.entry.id || + entry.enabled !== storedRow.rawRow.enabled || + !namedNxtThemeRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) { + failures.set("MATERIALIZATION_MISMATCH", + (failures.get("MATERIALIZATION_MISMATCH") || 0) + 1); + continue; + } + rows[pending.itemIndex] = Object.freeze({ + ...storedRow, + entry, + trusted: true + }); + state.playlist[pending.itemIndex] = entry; + restoredCount += 1; + if (outcome.codeWasRemapped) remappedCount += 1; + } + state.namedPlaylist.restoration = Object.freeze({ + ...restoration, + rows: Object.freeze(rows) + }); + state.namedPlaylist.nxtThemeRestorePending = null; + addLog(`DB NXT 테마 재검증 · 복원 ${restoredCount}건 · code 갱신 ${remappedCount}건 · 차단 ${response.rows.length - restoredCount}건`); + if (failures.size) { + const summary = [...failures.entries()] + .map(([code, count]) => `${code} ${count}`) + .join(" · "); + addLog(`DB NXT 테마 차단 사유 · ${summary}`); + } + finalizeNamedManualRestores(); + renderPlaylist(); + updatePreview(); + renderNamedPlaylist(); + renderPlayout(); + } + + function handleNamedNxtThemeRestoreError(payload) { + const record = state.namedPlaylist.nxtThemeRestorePending; + if (!record || payload?.requestId !== record.requestId) return; + const error = namedNxtThemeRestoreWorkflow.normalizeError(payload, record.requestId); + failNamedNxtThemeRestore( + record, + error?.message || "NXT 테마의 현재 MariaDB identity 재검증을 완료하지 못했습니다."); + } + function requestNamedPlaylistPagePlans() { const workflow = state.namedPlaylist; if (!nativeBridge || isPlaylistSnapshotLocked() || workflow.pending || @@ -6443,6 +7342,16 @@ addLog(`${marketTitles[payload.view]} DB 조회 실패 · ${state.liveData.error}`); } + function handleBridgeError(payload) { + const rawMessage = typeof payload?.message === "string" ? payload.message.trim() : ""; + const message = rawMessage && rawMessage.length <= 512 && + !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(rawMessage) + ? rawMessage + : "Native Bridge가 올바르지 않은 요청을 거부했습니다."; + addLog(`Native Bridge 요청 오류 · ${message}`); + showToast(message); + } + function handleNativeMessage(event) { let message = event.data; if (typeof message === "string") { @@ -6455,6 +7364,9 @@ if (operatorCatalogUi?.handleMessage(message.type, message.payload)) return; switch (message.type) { + case "bridge-error": + handleBridgeError(message.payload); + break; case "app-info": { const info = message.payload || {}; elements.bridgeState.textContent = "연결됨"; @@ -6512,6 +7424,12 @@ handleComparisonWorldError(message.payload); } break; + case "legacy-comparison-import-results": + handleLegacyComparisonImportResults(message.payload); + break; + case "legacy-comparison-import-error": + handleLegacyComparisonImportError(message.payload); + break; case "overseas-industry-results": handleOverseasSearchResults("industry", message.payload); break; @@ -6566,6 +7484,24 @@ case "named-playlist-load-results": handleNamedPlaylistLoadResults(message.payload); break; + case "named-comparison-restore-results": + handleNamedComparisonRestoreResults(message.payload); + break; + case "named-comparison-restore-error": + handleNamedComparisonRestoreError(message.payload); + break; + case "named-foreign-index-candle-restore-results": + handleNamedForeignIndexCandleRestoreResults(message.payload); + break; + case "named-foreign-index-candle-restore-error": + handleNamedForeignIndexCandleRestoreError(message.payload); + break; + case "named-nxt-theme-restore-results": + handleNamedNxtThemeRestoreResults(message.payload); + break; + case "named-nxt-theme-restore-error": + handleNamedNxtThemeRestoreError(message.payload); + break; case "named-playlist-mutation-results": handleNamedPlaylistMutationResults(message.payload); break; @@ -6591,17 +7527,23 @@ } function bindEvents() { - document.querySelector("#marketNav").addEventListener("click", event => { + const marketNav = document.querySelector("#marketNav"); + const marketNavReorderController = marketNavReorder.createController({ + nav: marketNav, + isBlocked: () => hasOpenOperatorModal() || isPlaylistSnapshotLocked() + }); + if (!marketNavReorderController.mount()) { + throw new Error("Market-nav identity contract is invalid."); + } + + marketNav.addEventListener("click", event => { const button = event.target.closest("button[data-market]"); if (!button) return; const previousMarket = state.activeMarket; document.querySelectorAll(".nav-item").forEach(item => item.classList.toggle("active", item === button)); state.activeMarket = button.dataset.market; if (previousMarket !== state.activeMarket && industryWorkflow.markets.includes(state.activeMarket)) { - state.stockWorkflowCollapsed = true; state.industryWorkflowCollapsed = false; - } else if (previousMarket !== state.activeMarket && state.activeMarket === "dashboard") { - state.stockWorkflowCollapsed = false; } elements.workspaceTitle.textContent = marketTitles[state.activeMarket]; renderStockWorkflow(); @@ -6631,12 +7573,10 @@ }); elements.stockSearchForm.addEventListener("submit", requestStockSearch); + elements.stockCutBatchAdd.addEventListener("click", addSelectedStockCuts); + elements.stockCutSelectionClear.addEventListener("click", clearStockCutSelection); elements.stockWorkflowToggle.addEventListener("click", () => { state.stockWorkflowCollapsed = !state.stockWorkflowCollapsed; - if (!state.stockWorkflowCollapsed && industryWorkflow.markets.includes(state.activeMarket)) { - state.industryWorkflowCollapsed = true; - renderIndustryWorkflow(); - } renderStockWorkflow(); }); elements.legacyFixedToggle.addEventListener("click", () => { @@ -6650,10 +7590,6 @@ elements.industryRetry.addEventListener("click", () => requestIndustries(state.activeMarket)); elements.industryWorkflowToggle.addEventListener("click", () => { state.industryWorkflowCollapsed = !state.industryWorkflowCollapsed; - if (!state.industryWorkflowCollapsed) { - state.stockWorkflowCollapsed = true; - renderStockWorkflow(); - } renderIndustryWorkflow(); }); elements.industrySearch.addEventListener("input", event => { @@ -6682,6 +7618,7 @@ }); elements.comparisonDomesticSearchForm.addEventListener("submit", requestComparisonDomesticSearch); elements.comparisonWorldSearchForm.addEventListener("submit", requestComparisonWorldSearch); + elements.comparisonImportLegacy.addEventListener("click", requestLegacyComparisonImport); elements.comparisonSwap.addEventListener("click", () => { if (isPlaylistSnapshotLocked()) return; const swapped = comparisonWorkflow.swapPair([ @@ -6734,6 +7671,7 @@ operatorCatalogUi?.openExpert(context || undefined); }); elements.tradingHaltSearchForm.addEventListener("submit", requestTradingHaltSearch); + elements.tradingHaltNameSort.addEventListener("click", toggleTradingHaltNameSort); elements.globalMa5.addEventListener("change", () => applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true)); elements.globalMa20.addEventListener("change", () => @@ -6805,7 +7743,36 @@ requestMarketData(state.activeMarket); }); + function hasOpenOperatorModal() { + return [...document.querySelectorAll( + 'dialog[open], [role="dialog"][aria-modal="true"]')] + .some(element => !element.hidden && !element.closest("[hidden]")); + } + + function isolateOperatorModalShortcut(event) { + if (!hasOpenOperatorModal()) return false; + + const key = String(event.key || ""); + const commandShortcut = ["F2", "F3", "F8", "Escape"].includes(key); + const playlistShortcut = ["Home", "End", "Delete", " "].includes(key); + const focusShortcut = event.ctrlKey && key.toLowerCase() === "k"; + if (commandShortcut || focusShortcut) { + event.preventDefault(); + } + if (commandShortcut || playlistShortcut || focusShortcut) { + event.stopPropagation(); + if (!event.repeat && ["F8", "Escape"].includes(key)) { + showToast("편집 창을 먼저 닫은 뒤 송출 명령을 실행하세요."); + } + } + + // Keep typing and native input editing inside the modal, but never let a key continue + // into the workspace-level playout, playlist, or focus shortcuts below. + return true; + } + document.addEventListener("keydown", event => { + if (isolateOperatorModalShortcut(event)) return; if (["F2", "F3", "F8", "Escape"].includes(event.key)) { event.preventDefault(); if (event.repeat) return; @@ -6925,6 +7892,7 @@ renderDatabaseStatus(); addLog("Native Bridge 없이 미리보기 전용 DRY RUN으로 실행"); } + requestMarketData(state.activeMarket); } initialize(); diff --git a/Web/comparison-import-workflow.js b/Web/comparison-import-workflow.js new file mode 100644 index 0000000..f801203 --- /dev/null +++ b/Web/comparison-import-workflow.js @@ -0,0 +1,235 @@ +(function (root, factory) { + "use strict"; + + const comparison = typeof module === "object" && module.exports + ? require("./comparison-workflow.js") + : root?.MbnComparisonWorkflow; + const api = Object.freeze(factory(comparison)); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnComparisonImportWorkflow = api; +})(typeof globalThis === "object" ? globalThis : this, function (comparison) { + "use strict"; + + if (!comparison) throw new Error("Comparison workflow dependency is unavailable."); + + const IMPORT_SCHEMA_VERSION = 1; + const STORAGE_ENVELOPE_SCHEMA_VERSION = 1; + const MAXIMUM_PAIRS = 500; + const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/; + const digestPattern = /^[A-F0-9]{64}$/; + const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/; + const controlCharacterPattern = /[\u0000-\u001f\u007f]/u; + const importedRecordIdPattern = /^legacy-compare-[a-f0-9]{12}-[1-9][0-9]{0,2}$/u; + + function hasOnlyKeys(value, expected) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const keys = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return keys.length === wanted.length && keys.every((key, index) => key === wanted[index]); + } + + function safeText(value, maximumLength) { + return typeof value === "string" && value.length > 0 && value.length <= maximumLength && + value === value.trim() && !controlCharacterPattern.test(value) + ? value + : null; + } + + function normalizeWireTarget(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || + typeof value.kind !== "string") return null; + let candidate; + switch (value.kind) { + case "market-target": + if (!hasOnlyKeys(value, ["kind", "target"]) || !safeText(value.target, 64)) return null; + candidate = { kind: value.kind, target: value.target }; + break; + case "krx-stock": + case "nxt-stock": + if (!hasOnlyKeys(value, ["kind", "market", "stockName", "stockCode"]) || + !safeText(value.market, 8) || !safeText(value.stockName, 200) || + !safeText(value.stockCode, 32)) return null; + candidate = { + kind: value.kind, + market: value.market, + stockName: value.stockName, + stockCode: value.stockCode + }; + break; + case "world-stock": + if (!hasOnlyKeys(value, ["kind", "inputName", "symbol", "nation"]) || + !safeText(value.inputName, 200) || !safeText(value.symbol, 64) || + !safeText(value.nation, 2)) return null; + candidate = { + kind: value.kind, + inputName: value.inputName, + displayName: value.inputName, + symbol: value.symbol, + nation: value.nation + }; + break; + default: + return null; + } + + return comparison.normalizeTarget(candidate); + } + + function normalizeImportResponse(value, expectedRequestId) { + if (!requestIdPattern.test(String(expectedRequestId || "")) || + !hasOnlyKeys(value, ["requestId", "sourceSha256", "sourceRowCount", "retrievedAt", "pairs"]) || + value.requestId !== expectedRequestId || + typeof value.sourceSha256 !== "string" || !digestPattern.test(value.sourceSha256) || + !Number.isInteger(value.sourceRowCount) || value.sourceRowCount < 1 || + value.sourceRowCount > MAXIMUM_PAIRS || + typeof value.retrievedAt !== "string" || !isoDatePattern.test(value.retrievedAt) || + !Number.isFinite(Date.parse(value.retrievedAt)) || + !Array.isArray(value.pairs) || value.pairs.length !== value.sourceRowCount) return null; + + const pairs = []; + const signatures = new Set(); + for (let index = 0; index < value.pairs.length; index += 1) { + const raw = value.pairs[index]; + if (!hasOnlyKeys(raw, ["rowNumber", "first", "second"]) || + raw.rowNumber !== index + 1) return null; + const first = normalizeWireTarget(raw.first); + const second = normalizeWireTarget(raw.second); + const pair = comparison.normalizePair([first, second]); + if (!pair) return null; + const signature = pair.map(comparison.targetKey).join("\u001f"); + if (!signature || signatures.has(signature)) return null; + signatures.add(signature); + pairs.push(Object.freeze({ rowNumber: index + 1, pair })); + } + + return Object.freeze({ + requestId: value.requestId, + sourceSha256: value.sourceSha256, + sourceRowCount: value.sourceRowCount, + retrievedAt: value.retrievedAt, + pairs: Object.freeze(pairs) + }); + } + + function pairSignature(value) { + const pair = comparison.normalizePair(value); + return pair ? pair.map(comparison.targetKey).join("\u001f") : null; + } + + function mergeImport(currentValue, importValue) { + const current = comparison.normalizePairList(currentValue); + if (!importValue || !Array.isArray(importValue.pairs) || + !digestPattern.test(String(importValue.sourceSha256 || "")) || + !Number.isInteger(importValue.sourceRowCount) || + importValue.sourceRowCount !== importValue.pairs.length || + importValue.sourceRowCount < 1 || importValue.sourceRowCount > MAXIMUM_PAIRS || + current.pairs.length + importValue.pairs.length > MAXIMUM_PAIRS) { + throw new RangeError("The legacy comparison import cannot fit the saved-pair schema."); + } + + const ids = new Set(current.pairs.map(record => record.id)); + const signatures = new Set(current.pairs.map(record => pairSignature(record.pair))); + let merged = current; + for (let index = 0; index < importValue.pairs.length; index += 1) { + const imported = importValue.pairs[index]; + if (imported?.rowNumber !== index + 1) { + throw new TypeError("The legacy comparison import row order is invalid."); + } + const signature = pairSignature(imported?.pair); + if (!signature || signatures.has(signature)) { + throw new RangeError("The legacy comparison import contains an existing pair."); + } + + const id = `legacy-compare-${importValue.sourceSha256.slice(0, 12).toLocaleLowerCase("en-US")}-${imported.rowNumber}`; + if (ids.has(id)) { + throw new RangeError("The legacy comparison import id already exists."); + } + + const record = comparison.createPairRecord(id, imported.pair); + if (!record) throw new TypeError("The legacy comparison import pair is invalid."); + merged = comparison.appendPair(merged, record); + ids.add(id); + signatures.add(signature); + } + + return merged; + } + + function createMarker(importValue, importedAt = new Date().toISOString()) { + if (!importValue || !digestPattern.test(String(importValue.sourceSha256 || "")) || + !Number.isInteger(importValue.sourceRowCount) || importValue.sourceRowCount < 1 || + importValue.sourceRowCount > MAXIMUM_PAIRS || + typeof importedAt !== "string" || !isoDatePattern.test(importedAt) || + !Number.isFinite(Date.parse(importedAt))) return null; + return Object.freeze({ + schemaVersion: IMPORT_SCHEMA_VERSION, + sourceSha256: importValue.sourceSha256, + importedPairCount: importValue.sourceRowCount, + importedAt + }); + } + + function normalizeMarker(value) { + if (!hasOnlyKeys(value, ["schemaVersion", "sourceSha256", "importedPairCount", "importedAt"]) || + value.schemaVersion !== IMPORT_SCHEMA_VERSION || + !digestPattern.test(String(value.sourceSha256 || "")) || + !Number.isInteger(value.importedPairCount) || value.importedPairCount < 1 || + value.importedPairCount > MAXIMUM_PAIRS || + typeof value.importedAt !== "string" || !isoDatePattern.test(value.importedAt) || + !Number.isFinite(Date.parse(value.importedAt))) return null; + return Object.freeze({ + schemaVersion: IMPORT_SCHEMA_VERSION, + sourceSha256: value.sourceSha256, + importedPairCount: value.importedPairCount, + importedAt: value.importedAt + }); + } + + function normalizeExactPairList(value) { + if (!hasOnlyKeys(value, ["schemaVersion", "pairs"]) || !Array.isArray(value.pairs) || + value.pairs.length > MAXIMUM_PAIRS || value.pairs.some(record => + !hasOnlyKeys(record, ["id", "pair"]))) return null; + const normalized = comparison.normalizePairList(value); + if (normalized.pairs.length !== value.pairs.length || + normalized.pairs.some((record, index) => record.id !== value.pairs[index].id)) return null; + return normalized; + } + + function normalizeStorageEnvelope(value) { + if (!hasOnlyKeys(value, ["schemaVersion", "pairList", "legacyImportMarker"]) || + value.schemaVersion !== STORAGE_ENVELOPE_SCHEMA_VERSION) return null; + const pairList = normalizeExactPairList(value.pairList); + const marker = value.legacyImportMarker === null + ? null + : normalizeMarker(value.legacyImportMarker); + if (!pairList || (value.legacyImportMarker !== null && !marker) || + (!marker && pairList.pairs.some(record => importedRecordIdPattern.test(record.id)))) { + return null; + } + return Object.freeze({ + schemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION, + pairList, + legacyImportMarker: marker + }); + } + + function createStorageEnvelope(pairList, marker = null) { + return normalizeStorageEnvelope({ + schemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION, + pairList, + legacyImportMarker: marker + }); + } + + return { + schemaVersion: IMPORT_SCHEMA_VERSION, + storageEnvelopeSchemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION, + maximumPairs: MAXIMUM_PAIRS, + normalizeImportResponse, + mergeImport, + createMarker, + normalizeMarker, + createStorageEnvelope, + normalizeStorageEnvelope + }; +}); diff --git a/Web/comparison-workflow.js b/Web/comparison-workflow.js index aa0eef8..758e627 100644 --- a/Web/comparison-workflow.js +++ b/Web/comparison-workflow.js @@ -171,6 +171,14 @@ return [null, null]; } + function krxIdentityEnvelope(value) { + const pair = normalizePair(value); + if (!pair || pair.some(target => target.kind !== "krx-stock")) return ""; + return pair + .map(target => `${target.market === "kospi" ? "KOSPI" : "KOSDAQ"}:${target.stockCode}`) + .join("|"); + } + function normalizePair(value) { const [firstValue, secondValue] = pairParts(value); const first = normalizeTarget(firstValue); @@ -288,6 +296,10 @@ const builderKey = selectedAction.kind === "return" ? "s5029" : selectedAction.builderKey; const code = selectedAction.kind === "return" ? "5029" : selectedAction.code; const graphicType = selectedAction.kind === "return" ? "RETURN_COMPARISON" : selectedAction.graphicType; + const dataCode = krxIdentityEnvelope(pair); + if (!dataCode) { + throw new RangeError("Comparison graphs require two exact KRX stock identities."); + } return Object.freeze({ builderKey, code, @@ -297,7 +309,7 @@ subject, graphicType, subtype: selectedAction.period, - dataCode: "" + dataCode }), effectiveMode: compatibility.effectiveMode, warning: compatibility.warning @@ -482,6 +494,7 @@ getCompatibility, isActionAllowed, buildComparisonSelection, + krxIdentityEnvelope, createComparisonPlaylistEntry, createYieldBatchEntries, restoreComparisonPlaylistEntry, diff --git a/Web/index.html b/Web/index.html index e28edb8..006005a 100644 --- a/Web/index.html +++ b/Web/index.html @@ -22,8 +22,8 @@