normalized-schema-generator 0.1.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: normalized-schema-generator
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: An anywidget for Microsoft Fabric notebooks that visually configures normalized schema JSON for Process Mining data import.
5
5
  Project-URL: Homepage, https://github.com/rosikm/normalized-schema-generator
6
6
  Project-URL: Repository, https://github.com/rosikm/normalized-schema-generator
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "normalized-schema-generator"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "An anywidget for Microsoft Fabric notebooks that visually configures normalized schema JSON for Process Mining data import."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -3,4 +3,4 @@
3
3
  from .widget import NormalizedSchemaWidget
4
4
 
5
5
  __all__ = ["NormalizedSchemaWidget"]
6
- __version__ = "0.1.0"
6
+ __version__ = "0.3.0"
@@ -5,8 +5,9 @@
5
5
 
6
6
  /* Header */
7
7
  .nsw-header { margin-bottom: 16px; }
8
- .nsw-header h2 { font-size: 20px; font-weight: 600; margin: 0 0 2px; color: var(--f-text); }
9
- .nsw-header p { font-size: 12px; color: var(--f-text2); margin: 0; }
8
+ .nsw-header h2 { font-size: 20px; font-weight: 600; margin: 0; color: var(--f-text); }
9
+ .nsw-header p { font-size: 12px; color: var(--f-text2); margin: 2px 0 0; }
10
+ .nsw-version { font-size: 11px; color: var(--f-text3); font-weight: 400; }
10
11
 
11
12
  /* Cards */
12
13
  .nsw-card { border: 1px solid var(--f-border); border-radius: var(--f-radius); padding: 16px; margin-bottom: 12px; background: var(--f-bg); box-shadow: var(--f-shadow4); }
@@ -96,7 +97,7 @@
96
97
  /* JSON output */
97
98
  .nsw-json-wrap { position: relative; }
98
99
  .nsw-json { font-family: var(--f-mono); font-size: 11.5px; background: var(--f-surface); border: 1px solid var(--f-border); border-radius: var(--f-radius-sm); padding: 12px; max-height: 400px; overflow: auto; white-space: pre; line-height: 1.45; color: var(--f-text); }
99
- .nsw-copy-btn { position: absolute; top: 8px; right: 8px; }
100
+ .nsw-json-actions { position: absolute; top: 8px; right: 16px; display: flex; gap: 4px; align-items: center; }
100
101
  .nsw-copied { font-size: 11px; color: var(--f-success); font-weight: 600; padding: 3px 10px; }
101
102
 
102
103
  /* Collapse toggle */
@@ -120,6 +121,42 @@
120
121
  /* Cases placeholder */
