@thinkingai/ae-cli 6.1.18 → 6.1.20

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 (127) hide show
  1. package/README.md +97 -38
  2. package/README.zh.md +101 -38
  3. package/dist/{auth-ROB2EDYV.js → auth-FUM37MHF.js} +241 -126
  4. package/dist/{capability-DKMYUTLC.js → capability-AXFQW5WM.js} +49 -35
  5. package/dist/{chunk-JHENBQ5B.js → chunk-4P355ZWB.js} +70 -1
  6. package/dist/chunk-6ZIQV5GW.js +26 -0
  7. package/dist/{chunk-TUKQZTMI.js → chunk-7K24F7T2.js} +2 -0
  8. package/dist/{chunk-OO6XR6LK.js → chunk-AMBFK2K3.js} +2 -0
  9. package/dist/chunk-ATSM5XAW.js +623 -0
  10. package/dist/{chunk-4XXOWOTA.js → chunk-BBEFP4SB.js} +31 -38
  11. package/dist/{chunk-BYYS3ANB.js → chunk-CZU3V3DQ.js} +5 -15
  12. package/dist/{chunk-VTWMIC5L.js → chunk-E2JKXMVO.js} +2 -0
  13. package/dist/chunk-ECBLHAZO.js +15 -0
  14. package/dist/{sync-HKIOZXQE.js → chunk-I4WQAEYB.js} +31 -540
  15. package/dist/{chunk-DWO43OIB.js → chunk-JQ3ENZZH.js} +2 -0
  16. package/dist/chunk-LNZBEQXW.js +25216 -0
  17. package/dist/{chunk-ZQ47LWTI.js → chunk-QJQZH5GQ.js} +49 -79
  18. package/dist/{chunk-QZ3AS4KK.js → chunk-RSG4ONOI.js} +9 -8
  19. package/dist/{chunk-4NN5IWVN.js → chunk-T6OSFQZD.js} +2 -0
  20. package/dist/{chunk-Y3LOALAV.js → chunk-TAL6CZH6.js} +8 -7
  21. package/dist/{chunk-3KWQYGYI.js → chunk-TKHSULJT.js} +2 -0
  22. package/dist/chunk-VXNMYUXU.js +202 -0
  23. package/dist/{chunk-3FY3RJ26.js → chunk-WZ6YVQSF.js} +15 -14
  24. package/dist/{chunk-6EIJSNBD.js → chunk-Y74WTIKJ.js} +2 -0
  25. package/dist/{community-report-client-C7WDGET3.js → community-report-client-XXWGSBSD.js} +3 -4
  26. package/dist/{config-BMYZX2UE.js → config-EXUGQN5T.js} +10 -10
  27. package/dist/{data-integration-QEKDWQDY.js → data-integration-4NZ53OMT.js} +920 -97
  28. package/dist/index.js +56 -81
  29. package/dist/{local-data-upload-client-4YYHSYD6.js → local-data-upload-client-KYOKVYZV.js} +3 -4
  30. package/dist/{memory-I2WXDTV2.js → memory-ATNKZNW5.js} +6 -7
  31. package/dist/{metadata-I4C2EWUN.js → metadata-VZVC7YMH.js} +10 -11
  32. package/dist/{model-HLHIEFMU.js → model-E4JMQ4V2.js} +8 -9
  33. package/dist/{personal-semantic-preference-LIPACBDX.js → personal-semantic-preference-7S2SQ3UV.js} +9 -10
  34. package/dist/{project-semantic-RT3R2VQD.js → project-semantic-2SZP2OPO.js} +13 -14
  35. package/dist/sync-YV3E66IF.js +520 -0
  36. package/dist/{te-agent-BR6VDBNX.js → te-agent-JB5T3PO7.js} +396 -92
  37. package/dist/{te-analysis-7VUNUYWZ.js → te-analysis-3YJAAT2D.js} +196 -43
  38. package/dist/{te-community-5DMNKJWY.js → te-community-UDBI672N.js} +12 -34
  39. package/dist/{te-dataops-6P5IKWNJ.js → te-dataops-ZLYOCXZ4.js} +480 -81
  40. package/dist/{te-engage-KZPR5R22.js → te-engage-4XG6OJML.js} +88 -16
  41. package/dist/{te-experiment-6BITX4RD.js → te-experiment-VXUWPINJ.js} +83 -12
  42. package/dist/te-kb-WYQWHFSC.js +1732 -0
  43. package/dist/{te-system-FXITO2JG.js → te-system-7G6F2LJA.js} +569 -35
  44. package/dist/{te-team-ADOC2ROP.js → te-team-E7FBBXMQ.js} +8 -7
  45. package/dist/{update-YCYCKJOO.js → update-D47BUG25.js} +8 -8
  46. package/package.json +22 -10
  47. package/skills/ae-agent/SKILL.md +30 -13
  48. package/skills/ae-agent/references/agent-distribution.md +94 -0
  49. package/skills/ae-agent/references/approval-request.md +4 -0
  50. package/skills/ae-agent/references/command_index.md +9 -2
  51. package/skills/ae-agent/references/create-automation.md +20 -2
  52. package/skills/ae-agent/references/get-agent-context.md +70 -0
  53. package/skills/ae-agent/references/list-automations.md +18 -3
  54. package/skills/ae-agent/references/list-mcp-credentials.md +1 -1
  55. package/skills/ae-agent/references/mcp-token.md +3 -3
  56. package/skills/ae-agent/references/set-mcp-credential.md +0 -1
  57. package/skills/ae-agent/references/update-automation.md +18 -0
  58. package/skills/ae-analysis/SKILL.md +11 -2
  59. package/skills/ae-analysis/references/adhoc_run.md +2 -0
  60. package/skills/ae-analysis/references/ai_models.md +23 -3
  61. package/skills/ae-analysis/references/analysis_gateway_assets.md +3 -3
  62. package/skills/ae-analysis/references/audience_models.md +18 -0
  63. package/skills/ae-analysis/references/command_index.md +9 -9
  64. package/skills/ae-analysis/references/cross_source_config.md +84 -0
  65. package/skills/ae-analysis/references/dashboard_update.md +1 -1
  66. package/skills/ae-analysis/references/project_timezone_update.md +13 -4
  67. package/skills/ae-analysis/references/property_create.md +2 -0
  68. package/skills/ae-analysis/references/super_metadata_batch_create.md +2 -0
  69. package/skills/ae-analysis/references/user_cluster_models.md +2 -0
  70. package/skills/ae-analysis/references/user_cluster_update.md +8 -4
  71. package/skills/ae-analysis/references/user_tag_create.md +30 -2
  72. package/skills/ae-analysis/references/user_tag_models.md +17 -3
  73. package/skills/ae-analysis/references/user_tag_update.md +14 -2
  74. package/skills/ae-data-integration/SKILL.md +3 -1
  75. package/skills/ae-data-integration/references/dimension-routing.md +36 -0
  76. package/skills/ae-data-integration/references/error-handling.md +54 -1
  77. package/skills/ae-data-integration/references/local-analysis.md +2 -0
  78. package/skills/ae-data-integration/references/source-inspect.md +18 -2
  79. package/skills/ae-data-integration/references/tracking-plan.md +1 -1
  80. package/skills/ae-data-integration/references/transform.md +4 -2
  81. package/skills/ae-data-integration/references/ue-mapping.md +5 -2
  82. package/skills/ae-data-integration/references/ue-routing.md +40 -1
  83. package/skills/ae-dataops/SKILL.md +11 -1
  84. package/skills/ae-dataops/references/dataops-backfill.md +135 -0
  85. package/skills/ae-engage/SKILL.md +5 -0
  86. package/skills/ae-engage/references/build-task-save-guide.md +5 -1
  87. package/skills/ae-engage/references/save-flow.md +37 -1
  88. package/skills/ae-engage/references/save-task.md +6 -0
  89. package/skills/ae-experiment/SKILL.md +6 -2
  90. package/skills/ae-experiment/references/save_metric.md +20 -8
  91. package/skills/ae-generate-tracking-plan/SKILL.md +25 -13
  92. package/skills/ae-generate-tracking-plan/references/business-dimension-mapping.md +1 -1
  93. package/skills/ae-kb/SKILL.md +218 -36
  94. package/skills/ae-kb/references/query-workflow.md +59 -29
  95. package/skills/ae-kb/references/versions.md +46 -0
  96. package/skills/ae-system/SKILL.md +29 -31
  97. package/skills/ae-system/references/channel-management.md +303 -0
  98. package/skills/ae-use-agent/SKILL.md +42 -0
  99. package/skills/ae-use-agent/references/local-agent.md +114 -0
  100. package/dist/auth-GBMV6TEJ.js +0 -14
  101. package/dist/capability-HYVVPG25.js +0 -352
  102. package/dist/chunk-EFH4XWYC.js +0 -556
  103. package/dist/chunk-J2DEBMRF.js +0 -313
  104. package/dist/chunk-JRJY5DMJ.js +0 -71
  105. package/dist/chunk-OMPRXM3V.js +0 -349
  106. package/dist/chunk-QNOLN2LJ.js +0 -509
  107. package/dist/chunk-RJDU7NYP.js +0 -1198
  108. package/dist/chunk-RNAALWJK.js +0 -98
  109. package/dist/chunk-SERWF6G5.js +0 -13
  110. package/dist/chunk-UW5UN47B.js +0 -70
  111. package/dist/chunk-ZQKDZXDO.js +0 -317
  112. package/dist/client-L2YDMHQ6.js +0 -203
  113. package/dist/memory-3ORCR7JH.js +0 -893
  114. package/dist/metadata-VUOQJE26.js +0 -339
  115. package/dist/model-UGRDX4MW.js +0 -139
  116. package/dist/personal-semantic-preference-OEISBRHM.js +0 -239
  117. package/dist/project-semantic-FFPWFPIW.js +0 -1114
  118. package/dist/sync-TFHU2UTG.js +0 -10261
  119. package/dist/te-agent-VLYOV7S4.js +0 -3894
  120. package/dist/te-analysis-4YGQL5RC.js +0 -9357
  121. package/dist/te-community-ISDQWJU7.js +0 -1859
  122. package/dist/te-dataops-CVULXNVB.js +0 -2209
  123. package/dist/te-engage-N5WI32H6.js +0 -4898
  124. package/dist/te-experiment-UVR4HLND.js +0 -988
  125. package/dist/te-kb-RCLSSH2Q.js +0 -935
  126. package/dist/te-system-K2GYMCTB.js +0 -2213
  127. package/skills/ae-agent/references/auto-provision-mcp-credentials.md +0 -57
@@ -1,15 +1,20 @@
1
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
1
2
  import {
3
+ require_excel,
4
+ require_styles_xform,
5
+ require_worksheet_reader,
2
6
  validateAndFix,
3
7
  validateDraft
4
- } from "./chunk-RJDU7NYP.js";
8
+ } from "./chunk-LNZBEQXW.js";
9
+ import "./chunk-I4WQAEYB.js";
5
10
  import {
6
11
  getConfigDir
7
- } from "./chunk-3FY3RJ26.js";
12
+ } from "./chunk-WZ6YVQSF.js";
8
13
  import {
9
14
  CliValidationError,
10
- LocalDataUploadError
11
- } from "./chunk-UW5UN47B.js";
12
- import "./chunk-JHENBQ5B.js";
15
+ LocalDataUploadError,
16
+ __toESM
17
+ } from "./chunk-4P355ZWB.js";
13
18
 
14
19
  // src/commands/data-integration/inspect.ts
15
20
  import { basename as basename3 } from "path";
@@ -103,11 +108,14 @@ function assessFileSize(fileName, format, sizeBytes, encoding) {
103
108
  }
104
109
 
105
110
  // src/commands/data-integration/input.ts
111
+ var import_exceljs = __toESM(require_excel(), 1);
112
+ var import_worksheet_reader = __toESM(require_worksheet_reader(), 1);
113
+ var import_styles_xform = __toESM(require_styles_xform(), 1);
106
114
  import { createHash } from "crypto";
107
115
  import { createReadStream as createReadStream2, statSync } from "fs";
108
- import { createRequire as createRequire2 } from "module";
109
116
  import { extname, basename } from "path";
110
117
  import { createInterface } from "readline";
118
+ import { StringDecoder } from "string_decoder";
111
119
  import { pipeline } from "stream/promises";
112
120
  import XLSXMod from "xlsx";
113
121
  import { parse as parseCsv } from "csv-parse";
