@thinkingai/ae-cli 6.1.17 → 6.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/README.zh.md +6 -0
- package/dist/{auth-77BUFLGC.js → auth-QPEVVBKT.js} +20 -15
- package/dist/auth-UMJAA72O.js +15 -0
- package/dist/{capability-72DTW5M2.js → capability-ASI62I4A.js} +53 -15
- package/dist/{chunk-UW5UN47B.js → chunk-533JK3YX.js} +2 -0
- package/dist/{chunk-UOUS37JQ.js → chunk-6QK7SYYY.js} +5 -4
- package/dist/{chunk-TUKQZTMI.js → chunk-7K24F7T2.js} +2 -0
- package/dist/{chunk-OO6XR6LK.js → chunk-AMBFK2K3.js} +2 -0
- package/dist/{sync-DAVKYVMW.js → chunk-B5HMQAOM.js} +31 -540
- package/dist/{chunk-VR3LCBHW.js → chunk-DKEXH6Q5.js} +6 -5
- package/dist/{chunk-VTWMIC5L.js → chunk-E2JKXMVO.js} +2 -0
- package/dist/{chunk-LYVNONC4.js → chunk-FR2EDAQM.js} +36 -0
- package/dist/{chunk-ILIU36SU.js → chunk-GJXRF7Y6.js} +6 -5
- package/dist/{chunk-DWO43OIB.js → chunk-JQ3ENZZH.js} +2 -0
- package/dist/chunk-JYMGHGCE.js +191 -0
- package/dist/{chunk-GT46FPXN.js → chunk-KEWVSO64.js} +18 -8
- package/dist/chunk-KZ6ZHF5J.js +294 -0
- package/dist/chunk-O6K2ODUE.js +318 -0
- package/dist/chunk-QL6R6TSK.js +25215 -0
- package/dist/{chunk-4SGZG4XY.js → chunk-RWXIXOZG.js} +28 -13
- package/dist/{chunk-YA6SMTXG.js → chunk-S5PONFBM.js} +5 -4
- package/dist/{chunk-4NN5IWVN.js → chunk-T6OSFQZD.js} +2 -0
- package/dist/{chunk-PTE56QPL.js → chunk-TKHSULJT.js} +6 -0
- package/dist/{chunk-6EIJSNBD.js → chunk-Y74WTIKJ.js} +2 -0
- package/dist/{client-TKG4WBHN.js → client-N7NGZUNI.js} +6 -4
- package/dist/{community-report-client-FI4LNVYS.js → community-report-client-6OL6GBQW.js} +3 -3
- package/dist/{config-RE6CMGPK.js → config-XNMACCKX.js} +10 -8
- package/dist/{data-integration-XQYB4X4F.js → data-integration-4NZGZ45E.js} +2502 -272
- package/dist/index.js +137 -1245
- package/dist/{local-data-upload-client-BWHSUQQK.js → local-data-upload-client-EFHDLXFG.js} +3 -3
- package/dist/{memory-YK33G4T7.js → memory-HBJQ2GC5.js} +8 -7
- package/dist/{metadata-UILXHBWF.js → metadata-62EWBEIG.js} +12 -11
- package/dist/{model-NR3JHFSJ.js → model-BHLJ2QMT.js} +10 -9
- package/dist/personal-semantic-preference-JBVMAI7L.js +240 -0
- package/dist/project-semantic-BQ4MPUS5.js +1115 -0
- package/dist/sync-MUWPRM5I.js +522 -0
- package/dist/{te-agent-4BKBODMF.js → te-agent-WQZTMEK4.js} +12 -10
- package/dist/{te-analysis-O6DCO6BS.js → te-analysis-MNCG65LU.js} +534 -48
- package/dist/{te-community-HLC43QKH.js → te-community-BNE6KREL.js} +8 -7
- package/dist/{te-dataops-EJP56W3K.js → te-dataops-TXLKM2W6.js} +469 -68
- package/dist/{te-engage-FGBGQ4IY.js → te-engage-TPKLRVTL.js} +84 -10
- package/dist/{te-experiment-SO5MPDMJ.js → te-experiment-6BYKTCY7.js} +303 -12
- package/dist/{te-kb-SQCLHG6X.js → te-kb-BUD6N3NI.js} +312 -25
- package/dist/{te-system-YARIK4S5.js → te-system-44LZJDTQ.js} +573 -37
- package/dist/{te-team-EFKWYKMK.js → te-team-XMP47YXE.js} +10 -7
- package/dist/{update-OGPSZM5A.js → update-UODFOZ4P.js} +9 -7
- package/package.json +18 -9
- package/skills/ae-agent/SKILL.md +3 -4
- package/skills/ae-agent/references/edit-skill.md +3 -0
- package/skills/ae-agent/references/get-skill-content.md +1 -1
- package/skills/ae-agent/references/rescan-skills.md +15 -13
- package/skills/ae-agent/references/upload-skill.md +7 -4
- package/skills/ae-analysis/SKILL.md +45 -4
- package/skills/ae-analysis/metadata_resolution.md +38 -4
- package/skills/ae-analysis/references/analysis_data_retrieval.md +29 -0
- package/skills/ae-analysis/references/analysis_gateway_assets.md +1 -1
- package/skills/ae-analysis/references/asset_authentication_export.md +22 -0
- package/skills/ae-analysis/references/asset_authentication_list.md +18 -14
- package/skills/ae-analysis/references/asset_authentication_update.md +29 -14
- package/skills/ae-analysis/references/audience_models.md +18 -0
- package/skills/ae-analysis/references/command_index.md +19 -11
- package/skills/ae-analysis/references/dashboard_get.md +18 -1
- package/skills/ae-analysis/references/dashboard_update.md +3 -0
- package/skills/ae-analysis/references/personal_semantic_preference_add.md +23 -0
- package/skills/ae-analysis/references/personal_semantic_preference_delete.md +17 -0
- package/skills/ae-analysis/references/personal_semantic_preference_get.md +19 -0
- package/skills/ae-analysis/references/personal_semantic_preference_list.md +21 -0
- package/skills/ae-analysis/references/personal_semantic_preference_update.md +19 -0
- package/skills/ae-analysis/references/project_timezone_update.md +13 -4
- package/skills/ae-analysis/references/property_create.md +2 -0
- package/skills/ae-analysis/references/super_metadata_batch_create.md +2 -0
- package/skills/ae-analysis/references/user_cluster_models.md +2 -0
- package/skills/ae-analysis/references/user_cluster_update.md +8 -4
- package/skills/ae-analysis/references/user_tag_create.md +7 -1
- package/skills/ae-analysis/references/user_tag_models.md +8 -2
- package/skills/ae-data-integration/SKILL.md +24 -4
- package/skills/ae-data-integration/references/custom-layer.md +93 -0
- package/skills/ae-data-integration/references/error-handling.md +145 -0
- package/skills/ae-data-integration/references/handoff.md +77 -18
- package/skills/ae-data-integration/references/local-analysis.md +1 -1
- package/skills/ae-data-integration/references/reuse.md +9 -5
- package/skills/ae-data-integration/references/sink-upload.md +1 -1
- package/skills/ae-data-integration/references/source-inspect.md +35 -13
- package/skills/ae-data-integration/references/tracking-plan.md +7 -5
- package/skills/ae-data-integration/references/transform.md +12 -10
- package/skills/ae-data-integration/references/ue-mapping.md +33 -11
- package/skills/ae-data-integration/references/ue-routing.md +23 -0
- package/skills/ae-dataops/SKILL.md +11 -1
- package/skills/ae-dataops/references/dataops-backfill.md +135 -0
- package/skills/ae-engage/SKILL.md +5 -0
- package/skills/ae-engage/references/build-task-save-guide.md +14 -1
- package/skills/ae-engage/references/save-flow.md +37 -1
- package/skills/ae-engage/references/save-task.md +88 -0
- package/skills/ae-experiment/SKILL.md +14 -4
- package/skills/ae-experiment/references/manage_feature_whitelist.md +66 -0
- package/skills/ae-experiment/references/manage_guardrail_metrics.md +26 -0
- package/skills/ae-experiment/references/save_experiment.md +1 -1
- package/skills/ae-experiment/references/save_metric.md +20 -8
- package/skills/ae-kb/SKILL.md +121 -10
- package/skills/ae-project-semantic/SKILL.md +193 -0
- package/skills/ae-project-semantic/references/query-routing-v5.md +165 -0
- package/skills/ae-project-semantic/references/recommendation-quality.md +68 -0
- package/skills/ae-system/SKILL.md +28 -30
- package/skills/ae-system/references/channel-management.md +303 -0
- package/dist/auth-2WTQOP77.js +0 -14
- package/dist/capability-PJHNI4GJ.js +0 -315
- package/dist/chunk-4KVPKXFX.js +0 -98
- package/dist/chunk-C4MGVGJW.js +0 -13
- package/dist/chunk-P3FGXJTU.js +0 -349
- package/dist/chunk-QGM4M3NI.js +0 -37
- package/dist/chunk-RGKJGKT7.js +0 -71
- package/dist/chunk-SAU3QFIQ.js +0 -556
- package/dist/chunk-VPKZ7I72.js +0 -509
- package/dist/chunk-ZZUOD757.js +0 -598
- package/dist/memory-CHRU2F7W.js +0 -893
- package/dist/metadata-XXR34N5P.js +0 -339
- package/dist/model-K3KLWIW6.js +0 -139
- package/dist/sync-FCKOVWWS.js +0 -10261
- package/dist/te-agent-HLW4VTQK.js +0 -3893
- package/dist/te-analysis-ZMNGOVNW.js +0 -8958
- package/dist/te-community-6HPBWJUZ.js +0 -1859
- package/dist/te-dataops-HDRUXY4K.js +0 -2208
- package/dist/te-engage-RAK5PESW.js +0 -4898
- package/dist/te-experiment-VZF7BT6G.js +0 -770
- package/dist/te-system-Z77IKZFN.js +0 -2213
|
@@ -1,13 +1,27 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
2
|
+
import {
|
|
3
|
+
require_excel,
|
|
4
|
+
require_styles_xform,
|
|
5
|
+
require_worksheet_reader,
|
|
6
|
+
validateAndFix,
|
|
7
|
+
validateDraft
|
|
8
|
+
} from "./chunk-QL6R6TSK.js";
|
|
9
|
+
import "./chunk-B5HMQAOM.js";
|
|
10
|
+
import {
|
|
11
|
+
getConfigDir
|
|
12
|
+
} from "./chunk-KZ6ZHF5J.js";
|
|
1
13
|
import {
|
|
2
14
|
CliValidationError,
|
|
3
15
|
LocalDataUploadError
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import
|
|
16
|
+
} from "./chunk-533JK3YX.js";
|
|
17
|
+
import {
|
|
18
|
+
__toESM
|
|
19
|
+
} from "./chunk-FR2EDAQM.js";
|
|
6
20
|
|
|
7
|
-
// src/commands/data-integration/
|
|
21
|
+
// src/commands/data-integration/inspect.ts
|
|
8
22
|
import { basename as basename3 } from "path";
|
|
9
23
|
|
|
10
|
-
// src/commands/data-integration/
|
|
24
|
+
// src/commands/data-integration/estimate.ts
|
|
11
25
|
var XLS_SIZE_WARN_BYTES = 100 * 1024 * 1024;
|
|
12
26
|
var LARGE_FILE_WARN_BYTES = 1024 * 1024 * 1024;
|
|
13
27
|
var XLS_HARD_LIMIT_BYTES = 1024 * 1024 * 1024;
|
|
@@ -95,12 +109,15 @@ function assessFileSize(fileName, format, sizeBytes, encoding) {
|
|
|
95
109
|
return baseAssessment;
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
// src/commands/data-integration/
|
|
112
|
+
// src/commands/data-integration/input.ts
|
|
113
|
+
var import_exceljs = __toESM(require_excel(), 1);
|
|
114
|
+
var import_worksheet_reader = __toESM(require_worksheet_reader(), 1);
|
|
115
|
+
var import_styles_xform = __toESM(require_styles_xform(), 1);
|
|
99
116
|
import { createHash } from "crypto";
|
|
100
117
|
import { createReadStream as createReadStream2, statSync } from "fs";
|
|
101
|
-
import { createRequire as createRequire2 } from "module";
|
|
102
118
|
import { extname, basename } from "path";
|
|
103
119
|
import { createInterface } from "readline";
|
|
120
|
+
import { StringDecoder } from "string_decoder";
|
|
104
121
|
import { pipeline } from "stream/promises";
|
|
105
122
|
import XLSXMod from "xlsx";
|
|
106
123
|
import { parse as parseCsv } from "csv-parse";
|
|
@@ -112,7 +129,7 @@ import StreamValues from "stream-json/streamers/StreamValues.js";
|
|
|
112
129
|
import * as unzipper from "unzipper";
|
|
113
130
|
import { SaxesParser } from "saxes";
|
|
114
131
|
|
|
115
|
-
// src/commands/data-integration/
|
|
132
|
+
// src/commands/data-integration/encoding.ts
|
|
116
133
|
import { createReadStream } from "fs";
|
|
117
134
|
import { createRequire } from "module";
|
|
118
135
|
import { openSync, readSync, closeSync } from "fs";
|
|
@@ -156,7 +173,7 @@ function readSample(filePath, maxBytes) {
|
|
|
156
173
|
}
|
|
157
174
|
}
|
|
158
175
|
|
|
159
|
-
// src/commands/data-integration/
|
|
176
|
+
// src/commands/data-integration/time.ts
|
|
160
177
|
var TIME_FORMATS = [
|
|
161
178
|
// Standard AE format
|
|
162
179
|
"yyyy-MM-dd HH:mm:ss.SSS",
|
|
@@ -400,7 +417,7 @@ function tokenizeFormat(format) {
|
|
|
400
417
|
return tokens;
|
|
401
418
|
}
|
|
402
419
|
|
|
403
|
-
// src/commands/data-integration/
|
|
420
|
+
// src/commands/data-integration/flatten.ts
|
|
404
421
|
var NDJSON_MAX_DEPTH = 1;
|
|
405
422
|
var NESTED_NODE_SAMPLE_LIMIT = 5;
|
|
406
423
|
var NESTED_SAMPLE_TRUNCATE = 40;
|
|
@@ -433,7 +450,7 @@ function flattenJSON(obj, prefix = "", depth = 0, maxDepth = NDJSON_MAX_DEPTH) {
|
|
|
433
450
|
}
|
|
434
451
|
return result;
|
|
435
452
|
}
|
|
436
|
-
function buildRowWithFlatten(obj, flattenRules) {
|
|
453
|
+
function buildRowWithFlatten(obj, flattenRules, misses) {
|
|
437
454
|
const row = {};
|
|
438
455
|
const coveredRoots = new Set(Object.values(flattenRules).map((path) => path.split(".")[0]));
|
|
439
456
|
const base = flattenJSON(obj);
|
|
@@ -442,43 +459,77 @@ function buildRowWithFlatten(obj, flattenRules) {
|
|
|
442
459
|
}
|
|
443
460
|
for (const [outColumn, sourcePath] of Object.entries(flattenRules)) {
|
|
444
461
|
const value = getNestedValue(obj, sourcePath);
|
|
445
|
-
|
|
462
|
+
if (value == null) {
|
|
463
|
+
recordFlattenMiss(misses, outColumn);
|
|
464
|
+
row[outColumn] = "";
|
|
465
|
+
} else {
|
|
466
|
+
row[outColumn] = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
467
|
+
}
|
|
446
468
|
}
|
|
447
469
|
return row;
|
|
448
470
|
}
|
|
449
|
-
function flattenLocalDataRow(value, flattenRules) {
|
|
471
|
+
function flattenLocalDataRow(value, flattenRules, misses) {
|
|
450
472
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
451
473
|
if (flattenRules && Object.keys(flattenRules).length > 0) {
|
|
452
|
-
return buildRowWithFlatten(value, flattenRules);
|
|
474
|
+
return buildRowWithFlatten(value, flattenRules, misses);
|
|
453
475
|
}
|
|
454
476
|
return value;
|
|
455
477
|
}
|
|
456
478
|
return { value };
|
|
457
479
|
}
|
|
458
|
-
function flattenDelimitedRow(row, flattenRules) {
|
|
480
|
+
function flattenDelimitedRow(row, flattenRules, misses) {
|
|
459
481
|
const result = { ...row };
|
|
460
482
|
for (const [outColumn, path] of Object.entries(flattenRules)) {
|
|
461
483
|
const dot = path.indexOf(".");
|
|
462
|
-
if (dot <= 0)
|
|
484
|
+
if (dot <= 0) {
|
|
485
|
+
recordFlattenMiss(misses, outColumn);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
463
488
|
const column = path.slice(0, dot);
|
|
464
489
|
const cell = row[column];
|
|
465
|
-
if (typeof cell !== "string")
|
|
490
|
+
if (typeof cell !== "string") {
|
|
491
|
+
recordFlattenMiss(misses, outColumn);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
466
494
|
let parsed;
|
|
467
495
|
try {
|
|
468
496
|
parsed = JSON.parse(cell);
|
|
469
497
|
} catch {
|
|
498
|
+
recordFlattenMiss(misses, outColumn);
|
|
470
499
|
continue;
|
|
471
500
|
}
|
|
472
501
|
const value = getNestedValue(parsed, path.slice(dot + 1));
|
|
473
|
-
if (value === null || value === void 0)
|
|
502
|
+
if (value === null || value === void 0) {
|
|
503
|
+
recordFlattenMiss(misses, outColumn);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
474
506
|
result[outColumn] = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
475
507
|
}
|
|
476
508
|
return result;
|
|
477
509
|
}
|
|
510
|
+
function recordFlattenMiss(misses, outColumn) {
|
|
511
|
+
if (!misses) return;
|
|
512
|
+
misses[outColumn] = (misses[outColumn] ?? 0) + 1;
|
|
513
|
+
}
|
|
478
514
|
function buildNestedTree(rows) {
|
|
479
515
|
const objects = rows.filter((row) => row !== null && typeof row === "object" && !Array.isArray(row));
|
|
480
516
|
return buildObjectChildren(objects, "");
|
|
481
517
|
}
|
|
518
|
+
function buildColumnNestedTree(cells) {
|
|
519
|
+
const objects = cells.filter((cell) => cell !== null && typeof cell === "object" && !Array.isArray(cell));
|
|
520
|
+
const arrays = cells.filter(Array.isArray);
|
|
521
|
+
if (objects.length >= arrays.length) {
|
|
522
|
+
return objects.length > 0 ? buildObjectChildren(objects, "") : [];
|
|
523
|
+
}
|
|
524
|
+
const elements = arrays.flat();
|
|
525
|
+
const elementObjects = elements.filter(
|
|
526
|
+
(value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
527
|
+
);
|
|
528
|
+
const elementKind = elementObjects.length > 0 ? "object" : "primitive";
|
|
529
|
+
const node = { path: "", name: "", kind: "array", elementKind, nonEmpty: elements.length > 0 };
|
|
530
|
+
if (elementKind === "object") node.children = buildObjectChildren(elementObjects, "");
|
|
531
|
+
return [node];
|
|
532
|
+
}
|
|
482
533
|
function buildObjectChildren(objects, parentPath) {
|
|
483
534
|
const keys = [];
|
|
484
535
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -504,8 +555,18 @@ function buildNode(path, name, values, nonEmpty) {
|
|
|
504
555
|
if (structural > 0 && structural >= values.length - structural) {
|
|
505
556
|
if (arrays.length > objects.length) {
|
|
506
557
|
const elementValues = arrays.flat();
|
|
507
|
-
const
|
|
508
|
-
|
|
558
|
+
const elementObjects = elementValues.filter(
|
|
559
|
+
(value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
560
|
+
);
|
|
561
|
+
const elementKind = elementObjects.length > 0 ? "object" : "primitive";
|
|
562
|
+
return {
|
|
563
|
+
path,
|
|
564
|
+
name,
|
|
565
|
+
kind: "array",
|
|
566
|
+
elementKind,
|
|
567
|
+
nonEmpty,
|
|
568
|
+
...elementKind === "object" ? { children: buildObjectChildren(elementObjects, path) } : {}
|
|
569
|
+
};
|
|
509
570
|
}
|
|
510
571
|
return { path, name, kind: "object", children: buildObjectChildren(objects, path), nonEmpty };
|
|
511
572
|
}
|
|
@@ -573,10 +634,8 @@ function isStrongDateTime(value) {
|
|
|
573
634
|
return /^\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.test(value.trim());
|
|
574
635
|
}
|
|
575
636
|
|
|
576
|
-
// src/commands/data-integration/
|
|
637
|
+
// src/commands/data-integration/input.ts
|
|
577
638
|
var XLSX = XLSXMod.default ?? XLSXMod;
|
|
578
|
-
var require3 = createRequire2(import.meta.url);
|
|
579
|
-
var ExcelWorksheetReader = require3("exceljs/lib/stream/xlsx/worksheet-reader");
|
|
580
639
|
function resolveLocalDataInputMeta(filePath) {
|
|
581
640
|
let format = resolveFormat(filePath);
|
|
582
641
|
let delimiter;
|
|
@@ -613,10 +672,12 @@ async function inspectLocalDataInput(filePath) {
|
|
|
613
672
|
const meta = resolveLocalDataInputMeta(filePath);
|
|
614
673
|
emitSizeWarning(meta.filePath, meta.format, meta.sizeBytes, meta.encoding);
|
|
615
674
|
try {
|
|
675
|
+
const discovered = await discoverDataSets(filePath, meta.format);
|
|
616
676
|
return {
|
|
617
677
|
...meta,
|
|
618
678
|
sha256: await sha256File(filePath),
|
|
619
|
-
dataSets:
|
|
679
|
+
dataSets: discovered.visible,
|
|
680
|
+
...discovered.hidden.length > 0 ? { excludedDataSets: discovered.hidden } : {}
|
|
620
681
|
};
|
|
621
682
|
} catch (error) {
|
|
622
683
|
if (error instanceof CliValidationError) throw error;
|
|
@@ -639,15 +700,28 @@ function emitSizeWarning(filePath, format, sizeBytes, encoding) {
|
|
|
639
700
|
}
|
|
640
701
|
function selectDataSet(input, requested) {
|
|
641
702
|
if (requested) {
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
703
|
+
const matches = (candidate) => candidate.id === requested || candidate.selector === requested || candidate.label === requested;
|
|
704
|
+
const selected = input.dataSets.find(matches);
|
|
705
|
+
if (selected) return selected;
|
|
706
|
+
const excluded = input.excludedDataSets?.find(matches);
|
|
707
|
+
if (excluded) {
|
|
708
|
+
process.stderr.write(`Warning: "${excluded.label}" is hidden in the source workbook and was selected explicitly.
|
|
709
|
+
`);
|
|
710
|
+
return excluded;
|
|
649
711
|
}
|
|
650
|
-
|
|
712
|
+
throw new CliValidationError("The requested Sheet or JSON Path was not found.", {
|
|
713
|
+
code: "LOCAL_DATA_SET_NOT_FOUND",
|
|
714
|
+
hint: `Choose one of: ${input.dataSets.map((item) => item.id).join(", ")}`,
|
|
715
|
+
location: { field: "data-set" }
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
const hidden = input.excludedDataSets ?? [];
|
|
719
|
+
if (input.dataSets.length === 0 && hidden.length > 0) {
|
|
720
|
+
throw new CliValidationError("Every data set in this file is hidden in the source.", {
|
|
721
|
+
code: "LOCAL_DATA_ALL_DATA_SETS_HIDDEN",
|
|
722
|
+
hint: `Confirm with the user, then pass --data-set with one of: ${hidden.map((item) => item.id).join(", ")}`,
|
|
723
|
+
location: { field: "data-set" }
|
|
724
|
+
});
|
|
651
725
|
}
|
|
652
726
|
if (input.dataSets.length !== 1) {
|
|
653
727
|
throw new CliValidationError("This file contains multiple data sets.", {
|
|
@@ -658,6 +732,24 @@ function selectDataSet(input, requested) {
|
|
|
658
732
|
}
|
|
659
733
|
return input.dataSets[0];
|
|
660
734
|
}
|
|
735
|
+
var LocalDataRowCallbackError = class extends Error {
|
|
736
|
+
constructor(cause) {
|
|
737
|
+
super(cause instanceof Error ? cause.message : String(cause));
|
|
738
|
+
this.cause = cause;
|
|
739
|
+
this.name = "LocalDataRowCallbackError";
|
|
740
|
+
}
|
|
741
|
+
cause;
|
|
742
|
+
};
|
|
743
|
+
function wrapRowCallback(onRow) {
|
|
744
|
+
return async (row, rowNumber) => {
|
|
745
|
+
try {
|
|
746
|
+
await onRow(row, rowNumber);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (error instanceof CliValidationError) throw error;
|
|
749
|
+
throw new LocalDataRowCallbackError(error);
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
}
|
|
661
753
|
async function streamLocalDataRows(input, dataSet, onRow, options = {}) {
|
|
662
754
|
const opts = {
|
|
663
755
|
delimiter: options.delimiter ?? input.delimiter ?? (input.format === "tsv" ? " " : ","),
|
|
@@ -665,24 +757,36 @@ async function streamLocalDataRows(input, dataSet, onRow, options = {}) {
|
|
|
665
757
|
headerNames: options.headerNames,
|
|
666
758
|
noHeader: options.noHeader,
|
|
667
759
|
flattenRules: options.flattenRules,
|
|
668
|
-
|
|
760
|
+
flattenMisses: options.flattenMisses,
|
|
761
|
+
mergeSheets: options.mergeSheets,
|
|
762
|
+
excelDateColumns: options.excelDateColumns,
|
|
763
|
+
cellIssues: options.cellIssues,
|
|
764
|
+
warnRagged: options.warnRagged,
|
|
765
|
+
skipRows: options.skipRows,
|
|
766
|
+
xlsxStructure: options.xlsxStructure,
|
|
767
|
+
fillMergedCells: options.fillMergedCells,
|
|
768
|
+
excludeHiddenRows: options.excludeHiddenRows
|
|
669
769
|
};
|
|
770
|
+
const wrappedRow = wrapRowCallback(onRow);
|
|
670
771
|
try {
|
|
671
772
|
switch (input.format) {
|
|
672
773
|
case "csv":
|
|
673
774
|
case "tsv":
|
|
674
|
-
return await streamDelimited(input.filePath,
|
|
775
|
+
return await streamDelimited(input.filePath, wrappedRow, opts);
|
|
675
776
|
case "jsonl":
|
|
676
|
-
return await streamJsonLines(input.filePath,
|
|
777
|
+
return await streamJsonLines(input.filePath, wrappedRow, opts);
|
|
677
778
|
case "json":
|
|
678
|
-
return await streamJson(input.filePath, dataSet.selector ?? "$",
|
|
779
|
+
return await streamJson(input.filePath, dataSet.selector ?? "$", wrappedRow, opts);
|
|
679
780
|
case "xlsx":
|
|
680
|
-
return await streamXlsx(input.filePath, dataSet.label,
|
|
781
|
+
return await streamXlsx(input.filePath, dataSet.label, wrappedRow, opts);
|
|
681
782
|
case "xls":
|
|
682
|
-
return await streamXls(input.filePath, dataSet.label,
|
|
783
|
+
return await streamXls(input.filePath, dataSet.label, wrappedRow, opts);
|
|
683
784
|
}
|
|
684
785
|
} catch (error) {
|
|
685
786
|
if (error instanceof CliValidationError) throw error;
|
|
787
|
+
if (error instanceof LocalDataRowCallbackError) {
|
|
788
|
+
throw error.cause instanceof Error ? error.cause : error;
|
|
789
|
+
}
|
|
686
790
|
throw localDataParseError(input.format);
|
|
687
791
|
}
|
|
688
792
|
}
|
|
@@ -789,6 +893,21 @@ function peekDelimitedRecords(filePath, options = {}) {
|
|
|
789
893
|
});
|
|
790
894
|
return records.slice(0, options.limit ?? 10);
|
|
791
895
|
}
|
|
896
|
+
var TITLE_ROW_SCAN_LIMIT = 3;
|
|
897
|
+
var TITLE_ROW_MIN_HEADER_WIDTH = 3;
|
|
898
|
+
function detectLeadingTitleRows(rows) {
|
|
899
|
+
const width = (row) => row.filter((value) => !isMissing(value)).length;
|
|
900
|
+
const titles = [];
|
|
901
|
+
for (let index = 0; index < Math.min(rows.length, TITLE_ROW_SCAN_LIMIT); index += 1) {
|
|
902
|
+
const cells = width(rows[index]);
|
|
903
|
+
if (cells > 1) break;
|
|
904
|
+
titles.push({ row: index + 1, non_empty_cells: cells });
|
|
905
|
+
}
|
|
906
|
+
if (titles.length === 0) return [];
|
|
907
|
+
const next = rows[titles.length];
|
|
908
|
+
if (!next || width(next) < TITLE_ROW_MIN_HEADER_WIDTH) return [];
|
|
909
|
+
return titles;
|
|
910
|
+
}
|
|
792
911
|
function escapeRegex(source) {
|
|
793
912
|
return source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
794
913
|
}
|
|
@@ -799,21 +918,29 @@ function isNumeric(value) {
|
|
|
799
918
|
}
|
|
800
919
|
async function discoverDataSets(filePath, format) {
|
|
801
920
|
if (format === "csv" || format === "tsv" || format === "jsonl") {
|
|
802
|
-
return [{ id: "$", kind: "file", label: basename(filePath), selector: "$" }];
|
|
921
|
+
return { visible: [{ id: "$", kind: "file", label: basename(filePath), selector: "$" }], hidden: [] };
|
|
803
922
|
}
|
|
804
923
|
if (format === "xls") {
|
|
805
924
|
const workbook = XLSX.readFile(filePath, { dense: true });
|
|
806
|
-
return
|
|
925
|
+
return {
|
|
926
|
+
visible: workbook.SheetNames.map((name) => ({ id: `sheet:${name}`, kind: "sheet", label: name, selector: name })),
|
|
927
|
+
hidden: []
|
|
928
|
+
};
|
|
807
929
|
}
|
|
808
930
|
if (format === "xlsx") {
|
|
809
|
-
|
|
931
|
+
const sheets = await readXlsxSheetDefinitions(filePath);
|
|
932
|
+
const toDataSet = (sheet) => ({
|
|
810
933
|
id: `sheet:${sheet.name}`,
|
|
811
934
|
kind: "sheet",
|
|
812
935
|
label: sheet.name,
|
|
813
936
|
selector: sheet.name
|
|
814
|
-
})
|
|
937
|
+
});
|
|
938
|
+
return {
|
|
939
|
+
visible: sheets.filter((sheet) => !sheet.hidden).map(toDataSet),
|
|
940
|
+
hidden: sheets.filter((sheet) => sheet.hidden).map(toDataSet)
|
|
941
|
+
};
|
|
815
942
|
}
|
|
816
|
-
return discoverJsonDataSets(filePath);
|
|
943
|
+
return { visible: await discoverJsonDataSets(filePath), hidden: [] };
|
|
817
944
|
}
|
|
818
945
|
async function discoverJsonDataSets(filePath) {
|
|
819
946
|
const encoding = detectEncoding(filePath);
|
|
@@ -859,9 +986,11 @@ function childPath(parent) {
|
|
|
859
986
|
async function streamDelimited(filePath, onRow, options) {
|
|
860
987
|
const delimiter = options.delimiter ?? ",";
|
|
861
988
|
const encoding = options.encoding ?? "utf-8";
|
|
989
|
+
const skipRows = Math.max(0, options.skipRows ?? 0);
|
|
862
990
|
let headerNames = options.headerNames;
|
|
863
991
|
if (!headerNames && options.noHeader) {
|
|
864
|
-
const
|
|
992
|
+
const peeked = peekDelimitedRecords(filePath, { delimiter, encoding, limit: skipRows + 1 });
|
|
993
|
+
const firstRecord = peeked[skipRows] ?? [];
|
|
865
994
|
headerNames = firstRecord.map((_, index) => `col_${index + 1}`);
|
|
866
995
|
}
|
|
867
996
|
const parser = parseCsv({
|
|
@@ -870,29 +999,41 @@ async function streamDelimited(filePath, onRow, options) {
|
|
|
870
999
|
quote: delimiter === " " ? null : '"',
|
|
871
1000
|
relax_column_count: true,
|
|
872
1001
|
skip_empty_lines: true,
|
|
873
|
-
trim: true
|
|
874
|
-
columns: headerNames ? headerNames : (headers) => dedupeHeaders(headers.map((header) => String(header).trim()))
|
|
1002
|
+
trim: true
|
|
875
1003
|
});
|
|
876
1004
|
decodeTextStream(filePath, encoding).pipe(parser);
|
|
877
1005
|
let count = 0;
|
|
878
|
-
|
|
1006
|
+
let widthMismatches = 0;
|
|
1007
|
+
let skipped = 0;
|
|
1008
|
+
let resolvedHeaders = headerNames;
|
|
1009
|
+
for await (const raw of parser) {
|
|
1010
|
+
const values = raw;
|
|
1011
|
+
if (skipped < skipRows) {
|
|
1012
|
+
skipped += 1;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
if (!resolvedHeaders) {
|
|
1016
|
+
resolvedHeaders = dedupeHeaders(values.map((header) => String(header).trim()));
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (values.length !== resolvedHeaders.length) widthMismatches += 1;
|
|
879
1020
|
count += 1;
|
|
880
|
-
const row =
|
|
1021
|
+
const row = {};
|
|
1022
|
+
for (let index = 0; index < resolvedHeaders.length; index += 1) {
|
|
1023
|
+
row[resolvedHeaders[index]] = values[index] ?? null;
|
|
1024
|
+
}
|
|
881
1025
|
await onRow(
|
|
882
|
-
options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules) : row,
|
|
1026
|
+
options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules, options.flattenMisses) : row,
|
|
883
1027
|
count
|
|
884
1028
|
);
|
|
885
1029
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1030
|
+
if (widthMismatches > 0 && options.warnRagged !== false) {
|
|
1031
|
+
process.stderr.write(
|
|
1032
|
+
`Warning: ${widthMismatches} record(s) had a column count different from the header row; extra fields were dropped and missing fields were treated as empty.
|
|
1033
|
+
`
|
|
1034
|
+
);
|
|
891
1035
|
}
|
|
892
|
-
|
|
893
|
-
const row = {};
|
|
894
|
-
for (const header of headerNames) row[header] = source[header] ?? null;
|
|
895
|
-
return row;
|
|
1036
|
+
return count;
|
|
896
1037
|
}
|
|
897
1038
|
async function streamJsonLines(filePath, onRow, options) {
|
|
898
1039
|
let count = 0;
|
|
@@ -909,7 +1050,7 @@ async function streamJsonLines(filePath, onRow, options) {
|
|
|
909
1050
|
location: { record: count }
|
|
910
1051
|
});
|
|
911
1052
|
}
|
|
912
|
-
await onRow(flattenLocalDataRow(value, options.flattenRules), count);
|
|
1053
|
+
await onRow(flattenLocalDataRow(value, options.flattenRules, options.flattenMisses), count);
|
|
913
1054
|
}
|
|
914
1055
|
return count;
|
|
915
1056
|
}
|
|
@@ -921,19 +1062,28 @@ async function streamJson(filePath, selector, onRow, options) {
|
|
|
921
1062
|
const chain = selector === "$" || selector === "$object" ? source.pipe(parser).pipe(streamer) : source.pipe(parser).pipe(Pick.pick({ filter: selector })).pipe(streamer);
|
|
922
1063
|
for await (const item of chain) {
|
|
923
1064
|
count += 1;
|
|
924
|
-
await onRow(flattenLocalDataRow(item.value, options.flattenRules), count);
|
|
1065
|
+
await onRow(flattenLocalDataRow(item.value, options.flattenRules, options.flattenMisses), count);
|
|
925
1066
|
}
|
|
926
1067
|
return count;
|
|
927
1068
|
}
|
|
928
1069
|
async function streamXlsx(filePath, sheetName, onRow, options) {
|
|
929
1070
|
const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
|
|
930
|
-
const targets = options.mergeSheets ? sheetDefinitions : sheetDefinitions.filter((sheet) => sheet.name === sheetName);
|
|
1071
|
+
const targets = options.mergeSheets ? sheetDefinitions.filter((sheet) => !sheet.hidden) : sheetDefinitions.filter((sheet) => sheet.name === sheetName);
|
|
1072
|
+
if (options.mergeSheets) {
|
|
1073
|
+
const excluded = sheetDefinitions.filter((sheet) => sheet.hidden).map((sheet) => sheet.name);
|
|
1074
|
+
if (excluded.length > 0) {
|
|
1075
|
+
process.stderr.write(`Warning: skipped ${excluded.length} hidden worksheet(s): ${excluded.join(", ")}.
|
|
1076
|
+
`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
931
1079
|
if (targets.length === 0) {
|
|
932
1080
|
throw new CliValidationError("The requested Excel Sheet was not found.", { code: "LOCAL_DATA_SET_NOT_FOUND" });
|
|
933
1081
|
}
|
|
934
1082
|
const archive = await unzipper.Open.file(filePath);
|
|
935
|
-
const
|
|
936
|
-
const
|
|
1083
|
+
const workbook = await readXlsxWorkbookContext(archive.files);
|
|
1084
|
+
const skipRows = Math.max(0, options.skipRows ?? 0);
|
|
1085
|
+
const collector = options.xlsxStructure;
|
|
1086
|
+
if (collector) collector.filled = Boolean(options.fillMergedCells);
|
|
937
1087
|
let count = 0;
|
|
938
1088
|
for (const definition of targets) {
|
|
939
1089
|
const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
|
|
@@ -943,19 +1093,37 @@ async function streamXlsx(filePath, sheetName, onRow, options) {
|
|
|
943
1093
|
location: { field: "input-file" }
|
|
944
1094
|
});
|
|
945
1095
|
}
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1096
|
+
const structure = collector || options.fillMergedCells || options.excludeHiddenRows ? await readXlsxSheetStructure(worksheetEntry) : EMPTY_XLSX_SHEET_STRUCTURE;
|
|
1097
|
+
if (collector) {
|
|
1098
|
+
collector.mergedRanges += structure.merged.length;
|
|
1099
|
+
collector.hiddenRowCount += structure.hiddenRows.size;
|
|
1100
|
+
for (const range of structure.merged) {
|
|
1101
|
+
if (collector.mergedRefs.length < XLSX_STRUCTURE_SAMPLE_LIMIT) collector.mergedRefs.push(range.ref);
|
|
1102
|
+
}
|
|
1103
|
+
for (const rowNumber of structure.hiddenRows) {
|
|
1104
|
+
if (collector.hiddenRows.length < XLSX_STRUCTURE_SAMPLE_LIMIT) collector.hiddenRows.push(rowNumber);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const merges = structure.merged.length > 0 ? new MergedRegionTracker(structure.merged, Boolean(options.fillMergedCells)) : void 0;
|
|
1108
|
+
const hiddenRows = options.excludeHiddenRows ? structure.hiddenRows : void 0;
|
|
1109
|
+
let hiddenColumnsResolved = false;
|
|
1110
|
+
const worksheet = new import_worksheet_reader.default({
|
|
1111
|
+
workbook,
|
|
952
1112
|
id: definition.id,
|
|
953
1113
|
iterator: worksheetEntry.stream(),
|
|
954
1114
|
options: { worksheets: "emit", hyperlinks: "ignore" }
|
|
955
1115
|
});
|
|
956
1116
|
let headers = options.headerNames;
|
|
1117
|
+
let skipped = 0;
|
|
957
1118
|
for await (const excelRow of worksheet) {
|
|
958
|
-
const
|
|
1119
|
+
const rawValues = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCellValue(excelRow.getCell(index + 1)));
|
|
1120
|
+
const reads = rawValues.map(readExcelCell);
|
|
1121
|
+
const values = reads.map((read) => read.value);
|
|
1122
|
+
merges?.observe(excelRow.number, values);
|
|
1123
|
+
if (skipped < skipRows) {
|
|
1124
|
+
skipped += 1;
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
959
1127
|
if (options.noHeader && !headers) {
|
|
960
1128
|
headers = values.map((_, index) => `col_${index + 1}`);
|
|
961
1129
|
}
|
|
@@ -964,8 +1132,49 @@ async function streamXlsx(filePath, sheetName, onRow, options) {
|
|
|
964
1132
|
continue;
|
|
965
1133
|
}
|
|
966
1134
|
if (values.every(isMissing)) continue;
|
|
1135
|
+
if (hiddenRows?.has(excelRow.number)) {
|
|
1136
|
+
if (collector) collector.excludedHiddenRows += 1;
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const covered = merges?.covered(excelRow.number, values);
|
|
967
1140
|
count += 1;
|
|
968
|
-
|
|
1141
|
+
if (collector) {
|
|
1142
|
+
if (!hiddenColumnsResolved) {
|
|
1143
|
+
hiddenColumnsResolved = true;
|
|
1144
|
+
for (const span of structure.hiddenColumns) {
|
|
1145
|
+
for (let column = span.min; column <= Math.min(span.max, headers.length); column += 1) {
|
|
1146
|
+
const header = headers[column - 1];
|
|
1147
|
+
if (header) collector.hiddenColumns.add(header);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
for (const column of covered ?? []) {
|
|
1152
|
+
const header = headers[column - 1];
|
|
1153
|
+
if (!header) continue;
|
|
1154
|
+
collector.coveredCells.set(header, (collector.coveredCells.get(header) ?? 0) + 1);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (options.excelDateColumns) {
|
|
1158
|
+
rawValues.forEach((raw, index) => {
|
|
1159
|
+
const header = headers?.[index];
|
|
1160
|
+
if (header && isExcelDateCell(raw)) options.excelDateColumns?.add(header);
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
const cellIssues = options.cellIssues;
|
|
1164
|
+
if (cellIssues) {
|
|
1165
|
+
reads.forEach((read, index) => {
|
|
1166
|
+
const header = headers?.[index];
|
|
1167
|
+
if (!read.issue || !header) return;
|
|
1168
|
+
const byColumn = cellIssues.get(read.issue) ?? /* @__PURE__ */ new Map();
|
|
1169
|
+
byColumn.set(header, (byColumn.get(header) ?? 0) + 1);
|
|
1170
|
+
cellIssues.set(read.issue, byColumn);
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
const row = Object.fromEntries(headers.map((header, index) => [header, values[index] ?? null]));
|
|
1174
|
+
await onRow(
|
|
1175
|
+
options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules) : row,
|
|
1176
|
+
count
|
|
1177
|
+
);
|
|
969
1178
|
}
|
|
970
1179
|
}
|
|
971
1180
|
return count;
|
|
@@ -980,28 +1189,29 @@ async function readXlsxSheetDefinitions(filePath) {
|
|
|
980
1189
|
location: { field: "input-file" }
|
|
981
1190
|
});
|
|
982
1191
|
}
|
|
983
|
-
const [workbookXml, relationshipsXml] = await Promise.all([
|
|
984
|
-
workbookEntry.buffer().then((value) => value.toString("utf8")),
|
|
985
|
-
relationshipsEntry.buffer().then((value) => value.toString("utf8"))
|
|
986
|
-
]);
|
|
987
1192
|
const targets = /* @__PURE__ */ new Map();
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1193
|
+
await parseXmlTags(relationshipsEntry, (tag) => {
|
|
1194
|
+
if (xmlLocalName(tag.name) !== "Relationship") return;
|
|
1195
|
+
const id = xmlAttributeByLocalName(tag.attributes, "Id");
|
|
1196
|
+
const target = xmlAttributeByLocalName(tag.attributes, "Target");
|
|
1197
|
+
if (id && target) targets.set(id, target);
|
|
1198
|
+
});
|
|
992
1199
|
const sheets = [];
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const relationshipId = attributes
|
|
1200
|
+
await parseXmlTags(workbookEntry, (tag) => {
|
|
1201
|
+
if (xmlLocalName(tag.name) !== "sheet") return;
|
|
1202
|
+
const relationshipId = xmlAttributeByLocalName(tag.attributes, "id");
|
|
996
1203
|
const target = relationshipId ? targets.get(relationshipId) : void 0;
|
|
997
1204
|
const fileNumber = target?.match(/worksheets\/sheet(\d+)\.xml$/)?.[1];
|
|
998
|
-
|
|
1205
|
+
const name = xmlAttributeByLocalName(tag.attributes, "name");
|
|
1206
|
+
if (!name || !relationshipId || !fileNumber) return;
|
|
1207
|
+
const state = xmlAttributeByLocalName(tag.attributes, "state");
|
|
999
1208
|
sheets.push({
|
|
1000
|
-
id: Number(attributes
|
|
1001
|
-
name: decodeXml(
|
|
1002
|
-
entryPath: normalizeXlsxEntryPath(target)
|
|
1209
|
+
id: Number(xmlAttributeByLocalName(tag.attributes, "sheetId") ?? fileNumber),
|
|
1210
|
+
name: decodeXml(name),
|
|
1211
|
+
entryPath: normalizeXlsxEntryPath(target),
|
|
1212
|
+
hidden: state === "hidden" || state === "veryHidden"
|
|
1003
1213
|
});
|
|
1004
|
-
}
|
|
1214
|
+
});
|
|
1005
1215
|
if (sheets.length === 0) {
|
|
1006
1216
|
throw new CliValidationError("XLSX workbook contains no readable Sheets.", {
|
|
1007
1217
|
code: "LOCAL_DATA_XLSX_INVALID",
|
|
@@ -1024,25 +1234,21 @@ function readXlsSheetHeaders(filePath) {
|
|
|
1024
1234
|
async function readXlsxSheetHeaders(filePath) {
|
|
1025
1235
|
const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
|
|
1026
1236
|
const archive = await unzipper.Open.file(filePath);
|
|
1027
|
-
const
|
|
1028
|
-
const sharedStrings = sharedStringsEntry ? await readXlsxSharedStrings(sharedStringsEntry) : [];
|
|
1237
|
+
const workbook = await readXlsxWorkbookContext(archive.files);
|
|
1029
1238
|
const sheets = [];
|
|
1030
1239
|
for (const definition of sheetDefinitions) {
|
|
1240
|
+
if (definition.hidden) continue;
|
|
1031
1241
|
const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
|
|
1032
1242
|
if (!worksheetEntry) continue;
|
|
1033
|
-
const worksheet = new
|
|
1034
|
-
workbook
|
|
1035
|
-
sharedStrings,
|
|
1036
|
-
styles: { getStyleModel: () => null },
|
|
1037
|
-
properties: { model: {} }
|
|
1038
|
-
},
|
|
1243
|
+
const worksheet = new import_worksheet_reader.default({
|
|
1244
|
+
workbook,
|
|
1039
1245
|
id: definition.id,
|
|
1040
1246
|
iterator: worksheetEntry.stream(),
|
|
1041
1247
|
options: { worksheets: "emit", hyperlinks: "ignore" }
|
|
1042
1248
|
});
|
|
1043
1249
|
let headers = [];
|
|
1044
1250
|
for await (const excelRow of worksheet) {
|
|
1045
|
-
const values = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) =>
|
|
1251
|
+
const values = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCell(readExcelCellValue(excelRow.getCell(index + 1))).value);
|
|
1046
1252
|
headers = firstRowHeaders(values);
|
|
1047
1253
|
break;
|
|
1048
1254
|
}
|
|
@@ -1053,13 +1259,82 @@ async function readXlsxSheetHeaders(filePath) {
|
|
|
1053
1259
|
function firstRowHeaders(values) {
|
|
1054
1260
|
return dedupeHeaders(values.map((value, index) => String(value ?? `column_${index + 1}`).trim()));
|
|
1055
1261
|
}
|
|
1262
|
+
async function peekXlsxRows(filePath, sheetName, limit = 10) {
|
|
1263
|
+
const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
|
|
1264
|
+
const definition = sheetDefinitions.find((sheet) => sheet.name === sheetName);
|
|
1265
|
+
if (!definition) return [];
|
|
1266
|
+
const archive = await unzipper.Open.file(filePath);
|
|
1267
|
+
const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
|
|
1268
|
+
if (!worksheetEntry) return [];
|
|
1269
|
+
const workbook = await readXlsxWorkbookContext(archive.files);
|
|
1270
|
+
const worksheet = new import_worksheet_reader.default({
|
|
1271
|
+
workbook,
|
|
1272
|
+
id: definition.id,
|
|
1273
|
+
iterator: worksheetEntry.stream(),
|
|
1274
|
+
options: { worksheets: "emit", hyperlinks: "ignore" }
|
|
1275
|
+
});
|
|
1276
|
+
const rows = [];
|
|
1277
|
+
for await (const excelRow of worksheet) {
|
|
1278
|
+
rows.push(Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCell(readExcelCellValue(excelRow.getCell(index + 1))).value));
|
|
1279
|
+
if (rows.length >= limit) break;
|
|
1280
|
+
}
|
|
1281
|
+
return rows;
|
|
1282
|
+
}
|
|
1283
|
+
var NO_XLSX_STYLES = { getStyleModel: () => null };
|
|
1284
|
+
var ELAPSED_TIME_FORMATS = /* @__PURE__ */ new Set(["mm:ss", "mmss.0", "[h]:mm:ss"]);
|
|
1285
|
+
function isElapsedTimeFormat(numFmt) {
|
|
1286
|
+
return ELAPSED_TIME_FORMATS.has(numFmt.trim()) || /\[[hms]+\]/i.test(numFmt);
|
|
1287
|
+
}
|
|
1288
|
+
async function readXlsxWorkbookContext(files) {
|
|
1289
|
+
const sharedStringsEntry = files.find((entry) => entry.path === "xl/sharedStrings.xml");
|
|
1290
|
+
const stylesEntry = files.find((entry) => entry.path === "xl/styles.xml");
|
|
1291
|
+
const workbookEntry = files.find((entry) => entry.path === "xl/workbook.xml");
|
|
1292
|
+
return {
|
|
1293
|
+
sharedStrings: sharedStringsEntry ? await readXlsxSharedStrings(sharedStringsEntry) : [],
|
|
1294
|
+
styles: await readXlsxStyles(stylesEntry),
|
|
1295
|
+
properties: { model: { date1904: workbookEntry ? await readXlsxDate1904(workbookEntry) : false } }
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
async function readXlsxStyles(entry) {
|
|
1299
|
+
if (!entry) return NO_XLSX_STYLES;
|
|
1300
|
+
let styles;
|
|
1301
|
+
try {
|
|
1302
|
+
styles = new import_styles_xform.default();
|
|
1303
|
+
styles.init();
|
|
1304
|
+
await styles.parseStream(entry.stream());
|
|
1305
|
+
} catch {
|
|
1306
|
+
return NO_XLSX_STYLES;
|
|
1307
|
+
}
|
|
1308
|
+
return {
|
|
1309
|
+
getStyleModel: (id) => {
|
|
1310
|
+
let model;
|
|
1311
|
+
try {
|
|
1312
|
+
model = styles.getStyleModel(id);
|
|
1313
|
+
} catch {
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
if (!model?.numFmt || !isElapsedTimeFormat(model.numFmt)) return model;
|
|
1317
|
+
const { numFmt, ...rest } = model;
|
|
1318
|
+
return rest;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
async function readXlsxDate1904(entry) {
|
|
1323
|
+
let date1904 = false;
|
|
1324
|
+
await parseXmlTags(entry, (tag) => {
|
|
1325
|
+
if (xmlLocalName(tag.name) !== "workbookPr") return;
|
|
1326
|
+
const value = xmlAttributeByLocalName(tag.attributes, "date1904");
|
|
1327
|
+
date1904 = value === "1" || value === "true";
|
|
1328
|
+
});
|
|
1329
|
+
return date1904;
|
|
1330
|
+
}
|
|
1056
1331
|
async function readXlsxSharedStrings(entry) {
|
|
1057
1332
|
const values = [];
|
|
1058
1333
|
let inItem = false;
|
|
1059
1334
|
let current = "";
|
|
1060
1335
|
const parser = new SaxesParser();
|
|
1061
1336
|
parser.on("opentag", (tag) => {
|
|
1062
|
-
if (tag.name === "si") {
|
|
1337
|
+
if (xmlLocalName(tag.name) === "si") {
|
|
1063
1338
|
inItem = true;
|
|
1064
1339
|
current = "";
|
|
1065
1340
|
}
|
|
@@ -1068,26 +1343,147 @@ async function readXlsxSharedStrings(entry) {
|
|
|
1068
1343
|
if (inItem) current += text;
|
|
1069
1344
|
});
|
|
1070
1345
|
parser.on("closetag", (tag) => {
|
|
1071
|
-
if (tag.name === "si") {
|
|
1346
|
+
if (xmlLocalName(tag.name) === "si") {
|
|
1072
1347
|
values.push(current);
|
|
1073
1348
|
inItem = false;
|
|
1074
1349
|
current = "";
|
|
1075
1350
|
}
|
|
1076
1351
|
});
|
|
1352
|
+
const decoder = new StringDecoder("utf8");
|
|
1077
1353
|
for await (const chunk of entry.stream()) {
|
|
1078
|
-
parser.write(Buffer.from(chunk)
|
|
1354
|
+
parser.write(decoder.write(Buffer.from(chunk)));
|
|
1079
1355
|
}
|
|
1356
|
+
parser.write(decoder.end());
|
|
1080
1357
|
parser.close();
|
|
1081
1358
|
return values;
|
|
1082
1359
|
}
|
|
1360
|
+
var EMPTY_XLSX_SHEET_STRUCTURE = {
|
|
1361
|
+
merged: [],
|
|
1362
|
+
hiddenRows: /* @__PURE__ */ new Set(),
|
|
1363
|
+
hiddenColumns: []
|
|
1364
|
+
};
|
|
1365
|
+
async function readXlsxSheetStructure(entry) {
|
|
1366
|
+
const merged = [];
|
|
1367
|
+
const hiddenRows = /* @__PURE__ */ new Set();
|
|
1368
|
+
const hiddenColumns = [];
|
|
1369
|
+
try {
|
|
1370
|
+
const parser = new SaxesParser();
|
|
1371
|
+
parser.on("opentag", (tag) => {
|
|
1372
|
+
const attributes = tag.attributes;
|
|
1373
|
+
if (xmlLocalName(tag.name) === "mergeCell") {
|
|
1374
|
+
const range = parseMergedRef(attributes.ref ?? "");
|
|
1375
|
+
if (range) merged.push(range);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (xmlLocalName(tag.name) === "row") {
|
|
1379
|
+
const rowNumber = Number(attributes.r);
|
|
1380
|
+
if (isXlsxFlagSet(attributes.hidden) && Number.isInteger(rowNumber) && rowNumber >= 1) {
|
|
1381
|
+
hiddenRows.add(rowNumber);
|
|
1382
|
+
}
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (xmlLocalName(tag.name) === "col" && isXlsxFlagSet(attributes.hidden)) {
|
|
1386
|
+
const min = Number(attributes.min);
|
|
1387
|
+
const max = Number(attributes.max);
|
|
1388
|
+
if (Number.isInteger(min) && min >= 1) {
|
|
1389
|
+
hiddenColumns.push({ min, max: Number.isInteger(max) && max >= min ? max : min });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
const decoder = new StringDecoder("utf8");
|
|
1394
|
+
for await (const chunk of entry.stream()) {
|
|
1395
|
+
parser.write(decoder.write(Buffer.from(chunk)));
|
|
1396
|
+
}
|
|
1397
|
+
parser.write(decoder.end());
|
|
1398
|
+
parser.close();
|
|
1399
|
+
} catch {
|
|
1400
|
+
return EMPTY_XLSX_SHEET_STRUCTURE;
|
|
1401
|
+
}
|
|
1402
|
+
return { merged, hiddenRows, hiddenColumns };
|
|
1403
|
+
}
|
|
1404
|
+
function isXlsxFlagSet(value) {
|
|
1405
|
+
return value === "1" || value === "true";
|
|
1406
|
+
}
|
|
1407
|
+
function parseMergedRef(ref) {
|
|
1408
|
+
const match = /^([A-Za-z]+)(\d+):([A-Za-z]+)(\d+)$/.exec(ref.trim());
|
|
1409
|
+
if (!match) return void 0;
|
|
1410
|
+
const left = xlsxColumnNumber(match[1]);
|
|
1411
|
+
const top = Number(match[2]);
|
|
1412
|
+
const right = xlsxColumnNumber(match[3]);
|
|
1413
|
+
const bottom = Number(match[4]);
|
|
1414
|
+
if (right < left || bottom < top || top < 1) return void 0;
|
|
1415
|
+
return { ref: ref.trim().toUpperCase(), top, bottom, left, right };
|
|
1416
|
+
}
|
|
1417
|
+
function xlsxColumnNumber(letters) {
|
|
1418
|
+
let column = 0;
|
|
1419
|
+
for (const letter of letters.toUpperCase()) column = column * 26 + (letter.charCodeAt(0) - 64);
|
|
1420
|
+
return column;
|
|
1421
|
+
}
|
|
1422
|
+
var MergedRegionTracker = class {
|
|
1423
|
+
constructor(ranges, fill) {
|
|
1424
|
+
this.fill = fill;
|
|
1425
|
+
this.pending = [...ranges].sort((left, right) => left.top - right.top || left.left - right.left);
|
|
1426
|
+
}
|
|
1427
|
+
fill;
|
|
1428
|
+
pending;
|
|
1429
|
+
next = 0;
|
|
1430
|
+
active = [];
|
|
1431
|
+
/** Advance to `rowNumber`, taking the anchor value of every range that starts on it. */
|
|
1432
|
+
observe(rowNumber, values) {
|
|
1433
|
+
while (this.next < this.pending.length && this.pending[this.next].top <= rowNumber) {
|
|
1434
|
+
this.active.push({ range: this.pending[this.next], anchor: void 0 });
|
|
1435
|
+
this.next += 1;
|
|
1436
|
+
}
|
|
1437
|
+
if (this.active.length === 0) return;
|
|
1438
|
+
this.active = this.active.filter((entry) => entry.range.bottom >= rowNumber);
|
|
1439
|
+
for (const entry of this.active) {
|
|
1440
|
+
if (entry.range.top === rowNumber) entry.anchor = values[entry.range.left - 1];
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* The 1-based columns whose cell on this row is empty only because a merge covers it, filled from
|
|
1445
|
+
* the anchor when enabled. `observe` must have run for the same row first.
|
|
1446
|
+
*/
|
|
1447
|
+
covered(rowNumber, values) {
|
|
1448
|
+
const columns = [];
|
|
1449
|
+
for (const entry of this.active) {
|
|
1450
|
+
if (isMissing(entry.anchor)) continue;
|
|
1451
|
+
const { range } = entry;
|
|
1452
|
+
for (let column = range.left; column <= range.right; column += 1) {
|
|
1453
|
+
if (range.top === rowNumber && column === range.left) continue;
|
|
1454
|
+
if (!isMissing(values[column - 1])) continue;
|
|
1455
|
+
columns.push(column);
|
|
1456
|
+
if (this.fill) values[column - 1] = entry.anchor;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
return columns;
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1083
1462
|
function normalizeXlsxEntryPath(target) {
|
|
1084
1463
|
const normalized = target.replace(/^\//, "");
|
|
1085
1464
|
return normalized.startsWith("xl/") ? normalized : `xl/${normalized.replace(/^\.\//, "")}`;
|
|
1086
1465
|
}
|
|
1087
|
-
function
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1466
|
+
function xmlLocalName(qname) {
|
|
1467
|
+
const colon = qname.lastIndexOf(":");
|
|
1468
|
+
return colon === -1 ? qname : qname.slice(colon + 1);
|
|
1469
|
+
}
|
|
1470
|
+
function xmlAttributeByLocalName(attributes, local) {
|
|
1471
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
1472
|
+
if (xmlLocalName(name) === local) return value;
|
|
1473
|
+
}
|
|
1474
|
+
return void 0;
|
|
1475
|
+
}
|
|
1476
|
+
async function parseXmlTags(entry, onOpenTag) {
|
|
1477
|
+
const parser = new SaxesParser();
|
|
1478
|
+
parser.on("opentag", (tag) => {
|
|
1479
|
+
onOpenTag({ name: tag.name, attributes: tag.attributes });
|
|
1480
|
+
});
|
|
1481
|
+
const decoder = new StringDecoder("utf8");
|
|
1482
|
+
for await (const chunk of entry.stream()) {
|
|
1483
|
+
parser.write(decoder.write(Buffer.from(chunk)));
|
|
1484
|
+
}
|
|
1485
|
+
parser.write(decoder.end());
|
|
1486
|
+
parser.close();
|
|
1091
1487
|
}
|
|
1092
1488
|
function decodeXml(source) {
|
|
1093
1489
|
return source.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
@@ -1099,10 +1495,12 @@ async function streamXls(filePath, sheetName, onRow, options) {
|
|
|
1099
1495
|
throw new CliValidationError("The requested Excel Sheet was not found.", { code: "LOCAL_DATA_SET_NOT_FOUND" });
|
|
1100
1496
|
}
|
|
1101
1497
|
let count = 0;
|
|
1498
|
+
const skipRows = Math.max(0, options.skipRows ?? 0);
|
|
1102
1499
|
for (const name of names) {
|
|
1103
1500
|
const sheet = workbook.Sheets[name];
|
|
1104
1501
|
if (!sheet) continue;
|
|
1105
|
-
const
|
|
1502
|
+
const allRows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: null });
|
|
1503
|
+
const rows = allRows.slice(skipRows);
|
|
1106
1504
|
if (rows.length === 0) continue;
|
|
1107
1505
|
let headers = options.headerNames;
|
|
1108
1506
|
let start = 0;
|
|
@@ -1117,27 +1515,136 @@ async function streamXls(filePath, sheetName, onRow, options) {
|
|
|
1117
1515
|
for (const values of rows.slice(start)) {
|
|
1118
1516
|
if (values.every(isMissing)) continue;
|
|
1119
1517
|
count += 1;
|
|
1120
|
-
|
|
1518
|
+
const row = Object.fromEntries(headers.map((header, index) => [header, values[index] ?? null]));
|
|
1519
|
+
await onRow(
|
|
1520
|
+
options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules) : row,
|
|
1521
|
+
count
|
|
1522
|
+
);
|
|
1121
1523
|
}
|
|
1122
1524
|
}
|
|
1123
1525
|
return count;
|
|
1124
1526
|
}
|
|
1125
|
-
function
|
|
1126
|
-
|
|
1127
|
-
|
|
1527
|
+
function excelDateToWallClock(value) {
|
|
1528
|
+
const pad = (part, width = 2) => String(part).padStart(width, "0");
|
|
1529
|
+
const date = `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}`;
|
|
1530
|
+
const [hours, minutes, seconds, ms] = [
|
|
1531
|
+
value.getUTCHours(),
|
|
1532
|
+
value.getUTCMinutes(),
|
|
1533
|
+
value.getUTCSeconds(),
|
|
1534
|
+
value.getUTCMilliseconds()
|
|
1535
|
+
];
|
|
1536
|
+
if (hours === 0 && minutes === 0 && seconds === 0 && ms === 0) return date;
|
|
1537
|
+
const time = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
|
1538
|
+
return ms === 0 ? `${date} ${time}` : `${date} ${time}.${pad(ms, 3)}`;
|
|
1539
|
+
}
|
|
1540
|
+
function isExcelDateCell(value) {
|
|
1541
|
+
if (value instanceof Date) return true;
|
|
1542
|
+
return Boolean(value && typeof value === "object" && "result" in value && value.result instanceof Date);
|
|
1543
|
+
}
|
|
1544
|
+
function readExcelCellValue(cell) {
|
|
1545
|
+
if (cell.type !== import_exceljs.default.ValueType.Formula) return cell.value;
|
|
1546
|
+
const model = cell.model;
|
|
1547
|
+
if (!model || !("result" in model)) return cell.value;
|
|
1548
|
+
return { formula: model.formula ?? "", result: model.result };
|
|
1549
|
+
}
|
|
1550
|
+
var CELL_ISSUE_ORDER = ["formula_no_cached_value", "error_value", "unreadable_object"];
|
|
1551
|
+
var CELL_ISSUE_MESSAGES = {
|
|
1552
|
+
formula_no_cached_value: "hold a formula whose last computed result is not stored in the file, and were read as missing. Excel normally saves that result next to the formula; a file exported without it has no value to upload. Recalculate and re-export in Excel, or export values instead of formulas. This tool never evaluates a formula and never guesses a result.",
|
|
1553
|
+
error_value: "hold an Excel error value (#N/A, #DIV/0!, \u2026), and were read as missing. Fix them in the source file if those cells were meant to carry data.",
|
|
1554
|
+
unreadable_object: "arrived in a cell shape this tool does not recognize, and were read as missing rather than passed on as an object. Report the file if these cells do carry data."
|
|
1555
|
+
};
|
|
1556
|
+
function cellIssueWarnings(cellIssues) {
|
|
1557
|
+
const warnings = [];
|
|
1558
|
+
for (const issue of CELL_ISSUE_ORDER) {
|
|
1559
|
+
const byColumn = cellIssues.get(issue);
|
|
1560
|
+
if (!byColumn || byColumn.size === 0) continue;
|
|
1561
|
+
const columns = [...byColumn.entries()].map(([column, count]) => `${column} (${count})`).join(", ");
|
|
1562
|
+
const total = [...byColumn.values()].reduce((sum, count) => sum + count, 0);
|
|
1563
|
+
warnings.push(`${total} cell(s) ${CELL_ISSUE_MESSAGES[issue]} Affected columns: ${columns}.`);
|
|
1564
|
+
}
|
|
1565
|
+
return warnings;
|
|
1566
|
+
}
|
|
1567
|
+
function cellIssueCounts(cellIssues) {
|
|
1568
|
+
const counts = {};
|
|
1569
|
+
for (const issue of CELL_ISSUE_ORDER) {
|
|
1570
|
+
const byColumn = cellIssues.get(issue);
|
|
1571
|
+
if (!byColumn || byColumn.size === 0) continue;
|
|
1572
|
+
counts[issue] = Object.fromEntries(byColumn);
|
|
1573
|
+
}
|
|
1574
|
+
return counts;
|
|
1575
|
+
}
|
|
1576
|
+
var XLSX_STRUCTURE_SAMPLE_LIMIT = 10;
|
|
1577
|
+
function createXlsxStructureCollector() {
|
|
1578
|
+
return {
|
|
1579
|
+
mergedRanges: 0,
|
|
1580
|
+
mergedRefs: [],
|
|
1581
|
+
coveredCells: /* @__PURE__ */ new Map(),
|
|
1582
|
+
filled: false,
|
|
1583
|
+
hiddenRows: [],
|
|
1584
|
+
hiddenRowCount: 0,
|
|
1585
|
+
excludedHiddenRows: 0,
|
|
1586
|
+
hiddenColumns: /* @__PURE__ */ new Set()
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
function xlsxStructureReport(collector) {
|
|
1590
|
+
const hasFindings = collector.mergedRanges > 0 || collector.hiddenRowCount > 0 || collector.hiddenColumns.size > 0;
|
|
1591
|
+
if (!hasFindings) return void 0;
|
|
1592
|
+
return {
|
|
1593
|
+
...collector.mergedRanges > 0 ? {
|
|
1594
|
+
merged_ranges: collector.mergedRanges,
|
|
1595
|
+
merged_range_samples: collector.mergedRefs,
|
|
1596
|
+
merged_covered_cells: Object.fromEntries(collector.coveredCells),
|
|
1597
|
+
merged_cells_filled: collector.filled
|
|
1598
|
+
} : {},
|
|
1599
|
+
...collector.hiddenRowCount > 0 ? {
|
|
1600
|
+
hidden_rows: collector.hiddenRowCount,
|
|
1601
|
+
hidden_row_samples: collector.hiddenRows,
|
|
1602
|
+
excluded_hidden_rows: collector.excludedHiddenRows
|
|
1603
|
+
} : {},
|
|
1604
|
+
...collector.hiddenColumns.size > 0 ? { hidden_columns: [...collector.hiddenColumns] } : {}
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
function xlsxStructureWarnings(collector, excludedColumns) {
|
|
1608
|
+
const warnings = [];
|
|
1609
|
+
const sample = (values, total) => values.join(", ") + (total > values.length ? ", \u2026" : "");
|
|
1610
|
+
if (collector.coveredCells.size > 0) {
|
|
1611
|
+
const columns = [...collector.coveredCells.entries()].map(([column, count]) => `${column} (${count})`).join(", ");
|
|
1612
|
+
const total = [...collector.coveredCells.values()].reduce((sum, count) => sum + count, 0);
|
|
1613
|
+
warnings.push(collector.filled ? `${total} cell(s) were empty only because a merged block covers them, and were filled from the value on the block's first row. Affected columns: ${columns}.` : `${total} cell(s) are empty only because a merged block covers them: Excel shows the value on the block's first row only, so every row below it reads as missing. Affected columns: ${columns}. Pass --fill-merged-cells to copy each block's value down its own range, or unmerge and fill the column in the source file.`);
|
|
1614
|
+
}
|
|
1615
|
+
if (collector.hiddenRowCount > 0) {
|
|
1616
|
+
const rows = sample(collector.hiddenRows, collector.hiddenRowCount);
|
|
1617
|
+
warnings.push(collector.excludedHiddenRows > 0 ? `Skipped ${collector.excludedHiddenRows} row(s) hidden in the source worksheet (source row ${rows}).` : `${collector.hiddenRowCount} row(s) are hidden in the source worksheet (source row ${rows}) and were read as data. Pass --exclude-hidden-rows to leave them out.`);
|
|
1618
|
+
}
|
|
1619
|
+
if (collector.hiddenColumns.size > 0) {
|
|
1620
|
+
const excluded = new Set(excludedColumns ?? []);
|
|
1621
|
+
const kept = [...collector.hiddenColumns].filter((column) => !excluded.has(column));
|
|
1622
|
+
if (kept.length > 0) {
|
|
1623
|
+
warnings.push(`${kept.length} column(s) are hidden in the source worksheet and were read as data: ${kept.join(", ")}. List them in the mapping's exclude_columns to leave them out.`);
|
|
1624
|
+
}
|
|
1128
1625
|
}
|
|
1129
|
-
return
|
|
1626
|
+
return warnings;
|
|
1130
1627
|
}
|
|
1131
|
-
function
|
|
1132
|
-
if (value instanceof Date) return value;
|
|
1628
|
+
function readExcelCell(value) {
|
|
1629
|
+
if (value instanceof Date) return { value: excelDateToWallClock(value) };
|
|
1133
1630
|
if (value && typeof value === "object") {
|
|
1134
|
-
if ("
|
|
1135
|
-
if ("
|
|
1631
|
+
if ("error" in value) return { value: null, issue: "error_value" };
|
|
1632
|
+
if ("formula" in value || "sharedFormula" in value) {
|
|
1633
|
+
if (!("result" in value)) return { value: null, issue: "formula_no_cached_value" };
|
|
1634
|
+
const result = value.result;
|
|
1635
|
+
if (result === null || result === void 0) return { value: null, issue: "formula_no_cached_value" };
|
|
1636
|
+
if (typeof result === "object" && "error" in result) return { value: null, issue: "error_value" };
|
|
1637
|
+
if (typeof result === "number" && Number.isNaN(result)) return { value: null, issue: "error_value" };
|
|
1638
|
+
if (result instanceof Date) return { value: excelDateToWallClock(result) };
|
|
1639
|
+
return { value: result };
|
|
1640
|
+
}
|
|
1641
|
+
if ("text" in value) return { value: String(value.text) };
|
|
1136
1642
|
if ("richText" in value) {
|
|
1137
|
-
return value.richText.map((part) => part.text).join("");
|
|
1643
|
+
return { value: value.richText.map((part) => part.text).join("") };
|
|
1138
1644
|
}
|
|
1645
|
+
return { value: null, issue: "unreadable_object" };
|
|
1139
1646
|
}
|
|
1140
|
-
return value ?? null;
|
|
1647
|
+
return { value: value ?? null };
|
|
1141
1648
|
}
|
|
1142
1649
|
function dedupeHeaders(headers) {
|
|
1143
1650
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -1170,9 +1677,19 @@ async function firstNonWhitespaceCharacter(filePath, encoding) {
|
|
|
1170
1677
|
return void 0;
|
|
1171
1678
|
}
|
|
1172
1679
|
|
|
1173
|
-
// src/commands/data-integration/
|
|
1680
|
+
// src/commands/data-integration/mapping.ts
|
|
1174
1681
|
import { readFileSync } from "fs";
|
|
1682
|
+
|
|
1683
|
+
// src/commands/data-integration/types.ts
|
|
1684
|
+
var MAPPING_VERSION = "ae-data-integration-mapping/v1";
|
|
1685
|
+
|
|
1686
|
+
// src/commands/data-integration/mapping.ts
|
|
1175
1687
|
var VALID_PROPERTY_NAME = /^[a-z][a-z0-9_]{0,49}$/;
|
|
1688
|
+
var VALID_CHILD_PROPERTY_NAME = /^[a-z][a-z0-9_]{0,49}(\.[a-z][a-z0-9_]{0,49})+$/;
|
|
1689
|
+
var FLATTEN_SUPPORTED_FORMATS = ["csv", "tsv", "json", "jsonl", "xls", "xlsx"];
|
|
1690
|
+
var SKIP_ROWS_SUPPORTED_FORMATS = ["csv", "tsv", "xls", "xlsx"];
|
|
1691
|
+
var XLSX_STRUCTURE_SUPPORTED_FORMATS = ["xlsx"];
|
|
1692
|
+
var XLSX_STRUCTURE_FIELDS = ["fill_merged_cells", "exclude_hidden_rows"];
|
|
1176
1693
|
function readLocalDataMapping(raw, options) {
|
|
1177
1694
|
const trimmed = raw.trim();
|
|
1178
1695
|
let text;
|
|
@@ -1202,8 +1719,8 @@ function readLocalDataMapping(raw, options) {
|
|
|
1202
1719
|
return value;
|
|
1203
1720
|
}
|
|
1204
1721
|
function validateMapping(value, options) {
|
|
1205
|
-
if (!isRecord(value) || value.version !==
|
|
1206
|
-
throw mappingError(
|
|
1722
|
+
if (!isRecord(value) || value.version !== MAPPING_VERSION) {
|
|
1723
|
+
throw mappingError(`Mapping version must be ${MAPPING_VERSION}.`);
|
|
1207
1724
|
}
|
|
1208
1725
|
const sha256Valid = typeof value.source?.sha256 === "string" && (options?.sourceWildcard ? value.source.sha256 === "*" || /^[a-f0-9]{64}$/i.test(value.source.sha256) : /^[a-f0-9]{64}$/i.test(value.source.sha256));
|
|
1209
1726
|
if (!isRecord(value.source) || !sha256Valid || !["csv", "tsv", "json", "jsonl", "xls", "xlsx"].includes(String(value.source.format)) || typeof value.source.data_set !== "string" || !value.source.data_set) {
|
|
@@ -1233,12 +1750,28 @@ function validateMapping(value, options) {
|
|
|
1233
1750
|
}
|
|
1234
1751
|
if (!Array.isArray(value.properties)) throw mappingError("Mapping properties must be an array.");
|
|
1235
1752
|
const targets = /* @__PURE__ */ new Set();
|
|
1753
|
+
const propertyTypes = /* @__PURE__ */ new Map();
|
|
1236
1754
|
for (const property of value.properties) {
|
|
1237
|
-
if (!isRecord(property) || typeof property.source !== "string" || typeof property.target !== "string" || !VALID_PROPERTY_NAME.test(property.target) || !["number", "string", "boolean", "datetime", "list", "object"].includes(String(property.type)) || property.transform !== void 0 && !["stringify", "number", "boolean", "json"].includes(String(property.transform)) || property.value_mapping !== void 0 && !isStringMap(property.value_mapping) || property.time_format !== void 0 && (typeof property.time_format !== "string" || !property.time_format.trim() || property.time_format.length > 64) || property.desc !== void 0 && (typeof property.desc !== "string" || !property.desc.trim() || property.desc.length > 200)) {
|
|
1755
|
+
if (!isRecord(property) || typeof property.source !== "string" || typeof property.target !== "string" || !(VALID_PROPERTY_NAME.test(property.target) || VALID_CHILD_PROPERTY_NAME.test(property.target)) || !["number", "string", "boolean", "datetime", "list", "object", "array_row"].includes(String(property.type)) || property.transform !== void 0 && !["stringify", "number", "boolean", "json"].includes(String(property.transform)) || property.value_mapping !== void 0 && !isStringMap(property.value_mapping) || property.time_format !== void 0 && (typeof property.time_format !== "string" || !property.time_format.trim() || property.time_format.length > 64) || property.desc !== void 0 && (typeof property.desc !== "string" || !property.desc.trim() || property.desc.length > 200)) {
|
|
1238
1756
|
throw mappingError("Every property mapping needs a source, legal AE target name, and supported type.");
|
|
1239
1757
|
}
|
|
1240
1758
|
if (targets.has(property.target)) throw mappingError("Property target names must be unique.");
|
|
1241
1759
|
targets.add(property.target);
|
|
1760
|
+
propertyTypes.set(property.target, { type: String(property.type), source: property.source });
|
|
1761
|
+
}
|
|
1762
|
+
for (const property of value.properties) {
|
|
1763
|
+
if (!property.target.includes(".")) continue;
|
|
1764
|
+
const parentName = property.target.split(".")[0];
|
|
1765
|
+
const parent = propertyTypes.get(parentName);
|
|
1766
|
+
if (!parent || parent.type !== "object" && parent.type !== "array_row") {
|
|
1767
|
+
throw mappingError(`The sub-property "${property.target}" references "${parentName}", which is not an object/array_row property in this mapping.`);
|
|
1768
|
+
}
|
|
1769
|
+
if (property.type === "object" || property.type === "array_row") {
|
|
1770
|
+
throw mappingError(`The sub-property "${property.target}" must be scalar or list, not ${property.type}.`);
|
|
1771
|
+
}
|
|
1772
|
+
if (property.source !== parent.source) {
|
|
1773
|
+
throw mappingError(`The sub-property "${property.target}" must read from the same source column as its parent "${parentName}".`);
|
|
1774
|
+
}
|
|
1242
1775
|
}
|
|
1243
1776
|
if (value.time_format !== void 0 && (typeof value.time_format !== "string" || !value.time_format.trim() || value.time_format.length > 64)) {
|
|
1244
1777
|
throw mappingError("time_format must be a non-empty string of at most 64 characters.");
|
|
@@ -1280,6 +1813,9 @@ function validateMapping(value, options) {
|
|
|
1280
1813
|
}
|
|
1281
1814
|
if (value.flatten_rules !== void 0) {
|
|
1282
1815
|
if (!isRecord(value.flatten_rules)) throw mappingError("flatten_rules must be an object of { column: dot.path }.");
|
|
1816
|
+
if (!FLATTEN_SUPPORTED_FORMATS.includes(String(value.source.format))) {
|
|
1817
|
+
throw mappingError(`flatten_rules are not supported for ${String(value.source.format)} input.`);
|
|
1818
|
+
}
|
|
1283
1819
|
for (const [column, path] of Object.entries(value.flatten_rules)) {
|
|
1284
1820
|
if (!VALID_PROPERTY_NAME.test(column) || typeof path !== "string" || !path.trim()) {
|
|
1285
1821
|
throw mappingError("flatten_rules keys must be legal AE property names and values must be non-empty dot paths.");
|
|
@@ -1289,6 +1825,23 @@ function validateMapping(value, options) {
|
|
|
1289
1825
|
if (value.headers !== void 0 && !isNonEmptyStringArray(value.headers, true)) {
|
|
1290
1826
|
throw mappingError("headers must be a non-empty array of unique strings.");
|
|
1291
1827
|
}
|
|
1828
|
+
if (value.skip_rows !== void 0) {
|
|
1829
|
+
if (typeof value.skip_rows !== "number" || !Number.isInteger(value.skip_rows) || value.skip_rows < 1) {
|
|
1830
|
+
throw mappingError("skip_rows must be a positive integer.");
|
|
1831
|
+
}
|
|
1832
|
+
if (!SKIP_ROWS_SUPPORTED_FORMATS.includes(String(value.source.format))) {
|
|
1833
|
+
throw mappingError(`skip_rows is not supported for ${String(value.source.format)} input.`);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
for (const field of XLSX_STRUCTURE_FIELDS) {
|
|
1837
|
+
if (value[field] === void 0) continue;
|
|
1838
|
+
if (typeof value[field] !== "boolean") {
|
|
1839
|
+
throw mappingError(`${field} must be a boolean when provided.`);
|
|
1840
|
+
}
|
|
1841
|
+
if (!XLSX_STRUCTURE_SUPPORTED_FORMATS.includes(String(value.source.format))) {
|
|
1842
|
+
throw mappingError(`${field} is not supported for ${String(value.source.format)} input.`);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1292
1845
|
if (value.missing_time !== void 0 && value.missing_time !== "now") {
|
|
1293
1846
|
throw mappingError('missing_time must be "now" when provided.');
|
|
1294
1847
|
}
|
|
@@ -1323,6 +1876,31 @@ function validateMapping(value, options) {
|
|
|
1323
1876
|
function isValidAeName(value) {
|
|
1324
1877
|
return VALID_PROPERTY_NAME.test(value);
|
|
1325
1878
|
}
|
|
1879
|
+
function sourceColumns(mapping) {
|
|
1880
|
+
const columns = /* @__PURE__ */ new Set();
|
|
1881
|
+
const flattenOut = new Set(Object.keys(mapping.flatten_rules ?? {}));
|
|
1882
|
+
for (const property of mapping.properties) {
|
|
1883
|
+
if (!flattenOut.has(property.source)) columns.add(property.source);
|
|
1884
|
+
}
|
|
1885
|
+
for (const path of Object.values(mapping.flatten_rules ?? {})) {
|
|
1886
|
+
const root = path.split(".")[0];
|
|
1887
|
+
if (root) columns.add(root);
|
|
1888
|
+
}
|
|
1889
|
+
const systemFields = [
|
|
1890
|
+
mapping.account_id_field,
|
|
1891
|
+
mapping.distinct_id_field,
|
|
1892
|
+
mapping.record_type_field,
|
|
1893
|
+
mapping.event_name_field,
|
|
1894
|
+
mapping.time.field,
|
|
1895
|
+
mapping.ip_field,
|
|
1896
|
+
mapping.uuid_field,
|
|
1897
|
+
mapping.zone_offset_field
|
|
1898
|
+
];
|
|
1899
|
+
for (const field of systemFields) if (field) columns.add(field);
|
|
1900
|
+
for (const column of mapping.exclude_columns ?? []) columns.add(column);
|
|
1901
|
+
for (const header of mapping.headers ?? []) columns.add(header);
|
|
1902
|
+
return [...columns].sort();
|
|
1903
|
+
}
|
|
1326
1904
|
function mappingError(message) {
|
|
1327
1905
|
return new CliValidationError(message, {
|
|
1328
1906
|
code: "LOCAL_DATA_MAPPING_INVALID",
|
|
@@ -1349,7 +1927,7 @@ function isRecord(value) {
|
|
|
1349
1927
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1350
1928
|
}
|
|
1351
1929
|
|
|
1352
|
-
// src/commands/data-integration/
|
|
1930
|
+
// src/commands/data-integration/multi.ts
|
|
1353
1931
|
var PROPERTY_TYPES = /* @__PURE__ */ new Set([
|
|
1354
1932
|
"number",
|
|
1355
1933
|
"string",
|
|
@@ -1494,14 +2072,63 @@ function isRecord2(value) {
|
|
|
1494
2072
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1495
2073
|
}
|
|
1496
2074
|
|
|
1497
|
-
// src/commands/data-integration/
|
|
2075
|
+
// src/commands/data-integration/profile.ts
|
|
1498
2076
|
import { createHash as createHash2, randomInt } from "crypto";
|
|
1499
2077
|
import { basename as basename2, extname as extname2 } from "path";
|
|
2078
|
+
|
|
2079
|
+
// src/commands/data-integration/field-spec.ts
|
|
2080
|
+
import { isIP } from "net";
|
|
2081
|
+
function stripQuotes(value) {
|
|
2082
|
+
if (typeof value !== "string") return value;
|
|
2083
|
+
const text = value;
|
|
2084
|
+
if (text.length >= 2 && (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'"))) {
|
|
2085
|
+
return text.slice(1, -1).trim();
|
|
2086
|
+
}
|
|
2087
|
+
return text.trim();
|
|
2088
|
+
}
|
|
2089
|
+
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2090
|
+
function isValidUuid(value) {
|
|
2091
|
+
if (typeof value !== "string") return false;
|
|
2092
|
+
return UUID_PATTERN.test(stripQuotes(value));
|
|
2093
|
+
}
|
|
2094
|
+
function isValidIp(value) {
|
|
2095
|
+
if (typeof value !== "string") return false;
|
|
2096
|
+
return isIP(stripQuotes(value)) !== 0;
|
|
2097
|
+
}
|
|
2098
|
+
function isPrivateIp(value) {
|
|
2099
|
+
if (typeof value !== "string") return false;
|
|
2100
|
+
const text = stripQuotes(value);
|
|
2101
|
+
const version = isIP(text);
|
|
2102
|
+
if (version === 6) {
|
|
2103
|
+
const lower = text.toLowerCase();
|
|
2104
|
+
if (lower === "::1") return true;
|
|
2105
|
+
const first2 = lower.split(":")[0];
|
|
2106
|
+
return first2.startsWith("fc") || first2.startsWith("fd") || /^fe[89ab]/.test(lower);
|
|
2107
|
+
}
|
|
2108
|
+
if (version !== 4) return false;
|
|
2109
|
+
const octets = text.split(".").map((part) => Number(part));
|
|
2110
|
+
const [first, second] = octets;
|
|
2111
|
+
return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 127 || first === 169 && second === 254;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// src/commands/data-integration/profile.ts
|
|
1500
2115
|
var UNIQUE_SAMPLE_LIMIT = 1e4;
|
|
1501
2116
|
var GLOBAL_UNIQUE_SAMPLE_LIMIT = 2e5;
|
|
1502
2117
|
var IDENTITY_MAX_LENGTH = 128;
|
|
1503
2118
|
var COLUMN_SAMPLE_LIMIT = 5;
|
|
1504
2119
|
var SAMPLE_TRUNCATE_LENGTH = 40;
|
|
2120
|
+
var VALUE_FREQUENCY_LIMIT = 200;
|
|
2121
|
+
var VALUE_FREQUENCY_TOP = 10;
|
|
2122
|
+
var NUMERIC_SAMPLE_LIMIT = 5e3;
|
|
2123
|
+
var SUMMARY_ROW_CANDIDATE_LIMIT = 50;
|
|
2124
|
+
var SUMMARY_ROW_WARNING_LIMIT = 10;
|
|
2125
|
+
var SUMMARY_LABEL_MAX_LENGTH = 24;
|
|
2126
|
+
var SUMMARY_LABELS_CJK = ["\u5408\u8BA1", "\u603B\u8BA1", "\u5C0F\u8BA1", "\u6C47\u603B"];
|
|
2127
|
+
var SUMMARY_LABELS_LATIN = /* @__PURE__ */ new Set(["total", "totals", "subtotal", "sub total", "grand total", "sum"]);
|
|
2128
|
+
var DUPLICATE_KEY_TRACK_LIMIT = 2e5;
|
|
2129
|
+
var DUPLICATE_GROUP_LIMIT = 20;
|
|
2130
|
+
var DUPLICATE_GROUP_ROW_LIMIT = 10;
|
|
2131
|
+
var DUPLICATE_WARNING_GROUP_LIMIT = 3;
|
|
1505
2132
|
var NESTED_TREE_SAMPLE_LIMIT = 1e3;
|
|
1506
2133
|
var USER_PROFILE_TYPES = /* @__PURE__ */ new Set([
|
|
1507
2134
|
"user_set",
|
|
@@ -1520,6 +2147,8 @@ var DISTINCT_NAMES = ["#distinct_id", "distinct_id", "distinctid", "device_id",
|
|
|
1520
2147
|
var TIME_NAMES = ["#time", "time", "timestamp", "event_time", "created_at", "occurred_at", "datetime", "date", "\u65F6\u95F4", "\u4E8B\u4EF6\u65F6\u95F4", "\u53D1\u751F\u65F6\u95F4", "\u521B\u5EFA\u65F6\u95F4", "\u4E0B\u5355\u65F6\u95F4", "\u8BA2\u5355\u65F6\u95F4"];
|
|
1521
2148
|
var EVENT_NAMES = ["#event_name", "event_name", "event", "action", "activity", "\u4E8B\u4EF6\u540D", "\u4E8B\u4EF6\u540D\u79F0", "\u4E8B\u4EF6", "\u884C\u4E3A", "\u52A8\u4F5C"];
|
|
1522
2149
|
var TYPE_NAMES = ["#type", "record_type", "data_type", "\u64CD\u4F5C\u7C7B\u578B"];
|
|
2150
|
+
var IP_NAMES = ["#ip", "ip", "ip_address", "ipaddress", "client_ip", "clientip", "remote_addr", "remoteaddr", "ip\u5730\u5740", "\u5BA2\u6237\u7AEFip"];
|
|
2151
|
+
var UUID_NAMES = ["#uuid", "uuid", "event_uuid", "eventuuid", "request_uuid", "requestuuid", "\u552F\u4E00\u6807\u8BC6", "\u552F\u4E00id"];
|
|
1523
2152
|
async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai", options = {}) {
|
|
1524
2153
|
const columns = /* @__PURE__ */ new Map();
|
|
1525
2154
|
const recognizedRecordTypes = /* @__PURE__ */ new Set();
|
|
@@ -1529,15 +2158,22 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
|
|
|
1529
2158
|
options.collectNestedTree && (input.format === "json" || input.format === "jsonl")
|
|
1530
2159
|
);
|
|
1531
2160
|
const collectDelimitedTree = Boolean(
|
|
1532
|
-
options.collectNestedTree && (input.format === "csv" || input.format === "tsv")
|
|
2161
|
+
options.collectNestedTree && (input.format === "csv" || input.format === "tsv" || input.format === "xlsx" || input.format === "xls")
|
|
1533
2162
|
);
|
|
1534
2163
|
let nestedObjects = [];
|
|
1535
2164
|
let nestedSeen = 0;
|
|
1536
2165
|
const delimitedNested = /* @__PURE__ */ new Map();
|
|
2166
|
+
const excelDateColumns = /* @__PURE__ */ new Set();
|
|
2167
|
+
const cellIssues = /* @__PURE__ */ new Map();
|
|
2168
|
+
const xlsxStructure = input.format === "xlsx" ? createXlsxStructureCollector() : void 0;
|
|
2169
|
+
const summaryCandidates = [];
|
|
2170
|
+
let summaryCandidatesTruncated = false;
|
|
2171
|
+
let duplicateKeys;
|
|
2172
|
+
let duplicateKeysResolved = false;
|
|
1537
2173
|
await streamLocalDataRows(
|
|
1538
2174
|
input,
|
|
1539
2175
|
dataSet,
|
|
1540
|
-
(row) => {
|
|
2176
|
+
(row, rowNumber) => {
|
|
1541
2177
|
rowCount += 1;
|
|
1542
2178
|
if (collectNestedTree && row !== null && typeof row === "object" && !Array.isArray(row)) {
|
|
1543
2179
|
nestedSeen += 1;
|
|
@@ -1548,6 +2184,10 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
|
|
|
1548
2184
|
if (slot < NESTED_TREE_SAMPLE_LIMIT) nestedObjects[slot] = row;
|
|
1549
2185
|
}
|
|
1550
2186
|
}
|
|
2187
|
+
let labelColumn;
|
|
2188
|
+
let numericCells;
|
|
2189
|
+
let keyColumnsPresent = false;
|
|
2190
|
+
let keyColumnsFilled = false;
|
|
1551
2191
|
for (const name of /* @__PURE__ */ new Set([...columns.keys(), ...Object.keys(row)])) {
|
|
1552
2192
|
let accumulator = columns.get(name);
|
|
1553
2193
|
if (!accumulator) {
|
|
@@ -1560,12 +2200,27 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
|
|
|
1560
2200
|
uniqueOverflow: false,
|
|
1561
2201
|
timeParseCount: 0,
|
|
1562
2202
|
timeFormatCounts: /* @__PURE__ */ new Map(),
|
|
2203
|
+
uuidValidCount: 0,
|
|
2204
|
+
ipValidCount: 0,
|
|
2205
|
+
lanIpCount: 0,
|
|
1563
2206
|
samples: [],
|
|
1564
|
-
sampleSet: /* @__PURE__ */ new Set()
|
|
2207
|
+
sampleSet: /* @__PURE__ */ new Set(),
|
|
2208
|
+
valueCounts: /* @__PURE__ */ new Map(),
|
|
2209
|
+
valueCountsOverflow: false,
|
|
2210
|
+
numericCount: 0,
|
|
2211
|
+
numericSum: 0,
|
|
2212
|
+
numericMin: Number.POSITIVE_INFINITY,
|
|
2213
|
+
numericMax: Number.NEGATIVE_INFINITY,
|
|
2214
|
+
numericSamples: [],
|
|
2215
|
+
keyLike: matchesName(name, ACCOUNT_NAMES) || matchesName(name, DISTINCT_NAMES) || matchesName(name, TIME_NAMES)
|
|
1565
2216
|
};
|
|
1566
2217
|
columns.set(name, accumulator);
|
|
1567
2218
|
}
|
|
1568
2219
|
const value = row[name];
|
|
2220
|
+
if (accumulator.keyLike) {
|
|
2221
|
+
keyColumnsPresent = true;
|
|
2222
|
+
if (!isMissing2(value)) keyColumnsFilled = true;
|
|
2223
|
+
}
|
|
1569
2224
|
if (isMissing2(value)) {
|
|
1570
2225
|
accumulator.missing += 1;
|
|
1571
2226
|
continue;
|
|
@@ -1581,25 +2236,38 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
|
|
|
1581
2236
|
} else {
|
|
1582
2237
|
accumulator.uniqueOverflow = true;
|
|
1583
2238
|
}
|
|
1584
|
-
if (options.collectSamples)
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
2239
|
+
if (options.collectSamples) {
|
|
2240
|
+
recordSample(accumulator, value);
|
|
2241
|
+
recordValueFrequency(accumulator, value);
|
|
2242
|
+
}
|
|
2243
|
+
if (type === "number") {
|
|
2244
|
+
const numeric = Number(value);
|
|
2245
|
+
recordNumeric(accumulator, numeric, options.collectSamples === true);
|
|
2246
|
+
if (Number.isFinite(numeric)) (numericCells ??= []).push([name, numeric]);
|
|
2247
|
+
} else if (labelColumn === void 0 && matchesSummaryLabel(value)) {
|
|
2248
|
+
labelColumn = name;
|
|
2249
|
+
}
|
|
2250
|
+
if (collectDelimitedTree && typeof value === "string") {
|
|
2251
|
+
const trimmed = value.trim();
|
|
2252
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
2253
|
+
try {
|
|
2254
|
+
const parsed = JSON.parse(value);
|
|
2255
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
2256
|
+
let sampler = delimitedNested.get(name);
|
|
2257
|
+
if (!sampler) {
|
|
2258
|
+
sampler = { seen: 0, values: [] };
|
|
2259
|
+
delimitedNested.set(name, sampler);
|
|
2260
|
+
}
|
|
2261
|
+
sampler.seen += 1;
|
|
2262
|
+
if (sampler.values.length < NESTED_TREE_SAMPLE_LIMIT) {
|
|
2263
|
+
sampler.values.push(parsed);
|
|
2264
|
+
} else {
|
|
2265
|
+
const slot = randomInt(sampler.seen);
|
|
2266
|
+
if (slot < NESTED_TREE_SAMPLE_LIMIT) sampler.values[slot] = parsed;
|
|
2267
|
+
}
|
|
1600
2268
|
}
|
|
2269
|
+
} catch {
|
|
1601
2270
|
}
|
|
1602
|
-
} catch {
|
|
1603
2271
|
}
|
|
1604
2272
|
}
|
|
1605
2273
|
if (isParseableTime(value, matchesName(name, TIME_NAMES))) {
|
|
@@ -1611,11 +2279,29 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
|
|
|
1611
2279
|
}
|
|
1612
2280
|
}
|
|
1613
2281
|
}
|
|
2282
|
+
if (matchesName(name, UUID_NAMES) && isValidUuid(value)) accumulator.uuidValidCount += 1;
|
|
2283
|
+
if (matchesName(name, IP_NAMES) && isValidIp(value)) {
|
|
2284
|
+
accumulator.ipValidCount += 1;
|
|
2285
|
+
if (isPrivateIp(value)) accumulator.lanIpCount += 1;
|
|
2286
|
+
}
|
|
1614
2287
|
if (matchesName(name, TYPE_NAMES)) {
|
|
1615
2288
|
const normalized = normalizeRecordType(value);
|
|
1616
2289
|
if (normalized) recognizedRecordTypes.add(normalized);
|
|
1617
2290
|
}
|
|
1618
2291
|
}
|
|
2292
|
+
if (labelColumn !== void 0 || keyColumnsPresent && !keyColumnsFilled && numericCells !== void 0) {
|
|
2293
|
+
if (summaryCandidates.length < SUMMARY_ROW_CANDIDATE_LIMIT) {
|
|
2294
|
+
summaryCandidates.push({ row: rowNumber, labelColumn, numericCells: numericCells ?? [] });
|
|
2295
|
+
} else {
|
|
2296
|
+
summaryCandidatesTruncated = true;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
if (!duplicateKeysResolved) {
|
|
2300
|
+
duplicateKeysResolved = true;
|
|
2301
|
+
const keyColumns = resolveDuplicateKeyColumns(Object.keys(row), options.duplicateKeyFields);
|
|
2302
|
+
if (keyColumns) duplicateKeys = createDuplicateKeyTracker(keyColumns);
|
|
2303
|
+
}
|
|
2304
|
+
if (duplicateKeys) recordDuplicateKey(duplicateKeys, row, rowNumber);
|
|
1619
2305
|
},
|
|
1620
2306
|
{
|
|
1621
2307
|
delimiter: options.delimiter,
|
|
@@ -1623,23 +2309,31 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
|
|
|
1623
2309
|
headerNames: options.headerNames,
|
|
1624
2310
|
noHeader: options.noHeader,
|
|
1625
2311
|
flattenRules: options.flattenRules,
|
|
1626
|
-
mergeSheets: options.mergeSheets
|
|
2312
|
+
mergeSheets: options.mergeSheets,
|
|
2313
|
+
excelDateColumns,
|
|
2314
|
+
cellIssues,
|
|
2315
|
+
warnRagged: options.warnRagged,
|
|
2316
|
+
skipRows: options.skipRows,
|
|
2317
|
+
xlsxStructure,
|
|
2318
|
+
fillMergedCells: options.fillMergedCells,
|
|
2319
|
+
excludeHiddenRows: options.excludeHiddenRows
|
|
1627
2320
|
}
|
|
1628
2321
|
);
|
|
1629
2322
|
const delimitedNestedTree = /* @__PURE__ */ new Map();
|
|
1630
2323
|
for (const [columnName, sampler] of delimitedNested) {
|
|
1631
|
-
if (sampler.
|
|
2324
|
+
if (sampler.values.length > 0) delimitedNestedTree.set(columnName, buildColumnNestedTree(sampler.values));
|
|
1632
2325
|
}
|
|
1633
2326
|
const columnProfiles = [];
|
|
1634
2327
|
const timeFormatByColumn = /* @__PURE__ */ new Map();
|
|
1635
2328
|
for (const column of columns.values()) {
|
|
1636
2329
|
const profile = formatColumnProfile(column, rowCount, options.collectSamples ?? false);
|
|
1637
|
-
const
|
|
1638
|
-
if (
|
|
2330
|
+
const nestedTree2 = delimitedNestedTree.get(column.name);
|
|
2331
|
+
if (nestedTree2 && nestedTree2.length > 0) profile.nested_tree = nestedTree2;
|
|
1639
2332
|
columnProfiles.push(profile);
|
|
1640
2333
|
const dominantFormat = dominantTimeFormat(column);
|
|
1641
2334
|
if (dominantFormat) timeFormatByColumn.set(column.name, dominantFormat);
|
|
1642
2335
|
}
|
|
2336
|
+
const nestedTree = collectNestedTree && nestedObjects.length > 0 ? buildNestedTree(nestedObjects) : void 0;
|
|
1643
2337
|
const identityCandidates = findIdentityCandidates(columnProfiles);
|
|
1644
2338
|
const recommendedMapping = recommendMapping({
|
|
1645
2339
|
input,
|
|
@@ -1649,9 +2343,34 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
|
|
|
1649
2343
|
recognizedRecordTypes,
|
|
1650
2344
|
sourceTimezone,
|
|
1651
2345
|
headerNames: options.headerNames,
|
|
1652
|
-
|
|
2346
|
+
skipRows: options.skipRows,
|
|
2347
|
+
fillMergedCells: options.fillMergedCells,
|
|
2348
|
+
excludeHiddenRows: options.excludeHiddenRows,
|
|
2349
|
+
timeFormatByColumn,
|
|
2350
|
+
nestedTree
|
|
1653
2351
|
});
|
|
1654
2352
|
const warnings = [...recommendedMapping.warnings ?? []];
|
|
2353
|
+
for (const column of columns.values()) {
|
|
2354
|
+
if (matchesName(column.name, UUID_NAMES) && column.nonMissing > 0) {
|
|
2355
|
+
const invalid = column.nonMissing - column.uuidValidCount;
|
|
2356
|
+
if (invalid > 0) warnings.push(`${column.name}: ${invalid} non-empty value(s) are not standard 36-character UUIDs; #uuid requires the standard UUID format.`);
|
|
2357
|
+
}
|
|
2358
|
+
if (matchesName(column.name, IP_NAMES) && column.nonMissing > 0) {
|
|
2359
|
+
const invalid = column.nonMissing - column.ipValidCount;
|
|
2360
|
+
if (invalid > 0) warnings.push(`${column.name}: ${invalid} non-empty value(s) are not valid IPv4 or IPv6 addresses.`);
|
|
2361
|
+
if (column.lanIpCount > 0) warnings.push(`${column.name}: ${column.lanIpCount} value(s) are private/LAN IP addresses; AE cannot resolve geo information for them.`);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
if (excelDateColumns.size > 0) {
|
|
2365
|
+
warnings.push(`${[...excelDateColumns].join(", ")}: read as date/time value(s) from the Excel number format rather than as Excel serial numbers. If these columns were uploaded to AE before, they may have been received as number properties, whose type is now locked; confirm with the user before mapping them.`);
|
|
2366
|
+
}
|
|
2367
|
+
warnings.push(...cellIssueWarnings(cellIssues));
|
|
2368
|
+
if (xlsxStructure) warnings.push(...xlsxStructureWarnings(xlsxStructure));
|
|
2369
|
+
const structureReport = xlsxStructure ? xlsxStructureReport(xlsxStructure) : void 0;
|
|
2370
|
+
const summaryRows = confirmSummaryRows(summaryCandidates, columns);
|
|
2371
|
+
if (summaryRows.length > 0) warnings.push(summaryRowWarning(summaryRows, summaryCandidatesTruncated));
|
|
2372
|
+
const duplicateKeyReport = duplicateKeys ? buildDuplicateKeyReport(duplicateKeys) : void 0;
|
|
2373
|
+
if (duplicateKeyReport) warnings.push(duplicateKeyWarning(duplicateKeyReport));
|
|
1655
2374
|
if (rowCount === 0) warnings.push("The selected data set contains no data rows.");
|
|
1656
2375
|
return {
|
|
1657
2376
|
version: "ae-local-data-profile/v1",
|
|
@@ -1670,7 +2389,10 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
|
|
|
1670
2389
|
(recommendedMapping.account_id_field || recommendedMapping.distinct_id_field) && recommendedMapping.time.field
|
|
1671
2390
|
),
|
|
1672
2391
|
warnings,
|
|
1673
|
-
...
|
|
2392
|
+
...nestedTree ? { nested_tree: nestedTree } : {},
|
|
2393
|
+
...structureReport ? { xlsx_structure: structureReport } : {},
|
|
2394
|
+
...summaryRows.length > 0 ? { summary_rows: summaryRows } : {},
|
|
2395
|
+
...duplicateKeyReport ? { duplicate_keys: duplicateKeyReport } : {}
|
|
1674
2396
|
};
|
|
1675
2397
|
}
|
|
1676
2398
|
function normalizeAeName(input, fallback) {
|
|
@@ -1743,24 +2465,12 @@ function recommendMapping(input) {
|
|
|
1743
2465
|
warnings.push("No event-name column was found; review the generated default event name.");
|
|
1744
2466
|
}
|
|
1745
2467
|
const reserved = new Set([account?.name, distinct?.name, time?.name, event?.name, recordType?.name].filter(Boolean));
|
|
1746
|
-
const
|
|
1747
|
-
const
|
|
1748
|
-
|
|
1749
|
-
let target = baseTarget;
|
|
1750
|
-
let suffix = 2;
|
|
1751
|
-
while (usedTargets.has(target)) target = `${baseTarget.slice(0, 46)}_${suffix++}`;
|
|
1752
|
-
usedTargets.add(target);
|
|
1753
|
-
const type = mappingType(column.inferred_type);
|
|
1754
|
-
return {
|
|
1755
|
-
source: column.name,
|
|
1756
|
-
target,
|
|
1757
|
-
type,
|
|
1758
|
-
...type === "object" || type === "list" ? { transform: "json" } : {}
|
|
1759
|
-
};
|
|
1760
|
-
});
|
|
2468
|
+
const recordRoots = /* @__PURE__ */ new Map();
|
|
2469
|
+
for (const node of input.nestedTree ?? []) recordRoots.set(node.name, node);
|
|
2470
|
+
const { properties, flattenRules } = recommendProperties(input.columns, reserved, recordRoots, warnings);
|
|
1761
2471
|
const defaultEventSource = input.dataSet.kind === "sheet" ? input.dataSet.label : basename2(input.input.filePath, extname2(input.input.filePath));
|
|
1762
2472
|
return {
|
|
1763
|
-
version:
|
|
2473
|
+
version: MAPPING_VERSION,
|
|
1764
2474
|
source: {
|
|
1765
2475
|
sha256: input.input.sha256,
|
|
1766
2476
|
format: input.input.format,
|
|
@@ -1777,9 +2487,13 @@ function recommendMapping(input) {
|
|
|
1777
2487
|
},
|
|
1778
2488
|
...time ? { time_format: input.timeFormatByColumn.get(time.name) } : {},
|
|
1779
2489
|
...input.headerNames && input.headerNames.length > 0 ? { headers: input.headerNames } : {},
|
|
2490
|
+
...input.skipRows ? { skip_rows: input.skipRows } : {},
|
|
2491
|
+
...input.fillMergedCells ? { fill_merged_cells: true } : {},
|
|
2492
|
+
...input.excludeHiddenRows ? { exclude_hidden_rows: true } : {},
|
|
1780
2493
|
...recordType ? { record_type_field: recordType.name } : {},
|
|
1781
2494
|
...event ? { event_name_field: event.name } : {},
|
|
1782
2495
|
...mode !== "user_set" && !event ? { default_event_name: normalizeAeName(defaultEventSource, "local_event") } : {},
|
|
2496
|
+
...Object.keys(flattenRules).length > 0 ? { flatten_rules: flattenRules } : {},
|
|
1783
2497
|
properties,
|
|
1784
2498
|
...warnings.length > 0 ? { warnings } : {}
|
|
1785
2499
|
};
|
|
@@ -1788,7 +2502,7 @@ function formatColumnProfile(column, rowCount, includeSamples) {
|
|
|
1788
2502
|
const nonNullTypes = [...column.types.entries()].filter(([type]) => type !== "null").sort((left, right) => right[1] - left[1]);
|
|
1789
2503
|
const inferred = nonNullTypes.length === 0 ? "unknown" : nonNullTypes.length === 1 ? nonNullTypes[0][0] : compatibleTypes(nonNullTypes.map(([type]) => type)) ? nonNullTypes[0][0] : "mixed";
|
|
1790
2504
|
const finalType = inferred === "number" && idLikeColumn(column.name) ? "string" : inferred;
|
|
1791
|
-
|
|
2505
|
+
const profile = {
|
|
1792
2506
|
name: column.name,
|
|
1793
2507
|
inferred_type: finalType,
|
|
1794
2508
|
missing_count: column.missing,
|
|
@@ -1800,6 +2514,15 @@ function formatColumnProfile(column, rowCount, includeSamples) {
|
|
|
1800
2514
|
time_parse_ratio: ratio(column.timeParseCount, column.nonMissing),
|
|
1801
2515
|
...includeSamples ? { samples: column.samples } : {}
|
|
1802
2516
|
};
|
|
2517
|
+
if (includeSamples) {
|
|
2518
|
+
const frequency = formatValueFrequency(column);
|
|
2519
|
+
if (frequency) profile.value_frequency = frequency;
|
|
2520
|
+
if (finalType === "number") {
|
|
2521
|
+
const summary = formatNumericSummary(column);
|
|
2522
|
+
if (summary) profile.numeric_summary = summary;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
return profile;
|
|
1803
2526
|
}
|
|
1804
2527
|
function findCandidate(columns, names) {
|
|
1805
2528
|
return columns.find((column) => matchesName(column.name, names));
|
|
@@ -1863,9 +2586,120 @@ function normalizeRecordType(value) {
|
|
|
1863
2586
|
};
|
|
1864
2587
|
return aliases[normalized];
|
|
1865
2588
|
}
|
|
2589
|
+
function recommendProperties(columns, reserved, recordRoots, warnings) {
|
|
2590
|
+
const properties = [];
|
|
2591
|
+
const flattenRules = {};
|
|
2592
|
+
const usedTargets = /* @__PURE__ */ new Set();
|
|
2593
|
+
const claim = (desired) => {
|
|
2594
|
+
let target = desired;
|
|
2595
|
+
let suffix = 2;
|
|
2596
|
+
while (usedTargets.has(target)) target = `${desired.slice(0, 46)}_${suffix++}`;
|
|
2597
|
+
usedTargets.add(target);
|
|
2598
|
+
return target;
|
|
2599
|
+
};
|
|
2600
|
+
const state = { properties, flattenRules, usedTargets, warnings, claim };
|
|
2601
|
+
columns.filter((column) => !reserved.has(column.name)).forEach((column, index) => {
|
|
2602
|
+
const baseName = normalizeAeName(column.name, `field_${index + 1}`);
|
|
2603
|
+
const valueNode = columnValueNode(column, recordRoots);
|
|
2604
|
+
if (!valueNode) {
|
|
2605
|
+
const type = mappingType(column.inferred_type);
|
|
2606
|
+
const target = claim(baseName);
|
|
2607
|
+
properties.push({ source: column.name, target, type, ...isContainerType(type) ? { transform: "json" } : {} });
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
if (valueNode.kind === "primitive") {
|
|
2611
|
+
const type = mappingType(valueNode.inferredType ?? column.inferred_type);
|
|
2612
|
+
const target = claim(baseName);
|
|
2613
|
+
properties.push({ source: column.name, target, type });
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
if (valueNode.kind === "object") {
|
|
2617
|
+
recommendObject(state, baseName, column.name, valueNode.children ?? [], column.name);
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
recommendArray(state, baseName, column.name, valueNode, column.name);
|
|
2621
|
+
});
|
|
2622
|
+
return { properties, flattenRules };
|
|
2623
|
+
}
|
|
2624
|
+
function columnValueNode(column, recordRoots) {
|
|
2625
|
+
const recordNode = recordRoots.get(column.name);
|
|
2626
|
+
if (recordNode) return recordNode;
|
|
2627
|
+
const tree = column.nested_tree;
|
|
2628
|
+
if (!tree || tree.length === 0) return void 0;
|
|
2629
|
+
if (tree.length === 1 && tree[0].kind === "array") return tree[0];
|
|
2630
|
+
return { path: "", name: column.name, kind: "object", children: tree, nonEmpty: true };
|
|
2631
|
+
}
|
|
2632
|
+
function isScalarNode(node) {
|
|
2633
|
+
return node.kind === "primitive" || node.kind === "array" && node.elementKind === "primitive";
|
|
2634
|
+
}
|
|
2635
|
+
function isContainerType(type) {
|
|
2636
|
+
return type === "object" || type === "list" || type === "array_row";
|
|
2637
|
+
}
|
|
2638
|
+
function scalarPropType(node) {
|
|
2639
|
+
if (node.kind === "array") return "list";
|
|
2640
|
+
switch (node.inferredType) {
|
|
2641
|
+
case "number":
|
|
2642
|
+
return "number";
|
|
2643
|
+
case "boolean":
|
|
2644
|
+
return "boolean";
|
|
2645
|
+
case "datetime":
|
|
2646
|
+
return "datetime";
|
|
2647
|
+
default:
|
|
2648
|
+
return "string";
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
function snakeSegment(name) {
|
|
2652
|
+
return normalizeAeName(name, "field");
|
|
2653
|
+
}
|
|
2654
|
+
function recommendObject(state, prefix, dotPath, children, columnName) {
|
|
2655
|
+
const hasComposite = children.some((child) => child.kind === "object" || child.kind === "array" && child.elementKind === "object");
|
|
2656
|
+
if (!hasComposite) {
|
|
2657
|
+
const parentTarget = state.claim(prefix);
|
|
2658
|
+
const source = dotPath === columnName ? columnName : parentTarget;
|
|
2659
|
+
if (source !== columnName) state.flattenRules[parentTarget] = dotPath;
|
|
2660
|
+
state.properties.push({ source, target: parentTarget, type: "object", transform: "json" });
|
|
2661
|
+
for (const child of children) {
|
|
2662
|
+
state.properties.push({ source: parentTarget, target: `${parentTarget}.${snakeSegment(child.name)}`, type: scalarPropType(child) });
|
|
2663
|
+
}
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
for (const child of children) {
|
|
2667
|
+
const childPrefix = `${prefix}_${snakeSegment(child.name)}`;
|
|
2668
|
+
const childDotPath = `${dotPath}.${child.name}`;
|
|
2669
|
+
if (isScalarNode(child)) {
|
|
2670
|
+
const target = state.claim(childPrefix);
|
|
2671
|
+
state.flattenRules[target] = childDotPath;
|
|
2672
|
+
state.properties.push({ source: target, target, type: scalarPropType(child) });
|
|
2673
|
+
} else if (child.kind === "object") {
|
|
2674
|
+
recommendObject(state, childPrefix, childDotPath, child.children ?? [], columnName);
|
|
2675
|
+
} else {
|
|
2676
|
+
recommendArray(state, childPrefix, childDotPath, child, columnName);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
function recommendArray(state, prefix, dotPath, node, columnName) {
|
|
2681
|
+
if (node.elementKind !== "object") {
|
|
2682
|
+
const target = state.claim(prefix);
|
|
2683
|
+
const source2 = dotPath === columnName ? columnName : target;
|
|
2684
|
+
if (source2 !== columnName) state.flattenRules[target] = dotPath;
|
|
2685
|
+
state.properties.push({ source: source2, target, type: "list", transform: "json" });
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
const parentTarget = state.claim(prefix);
|
|
2689
|
+
const source = dotPath === columnName ? columnName : parentTarget;
|
|
2690
|
+
if (source !== columnName) state.flattenRules[parentTarget] = dotPath;
|
|
2691
|
+
state.properties.push({ source, target: parentTarget, type: "array_row", transform: "json" });
|
|
2692
|
+
for (const field of node.children ?? []) {
|
|
2693
|
+
if (isScalarNode(field)) {
|
|
2694
|
+
state.properties.push({ source: parentTarget, target: `${parentTarget}.${snakeSegment(field.name)}`, type: scalarPropType(field) });
|
|
2695
|
+
} else {
|
|
2696
|
+
state.warnings.push(`${dotPath} element field "${field.name}" is nested; it stays inside the ${parentTarget} array data and is not declared as a sub-property (array-element flattening is not yet supported).`);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
1866
2700
|
function mappingType(type) {
|
|
1867
2701
|
if (type === "datetime") return "datetime";
|
|
1868
|
-
if (type === "number" || type === "boolean" || type === "
|
|
2702
|
+
if (type === "number" || type === "boolean" || type === "object" || type === "list") return type;
|
|
1869
2703
|
return "string";
|
|
1870
2704
|
}
|
|
1871
2705
|
function compatibleTypes(types) {
|
|
@@ -1903,6 +2737,176 @@ function recordSample(accumulator, value) {
|
|
|
1903
2737
|
accumulator.sampleSet.add(text);
|
|
1904
2738
|
accumulator.samples.push(text);
|
|
1905
2739
|
}
|
|
2740
|
+
function recordValueFrequency(accumulator, value) {
|
|
2741
|
+
if (accumulator.valueCountsOverflow) return;
|
|
2742
|
+
const text = truncateSample2(value);
|
|
2743
|
+
const seen = accumulator.valueCounts.get(text);
|
|
2744
|
+
if (seen !== void 0) {
|
|
2745
|
+
accumulator.valueCounts.set(text, seen + 1);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
if (accumulator.valueCounts.size >= VALUE_FREQUENCY_LIMIT) {
|
|
2749
|
+
accumulator.valueCountsOverflow = true;
|
|
2750
|
+
accumulator.valueCounts.clear();
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
accumulator.valueCounts.set(text, 1);
|
|
2754
|
+
}
|
|
2755
|
+
function recordNumeric(accumulator, numeric, retainForQuantiles) {
|
|
2756
|
+
if (!Number.isFinite(numeric)) return;
|
|
2757
|
+
accumulator.numericCount += 1;
|
|
2758
|
+
accumulator.numericSum += numeric;
|
|
2759
|
+
if (numeric < accumulator.numericMin) accumulator.numericMin = numeric;
|
|
2760
|
+
if (numeric > accumulator.numericMax) accumulator.numericMax = numeric;
|
|
2761
|
+
if (!retainForQuantiles) return;
|
|
2762
|
+
if (accumulator.numericSamples.length < NUMERIC_SAMPLE_LIMIT) {
|
|
2763
|
+
accumulator.numericSamples.push(numeric);
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
const slot = randomInt(accumulator.numericCount);
|
|
2767
|
+
if (slot < NUMERIC_SAMPLE_LIMIT) accumulator.numericSamples[slot] = numeric;
|
|
2768
|
+
}
|
|
2769
|
+
function formatValueFrequency(accumulator) {
|
|
2770
|
+
if (accumulator.valueCountsOverflow || accumulator.valueCounts.size === 0) return void 0;
|
|
2771
|
+
const ordered = [...accumulator.valueCounts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
2772
|
+
if (ordered[0][1] < 2) return void 0;
|
|
2773
|
+
return ordered.slice(0, VALUE_FREQUENCY_TOP).map(([value, count]) => ({ value, count, ratio: ratio(count, accumulator.nonMissing) }));
|
|
2774
|
+
}
|
|
2775
|
+
function formatNumericSummary(accumulator) {
|
|
2776
|
+
if (accumulator.numericCount === 0 || accumulator.numericSamples.length === 0) return void 0;
|
|
2777
|
+
const sorted = [...accumulator.numericSamples].sort((left, right) => left - right);
|
|
2778
|
+
return {
|
|
2779
|
+
count: accumulator.numericCount,
|
|
2780
|
+
min: round(accumulator.numericMin),
|
|
2781
|
+
max: round(accumulator.numericMax),
|
|
2782
|
+
sum: round(accumulator.numericSum),
|
|
2783
|
+
mean: round(accumulator.numericSum / accumulator.numericCount),
|
|
2784
|
+
p25: round(quantile(sorted, 0.25)),
|
|
2785
|
+
median: round(quantile(sorted, 0.5)),
|
|
2786
|
+
p75: round(quantile(sorted, 0.75)),
|
|
2787
|
+
quantiles_approximate: accumulator.numericCount > accumulator.numericSamples.length
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
function quantile(sorted, fraction) {
|
|
2791
|
+
const position = (sorted.length - 1) * fraction;
|
|
2792
|
+
const lower = Math.floor(position);
|
|
2793
|
+
const upper = Math.ceil(position);
|
|
2794
|
+
if (lower === upper) return sorted[lower];
|
|
2795
|
+
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
|
|
2796
|
+
}
|
|
2797
|
+
function round(value) {
|
|
2798
|
+
return Number.isFinite(value) ? Number(value.toFixed(6)) : value;
|
|
2799
|
+
}
|
|
2800
|
+
function matchesSummaryLabel(value) {
|
|
2801
|
+
if (typeof value !== "string") return false;
|
|
2802
|
+
const text = value.trim().replace(/[\s::、,,.。]+$/u, "");
|
|
2803
|
+
if (text.length === 0 || text.length > SUMMARY_LABEL_MAX_LENGTH) return false;
|
|
2804
|
+
if (SUMMARY_LABELS_CJK.some((label) => text.startsWith(label))) return true;
|
|
2805
|
+
return SUMMARY_LABELS_LATIN.has(text.toLowerCase());
|
|
2806
|
+
}
|
|
2807
|
+
function confirmSummaryRows(candidates, columns) {
|
|
2808
|
+
const confirmed = [];
|
|
2809
|
+
for (const candidate of candidates) {
|
|
2810
|
+
const totalColumns = [];
|
|
2811
|
+
for (const [name, value] of candidate.numericCells) {
|
|
2812
|
+
const accumulator = columns.get(name);
|
|
2813
|
+
if (!accumulator || accumulator.numericCount < 3 || value === 0) continue;
|
|
2814
|
+
const total = accumulator.numericSum;
|
|
2815
|
+
if (Math.abs(value * 2 - total) <= Math.max(1e-6, Math.abs(total) * 1e-9)) totalColumns.push(name);
|
|
2816
|
+
}
|
|
2817
|
+
const signals = [];
|
|
2818
|
+
if (candidate.labelColumn !== void 0) signals.push("total_label");
|
|
2819
|
+
if (totalColumns.length > 0) signals.push("column_total");
|
|
2820
|
+
if (signals.length === 0) continue;
|
|
2821
|
+
confirmed.push({
|
|
2822
|
+
row: candidate.row,
|
|
2823
|
+
signals,
|
|
2824
|
+
...candidate.labelColumn !== void 0 ? { label_column: candidate.labelColumn } : {},
|
|
2825
|
+
...totalColumns.length > 0 ? { total_columns: totalColumns } : {}
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
return confirmed;
|
|
2829
|
+
}
|
|
2830
|
+
function summaryRowWarning(rows, truncated) {
|
|
2831
|
+
const shown = rows.slice(0, SUMMARY_ROW_WARNING_LIMIT).map((entry) => entry.row);
|
|
2832
|
+
const remaining = rows.length - shown.length;
|
|
2833
|
+
const labelled = rows.filter((entry) => entry.signals.includes("total_label")).length;
|
|
2834
|
+
const totals = rows.filter((entry) => entry.signals.includes("column_total")).length;
|
|
2835
|
+
const reasons = [
|
|
2836
|
+
labelled > 0 ? `${labelled} carry a total-like label (\u5408\u8BA1 / \u603B\u8BA1 / \u5C0F\u8BA1 / \u6C47\u603B / Total / Subtotal)` : void 0,
|
|
2837
|
+
totals > 0 ? `${totals} hold a number equal to the total of its column's other rows` : void 0
|
|
2838
|
+
].filter((reason) => reason !== void 0).join("; ");
|
|
2839
|
+
const scope = truncated ? ` The scan stopped after ${SUMMARY_ROW_CANDIDATE_LIMIT} candidate rows, so there may be more.` : "";
|
|
2840
|
+
return `${rows.length} row(s) read as a summary line rather than an observation: ${reasons}. Rows ${shown.join(", ")}${remaining > 0 ? ` and ${remaining} more` : ""} (data-row ordinals, the numbering invalid.rows.jsonl and --salvage-from use; see summary_rows).${scope} Nothing was removed and nothing changed: convert writes these rows as records, and every number reported for their columns counts them, so a real total row makes that column's sum twice its actual total and its max the total. There is no flag that drops a data row; when the user confirms a row is a total, remove it from the source file or re-export without it, then inspect again.`;
|
|
2841
|
+
}
|
|
2842
|
+
function resolveDuplicateKeyColumns(names, explicit) {
|
|
2843
|
+
if (explicit && explicit.length > 0) {
|
|
2844
|
+
const unique = [...new Set(explicit.filter((name) => name.length > 0))];
|
|
2845
|
+
return unique.length > 1 ? unique : void 0;
|
|
2846
|
+
}
|
|
2847
|
+
const identity = names.find((name) => matchesName(name, ACCOUNT_NAMES)) ?? names.find((name) => matchesName(name, DISTINCT_NAMES));
|
|
2848
|
+
const time = names.find((name) => matchesName(name, TIME_NAMES));
|
|
2849
|
+
if (!identity || !time) return void 0;
|
|
2850
|
+
const event = names.find((name) => matchesName(name, EVENT_NAMES));
|
|
2851
|
+
return event ? [identity, time, event] : [identity, time];
|
|
2852
|
+
}
|
|
2853
|
+
function createDuplicateKeyTracker(columns) {
|
|
2854
|
+
return { columns, firstRow: /* @__PURE__ */ new Map(), groups: /* @__PURE__ */ new Map(), checkedRows: 0, overflow: false };
|
|
2855
|
+
}
|
|
2856
|
+
function recordDuplicateKey(tracker, row, rowNumber) {
|
|
2857
|
+
const parts = [];
|
|
2858
|
+
for (const name of tracker.columns) {
|
|
2859
|
+
const value = row[name];
|
|
2860
|
+
if (isMissing2(value)) return;
|
|
2861
|
+
parts.push(sampleText(value).trim());
|
|
2862
|
+
}
|
|
2863
|
+
tracker.checkedRows += 1;
|
|
2864
|
+
const hash = createHash2("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
2865
|
+
const group = tracker.groups.get(hash);
|
|
2866
|
+
if (group) {
|
|
2867
|
+
group.count += 1;
|
|
2868
|
+
if (group.rows.length < DUPLICATE_GROUP_ROW_LIMIT) group.rows.push(rowNumber);
|
|
2869
|
+
else group.rowsTruncated = true;
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
const firstRow = tracker.firstRow.get(hash);
|
|
2873
|
+
if (firstRow !== void 0) {
|
|
2874
|
+
tracker.groups.set(hash, { count: 2, rows: [firstRow, rowNumber], rowsTruncated: false });
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
if (tracker.firstRow.size >= DUPLICATE_KEY_TRACK_LIMIT) {
|
|
2878
|
+
tracker.overflow = true;
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
tracker.firstRow.set(hash, rowNumber);
|
|
2882
|
+
}
|
|
2883
|
+
function buildDuplicateKeyReport(tracker) {
|
|
2884
|
+
if (tracker.groups.size === 0) return void 0;
|
|
2885
|
+
const ordered = [...tracker.groups.entries()].sort(([, left], [, right]) => right.count - left.count || left.rows[0] - right.rows[0]);
|
|
2886
|
+
let extraRows = 0;
|
|
2887
|
+
for (const [, group] of ordered) extraRows += group.count - 1;
|
|
2888
|
+
const groups = ordered.slice(0, DUPLICATE_GROUP_LIMIT).map(([hash, group]) => ({
|
|
2889
|
+
key_hash: hash.slice(0, 16),
|
|
2890
|
+
count: group.count,
|
|
2891
|
+
rows: group.rows,
|
|
2892
|
+
...group.rowsTruncated ? { rows_truncated: true } : {}
|
|
2893
|
+
}));
|
|
2894
|
+
return {
|
|
2895
|
+
key_columns: tracker.columns,
|
|
2896
|
+
checked_rows: tracker.checkedRows,
|
|
2897
|
+
duplicate_groups: ordered.length,
|
|
2898
|
+
extra_rows: extraRows,
|
|
2899
|
+
groups,
|
|
2900
|
+
...ordered.length > DUPLICATE_GROUP_LIMIT ? { groups_truncated: true } : {},
|
|
2901
|
+
...tracker.overflow ? { tracking_truncated: true } : {}
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2904
|
+
function duplicateKeyWarning(report) {
|
|
2905
|
+
const examples = report.groups.slice(0, DUPLICATE_WARNING_GROUP_LIMIT).map((group) => `rows ${group.rows.join(", ")}${group.rows_truncated ? ", \u2026" : ""} (${group.count}\xD7)`).join("; ");
|
|
2906
|
+
const remaining = report.duplicate_groups - Math.min(report.groups.length, DUPLICATE_WARNING_GROUP_LIMIT);
|
|
2907
|
+
const scope = report.tracking_truncated ? ` Tracking stopped after ${DUPLICATE_KEY_TRACK_LIMIT} distinct keys, so there may be more.` : "";
|
|
2908
|
+
return `${report.duplicate_groups} business key(s) appear on more than one row, ${report.extra_rows} extra row(s) in total, over ${report.checked_rows} row(s) that carried a value in every key column. Key: ${report.key_columns.join(" + ")}. Examples: ${examples}${remaining > 0 ? ` and ${remaining} more group(s)` : ""} (data-row ordinals, the numbering invalid.rows.jsonl and --salvage-from use; see duplicate_keys).${scope} Nothing was removed: repeated rows are sometimes real (one order line per product), and AE appends every accepted event with no way to un-send it, so this has to be settled before upload. Ask the user whether the repeats are separate observations; if they are not, remove them from the source file and inspect again. Values are compared as written, so two spellings of the same instant are two different keys, and a source with a unique key of its own (an order id) is not compared on it unless the mapping names it.`;
|
|
2909
|
+
}
|
|
1906
2910
|
function truncateSample2(value) {
|
|
1907
2911
|
const text = sampleText(value);
|
|
1908
2912
|
if (text.length <= SAMPLE_TRUNCATE_LENGTH) return text;
|
|
@@ -1928,7 +2932,7 @@ function dominantTimeFormat(column) {
|
|
|
1928
2932
|
return best && bestCount >= total * 0.9 ? best : void 0;
|
|
1929
2933
|
}
|
|
1930
2934
|
|
|
1931
|
-
// src/commands/data-integration/
|
|
2935
|
+
// src/commands/data-integration/inspect.ts
|
|
1932
2936
|
var HEADERLESS_WARNING = "The first row appears to be data, not a header; columns were auto-named col_1..col_N. Re-run with --headers to supply explicit names.";
|
|
1933
2937
|
var dataIntegrationInspect = {
|
|
1934
2938
|
service: "data-integration",
|
|
@@ -1940,9 +2944,21 @@ var dataIntegrationInspect = {
|
|
|
1940
2944
|
{ name: "data-set", type: "string", sensitive: true, desc: "Sheet or JSON Path ID returned by a discovery-only inspection." },
|
|
1941
2945
|
{ name: "source-timezone", type: "string", default: "Asia/Shanghai", desc: "IANA timezone used to interpret source times without an offset." },
|
|
1942
2946
|
{ name: "headers", type: "string", sensitive: true, desc: "Comma-separated explicit column names for a headerless file; the first row is data." },
|
|
1943
|
-
{ name: "headerless", type: "boolean", default: false, desc: "Treat the first row as data and auto-generate col_1..col_N names." }
|
|
2947
|
+
{ name: "headerless", type: "boolean", default: false, desc: "Treat the first row as data and auto-generate col_1..col_N names." },
|
|
2948
|
+
{ name: "skip-rows", type: "number", default: 0, desc: "Discard the first N rows before the header row is read, for exports with a title or banner above the header. Delimited and Excel only." },
|
|
2949
|
+
{ name: "fill-merged-cells", type: "boolean", default: false, desc: "Copy each merged block value into the cells its own range covers, so rows below the first row of a merged block keep the value Excel displays. XLSX only; off by default because those cells are empty in the file." },
|
|
2950
|
+
{ name: "exclude-hidden-rows", type: "boolean", default: false, desc: "Leave rows hidden in the source worksheet out of the profile. XLSX only; off by default because a hidden row may still be real data." }
|
|
1944
2951
|
],
|
|
1945
2952
|
risk: "read",
|
|
2953
|
+
validate: (ctx) => {
|
|
2954
|
+
const skipRows = ctx.num("skip-rows");
|
|
2955
|
+
if (!Number.isInteger(skipRows) || skipRows < 0) {
|
|
2956
|
+
throw new CliValidationError("Skipped rows must be a non-negative integer.", {
|
|
2957
|
+
code: "LOCAL_DATA_SKIP_ROWS_INVALID",
|
|
2958
|
+
location: { field: "skip-rows" }
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
},
|
|
1946
2962
|
// Fast size/time pre-check (stat + format/encoding sniff only — no sha256, no profile).
|
|
1947
2963
|
// Lets agents surface the estimate before committing to a multi-minute full inspection.
|
|
1948
2964
|
dryRun: async (ctx) => {
|
|
@@ -1973,46 +2989,69 @@ var dataIntegrationInspect = {
|
|
|
1973
2989
|
const noHeader = ctx.bool("headerless");
|
|
1974
2990
|
const sourceTimezone = ctx.str("source-timezone");
|
|
1975
2991
|
const requested = ctx.str("data-set").trim() || void 0;
|
|
2992
|
+
const skipRows = ctx.num("skip-rows");
|
|
2993
|
+
const fillMergedCells = ctx.bool("fill-merged-cells");
|
|
2994
|
+
const excludeHiddenRows = ctx.bool("exclude-hidden-rows");
|
|
1976
2995
|
if (inputFiles.length === 1) {
|
|
1977
2996
|
const input = await inspectLocalDataInput(inputFiles[0]);
|
|
1978
2997
|
const headerConsistency = await readExcelHeaderConsistency(input);
|
|
2998
|
+
const excludedSheets = summarizeExcludedSheets(input);
|
|
1979
2999
|
if (!requested && input.dataSets.length > 1) {
|
|
1980
3000
|
return {
|
|
1981
3001
|
version: "ae-local-data-profile/v1",
|
|
1982
3002
|
selection_required: true,
|
|
1983
3003
|
source: { format: input.format, size_bytes: input.sizeBytes, sha256: input.sha256 },
|
|
1984
3004
|
data_sets: input.dataSets,
|
|
3005
|
+
...excludedSheets,
|
|
1985
3006
|
...headerConsistency ?? {},
|
|
1986
3007
|
next_step: "Run inspect again with --data-set, then review the recommended mapping."
|
|
1987
3008
|
};
|
|
1988
3009
|
}
|
|
1989
3010
|
const dataSet = selectDataSet(input, requested);
|
|
1990
|
-
const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input);
|
|
3011
|
+
const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input, skipRows);
|
|
3012
|
+
const headerSignal = headerNames || noHeader ? void 0 : await detectHeaderSignal(input, dataSet, skipRows);
|
|
1991
3013
|
const profile = await profileLocalData(input, dataSet, sourceTimezone, {
|
|
1992
3014
|
collectSamples: true,
|
|
1993
3015
|
collectNestedTree: true,
|
|
1994
3016
|
headerNames,
|
|
1995
|
-
noHeader: noHeader || Boolean(headerPresence)
|
|
3017
|
+
noHeader: noHeader || Boolean(headerPresence),
|
|
3018
|
+
skipRows,
|
|
3019
|
+
fillMergedCells,
|
|
3020
|
+
excludeHiddenRows
|
|
1996
3021
|
});
|
|
1997
3022
|
const annotated = headerPresence ? annotateHeaderless(profile, headerPresence) : profile;
|
|
1998
|
-
return
|
|
3023
|
+
return {
|
|
3024
|
+
...annotated,
|
|
3025
|
+
...excludedSheets,
|
|
3026
|
+
...headerConsistency ?? {},
|
|
3027
|
+
...annotateRowSkips(annotated.warnings, skipRows, headerSignal)
|
|
3028
|
+
};
|
|
1999
3029
|
}
|
|
2000
3030
|
const files = [];
|
|
2001
3031
|
for (const inputFile of inputFiles) {
|
|
2002
3032
|
const input = await inspectLocalDataInput(inputFile);
|
|
2003
3033
|
const dataSet = selectDataSet(input);
|
|
2004
|
-
const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input);
|
|
3034
|
+
const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input, skipRows);
|
|
3035
|
+
const headerSignal = headerNames || noHeader ? void 0 : await detectHeaderSignal(input, dataSet, skipRows);
|
|
2005
3036
|
const profile = await profileLocalData(input, dataSet, sourceTimezone, {
|
|
2006
3037
|
collectSamples: true,
|
|
2007
3038
|
collectNestedTree: true,
|
|
2008
3039
|
headerNames,
|
|
2009
|
-
noHeader: noHeader || Boolean(headerPresence)
|
|
3040
|
+
noHeader: noHeader || Boolean(headerPresence),
|
|
3041
|
+
skipRows,
|
|
3042
|
+
fillMergedCells,
|
|
3043
|
+
excludeHiddenRows
|
|
2010
3044
|
});
|
|
2011
3045
|
const annotated = headerPresence ? annotateHeaderless(profile, headerPresence) : profile;
|
|
2012
3046
|
const headerConsistency = await readExcelHeaderConsistency(input);
|
|
2013
3047
|
files.push({
|
|
2014
3048
|
file: basename3(inputFile),
|
|
2015
|
-
profile:
|
|
3049
|
+
profile: {
|
|
3050
|
+
...annotated,
|
|
3051
|
+
...summarizeExcludedSheets(input),
|
|
3052
|
+
...headerConsistency ?? {},
|
|
3053
|
+
...annotateRowSkips(annotated.warnings, skipRows, headerSignal)
|
|
3054
|
+
}
|
|
2016
3055
|
});
|
|
2017
3056
|
}
|
|
2018
3057
|
return {
|
|
@@ -2023,6 +3062,13 @@ var dataIntegrationInspect = {
|
|
|
2023
3062
|
};
|
|
2024
3063
|
}
|
|
2025
3064
|
};
|
|
3065
|
+
function summarizeExcludedSheets(input) {
|
|
3066
|
+
const excluded = input.excludedDataSets ?? [];
|
|
3067
|
+
if (excluded.length === 0) return {};
|
|
3068
|
+
return {
|
|
3069
|
+
excluded_sheets: excluded.map((dataSet) => ({ name: dataSet.label, reason: "hidden", data_set: dataSet.id }))
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
2026
3072
|
async function readExcelHeaderConsistency(input) {
|
|
2027
3073
|
if (input.format !== "xls" && input.format !== "xlsx") return void 0;
|
|
2028
3074
|
return summarizeHeaderConsistency(await readExcelSheetHeaders(input.filePath, input.format));
|
|
@@ -2033,10 +3079,11 @@ function summarizeHeaderConsistency(sheets) {
|
|
|
2033
3079
|
const allSame = sheets.every((sheet) => JSON.stringify(sheet.headers) === first);
|
|
2034
3080
|
return allSame ? { header_consistency: "all_same" } : { header_consistency: "different", header_details: sheets.map((sheet) => ({ name: sheet.name, headers: sheet.headers })) };
|
|
2035
3081
|
}
|
|
2036
|
-
function detectHeaderPresence(input) {
|
|
3082
|
+
function detectHeaderPresence(input, skipRows = 0) {
|
|
2037
3083
|
if (input.format !== "csv" && input.format !== "tsv") return void 0;
|
|
2038
3084
|
const delimiter = input.delimiter ?? (input.format === "tsv" ? " " : ",");
|
|
2039
|
-
const
|
|
3085
|
+
const peeked = peekDelimitedRecords(input.filePath, { delimiter, encoding: input.encoding, limit: 10 + skipRows });
|
|
3086
|
+
const records = peeked.slice(skipRows);
|
|
2040
3087
|
const detection = detectHeaderRow(records);
|
|
2041
3088
|
if (detection.hasHeaders) return void 0;
|
|
2042
3089
|
return {
|
|
@@ -2044,6 +3091,41 @@ function detectHeaderPresence(input) {
|
|
|
2044
3091
|
autoHeaders: (records[0] ?? []).map((_, index) => `col_${index + 1}`)
|
|
2045
3092
|
};
|
|
2046
3093
|
}
|
|
3094
|
+
async function detectHeaderSignal(input, dataSet, skipRows) {
|
|
3095
|
+
const rows = await peekHeaderRows(input, dataSet, skipRows);
|
|
3096
|
+
if (!rows || rows.length === 0) return void 0;
|
|
3097
|
+
const titleRows = detectLeadingTitleRows(rows).map((title) => ({ ...title, row: title.row + skipRows }));
|
|
3098
|
+
if (input.format !== "xlsx") return titleRows.length > 0 ? { titleRows } : void 0;
|
|
3099
|
+
const detection = detectHeaderRow(rows.map((row) => row.map((value) => isEmptyCell(value) ? "" : String(value))));
|
|
3100
|
+
if (detection.hasHeaders && titleRows.length === 0) return void 0;
|
|
3101
|
+
return { ...detection.hasHeaders ? {} : { detection }, titleRows };
|
|
3102
|
+
}
|
|
3103
|
+
async function peekHeaderRows(input, dataSet, skipRows) {
|
|
3104
|
+
if (input.format === "csv" || input.format === "tsv") {
|
|
3105
|
+
const delimiter = input.delimiter ?? (input.format === "tsv" ? " " : ",");
|
|
3106
|
+
return peekDelimitedRecords(input.filePath, { delimiter, encoding: input.encoding, limit: 10 + skipRows }).slice(skipRows);
|
|
3107
|
+
}
|
|
3108
|
+
if (input.format === "xlsx" && dataSet.kind === "sheet") {
|
|
3109
|
+
return (await peekXlsxRows(input.filePath, dataSet.label, 10 + skipRows)).slice(skipRows);
|
|
3110
|
+
}
|
|
3111
|
+
return void 0;
|
|
3112
|
+
}
|
|
3113
|
+
function isEmptyCell(value) {
|
|
3114
|
+
return value === null || value === void 0 || value === "";
|
|
3115
|
+
}
|
|
3116
|
+
var TITLE_ROW_WARNING = "The first rows here hold a single value each, which looks like a title or banner above the real header row; the header was still read from the first row. See leading_title_rows and re-run with --skip-rows N if that is what they are.";
|
|
3117
|
+
var EXCEL_HEADERLESS_WARNING = "The first row of this worksheet looks like data rather than a header, but it was still used as the header. See header_signal, and re-run with --headers or --headerless if it is data.";
|
|
3118
|
+
function annotateRowSkips(warnings, skipRows, signal) {
|
|
3119
|
+
const extra = [];
|
|
3120
|
+
if (signal?.titleRows.length) extra.push(TITLE_ROW_WARNING);
|
|
3121
|
+
if (signal?.detection) extra.push(EXCEL_HEADERLESS_WARNING);
|
|
3122
|
+
return {
|
|
3123
|
+
warnings: extra.length > 0 ? [...warnings, ...extra] : warnings,
|
|
3124
|
+
...skipRows > 0 ? { skipped_rows: skipRows } : {},
|
|
3125
|
+
...signal?.titleRows.length ? { leading_title_rows: signal.titleRows } : {},
|
|
3126
|
+
...signal?.detection ? { header_signal: signal.detection } : {}
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
2047
3129
|
function annotateHeaderless(profile, presence) {
|
|
2048
3130
|
return {
|
|
2049
3131
|
...profile,
|
|
@@ -2060,7 +3142,7 @@ function splitHeaders(raw) {
|
|
|
2060
3142
|
return headers.length > 0 ? headers : void 0;
|
|
2061
3143
|
}
|
|
2062
3144
|
|
|
2063
|
-
// src/commands/data-integration/
|
|
3145
|
+
// src/commands/data-integration/plan.ts
|
|
2064
3146
|
import { writeFile } from "fs/promises";
|
|
2065
3147
|
var VALID_LOCALES = /* @__PURE__ */ new Set(["zh", "en", "ja", "ko"]);
|
|
2066
3148
|
var EVENT_NAME_RE = /^[a-z][a-z0-9_]*$/;
|
|
@@ -2076,6 +3158,8 @@ function toPropType(type) {
|
|
|
2076
3158
|
return "array_string";
|
|
2077
3159
|
case "object":
|
|
2078
3160
|
return "object";
|
|
3161
|
+
case "array_row":
|
|
3162
|
+
return "array_row";
|
|
2079
3163
|
case "string":
|
|
2080
3164
|
return "string";
|
|
2081
3165
|
}
|
|
@@ -2083,13 +3167,16 @@ function toPropType(type) {
|
|
|
2083
3167
|
function buildDraftFromMapping(options) {
|
|
2084
3168
|
const { mapping } = options;
|
|
2085
3169
|
const excluded = new Set(mapping.exclude_columns ?? []);
|
|
2086
|
-
const properties = mapping.properties.filter((property) => !excluded.has(property.source)).map((property) =>
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
3170
|
+
const properties = mapping.properties.filter((property) => !excluded.has(property.source)).map((property) => {
|
|
3171
|
+
const leaf = property.target.includes(".") ? property.target.split(".").pop() : void 0;
|
|
3172
|
+
return {
|
|
3173
|
+
name: property.target,
|
|
3174
|
+
display_name: leaf ?? property.source,
|
|
3175
|
+
desc: property.desc ?? leaf ?? property.source,
|
|
3176
|
+
type: toPropType(property.type),
|
|
3177
|
+
source: "data"
|
|
3178
|
+
};
|
|
3179
|
+
});
|
|
2093
3180
|
const propNames = properties.map((property) => property.name);
|
|
2094
3181
|
const eventNames = resolveEventNames(options);
|
|
2095
3182
|
const events = eventNames.map((eventName) => {
|
|
@@ -2167,7 +3254,7 @@ function buildPlanDraft(ctx) {
|
|
|
2167
3254
|
location: { field: "lang" }
|
|
2168
3255
|
});
|
|
2169
3256
|
}
|
|
2170
|
-
|
|
3257
|
+
const draft = buildDraftFromMapping({
|
|
2171
3258
|
mapping,
|
|
2172
3259
|
planName: ctx.str("plan-name").trim() || mapping.default_event_name || "local-data",
|
|
2173
3260
|
eventNames: ctx.list("event-name"),
|
|
@@ -2175,6 +3262,17 @@ function buildPlanDraft(ctx) {
|
|
|
2175
3262
|
lang,
|
|
2176
3263
|
projectId: ctx.optionalNum("project-id")
|
|
2177
3264
|
});
|
|
3265
|
+
validateAndFix(draft);
|
|
3266
|
+
try {
|
|
3267
|
+
validateDraft(draft);
|
|
3268
|
+
} catch (error) {
|
|
3269
|
+
throw new CliValidationError("The tracking-plan draft is invalid.", {
|
|
3270
|
+
code: "LOCAL_DATA_PLAN_INVALID_DRAFT",
|
|
3271
|
+
hint: error instanceof Error ? error.message : String(error),
|
|
3272
|
+
location: { field: "mapping" }
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
return draft;
|
|
2178
3276
|
}
|
|
2179
3277
|
var dataIntegrationPlan = {
|
|
2180
3278
|
service: "data-integration",
|
|
@@ -2182,7 +3280,7 @@ var dataIntegrationPlan = {
|
|
|
2182
3280
|
usesAeHost: false,
|
|
2183
3281
|
description: "Convert a confirmed local-data mapping into a tracking-plan draft.json (source_type=data, sdk_integration_mode=none).",
|
|
2184
3282
|
flags: [
|
|
2185
|
-
{ name: "mapping", type: "string", required: true, sensitive: true, desc:
|
|
3283
|
+
{ name: "mapping", type: "string", required: true, sensitive: true, desc: `Confirmed ${MAPPING_VERSION} JSON, file path, or @file.` },
|
|
2186
3284
|
{ name: "event-name", type: "string", variadic: true, desc: "Concrete event name (track/mixed without default_event_name). Repeat for multiple events." },
|
|
2187
3285
|
{ name: "plan-name", type: "string", desc: 'Plan name. Default: the mapping default_event_name, else "local-data".' },
|
|
2188
3286
|
{ name: "app-type", type: "string", default: "unknown", desc: "Application type recorded in draft meta (informational)." },
|
|
@@ -2213,7 +3311,7 @@ var dataIntegrationPlan = {
|
|
|
2213
3311
|
}
|
|
2214
3312
|
};
|
|
2215
3313
|
|
|
2216
|
-
// src/commands/data-integration/
|
|
3314
|
+
// src/commands/data-integration/conversion.ts
|
|
2217
3315
|
import { randomInt as randomInt2, randomUUID } from "crypto";
|
|
2218
3316
|
import {
|
|
2219
3317
|
chmodSync,
|
|
@@ -2234,14 +3332,6 @@ import { createInterface as createInterface2 } from "readline";
|
|
|
2234
3332
|
var SORT_CHUNK_SIZE = 1e4;
|
|
2235
3333
|
var THREE_YEARS_MS = 3 * 365 * 24 * 60 * 60 * 1e3;
|
|
2236
3334
|
var THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1e3;
|
|
2237
|
-
function stripQuotes(value) {
|
|
2238
|
-
if (typeof value !== "string") return value;
|
|
2239
|
-
const text = value;
|
|
2240
|
-
if (text.length >= 2 && (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'"))) {
|
|
2241
|
-
return text.slice(1, -1).trim();
|
|
2242
|
-
}
|
|
2243
|
-
return text.trim();
|
|
2244
|
-
}
|
|
2245
3335
|
async function convertLocalData(options) {
|
|
2246
3336
|
const input = await inspectLocalDataInput(options.inputFile);
|
|
2247
3337
|
if (input.format !== options.mapping.source.format) {
|
|
@@ -2260,13 +3350,29 @@ async function convertLocalData(options) {
|
|
|
2260
3350
|
const dataSet = options.mergeSheets ? { id: "all-sheets", kind: "sheet", label: "all-sheets", selector: "all-sheets" } : selectDataSet(input, options.mapping.source.data_set);
|
|
2261
3351
|
const streamOptions = {
|
|
2262
3352
|
headerNames: options.mapping.headers,
|
|
3353
|
+
skipRows: options.mapping.skip_rows,
|
|
2263
3354
|
flattenRules: options.mapping.flatten_rules,
|
|
2264
|
-
mergeSheets: options.mergeSheets
|
|
3355
|
+
mergeSheets: options.mergeSheets,
|
|
3356
|
+
// Both XLSX layout decisions come from the mapping, so the rows converted here are the rows
|
|
3357
|
+
// inspect profiled — the mapping is the only place the user's answer to them is recorded.
|
|
3358
|
+
fillMergedCells: options.mapping.fill_merged_cells,
|
|
3359
|
+
excludeHiddenRows: options.mapping.exclude_hidden_rows,
|
|
3360
|
+
// The profile pass inside convert is internal (it writes profile.json); the ragged-row
|
|
3361
|
+
// warning is surfaced by the conversion pass below instead, so suppress it here.
|
|
3362
|
+
warnRagged: false,
|
|
3363
|
+
// The mapping's own columns are the key worth checking for repeats: they are what AE will
|
|
3364
|
+
// receive as identity, time, and event name. A file-wide `default_event_name` is the same on
|
|
3365
|
+
// every row, so it distinguishes nothing and is left out.
|
|
3366
|
+
duplicateKeyFields: [
|
|
3367
|
+
options.mapping.account_id_field ?? options.mapping.distinct_id_field,
|
|
3368
|
+
options.mapping.time.field,
|
|
3369
|
+
options.mapping.event_name_field
|
|
3370
|
+
].filter((field) => typeof field === "string" && field.length > 0)
|
|
2265
3371
|
};
|
|
2266
3372
|
const salvageSet = options.salvageFrom ? readSalvageRowNumbers(options.salvageFrom) : void 0;
|
|
2267
3373
|
let salvageMatched = 0;
|
|
2268
3374
|
const runId = `${formatRunTimestamp(/* @__PURE__ */ new Date())}-${randomUUID().slice(0, 8)}`;
|
|
2269
|
-
const outputDir = resolve(options.outputDir || join(".ae-cli", "data-integration", runId));
|
|
3375
|
+
const outputDir = resolve(options.outputDir || join(".ae-cli", "data-integration", "runs", runId));
|
|
2270
3376
|
prepareOutputDirectory(outputDir);
|
|
2271
3377
|
const profile = await profileLocalData(input, dataSet, options.mapping.time.source_timezone, streamOptions);
|
|
2272
3378
|
const profilePath = join(outputDir, "profile.json");
|
|
@@ -2283,7 +3389,12 @@ async function convertLocalData(options) {
|
|
|
2283
3389
|
let validRecords = 0;
|
|
2284
3390
|
let invalidRecords = 0;
|
|
2285
3391
|
const recordTypes = {};
|
|
2286
|
-
|
|
3392
|
+
const skippedFields = {};
|
|
3393
|
+
let lanIpRecords = 0;
|
|
3394
|
+
const flattenMisses = {};
|
|
3395
|
+
const cellIssues = /* @__PURE__ */ new Map();
|
|
3396
|
+
const xlsxStructure = input.format === "xlsx" ? createXlsxStructureCollector() : void 0;
|
|
3397
|
+
const rowCount = await streamLocalDataRows(
|
|
2287
3398
|
input,
|
|
2288
3399
|
dataSet,
|
|
2289
3400
|
async (row, rowNumber) => {
|
|
@@ -2297,6 +3408,10 @@ async function convertLocalData(options) {
|
|
|
2297
3408
|
}
|
|
2298
3409
|
validRecords += 1;
|
|
2299
3410
|
recordTypes[result.recordType] = (recordTypes[result.recordType] ?? 0) + 1;
|
|
3411
|
+
if (result.lanIp) lanIpRecords += 1;
|
|
3412
|
+
for (const skip of result.skips) {
|
|
3413
|
+
skippedFields[skip.code] = (skippedFields[skip.code] ?? 0) + 1;
|
|
3414
|
+
}
|
|
2300
3415
|
const line = JSON.stringify(result.record);
|
|
2301
3416
|
if (isUserProfileType(result.recordType)) {
|
|
2302
3417
|
userSetBuffer.push({ key: result.sortKey, line });
|
|
@@ -2307,14 +3422,33 @@ async function convertLocalData(options) {
|
|
|
2307
3422
|
},
|
|
2308
3423
|
{
|
|
2309
3424
|
headerNames: streamOptions.headerNames,
|
|
3425
|
+
skipRows: streamOptions.skipRows,
|
|
2310
3426
|
flattenRules: streamOptions.flattenRules,
|
|
2311
|
-
|
|
3427
|
+
flattenMisses,
|
|
3428
|
+
cellIssues,
|
|
3429
|
+
mergeSheets: streamOptions.mergeSheets,
|
|
3430
|
+
fillMergedCells: streamOptions.fillMergedCells,
|
|
3431
|
+
excludeHiddenRows: streamOptions.excludeHiddenRows,
|
|
3432
|
+
xlsxStructure
|
|
2312
3433
|
}
|
|
2313
3434
|
);
|
|
2314
3435
|
if (userSetBuffer.length > 0) flushSortChunk(outputDir, userSetBuffer, sortChunks);
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
3436
|
+
await Promise.all([finishStream(trackStream), finishStream(invalidStream)]);
|
|
3437
|
+
for (const [outColumn, count] of Object.entries(flattenMisses)) {
|
|
3438
|
+
process.stderr.write(`Warning: flatten rule "${outColumn}" did not materialize for ${count} row(s).
|
|
3439
|
+
`);
|
|
3440
|
+
}
|
|
3441
|
+
for (const warning of cellIssueWarnings(cellIssues)) {
|
|
3442
|
+
process.stderr.write(`Warning: ${warning}
|
|
3443
|
+
`);
|
|
3444
|
+
}
|
|
3445
|
+
if (xlsxStructure) {
|
|
3446
|
+
for (const warning of xlsxStructureWarnings(xlsxStructure, options.mapping.exclude_columns)) {
|
|
3447
|
+
process.stderr.write(`Warning: ${warning}
|
|
3448
|
+
`);
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
const structureReport = xlsxStructure ? xlsxStructureReport(xlsxStructure) : void 0;
|
|
2318
3452
|
if (salvageSet && salvageMatched === 0) {
|
|
2319
3453
|
throw new CliValidationError("The salvage file lists no rows from this source.", {
|
|
2320
3454
|
code: "LOCAL_DATA_SALVAGE_NO_MATCH",
|
|
@@ -2325,21 +3459,22 @@ async function convertLocalData(options) {
|
|
|
2325
3459
|
const validStream = secureWriteStream(validPath);
|
|
2326
3460
|
if (existsSync(trackTempPath)) {
|
|
2327
3461
|
for await (const chunk of createReadStream3(trackTempPath)) {
|
|
2328
|
-
|
|
3462
|
+
await writeRaw(validStream, chunk);
|
|
2329
3463
|
}
|
|
2330
3464
|
}
|
|
2331
3465
|
await mergeSortChunks(sortChunks, validStream);
|
|
2332
|
-
validStream
|
|
2333
|
-
await once(validStream, "finish");
|
|
3466
|
+
await finishStream(validStream);
|
|
2334
3467
|
if (existsSync(trackTempPath)) unlinkSync(trackTempPath);
|
|
2335
3468
|
for (const path of sortChunks) if (existsSync(path)) unlinkSync(path);
|
|
2336
3469
|
writeSecureJson(profilePath, profile);
|
|
2337
3470
|
writeSecureJson(mappingPath, options.mapping);
|
|
2338
3471
|
writeSecureText(transformPath, createTransformScript(options.inputFile, mappingPath));
|
|
2339
3472
|
const validBytes = statSize(validPath);
|
|
3473
|
+
const sourceRows = salvageSet ? salvageMatched : rowCount;
|
|
2340
3474
|
const blockedReasons = [
|
|
2341
|
-
...
|
|
2342
|
-
...validRecords === 0 ? ["No valid UE records were generated."] : []
|
|
3475
|
+
...rowCount === 0 ? ["The source contained no data rows."] : [],
|
|
3476
|
+
...rowCount > 0 && validRecords === 0 ? ["No valid UE records were generated."] : [],
|
|
3477
|
+
...invalidRecords > 0 ? ["Some source rows failed UE validation."] : []
|
|
2343
3478
|
];
|
|
2344
3479
|
const manifest = {
|
|
2345
3480
|
version: "ae-local-data-manifest/v1",
|
|
@@ -2357,10 +3492,18 @@ async function convertLocalData(options) {
|
|
|
2357
3492
|
valid_file: basename4(validPath),
|
|
2358
3493
|
valid_sha256: await sha256File(validPath),
|
|
2359
3494
|
invalid_file: basename4(invalidPath),
|
|
3495
|
+
source_rows: sourceRows,
|
|
2360
3496
|
valid_records: validRecords,
|
|
2361
3497
|
invalid_records: invalidRecords,
|
|
2362
3498
|
valid_bytes: validBytes,
|
|
2363
|
-
record_types: recordTypes
|
|
3499
|
+
record_types: recordTypes,
|
|
3500
|
+
...Object.keys(skippedFields).length > 0 ? { skipped_fields: skippedFields } : {},
|
|
3501
|
+
...lanIpRecords > 0 ? { lan_ip_records: lanIpRecords } : {},
|
|
3502
|
+
...Object.keys(flattenMisses).length > 0 ? { flatten_misses: flattenMisses } : {},
|
|
3503
|
+
...cellIssues.size > 0 ? { unreadable_cells: cellIssueCounts(cellIssues) } : {},
|
|
3504
|
+
...structureReport ? { xlsx_structure: structureReport } : {},
|
|
3505
|
+
...profile.summary_rows ? { summary_rows: profile.summary_rows } : {},
|
|
3506
|
+
...profile.duplicate_keys ? { duplicate_keys: profile.duplicate_keys } : {}
|
|
2364
3507
|
},
|
|
2365
3508
|
blocked_reasons: blockedReasons
|
|
2366
3509
|
};
|
|
@@ -2378,7 +3521,9 @@ async function convertLocalDataMulti(options) {
|
|
|
2378
3521
|
const profile = await profileLocalData(input, dataSet, sourceTimezone, {
|
|
2379
3522
|
collectSamples: true,
|
|
2380
3523
|
headerNames: options.mapping.headers,
|
|
2381
|
-
|
|
3524
|
+
skipRows: options.mapping.skip_rows,
|
|
3525
|
+
flattenRules: options.mapping.flatten_rules,
|
|
3526
|
+
warnRagged: false
|
|
2382
3527
|
});
|
|
2383
3528
|
profiled.push({ file: basename4(inputFile), profile });
|
|
2384
3529
|
}
|
|
@@ -2394,7 +3539,7 @@ async function convertLocalDataMulti(options) {
|
|
|
2394
3539
|
validateTypeResolutions(resolutions, profiled.map((entry) => entry.file));
|
|
2395
3540
|
const overrides = applyTypeResolutions(resolutions, profiled);
|
|
2396
3541
|
const parent = resolve(
|
|
2397
|
-
options.outputDir || join(".ae-cli", "data-integration", `${formatRunTimestamp(/* @__PURE__ */ new Date())}-${randomUUID().slice(0, 8)}`)
|
|
3542
|
+
options.outputDir || join(".ae-cli", "data-integration", "runs", `${formatRunTimestamp(/* @__PURE__ */ new Date())}-${randomUUID().slice(0, 8)}`)
|
|
2398
3543
|
);
|
|
2399
3544
|
prepareOutputDirectory(parent);
|
|
2400
3545
|
const files = [];
|
|
@@ -2452,11 +3597,32 @@ function convertRow(row, rowNumber, mapping, now) {
|
|
|
2452
3597
|
errors.push({ code: "INVALID_EVENT_NAME", field: mapping.event_name_field });
|
|
2453
3598
|
}
|
|
2454
3599
|
}
|
|
2455
|
-
const
|
|
2456
|
-
|
|
3600
|
+
const skips = [];
|
|
3601
|
+
let lanIp = false;
|
|
3602
|
+
let ip;
|
|
3603
|
+
if (recordType === "track") {
|
|
3604
|
+
const rawIp = readOptionalField(row, mapping.ip_field);
|
|
3605
|
+
if (rawIp) {
|
|
3606
|
+
if (isValidIp(rawIp)) {
|
|
3607
|
+
ip = rawIp;
|
|
3608
|
+
lanIp = isPrivateIp(rawIp);
|
|
3609
|
+
} else {
|
|
3610
|
+
skips.push({ code: "INVALID_IP", field: mapping.ip_field });
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
let uuid;
|
|
3615
|
+
{
|
|
3616
|
+
const rawUuid = readOptionalField(row, mapping.uuid_field);
|
|
3617
|
+
if (rawUuid) {
|
|
3618
|
+
if (isValidUuid(rawUuid)) uuid = rawUuid;
|
|
3619
|
+
else skips.push({ code: "INVALID_UUID", field: mapping.uuid_field });
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
2457
3622
|
const excluded = new Set(mapping.exclude_columns ?? []);
|
|
2458
3623
|
const properties = {};
|
|
2459
3624
|
for (const property of mapping.properties) {
|
|
3625
|
+
if (property.target.includes(".")) continue;
|
|
2460
3626
|
if (excluded.has(property.source)) continue;
|
|
2461
3627
|
let value = stripQuotes(row[property.source]);
|
|
2462
3628
|
if (isMissing2(value)) continue;
|
|
@@ -2470,8 +3636,8 @@ function convertRow(row, rowNumber, mapping, now) {
|
|
|
2470
3636
|
properties[property.target] = converted.value;
|
|
2471
3637
|
}
|
|
2472
3638
|
}
|
|
2473
|
-
const zoneOffset = mapping.zone_offset_value !== void 0 ? resolveZoneOffsetValue(mapping.zone_offset_value, now) : mapping.zone_offset_field ? readZoneOffset(row, mapping.zone_offset_field) : void 0;
|
|
2474
|
-
if (mapping.zone_offset_field && zoneOffset === void 0) {
|
|
3639
|
+
const zoneOffset = recordType === "track" && mapping.zone_offset_value !== void 0 ? resolveZoneOffsetValue(mapping.zone_offset_value, now) : recordType === "track" && mapping.zone_offset_field ? readZoneOffset(row, mapping.zone_offset_field) : void 0;
|
|
3640
|
+
if (recordType === "track" && mapping.zone_offset_field && zoneOffset === void 0) {
|
|
2475
3641
|
errors.push({ code: "INVALID_ZONE_OFFSET", field: mapping.zone_offset_field });
|
|
2476
3642
|
}
|
|
2477
3643
|
if (errors.length > 0 || !recordType || !normalizedTime) return { ok: false, errors };
|
|
@@ -2489,7 +3655,9 @@ function convertRow(row, rowNumber, mapping, now) {
|
|
|
2489
3655
|
ok: true,
|
|
2490
3656
|
recordType,
|
|
2491
3657
|
record,
|
|
2492
|
-
sortKey: `${accountId ?? distinctId ?? ""}\0${normalizedTime.instant.toISOString()}\0${String(rowNumber).padStart(12, "0")}
|
|
3658
|
+
sortKey: `${accountId ?? distinctId ?? ""}\0${normalizedTime.instant.toISOString()}\0${String(rowNumber).padStart(12, "0")}`,
|
|
3659
|
+
skips,
|
|
3660
|
+
lanIp
|
|
2493
3661
|
};
|
|
2494
3662
|
}
|
|
2495
3663
|
function readOptionalField(row, field) {
|
|
@@ -2597,7 +3765,7 @@ function convertProperty(value, type, transform, timeZone = "UTC", timeFormat) {
|
|
|
2597
3765
|
const normalized = normalizeTime(value, timeZone, timeFormat);
|
|
2598
3766
|
return normalized ? { ok: true, value: normalized.formatted } : { ok: false, code: "PROPERTY_TYPE_CONFLICT" };
|
|
2599
3767
|
}
|
|
2600
|
-
if (type === "list") {
|
|
3768
|
+
if (type === "list" || type === "array_row") {
|
|
2601
3769
|
if (!Array.isArray(value)) return { ok: false, code: "PROPERTY_TYPE_CONFLICT" };
|
|
2602
3770
|
return isPropertyWithinLimits(value, type) ? { ok: true, value } : { ok: false, code: "PROPERTY_LIMIT_EXCEEDED" };
|
|
2603
3771
|
}
|
|
@@ -2799,11 +3967,40 @@ function prepareOutputDirectory(path) {
|
|
|
2799
3967
|
chmodSync(path, 448);
|
|
2800
3968
|
}
|
|
2801
3969
|
function secureWriteStream(path) {
|
|
2802
|
-
|
|
3970
|
+
const stream = createWriteStream(path, { encoding: "utf8", mode: 384, flags: "wx" });
|
|
3971
|
+
let fail;
|
|
3972
|
+
const errorPromise = new Promise((_, reject) => {
|
|
3973
|
+
fail = reject;
|
|
3974
|
+
});
|
|
3975
|
+
errorPromise.catch(() => {
|
|
3976
|
+
});
|
|
3977
|
+
stream.on("error", (error) => fail?.(error));
|
|
3978
|
+
return { stream, path, errorPromise };
|
|
3979
|
+
}
|
|
3980
|
+
function writeFailure(path, error) {
|
|
3981
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3982
|
+
return new Error(`Failed to write "${path}": ${detail}. Check available disk space and directory permissions.`, { cause: error });
|
|
2803
3983
|
}
|
|
2804
|
-
async function
|
|
2805
|
-
|
|
2806
|
-
|
|
3984
|
+
async function writeRaw(target, data) {
|
|
3985
|
+
try {
|
|
3986
|
+
if (!target.stream.write(data)) {
|
|
3987
|
+
await Promise.race([once(target.stream, "drain"), target.errorPromise]);
|
|
3988
|
+
}
|
|
3989
|
+
} catch (error) {
|
|
3990
|
+
throw writeFailure(target.path, error);
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
async function writeLine(target, line) {
|
|
3994
|
+
await writeRaw(target, `${line}
|
|
3995
|
+
`);
|
|
3996
|
+
}
|
|
3997
|
+
async function finishStream(target) {
|
|
3998
|
+
try {
|
|
3999
|
+
target.stream.end();
|
|
4000
|
+
await Promise.race([once(target.stream, "finish"), target.errorPromise]);
|
|
4001
|
+
} catch (error) {
|
|
4002
|
+
throw writeFailure(target.path, error);
|
|
4003
|
+
}
|
|
2807
4004
|
}
|
|
2808
4005
|
function writeSecureJson(path, value) {
|
|
2809
4006
|
writeSecureText(path, `${JSON.stringify(value, null, 2)}
|
|
@@ -2830,7 +4027,7 @@ function formatRunTimestamp(value) {
|
|
|
2830
4027
|
return value.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
2831
4028
|
}
|
|
2832
4029
|
|
|
2833
|
-
// src/commands/data-integration/
|
|
4030
|
+
// src/commands/data-integration/convert.ts
|
|
2834
4031
|
var dataIntegrationConvert = {
|
|
2835
4032
|
service: "data-integration",
|
|
2836
4033
|
command: "convert",
|
|
@@ -2838,8 +4035,8 @@ var dataIntegrationConvert = {
|
|
|
2838
4035
|
description: "Convert one or more local data sets into validated UE JSONL and quarantine invalid rows.",
|
|
2839
4036
|
flags: [
|
|
2840
4037
|
{ name: "input-file", type: "string", required: true, sensitive: true, variadic: true, desc: "Source local data file. Repeat for multiple files (requires a wildcard mapping). The source is never modified." },
|
|
2841
|
-
{ name: "mapping", type: "string", required: true, sensitive: true, desc:
|
|
2842
|
-
{ name: "output-dir", type: "string", sensitive: true, desc: "New or empty output directory. Default: .ae-cli/data-integration/<run-id>." },
|
|
4038
|
+
{ name: "mapping", type: "string", required: true, sensitive: true, desc: `${MAPPING_VERSION} JSON, file path, or @file.` },
|
|
4039
|
+
{ name: "output-dir", type: "string", sensitive: true, desc: "New or empty output directory. Default: .ae-cli/data-integration/runs/<run-id>." },
|
|
2843
4040
|
{ name: "type-resolutions", type: "json", sensitive: true, desc: "JSON object resolving cross-file column type conflicts (unify, split, or skip)." },
|
|
2844
4041
|
{ name: "merge-sheets", type: "boolean", default: false, desc: "Stream every worksheet in file order instead of a single selected sheet." },
|
|
2845
4042
|
{ name: "salvage-from", type: "string", sensitive: true, desc: "Re-process only the rows listed in a previous run's invalid.rows.jsonl, against the current (fixed) mapping. Single-file only." }
|
|
@@ -2891,7 +4088,7 @@ var dataIntegrationConvert = {
|
|
|
2891
4088
|
}
|
|
2892
4089
|
};
|
|
2893
4090
|
|
|
2894
|
-
// src/commands/data-integration/
|
|
4091
|
+
// src/commands/data-integration/upload.ts
|
|
2895
4092
|
import { createReadStream as createReadStream4, readFileSync as readFileSync3, statSync as statSync3 } from "fs";
|
|
2896
4093
|
import { basename as basename5, dirname, resolve as resolve2 } from "path";
|
|
2897
4094
|
import { createInterface as createInterface3 } from "readline";
|
|
@@ -3236,22 +4433,900 @@ function isRecord3(value) {
|
|
|
3236
4433
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3237
4434
|
}
|
|
3238
4435
|
|
|
3239
|
-
// src/commands/data-integration/
|
|
4436
|
+
// src/commands/data-integration/handoff.ts
|
|
3240
4437
|
import { createHash as createHash3 } from "crypto";
|
|
3241
|
-
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
3242
|
-
import { join as
|
|
4438
|
+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
4439
|
+
import { dirname as dirname2, join as join3, resolve as resolve3 } from "path";
|
|
4440
|
+
import { tmpdir } from "os";
|
|
4441
|
+
|
|
4442
|
+
// src/commands/data-integration/archive.ts
|
|
4443
|
+
import { ZipArchive } from "archiver";
|
|
4444
|
+
import { createWriteStream as createWriteStream2, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
|
|
4445
|
+
import { join as join2, relative, sep } from "path";
|
|
4446
|
+
async function zipPackage(dir, zipPath) {
|
|
4447
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
4448
|
+
const output = createWriteStream2(zipPath, { mode: 384 });
|
|
4449
|
+
const zip = new ZipArchive({ zlib: { level: 9 } });
|
|
4450
|
+
output.on("close", resolvePromise);
|
|
4451
|
+
output.on("error", rejectPromise);
|
|
4452
|
+
zip.on("error", rejectPromise);
|
|
4453
|
+
zip.on("warning", (error) => {
|
|
4454
|
+
if (error.code !== "ENOENT") rejectPromise(error);
|
|
4455
|
+
});
|
|
4456
|
+
zip.pipe(output);
|
|
4457
|
+
const walk = (current) => {
|
|
4458
|
+
for (const name of readdirSync2(current)) {
|
|
4459
|
+
if (name === ".DS_Store") continue;
|
|
4460
|
+
const full = join2(current, name);
|
|
4461
|
+
const stats = statSync4(full);
|
|
4462
|
+
if (stats.isDirectory()) {
|
|
4463
|
+
walk(full);
|
|
4464
|
+
} else {
|
|
4465
|
+
const rel = relative(dir, full).split(sep).join("/");
|
|
4466
|
+
zip.file(full, { name: rel, mode: stats.mode & 511 });
|
|
4467
|
+
}
|
|
4468
|
+
}
|
|
4469
|
+
};
|
|
4470
|
+
walk(dir);
|
|
4471
|
+
void zip.finalize();
|
|
4472
|
+
});
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
// src/commands/data-integration/relay.ts
|
|
4476
|
+
var PIPELINE_VERSION = "ae-data-integration-pipeline/v1";
|
|
4477
|
+
var SHAPE_VERSION = "ae-data-integration-shape/v1";
|
|
4478
|
+
var DEFAULT_BATCH_SIZE2 = 500;
|
|
4479
|
+
var ENV_FILE = ".local/target.env";
|
|
4480
|
+
function buildPipelineDescriptor(entries, target = {}) {
|
|
4481
|
+
const first = entries[0];
|
|
4482
|
+
return {
|
|
4483
|
+
version: PIPELINE_VERSION,
|
|
4484
|
+
created_at: first?.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
4485
|
+
source: { type: "local_file", params: { format: first?.format ?? "csv" } },
|
|
4486
|
+
transform: { type: MAPPING_VERSION, refs: entries.map((entry) => entry.mapping_file) },
|
|
4487
|
+
sink: {
|
|
4488
|
+
type: "restful_sync_json",
|
|
4489
|
+
params: {
|
|
4490
|
+
batch_size: DEFAULT_BATCH_SIZE2,
|
|
4491
|
+
env_file: ENV_FILE,
|
|
4492
|
+
...target.pushurl ? { pushurl: target.pushurl } : {},
|
|
4493
|
+
...target.project_id ? { project_id: target.project_id } : {}
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
};
|
|
4497
|
+
}
|
|
4498
|
+
function buildShapeBaseline(items) {
|
|
4499
|
+
return {
|
|
4500
|
+
version: SHAPE_VERSION,
|
|
4501
|
+
entries: items.map(({ mapping, fingerprint }) => ({
|
|
4502
|
+
fingerprint,
|
|
4503
|
+
mode: mapping.mode,
|
|
4504
|
+
data_set: mapping.source.data_set,
|
|
4505
|
+
format: mapping.source.format,
|
|
4506
|
+
columns: sourceColumns(mapping)
|
|
4507
|
+
}))
|
|
4508
|
+
};
|
|
4509
|
+
}
|
|
4510
|
+
function sh(...lines) {
|
|
4511
|
+
return `${lines.join("\n")}
|
|
4512
|
+
`;
|
|
4513
|
+
}
|
|
4514
|
+
function generateBinScripts() {
|
|
4515
|
+
return [
|
|
4516
|
+
{ relPath: "bin/run.sh", content: runSh(), mode: 448 },
|
|
4517
|
+
{ relPath: "bin/upload.sh", content: uploadSh(), mode: 448 },
|
|
4518
|
+
{ relPath: "bin/bind_mapping.py", content: bindMappingPy(), mode: 448 },
|
|
4519
|
+
{ relPath: "bin/summarize.py", content: summarizePy(), mode: 448 },
|
|
4520
|
+
{ relPath: "bin/plan_check.py", content: planCheckPy(), mode: 448 },
|
|
4521
|
+
{ relPath: "bin/verify.py", content: verifyPy(), mode: 448 },
|
|
4522
|
+
{ relPath: "bin/resolve_appid.py", content: resolveAppidPy(), mode: 448 }
|
|
4523
|
+
];
|
|
4524
|
+
}
|
|
4525
|
+
function runSh() {
|
|
4526
|
+
return sh(
|
|
4527
|
+
"#!/usr/bin/env bash",
|
|
4528
|
+
"# Generic pipeline executor: source -> transform -> plan. Never uploads (see upload.sh).",
|
|
4529
|
+
"# Reads pipeline.json and dispatches each stage by its `type` to ae-cli subcommands.",
|
|
4530
|
+
"set -euo pipefail",
|
|
4531
|
+
'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
|
|
4532
|
+
'PKG_ROOT="$(dirname "$SCRIPT_DIR")"',
|
|
4533
|
+
'cd "$PKG_ROOT"',
|
|
4534
|
+
"",
|
|
4535
|
+
`SRC_TYPE="$(python3 -c 'import json; print(json.load(open("pipeline.json"))["source"]["type"])')"`,
|
|
4536
|
+
'case "$SRC_TYPE" in',
|
|
4537
|
+
" local_file) ;;",
|
|
4538
|
+
" *)",
|
|
4539
|
+
' echo "unsupported source type: $SRC_TYPE" >&2',
|
|
4540
|
+
' echo "re-run the full ae-data-integration pipeline (or upgrade ae-cli) for this source." >&2',
|
|
4541
|
+
" exit 64",
|
|
4542
|
+
" ;;",
|
|
4543
|
+
"esac",
|
|
4544
|
+
"",
|
|
4545
|
+
'INPUT="${1:-}"',
|
|
4546
|
+
'if [ -z "$INPUT" ]; then',
|
|
4547
|
+
" shopt -s nullglob; FILES=(inbox/*); shopt -u nullglob",
|
|
4548
|
+
' if [ "${#FILES[@]}" -ne 1 ]; then',
|
|
4549
|
+
' echo "usage: bin/run.sh <input-file>" >&2',
|
|
4550
|
+
' echo " (or put exactly one file in inbox/)" >&2',
|
|
4551
|
+
" exit 2",
|
|
4552
|
+
" fi",
|
|
4553
|
+
' INPUT="${FILES[0]}"',
|
|
4554
|
+
"fi",
|
|
4555
|
+
"",
|
|
4556
|
+
'RUN_DIR="runs/$(date +%Y%m%d-%H%M%S)"',
|
|
4557
|
+
'mkdir -p "$RUN_DIR"',
|
|
4558
|
+
'echo "run: $RUN_DIR"',
|
|
4559
|
+
"",
|
|
4560
|
+
'python3 bin/bind_mapping.py "$INPUT" "$RUN_DIR"',
|
|
4561
|
+
"",
|
|
4562
|
+
"while IFS= read -r ref; do",
|
|
4563
|
+
' ref_dir="$(dirname "$ref")"',
|
|
4564
|
+
' echo "convert: $ref_dir"',
|
|
4565
|
+
" ae-cli data-integration convert \\",
|
|
4566
|
+
' --input-file "$INPUT" \\',
|
|
4567
|
+
' --mapping "$RUN_DIR/bound/$ref_dir/mapping.json" \\',
|
|
4568
|
+
' --output-dir "$RUN_DIR/$ref_dir" || exit $?',
|
|
4569
|
+
`done < <(python3 -c 'import json,sys; print("\\n".join(json.load(open("pipeline.json"))["transform"]["refs"]))')`,
|
|
4570
|
+
"",
|
|
4571
|
+
'python3 bin/summarize.py "$RUN_DIR"',
|
|
4572
|
+
'python3 bin/plan_check.py "$RUN_DIR"',
|
|
4573
|
+
"",
|
|
4574
|
+
"# Salvage hint: quarantined rows are never silently dropped. Re-process only",
|
|
4575
|
+
"# them against the fixed mapping instead of re-uploading the whole file.",
|
|
4576
|
+
"while IFS= read -r ref; do",
|
|
4577
|
+
' ref_dir="$(dirname "$ref")"',
|
|
4578
|
+
' inv="$RUN_DIR/$ref_dir/invalid.rows.jsonl"',
|
|
4579
|
+
' if [ -s "$inv" ]; then',
|
|
4580
|
+
' echo "note: $inv has quarantined rows \u2014 salvage them with:"',
|
|
4581
|
+
' echo " ae-cli data-integration convert --input-file "$INPUT" --mapping "$RUN_DIR/bound/$ref_dir/mapping.json" --salvage-from "$inv" --output-dir "$RUN_DIR-salvage""',
|
|
4582
|
+
" fi",
|
|
4583
|
+
`done < <(python3 -c 'import json,sys; print("\\n".join(json.load(open("pipeline.json"))["transform"]["refs"]))')`,
|
|
4584
|
+
"",
|
|
4585
|
+
'echo "done. review the summary and plan check, then run: bin/upload.sh $RUN_DIR"'
|
|
4586
|
+
);
|
|
4587
|
+
}
|
|
4588
|
+
function uploadSh() {
|
|
4589
|
+
return sh(
|
|
4590
|
+
"#!/usr/bin/env bash",
|
|
4591
|
+
"# Sink executor. Dry-run by default; --confirm actually uploads.",
|
|
4592
|
+
"# Resolves the recorded target from pipeline.json (pushurl + project_id), derives",
|
|
4593
|
+
"# the APPID via `ae-cli project info get` (bin/resolve_appid.py), and falls back to",
|
|
4594
|
+
"# .local/target.env for explicit APPID / endpoint / project-id overrides.",
|
|
4595
|
+
"set -euo pipefail",
|
|
4596
|
+
'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
|
|
4597
|
+
'PKG_ROOT="$(dirname "$SCRIPT_DIR")"',
|
|
4598
|
+
'cd "$PKG_ROOT"',
|
|
4599
|
+
"",
|
|
4600
|
+
`SINK_TYPE="$(python3 -c 'import json; print(json.load(open("pipeline.json"))["sink"]["type"])')"`,
|
|
4601
|
+
'case "$SINK_TYPE" in',
|
|
4602
|
+
" restful_sync_json) ;;",
|
|
4603
|
+
" *)",
|
|
4604
|
+
' echo "unsupported sink type: $SINK_TYPE" >&2',
|
|
4605
|
+
' echo "re-run the full ae-data-integration pipeline (or upgrade ae-cli) for this sink." >&2',
|
|
4606
|
+
" exit 64",
|
|
4607
|
+
" ;;",
|
|
4608
|
+
"esac",
|
|
4609
|
+
"",
|
|
4610
|
+
`SINK_PARAMS="$(python3 -c 'import json; print(json.dumps(json.load(open("pipeline.json"))["sink"]["params"]))')"`,
|
|
4611
|
+
`BATCH_SIZE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("batch_size", 500))' "$SINK_PARAMS")"`,
|
|
4612
|
+
`ENV_FILE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("env_file", ".local/target.env"))' "$SINK_PARAMS")"`,
|
|
4613
|
+
`PUSHURL="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("pushurl") or "")' "$SINK_PARAMS")"`,
|
|
4614
|
+
`PROJECT_ID="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("project_id") or "")' "$SINK_PARAMS")"`,
|
|
4615
|
+
"",
|
|
4616
|
+
"CONFIRM=0",
|
|
4617
|
+
'RUN_DIR=""',
|
|
4618
|
+
'for arg in "$@"; do',
|
|
4619
|
+
' case "$arg" in',
|
|
4620
|
+
" --confirm) CONFIRM=1 ;;",
|
|
4621
|
+
' -*) echo "unknown flag: $arg" >&2; exit 2 ;;',
|
|
4622
|
+
' *) RUN_DIR="$arg" ;;',
|
|
4623
|
+
" esac",
|
|
4624
|
+
"done",
|
|
4625
|
+
"",
|
|
4626
|
+
'if [ -z "$RUN_DIR" ]; then',
|
|
4627
|
+
' echo "usage: bin/upload.sh [--confirm] <runs/<run-id>>" >&2',
|
|
4628
|
+
" exit 2",
|
|
4629
|
+
"fi",
|
|
4630
|
+
"",
|
|
4631
|
+
"# .local/target.env is optional: the package may record the target itself.",
|
|
4632
|
+
"# Env values still win as explicit overrides (the documented fallback).",
|
|
4633
|
+
'if [ -f "$ENV_FILE" ]; then set -a; . "$ENV_FILE"; set +a; fi',
|
|
4634
|
+
'[ -z "$PROJECT_ID" ] && PROJECT_ID="${AE_PROJECT_ID:-}"',
|
|
4635
|
+
"",
|
|
4636
|
+
"# Endpoint: the recorded pushurl (a receiver base URL; append /sync_json), else AE_ENDPOINT.",
|
|
4637
|
+
'if [ -n "$PUSHURL" ]; then',
|
|
4638
|
+
' case "$PUSHURL" in',
|
|
4639
|
+
' */sync_json) ENDPOINT="$PUSHURL" ;;',
|
|
4640
|
+
' *) ENDPOINT="${PUSHURL%/}/sync_json" ;;',
|
|
4641
|
+
" esac",
|
|
4642
|
+
"else",
|
|
4643
|
+
' : "${AE_ENDPOINT:?set AE_ENDPOINT in .local/target.env (or record pushurl at handoff)}"',
|
|
4644
|
+
' ENDPOINT="$AE_ENDPOINT"',
|
|
4645
|
+
"fi",
|
|
4646
|
+
"",
|
|
4647
|
+
"# APPID: explicit env wins, else derive from the recorded project via project info get.",
|
|
4648
|
+
'APPID="${AE_APPID:-}"',
|
|
4649
|
+
'if [ -z "$APPID" ] && [ -n "$PROJECT_ID" ]; then',
|
|
4650
|
+
' APPID="$(python3 bin/resolve_appid.py "$PROJECT_ID")"',
|
|
4651
|
+
"fi",
|
|
4652
|
+
': "${APPID:?set AE_APPID in .local/target.env (or record project_id at handoff)}"',
|
|
4653
|
+
"",
|
|
4654
|
+
"# Mask the APPID in anything this script prints \u2014 it must never land in logs.",
|
|
4655
|
+
"mask() {",
|
|
4656
|
+
' local a="$1"',
|
|
4657
|
+
' if [ "${#a}" -le 4 ]; then printf "%s" "****"; return; fi',
|
|
4658
|
+
' printf "%s%s" "$(printf "%*s" "$(( ${#a} - 4 ))" "" | tr " " "*")" "${a: -4}"',
|
|
4659
|
+
"}",
|
|
4660
|
+
"display_args() {",
|
|
4661
|
+
' local args=("$@") out=() i',
|
|
4662
|
+
" for ((i=0; i<${#args[@]}; i++)); do",
|
|
4663
|
+
' if [ "${args[$i]}" = "--appid" ] && [ -n "${args[$((i+1))]:-}" ]; then',
|
|
4664
|
+
' out+=("--appid" "$(mask "${args[$((i+1))]}")")',
|
|
4665
|
+
" i=$((i+1))",
|
|
4666
|
+
" else",
|
|
4667
|
+
' out+=("${args[$i]}")',
|
|
4668
|
+
" fi",
|
|
4669
|
+
" done",
|
|
4670
|
+
' printf "%s\\n" "${out[*]}"',
|
|
4671
|
+
"}",
|
|
4672
|
+
"",
|
|
4673
|
+
'echo "target: project_id=${PROJECT_ID:-<unset>}"',
|
|
4674
|
+
'echo " endpoint=$ENDPOINT"',
|
|
4675
|
+
'echo " appid=$(mask "$APPID")"',
|
|
4676
|
+
'if [ "$CONFIRM" -eq 0 ]; then',
|
|
4677
|
+
' echo "dry-run \u2014 re-run with --confirm to upload to this address and project."',
|
|
4678
|
+
"else",
|
|
4679
|
+
' echo "confirmed: uploading to the address and project shown above."',
|
|
4680
|
+
"fi",
|
|
4681
|
+
"",
|
|
4682
|
+
'FLAGS=(--endpoint "$ENDPOINT" --appid "$APPID" --batch-size "$BATCH_SIZE")',
|
|
4683
|
+
'if [ "$CONFIRM" -eq 0 ]; then FLAGS+=(--dry-run); fi',
|
|
4684
|
+
"",
|
|
4685
|
+
"while IFS= read -r ref; do",
|
|
4686
|
+
' ref_dir="$(dirname "$ref")"',
|
|
4687
|
+
' ue="$RUN_DIR/$ref_dir/valid.ue.jsonl"',
|
|
4688
|
+
' manifest="$RUN_DIR/$ref_dir/manifest.json"',
|
|
4689
|
+
' if [ ! -f "$ue" ]; then',
|
|
4690
|
+
' echo "missing $ue (run bin/run.sh first)" >&2',
|
|
4691
|
+
" exit 2",
|
|
4692
|
+
" fi",
|
|
4693
|
+
` status="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "$manifest")"`,
|
|
4694
|
+
' args=("${FLAGS[@]}" --ue-file "$ue" --manifest "$manifest")',
|
|
4695
|
+
' if [ "$status" = "blocked" ]; then',
|
|
4696
|
+
' echo "manifest $manifest is blocked (rows were quarantined)."',
|
|
4697
|
+
' echo "Uploading passes only the valid subset (--allow-clean-subset); quarantined rows stay in invalid.rows.jsonl."',
|
|
4698
|
+
' if [ "$CONFIRM" -eq 1 ]; then args+=(--allow-clean-subset); else echo " (dry-run) re-run with --confirm to accept the clean subset."; fi',
|
|
4699
|
+
" fi",
|
|
4700
|
+
' echo "> ae-cli data-integration upload $(display_args "${args[@]}")"',
|
|
4701
|
+
' ae-cli data-integration upload "${args[@]}" || exit $?',
|
|
4702
|
+
`done < <(python3 -c 'import json,sys; print("\\n".join(json.load(open("pipeline.json"))["transform"]["refs"]))')`
|
|
4703
|
+
);
|
|
4704
|
+
}
|
|
4705
|
+
function bindMappingPy() {
|
|
4706
|
+
return `#!/usr/bin/env python3
|
|
4707
|
+
"""Source stage: rebind the frozen mappings to a new same-shape file.
|
|
4708
|
+
|
|
4709
|
+
Runs \`ae-cli data-integration inspect\` once, then for every mapping the
|
|
4710
|
+
pipeline references (pipeline.json's transform.refs) re-binds the frozen
|
|
4711
|
+
mapping's \`source.sha256\` and \`source.data_set\` to the new file (identity
|
|
4712
|
+
fields only \u2014 business logic is untouched), after checking the column set
|
|
4713
|
+
against shape.json. Historical index entries the pipeline does not run are
|
|
4714
|
+
left alone \u2014 the index accumulates across handoffs in the same directory.
|
|
4715
|
+
|
|
4716
|
+
Usage: bin/bind_mapping.py <input-file> <run-dir>
|
|
4717
|
+
"""
|
|
4718
|
+
import json
|
|
4719
|
+
import os
|
|
4720
|
+
import subprocess
|
|
4721
|
+
import sys
|
|
4722
|
+
|
|
4723
|
+
|
|
4724
|
+
def fail(message):
|
|
4725
|
+
print(f"bind_mapping: {message}", file=sys.stderr)
|
|
4726
|
+
sys.exit(1)
|
|
4727
|
+
|
|
4728
|
+
|
|
4729
|
+
def pkg_root():
|
|
4730
|
+
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
4731
|
+
|
|
4732
|
+
|
|
4733
|
+
def load_json(path):
|
|
4734
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
4735
|
+
return json.load(f)
|
|
4736
|
+
|
|
4737
|
+
|
|
4738
|
+
def write_json(path, value):
|
|
4739
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
4740
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
4741
|
+
json.dump(value, f, ensure_ascii=False, indent=2)
|
|
4742
|
+
f.write("\\n")
|
|
4743
|
+
|
|
4744
|
+
|
|
4745
|
+
def dataset_key(dataset):
|
|
4746
|
+
return dataset.get("id") or dataset.get("label") or ""
|
|
4747
|
+
|
|
4748
|
+
|
|
4749
|
+
def main():
|
|
4750
|
+
if len(sys.argv) != 3:
|
|
4751
|
+
fail("usage: bind_mapping.py <input-file> <run-dir>")
|
|
4752
|
+
input_file, run_dir = sys.argv[1], sys.argv[2]
|
|
4753
|
+
root = pkg_root()
|
|
4754
|
+
|
|
4755
|
+
pipeline = load_json(os.path.join(root, "pipeline.json"))
|
|
4756
|
+
if pipeline["source"]["type"] != "local_file":
|
|
4757
|
+
fail(f"unsupported source type: {pipeline['source']['type']}")
|
|
4758
|
+
|
|
4759
|
+
index = load_json(os.path.join(root, "index.json"))
|
|
4760
|
+
shape = load_json(os.path.join(root, "shape.json"))
|
|
4761
|
+
shape_by_fp = {entry["fingerprint"]: entry for entry in shape["entries"]}
|
|
4762
|
+
index_by_ref = {entry["mapping_file"]: entry for entry in index["entries"]}
|
|
4763
|
+
|
|
4764
|
+
inspect = run_inspect(input_file)
|
|
4765
|
+
datasets = extract_datasets(inspect)
|
|
4766
|
+
headers_by_dataset = extract_headers(inspect, datasets)
|
|
4767
|
+
sha = (inspect.get("source") or {}).get("sha256")
|
|
4768
|
+
|
|
4769
|
+
# Rebind only the mappings this pipeline runs (transform.refs). The index
|
|
4770
|
+
# accumulates entries across handoffs in the same directory; earlier entries
|
|
4771
|
+
# may have no shape baseline here and are never converted by run.sh, so
|
|
4772
|
+
# walking the whole index would fail on them.
|
|
4773
|
+
for ref in pipeline["transform"]["refs"]:
|
|
4774
|
+
entry = index_by_ref.get(ref)
|
|
4775
|
+
if entry is None:
|
|
4776
|
+
fail(f"index entry missing for {ref}; re-run the full pipeline")
|
|
4777
|
+
fingerprint = entry["fingerprint"]
|
|
4778
|
+
baseline = shape_by_fp.get(fingerprint)
|
|
4779
|
+
if baseline is None:
|
|
4780
|
+
fail(f"shape baseline missing for {fingerprint}; re-run the full pipeline")
|
|
4781
|
+
frozen = load_json(os.path.join(root, ref))
|
|
4782
|
+
data_set_id = match_dataset(frozen, baseline, datasets, headers_by_dataset)
|
|
4783
|
+
validate_headers(data_set_id, baseline, headers_by_dataset)
|
|
4784
|
+
frozen["source"]["sha256"] = sha
|
|
4785
|
+
frozen["source"]["data_set"] = data_set_id
|
|
4786
|
+
out = os.path.join(root, run_dir, "bound", os.path.dirname(ref), "mapping.json")
|
|
4787
|
+
write_json(out, frozen)
|
|
4788
|
+
print(f"rebound {os.path.dirname(ref)} -> data_set {data_set_id!r}")
|
|
4789
|
+
print("shape check passed")
|
|
4790
|
+
|
|
4791
|
+
|
|
4792
|
+
def run_inspect(input_file):
|
|
4793
|
+
proc = subprocess.run(
|
|
4794
|
+
["ae-cli", "data-integration", "inspect", "--input-file", input_file],
|
|
4795
|
+
capture_output=True, text=True,
|
|
4796
|
+
)
|
|
4797
|
+
if proc.returncode != 0:
|
|
4798
|
+
fail(f"inspect failed: {proc.stderr.strip()}")
|
|
4799
|
+
try:
|
|
4800
|
+
parsed = json.loads(proc.stdout)
|
|
4801
|
+
except json.JSONDecodeError:
|
|
4802
|
+
fail("inspect returned non-JSON output")
|
|
4803
|
+
# ae-cli wraps every command result in { ok, data, error }; unwrap it.
|
|
4804
|
+
data = parsed.get("data") if isinstance(parsed, dict) else None
|
|
4805
|
+
if not isinstance(data, dict):
|
|
4806
|
+
fail("inspect returned no data payload")
|
|
4807
|
+
return data
|
|
4808
|
+
|
|
4809
|
+
|
|
4810
|
+
def extract_datasets(inspect):
|
|
4811
|
+
if inspect.get("selection_required"):
|
|
4812
|
+
return inspect.get("data_sets") or []
|
|
4813
|
+
data_set = inspect.get("data_set")
|
|
4814
|
+
return [data_set] if data_set else []
|
|
4815
|
+
|
|
4816
|
+
|
|
4817
|
+
def extract_headers(inspect, datasets):
|
|
4818
|
+
result = {}
|
|
4819
|
+
details = inspect.get("header_details")
|
|
4820
|
+
if details:
|
|
4821
|
+
for dataset in datasets:
|
|
4822
|
+
names = (dataset.get("label"), dataset.get("id"), dataset.get("selector"))
|
|
4823
|
+
for sheet in details:
|
|
4824
|
+
if sheet.get("name") in names:
|
|
4825
|
+
result[dataset_key(dataset)] = sheet.get("headers") or []
|
|
4826
|
+
break
|
|
4827
|
+
return result
|
|
4828
|
+
columns = inspect.get("columns")
|
|
4829
|
+
if columns and datasets:
|
|
4830
|
+
result[dataset_key(datasets[0])] = [column["name"] for column in columns]
|
|
4831
|
+
return result
|
|
4832
|
+
|
|
4833
|
+
|
|
4834
|
+
def match_dataset(frozen, baseline, datasets, headers_by_dataset):
|
|
4835
|
+
if not datasets:
|
|
4836
|
+
fail("inspect reported no data sets")
|
|
4837
|
+
wanted = frozen["source"]["data_set"]
|
|
4838
|
+
for dataset in datasets:
|
|
4839
|
+
if dataset.get("id") == wanted:
|
|
4840
|
+
return dataset.get("id")
|
|
4841
|
+
for dataset in datasets:
|
|
4842
|
+
if dataset.get("label") == wanted:
|
|
4843
|
+
return dataset.get("id")
|
|
4844
|
+
baseline_cols = set(baseline.get("columns") or [])
|
|
4845
|
+
if baseline_cols:
|
|
4846
|
+
for dataset in datasets:
|
|
4847
|
+
headers = headers_by_dataset.get(dataset_key(dataset))
|
|
4848
|
+
if headers and set(headers) == baseline_cols:
|
|
4849
|
+
return dataset.get("id")
|
|
4850
|
+
if len(datasets) == 1:
|
|
4851
|
+
return datasets[0].get("id")
|
|
4852
|
+
fail(f"cannot rebind data_set {wanted!r}: no exact or header match; re-run the full pipeline")
|
|
4853
|
+
|
|
4854
|
+
|
|
4855
|
+
def validate_headers(data_set_id, baseline, headers_by_dataset):
|
|
4856
|
+
baseline_cols = set(baseline.get("columns") or [])
|
|
4857
|
+
if not baseline_cols:
|
|
4858
|
+
return
|
|
4859
|
+
headers = headers_by_dataset.get(data_set_id)
|
|
4860
|
+
if headers is None:
|
|
4861
|
+
return
|
|
4862
|
+
if set(headers) != baseline_cols:
|
|
4863
|
+
missing = sorted(baseline_cols - set(headers))
|
|
4864
|
+
extra = sorted(set(headers) - baseline_cols)
|
|
4865
|
+
fail(
|
|
4866
|
+
f"shape mismatch for {data_set_id!r}: missing={missing} extra={extra} \u2014 "
|
|
4867
|
+
"re-run the full pipeline; do not edit the frozen mapping"
|
|
4868
|
+
)
|
|
4869
|
+
|
|
4870
|
+
|
|
4871
|
+
if __name__ == "__main__":
|
|
4872
|
+
main()
|
|
4873
|
+
`;
|
|
4874
|
+
}
|
|
4875
|
+
function summarizePy() {
|
|
4876
|
+
return `#!/usr/bin/env python3
|
|
4877
|
+
"""Transform stage summary: print valid/quarantined counts per data set."""
|
|
4878
|
+
import json
|
|
4879
|
+
import os
|
|
4880
|
+
import sys
|
|
4881
|
+
|
|
4882
|
+
|
|
4883
|
+
def pkg_root():
|
|
4884
|
+
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
4885
|
+
|
|
4886
|
+
|
|
4887
|
+
def main():
|
|
4888
|
+
if len(sys.argv) != 2:
|
|
4889
|
+
print("usage: summarize.py <run-dir>", file=sys.stderr)
|
|
4890
|
+
sys.exit(2)
|
|
4891
|
+
run_dir = sys.argv[1]
|
|
4892
|
+
root = pkg_root()
|
|
4893
|
+
with open(os.path.join(root, "pipeline.json"), "r", encoding="utf-8") as f:
|
|
4894
|
+
pipeline = json.load(f)
|
|
4895
|
+
total_valid = 0
|
|
4896
|
+
total_invalid = 0
|
|
4897
|
+
for ref in pipeline["transform"]["refs"]:
|
|
4898
|
+
ref_dir = os.path.dirname(ref)
|
|
4899
|
+
manifest_path = os.path.join(root, run_dir, ref_dir, "manifest.json")
|
|
4900
|
+
if not os.path.exists(manifest_path):
|
|
4901
|
+
continue
|
|
4902
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
4903
|
+
manifest = json.load(f)
|
|
4904
|
+
output = manifest["output"]
|
|
4905
|
+
total_valid += output["valid_records"]
|
|
4906
|
+
total_invalid += output["invalid_records"]
|
|
4907
|
+
print(f"{ref_dir}: {output['valid_records']} valid / {output['invalid_records']} quarantined")
|
|
4908
|
+
for reason in manifest.get("blocked_reasons") or []:
|
|
4909
|
+
print(f" - {reason}")
|
|
4910
|
+
print(f"total: {total_valid} valid / {total_invalid} quarantined")
|
|
4911
|
+
|
|
4912
|
+
|
|
4913
|
+
if __name__ == "__main__":
|
|
4914
|
+
main()
|
|
4915
|
+
`;
|
|
4916
|
+
}
|
|
4917
|
+
function planCheckPy() {
|
|
4918
|
+
return `#!/usr/bin/env python3
|
|
4919
|
+
"""Plan gate: every event and property produced must already exist in the frozen tracking plan."""
|
|
4920
|
+
import json
|
|
4921
|
+
import os
|
|
4922
|
+
import sys
|
|
4923
|
+
|
|
4924
|
+
|
|
4925
|
+
def pkg_root():
|
|
4926
|
+
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
4927
|
+
|
|
4928
|
+
|
|
4929
|
+
def load_json(path):
|
|
4930
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
4931
|
+
return json.load(f)
|
|
4932
|
+
|
|
4933
|
+
|
|
4934
|
+
def main():
|
|
4935
|
+
if len(sys.argv) != 2:
|
|
4936
|
+
print("usage: plan_check.py <run-dir>", file=sys.stderr)
|
|
4937
|
+
sys.exit(2)
|
|
4938
|
+
run_dir = sys.argv[1]
|
|
4939
|
+
root = pkg_root()
|
|
4940
|
+
pipeline = load_json(os.path.join(root, "pipeline.json"))
|
|
4941
|
+
new_events = []
|
|
4942
|
+
new_properties = []
|
|
4943
|
+
missing_plans = []
|
|
4944
|
+
for ref in pipeline["transform"]["refs"]:
|
|
4945
|
+
ref_dir = os.path.dirname(ref)
|
|
4946
|
+
plan_path = os.path.join(root, ref_dir, "plan.json")
|
|
4947
|
+
ue_path = os.path.join(root, run_dir, ref_dir, "valid.ue.jsonl")
|
|
4948
|
+
if not os.path.exists(plan_path):
|
|
4949
|
+
missing_plans.append(ref_dir)
|
|
4950
|
+
continue
|
|
4951
|
+
plan = load_json(plan_path)
|
|
4952
|
+
plan_events = {event["event_name"] for event in plan.get("events", [])}
|
|
4953
|
+
plan_properties = {prop["name"] for prop in plan.get("event_properties", [])}
|
|
4954
|
+
plan_properties |= {prop["name"] for prop in plan.get("common_event_properties", [])}
|
|
4955
|
+
plan_properties |= {prop["name"] for prop in plan.get("user_properties", [])}
|
|
4956
|
+
produced_events = set()
|
|
4957
|
+
produced_properties = set()
|
|
4958
|
+
if os.path.exists(ue_path):
|
|
4959
|
+
with open(ue_path, "r", encoding="utf-8") as f:
|
|
4960
|
+
for line in f:
|
|
4961
|
+
line = line.strip()
|
|
4962
|
+
if not line:
|
|
4963
|
+
continue
|
|
4964
|
+
record = json.loads(line)
|
|
4965
|
+
if record.get("#event_name"):
|
|
4966
|
+
produced_events.add(record["#event_name"])
|
|
4967
|
+
props = record.get("properties")
|
|
4968
|
+
if isinstance(props, dict):
|
|
4969
|
+
for key in props:
|
|
4970
|
+
if key.startswith("#"):
|
|
4971
|
+
continue
|
|
4972
|
+
produced_properties.add(key)
|
|
4973
|
+
new_events.extend(sorted(produced_events - plan_events))
|
|
4974
|
+
new_properties.extend(sorted(produced_properties - plan_properties))
|
|
4975
|
+
if missing_plans:
|
|
4976
|
+
print("no plan.json in package for: " + ", ".join(missing_plans), file=sys.stderr)
|
|
4977
|
+
print("run the Tracking plan step first \u2014 the plan gate cannot be skipped", file=sys.stderr)
|
|
4978
|
+
sys.exit(3)
|
|
4979
|
+
if new_events:
|
|
4980
|
+
print("new events not in the plan: " + ", ".join(new_events), file=sys.stderr)
|
|
4981
|
+
sys.exit(3)
|
|
4982
|
+
if new_properties:
|
|
4983
|
+
print("new properties not in the plan: " + ", ".join(new_properties), file=sys.stderr)
|
|
4984
|
+
sys.exit(3)
|
|
4985
|
+
print("plan coverage ok")
|
|
4986
|
+
|
|
4987
|
+
|
|
4988
|
+
if __name__ == "__main__":
|
|
4989
|
+
main()
|
|
4990
|
+
`;
|
|
4991
|
+
}
|
|
4992
|
+
function verifyPy() {
|
|
4993
|
+
return `#!/usr/bin/env python3
|
|
4994
|
+
"""Persistence consistency check: submit-window counts vs the platform summary.
|
|
4995
|
+
|
|
4996
|
+
A soft check, not a hard gate. It computes what this run submitted from the local
|
|
4997
|
+
UE output (knowable), snapshots \`ae-cli tracking ingest summary\` over the submit
|
|
4998
|
+
window before and after upload, and prints both next to the expected counts. It
|
|
4999
|
+
does NOT parse the summary payload into per-event numbers: the capability's data
|
|
5000
|
+
shape is server-defined and not a stable CLI contract, and a shared project cannot
|
|
5001
|
+
attribute the window delta to this import alone. For a hard per-event SQL judge,
|
|
5002
|
+
overlay a project custom layer (see custom-layer.md in the ae-data-integration skill).
|
|
5003
|
+
|
|
5004
|
+
verify.py <run-dir> --baseline snapshot the summary before upload
|
|
5005
|
+
verify.py <run-dir> --check snapshot again and diff against the baseline
|
|
5006
|
+
"""
|
|
5007
|
+
import json
|
|
5008
|
+
import os
|
|
5009
|
+
import subprocess
|
|
5010
|
+
import sys
|
|
5011
|
+
|
|
5012
|
+
|
|
5013
|
+
def pkg_root():
|
|
5014
|
+
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
5015
|
+
|
|
5016
|
+
|
|
5017
|
+
def load_json(path):
|
|
5018
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
5019
|
+
return json.load(f)
|
|
5020
|
+
|
|
5021
|
+
|
|
5022
|
+
def visit_window(record, lo, hi):
|
|
5023
|
+
raw = record.get("#time")
|
|
5024
|
+
if not isinstance(raw, str) or len(raw) < 19:
|
|
5025
|
+
return lo, hi
|
|
5026
|
+
stamp = raw[:19] # YYYY-MM-DD HH:mm:ss
|
|
5027
|
+
if lo is None or stamp < lo:
|
|
5028
|
+
lo = stamp
|
|
5029
|
+
if hi is None or stamp > hi:
|
|
5030
|
+
hi = stamp
|
|
5031
|
+
return lo, hi
|
|
5032
|
+
|
|
5033
|
+
|
|
5034
|
+
def main():
|
|
5035
|
+
if len(sys.argv) < 2:
|
|
5036
|
+
print("usage: verify.py <run-dir> [--baseline|--check]", file=sys.stderr)
|
|
5037
|
+
sys.exit(2)
|
|
5038
|
+
run_dir = sys.argv[1]
|
|
5039
|
+
mode = sys.argv[2] if len(sys.argv) > 2 else "--check"
|
|
5040
|
+
if mode not in ("--baseline", "--check"):
|
|
5041
|
+
print("usage: verify.py <run-dir> [--baseline|--check]", file=sys.stderr)
|
|
5042
|
+
sys.exit(2)
|
|
5043
|
+
root = pkg_root()
|
|
5044
|
+
pipeline = load_json(os.path.join(root, "pipeline.json"))
|
|
5045
|
+
project_id = pipeline["sink"]["params"].get("project_id") or os.environ.get("AE_PROJECT_ID") or "1"
|
|
5046
|
+
|
|
5047
|
+
expected_events = {}
|
|
5048
|
+
expected_users = 0
|
|
5049
|
+
lo = hi = None
|
|
5050
|
+
for ref in pipeline["transform"]["refs"]:
|
|
5051
|
+
ue = os.path.join(root, run_dir, os.path.dirname(ref), "valid.ue.jsonl")
|
|
5052
|
+
if not os.path.exists(ue):
|
|
5053
|
+
continue
|
|
5054
|
+
with open(ue, "r", encoding="utf-8") as f:
|
|
5055
|
+
for line in f:
|
|
5056
|
+
line = line.strip()
|
|
5057
|
+
if not line:
|
|
5058
|
+
continue
|
|
5059
|
+
record = json.loads(line)
|
|
5060
|
+
if record.get("#type") == "track" and record.get("#event_name"):
|
|
5061
|
+
expected_events[record["#event_name"]] = expected_events.get(record["#event_name"], 0) + 1
|
|
5062
|
+
else:
|
|
5063
|
+
expected_users += 1
|
|
5064
|
+
lo, hi = visit_window(record, lo, hi)
|
|
5065
|
+
|
|
5066
|
+
if lo is None:
|
|
5067
|
+
print("verify: no UE records found in " + run_dir, file=sys.stderr)
|
|
5068
|
+
sys.exit(2)
|
|
5069
|
+
|
|
5070
|
+
def run_summary():
|
|
5071
|
+
proc = subprocess.run(
|
|
5072
|
+
["ae-cli", "tracking", "ingest", "summary",
|
|
5073
|
+
"-p", str(project_id), "--start-time", lo, "--end-time", hi],
|
|
5074
|
+
capture_output=True, text=True,
|
|
5075
|
+
)
|
|
5076
|
+
if proc.returncode != 0:
|
|
5077
|
+
return {"error": (proc.stderr or proc.stdout).strip()[:500]}
|
|
5078
|
+
try:
|
|
5079
|
+
return json.loads(proc.stdout)
|
|
5080
|
+
except json.JSONDecodeError:
|
|
5081
|
+
return {"raw": proc.stdout.strip()[:500]}
|
|
5082
|
+
|
|
5083
|
+
print("window " + lo + " .. " + hi + " project_id=" + str(project_id))
|
|
5084
|
+
print("expected (this run):")
|
|
5085
|
+
for event, count in sorted(expected_events.items()):
|
|
5086
|
+
print(" {:<20}{:>10,}".format(event, count))
|
|
5087
|
+
print(" {:<20}{:>10,}".format("<user rows>", expected_users))
|
|
5088
|
+
print()
|
|
5089
|
+
|
|
5090
|
+
baseline_path = os.path.join(root, run_dir, "baseline.json")
|
|
5091
|
+
if mode == "--baseline":
|
|
5092
|
+
payload = run_summary()
|
|
5093
|
+
with open(baseline_path, "w", encoding="utf-8") as f:
|
|
5094
|
+
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
5095
|
+
print("baseline recorded: " + baseline_path)
|
|
5096
|
+
print("platform summary (before):")
|
|
5097
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
5098
|
+
sys.exit(0)
|
|
5099
|
+
|
|
5100
|
+
if not os.path.exists(baseline_path):
|
|
5101
|
+
print("no baseline.json \u2014 run \`verify.py <run-dir> --baseline\` before upload.", file=sys.stderr)
|
|
5102
|
+
print("falling back to a single after-upload summary:", file=sys.stderr)
|
|
5103
|
+
print(json.dumps(run_summary(), ensure_ascii=False, indent=2))
|
|
5104
|
+
sys.exit(1)
|
|
5105
|
+
|
|
5106
|
+
with open(baseline_path, "r", encoding="utf-8") as f:
|
|
5107
|
+
baseline = json.load(f)
|
|
5108
|
+
after = run_summary()
|
|
5109
|
+
print("platform summary before vs after:")
|
|
5110
|
+
print(json.dumps(baseline, ensure_ascii=False, indent=2))
|
|
5111
|
+
print("---")
|
|
5112
|
+
print(json.dumps(after, ensure_ascii=False, indent=2))
|
|
5113
|
+
print()
|
|
5114
|
+
print("summary changed: " + ("yes" if baseline != after else "no"))
|
|
5115
|
+
print()
|
|
5116
|
+
print("Boundary: the summary payload is server-defined and may under-report even")
|
|
5117
|
+
print("landed data; this check surfaces it for comparison, it does not auto-verify")
|
|
5118
|
+
print("per-event counts, and a shared project's window delta is not attributed to")
|
|
5119
|
+
print("this import. Cross-check with:")
|
|
5120
|
+
print(" ae-cli tracking live-data list -p " + str(project_id))
|
|
5121
|
+
print("For a hard SQL judge, add a project custom layer (custom-layer.md).")
|
|
5122
|
+
sys.exit(0)
|
|
5123
|
+
|
|
5124
|
+
|
|
5125
|
+
if __name__ == "__main__":
|
|
5126
|
+
main()
|
|
5127
|
+
`;
|
|
5128
|
+
}
|
|
5129
|
+
function resolveAppidPy() {
|
|
5130
|
+
return `#!/usr/bin/env python3
|
|
5131
|
+
"""Derive the destination APPID from \`ae-cli project info get --project-id <id>\`.
|
|
5132
|
+
|
|
5133
|
+
\`project info get\` returns \`data.appid\` at the top level (verified against the
|
|
5134
|
+
AE demo host), so this helper reads that exact field and prints it to stdout. It
|
|
5135
|
+
prints nothing to stdout and reports the payload when the field is absent or not
|
|
5136
|
+
a non-empty string \u2014 the caller then falls back to AE_APPID.
|
|
5137
|
+
|
|
5138
|
+
Usage: bin/resolve_appid.py <project-id>
|
|
5139
|
+
"""
|
|
5140
|
+
import json
|
|
5141
|
+
import subprocess
|
|
5142
|
+
import sys
|
|
5143
|
+
|
|
5144
|
+
|
|
5145
|
+
def main():
|
|
5146
|
+
if len(sys.argv) != 2:
|
|
5147
|
+
print("usage: resolve_appid.py <project-id>", file=sys.stderr)
|
|
5148
|
+
sys.exit(2)
|
|
5149
|
+
project_id = sys.argv[1]
|
|
5150
|
+
proc = subprocess.run(
|
|
5151
|
+
["ae-cli", "project", "info", "get", "--project-id", project_id],
|
|
5152
|
+
capture_output=True, text=True,
|
|
5153
|
+
)
|
|
5154
|
+
if proc.returncode != 0:
|
|
5155
|
+
print("resolve_appid: project info get failed: " + (proc.stderr or proc.stdout).strip()[:300], file=sys.stderr)
|
|
5156
|
+
sys.exit(0)
|
|
5157
|
+
try:
|
|
5158
|
+
parsed = json.loads(proc.stdout)
|
|
5159
|
+
except json.JSONDecodeError:
|
|
5160
|
+
print("resolve_appid: project info get returned non-JSON output", file=sys.stderr)
|
|
5161
|
+
sys.exit(0)
|
|
5162
|
+
data = parsed.get("data") if isinstance(parsed, dict) else None
|
|
5163
|
+
appid = data.get("appid") if isinstance(data, dict) else None
|
|
5164
|
+
if not isinstance(appid, str) or not appid:
|
|
5165
|
+
print("resolve_appid: project info get returned no appid; set AE_APPID", file=sys.stderr)
|
|
5166
|
+
print(json.dumps(data, ensure_ascii=False, indent=2) if data is not None else "{}", file=sys.stderr)
|
|
5167
|
+
sys.exit(0)
|
|
5168
|
+
masked = appid if len(appid) <= 4 else ("*" * (len(appid) - 4)) + appid[-4:]
|
|
5169
|
+
print("resolve_appid: resolved APPID data.appid = " + masked, file=sys.stderr)
|
|
5170
|
+
print(appid)
|
|
5171
|
+
|
|
5172
|
+
|
|
5173
|
+
if __name__ == "__main__":
|
|
5174
|
+
main()
|
|
5175
|
+
`;
|
|
5176
|
+
}
|
|
5177
|
+
function generateReadme() {
|
|
5178
|
+
return `# AE Data Integration \u2014 handoff package
|
|
5179
|
+
|
|
5180
|
+
A frozen, reusable pipeline for importing a **same-shape** local file into AE,
|
|
5181
|
+
generated by \`ae-cli data-integration handoff\`. Source and Transform are frozen
|
|
5182
|
+
(the confirmed business logic); only the Tracking-plan and Sink gates still
|
|
5183
|
+
require human confirmation.
|
|
5184
|
+
|
|
5185
|
+
## Quick start
|
|
5186
|
+
|
|
5187
|
+
\`\`\`bash
|
|
5188
|
+
cp <today's file> inbox/
|
|
5189
|
+
bin/run.sh # shape check -> convert -> plan check (no upload)
|
|
5190
|
+
bin/upload.sh runs/<latest> # dry-run
|
|
5191
|
+
bin/upload.sh runs/<latest> --confirm
|
|
5192
|
+
\`\`\`
|
|
5193
|
+
|
|
5194
|
+
Read [RUNBOOK.md](RUNBOOK.md) for the full flow, the four confirmation gates,
|
|
5195
|
+
and how to verify persistence.
|
|
5196
|
+
|
|
5197
|
+
## Handing off to an agent
|
|
5198
|
+
|
|
5199
|
+
Import \`inbox/<today's file>\` through the ae-data-integration skill using this
|
|
5200
|
+
package. Tell it to follow RUNBOOK.md and stop for confirmation before uploading.
|
|
5201
|
+
|
|
5202
|
+
## Package layout
|
|
5203
|
+
|
|
5204
|
+
| Path | Purpose |
|
|
5205
|
+
| --- | --- |
|
|
5206
|
+
| \`pipeline.json\` | Declarative source -> transform -> sink descriptor |
|
|
5207
|
+
| \`index.json\` | Structure-fingerprint index (reuse matching) |
|
|
5208
|
+
| \`shape.json\` | Column baseline used by the shape gate |
|
|
5209
|
+
| \`<fingerprint16>/\` | Frozen mapping + tracking plan + transform wrapper |
|
|
5210
|
+
| \`bin/run.sh\` | Source + Transform + Plan executor (never uploads) |
|
|
5211
|
+
| \`bin/upload.sh\` | Sink executor (dry-run by default; resolves the recorded target) |
|
|
5212
|
+
| \`bin/verify.py\` | Soft persistence check (submit window vs ingest summary) |
|
|
5213
|
+
| \`bin/resolve_appid.py\` | APPID derivation helper (project info get) |
|
|
5214
|
+
| \`.local/target.env\` | Upload target overrides (APPID / endpoint); only the template ships |
|
|
5215
|
+
| \`inbox/\` \`runs/\` | Daily input / per-run outputs |
|
|
5216
|
+
|
|
5217
|
+
## Safety
|
|
5218
|
+
|
|
5219
|
+
The package records at most a destination \`pushurl\` and \`project_id\` (no APPID,
|
|
5220
|
+
tokens, or raw data values). \`bin/upload.sh\` always requires \`--confirm\` before
|
|
5221
|
+
sending, so the operator re-confirms the address and project on every reuse. Copy
|
|
5222
|
+
\`.local/target.env.example\` to \`.local/target.env\` for explicit overrides and
|
|
5223
|
+
never commit it.
|
|
5224
|
+
`;
|
|
5225
|
+
}
|
|
5226
|
+
function generateRunbook() {
|
|
5227
|
+
return `# RUNBOOK \u2014 same-shape file import
|
|
5228
|
+
|
|
5229
|
+
Run this when a file of the **same shape** arrives again (same sheets and
|
|
5230
|
+
headers as \`shape.json\`). If the headers changed, stop: re-run the full
|
|
5231
|
+
ae-data-integration pipeline instead of editing the frozen mapping.
|
|
5232
|
+
|
|
5233
|
+
## Gates
|
|
5234
|
+
|
|
5235
|
+
1. **Shape gate** \u2014 \`bin/run.sh\` rebinds the frozen mappings to the new file
|
|
5236
|
+
and compares the column set against \`shape.json\`. A mismatch fails fast on
|
|
5237
|
+
purpose: a different shape means the frozen business logic was never reviewed
|
|
5238
|
+
for it.
|
|
5239
|
+
2. **Transform** \u2014 each frozen mapping runs through
|
|
5240
|
+
\`ae-cli data-integration convert\`. Quarantined rows land in
|
|
5241
|
+
\`invalid.rows.jsonl\`; they are never silently dropped.
|
|
5242
|
+
3. **Tracking-plan gate** \u2014 \`bin/run.sh\` verifies every produced event and
|
|
5243
|
+
property already exists in the frozen \`plan.json\`. New events or properties
|
|
5244
|
+
make it exit with code 3: merge them into the project tracking plan first,
|
|
5245
|
+
then return here to upload.
|
|
5246
|
+
4. **Sink gate** \u2014 \`bin/upload.sh\` is dry-run by default. Read
|
|
5247
|
+
\`record_count\`, \`batch_count\`, and \`manifest_status\` before adding
|
|
5248
|
+
\`--confirm\`. A \`blocked\` manifest means rows were quarantined;
|
|
5249
|
+
\`--confirm\` then uploads only the valid subset (\`--allow-clean-subset\`).
|
|
5250
|
+
|
|
5251
|
+
## Destination
|
|
5252
|
+
|
|
5253
|
+
The package records the destination it was handed off for when \`handoff\` was run
|
|
5254
|
+
with \`--pushurl\` / \`--project-id\` (see \`pipeline.json\` \u2192 \`sink.params\`). Reuse
|
|
5255
|
+
defaults to that target, but \`bin/upload.sh\` never sends without \`--confirm\`, so
|
|
5256
|
+
the operator re-confirms the address and project every time.
|
|
5257
|
+
|
|
5258
|
+
Resolution order at upload time:
|
|
5259
|
+
|
|
5260
|
+
- endpoint: recorded \`pushurl\` (+ \`/sync_json\`), else \`AE_ENDPOINT\`.
|
|
5261
|
+
- APPID: \`AE_APPID\`, else derived via \`ae-cli project info get --project-id <id>\`
|
|
5262
|
+
(see \`bin/resolve_appid.py\`; it reads the \`data.appid\` field \u2014 set \`AE_APPID\`
|
|
5263
|
+
when that field is absent).
|
|
5264
|
+
- project id: recorded \`project_id\`, else \`AE_PROJECT_ID\`.
|
|
5265
|
+
|
|
5266
|
+
\`.local/target.env\` remains the explicit override for all three:
|
|
5267
|
+
\`AE_ENDPOINT\` (full receiver URL ending in \`/sync_json\`), \`AE_APPID\`, \`AE_PROJECT_ID\`.
|
|
5268
|
+
|
|
5269
|
+
## Verify persistence
|
|
5270
|
+
|
|
5271
|
+
\`receiver_accepted\` is not persistence. About a minute after upload, confirm
|
|
5272
|
+
the data landed with ae-cli. The package ships a soft check that automates the
|
|
5273
|
+
before/after comparison:
|
|
5274
|
+
|
|
5275
|
+
\`\`\`bash
|
|
5276
|
+
bin/verify.py runs/<run-id> --baseline # before upload
|
|
5277
|
+
bin/upload.sh runs/<run-id> --confirm
|
|
5278
|
+
bin/verify.py runs/<run-id> --check # after upload
|
|
5279
|
+
\`\`\`
|
|
5280
|
+
|
|
5281
|
+
\`bin/verify.py\` prints the submit window and expected counts, then shows the
|
|
5282
|
+
\`tracking ingest summary\` payload before and after for comparison. It does not
|
|
5283
|
+
auto-verify per-event counts \u2014 the summary shape is server-defined, and a shared
|
|
5284
|
+
project's window delta is not attributed to this import. Cross-check with:
|
|
5285
|
+
|
|
5286
|
+
\`\`\`bash
|
|
5287
|
+
ae-cli tracking live-data list -p <AE_PROJECT_ID>
|
|
5288
|
+
ae-cli tracking ingest-error list -p <AE_PROJECT_ID> --data-name <name>
|
|
5289
|
+
\`\`\`
|
|
5290
|
+
|
|
5291
|
+
For a hard per-event SQL judge, overlay a project custom layer instead of editing
|
|
5292
|
+
this package (see \`custom-layer.md\` in the ae-data-integration skill).
|
|
5293
|
+
|
|
5294
|
+
## Interrupted uploads
|
|
5295
|
+
|
|
5296
|
+
If a batch times out or loses the network, that batch's state is unknown. Stop:
|
|
5297
|
+
verify what actually landed, then follow the ae-data-integration skill to resume
|
|
5298
|
+
from the verified offset. Never re-run the whole upload blindly.
|
|
5299
|
+
|
|
5300
|
+
## Files
|
|
5301
|
+
|
|
5302
|
+
See \`README.md\` for the package layout.
|
|
5303
|
+
`;
|
|
5304
|
+
}
|
|
5305
|
+
function generateEnvTemplate() {
|
|
5306
|
+
return [
|
|
5307
|
+
"# Upload target overrides. Copy this file to .local/target.env and fill only",
|
|
5308
|
+
"# what the package does not already record (pipeline.json sink.params).",
|
|
5309
|
+
"# AE_ENDPOINT: a full receiver URL ending in /sync_json (used when no pushurl is recorded).",
|
|
5310
|
+
"# AE_APPID: the destination project APPID (overrides the project info get derivation).",
|
|
5311
|
+
"# AE_PROJECT_ID: the destination project ID, used when no project_id is recorded.",
|
|
5312
|
+
"AE_ENDPOINT=",
|
|
5313
|
+
"AE_APPID=",
|
|
5314
|
+
"AE_PROJECT_ID=",
|
|
5315
|
+
""
|
|
5316
|
+
].join("\n");
|
|
5317
|
+
}
|
|
5318
|
+
function generateGitignore() {
|
|
5319
|
+
return ["inbox/", "runs/", ".local/target.env", ""].join("\n");
|
|
5320
|
+
}
|
|
5321
|
+
|
|
5322
|
+
// src/commands/data-integration/handoff.ts
|
|
3243
5323
|
var HANDOFF_INDEX_VERSION = "ae-data-integration-index/v1";
|
|
3244
5324
|
var HANDOFF_DIR_LEN = 16;
|
|
3245
5325
|
function structureFingerprint(mapping) {
|
|
3246
5326
|
const canonical = {
|
|
3247
5327
|
mode: mapping.mode,
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
distinct_id_field: mapping.distinct_id_field ?? null,
|
|
3251
|
-
record_type_field: mapping.record_type_field ?? null,
|
|
3252
|
-
event_name_field: mapping.event_name_field ?? null,
|
|
3253
|
-
columns: mapping.properties.map((property) => ({ source: property.source, type: property.type })).sort((left, right) => left.source.localeCompare(right.source)),
|
|
3254
|
-
excluded: [...mapping.exclude_columns ?? []].sort()
|
|
5328
|
+
format: mapping.source.format,
|
|
5329
|
+
columns: sourceColumns(mapping)
|
|
3255
5330
|
};
|
|
3256
5331
|
return createHash3("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
3257
5332
|
}
|
|
@@ -3262,17 +5337,17 @@ function upsertIndexEntry(index, entry) {
|
|
|
3262
5337
|
function buildHandoffPackage(outDir, mapping, planFile) {
|
|
3263
5338
|
const fingerprint = structureFingerprint(mapping);
|
|
3264
5339
|
const dirName = fingerprint.slice(0, HANDOFF_DIR_LEN);
|
|
3265
|
-
const handoffDir =
|
|
3266
|
-
const indexPath =
|
|
5340
|
+
const handoffDir = join3(outDir, dirName);
|
|
5341
|
+
const indexPath = join3(outDir, "index.json");
|
|
3267
5342
|
const index = readHandoffIndex(indexPath);
|
|
3268
5343
|
const reusedExisting = index.entries.some((item) => item.fingerprint === fingerprint);
|
|
3269
5344
|
mkdirSync2(handoffDir, { recursive: true, mode: 448 });
|
|
3270
5345
|
chmodSync2(handoffDir, 448);
|
|
3271
|
-
writeSecureJson2(
|
|
3272
|
-
writeSecureText2(
|
|
5346
|
+
writeSecureJson2(join3(handoffDir, "mapping.json"), mapping);
|
|
5347
|
+
writeSecureText2(join3(handoffDir, "transform.mjs"), createHandoffScript());
|
|
3273
5348
|
let planFileRel;
|
|
3274
5349
|
if (planFile) {
|
|
3275
|
-
writeSecureJson2(
|
|
5350
|
+
writeSecureJson2(join3(handoffDir, "plan.json"), readPlanFile(planFile));
|
|
3276
5351
|
planFileRel = `${dirName}/plan.json`;
|
|
3277
5352
|
}
|
|
3278
5353
|
const entry = {
|
|
@@ -3287,7 +5362,7 @@ function buildHandoffPackage(outDir, mapping, planFile) {
|
|
|
3287
5362
|
...planFileRel ? { plan_file: planFileRel } : {}
|
|
3288
5363
|
};
|
|
3289
5364
|
writeAtomicJson(indexPath, upsertIndexEntry(index, entry));
|
|
3290
|
-
return { outDir, handoffDir, dirName, fingerprint, reusedExisting, planFile: planFileRel };
|
|
5365
|
+
return { outDir, handoffDir, dirName, fingerprint, reusedExisting, planFile: planFileRel, entry };
|
|
3291
5366
|
}
|
|
3292
5367
|
function readHandoffIndex(path) {
|
|
3293
5368
|
let raw;
|
|
@@ -3382,56 +5457,199 @@ var dataIntegrationHandoff = {
|
|
|
3382
5457
|
service: "data-integration",
|
|
3383
5458
|
command: "handoff",
|
|
3384
5459
|
usesAeHost: false,
|
|
3385
|
-
description: "Export a reusable handoff package (frozen
|
|
5460
|
+
description: "Export a reusable handoff package (pipeline descriptor + frozen mappings + stage executors + docs) and a shareable zip.",
|
|
3386
5461
|
flags: [
|
|
3387
|
-
{ name: "mapping", type: "string", required: true, sensitive: true, desc:
|
|
5462
|
+
{ name: "mapping", type: "string", required: true, variadic: true, sensitive: true, desc: `Confirmed ${MAPPING_VERSION} JSON, file path, or @file. Repeat for multiple data sets (one sheet each).` },
|
|
3388
5463
|
{ name: "plan-file", type: "string", sensitive: true, desc: "Tracking-plan draft.json to reference inside the handoff package." },
|
|
3389
|
-
{ name: "out-dir", type: "string", sensitive: true, desc: "Handoff root directory. Default: .ae-data-integration." }
|
|
5464
|
+
{ name: "out-dir", type: "string", sensitive: true, desc: "Handoff root directory. Default: .ae-cli/data-integration (project workspace)." },
|
|
5465
|
+
{ name: "pushurl", type: "string", sensitive: true, desc: "Receiver base URL to record as the reuse upload target (sink endpoint = pushurl + /sync_json). Reuse still requires --confirm." },
|
|
5466
|
+
{ name: "project-id", type: "string", sensitive: true, desc: "Numeric destination project ID to record; upload derives the APPID from it via project info get." }
|
|
3390
5467
|
],
|
|
3391
5468
|
risk: "write",
|
|
3392
5469
|
dryRun: async (ctx) => {
|
|
3393
|
-
const
|
|
3394
|
-
const outDir = resolve3(ctx.str("out-dir").trim() || ".ae-data-integration");
|
|
5470
|
+
const mappings = ctx.list("mapping").map((source) => readLocalDataMapping(source));
|
|
5471
|
+
const outDir = resolve3(ctx.str("out-dir").trim() || join3(".ae-cli", "data-integration"));
|
|
3395
5472
|
const planFile = ctx.str("plan-file").trim() || void 0;
|
|
5473
|
+
const pushurl = ctx.str("pushurl").trim() || void 0;
|
|
5474
|
+
const projectId = ctx.str("project-id").trim() || void 0;
|
|
5475
|
+
const fingerprints = mappings.map(structureFingerprint);
|
|
3396
5476
|
return {
|
|
3397
5477
|
action: "handoff_local_data",
|
|
3398
5478
|
out_dir: outDir,
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
5479
|
+
mapping_count: mappings.length,
|
|
5480
|
+
fingerprints,
|
|
5481
|
+
target: { pushurl, project_id: projectId },
|
|
5482
|
+
files: handoffFileList(fingerprints, planFile),
|
|
5483
|
+
index_file: join3(outDir, "index.json"),
|
|
5484
|
+
pipeline_file: join3(outDir, "pipeline.json"),
|
|
5485
|
+
shape_file: join3(outDir, "shape.json"),
|
|
5486
|
+
zip_path: zipPathFor(outDir, fingerprints[0])
|
|
3405
5487
|
};
|
|
3406
5488
|
},
|
|
3407
5489
|
execute: async (ctx) => {
|
|
3408
|
-
const
|
|
3409
|
-
const outDir = resolve3(ctx.str("out-dir").trim() || ".ae-data-integration");
|
|
5490
|
+
const mappings = ctx.list("mapping").map((source) => readLocalDataMapping(source));
|
|
5491
|
+
const outDir = resolve3(ctx.str("out-dir").trim() || join3(".ae-cli", "data-integration"));
|
|
3410
5492
|
const planFile = ctx.str("plan-file").trim() || void 0;
|
|
3411
|
-
const
|
|
5493
|
+
const target = {
|
|
5494
|
+
pushurl: ctx.str("pushurl").trim() || void 0,
|
|
5495
|
+
project_id: ctx.str("project-id").trim() || void 0
|
|
5496
|
+
};
|
|
5497
|
+
const items = mappings.map((mapping) => ({
|
|
5498
|
+
mapping,
|
|
5499
|
+
build: buildHandoffPackage(outDir, mapping, planFile)
|
|
5500
|
+
}));
|
|
5501
|
+
const entries = items.map((item) => item.build.entry);
|
|
5502
|
+
const relayFiles = [
|
|
5503
|
+
{ relPath: "pipeline.json", content: `${JSON.stringify(buildPipelineDescriptor(entries, target), null, 2)}
|
|
5504
|
+
`, mode: 384 },
|
|
5505
|
+
{ relPath: "shape.json", content: `${JSON.stringify(buildShapeBaseline(items.map((item) => ({ mapping: item.mapping, fingerprint: item.build.fingerprint }))), null, 2)}
|
|
5506
|
+
`, mode: 384 },
|
|
5507
|
+
...generateBinScripts(),
|
|
5508
|
+
{ relPath: "README.md", content: generateReadme(), mode: 384 },
|
|
5509
|
+
{ relPath: "RUNBOOK.md", content: generateRunbook(), mode: 384 },
|
|
5510
|
+
{ relPath: ".local/target.env.example", content: generateEnvTemplate(), mode: 384 },
|
|
5511
|
+
{ relPath: ".gitignore", content: generateGitignore(), mode: 384 },
|
|
5512
|
+
{ relPath: "inbox/.gitkeep", content: "", mode: 384 },
|
|
5513
|
+
{ relPath: "runs/.gitkeep", content: "", mode: 384 }
|
|
5514
|
+
];
|
|
5515
|
+
for (const file of relayFiles) writeRelayFile(outDir, file);
|
|
5516
|
+
const zipPath = zipPathFor(outDir, entries[0].fingerprint);
|
|
5517
|
+
const stagingDir = stageScopedPackage(outDir, items.map((item) => item.build), entries, relayFiles);
|
|
5518
|
+
try {
|
|
5519
|
+
await zipPackage(stagingDir, zipPath);
|
|
5520
|
+
chmodSync2(zipPath, 384);
|
|
5521
|
+
} finally {
|
|
5522
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
5523
|
+
}
|
|
3412
5524
|
return {
|
|
3413
|
-
out_dir:
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
5525
|
+
out_dir: outDir,
|
|
5526
|
+
zip_path: zipPath,
|
|
5527
|
+
pipeline_file: join3(outDir, "pipeline.json"),
|
|
5528
|
+
shape_file: join3(outDir, "shape.json"),
|
|
5529
|
+
index_file: join3(outDir, "index.json"),
|
|
5530
|
+
handoff_dirs: items.map((item) => item.build.dirName),
|
|
5531
|
+
deliverables: buildDeliverables(outDir, relayFiles, items),
|
|
5532
|
+
next_steps: [
|
|
5533
|
+
`Review the pipeline descriptor and four confirmation gates: ${join3(outDir, "RUNBOOK.md")}.`,
|
|
5534
|
+
`Share the archive: ${zipPath}.`,
|
|
5535
|
+
`Next same-shape file: cd ${outDir} && bin/run.sh <new-file>, then bin/upload.sh runs/<run-id> --confirm.`
|
|
5536
|
+
],
|
|
5537
|
+
reused_existing: items.some((item) => item.build.reusedExisting)
|
|
3421
5538
|
};
|
|
3422
5539
|
}
|
|
3423
5540
|
};
|
|
5541
|
+
function zipPathFor(outDir, fingerprint) {
|
|
5542
|
+
if (!fingerprint) return void 0;
|
|
5543
|
+
return join3(dirname2(resolve3(outDir)), `ae-data-integration-handoff-${fingerprint.slice(0, 8)}.zip`);
|
|
5544
|
+
}
|
|
5545
|
+
var RELAY_FILE_PATHS = [
|
|
5546
|
+
"pipeline.json",
|
|
5547
|
+
"shape.json",
|
|
5548
|
+
"index.json",
|
|
5549
|
+
"README.md",
|
|
5550
|
+
"RUNBOOK.md",
|
|
5551
|
+
".local/target.env.example",
|
|
5552
|
+
".gitignore",
|
|
5553
|
+
"bin/run.sh",
|
|
5554
|
+
"bin/upload.sh",
|
|
5555
|
+
"bin/bind_mapping.py",
|
|
5556
|
+
"bin/summarize.py",
|
|
5557
|
+
"bin/plan_check.py",
|
|
5558
|
+
"bin/verify.py",
|
|
5559
|
+
"bin/resolve_appid.py",
|
|
5560
|
+
"inbox/.gitkeep",
|
|
5561
|
+
"runs/.gitkeep"
|
|
5562
|
+
];
|
|
5563
|
+
function mappingDirFiles(dirName, planFile) {
|
|
5564
|
+
return [
|
|
5565
|
+
`${dirName}/mapping.json`,
|
|
5566
|
+
`${dirName}/transform.mjs`,
|
|
5567
|
+
...planFile ? [`${dirName}/plan.json`] : []
|
|
5568
|
+
];
|
|
5569
|
+
}
|
|
5570
|
+
function handoffFileList(fingerprints, planFile) {
|
|
5571
|
+
const perMapping = fingerprints.flatMap(
|
|
5572
|
+
(fingerprint) => mappingDirFiles(fingerprint.slice(0, HANDOFF_DIR_LEN), planFile)
|
|
5573
|
+
);
|
|
5574
|
+
return [...RELAY_FILE_PATHS, ...perMapping];
|
|
5575
|
+
}
|
|
5576
|
+
function buildDeliverables(outDir, relayFiles, items) {
|
|
5577
|
+
const relay = [
|
|
5578
|
+
{ rel_path: "index.json", abs_path: join3(outDir, "index.json") },
|
|
5579
|
+
...relayFiles.map((file) => ({ rel_path: file.relPath, abs_path: join3(outDir, file.relPath) }))
|
|
5580
|
+
];
|
|
5581
|
+
const mappings = items.flatMap(
|
|
5582
|
+
(item) => mappingDirFiles(item.build.dirName, item.build.planFile).map((rel) => ({
|
|
5583
|
+
rel_path: rel,
|
|
5584
|
+
abs_path: join3(outDir, rel)
|
|
5585
|
+
}))
|
|
5586
|
+
);
|
|
5587
|
+
return [...relay, ...mappings];
|
|
5588
|
+
}
|
|
5589
|
+
function writeRelayFile(outDir, file) {
|
|
5590
|
+
const abs = join3(outDir, file.relPath);
|
|
5591
|
+
mkdirSync2(dirname2(abs), { recursive: true, mode: 448 });
|
|
5592
|
+
writeFileSync2(abs, file.content, { encoding: "utf8", mode: file.mode });
|
|
5593
|
+
chmodSync2(abs, file.mode);
|
|
5594
|
+
}
|
|
5595
|
+
function stageScopedPackage(outDir, mappingDirs, entries, relayFiles) {
|
|
5596
|
+
const staging = mkdtempSync(join3(tmpdir(), "ae-handoff-"));
|
|
5597
|
+
writeSecureJson2(join3(staging, "index.json"), { version: HANDOFF_INDEX_VERSION, entries });
|
|
5598
|
+
for (const file of relayFiles) writeRelayFile(staging, file);
|
|
5599
|
+
for (const { dirName, planFile } of mappingDirs) {
|
|
5600
|
+
for (const rel of mappingDirFiles(dirName, planFile)) {
|
|
5601
|
+
const dest = join3(staging, rel);
|
|
5602
|
+
mkdirSync2(dirname2(dest), { recursive: true, mode: 448 });
|
|
5603
|
+
writeFileSync2(dest, readFileSync4(join3(outDir, rel), "utf8"), { encoding: "utf8", mode: 384 });
|
|
5604
|
+
chmodSync2(dest, 384);
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
return staging;
|
|
5608
|
+
}
|
|
3424
5609
|
|
|
3425
|
-
// src/commands/data-integration/
|
|
5610
|
+
// src/commands/data-integration/reuse.ts
|
|
3426
5611
|
import { readFileSync as readFileSync5 } from "fs";
|
|
3427
|
-
import { dirname as
|
|
5612
|
+
import { dirname as dirname4, join as join5, resolve as resolve5 } from "path";
|
|
5613
|
+
|
|
5614
|
+
// src/commands/data-integration/handoff-root.ts
|
|
5615
|
+
import { existsSync as existsSync2 } from "fs";
|
|
5616
|
+
import { dirname as dirname3, join as join4, resolve as resolve4 } from "path";
|
|
5617
|
+
function globalHandoffDir() {
|
|
5618
|
+
const home = process.env.HOME;
|
|
5619
|
+
if (!home) return void 0;
|
|
5620
|
+
return join4(getConfigDir(), "data-integration");
|
|
5621
|
+
}
|
|
5622
|
+
function upwardHandoffDirs(startDir) {
|
|
5623
|
+
const dirs = [];
|
|
5624
|
+
let current = resolve4(startDir);
|
|
5625
|
+
for (; ; ) {
|
|
5626
|
+
const candidate = join4(current, ".ae-cli", "data-integration");
|
|
5627
|
+
if (!dirs.includes(candidate)) dirs.push(candidate);
|
|
5628
|
+
const parent = dirname3(current);
|
|
5629
|
+
if (parent === current) break;
|
|
5630
|
+
current = parent;
|
|
5631
|
+
}
|
|
5632
|
+
return dirs;
|
|
5633
|
+
}
|
|
5634
|
+
function reuseSearchPaths(startDir = process.cwd()) {
|
|
5635
|
+
const globalDir = globalHandoffDir();
|
|
5636
|
+
return globalDir ? [...upwardHandoffDirs(startDir), globalDir] : upwardHandoffDirs(startDir);
|
|
5637
|
+
}
|
|
5638
|
+
function findReuseRoot(startDir = process.cwd()) {
|
|
5639
|
+
for (const dir of upwardHandoffDirs(startDir)) {
|
|
5640
|
+
if (existsSync2(join4(dir, "index.json"))) return dir;
|
|
5641
|
+
}
|
|
5642
|
+
return globalHandoffDir();
|
|
5643
|
+
}
|
|
5644
|
+
|
|
5645
|
+
// src/commands/data-integration/reuse.ts
|
|
3428
5646
|
function detectReuse(mapping, outDir) {
|
|
3429
5647
|
const fingerprint = structureFingerprint(mapping);
|
|
3430
|
-
const indexPath =
|
|
5648
|
+
const indexPath = join5(outDir, "index.json");
|
|
3431
5649
|
const index = readHandoffIndex(indexPath);
|
|
3432
5650
|
const entry = index.entries.find((item) => item.fingerprint === fingerprint);
|
|
3433
5651
|
if (!entry) return { matched: false, fingerprint, index_file: indexPath };
|
|
3434
|
-
const mappingPath =
|
|
5652
|
+
const mappingPath = join5(outDir, entry.mapping_file);
|
|
3435
5653
|
const match = {
|
|
3436
5654
|
fingerprint: entry.fingerprint,
|
|
3437
5655
|
created_at: entry.created_at,
|
|
@@ -3442,7 +5660,7 @@ function detectReuse(mapping, outDir) {
|
|
|
3442
5660
|
mapping_file: entry.mapping_file,
|
|
3443
5661
|
...entry.plan_file ? { plan_file: entry.plan_file } : {},
|
|
3444
5662
|
...readFrozenEventName(mappingPath),
|
|
3445
|
-
run: `node ${
|
|
5663
|
+
run: `node ${join5(outDir, dirname4(entry.mapping_file), "transform.mjs")} <new-input-file> [<output-dir>]`
|
|
3446
5664
|
};
|
|
3447
5665
|
return { matched: true, fingerprint, index_file: indexPath, match };
|
|
3448
5666
|
}
|
|
@@ -3465,29 +5683,41 @@ var dataIntegrationReuse = {
|
|
|
3465
5683
|
service: "data-integration",
|
|
3466
5684
|
command: "reuse",
|
|
3467
5685
|
usesAeHost: false,
|
|
3468
|
-
description: "Match a candidate mapping against the .ae-data-integration/ handoff index and propose a reusable package.",
|
|
5686
|
+
description: "Match a candidate mapping against the .ae-cli/data-integration/ handoff index and propose a reusable package.",
|
|
3469
5687
|
flags: [
|
|
3470
|
-
{ name: "mapping", type: "string", required: true, sensitive: true, desc:
|
|
3471
|
-
{ name: "out-dir", type: "string", sensitive: true, desc: "Handoff root directory. Default: .ae-data-integration
|
|
5688
|
+
{ name: "mapping", type: "string", required: true, sensitive: true, desc: `Candidate ${MAPPING_VERSION} JSON, file path, or @file (typically inspect recommended_mapping).` },
|
|
5689
|
+
{ name: "out-dir", type: "string", sensitive: true, desc: "Handoff root directory. Default: nearest .ae-cli/data-integration/ upward from cwd, then ~/.ae-cli/data-integration/." }
|
|
3472
5690
|
],
|
|
3473
5691
|
risk: "read",
|
|
3474
5692
|
dryRun: async (ctx) => {
|
|
3475
5693
|
const mapping = readLocalDataMapping(ctx.str("mapping"));
|
|
3476
|
-
const outDir =
|
|
5694
|
+
const outDir = resolveReuseOutDir(ctx);
|
|
3477
5695
|
const result = detectReuse(mapping, outDir);
|
|
3478
5696
|
return {
|
|
3479
5697
|
action: "reuse_detect",
|
|
3480
5698
|
fingerprint: result.fingerprint,
|
|
3481
5699
|
matched: result.matched,
|
|
3482
|
-
index_file: result.index_file
|
|
5700
|
+
index_file: result.index_file,
|
|
5701
|
+
searched_paths: searchedIndexPaths(ctx)
|
|
3483
5702
|
};
|
|
3484
5703
|
},
|
|
3485
5704
|
execute: async (ctx) => {
|
|
3486
5705
|
const mapping = readLocalDataMapping(ctx.str("mapping"));
|
|
3487
|
-
const outDir =
|
|
3488
|
-
|
|
5706
|
+
const outDir = resolveReuseOutDir(ctx);
|
|
5707
|
+
const result = detectReuse(mapping, outDir);
|
|
5708
|
+
return { ...result, searched_paths: searchedIndexPaths(ctx) };
|
|
3489
5709
|
}
|
|
3490
5710
|
};
|
|
5711
|
+
function resolveReuseOutDir(ctx) {
|
|
5712
|
+
const explicit = ctx.str("out-dir").trim();
|
|
5713
|
+
if (explicit) return resolve5(explicit);
|
|
5714
|
+
return findReuseRoot() ?? resolve5(join5(".ae-cli", "data-integration"));
|
|
5715
|
+
}
|
|
5716
|
+
function searchedIndexPaths(ctx) {
|
|
5717
|
+
const explicit = ctx.str("out-dir").trim();
|
|
5718
|
+
const dirs = explicit ? [resolve5(explicit)] : reuseSearchPaths();
|
|
5719
|
+
return dirs.map((dir) => join5(dir, "index.json"));
|
|
5720
|
+
}
|
|
3491
5721
|
|
|
3492
5722
|
// src/commands/data-integration/index.ts
|
|
3493
5723
|
var commands = [dataIntegrationInspect, dataIntegrationPlan, dataIntegrationConvert, dataIntegrationUpload, dataIntegrationHandoff, dataIntegrationReuse];
|