121
122
  .nsw-cases-placeholder { display: flex; align-items: center; justify-content: center; padding: 16px; border: 1.5px dashed #E8AB02; border-radius: var(--f-radius); margin-bottom: 12px; }
122
123
 
124
+ /* Attribute mapping */
125
+ .nsw-card.attrs { border-left: 3px solid #8764B8; }
126
+ .nsw-attr-list { }
127
+ .nsw-attr-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f2f2f2; min-height: 34px; }
128
+ .nsw-attr-row:last-child { border-bottom: none; }
129
+ .nsw-attr-row:hover { background: var(--f-surface); }
130
+ .nsw-attr-name { font-family: var(--f-mono); font-size: 12px; flex: 1; min-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--f-text); }
131
+
132
+ /* Icon button group */
133
+ .nsw-icon-group { display: inline-flex; gap: 1px; background: var(--f-surface); border-radius: var(--f-radius-sm); padding: 2px; border: 1px solid var(--f-border); }
134
+ .nsw-icon-btn { width: 26px; height: 24px; border: none; background: transparent; cursor: pointer; border-radius: 3px; color: var(--f-text3); display: inline-flex; align-items: center; justify-content: center; padding: 0; transition: all 0.1s; }
135
+ .nsw-icon-btn:hover { color: var(--f-text); background: rgba(0,0,0,.06); }
136
+ .nsw-icon-btn.active { background: var(--f-brand); color: #fff; }
137
+ .nsw-icon-btn svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
138
+ .nsw-icon-btn[title]:hover::after { content: attr(title); position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: var(--f-text); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 3px; white-space: nowrap; pointer-events: none; z-index: 10; }
139
+ .nsw-icon-btn { position: relative; }
140
+
141
+ /* Data type pills */
142
+ .nsw-dtype-group { display: inline-flex; gap: 1px; background: var(--f-surface); border-radius: var(--f-radius-sm); padding: 2px; border: 1px solid var(--f-border); }
143
+ .nsw-dtype-btn { height: 22px; padding: 0 6px; border: none; background: transparent; cursor: pointer; border-radius: 3px; font-family: var(--f-font); font-size: 10px; font-weight: 600; color: var(--f-text3); letter-spacing: 0.03em; transition: all 0.1s; }
144
+ .nsw-dtype-btn:hover { color: var(--f-text); background: rgba(0,0,0,.06); }
145
+ .nsw-dtype-btn.active { color: #fff; }
146
+ .nsw-dtype-btn.active.t-str { background: var(--f-brand); }
147
+ .nsw-dtype-btn.active.t-int { background: var(--f-success); }
148
+ .nsw-dtype-btn.active.t-flt { background: var(--f-warn); }
149
+ .nsw-dtype-btn.active.t-date { background: #7B1FA2; }
150
+ .nsw-dtype-btn.active.t-bool { background: var(--f-danger); }
151
+
152
+ /* First/Last mini toggle */
153
+ .nsw-fl-group { display: inline-flex; gap: 1px; background: var(--f-surface); border-radius: var(--f-radius-sm); padding: 2px; border: 1px solid var(--f-border); }
154
+ .nsw-fl-btn { height: 22px; padding: 0 8px; border: none; background: transparent; cursor: pointer; border-radius: 3px; font-family: var(--f-font); font-size: 10px; font-weight: 500; color: var(--f-text3); transition: all 0.1s; }
155
+ .nsw-fl-btn:hover { color: var(--f-text); background: rgba(0,0,0,.06); }
156
+ .nsw-fl-btn.active { background: var(--f-brand); color: #fff; }
157
+ .nsw-fl-group.disabled { opacity: 0.35; pointer-events: none; }
158
+ .nsw-fl-group.disabled .nsw-fl-btn { cursor: default; }
159
+
123
160
  /* Scrollbar */
124
- .nsw-col-list::-webkit-scrollbar, .nsw-json::-webkit-scrollbar { width: 6px; }
125
- .nsw-col-list::-webkit-scrollbar-thumb, .nsw-json::-webkit-scrollbar-thumb { background: var(--f-border-strong); border-radius: 3px; }
161
+ .nsw-col-list::-webkit-scrollbar, .nsw-json::-webkit-scrollbar, .nsw-attr-list::-webkit-scrollbar { width: 6px; }
162
+ .nsw-col-list::-webkit-scrollbar-thumb, .nsw-json::-webkit-scrollbar-thumb, .nsw-attr-list::-webkit-scrollbar-thumb { background: var(--f-border-strong); border-radius: 3px; }
@@ -63,6 +63,7 @@ export function render({ model, el }) {
63
63
  eventsRef: { visible: false, editIndex: -1, targetTable: "", sourceCol: "", targetCol: "", keyType: "Integer", exportName: "", targetCols: [], loading: false },
64
64
  casesRef: { visible: false, editIndex: -1, targetTable: "", sourceCol: "", targetCol: "", keyType: "Integer", exportName: "", targetCols: [], loading: false },
65
65
  pendingRefFor: null,
66
+ attrsOpen: false,
66
67
  outputOpen: true,
67
68
  copied: false,
68
69
  };
@@ -86,13 +87,17 @@ export function render({ model, el }) {
86
87
  root.appendChild(buildLakehouseCard());
87
88
  root.appendChild(buildEventsCard());
88
89
  root.appendChild(buildCasesCard());
90
+ root.appendChild(buildAttributesCard());
89
91
  root.appendChild(buildOutputCard());
90
92
  }
91
93
 
92
94
  /* ── header ──────────────────────────────────────────── */
93
95
  function buildHeader() {
94
96
  return h("div", { className: "nsw-header" },
95
- h("h2", { textContent: "Normalized Schema Generator" }),
97
+ h("div", { style: "display:flex;align-items:baseline;gap:8px" },
98
+ h("h2", { textContent: "Normalized Schema Generator" }),
99
+ h("span", { className: "nsw-version", textContent: "v0.3.0" })
100
+ ),
96
101
  h("p", { textContent: "Configure your process mining star-schema and generate the dataSource JSON." })
97
102
  );
98
103
  }
@@ -106,6 +111,7 @@ export function render({ model, el }) {
106
111
 
107
112
  // Lakehouse selector
108
113
  const status = model.get("loading_status") || "";
114
+ const lhLoading = !!(status && (status.includes("tables") || status.includes("Reloading")));
109
115
  const lhOpts = lakehouses.map(l => ({ value: l.id, label: l.displayName }));
110
116
  const lhRow = h("div", { className: "nsw-row" },
111
117
  h("span", { className: "nsw-label", textContent: "Lakehouse" }),
@@ -114,9 +120,9 @@ export function render({ model, el }) {
114
120
  model.set("selected_lakehouse_id", val);
115
121
  model.set("selected_lakehouse_name", lh ? lh.displayName : "");
116
122
  model.save_changes();
117
- }, "Select a lakehouse...")
123
+ }, "Select a lakehouse...", lhLoading)
118
124
  );
119
- if (status && status.includes("tables")) {
125
+ if (lhLoading) {
120
126
  lhRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: status }));
121
127
  }
