pm-csv 2026.6.2 → 2026.6.5

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