pm-csv 2026.6.1 → 2026.6.2-1
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 +110 -21
- package/dist/index.d.ts +117 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +662 -189
- package/dist/index.js.map +1 -1
- package/manifest.json +3 -3
- package/package.json +4 -4
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,125 +355,491 @@ function readCSVFile(filePath, delimiter) {
|
|
|
218
355
|
const dataRows = rows.slice(1);
|
|
219
356
|
return { headers, dataRows };
|
|
220
357
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
* List existing items once and build a lookup from csv-key provenance value to
|
|
392
|
+
* item id, for idempotent upsert.
|
|
393
|
+
*/
|
|
394
|
+
function loadKeyIndex(pmRoot) {
|
|
395
|
+
const index = new Map();
|
|
396
|
+
const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json"], { encoding: "utf-8", maxBuffer: PM_LIST_MAX_BUFFER });
|
|
397
|
+
if (result.error)
|
|
398
|
+
throw new CommandError(`pm list-all failed: ${result.error.message}`);
|
|
399
|
+
if (result.status !== 0) {
|
|
400
|
+
throw new CommandError(result.stderr?.trim() || "pm list-all failed");
|
|
401
|
+
}
|
|
402
|
+
let items = [];
|
|
403
|
+
try {
|
|
404
|
+
items = JSON.parse(result.stdout).items ?? [];
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return index;
|
|
408
|
+
}
|
|
409
|
+
for (const item of items) {
|
|
410
|
+
for (const tag of item.tags ?? []) {
|
|
411
|
+
if (tag.startsWith(KEY_TAG_PREFIX)) {
|
|
412
|
+
index.set(normalizeKeyValue(decodeKeyTagValue(tag.slice(KEY_TAG_PREFIX.length))), item.id);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return index;
|
|
417
|
+
}
|
|
418
|
+
function importCSV(pmRoot, filePath, opts) {
|
|
419
|
+
const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8");
|
|
420
|
+
const result = { imported: 0, updated: 0, skipped: 0, errors: [], previews: [] };
|
|
421
|
+
if (rawHeaders.length === 0)
|
|
422
|
+
return result;
|
|
423
|
+
const headers = applyFieldMap(rawHeaders, opts.fieldMap);
|
|
424
|
+
if (!headers.includes("title")) {
|
|
425
|
+
throw new CommandError(`CSV is missing required 'title' column (after --map). Found: ${headers.join(", ")}`, EXIT_CODE.USAGE);
|
|
426
|
+
}
|
|
427
|
+
if (opts.keyField && !headers.includes(opts.keyField)) {
|
|
428
|
+
throw new CommandError(`--key column '${opts.keyField}' not found in CSV. Found: ${headers.join(", ")}`, EXIT_CODE.USAGE);
|
|
429
|
+
}
|
|
430
|
+
// Pre-load the dedup index only when upserting (one extra pm call, not per-row).
|
|
431
|
+
const keyIndex = opts.keyField && !opts.dryRun ? loadKeyIndex(pmRoot) : new Map();
|
|
432
|
+
for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
|
|
433
|
+
const row = dataRows[rowIndex];
|
|
434
|
+
const lineNo = rowIndex + 2; // 1-based + header row
|
|
435
|
+
const { get, parsed } = rowFields(headers, row);
|
|
436
|
+
if (!parsed.title) {
|
|
437
|
+
const msg = `Row ${lineNo}: skipping — 'title' is empty`;
|
|
438
|
+
console.error(msg);
|
|
439
|
+
result.skipped++;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const keyValue = opts.keyField ? get(opts.keyField) : "";
|
|
443
|
+
const existingId = keyValue ? keyIndex.get(normalizeKeyValue(keyValue)) : undefined;
|
|
444
|
+
if (opts.dryRun) {
|
|
445
|
+
result.previews.push({
|
|
446
|
+
action: existingId ? "update" : "create",
|
|
447
|
+
...parsed,
|
|
448
|
+
...(opts.keyField ? { key: keyValue } : {}),
|
|
449
|
+
...(opts.source ? { csv_source: opts.source } : {}),
|
|
450
|
+
});
|
|
451
|
+
if (existingId)
|
|
452
|
+
result.updated++;
|
|
453
|
+
else
|
|
454
|
+
result.imported++;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
if (existingId) {
|
|
459
|
+
upsertUpdate(pmRoot, existingId, parsed, opts.source);
|
|
460
|
+
result.updated++;
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
const newId = upsertCreate(pmRoot, parsed, opts.keyField ? keyValue : undefined, opts.source);
|
|
464
|
+
if (opts.keyField && keyValue && newId)
|
|
465
|
+
keyIndex.set(normalizeKeyValue(keyValue), newId);
|
|
466
|
+
result.imported++;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
const msg = `Row ${lineNo}: ${existingId ? "update" : "create"} failed — ${err instanceof Error ? err.message : String(err)}`;
|
|
471
|
+
console.error(msg);
|
|
472
|
+
result.errors.push(msg);
|
|
473
|
+
result.skipped++;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Append the relational/planning field flags shared by create and update.
|
|
480
|
+
* Flag names verified against the installed `pm create`/`pm update` contracts:
|
|
481
|
+
* --parent, --assignee, --sprint, --release, --blocked-by.
|
|
482
|
+
*/
|
|
483
|
+
function appendRelationalArgs(args, p) {
|
|
484
|
+
if (p.parent)
|
|
485
|
+
args.push("--parent", p.parent);
|
|
486
|
+
if (p.assignee)
|
|
487
|
+
args.push("--assignee", p.assignee);
|
|
488
|
+
if (p.sprint)
|
|
489
|
+
args.push("--sprint", p.sprint);
|
|
490
|
+
if (p.release)
|
|
491
|
+
args.push("--release", p.release);
|
|
492
|
+
if (p.blocked_by)
|
|
493
|
+
args.push("--blocked-by", p.blocked_by);
|
|
494
|
+
}
|
|
495
|
+
/** Create a new item, optionally carrying a csv-key provenance tag. Returns the new id. */
|
|
496
|
+
function upsertCreate(pmRoot, p, keyValue, source) {
|
|
497
|
+
const tags = [...p.tags];
|
|
498
|
+
// Encode the lower-cased key so the stored tag matches the lookup index
|
|
499
|
+
// regardless of pm's tag case-folding (see normalizeKeyValue).
|
|
500
|
+
if (keyValue)
|
|
501
|
+
tags.push(`${KEY_TAG_PREFIX}${encodeKeyTagValue(normalizeKeyValue(keyValue))}`);
|
|
502
|
+
// Provenance for the schema-registered csv_source field, persisted as a tag
|
|
503
|
+
// since the CLI exposes no scalar setter for extension-registered fields.
|
|
504
|
+
if (source)
|
|
505
|
+
tags.push(`${SOURCE_TAG_PREFIX}${encodeKeyTagValue(source)}`);
|
|
506
|
+
const args = ["--path", pmRoot, "create", "--title", p.title, "--status", p.status, "--json"];
|
|
507
|
+
if (p.body)
|
|
508
|
+
args.push("--body", p.body);
|
|
509
|
+
if (p.priority !== undefined)
|
|
510
|
+
args.push("--priority", String(p.priority));
|
|
511
|
+
if (p.type)
|
|
512
|
+
args.push("--type", p.type);
|
|
513
|
+
if (p.deadline)
|
|
514
|
+
args.push("--deadline", p.deadline);
|
|
515
|
+
appendRelationalArgs(args, p);
|
|
516
|
+
if (tags.length > 0)
|
|
517
|
+
args.push("--tags", tags.join(","));
|
|
518
|
+
const r = spawnSync("pm", args, { encoding: "utf-8" });
|
|
519
|
+
if (r.status !== 0)
|
|
520
|
+
throw new Error(r.stderr?.trim() || "pm create failed");
|
|
521
|
+
try {
|
|
522
|
+
const parsed = JSON.parse(r.stdout);
|
|
523
|
+
return parsed.id ?? parsed.item?.id ?? "";
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return "";
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** Update an existing item in place (status via update; close handled separately). */
|
|
530
|
+
function upsertUpdate(pmRoot, id, p, source) {
|
|
531
|
+
const args = ["--path", pmRoot, "update", id, "--title", p.title];
|
|
532
|
+
if (p.body !== undefined)
|
|
533
|
+
args.push("--body", p.body);
|
|
534
|
+
if (p.priority !== undefined)
|
|
535
|
+
args.push("--priority", String(p.priority));
|
|
536
|
+
if (p.type)
|
|
537
|
+
args.push("--type", p.type);
|
|
538
|
+
if (p.deadline)
|
|
539
|
+
args.push("--deadline", p.deadline);
|
|
540
|
+
appendRelationalArgs(args, p);
|
|
541
|
+
// Preserve the csv-key tag (additive) and refresh the user tags.
|
|
542
|
+
const addTags = [...p.tags];
|
|
543
|
+
if (source)
|
|
544
|
+
addTags.push(`${SOURCE_TAG_PREFIX}${encodeKeyTagValue(source)}`);
|
|
545
|
+
if (addTags.length > 0)
|
|
546
|
+
args.push("--add-tags", addTags.join(","));
|
|
547
|
+
// `update` cannot set a closed status; only set non-closed statuses here.
|
|
548
|
+
if (p.status !== "closed" && p.status !== "canceled")
|
|
549
|
+
args.push("--status", p.status);
|
|
550
|
+
const r = spawnSync("pm", args, { encoding: "utf-8" });
|
|
551
|
+
if (r.status !== 0)
|
|
552
|
+
throw new Error(r.stderr?.trim() || "pm update failed");
|
|
553
|
+
// Apply terminal statuses through the dedicated close command.
|
|
554
|
+
if (p.status === "closed" || p.status === "canceled") {
|
|
555
|
+
const reason = p.status === "canceled" ? "canceled" : "completed";
|
|
556
|
+
const cr = spawnSync("pm", ["--path", pmRoot, "close", id, "--reason", reason], { encoding: "utf-8" });
|
|
557
|
+
if (cr.status !== 0)
|
|
558
|
+
throw new Error(cr.stderr?.trim() || "pm close failed");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Parse a CSV and report data-quality issues without writing anything.
|
|
563
|
+
* Pure function over file contents — exposed for unit testing via the
|
|
564
|
+
* lower-level {@link validateParsedCSV} helper below.
|
|
565
|
+
*/
|
|
566
|
+
function validateCSV(filePath, opts) {
|
|
567
|
+
const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8");
|
|
568
|
+
return validateParsedCSV(rawHeaders, dataRows, opts.fieldMap);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Core validation logic over already-parsed headers + rows. Pure and
|
|
572
|
+
* side-effect-free so it can be unit tested directly.
|
|
573
|
+
*/
|
|
574
|
+
function validateParsedCSV(rawHeaders, dataRows, fieldMap) {
|
|
575
|
+
const mappedColumns = applyFieldMap(rawHeaders, fieldMap);
|
|
576
|
+
const hasTitleColumn = mappedColumns.includes("title");
|
|
577
|
+
const issues = [];
|
|
578
|
+
let rowsMissingTitle = 0;
|
|
579
|
+
let rowsWithUnknownStatus = 0;
|
|
580
|
+
let rowsWithNonIntegerPriority = 0;
|
|
581
|
+
const titleIdx = mappedColumns.indexOf("title");
|
|
582
|
+
const statusIdx = mappedColumns.indexOf("status");
|
|
583
|
+
const priorityIdx = mappedColumns.indexOf("priority");
|
|
584
|
+
for (const row of dataRows) {
|
|
585
|
+
if (hasTitleColumn) {
|
|
586
|
+
const title = (row[titleIdx] ?? "").trim();
|
|
587
|
+
if (!title)
|
|
588
|
+
rowsMissingTitle++;
|
|
589
|
+
}
|
|
590
|
+
if (statusIdx >= 0) {
|
|
591
|
+
const status = (row[statusIdx] ?? "").trim().toLowerCase();
|
|
592
|
+
if (status && !KNOWN_STATUSES.has(status))
|
|
593
|
+
rowsWithUnknownStatus++;
|
|
594
|
+
}
|
|
595
|
+
if (priorityIdx >= 0) {
|
|
596
|
+
const priority = (row[priorityIdx] ?? "").trim();
|
|
597
|
+
if (priority && !/^-?\d+$/.test(priority))
|
|
598
|
+
rowsWithNonIntegerPriority++;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (rawHeaders.length === 0) {
|
|
602
|
+
issues.push("CSV is empty (no header row).");
|
|
603
|
+
}
|
|
604
|
+
if (!hasTitleColumn) {
|
|
605
|
+
issues.push(`Missing required 'title' column (after --map). Detected: ${mappedColumns.join(", ") || "(none)"}`);
|
|
606
|
+
}
|
|
607
|
+
if (rowsMissingTitle > 0) {
|
|
608
|
+
issues.push(`${rowsMissingTitle} row(s) have an empty title and would be skipped.`);
|
|
609
|
+
}
|
|
610
|
+
if (rowsWithUnknownStatus > 0) {
|
|
611
|
+
issues.push(`${rowsWithUnknownStatus} row(s) have an unrecognized status (would fall back to 'open').`);
|
|
612
|
+
}
|
|
613
|
+
if (rowsWithNonIntegerPriority > 0) {
|
|
614
|
+
issues.push(`${rowsWithNonIntegerPriority} row(s) have a non-integer priority (would be ignored).`);
|
|
615
|
+
}
|
|
616
|
+
// Only a missing title column (or empty file) is a structural problem.
|
|
617
|
+
const ok = hasTitleColumn && rawHeaders.length > 0;
|
|
618
|
+
return {
|
|
619
|
+
ok,
|
|
620
|
+
rowCount: dataRows.length,
|
|
621
|
+
detectedColumns: rawHeaders,
|
|
622
|
+
mappedColumns,
|
|
623
|
+
hasTitleColumn,
|
|
624
|
+
rowsMissingTitle,
|
|
625
|
+
rowsWithUnknownStatus,
|
|
626
|
+
rowsWithNonIntegerPriority,
|
|
627
|
+
issues,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
// Parse a `--columns id,title,status` spec into a validated, ordered subset of
|
|
631
|
+
// the export columns. Unknown column names throw a USAGE error; an empty/absent
|
|
632
|
+
// spec falls back to the full default column set.
|
|
633
|
+
function selectExportColumns(spec) {
|
|
634
|
+
if (!spec || !spec.trim())
|
|
635
|
+
return EXPORT_COLUMNS;
|
|
636
|
+
const requested = spec.split(",").map((s) => s.trim()).filter(Boolean);
|
|
637
|
+
const valid = new Set(EXPORT_COLUMNS);
|
|
638
|
+
const unknown = requested.filter((c) => !valid.has(c));
|
|
639
|
+
if (unknown.length > 0) {
|
|
640
|
+
throw new CommandError(`Unknown export column(s): ${unknown.join(", ")}. Valid: ${EXPORT_COLUMNS.join(", ")}`, EXIT_CODE.USAGE);
|
|
641
|
+
}
|
|
642
|
+
return requested;
|
|
643
|
+
}
|
|
644
|
+
function buildCsvExport(pmRoot, opts) {
|
|
645
|
+
const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json", "--include-body"], { encoding: "utf-8" });
|
|
646
|
+
if (result.status !== 0) {
|
|
647
|
+
throw new CommandError(result.stderr || "pm list-all failed");
|
|
648
|
+
}
|
|
649
|
+
let items = JSON.parse(result.stdout).items ?? [];
|
|
650
|
+
if (opts.statusFilter)
|
|
651
|
+
items = items.filter((i) => i.status === opts.statusFilter);
|
|
652
|
+
if (opts.typeFilter)
|
|
653
|
+
items = items.filter((i) => i.type === opts.typeFilter);
|
|
654
|
+
// Surface provenance: derive csv_source from the internal csv-source: tag.
|
|
655
|
+
for (const item of items) {
|
|
656
|
+
const sourceTag = (item.tags ?? []).find((t) => t.startsWith(SOURCE_TAG_PREFIX));
|
|
657
|
+
if (sourceTag)
|
|
658
|
+
item.csv_source = decodeKeyTagValue(sourceTag.slice(SOURCE_TAG_PREFIX.length));
|
|
659
|
+
}
|
|
660
|
+
const dataRows = items.map((item) => opts.columns.map((col) => {
|
|
661
|
+
const val = item[col];
|
|
662
|
+
if (val === undefined || val === null)
|
|
663
|
+
return "";
|
|
664
|
+
if (Array.isArray(val)) {
|
|
665
|
+
// Strip internal provenance tags (csv-key / csv-source) so a round-trip
|
|
666
|
+
// export stays clean.
|
|
667
|
+
const visible = val.filter((t) => typeof t === "string" &&
|
|
668
|
+
!t.startsWith(KEY_TAG_PREFIX) &&
|
|
669
|
+
!t.startsWith(SOURCE_TAG_PREFIX));
|
|
670
|
+
return stringifyTags(visible);
|
|
671
|
+
}
|
|
672
|
+
return String(val);
|
|
673
|
+
}));
|
|
674
|
+
const allRows = opts.noHeader ? dataRows : [opts.columns.map(String), ...dataRows];
|
|
675
|
+
// --excel implies CRLF (and a UTF-8 BOM prefix, added below).
|
|
676
|
+
const eol = opts.crlf || opts.excel ? "\r\n" : "\n";
|
|
677
|
+
let csvText = serializeCSV(allRows, { delimiter: opts.delimiter, eol });
|
|
678
|
+
if (opts.excel)
|
|
679
|
+
csvText = "" + csvText;
|
|
680
|
+
return {
|
|
681
|
+
csvText,
|
|
682
|
+
count: items.length,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
224
685
|
export default defineExtension({
|
|
225
686
|
name: "pm-csv",
|
|
226
|
-
version: "2026.6.1",
|
|
687
|
+
version: "2026.6.2-1",
|
|
227
688
|
activate(api) {
|
|
689
|
+
// -----------------------------------------------------------------------
|
|
690
|
+
// Schema: register an optional `csv_source` provenance field so imported
|
|
691
|
+
// items can record where they came from (set via `pm csv import --source`).
|
|
692
|
+
// Guarded: only call when the running SDK exposes registerItemFields, so
|
|
693
|
+
// older hosts that lack the schema capability degrade to a no-op (and the
|
|
694
|
+
// manifest still declares "schema" because we genuinely implement it).
|
|
695
|
+
//
|
|
696
|
+
// NOTE: pm 2026.5.31 accepts the field into the schema registry but exposes
|
|
697
|
+
// no `pm create --csv_source` setter for extension-registered scalar fields,
|
|
698
|
+
// so the importer persists the provenance label as a `csv-source:` tag
|
|
699
|
+
// (stripped from exports and surfaced back via the csv_source export column).
|
|
700
|
+
// -----------------------------------------------------------------------
|
|
701
|
+
if (typeof api.registerItemFields === "function") {
|
|
702
|
+
try {
|
|
703
|
+
api.registerItemFields([
|
|
704
|
+
{ name: "csv_source", type: "string", optional: true },
|
|
705
|
+
]);
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
// Never let a schema-registration hiccup break command registration.
|
|
709
|
+
console.error(`pm-csv: csv_source field not registered — ${err instanceof Error ? err.message : String(err)}`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
228
712
|
// -----------------------------------------------------------------------
|
|
229
713
|
// Command: pm csv import <file>
|
|
230
714
|
// -----------------------------------------------------------------------
|
|
231
715
|
api.registerCommand({
|
|
232
716
|
name: "csv import",
|
|
233
|
-
description: "Import pm items from a CSV file
|
|
234
|
-
"
|
|
235
|
-
"
|
|
717
|
+
description: "Import pm items from a CSV file with full RFC-4180 parsing (quoted fields, " +
|
|
718
|
+
"embedded newlines, escaped quotes, BOM, CRLF). Expected columns: title, type, " +
|
|
719
|
+
"status, priority, tags, deadline, body, parent, assignee, sprint, release, " +
|
|
720
|
+
"blocked_by. Only 'title' is required. Use --map to remap arbitrary headers, " +
|
|
721
|
+
"--key for idempotent re-import (upsert), --encoding for non-UTF-8 files, and " +
|
|
722
|
+
"--source to record import provenance in the csv_source field.",
|
|
236
723
|
intent: "import items from a CSV file into pm",
|
|
237
724
|
examples: [
|
|
238
725
|
"pm csv import tasks.csv",
|
|
239
726
|
"pm csv import backlog.csv --delimiter ';'",
|
|
727
|
+
"pm csv import data.tsv --delimiter tab",
|
|
728
|
+
"pm csv import jira.csv --map 'Summary=title,Owner=tags'",
|
|
729
|
+
"pm csv import items.csv --key title # idempotent re-import (no duplicates)",
|
|
730
|
+
"pm csv import legacy.csv --encoding latin1",
|
|
731
|
+
"pm csv import sprint12.csv --source 'jira-export-2026-06'",
|
|
240
732
|
"pm csv import items.csv --dry-run",
|
|
241
733
|
],
|
|
242
734
|
flags: [
|
|
243
|
-
{ long: "--delimiter", value_name: "char", description: "
|
|
735
|
+
{ long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
|
|
736
|
+
{ long: "--map", value_name: "col=field", description: "Remap a CSV header to a pm field (repeatable, comma-joined). e.g. --map 'Summary=title'" },
|
|
737
|
+
{ long: "--key", value_name: "field", description: "Dedup key column: re-import updates the matching item instead of creating a duplicate" },
|
|
738
|
+
{ long: "--encoding", value_name: "enc", description: "Source file encoding: utf-8 (default) | utf16le | latin1" },
|
|
739
|
+
{ long: "--source", value_name: "label", description: "Record an import-provenance label in the csv_source field of created/updated items" },
|
|
244
740
|
{ long: "--dry-run", description: "Preview without writing" },
|
|
245
741
|
],
|
|
246
742
|
async run(ctx) {
|
|
247
743
|
const filePath = ctx.args[0];
|
|
248
744
|
if (!filePath) {
|
|
249
|
-
throw new CommandError("Usage: pm csv import <file> [--delimiter <char>] [--dry-run]", EXIT_CODE.USAGE);
|
|
745
|
+
throw new CommandError("Usage: pm csv import <file> [--delimiter <char>] [--map col=field] [--key field] [--encoding enc] [--source label] [--dry-run]", EXIT_CODE.USAGE);
|
|
250
746
|
}
|
|
251
|
-
const delimiter = ctx.options["delimiter"]
|
|
747
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
252
748
|
const dryRun = readBoolOption(ctx.options, "dry-run", "dryRun");
|
|
749
|
+
const fieldMap = parseFieldMap(ctx.options["map"]);
|
|
750
|
+
const keyField = (ctx.options["key"] ?? "").trim().toLowerCase() || undefined;
|
|
751
|
+
const encoding = resolveEncoding(ctx.options["encoding"]);
|
|
752
|
+
const source = (ctx.options["source"] ?? "").trim() || undefined;
|
|
253
753
|
const absolutePath = resolve(filePath);
|
|
254
754
|
console.error(`Reading CSV from: ${absolutePath}`);
|
|
255
|
-
let
|
|
256
|
-
let dataRows;
|
|
755
|
+
let res;
|
|
257
756
|
try {
|
|
258
|
-
({
|
|
757
|
+
res = importCSV(ctx.pm_root, absolutePath, { delimiter, dryRun, fieldMap, keyField, encoding, source });
|
|
259
758
|
}
|
|
260
759
|
catch (err) {
|
|
760
|
+
if (err instanceof CommandError)
|
|
761
|
+
throw err;
|
|
261
762
|
const msg = err instanceof Error ? err.message : String(err);
|
|
262
763
|
const exitCode = /ENOENT|no such file/i.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
|
|
263
|
-
throw new CommandError(`Failed to
|
|
764
|
+
throw new CommandError(`Failed to import: ${msg}`, exitCode);
|
|
765
|
+
}
|
|
766
|
+
if (dryRun) {
|
|
767
|
+
console.error(`[dry-run] Would create ${res.imported}, update ${res.updated}, skip ${res.skipped}.`);
|
|
768
|
+
return {
|
|
769
|
+
dryRun: true,
|
|
770
|
+
wouldCreate: res.imported,
|
|
771
|
+
wouldUpdate: res.updated,
|
|
772
|
+
wouldSkip: res.skipped,
|
|
773
|
+
previews: res.previews,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
console.error(`Imported ${res.imported}, updated ${res.updated}, skipped ${res.skipped}.`);
|
|
777
|
+
return { imported: res.imported, updated: res.updated, skipped: res.skipped, errors: res.errors };
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
// -----------------------------------------------------------------------
|
|
781
|
+
// Command: pm csv validate <file>
|
|
782
|
+
// -----------------------------------------------------------------------
|
|
783
|
+
api.registerCommand({
|
|
784
|
+
name: "csv validate",
|
|
785
|
+
description: "Validate a CSV without importing it. Reports row count, detected/mapped " +
|
|
786
|
+
"columns, rows missing a title, rows with unrecognized status, rows with a " +
|
|
787
|
+
"non-integer priority, and whether the required 'title' column is present " +
|
|
788
|
+
"(after --map). Exits non-zero on structural problems (missing title column). " +
|
|
789
|
+
"Honors --delimiter, --map, --encoding; supports --json.",
|
|
790
|
+
intent: "validate a CSV file without importing",
|
|
791
|
+
examples: [
|
|
792
|
+
"pm csv validate tasks.csv",
|
|
793
|
+
"pm csv validate jira.csv --map 'Summary=title'",
|
|
794
|
+
"pm csv validate data.tsv --delimiter tab --json",
|
|
795
|
+
],
|
|
796
|
+
flags: [
|
|
797
|
+
{ long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
|
|
798
|
+
{ long: "--map", value_name: "col=field", description: "Remap a CSV header to a pm field (repeatable, comma-joined) before validating" },
|
|
799
|
+
{ long: "--encoding", value_name: "enc", description: "Source file encoding: utf-8 (default) | utf16le | latin1" },
|
|
800
|
+
{ long: "--json", description: "Emit the report as JSON" },
|
|
801
|
+
],
|
|
802
|
+
async run(ctx) {
|
|
803
|
+
const filePath = ctx.args[0];
|
|
804
|
+
if (!filePath) {
|
|
805
|
+
throw new CommandError("Usage: pm csv validate <file> [--delimiter <char>] [--map col=field] [--encoding enc] [--json]", EXIT_CODE.USAGE);
|
|
264
806
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
807
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
808
|
+
const fieldMap = parseFieldMap(ctx.options["map"]);
|
|
809
|
+
const encoding = resolveEncoding(ctx.options["encoding"]);
|
|
810
|
+
const asJson = readBoolOption(ctx.options, "json");
|
|
811
|
+
const absolutePath = resolve(filePath);
|
|
812
|
+
let report;
|
|
813
|
+
try {
|
|
814
|
+
report = validateCSV(absolutePath, { delimiter, fieldMap, encoding });
|
|
268
815
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
816
|
+
catch (err) {
|
|
817
|
+
if (err instanceof CommandError)
|
|
818
|
+
throw err;
|
|
819
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
820
|
+
const exitCode = /ENOENT|no such file/i.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
|
|
821
|
+
throw new CommandError(`Failed to validate: ${msg}`, exitCode);
|
|
272
822
|
}
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
const rawStatus = get("status");
|
|
291
|
-
const status = rawStatus ? normalizeStatus(rawStatus) : "open";
|
|
292
|
-
const rawPriority = get("priority");
|
|
293
|
-
const priority = rawPriority ? parseInt(rawPriority, 10) : undefined;
|
|
294
|
-
const tags = parseTags(get("tags"));
|
|
295
|
-
const type = get("type") || undefined;
|
|
296
|
-
// pm has no milestone/due_date fields on `create`; the deadline column
|
|
297
|
-
// (accept legacy "due_date" header too) maps to `--deadline`.
|
|
298
|
-
const deadline = get("deadline") || get("due_date") || undefined;
|
|
299
|
-
const body = get("body") || undefined;
|
|
300
|
-
if (dryRun) {
|
|
301
|
-
previews.push({ title, type, status, priority, tags, deadline, body });
|
|
302
|
-
imported++;
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
try {
|
|
306
|
-
const spawnArgs = [
|
|
307
|
-
"--path", ctx.pm_root,
|
|
308
|
-
"create",
|
|
309
|
-
"--title", title,
|
|
310
|
-
"--status", status,
|
|
311
|
-
];
|
|
312
|
-
if (body)
|
|
313
|
-
spawnArgs.push("--body", body);
|
|
314
|
-
if (priority !== undefined && !isNaN(priority))
|
|
315
|
-
spawnArgs.push("--priority", String(priority));
|
|
316
|
-
if (type)
|
|
317
|
-
spawnArgs.push("--type", type);
|
|
318
|
-
if (deadline)
|
|
319
|
-
spawnArgs.push("--deadline", deadline);
|
|
320
|
-
if (tags.length > 0)
|
|
321
|
-
spawnArgs.push("--tags", tags.join(","));
|
|
322
|
-
const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
|
|
323
|
-
if (result.status !== 0) {
|
|
324
|
-
throw new Error(result.stderr || "pm create failed");
|
|
325
|
-
}
|
|
326
|
-
imported++;
|
|
327
|
-
}
|
|
328
|
-
catch (err) {
|
|
329
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
330
|
-
console.error(`Row ${rowIndex + 2}: create failed — ${msg}`);
|
|
331
|
-
skipped++;
|
|
823
|
+
// Human-readable summary on stderr (so --json stdout stays clean).
|
|
824
|
+
console.error(`Rows: ${report.rowCount}`);
|
|
825
|
+
console.error(`Detected columns: ${report.detectedColumns.join(", ") || "(none)"}`);
|
|
826
|
+
console.error(`Mapped columns: ${report.mappedColumns.join(", ") || "(none)"}`);
|
|
827
|
+
console.error(`Has 'title' column: ${report.hasTitleColumn ? "yes" : "no"}`);
|
|
828
|
+
console.error(`Rows missing title: ${report.rowsMissingTitle}`);
|
|
829
|
+
console.error(`Rows w/ unknown status: ${report.rowsWithUnknownStatus}`);
|
|
830
|
+
console.error(`Rows w/ non-integer priority: ${report.rowsWithNonIntegerPriority}`);
|
|
831
|
+
for (const issue of report.issues)
|
|
832
|
+
console.error(` - ${issue}`);
|
|
833
|
+
console.error(report.ok ? "Validation OK." : "Validation FAILED (structural problems).");
|
|
834
|
+
// Structural problems (no title column / empty) → non-zero exit.
|
|
835
|
+
if (!report.ok) {
|
|
836
|
+
if (asJson) {
|
|
837
|
+
// Surface the structured report even on failure before throwing.
|
|
838
|
+
console.error(JSON.stringify(report, null, 2));
|
|
332
839
|
}
|
|
840
|
+
throw new CommandError("CSV is missing the required 'title' column (after --map).", EXIT_CODE.USAGE);
|
|
333
841
|
}
|
|
334
|
-
|
|
335
|
-
console.error(`[dry-run] Would import ${imported} item(s), skip ${skipped} item(s).`);
|
|
336
|
-
return { dryRun: true, wouldImport: imported, wouldSkip: skipped, previews };
|
|
337
|
-
}
|
|
338
|
-
console.error(`Imported ${imported} item(s), skipped ${skipped} item(s).`);
|
|
339
|
-
return { imported, skipped };
|
|
842
|
+
return report;
|
|
340
843
|
},
|
|
341
844
|
});
|
|
342
845
|
// -----------------------------------------------------------------------
|
|
@@ -353,62 +856,80 @@ export default defineExtension({
|
|
|
353
856
|
"pm csv export --output backlog.csv --delimiter ';'",
|
|
354
857
|
"pm csv export --status open --output todos.csv",
|
|
355
858
|
"pm csv export --type Feature --output features.csv",
|
|
859
|
+
"pm csv export --excel --output for-excel.csv",
|
|
356
860
|
],
|
|
357
861
|
flags: [
|
|
358
862
|
{ long: "--output", value_name: "file", description: "Output file path (default: print to stdout)" },
|
|
359
|
-
{ long: "--delimiter", value_name: "char", description: "
|
|
863
|
+
{ long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
|
|
360
864
|
{ long: "--status", value_name: "filter", description: "Filter by status: open | in_progress | blocked | closed | canceled | draft" },
|
|
361
865
|
{ long: "--type", value_name: "type", description: "Filter by item type" },
|
|
866
|
+
{ long: "--columns", value_name: "list", description: `Comma-separated columns to export, in order (default: all). Valid: ${EXPORT_COLUMNS.join(", ")}` },
|
|
867
|
+
{ long: "--no-header", description: "Omit the CSV header row" },
|
|
868
|
+
{ long: "--crlf", description: "Use CRLF line endings (RFC-4180 / Excel)" },
|
|
869
|
+
{ long: "--excel", description: "Excel-friendly output: CRLF line endings + a UTF-8 BOM prefix" },
|
|
362
870
|
],
|
|
363
871
|
async run(ctx) {
|
|
364
|
-
const delimiter = ctx.options["delimiter"]
|
|
872
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
365
873
|
const outputPath = ctx.options["output"];
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
const
|
|
874
|
+
const columns = selectExportColumns(ctx.options["columns"]);
|
|
875
|
+
const noHeader = readBoolOption(ctx.options, "no-header", "noHeader");
|
|
876
|
+
const crlf = readBoolOption(ctx.options, "crlf");
|
|
877
|
+
const excel = readBoolOption(ctx.options, "excel");
|
|
370
878
|
console.error("Fetching pm items…");
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (typeFilter) {
|
|
382
|
-
allItems = allItems.filter((item) => item.type === typeFilter);
|
|
383
|
-
}
|
|
384
|
-
const items = allItems;
|
|
385
|
-
if (items.length === 0) {
|
|
879
|
+
const { csvText, count } = buildCsvExport(ctx.pm_root, {
|
|
880
|
+
statusFilter: ctx.options["status"],
|
|
881
|
+
typeFilter: ctx.options["type"],
|
|
882
|
+
delimiter,
|
|
883
|
+
columns,
|
|
884
|
+
noHeader,
|
|
885
|
+
crlf,
|
|
886
|
+
excel,
|
|
887
|
+
});
|
|
888
|
+
if (count === 0) {
|
|
386
889
|
console.error("No items found.");
|
|
387
890
|
return { exported: 0 };
|
|
388
891
|
}
|
|
389
|
-
// Build CSV rows
|
|
390
|
-
const headerRow = EXPORT_COLUMNS.map(String);
|
|
391
|
-
const dataRows = items.map((item) => EXPORT_COLUMNS.map((col) => {
|
|
392
|
-
const val = item[col];
|
|
393
|
-
if (val === undefined || val === null)
|
|
394
|
-
return "";
|
|
395
|
-
if (Array.isArray(val))
|
|
396
|
-
return stringifyTags(val);
|
|
397
|
-
return String(val);
|
|
398
|
-
}));
|
|
399
|
-
const csvText = serializeCSV([headerRow, ...dataRows], delimiter);
|
|
400
892
|
if (outputPath) {
|
|
401
893
|
const absolutePath = resolve(outputPath);
|
|
402
894
|
writeFileSync(absolutePath, csvText + "\n", "utf-8");
|
|
403
|
-
console.error(`Exported ${
|
|
404
|
-
return { exported:
|
|
895
|
+
console.error(`Exported ${count} item(s) to: ${absolutePath}`);
|
|
896
|
+
return { exported: count, file: absolutePath };
|
|
405
897
|
}
|
|
406
898
|
// Print to stdout — return as data so the CLI host renders it
|
|
407
|
-
console.error(`Exported ${
|
|
408
|
-
return { exported:
|
|
899
|
+
console.error(`Exported ${count} item(s).`);
|
|
900
|
+
return { exported: count, csv: csvText };
|
|
409
901
|
},
|
|
410
902
|
});
|
|
411
903
|
// -----------------------------------------------------------------------
|
|
904
|
+
// Exporter: csv-export (native export pipeline — `pm csv-export export`)
|
|
905
|
+
// Mirrors the importer so CSV is a first-class import/export pair.
|
|
906
|
+
// -----------------------------------------------------------------------
|
|
907
|
+
api.registerExporter("csv-export", async (ctx) => {
|
|
908
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
909
|
+
const outputPath = ctx.options["output"];
|
|
910
|
+
const columns = selectExportColumns(ctx.options["columns"]);
|
|
911
|
+
const noHeader = readBoolOption(ctx.options, "no-header", "noHeader");
|
|
912
|
+
const crlf = readBoolOption(ctx.options, "crlf");
|
|
913
|
+
const excel = readBoolOption(ctx.options, "excel");
|
|
914
|
+
const { csvText, count } = buildCsvExport(ctx.pm_root, {
|
|
915
|
+
statusFilter: ctx.options["status"],
|
|
916
|
+
typeFilter: ctx.options["type"],
|
|
917
|
+
delimiter,
|
|
918
|
+
columns,
|
|
919
|
+
noHeader,
|
|
920
|
+
crlf,
|
|
921
|
+
excel,
|
|
922
|
+
});
|
|
923
|
+
if (outputPath) {
|
|
924
|
+
const absolutePath = resolve(outputPath);
|
|
925
|
+
writeFileSync(absolutePath, csvText + "\n", "utf-8");
|
|
926
|
+
console.error(`csv-export: wrote ${count} item(s) to ${absolutePath}`);
|
|
927
|
+
return { exported: count, file: absolutePath };
|
|
928
|
+
}
|
|
929
|
+
console.log(csvText);
|
|
930
|
+
return { exported: count, csv: csvText };
|
|
931
|
+
});
|
|
932
|
+
// -----------------------------------------------------------------------
|
|
412
933
|
// Importer: csv-import (programmatic / config-driven)
|
|
413
934
|
// -----------------------------------------------------------------------
|
|
414
935
|
api.registerImporter("csv-import", async (ctx) => {
|
|
@@ -417,76 +938,28 @@ export default defineExtension({
|
|
|
417
938
|
console.error("csv-import: no 'file' provided in options — skipping.");
|
|
418
939
|
return;
|
|
419
940
|
}
|
|
420
|
-
const delimiter = ctx.options["delimiter"]
|
|
941
|
+
const delimiter = resolveDelimiter(ctx.options["delimiter"]);
|
|
942
|
+
const fieldMap = parseFieldMap(ctx.options["map"]);
|
|
943
|
+
const keyField = (ctx.options["key"] ?? "").trim().toLowerCase() || undefined;
|
|
944
|
+
const encoding = resolveEncoding(ctx.options["encoding"]);
|
|
945
|
+
const source = (ctx.options["source"] ?? "").trim() || undefined;
|
|
421
946
|
const absolutePath = resolve(filePath);
|
|
422
947
|
console.error(`csv-import: reading ${absolutePath}`);
|
|
423
|
-
let
|
|
424
|
-
let dataRows;
|
|
948
|
+
let res;
|
|
425
949
|
try {
|
|
426
|
-
({
|
|
950
|
+
res = importCSV(ctx.pm_root, absolutePath, { delimiter, dryRun: false, fieldMap, keyField, encoding, source });
|
|
427
951
|
}
|
|
428
952
|
catch (err) {
|
|
429
953
|
const msg = err instanceof Error ? err.message : String(err);
|
|
430
|
-
console.error(`csv-import: failed
|
|
954
|
+
console.error(`csv-import: failed — ${msg}`);
|
|
431
955
|
return;
|
|
432
956
|
}
|
|
433
|
-
|
|
434
|
-
console.error("csv-import: CSV is missing required 'title' column — skipping.");
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
const col = (name) => headers.indexOf(name);
|
|
438
|
-
let imported = 0;
|
|
439
|
-
let skipped = 0;
|
|
440
|
-
for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
|
|
441
|
-
const row = dataRows[rowIndex];
|
|
442
|
-
const get = (name) => {
|
|
443
|
-
const idx = col(name);
|
|
444
|
-
return idx >= 0 ? (row[idx] ?? "").trim() : "";
|
|
445
|
-
};
|
|
446
|
-
const title = get("title");
|
|
447
|
-
if (!title) {
|
|
448
|
-
skipped++;
|
|
449
|
-
continue;
|
|
450
|
-
}
|
|
451
|
-
const rawStatus = get("status");
|
|
452
|
-
const status = rawStatus ? normalizeStatus(rawStatus) : "open";
|
|
453
|
-
const rawPriority = get("priority");
|
|
454
|
-
const priority = rawPriority ? parseInt(rawPriority, 10) : undefined;
|
|
455
|
-
const tags = parseTags(get("tags"));
|
|
456
|
-
const type = get("type") || undefined;
|
|
457
|
-
const deadline = get("deadline") || get("due_date") || undefined;
|
|
458
|
-
const body = get("body") || undefined;
|
|
459
|
-
try {
|
|
460
|
-
const spawnArgs = [
|
|
461
|
-
"--path", ctx.pm_root,
|
|
462
|
-
"create",
|
|
463
|
-
"--title", title,
|
|
464
|
-
"--status", status,
|
|
465
|
-
];
|
|
466
|
-
if (body)
|
|
467
|
-
spawnArgs.push("--body", body);
|
|
468
|
-
if (priority !== undefined && !isNaN(priority))
|
|
469
|
-
spawnArgs.push("--priority", String(priority));
|
|
470
|
-
if (type)
|
|
471
|
-
spawnArgs.push("--type", type);
|
|
472
|
-
if (deadline)
|
|
473
|
-
spawnArgs.push("--deadline", deadline);
|
|
474
|
-
if (tags.length > 0)
|
|
475
|
-
spawnArgs.push("--tags", tags.join(","));
|
|
476
|
-
const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
|
|
477
|
-
if (result.status !== 0) {
|
|
478
|
-
throw new Error(result.stderr || "pm create failed");
|
|
479
|
-
}
|
|
480
|
-
imported++;
|
|
481
|
-
}
|
|
482
|
-
catch (err) {
|
|
483
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
484
|
-
console.error(`csv-import: row ${rowIndex + 2} create failed — ${msg}`);
|
|
485
|
-
skipped++;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
console.error(`csv-import: done — imported ${imported}, skipped ${skipped}.`);
|
|
957
|
+
console.error(`csv-import: done — imported ${res.imported}, updated ${res.updated}, skipped ${res.skipped}.`);
|
|
489
958
|
});
|
|
490
959
|
},
|
|
491
960
|
});
|
|
961
|
+
// ---------------------------------------------------------------------------
|
|
962
|
+
// Named exports — pure helpers exposed for unit testing (no side effects).
|
|
963
|
+
// ---------------------------------------------------------------------------
|
|
964
|
+
export { parseCSV, serializeCSV, serializeField, stripBOM, resolveDelimiter, parseFieldMap, applyFieldMap, normalizeStatus, parseTags, stringifyTags, encodeKeyTagValue, decodeKeyTagValue, normalizeKeyValue, selectExportColumns, resolveEncoding, validateParsedCSV, EXPORT_COLUMNS, IMPORT_COLUMNS, };
|
|
492
965
|
//# sourceMappingURL=index.js.map
|