122
128
  card.appendChild(lhRow);
@@ -333,6 +339,8 @@ export function render({ model, el }) {
333
339
 
334
340
  // Table selector
335
341
  const loadingStatus = model.get("loading_status") || "";
342
+ const isLoadingTables = loadingStatus.includes("tables") || loadingStatus.includes("Reloading");
343
+ const isLoadingCols = selTable && columns.length === 0 && loadingStatus.includes("columns");
336
344
  const tOpts = tables.map(t => ({ value: t.name, label: t.name }));
337
345
  const tblRow = h("div", { className: "nsw-row" },
338
346
  h("span", { className: "nsw-label", textContent: "Table" }),
@@ -341,9 +349,11 @@ export function render({ model, el }) {
341
349
  model.set("events_joins", []);
342
350
  model.save_changes();
343
351
  resetRefForm(S.eventsRef);
344
- }, selLH ? "Select events table..." : "Select a lakehouse first", !selLH)
352
+ }, isLoadingTables ? "Loading tables..." : (selLH ? "Select events table..." : "Select a lakehouse first"), !selLH || isLoadingTables || isLoadingCols)
345
353
  );
346
- if (selTable && columns.length === 0 && loadingStatus.includes("columns")) {
354
+ if (isLoadingTables) {
355
+ tblRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: "" }));
356
+ } else if (isLoadingCols) {
347
357
  tblRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: "Loading columns..." }));
348
358
  }
349
359
  card.appendChild(tblRow);
@@ -497,6 +507,8 @@ export function render({ model, el }) {
497
507
  const selLH = model.get("selected_lakehouse_id") || "";
498
508
 
499
509
  const casesLoadingStatus = model.get("loading_status") || "";
510
+ const casesLoadingTables = casesLoadingStatus.includes("tables") || casesLoadingStatus.includes("Reloading");
511
+ const casesLoadingCols = selTable && columns.length === 0 && casesLoadingStatus.includes("columns");
500
512
  const tOpts = tables.map(t => ({ value: t.name, label: t.name }));
501
513
  const casesTblRow = h("div", { className: "nsw-row" },
502
514
  h("span", { className: "nsw-label", textContent: "Table" }),
@@ -506,9 +518,11 @@ export function render({ model, el }) {
506
518
  model.set("cases_relationship", {});
507
519
  model.save_changes();
508
520
  resetRefForm(S.casesRef);
509
- }, selLH ? "Select cases table..." : "Select a lakehouse first", !selLH)
521
+ }, casesLoadingTables ? "Loading tables..." : (selLH ? "Select cases table..." : "Select a lakehouse first"), !selLH || casesLoadingTables || casesLoadingCols)
510
522
  );
