@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.
- package/README.md +2 -0
- package/README.zh.md +6 -0
- package/dist/{auth-ROB2EDYV.js → auth-QPEVVBKT.js} +13 -7
- package/dist/auth-UMJAA72O.js +15 -0
- package/dist/{capability-GQ47BCFI.js → capability-RUSV77CO.js} +11 -10
- package/dist/{chunk-UW5UN47B.js → chunk-533JK3YX.js} +2 -0
- package/dist/{chunk-4XXOWOTA.js → chunk-6QK7SYYY.js} +5 -4
- package/dist/{chunk-TUKQZTMI.js → chunk-7K24F7T2.js} +2 -0
- package/dist/{chunk-OO6XR6LK.js → chunk-AMBFK2K3.js} +2 -0
- package/dist/{sync-HKIOZXQE.js → chunk-B5HMQAOM.js} +31 -540
- package/dist/{chunk-DQRPU6EE.js → chunk-BVDST5YT.js} +5 -4
- package/dist/{chunk-VTWMIC5L.js → chunk-E2JKXMVO.js} +2 -0
- package/dist/{chunk-JHENBQ5B.js → chunk-FR2EDAQM.js} +1 -0
- package/dist/{chunk-ZQ47LWTI.js → chunk-GJXRF7Y6.js} +6 -5
- package/dist/{chunk-DWO43OIB.js → chunk-JQ3ENZZH.js} +2 -0
- package/dist/chunk-JYMGHGCE.js +191 -0
- package/dist/{chunk-BYYS3ANB.js → chunk-KEWVSO64.js} +5 -4
- package/dist/{chunk-3FY3RJ26.js → chunk-KZ6ZHF5J.js} +2 -1
- package/dist/{chunk-2MN54X6H.js → chunk-NSIVXKJH.js} +4 -3
- package/dist/{chunk-ZQKDZXDO.js → chunk-O6K2ODUE.js} +3 -2
- package/dist/chunk-QL6R6TSK.js +25215 -0
- package/dist/{chunk-J2DEBMRF.js → chunk-RWXIXOZG.js} +22 -9
- package/dist/{chunk-4NN5IWVN.js → chunk-T6OSFQZD.js} +2 -0
- package/dist/{chunk-3KWQYGYI.js → chunk-TKHSULJT.js} +2 -0
- package/dist/{chunk-6EIJSNBD.js → chunk-Y74WTIKJ.js} +2 -0
- package/dist/{client-L2YDMHQ6.js → client-N7NGZUNI.js} +4 -3
- package/dist/{community-report-client-C7WDGET3.js → community-report-client-6OL6GBQW.js} +3 -2
- package/dist/{config-BMYZX2UE.js → config-XNMACCKX.js} +8 -7
- package/dist/{data-integration-QEKDWQDY.js → data-integration-4NZGZ45E.js} +913 -92
- package/dist/index.js +36 -34
- package/dist/{local-data-upload-client-4YYHSYD6.js → local-data-upload-client-EFHDLXFG.js} +3 -2
- package/dist/{memory-3ORCR7JH.js → memory-HBJQ2GC5.js} +8 -7
- package/dist/{metadata-A6QLH3IS.js → metadata-PGDJE5RQ.js} +12 -11
- package/dist/{model-UGRDX4MW.js → model-BHLJ2QMT.js} +10 -9
- package/dist/{personal-semantic-preference-QZFAJWGE.js → personal-semantic-preference-E5NZ56DN.js} +11 -10
- package/dist/sync-MUWPRM5I.js +522 -0
- package/dist/{te-agent-BR6VDBNX.js → te-agent-WQZTMEK4.js} +10 -9
- package/dist/{te-analysis-MUKUXJL4.js → te-analysis-YWCSSLYL.js} +107 -20
- package/dist/{te-community-ISDQWJU7.js → te-community-BNE6KREL.js} +8 -7
- package/dist/{te-dataops-6P5IKWNJ.js → te-dataops-TXLKM2W6.js} +467 -67
- package/dist/{te-engage-ELA3C5BM.js → te-engage-VEW4CJHK.js} +26 -10
- package/dist/{te-kb-RCLSSH2Q.js → te-kb-BUD6N3NI.js} +311 -24
- package/dist/{te-system-FXITO2JG.js → te-system-44LZJDTQ.js} +573 -37
- package/dist/{te-team-ADOC2ROP.js → te-team-XMP47YXE.js} +10 -7
- package/dist/{update-YCYCKJOO.js → update-UODFOZ4P.js} +7 -6
- package/package.json +18 -9
- package/skills/ae-analysis/references/analysis_gateway_assets.md +1 -1
- package/skills/ae-analysis/references/audience_models.md +18 -0
- package/skills/ae-analysis/references/command_index.md +2 -2
- package/skills/ae-analysis/references/project_timezone_update.md +13 -4
- package/skills/ae-analysis/references/property_create.md +2 -0
- package/skills/ae-analysis/references/super_metadata_batch_create.md +2 -0
- package/skills/ae-analysis/references/user_cluster_models.md +2 -0
- package/skills/ae-analysis/references/user_cluster_update.md +8 -4
- package/skills/ae-analysis/references/user_tag_create.md +7 -1
- package/skills/ae-analysis/references/user_tag_models.md +8 -2
- package/skills/ae-data-integration/SKILL.md +1 -0
- package/skills/ae-data-integration/references/error-handling.md +54 -1
- package/skills/ae-data-integration/references/source-inspect.md +18 -2
- package/skills/ae-data-integration/references/transform.md +3 -1
- package/skills/ae-data-integration/references/ue-routing.md +23 -0
- package/skills/ae-dataops/SKILL.md +11 -1
- package/skills/ae-dataops/references/dataops-backfill.md +135 -0
- package/skills/ae-engage/SKILL.md +3 -0
- package/skills/ae-engage/references/build-task-save-guide.md +5 -1
- package/skills/ae-engage/references/save-task.md +6 -0
- package/skills/ae-kb/SKILL.md +120 -9
- package/skills/ae-system/SKILL.md +28 -30
- package/skills/ae-system/references/channel-management.md +303 -0
- package/dist/auth-GBMV6TEJ.js +0 -14
- package/dist/capability-IINANQJA.js +0 -349
- package/dist/chunk-GJLGIMAO.js +0 -556
- package/dist/chunk-OMPRXM3V.js +0 -349
- package/dist/chunk-QNOLN2LJ.js +0 -509
- package/dist/chunk-RJDU7NYP.js +0 -1198
- package/dist/chunk-RNAALWJK.js +0 -98
- package/dist/chunk-SERWF6G5.js +0 -13
- package/dist/chunk-XNVMVIUI.js +0 -71
- package/dist/memory-I2WXDTV2.js +0 -893
- package/dist/metadata-HC7GBTTD.js +0 -339
- package/dist/model-HLHIEFMU.js +0 -139
- package/dist/personal-semantic-preference-YXAZBFVW.js +0 -239
- package/dist/sync-TFHU2UTG.js +0 -10261
- package/dist/te-agent-VLYOV7S4.js +0 -3894
- package/dist/te-analysis-JECYCV6K.js +0 -9357
- package/dist/te-community-5DMNKJWY.js +0 -1859
- package/dist/te-dataops-CVULXNVB.js +0 -2209
- package/dist/te-engage-MC5IQZIU.js +0 -4898
- 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-
|
|
8
|
+
} from "./chunk-QL6R6TSK.js";
|
|
9
|
+
import "./chunk-B5HMQAOM.js";
|
|
5
10
|
import {
|
|
6
11
|
getConfigDir
|
|
7
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-KZ6ZHF5J.js";
|
|
8
13
|
import {
|
|
9
14
|
CliValidationError,
|
|
10
15
|
LocalDataUploadError
|
|
11
|
-
} from "./chunk-
|
|
12
|
-
import
|
|
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:
|
|
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
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1018
|
-
const
|
|
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
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
const relationshipId = attributes
|
|
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
|
-
|
|
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
|
|
1087
|
-
name: decodeXml(
|
|
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
|
|
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
|
|
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) =>
|
|
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)
|
|
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
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1176
|
-
|
|
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(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/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
|
|
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
|
|
1216
|
-
|
|
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 ("
|
|
1219
|
-
if ("
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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);
|