@@ -626,8 +634,6 @@ function isStrongDateTime(value) {
626
634
 
627
635
  // src/commands/data-integration/input.ts
628
636
  var XLSX = XLSXMod.default ?? XLSXMod;
629
- var require3 = createRequire2(import.meta.url);
630
- var ExcelWorksheetReader = require3("exceljs/lib/stream/xlsx/worksheet-reader");
631
637
  function resolveLocalDataInputMeta(filePath) {
632
638
  let format = resolveFormat(filePath);
633
639
  let delimiter;
@@ -664,10 +670,12 @@ async function inspectLocalDataInput(filePath) {
664
670
  const meta = resolveLocalDataInputMeta(filePath);
665
671
  emitSizeWarning(meta.filePath, meta.format, meta.sizeBytes, meta.encoding);
666
672
  try {
673
+ const discovered = await discoverDataSets(filePath, meta.format);
667
674
  return {
668
675
  ...meta,
669
676
  sha256: await sha256File(filePath),
670
- dataSets: await discoverDataSets(filePath, meta.format)
677
+ dataSets: discovered.visible,
678
+ ...discovered.hidden.length > 0 ? { excludedDataSets: discovered.hidden } : {}
671
679
  };
672
680
  } catch (error) {
673
681
  if (error instanceof CliValidationError) throw error;
@@ -690,15 +698,28 @@ function emitSizeWarning(filePath, format, sizeBytes, encoding) {
690
698
  }
691
699
  function selectDataSet(input, requested) {
692
700
  if (requested) {
693
- const selected = input.dataSets.find((candidate) => candidate.id === requested || candidate.selector === requested || candidate.label === requested);
694
- if (!selected) {
695
- throw new CliValidationError("The requested Sheet or JSON Path was not found.", {
696
- code: "LOCAL_DATA_SET_NOT_FOUND",
697
- hint: `Choose one of: ${input.dataSets.map((item) => item.id).join(", ")}`,
698
- location: { field: "data-set" }
699
- });
701
+ const matches = (candidate) => candidate.id === requested || candidate.selector === requested || candidate.label === requested;
702
+ const selected = input.dataSets.find(matches);
703
+ if (selected) return selected;
704
+ const excluded = input.excludedDataSets?.find(matches);
705
+ if (excluded) {
706
+ process.stderr.write(`Warning: "${excluded.label}" is hidden in the source workbook and was selected explicitly.
707
+ `);
708
+ return excluded;
700
709
  }
701
- return selected;
710
+ throw new CliValidationError("The requested Sheet or JSON Path was not found.", {
711
+ code: "LOCAL_DATA_SET_NOT_FOUND",
712
+ hint: `Choose one of: ${input.dataSets.map((item) => item.id).join(", ")}`,
713
+ location: { field: "data-set" }
714
+ });
715
+ }
716
+ const hidden = input.excludedDataSets ?? [];
717
+ if (input.dataSets.length === 0 && hidden.length > 0) {
718
+ throw new CliValidationError("Every data set in this file is hidden in the source.", {
719
+ code: "LOCAL_DATA_ALL_DATA_SETS_HIDDEN",
720
+ hint: `Confirm with the user, then pass --data-set with one of: ${hidden.map((item) => item.id).join(", ")}`,
721
+ location: { field: "data-set" }
722
+ });
702
723
  }
703
724
  if (input.dataSets.length !== 1) {
704
725
  throw new CliValidationError("This file contains multiple data sets.", {
@@ -736,7 +757,13 @@ async function streamLocalDataRows(input, dataSet, onRow, options = {}) {
736
757
  flattenRules: options.flattenRules,
737
758
  flattenMisses: options.flattenMisses,
738
759
  mergeSheets: options.mergeSheets,
739
- warnRagged: options.warnRagged
760
+ excelDateColumns: options.excelDateColumns,
761
+ cellIssues: options.cellIssues,
762
+ warnRagged: options.warnRagged,
763
+ skipRows: options.skipRows,
764
+ xlsxStructure: options.xlsxStructure,
765
+ fillMergedCells: options.fillMergedCells,
766
+ excludeHiddenRows: options.excludeHiddenRows
740
767
  };
741
768
  const wrappedRow = wrapRowCallback(onRow);
742
769
  try {
@@ -864,6 +891,21 @@ function peekDelimitedRecords(filePath, options = {}) {
864
891
  });
865
892
  return records.slice(0, options.limit ?? 10);
866
893
  }
894
+ var TITLE_ROW_SCAN_LIMIT = 3;
895
+ var TITLE_ROW_MIN_HEADER_WIDTH = 3;
896
+ function detectLeadingTitleRows(rows) {
897
+ const width = (row) => row.filter((value) => !isMissing(value)).length;
898
+ const titles = [];
899
+ for (let index = 0; index < Math.min(rows.length, TITLE_ROW_SCAN_LIMIT); index += 1) {
900
+ const cells = width(rows[index]);
901
+ if (cells > 1) break;
902
+ titles.push({ row: index + 1, non_empty_cells: cells });
903
+ }
904
+ if (titles.length === 0) return [];
905
+ const next = rows[titles.length];
906
+ if (!next || width(next) < TITLE_ROW_MIN_HEADER_WIDTH) return [];
907
+ return titles;
908
+ }
867
909
  function escapeRegex(source) {
868
910
  return source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
869
911
  }
@@ -874,21 +916,29 @@ function isNumeric(value) {
874
916
  }
875
917
  async function discoverDataSets(filePath, format) {
876
918
  if (format === "csv" || format === "tsv" || format === "jsonl") {
877
- return [{ id: "$", kind: "file", label: basename(filePath), selector: "$" }];
919
+ return { visible: [{ id: "$", kind: "file", label: basename(filePath), selector: "$" }], hidden: [] };
878
920
  }
879
921
  if (format === "xls") {
880
922
  const workbook = XLSX.readFile(filePath, { dense: true });
881
- return workbook.SheetNames.map((name) => ({ id: `sheet:${name}`, kind: "sheet", label: name, selector: name }));
923
+ return {
924
+ visible: workbook.SheetNames.map((name) => ({ id: `sheet:${name}`, kind: "sheet", label: name, selector: name })),
925
+ hidden: []
926
+ };
882
927
  }
883
928
  if (format === "xlsx") {
884
- return (await readXlsxSheetDefinitions(filePath)).map((sheet) => ({
929
+ const sheets = await readXlsxSheetDefinitions(filePath);
930
+ const toDataSet = (sheet) => ({
885
931
  id: `sheet:${sheet.name}`,
886
932
  kind: "sheet",
887
933
  label: sheet.name,
888
934
  selector: sheet.name
889
- }));
935
+ });
936
+ return {
937
+ visible: sheets.filter((sheet) => !sheet.hidden).map(toDataSet),
938
+ hidden: sheets.filter((sheet) => sheet.hidden).map(toDataSet)
939
+ };
890
940
  }
891
- return discoverJsonDataSets(filePath);
941
+ return { visible: await discoverJsonDataSets(filePath), hidden: [] };
892
942
  }
893
943
  async function discoverJsonDataSets(filePath) {
894
944
  const encoding = detectEncoding(filePath);
@@ -934,9 +984,11 @@ function childPath(parent) {
934
984
  async function streamDelimited(filePath, onRow, options) {
935
985
  const delimiter = options.delimiter ?? ",";
936
986
  const encoding = options.encoding ?? "utf-8";
987
+ const skipRows = Math.max(0, options.skipRows ?? 0);
937
988
  let headerNames = options.headerNames;
938
989
  if (!headerNames && options.noHeader) {
939
- const firstRecord = peekDelimitedRecords(filePath, { delimiter, encoding, limit: 1 })[0] ?? [];
990
+ const peeked = peekDelimitedRecords(filePath, { delimiter, encoding, limit: skipRows + 1 });
991
+ const firstRecord = peeked[skipRows] ?? [];
940
992
  headerNames = firstRecord.map((_, index) => `col_${index + 1}`);
941
993
  }
942
994
  const parser = parseCsv({
@@ -950,9 +1002,14 @@ async function streamDelimited(filePath, onRow, options) {
950
1002
  decodeTextStream(filePath, encoding).pipe(parser);
951
1003
  let count = 0;
952
1004
  let widthMismatches = 0;
1005
+ let skipped = 0;
953
1006
  let resolvedHeaders = headerNames;
954
1007
  for await (const raw of parser) {
955
1008
  const values = raw;
1009
+ if (skipped < skipRows) {
1010
+ skipped += 1;
1011
+ continue;
1012
+ }
956
1013
  if (!resolvedHeaders) {
957
1014
  resolvedHeaders = dedupeHeaders(values.map((header) => String(header).trim()));
958
1015
  continue;
@@ -1009,13 +1066,22 @@ async function streamJson(filePath, selector, onRow, options) {
1009
1066
  }
1010
1067
  async function streamXlsx(filePath, sheetName, onRow, options) {
1011
1068
  const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
1012
- const targets = options.mergeSheets ? sheetDefinitions : sheetDefinitions.filter((sheet) => sheet.name === sheetName);
1069
+ const targets = options.mergeSheets ? sheetDefinitions.filter((sheet) => !sheet.hidden) : sheetDefinitions.filter((sheet) => sheet.name === sheetName);
1070
+ if (options.mergeSheets) {
1071
+ const excluded = sheetDefinitions.filter((sheet) => sheet.hidden).map((sheet) => sheet.name);
1072
+ if (excluded.length > 0) {
1073
+ process.stderr.write(`Warning: skipped ${excluded.length} hidden worksheet(s): ${excluded.join(", ")}.
1074
+ `);
1075
+ }
1076
+ }
1013
1077
  if (targets.length === 0) {
1014
1078
  throw new CliValidationError("The requested Excel Sheet was not found.", { code: "LOCAL_DATA_SET_NOT_FOUND" });
1015
1079
  }
1016
1080
  const archive = await unzipper.Open.file(filePath);
1017
- const sharedStringsEntry = archive.files.find((entry) => entry.path === "xl/sharedStrings.xml");
1018
- const sharedStrings = sharedStringsEntry ? await readXlsxSharedStrings(sharedStringsEntry) : [];
1081
+ const workbook = await readXlsxWorkbookContext(archive.files);
1082
+ const skipRows = Math.max(0, options.skipRows ?? 0);
1083
+ const collector = options.xlsxStructure;
1084
+ if (collector) collector.filled = Boolean(options.fillMergedCells);
1019
1085
  let count = 0;
1020
1086
  for (const definition of targets) {
1021
1087
  const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
@@ -1025,19 +1091,37 @@ async function streamXlsx(filePath, sheetName, onRow, options) {
1025
1091
  location: { field: "input-file" }
1026
1092
  });
1027
1093
  }
1028
- const worksheet = new ExcelWorksheetReader({
1029
- workbook: {
1030
- sharedStrings,
1031
- styles: { getStyleModel: () => null },
1032
- properties: { model: {} }
1033
- },
1094
+ const structure = collector || options.fillMergedCells || options.excludeHiddenRows ? await readXlsxSheetStructure(worksheetEntry) : EMPTY_XLSX_SHEET_STRUCTURE;
1095
+ if (collector) {
1096
+ collector.mergedRanges += structure.merged.length;
1097
+ collector.hiddenRowCount += structure.hiddenRows.size;
1098
+ for (const range of structure.merged) {
1099
+ if (collector.mergedRefs.length < XLSX_STRUCTURE_SAMPLE_LIMIT) collector.mergedRefs.push(range.ref);
1100
+ }
1101
+ for (const rowNumber of structure.hiddenRows) {
1102
+ if (collector.hiddenRows.length < XLSX_STRUCTURE_SAMPLE_LIMIT) collector.hiddenRows.push(rowNumber);
1103
+ }
1104
+ }
1105
+ const merges = structure.merged.length > 0 ? new MergedRegionTracker(structure.merged, Boolean(options.fillMergedCells)) : void 0;
1106
+ const hiddenRows = options.excludeHiddenRows ? structure.hiddenRows : void 0;
1107
+ let hiddenColumnsResolved = false;
1108
+ const worksheet = new import_worksheet_reader.default({
1109
+ workbook,
1034
1110
  id: definition.id,
1035
1111
  iterator: worksheetEntry.stream(),
1036
1112
  options: { worksheets: "emit", hyperlinks: "ignore" }
1037
1113
  });
1038
1114
  let headers = options.headerNames;
1115
+ let skipped = 0;
1039
1116
  for await (const excelRow of worksheet) {
1040
- const values = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => normalizeExcelValue(excelRow.getCell(index + 1).value));
1117
+ const rawValues = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCellValue(excelRow.getCell(index + 1)));
1118
+ const reads = rawValues.map(readExcelCell);
1119
+ const values = reads.map((read) => read.value);
1120
+ merges?.observe(excelRow.number, values);
1121
+ if (skipped < skipRows) {
1122
+ skipped += 1;
1123
+ continue;
1124
+ }
1041
1125
  if (options.noHeader && !headers) {
1042
1126
  headers = values.map((_, index) => `col_${index + 1}`);
1043
1127
  }
@@ -1046,7 +1130,44 @@ async function streamXlsx(filePath, sheetName, onRow, options) {
1046
1130
  continue;
1047
1131
  }
1048
1132
  if (values.every(isMissing)) continue;
1133
+ if (hiddenRows?.has(excelRow.number)) {
1134
+ if (collector) collector.excludedHiddenRows += 1;
1135
+ continue;
1136
+ }
1137
+ const covered = merges?.covered(excelRow.number, values);
1049
1138
  count += 1;
1139
+ if (collector) {
1140
+ if (!hiddenColumnsResolved) {
1141
+ hiddenColumnsResolved = true;
1142
+ for (const span of structure.hiddenColumns) {
1143
+ for (let column = span.min; column <= Math.min(span.max, headers.length); column += 1) {
1144
+ const header = headers[column - 1];
1145
+ if (header) collector.hiddenColumns.add(header);
1146
+ }
1147
+ }
1148
+ }
1149
+ for (const column of covered ?? []) {
1150
+ const header = headers[column - 1];
1151
+ if (!header) continue;
1152
+ collector.coveredCells.set(header, (collector.coveredCells.get(header) ?? 0) + 1);
1153
+ }
1154
+ }
1155
+ if (options.excelDateColumns) {
1156
+ rawValues.forEach((raw, index) => {
1157
+ const header = headers?.[index];
1158
+ if (header && isExcelDateCell(raw)) options.excelDateColumns?.add(header);
1159
+ });
1160
+ }
1161
+ const cellIssues = options.cellIssues;
1162
+ if (cellIssues) {
1163
+ reads.forEach((read, index) => {
1164
+ const header = headers?.[index];
1165
+ if (!read.issue || !header) return;
1166
+ const byColumn = cellIssues.get(read.issue) ?? /* @__PURE__ */ new Map();
1167
+ byColumn.set(header, (byColumn.get(header) ?? 0) + 1);
1168
+ cellIssues.set(read.issue, byColumn);
1169
+ });
1170
+ }
1050
1171
  const row = Object.fromEntries(headers.map((header, index) => [header, values[index] ?? null]));
1051
1172
  await onRow(
1052
1173
  options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules) : row,
@@ -1066,28 +1187,29 @@ async function readXlsxSheetDefinitions(filePath) {
1066
1187
  location: { field: "input-file" }
1067
1188
  });
1068
1189
  }
1069
- const [workbookXml, relationshipsXml] = await Promise.all([
1070
- workbookEntry.buffer().then((value) => value.toString("utf8")),
1071
- relationshipsEntry.buffer().then((value) => value.toString("utf8"))
1072
- ]);
1073
1190
  const targets = /* @__PURE__ */ new Map();
1074
- for (const match of relationshipsXml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
1075
- const attributes = parseXmlAttributes(match[1]);
1076
- if (attributes.Id && attributes.Target) targets.set(attributes.Id, attributes.Target);
1077
- }
1191
+ await parseXmlTags(relationshipsEntry, (tag) => {
1192
+ if (xmlLocalName(tag.name) !== "Relationship") return;
1193
+ const id = xmlAttributeByLocalName(tag.attributes, "Id");
1194
+ const target = xmlAttributeByLocalName(tag.attributes, "Target");
1195
+ if (id && target) targets.set(id, target);
1196
+ });
1078
1197
  const sheets = [];
1079
- for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?\s*>/g)) {
1080
- const attributes = parseXmlAttributes(match[1]);
1081
- const relationshipId = attributes["r:id"];
1198
+ await parseXmlTags(workbookEntry, (tag) => {
1199
+ if (xmlLocalName(tag.name) !== "sheet") return;
1200
+ const relationshipId = xmlAttributeByLocalName(tag.attributes, "id");
1082
1201
  const target = relationshipId ? targets.get(relationshipId) : void 0;
1083
1202
  const fileNumber = target?.match(/worksheets\/sheet(\d+)\.xml$/)?.[1];
1084
- if (!attributes.name || !relationshipId || !fileNumber) continue;
1203
+ const name = xmlAttributeByLocalName(tag.attributes, "name");
1204
+ if (!name || !relationshipId || !fileNumber) return;
1205
+ const state = xmlAttributeByLocalName(tag.attributes, "state");
1085
1206
  sheets.push({
1086
- id: Number(attributes.sheetId ?? fileNumber),
1087
- name: decodeXml(attributes.name),
1088
- entryPath: normalizeXlsxEntryPath(target)
1207
+ id: Number(xmlAttributeByLocalName(tag.attributes, "sheetId") ?? fileNumber),
1208
+ name: decodeXml(name),
1209
+ entryPath: normalizeXlsxEntryPath(target),
1210
+ hidden: state === "hidden" || state === "veryHidden"
1089
1211
  });
1090
- }
1212
+ });
1091
1213
  if (sheets.length === 0) {
1092
1214
  throw new CliValidationError("XLSX workbook contains no readable Sheets.", {
1093
1215
  code: "LOCAL_DATA_XLSX_INVALID",
@@ -1110,25 +1232,21 @@ function readXlsSheetHeaders(filePath) {
1110
1232
  async function readXlsxSheetHeaders(filePath) {
1111
1233
  const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
1112
1234
  const archive = await unzipper.Open.file(filePath);
1113
- const sharedStringsEntry = archive.files.find((entry) => entry.path === "xl/sharedStrings.xml");
1114
- const sharedStrings = sharedStringsEntry ? await readXlsxSharedStrings(sharedStringsEntry) : [];
1235
+ const workbook = await readXlsxWorkbookContext(archive.files);
1115
1236
  const sheets = [];
1116
1237
  for (const definition of sheetDefinitions) {
1238
+ if (definition.hidden) continue;
1117
1239
  const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
1118
1240
  if (!worksheetEntry) continue;
1119
- const worksheet = new ExcelWorksheetReader({
1120
- workbook: {
1121
- sharedStrings,
1122
- styles: { getStyleModel: () => null },
1123
- properties: { model: {} }
1124
- },
1241
+ const worksheet = new import_worksheet_reader.default({
1242
+ workbook,
1125
1243
  id: definition.id,
1126
1244
  iterator: worksheetEntry.stream(),
1127
1245
  options: { worksheets: "emit", hyperlinks: "ignore" }
1128
1246
  });
1129
1247
  let headers = [];
1130
1248
  for await (const excelRow of worksheet) {
1131
- const values = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => normalizeExcelValue(excelRow.getCell(index + 1).value));
1249
+ const values = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCell(readExcelCellValue(excelRow.getCell(index + 1))).value);
1132
1250
  headers = firstRowHeaders(values);
1133
1251
  break;
1134
1252
  }
@@ -1139,13 +1257,82 @@ async function readXlsxSheetHeaders(filePath) {
1139
1257
  function firstRowHeaders(values) {
1140
1258
  return dedupeHeaders(values.map((value, index) => String(value ?? `column_${index + 1}`).trim()));
1141
1259
  }
1260
+ async function peekXlsxRows(filePath, sheetName, limit = 10) {
1261
+ const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
1262
+ const definition = sheetDefinitions.find((sheet) => sheet.name === sheetName);
1263
+ if (!definition) return [];
1264
+ const archive = await unzipper.Open.file(filePath);
1265
+ const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
1266
+ if (!worksheetEntry) return [];
1267
+ const workbook = await readXlsxWorkbookContext(archive.files);
1268
+ const worksheet = new import_worksheet_reader.default({
1269
+ workbook,
1270
+ id: definition.id,
1271
+ iterator: worksheetEntry.stream(),
1272
+ options: { worksheets: "emit", hyperlinks: "ignore" }
1273
+ });
1274
+ const rows = [];
1275
+ for await (const excelRow of worksheet) {
1276
+ rows.push(Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCell(readExcelCellValue(excelRow.getCell(index + 1))).value));
1277
+ if (rows.length >= limit) break;
1278
+ }
1279
+ return rows;
1280
+ }
1281
+ var NO_XLSX_STYLES = { getStyleModel: () => null };
1282
+ var ELAPSED_TIME_FORMATS = /* @__PURE__ */ new Set(["mm:ss", "mmss.0", "[h]:mm:ss"]);
1283
+ function isElapsedTimeFormat(numFmt) {
1284
+ return ELAPSED_TIME_FORMATS.has(numFmt.trim()) || /\[[hms]+\]/i.test(numFmt);
1285
+ }
1286
+ async function readXlsxWorkbookContext(files) {
1287
+ const sharedStringsEntry = files.find((entry) => entry.path === "xl/sharedStrings.xml");
1288
+ const stylesEntry = files.find((entry) => entry.path === "xl/styles.xml");
1289
+ const workbookEntry = files.find((entry) => entry.path === "xl/workbook.xml");
1290
+ return {
1291
+ sharedStrings: sharedStringsEntry ? await readXlsxSharedStrings(sharedStringsEntry) : [],
1292
+ styles: await readXlsxStyles(stylesEntry),
1293
+ properties: { model: { date1904: workbookEntry ? await readXlsxDate1904(workbookEntry) : false } }
1294
+ };
1295
+ }
1296
+ async function readXlsxStyles(entry) {
1297
+ if (!entry) return NO_XLSX_STYLES;
1298
+ let styles;
1299
+ try {
1300
+ styles = new import_styles_xform.default();
1301
+ styles.init();
1302
+ await styles.parseStream(entry.stream());
1303
+ } catch {
1304
+ return NO_XLSX_STYLES;
1305
+ }
1306
+ return {
1307
+ getStyleModel: (id) => {
1308
+ let model;
1309
+ try {
1310
+ model = styles.getStyleModel(id);
1311
+ } catch {
1312
+ return null;
1313
+ }
1314
+ if (!model?.numFmt || !isElapsedTimeFormat(model.numFmt)) return model;
1315
+ const { numFmt, ...rest } = model;
1316
+ return rest;
1317
+ }
1318
+ };
1319
+ }
1320
+ async function readXlsxDate1904(entry) {
1321
+ let date1904 = false;
1322
+ await parseXmlTags(entry, (tag) => {
1323
+ if (xmlLocalName(tag.name) !== "workbookPr") return;
1324
+ const value = xmlAttributeByLocalName(tag.attributes, "date1904");
1325
+ date1904 = value === "1" || value === "true";
1326
+ });
1327
+ return date1904;
1328
+ }
1142
1329
  async function readXlsxSharedStrings(entry) {
1143
1330
  const values = [];
1144
1331
  let inItem = false;
1145
1332
  let current = "";
1146
1333
  const parser = new SaxesParser();
1147
1334
  parser.on("opentag", (tag) => {
1148
- if (tag.name === "si") {
1335
+ if (xmlLocalName(tag.name) === "si") {
1149
1336
  inItem = true;
1150
1337
  current = "";
1151
1338
  }
@@ -1154,26 +1341,147 @@ async function readXlsxSharedStrings(entry) {
1154
1341
  if (inItem) current += text;
1155
1342
  });
1156
1343
  parser.on("closetag", (tag) => {
1157
- if (tag.name === "si") {
1344
+ if (xmlLocalName(tag.name) === "si") {
1158
1345
  values.push(current);
1159
1346
  inItem = false;
1160
1347
  current = "";
1161
1348
  }
1162
1349
  });
1350
+ const decoder = new StringDecoder("utf8");
1163
1351
  for await (const chunk of entry.stream()) {
1164
- parser.write(Buffer.from(chunk).toString("utf8"));
1352
+ parser.write(decoder.write(Buffer.from(chunk)));
1165
1353
  }
1354
+ parser.write(decoder.end());
1166
1355
  parser.close();
1167
1356
  return values;
1168
1357
  }
1358
+ var EMPTY_XLSX_SHEET_STRUCTURE = {
1359
+ merged: [],
1360
+ hiddenRows: /* @__PURE__ */ new Set(),
1361
+ hiddenColumns: []
1362
+ };
1363
+ async function readXlsxSheetStructure(entry) {
1364
+ const merged = [];
1365
+ const hiddenRows = /* @__PURE__ */ new Set();
1366
+ const hiddenColumns = [];
1367
+ try {
1368
+ const parser = new SaxesParser();
1369
+ parser.on("opentag", (tag) => {
1370
+ const attributes = tag.attributes;
1371
+ if (xmlLocalName(tag.name) === "mergeCell") {
1372
+ const range = parseMergedRef(attributes.ref ?? "");
1373
+ if (range) merged.push(range);
1374
+ return;
1375
+ }
1376
+ if (xmlLocalName(tag.name) === "row") {
1377
+ const rowNumber = Number(attributes.r);
1378
+ if (isXlsxFlagSet(attributes.hidden) && Number.isInteger(rowNumber) && rowNumber >= 1) {
1379
+ hiddenRows.add(rowNumber);
1380
+ }
1381
+ return;
1382
+ }
1383
+ if (xmlLocalName(tag.name) === "col" && isXlsxFlagSet(attributes.hidden)) {
1384
+ const min = Number(attributes.min);
1385
+ const max = Number(attributes.max);
1386
+ if (Number.isInteger(min) && min >= 1) {
1387
+ hiddenColumns.push({ min, max: Number.isInteger(max) && max >= min ? max : min });
1388
+ }
1389
+ }
1390
+ });
1391
+ const decoder = new StringDecoder("utf8");
1392
+ for await (const chunk of entry.stream()) {
1393
+ parser.write(decoder.write(Buffer.from(chunk)));
1394
+ }
1395
+ parser.write(decoder.end());
1396
+ parser.close();
1397
+ } catch {
1398
+ return EMPTY_XLSX_SHEET_STRUCTURE;
1399
+ }
1400
+ return { merged, hiddenRows, hiddenColumns };
1401
+ }
1402
+ function isXlsxFlagSet(value) {
1403
+ return value === "1" || value === "true";
1404
+ }
1405
+ function parseMergedRef(ref) {
1406
+ const match = /^([A-Za-z]+)(\d+):([A-Za-z]+)(\d+)$/.exec(ref.trim());
1407
+ if (!match) return void 0;
1408
+ const left = xlsxColumnNumber(match[1]);
1409
+ const top = Number(match[2]);
1410
+ const right = xlsxColumnNumber(match[3]);
1411
+ const bottom = Number(match[4]);
1412
+ if (right < left || bottom < top || top < 1) return void 0;
1413
+ return { ref: ref.trim().toUpperCase(), top, bottom, left, right };
1414
+ }
1415
+ function xlsxColumnNumber(letters) {
1416
+ let column = 0;
1417
+ for (const letter of letters.toUpperCase()) column = column * 26 + (letter.charCodeAt(0) - 64);
1418
+ return column;
1419
+ }
1420
+ var MergedRegionTracker = class {
1421
+ constructor(ranges, fill) {
1422
+ this.fill = fill;
1423
+ this.pending = [...ranges].sort((left, right) => left.top - right.top || left.left - right.left);
1424
+ }
1425
+ fill;
1426
+ pending;
1427
+ next = 0;
1428
+ active = [];
1429
+ /** Advance to `rowNumber`, taking the anchor value of every range that starts on it. */
1430
+ observe(rowNumber, values) {
1431
+ while (this.next < this.pending.length && this.pending[this.next].top <= rowNumber) {
1432
+ this.active.push({ range: this.pending[this.next], anchor: void 0 });
1433
+ this.next += 1;
1434
+ }
1435
+ if (this.active.length === 0) return;
1436
+ this.active = this.active.filter((entry) => entry.range.bottom >= rowNumber);
1437
+ for (const entry of this.active) {
1438
+ if (entry.range.top === rowNumber) entry.anchor = values[entry.range.left - 1];
1439
+ }
1440
+ }
1441
+ /**
1442
+ * The 1-based columns whose cell on this row is empty only because a merge covers it, filled from
1443
+ * the anchor when enabled. `observe` must have run for the same row first.
1444
+ */
1445
+ covered(rowNumber, values) {
1446
+ const columns = [];
1447
+ for (const entry of this.active) {
1448
+ if (isMissing(entry.anchor)) continue;
1449
+ const { range } = entry;
1450
+ for (let column = range.left; column <= range.right; column += 1) {
1451
+ if (range.top === rowNumber && column === range.left) continue;
1452
+ if (!isMissing(values[column - 1])) continue;
1453
+ columns.push(column);
1454
+ if (this.fill) values[column - 1] = entry.anchor;
1455
+ }
1456
+ }
1457
+ return columns;
1458
+ }
1459
+ };
1169
1460
  function normalizeXlsxEntryPath(target) {
1170
1461
  const normalized = target.replace(/^\//, "");
1171
1462
  return normalized.startsWith("xl/") ? normalized : `xl/${normalized.replace(/^\.\//, "")}`;
1172
1463
  }
1173
- function parseXmlAttributes(source) {
1174
- const attributes = {};
1175
- for (const match of source.matchAll(/([\w:-]+)="([^"]*)"/g)) attributes[match[1]] = match[2];
1176
- return attributes;
1464
+ function xmlLocalName(qname) {
1465
+ const colon = qname.lastIndexOf(":");
1466
+ return colon === -1 ? qname : qname.slice(colon + 1);
1467
+ }
1468
+ function xmlAttributeByLocalName(attributes, local) {
1469
+ for (const [name, value] of Object.entries(attributes)) {
1470
+ if (xmlLocalName(name) === local) return value;
1471
+ }
1472
+ return void 0;
1473
+ }
1474
+ async function parseXmlTags(entry, onOpenTag) {
1475
+ const parser = new SaxesParser();
1476
+ parser.on("opentag", (tag) => {
1477
+ onOpenTag({ name: tag.name, attributes: tag.attributes });
1478
+ });
1479
+ const decoder = new StringDecoder("utf8");
1480
+ for await (const chunk of entry.stream()) {
1481
+ parser.write(decoder.write(Buffer.from(chunk)));
1482
+ }
1483
+ parser.write(decoder.end());
1484
+ parser.close();
1177
1485
  }
1178
1486
  function decodeXml(source) {
1179
1487
  return source.replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
@@ -1185,10 +1493,12 @@ async function streamXls(filePath, sheetName, onRow, options) {
1185
1493
  throw new CliValidationError("The requested Excel Sheet was not found.", { code: "LOCAL_DATA_SET_NOT_FOUND" });
1186
1494
  }
1187
1495
  let count = 0;
1496
+ const skipRows = Math.max(0, options.skipRows ?? 0);
1188
1497
  for (const name of names) {
1189
1498
  const sheet = workbook.Sheets[name];
1190
1499
  if (!sheet) continue;
1191
- const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: null });
1500
+ const allRows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: null });
1501
+ const rows = allRows.slice(skipRows);
1192
1502
  if (rows.length === 0) continue;
1193
1503
  let headers = options.headerNames;
1194
1504
  let start = 0;
@@ -1212,16 +1522,127 @@ async function streamXls(filePath, sheetName, onRow, options) {
1212
1522
  }
1213
1523
  return count;
1214
1524
  }
1215
- function normalizeExcelValue(value) {
1216
- if (value instanceof Date) return value;
1525
+ function excelDateToWallClock(value) {
1526
+ const pad = (part, width = 2) => String(part).padStart(width, "0");
1527
+ const date = `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}`;
1528
+ const [hours, minutes, seconds, ms] = [
1529
+ value.getUTCHours(),
1530
+ value.getUTCMinutes(),
1531
+ value.getUTCSeconds(),
1532
+ value.getUTCMilliseconds()
1533
+ ];
1534
+ if (hours === 0 && minutes === 0 && seconds === 0 && ms === 0) return date;
1535
+ const time = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
1536
+ return ms === 0 ? `${date} ${time}` : `${date} ${time}.${pad(ms, 3)}`;
1537
+ }
1538
+ function isExcelDateCell(value) {
1539
+ if (value instanceof Date) return true;
1540
+ return Boolean(value && typeof value === "object" && "result" in value && value.result instanceof Date);
1541
+ }
1542
+ function readExcelCellValue(cell) {
1543
+ if (cell.type !== import_exceljs.default.ValueType.Formula) return cell.value;
1544
+ const model = cell.model;
1545
+ if (!model || !("result" in model)) return cell.value;
1546
+ return { formula: model.formula ?? "", result: model.result };
1547
+ }
1548
+ var CELL_ISSUE_ORDER = ["formula_no_cached_value", "error_value", "unreadable_object"];
1549
+ var CELL_ISSUE_MESSAGES = {
1550
+ formula_no_cached_value: "hold a formula whose last computed result is not stored in the file, and were read as missing. Excel normally saves that result next to the formula; a file exported without it has no value to upload. Recalculate and re-export in Excel, or export values instead of formulas. This tool never evaluates a formula and never guesses a result.",
1551
+ error_value: "hold an Excel error value (#N/A, #DIV/0!, \u2026), and were read as missing. Fix them in the source file if those cells were meant to carry data.",
1552
+ unreadable_object: "arrived in a cell shape this tool does not recognize, and were read as missing rather than passed on as an object. Report the file if these cells do carry data."
1553
+ };
1554
+ function cellIssueWarnings(cellIssues) {
1555
+ const warnings = [];
1556
+ for (const issue of CELL_ISSUE_ORDER) {
1557
+ const byColumn = cellIssues.get(issue);
1558
+ if (!byColumn || byColumn.size === 0) continue;
1559
+ const columns = [...byColumn.entries()].map(([column, count]) => `${column} (${count})`).join(", ");
1560
+ const total = [...byColumn.values()].reduce((sum, count) => sum + count, 0);
1561
+ warnings.push(`${total} cell(s) ${CELL_ISSUE_MESSAGES[issue]} Affected columns: ${columns}.`);
1562
+ }
1563
+ return warnings;
1564
+ }
1565
+ function cellIssueCounts(cellIssues) {
1566
+ const counts = {};
1567
+ for (const issue of CELL_ISSUE_ORDER) {
1568
+ const byColumn = cellIssues.get(issue);
1569
+ if (!byColumn || byColumn.size === 0) continue;
1570
+ counts[issue] = Object.fromEntries(byColumn);
1571
+ }
1572
+ return counts;
1573
+ }
1574
+ var XLSX_STRUCTURE_SAMPLE_LIMIT = 10;
1575
+ function createXlsxStructureCollector() {
1576
+ return {
1577
+ mergedRanges: 0,
1578
+ mergedRefs: [],
1579
+ coveredCells: /* @__PURE__ */ new Map(),
1580
+ filled: false,
1581
+ hiddenRows: [],
1582
+ hiddenRowCount: 0,
1583
+ excludedHiddenRows: 0,
1584
+ hiddenColumns: /* @__PURE__ */ new Set()
1585
+ };
1586
+ }
1587
+ function xlsxStructureReport(collector) {
1588
+ const hasFindings = collector.mergedRanges > 0 || collector.hiddenRowCount > 0 || collector.hiddenColumns.size > 0;
1589
+ if (!hasFindings) return void 0;
1590
+ return {
1591
+ ...collector.mergedRanges > 0 ? {
1592
+ merged_ranges: collector.mergedRanges,
1593
+ merged_range_samples: collector.mergedRefs,
1594
+ merged_covered_cells: Object.fromEntries(collector.coveredCells),
1595
+ merged_cells_filled: collector.filled
1596
+ } : {},
1597
+ ...collector.hiddenRowCount > 0 ? {
1598
+ hidden_rows: collector.hiddenRowCount,
1599
+ hidden_row_samples: collector.hiddenRows,
1600
+ excluded_hidden_rows: collector.excludedHiddenRows
1601
+ } : {},
1602
+ ...collector.hiddenColumns.size > 0 ? { hidden_columns: [...collector.hiddenColumns] } : {}
1603
+ };
1604
+ }
1605
+ function xlsxStructureWarnings(collector, excludedColumns) {
1606
+ const warnings = [];
1607
+ const sample = (values, total) => values.join(", ") + (total > values.length ? ", \u2026" : "");
1608
+ if (collector.coveredCells.size > 0) {
1609
+ const columns = [...collector.coveredCells.entries()].map(([column, count]) => `${column} (${count})`).join(", ");
1610
+ const total = [...collector.coveredCells.values()].reduce((sum, count) => sum + count, 0);
1611
+ warnings.push(collector.filled ? `${total} cell(s) were empty only because a merged block covers them, and were filled from the value on the block's first row. Affected columns: ${columns}.` : `${total} cell(s) are empty only because a merged block covers them: Excel shows the value on the block's first row only, so every row below it reads as missing. Affected columns: ${columns}. Pass --fill-merged-cells to copy each block's value down its own range, or unmerge and fill the column in the source file.`);
1612
+ }
1613
+ if (collector.hiddenRowCount > 0) {
1614
+ const rows = sample(collector.hiddenRows, collector.hiddenRowCount);
1615
+ warnings.push(collector.excludedHiddenRows > 0 ? `Skipped ${collector.excludedHiddenRows} row(s) hidden in the source worksheet (source row ${rows}).` : `${collector.hiddenRowCount} row(s) are hidden in the source worksheet (source row ${rows}) and were read as data. Pass --exclude-hidden-rows to leave them out.`);
1616
+ }
1617
+ if (collector.hiddenColumns.size > 0) {
1618
+ const excluded = new Set(excludedColumns ?? []);
1619
+ const kept = [...collector.hiddenColumns].filter((column) => !excluded.has(column));
1620
+ if (kept.length > 0) {
1621
+ warnings.push(`${kept.length} column(s) are hidden in the source worksheet and were read as data: ${kept.join(", ")}. List them in the mapping's exclude_columns to leave them out.`);
1622
+ }
1623
+ }
1624
+ return warnings;
1625
+ }
1626
+ function readExcelCell(value) {
1627
+ if (value instanceof Date) return { value: excelDateToWallClock(value) };
1217
1628
  if (value && typeof value === "object") {
1218
- if ("result" in value) return value.result ?? null;
1219
- if ("text" in value) return String(value.text);
1629
+ if ("error" in value) return { value: null, issue: "error_value" };
1630
+ if ("formula" in value || "sharedFormula" in value) {
1631
+ if (!("result" in value)) return { value: null, issue: "formula_no_cached_value" };
1632
+ const result = value.result;
1633
+ if (result === null || result === void 0) return { value: null, issue: "formula_no_cached_value" };
1634
+ if (typeof result === "object" && "error" in result) return { value: null, issue: "error_value" };
1635
+ if (typeof result === "number" && Number.isNaN(result)) return { value: null, issue: "error_value" };
1636
+ if (result instanceof Date) return { value: excelDateToWallClock(result) };
1637
+ return { value: result };
1638
+ }
1639
+ if ("text" in value) return { value: String(value.text) };
1220
1640
  if ("richText" in value) {
1221
- return value.richText.map((part) => part.text).join("");
1641
+ return { value: value.richText.map((part) => part.text).join("") };
1222
1642
  }
1643
+ return { value: null, issue: "unreadable_object" };
1223
1644
  }
1224
- return value ?? null;
1645
+ return { value: value ?? null };
1225
1646
  }
1226
1647
  function dedupeHeaders(headers) {
1227
1648
  const counts = /* @__PURE__ */ new Map();
@@ -1263,7 +1684,11 @@ var MAPPING_VERSION = "ae-data-integration-mapping/v1";
1263
1684
  // src/commands/data-integration/mapping.ts
1264
1685
  var VALID_PROPERTY_NAME = /^[a-z][a-z0-9_]{0,49}$/;
1265
1686
  var VALID_CHILD_PROPERTY_NAME = /^[a-z][a-z0-9_]{0,49}(\.[a-z][a-z0-9_]{0,49})+$/;
1687
+ var VALID_EVENT_NAME = /^[A-Za-z][A-Za-z0-9_]{0,49}$/;
1266
1688
  var FLATTEN_SUPPORTED_FORMATS = ["csv", "tsv", "json", "jsonl", "xls", "xlsx"];
1689
+ var SKIP_ROWS_SUPPORTED_FORMATS = ["csv", "tsv", "xls", "xlsx"];
1690
+ var XLSX_STRUCTURE_SUPPORTED_FORMATS = ["xlsx"];
1691
+ var XLSX_STRUCTURE_FIELDS = ["fill_merged_cells", "exclude_hidden_rows"];
1267
1692
  function readLocalDataMapping(raw, options) {
1268
1693
  const trimmed = raw.trim();
1269
1694
  let text;
@@ -1319,7 +1744,7 @@ function validateMapping(value, options) {
1319
1744
  if (value.mode !== "user_set" && !value.event_name_field && !value.default_event_name) {
1320
1745
  throw mappingError("Track mappings require an event field or default event name.");
1321
1746
  }
1322
- if (value.default_event_name && !VALID_PROPERTY_NAME.test(value.default_event_name)) {
1747
+ if (value.default_event_name && !VALID_EVENT_NAME.test(value.default_event_name)) {
1323
1748
  throw mappingError("The default event name is not a legal AE event name.");
1324
1749
  }
1325
1750
  if (!Array.isArray(value.properties)) throw mappingError("Mapping properties must be an array.");
@@ -1353,7 +1778,7 @@ function validateMapping(value, options) {
1353
1778
  if (value.event_meta !== void 0) {
1354
1779
  if (!isRecord(value.event_meta)) throw mappingError("event_meta must be an object keyed by AE event name.");
1355
1780
  for (const [name, meta] of Object.entries(value.event_meta)) {
1356
- if (!VALID_PROPERTY_NAME.test(name) || !isRecord(meta) || meta.desc !== void 0 && (typeof meta.desc !== "string" || !meta.desc.trim() || meta.desc.length > 200) || meta.tag !== void 0 && (typeof meta.tag !== "string" || !meta.tag.trim() || meta.tag.length > 64)) {
1781
+ if (!VALID_EVENT_NAME.test(name) || !isRecord(meta) || meta.desc !== void 0 && (typeof meta.desc !== "string" || !meta.desc.trim() || meta.desc.length > 200) || meta.tag !== void 0 && (typeof meta.tag !== "string" || !meta.tag.trim() || meta.tag.length > 64)) {
1357
1782
  throw mappingError("event_meta entries need a legal AE event name and non-empty desc/tag strings.");
1358
1783
  }
1359
1784
  }
@@ -1366,7 +1791,7 @@ function validateMapping(value, options) {
1366
1791
  if (!isStringMap(map)) throw mappingError(`value_mapping.${key} must map strings to strings.`);
1367
1792
  if (key === "event_name") {
1368
1793
  for (const target of Object.values(map)) {
1369
- if (!VALID_PROPERTY_NAME.test(target)) {
1794
+ if (!VALID_EVENT_NAME.test(target)) {
1370
1795
  throw mappingError("value_mapping.event_name values must be legal AE event names.");
1371
1796
  }
1372
1797
  }
@@ -1399,6 +1824,23 @@ function validateMapping(value, options) {
1399
1824
  if (value.headers !== void 0 && !isNonEmptyStringArray(value.headers, true)) {
1400
1825
  throw mappingError("headers must be a non-empty array of unique strings.");
1401
1826
  }
1827
+ if (value.skip_rows !== void 0) {
1828
+ if (typeof value.skip_rows !== "number" || !Number.isInteger(value.skip_rows) || value.skip_rows < 1) {
1829
+ throw mappingError("skip_rows must be a positive integer.");
1830
+ }
1831
+ if (!SKIP_ROWS_SUPPORTED_FORMATS.includes(String(value.source.format))) {
1832
+ throw mappingError(`skip_rows is not supported for ${String(value.source.format)} input.`);
1833
+ }
1834
+ }
1835
+ for (const field of XLSX_STRUCTURE_FIELDS) {
1836
+ if (value[field] === void 0) continue;
1837
+ if (typeof value[field] !== "boolean") {
1838
+ throw mappingError(`${field} must be a boolean when provided.`);
1839
+ }
1840
+ if (!XLSX_STRUCTURE_SUPPORTED_FORMATS.includes(String(value.source.format))) {
1841
+ throw mappingError(`${field} is not supported for ${String(value.source.format)} input.`);
1842
+ }
1843
+ }
1402
1844
  if (value.missing_time !== void 0 && value.missing_time !== "now") {
1403
1845
  throw mappingError('missing_time must be "now" when provided.');
1404
1846
  }
@@ -1433,6 +1875,9 @@ function validateMapping(value, options) {
1433
1875
  function isValidAeName(value) {
1434
1876
  return VALID_PROPERTY_NAME.test(value);
1435
1877
  }
1878
+ function isValidEventName(value) {
1879
+ return VALID_EVENT_NAME.test(value);
1880
+ }
1436
1881
  function sourceColumns(mapping) {
1437
1882
  const columns = /* @__PURE__ */ new Set();
1438
1883
  const flattenOut = new Set(Object.keys(mapping.flatten_rules ?? {}));
@@ -1674,6 +2119,18 @@ var GLOBAL_UNIQUE_SAMPLE_LIMIT = 2e5;
1674
2119
  var IDENTITY_MAX_LENGTH = 128;
1675
2120
  var COLUMN_SAMPLE_LIMIT = 5;
1676
2121
  var SAMPLE_TRUNCATE_LENGTH = 40;
2122
+ var VALUE_FREQUENCY_LIMIT = 200;
2123
+ var VALUE_FREQUENCY_TOP = 10;
2124
+ var NUMERIC_SAMPLE_LIMIT = 5e3;
2125
+ var SUMMARY_ROW_CANDIDATE_LIMIT = 50;
2126
+ var SUMMARY_ROW_WARNING_LIMIT = 10;
2127
+ var SUMMARY_LABEL_MAX_LENGTH = 24;
2128
+ var SUMMARY_LABELS_CJK = ["\u5408\u8BA1", "\u603B\u8BA1", "\u5C0F\u8BA1", "\u6C47\u603B"];
2129
+ var SUMMARY_LABELS_LATIN = /* @__PURE__ */ new Set(["total", "totals", "subtotal", "sub total", "grand total", "sum"]);
2130
+ var DUPLICATE_KEY_TRACK_LIMIT = 2e5;
2131
+ var DUPLICATE_GROUP_LIMIT = 20;
2132
+ var DUPLICATE_GROUP_ROW_LIMIT = 10;
2133
+ var DUPLICATE_WARNING_GROUP_LIMIT = 3;
1677
2134
  var NESTED_TREE_SAMPLE_LIMIT = 1e3;
1678
2135
  var USER_PROFILE_TYPES = /* @__PURE__ */ new Set([
1679
2136
  "user_set",
@@ -1708,10 +2165,17 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1708
2165
  let nestedObjects = [];
1709
2166
  let nestedSeen = 0;
1710
2167
  const delimitedNested = /* @__PURE__ */ new Map();
2168
+ const excelDateColumns = /* @__PURE__ */ new Set();
2169
+ const cellIssues = /* @__PURE__ */ new Map();
2170
+ const xlsxStructure = input.format === "xlsx" ? createXlsxStructureCollector() : void 0;
2171
+ const summaryCandidates = [];
2172
+ let summaryCandidatesTruncated = false;
2173
+ let duplicateKeys;
2174
+ let duplicateKeysResolved = false;
1711
2175
  await streamLocalDataRows(
1712
2176
  input,
1713
2177
  dataSet,
1714
- (row) => {
2178
+ (row, rowNumber) => {
1715
2179
  rowCount += 1;
1716
2180
  if (collectNestedTree && row !== null && typeof row === "object" && !Array.isArray(row)) {
1717
2181
  nestedSeen += 1;
@@ -1722,6 +2186,10 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1722
2186
  if (slot < NESTED_TREE_SAMPLE_LIMIT) nestedObjects[slot] = row;
1723
2187
  }
1724
2188
  }
2189
+ let labelColumn;
2190
+ let numericCells;
2191
+ let keyColumnsPresent = false;
2192
+ let keyColumnsFilled = false;
1725
2193
  for (const name of /* @__PURE__ */ new Set([...columns.keys(), ...Object.keys(row)])) {
1726
2194
  let accumulator = columns.get(name);
1727
2195
  if (!accumulator) {
@@ -1738,11 +2206,23 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1738
2206
  ipValidCount: 0,
1739
2207
  lanIpCount: 0,
1740
2208
  samples: [],
1741
- sampleSet: /* @__PURE__ */ new Set()
2209
+ sampleSet: /* @__PURE__ */ new Set(),
2210
+ valueCounts: /* @__PURE__ */ new Map(),
2211
+ valueCountsOverflow: false,
2212
+ numericCount: 0,
2213
+ numericSum: 0,
2214
+ numericMin: Number.POSITIVE_INFINITY,
2215
+ numericMax: Number.NEGATIVE_INFINITY,
2216
+ numericSamples: [],
2217
+ keyLike: matchesName(name, ACCOUNT_NAMES) || matchesName(name, DISTINCT_NAMES) || matchesName(name, TIME_NAMES)
1742
2218
  };
1743
2219
  columns.set(name, accumulator);
1744
2220
  }
1745
2221
  const value = row[name];
2222
+ if (accumulator.keyLike) {
2223
+ keyColumnsPresent = true;
2224
+ if (!isMissing2(value)) keyColumnsFilled = true;
2225
+ }
1746
2226
  if (isMissing2(value)) {
1747
2227
  accumulator.missing += 1;
1748
2228
  continue;
@@ -1758,7 +2238,17 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1758
2238
  } else {
1759
2239
  accumulator.uniqueOverflow = true;
1760
2240
  }
1761
- if (options.collectSamples) recordSample(accumulator, value);
2241
+ if (options.collectSamples) {
2242
+ recordSample(accumulator, value);
2243
+ recordValueFrequency(accumulator, value);
2244
+ }
2245
+ if (type === "number") {
2246
+ const numeric = Number(value);
2247
+ recordNumeric(accumulator, numeric, options.collectSamples === true);
2248
+ if (Number.isFinite(numeric)) (numericCells ??= []).push([name, numeric]);
2249
+ } else if (labelColumn === void 0 && matchesSummaryLabel(value)) {
2250
+ labelColumn = name;
2251
+ }
1762
2252
  if (collectDelimitedTree && typeof value === "string") {
1763
2253
  const trimmed = value.trim();
1764
2254
  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
@@ -1801,6 +2291,19 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1801
2291
  if (normalized) recognizedRecordTypes.add(normalized);
1802
2292
  }
1803
2293
  }
2294
+ if (labelColumn !== void 0 || keyColumnsPresent && !keyColumnsFilled && numericCells !== void 0) {
2295
+ if (summaryCandidates.length < SUMMARY_ROW_CANDIDATE_LIMIT) {
2296
+ summaryCandidates.push({ row: rowNumber, labelColumn, numericCells: numericCells ?? [] });
2297
+ } else {
2298
+ summaryCandidatesTruncated = true;
2299
+ }
2300
+ }
2301
+ if (!duplicateKeysResolved) {
2302
+ duplicateKeysResolved = true;
2303
+ const keyColumns = resolveDuplicateKeyColumns(Object.keys(row), options.duplicateKeyFields);
2304
+ if (keyColumns) duplicateKeys = createDuplicateKeyTracker(keyColumns);
2305
+ }
2306
+ if (duplicateKeys) recordDuplicateKey(duplicateKeys, row, rowNumber);
1804
2307
  },
1805
2308
  {
1806
2309
  delimiter: options.delimiter,
@@ -1809,7 +2312,13 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1809
2312
  noHeader: options.noHeader,
1810
2313
  flattenRules: options.flattenRules,
1811
2314
  mergeSheets: options.mergeSheets,
1812
- warnRagged: options.warnRagged
2315
+ excelDateColumns,
2316
+ cellIssues,
2317
+ warnRagged: options.warnRagged,
2318
+ skipRows: options.skipRows,
2319
+ xlsxStructure,
2320
+ fillMergedCells: options.fillMergedCells,
2321
+ excludeHiddenRows: options.excludeHiddenRows
1813
2322
  }
1814
2323
  );
1815
2324
  const delimitedNestedTree = /* @__PURE__ */ new Map();
@@ -1836,6 +2345,9 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1836
2345
  recognizedRecordTypes,
1837
2346
  sourceTimezone,
1838
2347
  headerNames: options.headerNames,
2348
+ skipRows: options.skipRows,
2349
+ fillMergedCells: options.fillMergedCells,
2350
+ excludeHiddenRows: options.excludeHiddenRows,
1839
2351
  timeFormatByColumn,
1840
2352
  nestedTree
1841
2353
  });
@@ -1851,6 +2363,16 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1851
2363
  if (column.lanIpCount > 0) warnings.push(`${column.name}: ${column.lanIpCount} value(s) are private/LAN IP addresses; AE cannot resolve geo information for them.`);
1852
2364
  }
1853
2365
  }
2366
+ if (excelDateColumns.size > 0) {
2367
+ warnings.push(`${[...excelDateColumns].join(", ")}: read as date/time value(s) from the Excel number format rather than as Excel serial numbers. If these columns were uploaded to AE before, they may have been received as number properties, whose type is now locked; confirm with the user before mapping them.`);
2368
+ }
2369
+ warnings.push(...cellIssueWarnings(cellIssues));
2370
+ if (xlsxStructure) warnings.push(...xlsxStructureWarnings(xlsxStructure));
2371
+ const structureReport = xlsxStructure ? xlsxStructureReport(xlsxStructure) : void 0;
2372
+ const summaryRows = confirmSummaryRows(summaryCandidates, columns);
2373
+ if (summaryRows.length > 0) warnings.push(summaryRowWarning(summaryRows, summaryCandidatesTruncated));
2374
+ const duplicateKeyReport = duplicateKeys ? buildDuplicateKeyReport(duplicateKeys) : void 0;
2375
+ if (duplicateKeyReport) warnings.push(duplicateKeyWarning(duplicateKeyReport));
1854
2376
  if (rowCount === 0) warnings.push("The selected data set contains no data rows.");
1855
2377
  return {
1856
2378
  version: "ae-local-data-profile/v1",
@@ -1869,7 +2391,10 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1869
2391
  (recommendedMapping.account_id_field || recommendedMapping.distinct_id_field) && recommendedMapping.time.field
1870
2392
  ),
1871
2393
  warnings,
1872
- ...nestedTree ? { nested_tree: nestedTree } : {}
2394
+ ...nestedTree ? { nested_tree: nestedTree } : {},
2395
+ ...structureReport ? { xlsx_structure: structureReport } : {},
2396
+ ...summaryRows.length > 0 ? { summary_rows: summaryRows } : {},
2397
+ ...duplicateKeyReport ? { duplicate_keys: duplicateKeyReport } : {}
1873
2398
  };
1874
2399
  }
1875
2400
  function normalizeAeName(input, fallback) {
@@ -1964,6 +2489,9 @@ function recommendMapping(input) {
1964
2489
  },
1965
2490
  ...time ? { time_format: input.timeFormatByColumn.get(time.name) } : {},
1966
2491
  ...input.headerNames && input.headerNames.length > 0 ? { headers: input.headerNames } : {},
2492
+ ...input.skipRows ? { skip_rows: input.skipRows } : {},
2493
+ ...input.fillMergedCells ? { fill_merged_cells: true } : {},
2494
+ ...input.excludeHiddenRows ? { exclude_hidden_rows: true } : {},
1967
2495
  ...recordType ? { record_type_field: recordType.name } : {},
1968
2496
  ...event ? { event_name_field: event.name } : {},
1969
2497
  ...mode !== "user_set" && !event ? { default_event_name: normalizeAeName(defaultEventSource, "local_event") } : {},
@@ -1976,7 +2504,7 @@ function formatColumnProfile(column, rowCount, includeSamples) {
1976
2504
  const nonNullTypes = [...column.types.entries()].filter(([type]) => type !== "null").sort((left, right) => right[1] - left[1]);
1977
2505
  const inferred = nonNullTypes.length === 0 ? "unknown" : nonNullTypes.length === 1 ? nonNullTypes[0][0] : compatibleTypes(nonNullTypes.map(([type]) => type)) ? nonNullTypes[0][0] : "mixed";
1978
2506
  const finalType = inferred === "number" && idLikeColumn(column.name) ? "string" : inferred;
1979
- return {
2507
+ const profile = {
1980
2508
  name: column.name,
1981
2509
  inferred_type: finalType,
1982
2510
  missing_count: column.missing,
@@ -1988,6 +2516,15 @@ function formatColumnProfile(column, rowCount, includeSamples) {
1988
2516
  time_parse_ratio: ratio(column.timeParseCount, column.nonMissing),
1989
2517
  ...includeSamples ? { samples: column.samples } : {}
1990
2518
  };
2519
+ if (includeSamples) {
2520
+ const frequency = formatValueFrequency(column);
2521
+ if (frequency) profile.value_frequency = frequency;
2522
+ if (finalType === "number") {
2523
+ const summary = formatNumericSummary(column);
2524
+ if (summary) profile.numeric_summary = summary;
2525
+ }
2526
+ }
2527
+ return profile;
1991
2528
  }
1992
2529
  function findCandidate(columns, names) {
1993
2530
  return columns.find((column) => matchesName(column.name, names));
@@ -2202,6 +2739,176 @@ function recordSample(accumulator, value) {
2202
2739
  accumulator.sampleSet.add(text);
2203
2740
  accumulator.samples.push(text);
2204
2741
  }
2742
+ function recordValueFrequency(accumulator, value) {
2743
+ if (accumulator.valueCountsOverflow) return;
2744
+ const text = truncateSample2(value);
2745
+ const seen = accumulator.valueCounts.get(text);
2746
+ if (seen !== void 0) {
2747
+ accumulator.valueCounts.set(text, seen + 1);
2748
+ return;
2749
+ }
2750
+ if (accumulator.valueCounts.size >= VALUE_FREQUENCY_LIMIT) {
2751
+ accumulator.valueCountsOverflow = true;
2752
+ accumulator.valueCounts.clear();
2753
+ return;
2754
+ }
2755
+ accumulator.valueCounts.set(text, 1);
2756
+ }
2757
+ function recordNumeric(accumulator, numeric, retainForQuantiles) {
2758
+ if (!Number.isFinite(numeric)) return;
2759
+ accumulator.numericCount += 1;
2760
+ accumulator.numericSum += numeric;
2761
+ if (numeric < accumulator.numericMin) accumulator.numericMin = numeric;
2762
+ if (numeric > accumulator.numericMax) accumulator.numericMax = numeric;
2763
+ if (!retainForQuantiles) return;
2764
+ if (accumulator.numericSamples.length < NUMERIC_SAMPLE_LIMIT) {
2765
+ accumulator.numericSamples.push(numeric);
2766
+ return;
2767
+ }
2768
+ const slot = randomInt(accumulator.numericCount);
2769
+ if (slot < NUMERIC_SAMPLE_LIMIT) accumulator.numericSamples[slot] = numeric;
2770
+ }
2771
+ function formatValueFrequency(accumulator) {
2772
+ if (accumulator.valueCountsOverflow || accumulator.valueCounts.size === 0) return void 0;
2773
+ const ordered = [...accumulator.valueCounts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
2774
+ if (ordered[0][1] < 2) return void 0;
2775
+ return ordered.slice(0, VALUE_FREQUENCY_TOP).map(([value, count]) => ({ value, count, ratio: ratio(count, accumulator.nonMissing) }));
2776
+ }
2777
+ function formatNumericSummary(accumulator) {
2778
+ if (accumulator.numericCount === 0 || accumulator.numericSamples.length === 0) return void 0;
2779
+ const sorted = [...accumulator.numericSamples].sort((left, right) => left - right);
2780
+ return {
2781
+ count: accumulator.numericCount,
2782
+ min: round(accumulator.numericMin),
2783
+ max: round(accumulator.numericMax),
2784
+ sum: round(accumulator.numericSum),
2785
+ mean: round(accumulator.numericSum / accumulator.numericCount),
2786
+ p25: round(quantile(sorted, 0.25)),
2787
+ median: round(quantile(sorted, 0.5)),
2788
+ p75: round(quantile(sorted, 0.75)),
2789
+ quantiles_approximate: accumulator.numericCount > accumulator.numericSamples.length
2790
+ };
2791
+ }
2792
+ function quantile(sorted, fraction) {
2793
+ const position = (sorted.length - 1) * fraction;
2794
+ const lower = Math.floor(position);
2795
+ const upper = Math.ceil(position);
2796
+ if (lower === upper) return sorted[lower];
2797
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
2798
+ }
2799
+ function round(value) {
2800
+ return Number.isFinite(value) ? Number(value.toFixed(6)) : value;
2801
+ }
2802
+ function matchesSummaryLabel(value) {
2803
+ if (typeof value !== "string") return false;
2804
+ const text = value.trim().replace(/[\s::、,,.。]+$/u, "");
2805
+ if (text.length === 0 || text.length > SUMMARY_LABEL_MAX_LENGTH) return false;
2806
+ if (SUMMARY_LABELS_CJK.some((label) => text.startsWith(label))) return true;
2807
+ return SUMMARY_LABELS_LATIN.has(text.toLowerCase());
2808
+ }
2809
+ function confirmSummaryRows(candidates, columns) {
2810
+ const confirmed = [];
2811
+ for (const candidate of candidates) {
2812
+ const totalColumns = [];
2813
+ for (const [name, value] of candidate.numericCells) {
2814
+ const accumulator = columns.get(name);
2815
+ if (!accumulator || accumulator.numericCount < 3 || value === 0) continue;
2816
+ const total = accumulator.numericSum;
2817
+ if (Math.abs(value * 2 - total) <= Math.max(1e-6, Math.abs(total) * 1e-9)) totalColumns.push(name);
2818
+ }
2819
+ const signals = [];
2820
+ if (candidate.labelColumn !== void 0) signals.push("total_label");
2821
+ if (totalColumns.length > 0) signals.push("column_total");
2822
+ if (signals.length === 0) continue;
2823
+ confirmed.push({
2824
+ row: candidate.row,
2825
+ signals,
2826
+ ...candidate.labelColumn !== void 0 ? { label_column: candidate.labelColumn } : {},
2827
+ ...totalColumns.length > 0 ? { total_columns: totalColumns } : {}
2828
+ });
2829
+ }
2830
+ return confirmed;
2831
+ }
2832
+ function summaryRowWarning(rows, truncated) {
2833
+ const shown = rows.slice(0, SUMMARY_ROW_WARNING_LIMIT).map((entry) => entry.row);
2834
+ const remaining = rows.length - shown.length;
2835
+ const labelled = rows.filter((entry) => entry.signals.includes("total_label")).length;
2836
+ const totals = rows.filter((entry) => entry.signals.includes("column_total")).length;
2837
+ const reasons = [
2838
+ labelled > 0 ? `${labelled} carry a total-like label (\u5408\u8BA1 / \u603B\u8BA1 / \u5C0F\u8BA1 / \u6C47\u603B / Total / Subtotal)` : void 0,
2839
+ totals > 0 ? `${totals} hold a number equal to the total of its column's other rows` : void 0
2840
+ ].filter((reason) => reason !== void 0).join("; ");
2841
+ const scope = truncated ? ` The scan stopped after ${SUMMARY_ROW_CANDIDATE_LIMIT} candidate rows, so there may be more.` : "";
2842
+ return `${rows.length} row(s) read as a summary line rather than an observation: ${reasons}. Rows ${shown.join(", ")}${remaining > 0 ? ` and ${remaining} more` : ""} (data-row ordinals, the numbering invalid.rows.jsonl and --salvage-from use; see summary_rows).${scope} Nothing was removed and nothing changed: convert writes these rows as records, and every number reported for their columns counts them, so a real total row makes that column's sum twice its actual total and its max the total. There is no flag that drops a data row; when the user confirms a row is a total, remove it from the source file or re-export without it, then inspect again.`;
2843
+ }
2844
+ function resolveDuplicateKeyColumns(names, explicit) {
2845
+ if (explicit && explicit.length > 0) {
2846
+ const unique = [...new Set(explicit.filter((name) => name.length > 0))];
2847
+ return unique.length > 1 ? unique : void 0;
2848
+ }
2849
+ const identity = names.find((name) => matchesName(name, ACCOUNT_NAMES)) ?? names.find((name) => matchesName(name, DISTINCT_NAMES));
2850
+ const time = names.find((name) => matchesName(name, TIME_NAMES));
2851
+ if (!identity || !time) return void 0;
2852
+ const event = names.find((name) => matchesName(name, EVENT_NAMES));
2853
+ return event ? [identity, time, event] : [identity, time];
2854
+ }
2855
+ function createDuplicateKeyTracker(columns) {
2856
+ return { columns, firstRow: /* @__PURE__ */ new Map(), groups: /* @__PURE__ */ new Map(), checkedRows: 0, overflow: false };
2857
+ }
2858
+ function recordDuplicateKey(tracker, row, rowNumber) {
2859
+ const parts = [];
2860
+ for (const name of tracker.columns) {
2861
+ const value = row[name];
2862
+ if (isMissing2(value)) return;
2863
+ parts.push(sampleText(value).trim());
2864
+ }
2865
+ tracker.checkedRows += 1;
2866
+ const hash = createHash2("sha256").update(JSON.stringify(parts)).digest("hex");
2867
+ const group = tracker.groups.get(hash);
2868
+ if (group) {
2869
+ group.count += 1;
2870
+ if (group.rows.length < DUPLICATE_GROUP_ROW_LIMIT) group.rows.push(rowNumber);
2871
+ else group.rowsTruncated = true;
2872
+ return;
2873
+ }
2874
+ const firstRow = tracker.firstRow.get(hash);
2875
+ if (firstRow !== void 0) {
2876
+ tracker.groups.set(hash, { count: 2, rows: [firstRow, rowNumber], rowsTruncated: false });
2877
+ return;
2878
+ }
2879
+ if (tracker.firstRow.size >= DUPLICATE_KEY_TRACK_LIMIT) {
2880
+ tracker.overflow = true;
2881
+ return;
2882
+ }
2883
+ tracker.firstRow.set(hash, rowNumber);
2884
+ }
2885
+ function buildDuplicateKeyReport(tracker) {
2886
+ if (tracker.groups.size === 0) return void 0;
2887
+ const ordered = [...tracker.groups.entries()].sort(([, left], [, right]) => right.count - left.count || left.rows[0] - right.rows[0]);
2888
+ let extraRows = 0;
2889
+ for (const [, group] of ordered) extraRows += group.count - 1;
2890
+ const groups = ordered.slice(0, DUPLICATE_GROUP_LIMIT).map(([hash, group]) => ({
2891
+ key_hash: hash.slice(0, 16),
2892
+ count: group.count,
2893
+ rows: group.rows,
2894
+ ...group.rowsTruncated ? { rows_truncated: true } : {}
2895
+ }));
2896
+ return {
2897
+ key_columns: tracker.columns,
2898
+ checked_rows: tracker.checkedRows,
2899
+ duplicate_groups: ordered.length,
2900
+ extra_rows: extraRows,
2901
+ groups,
2902
+ ...ordered.length > DUPLICATE_GROUP_LIMIT ? { groups_truncated: true } : {},
2903
+ ...tracker.overflow ? { tracking_truncated: true } : {}
2904
+ };
2905
+ }
2906
+ function duplicateKeyWarning(report) {
2907
+ const examples = report.groups.slice(0, DUPLICATE_WARNING_GROUP_LIMIT).map((group) => `rows ${group.rows.join(", ")}${group.rows_truncated ? ", \u2026" : ""} (${group.count}\xD7)`).join("; ");
2908
+ const remaining = report.duplicate_groups - Math.min(report.groups.length, DUPLICATE_WARNING_GROUP_LIMIT);
2909
+ const scope = report.tracking_truncated ? ` Tracking stopped after ${DUPLICATE_KEY_TRACK_LIMIT} distinct keys, so there may be more.` : "";
2910
+ return `${report.duplicate_groups} business key(s) appear on more than one row, ${report.extra_rows} extra row(s) in total, over ${report.checked_rows} row(s) that carried a value in every key column. Key: ${report.key_columns.join(" + ")}. Examples: ${examples}${remaining > 0 ? ` and ${remaining} more group(s)` : ""} (data-row ordinals, the numbering invalid.rows.jsonl and --salvage-from use; see duplicate_keys).${scope} Nothing was removed: repeated rows are sometimes real (one order line per product), and AE appends every accepted event with no way to un-send it, so this has to be settled before upload. Ask the user whether the repeats are separate observations; if they are not, remove them from the source file and inspect again. Values are compared as written, so two spellings of the same instant are two different keys, and a source with a unique key of its own (an order id) is not compared on it unless the mapping names it.`;
2911
+ }
2205
2912
  function truncateSample2(value) {
2206
2913
  const text = sampleText(value);
2207
2914
  if (text.length <= SAMPLE_TRUNCATE_LENGTH) return text;
@@ -2239,9 +2946,21 @@ var dataIntegrationInspect = {
2239
2946
  { name: "data-set", type: "string", sensitive: true, desc: "Sheet or JSON Path ID returned by a discovery-only inspection." },
2240
2947
  { name: "source-timezone", type: "string", default: "Asia/Shanghai", desc: "IANA timezone used to interpret source times without an offset." },
2241
2948
  { name: "headers", type: "string", sensitive: true, desc: "Comma-separated explicit column names for a headerless file; the first row is data." },
2242
- { name: "headerless", type: "boolean", default: false, desc: "Treat the first row as data and auto-generate col_1..col_N names." }
2949
+ { name: "headerless", type: "boolean", default: false, desc: "Treat the first row as data and auto-generate col_1..col_N names." },
2950
+ { name: "skip-rows", type: "number", default: 0, desc: "Discard the first N rows before the header row is read, for exports with a title or banner above the header. Delimited and Excel only." },
2951
+ { name: "fill-merged-cells", type: "boolean", default: false, desc: "Copy each merged block value into the cells its own range covers, so rows below the first row of a merged block keep the value Excel displays. XLSX only; off by default because those cells are empty in the file." },
2952
+ { name: "exclude-hidden-rows", type: "boolean", default: false, desc: "Leave rows hidden in the source worksheet out of the profile. XLSX only; off by default because a hidden row may still be real data." }
2243
2953
  ],
2244
2954
  risk: "read",
2955
+ validate: (ctx) => {
2956
+ const skipRows = ctx.num("skip-rows");
2957
+ if (!Number.isInteger(skipRows) || skipRows < 0) {
2958
+ throw new CliValidationError("Skipped rows must be a non-negative integer.", {
2959
+ code: "LOCAL_DATA_SKIP_ROWS_INVALID",
2960
+ location: { field: "skip-rows" }
2961
+ });
2962
+ }
2963
+ },
2245
2964
  // Fast size/time pre-check (stat + format/encoding sniff only — no sha256, no profile).
2246
2965
  // Lets agents surface the estimate before committing to a multi-minute full inspection.
2247
2966
  dryRun: async (ctx) => {
@@ -2272,46 +2991,69 @@ var dataIntegrationInspect = {
2272
2991
  const noHeader = ctx.bool("headerless");
2273
2992
  const sourceTimezone = ctx.str("source-timezone");
2274
2993
  const requested = ctx.str("data-set").trim() || void 0;
2994
+ const skipRows = ctx.num("skip-rows");
2995
+ const fillMergedCells = ctx.bool("fill-merged-cells");
2996
+ const excludeHiddenRows = ctx.bool("exclude-hidden-rows");
2275
2997
  if (inputFiles.length === 1) {
2276
2998
  const input = await inspectLocalDataInput(inputFiles[0]);
2277
2999
  const headerConsistency = await readExcelHeaderConsistency(input);
3000
+ const excludedSheets = summarizeExcludedSheets(input);
2278
3001
  if (!requested && input.dataSets.length > 1) {
2279
3002
  return {
2280
3003
  version: "ae-local-data-profile/v1",
2281
3004
  selection_required: true,
2282
3005
  source: { format: input.format, size_bytes: input.sizeBytes, sha256: input.sha256 },
2283
3006
  data_sets: input.dataSets,
3007
+ ...excludedSheets,
2284
3008
  ...headerConsistency ?? {},
2285
3009
  next_step: "Run inspect again with --data-set, then review the recommended mapping."
2286
3010
  };
2287
3011
  }
2288
3012
  const dataSet = selectDataSet(input, requested);
2289
- const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input);
3013
+ const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input, skipRows);
3014
+ const headerSignal = headerNames || noHeader ? void 0 : await detectHeaderSignal(input, dataSet, skipRows);
2290
3015
  const profile = await profileLocalData(input, dataSet, sourceTimezone, {
2291
3016
  collectSamples: true,
2292
3017
  collectNestedTree: true,
2293
3018
  headerNames,
2294
- noHeader: noHeader || Boolean(headerPresence)
3019
+ noHeader: noHeader || Boolean(headerPresence),
3020
+ skipRows,
3021
+ fillMergedCells,
3022
+ excludeHiddenRows
2295
3023
  });
2296
3024
  const annotated = headerPresence ? annotateHeaderless(profile, headerPresence) : profile;
2297
- return headerConsistency ? { ...annotated, ...headerConsistency } : annotated;
3025
+ return {
3026
+ ...annotated,
3027
+ ...excludedSheets,
3028
+ ...headerConsistency ?? {},
3029
+ ...annotateRowSkips(annotated.warnings, skipRows, headerSignal)
3030
+ };
2298
3031
  }
2299
3032
  const files = [];
2300
3033
  for (const inputFile of inputFiles) {
2301
3034
  const input = await inspectLocalDataInput(inputFile);
2302
3035
  const dataSet = selectDataSet(input);
2303
- const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input);
3036
+ const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input, skipRows);
3037
+ const headerSignal = headerNames || noHeader ? void 0 : await detectHeaderSignal(input, dataSet, skipRows);
2304
3038
  const profile = await profileLocalData(input, dataSet, sourceTimezone, {
2305
3039
  collectSamples: true,
2306
3040
  collectNestedTree: true,
2307
3041
  headerNames,
2308
- noHeader: noHeader || Boolean(headerPresence)
3042
+ noHeader: noHeader || Boolean(headerPresence),
3043
+ skipRows,
3044
+ fillMergedCells,
3045
+ excludeHiddenRows
2309
3046
  });
2310
3047
  const annotated = headerPresence ? annotateHeaderless(profile, headerPresence) : profile;
2311
3048
  const headerConsistency = await readExcelHeaderConsistency(input);
2312
3049
  files.push({
2313
3050
  file: basename3(inputFile),
2314
- profile: headerConsistency ? { ...annotated, ...headerConsistency } : annotated
3051
+ profile: {
3052
+ ...annotated,
3053
+ ...summarizeExcludedSheets(input),
3054
+ ...headerConsistency ?? {},
3055
+ ...annotateRowSkips(annotated.warnings, skipRows, headerSignal)
3056
+ }
2315
3057
  });
2316
3058
  }
2317
3059
  return {
@@ -2322,6 +3064,13 @@ var dataIntegrationInspect = {
2322
3064
  };
2323
3065
  }
2324
3066
  };
3067
+ function summarizeExcludedSheets(input) {
3068
+ const excluded = input.excludedDataSets ?? [];
3069
+ if (excluded.length === 0) return {};
3070
+ return {
3071
+ excluded_sheets: excluded.map((dataSet) => ({ name: dataSet.label, reason: "hidden", data_set: dataSet.id }))
3072
+ };
3073
+ }
2325
3074
  async function readExcelHeaderConsistency(input) {
2326
3075
  if (input.format !== "xls" && input.format !== "xlsx") return void 0;
2327
3076
  return summarizeHeaderConsistency(await readExcelSheetHeaders(input.filePath, input.format));
@@ -2332,10 +3081,11 @@ function summarizeHeaderConsistency(sheets) {
2332
3081
  const allSame = sheets.every((sheet) => JSON.stringify(sheet.headers) === first);
2333
3082
  return allSame ? { header_consistency: "all_same" } : { header_consistency: "different", header_details: sheets.map((sheet) => ({ name: sheet.name, headers: sheet.headers })) };
2334
3083
  }
2335
- function detectHeaderPresence(input) {
3084
+ function detectHeaderPresence(input, skipRows = 0) {
2336
3085
  if (input.format !== "csv" && input.format !== "tsv") return void 0;
2337
3086
  const delimiter = input.delimiter ?? (input.format === "tsv" ? " " : ",");
2338
- const records = peekDelimitedRecords(input.filePath, { delimiter, encoding: input.encoding, limit: 10 });
3087
+ const peeked = peekDelimitedRecords(input.filePath, { delimiter, encoding: input.encoding, limit: 10 + skipRows });
3088
+ const records = peeked.slice(skipRows);
2339
3089
  const detection = detectHeaderRow(records);
2340
3090
  if (detection.hasHeaders) return void 0;
2341
3091
  return {
@@ -2343,6 +3093,41 @@ function detectHeaderPresence(input) {
2343
3093
  autoHeaders: (records[0] ?? []).map((_, index) => `col_${index + 1}`)
2344
3094
  };
2345
3095
  }
3096
+ async function detectHeaderSignal(input, dataSet, skipRows) {
3097
+ const rows = await peekHeaderRows(input, dataSet, skipRows);
3098
+ if (!rows || rows.length === 0) return void 0;
3099
+ const titleRows = detectLeadingTitleRows(rows).map((title) => ({ ...title, row: title.row + skipRows }));
3100
+ if (input.format !== "xlsx") return titleRows.length > 0 ? { titleRows } : void 0;
3101
+ const detection = detectHeaderRow(rows.map((row) => row.map((value) => isEmptyCell(value) ? "" : String(value))));
3102
+ if (detection.hasHeaders && titleRows.length === 0) return void 0;
3103
+ return { ...detection.hasHeaders ? {} : { detection }, titleRows };
3104
+ }
3105
+ async function peekHeaderRows(input, dataSet, skipRows) {
3106
+ if (input.format === "csv" || input.format === "tsv") {
3107
+ const delimiter = input.delimiter ?? (input.format === "tsv" ? " " : ",");
3108
+ return peekDelimitedRecords(input.filePath, { delimiter, encoding: input.encoding, limit: 10 + skipRows }).slice(skipRows);
3109
+ }
3110
+ if (input.format === "xlsx" && dataSet.kind === "sheet") {
3111
+ return (await peekXlsxRows(input.filePath, dataSet.label, 10 + skipRows)).slice(skipRows);
3112
+ }
3113
+ return void 0;
3114
+ }
3115
+ function isEmptyCell(value) {
3116
+ return value === null || value === void 0 || value === "";
3117
+ }
3118
+ var TITLE_ROW_WARNING = "The first rows here hold a single value each, which looks like a title or banner above the real header row; the header was still read from the first row. See leading_title_rows and re-run with --skip-rows N if that is what they are.";
3119
+ var EXCEL_HEADERLESS_WARNING = "The first row of this worksheet looks like data rather than a header, but it was still used as the header. See header_signal, and re-run with --headers or --headerless if it is data.";
3120
+ function annotateRowSkips(warnings, skipRows, signal) {
3121
+ const extra = [];
3122
+ if (signal?.titleRows.length) extra.push(TITLE_ROW_WARNING);
3123
+ if (signal?.detection) extra.push(EXCEL_HEADERLESS_WARNING);
3124
+ return {
3125
+ warnings: extra.length > 0 ? [...warnings, ...extra] : warnings,
3126
+ ...skipRows > 0 ? { skipped_rows: skipRows } : {},
3127
+ ...signal?.titleRows.length ? { leading_title_rows: signal.titleRows } : {},
3128
+ ...signal?.detection ? { header_signal: signal.detection } : {}
3129
+ };
3130
+ }
2346
3131
  function annotateHeaderless(profile, presence) {
2347
3132
  return {
2348
3133
  ...profile,
@@ -2362,7 +3147,7 @@ function splitHeaders(raw) {
2362
3147
  // src/commands/data-integration/plan.ts
2363
3148
  import { writeFile } from "fs/promises";
2364
3149
  var VALID_LOCALES = /* @__PURE__ */ new Set(["zh", "en", "ja", "ko"]);
2365
- var EVENT_NAME_RE = /^[a-z][a-z0-9_]*$/;
3150
+ var EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
2366
3151
  function toPropType(type) {
2367
3152
  switch (type) {
2368
3153
  case "number":
@@ -2439,7 +3224,7 @@ function resolveEventNames(options) {
2439
3224
  if (!EVENT_NAME_RE.test(name)) {
2440
3225
  throw new CliValidationError(`The event name "${name}" is not a legal AE event name.`, {
2441
3226
  code: "LOCAL_DATA_PLAN_INVALID_EVENT_NAME",
2442
- hint: "Event names must match ^[a-z][a-z0-9_]*$.",
3227
+ hint: "Event names must match ^[A-Za-z][A-Za-z0-9_]*$.",
2443
3228
  location: { field: "event-name" }
2444
3229
  });
2445
3230
  }
@@ -2567,11 +3352,24 @@ async function convertLocalData(options) {
2567
3352
  const dataSet = options.mergeSheets ? { id: "all-sheets", kind: "sheet", label: "all-sheets", selector: "all-sheets" } : selectDataSet(input, options.mapping.source.data_set);
2568
3353
  const streamOptions = {
2569
3354
  headerNames: options.mapping.headers,
3355
+ skipRows: options.mapping.skip_rows,
2570
3356
  flattenRules: options.mapping.flatten_rules,
2571
3357
  mergeSheets: options.mergeSheets,
3358
+ // Both XLSX layout decisions come from the mapping, so the rows converted here are the rows
3359
+ // inspect profiled — the mapping is the only place the user's answer to them is recorded.
3360
+ fillMergedCells: options.mapping.fill_merged_cells,
3361
+ excludeHiddenRows: options.mapping.exclude_hidden_rows,
2572
3362
  // The profile pass inside convert is internal (it writes profile.json); the ragged-row
2573
3363
  // warning is surfaced by the conversion pass below instead, so suppress it here.
2574
- warnRagged: false
3364
+ warnRagged: false,
3365
+ // The mapping's own columns are the key worth checking for repeats: they are what AE will
3366
+ // receive as identity, time, and event name. A file-wide `default_event_name` is the same on
3367
+ // every row, so it distinguishes nothing and is left out.
3368
+ duplicateKeyFields: [
3369
+ options.mapping.account_id_field ?? options.mapping.distinct_id_field,
3370
+ options.mapping.time.field,
3371
+ options.mapping.event_name_field
3372
+ ].filter((field) => typeof field === "string" && field.length > 0)
2575
3373
  };
2576
3374
  const salvageSet = options.salvageFrom ? readSalvageRowNumbers(options.salvageFrom) : void 0;
2577
3375
  let salvageMatched = 0;
@@ -2596,6 +3394,8 @@ async function convertLocalData(options) {
2596
3394
  const skippedFields = {};
2597
3395
  let lanIpRecords = 0;
2598
3396
  const flattenMisses = {};
3397
+ const cellIssues = /* @__PURE__ */ new Map();
3398
+ const xlsxStructure = input.format === "xlsx" ? createXlsxStructureCollector() : void 0;
2599
3399
  const rowCount = await streamLocalDataRows(
2600
3400
  input,
2601
3401
  dataSet,
@@ -2624,9 +3424,14 @@ async function convertLocalData(options) {
2624
3424
  },
2625
3425
  {
2626
3426
  headerNames: streamOptions.headerNames,
3427
+ skipRows: streamOptions.skipRows,
2627
3428
  flattenRules: streamOptions.flattenRules,
2628
3429
  flattenMisses,
2629
- mergeSheets: streamOptions.mergeSheets
3430
+ cellIssues,
3431
+ mergeSheets: streamOptions.mergeSheets,
3432
+ fillMergedCells: streamOptions.fillMergedCells,
3433
+ excludeHiddenRows: streamOptions.excludeHiddenRows,
3434
+ xlsxStructure
2630
3435
  }
2631
3436
  );
2632
3437
  if (userSetBuffer.length > 0) flushSortChunk(outputDir, userSetBuffer, sortChunks);
@@ -2635,6 +3440,17 @@ async function convertLocalData(options) {
2635
3440
  process.stderr.write(`Warning: flatten rule "${outColumn}" did not materialize for ${count} row(s).
2636
3441
  `);
2637
3442
  }
3443
+ for (const warning of cellIssueWarnings(cellIssues)) {
3444
+ process.stderr.write(`Warning: ${warning}
3445
+ `);
3446
+ }
3447
+ if (xlsxStructure) {
3448
+ for (const warning of xlsxStructureWarnings(xlsxStructure, options.mapping.exclude_columns)) {
3449
+ process.stderr.write(`Warning: ${warning}
3450
+ `);
3451
+ }
3452
+ }
3453
+ const structureReport = xlsxStructure ? xlsxStructureReport(xlsxStructure) : void 0;
2638
3454
  if (salvageSet && salvageMatched === 0) {
2639
3455
  throw new CliValidationError("The salvage file lists no rows from this source.", {
2640
3456
  code: "LOCAL_DATA_SALVAGE_NO_MATCH",
@@ -2656,6 +3472,7 @@ async function convertLocalData(options) {
2656
3472
  writeSecureJson(mappingPath, options.mapping);
2657
3473
  writeSecureText(transformPath, createTransformScript(options.inputFile, mappingPath));
2658
3474
  const validBytes = statSize(validPath);
3475
+ const sourceRows = salvageSet ? salvageMatched : rowCount;
2659
3476
  const blockedReasons = [
2660
3477
  ...rowCount === 0 ? ["The source contained no data rows."] : [],
2661
3478
  ...rowCount > 0 && validRecords === 0 ? ["No valid UE records were generated."] : [],
@@ -2677,13 +3494,18 @@ async function convertLocalData(options) {
2677
3494
  valid_file: basename4(validPath),
2678
3495
  valid_sha256: await sha256File(validPath),
2679
3496
  invalid_file: basename4(invalidPath),
3497
+ source_rows: sourceRows,
2680
3498
  valid_records: validRecords,
2681
3499
  invalid_records: invalidRecords,
2682
3500
  valid_bytes: validBytes,
2683
3501
  record_types: recordTypes,
2684
3502
  ...Object.keys(skippedFields).length > 0 ? { skipped_fields: skippedFields } : {},
2685
3503
  ...lanIpRecords > 0 ? { lan_ip_records: lanIpRecords } : {},
2686
- ...Object.keys(flattenMisses).length > 0 ? { flatten_misses: flattenMisses } : {}
3504
+ ...Object.keys(flattenMisses).length > 0 ? { flatten_misses: flattenMisses } : {},
3505
+ ...cellIssues.size > 0 ? { unreadable_cells: cellIssueCounts(cellIssues) } : {},
3506
+ ...structureReport ? { xlsx_structure: structureReport } : {},
3507
+ ...profile.summary_rows ? { summary_rows: profile.summary_rows } : {},
3508
+ ...profile.duplicate_keys ? { duplicate_keys: profile.duplicate_keys } : {}
2687
3509
  },
2688
3510
  blocked_reasons: blockedReasons
2689
3511
  };
@@ -2701,6 +3523,7 @@ async function convertLocalDataMulti(options) {
2701
3523
  const profile = await profileLocalData(input, dataSet, sourceTimezone, {
2702
3524
  collectSamples: true,
2703
3525
  headerNames: options.mapping.headers,
3526
+ skipRows: options.mapping.skip_rows,
2704
3527
  flattenRules: options.mapping.flatten_rules,
2705
3528
  warnRagged: false
2706
3529
  });
@@ -2772,7 +3595,7 @@ function convertRow(row, rowNumber, mapping, now) {
2772
3595
  if (recordType === "track") {
2773
3596
  const rawEvent = String(stripQuotes(mapping.event_name_field ? row[mapping.event_name_field] ?? "" : mapping.default_event_name ?? ""));
2774
3597
  eventName = mapping.value_mapping?.event_name && mapping.value_mapping.event_name[rawEvent] !== void 0 ? mapping.value_mapping.event_name[rawEvent] : rawEvent;
2775
- if (!isValidAeName(eventName)) {
3598
+ if (!isValidEventName(eventName)) {
2776
3599
  errors.push({ code: "INVALID_EVENT_NAME", field: mapping.event_name_field });
2777
3600
  }
2778
3601
  }