feat: restore legacy drag events and Tornado launch integration
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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="종목명 검색">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user