511
- if (selTable && columns.length === 0 && casesLoadingStatus.includes("columns")) {
523
+ if (casesLoadingTables) {
524
+ casesTblRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: "" }));
525
+ } else if (casesLoadingCols) {
512
526
  casesTblRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: "Loading columns..." }));
513
527
  }
514
528
  card.appendChild(casesTblRow);
@@ -564,7 +578,125 @@ export function render({ model, el }) {
564
578
  return card;
565
579
  }
566
580
 
567
- /* ── Section D: JSON output ──────────────────────────── */
581
+ /* ── SVG icons for attribute types (16x16 viewBox) ──── */
582
+ const ICONS = {
583
+ caseid: '<svg viewBox="0 0 16 16"><path d="M10 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a2 2 0 0 1 2 2v.27c.6.34 1 .99 1 1.73a2 2 0 0 1-4 0c0-.74.4-1.39 1-1.73V9a1 1 0 0 0-1-1H6a1 1 0 0 0-1 1v.27c.6.34 1 .99 1 1.73a2 2 0 0 1-4 0c0-.74.4-1.39 1-1.73V9a2 2 0 0 1 2-2h1V5.73C5.4 5.39 5 4.74 5 4a2 2 0 0 1 2-2"/><circle cx="10" cy="4" r="1"/><circle cx="6" cy="11" r="1"/><circle cx="14" cy="11" r="1"/>',
584
+ activity: '<svg viewBox="0 0 16 16"><polygon points="5,2 14,8 5,14"/>',
585
+ start: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="none"/><path d="M8 4v4l3 1.5"/>',
586
+ end: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="none"/><path d="M5.5 8.5l2 2 3.5-4"/>',
587
+ resource: '<svg viewBox="0 0 16 16"><circle cx="8" cy="5" r="2.5" fill="none"/><path d="M3 14c0-2.8 2.2-5 5-5s5 2.2 5 5"/>',
588
+ evtattr: '<svg viewBox="0 0 16 16"><path d="M2 4h12M2 8h12M2 12h8"/>',
589
+ caseattr: '<svg viewBox="0 0 16 16"><path d="M2 3h5l1.5 2H14v8H2z"/>',
590
+ fin_evt: '<svg viewBox="0 0 16 16"><path d="M2 4h7M2 8h7M2 12h5"/><text x="13" y="10" text-anchor="middle" font-size="7" font-weight="700" fill="currentColor" stroke="none">$</text>',
591
+ fin_case: '<svg viewBox="0 0 16 16"><path d="M2 3h5l1.5 2H11v5H2z"/><text x="14" y="12" text-anchor="middle" font-size="7" font-weight="700" fill="currentColor" stroke="none">$</text>',
592
+ fin_res: '<svg viewBox="0 0 16 16"><circle cx="6" cy="5" r="2.5" fill="none"/><path d="M1 14c0-2.8 2.2-5 5-5s5 2.2 5 5"/><text x="14" y="7" text-anchor="middle" font-size="7" font-weight="700" fill="currentColor" stroke="none">$</text>',
593
+ };
594
+
595
+ /* Attribute type definitions: key, icon, tooltip, ImportType, Level, FinanceImportType */
596
+ const ATTR_TYPES = [
597
+ { key: "caseid", icon: ICONS.caseid, tip: "Case ID", it: "Case", lv: "Event", fi: "None" },
598
+ { key: "activity", icon: ICONS.activity, tip: "Activity", it: "Activity", lv: "Event", fi: "None" },
599
+ { key: "start", icon: ICONS.start, tip: "Start timestamp", it: "Start", lv: "Event", fi: "None" },
600
+ { key: "end", icon: ICONS.end, tip: "End timestamp", it: "End", lv: "Event", fi: "None" },
601
+ { key: "resource", icon: ICONS.resource, tip: "Resource", it: "Resource", lv: "Event", fi: "None" },
602
+ { key: "evtattr", icon: ICONS.evtattr, tip: "Event-level attribute",it: "Other", lv: "Event", fi: "None" },
603
+ { key: "caseattr", icon: ICONS.caseattr, tip: "Case-level attribute", it: "Other", lv: "Case", fi: "None" },
604
+ { key: "fin_evt", icon: ICONS.fin_evt, tip: "Finance per event", it: "Other", lv: "Event", fi: "PerEvent" },
605
+ { key: "fin_case", icon: ICONS.fin_case, tip: "Finance per case", it: "Other", lv: "Case", fi: "PerCase" },
606
+ { key: "fin_res", icon: ICONS.fin_res, tip: "Finance per resource", it: "Other", lv: "Event", fi: "PerResource" },
607
+ ];
608
+
609
+ function attrTypeKey(a) {
610
+ for (const t of ATTR_TYPES) {
611
+ if (t.it === a.ImportType && t.lv === a.Level && t.fi === a.FinanceImportType) return t.key;
612
+ }
613
+ return "evtattr";
614
+ }
615
+
616
+ const DTYPES = [
617
+ { key: "String", label: "STR", cls: "t-str" },
618
+ { key: "Integer", label: "INT", cls: "t-int" },
619
+ { key: "Float", label: "FLT", cls: "t-flt" },
620
+ { key: "Date", label: "DATE", cls: "t-date" },
621
+ { key: "Boolean", label: "BOOL", cls: "t-bool" },
622
+ ];
623
+
624
+ /* ── Section D: Attribute Mapping ────────────────────── */
625
+ function buildAttributesCard() {
626
+ const attrs = model.get("attributes_config") || [];
627
+ if (attrs.length === 0) return h("div", null);
628
+
629
+ const card = h("div", { className: "nsw-card attrs" });
630
+
631
+ const toggleBtn = h("button", { className: "nsw-collapse-btn", onClick: () => { S.attrsOpen = !S.attrsOpen; rebuild(); } },
632
+ h("span", { className: "arrow " + (S.attrsOpen ? "open" : ""), textContent: "▶" }),
633
+ h("span", { textContent: "Attribute Mapping" }),
634
+ h("span", { style: "font-weight:400;color:var(--f-text3);font-size:12px;margin-left:4px", textContent: "(" + attrs.length + ")" })
635
+ );
636
+ card.appendChild(toggleBtn);
637
+
638
+ if (!S.attrsOpen) return card;
639
+
640
+ function updAttr(i, fields) {
641
+ const updated = JSON.parse(JSON.stringify(attrs));
642
+ Object.assign(updated[i], fields);
643
+ model.set("attributes_config", updated);
644
+ model.save_changes();
645
+ }
646
+
647
+ const list = h("div", { className: "nsw-attr-list" });
648
+ attrs.forEach((attr, i) => {
649
+ const curType = attrTypeKey(attr);
650
+ const isCaseLevel = attr.Level === "Case";
651
+
652
+ // Attribute type icon group
653
+ const typeGroup = h("div", { className: "nsw-icon-group" });
654
+ for (const t of ATTR_TYPES) {
655
+ const btn = h("button", {
656
+ className: "nsw-icon-btn" + (curType === t.key ? " active" : ""),
657
+ title: t.tip,
658
+ innerHTML: t.icon,
659
+ onClick: () => updAttr(i, { ImportType: t.it, Level: t.lv, FinanceImportType: t.fi })
660
+ });
661
+ typeGroup.appendChild(btn);
662
+ }
663
+
664
+ // First/Last toggle (always visible, disabled when not case-level)
665
+ const curFL = attr.CaseLevelAttributeHandling || "First";
666
+ const flGroup = h("div", { className: "nsw-fl-group" + (isCaseLevel ? "" : " disabled") });
667
+ for (const v of ["First", "Last"]) {
668
+ flGroup.appendChild(h("button", {
669
+ className: "nsw-fl-btn" + (isCaseLevel && curFL === v ? " active" : ""),
670
+ textContent: v,
671
+ onClick: isCaseLevel ? () => updAttr(i, { CaseLevelAttributeHandling: v }) : null
672
+ }));
673
+ }
674
+
675
+ // Data type pills
676
+ const dtGroup = h("div", { className: "nsw-dtype-group" });
677
+ const curDt = attr.SourceDataType || "String";
678
+ for (const d of DTYPES) {
679
+ dtGroup.appendChild(h("button", {
680
+ className: "nsw-dtype-btn " + d.cls + (curDt === d.key ? " active" : ""),
681
+ textContent: d.label,
682
+ onClick: () => updAttr(i, { SourceDataType: d.key })
683
+ }));
684
+ }
685
+
686
+ const row = h("div", { className: "nsw-attr-row" },
687
+ h("span", { className: "nsw-attr-name", textContent: attr.Name }),
688
+ typeGroup,
689
+ flGroup,
690
+ dtGroup
691
+ );
692
+ list.appendChild(row);
693
+ });
694
+
695
+ card.appendChild(list);
696
+ return card;
697
+ }
698
+
699
+ /* ── Section E: JSON output ──────────────────────────── */
568
700
  function buildOutputCard() {
569
701
  const card = h("div", { className: "nsw-card output" });
570
702
  const json = model.get("config_json") || "";
@@ -581,11 +713,12 @@ export function render({ model, el }) {
581
713
  } else {
582
714
  const wrap = h("div", { className: "nsw-json-wrap" });
583
715
  wrap.appendChild(h("div", { className: "nsw-json", textContent: json }));
716
+ const btnGroup = h("div", { className: "nsw-json-actions" });
584
717
  if (S.copied) {
585
- wrap.appendChild(h("span", { className: "nsw-copy-btn nsw-copied", textContent: "Copied!" }));
718
+ btnGroup.appendChild(h("span", { className: "nsw-copied", textContent: "Copied!" }));
586
719
  } else {
587
- wrap.appendChild(h("button", {
588
- className: "nsw-btn outline nsw-copy-btn",
720
+ btnGroup.appendChild(h("button", {
721
+ className: "nsw-btn outline sm",
589
722
  textContent: "Copy",
590
723
  onClick: () => {
591
724
  navigator.clipboard.writeText(json).then(() => {
@@ -596,6 +729,20 @@ export function render({ model, el }) {
596
729
  }
597
730
  }));
598
731
  }
732
+ btnGroup.appendChild(h("button", {
733
+ className: "nsw-btn outline sm",
734
+ textContent: "Save",
735
+ onClick: () => {
736
+ const blob = new Blob([json], { type: "application/json" });
737
+ const url = URL.createObjectURL(blob);
738
+ const a = document.createElement("a");
739
+ a.href = url;
740
+ a.download = "normalized_config.json";
741
+ a.click();
742
+ URL.revokeObjectURL(url);
743
+ }
744
+ }));
745
+ wrap.appendChild(btnGroup);
599
746
  card.appendChild(wrap);
600
747
  }
601
748
  }
@@ -612,7 +759,7 @@ export function render({ model, el }) {
612
759
  "lakehouses", "selected_lakehouse_id", "data_source_file_type",
613
760
  "available_tables", "events_table_name", "events_columns", "events_joins",
614
761
  "cases_enabled", "cases_table_name", "cases_columns", "cases_joins", "cases_relationship",
615
- "config_json", "loading_status", "error_message"
762
+ "attributes_config", "config_json", "loading_status", "error_message"
616
763
  ];
617
764
  for (const t of watchedTraits) {
618
765
  model.on("change:" + t, rebuild);
@@ -265,6 +265,9 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
265
265
  # Events→Cases FK relationship: {fk_column, pk_column, join_key_type}
266
266
  cases_relationship = traitlets.Dict({}).tag(sync=True)
267
267
 
268
+ # Attribute mapping
269
+ attributes_config = traitlets.List([]).tag(sync=True)
270
+
268
271
  # Communication & output
269
272
  fetch_columns_request = traitlets.Unicode("").tag(sync=True)
270
273
  fetch_columns_response = traitlets.List([]).tag(sync=True)
@@ -409,6 +412,11 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
409
412
  def _on_data_change(self, change):
410
413
  self._generate_config()
411
414
 
415
+ @traitlets.observe("attributes_config")
416
+ def _on_attributes_change(self, change):
417
+ # Only regenerate JSON, don't re-sync attributes (avoid loop)
418
+ self._generate_config(sync_attributes=False)
419
+
412
420
  # ── JSON generation ──────────────────────────────────
413
421
 
414
422
  def _find_table_path(self, table_name):
@@ -417,13 +425,36 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
417
425
  return t["path"]
418
426
  return ""
419
427
 
420
- def _generate_config(self):
428
+ @staticmethod
429
+ def _dataset_name(table_name):
430
+ """Extract a clean dataset name: strip path and file extension."""
431
+ import posixpath
432
+ base = posixpath.basename(table_name)
433
+ # Remove known extensions (.csv, .parquet, .snappy.parquet)
434
+ for ext in (".snappy.parquet", ".parquet", ".csv"):
435
+ if base.lower().endswith(ext):
436
+ base = base[: -len(ext)]
437
+ break
438
+ return base
439
+
440
+ @staticmethod
441
+ def _dtype_to_source_data_type(dtype):
442
+ return {
443
+ "int": "Integer",
444
+ "double": "Float",
445
+ "string": "String",
446
+ "timestamp": "Date",
447
+ "boolean": "Boolean",
448
+ }.get(dtype, "String")
449
+
450
+ def _generate_config(self, sync_attributes=True):
421
451
  self.error_message = ""
422
452
  if not self.selected_lakehouse_id or not self.events_table_name:
423
453
  self.config_json = ""
424
454
  return
425
455
 
426
456
  try:
457
+ dn = self._dataset_name
427
458
  datasets = []
428
459
  join_dataset_names = set()
429
460
 
@@ -446,19 +477,18 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
446
477
  if c.get("included") and c["name"] not in events_fk_cols
447
478
  ]
448
479
  events_join_arr = []
449
- # Add the Events→Cases join first
450
480
  if has_cases_rel:
451
481
  events_join_arr.append({
452
482
  "SourceColumnName": cases_rel["fk_column"],
453
483
  "TargetColumnName": cases_rel["pk_column"],
454
- "TargetDatasetName": self.cases_table_name,
484
+ "TargetDatasetName": dn(self.cases_table_name),
455
485
  "JoinKeyType": cases_rel.get("join_key_type", "Integer"),
456
486
  })
457
487
  for j in self.events_joins:
458
488
  entry = {
459
489
  "SourceColumnName": j["source_column"],
460
490
  "TargetColumnName": j["target_column"],
461
- "TargetDatasetName": j["target_table"],
491
+ "TargetDatasetName": dn(j["target_table"]),
462
492
  "JoinKeyType": j["join_key_type"],
463
493
  }
464
494
  if j.get("export_name"):
@@ -468,12 +498,19 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
468
498
 
469
499
  datasets.append({
470
500
  "Kind": 0,
471
- "Name": self.events_table_name,
501
+ "Name": dn(self.events_table_name),
472
502
  "Path": self._find_table_path(self.events_table_name),
473
503
  "Columns": events_columns,
474
504
  "Join": events_join_arr if events_join_arr else None,
475
505
  })
476
506
 
507
+ # Collect attribute names + dtypes + level for Attributes section
508
+ # {col_name: {dtype, level}}
509
+ attr_info = {}
510
+ for c in self.events_columns:
511
+ if c.get("included") and c["name"] not in events_fk_cols:
512
+ attr_info[c["name"]] = {"dtype": c["dtype"], "level": "Event"}
513
+
477
514
  # ── Cases dataset (Kind=1) ──
478
515
  if self.cases_enabled and self.cases_table_name:
479
516
  cases_fk_cols = {j["source_column"] for j in self.cases_joins}
@@ -487,7 +524,7 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
487
524
  entry = {
488
525
  "SourceColumnName": j["source_column"],
489
526
  "TargetColumnName": j["target_column"],
490
- "TargetDatasetName": j["target_table"],
527
+ "TargetDatasetName": dn(j["target_table"]),
491
528
  "JoinKeyType": j["join_key_type"],
492
529
  }
493
530
  if j.get("export_name"):
@@ -497,12 +534,16 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
497
534
 
498
535
  datasets.append({
499
536
  "Kind": 1,
500
- "Name": self.cases_table_name,
537
+ "Name": dn(self.cases_table_name),
501
538
  "Path": self._find_table_path(self.cases_table_name),
502
539
  "Columns": cases_columns,
503
540
  "Join": cases_join_arr if cases_join_arr else None,
504
541
  })
505
542
 
543
+ for c in self.cases_columns:
544
+ if c.get("included") and c["name"] not in cases_fk_cols:
545
+ attr_info[c["name"]] = {"dtype": c["dtype"], "level": "Case"}
546
+
506
547
  # ── Join datasets (Kind=2) ──
507
548
  join_table_cols = {}
508
549
  for j in list(self.events_joins) + list(self.cases_joins):
@@ -524,11 +565,63 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
524
565
  ]
525
566
  datasets.append({
526
567
  "Kind": 2,
527
- "Name": tname,
568
+ "Name": dn(tname),
528
569
  "Path": self._find_table_path(tname),
529
570
  "Columns": join_cols,
530
571
  "Join": None,
531
572
  })
573
+ # Join target columns become Event-level attributes
574
+ for c in cols_map.values():
575
+ if c.get("included"):
576
+ attr_info[c["name"]] = {"dtype": c["dtype"], "level": "Event"}
577
+
578
+ # Join ExportNames also become attributes
579
+ for j in list(self.events_joins) + list(self.cases_joins):
580
+ if j.get("export_name"):
581
+ attr_info[j["export_name"]] = {
582
+ "dtype": "int", # FK columns are typically int
583
+ "level": "Event",
584
+ }
585
+
586
+ # ── Sync attributes_config with current columns ──
587
+ if sync_attributes:
588
+ existing = {a["Name"]: a for a in self.attributes_config}
589
+ new_attrs = []
590
+ for col_name, info in attr_info.items():
591
+ if col_name in existing:
592
+ new_attrs.append(existing[col_name])
593
+ else:
594
+ new_attrs.append({
595
+ "Name": col_name,
596
+ "SourceDataType": self._dtype_to_source_data_type(info["dtype"]),
597
+ "ImportType": "Other",
598
+ "Level": info["level"],
599
+ "FinanceImportType": "None",
600
+ "CaseLevelAttributeHandling": "None",
601
+ "IsDisplayAttribute": False,
602
+ "IsExcluded": False,
603
+ "DefaultValue": "",
604
+ })
605
+ if new_attrs != list(self.attributes_config):
606
+ self.attributes_config = new_attrs
607
+
608
+ # ── Build Attributes for JSON from attributes_config ──
609
+ attrs_by_name = {a["Name"]: a for a in self.attributes_config}
610
+ attributes = []
611
+ for col_name in attr_info:
612
+ a = attrs_by_name.get(col_name)
613
+ if a:
614
+ attributes.append({
615
+ "Name": a["Name"],
616
+ "SourceDataType": a.get("SourceDataType", "String"),
617
+ "ImportType": a.get("ImportType", "Other"),
618
+ "Level": a.get("Level", "Event"),
619
+ "FinanceImportType": a.get("FinanceImportType", "None"),
620
+ "CaseLevelAttributeHandling": a.get("CaseLevelAttributeHandling", "None"),
621
+ "IsDisplayAttribute": a.get("IsDisplayAttribute", False),
622
+ "IsExcluded": a.get("IsExcluded", False),
623
+ "DefaultValue": a.get("DefaultValue", ""),
624
+ })
532
625
 
533
626
  # ── Assemble full config ──
534
627
  config = {
@@ -548,7 +641,7 @@ class NormalizedSchemaWidget(anywidget.AnyWidget):
548
641
  "datasets": datasets,
549
642
  },
550
643
  "miningMetadata": {
551
- "ImportConfiguration": {"Attributes": []},
644
+ "ImportConfiguration": {"Attributes": attributes},
552
645
  "Views": [],
553
646
  "ProcessExtendedMetadata": {
554
647
  "CalculatedMetrics": [],