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