feat: restore legacy drag events and Tornado launch integration

This commit is contained in:
2026-07-15 15:35:25 +09:00
parent 6e0c275fdd
commit 06a16e5741
32 changed files with 3096 additions and 101 deletions

View File

@@ -21,6 +21,11 @@ public interface IThemeCatalogPersistenceService
{
bool CanMutate { get; }
Task<bool> ThemeNameExistsAsync(
ThemeMarket market,
string themeTitle,
CancellationToken cancellationToken = default);
Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default);
@@ -51,6 +56,8 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
internal const string NextKrxCodeQueryName = "THEME_NEXT_CODE_KRX";
internal const string NextNxtCodeQueryName = "THEME_NEXT_CODE_NXT";
internal const string NameExistsKrxQueryName = "THEME_NAME_EXISTS_KRX";
internal const string NameExistsNxtQueryName = "THEME_NAME_EXISTS_NXT";
private const int MaximumThemeTitleLength = 128;
private const int MaximumItemNameLength = 200;
@@ -95,6 +102,20 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
FROM SB_LIST
""";
private const string NameExistsKrxSql = """
SELECT COUNT(*) THEME_COUNT
FROM SB_LIST s
WHERE s.SB_MARKET = 'KRX'
AND s.SB_TITLE = :theme_title
""";
private const string NameExistsNxtSql = """
SELECT COUNT(*) THEME_COUNT
FROM SB_LIST s
WHERE s.SB_MARKET = 'NXT'
AND s.SB_TITLE = @theme_title
""";
private readonly IDataQueryExecutor _queryExecutor;
private readonly IOperatorCatalogMutationExecutor? _mutationExecutor;
@@ -113,6 +134,38 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
public bool CanMutate => _mutationExecutor is not null;
public async Task<bool> ThemeNameExistsAsync(
ThemeMarket market,
string themeTitle,
CancellationToken cancellationToken = default)
{
var canonicalTitle = ValidateThemeTitle(themeTitle, market, nameof(themeTitle));
var profile = ThemeMutationProfiles.For(market);
cancellationToken.ThrowIfCancellationRequested();
var query = new DataQuerySpec(
market == ThemeMarket.Krx ? NameExistsKrxSql : NameExistsNxtSql,
[new DataQueryParameter("theme_title", canonicalTitle, DbType.String)]);
query.ValidateFor(profile.Source);
var table = await _queryExecutor.ExecuteAsync(
profile.Source,
market == ThemeMarket.Krx ? NameExistsKrxQueryName : NameExistsNxtQueryName,
query,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (table is null ||
table.Columns.Count != 1 ||
!string.Equals(table.Columns[0].ColumnName, "THEME_COUNT", StringComparison.Ordinal) ||
table.Rows.Count != 1 ||
!TryReadNonNegativeCount(table.Rows[0][0], out var count))
{
throw new ThemeSelectionDataException(
"Theme name availability data has an invalid schema or value.");
}
return count > 0;
}
public async Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default)
@@ -417,6 +470,27 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
return value;
}
private static bool TryReadNonNegativeCount(object? value, out decimal count)
{
count = 0;
if (value is null or DBNull || value is bool || value is char || value is string)
{
return false;
}
try
{
count = Convert.ToDecimal(value, CultureInfo.InvariantCulture);
return count >= 0 && decimal.Truncate(count) == count;
}
catch (Exception exception) when (
exception is InvalidCastException or FormatException or OverflowException)
{
count = 0;
return false;
}
}
private static bool IsSafeText(string value)
{
foreach (var character in value)

View File

@@ -23,6 +23,12 @@ public enum LegacyPlaylistMoveDirection
Down
}
public enum LegacyDragDropPosition
{
Before,
After
}
public enum LegacyOperatorTabId
{
Overseas,
@@ -549,6 +555,29 @@ public sealed class LegacyOperatorController
return await SelectTabAsync(source, cancellationToken).ConfigureAwait(false);
}
public Task<LegacyOperatorSnapshot> SwapTabsAtExpectedPositionsAsync(
LegacyOperatorTabId source,
LegacyOperatorTabId target,
int expectedSourceIndex,
int expectedTargetIndex,
CancellationToken cancellationToken = default)
{
if (source == target ||
expectedSourceIndex < 0 || expectedSourceIndex >= _tabOrder.Count ||
expectedTargetIndex < 0 || expectedTargetIndex >= _tabOrder.Count ||
expectedSourceIndex == expectedTargetIndex ||
_tabOrder[expectedSourceIndex] != source ||
_tabOrder[expectedTargetIndex] != target)
{
return Task.FromResult(CreateSnapshot());
}
// The expected positions make a repeated dragover message idempotent. Once
// the first message swaps the two tabs, an identical delivery no longer
// matches the C#-owned order and therefore cannot swap them back.
return SwapTabsAsync(source, target, cancellationToken);
}
public LegacyOperatorSnapshot SetMovingAverages(bool ma5, bool ma20)
{
var next = new LegacyMovingAverageSelection(ma5, ma20);
@@ -831,6 +860,31 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReorderOperatorCatalogDraftRow(
string sourceRowId,
string targetRowId,
LegacyDragDropPosition position)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
}
var beforeRevision = _operatorCatalogWorkflow.Current.Revision;
var reordered = _operatorCatalogWorkflow.ReorderDraftRow(
sourceRowId,
targetRowId,
position);
if (reordered.Revision == beforeRevision)
{
return CreateSnapshot();
}
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount(
string rowId,
decimal buyAmount)
@@ -882,6 +936,23 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> CheckOperatorCatalogThemeNameAsync(
string editorName,
CancellationToken cancellationToken = default)
{
if (_operatorCatalogWorkflow is null)
{
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
}
await _operatorCatalogWorkflow.CheckThemeNameAvailabilityAsync(
editorName,
cancellationToken).ConfigureAwait(false);
ApplyOperatorCatalogStatus();
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> SaveOperatorCatalogAsync(
string editorName,
CancellationToken cancellationToken = default)
@@ -2769,6 +2840,82 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReorderPlaylistRows(
string sourceRowId,
string targetRowId,
LegacyDragDropPosition position)
{
ArgumentNullException.ThrowIfNull(sourceRowId);
ArgumentNullException.ThrowIfNull(targetRowId);
var sourceIndex = FindPlaylistIndex(sourceRowId);
var targetIndex = FindPlaylistIndex(targetRowId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex)
{
return CreateSnapshot();
}
var sourceWasSelected = _selectedPlaylistRowIds.Contains(sourceRowId);
var movingIds = sourceWasSelected
? new HashSet<string>(_selectedPlaylistRowIds, StringComparer.Ordinal)
: new HashSet<string>([sourceRowId], StringComparer.Ordinal);
if (movingIds.Contains(targetRowId))
{
return CreateSnapshot();
}
var movingRows = _playlist
.Where(row => movingIds.Contains(row.RowId))
.ToArray();
var remainingRows = _playlist
.Where(row => !movingIds.Contains(row.RowId))
.ToList();
var remainingTargetIndex = remainingRows.FindIndex(row =>
string.Equals(row.RowId, targetRowId, StringComparison.Ordinal));
if (movingRows.Length == 0 || remainingTargetIndex < 0)
{
return CreateSnapshot();
}
var insertionIndex = position switch
{
LegacyDragDropPosition.Before => remainingTargetIndex,
LegacyDragDropPosition.After => remainingTargetIndex + 1,
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null)
};
remainingRows.InsertRange(insertionIndex, movingRows);
var orderChanged = !_playlist
.Select(static row => row.RowId)
.SequenceEqual(
remainingRows.Select(static row => row.RowId),
StringComparer.Ordinal);
var selectionChanged = !sourceWasSelected ||
!string.Equals(_activePlaylistRowId, sourceRowId, StringComparison.Ordinal);
if (!orderChanged && !selectionChanged)
{
return CreateSnapshot();
}
if (orderChanged)
{
_playlist.Clear();
_playlist.AddRange(remainingRows);
}
if (!sourceWasSelected)
{
_selectedPlaylistRowIds.Clear();
_selectedPlaylistRowIds.Add(sourceRowId);
}
_activePlaylistRowId = sourceRowId;
_playlistCursorIndex = FindPlaylistIndex(sourceRowId);
_playlistPriorCursorIndex = _playlistCursorIndex;
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorSnapshot> RefreshNamedPlaylistsAsync(
CancellationToken cancellationToken = default)
{

View File

@@ -25,6 +25,14 @@ public enum LegacyOperatorCatalogStatusKind
Error
}
public enum LegacyThemeNameAvailability
{
NotChecked,
Available,
Duplicate,
Indeterminate
}
public enum LegacyOperatorCatalogMoveDirection
{
Up,
@@ -65,6 +73,8 @@ public sealed record LegacyOperatorCatalogWorkflowSnapshot(
string? SelectedResultId,
string CodeLabel,
string EditorName,
LegacyThemeNameAvailability ThemeNameAvailability,
string CheckedThemeName,
IReadOnlyList<LegacyOperatorCatalogDraftRowView> DraftRows,
string StockQuery,
IReadOnlyList<LegacyOperatorCatalogStockResultView> StockResults,
@@ -73,6 +83,7 @@ public sealed record LegacyOperatorCatalogWorkflowSnapshot(
bool SchemaValidated,
bool WritesQuarantined,
bool CanBeginCreate,
bool CanCheckThemeName,
bool CanSave,
bool CanDelete,
bool CanAddStock,
@@ -224,6 +235,9 @@ public sealed class LegacyOperatorCatalogWorkflowController
private string _stockQuery = string.Empty;
private string _codeLabel = string.Empty;
private string _editorName = string.Empty;
private LegacyThemeNameAvailability _themeNameAvailability =
LegacyThemeNameAvailability.NotChecked;
private string _checkedThemeName = string.Empty;
private string? _selectedResultId;
private string? _selectedStockResultId;
private ThemeSelectionIdentity? _expectedThemeIdentity;
@@ -504,6 +518,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
_selectedResultId = resultId;
_mode = LegacyOperatorCatalogMode.Edit;
ResetThemeNameAvailability();
ResetStockSearch();
SetStatus("편집할 항목을 불러왔습니다.", LegacyOperatorCatalogStatusKind.Information);
}
@@ -545,6 +560,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
_expectedThemeIdentity = null;
_expectedExpertIdentity = null;
_draftRows.Clear();
ResetThemeNameAvailability();
ResetStockSearch();
SetStatus("새 ThemeA 항목을 입력하세요.", LegacyOperatorCatalogStatusKind.Information);
}
@@ -562,6 +578,53 @@ public sealed class LegacyOperatorCatalogWorkflowController
return CreateSnapshot();
}
public async Task<LegacyOperatorCatalogWorkflowSnapshot> CheckThemeNameAvailabilityAsync(
string editorName,
CancellationToken cancellationToken = default)
{
EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme);
EnsureEditing();
_editorName = ValidateRequiredText(editorName, 128);
_checkedThemeName = _editorName;
_themeNameAvailability = LegacyThemeNameAvailability.Indeterminate;
try
{
var duplicate = await _themePersistenceService.ThemeNameExistsAsync(
_newThemeMarket,
_editorName,
cancellationToken).ConfigureAwait(false);
if (duplicate)
{
_themeNameAvailability = LegacyThemeNameAvailability.Duplicate;
SetStatus(
"이미 사용중인 테마명입니다.",
LegacyOperatorCatalogStatusKind.Warning);
}
else
{
_themeNameAvailability = LegacyThemeNameAvailability.Available;
SetStatus(
"사용 가능한 테마명입니다.",
LegacyOperatorCatalogStatusKind.Information);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
SetStatus(
"테마명 중복 여부를 확인하지 못했습니다. DB 연결을 확인하세요.",
LegacyOperatorCatalogStatusKind.Error);
}
Touch();
return CreateSnapshot();
}
public async Task<LegacyOperatorCatalogWorkflowSnapshot> BeginCreateExpertAsync(
CancellationToken cancellationToken = default)
{
@@ -730,6 +793,51 @@ public sealed class LegacyOperatorCatalogWorkflowController
return CreateSnapshot();
}
public LegacyOperatorCatalogWorkflowSnapshot ReorderDraftRow(
string sourceRowId,
string targetRowId,
LegacyDragDropPosition position)
{
ArgumentNullException.ThrowIfNull(sourceRowId);
ArgumentNullException.ThrowIfNull(targetRowId);
EnsureEditing();
var sourceIndex = _draftRows.FindIndex(row =>
string.Equals(row.RowId, sourceRowId, StringComparison.Ordinal));
var targetIndex = _draftRows.FindIndex(row =>
string.Equals(row.RowId, targetRowId, StringComparison.Ordinal));
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex)
{
return CreateSnapshot();
}
var candidate = _draftRows.ToList();
var movingRow = candidate[sourceIndex];
candidate.RemoveAt(sourceIndex);
var remainingTargetIndex = candidate.FindIndex(row =>
string.Equals(row.RowId, targetRowId, StringComparison.Ordinal));
var insertionIndex = position switch
{
LegacyDragDropPosition.Before => remainingTargetIndex,
LegacyDragDropPosition.After => remainingTargetIndex + 1,
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null)
};
candidate.Insert(insertionIndex, movingRow);
if (_draftRows.Select(static row => row.RowId).SequenceEqual(
candidate.Select(static row => row.RowId),
StringComparer.Ordinal))
{
return CreateSnapshot();
}
_draftRows.Clear();
_draftRows.AddRange(candidate);
SetStatus("표시 순서를 변경했습니다.", LegacyOperatorCatalogStatusKind.Information);
Touch();
return CreateSnapshot();
}
public LegacyOperatorCatalogWorkflowSnapshot SetDraftBuyAmount(
string rowId,
decimal buyAmount)
@@ -1103,6 +1211,8 @@ public sealed class LegacyOperatorCatalogWorkflowController
_selectedResultId,
_codeLabel,
_editorName,
_themeNameAvailability,
_checkedThemeName,
Array.AsReadOnly(draftViews),
_stockQuery,
Array.AsReadOnly(stockViews),
@@ -1111,6 +1221,9 @@ public sealed class LegacyOperatorCatalogWorkflowController
_schemaValidated,
quarantined,
canMutate && _mode == LegacyOperatorCatalogMode.List,
_schemaValidated && _isOpen &&
_entity == LegacyOperatorCatalogEntity.Theme &&
_mode is LegacyOperatorCatalogMode.Create or LegacyOperatorCatalogMode.Edit,
canMutate &&
(_mode is LegacyOperatorCatalogMode.Create or LegacyOperatorCatalogMode.Edit),
canMutate && _mode == LegacyOperatorCatalogMode.Edit,
@@ -1149,9 +1262,16 @@ public sealed class LegacyOperatorCatalogWorkflowController
_codeLabel = string.Empty;
_editorName = string.Empty;
_draftRows.Clear();
ResetThemeNameAvailability();
ResetStockSearch();
}
private void ResetThemeNameAvailability()
{
_themeNameAvailability = LegacyThemeNameAvailability.NotChecked;
_checkedThemeName = string.Empty;
}
private void ResetStockSearch()
{
_stockQuery = string.Empty;

View File

@@ -40,6 +40,11 @@ public sealed record LegacySelectPlaylistRowIntent(
public sealed record LegacyMoveSelectedPlaylistRowsIntent(
LegacyPlaylistMoveDirection Direction) : LegacyUiIntent;
public sealed record LegacyReorderPlaylistRowsIntent(
string SourceRowId,
string TargetRowId,
LegacyDragDropPosition Position) : LegacyUiIntent;
public sealed record LegacyDeleteSelectedPlaylistRowsIntent : LegacyUiIntent;
public sealed record LegacyDeleteAllPlaylistRowsIntent : LegacyUiIntent;
@@ -54,6 +59,12 @@ public sealed record LegacySwapTabsIntent(
LegacyOperatorTabId Source,
LegacyOperatorTabId Target) : LegacyUiIntent;
public sealed record LegacyHoverSwapTabsIntent(
LegacyOperatorTabId Source,
LegacyOperatorTabId Target,
int ExpectedSourceIndex,
int ExpectedTargetIndex) : LegacyUiIntent;
public sealed record LegacySetMovingAveragesIntent(bool Ma5, bool Ma20) : LegacyUiIntent;
public sealed record LegacyActivateFixedActionIntent(string ActionId) : LegacyUiIntent;
@@ -180,12 +191,19 @@ public sealed record LegacyMoveOperatorCatalogDraftRowIntent(
string RowId,
LegacyOperatorCatalogMoveDirection Direction) : LegacyUiIntent;
public sealed record LegacyReorderOperatorCatalogDraftRowIntent(
string SourceRowId,
string TargetRowId,
LegacyDragDropPosition Position) : LegacyUiIntent;
public sealed record LegacyRemoveOperatorCatalogDraftRowIntent(string RowId) : LegacyUiIntent;
public sealed record LegacySetOperatorCatalogDraftBuyAmountIntent(
string RowId,
decimal BuyAmount) : LegacyUiIntent;
public sealed record LegacyCheckOperatorCatalogThemeNameIntent(string Name) : LegacyUiIntent;
public sealed record LegacySaveOperatorCatalogIntent(string Name) : LegacyUiIntent;
public sealed record LegacyDeleteOperatorCatalogIntent : LegacyUiIntent;
@@ -336,12 +354,14 @@ public static class LegacyBridgeProtocol
"set-all-playlist-enabled" => ParseAllEnabled(payload),
"select-playlist-row" => ParsePlaylistSelection(payload),
"move-selected-playlist-rows" => ParsePlaylistMove(payload),
"reorder-playlist-rows" => ParsePlaylistReorder(payload),
"delete-selected-playlist-rows" => ParseDeleteSelected(payload),
"delete-all-playlist-rows" => ParseDeleteAll(payload),
"toggle-active-playlist-enabled" => ParseToggleActiveEnabled(payload),
"select-playlist-boundary" => ParsePlaylistBoundary(payload),
"select-tab" => ParseTabSelection(payload),
"swap-tabs" => ParseTabSwap(payload),
"hover-swap-tabs" => ParseTabHoverSwap(payload),
"set-moving-averages" => ParseMovingAverages(payload),
"activate-fixed-action" => ParseFixedAction(payload),
"activate-fixed-section" => ParseFixedSection(payload),
@@ -423,9 +443,13 @@ public static class LegacyBridgeProtocol
"activate-operator-catalog-stock" =>
ParseOperatorCatalogStockActivation(payload),
"move-operator-catalog-draft-row" => ParseOperatorCatalogDraftMove(payload),
"reorder-operator-catalog-draft-row" =>
ParseOperatorCatalogDraftReorder(payload),
"remove-operator-catalog-draft-row" => ParseOperatorCatalogDraftRemove(payload),
"set-operator-catalog-draft-buy-amount" =>
ParseOperatorCatalogDraftBuyAmount(payload),
"check-operator-catalog-theme-name" =>
ParseOperatorCatalogThemeNameCheck(payload),
"save-operator-catalog" => ParseOperatorCatalogSave(payload),
"delete-operator-catalog" => ParseOperatorCatalogEmpty(
payload,
@@ -831,6 +855,8 @@ public static class LegacyBridgeProtocol
snapshot.OperatorCatalog.SelectedResultId,
snapshot.OperatorCatalog.CodeLabel,
snapshot.OperatorCatalog.EditorName,
snapshot.OperatorCatalog.ThemeNameAvailability,
snapshot.OperatorCatalog.CheckedThemeName,
draftRows = snapshot.OperatorCatalog.DraftRows.Select(row => new
{
row.RowId,
@@ -853,6 +879,7 @@ public static class LegacyBridgeProtocol
snapshot.OperatorCatalog.SchemaValidated,
snapshot.OperatorCatalog.WritesQuarantined,
snapshot.OperatorCatalog.CanBeginCreate,
snapshot.OperatorCatalog.CanCheckThemeName,
snapshot.OperatorCatalog.CanSave,
snapshot.OperatorCatalog.CanDelete,
snapshot.OperatorCatalog.CanAddStock,
@@ -1136,6 +1163,25 @@ public static class LegacyBridgeProtocol
: null;
}
private static LegacyUiIntent? ParsePlaylistReorder(JsonElement payload)
{
if (!HasOnlyProperties(payload, "sourceRowId", "targetRowId", "position") ||
!TryString(payload, "sourceRowId", 128, out var sourceRowId) ||
!TryString(payload, "targetRowId", 128, out var targetRowId) ||
!IsSafeIdentifier(sourceRowId) ||
!IsSafeIdentifier(targetRowId) ||
string.Equals(sourceRowId, targetRowId, StringComparison.Ordinal) ||
!TryString(payload, "position", 8, out var positionText))
{
return null;
}
var position = ParseDragDropPosition(positionText);
return position.HasValue
? new LegacyReorderPlaylistRowsIntent(sourceRowId, targetRowId, position.Value)
: null;
}
private static LegacyUiIntent? ParseDeleteSelected(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyDeleteSelectedPlaylistRowsIntent() : null;
@@ -1174,6 +1220,27 @@ public static class LegacyBridgeProtocol
? new LegacySwapTabsIntent(source, target)
: null;
private static LegacyUiIntent? ParseTabHoverSwap(JsonElement payload)
{
if (!HasOnlyProperties(
payload,
"source",
"target",
"expectedSourceIndex",
"expectedTargetIndex") ||
!TryTabId(payload, "source", out var source) ||
!TryTabId(payload, "target", out var target) ||
source == target ||
!TryInt32(payload, "expectedSourceIndex", 0, 9, out var sourceIndex) ||
!TryInt32(payload, "expectedTargetIndex", 0, 9, out var targetIndex) ||
sourceIndex == targetIndex)
{
return null;
}
return new LegacyHoverSwapTabsIntent(source, target, sourceIndex, targetIndex);
}
private static LegacyUiIntent? ParseMovingAverages(JsonElement payload) =>
HasOnlyProperties(payload, "ma5", "ma20") &&
TryBoolean(payload, "ma5", out var ma5) &&
@@ -1630,6 +1697,28 @@ public static class LegacyBridgeProtocol
: null;
}
private static LegacyUiIntent? ParseOperatorCatalogDraftReorder(JsonElement payload)
{
if (!HasOnlyProperties(payload, "sourceRowId", "targetRowId", "position") ||
!TryString(payload, "sourceRowId", 128, out var sourceRowId) ||
!TryString(payload, "targetRowId", 128, out var targetRowId) ||
!IsSafeIdentifier(sourceRowId) ||
!IsSafeIdentifier(targetRowId) ||
string.Equals(sourceRowId, targetRowId, StringComparison.Ordinal) ||
!TryString(payload, "position", 8, out var positionText))
{
return null;
}
var position = ParseDragDropPosition(positionText);
return position.HasValue
? new LegacyReorderOperatorCatalogDraftRowIntent(
sourceRowId,
targetRowId,
position.Value)
: null;
}
private static LegacyUiIntent? ParseOperatorCatalogDraftRemove(JsonElement payload) =>
HasOnlyProperties(payload, "rowId") &&
TryString(payload, "rowId", 128, out var rowId) &&
@@ -1662,6 +1751,19 @@ public static class LegacyBridgeProtocol
? new LegacySaveOperatorCatalogIntent(name)
: null;
private static LegacyUiIntent? ParseOperatorCatalogThemeNameCheck(JsonElement payload) =>
HasOnlyProperties(payload, "name") &&
TrySafeString(
payload,
"name",
128,
allowEmpty: false,
allowUnderscore: true,
out var name) &&
string.Equals(name, name.Trim(), StringComparison.Ordinal)
? new LegacyCheckOperatorCatalogThemeNameIntent(name)
: null;
private static LegacyUiIntent? ParseNamedPlaylistEmpty(
JsonElement payload,
Func<LegacyUiIntent> factory) =>
@@ -2361,6 +2463,13 @@ public static class LegacyBridgeProtocol
value >= minimum && value <= maximum;
}
private static LegacyDragDropPosition? ParseDragDropPosition(string value) => value switch
{
"before" => LegacyDragDropPosition.Before,
"after" => LegacyDragDropPosition.After,
_ => null
};
private static bool TryInt64(
JsonElement element,
string name,

View File

@@ -1,4 +1,5 @@
using Microsoft.Windows.AppLifecycle;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
@@ -37,6 +38,20 @@ public partial class App : Application
_mainInstance = registeredInstance;
_mainInstance.Activated += OnMainInstanceActivated;
#if DEBUG
const bool isDebugBuild = true;
#else
const bool isDebugBuild = false;
#endif
var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply(
DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
args.Arguments,
Environment.GetCommandLineArgs()),
isDebugBuild);
System.Diagnostics.Debug.WriteLine(
$"Development Live bootstrap: {developmentLive.Code}");
_window = new MainWindow();
_window.Activate();
}

View File

@@ -425,6 +425,7 @@ public sealed partial class MainWindow
LegacySetPlaylistEnabledIntent or
LegacySetAllPlaylistEnabledIntent or
LegacyMoveSelectedPlaylistRowsIntent or
LegacyReorderPlaylistRowsIntent or
LegacyDeleteSelectedPlaylistRowsIntent or
LegacyDeleteAllPlaylistRowsIntent or
LegacyToggleActivePlaylistEnabledIntent or
@@ -454,6 +455,7 @@ public sealed partial class MainWindow
LegacyAddOperatorCatalogStockIntent or
LegacyActivateOperatorCatalogStockIntent or
LegacyMoveOperatorCatalogDraftRowIntent or
LegacyReorderOperatorCatalogDraftRowIntent or
LegacyRemoveOperatorCatalogDraftRowIntent or
LegacySetOperatorCatalogDraftBuyAmountIntent or
LegacySaveOperatorCatalogIntent or

View File

@@ -378,6 +378,7 @@ public sealed partial class MainWindow : Window
LegacySearchOperatorCatalogStocksIntent or
LegacyActivateOperatorCatalogStockIntent or
LegacySetOperatorCatalogDraftBuyAmountIntent or
LegacyCheckOperatorCatalogThemeNameIntent or
LegacySaveOperatorCatalogIntent or
LegacyDeleteOperatorCatalogIntent or
LegacyAddComparisonPairIntent or
@@ -481,6 +482,11 @@ public sealed partial class MainWindow : Window
selection.Shift),
LegacyMoveSelectedPlaylistRowsIntent move =>
_controller.MoveSelectedPlaylistRows(move.Direction),
LegacyReorderPlaylistRowsIntent reorder =>
_controller.ReorderPlaylistRows(
reorder.SourceRowId,
reorder.TargetRowId,
reorder.Position),
LegacyDeleteSelectedPlaylistRowsIntent =>
_controller.DeleteSelectedPlaylistRows(),
LegacyDeleteAllPlaylistRowsIntent =>
@@ -496,6 +502,13 @@ public sealed partial class MainWindow : Window
tabs.Source,
tabs.Target,
cancellationToken),
LegacyHoverSwapTabsIntent tabs =>
await _controller.SwapTabsAtExpectedPositionsAsync(
tabs.Source,
tabs.Target,
tabs.ExpectedSourceIndex,
tabs.ExpectedTargetIndex,
cancellationToken),
LegacySetMovingAveragesIntent movingAverages =>
_controller.SetMovingAverages(movingAverages.Ma5, movingAverages.Ma20),
LegacyActivateFixedActionIntent action =>
@@ -656,12 +669,21 @@ public sealed partial class MainWindow : Window
activation.BuyAmount),
LegacyMoveOperatorCatalogDraftRowIntent move =>
_controller.MoveOperatorCatalogDraftRow(move.RowId, move.Direction),
LegacyReorderOperatorCatalogDraftRowIntent reorder =>
_controller.ReorderOperatorCatalogDraftRow(
reorder.SourceRowId,
reorder.TargetRowId,
reorder.Position),
LegacyRemoveOperatorCatalogDraftRowIntent remove =>
_controller.RemoveOperatorCatalogDraftRow(remove.RowId),
LegacySetOperatorCatalogDraftBuyAmountIntent amount =>
_controller.SetOperatorCatalogDraftBuyAmount(
amount.RowId,
amount.BuyAmount),
LegacyCheckOperatorCatalogThemeNameIntent check =>
await _controller.CheckOperatorCatalogThemeNameAsync(
check.Name,
cancellationToken),
LegacySaveOperatorCatalogIntent save =>
await _controller.SaveOperatorCatalogAsync(
save.Name,
@@ -869,6 +891,12 @@ public sealed partial class MainWindow : Window
public bool CanMutate => false;
public Task<bool> ThemeNameExistsAsync(
ThemeMarket market,
string themeTitle,
CancellationToken cancellationToken = default) =>
Task.FromException<bool>(new InvalidOperationException(_message));
public Task<string> SuggestNextCodeAsync(
ThemeMarket market,
CancellationToken cancellationToken = default) =>

View File

@@ -1,7 +1,12 @@
{
"profiles": {
"MBN_STOCK_WEBVIEW.LegacyParityApp (Package)": {
"commandName": "MsixPackage"
"MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)": {
"commandName": "MsixPackage",
"commandLineArgs": "--development-live"
},
"MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)": {
"commandName": "MsixPackage",
"commandLineArgs": ""
}
}
}

View File

@@ -61,6 +61,8 @@
const operatorCatalogCode = document.getElementById("operator-catalog-code");
const operatorCatalogNameLabel = document.getElementById("operator-catalog-name-label");
const operatorCatalogName = document.getElementById("operator-catalog-name");
const operatorCatalogNameCheck = document.getElementById("operator-catalog-name-check");
const operatorCatalogNameStatus = document.getElementById("operator-catalog-name-status");
const operatorCatalogStockQuery = document.getElementById("operator-catalog-stock-query");
const operatorCatalogStockSearch = document.getElementById("operator-catalog-stock-search");
const operatorCatalogStockResults = document.getElementById("operator-catalog-stock-results");
@@ -87,11 +89,36 @@
let namedPlaylistResultClickTimer = null;
let pendingNamedPlaylistCommand = null;
let lastCommandSequence = 0;
const selectedCutsDragToken = "legacy-selected-cuts";
const playlistDragMime = "text/x-mbn-playlist-row";
const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row";
let draggedPlaylistRowId = null;
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragBlockedRowId = null;
let playlistMutationLocked = false;
let draggedTabId = null;
let tabDragLastTargetId = null;
let pendingTabHoverSwap = null;
let draggedOperatorCatalogRowId = null;
let operatorCatalogDragBlockedRowId = null;
let operatorCatalogMutationLocked = false;
function send(type, payload) {
if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
}
function isOperatorMutationLocked(state) {
const playout = state && state.playout;
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
playout.isPlayCompletionPending === true || playout.phase !== "idle"));
}
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after";
}
function clearDelayedClick(timer) {
if (timer !== null) window.clearTimeout(timer);
}
@@ -166,7 +193,7 @@
element.addEventListener("dragstart", function (event) {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", "legacy-selected-cuts");
event.dataTransfer.setData("text/plain", selectedCutsDragToken);
}
});
return element;
@@ -197,6 +224,83 @@
}
}
function clearPlaylistDragVisuals() {
playlistRows.querySelectorAll(".dragging, .drag-before, .drag-after")
.forEach(function (row) {
row.classList.remove("dragging", "drag-before", "drag-after");
});
}
function onPlaylistDragStart(event) {
const row = event.currentTarget;
const rowId = row.dataset.rowId;
if (playlistMutationLocked || !rowId || !event.dataTransfer ||
playlistDragBlockedRowId === rowId || event.target.closest("input, button")) {
playlistDragBlockedRowId = null;
event.preventDefault();
return;
}
playlistDragBlockedRowId = null;
deferredPlaylistPlainSelectionRowId = null;
draggedPlaylistRowId = rowId;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(playlistDragMime, rowId);
row.classList.add("dragging");
}
function onPlaylistDragOver(event) {
const target = event.currentTarget;
if (playlistMutationLocked || !draggedPlaylistRowId ||
target.dataset.rowId === draggedPlaylistRowId) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
const position = dragDropPosition(event, target);
target.classList.toggle("drag-before", position === "before");
target.classList.toggle("drag-after", position === "after");
}
function onPlaylistDragLeave(event) {
event.currentTarget.classList.remove("drag-before", "drag-after");
}
function onPlaylistDrop(event) {
const target = event.currentTarget;
const sourceRowId = draggedPlaylistRowId;
const targetRowId = target.dataset.rowId;
if (playlistMutationLocked || !sourceRowId || !targetRowId ||
sourceRowId === targetRowId || !event.dataTransfer) {
clearPlaylistDragVisuals();
draggedPlaylistRowId = null;
return;
}
const token = event.dataTransfer.getData(playlistDragMime);
if (token !== sourceRowId) {
clearPlaylistDragVisuals();
draggedPlaylistRowId = null;
return;
}
event.preventDefault();
const position = dragDropPosition(event, target);
clearPlaylistDragVisuals();
draggedPlaylistRowId = null;
send("reorder-playlist-rows", {
sourceRowId: sourceRowId,
targetRowId: targetRowId,
position: position
});
}
function onPlaylistDragEnd() {
draggedPlaylistRowId = null;
deferredPlaylistPlainSelectionRowId = null;
playlistDragBlockedRowId = null;
clearPlaylistDragVisuals();
}
function createPlaylistElement(row) {
const element = document.createElement("div");
element.className = "playlist-row playlist-data-row";
@@ -222,18 +326,67 @@
}
element.addEventListener("pointerdown", function (event) {
if (event.button !== 0 || enabledCell.contains(event.target)) return;
if (event.button !== 0) return;
if (enabledCell.contains(event.target)) {
// Native dragstart targets the nearest draggable ancestor (the row),
// not necessarily the checkbox originally pressed. Remember the
// pointer origin so a checkbox gesture cannot reorder the row.
playlistDragBlockedRowId = element.dataset.rowId;
return;
}
playlistDragBlockedRowId = null;
element.focus({ preventScroll: true });
const plainSelection = !event.ctrlKey && !event.shiftKey;
const preserveSelectedBlock = plainSelection &&
element.getAttribute("aria-selected") === "true" &&
playlistRows.querySelectorAll('.playlist-data-row[aria-selected="true"]').length > 1;
if (preserveSelectedBlock) {
deferredPlaylistPlainSelectionRowId = element.dataset.rowId;
return;
}
deferredPlaylistPlainSelectionRowId = null;
send("select-playlist-row", {
rowId: element.dataset.rowId,
control: event.ctrlKey,
shift: event.shiftKey
});
});
element.addEventListener("pointerup", function (event) {
if (enabledCell.contains(event.target)) {
playlistDragBlockedRowId = null;
return;
}
if (event.button !== 0 ||
deferredPlaylistPlainSelectionRowId !== element.dataset.rowId) return;
deferredPlaylistPlainSelectionRowId = null;
send("select-playlist-row", {
rowId: element.dataset.rowId,
control: false,
shift: false
});
});
element.addEventListener("pointercancel", function () {
if (playlistDragBlockedRowId === element.dataset.rowId) {
playlistDragBlockedRowId = null;
}
});
element.addEventListener("dragstart", onPlaylistDragStart);
element.addEventListener("dragover", onPlaylistDragOver);
element.addEventListener("dragleave", onPlaylistDragLeave);
element.addEventListener("drop", onPlaylistDrop);
element.addEventListener("dragend", onPlaylistDragEnd);
return element;
}
function renderPlaylist(state) {
playlistMutationLocked = isOperatorMutationLocked(state);
if (playlistMutationLocked) {
draggedPlaylistRowId = null;
deferredPlaylistPlainSelectionRowId = null;
playlistDragBlockedRowId = null;
clearPlaylistDragVisuals();
}
const existingRows = new Map();
Array.from(playlistRows.children).forEach(function (element) {
existingRows.set(element.dataset.rowId, element);
@@ -257,6 +410,8 @@
element.className = "playlist-row playlist-data-row" +
(row.isSelected ? " selected" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
element.draggable = !playlistMutationLocked;
element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false");
[row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText]
.forEach(function (text, cellIndex) {
const cell = element.children.item(cellIndex + 1);
@@ -281,6 +436,59 @@
playlistDeleteAll.disabled = interactionDisabled;
}
function tabOrderFromDom() {
return Array.from(marketTabs.querySelectorAll("button[data-tab-id]"))
.map(function (button) { return button.dataset.tabId; });
}
function clearPendingTabHoverSwap() {
if (pendingTabHoverSwap && pendingTabHoverSwap.timeoutId !== null) {
window.clearTimeout(pendingTabHoverSwap.timeoutId);
}
pendingTabHoverSwap = null;
}
function acknowledgePendingTabHoverSwap(order) {
if (!pendingTabHoverSwap) return;
const orderKey = order.join("|");
if (orderKey === pendingTabHoverSwap.expectedOrderKey ||
orderKey !== pendingTabHoverSwap.initialOrderKey) {
clearPendingTabHoverSwap();
}
}
function requestTabHoverSwap(targetId) {
if (uiBusy || !draggedTabId || !targetId || draggedTabId === targetId ||
pendingTabHoverSwap) return;
const order = tabOrderFromDom();
const sourceIndex = order.indexOf(draggedTabId);
const targetIndex = order.indexOf(targetId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
const expectedOrder = order.slice();
expectedOrder[sourceIndex] = targetId;
expectedOrder[targetIndex] = draggedTabId;
const pending = {
initialOrderKey: order.join("|"),
expectedOrderKey: expectedOrder.join("|"),
timeoutId: null
};
pending.timeoutId = window.setTimeout(function () {
if (pendingTabHoverSwap === pending) {
pendingTabHoverSwap = null;
tabDragLastTargetId = null;
}
}, 500);
pendingTabHoverSwap = pending;
send("hover-swap-tabs", {
source: draggedTabId,
target: targetId,
expectedSourceIndex: sourceIndex,
expectedTargetIndex: targetIndex
});
}
function renderTabs(state) {
if (!Array.isArray(state.tabs)) return;
const existingTabs = new Map();
@@ -298,6 +506,8 @@
button.disabled = state.isBusy === true || state.fixedSectionBatch != null;
button.draggable = state.isBusy !== true && state.fixedSectionBatch == null;
});
acknowledgePendingTabHoverSwap(
state.tabs.map(function (tab) { return tab.id; }));
}
function renderFixedCatalog(state) {
@@ -1528,19 +1738,116 @@
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
}
function clearOperatorCatalogDragVisuals() {
operatorCatalogDraftRows.querySelectorAll(".dragging, .drag-before, .drag-after")
.forEach(function (row) {
row.classList.remove("dragging", "drag-before", "drag-after");
});
}
function onOperatorCatalogPointerDown(event) {
if (event.button !== 0) return;
const rowId = event.currentTarget.dataset.operatorCatalogRowId;
operatorCatalogDragBlockedRowId = event.target.closest("button, input")
? rowId
: null;
}
function onOperatorCatalogPointerEnd(event) {
if (operatorCatalogDragBlockedRowId ===
event.currentTarget.dataset.operatorCatalogRowId) {
operatorCatalogDragBlockedRowId = null;
}
}
function onOperatorCatalogDragStart(event) {
const row = event.currentTarget;
const sourceRowId = row.dataset.operatorCatalogRowId;
if (operatorCatalogMutationLocked || !sourceRowId || !event.dataTransfer ||
operatorCatalogDragBlockedRowId === sourceRowId ||
event.target.closest("button, input")) {
operatorCatalogDragBlockedRowId = null;
event.preventDefault();
return;
}
operatorCatalogDragBlockedRowId = null;
draggedOperatorCatalogRowId = sourceRowId;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(operatorCatalogDragMime, sourceRowId);
row.classList.add("dragging");
}
function onOperatorCatalogDragOver(event) {
const target = event.currentTarget;
if (operatorCatalogMutationLocked || !draggedOperatorCatalogRowId ||
target.dataset.operatorCatalogRowId === draggedOperatorCatalogRowId) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
const position = dragDropPosition(event, target);
target.classList.toggle("drag-before", position === "before");
target.classList.toggle("drag-after", position === "after");
}
function onOperatorCatalogDragLeave(event) {
event.currentTarget.classList.remove("drag-before", "drag-after");
}
function onOperatorCatalogDrop(event) {
const target = event.currentTarget;
const sourceRowId = draggedOperatorCatalogRowId;
const targetRowId = target.dataset.operatorCatalogRowId;
if (operatorCatalogMutationLocked || !sourceRowId || !targetRowId ||
sourceRowId === targetRowId || !event.dataTransfer ||
event.dataTransfer.getData(operatorCatalogDragMime) !== sourceRowId) {
draggedOperatorCatalogRowId = null;
clearOperatorCatalogDragVisuals();
return;
}
event.preventDefault();
const position = dragDropPosition(event, target);
draggedOperatorCatalogRowId = null;
clearOperatorCatalogDragVisuals();
send("reorder-operator-catalog-draft-row", {
sourceRowId: sourceRowId,
targetRowId: targetRowId,
position: position
});
}
function onOperatorCatalogDragEnd() {
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
}
function renderOperatorCatalog(state) {
const catalog = state.operatorCatalog;
const busy = state.isBusy === true;
operatorCatalogMutationLocked = isOperatorMutationLocked(state);
operatorCatalogState = catalog || null;
if (!catalog || catalog.isOpen !== true) {
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
clearDelayedClick(operatorCatalogStockClickTimer);
operatorCatalogStockClickTimer = null;
operatorCatalogModal.hidden = true;
operatorCatalogDraftKey = "";
operatorCatalogNameDraft = "";
operatorCatalogNameStatus.textContent = "";
operatorCatalogNameStatus.className = "";
return;
}
if (operatorCatalogMutationLocked) {
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
}
operatorCatalogModal.hidden = false;
const isTheme = catalog.entity === "theme";
const editing = catalog.mode === "create" || catalog.mode === "edit";
@@ -1599,6 +1906,27 @@
operatorCatalogNameLabel.textContent = isTheme ? "테마명" : "전문가명";
operatorCatalogName.placeholder = isTheme ? "ThemeA 이름" : "전문가 이름";
operatorCatalogName.disabled = busy || !editing;
const currentThemeName = operatorCatalogNameDraft.trim();
const checkedCurrentThemeName = isTheme && currentThemeName.length > 0 &&
catalog.checkedThemeName === currentThemeName;
const themeNameAvailability = checkedCurrentThemeName
? catalog.themeNameAvailability
: "notChecked";
operatorCatalogNameCheck.hidden = !isTheme;
operatorCatalogNameCheck.disabled = busy || !isTheme || !editing ||
catalog.canCheckThemeName !== true || currentThemeName.length === 0;
operatorCatalogNameStatus.hidden = !isTheme;
operatorCatalogNameStatus.className = themeNameAvailability === "available" ||
themeNameAvailability === "duplicate" || themeNameAvailability === "indeterminate"
? themeNameAvailability
: "";
operatorCatalogNameStatus.textContent = themeNameAvailability === "available"
? "사용 가능"
: themeNameAvailability === "duplicate"
? "이미 사용 중"
: themeNameAvailability === "indeterminate"
? "확인 불가"
: "";
if (document.activeElement !== operatorCatalogStockQuery) {
operatorCatalogStockQuery.value = catalog.stockQuery || "";
}
@@ -1632,6 +1960,19 @@
(catalog.draftRows || []).forEach(function (row, index) {
const line = document.createElement("div");
line.className = "operator-catalog-draft-row";
line.dataset.operatorCatalogRowId = row.rowId;
line.draggable = !operatorCatalogMutationLocked;
line.setAttribute(
"aria-disabled",
operatorCatalogMutationLocked ? "true" : "false");
line.addEventListener("pointerdown", onOperatorCatalogPointerDown);
line.addEventListener("pointerup", onOperatorCatalogPointerEnd);
line.addEventListener("pointercancel", onOperatorCatalogPointerEnd);
line.addEventListener("dragstart", onOperatorCatalogDragStart);
line.addEventListener("dragover", onOperatorCatalogDragOver);
line.addEventListener("dragleave", onOperatorCatalogDragLeave);
line.addEventListener("drop", onOperatorCatalogDrop);
line.addEventListener("dragend", onOperatorCatalogDragEnd);
const order = document.createElement("span");
order.textContent = String(row.position + 1);
const name = document.createElement("strong");
@@ -2012,6 +2353,25 @@
});
operatorCatalogName.addEventListener("input", function () {
operatorCatalogNameDraft = operatorCatalogName.value;
operatorCatalogNameStatus.textContent = "";
operatorCatalogNameStatus.className = "";
operatorCatalogNameCheck.disabled = uiBusy || !operatorCatalogState ||
operatorCatalogState.entity !== "theme" ||
(operatorCatalogState.mode !== "create" && operatorCatalogState.mode !== "edit") ||
operatorCatalogState.canCheckThemeName !== true ||
operatorCatalogName.value.trim().length === 0;
});
operatorCatalogNameCheck.addEventListener("click", function () {
const name = operatorCatalogName.value.trim();
if (!name) {
operatorCatalogName.setCustomValidity("이름을 입력하세요.");
operatorCatalogName.reportValidity();
return;
}
operatorCatalogName.setCustomValidity("");
operatorCatalogNameDraft = name;
send("check-operator-catalog-theme-name", { name: name });
});
function searchOperatorCatalogStocks() {
const query = operatorCatalogStockQuery.value.trim();
@@ -2119,12 +2479,28 @@
searchOperatorCatalog();
});
function hasDragType(dataTransfer, expectedType) {
return !!dataTransfer && Array.from(dataTransfer.types || [])
.some(function (type) { return type === expectedType; });
}
function isSelectedCutsDrop(dataTransfer) {
return hasDragType(dataTransfer, "text/plain") &&
dataTransfer.getData("text/plain") === selectedCutsDragToken;
}
playlistDropZone.addEventListener("dragover", function (event) {
// A playlist row also drops inside this ancestor. Only advertise the
// drop zone for the cut-list payload; otherwise the bubbled row drop
// would append the still-selected cut as an extra playlist entry.
if (!hasDragType(event.dataTransfer, "text/plain")) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
});
playlistDropZone.addEventListener("drop", function (event) {
if (!isSelectedCutsDrop(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
send("drop-selected-cuts", {});
});
playlistMoveUp.addEventListener("click", function () {
@@ -2155,20 +2531,43 @@
marketTabs.addEventListener("dragstart", function (event) {
const button = event.target.closest("button[data-tab-id]");
if (!button || !event.dataTransfer) return;
clearPendingTabHoverSwap();
draggedTabId = button.dataset.tabId;
tabDragLastTargetId = null;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/x-mbn-tab", button.dataset.tabId);
event.dataTransfer.setData("text/x-mbn-tab", draggedTabId);
button.classList.add("dragging");
});
marketTabs.addEventListener("dragover", function (event) {
if (event.target.closest("button[data-tab-id]")) event.preventDefault();
const target = event.target.closest("button[data-tab-id]");
if (!target || !draggedTabId) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
const targetId = target.dataset.tabId;
if (targetId === draggedTabId) {
tabDragLastTargetId = null;
return;
}
if (tabDragLastTargetId === targetId) return;
if (pendingTabHoverSwap) return;
tabDragLastTargetId = targetId;
requestTabHoverSwap(targetId);
});
marketTabs.addEventListener("drop", function (event) {
const target = event.target.closest("button[data-tab-id]");
if (!target || !event.dataTransfer) return;
if (!target || !event.dataTransfer || !draggedTabId) return;
event.preventDefault();
const source = event.dataTransfer.getData("text/x-mbn-tab");
if (source && source !== target.dataset.tabId) {
send("swap-tabs", { source: source, target: target.dataset.tabId });
}
if (event.dataTransfer.getData("text/x-mbn-tab") !== draggedTabId) return;
draggedTabId = null;
tabDragLastTargetId = null;
marketTabs.querySelectorAll("button.dragging")
.forEach(function (button) { button.classList.remove("dragging"); });
});
marketTabs.addEventListener("dragend", function () {
draggedTabId = null;
tabDragLastTargetId = null;
marketTabs.querySelectorAll("button.dragging")
.forEach(function (button) { button.classList.remove("dragging"); });
});
function sendMovingAverages() {
send("set-moving-averages", {
@@ -2498,6 +2897,10 @@
send("search-trading-halts", { query: event.target.value });
}
});
window.addEventListener("pointerup", function () {
deferredPlaylistPlainSelectionRowId = null;
operatorCatalogDragBlockedRowId = null;
});
document.addEventListener("keydown", function (event) {
if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) {

View File

@@ -196,10 +196,12 @@
<h3 id="operator-catalog-editor-title">추가·편집</h3>
<span id="operator-catalog-code" class="operator-catalog-code"></span>
</div>
<label class="operator-catalog-name-field">
<span id="operator-catalog-name-label">이름</span>
<div class="operator-catalog-name-field">
<label id="operator-catalog-name-label" for="operator-catalog-name">이름</label>
<input id="operator-catalog-name" maxlength="128" autocomplete="off">
</label>
<button id="operator-catalog-name-check" type="button">중복확인</button>
<small id="operator-catalog-name-status" aria-live="polite"></small>
</div>
<div class="operator-catalog-stock-search">
<input id="operator-catalog-stock-query" type="search" maxlength="64"
autocomplete="off" placeholder="종목명 검색">

View File

@@ -140,6 +140,8 @@ button:disabled { color: #555; opacity: 1; }
.market-tabs { display: flex; height: 29px; border-bottom: 1px solid #999; }
.market-tabs button { min-width: 48px; padding: 3px 8px; border-width: 0 1px 0 0; background: #efefef; }
.market-tabs button.active { background: #fff; border-top: 2px solid #e8781d; }
.market-tabs button[draggable="true"] { cursor: grab; }
.market-tabs button.dragging { opacity: .58; cursor: grabbing; }
.expand-line { height: 31px; padding: 7px 8px 0; }
.category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; }
.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; }
@@ -237,7 +239,10 @@ button:disabled { color: #555; opacity: 1; }
.operator-catalog-results button.selected { background: #dcebdc; }
.operator-catalog-editor { grid-template-rows: auto auto auto minmax(92px, 20%) auto auto minmax(120px, 1fr) auto; gap: 7px; padding-bottom: 8px; }
.operator-catalog-editor[hidden] { display: none; }
.operator-catalog-name-field { display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 6px; padding: 0 9px; }
.operator-catalog-name-field { display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; align-items: center; gap: 6px; padding: 0 9px; }
.operator-catalog-name-field small { grid-column: 2 / 4; min-height: 16px; color: #555; }
.operator-catalog-name-field small.available { color: #176b2d; font-weight: 600; }
.operator-catalog-name-field small.duplicate, .operator-catalog-name-field small.indeterminate { color: #a40000; font-weight: 600; }
.operator-catalog-stock-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 0 9px; }
.operator-catalog-stock-results { margin: 0 9px; border: 1px solid #aaa; }
.operator-catalog-stock-results button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; padding: 4px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
@@ -252,6 +257,10 @@ button:disabled { color: #555; opacity: 1; }
.operator-catalog-draft-row > span { text-align: center; }
.operator-catalog-draft-row > small { color: #555; text-align: right; }
.operator-catalog-draft-row > input { min-width: 90px; width: 100%; height: 27px; text-align: right; }
.operator-catalog-draft-row[draggable="true"] { cursor: grab; }
.operator-catalog-draft-row.dragging { opacity: .55; cursor: grabbing; }
.operator-catalog-draft-row.drag-before { box-shadow: inset 0 3px #176b2d; }
.operator-catalog-draft-row.drag-after { box-shadow: inset 0 -3px #176b2d; }
.operator-catalog-editor-actions { display: grid; grid-template-columns: auto 1fr auto auto; gap: 7px; padding: 2px 9px 0; }
.operator-catalog-editor-actions button:last-child { min-width: 92px; color: #fff; background: #176b2d; font-weight: 700; }
@@ -272,6 +281,10 @@ button:disabled { color: #555; opacity: 1; }
.playlist-data-row .enabled-cell { text-align: center; }
.playlist-data-row.selected { background: #dcdcdc; }
.playlist-data-row:focus { outline: 1px dotted #333; outline-offset: -2px; }
.playlist-data-row[draggable="true"] { cursor: grab; }
.playlist-data-row.dragging { opacity: .55; cursor: grabbing; }
.playlist-data-row.drag-before { box-shadow: inset 0 3px #176b2d; }
.playlist-data-row.drag-after { box-shadow: inset 0 -3px #176b2d; }
.playout-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: 12px; }
.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; }

View File

@@ -0,0 +1,399 @@
using System.Security;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
internal enum DevelopmentLiveBootstrapStatus
{
Skipped,
Applied,
Rejected
}
internal readonly record struct DevelopmentLiveBootstrapResult(
DevelopmentLiveBootstrapStatus Status,
string Code)
{
public bool IsApplied => Status == DevelopmentLiveBootstrapStatus.Applied;
}
internal interface IDevelopmentLiveBootstrapPlatform
{
byte[] ReadAuthorizationFile(string path, int maximumBytes);
string? GetProcessEnvironmentVariable(string name);
void SetProcessEnvironmentVariable(string name, string? value);
}
/// <summary>
/// Arms the existing process-scoped Live and vendor-binary gates for one explicit
/// Visual Studio Debug launch. The ordinary app startup path never calls this with
/// a true debug-build capability, and the authorization file is deliberately kept
/// outside the package and source tree.
/// </summary>
internal static class DevelopmentLiveLaunchBootstrap
{
internal const string ExactLaunchArgument = "--development-live";
internal const string ModeEnvironmentVariable = "MBN_STOCK_PLAYOUT_MODE";
internal const string RequiredMode = "Live";
internal const int SchemaVersion = 1;
internal const int MaximumAuthorizationFileBytes = 4 * 1024;
private const string SchemaVersionProperty = "schemaVersion";
private const string ModeProperty = "mode";
private const string AuthorizationProperty = "authorization";
private const string NativeSha256Property = "nativeSha256";
private const string InteropSha256Property = "interopSha256";
private static readonly IReadOnlySet<string> AllowedProperties =
new HashSet<string>(StringComparer.Ordinal)
{
SchemaVersionProperty,
ModeProperty,
AuthorizationProperty,
NativeSha256Property,
InteropSha256Property
};
internal static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"Config",
"playout.development-live.local.json");
internal static string? ResolveLaunchArguments(
string? activationArguments,
IReadOnlyList<string> processArguments)
{
ArgumentNullException.ThrowIfNull(processArguments);
// Packaged WinUI launches can expose an empty LaunchActivatedEventArgs.Arguments
// even though the full-trust process received the Visual Studio command-line
// argument. Fall back only to one exact process argument; never concatenate,
// trim, or ignore additional command-line input.
if (!string.IsNullOrEmpty(activationArguments))
{
return activationArguments;
}
return processArguments.Count == 2
? processArguments[1]
: null;
}
internal static DevelopmentLiveBootstrapResult TryApply(
string? launchArguments,
bool isDebugBuild) =>
TryApply(
launchArguments,
isDebugBuild,
DefaultPath,
new SystemDevelopmentLiveBootstrapPlatform());
internal static DevelopmentLiveBootstrapResult TryApply(
string? launchArguments,
bool isDebugBuild,
string authorizationFilePath,
IDevelopmentLiveBootstrapPlatform platform)
{
ArgumentNullException.ThrowIfNull(platform);
if (!string.Equals(
launchArguments,
ExactLaunchArgument,
StringComparison.Ordinal))
{
return new DevelopmentLiveBootstrapResult(
DevelopmentLiveBootstrapStatus.Skipped,
"development-live-not-requested");
}
if (!isDebugBuild)
{
return new DevelopmentLiveBootstrapResult(
DevelopmentLiveBootstrapStatus.Skipped,
"development-live-debug-only");
}
if (string.IsNullOrWhiteSpace(authorizationFilePath))
{
return Rejected("development-live-authorization-unavailable");
}
byte[] json;
try
{
json = platform.ReadAuthorizationFile(
authorizationFilePath,
MaximumAuthorizationFileBytes);
}
catch (Exception exception) when (IsExpectedFileFailure(exception))
{
return Rejected("development-live-authorization-unavailable");
}
if (!TryParseAuthorization(json, out var nativeSha256, out var interopSha256))
{
return Rejected("development-live-authorization-invalid");
}
return TryApplyEnvironment(platform, nativeSha256, interopSha256)
? new DevelopmentLiveBootstrapResult(
DevelopmentLiveBootstrapStatus.Applied,
"development-live-applied")
: Rejected("development-live-environment-failed");
}
private static bool TryParseAuthorization(
byte[] json,
out string nativeSha256,
out string interopSha256)
{
nativeSha256 = string.Empty;
interopSha256 = string.Empty;
if (json.Length is 0 or > MaximumAuthorizationFileBytes)
{
return false;
}
try
{
using var document = JsonDocument.Parse(
json,
new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 4
});
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
int? schemaVersion = null;
string? mode = null;
string? authorization = null;
string? native = null;
string? interop = null;
foreach (var property in document.RootElement.EnumerateObject())
{
if (!AllowedProperties.Contains(property.Name) || !seen.Add(property.Name))
{
return false;
}
switch (property.Name)
{
case SchemaVersionProperty:
if (property.Value.ValueKind != JsonValueKind.Number ||
!property.Value.TryGetInt32(out var parsedVersion))
{
return false;
}
schemaVersion = parsedVersion;
break;
case ModeProperty:
mode = StrictString(property.Value);
break;
case AuthorizationProperty:
authorization = StrictString(property.Value);
break;
case NativeSha256Property:
native = StrictString(property.Value);
break;
case InteropSha256Property:
interop = StrictString(property.Value);
break;
}
}
if (seen.Count != AllowedProperties.Count ||
schemaVersion != SchemaVersion ||
!string.Equals(mode, RequiredMode, StringComparison.Ordinal) ||
!string.Equals(
authorization,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
StringComparison.Ordinal) ||
!IsSha256(native) ||
!IsSha256(interop))
{
return false;
}
nativeSha256 = native!.ToUpperInvariant();
interopSha256 = interop!.ToUpperInvariant();
return true;
}
catch (JsonException)
{
return false;
}
}
private static bool TryApplyEnvironment(
IDevelopmentLiveBootstrapPlatform platform,
string nativeSha256,
string interopSha256)
{
string[] names =
[
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable,
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
ModeEnvironmentVariable,
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable
];
var originals = new Dictionary<string, string?>(StringComparer.Ordinal);
try
{
foreach (var name in names)
{
originals.Add(name, platform.GetProcessEnvironmentVariable(name));
}
// The authorization value is the final write. A partially applied setup
// therefore cannot newly satisfy the two-part Live gate.
platform.SetProcessEnvironmentVariable(
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable,
nativeSha256);
platform.SetProcessEnvironmentVariable(
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
interopSha256);
platform.SetProcessEnvironmentVariable(ModeEnvironmentVariable, RequiredMode);
platform.SetProcessEnvironmentVariable(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
return true;
}
catch (Exception exception) when (IsExpectedEnvironmentFailure(exception))
{
for (var index = names.Length - 1; index >= 0; index--)
{
var name = names[index];
if (!originals.TryGetValue(name, out var original))
{
continue;
}
try
{
platform.SetProcessEnvironmentVariable(name, original);
}
catch (Exception rollbackException) when (
IsExpectedEnvironmentFailure(rollbackException))
{
// The real process environment API is expected to be all-or-nothing
// for these bounded names and values. Never turn a rollback failure
// into a second startup attempt or expose a value in diagnostics.
}
}
return false;
}
}
private static string? StrictString(JsonElement value) =>
value.ValueKind == JsonValueKind.String ? value.GetString() : null;
private static bool IsSha256(string? value)
{
if (value is null || value.Length != 64)
{
return false;
}
foreach (var character in value)
{
if (!((character >= '0' && character <= '9') ||
(character >= 'A' && character <= 'F') ||
(character >= 'a' && character <= 'f')))
{
return false;
}
}
return true;
}
private static bool IsExpectedFileFailure(Exception exception) =>
exception is IOException or UnauthorizedAccessException or SecurityException or
ArgumentException or NotSupportedException;
private static bool IsExpectedEnvironmentFailure(Exception exception) =>
exception is ArgumentException or SecurityException or InvalidOperationException;
private static DevelopmentLiveBootstrapResult Rejected(string code) =>
new(DevelopmentLiveBootstrapStatus.Rejected, code);
}
internal sealed class SystemDevelopmentLiveBootstrapPlatform :
IDevelopmentLiveBootstrapPlatform
{
public byte[] ReadAuthorizationFile(string path, int maximumBytes)
{
var fullPath = Path.GetFullPath(path);
EnsureTrustedLocalPath(fullPath);
var attributes = File.GetAttributes(fullPath);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw new InvalidDataException();
}
using var stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan);
if (stream.Length is <= 0 || stream.Length > maximumBytes)
{
throw new InvalidDataException();
}
var bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);
return bytes;
}
public string? GetProcessEnvironmentVariable(string name) =>
Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process);
public void SetProcessEnvironmentVariable(string name, string? value) =>
Environment.SetEnvironmentVariable(
name,
value,
EnvironmentVariableTarget.Process);
private static void EnsureTrustedLocalPath(string fullPath)
{
var localRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)));
var rootPrefix = localRoot + Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException();
}
var directory = Path.GetDirectoryName(fullPath);
while (!string.IsNullOrEmpty(directory) &&
!string.Equals(directory, localRoot, StringComparison.OrdinalIgnoreCase))
{
if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidDataException();
}
directory = Path.GetDirectoryName(directory);
}
if (!string.Equals(directory, localRoot, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException();
}
}
}

View File

@@ -2,3 +2,4 @@ using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")]
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.PlayoutSmoke")]
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.LegacyParityApp")]