deukpack 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.ko.md +138 -0
  4. package/README.md +182 -0
  5. package/RELEASING.md +71 -0
  6. package/bin/deukpack.js +9 -0
  7. package/dist/ast/DeukPackASTBuilder.d.ts +153 -0
  8. package/dist/ast/DeukPackASTBuilder.d.ts.map +1 -0
  9. package/dist/ast/DeukPackASTBuilder.js +931 -0
  10. package/dist/ast/DeukPackASTBuilder.js.map +1 -0
  11. package/dist/codegen/CSharpGenerator.d.ts +136 -0
  12. package/dist/codegen/CSharpGenerator.d.ts.map +1 -0
  13. package/dist/codegen/CSharpGenerator.js +2303 -0
  14. package/dist/codegen/CSharpGenerator.js.map +1 -0
  15. package/dist/codegen/CodeGenerator.d.ts +11 -0
  16. package/dist/codegen/CodeGenerator.d.ts.map +1 -0
  17. package/dist/codegen/CodeGenerator.js +11 -0
  18. package/dist/codegen/CodeGenerator.js.map +1 -0
  19. package/dist/codegen/CppGenerator.d.ts +23 -0
  20. package/dist/codegen/CppGenerator.d.ts.map +1 -0
  21. package/dist/codegen/CppGenerator.js +215 -0
  22. package/dist/codegen/CppGenerator.js.map +1 -0
  23. package/dist/codegen/HighPerformanceCSharpGenerator.d.ts +29 -0
  24. package/dist/codegen/HighPerformanceCSharpGenerator.d.ts.map +1 -0
  25. package/dist/codegen/HighPerformanceCSharpGenerator.js +486 -0
  26. package/dist/codegen/HighPerformanceCSharpGenerator.js.map +1 -0
  27. package/dist/core/DeukPackEngine.d.ts +69 -0
  28. package/dist/core/DeukPackEngine.d.ts.map +1 -0
  29. package/dist/core/DeukPackEngine.js +379 -0
  30. package/dist/core/DeukPackEngine.js.map +1 -0
  31. package/dist/core/DeukPackGenerator.d.ts +9 -0
  32. package/dist/core/DeukPackGenerator.d.ts.map +1 -0
  33. package/dist/core/DeukPackGenerator.js +15 -0
  34. package/dist/core/DeukPackGenerator.js.map +1 -0
  35. package/dist/core/DeukParser.d.ts +12 -0
  36. package/dist/core/DeukParser.d.ts.map +1 -0
  37. package/dist/core/DeukParser.js +27 -0
  38. package/dist/core/DeukParser.js.map +1 -0
  39. package/dist/core/IdlParser.d.ts +27 -0
  40. package/dist/core/IdlParser.d.ts.map +1 -0
  41. package/dist/core/IdlParser.js +157 -0
  42. package/dist/core/IdlParser.js.map +1 -0
  43. package/dist/core/ProtoParser.d.ts +12 -0
  44. package/dist/core/ProtoParser.d.ts.map +1 -0
  45. package/dist/core/ProtoParser.js +27 -0
  46. package/dist/core/ProtoParser.js.map +1 -0
  47. package/dist/csharp/DpExcelProtocol.cs +3005 -0
  48. package/dist/csharp/DpProtocolLibrary.cs +13 -0
  49. package/dist/index.d.ts +22 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +43 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/lexer/DeukLexer.d.ts +31 -0
  54. package/dist/lexer/DeukLexer.d.ts.map +1 -0
  55. package/dist/lexer/DeukLexer.js +292 -0
  56. package/dist/lexer/DeukLexer.js.map +1 -0
  57. package/dist/lexer/IdlLexer.d.ts +33 -0
  58. package/dist/lexer/IdlLexer.d.ts.map +1 -0
  59. package/dist/lexer/IdlLexer.js +286 -0
  60. package/dist/lexer/IdlLexer.js.map +1 -0
  61. package/dist/native/NativeDeukPackEngine.d.ts +30 -0
  62. package/dist/native/NativeDeukPackEngine.d.ts.map +1 -0
  63. package/dist/native/NativeDeukPackEngine.js +99 -0
  64. package/dist/native/NativeDeukPackEngine.js.map +1 -0
  65. package/dist/proto/ProtoASTBuilder.d.ts +29 -0
  66. package/dist/proto/ProtoASTBuilder.d.ts.map +1 -0
  67. package/dist/proto/ProtoASTBuilder.js +239 -0
  68. package/dist/proto/ProtoASTBuilder.js.map +1 -0
  69. package/dist/proto/ProtoLexer.d.ts +29 -0
  70. package/dist/proto/ProtoLexer.d.ts.map +1 -0
  71. package/dist/proto/ProtoLexer.js +264 -0
  72. package/dist/proto/ProtoLexer.js.map +1 -0
  73. package/dist/proto/ProtoTypes.d.ts +40 -0
  74. package/dist/proto/ProtoTypes.d.ts.map +1 -0
  75. package/dist/proto/ProtoTypes.js +37 -0
  76. package/dist/proto/ProtoTypes.js.map +1 -0
  77. package/dist/protocols/BinaryProtocol.d.ts +7 -0
  78. package/dist/protocols/BinaryProtocol.d.ts.map +1 -0
  79. package/dist/protocols/BinaryProtocol.js +11 -0
  80. package/dist/protocols/BinaryProtocol.js.map +1 -0
  81. package/dist/protocols/BinaryWriter.d.ts +22 -0
  82. package/dist/protocols/BinaryWriter.d.ts.map +1 -0
  83. package/dist/protocols/BinaryWriter.js +104 -0
  84. package/dist/protocols/BinaryWriter.js.map +1 -0
  85. package/dist/protocols/CompactProtocol.d.ts +7 -0
  86. package/dist/protocols/CompactProtocol.d.ts.map +1 -0
  87. package/dist/protocols/CompactProtocol.js +11 -0
  88. package/dist/protocols/CompactProtocol.js.map +1 -0
  89. package/dist/protocols/ExcelProtocol.d.ts +98 -0
  90. package/dist/protocols/ExcelProtocol.d.ts.map +1 -0
  91. package/dist/protocols/ExcelProtocol.js +639 -0
  92. package/dist/protocols/ExcelProtocol.js.map +1 -0
  93. package/dist/protocols/JsonProtocol.d.ts +68 -0
  94. package/dist/protocols/JsonProtocol.d.ts.map +1 -0
  95. package/dist/protocols/JsonProtocol.js +422 -0
  96. package/dist/protocols/JsonProtocol.js.map +1 -0
  97. package/dist/protocols/WireProtocol.d.ts +348 -0
  98. package/dist/protocols/WireProtocol.d.ts.map +1 -0
  99. package/dist/protocols/WireProtocol.js +912 -0
  100. package/dist/protocols/WireProtocol.js.map +1 -0
  101. package/dist/serialization/WireDeserializer.d.ts +8 -0
  102. package/dist/serialization/WireDeserializer.d.ts.map +1 -0
  103. package/dist/serialization/WireDeserializer.js +13 -0
  104. package/dist/serialization/WireDeserializer.js.map +1 -0
  105. package/dist/serialization/WireSerializer.d.ts +20 -0
  106. package/dist/serialization/WireSerializer.d.ts.map +1 -0
  107. package/dist/serialization/WireSerializer.js +100 -0
  108. package/dist/serialization/WireSerializer.js.map +1 -0
  109. package/dist/types/DeukPackTypes.d.ts +291 -0
  110. package/dist/types/DeukPackTypes.d.ts.map +1 -0
  111. package/dist/types/DeukPackTypes.js +76 -0
  112. package/dist/types/DeukPackTypes.js.map +1 -0
  113. package/dist/utils/EndianUtils.d.ts +11 -0
  114. package/dist/utils/EndianUtils.d.ts.map +1 -0
  115. package/dist/utils/EndianUtils.js +32 -0
  116. package/dist/utils/EndianUtils.js.map +1 -0
  117. package/dist/utils/PerformanceMonitor.d.ts +26 -0
  118. package/dist/utils/PerformanceMonitor.d.ts.map +1 -0
  119. package/dist/utils/PerformanceMonitor.js +57 -0
  120. package/dist/utils/PerformanceMonitor.js.map +1 -0
  121. package/package.json +77 -0
  122. package/scripts/build_deukpack.js +669 -0
