pm-csv 2026.6.2 → 2026.6.5
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 +29 -1
- package/README.md +136 -22
- package/dist/index.d.ts +189 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +775 -166
- package/dist/index.js.map +1 -1
- package/manifest.json +3 -3
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -25,7 +25,10 @@ class CommandError extends Error {
|
|
|
25
25
|
this.exitCode = exitCode;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
// Columns accepted on import (order independent — driven by header row)
|
|
28
|
+
// Columns accepted on import (order independent — driven by header row).
|
|
29
|
+
// The relational/planning fields (parent, assignee, sprint, release,
|
|
30
|
+
// blocked_by) all map to real `pm create`/`pm update` flags verified against
|
|
31
|
+
// the installed CLI (--parent, --assignee, --sprint, --release, --blocked-by).
|
|
29
32
|
const IMPORT_COLUMNS = [
|
|
30
33
|
"title",
|
|
31
34
|
"type",
|
|
@@ -34,6 +37,11 @@ const IMPORT_COLUMNS = [
|
|
|
34
37
|
"tags",
|
|
35
38
|
"deadline",
|
|
36
39
|
"body",
|
|
40
|
+
"parent",
|
|
41
|
+
"assignee",
|
|
42
|
+
"sprint",
|
|
43
|
+
"release",
|
|
44
|
+
"blocked_by",
|
|
37
45
|
];
|
|
38
46
|
// Columns written on export (fixed order)
|
|
39
47
|
const EXPORT_COLUMNS = [
|
|
@@ -45,9 +53,26 @@ const EXPORT_COLUMNS = [
|
|
|
45
53
|
"tags",
|
|
46
54
|
"deadline",
|
|
47
55
|
"body",
|
|
56
|
+
"parent",
|
|
57
|
+
"assignee",
|
|
58
|
+
"sprint",
|
|
59
|
+
"release",
|
|
60
|
+
"blocked_by",
|
|
61
|
+
"csv_source",
|
|
48
62
|
"created_at",
|
|
49
63
|
"updated_at",
|
|
50
64
|
];
|
|
65
|
+
/** Recognized status values for the `csv validate` report. */
|
|
66
|
+
const KNOWN_STATUSES = new Set([
|
|
67
|
+
"open", "todo", "new",
|
|
68
|
+
"in_progress", "wip", "in progress", "doing",
|
|
69
|
+
"blocked", "on_hold", "on hold",
|
|
70
|
+
"closed", "done", "complete", "completed",
|
|
71
|
+
"canceled", "cancelled",
|
|
72
|
+
"draft",
|
|
73
|
+
]);
|
|
74
|
+
/** Supported file encodings for `--encoding` on import. */
|
|
75
|
+
const SUPPORTED_ENCODINGS = ["utf-8", "utf8", "utf16le", "latin1"];
|
|
51
76
|
// ---------------------------------------------------------------------------
|
|
52
77
|
// CSV parser — no external dependencies
|
|
53
78
|
// ---------------------------------------------------------------------------
|
|
@@ -123,6 +148,14 @@ function parseCSV(text, delimiter = ",") {
|
|
|
123
148
|
}
|
|
124
149
|
return rows;
|
|
125
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Strip a leading UTF-8 BOM (U+FEFF) if present. Files exported by Excel and
|
|
153
|
+
* many Windows tools start with a BOM; without removing it the first header
|
|
154
|
+
* name silently becomes "title" and the required-column check fails.
|
|
155
|
+
*/
|
|
156
|
+
function stripBOM(text) {
|
|
157
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
158
|
+
}
|
|
126
159
|
/**
|
|
127
160
|
* Serialize a single field value for CSV output.
|
|
128
161
|
* Wraps in double-quotes when the value contains the delimiter, quotes, or newlines.
|
|
@@ -140,10 +173,14 @@ function serializeField(value, delimiter) {
|
|
|
140
173
|
/**
|
|
141
174
|
* Serialize a full array of rows into a CSV string.
|
|
142
175
|
*/
|
|
143
|
-
function serializeCSV(rows,
|
|
176
|
+
function serializeCSV(rows, delimiterOrOpts) {
|
|
177
|
+
const opts = typeof delimiterOrOpts === "string"
|
|
178
|
+
? { delimiter: delimiterOrOpts }
|
|
179
|
+
: delimiterOrOpts;
|
|
180
|
+
const eol = opts.eol ?? "\n";
|
|
144
181
|
return rows
|
|
145
|
-
.map((row) => row.map((f) => serializeField(f, delimiter)).join(delimiter))
|
|
146
|
-
.join(
|
|
182
|
+
.map((row) => row.map((f) => serializeField(f, opts.delimiter)).join(opts.delimiter))
|
|
183
|
+
.join(eol);
|
|
147
184
|
}
|
|
148
185
|
// ---------------------------------------------------------------------------
|
|
149
186
|
// Helpers
|
|
@@ -160,6 +197,102 @@ function readBoolOption(options, ...keys) {
|
|
|
160
197
|
}
|
|
161
198
|
return false;
|
|
162
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether the CSV header row should be omitted. The host parses `--no-header`
|
|
202
|
+
* as the negation of the registered positive `--header` flag (setting
|
|
203
|
+
* `header=false`); the literal `no-header`/`noHeader` keys are accepted as a
|
|
204
|
+
* fallback for the exporter-capability path. Precedence: an explicit
|
|
205
|
+
* `header=false` (i.e. `--no-header`) always wins. Shared by the `csv export`
|
|
206
|
+
* command and the `csv-export` exporter so the two paths can't drift.
|
|
207
|
+
*/
|
|
208
|
+
function readNoHeaderOption(options) {
|
|
209
|
+
return options["header"] === false || readBoolOption(options, "no-header", "noHeader");
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Resolve a user-supplied delimiter, accepting friendly aliases so TSV is easy:
|
|
213
|
+
* --delimiter tab --delimiter "\t" --delimiter ";"
|
|
214
|
+
* A literal backslash-t is interpreted as a tab.
|
|
215
|
+
*/
|
|
216
|
+
function resolveDelimiter(raw) {
|
|
217
|
+
if (raw === undefined || raw === "")
|
|
218
|
+
return ",";
|
|
219
|
+
const lower = raw.toLowerCase();
|
|
220
|
+
if (lower === "tab" || lower === "\\t" || lower === "tsv")
|
|
221
|
+
return "\t";
|
|
222
|
+
if (lower === "comma")
|
|
223
|
+
return ",";
|
|
224
|
+
if (lower === "semicolon")
|
|
225
|
+
return ";";
|
|
226
|
+
if (lower === "pipe")
|
|
227
|
+
return "|";
|
|
228
|
+
return raw;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Provenance tag prefix used for idempotent upsert. When the importer is told
|
|
232
|
+
* to key on a column, the created item is tagged `csv-key:<value>` so a later
|
|
233
|
+
* re-import can find and update the same item instead of duplicating it.
|
|
234
|
+
*/
|
|
235
|
+
const KEY_TAG_PREFIX = "csv-key:";
|
|
236
|
+
// Provenance tag prefix written when `--source <label>` is given. The CLI's
|
|
237
|
+
// registerItemFields registers the `csv_source` schema field but (as of
|
|
238
|
+
// pm 2026.5.31) does not expose a `pm create --csv_source` setter, so we
|
|
239
|
+
// persist the label as a queryable tag. Stripped from exports like csv-key.
|
|
240
|
+
const SOURCE_TAG_PREFIX = "csv-source:";
|
|
241
|
+
const PM_LIST_MAX_BUFFER = 16 * 1024 * 1024;
|
|
242
|
+
/**
|
|
243
|
+
* Normalize a dedup key value for stable matching. pm lower-cases tags on
|
|
244
|
+
* storage, so a `csv-key:` tag written from "Fix Bug" comes back as
|
|
245
|
+
* "fix bug"; we therefore fold the key to lower-case on BOTH write and lookup
|
|
246
|
+
* so re-imports match (and thus update) instead of duplicating.
|
|
247
|
+
*/
|
|
248
|
+
function normalizeKeyValue(value) {
|
|
249
|
+
return value.trim().toLowerCase();
|
|
250
|
+
}
|
|
251
|
+
function encodeKeyTagValue(value) {
|
|
252
|
+
return encodeURIComponent(value);
|
|
253
|
+
}
|
|
254
|
+
function decodeKeyTagValue(value) {
|
|
255
|
+
try {
|
|
256
|
+
return decodeURIComponent(value);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Parse a `--map csvHeader=field` spec (repeatable / comma-joined) into a
|
|
264
|
+
* lookup from a normalized CSV header name to the canonical pm field name.
|
|
265
|
+
* Example: `--map "Summary=title,Owner=tags"`.
|
|
266
|
+
*/
|
|
267
|
+
function parseFieldMap(spec) {
|
|
268
|
+
const map = {};
|
|
269
|
+
if (spec === undefined)
|
|
270
|
+
return map;
|
|
271
|
+
const parts = (Array.isArray(spec) ? spec : [spec]).flatMap((s) => s.split(","));
|
|
272
|
+
for (const part of parts) {
|
|
273
|
+
const trimmed = part.trim();
|
|
274
|
+
if (!trimmed)
|
|
275
|
+
continue;
|
|
276
|
+
const eq = trimmed.indexOf("=");
|
|
277
|
+
if (eq < 0) {
|
|
278
|
+
throw new CommandError(`Invalid --map entry "${trimmed}"; expected csvHeader=field`, EXIT_CODE.USAGE);
|
|
279
|
+
}
|
|
280
|
+
const from = trimmed.slice(0, eq).trim().toLowerCase();
|
|
281
|
+
const to = trimmed.slice(eq + 1).trim().toLowerCase();
|
|
282
|
+
if (!from || !to) {
|
|
283
|
+
throw new CommandError(`Invalid --map entry "${trimmed}"; expected csvHeader=field`, EXIT_CODE.USAGE);
|
|
284
|
+
}
|
|
285
|
+
map[from] = to;
|
|
286
|
+
}
|
|
287
|
+
return map;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Apply a field map to a list of header names, producing the effective
|
|
291
|
+
* (canonical) header used for column lookup.
|
|
292
|
+
*/
|
|
293
|
+
function applyFieldMap(headers, fieldMap) {
|
|
294
|
+
return headers.map((h) => fieldMap[h] ?? h);
|
|
295
|
+
}
|
|
163
296
|
/**
|
|
164
297
|
* Map an arbitrary status string (from the CSV) to a valid SDK status.
|
|
165
298
|
* Falls back to "open".
|
|
@@ -204,12 +337,27 @@ function stringifyTags(tags) {
|
|
|
204
337
|
return "";
|
|
205
338
|
return tags.join(",");
|
|
206
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Validate and normalize a user-supplied `--encoding` value to a Node-supported
|
|
342
|
+
* BufferEncoding. Accepts utf-8/utf8, utf16le, latin1. Throws USAGE otherwise.
|
|
343
|
+
*/
|
|
344
|
+
function resolveEncoding(raw) {
|
|
345
|
+
if (raw === undefined || raw === "")
|
|
346
|
+
return "utf-8";
|
|
347
|
+
const lower = raw.trim().toLowerCase();
|
|
348
|
+
if (SUPPORTED_ENCODINGS.includes(lower)) {
|
|
349
|
+
return lower;
|
|
350
|
+
}
|
|
351
|
+
throw new CommandError(`Unknown --encoding '${raw}'. Supported: ${SUPPORTED_ENCODINGS.join(", ")}`, EXIT_CODE.USAGE);
|
|
352
|
+
}
|
|
207
353
|
/**
|
|
208
354
|
* Read rows from a CSV file, returning header and data rows separately.
|
|
209
|
-
* Skips fully-empty rows.
|
|
355
|
+
* Skips fully-empty rows. Decodes with the given encoding (default utf-8).
|
|
210
356
|
*/
|
|
211
|
-
function readCSVFile(filePath, delimiter) {
|
|
212
|
-
|
|
357
|
+
function readCSVFile(filePath, delimiter, encoding = "utf-8") {
|
|
358
|
+
// Node's BufferEncoding spells utf-8 as "utf8"; normalize.
|
|
359
|
+
const bufEnc = encoding === "utf-8" ? "utf8" : encoding;
|
|
360
|
+
const text = stripBOM(readFileSync(filePath, bufEnc));
|
|
213
361
|
const rows = parseCSV(text, delimiter).filter((r) => r.some((f) => f.trim() !== ""));
|
|
214
362
|
if (rows.length === 0) {
|
|
215
363
|
return { headers: [], dataRows: [] };
|
|
@@ -218,20 +366,432 @@ function readCSVFile(filePath, delimiter) {
|
|
|
218
366
|
const dataRows = rows.slice(1);
|
|
219
367
|
return { headers, dataRows };
|
|
220
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* Build the canonical field accessor for a header row + data row.
|
|
371
|
+
*/
|
|
372
|
+
function rowFields(headers, row) {
|
|
373
|
+
const col = (name) => headers.indexOf(name);
|
|
374
|
+
const get = (name) => {
|
|
375
|
+
const idx = col(name);
|
|
376
|
+
return idx >= 0 ? (row[idx] ?? "").trim() : "";
|
|
377
|
+
};
|
|
378
|
+
const rawStatus = get("status");
|
|
379
|
+
const rawPriority = get("priority");
|
|
380
|
+
const priority = rawPriority ? parseInt(rawPriority, 10) : undefined;
|
|
381
|
+
return {
|
|
382
|
+
get,
|
|
383
|
+
parsed: {
|
|
384
|
+
title: get("title"),
|
|
385
|
+
status: rawStatus ? normalizeStatus(rawStatus) : "open",
|
|
386
|
+
priority: priority !== undefined && !isNaN(priority) ? priority : undefined,
|
|
387
|
+
tags: parseTags(get("tags")),
|
|
388
|
+
type: get("type") || undefined,
|
|
389
|
+
// pm has no milestone/due_date fields; map deadline (accept legacy header).
|
|
390
|
+
deadline: get("deadline") || get("due_date") || undefined,
|
|
391
|
+
body: get("body") || undefined,
|
|
392
|
+
parent: get("parent") || undefined,
|
|
393
|
+
assignee: get("assignee") || undefined,
|
|
394
|
+
sprint: get("sprint") || undefined,
|
|
395
|
+
release: get("release") || undefined,
|
|
396
|
+
// Accept both blocked_by and a friendlier blocked-by header.
|
|
397
|
+
blocked_by: get("blocked_by") || get("blocked-by") || undefined,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Parse the `--status`/`--type`/`--priority` import filter flags into a
|
|
403
|
+
* normalized {@link ImportRowFilter}. Mirrors export filter semantics:
|
|
404
|
+
* - status is normalized through {@link normalizeStatus} so the same alias
|
|
405
|
+
* vocabulary as a CSV `status` cell applies (e.g. `--status done` matches
|
|
406
|
+
* rows whose status normalizes to `closed`).
|
|
407
|
+
* - priority must be an integer; a non-integer is a USAGE error.
|
|
408
|
+
* - type is matched case-insensitively (lower-cased here and at compare time).
|
|
409
|
+
* Returns `undefined` when no filter flag is set (the common no-filter path).
|
|
410
|
+
*/
|
|
411
|
+
function parseImportFilter(statusRaw, typeRaw, priorityRaw) {
|
|
412
|
+
const status = statusRaw && statusRaw.trim() ? normalizeStatus(statusRaw) : undefined;
|
|
413
|
+
const type = typeRaw && typeRaw.trim() ? typeRaw.trim().toLowerCase() : undefined;
|
|
414
|
+
let priority;
|
|
415
|
+
if (priorityRaw !== undefined && priorityRaw.trim() !== "") {
|
|
416
|
+
const n = Number(priorityRaw.trim());
|
|
417
|
+
if (!Number.isInteger(n)) {
|
|
418
|
+
throw new CommandError(`Invalid --priority filter '${priorityRaw}'; expected an integer.`, EXIT_CODE.USAGE);
|
|
419
|
+
}
|
|
420
|
+
priority = n;
|
|
421
|
+
}
|
|
422
|
+
if (status === undefined && type === undefined && priority === undefined) {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
return { status, type, priority };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Pure predicate: does a parsed row satisfy every set filter criterion?
|
|
429
|
+
* Unset criteria are wildcards. Exposed for unit testing.
|
|
430
|
+
*/
|
|
431
|
+
function rowMatchesFilter(row, filter) {
|
|
432
|
+
if (!filter)
|
|
433
|
+
return true;
|
|
434
|
+
if (filter.status !== undefined && row.status !== filter.status)
|
|
435
|
+
return false;
|
|
436
|
+
if (filter.type !== undefined && (row.type ?? "").toLowerCase() !== filter.type)
|
|
437
|
+
return false;
|
|
438
|
+
if (filter.priority !== undefined && row.priority !== filter.priority)
|
|
439
|
+
return false;
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* List existing items once and build a lookup from csv-key provenance value to
|
|
444
|
+
* item id, for idempotent upsert.
|
|
445
|
+
*/
|
|
446
|
+
function loadKeyIndex(pmRoot) {
|
|
447
|
+
const index = new Map();
|
|
448
|
+
const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json"], { encoding: "utf-8", maxBuffer: PM_LIST_MAX_BUFFER });
|
|
449
|
+
if (result.error)
|
|
450
|
+
throw new CommandError(`pm list-all failed: ${result.error.message}`);
|
|
451
|
+
if (result.status !== 0) {
|
|
452
|
+
throw new CommandError(result.stderr?.trim() || "pm list-all failed");
|
|
453
|
+
}
|
|
454
|
+
let items = [];
|
|
455
|
+
try {
|
|
456
|
+
items = JSON.parse(result.stdout).items ?? [];
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return index;
|
|
460
|
+
}
|
|
461
|
+
for (const item of items) {
|
|
462
|
+
for (const tag of item.tags ?? []) {
|
|
463
|
+
if (tag.startsWith(KEY_TAG_PREFIX)) {
|
|
464
|
+
index.set(normalizeKeyValue(decodeKeyTagValue(tag.slice(KEY_TAG_PREFIX.length))), item.id);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return index;
|
|
469
|
+
}
|
|
470
|
+
function importCSV(pmRoot, filePath, opts) {
|
|
471
|
+
const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8");
|
|
472
|
+
const result = { imported: 0, updated: 0, skipped: 0, filtered: 0, errors: [], previews: [] };
|
|
473
|
+
if (rawHeaders.length === 0)
|
|
474
|
+
return result;
|
|
475
|
+
const headers = applyFieldMap(rawHeaders, opts.fieldMap);
|
|
476
|
+
if (!headers.includes("title")) {
|
|
477
|
+
throw new CommandError(`CSV is missing required 'title' column (after --map). Found: ${headers.join(", ")}`, EXIT_CODE.USAGE);
|
|
478
|
+
}
|
|
479
|
+
if (opts.keyField && !headers.includes(opts.keyField)) {
|
|
480
|
+
throw new CommandError(`--key column '${opts.keyField}' not found in CSV. Found: ${headers.join(", ")}`, EXIT_CODE.USAGE);
|
|
481
|
+
}
|
|
482
|
+
// Pre-load the dedup index only when upserting (one extra pm call, not per-row).
|
|
483
|
+
const keyIndex = opts.keyField && !opts.dryRun ? loadKeyIndex(pmRoot) : new Map();
|
|
484
|
+
for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
|
|
485
|
+
const row = dataRows[rowIndex];
|
|
486
|
+
const lineNo = rowIndex + 2; // 1-based + header row
|
|
487
|
+
const { get, parsed } = rowFields(headers, row);
|
|
488
|
+
if (!parsed.title) {
|
|
489
|
+
const msg = `Row ${lineNo}: skipping — 'title' is empty`;
|
|
490
|
+
console.error(msg);
|
|
491
|
+
result.skipped++;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Row-level filter (mirrors export filter semantics): skip non-matching
|
|
495
|
+
// rows BEFORE any create/update so they never become pm items.
|
|
496
|
+
if (!rowMatchesFilter(parsed, opts.filter)) {
|
|
497
|
+
result.skipped++;
|
|
498
|
+
result.filtered++;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const keyValue = opts.keyField ? get(opts.keyField) : "";
|
|
502
|
+
const existingId = keyValue ? keyIndex.get(normalizeKeyValue(keyValue)) : undefined;
|
|
503
|
+
if (opts.dryRun) {
|
|
504
|
+
result.previews.push({
|
|
505
|
+
action: existingId ? "update" : "create",
|
|
506
|
+
...parsed,
|
|
507
|
+
...(opts.keyField ? { key: keyValue } : {}),
|
|
508
|
+
...(opts.source ? { csv_source: opts.source } : {}),
|
|
509
|
+
});
|
|
510
|
+
if (existingId)
|
|
511
|
+
result.updated++;
|
|
512
|
+
else
|
|
513
|
+
result.imported++;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
if (existingId) {
|
|
518
|
+
upsertUpdate(pmRoot, existingId, parsed, opts.source);
|
|
519
|
+
result.updated++;
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
const newId = upsertCreate(pmRoot, parsed, opts.keyField ? keyValue : undefined, opts.source);
|
|
523
|
+
if (opts.keyField && keyValue && newId)
|
|
524
|
+
keyIndex.set(normalizeKeyValue(keyValue), newId);
|
|
525
|
+
result.imported++;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
const msg = `Row ${lineNo}: ${existingId ? "update" : "create"} failed — ${err instanceof Error ? err.message : String(err)}`;
|
|
530
|
+
console.error(msg);
|
|
531
|
+
result.errors.push(msg);
|
|
532
|
+
result.skipped++;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Append the relational/planning field flags shared by create and update.
|
|
539
|
+
* Flag names verified against the installed `pm create`/`pm update` contracts:
|
|
540
|
+
* --parent, --assignee, --sprint, --release, --blocked-by.
|
|
541
|
+
*/
|
|
542
|
+
function appendRelationalArgs(args, p) {
|
|
543
|
+
if (p.parent)
|
|
544
|
+
args.push("--parent", p.parent);
|
|
545
|
+
if (p.assignee)
|
|
546
|
+
args.push("--assignee", p.assignee);
|
|
547
|
+
if (p.sprint)
|
|
548
|
+
args.push("--sprint", p.sprint);
|
|
549
|
+
if (p.release)
|
|
550
|
+
args.push("--release", p.release);
|
|
551
|
+
if (p.blocked_by)
|
|
552
|
+
args.push("--blocked-by", p.blocked_by);
|
|
553
|
+
}
|
|
554
|
+
/** Create a new item, optionally carrying a csv-key provenance tag. Returns the new id. */
|
|
555
|
+
function upsertCreate(pmRoot, p, keyValue, source) {
|
|
556
|
+
const tags = [...p.tags];
|
|
557
|
+
// Encode the lower-cased key so the stored tag matches the lookup index
|
|
558
|
+
// regardless of pm's tag case-folding (see normalizeKeyValue).
|
|
559
|
+
if (keyValue)
|
|
560
|
+
tags.push(`${KEY_TAG_PREFIX}${encodeKeyTagValue(normalizeKeyValue(keyValue))}`);
|
|
561
|
+
// Provenance for the schema-registered csv_source field, persisted as a tag
|
|
562
|
+
// since the CLI exposes no scalar setter for extension-registered fields.
|
|
563
|
+
if (source)
|
|
564
|
+
tags.push(`${SOURCE_TAG_PREFIX}${encodeKeyTagValue(source)}`);
|
|
565
|
+
const args = ["--path", pmRoot, "create", "--title", p.title, "--status", p.status, "--json"];
|
|
566
|
+
if (p.body)
|
|
567
|
+
args.push("--body", p.body);
|
|
568
|
+
if (p.priority !== undefined)
|
|
569
|
+
args.push("--priority", String(p.priority));
|
|
570
|
+
if (p.type)
|
|
571
|
+
args.push("--type", p.type);
|
|
572
|
+
if (p.deadline)
|
|
573
|
+
args.push("--deadline", p.deadline);
|
|
574
|
+
appendRelationalArgs(args, p);
|
|
575
|
+
if (tags.length > 0)
|
|
576
|
+
args.push("--tags", tags.join(","));
|
|
577
|
+
const r = spawnSync("pm", args, { encoding: "utf-8" });
|
|
578
|
+
if (r.status !== 0)
|
|
579
|
+
throw new Error(r.stderr?.trim() || "pm create failed");
|
|
580
|
+
try {
|
|
581
|
+
const parsed = JSON.parse(r.stdout);
|
|
582
|
+
return parsed.id ?? parsed.item?.id ?? "";
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return "";
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** Update an existing item in place (status via update; close handled separately). */
|
|
589
|
+
function upsertUpdate(pmRoot, id, p, source) {
|
|
590
|
+
const args = ["--path", pmRoot, "update", id, "--title", p.title];
|
|
591
|
+
if (p.body !== undefined)
|
|
592
|
+
args.push("--body", p.body);
|
|
593
|
+
if (p.priority !== undefined)
|
|
594
|
+
args.push("--priority", String(p.priority));
|
|
595
|
+
if (p.type)
|
|
596
|
+
args.push("--type", p.type);
|
|
597
|
+
if (p.deadline)
|
|
598
|
+
args.push("--deadline", p.deadline);
|
|
599
|
+
appendRelationalArgs(args, p);
|
|
600
|
+
// Preserve the csv-key tag (additive) and refresh the user tags.
|
|
601
|
+
const addTags = [...p.tags];
|
|
602
|
+
if (source)
|
|
603
|
+
addTags.push(`${SOURCE_TAG_PREFIX}${encodeKeyTagValue(source)}`);
|
|
604
|
+
if (addTags.length > 0)
|
|
605
|
+
args.push("--add-tags", addTags.join(","));
|
|
606
|
+
// `update` cannot set a closed status; only set non-closed statuses here.
|
|
607
|
+
if (p.status !== "closed" && p.status !== "canceled")
|
|
608
|
+
args.push("--status", p.status);
|
|
609
|
+
const r = spawnSync("pm", args, { encoding: "utf-8" });
|
|
610
|
+
if (r.status !== 0)
|
|
611
|
+
throw new Error(r.stderr?.trim() || "pm update failed");
|
|
612
|
+
// Apply terminal statuses through the dedicated close command.
|
|
613
|
+
if (p.status === "closed" || p.status === "canceled") {
|
|
614
|
+
const reason = p.status === "canceled" ? "canceled" : "completed";
|
|
615
|
+
const cr = spawnSync("pm", ["--path", pmRoot, "close", id, "--reason", reason], { encoding: "utf-8" });
|
|
616
|
+
if (cr.status !== 0)
|
|
617
|
+
throw new Error(cr.stderr?.trim() || "pm close failed");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Parse a CSV and report data-quality issues without writing anything.
|
|
622
|
+
* Pure function over file contents — exposed for unit testing via the
|
|
623
|
+
* lower-level {@link validateParsedCSV} helper below.
|
|
624
|
+
*/
|
|
625
|
+
function validateCSV(filePath, opts) {
|
|
626
|
+
const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8");
|
|
627
|
+
return validateParsedCSV(rawHeaders, dataRows, opts.fieldMap);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Core validation logic over already-parsed headers + rows. Pure and
|
|
631
|
+
* side-effect-free so it can be unit tested directly.
|
|
632
|
+
*/
|
|
633
|
+
function validateParsedCSV(rawHeaders, dataRows, fieldMap) {
|
|
634
|
+
const mappedColumns = applyFieldMap(rawHeaders, fieldMap);
|
|
635
|
+
const hasTitleColumn = mappedColumns.includes("title");
|
|
636
|
+
const issues = [];
|
|
637
|
+
let rowsMissingTitle = 0;
|
|
638
|
+
let rowsWithUnknownStatus = 0;
|
|
639
|
+
let rowsWithNonIntegerPriority = 0;
|
|
640
|
+
const titleIdx = mappedColumns.indexOf("title");
|
|
641
|
+
const statusIdx = mappedColumns.indexOf("status");
|
|
642
|
+
const priorityIdx = mappedColumns.indexOf("priority");
|
|
643
|
+
for (const row of dataRows) {
|
|
644
|
+
if (hasTitleColumn) {
|
|
645
|
+
const title = (row[titleIdx] ?? "").trim();
|
|
646
|
+
if (!title)
|
|
647
|
+
rowsMissingTitle++;
|
|
648
|
+
}
|
|
649
|
+
if (statusIdx >= 0) {
|
|
650
|
+
const status = (row[statusIdx] ?? "").trim().toLowerCase();
|
|
651
|
+
if (status && !KNOWN_STATUSES.has(status))
|
|
652
|
+
rowsWithUnknownStatus++;
|
|
653
|
+
}
|
|
654
|
+
if (priorityIdx >= 0) {
|
|
655
|
+
const priority = (row[priorityIdx] ?? "").trim();
|
|
656
|
+
if (priority && !/^-?\d+$/.test(priority))
|
|
657
|
+
rowsWithNonIntegerPriority++;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (rawHeaders.length === 0) {
|
|
661
|
+
issues.push("CSV is empty (no header row).");
|
|
662
|
+
}
|
|
663
|
+
if (!hasTitleColumn) {
|
|
664
|
+
issues.push(`Missing required 'title' column (after --map). Detected: ${mappedColumns.join(", ") || "(none)"}`);
|
|
665
|
+
}
|
|
666
|
+
if (rowsMissingTitle > 0) {
|
|
667
|
+
issues.push(`${rowsMissingTitle} row(s) have an empty title and would be skipped.`);
|
|
668
|
+
}
|
|
669
|
+
if (rowsWithUnknownStatus > 0) {
|
|
670
|
+
issues.push(`${rowsWithUnknownStatus} row(s) have an unrecognized status (would fall back to 'open').`);
|
|
671
|
+
}
|
|
672
|
+
if (rowsWithNonIntegerPriority > 0) {
|
|
673
|
+
issues.push(`${rowsWithNonIntegerPriority} row(s) have a non-integer priority (would be ignored).`);
|
|
674
|
+
}
|
|
675
|
+
// Only a missing title column (or empty file) is a structural problem.
|
|
676
|
+
const ok = hasTitleColumn && rawHeaders.length > 0;
|
|
677
|
+
return {
|
|
678
|
+
ok,
|
|
679
|
+
rowCount: dataRows.length,
|
|
680
|
+
detectedColumns: rawHeaders,
|
|
681
|
+
mappedColumns,
|
|
682
|
+
hasTitleColumn,
|
|
683
|
+
rowsMissingTitle,
|
|
684
|
+
rowsWithUnknownStatus,
|
|
685
|
+
rowsWithNonIntegerPriority,
|
|
686
|
+
issues,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Discover custom item fields registered in the workspace runtime schema and
|
|
691
|
+
* return those that are NOT already covered by the built-in export columns
|
|
692
|
+
* (or the provenance `csv_source` column).
|
|
693
|
+
*
|
|
694
|
+
* This is the standalone-extension-safe equivalent of the SDK's
|
|
695
|
+
* `resolveRuntimeFieldRegistry(settings.schema)`: a standalone-installed
|
|
696
|
+
* extension only loads its own `dist/`, so `@unbrained/pm-cli` is not
|
|
697
|
+
* resolvable at runtime and the SDK function cannot be imported. We instead
|
|
698
|
+
* read the very same inputs that function consumes — the workspace
|
|
699
|
+
* `settings.json` `schema.fields` plus the file it points at
|
|
700
|
+
* (`schema.files.fields`, default `schema/fields.json`) — and merge them by
|
|
701
|
+
* field key. The shape matches the SDK `RuntimeFieldDefinition` type.
|
|
702
|
+
*
|
|
703
|
+
* Never throws: any read/parse problem yields an empty list so export still
|
|
704
|
+
* works on hosts without a runtime field schema.
|
|
705
|
+
*/
|
|
706
|
+
function discoverCustomFields(pmRoot) {
|
|
707
|
+
const builtin = new Set(EXPORT_COLUMNS);
|
|
708
|
+
const byKey = new Map();
|
|
709
|
+
const collect = (fields) => {
|
|
710
|
+
if (!Array.isArray(fields))
|
|
711
|
+
return;
|
|
712
|
+
for (const raw of fields) {
|
|
713
|
+
if (!raw || typeof raw !== "object")
|
|
714
|
+
continue;
|
|
715
|
+
const def = raw;
|
|
716
|
+
const key = typeof def.key === "string" ? def.key.trim() : "";
|
|
717
|
+
if (!key || builtin.has(key))
|
|
718
|
+
continue;
|
|
719
|
+
const metadataKey = (typeof def.metadata_key === "string" && def.metadata_key.trim()) ||
|
|
720
|
+
(typeof def.front_matter_key === "string" && def.front_matter_key.trim()) ||
|
|
721
|
+
key;
|
|
722
|
+
if (!byKey.has(key))
|
|
723
|
+
byKey.set(key, { key, metadataKey });
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
let settings = {};
|
|
727
|
+
try {
|
|
728
|
+
settings = JSON.parse(readFileSync(resolve(pmRoot, "settings.json"), "utf-8"));
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
return [];
|
|
732
|
+
}
|
|
733
|
+
const schema = (settings["schema"] ?? {});
|
|
734
|
+
// Inline fields declared directly in settings.json.
|
|
735
|
+
collect(schema["fields"]);
|
|
736
|
+
// File-backed fields (schema.files.fields, default schema/fields.json) — the
|
|
737
|
+
// path the CLI scaffolds and the SDK loader reads.
|
|
738
|
+
const files = (schema["files"] ?? {});
|
|
739
|
+
const fieldsPath = typeof files["fields"] === "string" && files["fields"].trim()
|
|
740
|
+
? files["fields"]
|
|
741
|
+
: "schema/fields.json";
|
|
742
|
+
try {
|
|
743
|
+
const fileJson = JSON.parse(readFileSync(resolve(pmRoot, fieldsPath), "utf-8"));
|
|
744
|
+
collect(fileJson?.fields);
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
// No file / unreadable / unparsable — inline fields (if any) still apply.
|
|
748
|
+
}
|
|
749
|
+
return [...byKey.values()];
|
|
750
|
+
}
|
|
221
751
|
// Parse a `--columns id,title,status` spec into a validated, ordered subset of
|
|
222
752
|
// the export columns. Unknown column names throw a USAGE error; an empty/absent
|
|
223
|
-
// spec falls back to the full default column set.
|
|
224
|
-
|
|
753
|
+
// spec falls back to the full default column set. `extraValid` lets discovered
|
|
754
|
+
// custom-field keys be selected explicitly via --columns alongside --all-fields.
|
|
755
|
+
function selectExportColumns(spec, extraValid = []) {
|
|
225
756
|
if (!spec || !spec.trim())
|
|
226
|
-
return EXPORT_COLUMNS;
|
|
757
|
+
return [...EXPORT_COLUMNS];
|
|
227
758
|
const requested = spec.split(",").map((s) => s.trim()).filter(Boolean);
|
|
228
|
-
const valid = new Set(EXPORT_COLUMNS);
|
|
759
|
+
const valid = new Set([...EXPORT_COLUMNS, ...extraValid]);
|
|
229
760
|
const unknown = requested.filter((c) => !valid.has(c));
|
|
230
761
|
if (unknown.length > 0) {
|
|
231
|
-
|
|
762
|
+
const validList = [...EXPORT_COLUMNS, ...extraValid].join(", ");
|
|
763
|
+
throw new CommandError(`Unknown export column(s): ${unknown.join(", ")}. Valid: ${validList}`, EXIT_CODE.USAGE);
|
|
232
764
|
}
|
|
233
765
|
return requested;
|
|
234
766
|
}
|
|
767
|
+
/**
|
|
768
|
+
* Resolve the effective export column list (and the column→property remap for
|
|
769
|
+
* custom fields) shared by `csv export` and the `csv-export` exporter.
|
|
770
|
+
*
|
|
771
|
+
* - With no `--columns` and no discovery, returns the default built-in set.
|
|
772
|
+
* - With `discover` set, appends every discovered custom field key not already
|
|
773
|
+
* present (default column set otherwise unchanged — strictly additive).
|
|
774
|
+
* - With `--columns`, the explicit, ordered selection wins; discovered custom
|
|
775
|
+
* field keys become selectable names too.
|
|
776
|
+
*/
|
|
777
|
+
function resolveExportColumns(pmRoot, columnsSpec, discover) {
|
|
778
|
+
const discovered = discover ? discoverCustomFields(pmRoot) : [];
|
|
779
|
+
const columnSource = {};
|
|
780
|
+
for (const f of discovered) {
|
|
781
|
+
if (f.key !== f.metadataKey)
|
|
782
|
+
columnSource[f.key] = f.metadataKey;
|
|
783
|
+
}
|
|
784
|
+
if (columnsSpec && columnsSpec.trim()) {
|
|
785
|
+
const columns = selectExportColumns(columnsSpec, discovered.map((f) => f.key));
|
|
786
|
+
return { columns, columnSource };
|
|
787
|
+
}
|
|
788
|
+
const columns = [...EXPORT_COLUMNS];
|
|
789
|
+
for (const f of discovered) {
|
|
790
|
+
if (!columns.includes(f.key))
|
|
791
|
+
columns.push(f.key);
|
|
792
|
+
}
|
|
793
|
+
return { columns, columnSource };
|
|
794
|
+
}
|
|
235
795
|
function buildCsvExport(pmRoot, opts) {
|
|
236
796
|
const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json", "--include-body"], { encoding: "utf-8" });
|
|
237
797
|
if (result.status !== 0) {
|
|
@@ -242,133 +802,205 @@ function buildCsvExport(pmRoot, opts) {
|
|
|
242
802
|
items = items.filter((i) => i.status === opts.statusFilter);
|
|
243
803
|
if (opts.typeFilter)
|
|
244
804
|
items = items.filter((i) => i.type === opts.typeFilter);
|
|
245
|
-
|
|
805
|
+
// Surface provenance: derive csv_source from the internal csv-source: tag.
|
|
806
|
+
for (const item of items) {
|
|
807
|
+
const sourceTag = (item.tags ?? []).find((t) => t.startsWith(SOURCE_TAG_PREFIX));
|
|
808
|
+
if (sourceTag)
|
|
809
|
+
item.csv_source = decodeKeyTagValue(sourceTag.slice(SOURCE_TAG_PREFIX.length));
|
|
810
|
+
}
|
|
246
811
|
const dataRows = items.map((item) => opts.columns.map((col) => {
|
|
247
|
-
const
|
|
812
|
+
const prop = opts.columnSource?.[col] ?? col;
|
|
813
|
+
const val = item[prop];
|
|
248
814
|
if (val === undefined || val === null)
|
|
249
815
|
return "";
|
|
250
|
-
if (Array.isArray(val))
|
|
251
|
-
|
|
816
|
+
if (Array.isArray(val)) {
|
|
817
|
+
// Strip internal provenance tags (csv-key / csv-source) so a round-trip
|
|
818
|
+
// export stays clean.
|
|
819
|
+
const visible = val.filter((t) => typeof t === "string" &&
|
|
820
|
+
!t.startsWith(KEY_TAG_PREFIX) &&
|
|
821
|
+
!t.startsWith(SOURCE_TAG_PREFIX));
|
|
822
|
+
return stringifyTags(visible);
|
|
823
|
+
}
|
|
252
824
|
return String(val);
|
|
253
825
|
}));
|
|
254
|
-
|
|
826
|
+
const allRows = opts.noHeader ? dataRows : [opts.columns.map(String), ...dataRows];
|
|
827
|
+
// --excel implies CRLF (and a UTF-8 BOM prefix, added below).
|
|
828
|
+
const eol = opts.crlf || opts.excel ? "\r\n" : "\n";
|
|
829
|
+
let csvText = serializeCSV(allRows, { delimiter: opts.delimiter, eol });
|
|
830
|
+
if (opts.excel)
|
|
831
|
+
csvText = "" + csvText;
|
|
832
|
+
return {
|
|
833
|
+
csvText,
|
|
834
|
+
count: items.length,
|
|
835
|
+
};
|
|
255
836
|
}
|
|
256
837
|
export default defineExtension({
|
|
257
838
|
name: "pm-csv",
|
|
258
|
-
version: "2026.6.
|
|
839
|
+
version: "2026.6.5",
|
|
259
840
|
activate(api) {
|
|
841
|
+
// -----------------------------------------------------------------------
|
|
842
|
+
// Schema: register an optional `csv_source` provenance field so imported
|
|
843
|
+
// items can record where they came from (set via `pm csv import --source`).
|
|
844
|
+
// Guarded: only call when the running SDK exposes registerItemFields, so
|
|
845
|
+
// older hosts that lack the schema capability degrade to a no-op (and the
|
|
846
|
+
// manifest still declares "schema" because we genuinely implement it).
|
|
847
|
+
//
|
|
848
|
+
// NOTE: pm 2026.5.31 accepts the field into the schema registry but exposes
|
|
849
|
+
// no `pm create --csv_source` setter for extension-registered scalar fields,
|
|
850
|
+
// so the importer persists the provenance label as a `csv-source:` tag
|
|
851
|
+
// (stripped from exports and surfaced back via the csv_source export column).
|
|
852
|
+
// -----------------------------------------------------------------------
|
|
853
|
+
if (typeof api.registerItemFields === "function") {
|
|
854
|
+
try {
|
|
855
|
+
api.registerItemFields([
|
|
856
|
+
{ name: "csv_source", type: "string", optional: true },
|
|
857
|
+
]);
|
|
858
|
+
}
|
|
859
|
+
catch (err) {
|
|
860
|
+
// Never let a schema-registration hiccup break command registration.
|
|
861
|
+
console.error(`pm-csv: csv_source field not registered — ${err instanceof Error ? err.message : String(err)}`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
260
864
|
// -----------------------------------------------------------------------
|
|
261
865
|
// Command: pm csv import <file>
|
|
262
866
|
// -----------------------------------------------------------------------
|
|
263
867
|
api.registerCommand({
|
|
264
868
|
name: "csv import",
|
|
265
|
-
description: "Import pm items from a CSV file
|
|
266
|
-
"
|
|
267
|
-
"
|
|
869
|
+
description: "Import pm items from a CSV file with full RFC-4180 parsing (quoted fields, " +
|
|
870
|
+
"embedded newlines, escaped quotes, BOM, CRLF). Expected columns: title, type, " +
|
|
871
|
+
"status, priority, tags, deadline, body, parent, assignee, sprint, release, " +
|
|
872
|
+
"blocked_by. Only 'title' is required. Use --map to remap arbitrary headers, " +
|
|
873
|
+
"--key for idempotent re-import (upsert), --encoding for non-UTF-8 files, " +
|
|
874
|
+
"--source to record import provenance in the csv_source field, and " +
|
|
875
|
+
"--status/--type/--priority to import only matching rows (others are skipped).",
|
|
268
876
|
intent: "import items from a CSV file into pm",
|
|
269
877
|
examples: [
|
|
270
878
|
"pm csv import tasks.csv",
|
|
271
879
|
"pm csv import backlog.csv --delimiter ';'",
|
|
880
|
+
"pm csv import data.tsv --delimiter tab",
|
|
881
|
+
"pm csv import jira.csv --map 'Summary=title,Owner=tags'",
|
|
882
|
+
"pm csv import items.csv --key title # idempotent re-import (no duplicates)",
|
|
883
|
+
"pm csv import legacy.csv --encoding latin1",
|
|
884
|
+
"pm csv import sprint12.csv --source 'jira-export-2026-06'",
|
|
885
|
+
"pm csv import tasks.csv --status open # import only open rows",
|
|
886
|
+
"pm csv import tasks.csv --type Feature --priority 1",
|
|
272
887
|
"pm csv import items.csv --dry-run",
|
|
273
888
|
],
|
|
274
889
|
flags: [
|
|
275
|
-
{ long: "--delimiter", value_name: "char", description: "
|
|
890
|
+
{ long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
|
|
891
|
+
{ long: "--map", value_name: "col=field", description: "Remap a CSV header to a pm field (repeatable, comma-joined). e.g. --map 'Summary=title'" },
|
|
892
|
+
{ long: "--key", value_name: "field", description: "Dedup key column: re-import updates the matching item instead of creating a duplicate" },
|
|
893
|
+
{ long: "--encoding", value_name: "enc", description: "Source file encoding: utf-8 (default) | utf16le | latin1" },
|
|
894
|
+
{ long: "--source", value_name: "label", description: "Record an import-provenance label in the csv_source field of created/updated items" },
|
|
895
|
+
{ long: "--status", value_name: "filter", description: "Import only rows whose (normalized) status matches: open | in_progress | blocked | closed | canceled | draft" },
|
|
896
|
+
{ long: "--type", value_name: "type", description: "Import only rows whose type matches (case-insensitive)" },
|
|
897
|
+
{ long: "--priority", value_name: "n", description: "Import only rows whose integer priority equals this value" },
|
|
276
898
|
{ long: "--dry-run", description: "Preview without writing" },
|
|
277
899
|
],
|
|
278
900
|
async run(ctx) {
|
|
279
901
|
const filePath = ctx.args[0];
|
|
280
902
|
if (!filePath) {
|
|
281
|
-
throw new CommandError("Usage: pm csv import <file> [--delimiter <char>] [--dry-run]", EXIT_CODE.USAGE);
|
|
903
|
+
throw new CommandError("Usage: pm csv import <file> [--delimiter <char>] [--map col=field] [--key field] [--encoding enc] [--source label] [--status s] [--type t] [--priority n] [--dry-run]", EXIT_CODE.USAGE);
|
|
282
904
|
}
|
|
283
|
-
const delimiter = ctx.options["delimiter"]
|
|
905
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
284
906
|
const dryRun = readBoolOption(ctx.options, "dry-run", "dryRun");
|
|
907
|
+
const fieldMap = parseFieldMap(ctx.options["map"]);
|
|
908
|
+
const keyField = (ctx.options["key"] ?? "").trim().toLowerCase() || undefined;
|
|
909
|
+
const encoding = resolveEncoding(ctx.options["encoding"]);
|
|
910
|
+
const source = (ctx.options["source"] ?? "").trim() || undefined;
|
|
911
|
+
const filter = parseImportFilter(ctx.options["status"], ctx.options["type"], ctx.options["priority"]);
|
|
285
912
|
const absolutePath = resolve(filePath);
|
|
286
913
|
console.error(`Reading CSV from: ${absolutePath}`);
|
|
287
|
-
let
|
|
288
|
-
let dataRows;
|
|
914
|
+
let res;
|
|
289
915
|
try {
|
|
290
|
-
({
|
|
916
|
+
res = importCSV(ctx.pm_root, absolutePath, { delimiter, dryRun, fieldMap, keyField, encoding, source, filter });
|
|
291
917
|
}
|
|
292
918
|
catch (err) {
|
|
919
|
+
if (err instanceof CommandError)
|
|
920
|
+
throw err;
|
|
293
921
|
const msg = err instanceof Error ? err.message : String(err);
|
|
294
922
|
const exitCode = /ENOENT|no such file/i.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
|
|
295
|
-
throw new CommandError(`Failed to
|
|
923
|
+
throw new CommandError(`Failed to import: ${msg}`, exitCode);
|
|
296
924
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
925
|
+
const filterNote = res.filtered > 0 ? ` (${res.filtered} filtered out)` : "";
|
|
926
|
+
if (dryRun) {
|
|
927
|
+
console.error(`[dry-run] Would create ${res.imported}, update ${res.updated}, skip ${res.skipped}${filterNote}.`);
|
|
928
|
+
return {
|
|
929
|
+
dryRun: true,
|
|
930
|
+
wouldCreate: res.imported,
|
|
931
|
+
wouldUpdate: res.updated,
|
|
932
|
+
wouldSkip: res.skipped,
|
|
933
|
+
filtered: res.filtered,
|
|
934
|
+
previews: res.previews,
|
|
935
|
+
};
|
|
300
936
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
937
|
+
console.error(`Imported ${res.imported}, updated ${res.updated}, skipped ${res.skipped}${filterNote}.`);
|
|
938
|
+
return { imported: res.imported, updated: res.updated, skipped: res.skipped, filtered: res.filtered, errors: res.errors };
|
|
939
|
+
},
|
|
940
|
+
});
|
|
941
|
+
// -----------------------------------------------------------------------
|
|
942
|
+
// Command: pm csv validate <file>
|
|
943
|
+
// -----------------------------------------------------------------------
|
|
944
|
+
api.registerCommand({
|
|
945
|
+
name: "csv validate",
|
|
946
|
+
description: "Validate a CSV without importing it. Reports row count, detected/mapped " +
|
|
947
|
+
"columns, rows missing a title, rows with unrecognized status, rows with a " +
|
|
948
|
+
"non-integer priority, and whether the required 'title' column is present " +
|
|
949
|
+
"(after --map). Exits non-zero on structural problems (missing title column). " +
|
|
950
|
+
"Honors --delimiter, --map, --encoding; supports --json.",
|
|
951
|
+
intent: "validate a CSV file without importing",
|
|
952
|
+
examples: [
|
|
953
|
+
"pm csv validate tasks.csv",
|
|
954
|
+
"pm csv validate jira.csv --map 'Summary=title'",
|
|
955
|
+
"pm csv validate data.tsv --delimiter tab --json",
|
|
956
|
+
],
|
|
957
|
+
flags: [
|
|
958
|
+
{ long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
|
|
959
|
+
{ long: "--map", value_name: "col=field", description: "Remap a CSV header to a pm field (repeatable, comma-joined) before validating" },
|
|
960
|
+
{ long: "--encoding", value_name: "enc", description: "Source file encoding: utf-8 (default) | utf16le | latin1" },
|
|
961
|
+
{ long: "--json", description: "Emit the report as JSON" },
|
|
962
|
+
],
|
|
963
|
+
async run(ctx) {
|
|
964
|
+
const filePath = ctx.args[0];
|
|
965
|
+
if (!filePath) {
|
|
966
|
+
throw new CommandError("Usage: pm csv validate <file> [--delimiter <char>] [--map col=field] [--encoding enc] [--json]", EXIT_CODE.USAGE);
|
|
304
967
|
}
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const idx = col(name);
|
|
314
|
-
return idx >= 0 ? (row[idx] ?? "").trim() : "";
|
|
315
|
-
};
|
|
316
|
-
const title = get("title");
|
|
317
|
-
if (!title) {
|
|
318
|
-
console.error(`Row ${rowIndex + 2}: skipping — 'title' is empty`);
|
|
319
|
-
skipped++;
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
const rawStatus = get("status");
|
|
323
|
-
const status = rawStatus ? normalizeStatus(rawStatus) : "open";
|
|
324
|
-
const rawPriority = get("priority");
|
|
325
|
-
const priority = rawPriority ? parseInt(rawPriority, 10) : undefined;
|
|
326
|
-
const tags = parseTags(get("tags"));
|
|
327
|
-
const type = get("type") || undefined;
|
|
328
|
-
// pm has no milestone/due_date fields on `create`; the deadline column
|
|
329
|
-
// (accept legacy "due_date" header too) maps to `--deadline`.
|
|
330
|
-
const deadline = get("deadline") || get("due_date") || undefined;
|
|
331
|
-
const body = get("body") || undefined;
|
|
332
|
-
if (dryRun) {
|
|
333
|
-
previews.push({ title, type, status, priority, tags, deadline, body });
|
|
334
|
-
imported++;
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
try {
|
|
338
|
-
const spawnArgs = [
|
|
339
|
-
"--path", ctx.pm_root,
|
|
340
|
-
"create",
|
|
341
|
-
"--title", title,
|
|
342
|
-
"--status", status,
|
|
343
|
-
];
|
|
344
|
-
if (body)
|
|
345
|
-
spawnArgs.push("--body", body);
|
|
346
|
-
if (priority !== undefined && !isNaN(priority))
|
|
347
|
-
spawnArgs.push("--priority", String(priority));
|
|
348
|
-
if (type)
|
|
349
|
-
spawnArgs.push("--type", type);
|
|
350
|
-
if (deadline)
|
|
351
|
-
spawnArgs.push("--deadline", deadline);
|
|
352
|
-
if (tags.length > 0)
|
|
353
|
-
spawnArgs.push("--tags", tags.join(","));
|
|
354
|
-
const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
|
|
355
|
-
if (result.status !== 0) {
|
|
356
|
-
throw new Error(result.stderr || "pm create failed");
|
|
357
|
-
}
|
|
358
|
-
imported++;
|
|
359
|
-
}
|
|
360
|
-
catch (err) {
|
|
361
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
362
|
-
console.error(`Row ${rowIndex + 2}: create failed — ${msg}`);
|
|
363
|
-
skipped++;
|
|
364
|
-
}
|
|
968
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
969
|
+
const fieldMap = parseFieldMap(ctx.options["map"]);
|
|
970
|
+
const encoding = resolveEncoding(ctx.options["encoding"]);
|
|
971
|
+
const asJson = readBoolOption(ctx.options, "json");
|
|
972
|
+
const absolutePath = resolve(filePath);
|
|
973
|
+
let report;
|
|
974
|
+
try {
|
|
975
|
+
report = validateCSV(absolutePath, { delimiter, fieldMap, encoding });
|
|
365
976
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
977
|
+
catch (err) {
|
|
978
|
+
if (err instanceof CommandError)
|
|
979
|
+
throw err;
|
|
980
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
981
|
+
const exitCode = /ENOENT|no such file/i.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
|
|
982
|
+
throw new CommandError(`Failed to validate: ${msg}`, exitCode);
|
|
369
983
|
}
|
|
370
|
-
|
|
371
|
-
|
|
984
|
+
// Human-readable summary on stderr (so --json stdout stays clean).
|
|
985
|
+
console.error(`Rows: ${report.rowCount}`);
|
|
986
|
+
console.error(`Detected columns: ${report.detectedColumns.join(", ") || "(none)"}`);
|
|
987
|
+
console.error(`Mapped columns: ${report.mappedColumns.join(", ") || "(none)"}`);
|
|
988
|
+
console.error(`Has 'title' column: ${report.hasTitleColumn ? "yes" : "no"}`);
|
|
989
|
+
console.error(`Rows missing title: ${report.rowsMissingTitle}`);
|
|
990
|
+
console.error(`Rows w/ unknown status: ${report.rowsWithUnknownStatus}`);
|
|
991
|
+
console.error(`Rows w/ non-integer priority: ${report.rowsWithNonIntegerPriority}`);
|
|
992
|
+
for (const issue of report.issues)
|
|
993
|
+
console.error(` - ${issue}`);
|
|
994
|
+
console.error(report.ok ? "Validation OK." : "Validation FAILED (structural problems).");
|
|
995
|
+
// Structural problems (no title column / empty) → non-zero exit.
|
|
996
|
+
if (!report.ok) {
|
|
997
|
+
if (asJson) {
|
|
998
|
+
// Surface the structured report even on failure before throwing.
|
|
999
|
+
console.error(JSON.stringify(report, null, 2));
|
|
1000
|
+
}
|
|
1001
|
+
throw new CommandError("CSV is missing the required 'title' column (after --map).", EXIT_CODE.USAGE);
|
|
1002
|
+
}
|
|
1003
|
+
return report;
|
|
372
1004
|
},
|
|
373
1005
|
});
|
|
374
1006
|
// -----------------------------------------------------------------------
|
|
@@ -385,24 +1017,39 @@ export default defineExtension({
|
|
|
385
1017
|
"pm csv export --output backlog.csv --delimiter ';'",
|
|
386
1018
|
"pm csv export --status open --output todos.csv",
|
|
387
1019
|
"pm csv export --type Feature --output features.csv",
|
|
1020
|
+
"pm csv export --all-fields --output full.csv # include custom workspace fields",
|
|
1021
|
+
"pm csv export --excel --output for-excel.csv",
|
|
388
1022
|
],
|
|
389
1023
|
flags: [
|
|
390
1024
|
{ long: "--output", value_name: "file", description: "Output file path (default: print to stdout)" },
|
|
391
|
-
{ long: "--delimiter", value_name: "char", description: "
|
|
1025
|
+
{ long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
|
|
392
1026
|
{ long: "--status", value_name: "filter", description: "Filter by status: open | in_progress | blocked | closed | canceled | draft" },
|
|
393
1027
|
{ long: "--type", value_name: "type", description: "Filter by item type" },
|
|
394
|
-
{ long: "--columns", value_name: "list", description: `Comma-separated columns to export, in order (default: all). Valid: ${EXPORT_COLUMNS.join(", ")}` },
|
|
1028
|
+
{ long: "--columns", value_name: "list", description: `Comma-separated columns to export, in order (default: all). Valid: ${EXPORT_COLUMNS.join(", ")} (plus any discovered custom fields)` },
|
|
1029
|
+
{ long: "--all-fields", description: "Discover custom item fields registered in the workspace schema and append them as columns" },
|
|
1030
|
+
{ long: "--discover-fields", description: "Alias for --all-fields" },
|
|
1031
|
+
{ long: "--header", description: "Include the CSV header row (default: on). Pass --no-header to omit it." },
|
|
1032
|
+
{ long: "--crlf", description: "Use CRLF line endings (RFC-4180 / Excel)" },
|
|
1033
|
+
{ long: "--excel", description: "Excel-friendly output: CRLF line endings + a UTF-8 BOM prefix" },
|
|
395
1034
|
],
|
|
396
1035
|
async run(ctx) {
|
|
397
|
-
const delimiter = ctx.options["delimiter"]
|
|
1036
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
398
1037
|
const outputPath = ctx.options["output"];
|
|
399
|
-
const
|
|
1038
|
+
const discover = readBoolOption(ctx.options, "all-fields", "allFields", "discover-fields", "discoverFields");
|
|
1039
|
+
const { columns, columnSource } = resolveExportColumns(ctx.pm_root, ctx.options["columns"], discover);
|
|
1040
|
+
const noHeader = readNoHeaderOption(ctx.options);
|
|
1041
|
+
const crlf = readBoolOption(ctx.options, "crlf");
|
|
1042
|
+
const excel = readBoolOption(ctx.options, "excel");
|
|
400
1043
|
console.error("Fetching pm items…");
|
|
401
1044
|
const { csvText, count } = buildCsvExport(ctx.pm_root, {
|
|
402
1045
|
statusFilter: ctx.options["status"],
|
|
403
1046
|
typeFilter: ctx.options["type"],
|
|
404
1047
|
delimiter,
|
|
405
1048
|
columns,
|
|
1049
|
+
columnSource,
|
|
1050
|
+
noHeader,
|
|
1051
|
+
crlf,
|
|
1052
|
+
excel,
|
|
406
1053
|
});
|
|
407
1054
|
if (count === 0) {
|
|
408
1055
|
console.error("No items found.");
|
|
@@ -424,14 +1071,22 @@ export default defineExtension({
|
|
|
424
1071
|
// Mirrors the importer so CSV is a first-class import/export pair.
|
|
425
1072
|
// -----------------------------------------------------------------------
|
|
426
1073
|
api.registerExporter("csv-export", async (ctx) => {
|
|
427
|
-
const delimiter = ctx.options["delimiter"]
|
|
1074
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
428
1075
|
const outputPath = ctx.options["output"];
|
|
429
|
-
const
|
|
1076
|
+
const discover = readBoolOption(ctx.options, "all-fields", "allFields", "discover-fields", "discoverFields");
|
|
1077
|
+
const { columns, columnSource } = resolveExportColumns(ctx.pm_root, ctx.options["columns"], discover);
|
|
1078
|
+
const noHeader = readNoHeaderOption(ctx.options);
|
|
1079
|
+
const crlf = readBoolOption(ctx.options, "crlf");
|
|
1080
|
+
const excel = readBoolOption(ctx.options, "excel");
|
|
430
1081
|
const { csvText, count } = buildCsvExport(ctx.pm_root, {
|
|
431
1082
|
statusFilter: ctx.options["status"],
|
|
432
1083
|
typeFilter: ctx.options["type"],
|
|
433
1084
|
delimiter,
|
|
434
1085
|
columns,
|
|
1086
|
+
columnSource,
|
|
1087
|
+
noHeader,
|
|
1088
|
+
crlf,
|
|
1089
|
+
excel,
|
|
435
1090
|
});
|
|
436
1091
|
if (outputPath) {
|
|
437
1092
|
const absolutePath = resolve(outputPath);
|
|
@@ -451,76 +1106,30 @@ export default defineExtension({
|
|
|
451
1106
|
console.error("csv-import: no 'file' provided in options — skipping.");
|
|
452
1107
|
return;
|
|
453
1108
|
}
|
|
454
|
-
const delimiter = ctx.options["delimiter"]
|
|
1109
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
1110
|
+
const fieldMap = parseFieldMap(ctx.options["map"]);
|
|
1111
|
+
const keyField = (ctx.options["key"] ?? "").trim().toLowerCase() || undefined;
|
|
1112
|
+
const encoding = resolveEncoding(ctx.options["encoding"]);
|
|
1113
|
+
const source = (ctx.options["source"] ?? "").trim() || undefined;
|
|
1114
|
+
const filter = parseImportFilter(ctx.options["status"], ctx.options["type"], ctx.options["priority"]);
|
|
455
1115
|
const absolutePath = resolve(filePath);
|
|
456
1116
|
console.error(`csv-import: reading ${absolutePath}`);
|
|
457
|
-
let
|
|
458
|
-
let dataRows;
|
|
1117
|
+
let res;
|
|
459
1118
|
try {
|
|
460
|
-
({
|
|
1119
|
+
res = importCSV(ctx.pm_root, absolutePath, { delimiter, dryRun: false, fieldMap, keyField, encoding, source, filter });
|
|
461
1120
|
}
|
|
462
1121
|
catch (err) {
|
|
463
1122
|
const msg = err instanceof Error ? err.message : String(err);
|
|
464
|
-
console.error(`csv-import: failed
|
|
1123
|
+
console.error(`csv-import: failed — ${msg}`);
|
|
465
1124
|
return;
|
|
466
1125
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
const col = (name) => headers.indexOf(name);
|
|
472
|
-
let imported = 0;
|
|
473
|
-
let skipped = 0;
|
|
474
|
-
for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
|
|
475
|
-
const row = dataRows[rowIndex];
|
|
476
|
-
const get = (name) => {
|
|
477
|
-
const idx = col(name);
|
|
478
|
-
return idx >= 0 ? (row[idx] ?? "").trim() : "";
|
|
479
|
-
};
|
|
480
|
-
const title = get("title");
|
|
481
|
-
if (!title) {
|
|
482
|
-
skipped++;
|
|
483
|
-
continue;
|
|
484
|
-
}
|
|
485
|
-
const rawStatus = get("status");
|
|
486
|
-
const status = rawStatus ? normalizeStatus(rawStatus) : "open";
|
|
487
|
-
const rawPriority = get("priority");
|
|
488
|
-
const priority = rawPriority ? parseInt(rawPriority, 10) : undefined;
|
|
489
|
-
const tags = parseTags(get("tags"));
|
|
490
|
-
const type = get("type") || undefined;
|
|
491
|
-
const deadline = get("deadline") || get("due_date") || undefined;
|
|
492
|
-
const body = get("body") || undefined;
|
|
493
|
-
try {
|
|
494
|
-
const spawnArgs = [
|
|
495
|
-
"--path", ctx.pm_root,
|
|
496
|
-
"create",
|
|
497
|
-
"--title", title,
|
|
498
|
-
"--status", status,
|
|
499
|
-
];
|
|
500
|
-
if (body)
|
|
501
|
-
spawnArgs.push("--body", body);
|
|
502
|
-
if (priority !== undefined && !isNaN(priority))
|
|
503
|
-
spawnArgs.push("--priority", String(priority));
|
|
504
|
-
if (type)
|
|
505
|
-
spawnArgs.push("--type", type);
|
|
506
|
-
if (deadline)
|
|
507
|
-
spawnArgs.push("--deadline", deadline);
|
|
508
|
-
if (tags.length > 0)
|
|
509
|
-
spawnArgs.push("--tags", tags.join(","));
|
|
510
|
-
const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
|
|
511
|
-
if (result.status !== 0) {
|
|
512
|
-
throw new Error(result.stderr || "pm create failed");
|
|
513
|
-
}
|
|
514
|
-
imported++;
|
|
515
|
-
}
|
|
516
|
-
catch (err) {
|
|
517
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
518
|
-
console.error(`csv-import: row ${rowIndex + 2} create failed — ${msg}`);
|
|
519
|
-
skipped++;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
console.error(`csv-import: done — imported ${imported}, skipped ${skipped}.`);
|
|
1126
|
+
const filterNote = res.filtered > 0 ? ` (${res.filtered} filtered out)` : "";
|
|
1127
|
+
console.error(`csv-import: done — imported ${res.imported}, updated ${res.updated}, skipped ${res.skipped}${filterNote}.`);
|
|
523
1128
|
});
|
|
524
1129
|
},
|
|
525
1130
|
});
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
// Named exports — pure helpers exposed for unit testing (no side effects).
|
|
1133
|
+
// ---------------------------------------------------------------------------
|
|
1134
|
+
export { parseCSV, serializeCSV, serializeField, stripBOM, resolveDelimiter, parseFieldMap, applyFieldMap, normalizeStatus, parseTags, stringifyTags, encodeKeyTagValue, decodeKeyTagValue, normalizeKeyValue, selectExportColumns, resolveEncoding, validateParsedCSV, parseImportFilter, rowMatchesFilter, discoverCustomFields, EXPORT_COLUMNS, IMPORT_COLUMNS, };
|
|
526
1135
|
//# sourceMappingURL=index.js.map
|