From 281b0c13aac6465d1b83dd15610d6325174c5f5e Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sat, 7 Mar 2026 18:48:29 +0100 Subject: [PATCH] Removed header output relabeling and added column filtering with label handling. --- README.md | 2 +- docs/Commands.md | 13 ++--- package-lock.json | 2 +- package.json | 2 +- scripts/bump-patch.mjs | 20 +++++-- src/cli.ts | 21 +++----- src/cli/utils.ts | 117 ++++++++++++++++++++++++++++------------- src/index.ts | 3 +- src/markdown.ts | 60 +++++++++++++++------ 9 files changed, 160 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 7a7f034..0230a96 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This package hosts non-domain-specific utilities that can be reused by CLI and l Current exported areas: - Markdown table rendering (`toMarkdownTable`) -- Output helpers for CLI tools (`renderOutput`, `normalizeOutputFormat`, `parseHeaderSpec`) +- Output helpers for CLI tools (`renderOutput`, `normalizeOutputFormat`, `parseColumnSpec`) - Input readers for stdin (`readJsonFromStdin`, `readCsvFromStdin`) - JMESPath output filtering (`outputFiltered`) diff --git a/docs/Commands.md b/docs/Commands.md index 0c34b63..76b476e 100644 --- a/docs/Commands.md +++ b/docs/Commands.md @@ -6,16 +6,17 @@ The `sk-tools` package provides generic CLI utilities not tied to a specific clo **Command name:** `table` -**Usage:** `sk-tools table [--from|-F ] [--header|-H ] [global options]` +**Usage:** `sk-tools table [--from|-F ] [--columns|-C ] [global options]` **Options:** - `--from`, `-F` - Input format read from stdin. Default: `json`. -- `--header`, `-H` - Header definition. Possible values: - - `col1, col2, ...` - Column names separated by comma. The number of columns should match table columns. - - `key1: Col1, key2: Col2, ...` - Property-to-column mapping. - - `auto`, `a` - Header generated automatically from result keys. - - `original`, `o` - Header based on original table shape. +- `--columns`, `-C` - Column definition. Possible values: + - `col1` - Select column (case-insensitive match), keep raw header label. + - `col1:` - Select column (case-insensitive match), use auto-generated header label. + - `col1: Label 1` - Select column (case-insensitive match), use custom header label. + - Prefix token with `=` for exact column-name match: `=col1`, `=col1:`, `=col1:Label`. + - Tokens are comma-separated and rendered in the specified order. **Global options:** diff --git a/package-lock.json b/package-lock.json index d220509..7d37cfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@slawek/sk-tools", - "version": "0.1.4", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index d904b2b..873ca2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slawek/sk-tools", - "version": "0.1.4", + "version": "0.2.0", "type": "module", "files": [ "dist", diff --git a/scripts/bump-patch.mjs b/scripts/bump-patch.mjs index 259dd83..d850705 100755 --- a/scripts/bump-patch.mjs +++ b/scripts/bump-patch.mjs @@ -2,9 +2,10 @@ import fs from 'node:fs'; import path from 'node:path'; +import { parseArgs } from 'node:util'; import semver from 'semver'; -function bump(fileName, version) { +function bump(fileName, version, releaseType = 'patch') { const filePath = path.resolve(process.cwd(), fileName); if (!fs.existsSync(filePath)) { @@ -18,7 +19,7 @@ function bump(fileName, version) { throw new Error(`${fileName} does not contain a string "version" field.`); } - const nextVersion = version ?? semver.inc(currentVersion, 'patch'); + const nextVersion = version ?? semver.inc(currentVersion, releaseType); if (!nextVersion) { throw new Error(`Unsupported semver format: "${currentVersion}"`); @@ -31,5 +32,18 @@ function bump(fileName, version) { return nextVersion; } -const bumpedVersion = bump('package.json'); +const { values } = parseArgs({ + options: { + 'release-type': { type: 'string', short: 'r' }, + }, + strict: true, + allowPositionals: false, +}); + +const releaseType = values['release-type'] ?? 'patch'; +if (!['major', 'minor', 'patch'].includes(releaseType)) { + throw new Error(`Invalid --release-type '${releaseType}'. Allowed: major, minor, patch.`); +} + +const bumpedVersion = bump('package.json', undefined, releaseType); bump('package-lock.json', bumpedVersion); diff --git a/src/cli.ts b/src/cli.ts index 3203641..1028168 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,19 +4,16 @@ import { parseArgs } from "node:util"; import { - normalizeOutputFormat, - outputFiltered, - parseHeaderSpec, + renderCliOutput, readCsvFromStdin, readJsonFromStdin, - renderOutput, } from "./cli/utils.ts"; type CliValues = { help?: boolean; from?: string; query?: string; - header?: string; + columns?: string; output?: string; [key: string]: string | boolean | undefined; }; @@ -37,11 +34,11 @@ or: sk-tools --help`; } function usageTable(): string { - return `Usage: sk-tools table [--from|-F ] [--header|-H ] [global options] + return `Usage: sk-tools table [--from|-F ] [--columns|-C ] [global options] Options: --from, -F Input format on stdin (default: json) - --header, -H Header mode: auto|a (default), original|o, or "col1, col2" or "key1: Label 1, key2: Label 2"`; + --columns, -C Column tokens: col (raw), col: (auto), col:Label (custom), with exact match via = prefix (e.g. =col:)`; } function usageCommand(command: string): string { @@ -90,7 +87,7 @@ async function main(): Promise { help: { type: "boolean", short: "h" }, from: { type: "string", short: "F" }, query: { type: "string", short: "q" }, - header: { type: "string", short: "H" }, + columns: { type: "string", short: "C" }, output: { type: "string", short: "o" }, }, strict: true, @@ -108,12 +105,8 @@ async function main(): Promise { throw new Error(`Unknown command: ${command}`); } - const outputFormat = normalizeOutputFormat(typedValues.output); - const result = await runTableCommand(typedValues); - const filtered = outputFiltered(result, typedValues.query); - const headerSpec = parseHeaderSpec(typedValues.header); - - renderOutput(outputFormat, headerSpec, filtered); + const output = await runTableCommand(typedValues); + renderCliOutput(output, typedValues.output, typedValues.query, typedValues.columns); } main().catch((err: unknown) => { diff --git a/src/cli/utils.ts b/src/cli/utils.ts index 1c780f2..344b836 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -5,11 +5,16 @@ import { dsvFormat } from "d3-dsv"; import { toMarkdownTable } from "../markdown.ts"; -type HeaderSpec = - | { mode: "auto" } - | { mode: "original" } - | { mode: "list"; labels: string[] } - | { mode: "map"; map: Record }; +type ColumnToken = { + sourceKey: string; + exactMatch: boolean; + labelMode: "raw" | "auto" | "custom"; + customLabel?: string; +}; + +type ColumnSpec = + | { mode: "default" } + | { mode: "tokens"; tokens: ColumnToken[] }; type OutputFormat = "json" | "table" | "alignedtable" | "prettytable" | "tsv"; @@ -22,41 +27,69 @@ export function outputFiltered(object: unknown, query?: string): unknown { : object; } -export function parseHeaderSpec(headerValue?: string): HeaderSpec { - if (!headerValue) { - return { mode: "auto" }; +function parseColumnToken(rawToken: string): ColumnToken { + const token = rawToken.trim(); + if (!token) { + throw new Error("Invalid --columns token: empty token"); } - const raw = headerValue.trim(); - if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") { - return { mode: "auto" }; + const exactMatch = token.startsWith("="); + const core = exactMatch ? token.slice(1).trim() : token; + if (!core) { + throw new Error(`Invalid --columns token: '${rawToken}'`); } - if (raw.toLowerCase() === "original" || raw.toLowerCase() === "o") { - return { mode: "original" }; + + const separatorIndex = core.indexOf(":"); + if (separatorIndex < 0) { + return { + sourceKey: core, + exactMatch, + labelMode: "raw", + }; + } + + const sourceKey = core.slice(0, separatorIndex).trim(); + if (!sourceKey) { + throw new Error(`Invalid --columns token: '${rawToken}'`); + } + + const rawLabel = core.slice(separatorIndex + 1); + const label = rawLabel.trim(); + if (!label) { + return { + sourceKey, + exactMatch, + labelMode: "auto", + }; + } + + return { + sourceKey, + exactMatch, + labelMode: "custom", + customLabel: label, + }; +} + +export function parseColumnSpec(columnsValue?: string): ColumnSpec { + if (!columnsValue) { + return { mode: "default" }; + } + + const raw = columnsValue.trim(); + if (raw === "") { + return { mode: "default" }; } const parts = raw.split(",").map((p) => p.trim()).filter(Boolean); - const isMap = parts.some((p) => p.includes(":")); - - if (!isMap) { - return { mode: "list", labels: parts }; + if (parts.length === 0) { + return { mode: "default" }; } - const map: Record = {}; - for (const part of parts) { - const idx = part.indexOf(":"); - if (idx < 0) { - throw new Error(`Invalid --header mapping segment: '${part}'`); - } - const key = part.slice(0, idx).trim(); - const label = part.slice(idx + 1).trim(); - if (!key || !label) { - throw new Error(`Invalid --header mapping segment: '${part}'`); - } - map[key] = label; - } - - return { mode: "map", map }; + return { + mode: "tokens", + tokens: parts.map((part) => parseColumnToken(part)), + }; } export function normalizeOutputFormat(outputValue?: string): OutputFormat { @@ -202,7 +235,7 @@ export async function readCsvFromStdin(separator: string): Promise { export function renderOutput( outputFormat: OutputFormat, - headerSpec: HeaderSpec, + columnSpec: ColumnSpec, output: unknown, ): void { if (outputFormat === "tsv") { @@ -215,12 +248,24 @@ export function renderOutput( // - alignedtable: aligned columns, no quoting // - prettytable: aligned columns + quoting selected values if (outputFormat === "prettytable") { - console.log(toMarkdownTable(output, "prettytable", headerSpec)); + console.log(toMarkdownTable(output, "prettytable", columnSpec)); } else if (outputFormat === "alignedtable") { - console.log(toMarkdownTable(output, "alignedtable", headerSpec)); + console.log(toMarkdownTable(output, "alignedtable", columnSpec)); } else if (outputFormat === "table") { - console.log(toMarkdownTable(output, "table", headerSpec)); + console.log(toMarkdownTable(output, "table", columnSpec)); } else { console.log(JSON.stringify(output, null, 2)); } } + +export function renderCliOutput( + output: unknown, + outputValue?: string, + query?: string, + columnsValue?: string, +): void { + const outputFormat = normalizeOutputFormat(outputValue); + const filtered = outputFiltered(output, query); + const columnSpec = parseColumnSpec(columnsValue); + renderOutput(outputFormat, columnSpec, filtered); +} diff --git a/src/index.ts b/src/index.ts index 744353d..3285417 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,10 +3,11 @@ export { toMarkdownTable } from "./markdown.ts"; export { outputFiltered, - parseHeaderSpec, + parseColumnSpec, normalizeOutputFormat, readJsonFromStdin, readCsvFromStdin, renderOutput, + renderCliOutput, } from "./cli/utils.ts"; export { getConfigDir, getConfig } from "./config/index.ts"; diff --git a/src/markdown.ts b/src/markdown.ts index 5faacf0..416ee0e 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -5,12 +5,16 @@ import { validate as validateUuid } from "uuid"; type Scalar = string | number | boolean | null | undefined; type ScalarRow = Record; -type HeaderSpec = +type ColumnToken = { + sourceKey: string; + exactMatch: boolean; + labelMode: "raw" | "auto" | "custom"; + customLabel?: string; +}; + +type ColumnSpec = | { mode: "default" } - | { mode: "auto" } - | { mode: "original" } - | { mode: "list"; labels: string[] } - | { mode: "map"; map: Record }; + | { mode: "tokens"; tokens: ColumnToken[] }; type TableMode = "table" | "alignedtable" | "prettytable"; @@ -103,24 +107,46 @@ function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: Sca return { headers, rows: scalarRows }; } +function resolveColumnKey(headers: string[], token: ColumnToken): string { + if (token.exactMatch) { + return headers.find((header) => header === token.sourceKey) ?? token.sourceKey; + } + + const matches = headers.filter( + (header) => header.toLowerCase() === token.sourceKey.toLowerCase(), + ); + + if (matches.length > 1) { + throw new Error( + `Ambiguous --columns token '${token.sourceKey}' matches multiple columns: ${matches.join(", ")}. Use exact match with '=...'.`, + ); + } + + return matches[0] ?? token.sourceKey; +} + export function toMarkdownTable( value: unknown, mode: TableMode = "table", - headerSpec: HeaderSpec = { mode: "default" }, + columnSpec: ColumnSpec = { mode: "default" }, ): string { const prettyMode = mode === "prettytable"; const { headers, rows } = getScalarRowsAndHeaders(value); - const headerDefinitions = headers.map((key, idx) => { - let label = key; - if (headerSpec.mode === "auto") { - label = toAutoHeaderLabel(key); - } else if (headerSpec.mode === "list" && headerSpec.labels[idx]) { - label = headerSpec.labels[idx]; - } else if (headerSpec.mode === "map" && headerSpec.map[key]) { - label = headerSpec.map[key]; - } - return { key, label }; - }); + const headerDefinitions = columnSpec.mode === "default" + ? headers.map((key) => ({ + key, + label: toAutoHeaderLabel(key), + })) + : columnSpec.tokens.map((token) => { + const key = resolveColumnKey(headers, token); + let label = key; + if (token.labelMode === "auto") { + label = toAutoHeaderLabel(key); + } else if (token.labelMode === "custom") { + label = token.customLabel ?? ""; + } + return { key, label }; + }); if (mode !== "alignedtable" && mode !== "prettytable") { const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`;