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