@thinkingai/ae-cli 6.0.46 → 6.0.47

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