@analitikisler/ai-tag-debugger 0.9.0 → 0.11.0
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/CHANGELOG.md +27 -0
- package/README.md +195 -46
- package/README.tr.md +334 -185
- package/package.json +5 -2
- package/src/agent.js +1 -1
- package/src/ask.js +123 -0
- package/src/audit.js +35 -13
- package/src/changes.js +66 -0
- package/src/checks.js +56 -1
- package/src/cli.js +176 -74
- package/src/discover.js +185 -0
- package/src/help.js +107 -0
- package/src/i18n.js +24 -0
- package/src/journeys.js +87 -8
- package/src/mcp.js +105 -11
- package/src/plan-html.js +76 -0
- package/src/plan.js +14 -0
- package/src/providers.js +1 -1
- package/src/report.js +59 -10
- package/src/sheet.js +321 -0
- package/src/workspace.js +77 -0
package/src/sheet.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Plan import and export as a spreadsheet: one row per event parameter on an "events" tab and
|
|
2
|
+
// one row per user property on a "user_properties" tab. CSV needs nothing extra; .xlsx uses the
|
|
3
|
+
// optional fflate package (zip), loaded only when an .xlsx file is read or written.
|
|
4
|
+
import { readFile, writeFile, stat } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { PROVIDERS as PLATFORMS } from "./parsers.js";
|
|
7
|
+
|
|
8
|
+
export const EVENT_COLUMNS = ["event", "platform", "parameter", "required", "example", "trigger", "description"];
|
|
9
|
+
// Optional columns that keep the rest of a plan on a round trip.
|
|
10
|
+
export const EXTRA_EVENT_COLUMNS = ["custom", "journeys"];
|
|
11
|
+
export const USER_PROPERTY_COLUMNS = ["name", "description", "example"];
|
|
12
|
+
const TRIGGER_SEP = " > ";
|
|
13
|
+
|
|
14
|
+
// ---------- plan <-> rows ----------
|
|
15
|
+
|
|
16
|
+
const yesNo = (v) => (v ? "yes" : "no");
|
|
17
|
+
|
|
18
|
+
/** The plan as the two tabs' rows: { events: object[], user_properties: object[] }. */
|
|
19
|
+
export function planToRows(plan) {
|
|
20
|
+
const events = [];
|
|
21
|
+
for (const ev of plan.events ?? []) {
|
|
22
|
+
const trigger = ev.trigger ? [ev.trigger.page, ev.trigger.element].filter(Boolean).join(TRIGGER_SEP) : "";
|
|
23
|
+
const params = { ...(ev.parameters ?? {}) };
|
|
24
|
+
for (const p of ev.required_params ?? []) params[p] ??= {};
|
|
25
|
+
const required = new Set(ev.required_params ?? []);
|
|
26
|
+
const base = { event: ev.name, platform: ev.platform ?? "ga4", custom: ev.custom ? "yes" : "", journeys: (ev.journeys ?? []).join(", ") };
|
|
27
|
+
const names = Object.keys(params);
|
|
28
|
+
// An event row (empty parameter) carries the event's own description; it is left out when the
|
|
29
|
+
// event has parameters and nothing to say.
|
|
30
|
+
if (!names.length || ev.description) events.push({ ...base, parameter: "", required: "", example: "", trigger, description: ev.description ?? "" });
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
const p = params[name] ?? {};
|
|
33
|
+
events.push({
|
|
34
|
+
...base,
|
|
35
|
+
parameter: name,
|
|
36
|
+
required: yesNo(required.has(name)),
|
|
37
|
+
example: p.example ?? "",
|
|
38
|
+
trigger,
|
|
39
|
+
description: p.description ?? "",
|
|
40
|
+
custom: [ev.custom ? "event" : "", p.custom ? "parameter" : ""].filter(Boolean).join(", "),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const user_properties = (plan.user_properties ?? []).map((u) => ({ name: u.name, description: u.description ?? "", example: u.example ?? "" }));
|
|
45
|
+
return { events, user_properties };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fail = (msg) => {
|
|
49
|
+
const err = new Error(msg);
|
|
50
|
+
err.sheetError = true;
|
|
51
|
+
return err;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function headerOf(tab, rows, needed, optional, warnings) {
|
|
55
|
+
if (!rows.length) throw fail(`${tab}: the tab is empty. Its first row must name the columns: ${needed.join(", ")}.`);
|
|
56
|
+
const header = rows[0].map((h) => String(h ?? "").trim().toLowerCase());
|
|
57
|
+
const missing = needed.filter((c) => !header.includes(c) && !(c === "description" && tab === "events"));
|
|
58
|
+
if (missing.length) throw fail(`${tab}, row 1: missing column${missing.length > 1 ? "s" : ""} ${missing.map((c) => `"${c}"`).join(", ")}. The first row must name the columns: ${needed.join(", ")}.`);
|
|
59
|
+
const unknown = header.filter((h) => h && !needed.includes(h) && !optional.includes(h));
|
|
60
|
+
for (const h of unknown) warnings.push(`${tab}: column "${h}" is not used and was ignored.`);
|
|
61
|
+
return header;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function records(rows, header) {
|
|
65
|
+
return rows.slice(1).map((cells, i) => {
|
|
66
|
+
const rec = { row: i + 2 };
|
|
67
|
+
header.forEach((h, j) => {
|
|
68
|
+
if (h) rec[h] = String(cells[j] ?? "").trim();
|
|
69
|
+
});
|
|
70
|
+
return rec;
|
|
71
|
+
}).filter((r) => header.some((h) => h && r[h]));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Builds a plan from the two tabs' rows (arrays of cell arrays, header first).
|
|
76
|
+
* Returns { plan, warnings }. Throws an error naming the tab, row and fix.
|
|
77
|
+
*/
|
|
78
|
+
export function rowsToPlan({ events: eventRows = [], user_properties: upRows = null }, { site } = {}) {
|
|
79
|
+
const warnings = [];
|
|
80
|
+
const header = headerOf("events", eventRows, EVENT_COLUMNS, EXTRA_EVENT_COLUMNS, warnings);
|
|
81
|
+
const byKey = new Map();
|
|
82
|
+
for (const r of records(eventRows, header)) {
|
|
83
|
+
const at = `events, row ${r.row}`;
|
|
84
|
+
if (!r.event) throw fail(`${at}: "event" is empty. Give the event name, e.g. purchase.`);
|
|
85
|
+
const platform = (r.platform || "ga4").toLowerCase();
|
|
86
|
+
if (!PLATFORMS[platform]) throw fail(`${at}: "platform" must be one of ${Object.keys(PLATFORMS).join(", ")} (found "${r.platform}").`);
|
|
87
|
+
const required = (r.required ?? "").toLowerCase();
|
|
88
|
+
if (!["yes", "no", ""].includes(required)) throw fail(`${at}: "required" must be yes or no (found "${r.required}").`);
|
|
89
|
+
if (required && !r.parameter) throw fail(`${at}: "required" is set but "parameter" is empty. Name the parameter, or leave "required" empty on the event's own row.`);
|
|
90
|
+
const custom = (r.custom ?? "").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
|
|
91
|
+
const okCustom = ["yes", "no", "event", "parameter"];
|
|
92
|
+
const bad = custom.find((c) => !okCustom.includes(c));
|
|
93
|
+
if (bad) throw fail(`${at}: "custom" must be yes, no, event or parameter (found "${r.custom}").`);
|
|
94
|
+
|
|
95
|
+
const key = `${platform}:${r.event}`;
|
|
96
|
+
let ev = byKey.get(key);
|
|
97
|
+
if (!ev) {
|
|
98
|
+
ev = { platform, name: r.event, parameters: {}, required_params: [] };
|
|
99
|
+
byKey.set(key, ev);
|
|
100
|
+
}
|
|
101
|
+
if (custom.includes("event") || (!r.parameter && custom.includes("yes"))) ev.custom = true;
|
|
102
|
+
if (r.trigger && !ev.trigger) {
|
|
103
|
+
const [page, ...rest] = r.trigger.split(TRIGGER_SEP);
|
|
104
|
+
ev.trigger = { page: page.trim(), ...(rest.length ? { element: rest.join(TRIGGER_SEP).trim() } : {}) };
|
|
105
|
+
}
|
|
106
|
+
if (r.journeys && !ev.journeys) ev.journeys = r.journeys.split(",").map((s) => s.trim()).filter(Boolean);
|
|
107
|
+
if (!r.parameter) {
|
|
108
|
+
if (r.description) ev.description = r.description;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ev.parameters[r.parameter]) throw fail(`${at}: parameter "${r.parameter}" of ${r.event} is listed twice. Keep one row per parameter.`);
|
|
112
|
+
ev.parameters[r.parameter] = {
|
|
113
|
+
...(custom.includes("parameter") || custom.includes("yes") ? { custom: true } : {}),
|
|
114
|
+
...(r.description ? { description: r.description } : {}),
|
|
115
|
+
...(r.example ? { example: r.example } : {}),
|
|
116
|
+
};
|
|
117
|
+
if (required === "yes") ev.required_params.push(r.parameter);
|
|
118
|
+
}
|
|
119
|
+
if (!byKey.size) throw fail("events: no events found. Add one row per event parameter below the column names.");
|
|
120
|
+
|
|
121
|
+
const events = [...byKey.values()].map(({ platform, name, custom, description, trigger, journeys, required_params, parameters }) => ({
|
|
122
|
+
platform,
|
|
123
|
+
name,
|
|
124
|
+
...(custom ? { custom: true } : {}),
|
|
125
|
+
...(description ? { description } : {}),
|
|
126
|
+
...(trigger ? { trigger } : {}),
|
|
127
|
+
...(journeys ? { journeys } : {}),
|
|
128
|
+
...(required_params.length ? { required_params } : {}),
|
|
129
|
+
...(Object.keys(parameters).length ? { parameters } : {}),
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
const user_properties = [];
|
|
133
|
+
if (upRows) {
|
|
134
|
+
const upHeader = headerOf("user_properties", upRows, USER_PROPERTY_COLUMNS, [], warnings);
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
for (const r of records(upRows, upHeader)) {
|
|
137
|
+
if (!r.name) throw fail(`user_properties, row ${r.row}: "name" is empty. Give the user property name, e.g. customer_type.`);
|
|
138
|
+
if (seen.has(r.name)) throw fail(`user_properties, row ${r.row}: "${r.name}" is listed twice. Keep one row per user property.`);
|
|
139
|
+
seen.add(r.name);
|
|
140
|
+
user_properties.push({ name: r.name, description: r.description ?? "", example: r.example ?? "" });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { plan: { ...(site ? { site } : {}), consent_mode: true, events, user_properties }, warnings };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const toCells = (objs, columns) => [columns, ...objs.map((o) => columns.map((c) => o[c] ?? ""))];
|
|
147
|
+
|
|
148
|
+
// ---------- CSV ----------
|
|
149
|
+
|
|
150
|
+
const csvCell = (v) => {
|
|
151
|
+
const s = String(v ?? "");
|
|
152
|
+
return /[",\r\n]/.test(s) || /^\s|\s$/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
153
|
+
};
|
|
154
|
+
export const toCsv = (rows) => `${rows.map((r) => r.map(csvCell).join(",")).join("\r\n")}\r\n`;
|
|
155
|
+
|
|
156
|
+
/** Parses CSV text (RFC 4180, comma or semicolon separated) into an array of cell arrays. */
|
|
157
|
+
export function parseCsv(text) {
|
|
158
|
+
text = text.replace(/^/, "");
|
|
159
|
+
const firstLine = text.slice(0, text.search(/\r?\n|$/));
|
|
160
|
+
const sep = (firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ";" : ",";
|
|
161
|
+
const rows = [];
|
|
162
|
+
let row = [];
|
|
163
|
+
let cell = "";
|
|
164
|
+
let quoted = false;
|
|
165
|
+
for (let i = 0; i < text.length; i++) {
|
|
166
|
+
const ch = text[i];
|
|
167
|
+
if (quoted) {
|
|
168
|
+
if (ch === '"' && text[i + 1] === '"') {
|
|
169
|
+
cell += '"';
|
|
170
|
+
i++;
|
|
171
|
+
} else if (ch === '"') quoted = false;
|
|
172
|
+
else cell += ch;
|
|
173
|
+
} else if (ch === '"') quoted = true;
|
|
174
|
+
else if (ch === sep) {
|
|
175
|
+
row.push(cell);
|
|
176
|
+
cell = "";
|
|
177
|
+
} else if (ch === "\n" || ch === "\r") {
|
|
178
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
179
|
+
row.push(cell);
|
|
180
|
+
rows.push(row);
|
|
181
|
+
row = [];
|
|
182
|
+
cell = "";
|
|
183
|
+
} else cell += ch;
|
|
184
|
+
}
|
|
185
|
+
if (cell || row.length) {
|
|
186
|
+
row.push(cell);
|
|
187
|
+
rows.push(row);
|
|
188
|
+
}
|
|
189
|
+
return rows;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------- XLSX (minimal: inline strings out, shared or inline strings and numbers in) ----------
|
|
193
|
+
|
|
194
|
+
async function loadFflate() {
|
|
195
|
+
try {
|
|
196
|
+
return await import("fflate");
|
|
197
|
+
} catch {
|
|
198
|
+
throw new Error('Reading and writing .xlsx files needs the optional "fflate" package, which is not installed. Install it with: npm install fflate (or use CSV: --format csv for export, a .csv file for import).');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const xmlEscape = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "");
|
|
203
|
+
const xmlUnescape = (s) => s.replace(/&(lt|gt|quot|apos|amp|#\d+|#x[0-9a-f]+);/gi, (_, e) => {
|
|
204
|
+
const named = { lt: "<", gt: ">", quot: '"', apos: "'", amp: "&" }[e.toLowerCase()];
|
|
205
|
+
if (named) return named;
|
|
206
|
+
return String.fromCodePoint(e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
|
|
207
|
+
});
|
|
208
|
+
const colName = (i) => (i >= 26 ? colName(Math.floor(i / 26) - 1) : "") + String.fromCharCode(65 + (i % 26));
|
|
209
|
+
const colIndex = (letters) => [...letters].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
|
|
210
|
+
|
|
211
|
+
function sheetXml(rows) {
|
|
212
|
+
const body = rows.map((r, i) => `<row r="${i + 1}">${r.map((v, j) => (v === "" || v == null ? "" : `<c r="${colName(j)}${i + 1}" t="inlineStr"${i === 0 ? ' s="1"' : ""}><is><t xml:space="preserve">${xmlEscape(v)}</t></is></c>`)).join("")}</row>`).join("");
|
|
213
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetData>${body}</sheetData></worksheet>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** An .xlsx file (Uint8Array) with one sheet per entry of { name: rows }. */
|
|
217
|
+
export async function writeXlsx(sheets) {
|
|
218
|
+
const { zipSync, strToU8 } = await loadFflate();
|
|
219
|
+
const names = Object.keys(sheets);
|
|
220
|
+
const files = {
|
|
221
|
+
"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${names.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("")}</Types>`,
|
|
222
|
+
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
|
|
223
|
+
"xl/workbook.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${names.map((n, i) => `<sheet name="${xmlEscape(n)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("")}</sheets></workbook>`,
|
|
224
|
+
"xl/_rels/workbook.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${names.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("")}<Relationship Id="rId${names.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`,
|
|
225
|
+
"xl/styles.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf/></cellStyleXfs><cellXfs count="2"><xf/><xf fontId="1" applyFont="1"/></cellXfs></styleSheet>`,
|
|
226
|
+
};
|
|
227
|
+
names.forEach((n, i) => (files[`xl/worksheets/sheet${i + 1}.xml`] = sheetXml(sheets[n])));
|
|
228
|
+
return zipSync(Object.fromEntries(Object.entries(files).map(([k, v]) => [k, strToU8(v)])));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const textOf = (xml) => [...xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>|<t(?:\s[^>]*)?\/>/g)].map((m) => xmlUnescape(m[1] ?? "")).join("");
|
|
232
|
+
|
|
233
|
+
/** Reads an .xlsx file's sheets as { name: rows } (rows are arrays of cell text). */
|
|
234
|
+
export async function readXlsx(bytes) {
|
|
235
|
+
const { unzipSync, strFromU8 } = await loadFflate();
|
|
236
|
+
let zip;
|
|
237
|
+
try {
|
|
238
|
+
zip = unzipSync(bytes);
|
|
239
|
+
} catch {
|
|
240
|
+
throw new Error("This is not a readable .xlsx file. Save it as an Excel workbook (.xlsx) or as CSV.");
|
|
241
|
+
}
|
|
242
|
+
const get = (p) => (zip[p] ? strFromU8(zip[p]) : null);
|
|
243
|
+
const workbook = get("xl/workbook.xml");
|
|
244
|
+
if (!workbook) throw new Error("This is not a readable .xlsx file. Save it as an Excel workbook (.xlsx) or as CSV.");
|
|
245
|
+
const rels = Object.fromEntries([...(get("xl/_rels/workbook.xml.rels") ?? "").matchAll(/<Relationship\b[^>]*>/g)].map((m) => [m[0].match(/Id="([^"]+)"/)?.[1], m[0].match(/Target="([^"]+)"/)?.[1]]));
|
|
246
|
+
const shared = [...(get("xl/sharedStrings.xml") ?? "").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => textOf(m[1].replace(/<rPh\b[\s\S]*?<\/rPh>/g, "")));
|
|
247
|
+
const sheets = {};
|
|
248
|
+
for (const m of workbook.matchAll(/<sheet\b[^>]*>/g)) {
|
|
249
|
+
const name = xmlUnescape(m[0].match(/name="([^"]*)"/)?.[1] ?? "");
|
|
250
|
+
const target = rels[m[0].match(/r:id="([^"]+)"/)?.[1]];
|
|
251
|
+
if (!target) continue;
|
|
252
|
+
const file = target.startsWith("/") ? target.slice(1) : path.posix.normalize(`xl/${target}`);
|
|
253
|
+
const xml = get(file) ?? "";
|
|
254
|
+
const rows = [];
|
|
255
|
+
for (const r of xml.matchAll(/<row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g)) {
|
|
256
|
+
const rowNum = Number(r[1].match(/r="(\d+)"/)?.[1] ?? rows.length + 1);
|
|
257
|
+
const cells = [];
|
|
258
|
+
for (const c of (r[2] ?? "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
|
|
259
|
+
const ref = c[1].match(/r="([A-Z]+)\d+"/)?.[1];
|
|
260
|
+
const type = c[1].match(/t="([^"]+)"/)?.[1];
|
|
261
|
+
const inner = c[2] ?? "";
|
|
262
|
+
const v = inner.match(/<v>([\s\S]*?)<\/v>/)?.[1];
|
|
263
|
+
let value = "";
|
|
264
|
+
if (type === "s") value = shared[Number(v)] ?? "";
|
|
265
|
+
else if (type === "inlineStr") value = textOf(inner.match(/<is>([\s\S]*?)<\/is>/)?.[1] ?? "");
|
|
266
|
+
else if (type === "b") value = v === "1" ? "TRUE" : "FALSE";
|
|
267
|
+
else if (v != null) value = xmlUnescape(v);
|
|
268
|
+
cells[ref ? colIndex(ref) : cells.length] = value;
|
|
269
|
+
}
|
|
270
|
+
rows[rowNum - 1] = Array.from(cells, (x) => x ?? "");
|
|
271
|
+
}
|
|
272
|
+
sheets[name] = Array.from(rows, (x) => x ?? []);
|
|
273
|
+
}
|
|
274
|
+
return sheets;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------- files ----------
|
|
278
|
+
|
|
279
|
+
const exists = (p) => stat(p).then(() => true, () => false);
|
|
280
|
+
|
|
281
|
+
/** Reads a plan spreadsheet (.xlsx, or events.csv with an optional user_properties.csv beside it). */
|
|
282
|
+
export async function readPlanSheet(file, { site } = {}) {
|
|
283
|
+
const ext = path.extname(file).toLowerCase();
|
|
284
|
+
if (!(await exists(file))) throw new Error(`No file at ${file}.`);
|
|
285
|
+
let tabs;
|
|
286
|
+
if (ext === ".xlsx") {
|
|
287
|
+
const sheets = await readXlsx(await readFile(file));
|
|
288
|
+
const find = (n) => Object.entries(sheets).find(([k]) => k.trim().toLowerCase() === n)?.[1];
|
|
289
|
+
const events = find("events");
|
|
290
|
+
if (!events) throw new Error(`${path.basename(file)} has no tab named "events" (found ${Object.keys(sheets).map((k) => `"${k}"`).join(", ") || "none"}). Rename the tab with the events to "events".`);
|
|
291
|
+
tabs = { events, user_properties: find("user_properties") ?? null };
|
|
292
|
+
} else if (ext === ".csv") {
|
|
293
|
+
const upFile = path.join(path.dirname(file), "user_properties.csv");
|
|
294
|
+
const isUp = path.basename(file).toLowerCase() === "user_properties.csv";
|
|
295
|
+
if (isUp) throw new Error(`Import the events file (events.csv); ${path.basename(file)} beside it is read too.`);
|
|
296
|
+
tabs = { events: parseCsv(await readFile(file, "utf8")), user_properties: (await exists(upFile)) ? parseCsv(await readFile(upFile, "utf8")) : null };
|
|
297
|
+
} else {
|
|
298
|
+
throw new Error(`Import a .xlsx or .csv file (got ${path.basename(file)}).`);
|
|
299
|
+
}
|
|
300
|
+
const result = rowsToPlan(tabs, { site });
|
|
301
|
+
if (!tabs.user_properties) result.warnings.push(ext === ".csv" ? "No user_properties.csv beside the file, so the plan has no user properties." : 'No "user_properties" tab, so the plan has no user properties.');
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Writes a plan as plan.xlsx (or events.csv and user_properties.csv) in dir; returns the files written. */
|
|
306
|
+
export async function writePlanSheet(plan, dir, { format = "xlsx" } = {}) {
|
|
307
|
+
const { events, user_properties } = planToRows(plan);
|
|
308
|
+
const hasExtra = (c) => events.some((r) => r[c]);
|
|
309
|
+
const eventColumns = [...EVENT_COLUMNS, ...EXTRA_EVENT_COLUMNS.filter(hasExtra)];
|
|
310
|
+
const tabs = { events: toCells(events, eventColumns), user_properties: toCells(user_properties, USER_PROPERTY_COLUMNS) };
|
|
311
|
+
if (format === "csv") {
|
|
312
|
+
const files = [path.join(dir, "events.csv"), path.join(dir, "user_properties.csv")];
|
|
313
|
+
await writeFile(files[0], `${toCsv(tabs.events)}`);
|
|
314
|
+
await writeFile(files[1], `${toCsv(tabs.user_properties)}`);
|
|
315
|
+
return files;
|
|
316
|
+
}
|
|
317
|
+
if (format !== "xlsx") throw new Error(`--format must be xlsx or csv (found "${format}").`);
|
|
318
|
+
const file = path.join(dir, "plan.xlsx");
|
|
319
|
+
await writeFile(file, await writeXlsx(tabs));
|
|
320
|
+
return [file];
|
|
321
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Where plans and runs are kept unless --data-dir says otherwise. */
|
|
5
|
+
export const DEFAULT_DATA_DIR = "aitd";
|
|
6
|
+
export const PLAN_FILE = "measurement-plan.json";
|
|
7
|
+
export const PLAN_HTML_FILE = "plan.html";
|
|
8
|
+
|
|
9
|
+
const ID = /^\d{4}-\d{2}-\d{2}_\d{4}(\d{2})?$/;
|
|
10
|
+
|
|
11
|
+
/** The folder name for a site: its host without "www.", e.g. https://www.example.com/x -> example.com. */
|
|
12
|
+
export function domainOf(url) {
|
|
13
|
+
let host;
|
|
14
|
+
try {
|
|
15
|
+
host = new URL(url).hostname;
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error(`"${url}" is not a full URL. Use e.g. https://www.example.com`);
|
|
18
|
+
}
|
|
19
|
+
return host.toLowerCase().replace(/^www\./, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
23
|
+
|
|
24
|
+
/** A local-time ID, YYYY-MM-DD_HHMM, with seconds added (_HHMMSS) when taken lists the minute's ID already. */
|
|
25
|
+
export function newId(taken = [], now = new Date()) {
|
|
26
|
+
const minute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
27
|
+
if (!taken.includes(minute)) return minute;
|
|
28
|
+
return `${minute}${pad(now.getSeconds())}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const siteDir = (dataDir = DEFAULT_DATA_DIR, site) => path.resolve(dataDir, domainOf(site));
|
|
32
|
+
export const plansDir = (dataDir, site) => path.join(siteDir(dataDir, site), "plans");
|
|
33
|
+
export const runsDir = (dataDir, site) => path.join(siteDir(dataDir, site), "runs");
|
|
34
|
+
|
|
35
|
+
async function listIds(dir) {
|
|
36
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
37
|
+
return entries.filter((e) => e.isDirectory() && ID.test(e.name)).map((e) => e.name).sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The site's plan IDs, oldest first and newest last. */
|
|
41
|
+
export const listPlanIds = (dataDir, site) => listIds(plansDir(dataDir, site));
|
|
42
|
+
export const listRunIds = (dataDir, site) => listIds(runsDir(dataDir, site));
|
|
43
|
+
|
|
44
|
+
/** A new, not yet created folder for the site's next plan. */
|
|
45
|
+
export async function newPlanDir(dataDir, site, now) {
|
|
46
|
+
const id = newId(await listPlanIds(dataDir, site), now);
|
|
47
|
+
return { id, dir: path.join(plansDir(dataDir, site), id) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A new, not yet created folder for the site's next run. */
|
|
51
|
+
export async function newRunDir(dataDir, site, now) {
|
|
52
|
+
const id = newId(await listRunIds(dataDir, site), now);
|
|
53
|
+
return { id, dir: path.join(runsDir(dataDir, site), id) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Finds a plan file from a reference: "latest" or "last", "first", a plan ID, or a path to a plan
|
|
58
|
+
* file or the folder that holds one. Returns { id, file }; id is null for a path outside the site's plans.
|
|
59
|
+
*/
|
|
60
|
+
export async function resolvePlan(ref, { dataDir = DEFAULT_DATA_DIR, site } = {}) {
|
|
61
|
+
const value = String(ref ?? "latest").trim();
|
|
62
|
+
const byKeyword = ["latest", "last", "first"].includes(value);
|
|
63
|
+
if (byKeyword || ID.test(value)) {
|
|
64
|
+
if (!site) throw new Error(`Add --site <url> to find plan "${value}".`);
|
|
65
|
+
const ids = await listPlanIds(dataDir, site);
|
|
66
|
+
if (!ids.length) throw new Error(`No plan yet for ${domainOf(site)}. Draft one with: aitd plan --site ${site}`);
|
|
67
|
+
const id = value === "first" ? ids[0] : byKeyword ? ids.at(-1) : ids.includes(value) ? value : null;
|
|
68
|
+
if (!id) throw new Error(`No plan ${value} for ${domainOf(site)}. Available: ${ids.join(", ")}`);
|
|
69
|
+
return { id, file: path.join(plansDir(dataDir, site), id, PLAN_FILE) };
|
|
70
|
+
}
|
|
71
|
+
const info = await stat(value).catch(() => null);
|
|
72
|
+
if (!info) throw new Error(`No plan found at ${value}. Use latest, first, a plan ID, or a path to a plan file or folder.`);
|
|
73
|
+
const file = info.isDirectory() ? path.join(value, PLAN_FILE) : value;
|
|
74
|
+
if (info.isDirectory() && !(await stat(file).catch(() => null))) throw new Error(`${value} has no ${PLAN_FILE}.`);
|
|
75
|
+
const id = path.basename(path.dirname(path.resolve(file)));
|
|
76
|
+
return { id: ID.test(id) ? id : null, file: path.resolve(file) };
|
|
77
|
+
}
|