@devdogsuga/backstage 0.1.8 → 0.1.10
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 +42 -24
- package/dist/{catalog-BWtUtxMF.js → catalog-CaTnrGVr.js} +20 -10
- package/dist/{cli-D6swhEN-.js → cli-DjIwbjnx.js} +1 -1
- package/dist/{cli-J_avqnlu.js → cli-feRmGaGi.js} +4 -4
- package/dist/commands-C8pXvbv1.js +699 -0
- package/dist/{commands-D-sNogNX.js → commands-CD1v_-6y.js} +3 -3
- package/dist/{commands-Dwr4_CSu.js → commands-KY-9X1Rh.js} +2 -2
- package/dist/{commands-zApyD2Rk.js → commands-Ye0G_5bZ.js} +2 -2
- package/dist/{commands-CQ-vbNe9.js → commands-jJcS7D2a.js} +4 -4
- package/dist/launch.js +2 -2
- package/dist/{meetings-CtBMT_25.js → meetings-Du5asTAj.js} +6 -8
- package/dist/{queries-DeEwfe2c.js → queries-D3mTchag.js} +102 -10
- package/dist/{read-BLyKoe4l.js → read-B8DSwYfB.js} +1 -1
- package/package.json +6 -6
- package/dist/commands-D4FcDQuh.js +0 -225
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { n as isDryRun } from "./dry-run-CFCZUpjb.js";
|
|
2
|
+
import { o as unwrap, r as errorMessage, t as UsageError } from "./ui-Bb69ZfVv.js";
|
|
3
|
+
import { i as exportParams, n as EXPORT_KINDS, r as auditFilters, t as EXPORTS } from "./queries-D3mTchag.js";
|
|
4
|
+
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
5
|
+
import { c as ProductionError, d as requireProductionKey, i as plural, l as connect, o as reportFailure, r as expandHome, s as say, t as canPrompt, u as dbError } from "./cli-DjIwbjnx.js";
|
|
6
|
+
import { i as parseBound, n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-Du5asTAj.js";
|
|
7
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, join } from "node:path";
|
|
9
|
+
import { autocomplete, confirm, multiselect, text } from "@clack/prompts";
|
|
10
|
+
import { open, rm } from "node:fs/promises";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { parseArgs, promisify } from "node:util";
|
|
13
|
+
/**
|
|
14
|
+
* Quotes a field only when it has to be: a comma, a quote, a carriage return
|
|
15
|
+
* or a line feed. Quoting only what needs it keeps the file readable in a
|
|
16
|
+
* terminal.
|
|
17
|
+
*
|
|
18
|
+
* A leading `=`, `+`, `-`, `@`, tab or carriage return gets a tab prefix:
|
|
19
|
+
* spreadsheets start a formula on the first four, and a member can name their
|
|
20
|
+
* team `=cmd|'/c calc'!A1`. The tab is invisible in a cell and neutralises it.
|
|
21
|
+
*/
|
|
22
|
+
function csvField(value) {
|
|
23
|
+
if (value === null || value === void 0) return "";
|
|
24
|
+
let text;
|
|
25
|
+
if (typeof value === "string") text = value;
|
|
26
|
+
else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") text = String(value);
|
|
27
|
+
else if (value instanceof Date) text = csvTimestamp(value);
|
|
28
|
+
else text = JSON.stringify(value) ?? "";
|
|
29
|
+
const guarded = /^[=+\-@\t\r]/.test(text) ? `\t${text}` : text;
|
|
30
|
+
if (!/[",\r\n]/.test(guarded)) return guarded;
|
|
31
|
+
return `"${guarded.replace(/"/g, "\"\"")}"`;
|
|
32
|
+
}
|
|
33
|
+
function csvRow(values) {
|
|
34
|
+
return values.map(csvField).join(",") + "\r\n";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* ISO 8601 with an explicit offset, never a bare local time: a timestamp
|
|
38
|
+
* without one is read in whatever zone the importer runs in.
|
|
39
|
+
*/
|
|
40
|
+
function csvTimestamp(value) {
|
|
41
|
+
if (value === null) return "";
|
|
42
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
43
|
+
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/production/save-path.ts
|
|
47
|
+
/**
|
|
48
|
+
* "Where should this go?": a save-as prompt with path completion.
|
|
49
|
+
*
|
|
50
|
+
* clack's own `path` prompt only submits a path that already exists (it is a
|
|
51
|
+
* picker), so a new file name cannot be typed into it. This is the same
|
|
52
|
+
* `autocomplete` underneath, with the typed path itself always first: Enter
|
|
53
|
+
* saves exactly what was typed, the arrow keys pick a suggestion, and Tab
|
|
54
|
+
* completes the highlighted one into the input to keep typing from, the way
|
|
55
|
+
* a shell does. A directory, typed or picked, means the suggested file name
|
|
56
|
+
* inside it.
|
|
57
|
+
*/
|
|
58
|
+
function isDirectory$1(path) {
|
|
59
|
+
try {
|
|
60
|
+
return statSync(path).isDirectory();
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* What to offer for `input`: the input itself, then the entries of the
|
|
67
|
+
* directory it is being typed into whose names start with what follows the
|
|
68
|
+
* last `/`, directories first and marked with a trailing `/`. Dotfiles only
|
|
69
|
+
* when the name being typed starts with a dot. Values keep the input's own
|
|
70
|
+
* spelling (`~/`, `./`) so completing one never rewrites what was typed.
|
|
71
|
+
*/
|
|
72
|
+
function pathSuggestions(input, fileName) {
|
|
73
|
+
if (!input) return [];
|
|
74
|
+
const full = expandHome(input);
|
|
75
|
+
const own = {
|
|
76
|
+
value: input,
|
|
77
|
+
label: input,
|
|
78
|
+
hint: isDirectory$1(full) ? `saves ${join(input, fileName)}` : existsSync(full) ? "replaces this file" : isDirectory$1(dirname(full)) ? "new file" : "no such folder"
|
|
79
|
+
};
|
|
80
|
+
const cut = input.lastIndexOf("/") + 1;
|
|
81
|
+
const lead = input.slice(0, cut);
|
|
82
|
+
const prefix = input.slice(cut);
|
|
83
|
+
let names;
|
|
84
|
+
try {
|
|
85
|
+
names = readdirSync(expandHome(lead || "."));
|
|
86
|
+
} catch {
|
|
87
|
+
return [own];
|
|
88
|
+
}
|
|
89
|
+
const shown = (name) => name.startsWith(prefix) && (prefix.startsWith(".") || !name.startsWith("."));
|
|
90
|
+
return [own, ...names.filter(shown).map((name) => {
|
|
91
|
+
const value = lead + name;
|
|
92
|
+
return {
|
|
93
|
+
name,
|
|
94
|
+
value,
|
|
95
|
+
directory: isDirectory$1(expandHome(value))
|
|
96
|
+
};
|
|
97
|
+
}).sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)).map(({ value, directory }) => ({
|
|
98
|
+
value: directory ? `${value}/` : value,
|
|
99
|
+
label: directory ? `${value}/` : value
|
|
100
|
+
})).filter((s) => s.value !== input)];
|
|
101
|
+
}
|
|
102
|
+
/** The file a submitted path names: a directory means `fileName` inside it. */
|
|
103
|
+
function resolveSavePath(input, fileName) {
|
|
104
|
+
const full = expandHome(input.trim());
|
|
105
|
+
return isDirectory$1(full) ? join(full, fileName) : full;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Asks where to save a file, suggesting `suggested`. An existing file is only
|
|
109
|
+
* replaced after a second, default-no question; answering no asks again.
|
|
110
|
+
* Returns the full path (`~` expanded, a directory resolved to the file in it).
|
|
111
|
+
*/
|
|
112
|
+
async function askSavePath(options) {
|
|
113
|
+
const fileName = basename(options.suggested);
|
|
114
|
+
for (;;) {
|
|
115
|
+
let wired = false;
|
|
116
|
+
const path = resolveSavePath(unwrap(await autocomplete({
|
|
117
|
+
message: options.message,
|
|
118
|
+
initialUserInput: options.suggested,
|
|
119
|
+
maxItems: 6,
|
|
120
|
+
filter: () => true,
|
|
121
|
+
options() {
|
|
122
|
+
if (!wired) {
|
|
123
|
+
wired = true;
|
|
124
|
+
const prompt = this;
|
|
125
|
+
prompt.on("key", (_char, key) => {
|
|
126
|
+
const focused = prompt.focusedValue;
|
|
127
|
+
if (key.name !== "tab" || typeof focused !== "string") return;
|
|
128
|
+
prompt._clearUserInput();
|
|
129
|
+
prompt._setUserInput(focused, true);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return pathSuggestions(this.userInput, fileName);
|
|
133
|
+
},
|
|
134
|
+
validate: (value) => {
|
|
135
|
+
if (typeof value !== "string" || !value.trim()) return "A path, please.";
|
|
136
|
+
const folder = dirname(resolveSavePath(value, fileName));
|
|
137
|
+
return isDirectory$1(folder) ? void 0 : `${folder} does not exist.`;
|
|
138
|
+
}
|
|
139
|
+
})), fileName);
|
|
140
|
+
if (!existsSync(path)) return path;
|
|
141
|
+
if (unwrap(await confirm({
|
|
142
|
+
message: `${path} already exists. Replace it?`,
|
|
143
|
+
initialValue: false
|
|
144
|
+
}))) return path;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/export/formats.ts
|
|
149
|
+
/** What each export can be written as; the first is its default. */
|
|
150
|
+
const FORMATS_BY_KIND = {
|
|
151
|
+
stars: ["platform"],
|
|
152
|
+
attendance: [
|
|
153
|
+
"platform",
|
|
154
|
+
"bevy",
|
|
155
|
+
"involvement"
|
|
156
|
+
],
|
|
157
|
+
reflections: ["platform"],
|
|
158
|
+
responses: ["platform", "wide"]
|
|
159
|
+
};
|
|
160
|
+
function text$1(value) {
|
|
161
|
+
return typeof value === "string" ? value.trim() : "";
|
|
162
|
+
}
|
|
163
|
+
/** The UGA address on file: the profile's MyID email, else a uga.edu sign-in. */
|
|
164
|
+
function ugaEmail(row) {
|
|
165
|
+
const uga = text$1(row.uga_email).toLowerCase();
|
|
166
|
+
if (uga) return uga;
|
|
167
|
+
const signIn = text$1(row.email).toLowerCase();
|
|
168
|
+
return signIn.endsWith("@uga.edu") ? signIn : null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* First and last name: the roster's (or legal) name when the profile has
|
|
172
|
+
* one, else the preferred name split at its first space.
|
|
173
|
+
*/
|
|
174
|
+
function splitName(row) {
|
|
175
|
+
const first = text$1(row.first_name);
|
|
176
|
+
const last = text$1(row.last_name);
|
|
177
|
+
if (first || last) return [first, last];
|
|
178
|
+
const preferred = text$1(row.preferred_name);
|
|
179
|
+
const space = preferred.indexOf(" ");
|
|
180
|
+
return space === -1 ? [preferred, ""] : [preferred.slice(0, space), preferred.slice(space + 1).trim()];
|
|
181
|
+
}
|
|
182
|
+
/** One line per new key; rows without a key are counted as skipped. */
|
|
183
|
+
function oncePer(key, line) {
|
|
184
|
+
const seen = /* @__PURE__ */ new Set();
|
|
185
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
186
|
+
return {
|
|
187
|
+
page: (rows) => {
|
|
188
|
+
let out = "";
|
|
189
|
+
let count = 0;
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
const k = key(row);
|
|
192
|
+
if (k === null) {
|
|
193
|
+
skipped.add(text$1(row.user_id));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (seen.has(k)) continue;
|
|
197
|
+
seen.add(k);
|
|
198
|
+
out += line(row, k);
|
|
199
|
+
count++;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
text: out,
|
|
203
|
+
count
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
skipped: () => skipped.size
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const BEVY_COLUMNS = [
|
|
210
|
+
"first_name",
|
|
211
|
+
"last_name",
|
|
212
|
+
"email",
|
|
213
|
+
"checked_in",
|
|
214
|
+
"job_title",
|
|
215
|
+
"company",
|
|
216
|
+
"ticket_title",
|
|
217
|
+
"ticket_venue"
|
|
218
|
+
];
|
|
219
|
+
/** The platform's CSV for an export: its columns, a row per row. */
|
|
220
|
+
function platformFormat(columns) {
|
|
221
|
+
return {
|
|
222
|
+
name: "platform",
|
|
223
|
+
label: "Platform CSV",
|
|
224
|
+
hint: "every column, one row per record",
|
|
225
|
+
suffix: "",
|
|
226
|
+
extension: "csv",
|
|
227
|
+
bom: true,
|
|
228
|
+
perEvent: false,
|
|
229
|
+
unit: ["row", "rows"],
|
|
230
|
+
needs: "",
|
|
231
|
+
head: csvRow(columns),
|
|
232
|
+
writer: () => ({
|
|
233
|
+
page: (rows) => ({
|
|
234
|
+
text: rows.map((row) => csvRow(columns.map((c) => row[c]))).join(""),
|
|
235
|
+
count: rows.length
|
|
236
|
+
}),
|
|
237
|
+
skipped: () => 0
|
|
238
|
+
})
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function bevyFormat(survey = {
|
|
242
|
+
columns: [],
|
|
243
|
+
byUser: /* @__PURE__ */ new Map()
|
|
244
|
+
}) {
|
|
245
|
+
return {
|
|
246
|
+
name: "bevy",
|
|
247
|
+
label: "Bevy attendee import",
|
|
248
|
+
hint: "gdg.community.dev; one row per person, checked in, survey answers",
|
|
249
|
+
suffix: "bevy",
|
|
250
|
+
extension: "csv",
|
|
251
|
+
bom: false,
|
|
252
|
+
perEvent: true,
|
|
253
|
+
unit: ["person", "people"],
|
|
254
|
+
needs: "an email",
|
|
255
|
+
head: csvRow([...BEVY_COLUMNS, ...survey.columns]),
|
|
256
|
+
writer: () => oncePer((row) => (ugaEmail(row) ?? text$1(row.email).toLowerCase()) || null, (row, email) => {
|
|
257
|
+
const [first, last] = splitName(row);
|
|
258
|
+
const answers = survey.byUser.get(text$1(row.user_id));
|
|
259
|
+
return csvRow([
|
|
260
|
+
first,
|
|
261
|
+
last,
|
|
262
|
+
email,
|
|
263
|
+
"TRUE",
|
|
264
|
+
"",
|
|
265
|
+
"",
|
|
266
|
+
"",
|
|
267
|
+
"",
|
|
268
|
+
...survey.columns.map((c) => answers?.get(c) ?? "")
|
|
269
|
+
]);
|
|
270
|
+
})
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
bevyFormat();
|
|
274
|
+
/**
|
|
275
|
+
* Survey responses as one row per person and one column per question (its
|
|
276
|
+
* id), in the order questions first appear. Every row has to be in before the
|
|
277
|
+
* header is known, so it writes everything at the end; one meeting's answers
|
|
278
|
+
* are what it is for, which keeps that small.
|
|
279
|
+
*/
|
|
280
|
+
const WIDE = {
|
|
281
|
+
name: "wide",
|
|
282
|
+
label: "Wide table",
|
|
283
|
+
hint: "one row per person, one column per question",
|
|
284
|
+
suffix: "wide",
|
|
285
|
+
extension: "csv",
|
|
286
|
+
bom: true,
|
|
287
|
+
perEvent: true,
|
|
288
|
+
unit: ["person", "people"],
|
|
289
|
+
needs: "",
|
|
290
|
+
head: "",
|
|
291
|
+
writer: () => {
|
|
292
|
+
const questions = [];
|
|
293
|
+
const people = /* @__PURE__ */ new Map();
|
|
294
|
+
return {
|
|
295
|
+
page: (rows) => {
|
|
296
|
+
for (const row of rows) {
|
|
297
|
+
const question = text$1(row.question_id);
|
|
298
|
+
if (!questions.includes(question)) questions.push(question);
|
|
299
|
+
const id = text$1(row.user_id);
|
|
300
|
+
const person = people.get(id) ?? {
|
|
301
|
+
row,
|
|
302
|
+
answers: /* @__PURE__ */ new Map()
|
|
303
|
+
};
|
|
304
|
+
person.answers.set(question, row.answer);
|
|
305
|
+
people.set(id, person);
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
text: "",
|
|
309
|
+
count: 0
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
skipped: () => 0,
|
|
313
|
+
end: () => {
|
|
314
|
+
const lead = [
|
|
315
|
+
"user_id",
|
|
316
|
+
"preferred_name",
|
|
317
|
+
"email"
|
|
318
|
+
];
|
|
319
|
+
let out = csvRow([...lead, ...questions]);
|
|
320
|
+
for (const { row, answers } of people.values()) out += csvRow([...lead.map((c) => row[c]), ...questions.map((q) => answers.get(q) ?? "")]);
|
|
321
|
+
return {
|
|
322
|
+
text: out,
|
|
323
|
+
count: people.size
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const INVOLVEMENT = {
|
|
330
|
+
name: "involvement",
|
|
331
|
+
label: "Involvement Network list",
|
|
332
|
+
hint: "one MyID email per line",
|
|
333
|
+
suffix: "involvement",
|
|
334
|
+
extension: "txt",
|
|
335
|
+
bom: false,
|
|
336
|
+
perEvent: true,
|
|
337
|
+
unit: ["email", "emails"],
|
|
338
|
+
needs: "a UGA email",
|
|
339
|
+
head: "",
|
|
340
|
+
writer: () => oncePer(ugaEmail, (_row, email) => `${email}\r
|
|
341
|
+
`)
|
|
342
|
+
};
|
|
343
|
+
function formatFor(name, columns, survey) {
|
|
344
|
+
if (name === "bevy") return bevyFormat(survey);
|
|
345
|
+
if (name === "involvement") return INVOLVEMENT;
|
|
346
|
+
if (name === "wide") return WIDE;
|
|
347
|
+
return platformFormat(columns);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* `--format` values for an export, comma-separated or repeated, checked
|
|
351
|
+
* against what that export offers and deduplicated.
|
|
352
|
+
*/
|
|
353
|
+
function parseFormats(raw, kind) {
|
|
354
|
+
const offered = FORMATS_BY_KIND[kind];
|
|
355
|
+
const names = raw.flatMap((r) => r.split(",")).map((r) => r.trim().toLowerCase()).filter(Boolean);
|
|
356
|
+
for (const name of names) if (!offered.includes(name)) throw new Error(offered.length === 1 ? `The ${kind} export has one format; leave --format off.` : `Unknown format "${name}" for ${kind}. Try ${offered.join(", ")}.`);
|
|
357
|
+
return [...new Set(names)];
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The Bevy file's survey columns from a meeting's `responses` rows: each
|
|
361
|
+
* mapped question anyone answered, and each person's answer in words.
|
|
362
|
+
*/
|
|
363
|
+
function bevySurvey(rows) {
|
|
364
|
+
const columns = [];
|
|
365
|
+
const byUser = /* @__PURE__ */ new Map();
|
|
366
|
+
for (const row of rows) {
|
|
367
|
+
const column = row.definition?.bevy;
|
|
368
|
+
if (!column) continue;
|
|
369
|
+
if (!columns.includes(column)) columns.push(column);
|
|
370
|
+
const id = text$1(row.user_id);
|
|
371
|
+
const answers = byUser.get(id) ?? /* @__PURE__ */ new Map();
|
|
372
|
+
answers.set(column, text$1(row.answer));
|
|
373
|
+
byUser.set(id, answers);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
columns: columns.sort(),
|
|
377
|
+
byUser
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/export/commands.ts
|
|
382
|
+
/**
|
|
383
|
+
* `backstage export <stars|attendance|reflections>`: the platform's CSV
|
|
384
|
+
* downloads, moved here from `/console/exports`.
|
|
385
|
+
*
|
|
386
|
+
* pnpm backstage export stars --from 2026-08-17 --to 2026-12-12
|
|
387
|
+
* pnpm backstage export attendance --meeting 2026-09-09 --format bevy,involvement
|
|
388
|
+
* pnpm backstage export attendance --meeting 2026-09-09 --out -
|
|
389
|
+
*
|
|
390
|
+
* ## Audited, as the downloads were
|
|
391
|
+
*
|
|
392
|
+
* Each export writes a `platform.exportAudit` row BEFORE the first row is
|
|
393
|
+
* read, as the web routes did: an export that fails halfway still put rows
|
|
394
|
+
* in front of somebody. The row names the officer's platform account, found
|
|
395
|
+
* through the GitHub login `gh` is signed in as (the identity they linked on
|
|
396
|
+
* the platform), so an export nobody can attribute is refused rather than
|
|
397
|
+
* recorded as nobody's. The row count is filled in when the file is done.
|
|
398
|
+
*
|
|
399
|
+
* ## Formats
|
|
400
|
+
*
|
|
401
|
+
* Attendance can be written as the platform CSV, a Bevy attendee import, an
|
|
402
|
+
* Involvement Network email list, or several at once from one read (see
|
|
403
|
+
* `formats.ts`); `--format`, else asked at a terminal, else the platform CSV.
|
|
404
|
+
* Each file gets its own audit row, its format in the filters.
|
|
405
|
+
*
|
|
406
|
+
* ## The files
|
|
407
|
+
*
|
|
408
|
+
* At a terminal, each file's destination is asked for, with path completion
|
|
409
|
+
* (`production/save-path.ts`), suggesting `<kind>[-<meeting>][-<from>][-<to>]
|
|
410
|
+
* [-<format>].<ext>`. Without one, that name in the working directory.
|
|
411
|
+
* `--out` names the file, or a folder for them all; `--out -` streams one
|
|
412
|
+
* export to stdout without the byte-order mark, for a pipe. Files are created
|
|
413
|
+
* readable by their owner only (they are member PII), never over an existing
|
|
414
|
+
* file unless `--yes` or the prompt's own question says so. A failure partway
|
|
415
|
+
* removes every partial file.
|
|
416
|
+
*
|
|
417
|
+
* `--from`/`--to` take Eastern days, `--to` inclusive (see `production/time.ts`).
|
|
418
|
+
*/
|
|
419
|
+
function parse(argv) {
|
|
420
|
+
const [kind, ...rest] = argv;
|
|
421
|
+
if (!kind || !EXPORT_KINDS.includes(kind)) throw new UsageError(kind ? `Unknown export "${kind}". Try ${EXPORT_KINDS.join(", ")}.` : `Name the export: ${EXPORT_KINDS.join(", ")}.`);
|
|
422
|
+
try {
|
|
423
|
+
const { values } = parseArgs({
|
|
424
|
+
args: rest,
|
|
425
|
+
options: {
|
|
426
|
+
from: { type: "string" },
|
|
427
|
+
to: { type: "string" },
|
|
428
|
+
meeting: { type: "string" },
|
|
429
|
+
format: {
|
|
430
|
+
type: "string",
|
|
431
|
+
multiple: true
|
|
432
|
+
},
|
|
433
|
+
out: { type: "string" },
|
|
434
|
+
yes: { type: "boolean" },
|
|
435
|
+
"dry-run": { type: "boolean" }
|
|
436
|
+
},
|
|
437
|
+
allowPositionals: false,
|
|
438
|
+
strict: true
|
|
439
|
+
});
|
|
440
|
+
if (values.meeting && kind !== "attendance" && kind !== "responses") throw new UsageError("--meeting only applies to the attendance and responses exports.");
|
|
441
|
+
return {
|
|
442
|
+
kind,
|
|
443
|
+
values
|
|
444
|
+
};
|
|
445
|
+
} catch (err) {
|
|
446
|
+
if (err instanceof UsageError) throw err;
|
|
447
|
+
throw new UsageError(errorMessage(err));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const run = promisify(execFile);
|
|
451
|
+
/** The GitHub login `gh` is signed in as. */
|
|
452
|
+
async function ghLogin() {
|
|
453
|
+
try {
|
|
454
|
+
const { stdout } = await run("gh", [
|
|
455
|
+
"api",
|
|
456
|
+
"user",
|
|
457
|
+
"--jq",
|
|
458
|
+
".login"
|
|
459
|
+
]);
|
|
460
|
+
const login = stdout.trim();
|
|
461
|
+
if (login) return login;
|
|
462
|
+
} catch {}
|
|
463
|
+
throw new ProductionError("Exports are audited under your GitHub login, and `gh` could not say who you are. Install it from https://cli.github.com and run `gh auth login`.");
|
|
464
|
+
}
|
|
465
|
+
const OFFICER_QUERY = `
|
|
466
|
+
select distinct i."user_id"::text as "userId"
|
|
467
|
+
from "auth"."identities" i
|
|
468
|
+
where i."provider" = 'github'
|
|
469
|
+
and lower(i."identity_data" ->> 'user_name') = lower($1)
|
|
470
|
+
`;
|
|
471
|
+
function sourceWith(sql) {
|
|
472
|
+
return {
|
|
473
|
+
officersFor: async (login) => (await sql.unsafe(OFFICER_QUERY, [login])).map((r) => String(r.userId)),
|
|
474
|
+
findMeetings: findMeetingsWith(sql),
|
|
475
|
+
audit: async (userId, kind, filters) => {
|
|
476
|
+
const [row] = await sql.unsafe(`insert into "platform"."exportAudit" ("userId", "kind", "filters")
|
|
477
|
+
values ($1, $2, $3::text::jsonb) returning "id"::text as "id"`, [
|
|
478
|
+
userId,
|
|
479
|
+
kind,
|
|
480
|
+
JSON.stringify(filters)
|
|
481
|
+
]);
|
|
482
|
+
return String(row.id);
|
|
483
|
+
},
|
|
484
|
+
rows: async function* (kind, filters) {
|
|
485
|
+
const { sql: query, transform } = EXPORTS[kind];
|
|
486
|
+
for await (const page of sql.unsafe(query, exportParams(filters)).cursor(500)) yield transform ? page.map(transform) : page;
|
|
487
|
+
},
|
|
488
|
+
finish: async (auditId, rowCount) => {
|
|
489
|
+
await sql.unsafe(`update "platform"."exportAudit" set "rowCount" = $1 where "id" = $2::uuid`, [rowCount, auditId]);
|
|
490
|
+
},
|
|
491
|
+
close: () => sql.end({ timeout: 5 })
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
async function productionSource() {
|
|
495
|
+
return sourceWith(connect(await requireProductionKey("DB_URL")));
|
|
496
|
+
}
|
|
497
|
+
async function fileSink(path, overwrite, bom) {
|
|
498
|
+
if (path === "-") return {
|
|
499
|
+
write: (text) => new Promise((resolve, reject) => process.stdout.write(text, (err) => err ? reject(err) : resolve())),
|
|
500
|
+
close: () => Promise.resolve(),
|
|
501
|
+
discard: () => Promise.resolve()
|
|
502
|
+
};
|
|
503
|
+
let handle;
|
|
504
|
+
try {
|
|
505
|
+
handle = await open(path, overwrite ? "w" : "wx", 384);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
if (err.code === "EEXIST") throw new UsageError(`${path} already exists. Pass --yes to replace it.`);
|
|
508
|
+
throw new UsageError(`Could not write ${path}.`);
|
|
509
|
+
}
|
|
510
|
+
if (bom) await handle.write("");
|
|
511
|
+
return {
|
|
512
|
+
write: async (text) => {
|
|
513
|
+
await handle.write(text);
|
|
514
|
+
},
|
|
515
|
+
close: () => handle.close(),
|
|
516
|
+
discard: async () => {
|
|
517
|
+
await handle.close().catch(() => void 0);
|
|
518
|
+
await rm(path, { force: true });
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function defaultName(kind, values, format, meetingSlug) {
|
|
523
|
+
const parts = [kind];
|
|
524
|
+
if (meetingSlug) parts.push(meetingSlug);
|
|
525
|
+
if (values.from) parts.push(values.from.slice(0, 10));
|
|
526
|
+
if (values.to) parts.push(values.to.slice(0, 10));
|
|
527
|
+
if (format.suffix) parts.push(format.suffix);
|
|
528
|
+
return `${parts.join("-")}.${format.extension}`;
|
|
529
|
+
}
|
|
530
|
+
function isDirectory(path) {
|
|
531
|
+
try {
|
|
532
|
+
return statSync(path).isDirectory();
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async function pickFormats(kind) {
|
|
538
|
+
return unwrap(await multiselect({
|
|
539
|
+
message: "Which formats? (space to choose, enter to export)",
|
|
540
|
+
options: FORMATS_BY_KIND[kind].map((name) => {
|
|
541
|
+
const format = formatFor(name, []);
|
|
542
|
+
return {
|
|
543
|
+
value: name,
|
|
544
|
+
label: format.label,
|
|
545
|
+
hint: format.hint
|
|
546
|
+
};
|
|
547
|
+
}),
|
|
548
|
+
initialValues: ["platform"],
|
|
549
|
+
required: true
|
|
550
|
+
}));
|
|
551
|
+
}
|
|
552
|
+
async function askMeeting() {
|
|
553
|
+
return unwrap(await text({
|
|
554
|
+
message: "Which meeting? Its day, slug or id.",
|
|
555
|
+
placeholder: "2026-09-09",
|
|
556
|
+
validate: (v) => v?.trim() ? void 0 : "A meeting, please."
|
|
557
|
+
})).trim();
|
|
558
|
+
}
|
|
559
|
+
async function askPath(message, suggested) {
|
|
560
|
+
return askSavePath({
|
|
561
|
+
message,
|
|
562
|
+
suggested
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Where each file goes: `--out` (a file, `-`, or a folder for them all), else
|
|
567
|
+
* asked at a terminal, else its default name here.
|
|
568
|
+
*/
|
|
569
|
+
async function planFiles(formats, name, values, interactive, ask) {
|
|
570
|
+
const overwrite = values.yes === true;
|
|
571
|
+
const out = values.out === void 0 ? void 0 : expandHome(values.out);
|
|
572
|
+
if (out !== void 0 && !isDirectory(out)) {
|
|
573
|
+
if (formats.length > 1) throw new UsageError(out === "-" ? "--out - streams one export; choose one --format." : "--out names one file; for several formats, give it a folder.");
|
|
574
|
+
return [{
|
|
575
|
+
format: formats[0],
|
|
576
|
+
path: out,
|
|
577
|
+
overwrite
|
|
578
|
+
}];
|
|
579
|
+
}
|
|
580
|
+
const planned = [];
|
|
581
|
+
for (const format of formats) if (out !== void 0) planned.push({
|
|
582
|
+
format,
|
|
583
|
+
path: join(out, name(format)),
|
|
584
|
+
overwrite
|
|
585
|
+
});
|
|
586
|
+
else if (interactive) {
|
|
587
|
+
const path = await ask(`Save the ${format.label} to`, name(format));
|
|
588
|
+
planned.push({
|
|
589
|
+
format,
|
|
590
|
+
path,
|
|
591
|
+
overwrite: true
|
|
592
|
+
});
|
|
593
|
+
} else planned.push({
|
|
594
|
+
format,
|
|
595
|
+
path: name(format),
|
|
596
|
+
overwrite
|
|
597
|
+
});
|
|
598
|
+
return planned;
|
|
599
|
+
}
|
|
600
|
+
const shown = (path) => path === "-" ? "stdout" : path;
|
|
601
|
+
/** " as Bevy attendee import"; nothing for the platform CSV, the default. */
|
|
602
|
+
const as = (format) => format.name === "platform" ? "" : ` as ${format.label}`;
|
|
603
|
+
async function runExport(argv, deps = {}) {
|
|
604
|
+
let source;
|
|
605
|
+
const sinks = [];
|
|
606
|
+
let done = false;
|
|
607
|
+
try {
|
|
608
|
+
const { kind, values } = parse(argv);
|
|
609
|
+
const filters = {
|
|
610
|
+
from: parseBound("--from", values.from),
|
|
611
|
+
to: parseBound("--to", values.to)
|
|
612
|
+
};
|
|
613
|
+
if (filters.from && filters.to && filters.from >= filters.to) throw new UsageError("--from must come before --to.");
|
|
614
|
+
const interactive = deps.interactive ?? canPrompt();
|
|
615
|
+
let names;
|
|
616
|
+
try {
|
|
617
|
+
names = values.format ? parseFormats(values.format, kind) : [];
|
|
618
|
+
} catch (err) {
|
|
619
|
+
throw new UsageError(errorMessage(err));
|
|
620
|
+
}
|
|
621
|
+
if (names.length === 0) names = FORMATS_BY_KIND[kind].length > 1 && interactive ? await (deps.pickFormats ?? pickFormats)(kind) : ["platform"];
|
|
622
|
+
const { columns } = EXPORTS[kind];
|
|
623
|
+
let formats = names.map((n) => formatFor(n, columns));
|
|
624
|
+
const perEvent = formats.filter((f) => f.perEvent);
|
|
625
|
+
if (perEvent.length > 0 && !values.meeting) {
|
|
626
|
+
if (!interactive) throw new UsageError(`The ${perEvent.map((f) => f.label).join(" and ")} ${perEvent.length === 1 ? "file is" : "files are"} for one meeting; name it with --meeting.`);
|
|
627
|
+
values.meeting = await (deps.askMeeting ?? askMeeting)();
|
|
628
|
+
}
|
|
629
|
+
const login = await (deps.ghLogin ?? ghLogin)();
|
|
630
|
+
source = await (deps.source ?? productionSource)();
|
|
631
|
+
const officers = await source.officersFor(login);
|
|
632
|
+
if (officers.length !== 1) throw new ProductionError(officers.length === 0 ? `No platform account has linked the GitHub login ${login}. Link GitHub on your platform profile, then export again.` : `${plural(officers.length, "platform account")} linked the GitHub login ${login}; an export must be attributable to one.`);
|
|
633
|
+
let slug;
|
|
634
|
+
if (values.meeting) {
|
|
635
|
+
const meeting = pickMeeting(values.meeting, await source.findMeetings(values.meeting));
|
|
636
|
+
filters.meetingId = meeting.id;
|
|
637
|
+
slug = meeting.slug;
|
|
638
|
+
say(`Meeting: ${describeMeeting(meeting)}.`);
|
|
639
|
+
}
|
|
640
|
+
if (names.includes("bevy") && filters.meetingId) {
|
|
641
|
+
const answers = [];
|
|
642
|
+
for await (const page of source.rows("responses", { meetingId: filters.meetingId })) answers.push(...page);
|
|
643
|
+
const survey = bevySurvey(answers);
|
|
644
|
+
formats = formats.map((f) => f.name === "bevy" ? bevyFormat(survey) : f);
|
|
645
|
+
}
|
|
646
|
+
const files = await planFiles(formats, (format) => defaultName(kind, values, format, slug), values, interactive, deps.askPath ?? askPath);
|
|
647
|
+
if (values["dry-run"] === true || isDryRun()) {
|
|
648
|
+
for (const file of files) say(`Would export ${kind}${as(file.format)} to ${shown(file.path)}, audited as ${login}.`);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const open = deps.sink ?? fileSink;
|
|
652
|
+
const writing = [];
|
|
653
|
+
for (const file of files) {
|
|
654
|
+
const sink = await open(file.path, file.overwrite, file.format.bom && file.path !== "-");
|
|
655
|
+
sinks.push(sink);
|
|
656
|
+
const recorded = auditFilters(filters);
|
|
657
|
+
if (file.format.name !== "platform") recorded.format = file.format.name;
|
|
658
|
+
writing.push({
|
|
659
|
+
file,
|
|
660
|
+
sink,
|
|
661
|
+
writer: file.format.writer(),
|
|
662
|
+
auditId: await source.audit(officers[0], kind, recorded),
|
|
663
|
+
count: 0
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
for (const w of writing) if (w.file.format.head) await w.sink.write(w.file.format.head);
|
|
667
|
+
for await (const page of source.rows(kind, filters)) for (const w of writing) {
|
|
668
|
+
const { text, count } = w.writer.page(page);
|
|
669
|
+
if (text) await w.sink.write(text);
|
|
670
|
+
w.count += count;
|
|
671
|
+
}
|
|
672
|
+
for (const w of writing) {
|
|
673
|
+
const last = w.writer.end?.();
|
|
674
|
+
if (last?.text) await w.sink.write(last.text);
|
|
675
|
+
w.count += last?.count ?? 0;
|
|
676
|
+
await w.sink.close();
|
|
677
|
+
}
|
|
678
|
+
done = true;
|
|
679
|
+
for (const w of writing) {
|
|
680
|
+
await source.finish(w.auditId, w.count);
|
|
681
|
+
const [one, many] = w.file.format.unit;
|
|
682
|
+
say(`Wrote ${plural(w.count, one, many)}${as(w.file.format)} to ${shown(w.file.path)}.`, "success");
|
|
683
|
+
const skipped = w.writer.skipped();
|
|
684
|
+
if (skipped > 0) say(`${plural(skipped, "attendee")} without ${w.file.format.needs} on file ${skipped === 1 ? "is" : "are"} not in ${shown(w.file.path)}.`, "warn");
|
|
685
|
+
}
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if (!done) await Promise.all(sinks.map((s) => s.discard().catch(() => void 0)));
|
|
688
|
+
const worded = err instanceof UsageError || err instanceof ProductionError ? err : err.code ? dbError("The export failed", err) : err;
|
|
689
|
+
reportFailure("export", worded);
|
|
690
|
+
} finally {
|
|
691
|
+
await source?.close().catch(() => void 0);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const handleExport = async (rest) => {
|
|
695
|
+
await runExport(rest);
|
|
696
|
+
return process.exitCode ? null : DONE;
|
|
697
|
+
};
|
|
698
|
+
//#endregion
|
|
699
|
+
export { handleExport };
|