@@ -0,0 +1,3005 @@
1
+ /**
2
+ * DeukPack DpExcelProtocol
3
+ * DpProtocol implementation that reads/writes Excel data using numeric field-ID hierarchy headers.
4
+ *
5
+ * Excel layout (신버전 3행 헤더):
6
+ * Row 1: HIERARCHY_ID / FIELD_ID (numeric field IDs, dot-separated: "1", "20", "20.1", "20.2.1")
7
+ * Row 2: DATATYPE — 득팩 표준 타입명 (int32, list<T>, record, enum<T> 등). 레거시(i32, lst, rec) 입력 수용.
8
+ * Row 3: COLUMN_NAME (human-readable)
9
+ * Row 4+: DATA
10
+ *
11
+ * Structure patterns:
12
+ * Primitive: col "1" (int64) → one cell per row
13
+ * Struct: col "40" (record), "40.1" (int32), "40.22.1" (string) → implicit parent inference
14
+ * List: col "20" (list<T>), "20.1" (int32 elem) → root col has list index (0,1,2,...), multi-row
15
+ *
16
+ * Type handling: 모든 타입 판별은 DpTypeNames.FromProtocolName(dt) 사용. 레거시(i32/lst/rec)와 표준(int32/list/record) 동일 처리.
17
+ * 분리 시트: ContainerSheetPolicy 단일 통로. IsContainerType / GetKindForWireType / GetSheetNamesForLookup 사용.
18
+ */
19
+
20
+ using System;
21
+ using System.Collections;
22
+ using System.Collections.Generic;
23
+ using System.Linq;
24
+ using System.Reflection;
25
+ using System.Text;
26
+
27
+ namespace DeukPack.Protocol
28
+ {
29
+ /// <summary>Header-only tree node from Excel rows 1–3. Usable without csDeukDefine DLL.</summary>
30
+ public class ExcelHeaderEntry
31
+ {
32
+ public string HierarchyId { get; set; }
33
+ public string DataType { get; set; }
34
+ public string ColumnName { get; set; }
35
+ /// <summary>1-based column index in the sheet, or -1 for implicit (no physical column).</summary>
36
+ public int Col { get; set; }
37
+ public List<ExcelHeaderEntry> Children { get; set; } = new List<ExcelHeaderEntry>();
38
+ }
39
+
40
+ /// <summary>DpSchema/Excel 헤더 공통 flat 엔트리. FlattenSchema()와 GetFlatHeaders() 모두 이 타입 반환.</summary>
41
+ public class FlatHeaderField
42
+ {
43
+ public string HierarchyId { get; set; }
44
+ public string DataType { get; set; }
45
+ public string ColumnName { get; set; }
46
+ public string DocComment { get; set; }
47
+ /// <summary>Excel 시트 컬럼 위치 (1-based). DLL 스키마 유래 시 0.</summary>
48
+ public int Col { get; set; }
49
+ /// <summary>True if this entry is a struct grouping marker (no data, visual separator only).</summary>
50
+ public bool IsStructMarker { get; set; }
51
+ /// <summary>Immediate parent struct type name (e.g. "Position" for x/y/z). Null/empty for top-level fields.</summary>
52
+ public string ParentStructName { get; set; }
53
+ }
54
+
55
+ public interface IExcelSheet
56
+ {
57
+ string CellValue(int row, int col);
58
+ bool IsCellEmpty(int row, int col);
59
+ int LastColumn { get; }
60
+ int LastRow { get; }
61
+ /// <summary>워크북 이름(확장자 제외). 헤더 버전(v1/v2) 판별용. 구현체가 모르면 빈 문자열.</summary>
62
+ string WorkbookName { get; }
63
+ /// <summary>시트(탭) 이름. 버전 판별용. 구현체가 모르면 빈 문자열.</summary>
64
+ string SheetName { get; }
65
+ }
66
+
67
+ public interface IWritableExcelSheet : IExcelSheet
68
+ {
69
+ void SetCellValue(int row, int col, string value);
70
+ void SetCellFormula(int row, int col, string formula);
71
+ void SetColumnNumberFormat(int col, string format);
72
+ void SetRangeInteriorColor(int rowStart, int rowEnd, int colStart, int colEnd, int colorOle);
73
+ void SetRangeFont(int rowStart, int rowEnd, int colStart, int colEnd, int fontColorOle, bool bold, bool italic, double fontSize);
74
+ void SetBottomBorder(int row, int colStart, int colEnd, int colorOle);
75
+ void AutoFitColumns(int colStart, int colEnd);
76
+ }
77
+
78
+ /// <summary>
79
+ /// Provides separated container sheets (list/set/map) by sheet name.
80
+ /// Return null if the sheet does not exist → fallback to embedded (main sheet) mode.
81
+ /// For write: also provides a writable sheet for appending rows.
82
+ /// </summary>
83
+ public interface IContainerSheetResolver
84
+ {
85
+ /// <summary>Returns the read-only view of a container sheet, or null if not present.</summary>
86
+ IExcelSheet GetSheet(string sheetName);
87
+ /// <summary>Returns the writable view of a container sheet, creating it if needed.
88
+ /// Return null to fall back to embedded write.</summary>
89
+ IWritableExcelSheet GetOrCreateSheet(string sheetName, string[] headerHierarchyIds, string[] headerDataTypes, string[] headerColumnNames, string[] headerStructNames = null);
90
+ }
91
+
92
+ /// <summary>
93
+ /// 분리 시트 정책 단일 통로. 스키마에서 "표시 위치가 분리되는" 타입(List/Set/Map)과 시트 kind/이름 규칙을 한 곳에서 정의.
94
+ /// 시트 코드 변경 시 이 정책만 수정하면 됨.
95
+ /// </summary>
96
+ public static class ContainerSheetPolicy
97
+ {
98
+ /// <summary>분리 시트를 갖는 타입. 이 타입만 별도 시트로 표시.</summary>
99
+ public static bool IsContainerType(DpWireType wt)
100
+ {
101
+ return wt == DpWireType.List || wt == DpWireType.Set || wt == DpWireType.Map;
102
+ }
103
+
104
+ /// <summary>쓰기/신규 시트 생성 시 사용할 canonical kind (표준 표기).</summary>
105
+ public static string GetKindForWireType(DpWireType wt)
106
+ {
107
+ if (wt == DpWireType.List) return "list";
108
+ if (wt == DpWireType.Set) return "set";
109
+ if (wt == DpWireType.Map) return "map";
110
+ return null;
111
+ }
112
+
113
+ /// <summary>시트 조회 시 시도할 kind 목록 (canonical 먼저, 레거시 호환 후).</summary>
114
+ public static string[] GetKindVariantsForLookup(DpWireType wt)
115
+ {
116
+ if (wt == DpWireType.List) return new[] { "list", "lst" };
117
+ if (wt == DpWireType.Set) return new[] { "set" };
118
+ if (wt == DpWireType.Map) return new[] { "map" };
119
+ return System.Array.Empty<string>();
120
+ }
121
+
122
+ /// <summary>파싱 시 허용할 kind 문자열. 여기만 수정하면 TryParse 동작 일괄 변경.</summary>
123
+ public static readonly string[] ValidKindsForParsing = { "lst", "list", "set", "map" };
124
+
125
+ public static bool IsValidKind(string kind)
126
+ {
127
+ if (string.IsNullOrEmpty(kind)) return false;
128
+ string k = kind.Trim().ToLowerInvariant();
129
+ foreach (var v in ValidKindsForParsing)
130
+ if (k == v) return true;
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /// <summary>
136
+ /// Helpers for container sheet naming and parsing. 시트 이름/파일 이름 형식은 여기서만 정의.
137
+ /// Sheet name format: "{fieldName}:{fieldId}" e.g. "spawners:30.4.1" — 공간 절약, 31자 잘림 시 이름만 축약해 필드ID 유지.
138
+ /// </summary>
139
+ public static class ContainerSheetNaming
140
+ {
141
+ const int EXCEL_SHEET_NAME_MAX = 31;
142
+
143
+ /// <summary>Build the container sheet name: "{fieldName}:{fieldId}". Excel 31자 제한 시 fieldName만 축약해 필드ID는 항상 보존.</summary>
144
+ public static string FormatContainerSheetName(string fieldId, string kind, string fieldName)
145
+ {
146
+ string name = (fieldName ?? "").Trim();
147
+ string id = (fieldId ?? "").Trim();
148
+ string suffix = (id.Length > 0) ? ":" + id : "";
149
+ int maxNameLen = EXCEL_SHEET_NAME_MAX - suffix.Length;
150
+ if (maxNameLen <= 0) return suffix.Length > EXCEL_SHEET_NAME_MAX ? suffix.Substring(0, EXCEL_SHEET_NAME_MAX) : suffix;
151
+ if (name.Length > maxNameLen) name = name.Substring(0, maxNameLen);
152
+ return name + suffix;
153
+ }
154
+
155
+ /// <summary>분리 시트 조회 시 사용할 후보 시트 이름. "{fieldName}:{fieldId}" 먼저, 이어서 레거시 "fieldId kind fieldName" 순.</summary>
156
+ public static string[] GetSheetNamesForLookup(string fieldId, DpWireType wireType, string fieldName)
157
+ {
158
+ var canonical = FormatContainerSheetName(fieldId, null, fieldName ?? "");
159
+ var kinds = ContainerSheetPolicy.GetKindVariantsForLookup(wireType);
160
+ var names = new List<string> { canonical };
161
+ foreach (string k in kinds)
162
+ {
163
+ string legacy = $"{fieldId} {k} {fieldName ?? ""}".Trim();
164
+ if (legacy.Length <= EXCEL_SHEET_NAME_MAX && !names.Contains(legacy, StringComparer.OrdinalIgnoreCase))
165
+ names.Add(legacy);
166
+ }
167
+ return names.ToArray();
168
+ }
169
+
170
+ static bool LooksLikeFieldId(string value)
171
+ {
172
+ if (string.IsNullOrEmpty(value)) return false;
173
+ foreach (char c in value) if (c != '.' && !char.IsDigit(c)) return false;
174
+ return true;
175
+ }
176
+
177
+ /// <summary>Parse container sheet name. "{fieldName}:{fieldId}" 또는 레거시 "{fieldId} {kind} {fieldName}".</summary>
178
+ public static bool TryParseContainerSheetName(string sheetName,
179
+ out string fieldId, out string kind, out string fieldName)
180
+ {
181
+ fieldId = kind = fieldName = null;
182
+ if (string.IsNullOrWhiteSpace(sheetName)) return false;
183
+ string s = sheetName.Trim();
184
+ // 신규 형식: "spawners:30.4.1" — 마지막 ':' 기준으로 우측이 숫자·점이면 fieldId
185
+ int lastColon = s.LastIndexOf(':');
186
+ if (lastColon >= 0 && lastColon < s.Length - 1)
187
+ {
188
+ string right = s.Substring(lastColon + 1).Trim();
189
+ if (LooksLikeFieldId(right))
190
+ {
191
+ fieldName = s.Substring(0, lastColon).Trim();
192
+ fieldId = right;
193
+ kind = "list";
194
+ return true;
195
+ }
196
+ }
197
+ // 레거시: "30 list spawners" 또는 이전 괄호 형식 "spawners (30.4.1)"
198
+ int lastOpen = s.LastIndexOf(" (", StringComparison.Ordinal);
199
+ if (lastOpen >= 0 && s.Length > lastOpen + 2 && s[s.Length - 1] == ')')
200
+ {
201
+ fieldName = s.Substring(0, lastOpen).Trim();
202
+ fieldId = s.Substring(lastOpen + 2, s.Length - lastOpen - 3).Trim();
203
+ if (LooksLikeFieldId(fieldId)) { kind = "list"; return true; }
204
+ }
205
+ if (s.Length > 2 && s[0] == '(' && s[s.Length - 1] == ')')
206
+ {
207
+ fieldId = s.Substring(1, s.Length - 2).Trim();
208
+ if (LooksLikeFieldId(fieldId)) { fieldName = ""; kind = "list"; return true; }
209
+ }
210
+ var parts = s.Split(new[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
211
+ if (parts.Length < 3) return false;
212
+ string k = parts[1].ToLowerInvariant();
213
+ if (!ContainerSheetPolicy.IsValidKind(k)) return false;
214
+ fieldId = parts[0];
215
+ kind = k;
216
+ fieldName = parts[2];
217
+ return true;
218
+ }
219
+
220
+ /// <summary>Build container file name: "{exportBase}.{fieldId}.{kind}.{fieldName}.{ext}"</summary>
221
+ public static string FormatContainerFileName(string exportBase, string fieldId, string kind, string fieldName, string ext)
222
+ => $"{exportBase}.{fieldId}.{kind}.{fieldName}{ext}";
223
+
224
+ /// <summary>
225
+ /// Extract exportBase from a workbook filename: remove the trailing "@deuk" or "@gplat" (compatible) suffix.
226
+ /// e.g. "mo_map@deuk.xlsx" → "mo_map", "npc@entity@gplat.xlsx" → "npc@entity"
227
+ /// </summary>
228
+ public static string GetExportBase(string workbookPath)
229
+ {
230
+ if (string.IsNullOrEmpty(workbookPath)) return "";
231
+ string name = System.IO.Path.GetFileNameWithoutExtension(workbookPath);
232
+ const string primarySuffix = "@deuk";
233
+ const string compatibleSuffix = "@gplat";
234
+ if (name.EndsWith(primarySuffix, StringComparison.OrdinalIgnoreCase))
235
+ name = name.Substring(0, name.Length - primarySuffix.Length);
236
+ else if (name.EndsWith(compatibleSuffix, StringComparison.OrdinalIgnoreCase))
237
+ name = name.Substring(0, name.Length - compatibleSuffix.Length);
238
+ return name;
239
+ }
240
+
241
+ /// <summary>Column 1 of every separated sheet: nav-back button (value = meta_id, click to return to main sheet).</summary>
242
+ public const string NAV_BACK_COLUMN = "_nav";
243
+ /// <summary>Column 2 of every separated sheet: meta_id (links back to the parent record).</summary>
244
+ public const string META_ID_COLUMN = "meta_id";
245
+ /// <summary>Column 3 of every separated sheet: name (human-readable, copied from parent).</summary>
246
+ public const string META_NAME_COLUMN = "name";
247
+
248
+ private const string BACKUP_MARKER = "_bak_";
249
+
250
+ /// <summary>백업 시트 명명 통일: b_원래이름_날자 (날자 = yyMMdd_HHmm). Excel 시트명 31자 제한.</summary>
251
+ public const string BACKUP_PREFIX = "b_";
252
+ /// <summary>백업 날자 형식. MakeBackupSheetName와 IsBackupSheet 판별에 동일 사용.</summary>
253
+ public const string BACKUP_DATE_FORMAT = "yyMMdd_HHmm";
254
+
255
+ /// <summary>
256
+ /// True if the sheet name looks like a backup: contains "_bak_" (legacy) or matches b_원래이름_yyMMdd_HHmm.
257
+ /// </summary>
258
+ public static bool IsBackupSheet(string sheetName)
259
+ {
260
+ if (string.IsNullOrEmpty(sheetName)) return false;
261
+ if (sheetName.IndexOf(BACKUP_MARKER, StringComparison.OrdinalIgnoreCase) >= 0)
262
+ return true;
263
+ if (sheetName.StartsWith(BACKUP_PREFIX, StringComparison.OrdinalIgnoreCase) && sheetName.Length >= 15)
264
+ {
265
+ string suffix = sheetName.Substring(sheetName.Length - 12);
266
+ if (suffix.Length == 12 && suffix[0] == '_' && suffix[7] == '_')
267
+ {
268
+ bool digits = true;
269
+ for (int i = 1; i <= 6 && digits; i++) digits = char.IsDigit(suffix[i]);
270
+ for (int i = 8; i <= 11 && digits; i++) digits = char.IsDigit(suffix[i]);
271
+ if (digits) return true;
272
+ }
273
+ }
274
+ return false;
275
+ }
276
+
277
+ /// <summary>백업 시트 이름 생성 (통일 규칙: b_원래이름_yyMMdd_HHmm). Excel 31자 제한. existingSheetNames에 없으면 그대로, 있으면 _2, _3 붙임.</summary>
278
+ public static string MakeBackupSheetName(string originalSheetName, System.Collections.Generic.IEnumerable<string> existingSheetNames)
279
+ {
280
+ const int maxLen = 31;
281
+ string ts = System.DateTime.Now.ToString(BACKUP_DATE_FORMAT);
282
+ int suffixLen = BACKUP_PREFIX.Length + 1 + ts.Length;
283
+ int maxBase = Math.Max(1, maxLen - suffixLen);
284
+ string basePart = (originalSheetName ?? "").Trim();
285
+ if (basePart.Length > maxBase) basePart = basePart.Substring(0, maxBase);
286
+ string candidate = BACKUP_PREFIX + basePart + "_" + ts;
287
+ if (candidate.Length > maxLen) candidate = candidate.Substring(0, maxLen);
288
+
289
+ var existing = new System.Collections.Generic.HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
290
+ if (existingSheetNames != null)
291
+ {
292
+ foreach (string n in existingSheetNames)
293
+ if (!string.IsNullOrEmpty(n)) existing.Add(n);
294
+ }
295
+ if (existing.Count > 0 && existing.Contains(candidate))
296
+ {
297
+ for (int i = 2; i < 100; i++)
298
+ {
299
+ string alt = candidate.Length + 2 <= maxLen ? $"{candidate}_{i}" : $"{candidate.Substring(0, maxLen - 2)}_{i}";
300
+ if (alt.Length > maxLen) alt = alt.Substring(0, maxLen);
301
+ if (!existing.Contains(alt)) { candidate = alt; break; }
302
+ }
303
+ }
304
+ return candidate;
305
+ }
306
+
307
+ /// <summary>
308
+ /// True if the sheet name is a separated container sheet (lst/set/map).
309
+ /// </summary>
310
+ public static bool IsContainerSheet(string sheetName)
311
+ {
312
+ return TryParseContainerSheetName(sheetName, out _, out _, out _);
313
+ }
314
+
315
+ /// <summary>
316
+ /// True if the sheet is a "main content" sheet: not a backup, not a container (lst/set/map).
317
+ /// </summary>
318
+ public static bool IsMainContentSheet(string sheetName)
319
+ {
320
+ if (string.IsNullOrEmpty(sheetName)) return false;
321
+ if (IsBackupSheet(sheetName)) return false;
322
+ if (IsContainerSheet(sheetName)) return false;
323
+ return true;
324
+ }
325
+ }
326
+
327
+ public class DpExcelProtocol : DpProtocol
328
+ {
329
+ private readonly IExcelSheet _sheet;
330
+ private int _dataRow;
331
+ private int _maxDataRow;
332
+ private readonly int _sheetFirstDataRow;
333
+
334
+ // Optional resolver for separated container sheets (list/set/map)
335
+ private readonly IContainerSheetResolver _resolver;
336
+
337
+ // Column mapping built from Row 1 (HIERARCHY_ID)
338
+ private readonly Dictionary<string, int> _fieldIdToCol = new Dictionary<string, int>();
339
+ private readonly Dictionary<int, string> _colDataType = new Dictionary<int, string>();
340
+ private readonly Dictionary<int, string> _colColumnName = new Dictionary<int, string>();
341
+
342
+ // Struct navigation: path stack + pending fields stack
343
+ private readonly Stack<string> _pathStack = new Stack<string>();
344
+ private string _currentPath = "";
345
+ private readonly Stack<StructState> _structStack = new Stack<StructState>();
346
+ private List<FieldEntry> _pendingFields;
347
+ private int _pendingIdx;
348
+
349
+ // List navigation
350
+ private readonly Stack<ListState> _listStack = new Stack<ListState>();
351
+
352
+ // Last field returned by ReadFieldBegin (for ReadCurrentCellValue)
353
+ private FieldEntry _lastField;
354
+
355
+ // Row stack: save/restore _dataRow across nested structs within lists
356
+ private readonly Stack<int> _dataRowStack = new Stack<int>();
357
+
358
+ // Separated sheet context: when reading a struct element from a container sheet
359
+ private IExcelSheet _currentSeparatedSheet;
360
+ private int _currentSeparatedRow;
361
+
362
+ private struct FieldEntry
363
+ {
364
+ public short Id;
365
+ public DpWireType Type;
366
+ public int Col; // -1 for implicit parents
367
+ public string Path; // full field-ID path (e.g., "40.22")
368
+ public string ColumnName; // human-readable name (for container sheet naming)
369
+ }
370
+
371
+ private struct StructState
372
+ {
373
+ public List<FieldEntry> Fields;
374
+ public int Index;
375
+ }
376
+
377
+ private class ListState
378
+ {
379
+ public int StartRow;
380
+ public int CurrentElement;
381
+ public int Count;
382
+ public DpWireType ElementType;
383
+ public int RootCol;
384
+ public string ListPath; // field-ID path of the list root (e.g., "20")
385
+ public int SavedDataRow; // _dataRow before list started
386
+ public int StructDepth; // 0 = at list level; >0 = inside struct element(s)
387
+ public int PrimitiveElemCol; // for primitive lists: the child column holding the value
388
+
389
+ // Separated sheet state (non-null when reading from a container sheet)
390
+ public IExcelSheet SeparatedSheet; // the container sheet (read)
391
+ public int[] SeparatedDataRows; // rows in container sheet for this meta_id
392
+ }
393
+
394
+ public const int HIERARCHY_ID_ROW = 1;
395
+ public const int DATATYPE_ROW = 2;
396
+ public const int COLUMN_NAME_ROW = 3;
397
+ /// <summary>신버전 헤더 행 수(3). 헤더 고정 시 1~3행 고정.</summary>
398
+ public const int HEADER_ROW_COUNT = 3;
399
+ /// <summary>신버전 데이터 시작 행(4).</summary>
400
+ public const int FIRST_DATA_ROW = 4;
401
+ /// <summary>이전 스키마 헤더 행 수(4).</summary>
402
+ public const int LEGACY_HEADER_ROW_COUNT = 4;
403
+ /// <summary>이전 스키마 데이터 시작 행(5).</summary>
404
+ public const int LEGACY_FIRST_DATA_ROW = 5;
405
+
406
+ /// <summary>메인 시트 고정 열 이름 (1~3열). tuid 기반 테이블 기본값. keyed 테이블은 GetMainSheetFixedColumnNamesForCategory 사용.</summary>
407
+ public static readonly string[] MainSheetFixedColumnNames = { "tuid", "tid", "name", "note" };
408
+
409
+ /// <summary>카테고리별 메인 시트 고정 열. getKeyFieldNames가 null이면 MainSheetFixedColumnNames. keyed(키≠tuid)면 tuid 제외, 키→name→note 순.</summary>
410
+ public static IReadOnlyList<string> GetMainSheetFixedColumnNamesForCategory(string category, Func<string, IReadOnlyList<string>> getKeyFieldNames = null)
411
+ {
412
+ if (string.IsNullOrEmpty(category) || getKeyFieldNames == null)
413
+ return MainSheetFixedColumnNames;
414
+ var keys = getKeyFieldNames(category);
415
+ if (keys == null || keys.Count == 0)
416
+ return MainSheetFixedColumnNames;
417
+ bool isKeyed = !string.Equals(keys[0], "tuid", StringComparison.OrdinalIgnoreCase);
418
+ if (!isKeyed)
419
+ return MainSheetFixedColumnNames;
420
+ var list = new List<string>(keys);
421
+ if (!list.Contains("name")) list.Add("name");
422
+ if (!list.Contains("note")) list.Add("note");
423
+ return list;
424
+ }
425
+
426
+ /// <summary>워크북·시트 이름으로 v1/v2 판별 후 (데이터 시작 행, 헤더 행 수) 반환. 애드인에서 프로토콜 없이 행 정보가 필요할 때 사용.</summary>
427
+ /// <param name="defaultMainSheetName">프로젝트 기본 메인 시트 이름(deukpack.config). 이 이름과 일치하면 v2로 간주. null이면 기존 동작(시트명 "meta"만 v2).</param>
428
+ public static (int firstDataRow, int headerRowCount) GetLayout(string workbookNameOrPath, string sheetName = null, string defaultMainSheetName = null)
429
+ {
430
+ bool newVer = IsNewVersionWorkbook(workbookNameOrPath, sheetName, defaultMainSheetName);
431
+ return (firstDataRow: GetFirstDataRow(newVer), headerRowCount: GetHeaderRowCount(newVer));
432
+ }
433
+
434
+ /// <summary>컨테이너(리스트) 시트의 헤더 정보: 1열이 _nav이면 (true, 2, 3), 아니면 (false, 1, 2). 메인/리스트 공통 모듈에서 한 번만 조회해 사용.</summary>
435
+ public static (bool hasNavCol, int metaIdCol, int metaNameCol) GetContainerLayout(IExcelSheet sheet)
436
+ {
437
+ if (sheet == null) return (false, 1, 2);
438
+ string col1Hier = sheet.CellValue(HIERARCHY_ID_ROW, 1)?.Trim() ?? "";
439
+ bool hasNav = string.Equals(col1Hier, ContainerSheetNaming.NAV_BACK_COLUMN, StringComparison.OrdinalIgnoreCase);
440
+ return hasNav ? (true, 2, 3) : (false, 1, 2);
441
+ }
442
+
443
+ /// <summary>v2(신규)면 true. 시트명이 defaultMainSheetName 또는 "meta"이면 v2. 워크북 파일명이 "meta"여도 v2.</summary>
444
+ public static bool IsNewVersionWorkbook(string workbookNameOrPath, string mainSheetName = null, string defaultMainSheetName = null)
445
+ {
446
+ if (!string.IsNullOrWhiteSpace(mainSheetName))
447
+ {
448
+ var sn = mainSheetName.Trim();
449
+ if (string.Equals(sn, "meta", StringComparison.OrdinalIgnoreCase)) return true;
450
+ if (!string.IsNullOrWhiteSpace(defaultMainSheetName) && string.Equals(sn, defaultMainSheetName.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
451
+ }
452
+ if (string.IsNullOrWhiteSpace(workbookNameOrPath)) return false;
453
+ string name = System.IO.Path.GetFileNameWithoutExtension(workbookNameOrPath.Trim());
454
+ if (string.IsNullOrEmpty(name)) return false;
455
+ name = name.Trim();
456
+ if (string.Equals(name, "meta", StringComparison.OrdinalIgnoreCase)) return true;
457
+ if (!string.IsNullOrWhiteSpace(defaultMainSheetName) && string.Equals(name, defaultMainSheetName.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
458
+ return false;
459
+ }
460
+
461
+ internal static int GetFirstDataRow(bool isNewVersion) => isNewVersion ? FIRST_DATA_ROW : LEGACY_FIRST_DATA_ROW;
462
+ internal static int GetHeaderRowCount(bool isNewVersion) => isNewVersion ? HEADER_ROW_COUNT : LEGACY_HEADER_ROW_COUNT;
463
+
464
+ private bool _useCompactHeader;
465
+ private readonly int _firstDataRow;
466
+ private readonly int _headerRowCount;
467
+
468
+ /// <summary>현재 시트의 데이터 시작 행(v1=5, v2=4). 프로토콜 내부 버전 정보 기준.</summary>
469
+ public int FirstDataRow => _firstDataRow;
470
+ /// <summary>현재 시트의 헤더 행 수(v1=4, v2=3). 프로토콜 내부 버전 정보 기준.</summary>
471
+ public int HeaderRowCount => _headerRowCount;
472
+ /// <summary>true면 v2(3행 헤더). 쓰기 시 설정 가능.</summary>
473
+ public bool UseCompactHeader { get => _useCompactHeader; set => _useCompactHeader = value; }
474
+
475
+ /// <summary>시트와 워크북/시트 이름으로 버전을 판별하여 행 정보를 프로토콜 내부에 보관. workbookName/sheetName이 null이면 sheet.WorkbookName/SheetName 사용. defaultMainSheetName은 애드인에서 deukpack.config 기준으로 전달.</summary>
476
+ public DpExcelProtocol(IExcelSheet sheet, int dataRow = FIRST_DATA_ROW,
477
+ IContainerSheetResolver resolver = null, string workbookName = null, string sheetName = null, string defaultMainSheetName = null)
478
+ {
479
+ _sheet = sheet ?? throw new ArgumentNullException(nameof(sheet));
480
+ string wb = workbookName ?? sheet.WorkbookName ?? "";
481
+ string sn = sheetName ?? sheet.SheetName ?? "";
482
+ bool isNewVersion = IsNewVersionWorkbook(wb, sn, defaultMainSheetName);
483
+ _useCompactHeader = isNewVersion;
484
+ _firstDataRow = GetFirstDataRow(isNewVersion);
485
+ _headerRowCount = GetHeaderRowCount(isNewVersion);
486
+ _sheetFirstDataRow = _firstDataRow;
487
+ _dataRow = dataRow;
488
+ _maxDataRow = dataRow;
489
+ _resolver = resolver;
490
+ BuildColumnMap();
491
+ }
492
+
493
+ /// <summary>Source file path this protocol was created from. Set by caller for tracking.</summary>
494
+ public string SourceFilePath { get; set; }
495
+
496
+ /// <summary>
497
+ /// Optional schema resolver for generating separated sheet headers during Write.
498
+ /// Set before calling obj.Write() so WriteListBegin can create sheets with correct headers.
499
+ /// </summary>
500
+ public Func<string, DpSchema> ResolveTypeName { get; set; }
501
+
502
+ /// <summary>
503
+ /// Resolves meta schema name (e.g. "mo_map") to the data DpSchema for Write.
504
+ /// Set by add-in so BeginWrite(sheet, row, metaSchemaName) can use DLL schema when name is given.
505
+ /// </summary>
506
+ public Func<string, DpSchema> ResolveMetaSchema { get; set; }
507
+
508
+ /// <summary>
509
+ /// 현재 쓰기 중인 메타 카테고리(예: "mo_skill", "level"). keyed 테이블(level 등) 시 시트에서 meta_id 제외·키 선행 정렬용.
510
+ /// BeginWrite 전에 설정. GetKeyFieldNames와 함께 사용.
511
+ /// </summary>
512
+ public string WriteCategory { get; set; }
513
+
514
+ /// <summary>
515
+ /// 카테고리별 키 필드명 조회(예: Generated.MetaTableRegistry.GetKeyFieldNames). keyed 테이블 시 고정 열 순서·meta_id 미생성에 사용.
516
+ /// </summary>
517
+ public Func<string, IReadOnlyList<string>> GetKeyFieldNames { get; set; }
518
+
519
+ /// <summary>
520
+ /// For WriteI32: (hierarchyId) → field descriptor. Used only to detect enum and resolve display; set by add-in.
521
+ /// </summary>
522
+ public Func<string, object> GetFieldDescriptor { get; set; }
523
+
524
+ /// <summary>
525
+ /// For WriteI32: (fieldDescriptor) → "value:name" list for that enum type, or null if not enum. Set by add-in.
526
+ /// </summary>
527
+ public Func<object, List<string>> GetEnumValuesForField { get; set; }
528
+
529
+ public int DataRow => _dataRow;
530
+ /// <summary>The furthest row reached during Read(). Use to determine how many rows were consumed.</summary>
531
+ public int MaxDataRow => _maxDataRow;
532
+ public IExcelSheet Sheet => _sheet;
533
+ public void SetDataRow(int row) { _dataRow = row; if (row > _maxDataRow) _maxDataRow = row; }
534
+
535
+ /// <summary>
536
+ /// Reset protocol navigation state for reading a new record at the given row.
537
+ /// Reuses the existing column map (built once in the constructor).
538
+ /// </summary>
539
+ public void ResetForRow(int row)
540
+ {
541
+ _dataRow = row;
542
+ _maxDataRow = row;
543
+ _pathStack.Clear();
544
+ _structStack.Clear();
545
+ _listStack.Clear();
546
+ _dataRowStack.Clear();
547
+ _currentPath = "";
548
+ _pendingFields = null;
549
+ _pendingIdx = 0;
550
+ _lastField = default;
551
+ _currentSeparatedSheet = null;
552
+ _currentSeparatedRow = 0;
553
+ }
554
+
555
+ /// <summary>
556
+ /// Iterate all top-level records in this sheet.
557
+ /// Automatically handles row advancement (including multi-row list records).
558
+ /// The handler receives this protocol positioned at each record's start row.
559
+ /// </summary>
560
+ public void ForEachRecord(Action<DpExcelProtocol> handler)
561
+ {
562
+ int lastRow = _sheet.LastRow;
563
+ for (int row = _sheetFirstDataRow; row <= lastRow; )
564
+ {
565
+ ResetForRow(row);
566
+ handler(this);
567
+ row = Math.Max(_maxDataRow + 1, row + 1);
568
+ }
569
+ }
570
+
571
+ #region Column Map
572
+
573
+ private readonly List<string> _diagColumnHeaders = new List<string>();
574
+
575
+ /// <summary>Diagnostic: column headers that were read from Row 1. Use for debugging.</summary>
576
+ public IReadOnlyList<string> DiagColumnHeaders => _diagColumnHeaders;
577
+
578
+ private int ColumnNameRowForRead => COLUMN_NAME_ROW;
579
+
580
+ private void BuildColumnMap()
581
+ {
582
+ _fieldIdToCol.Clear();
583
+ _colDataType.Clear();
584
+ _colColumnName.Clear();
585
+ _diagColumnHeaders.Clear();
586
+ int nameRow = ColumnNameRowForRead;
587
+ for (int col = 1; col <= _sheet.LastColumn; col++)
588
+ {
589
+ var hier = _sheet.CellValue(HIERARCHY_ID_ROW, col)?.Trim();
590
+ if (string.IsNullOrEmpty(hier))
591
+ continue;
592
+ var dtRaw = _sheet.CellValue(DATATYPE_ROW, col)?.Trim() ?? "";
593
+ var dtNorm = string.IsNullOrEmpty(dtRaw) ? "" : DpTypeNames.ToDisplayTypeName(dtRaw);
594
+ var cn = _sheet.CellValue(nameRow, col)?.Trim() ?? "";
595
+ _fieldIdToCol[hier] = col;
596
+ _colDataType[col] = dtNorm;
597
+ _colColumnName[col] = cn;
598
+ _diagColumnHeaders.Add($"col{col}={hier}({dtNorm})");
599
+ }
600
+ }
601
+
602
+ /// <summary>
603
+ /// Build a tree from sheet headers (rows 1–3) only. No DLL dependency; use for display (e.g. schema tree).
604
+ /// List/rec appear as parent nodes with children from hierarchy IDs.
605
+ /// Sorted by column position (physical sheet order = define order).
606
+ /// </summary>
607
+ public List<ExcelHeaderEntry> GetHeaderTree()
608
+ {
609
+ var allPaths = new List<string>(_fieldIdToCol.Keys);
610
+
611
+ var roots = new List<string>();
612
+ foreach (var p in allPaths)
613
+ {
614
+ if (string.IsNullOrEmpty(p)) continue;
615
+ if (p.IndexOf('.') < 0)
616
+ roots.Add(p);
617
+ }
618
+ roots.Sort(ComparePathByCol);
619
+
620
+ var result = new List<ExcelHeaderEntry>();
621
+ foreach (var path in roots)
622
+ result.Add(BuildHeaderNode(path, allPaths));
623
+ return result;
624
+ }
625
+
626
+ /// <summary>
627
+ /// Excel 시트 헤더(Row 1–3)를 FlatHeaderField 리스트로 반환 (컬럼 물리 순서).
628
+ /// FlattenSchema()와 동일한 형태이므로 두 결과를 직접 비교 가능.
629
+ /// </summary>
630
+ public List<FlatHeaderField> GetFlatHeaders()
631
+ {
632
+ var byCol = new List<KeyValuePair<string, int>>(_fieldIdToCol.Count);
633
+ foreach (var kv in _fieldIdToCol)
634
+ byCol.Add(new KeyValuePair<string, int>(kv.Key, kv.Value));
635
+ byCol.Sort((a, b) => a.Value.CompareTo(b.Value));
636
+
637
+ var result = new List<FlatHeaderField>(byCol.Count);
638
+ foreach (var kv in byCol)
639
+ {
640
+ result.Add(new FlatHeaderField
641
+ {
642
+ HierarchyId = kv.Key,
643
+ DataType = _colDataType.TryGetValue(kv.Value, out var dt) ? dt : "",
644
+ ColumnName = _sheet.CellValue(ColumnNameRowForRead, kv.Value)?.Trim() ?? "",
645
+ DocComment = "",
646
+ Col = kv.Value
647
+ });
648
+ }
649
+ return result;
650
+ }
651
+
652
+ private int ComparePathByCol(string a, string b)
653
+ {
654
+ int colA = _fieldIdToCol.TryGetValue(a, out int ca) ? ca : int.MaxValue;
655
+ int colB = _fieldIdToCol.TryGetValue(b, out int cb) ? cb : int.MaxValue;
656
+ return colA.CompareTo(colB);
657
+ }
658
+
659
+ private ExcelHeaderEntry BuildHeaderNode(string path, List<string> allPaths)
660
+ {
661
+ int col = _fieldIdToCol.TryGetValue(path, out int c) ? c : -1;
662
+ string dataType = col > 0 && _colDataType.TryGetValue(col, out var dt) ? dt : "";
663
+ string columnName = col > 0 ? (_sheet.CellValue(ColumnNameRowForRead, col)?.Trim() ?? "") : "";
664
+
665
+ var entry = new ExcelHeaderEntry
666
+ {
667
+ HierarchyId = path,
668
+ DataType = dataType,
669
+ ColumnName = columnName,
670
+ Col = col
671
+ };
672
+
673
+ string prefix = path + ".";
674
+ foreach (var p in allPaths)
675
+ {
676
+ if (!p.StartsWith(prefix)) continue;
677
+ string remainder = p.Substring(prefix.Length);
678
+ if (remainder.IndexOf('.') >= 0) continue;
679
+ entry.Children.Add(BuildHeaderNode(p, allPaths));
680
+ }
681
+ entry.Children.Sort((a, b) => a.Col.CompareTo(b.Col));
682
+ return entry;
683
+ }
684
+
685
+ /// <summary>
686
+ /// Collect direct children of parentPath.
687
+ /// Type is ALWAYS determined from DATATYPE (Row 2), not from structural position.
688
+ /// - rec/lst/map with explicit column → type from DATATYPE
689
+ /// - Implicit parent (no column, inferred from descendants) → Struct
690
+ /// (lst/map always have explicit root columns in Excel format)
691
+ /// Explicit columns always take priority regardless of Dictionary iteration order.
692
+ /// </summary>
693
+ private List<FieldEntry> CollectChildFields(string parentPath)
694
+ {
695
+ var entries = new Dictionary<short, FieldEntry>();
696
+ string prefix = string.IsNullOrEmpty(parentPath) ? "" : parentPath + ".";
697
+
698
+ foreach (var kvp in _fieldIdToCol)
699
+ {
700
+ string path = kvp.Key;
701
+ int col = kvp.Value;
702
+
703
+ string remainder;
704
+ if (!string.IsNullOrEmpty(prefix))
705
+ {
706
+ if (!path.StartsWith(prefix)) continue;
707
+ remainder = path.Substring(prefix.Length);
708
+ }
709
+ else
710
+ {
711
+ remainder = path;
712
+ }
713
+
714
+ int dotPos = remainder.IndexOf('.');
715
+ string firstSeg = dotPos >= 0 ? remainder.Substring(0, dotPos) : remainder;
716
+ if (!short.TryParse(firstSeg, out short fieldId)) continue;
717
+
718
+ string childPath = string.IsNullOrEmpty(prefix) ? firstSeg : prefix + firstSeg;
719
+ bool isExactColumn = (path == childPath);
720
+
721
+ if (isExactColumn)
722
+ {
723
+ // This column IS the direct child → type from its DATATYPE (rec, lst, map, i64, str, etc.)
724
+ var dt = _colDataType.ContainsKey(col) ? _colDataType[col] : "";
725
+ var cn = _colColumnName.ContainsKey(col) ? _colColumnName[col] : "";
726
+ entries[fieldId] = new FieldEntry
727
+ {
728
+ Id = fieldId,
729
+ Type = DataTypeToDpWireType(dt),
730
+ Col = col,
731
+ Path = childPath,
732
+ ColumnName = cn
733
+ };
734
+ }
735
+ else if (!entries.ContainsKey(fieldId))
736
+ {
737
+ // Descendant column — check if the direct child has its own explicit column
738
+ if (_fieldIdToCol.TryGetValue(childPath, out int explicitCol))
739
+ {
740
+ var dt = _colDataType.ContainsKey(explicitCol) ? _colDataType[explicitCol] : "";
741
+ var cn = _colColumnName.ContainsKey(explicitCol) ? _colColumnName[explicitCol] : "";
742
+ entries[fieldId] = new FieldEntry
743
+ {
744
+ Id = fieldId,
745
+ Type = DataTypeToDpWireType(dt),
746
+ Col = explicitCol,
747
+ Path = childPath,
748
+ ColumnName = cn
749
+ };
750
+ }
751
+ else
752
+ {
753
+ // No explicit column: implicit parent.
754
+ // Lists/maps always have root columns, so this must be a struct.
755
+ entries[fieldId] = new FieldEntry
756
+ {
757
+ Id = fieldId,
758
+ Type = DpWireType.Struct,
759
+ Col = -1,
760
+ Path = childPath,
761
+ ColumnName = ""
762
+ };
763
+ }
764
+ }
765
+ // else: explicit column already recorded for this fieldId — keep it
766
+ }
767
+
768
+ var result = new List<FieldEntry>(entries.Values);
769
+ result.Sort((a, b) => a.Id.CompareTo(b.Id));
770
+ return result;
771
+ }
772
+
773
+ private static DpWireType DataTypeToDpWireType(string dt)
774
+ {
775
+ return DpTypeNames.FromProtocolName(dt);
776
+ }
777
+
778
+ private static string ExtractContainerElemType(string dt)
779
+ {
780
+ int lt = dt.IndexOf('<');
781
+ int gt = dt.LastIndexOf('>');
782
+ if (lt >= 0 && gt > lt)
783
+ return dt.Substring(lt + 1, gt - lt - 1).Trim();
784
+ return "record";
785
+ }
786
+
787
+ #endregion
788
+
789
+ #region Read - Struct
790
+
791
+ public DpRecord ReadStructBegin()
792
+ {
793
+ if (_listStack.Count > 0)
794
+ {
795
+ var ls = _listStack.Peek();
796
+ bool isDirectListElement = (ls.StructDepth == 0);
797
+
798
+ if (isDirectListElement)
799
+ {
800
+ // Balance the pathStack: ReadFieldBegin pushes for normal struct fields,
801
+ // but list elements don't go through ReadFieldBegin, so push here.
802
+ _pathStack.Push(_currentPath);
803
+
804
+ if (ls.SeparatedSheet != null)
805
+ {
806
+ // Separated sheet: row comes from SeparatedDataRows
807
+ if (ls.SeparatedDataRows != null && ls.CurrentElement < ls.SeparatedDataRows.Length)
808
+ _dataRow = ls.SeparatedDataRows[ls.CurrentElement];
809
+ }
810
+ else
811
+ {
812
+ _dataRow = ls.StartRow + ls.CurrentElement;
813
+ if (_dataRow > _maxDataRow) _maxDataRow = _dataRow;
814
+ }
815
+ ls.CurrentElement++;
816
+ }
817
+ ls.StructDepth++;
818
+ }
819
+
820
+ if (_pendingFields != null)
821
+ _structStack.Push(new StructState { Fields = _pendingFields, Index = _pendingIdx });
822
+
823
+ // If inside a separated list, collect fields from the separated sheet's column map (full-path headers)
824
+ if (_listStack.Count > 0 && _listStack.Peek().SeparatedSheet != null && _listStack.Peek().StructDepth == 1)
825
+ {
826
+ var ls = _listStack.Peek();
827
+ var sepProto = new DpExcelProtocol(ls.SeparatedSheet, _dataRow);
828
+ _pendingFields = sepProto.CollectChildFields(ls.ListPath ?? "");
829
+ _pendingIdx = 0;
830
+ // Remap: redirect _lastField reads to the separated sheet
831
+ _currentSeparatedSheet = ls.SeparatedSheet;
832
+ _currentSeparatedRow = _dataRow;
833
+ }
834
+ else
835
+ {
836
+ _pendingFields = CollectChildFields(_currentPath);
837
+ _pendingIdx = 0;
838
+ }
839
+
840
+ return new DpRecord("struct");
841
+ }
842
+
843
+ public void ReadStructEnd()
844
+ {
845
+ if (_listStack.Count > 0)
846
+ _listStack.Peek().StructDepth--;
847
+
848
+ // Clear separated sheet context when leaving the struct
849
+ if (_listStack.Count == 0 || _listStack.Peek().StructDepth == 0)
850
+ {
851
+ _currentSeparatedSheet = null;
852
+ _currentSeparatedRow = 0;
853
+ }
854
+
855
+ if (_pathStack.Count > 0)
856
+ _currentPath = _pathStack.Pop();
857
+ if (_structStack.Count > 0)
858
+ {
859
+ var s = _structStack.Pop();
860
+ _pendingFields = s.Fields;
861
+ _pendingIdx = s.Index;
862
+ }
863
+ }
864
+
865
+ public DpColumn ReadFieldBegin()
866
+ {
867
+ while (_pendingIdx < _pendingFields.Count)
868
+ {
869
+ var entry = _pendingFields[_pendingIdx];
870
+ _pendingIdx++;
871
+
872
+ // Skip empty primitives
873
+ if (entry.Type != DpWireType.Struct && entry.Type != DpWireType.List &&
874
+ entry.Type != DpWireType.Map && entry.Type != DpWireType.Set)
875
+ {
876
+ if (entry.Col <= 0 || _sheet.IsCellEmpty(_dataRow, entry.Col))
877
+ continue;
878
+ }
879
+
880
+ // Skip structs with no child data
881
+ if (entry.Type == DpWireType.Struct && !HasAnyChildData(entry.Path))
882
+ continue;
883
+
884
+ // Skip lists with no elements
885
+ if (entry.Type == DpWireType.List && CountListElements(entry) == 0)
886
+ continue;
887
+
888
+ _lastField = entry;
889
+
890
+ // For struct, push path navigation
891
+ if (entry.Type == DpWireType.Struct)
892
+ {
893
+ _pathStack.Push(_currentPath);
894
+ _currentPath = entry.Path;
895
+ }
896
+
897
+ return new DpColumn(entry.Path, entry.Type, entry.Id);
898
+ }
899
+
900
+ return new DpColumn("", DpWireType.Stop, 0);
901
+ }
902
+
903
+ public void ReadFieldEnd() { }
904
+
905
+ private bool HasAnyChildData(string parentPath)
906
+ {
907
+ string prefix = parentPath + ".";
908
+ foreach (var kvp in _fieldIdToCol)
909
+ {
910
+ if (!kvp.Key.StartsWith(prefix)) continue;
911
+
912
+ if (_colDataType.TryGetValue(kvp.Value, out var dt) &&
913
+ DpTypeNames.IsContainerType(DpTypeNames.FromProtocolName(dt)))
914
+ continue;
915
+
916
+ if (!_sheet.IsCellEmpty(_dataRow, kvp.Value))
917
+ return true;
918
+ }
919
+ return false;
920
+ }
921
+
922
+ #endregion
923
+
924
+ #region Read - List
925
+
926
+ /// <summary>
927
+ /// Count list elements starting from _dataRow.
928
+ /// A list element = a row where either:
929
+ /// - The list root column has a non-empty index value, OR
930
+ /// - Any child column of the list has data (same meta_id)
931
+ /// Ends when: different meta_id, or gap row with no data in list columns.
932
+ /// </summary>
933
+ private int CountListElements(FieldEntry listField)
934
+ {
935
+ int rootCol = listField.Col;
936
+ string listPath = listField.Path;
937
+ string prefix = listPath + ".";
938
+ int count = 0;
939
+ string metaId = _sheet.CellValue(_dataRow, 1) ?? "";
940
+
941
+ for (int r = _dataRow; r <= _sheet.LastRow; r++)
942
+ {
943
+ // Different meta_id = new record, stop
944
+ if (r > _dataRow)
945
+ {
946
+ var rid = _sheet.CellValue(r, 1) ?? "";
947
+ if (!string.IsNullOrEmpty(rid) && rid != metaId)
948
+ break;
949
+ }
950
+
951
+ bool hasData = false;
952
+ // Check root column (list index)
953
+ if (rootCol > 0 && !_sheet.IsCellEmpty(r, rootCol))
954
+ hasData = true;
955
+
956
+ // Check child columns
957
+ if (!hasData)
958
+ {
959
+ foreach (var kvp in _fieldIdToCol)
960
+ {
961
+ if (kvp.Key.StartsWith(prefix) && !_sheet.IsCellEmpty(r, kvp.Value))
962
+ {
963
+ hasData = true;
964
+ break;
965
+ }
966
+ }
967
+ }
968
+
969
+ if (hasData) count++;
970
+ else if (count > 0) break; // gap = end
971
+ }
972
+ return count;
973
+ }
974
+
975
+ /// <summary>
976
+ /// Determine list element type from DATATYPE + child column structure.
977
+ /// Priority: explicit &lt;type&gt; in root datatype → child column DATATYPE analysis.
978
+ /// </summary>
979
+ private DpWireType InferListElementType(FieldEntry listEntry)
980
+ {
981
+ var dt = (listEntry.Col > 0 && _colDataType.ContainsKey(listEntry.Col))
982
+ ? _colDataType[listEntry.Col] : "list";
983
+
984
+ // 1) Explicit element type in DATATYPE (e.g., "lst<i64>", "lst<rec>")
985
+ int lt = dt.IndexOf('<');
986
+ if (lt >= 0)
987
+ {
988
+ var inner = ExtractContainerElemType(dt);
989
+ return DataTypeToDpWireType(inner);
990
+ }
991
+
992
+ // 2) Infer from direct children using their DATATYPE
993
+ string prefix = listEntry.Path + ".";
994
+ var childIds = new Dictionary<short, (string dt, int col, string path)>();
995
+
996
+ foreach (var kvp in _fieldIdToCol)
997
+ {
998
+ if (!kvp.Key.StartsWith(prefix)) continue;
999
+ string remainder = kvp.Key.Substring(prefix.Length);
1000
+ int dotPos = remainder.IndexOf('.');
1001
+ string firstSeg = dotPos >= 0 ? remainder.Substring(0, dotPos) : remainder;
1002
+ if (!short.TryParse(firstSeg, out short fid)) continue;
1003
+
1004
+ string childPath = listEntry.Path + "." + firstSeg;
1005
+ bool isExactChild = (kvp.Key == childPath);
1006
+
1007
+ if (isExactChild)
1008
+ {
1009
+ // Exact child column: its DATATYPE is authoritative
1010
+ string childDt = _colDataType.ContainsKey(kvp.Value) ? _colDataType[kvp.Value] : "";
1011
+ childIds[fid] = (childDt, kvp.Value, childPath);
1012
+ }
1013
+ else if (!childIds.ContainsKey(fid))
1014
+ {
1015
+ // Descendant: check if the direct child has an explicit column
1016
+ if (_fieldIdToCol.TryGetValue(childPath, out int explicitCol))
1017
+ {
1018
+ string childDt = _colDataType.ContainsKey(explicitCol) ? _colDataType[explicitCol] : "";
1019
+ childIds[fid] = (childDt, explicitCol, childPath);
1020
+ }
1021
+ else
1022
+ {
1023
+ childIds[fid] = ("record", -1, childPath); // implicit struct child
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ if (childIds.Count == 0) return DpWireType.Struct;
1029
+
1030
+ // Single direct child whose DATATYPE is a primitive → primitive list
1031
+ if (childIds.Count == 1)
1032
+ {
1033
+ foreach (var c in childIds.Values)
1034
+ {
1035
+ var childDpWireType = DataTypeToDpWireType(c.dt);
1036
+ bool isPrimitive = (childDpWireType != DpWireType.Struct && childDpWireType != DpWireType.List &&
1037
+ childDpWireType != DpWireType.Map && childDpWireType != DpWireType.Set);
1038
+ if (isPrimitive) return childDpWireType;
1039
+ }
1040
+ }
1041
+
1042
+ // Multiple children or container child → struct element
1043
+ return DpWireType.Struct;
1044
+ }
1045
+
1046
+ public DpList ReadListBegin()
1047
+ {
1048
+ var entry = _lastField;
1049
+
1050
+ // ── Separated sheet mode (정책에서 시트 이름 후보 조회) ─────────────────────
1051
+ if (_resolver != null)
1052
+ {
1053
+ var namesToTry = ContainerSheetNaming.GetSheetNamesForLookup(entry.Path, DpWireType.List, entry.ColumnName);
1054
+ foreach (var sheetName in namesToTry)
1055
+ {
1056
+ var sepSheet = _resolver.GetSheet(sheetName);
1057
+ if (sepSheet != null)
1058
+ return ReadListBeginFromSeparatedSheet(entry, sepSheet, ContainerSheetPolicy.GetKindForWireType(DpWireType.List));
1059
+ }
1060
+ }
1061
+
1062
+ // ── Embedded (original) mode ──────────────────────────────────────
1063
+ var elemDpWireType = InferListElementType(entry);
1064
+ int count = CountListElements(entry);
1065
+
1066
+ // For primitive lists, find the child element column
1067
+ int primitiveElemCol = -1;
1068
+ if (elemDpWireType != DpWireType.Struct)
1069
+ {
1070
+ string prefix = entry.Path + ".";
1071
+ foreach (var kvp in _fieldIdToCol)
1072
+ {
1073
+ if (kvp.Key.StartsWith(prefix))
1074
+ {
1075
+ primitiveElemCol = kvp.Value;
1076
+ break;
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ var state = new ListState
1082
+ {
1083
+ StartRow = _dataRow,
1084
+ CurrentElement = 0,
1085
+ Count = count,
1086
+ ElementType = elemDpWireType,
1087
+ RootCol = entry.Col,
1088
+ ListPath = entry.Path,
1089
+ SavedDataRow = _dataRow,
1090
+ PrimitiveElemCol = primitiveElemCol
1091
+ };
1092
+ _listStack.Push(state);
1093
+
1094
+ // For primitive lists, redirect _lastField to the element column
1095
+ if (primitiveElemCol > 0)
1096
+ _lastField = new FieldEntry { Id = 0, Type = elemDpWireType, Col = primitiveElemCol, Path = entry.Path };
1097
+
1098
+ // Push path for list children
1099
+ _pathStack.Push(_currentPath);
1100
+ _currentPath = entry.Path;
1101
+
1102
+ return new DpList { ElementType = elemDpWireType, Count = count };
1103
+ }
1104
+
1105
+ /// <summary>
1106
+ /// Read list elements from a separated container sheet.
1107
+ /// Rows whose meta_id matches the current record's meta_id (col 1 of main sheet) are used.
1108
+ /// Empty-element rows (all element cols empty) are treated as an empty list placeholder.
1109
+ /// </summary>
1110
+ private DpList ReadListBeginFromSeparatedSheet(FieldEntry entry, IExcelSheet sepSheet, string kind)
1111
+ {
1112
+ string metaId = _sheet.CellValue(_dataRow, 1) ?? "";
1113
+
1114
+ // Detect meta_id column dynamically: new layout has _nav in col 1 → meta_id in col 2;
1115
+ // old layout has meta_id directly in col 1.
1116
+ int metaIdCol = 1;
1117
+ int firstElemCol = 4;
1118
+ string col1Hier = sepSheet.CellValue(HIERARCHY_ID_ROW, 1)?.Trim() ?? "";
1119
+ if (string.Equals(col1Hier, ContainerSheetNaming.NAV_BACK_COLUMN, StringComparison.OrdinalIgnoreCase))
1120
+ {
1121
+ metaIdCol = 2;
1122
+ firstElemCol = 4;
1123
+ }
1124
+ else
1125
+ {
1126
+ metaIdCol = 1;
1127
+ firstElemCol = 3;
1128
+ }
1129
+
1130
+ var matchingRows = new List<int>();
1131
+ for (int r = _sheetFirstDataRow; r <= sepSheet.LastRow; r++)
1132
+ {
1133
+ string rid = ParseMetaIdFromListCell(sepSheet.CellValue(r, metaIdCol)?.Trim() ?? "");
1134
+ if (rid == metaId)
1135
+ matchingRows.Add(r);
1136
+ }
1137
+
1138
+ var sepProto = new DpExcelProtocol(sepSheet, _sheetFirstDataRow);
1139
+ DpWireType elemDpWireType = DpWireType.Struct;
1140
+ bool allElemEmpty = true;
1141
+ if (matchingRows.Count > 0)
1142
+ {
1143
+ for (int r = 0; r < matchingRows.Count && allElemEmpty; r++)
1144
+ {
1145
+ for (int c = firstElemCol; c <= sepSheet.LastColumn; c++)
1146
+ {
1147
+ if (!sepSheet.IsCellEmpty(matchingRows[r], c)) { allElemEmpty = false; break; }
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ int count = (matchingRows.Count == 0 || allElemEmpty) ? 0 : matchingRows.Count;
1153
+
1154
+ elemDpWireType = sepProto.InferSeparatedListElementType();
1155
+
1156
+ int primitiveElemCol = -1;
1157
+ if (elemDpWireType != DpWireType.Struct && sepSheet.LastColumn >= firstElemCol)
1158
+ primitiveElemCol = firstElemCol;
1159
+
1160
+ var state = new ListState
1161
+ {
1162
+ StartRow = matchingRows.Count > 0 ? matchingRows[0] : _sheetFirstDataRow,
1163
+ CurrentElement = 0,
1164
+ Count = count,
1165
+ ElementType = elemDpWireType,
1166
+ RootCol = entry.Col,
1167
+ ListPath = entry.Path,
1168
+ SavedDataRow = _dataRow,
1169
+ PrimitiveElemCol = primitiveElemCol,
1170
+ SeparatedSheet = sepSheet,
1171
+ SeparatedDataRows = matchingRows.ToArray()
1172
+ };
1173
+ _listStack.Push(state);
1174
+
1175
+ if (primitiveElemCol > 0)
1176
+ _lastField = new FieldEntry { Id = 0, Type = elemDpWireType, Col = primitiveElemCol, Path = entry.Path };
1177
+
1178
+ _pathStack.Push(_currentPath);
1179
+ _currentPath = entry.Path;
1180
+
1181
+ return new DpList { ElementType = elemDpWireType, Count = count };
1182
+ }
1183
+
1184
+ /// <summary>
1185
+ /// Infer list element type for a separated sheet.
1186
+ /// The sheet's first column after tuid/name (col 3+) determines the type.
1187
+ /// </summary>
1188
+ private DpWireType InferSeparatedListElementType()
1189
+ {
1190
+ if (_sheet.LastColumn < 3) return DpWireType.Struct;
1191
+
1192
+ string commonPrefix = null;
1193
+ foreach (var kv in _fieldIdToCol)
1194
+ {
1195
+ if (string.Equals(kv.Key, ContainerSheetNaming.NAV_BACK_COLUMN, StringComparison.OrdinalIgnoreCase)) continue;
1196
+ if (string.Equals(kv.Key, ContainerSheetNaming.META_ID_COLUMN, StringComparison.OrdinalIgnoreCase)) continue;
1197
+ if (string.Equals(kv.Key, ContainerSheetNaming.META_NAME_COLUMN, StringComparison.OrdinalIgnoreCase)) continue;
1198
+ if (kv.Key == "value") continue;
1199
+
1200
+ if (commonPrefix == null) { commonPrefix = kv.Key; continue; }
1201
+ int minLen = Math.Min(commonPrefix.Length, kv.Key.Length);
1202
+ int match = 0;
1203
+ for (int i = 0; i < minLen; i++)
1204
+ {
1205
+ if (commonPrefix[i] == kv.Key[i]) match++;
1206
+ else break;
1207
+ }
1208
+ commonPrefix = commonPrefix.Substring(0, match);
1209
+ }
1210
+
1211
+ if (string.IsNullOrEmpty(commonPrefix)) return DpWireType.Struct;
1212
+ // Trim to last complete segment boundary
1213
+ int lastDot = commonPrefix.LastIndexOf('.');
1214
+ string listPath = lastDot > 0 ? commonPrefix.Substring(0, lastDot) : "";
1215
+
1216
+ // Count direct children under listPath
1217
+ string prefix = string.IsNullOrEmpty(listPath) ? "" : listPath + ".";
1218
+ var directChildren = new HashSet<string>();
1219
+ foreach (var kv in _fieldIdToCol)
1220
+ {
1221
+ if (string.Equals(kv.Key, ContainerSheetNaming.META_ID_COLUMN, StringComparison.OrdinalIgnoreCase)) continue;
1222
+ if (string.Equals(kv.Key, ContainerSheetNaming.META_NAME_COLUMN, StringComparison.OrdinalIgnoreCase)) continue;
1223
+ string remainder = !string.IsNullOrEmpty(prefix) && kv.Key.StartsWith(prefix)
1224
+ ? kv.Key.Substring(prefix.Length)
1225
+ : (string.IsNullOrEmpty(prefix) ? kv.Key : null);
1226
+ if (remainder == null) continue;
1227
+ int dot = remainder.IndexOf('.');
1228
+ string firstSeg = dot >= 0 ? remainder.Substring(0, dot) : remainder;
1229
+ directChildren.Add(firstSeg);
1230
+ }
1231
+
1232
+ if (directChildren.Count == 1)
1233
+ {
1234
+ string childSeg = null;
1235
+ foreach (var s in directChildren) { childSeg = s; break; }
1236
+ string childPath = string.IsNullOrEmpty(prefix) ? childSeg : prefix + childSeg;
1237
+ if (_fieldIdToCol.TryGetValue(childPath, out int col))
1238
+ {
1239
+ string dt = _colDataType.ContainsKey(col) ? _colDataType[col] : "";
1240
+ var tt = DataTypeToDpWireType(dt);
1241
+ if (tt != DpWireType.Struct && tt != DpWireType.List && tt != DpWireType.Map && tt != DpWireType.Set)
1242
+ return tt;
1243
+ }
1244
+ }
1245
+ return DpWireType.Struct;
1246
+ }
1247
+
1248
+ public void ReadListEnd()
1249
+ {
1250
+ if (_listStack.Count > 0)
1251
+ {
1252
+ var state = _listStack.Pop();
1253
+ // Restore _dataRow: for the caller, _dataRow should still be on the original record row
1254
+ // But if this is within a ReadFromExcelDirect loop, the caller advances rows
1255
+ _dataRow = state.SavedDataRow;
1256
+ }
1257
+ // Pop the list path
1258
+ if (_pathStack.Count > 0)
1259
+ _currentPath = _pathStack.Pop();
1260
+ }
1261
+
1262
+ /// <summary>
1263
+ /// For primitive list elements: advance _dataRow to the next element row.
1264
+ /// Only triggers when inside a list AND the current path matches the list path
1265
+ /// (struct elements are advanced in ReadStructBegin instead).
1266
+ /// </summary>
1267
+ private void AdvanceListElementIfPrimitive()
1268
+ {
1269
+ if (_listStack.Count == 0) return;
1270
+ var state = _listStack.Peek();
1271
+ // Only advance for primitive list elements (StructDepth==0 means not inside a struct)
1272
+ if (state.StructDepth > 0) return;
1273
+
1274
+ if (state.SeparatedSheet != null)
1275
+ {
1276
+ // Separated sheet: index into SeparatedDataRows
1277
+ if (state.SeparatedDataRows != null && state.CurrentElement < state.SeparatedDataRows.Length)
1278
+ _dataRow = state.SeparatedDataRows[state.CurrentElement];
1279
+ state.CurrentElement++;
1280
+ }
1281
+ else
1282
+ {
1283
+ _dataRow = state.StartRow + state.CurrentElement;
1284
+ if (_dataRow > _maxDataRow) _maxDataRow = _dataRow;
1285
+ state.CurrentElement++;
1286
+ }
1287
+ }
1288
+
1289
+ #endregion
1290
+
1291
+ #region Read - Primitives
1292
+
1293
+ public bool ReadBool()
1294
+ {
1295
+ AdvanceListElementIfPrimitive();
1296
+ var val = ReadCurrentCellValue();
1297
+ if (string.IsNullOrEmpty(val)) return false;
1298
+ if (val == "1") return true;
1299
+ if (val == "0") return false;
1300
+ bool.TryParse(val, out bool bv);
1301
+ return bv;
1302
+ }
1303
+
1304
+ public byte ReadByte()
1305
+ {
1306
+ AdvanceListElementIfPrimitive();
1307
+ var val = ReadCurrentCellValue();
1308
+ if (string.IsNullOrEmpty(val)) return 0;
1309
+ byte.TryParse(val.Split(':')[0].Trim(), out byte r);
1310
+ return r;
1311
+ }
1312
+
1313
+ public short ReadI16()
1314
+ {
1315
+ AdvanceListElementIfPrimitive();
1316
+ var val = ReadCurrentCellValue();
1317
+ if (string.IsNullOrEmpty(val)) return 0;
1318
+ short.TryParse(val.Split(':')[0].Trim(), out short r);
1319
+ return r;
1320
+ }
1321
+
1322
+ public int ReadI32()
1323
+ {
1324
+ AdvanceListElementIfPrimitive();
1325
+ var val = ReadCurrentCellValue();
1326
+ if (string.IsNullOrEmpty(val)) return 0;
1327
+ var token = val.Split(':')[0].Trim();
1328
+ if (int.TryParse(token, out int r))
1329
+ return r;
1330
+ // Enum string name → resolve via DATATYPE "enum<ns.EnumType>"
1331
+ return ResolveEnumName(token);
1332
+ }
1333
+
1334
+ public long ReadI64()
1335
+ {
1336
+ AdvanceListElementIfPrimitive();
1337
+ var val = ReadCurrentCellValue();
1338
+ if (string.IsNullOrEmpty(val)) return 0L;
1339
+ var parts = val.Split(':');
1340
+ long.TryParse(parts[0].Trim(), out long r);
1341
+ return r;
1342
+ }
1343
+
1344
+ public double ReadDouble()
1345
+ {
1346
+ AdvanceListElementIfPrimitive();
1347
+ var val = ReadCurrentCellValue();
1348
+ double.TryParse(val, System.Globalization.NumberStyles.Float,
1349
+ System.Globalization.CultureInfo.InvariantCulture, out double r);
1350
+ return r;
1351
+ }
1352
+
1353
+ public string ReadString()
1354
+ {
1355
+ AdvanceListElementIfPrimitive();
1356
+ return ReadCurrentCellValue()?.Trim() ?? "";
1357
+ }
1358
+
1359
+ public byte[] ReadBinary()
1360
+ {
1361
+ AdvanceListElementIfPrimitive();
1362
+ var val = ReadCurrentCellValue();
1363
+ return string.IsNullOrEmpty(val) ? Array.Empty<byte>() : Encoding.UTF8.GetBytes(val);
1364
+ }
1365
+
1366
+ public DpSet ReadSetBegin()
1367
+ {
1368
+ var entry = _lastField;
1369
+
1370
+ // Try separated sheet (정책에서 시트 이름 조회)
1371
+ if (_resolver != null)
1372
+ {
1373
+ var namesToTry = ContainerSheetNaming.GetSheetNamesForLookup(entry.Path, DpWireType.Set, entry.ColumnName);
1374
+ foreach (var sheetName in namesToTry)
1375
+ {
1376
+ var sepSheet = _resolver.GetSheet(sheetName);
1377
+ if (sepSheet != null)
1378
+ {
1379
+ var tl = ReadListBeginFromSeparatedSheet(entry, sepSheet, ContainerSheetPolicy.GetKindForWireType(DpWireType.Set));
1380
+ return new DpSet { ElementType = tl.ElementType, Count = tl.Count };
1381
+ }
1382
+ }
1383
+ }
1384
+
1385
+ var lb = ReadListBegin();
1386
+ return new DpSet { ElementType = lb.ElementType, Count = lb.Count };
1387
+ }
1388
+
1389
+ public void ReadSetEnd() { ReadListEnd(); }
1390
+
1391
+ public DpDict ReadMapBegin()
1392
+ {
1393
+ return new DpDict { KeyType = DpWireType.I64, ValueType = DpWireType.Struct, Count = 0 };
1394
+ }
1395
+
1396
+ public void ReadMapEnd() { }
1397
+
1398
+ private string ReadCurrentCellValue()
1399
+ {
1400
+ if (_lastField.Col <= 0) return "";
1401
+ // If reading from a separated container sheet struct element, use that sheet
1402
+ if (_currentSeparatedSheet != null)
1403
+ return _currentSeparatedSheet.CellValue(_currentSeparatedRow, _lastField.Col) ?? "";
1404
+ return _sheet.CellValue(_dataRow, _lastField.Col) ?? "";
1405
+ }
1406
+
1407
+ /// <summary>
1408
+ /// Resolve enum string name to integer value.
1409
+ /// Uses the column's DATATYPE "enum&lt;ns.EnumType&gt;" to find the CLR enum type via reflection.
1410
+ /// </summary>
1411
+ private int ResolveEnumName(string name)
1412
+ {
1413
+ if (_lastField.Col <= 0) return 0;
1414
+ if (!_colDataType.TryGetValue(_lastField.Col, out var dt)) return 0;
1415
+ var enumTypeName = ExtractContainerElemType(dt);
1416
+ if (string.IsNullOrEmpty(enumTypeName) || enumTypeName == "rec" || enumTypeName == "record") return 0;
1417
+
1418
+ // Search loaded assemblies for the enum type
1419
+ foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
1420
+ {
1421
+ foreach (var t in asm.GetTypes())
1422
+ {
1423
+ if (!t.IsEnum) continue;
1424
+ if (t.Name == enumTypeName || t.FullName == enumTypeName ||
1425
+ t.FullName?.EndsWith("." + enumTypeName) == true)
1426
+ {
1427
+ try
1428
+ {
1429
+ var v = Enum.Parse(t, name, true);
1430
+ return Convert.ToInt32(v);
1431
+ }
1432
+ catch (ArgumentException) { }
1433
+ }
1434
+ }
1435
+ }
1436
+ return 0;
1437
+ }
1438
+
1439
+ #endregion
1440
+
1441
+ #region Write
1442
+
1443
+ private IWritableExcelSheet _writeSheet;
1444
+ private int _writeRow;
1445
+ private int _maxWriteRow;
1446
+ private string _writeCurrentPath = "";
1447
+ private readonly Stack<string> _writePathStack = new Stack<string>();
1448
+ private DpColumn _writeCurrentField;
1449
+ private DpSchema _writeSchema;
1450
+
1451
+ private class WriteListState
1452
+ {
1453
+ public int StartRow;
1454
+ public int CurrentElement;
1455
+ public string ListPath;
1456
+ public int StructDepth;
1457
+ public bool IsPrimitiveList;
1458
+ public int PrimitiveElemCol;
1459
+
1460
+ // Separated sheet write state
1461
+ public IWritableExcelSheet SeparatedSheet; // non-null when writing to a container sheet
1462
+ public string SeparatedSheetName; // sheet name for merge tracking in FinishWrite
1463
+ public string SeparatedMetaId; // meta_id value to write in col 1
1464
+ public string SeparatedMetaName; // name value to write in col 2
1465
+ public int MainSheetRow; // _writeRow before entering separated list
1466
+ public int MainSheetMaxRow; // _maxWriteRow before entering separated list
1467
+ }
1468
+ private readonly Stack<WriteListState> _writeListStack = new Stack<WriteListState>();
1469
+ private int _mainSheetFirstDataRow = FIRST_DATA_ROW;
1470
+ private int _globalFirstDataRow = FIRST_DATA_ROW;
1471
+ private int _globalMaxWriteRow = FIRST_DATA_ROW;
1472
+ private readonly List<(string sheetName, int firstRow, int lastRow, int colCount)> _containerSheetsWritten = new List<(string, int, int, int)>();
1473
+ /// <summary>Per container sheet name: last data row written. Used to compute append row for next record (avoids stale UsedRange).</summary>
1474
+ private readonly Dictionary<string, int> _containerSheetLastRow = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
1475
+ private IExcelSheet _cachedSepSheet;
1476
+ private DpExcelProtocol _cachedSepProto;
1477
+
1478
+ /// <summary>
1479
+ /// Enable write mode. Call before Write() on an IDeukPack object.
1480
+ /// metaSchemaName: when non-null, use DLL schema (ResolveMetaSchema) for column mapping; otherwise use sheet headers.
1481
+ /// When writeHeaders is true, the schema-derived column map is physically written to the sheet (rows 1–3).
1482
+ /// For keyed tables (e.g. level): set WriteCategory and GetKeyFieldNames before calling so meta_id is omitted and columns order key→name→note.
1483
+ /// </summary>
1484
+ public void BeginWrite(IWritableExcelSheet sheet, int startRow = FIRST_DATA_ROW, string metaSchemaName = null, bool writeHeaders = false)
1485
+ {
1486
+ _writeSheet = sheet ?? throw new ArgumentNullException(nameof(sheet));
1487
+ _writeRow = startRow;
1488
+ _maxWriteRow = startRow;
1489
+ _mainSheetFirstDataRow = startRow;
1490
+ _writeCurrentPath = "";
1491
+ _writePathStack.Clear();
1492
+ _writeListStack.Clear();
1493
+ if (writeHeaders)
1494
+ {
1495
+ _globalFirstDataRow = startRow;
1496
+ _globalMaxWriteRow = startRow;
1497
+ _containerSheetsWritten.Clear();
1498
+ _containerSheetLastRow.Clear();
1499
+ }
1500
+ else
1501
+ {
1502
+ _globalMaxWriteRow = Math.Max(_globalMaxWriteRow, _maxWriteRow);
1503
+ }
1504
+
1505
+ DpSchema schema = null;
1506
+ if (!string.IsNullOrEmpty(metaSchemaName))
1507
+ schema = ResolveMetaSchema?.Invoke(metaSchemaName);
1508
+ _writeSchema = schema;
1509
+
1510
+ if (schema != null)
1511
+ {
1512
+ BuildColumnMapFromSchema(schema);
1513
+ if (writeHeaders)
1514
+ {
1515
+ WriteHeadersToSheet(sheet);
1516
+ EnsureContainerSheetsExist();
1517
+ ApplyHeaderStyles(sheet, _writeFlatFields);
1518
+ }
1519
+ }
1520
+ }
1521
+
1522
+ /// <summary>
1523
+ /// Create empty container sheets for all list/set/map fields so that console Write matches add-in.
1524
+ /// Uses _writeFlatFields, _resolver, BuildContainerSheetHeaders; applies meta_id col NumberFormat "0".
1525
+ /// </summary>
1526
+ private void EnsureContainerSheetsExist()
1527
+ {
1528
+ if (_writeFlatFields == null || _resolver == null) return;
1529
+ foreach (var f in _writeFlatFields)
1530
+ {
1531
+ string dt = (f.DataType ?? "").Trim();
1532
+ var wt = DpTypeNames.FromProtocolName(dt);
1533
+ if (!ContainerSheetPolicy.IsContainerType(wt)) continue;
1534
+
1535
+ string kind = ContainerSheetPolicy.GetKindForWireType(wt);
1536
+ if (string.IsNullOrEmpty(kind)) continue;
1537
+ string sheetName = ContainerSheetNaming.FormatContainerSheetName(f.HierarchyId, kind, f.ColumnName ?? "");
1538
+
1539
+ string innerType = ResolveElementTypeNameFromSchema(f.HierarchyId);
1540
+ if (string.IsNullOrEmpty(innerType)) innerType = DpTypeNames.StripOuterGeneric(dt);
1541
+ DpWireType elemType = (ResolveTypeName != null && !string.IsNullOrEmpty(innerType) && ResolveTypeName(innerType) != null)
1542
+ ? DpWireType.Struct
1543
+ : DpTypeNames.FromProtocolName(innerType ?? "");
1544
+
1545
+ BuildContainerSheetHeaders(f.HierarchyId, elemType, out string[] hdrIds, out string[] hdrTypes, out string[] hdrNames, out string[] hdrStructNames, out bool[] hdrIsMarker);
1546
+ if (hdrIds == null) continue;
1547
+
1548
+ var sepSheet = _resolver.GetOrCreateSheet(sheetName, hdrIds, hdrTypes, hdrNames,
1549
+ _useCompactHeader ? hdrStructNames : null);
1550
+ if (sepSheet != null)
1551
+ {
1552
+ sepSheet.SetColumnNumberFormat(1, "0");
1553
+ sepSheet.SetColumnNumberFormat(2, "@");
1554
+ var listFlat = HeaderArraysToFlatFields(hdrIds, hdrTypes, hdrNames, hdrStructNames, hdrIsMarker);
1555
+ ApplyHeaderStyles(sepSheet, listFlat);
1556
+ }
1557
+ }
1558
+ }
1559
+
1560
+ /// <summary>
1561
+ /// Build column mapping directly from the DpSchema via FlattenSchema.
1562
+ /// When WriteCategory and GetKeyFieldNames are set: keyed tables exclude meta_id column and order key→name→note.
1563
+ /// </summary>
1564
+ private void BuildColumnMapFromSchema(DpSchema schema)
1565
+ {
1566
+ _fieldIdToCol.Clear();
1567
+ _colDataType.Clear();
1568
+ _colColumnName.Clear();
1569
+
1570
+ var fullFlat = FlattenSchema(schema, ResolveTypeName, "", mainSheetOnly: true);
1571
+ if (!string.IsNullOrEmpty(WriteCategory) && GetKeyFieldNames != null)
1572
+ {
1573
+ var fixedNames = GetMainSheetFixedColumnNamesForCategory(WriteCategory, GetKeyFieldNames);
1574
+ bool isKeyed = fixedNames != null && fixedNames.Count > 0 &&
1575
+ !string.Equals(fixedNames[0], "tuid", StringComparison.OrdinalIgnoreCase);
1576
+ if (isKeyed)
1577
+ {
1578
+ fullFlat = fullFlat.Where(f => !string.Equals(f.ColumnName, "meta_id", StringComparison.OrdinalIgnoreCase)
1579
+ && !string.Equals(f.ColumnName, "tuid", StringComparison.OrdinalIgnoreCase)).ToList();
1580
+ }
1581
+ fullFlat = ReorderFlatWithMetaIdTidMetaNameFirst(fullFlat, WriteCategory, GetKeyFieldNames);
1582
+ }
1583
+ for (int i = 0; i < fullFlat.Count; i++)
1584
+ {
1585
+ int col = i + 1;
1586
+ var f = fullFlat[i];
1587
+ _fieldIdToCol[f.HierarchyId] = col;
1588
+ _colDataType[col] = f.DataType ?? "";
1589
+ _colColumnName[col] = f.ColumnName ?? "";
1590
+ }
1591
+ _writeFlatFields = fullFlat;
1592
+ _writeStructRanges = null;
1593
+ }
1594
+
1595
+ private static List<(int colStart, int colEnd, string structName)> BuildStructRangesForCompact(List<FlatHeaderField> fullFlat)
1596
+ {
1597
+ var list = new List<(int, int, string)>();
1598
+ int dataCol = 0;
1599
+ string currentStruct = null;
1600
+ int runStart = 0;
1601
+ for (int i = 0; i < fullFlat.Count; i++)
1602
+ {
1603
+ if (fullFlat[i].IsStructMarker)
1604
+ {
1605
+ if (currentStruct != null && runStart > 0)
1606
+ list.Add((runStart, dataCol, currentStruct));
1607
+ string tn = fullFlat[i].DataType?.Trim();
1608
+ currentStruct = (!string.IsNullOrEmpty(tn) && !string.Equals(tn, "rec", StringComparison.OrdinalIgnoreCase) && !string.Equals(tn, "record", StringComparison.OrdinalIgnoreCase))
1609
+ ? tn : (fullFlat[i].ColumnName?.Trim() ?? "");
1610
+ runStart = dataCol + 1;
1611
+ }
1612
+ else
1613
+ {
1614
+ dataCol++;
1615
+ }
1616
+ }
1617
+ if (currentStruct != null && runStart > 0)
1618
+ list.Add((runStart, dataCol, currentStruct));
1619
+ return list;
1620
+ }
1621
+
1622
+ private List<FlatHeaderField> _writeFlatFields;
1623
+ #pragma warning disable CS0414
1624
+ private List<(int colStart, int colEnd, string structName)> _writeStructRanges;
1625
+ #pragma warning restore CS0414
1626
+
1627
+ private const int STRUCT_NAME_ROW_COMPACT = 3;
1628
+ private const int VARIABLE_NAME_ROW_COMPACT = 4;
1629
+
1630
+ /// <summary>
1631
+ /// Physically write header rows to the given sheet from the current column map.
1632
+ /// 신버전 3행: Row 1 = HierarchyId, Row 2 = DataType, Row 3 = ColumnName.
1633
+ /// </summary>
1634
+ private void WriteHeadersToSheet(IWritableExcelSheet sheet)
1635
+ {
1636
+ if (_writeFlatFields == null) return;
1637
+
1638
+ for (int i = 0; i < _writeFlatFields.Count; i++)
1639
+ {
1640
+ int col = i + 1;
1641
+ var f = _writeFlatFields[i];
1642
+ sheet.SetCellValue(HIERARCHY_ID_ROW, col, f.HierarchyId);
1643
+ sheet.SetCellValue(DATATYPE_ROW, col, f.DataType ?? "");
1644
+ string nameRow3 = f.ColumnName ?? "";
1645
+ if (string.IsNullOrWhiteSpace(nameRow3) && f.IsStructMarker)
1646
+ nameRow3 = f.ParentStructName ?? f.HierarchyId ?? "";
1647
+ sheet.SetCellValue(COLUMN_NAME_ROW, col, nameRow3 ?? "");
1648
+ }
1649
+
1650
+ for (int c = 1; c <= _writeFlatFields.Count; c++)
1651
+ {
1652
+ string dt = (_writeFlatFields[c - 1].DataType ?? "").Trim();
1653
+ var wt = DpTypeNames.FromProtocolName(dt);
1654
+ if (wt == DpWireType.I64 || wt == DpWireType.I32 || wt == DpWireType.I16 || wt == DpWireType.Byte)
1655
+ sheet.SetColumnNumberFormat(c, "0");
1656
+ }
1657
+ }
1658
+
1659
+ /// <summary>The highest row written so far. Use to determine how many rows were consumed.</summary>
1660
+ public int WriteRow => _maxWriteRow;
1661
+
1662
+ /// <summary>
1663
+ /// Call after all records have been written. Applies data row stripes to main sheet and to each written container sheet.
1664
+ /// Console Write must call this to get the same result as add-in.
1665
+ /// </summary>
1666
+ public void FinishWrite()
1667
+ {
1668
+ if (_writeSheet == null || _writeFlatFields == null) return;
1669
+ _globalMaxWriteRow = Math.Max(_globalMaxWriteRow, _maxWriteRow);
1670
+ int colCount = _writeFlatFields.Count;
1671
+ if (colCount > 0 && _globalMaxWriteRow >= _globalFirstDataRow)
1672
+ ApplyDataStripes(_writeSheet, _globalFirstDataRow, _globalMaxWriteRow, colCount, _writeFlatFields);
1673
+ // Merge ranges per sheet name so meta_id grouping colors all groups across all records
1674
+ var byName = new Dictionary<string, (int firstRow, int lastRow, int cols)>(StringComparer.OrdinalIgnoreCase);
1675
+ foreach (var (sheetName, firstRow, lastRow, cols) in _containerSheetsWritten)
1676
+ {
1677
+ if (string.IsNullOrEmpty(sheetName) || cols < 1) continue;
1678
+ if (byName.TryGetValue(sheetName, out var existing))
1679
+ {
1680
+ int mergedFirst = Math.Min(existing.firstRow, firstRow);
1681
+ int mergedLast = Math.Max(existing.lastRow, lastRow);
1682
+ byName[sheetName] = (mergedFirst, mergedLast, Math.Max(existing.cols, cols));
1683
+ }
1684
+ else
1685
+ byName[sheetName] = (firstRow, lastRow, cols);
1686
+ }
1687
+ foreach (var kv in byName)
1688
+ {
1689
+ var (firstRow, lastRow, cols) = kv.Value;
1690
+ if (lastRow < firstRow) continue;
1691
+ // Get fresh writable adapter with current LastRow/LastColumn
1692
+ var freshSheet = _resolver?.GetOrCreateSheet(kv.Key, null, null, null);
1693
+ if (freshSheet != null)
1694
+ ApplyContainerDataStripes(freshSheet, firstRow, lastRow, cols);
1695
+ }
1696
+ }
1697
+
1698
+ #region Excel styling (header colors + data stripes; same result as add-in)
1699
+
1700
+ private static int ToOle(int r, int g, int b) => (r & 0xFF) | ((g & 0xFF) << 8) | ((b & 0xFF) << 16);
1701
+ private static int Lighten(int ole, float amount)
1702
+ {
1703
+ int r = (ole & 0xFF) + (int)((255 - (ole & 0xFF)) * amount);
1704
+ int g = ((ole >> 8) & 0xFF) + (int)((255 - ((ole >> 8) & 0xFF)) * amount);
1705
+ int b = ((ole >> 16) & 0xFF) + (int)((255 - ((ole >> 16) & 0xFF)) * amount);
1706
+ return ToOle(Math.Min(r, 255), Math.Min(g, 255), Math.Min(b, 255));
1707
+ }
1708
+ private static int Darken(int ole, float amount)
1709
+ {
1710
+ int r = (ole & 0xFF) - (int)((ole & 0xFF) * amount);
1711
+ int g = ((ole >> 8) & 0xFF) - (int)(((ole >> 8) & 0xFF) * amount);
1712
+ int b = ((ole >> 16) & 0xFF) - (int)(((ole >> 16) & 0xFF) * amount);
1713
+ return ToOle(Math.Max(0, r), Math.Max(0, g), Math.Max(0, b));
1714
+ }
1715
+ private static readonly int ColHdrPrimitive = ToOle(240, 253, 244);
1716
+ private static readonly int ColHdrStruct = ToOle(238, 242, 255);
1717
+ private static readonly int ColHdrList = ToOle(255, 250, 235);
1718
+ private static readonly int ColHdrMap = ToOle(245, 243, 255);
1719
+ private static readonly int ColHdrEnum = ToOle(255, 241, 242);
1720
+ private static readonly int ColHdrLink = ToOle(236, 254, 255);
1721
+ private static readonly int ColHdrRow3Base = ToOle(51, 65, 85);
1722
+ private static readonly int ColHdrRow3Struct = ToOle(49, 46, 129);
1723
+ private static readonly int ColHdrRow3List = ToOle(180, 83, 9);
1724
+ private static readonly int ColHdrRow3Map = ToOle(91, 33, 182);
1725
+ private static readonly int ColHdrRow3Enum = ToOle(157, 23, 77);
1726
+ private static readonly int ColHdrRow3Link = ToOle(21, 94, 117);
1727
+ private static readonly int FontGray1 = ToOle(120, 120, 120);
1728
+ private static readonly int FontGray2 = ToOle(130, 130, 130);
1729
+ private static readonly int FontWhite = ToOle(255, 255, 255);
1730
+ private static readonly int FontGray4 = ToOle(160, 160, 160);
1731
+
1732
+ private static void ClassifyColumnColor(string dt, FlatHeaderField field, out int pastelOle, out int row3Ole)
1733
+ {
1734
+ if (field != null && field.IsStructMarker)
1735
+ { pastelOle = ColHdrStruct; row3Ole = ColHdrRow3Struct; return; }
1736
+ string lower = (dt ?? "").ToLowerInvariant();
1737
+ if (lower.StartsWith("enum", StringComparison.Ordinal))
1738
+ { pastelOle = ColHdrEnum; row3Ole = ColHdrRow3Enum; return; }
1739
+ if (lower.StartsWith("_link") || lower.StartsWith("link<") || lower.StartsWith("linktid<") || lower.StartsWith("_linktid"))
1740
+ { pastelOle = ColHdrLink; row3Ole = ColHdrRow3Link; return; }
1741
+ var ttype = DpTypeNames.FromProtocolName(dt);
1742
+ switch (ttype)
1743
+ {
1744
+ case DpWireType.List:
1745
+ case DpWireType.Set: pastelOle = ColHdrList; row3Ole = ColHdrRow3List; return;
1746
+ case DpWireType.Map: pastelOle = ColHdrMap; row3Ole = ColHdrRow3Map; return;
1747
+ case DpWireType.Struct: pastelOle = ColHdrStruct; row3Ole = ColHdrRow3Struct; return;
1748
+ default: pastelOle = ColHdrPrimitive; row3Ole = ColHdrRow3Base; return;
1749
+ }
1750
+ }
1751
+
1752
+ private static void ApplyHeaderStyles(IWritableExcelSheet sheet, List<FlatHeaderField> flatFields)
1753
+ {
1754
+ if (sheet == null || flatFields == null || flatFields.Count == 0) return;
1755
+ int colCount = flatFields.Count;
1756
+ sheet.SetRangeFont(1, 1, 1, colCount, FontGray1, true, false, 9);
1757
+ sheet.SetRangeFont(2, 2, 1, colCount, FontGray2, false, true, 9);
1758
+ sheet.SetRangeFont(3, 3, 1, colCount, FontWhite, true, false, 9);
1759
+
1760
+ for (int c = 1; c <= colCount; c++)
1761
+ {
1762
+ var f = c - 1 < flatFields.Count ? flatFields[c - 1] : null;
1763
+ string dt = (f?.DataType ?? "").Trim();
1764
+ ClassifyColumnColor(dt, f, out int pastel, out int row3Bg);
1765
+ sheet.SetRangeInteriorColor(1, 1, c, c, pastel);
1766
+ sheet.SetRangeInteriorColor(2, 2, c, c, Lighten(pastel, 0.4f));
1767
+ bool hasParentStruct = !string.IsNullOrEmpty(f?.ParentStructName);
1768
+ sheet.SetRangeInteriorColor(3, 3, c, c, hasParentStruct ? ColHdrRow3Struct : row3Bg);
1769
+ }
1770
+ sheet.AutoFitColumns(1, colCount);
1771
+ }
1772
+
1773
+ // ── 그룹 색상: 파랑톤(짝수 그룹) / 분홍톤(홀수 그룹), 그룹 내 행 교차 ──
1774
+
1775
+ // 파랑톤 (Group A) — 밝은/어두운
1776
+ private static readonly int BlueLight = ToOle(232, 240, 254);
1777
+ private static readonly int BlueDark = ToOle(210, 224, 245);
1778
+ // 분홍톤 (Group B) — 밝은/어두운
1779
+ private static readonly int PinkLight = ToOle(254, 236, 243);
1780
+ private static readonly int PinkDark = ToOle(245, 218, 232);
1781
+ // 특수 컬럼 마커 (list/struct/map) — 파랑 계열
1782
+ private static readonly int ColDataListBlueL = ToOle(225, 237, 255);
1783
+ private static readonly int ColDataListBlueD = ToOle(205, 222, 248);
1784
+ private static readonly int ColDataStructBlueL = ToOle(228, 234, 255);
1785
+ private static readonly int ColDataStructBlueD = ToOle(212, 222, 252);
1786
+ private static readonly int ColDataMapBlueL = ToOle(235, 228, 255);
1787
+ private static readonly int ColDataMapBlueD = ToOle(222, 212, 252);
1788
+ // 특수 컬럼 마커 — 분홍 계열
1789
+ private static readonly int ColDataListPinkL = ToOle(255, 232, 240);
1790
+ private static readonly int ColDataListPinkD = ToOle(248, 212, 225);
1791
+ private static readonly int ColDataStructPinkL = ToOle(255, 228, 235);
1792
+ private static readonly int ColDataStructPinkD = ToOle(248, 212, 222);
1793
+ private static readonly int ColDataMapPinkL = ToOle(248, 228, 245);
1794
+ private static readonly int ColDataMapPinkD = ToOle(240, 212, 238);
1795
+
1796
+ private static void ApplyDataStripes(IWritableExcelSheet sheet, int firstDataRow, int lastDataRow, int colCount, List<FlatHeaderField> flatFields = null)
1797
+ {
1798
+ if (sheet == null || colCount < 1 || lastDataRow < firstDataRow) return;
1799
+
1800
+ int[] colCategory = null;
1801
+ if (flatFields != null && flatFields.Count >= colCount)
1802
+ {
1803
+ colCategory = new int[colCount + 1]; // 0=normal, 1=list/set, 2=struct, 3=map
1804
+ for (int c = 1; c <= colCount; c++)
1805
+ {
1806
+ var f = flatFields[c - 1];
1807
+ if (f.IsStructMarker) { colCategory[c] = 2; continue; }
1808
+ var wt = DpTypeNames.FromProtocolName(f.DataType ?? "");
1809
+ if (wt == DpWireType.List || wt == DpWireType.Set) colCategory[c] = 1;
1810
+ else if (wt == DpWireType.Map) colCategory[c] = 3;
1811
+ else colCategory[c] = 0;
1812
+ }
1813
+ }
1814
+
1815
+ var groups = BuildMetaIdGroups(sheet, firstDataRow, lastDataRow);
1816
+
1817
+ for (int gi = 0; gi < groups.Count; gi++)
1818
+ {
1819
+ var (gStart, gEnd) = groups[gi];
1820
+ bool blue = (gi % 2) == 0;
1821
+
1822
+ for (int r = gStart; r <= gEnd; r++)
1823
+ {
1824
+ bool light = ((r - gStart) % 2) == 0;
1825
+ for (int c = 1; c <= colCount; c++)
1826
+ {
1827
+ int cat = (colCategory != null) ? colCategory[c] : 0;
1828
+ int bg = PickGroupColor(blue, light, cat);
1829
+ if ((c % 2) == 1) bg = Darken(bg, 0.04f);
1830
+ try { sheet.SetRangeInteriorColor(r, r, c, c, bg); } catch { }
1831
+ }
1832
+ }
1833
+
1834
+ if (gi < groups.Count - 1)
1835
+ try { sheet.SetBottomBorder(gEnd, 1, colCount, GrpBorderColor); } catch { }
1836
+ }
1837
+ }
1838
+
1839
+ private static int PickGroupColor(bool blue, bool light, int cat)
1840
+ {
1841
+ if (cat == 1) return blue ? (light ? ColDataListBlueL : ColDataListBlueD) : (light ? ColDataListPinkL : ColDataListPinkD);
1842
+ if (cat == 2) return blue ? (light ? ColDataStructBlueL : ColDataStructBlueD) : (light ? ColDataStructPinkL : ColDataStructPinkD);
1843
+ if (cat == 3) return blue ? (light ? ColDataMapBlueL : ColDataMapBlueD) : (light ? ColDataMapPinkL : ColDataMapPinkD);
1844
+ return blue ? (light ? BlueLight : BlueDark) : (light ? PinkLight : PinkDark);
1845
+ }
1846
+
1847
+ private static List<(int start, int end)> BuildMetaIdGroups(IExcelSheet sheet, int firstDataRow, int lastDataRow)
1848
+ {
1849
+ var groups = new List<(int start, int end)>();
1850
+ string currentId = null;
1851
+ int groupStart = firstDataRow;
1852
+ for (int r = firstDataRow; r <= lastDataRow; r++)
1853
+ {
1854
+ string id = sheet.CellValue(r, 1)?.Trim() ?? "";
1855
+ if (!string.Equals(id, currentId, StringComparison.Ordinal))
1856
+ {
1857
+ if (currentId != null)
1858
+ groups.Add((groupStart, r - 1));
1859
+ currentId = id;
1860
+ groupStart = r;
1861
+ }
1862
+ }
1863
+ if (currentId != null)
1864
+ groups.Add((groupStart, lastDataRow));
1865
+ if (groups.Count == 0)
1866
+ groups.Add((firstDataRow, lastDataRow));
1867
+ return groups;
1868
+ }
1869
+
1870
+ // ── Container sheet: group-aware data stripes ─────────────────────────
1871
+
1872
+ private static readonly int GrpBorderColor = ToOle(160, 170, 185);
1873
+
1874
+ /// <summary>
1875
+ /// Apply group-aware coloring to a container (list/set/map) sheet:
1876
+ /// - 짝수 그룹 = 파랑톤, 홀수 그룹 = 분홍톤 (그룹 내 행은 해당 톤의 밝은/어두운으로 교차)
1877
+ /// - Bottom border at group boundaries
1878
+ /// </summary>
1879
+ public static void ApplyContainerDataStripes(IWritableExcelSheet sheet, int firstDataRow, int lastDataRow, int colCount)
1880
+ {
1881
+ if (sheet == null || colCount < 1 || lastDataRow < firstDataRow) return;
1882
+
1883
+ var groups = BuildMetaIdGroups(sheet, firstDataRow, lastDataRow);
1884
+
1885
+ for (int gi = 0; gi < groups.Count; gi++)
1886
+ {
1887
+ var (gStart, gEnd) = groups[gi];
1888
+ bool blue = (gi % 2) == 0;
1889
+
1890
+ for (int r = gStart; r <= gEnd; r++)
1891
+ {
1892
+ bool light = ((r - gStart) % 2) == 0;
1893
+ int baseBg = blue ? (light ? BlueLight : BlueDark) : (light ? PinkLight : PinkDark);
1894
+ for (int c = 1; c <= colCount; c++)
1895
+ {
1896
+ int bg = (c % 2) == 1 ? Darken(baseBg, 0.04f) : baseBg;
1897
+ try { sheet.SetRangeInteriorColor(r, r, c, c, bg); } catch { }
1898
+ }
1899
+ }
1900
+
1901
+ if (gi < groups.Count - 1)
1902
+ try { sheet.SetBottomBorder(gEnd, 1, colCount, GrpBorderColor); } catch { }
1903
+ }
1904
+ }
1905
+
1906
+ /// <summary>
1907
+ /// Format enum columns to "value:name" and link columns to "value:name" or "value:NIA"/"0:MIA".
1908
+ /// Used after schema migration and when opening schema pane so numeric cells show resolved names.
1909
+ /// </summary>
1910
+ /// <param name="metaIdCol">1-based column index; when &gt; 0, only rows with non-empty value in this column are formatted (각 시트 첫 번째 칼럼 데이터 유무로 판단).</param>
1911
+ /// <param name="getEnumValuesForField">Returns "value:name" or "value:name:comment" list for the given schema field (caller resolves via loader/schema); null/empty if unknown.</param>
1912
+ /// <param name="getLinkNameForId">Optional. For link columns: (field, idString) => display name; null if not found. When provided, cells are written as "id:name" instead of "id:NIA".</param>
1913
+ public static void ApplyEnumAndLinkDisplayOnly(
1914
+ IExcelSheet sheet,
1915
+ IWritableExcelSheet writable,
1916
+ int firstDataRow,
1917
+ int lastDataRow,
1918
+ List<FlatHeaderField> flatFields,
1919
+ Dictionary<string, int> colByPath,
1920
+ Func<FlatHeaderField, List<string>> getEnumValuesForField,
1921
+ Func<FlatHeaderField, string, string> getLinkNameForId = null,
1922
+ int metaIdCol = 0)
1923
+ {
1924
+ if (flatFields == null || colByPath == null || sheet == null || writable == null) return;
1925
+ int effectiveLast = lastDataRow < firstDataRow ? firstDataRow : lastDataRow;
1926
+
1927
+ foreach (var f in flatFields)
1928
+ {
1929
+ if (!colByPath.TryGetValue(f.HierarchyId, out int col) || col < 1) continue;
1930
+ string dt = (f.DataType ?? "").Trim();
1931
+
1932
+ if (DpTypeNames.IsEnumDataType(dt))
1933
+ {
1934
+ var values = getEnumValuesForField?.Invoke(f);
1935
+ if (values == null) values = new List<string>();
1936
+ for (int r = firstDataRow; r <= effectiveLast; r++)
1937
+ {
1938
+ try
1939
+ {
1940
+ if (metaIdCol > 0 && string.IsNullOrWhiteSpace(sheet.CellValue(r, metaIdCol)?.Trim()))
1941
+ continue;
1942
+ string str = sheet.CellValue(r, col)?.Trim() ?? "";
1943
+ if (string.IsNullOrEmpty(str))
1944
+ {
1945
+ writable.SetColumnNumberFormat(col, "@");
1946
+ writable.SetCellValue(r, col, "0:MIA");
1947
+ continue;
1948
+ }
1949
+ if (str.IndexOf(':') >= 0) continue;
1950
+ if (int.TryParse(str, out int num))
1951
+ {
1952
+ string line = null;
1953
+ foreach (var v in values)
1954
+ {
1955
+ var parts = v.Split(new[] { ':' }, 2);
1956
+ if (parts.Length > 0 && int.TryParse(parts[0].Trim(), out int vv) && vv == num)
1957
+ { line = v; break; }
1958
+ }
1959
+ writable.SetColumnNumberFormat(col, "@");
1960
+ writable.SetCellValue(r, col, !string.IsNullOrEmpty(line) ? line : (num == 0 ? "0:MIA" : $"{num}:NIA"));
1961
+ }
1962
+ }
1963
+ catch { }
1964
+ }
1965
+ }
1966
+ else if (IsLinkDataType(dt))
1967
+ {
1968
+ for (int r = firstDataRow; r <= effectiveLast; r++)
1969
+ {
1970
+ try
1971
+ {
1972
+ if (metaIdCol > 0 && string.IsNullOrWhiteSpace(sheet.CellValue(r, metaIdCol)?.Trim()))
1973
+ continue;
1974
+ string str = sheet.CellValue(r, col)?.Trim() ?? "";
1975
+ if (string.IsNullOrEmpty(str))
1976
+ {
1977
+ writable.SetColumnNumberFormat(col, "@");
1978
+ writable.SetCellValue(r, col, "0:MIA");
1979
+ continue;
1980
+ }
1981
+ if (str.IndexOf(':') >= 0) continue;
1982
+ if (long.TryParse(str, out long numL))
1983
+ {
1984
+ writable.SetColumnNumberFormat(col, "@");
1985
+ string name = getLinkNameForId?.Invoke(f, str);
1986
+ if (numL == 0)
1987
+ writable.SetCellValue(r, col, "0:MIA");
1988
+ else if (!string.IsNullOrEmpty(name))
1989
+ writable.SetCellValue(r, col, $"{numL}:{name}");
1990
+ else
1991
+ writable.SetCellValue(r, col, $"{numL}:NIA");
1992
+ }
1993
+ }
1994
+ catch { }
1995
+ }
1996
+ }
1997
+ }
1998
+ }
1999
+
2000
+ /// <summary>True if the data type string denotes a link (meta_id or tid) column.</summary>
2001
+ public static bool IsLinkDataType(string dt)
2002
+ {
2003
+ if (string.IsNullOrEmpty(dt)) return false;
2004
+ return dt.StartsWith("_link_", StringComparison.OrdinalIgnoreCase)
2005
+ || dt.StartsWith("_linktid_", StringComparison.OrdinalIgnoreCase)
2006
+ || dt.StartsWith("link<", StringComparison.OrdinalIgnoreCase)
2007
+ || dt.StartsWith("lnk<", StringComparison.OrdinalIgnoreCase)
2008
+ || dt.StartsWith("linktid<", StringComparison.OrdinalIgnoreCase);
2009
+ }
2010
+
2011
+ /// <summary>
2012
+ /// Walks the schema by HierarchyId path (e.g. "30.2.1") and returns the field descriptor.
2013
+ /// Caller supplies getOrderedFields(schema) and getSchemaByTypeName(typeName) so the protocol stays agnostic of loader/reflection source.
2014
+ /// </summary>
2015
+ public static object GetFieldDescriptorByHierarchyId(
2016
+ object schema,
2017
+ string hierarchyId,
2018
+ Func<object, IEnumerable<KeyValuePair<string, object>>> getOrderedFields,
2019
+ Func<string, object> getSchemaByTypeName)
2020
+ {
2021
+ if (schema == null || string.IsNullOrEmpty(hierarchyId) || getOrderedFields == null || getSchemaByTypeName == null) return null;
2022
+ var parts = hierarchyId.Split('.');
2023
+ object currentSchema = schema;
2024
+ for (int i = 0; i < parts.Length; i++)
2025
+ {
2026
+ var fields = getOrderedFields(currentSchema);
2027
+ if (fields == null) return null;
2028
+ var pair = fields.FirstOrDefault(kv => string.Equals(kv.Key?.ToString(), parts[i].Trim(), StringComparison.Ordinal));
2029
+ if (pair.Value == null) return null;
2030
+ if (i == parts.Length - 1) return pair.Value;
2031
+ string typeName = GetTypeNameFromFieldDescriptor(pair.Value);
2032
+ if (string.IsNullOrEmpty(typeName)) return null;
2033
+ string typeKind = GetFieldTypeString(pair.Value);
2034
+ if (typeKind == "List" || typeKind == "Set" || typeKind == "Map")
2035
+ typeName = DpTypeNames.StripOuterGeneric(typeName);
2036
+ currentSchema = getSchemaByTypeName(typeName);
2037
+ if (currentSchema == null) return null;
2038
+ }
2039
+ return null;
2040
+ }
2041
+
2042
+ /// <summary>득팩 필드 디스크립터에서 타입 이름을 가져옴. typedef인 경우 typedef 이름이 반환됨.</summary>
2043
+ public static string GetFieldTypeName(object fieldDescriptor)
2044
+ {
2045
+ if (fieldDescriptor == null) return null;
2046
+ try
2047
+ {
2048
+ var prop = fieldDescriptor.GetType().GetProperty("TypeName");
2049
+ return prop?.GetValue(fieldDescriptor)?.ToString()?.Trim();
2050
+ }
2051
+ catch { return null; }
2052
+ }
2053
+
2054
+ /// <summary>득팩 필드 디스크립터가 typedef 타입인지 여부. Type이 primitive이고 TypeName이 있으면 typedef로 간주.</summary>
2055
+ public static bool IsTypedefField(object fieldDescriptor)
2056
+ {
2057
+ if (fieldDescriptor == null) return false;
2058
+ string typeName = GetFieldTypeName(fieldDescriptor);
2059
+ if (string.IsNullOrWhiteSpace(typeName)) return false;
2060
+ string typeStr = GetFieldTypeString(fieldDescriptor);
2061
+ if (string.IsNullOrEmpty(typeStr)) return false;
2062
+ switch (typeStr)
2063
+ {
2064
+ case "Bool": case "Byte": case "I16": case "I32": case "I64": case "Double": case "String":
2065
+ return typeName.IndexOf('<') < 0;
2066
+ default: return false;
2067
+ }
2068
+ }
2069
+
2070
+ private static string GetTypeNameFromFieldDescriptor(object fieldDescriptor) => GetFieldTypeName(fieldDescriptor);
2071
+
2072
+ private static string GetFieldTypeString(object fieldDescriptor)
2073
+ {
2074
+ if (fieldDescriptor == null) return null;
2075
+ try
2076
+ {
2077
+ var prop = fieldDescriptor.GetType().GetProperty("Type");
2078
+ return prop?.GetValue(fieldDescriptor)?.ToString()?.Trim();
2079
+ }
2080
+ catch { return null; }
2081
+ }
2082
+
2083
+ /// <summary>
2084
+ /// Build path → column index map from sheet row 1 (HIERARCHY_ID). All Excel-style header column resolution goes through the protocol.
2085
+ /// </summary>
2086
+ public static Dictionary<string, int> BuildColByPath(IExcelSheet sheet)
2087
+ {
2088
+ var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
2089
+ if (sheet == null) return map;
2090
+ for (int col = 1; col <= sheet.LastColumn; col++)
2091
+ {
2092
+ string path = sheet.CellValue(HIERARCHY_ID_ROW, col)?.Trim();
2093
+ if (!string.IsNullOrEmpty(path) && !map.ContainsKey(path))
2094
+ map[path] = col;
2095
+ }
2096
+ return map;
2097
+ }
2098
+
2099
+ /// <summary>
2100
+ /// Get ordered (key, fieldDescriptor) list from a schema object via reflection. Single source for Excel/schema field enumeration.
2101
+ /// </summary>
2102
+ public static List<KeyValuePair<string, object>> GetOrderedSchemaFields(object schema)
2103
+ {
2104
+ if (schema == null) return new List<KeyValuePair<string, object>>();
2105
+ try
2106
+ {
2107
+ var fieldsProp = schema.GetType().GetProperty("Fields");
2108
+ if (fieldsProp == null) return new List<KeyValuePair<string, object>>();
2109
+ var fieldsVal = fieldsProp.GetValue(schema);
2110
+ if (fieldsVal == null || !(fieldsVal is IEnumerable en)) return new List<KeyValuePair<string, object>>();
2111
+ var list = new List<KeyValuePair<string, object>>();
2112
+ foreach (var item in en)
2113
+ {
2114
+ if (item == null) continue;
2115
+ var kvType = item.GetType();
2116
+ string key = kvType.GetProperty("Key")?.GetValue(item)?.ToString() ?? "";
2117
+ object val = kvType.GetProperty("Value")?.GetValue(item);
2118
+ list.Add(new KeyValuePair<string, object>(key, val));
2119
+ }
2120
+ return list.OrderBy(kv =>
2121
+ {
2122
+ try
2123
+ {
2124
+ if (kv.Value == null) return 0;
2125
+ var orderProp = kv.Value.GetType().GetProperty("Order");
2126
+ if (orderProp != null)
2127
+ return Convert.ToInt32(orderProp.GetValue(kv.Value));
2128
+ }
2129
+ catch { }
2130
+ return int.TryParse(kv.Key, out int id) ? id : 0;
2131
+ }).ToList();
2132
+ }
2133
+ catch { return new List<KeyValuePair<string, object>>(); }
2134
+ }
2135
+
2136
+ /// <summary>
2137
+ /// Convert schema object (from DLL/reflection) to DpSchema. All Excel-style schema object handling in the protocol.
2138
+ /// </summary>
2139
+ public static DpSchema BuildDpSchemaFromObject(object schema)
2140
+ {
2141
+ if (schema == null) return null;
2142
+ #pragma warning disable CS0618
2143
+ if (schema is ThriftSchema ts) return ts.ToDpSchema();
2144
+ #pragma warning restore CS0618
2145
+ if (schema is DpSchema ds) return ds;
2146
+ try
2147
+ {
2148
+ var schemaType = schema.GetType();
2149
+ var result = new DpSchema();
2150
+ result.Name = ReflectString(schemaType, schema, "Name") ?? "";
2151
+ result.Fields = new Dictionary<int, DpFieldSchema>();
2152
+ var fields = GetOrderedSchemaFields(schema);
2153
+ foreach (var kv in fields)
2154
+ {
2155
+ if (!int.TryParse(kv.Key, out int id)) continue;
2156
+ object f = kv.Value;
2157
+ if (f == null) continue;
2158
+ var ft = f.GetType();
2159
+ var fs = new DpFieldSchema
2160
+ {
2161
+ Id = id,
2162
+ Name = ReflectString(ft, f, "Name") ?? "",
2163
+ TypeName = ReflectString(ft, f, "TypeName") ?? "",
2164
+ DocComment = ReflectString(ft, f, "DocComment") ?? ""
2165
+ };
2166
+ var orderProp = ft.GetProperty("Order");
2167
+ fs.Order = orderProp != null ? Convert.ToInt32(orderProp.GetValue(f)) : id;
2168
+ string typeStr = "String";
2169
+ try
2170
+ {
2171
+ var typeProp = ft.GetProperty("Type");
2172
+ if (typeProp != null) typeStr = typeProp.GetValue(f)?.ToString() ?? "String";
2173
+ }
2174
+ catch { }
2175
+ switch (typeStr)
2176
+ {
2177
+ case "Bool": fs.Type = DpSchemaType.Bool; break;
2178
+ case "Byte": fs.Type = DpSchemaType.Byte; break;
2179
+ case "I16": fs.Type = DpSchemaType.I16; break;
2180
+ case "I32": fs.Type = DpSchemaType.I32; break;
2181
+ case "I64": fs.Type = DpSchemaType.I64; break;
2182
+ case "Double": fs.Type = DpSchemaType.Double; break;
2183
+ case "Struct": fs.Type = DpSchemaType.Struct; break;
2184
+ case "List": fs.Type = DpSchemaType.List; break;
2185
+ case "Set": fs.Type = DpSchemaType.Set; break;
2186
+ case "Map": fs.Type = DpSchemaType.Map; break;
2187
+ case "Enum": fs.Type = DpSchemaType.Enum; break;
2188
+ default: fs.Type = DpSchemaType.String; break;
2189
+ }
2190
+ result.Fields[id] = fs;
2191
+ }
2192
+ return result;
2193
+ }
2194
+ catch { return null; }
2195
+ }
2196
+
2197
+ /// <summary>Compatibility: use BuildDpSchemaFromObject.</summary>
2198
+ public static DpSchema BuildThriftSchemaFromObject(object schema) => BuildDpSchemaFromObject(schema);
2199
+
2200
+ /// <summary>
2201
+ /// Field descriptor → Excel DATATYPE string (e.g. "enum&lt;X&gt;", "i64"). Protocol owns all Excel-style type string derivation.
2202
+ /// </summary>
2203
+ public static string GetDataTypeFromFieldDescriptor(object fieldDescriptor)
2204
+ {
2205
+ if (fieldDescriptor == null) return "string";
2206
+ try
2207
+ {
2208
+ var ft = fieldDescriptor.GetType();
2209
+ var fs = new DpFieldSchema
2210
+ {
2211
+ Name = ReflectString(ft, fieldDescriptor, "Name") ?? "",
2212
+ TypeName = ReflectString(ft, fieldDescriptor, "TypeName") ?? ""
2213
+ };
2214
+ string typeStr = "String";
2215
+ var typeProp = ft.GetProperty("Type");
2216
+ if (typeProp != null) typeStr = typeProp.GetValue(fieldDescriptor)?.ToString() ?? "String";
2217
+ switch (typeStr)
2218
+ {
2219
+ case "Bool": fs.Type = DpSchemaType.Bool; break;
2220
+ case "Byte": fs.Type = DpSchemaType.Byte; break;
2221
+ case "I16": fs.Type = DpSchemaType.I16; break;
2222
+ case "I32": fs.Type = DpSchemaType.I32; break;
2223
+ case "I64": fs.Type = DpSchemaType.I64; break;
2224
+ case "Double": fs.Type = DpSchemaType.Double; break;
2225
+ case "Struct": fs.Type = DpSchemaType.Struct; break;
2226
+ case "List": fs.Type = DpSchemaType.List; break;
2227
+ case "Set": fs.Type = DpSchemaType.Set; break;
2228
+ case "Map": fs.Type = DpSchemaType.Map; break;
2229
+ case "Enum": fs.Type = DpSchemaType.Enum; break;
2230
+ default: fs.Type = DpSchemaType.String; break;
2231
+ }
2232
+ return DpTypeNames.SchemaFieldToDataType(fs);
2233
+ }
2234
+ catch { return "string"; }
2235
+ }
2236
+
2237
+ /// <summary>Read a string property from an object via reflection. Used for schema/field descriptor access.</summary>
2238
+ public static string ReflectString(Type t, object obj, string propName)
2239
+ {
2240
+ if (t == null || obj == null) return null;
2241
+ try
2242
+ {
2243
+ var prop = t.GetProperty(propName);
2244
+ return prop?.GetValue(obj)?.ToString();
2245
+ }
2246
+ catch { return null; }
2247
+ }
2248
+
2249
+ #endregion
2250
+
2251
+ private int WriteFieldPathToCol(string fieldPath)
2252
+ {
2253
+ if (_fieldIdToCol.TryGetValue(fieldPath, out int col))
2254
+ return col;
2255
+ return -1;
2256
+ }
2257
+
2258
+ private void TrackWriteRow(int row)
2259
+ {
2260
+ if (row > _maxWriteRow) _maxWriteRow = row;
2261
+ }
2262
+
2263
+ public void WriteStructBegin(DpRecord s)
2264
+ {
2265
+ if (_writeSheet == null) return;
2266
+
2267
+ if (_writeListStack.Count > 0)
2268
+ {
2269
+ var ls = _writeListStack.Peek();
2270
+ if (ls.StructDepth == 0)
2271
+ {
2272
+ if (ls.SeparatedSheet != null)
2273
+ {
2274
+ int r = ls.StartRow + ls.CurrentElement;
2275
+ ls.SeparatedSheet.SetCellValue(r, 1, ls.SeparatedMetaId ?? "");
2276
+ ls.SeparatedSheet.SetCellValue(r, 2, ls.SeparatedMetaName);
2277
+ _writeRow = r;
2278
+ TrackWriteRow(r);
2279
+ }
2280
+ else
2281
+ {
2282
+ _writeRow = ls.StartRow + ls.CurrentElement;
2283
+ TrackWriteRow(_writeRow);
2284
+ }
2285
+ ls.CurrentElement++;
2286
+ _writePathStack.Push(_writeCurrentPath);
2287
+ _writeCurrentPath = ls.ListPath;
2288
+ }
2289
+ ls.StructDepth++;
2290
+ }
2291
+ // Non-list struct path is already set by WriteFieldBegin (DpWireType.Struct case)
2292
+ }
2293
+
2294
+ public void WriteStructEnd()
2295
+ {
2296
+ if (_writeSheet == null) return;
2297
+
2298
+ if (_writeListStack.Count > 0)
2299
+ {
2300
+ var ls = _writeListStack.Peek();
2301
+ ls.StructDepth--;
2302
+ if (ls.StructDepth == 0)
2303
+ {
2304
+ // Pop the path that WriteStructBegin pushed for this list element
2305
+ if (_writePathStack.Count > 0)
2306
+ _writeCurrentPath = _writePathStack.Pop();
2307
+ }
2308
+ }
2309
+ else
2310
+ {
2311
+ // Normal struct: WriteFieldBegin pushed the path, pop it here
2312
+ if (_writePathStack.Count > 0)
2313
+ _writeCurrentPath = _writePathStack.Pop();
2314
+ }
2315
+ }
2316
+
2317
+ public void WriteFieldBegin(DpColumn f)
2318
+ {
2319
+ if (_writeSheet == null) return;
2320
+ _writeCurrentField = f;
2321
+
2322
+ if (f.Type == DpWireType.Struct)
2323
+ {
2324
+ string fieldPath = string.IsNullOrEmpty(_writeCurrentPath)
2325
+ ? f.ID.ToString()
2326
+ : _writeCurrentPath + "." + f.ID;
2327
+ _writePathStack.Push(_writeCurrentPath);
2328
+ _writeCurrentPath = fieldPath;
2329
+ }
2330
+ }
2331
+
2332
+ public void WriteFieldEnd()
2333
+ {
2334
+ if (_writeSheet == null) return;
2335
+ }
2336
+
2337
+ public void WriteFieldStop()
2338
+ {
2339
+ if (_writeSheet == null) return;
2340
+ }
2341
+
2342
+ private void WriteCellForCurrentField(string value)
2343
+ {
2344
+ if (_writeSheet == null) return;
2345
+
2346
+ // Primitive inside a primitive list
2347
+ if (_writeListStack.Count > 0)
2348
+ {
2349
+ var ls = _writeListStack.Peek();
2350
+ if (ls.IsPrimitiveList && ls.StructDepth == 0)
2351
+ {
2352
+ if (ls.SeparatedSheet != null)
2353
+ {
2354
+ int r = ls.StartRow + ls.CurrentElement;
2355
+ ls.SeparatedSheet.SetCellValue(r, 1, ls.SeparatedMetaId ?? "");
2356
+ ls.SeparatedSheet.SetCellValue(r, 2, ls.SeparatedMetaName);
2357
+ ls.SeparatedSheet.SetCellValue(r, 3, value);
2358
+ ls.CurrentElement++;
2359
+ return;
2360
+ }
2361
+ else
2362
+ {
2363
+ _writeRow = ls.StartRow + ls.CurrentElement;
2364
+ TrackWriteRow(_writeRow);
2365
+ ls.CurrentElement++;
2366
+
2367
+ if (ls.PrimitiveElemCol > 0)
2368
+ {
2369
+ _writeSheet.SetCellValue(_writeRow, ls.PrimitiveElemCol, value);
2370
+ return;
2371
+ }
2372
+ }
2373
+ }
2374
+ else if (ls.SeparatedSheet != null && ls.StructDepth > 0)
2375
+ {
2376
+ // Full path for the field in the separated sheet (headers use full path)
2377
+ string fullPath = string.IsNullOrEmpty(_writeCurrentPath)
2378
+ ? _writeCurrentField.ID.ToString()
2379
+ : _writeCurrentPath + "." + _writeCurrentField.ID;
2380
+
2381
+ // Reuse cached separated protocol, or create one
2382
+ if (_cachedSepProto == null || _cachedSepSheet != ls.SeparatedSheet)
2383
+ {
2384
+ _cachedSepSheet = ls.SeparatedSheet;
2385
+ // 쓰기 시 분리 시트는 항상 v2(3행 헤더)
2386
+ _cachedSepProto = new DpExcelProtocol(ls.SeparatedSheet, FIRST_DATA_ROW, null, "meta", ls.SeparatedSheet?.SheetName ?? "");
2387
+ }
2388
+ int sepCol = _cachedSepProto.WriteFieldPathToCol(fullPath);
2389
+ if (sepCol > 0)
2390
+ {
2391
+ ls.SeparatedSheet.SetCellValue(_writeRow, sepCol, value);
2392
+ return;
2393
+ }
2394
+ }
2395
+ }
2396
+
2397
+ string fieldPath = string.IsNullOrEmpty(_writeCurrentPath)
2398
+ ? _writeCurrentField.ID.ToString()
2399
+ : _writeCurrentPath + "." + _writeCurrentField.ID;
2400
+
2401
+ int col = WriteFieldPathToCol(fieldPath);
2402
+ if (col > 0)
2403
+ {
2404
+ _writeSheet.SetCellValue(_writeRow, col, value);
2405
+ TrackWriteRow(_writeRow);
2406
+ }
2407
+ }
2408
+
2409
+ public void WriteBool(bool b)
2410
+ {
2411
+ WriteCellForCurrentField(b ? "1" : "0");
2412
+ }
2413
+
2414
+ public void WriteByte(byte b)
2415
+ {
2416
+ WriteCellForCurrentField(b.ToString());
2417
+ }
2418
+
2419
+ public void WriteI16(short i16)
2420
+ {
2421
+ WriteCellForCurrentField(i16.ToString());
2422
+ }
2423
+
2424
+ public void WriteI32(int i32)
2425
+ {
2426
+ string fieldPath = string.IsNullOrEmpty(_writeCurrentPath)
2427
+ ? _writeCurrentField.ID.ToString()
2428
+ : _writeCurrentPath + "." + _writeCurrentField.ID;
2429
+ string value = ResolveEnumDisplayValue(fieldPath, i32);
2430
+ WriteCellForCurrentField(value ?? i32.ToString());
2431
+ }
2432
+
2433
+ /// <summary>
2434
+ /// Uses schema _colDataType to decide if field is enum; only then resolves "value:name" via delegates. For paths not in map (e.g. list element), falls back to delegates to detect enum.
2435
+ /// </summary>
2436
+ private string ResolveEnumDisplayValue(string fieldPath, int i32)
2437
+ {
2438
+ object fd = GetFieldDescriptor?.Invoke(fieldPath);
2439
+ bool isEnum = _fieldIdToCol.TryGetValue(fieldPath, out int col)
2440
+ && _colDataType.TryGetValue(col, out var dt)
2441
+ && DpTypeNames.IsEnumDataType(dt ?? "");
2442
+ if (!isEnum && fd != null && GetEnumValuesForField != null && GetEnumValuesForField(fd) != null)
2443
+ isEnum = true;
2444
+ if (!isEnum || fd == null || GetEnumValuesForField == null) return null;
2445
+ var list = GetEnumValuesForField(fd);
2446
+ if (list == null || list.Count == 0) return null;
2447
+ foreach (var line in list)
2448
+ {
2449
+ var parts = line.Split(new[] { ':' }, 2);
2450
+ if (parts.Length >= 1 && int.TryParse(parts[0].Trim(), out int v) && v == i32)
2451
+ return line;
2452
+ }
2453
+ return i32 == 0 ? "0:MIA" : $"{i32}:NIA";
2454
+ }
2455
+
2456
+ public void WriteI64(long i64)
2457
+ {
2458
+ WriteCellForCurrentField(i64.ToString());
2459
+ }
2460
+
2461
+ public void WriteDouble(double d)
2462
+ {
2463
+ WriteCellForCurrentField(d.ToString(System.Globalization.CultureInfo.InvariantCulture));
2464
+ }
2465
+
2466
+ public void WriteString(string s)
2467
+ {
2468
+ WriteCellForCurrentField(s ?? "");
2469
+ }
2470
+
2471
+ public void WriteBinary(byte[] b)
2472
+ {
2473
+ WriteCellForCurrentField(b != null ? Convert.ToBase64String(b) : "");
2474
+ }
2475
+
2476
+ public void WriteListBegin(DpList list)
2477
+ {
2478
+ if (_writeSheet == null) return;
2479
+
2480
+ string fieldPath = string.IsNullOrEmpty(_writeCurrentPath)
2481
+ ? _writeCurrentField.ID.ToString()
2482
+ : _writeCurrentPath + "." + _writeCurrentField.ID;
2483
+
2484
+ // ── Separated sheet mode (정책에서 kind·시트 이름 사용) ─────────────────────
2485
+ if (_resolver != null)
2486
+ {
2487
+ string fieldName = "";
2488
+ if (_fieldIdToCol.TryGetValue(fieldPath, out int rootCol2) && _colColumnName.ContainsKey(rootCol2))
2489
+ fieldName = _colColumnName[rootCol2];
2490
+
2491
+ string kind = ContainerSheetPolicy.GetKindForWireType(DpWireType.List);
2492
+ string sheetName = ContainerSheetNaming.FormatContainerSheetName(fieldPath, kind, fieldName);
2493
+
2494
+ string[] hdrIds = null, hdrTypes = null, hdrNames = null, hdrStructNames = null;
2495
+ bool[] hdrIsMarker = null;
2496
+ BuildContainerSheetHeaders(fieldPath, list.ElementType, out hdrIds, out hdrTypes, out hdrNames, out hdrStructNames, out hdrIsMarker);
2497
+
2498
+ var sepWriteSheet = _resolver.GetOrCreateSheet(
2499
+ sheetName,
2500
+ headerHierarchyIds: hdrIds,
2501
+ headerDataTypes: hdrTypes,
2502
+ headerColumnNames: hdrNames,
2503
+ headerStructNames: _useCompactHeader ? hdrStructNames : null);
2504
+
2505
+ if (sepWriteSheet != null)
2506
+ {
2507
+ // Next append row: use tracked last row per sheet so multiple records append correctly (UsedRange/LastRow can be stale after NumberFormat or COM).
2508
+ int appendRow = _containerSheetLastRow.TryGetValue(sheetName, out int lastRow) ? lastRow + 1 : FIRST_DATA_ROW;
2509
+
2510
+ // Get tuid/name from the write sheet (already written by preceding fields)
2511
+ string metaId = _writeSheet.CellValue(_writeRow, 1) ?? "";
2512
+ string metaName = _writeSheet.CellValue(_writeRow, 2) ?? "";
2513
+
2514
+ // Write list root column on main sheet: "[N >]" 표시 (마우스 클릭 시 리스트 탭으로 이동)
2515
+ int rootColMain = WriteFieldPathToCol(fieldPath);
2516
+ if (rootColMain > 0)
2517
+ _writeSheet.SetCellValue(_writeRow, rootColMain, "[" + list.Count + " >]");
2518
+
2519
+ bool isPrimitive = (list.ElementType != DpWireType.Struct
2520
+ && list.ElementType != DpWireType.List
2521
+ && list.ElementType != DpWireType.Map
2522
+ && list.ElementType != DpWireType.Set);
2523
+ int primitiveElemCol = isPrimitive ? 3 : -1; // col 3 = first element col (after tuid, name)
2524
+
2525
+ var state = new WriteListState
2526
+ {
2527
+ StartRow = appendRow,
2528
+ CurrentElement = 0,
2529
+ ListPath = fieldPath,
2530
+ StructDepth = 0,
2531
+ IsPrimitiveList = isPrimitive,
2532
+ PrimitiveElemCol = primitiveElemCol,
2533
+ SeparatedSheet = sepWriteSheet,
2534
+ SeparatedSheetName = sheetName,
2535
+ SeparatedMetaId = metaId,
2536
+ SeparatedMetaName = metaName,
2537
+ MainSheetRow = _writeRow,
2538
+ MainSheetMaxRow = _maxWriteRow
2539
+ };
2540
+ _maxWriteRow = appendRow - 1;
2541
+ _writeListStack.Push(state);
2542
+ _writePathStack.Push(_writeCurrentPath);
2543
+ _writeCurrentPath = fieldPath;
2544
+ return;
2545
+ }
2546
+ }
2547
+
2548
+ // ── Embedded (original) mode ──────────────────────────────────────
2549
+ bool isPrimitive2 = (list.ElementType != DpWireType.Struct
2550
+ && list.ElementType != DpWireType.List
2551
+ && list.ElementType != DpWireType.Map
2552
+ && list.ElementType != DpWireType.Set);
2553
+ int primitiveElemCol2 = -1;
2554
+ if (isPrimitive2)
2555
+ {
2556
+ string prefix = fieldPath + ".";
2557
+ foreach (var kvp in _fieldIdToCol)
2558
+ {
2559
+ if (kvp.Key.StartsWith(prefix))
2560
+ {
2561
+ primitiveElemCol2 = kvp.Value;
2562
+ break;
2563
+ }
2564
+ }
2565
+ }
2566
+
2567
+ // Write list root column: record count
2568
+ int rootCol = WriteFieldPathToCol(fieldPath);
2569
+ if (rootCol > 0 && list.Count > 0)
2570
+ _writeSheet.SetCellValue(_writeRow, rootCol, list.Count.ToString());
2571
+
2572
+ var embState = new WriteListState
2573
+ {
2574
+ StartRow = _writeRow,
2575
+ CurrentElement = 0,
2576
+ ListPath = fieldPath,
2577
+ StructDepth = 0,
2578
+ IsPrimitiveList = isPrimitive2,
2579
+ PrimitiveElemCol = primitiveElemCol2
2580
+ };
2581
+ _writeListStack.Push(embState);
2582
+
2583
+ _writePathStack.Push(_writeCurrentPath);
2584
+ _writeCurrentPath = fieldPath;
2585
+ }
2586
+
2587
+ public void WriteListEnd()
2588
+ {
2589
+ if (_writeSheet == null) return;
2590
+
2591
+ if (_writeListStack.Count > 0)
2592
+ {
2593
+ var state = _writeListStack.Pop();
2594
+ if (state.SeparatedSheet != null)
2595
+ {
2596
+ if (state.CurrentElement > 0)
2597
+ {
2598
+ int lastRow = state.StartRow + state.CurrentElement - 1;
2599
+ _containerSheetsWritten.Add((state.SeparatedSheetName, state.StartRow, lastRow, state.SeparatedSheet.LastColumn));
2600
+ _containerSheetLastRow[state.SeparatedSheetName] = lastRow;
2601
+ }
2602
+ _writeRow = state.MainSheetRow;
2603
+ _maxWriteRow = state.MainSheetMaxRow;
2604
+ }
2605
+ else
2606
+ {
2607
+ // Embedded: advance _writeRow past rows consumed by this list
2608
+ int lastListRow = state.StartRow + state.CurrentElement - 1;
2609
+ if (lastListRow > _maxWriteRow) _maxWriteRow = lastListRow;
2610
+ _writeRow = lastListRow > state.StartRow ? lastListRow : state.StartRow;
2611
+ }
2612
+ }
2613
+ if (_writePathStack.Count > 0)
2614
+ _writeCurrentPath = _writePathStack.Pop();
2615
+ }
2616
+
2617
+ public void WriteSetBegin(DpSet set)
2618
+ {
2619
+ WriteListBegin(new DpList { ElementType = set.ElementType, Count = set.Count });
2620
+ }
2621
+
2622
+ public void WriteSetEnd() { WriteListEnd(); }
2623
+
2624
+ public void WriteMapBegin(DpDict map)
2625
+ {
2626
+ if (_writeSheet == null) return;
2627
+ }
2628
+
2629
+ public void WriteMapEnd()
2630
+ {
2631
+ if (_writeSheet == null) return;
2632
+ }
2633
+
2634
+ /// <summary>
2635
+ /// Walk _writeSchema (and nested schemas via ResolveTypeName) to find the element TypeName
2636
+ /// for a list/set field at the given fieldPath (e.g. "30" → spawners → "List&lt;Spawner&gt;" → "Spawner").
2637
+ /// Returns null if schema is not available or field not found.
2638
+ /// </summary>
2639
+ private string ResolveElementTypeNameFromSchema(string fieldPath)
2640
+ {
2641
+ if (_writeSchema == null || _writeSchema.Fields == null || string.IsNullOrEmpty(fieldPath))
2642
+ return null;
2643
+
2644
+ string[] parts = fieldPath.Split('.');
2645
+ DpSchema current = _writeSchema;
2646
+
2647
+ for (int i = 0; i < parts.Length; i++)
2648
+ {
2649
+ if (current?.Fields == null) return null;
2650
+ if (!int.TryParse(parts[i], out int fid)) return null;
2651
+ if (!current.Fields.TryGetValue(fid, out var field)) return null;
2652
+
2653
+ if (i == parts.Length - 1)
2654
+ {
2655
+ // This is the target list/set field - strip outer generic to get element type
2656
+ return DpTypeNames.StripOuterGeneric(field.TypeName ?? "");
2657
+ }
2658
+
2659
+ // Intermediate field: must be struct or list<struct> - resolve to descend
2660
+ string innerType = field.Type == DpSchemaType.Struct
2661
+ ? field.TypeName
2662
+ : DpTypeNames.StripOuterGeneric(field.TypeName ?? "");
2663
+ if (string.IsNullOrEmpty(innerType) || ResolveTypeName == null) return null;
2664
+ current = ResolveTypeName(innerType);
2665
+ }
2666
+ return null;
2667
+ }
2668
+
2669
+ #region Excel header arrays (shared main / container)
2670
+
2671
+ /// <summary>Container 시트 상단 2열 고정 (meta_id=[&lt; meta_id] 표현, name). nav 제거.</summary>
2672
+ private static void GetContainerFixedHeaderArrays(
2673
+ out string[] hierIds, out string[] dataTypes, out string[] colNames, out string[] structNames, out bool[] isMarker)
2674
+ {
2675
+ hierIds = new[] { ContainerSheetNaming.META_ID_COLUMN, ContainerSheetNaming.META_NAME_COLUMN };
2676
+ dataTypes = new[] { DpTypeNames.ToProtocolName(DpWireType.I64), DpTypeNames.ToProtocolName(DpWireType.String) };
2677
+ colNames = new[] { "meta_id", "name" };
2678
+ structNames = new[] { "", "" };
2679
+ isMarker = new[] { false, false };
2680
+ }
2681
+
2682
+ /// <summary>리스트 시트 meta_id 셀 값 "[&lt; id]"에서 id 추출. 매칭/이동 시 사용.</summary>
2683
+ public static string ParseMetaIdFromListCell(string cellValue)
2684
+ {
2685
+ if (string.IsNullOrEmpty(cellValue)) return cellValue;
2686
+ var s = cellValue.Trim();
2687
+ if (s.StartsWith("[< ", StringComparison.Ordinal) && s.EndsWith("]", StringComparison.Ordinal) && s.Length > 4)
2688
+ return s.Substring(3, s.Length - 4).Trim();
2689
+ return s;
2690
+ }
2691
+
2692
+ /// <summary>FlatHeaderField 리스트 → 헤더 배열로 변환. pathPrefix 있으면 HierarchyId 앞에 붙임 (리스트 요소 열용).</summary>
2693
+ private static void FlatFieldsToHeaderArrays(IReadOnlyList<FlatHeaderField> fields, string pathPrefix,
2694
+ out string[] hierIds, out string[] dataTypes, out string[] colNames, out string[] structNames, out bool[] isMarker)
2695
+ {
2696
+ int n = fields?.Count ?? 0;
2697
+ hierIds = new string[n];
2698
+ dataTypes = new string[n];
2699
+ colNames = new string[n];
2700
+ structNames = new string[n];
2701
+ isMarker = new bool[n];
2702
+ string prefix = pathPrefix ?? "";
2703
+ for (int i = 0; i < n; i++)
2704
+ {
2705
+ var f = fields[i];
2706
+ hierIds[i] = string.IsNullOrEmpty(prefix) ? f.HierarchyId : (prefix + f.HierarchyId);
2707
+ dataTypes[i] = f.DataType ?? "";
2708
+ colNames[i] = (string.IsNullOrEmpty(f.ColumnName) && f.IsStructMarker ? (f.ParentStructName ?? f.HierarchyId) : f.ColumnName) ?? "";
2709
+ structNames[i] = f.ParentStructName ?? "";
2710
+ isMarker[i] = f.IsStructMarker;
2711
+ }
2712
+ }
2713
+
2714
+ /// <summary>헤더 배열 5개 → List&lt;FlatHeaderField&gt;. 스타일 적용용. null 배열은 길이 0으로 취급.</summary>
2715
+ private static List<FlatHeaderField> HeaderArraysToFlatFields(
2716
+ string[] hierIds, string[] dataTypes, string[] colNames, string[] structNames, bool[] isMarker)
2717
+ {
2718
+ int n = hierIds?.Length ?? 0;
2719
+ var list = new List<FlatHeaderField>(n);
2720
+ for (int i = 0; i < n; i++)
2721
+ {
2722
+ list.Add(new FlatHeaderField
2723
+ {
2724
+ HierarchyId = i < hierIds.Length ? hierIds[i] : "",
2725
+ DataType = dataTypes != null && i < dataTypes.Length ? dataTypes[i] : "",
2726
+ ColumnName = colNames != null && i < colNames.Length ? colNames[i] : "",
2727
+ ParentStructName = structNames != null && i < structNames.Length ? structNames[i] : "",
2728
+ IsStructMarker = isMarker != null && i < isMarker.Length && isMarker[i]
2729
+ });
2730
+ }
2731
+ return list;
2732
+ }
2733
+
2734
+ #endregion
2735
+
2736
+ /// <summary>
2737
+ /// Build header arrays (meta_id, name, element columns) for a container sheet.
2738
+ /// Same rule as main sheet: include all FlattenSchema output (do not filter struct markers)
2739
+ /// so schema/read mapping stays valid. Uses ResolveTypeName to look up element schema.
2740
+ /// Returns null arrays if element schema is not resolvable (primitive list gets fixed headers).
2741
+ /// </summary>
2742
+ private void BuildContainerSheetHeaders(string fieldPath, DpWireType elementType,
2743
+ out string[] hierIds, out string[] dataTypes, out string[] colNames, out string[] structNames, out bool[] isMarker)
2744
+ {
2745
+ hierIds = null; dataTypes = null; colNames = null; structNames = null; isMarker = null;
2746
+
2747
+ bool isPrimitive = (elementType != DpWireType.Struct && elementType != DpWireType.List
2748
+ && elementType != DpWireType.Map && elementType != DpWireType.Set);
2749
+ if (isPrimitive)
2750
+ {
2751
+ GetContainerFixedHeaderArrays(out string[] fixHier, out string[] fixTypes, out string[] fixCols, out string[] fixStruct, out bool[] fixMark);
2752
+ hierIds = new string[3];
2753
+ dataTypes = new string[3];
2754
+ colNames = new string[3];
2755
+ structNames = new string[3];
2756
+ isMarker = new bool[3];
2757
+ Array.Copy(fixHier, hierIds, 2);
2758
+ Array.Copy(fixTypes, dataTypes, 2);
2759
+ Array.Copy(fixCols, colNames, 2);
2760
+ Array.Copy(fixStruct, structNames, 2);
2761
+ Array.Copy(fixMark, isMarker, 2);
2762
+ hierIds[2] = "value";
2763
+ dataTypes[2] = DpTypeNames.ToProtocolName(elementType);
2764
+ colNames[2] = "value";
2765
+ structNames[2] = "";
2766
+ isMarker[2] = false;
2767
+ return;
2768
+ }
2769
+
2770
+ if (elementType == DpWireType.Struct && ResolveTypeName != null)
2771
+ {
2772
+ string typeName = ResolveElementTypeNameFromSchema(fieldPath);
2773
+ if (string.IsNullOrEmpty(typeName))
2774
+ {
2775
+ if (_fieldIdToCol.TryGetValue(fieldPath, out int rootCol) && _colDataType.ContainsKey(rootCol))
2776
+ {
2777
+ string dt = _colDataType[rootCol];
2778
+ typeName = DpTypeNames.StripOuterGeneric(dt);
2779
+ }
2780
+ }
2781
+ if (string.IsNullOrEmpty(typeName)) return;
2782
+
2783
+ var elemSchema = ResolveTypeName(typeName);
2784
+ if (elemSchema == null) return;
2785
+
2786
+ // Same as main sheet: full FlattenSchema (no filtering of struct markers).
2787
+ var elemFields = FlattenSchema(elemSchema, ResolveTypeName, "");
2788
+ GetContainerFixedHeaderArrays(out string[] fixHier, out string[] fixTypes, out string[] fixCols, out string[] fixStruct, out bool[] fixMark);
2789
+ FlatFieldsToHeaderArrays(elemFields, fieldPath + ".", out string[] elemHier, out string[] elemTypes, out string[] elemCols, out string[] elemStruct, out bool[] elemMark);
2790
+
2791
+ int total = fixHier.Length + elemHier.Length;
2792
+ hierIds = new string[total];
2793
+ dataTypes = new string[total];
2794
+ colNames = new string[total];
2795
+ structNames = new string[total];
2796
+ isMarker = new bool[total];
2797
+ Array.Copy(fixHier, hierIds, fixHier.Length);
2798
+ Array.Copy(fixTypes, dataTypes, fixTypes.Length);
2799
+ Array.Copy(fixCols, colNames, fixCols.Length);
2800
+ Array.Copy(fixStruct, structNames, fixStruct.Length);
2801
+ Array.Copy(fixMark, isMarker, fixMark.Length);
2802
+ Array.Copy(elemHier, 0, hierIds, fixHier.Length, elemHier.Length);
2803
+ Array.Copy(elemTypes, 0, dataTypes, fixTypes.Length, elemTypes.Length);
2804
+ Array.Copy(elemCols, 0, colNames, fixCols.Length, elemCols.Length);
2805
+ Array.Copy(elemStruct, 0, structNames, fixStruct.Length, elemStruct.Length);
2806
+ Array.Copy(elemMark, 0, isMarker, fixMark.Length, elemMark.Length);
2807
+ }
2808
+ }
2809
+
2810
+ #endregion
2811
+
2812
+ #region Schema → Flat Header
2813
+
2814
+ /// <summary>
2815
+ /// 각 FlatHeaderField의 ColumnName을 getDisplayNameForHierarchyId(HierarchyId) 결과로 덮어씀.
2816
+ /// Excel에서 읽은 헤더는 리스트 내부 등에서 부모명이 들어올 수 있으므로, 스키마 필드명으로 보정할 때 사용.
2817
+ /// </summary>
2818
+ public static void ApplyDisplayNames(List<FlatHeaderField> fields, Func<string, string> getDisplayNameForHierarchyId)
2819
+ {
2820
+ if (fields == null || getDisplayNameForHierarchyId == null) return;
2821
+ foreach (var f in fields)
2822
+ {
2823
+ if (string.IsNullOrEmpty(f?.HierarchyId)) continue;
2824
+ string name = getDisplayNameForHierarchyId(f.HierarchyId);
2825
+ if (!string.IsNullOrEmpty(name))
2826
+ f.ColumnName = name;
2827
+ }
2828
+ }
2829
+
2830
+ /// <summary>
2831
+ /// Reorder flat fields so tuid, tid, name (by ColumnName) come first, then the rest.
2832
+ /// Used for meta_data main sheet column fix.
2833
+ /// </summary>
2834
+ public static List<FlatHeaderField> ReorderFlatWithMetaIdTidMetaNameFirst(List<FlatHeaderField> flat)
2835
+ {
2836
+ return ReorderFlatWithMetaIdTidMetaNameFirst(flat, null, null);
2837
+ }
2838
+
2839
+ /// <summary>
2840
+ /// Reorder flat fields by key→name→note. category와 getKeyFieldNames가 있으면 해당 카테고리 고정 열 순서 사용(keyed면 meta_id 제외).
2841
+ /// </summary>
2842
+ public static List<FlatHeaderField> ReorderFlatWithMetaIdTidMetaNameFirst(List<FlatHeaderField> flat, string category, Func<string, IReadOnlyList<string>> getKeyFieldNames)
2843
+ {
2844
+ if (flat == null || flat.Count == 0) return flat;
2845
+ var fixedNames = (category != null && getKeyFieldNames != null)
2846
+ ? GetMainSheetFixedColumnNamesForCategory(category, getKeyFieldNames)
2847
+ : MainSheetFixedColumnNames;
2848
+ var reordered = new List<FlatHeaderField>(flat.Count);
2849
+ foreach (string name in fixedNames)
2850
+ {
2851
+ for (int i = 0; i < flat.Count; i++)
2852
+ {
2853
+ string cn = flat[i].ColumnName ?? "";
2854
+ if (string.Equals(cn, name, StringComparison.OrdinalIgnoreCase) ||
2855
+ (name == "tuid" && string.Equals(cn, "meta_id", StringComparison.OrdinalIgnoreCase)) ||
2856
+ (name == "name" && string.Equals(cn, "meta_name", StringComparison.OrdinalIgnoreCase)) ||
2857
+ (name == "note" && string.Equals(cn, "meta_note", StringComparison.OrdinalIgnoreCase)))
2858
+ {
2859
+ reordered.Add(flat[i]);
2860
+ break;
2861
+ }
2862
+ }
2863
+ }
2864
+ for (int i = 0; i < flat.Count; i++)
2865
+ {
2866
+ string cn = flat[i].ColumnName ?? "";
2867
+ bool isFixed = false;
2868
+ foreach (string name in fixedNames)
2869
+ {
2870
+ if (string.Equals(cn, name, StringComparison.OrdinalIgnoreCase) ||
2871
+ (name == "tuid" && string.Equals(cn, "meta_id", StringComparison.OrdinalIgnoreCase)) ||
2872
+ (name == "name" && string.Equals(cn, "meta_name", StringComparison.OrdinalIgnoreCase)) ||
2873
+ (name == "note" && string.Equals(cn, "meta_note", StringComparison.OrdinalIgnoreCase)))
2874
+ { isFixed = true; break; }
2875
+ }
2876
+ if (!isFixed) reordered.Add(flat[i]);
2877
+ }
2878
+ return reordered;
2879
+ }
2880
+
2881
+ /// <summary>Excel 스키마 정보용 DataType. typedef이고 TypeName에 _link 포함인 경우만 typedef 이름 사용.</summary>
2882
+ private static string GetExcelDataTypeForField(DpFieldSchema f)
2883
+ {
2884
+ if (f == null) return "string";
2885
+ string tn = (f.TypeName ?? "").Trim();
2886
+ if (tn.IndexOf("_link", StringComparison.OrdinalIgnoreCase) >= 0)
2887
+ return DpTypeNames.StripNamespaceFromTypeName(tn);
2888
+ return DpTypeNames.SchemaFieldToDataType(f);
2889
+ }
2890
+
2891
+ /// <summary>
2892
+ /// DpSchema를 Excel 헤더용 flat 리스트로 변환.
2893
+ /// Struct/List&lt;Struct&gt; 필드는 재귀적으로 하위 필드를 펼침.
2894
+ /// resolveTypeName: TypeName → 하위 DpSchema 조회 콜백 (DefineLoader.GetSchemaByTypeName 등).
2895
+ /// mainSheetOnly: true이면 list/set/map 필드는 루트 엔트리 1개만 반환 (분리 시트 모드 메인용).
2896
+ /// </summary>
2897
+ public static List<FlatHeaderField> FlattenSchema(
2898
+ DpSchema schema,
2899
+ Func<string, DpSchema> resolveTypeName,
2900
+ string parentPath = "",
2901
+ bool mainSheetOnly = false,
2902
+ string parentStructName = null)
2903
+ {
2904
+ var result = new List<FlatHeaderField>();
2905
+ if (schema?.Fields == null) return result;
2906
+
2907
+ var ordered = new List<KeyValuePair<int, DpFieldSchema>>(schema.Fields);
2908
+ ordered.Sort((a, b) => a.Value.Order.CompareTo(b.Value.Order));
2909
+
2910
+ foreach (var kv in ordered)
2911
+ {
2912
+ int fieldId = kv.Key;
2913
+ var f = kv.Value;
2914
+ string path = string.IsNullOrEmpty(parentPath)
2915
+ ? fieldId.ToString()
2916
+ : parentPath + "." + fieldId;
2917
+
2918
+ bool isContainer = (f.Type == DpSchemaType.List || f.Type == DpSchemaType.Set || f.Type == DpSchemaType.Map);
2919
+
2920
+ if (f.Type == DpSchemaType.Struct && resolveTypeName != null)
2921
+ {
2922
+ var nested = resolveTypeName(f.TypeName);
2923
+ if (nested?.Fields != null)
2924
+ {
2925
+ result.Add(new FlatHeaderField
2926
+ {
2927
+ HierarchyId = path,
2928
+ DataType = f.TypeName ?? "record",
2929
+ ColumnName = f.Name ?? "",
2930
+ DocComment = f.DocComment ?? "",
2931
+ IsStructMarker = true,
2932
+ ParentStructName = parentStructName ?? ""
2933
+ });
2934
+ string sn = !string.IsNullOrEmpty(f.TypeName) ? DpTypeNames.StripNamespaceFromTypeName(f.TypeName) : "";
2935
+ string childStructName = !string.IsNullOrEmpty(sn) ? sn : (f.Name ?? "");
2936
+ result.AddRange(FlattenSchema(nested, resolveTypeName, path, mainSheetOnly, childStructName));
2937
+ }
2938
+ else
2939
+ result.Add(new FlatHeaderField
2940
+ {
2941
+ HierarchyId = path,
2942
+ DataType = "record",
2943
+ ColumnName = f.Name ?? "",
2944
+ DocComment = f.DocComment ?? "",
2945
+ ParentStructName = parentStructName ?? ""
2946
+ });
2947
+ }
2948
+ else if (isContainer && mainSheetOnly)
2949
+ {
2950
+ result.Add(new FlatHeaderField
2951
+ {
2952
+ HierarchyId = path,
2953
+ DataType = GetExcelDataTypeForField(f),
2954
+ ColumnName = f.Name ?? "",
2955
+ DocComment = f.DocComment ?? "",
2956
+ ParentStructName = parentStructName ?? ""
2957
+ });
2958
+ }
2959
+ else if ((f.Type == DpSchemaType.List || f.Type == DpSchemaType.Set) && resolveTypeName != null)
2960
+ {
2961
+ string innerTypeName = DpTypeNames.StripOuterGeneric(f.TypeName ?? "");
2962
+ var elemSchema = resolveTypeName(innerTypeName);
2963
+ if (elemSchema?.Fields != null)
2964
+ {
2965
+ result.Add(new FlatHeaderField
2966
+ {
2967
+ HierarchyId = path,
2968
+ DataType = DpTypeNames.ToProtocolName(
2969
+ DpTypeNames.FromSchemaTypeName(f.Type.ToString())),
2970
+ ColumnName = f.Name ?? "",
2971
+ DocComment = f.DocComment ?? "",
2972
+ ParentStructName = parentStructName ?? ""
2973
+ });
2974
+ result.AddRange(FlattenSchema(elemSchema, resolveTypeName, path, mainSheetOnly, parentStructName));
2975
+ }
2976
+ else
2977
+ {
2978
+ result.Add(new FlatHeaderField
2979
+ {
2980
+ HierarchyId = path,
2981
+ DataType = GetExcelDataTypeForField(f),
2982
+ ColumnName = f.Name ?? "",
2983
+ DocComment = f.DocComment ?? "",
2984
+ ParentStructName = parentStructName ?? ""
2985
+ });
2986
+ }
2987
+ }
2988
+ else
2989
+ {
2990
+ result.Add(new FlatHeaderField
2991
+ {
2992
+ HierarchyId = path,
2993
+ DataType = GetExcelDataTypeForField(f),
2994
+ ColumnName = f.Name ?? "",
2995
+ DocComment = f.DocComment ?? "",
2996
+ ParentStructName = parentStructName ?? ""
2997
+ });
2998
+ }
2999
+ }
3000
+ return result;
3001
+ }
3002
+
3003
+ #endregion
3004
+ }
3005
+ }