Removed header output relabeling and added column filtering with label handling.
All checks were successful
build / build (push) Successful in 8s
All checks were successful
build / build (push) Successful in 8s
This commit is contained in:
@@ -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`)
|
||||
|
||||
|
||||
@@ -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 <json|csv|tsv>] [--header|-H <definition|auto|a|original|o>] [global options]`
|
||||
**Usage:** `sk-tools table [--from|-F <json|csv|tsv>] [--columns|-C <definition>] [global options]`
|
||||
|
||||
**Options:**
|
||||
|
||||
- `--from`, `-F` <json|csv|tsv> - Input format read from stdin. Default: `json`.
|
||||
- `--header`, `-H` <definition|auto|a|original|o> - 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` <definition> - 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:**
|
||||
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@slawek/sk-tools",
|
||||
"version": "0.1.4",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@slawek/sk-tools",
|
||||
"version": "0.1.4",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -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);
|
||||
|
||||
21
src/cli.ts
21
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 <command> --help`;
|
||||
}
|
||||
|
||||
function usageTable(): string {
|
||||
return `Usage: sk-tools table [--from|-F <json|csv|tsv>] [--header|-H <definition|auto|a|original|o>] [global options]
|
||||
return `Usage: sk-tools table [--from|-F <json|csv|tsv>] [--columns|-C <columns>] [global options]
|
||||
|
||||
Options:
|
||||
--from, -F <json|csv|tsv> Input format on stdin (default: json)
|
||||
--header, -H <value> Header mode: auto|a (default), original|o, or "col1, col2" or "key1: Label 1, key2: Label 2"`;
|
||||
--columns, -C <value> 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<void> {
|
||||
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<void> {
|
||||
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) => {
|
||||
|
||||
117
src/cli/utils.ts
117
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<string, string> };
|
||||
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<string, string> = {};
|
||||
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<unknown> {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -5,12 +5,16 @@ import { validate as validateUuid } from "uuid";
|
||||
type Scalar = string | number | boolean | null | undefined;
|
||||
type ScalarRow = Record<string, Scalar>;
|
||||
|
||||
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<string, string> };
|
||||
| { 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(" | ")} |`;
|
||||
|
||||
Reference in New Issue
Block a user