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 @@