diff --git a/src/SheetMe.Core/Catalog/ControlRegistry.cs b/src/SheetMe.Core/Catalog/ControlRegistry.cs index b388b94..670bb9c 100644 --- a/src/SheetMe.Core/Catalog/ControlRegistry.cs +++ b/src/SheetMe.Core/Catalog/ControlRegistry.cs @@ -58,6 +58,16 @@ public static class ControlRegistry { Key = "ReLoadDataMsgNoCheck", Label = "재조회 무확인", Editor = PropEditorKind.Toggle, Default = "False", }; + + /// + /// 데이터소스 배선 — 레거시가 bzMDataTableField 편집기를 붙이는 8종에만 준다 + /// (TextBox.vb:1188, ComboBox.vb:927, CheckBox.vb:900, RadioButton.vb:1581, + /// DateTimePicker.vb:544, MaskedTextBox.vb:699, MCalcBox.vb:249, MPictureBox.vb:1575). + /// + public static PropertyDef DataTableField => new() + { + Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.DataTableField, + }; } private static readonly List all = new() @@ -95,6 +105,7 @@ public static class ControlRegistry new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag }, new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text }, + Runtime.DataTableField, // 레거시 TextBox.vb:63 mbRwdRsvWrdYon = True — 2022-09-28 추가된 속성이라 그 이전 서식엔 키가 없다. // 기본값을 알려주지 않으면 항상 '꺼짐'으로 보이고, 사용자가 껐다 켜면 명시 False 가 기록돼 기능이 죽는다. new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle, Default = "True" }, @@ -117,6 +128,7 @@ public static class ControlRegistry new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Mask }, Runtime.IsRequiredValue, new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, + Runtime.DataTableField, new() { Key = "AutoHeight", Label = "자동 높이", Editor = PropEditorKind.Toggle, Default = "False" }, Runtime.PrintOutPut, Runtime.PreventEditing, @@ -132,6 +144,7 @@ public static class ControlRegistry { new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text }, new() { Key = "Checked", Label = "기본 체크", Editor = PropEditorKind.Toggle }, + Runtime.DataTableField, // 진짜 배점이다 — CheckBox.vb:352-367 게터가 저장값(mdScore)을 그대로 돌려준다(체크됐을 때). new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number, Default = "0" }, Runtime.IsRequiredValue, @@ -147,6 +160,7 @@ public static class ControlRegistry { new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text }, new() { Key = "Checked", Label = "기본 선택", Editor = PropEditorKind.Toggle }, + Runtime.DataTableField, // CheckBox 와 같은 구조(RadioButton.vb Score 게터가 mdScore 반환) — 낙상·욕창 평가지의 배점원 new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number, Default = "0" }, Runtime.IsRequiredValue, @@ -162,7 +176,7 @@ public static class ControlRegistry { new() { Key = "Items", Label = "선택지(줄바꿈 구분)", Editor = PropEditorKind.StringList }, Runtime.IsRequiredValue, - new() { Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.Text }, + Runtime.DataTableField, // 콤보의 배점은 Score 가 아니라 ItemScore 다 — ComboBox.vb Score 게터는 저장값을 읽지 않고 // ItemScore 를 "/" 로 쪼개 SelectedIndex 번째를 돌려준다. 항목 순서와 1:1 로 맞춰야 한다. new() { Key = "ItemScore", Label = "항목 배점", Editor = PropEditorKind.Text }, @@ -193,6 +207,7 @@ public static class ControlRegistry { new() { Key = "Format", Label = "형식", Editor = PropEditorKind.Choice, Default = "Long", Choices = new[] { "Long", "Short", "Time", "Custom" } }, new() { Key = "CustomFormat", Label = "사용자 형식", Editor = PropEditorKind.Text }, + Runtime.DataTableField, Runtime.IsRequiredValue, new() { Key = "Visible", Label = "표시", Editor = PropEditorKind.Toggle, Default = "True" }, Runtime.PrintOutPut, @@ -253,6 +268,7 @@ public static class ControlRegistry { new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Default = "Normal", Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } }, + Runtime.DataTableField, // 서명란 — 이 값이 True 여야 런타임이 서명 수집 대상으로 잡는다(MPictureBox.vb:1480, 사용처 :992/:1075/:1212…). // 이걸 못 걸면 서명이 필요한 동의서를 SheetMe 만으로 새로 만들 수 없다. new() { Key = "IsSignature", Label = "서명란", Editor = PropEditorKind.Toggle, Default = "False" }, @@ -273,6 +289,7 @@ public static class ControlRegistry { new() { Key = "Formula", Label = "수식", Editor = PropEditorKind.MultilineText }, new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text }, + Runtime.DataTableField, // 계산박스에는 Score 를 노출하지 않는다 — MCalcBox.vb Score 게터는 저장값이 아니라 // Me.Text 를 숫자로 읽어 돌려주고, 세터는 반대로 Text 를 덮어쓴다. 배점은 이 칸이 아니라 // 합산 대상인 CheckBox/RadioButton 쪽에 준다. @@ -383,6 +400,12 @@ public enum PropEditorKind /// 입력 마스크 — 프리셋 목록 + 미리보기 대화상자 Mask, + + /// + /// 데이터소스 배선 — 전용 대화상자가 문법을 조립한다. + /// 자유 텍스트로 두면 형식을 어긴 값이 런타임에서 무음으로 실패한다(빈 catch). + /// + DataTableField, } /// 인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집 diff --git a/src/SheetMe.Core/Catalog/DataTableFieldSpec.cs b/src/SheetMe.Core/Catalog/DataTableFieldSpec.cs new file mode 100644 index 0000000..05a7210 --- /dev/null +++ b/src/SheetMe.Core/Catalog/DataTableFieldSpec.cs @@ -0,0 +1,166 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace SheetMe.Core.Catalog; + +/// DataTableField 값의 두 가지 형태 +public enum DataTableFieldForm +{ + /// {이름}.Select("{필터}")({행}).Item("{필드}") — 레거시 전용 편집기가 만드는 형태 + Select, + + /// {이름}.Rows({행}).Item("{필드}") — 손으로 쓴 형태. 운영에서 다수다 + Rows, +} + +/// +/// MDataTable 조회 결과를 입력 칸에 꽂는 배선 문자열 — 파싱·조립·검증. +/// +/// 왜 전용 취급이 필요한가. 런타임 파서는 문자열을 쪼개서 읽는데(ucLoadSheetBase.DataTableBinding_), +/// 형식을 어기면 예외를 빈 catch 가 삼켜서 아무 표시 없이 값이 안 채워진다. +/// 디자이너에서는 정상으로 보이고 임상 화면에서만 빈칸이 되므로 원인 추적이 어렵다. +/// 그래서 우리는 문법 골격을 코드가 조립하고, 저장 전에 형식을 검사한다. +/// +/// 두 형태를 모두 다룬다. 레거시 전용 편집기는 Select 형만 만들지만, +/// 운영 실측(진단 --db-dtf)은 반대다 — Rows 형 1,624건(76%) 대 Select 형 268건(12%). +/// Select 형만 읽으면 기존 배선의 대부분을 편집기가 못 열고, 형태를 함부로 바꾸면 +/// Rows 형만 이해하는 보조 로더(bzDesignSheetLoader)에서 동작이 달라질 수 있다. +/// 그래서 연 값의 형태를 그대로 유지하고, 새로 만들 때만 레거시 편집기와 같은 Select 형을 쓴다. +/// +public sealed class DataTableFieldSpec +{ + #region Member Fields + // 파서는 첫 괄호쌍과 첫 따옴표쌍만 본다 — 공백은 어디에도 넣지 않는다(레거시 조립도 공백 0개다) + private static readonly Regex SelectPattern = new( + @"^(?[^.\s""]+)\.Select\((?"")(?[^""]*)""\)\((?\d+)\)\.Item\(""(?[^""]*)""\)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex RowsPattern = new( + @"^(?[^.\s""]+)\.Rows\((?\d+)\)\.Item\(""(?[^""]*)""\)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + #endregion + + #region Properties + /// 값 형태 + public DataTableFieldForm Form { get; set; } = DataTableFieldForm.Select; + + /// 데이터소스(MDataTable) 컨트롤 이름 — 런타임이 대소문자까지 맞춰 찾는다 + public string TableName { get; set; } = string.Empty; + + /// DataTable.Select 필터 — Rows 형에서는 쓰이지 않는다 + public string Filter { get; set; } = string.Empty; + + /// 행 번호(0부터) + public int RowIndex { get; set; } + + /// 컬럼명 또는 SQL 별칭 표현식 + public string Field { get; set; } = string.Empty; + #endregion + + #region Methods + /// 기존 값 해석 — 알아볼 수 없으면 null(원문을 손대지 않기 위해 호출부가 판단한다) + public static DataTableFieldSpec? Parse(string? value) + { + if (value is null) + { + return null; + } + var text = value.Trim(); + if (text.Length == 0) + { + return null; + } + + var select = SelectPattern.Match(text); + if (select.Success) + { + return new DataTableFieldSpec + { + Form = DataTableFieldForm.Select, + TableName = select.Groups["name"].Value, + Filter = select.Groups["filter"].Value, + RowIndex = int.Parse(select.Groups["row"].Value, CultureInfo.InvariantCulture), + Field = select.Groups["field"].Value, + }; + } + + var rows = RowsPattern.Match(text); + if (rows.Success) + { + return new DataTableFieldSpec + { + Form = DataTableFieldForm.Rows, + TableName = rows.Groups["name"].Value, + RowIndex = int.Parse(rows.Groups["row"].Value, CultureInfo.InvariantCulture), + Field = rows.Groups["field"].Value, + }; + } + return null; + } + + /// 문자열 조립 — 공백 없이(레거시 조립과 같은 형태) + public string Compose() + => Form == DataTableFieldForm.Rows + ? $"{TableName}.Rows({RowIndex.ToString(CultureInfo.InvariantCulture)}).Item(\"{Field}\")" + : $"{TableName}.Select(\"{Filter}\")({RowIndex.ToString(CultureInfo.InvariantCulture)}).Item(\"{Field}\")"; + + /// + /// 저장 전 검사 — 문제가 없으면 null, 있으면 사용자에게 보일 이유 한 줄. + /// + /// 레거시는 검사가 전혀 없고 실패가 100% 무음이라, 여기서 막지 않으면 운영에 도달한다. + /// + public string? Validate(IReadOnlyCollection? knownTables = null) + { + if (TableName.Length == 0) + { + return "데이터소스를 선택하세요."; + } + if (TableName.Contains('.', StringComparison.Ordinal)) + { + // 런타임은 첫 '.' 앞을 이름으로 자른다 — 이름에 점이 있으면 영영 못 찾는다 + return "데이터소스 이름에 마침표(.)를 쓸 수 없습니다."; + } + if (TableName.Contains('"', StringComparison.Ordinal) || TableName.Any(char.IsWhiteSpace)) + { + return "데이터소스 이름에 공백이나 큰따옴표를 쓸 수 없습니다."; + } + if (Field.Length == 0) + { + return "필드를 입력하세요."; + } + if (Field.Contains('"', StringComparison.Ordinal)) + { + // 필드는 SQL 별칭 표현식일 수 있어 넓게 허용하되(예: EMDOPRDTE||'-'||EMDOPNAME) 큰따옴표만 막는다 + return "필드에 큰따옴표를 쓸 수 없습니다."; + } + if (Form == DataTableFieldForm.Select && Filter.Contains('"', StringComparison.Ordinal)) + { + // 파서가 따옴표로 쪼개므로 필터 안의 따옴표는 필터·행·필드를 전부 어긋나게 만든다 + return "필터에 큰따옴표를 쓸 수 없습니다."; + } + if (RowIndex < 0) + { + return "행 번호는 0 이상이어야 합니다."; + } + if (knownTables is { Count: > 0 } && !knownTables.Contains(TableName, StringComparer.Ordinal)) + { + // 대소문자까지 맞아야 한다 — 런타임 Hashtable 조회가 대소문자를 구분한다. + // 실측에서도 같은 서식에 없는 이름을 가리키는 값이 20건 있었다(전부 조용히 실패한다). + return $"이 서식에 '{TableName}' 데이터소스가 없습니다 — 대소문자까지 정확해야 합니다."; + } + return null; + } + + /// 조립 결과가 다시 같은 값으로 해석되는지(왕복 자기검사) + public bool RoundTrips() + { + var parsed = Parse(Compose()); + return parsed is not null + && parsed.Form == Form + && string.Equals(parsed.TableName, TableName, StringComparison.Ordinal) + && parsed.RowIndex == RowIndex + && string.Equals(parsed.Field, Field, StringComparison.Ordinal) + && (Form == DataTableFieldForm.Rows || string.Equals(parsed.Filter, Filter, StringComparison.Ordinal)); + } + #endregion +} diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index 1f94801..679553f 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -142,6 +142,11 @@ public partial class App : Application return Diagnostics.DbSmoke.RunRow(args[1], args[2]); } + if (args.Length >= 2 && args[0] == "--db-dtf") + { + return Diagnostics.DbSmoke.RunDataTableFieldReport(args[1]); + } + if (args.Length >= 2 && args[0] == "--db-lines") { return Diagnostics.DbSmoke.RunLineReport(args[1]); diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 7f60cec..87d17a6 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -615,6 +615,162 @@ public static class DbSmoke } } + /// + /// DataTableField / DataTableItms 값 형태 분포(--db-dtf) — 전용 편집기가 어떤 형태를 읽어야 하는지(읽기 전용). + /// + /// 레거시 편집기가 만드는 정본은 {Name}.Select("{필터}")({행}).Item("{필드}") 한 가지지만, + /// 손으로 쓴 {Name}.Rows({행}).Item("{필드}") 형이 섞여 있다. 어느 쪽이 얼마나 되는지 모르면 + /// 편집기가 기존 값을 못 읽어 사용자가 열 때마다 백지에서 다시 조립하게 된다. + /// 파서 제약도 함께 센다 — 필터에 큰따옴표가 들어가면 런타임이 조용히 어긋난다. + /// + public static int RunDataTableFieldReport(string reportPath) + { + var lines = new List(); + try + { + var config = ConfigService.Current; + var store = new OracleLegacyFormStore(config.ConnectionString); + var serializer = new LegacyXmlSerializer(); + var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList(); + + var selectForm = 0; + var rowsForm = 0; + var otherForm = new Dictionary(StringComparer.Ordinal); + var empty = 0; + var quoteInFilter = new List(); + var samples = new List(); + var itms = new Dictionary(StringComparer.Ordinal); + // 첫 토큰이 그 서식의 MDataTable 이름과 대소문자까지 맞는지 — 어긋나면 런타임이 무음 실패한다 + var unresolved = new List(); + + foreach (var sheet in sheets) + { + try + { + var raw = store.LoadActiveDesignRaw(sheet.ShtCod); + if (raw is null) + { + continue; + } + var document = serializer.Read(raw.Value.Xml); + var tableNames = new HashSet(StringComparer.Ordinal); + void CollectTables(Core.Models.ControlElement element) + { + if (string.Equals(element.Type, "DataTable", StringComparison.Ordinal)) + { + tableNames.Add(element.Id); + } + foreach (var child in element.Children) + { + CollectTables(child); + } + } + + void Visit(Core.Models.ControlElement element) + { + var value = element.Props.GetText("DataTableField"); + if (value is not null) + { + var trimmed = value.Trim(); + if (trimmed.Length == 0) + { + empty++; + } + else if (trimmed.Contains(".Select(", StringComparison.Ordinal)) + { + selectForm++; + if (samples.Count < 12) + { + samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}"); + } + // C6 — 필터 안의 큰따옴표. Split(""")(") 이 앞쪽에서 잘려 전부 어긋난다 + var afterSelect = trimmed.Split(".Select(\"", 2, StringSplitOptions.None); + if (afterSelect.Length == 2) + { + var filter = afterSelect[1].Split("\")(", 2, StringSplitOptions.None)[0]; + if (filter.Contains('"')) + { + quoteInFilter.Add($" {sheet.ShtCod} {element.Id}: {trimmed}"); + } + } + } + else if (trimmed.Contains(".Rows(", StringComparison.Ordinal)) + { + rowsForm++; + if (samples.Count < 12) + { + samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}"); + } + } + else + { + otherForm[trimmed] = otherForm.GetValueOrDefault(trimmed) + 1; + } + + if (trimmed.Length > 0) + { + var head = trimmed.Split('.')[0]; + if (head.Length > 0 && !tableNames.Contains(head) && unresolved.Count < 20) + { + unresolved.Add($" {sheet.ShtCod} {element.Id}: '{head}' — 이 서식의 데이터소스: " + + (tableNames.Count == 0 ? "(없음)" : string.Join(",", tableNames))); + } + } + } + if (element.Props.GetText("DataTableItms") is { } items && items.Trim().Length > 0) + { + var kind = items.Contains(".Select(", StringComparison.Ordinal) ? "Select형" + : items.Contains(".Rows(", StringComparison.Ordinal) ? "Rows형(콤보 안 채워짐)" + : "기타"; + itms[kind] = itms.GetValueOrDefault(kind) + 1; + } + foreach (var child in element.Children) + { + Visit(child); + } + } + + foreach (var page in document.Pages) + { + CollectTables(page.Root); + } + foreach (var page in document.Pages) + { + Visit(page.Root); + } + } + catch + { + // 통계용 + } + } + + lines.Add($"DataTableField — Select형 {selectForm}건 / Rows형 {rowsForm}건 / 기타 {otherForm.Values.Sum()}건 / 빈값 {empty}건"); + if (otherForm.Count > 0) + { + lines.Add("[기타 형태]"); + lines.AddRange(otherForm.OrderByDescending(p => p.Value).Take(10).Select(p => $" {p.Key} ×{p.Value}")); + } + lines.Add($"필터에 큰따옴표가 든 값(런타임 무음 실패): {quoteInFilter.Count}건"); + lines.AddRange(quoteInFilter.Take(10)); + lines.Add($"첫 토큰이 같은 서식의 데이터소스 이름과 안 맞는 값: {unresolved.Count}건"); + lines.AddRange(unresolved.Take(10)); + lines.Add("DataTableItms 형태: " + (itms.Count == 0 ? "(없음)" + : string.Join(", ", itms.OrderByDescending(p => p.Value).Select(p => $"{p.Key}×{p.Value}")))); + lines.Add("[표본]"); + lines.AddRange(samples); + + File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); + return 0; + } + catch (Exception ex) + { + lines.Add("실패: " + ex); + File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); + return 1; + } + } + /// /// 선(MLine) 방향 일치 검사(--db-lines) — 디자이너 렌더를 바꿔야 하는지 판단하는 근거(읽기 전용). /// diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs index da3c7a3..490f83c 100644 --- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs +++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs @@ -117,6 +117,9 @@ public static class DialogShots "SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <>")), ("04-tag-picker", () => new Views.TagPickerDialogView("동작 태그", SampleTags(), SampleTags()[1])), ("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")), + // 데이터소스 배선 — 운영 다수파인 Rows 형 값을 넣어 되읽기·형태 유지가 보이게 찍는다 + ("05b-datatable-field", () => new Views.DataTableFieldDialogView( + "MDataTable2.Rows(0).Item(\"ALGYON\")", new[] { "MDataTable1", "MDataTable2" })), ("06-font-manager", () => new Views.FontManagerDialogView(designer)), ("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")), ("08-preview", () => new Views.PreviewWindow(designer)), diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index e98a14d..1676dcf 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -820,6 +820,43 @@ public static class EditSmoke designer.Selection.Clear(); } + // 20-5g) 데이터소스 배선 — 전용 행이 붙고, 이 서식의 데이터소스만 후보가 된다 + { + designer.AddControlAt("DataTable", new Point(600, 320)); + var source = designer.Pages[0].Controls.First(c => c.Type == "DataTable"); + var bound = Find("TextBox"); + designer.Selection.SetSingle(bound); + // 배선은 '무슨 값이 채워지는가'라 데이터 탭에 있다 + var previousTab = designer.Inspector.SelectedTab; + designer.Inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Data; + var dtfRow = designer.Inspector.Rows + .OfType().FirstOrDefault(); + Check("배선: 전용 편집 행이 붙는다", dtfRow is not null); + if (dtfRow is null) + { + designer.Inspector.SelectedTab = previousTab; + goto skipWiring; + } + Check("배선: 값이 없으면 안내 문구", dtfRow.Summary == "(배선 없음)", dtfRow.Summary); + + // 운영 다수파인 Rows 형을 읽어야 한다(1,624건, 76%) + dtfRow.ValueText = $"{source.Id}.Rows(0).Item(\"ALGYON\")"; + Check("배선: Rows 형을 읽는다", dtfRow.Summary.Contains(source.Id) && dtfRow.Summary.Contains("ALGYON"), + dtfRow.Summary); + + // 해석 못 하는 값은 조용히 고치지 않고 경고로 드러낸다 + dtfRow.ValueText = "MDataTable1.Rows"; + Check("배선: 미완성 값은 경고로 드러낸다", dtfRow.Summary.StartsWith("⚠"), dtfRow.Summary); + + dtfRow.ValueText = string.Empty; + designer.Inspector.SelectedTab = previousTab; + + skipWiring: + designer.Selection.SetSingle(source); + designer.DeleteSelection(); + designer.Selection.Clear(); + } + // 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다 Check("태그 카탈로그: 미구현 3종 제외", !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_BMI") diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs index 9d41ace..359fd9d 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs @@ -602,11 +602,41 @@ public sealed class InspectorViewModel : ViewModelBase "액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags), PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label), PropEditorKind.Mask => new MaskRowViewModel(def.Label), + // 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다 + PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames), _ => new TextRowViewModel(def.Label), }; AddRow(row, binding); } + /// + /// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지. + /// + /// 레거시 편집기는 열려 있는 모든 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고, + /// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다. + /// + private IReadOnlyList DataTableNames() + { + var names = new List(); + void Visit(SheetMe.Core.Models.ControlElement element) + { + if (string.Equals(element.Type, "DataTable", StringComparison.Ordinal) && element.Id.Length > 0) + { + names.Add(element.Id); + } + foreach (var child in element.Children) + { + Visit(child); + } + } + foreach (var page in designer.Document.Pages) + { + Visit(page.Root); + } + names.Sort(StringComparer.Ordinal); + return names; + } + /// /// 선의 방향·굵기 변경 — 속성과 크기를 함께 맞춘다(레거시 MLine 의 세터/OnResize 이식). /// diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs index b2ce6e5..ebe152c 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs @@ -661,6 +661,62 @@ public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel } } +/// +/// 데이터소스 배선 행 — 전용 대화상자로만 편집한다. +/// +/// 자유 텍스트로 두면 형식을 어긴 값이 저장되고, 런타임은 그 예외를 빈 catch 로 삼켜 +/// 아무 표시 없이 값을 비워 둔다. 디자이너에서는 정상으로 보이므로 원인 추적이 어렵다. +/// +public sealed class DataTableFieldRowViewModel : PropertyRowViewModel +{ + private readonly Func> tablesOf; + + /// 사람이 읽는 요약 — 배선이 없으면 안내 문구 + public string Summary + { + get + { + if (ValueText.Length == 0) + { + return "(배선 없음)"; + } + var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText); + return spec is null + // 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다 + ? $"⚠ 형식을 알 수 없음: {ValueText}" + : $"{spec.TableName} · {spec.Field}" + (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex}행"); + } + } + + /// 편집기 열기 + public M.Framework.WPF.ICustomCommand? EditCommand { get; set; } + + public DataTableFieldRowViewModel(string label, Func> tablesOf) : base(label) + { + this.tablesOf = tablesOf; + EditCommand = new M.Framework.WPF.Command((sender, e) => OnEdit()); + PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ValueText)) + { + OnPropertyChanged(nameof(Summary)); + } + }; + } + + private void OnEdit() + { + var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf()) + { + Owner = System.Windows.Application.Current.MainWindow, + }; + if (dialog.ShowDialog() == true && dialog.Result is not null) + { + ValueText = dialog.Result; + } + } +} + /// /// 속성 추가 행 — 키 입력 후 빈 속성 생성(고급). /// diff --git a/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml b/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml new file mode 100644 index 0000000..cdfd2d6 --- /dev/null +++ b/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml @@ -0,0 +1,72 @@ + + + + + + +