초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)

레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너.
기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49.

이 커밋에 함께 포함된 자격증명 분리:
- appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환
- 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외,
  csproj Debug 조건부 복사라 Release 산출물에 실리지 않음)
- ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경,
  미치환 플레이스홀더는 '미설정'으로 간주

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-11 17:20:22 +09:00
co-authored by Claude Fable 5
commit 16c07f48dc
102 changed files with 14210 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 배치된 컨트롤 1개 — 레거시 XML의 &lt;Object&gt; 노드 1개에 대응.
/// 모든 Property 는 <see cref="Props"/>에 원본 순서로 무손실 보존되고,
/// 편집 편의를 위해 Location/Size 만 <see cref="Bounds"/>로 리프트한다
/// (편집 중 Props 의 Location/Size 는 stale — 쓰기 시 Bounds 값으로 동기화된다).
/// </summary>
public sealed class ControlElement
{
#region Properties
/// <summary>중립 타입명 — ControlRegistry/LegacyTypeCatalog 의 키. 미지원 타입은 "Placeholder"</summary>
public string Type { get; set; } = string.Empty;
/// <summary>컨트롤 이름(name 어트리뷰트) — 레거시 SctObjNam 등가, 문서 내 유일해야 함</summary>
public string Id { get; set; } = string.Empty;
/// <summary>원본 AssemblyQualifiedName — 읽기 시 원문 보존, 신규 생성 시 카탈로그에서 채움</summary>
public string? LegacyAqn { get; set; }
/// <summary>원본에 name 어트리뷰트가 있었는지 (왕복 보존)</summary>
public bool HasNameAttr { get; set; } = true;
/// <summary>원본에 children 어트리뷰트가 있었는지 (Control 이면 "Controls")</summary>
public bool HasChildrenAttr { get; set; } = true;
/// <summary>Object 어트리뷰트 DisplaySequence 값 (레거시 writer 는 항상 0 기록)</summary>
public int DisplaySequence { get; set; }
/// <summary>부모 기준 배치(px) — Props 의 Location/Size 리프트</summary>
public LayoutRect Bounds { get; set; } = new();
/// <summary>원본 Props 에 Location 이 있었는지 — 쓰기 시 생략/기록 판단</summary>
public bool HasLocation { get; set; }
/// <summary>원본 Props 에 Size 가 있었는지 — 쓰기 시 생략/기록 판단</summary>
public bool HasSize { get; set; }
/// <summary>전체 속성 가방(원본 순서 보존) — Location/Size 포함(쓰기 시 Bounds 로 동기화)</summary>
public PropBag Props { get; set; } = new();
/// <summary>자식 컨트롤 — 레거시 Controls 컬렉션 순서 그대로(index 0 = 최상위 z-order)</summary>
public List<ControlElement> Children { get; set; } = new();
/// <summary>디자이너 편집 잠금(직렬화 안 함 — 편집 전용 상태)</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool Locked { get; set; }
/// <summary>디자이너 임시 숨김(직렬화 안 함 — 편집 전용 상태, 레거시 Visible 과 별개)</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool Hidden { get; set; }
/// <summary>디자이너 그룹 식별자(직렬화 안 함 — 편집 전용 상태)</summary>
[System.Text.Json.Serialization.JsonIgnore]
public string? GroupId { get; set; }
#endregion
#region Methods
/// <summary>텍스트 속성(Text) 편의 접근 — 없으면 빈 문자열</summary>
public string GetText() => Props.GetText("Text") ?? string.Empty;
/// <summary>깊은 복제(자식·속성 포함)</summary>
public ControlElement Clone()
{
var clone = new ControlElement
{
Type = Type,
Id = Id,
LegacyAqn = LegacyAqn,
HasNameAttr = HasNameAttr,
HasChildrenAttr = HasChildrenAttr,
DisplaySequence = DisplaySequence,
Bounds = Bounds.Clone(),
HasLocation = HasLocation,
HasSize = HasSize,
Props = Props.Clone(),
Locked = Locked,
Hidden = Hidden,
GroupId = GroupId,
};
foreach (var child in Children)
{
clone.Children.Add(child.Clone());
}
return clone;
}
#endregion
}
+40
View File
@@ -0,0 +1,40 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 서식 문서 — 직렬화(레거시 XML/JSON)의 단위. 페이지 목록과 메타데이터를 갖는다.
/// FormId/Title 은 XML 본문이 아닌 DB(E_ShtMst)에서 오는 메타데이터다.
/// </summary>
public sealed class FormDocument
{
#region Properties
/// <summary>서식 코드 — E_ShtMst.ShtCod (파일 기반일 때는 파일명 유래)</summary>
public string FormId { get; set; } = string.Empty;
/// <summary>서식 명칭 — E_ShtMst.ShtKorNam</summary>
public string Title { get; set; } = string.Empty;
/// <summary>페이지 목록(순서 = 페이지 순서)</summary>
public List<FormPage> Pages { get; set; } = new();
/// <summary>부가 메타(출처/감사/읽기 경고)</summary>
public FormMeta Meta { get; set; } = new();
#endregion
#region Methods
/// <summary>깊은 복제 — Undo 스냅샷용(편집 전용 상태 포함)</summary>
public FormDocument Clone()
{
var clone = new FormDocument
{
FormId = FormId,
Title = Title,
Meta = Meta.Clone(),
};
foreach (var page in Pages)
{
clone.Pages.Add(page.Clone());
}
return clone;
}
#endregion
}
+32
View File
@@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace SheetMe.Core.Models;
/// <summary>서식 문서 부가 메타 — 출처·감사 정보와 읽기 시 수집된 경고.</summary>
public sealed class FormMeta
{
#region Properties
/// <summary>레거시 가져오기 출처 디자인 키 — E_SdgMst.SdgKey</summary>
public decimal? SourceSdgKey { get; set; }
/// <summary>최종 수정자</summary>
public string? UpdatedBy { get; set; }
/// <summary>최종 수정일시(yyyyMMddHHmmss)</summary>
public string? UpdatedAt { get; set; }
/// <summary>읽기(파싱) 중 수집된 경고 — 직렬화하지 않음</summary>
[JsonIgnore]
public List<string> ReadWarnings { get; } = new();
#endregion
#region Methods
/// <summary>복제(경고 제외)</summary>
public FormMeta Clone() => new()
{
SourceSdgKey = SourceSdgKey,
UpdatedBy = UpdatedBy,
UpdatedAt = UpdatedAt,
};
#endregion
}
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
namespace SheetMe.Core.Models;
/// <summary>
/// 서식 페이지 1장 — 레거시의 MDesignerHost 루트 &lt;Object&gt; 1개에 대응.
/// 루트 노드 자체를 <see cref="Root"/>(ControlElement)로 보존해 루트 속성까지 무손실 왕복한다.
/// </summary>
public sealed class FormPage
{
#region Properties
/// <summary>페이지 루트(MDesignerHost) — 자식이 페이지의 컨트롤들</summary>
public ControlElement Root { get; set; } = new();
/// <summary>페이지의 컨트롤 목록(루트의 자식) — index 0 = 최상위 z-order(레거시 규약)</summary>
[JsonIgnore]
public List<ControlElement> Controls => Root.Children;
/// <summary>페이지 너비(px) — 루트 Size 리프트, 레거시 기본 720</summary>
[JsonIgnore]
public double Width
{
get => Root.Bounds.W > 0 ? Root.Bounds.W : 720;
set => Root.Bounds.W = value;
}
/// <summary>페이지 높이(px) — 루트 Size 리프트, 레거시 기본 856</summary>
[JsonIgnore]
public double Height
{
get => Root.Bounds.H > 0 ? Root.Bounds.H : 856;
set => Root.Bounds.H = value;
}
#endregion
#region Methods
/// <summary>깊은 복제</summary>
public FormPage Clone() => new() { Root = Root.Clone() };
#endregion
}
+26
View File
@@ -0,0 +1,26 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 배치 사각형 — 부모(페이지 루트 또는 컨테이너) 기준 상대 좌표(px, WinForms px = WPF DIP 1:1).
/// </summary>
public sealed class LayoutRect
{
#region Properties
/// <summary>부모 기준 X(px)</summary>
public double X { get; set; }
/// <summary>부모 기준 Y(px)</summary>
public double Y { get; set; }
/// <summary>너비(px)</summary>
public double W { get; set; }
/// <summary>높이(px)</summary>
public double H { get; set; }
#endregion
#region Methods
/// <summary>복제</summary>
public LayoutRect Clone() => new() { X = X, Y = Y, W = W, H = H };
#endregion
}
@@ -0,0 +1,84 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 레거시 서식 XML의 Property 값 변형(variant) 기본형.
/// 레거시 직렬화기(clsBasicDesignerLoader.WriteValue)가 만들어내는 5가지 형태 + 빈 값(Null)을
/// 원형 그대로 보존해 무손실 왕복을 보장한다.
/// </summary>
public abstract class LegacyPropValue
{
/// <summary>텍스트 값 — &lt;Property&gt;text&lt;/Property&gt; (TypeConverter invariant 문자열)</summary>
public sealed class TextValue : LegacyPropValue
{
/// <summary>속성 문자열 값</summary>
public string Value { get; set; } = string.Empty;
public TextValue() { }
public TextValue(string value) => Value = value;
public override LegacyPropValue Clone() => new TextValue(Value);
}
/// <summary>빈 값 — &lt;Property /&gt; 또는 내용 없는 요소 (로더에서 null 로 설정됨)</summary>
public sealed class NullValue : LegacyPropValue
{
public override LegacyPropValue Clone() => new NullValue();
}
/// <summary>중첩 속성 — Content 직렬화 속성의 자식 &lt;Property&gt; 목록 (예: DataBindings)</summary>
public sealed class NestedValue : LegacyPropValue
{
/// <summary>자식 Property 가방</summary>
public PropBag Children { get; set; } = new();
public override LegacyPropValue Clone() => new NestedValue { Children = Children.Clone() };
}
/// <summary>컬렉션 항목 — Content IList 속성의 &lt;Item type="AQN"&gt; 목록 (예: ComboBox Items)</summary>
public sealed class ItemsValue : LegacyPropValue
{
/// <summary>항목 목록</summary>
public List<LegacyItem> Items { get; set; } = new();
public override LegacyPropValue Clone()
{
var clone = new ItemsValue();
foreach (var item in Items)
{
clone.Items.Add(new LegacyItem { Aqn = item.Aqn, Value = item.Value.Clone() });
}
return clone;
}
}
/// <summary>바이너리 값 — &lt;Binary&gt;base64&lt;/Binary&gt; (BinaryFormatter/Byte[] 직렬화 잔재, verbatim 보존)</summary>
public sealed class BinaryValue : LegacyPropValue
{
/// <summary>base64 원문</summary>
public string Base64 { get; set; } = string.Empty;
public override LegacyPropValue Clone() => new BinaryValue { Base64 = Base64 };
}
/// <summary>컴포넌트 참조 — &lt;Reference name="..." /&gt; (같은 호스트 내 사이트 참조)</summary>
public sealed class ReferenceValue : LegacyPropValue
{
/// <summary>참조 대상 컴포넌트 이름</summary>
public string Name { get; set; } = string.Empty;
public override LegacyPropValue Clone() => new ReferenceValue { Name = Name };
}
/// <summary>깊은 복제</summary>
public abstract LegacyPropValue Clone();
}
/// <summary>Content IList 속성의 항목 1개 — &lt;Item type="AQN"&gt;값&lt;/Item&gt;</summary>
public sealed class LegacyItem
{
/// <summary>항목 타입의 AssemblyQualifiedName 원문</summary>
public string Aqn { get; set; } = string.Empty;
/// <summary>항목 값(대개 TextValue)</summary>
public LegacyPropValue Value { get; set; } = new LegacyPropValue.NullValue();
}
+102
View File
@@ -0,0 +1,102 @@
using System.Collections;
namespace SheetMe.Core.Models;
/// <summary>
/// 순서 보존 속성 가방 — 레거시 XML의 &lt;Property&gt; 목록을 원본 순서 그대로 담는다.
/// 값 갱신 시 원래 위치를 유지하고, 새 키는 뒤에 추가되어 왕복(diff) 안정성을 보장한다.
/// </summary>
public sealed class PropBag : IEnumerable<KeyValuePair<string, LegacyPropValue>>
{
#region Member Fields
private readonly List<string> keys = new();
private readonly Dictionary<string, LegacyPropValue> map = new(StringComparer.Ordinal);
#endregion
#region Properties
/// <summary>속성 개수</summary>
public int Count => keys.Count;
/// <summary>속성 이름 목록(원본 순서)</summary>
public IReadOnlyList<string> Keys => keys;
#endregion
#region Methods
/// <summary>속성 값 조회 — 없으면 null</summary>
public LegacyPropValue? Get(string name)
=> map.TryGetValue(name, out var value) ? value : null;
/// <summary>텍스트 속성 값 조회 — TextValue 가 아니면 null</summary>
public string? GetText(string name)
=> Get(name) is LegacyPropValue.TextValue text ? text.Value : null;
/// <summary>속성 존재 여부</summary>
public bool Contains(string name) => map.ContainsKey(name);
/// <summary>속성 설정 — 기존 키는 위치 유지, 새 키는 뒤에 추가</summary>
public void Set(string name, LegacyPropValue value)
{
if (!map.ContainsKey(name))
{
keys.Add(name);
}
map[name] = value;
}
/// <summary>텍스트 속성 설정</summary>
public void SetText(string name, string value)
=> Set(name, new LegacyPropValue.TextValue(value));
/// <summary>속성 제거</summary>
public bool Remove(string name)
{
if (!map.Remove(name))
{
return false;
}
keys.Remove(name);
return true;
}
/// <summary>지정 키 바로 앞에 새 키 삽입 — 기준 키가 없으면 뒤에 추가</summary>
public void InsertBefore(string anchorName, string name, LegacyPropValue value)
{
if (map.ContainsKey(name))
{
map[name] = value;
return;
}
var index = keys.IndexOf(anchorName);
if (index < 0)
{
keys.Add(name);
}
else
{
keys.Insert(index, name);
}
map[name] = value;
}
/// <summary>깊은 복제</summary>
public PropBag Clone()
{
var clone = new PropBag();
foreach (var key in keys)
{
clone.Set(key, map[key].Clone());
}
return clone;
}
public IEnumerator<KeyValuePair<string, LegacyPropValue>> GetEnumerator()
{
foreach (var key in keys)
{
yield return new KeyValuePair<string, LegacyPropValue>(key, map[key]);
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
#endregion
}