diff --git a/package-lock.json b/package-lock.json index 2f679f9..3c129e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "LGPL-3.0-only", "dependencies": { "argparse": "^2.0.1", - "columnify": "^1.6.0", "csv-stringify": "^6.5.1", "js-yaml": "^4.1.0", "matcher": "^5.0.0", @@ -18,14 +17,14 @@ "mdast-util-frontmatter": "^2.0.1", "mdast-util-gfm": "^3.0.0", "micromark-extension-frontmatter": "^2.0.0", - "micromark-extension-gfm": "^3.0.0" + "micromark-extension-gfm": "^3.0.0", + "simple-wcswidth": "^1.0.1" }, "bin": { "taskparser": "dist/bin.js" }, "devDependencies": { "@types/argparse": "^2.0.16", - "@types/columnify": "^1.5.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22.7.6", "typescript": "^5.6.3" @@ -38,13 +37,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/columnify": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/columnify/-/columnify-1.5.4.tgz", - "integrity": "sha512-YPEVzmy3kJupUee1ueLuvGspy6U2JHcxt6rYvRsSCEgVC54+KdBFjQ6NG/0koZk69e1bfXwSusgChwdFhvEXMw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -91,15 +83,6 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -126,28 +109,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/columnify": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/columnify/-/columnify-1.6.0.tgz", - "integrity": "sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==", - "license": "MIT", - "dependencies": { - "strip-ansi": "^6.0.1", - "wcwidth": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/csv-stringify": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.5.1.tgz", @@ -184,18 +145,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1088,17 +1037,10 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } + "node_modules/simple-wcswidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.0.1.tgz", + "integrity": "sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==" }, "node_modules/typescript": { "version": "5.6.3", @@ -1175,15 +1117,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index b344536..0c0a58f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ }, "dependencies": { "argparse": "^2.0.1", - "columnify": "^1.6.0", "csv-stringify": "^6.5.1", "js-yaml": "^4.1.0", "matcher": "^5.0.0", @@ -30,11 +29,11 @@ "mdast-util-frontmatter": "^2.0.1", "mdast-util-gfm": "^3.0.0", "micromark-extension-frontmatter": "^2.0.0", - "micromark-extension-gfm": "^3.0.0" + "micromark-extension-gfm": "^3.0.0", + "simple-wcswidth": "^1.0.1" }, "devDependencies": { "@types/argparse": "^2.0.16", - "@types/columnify": "^1.5.4", "@types/js-yaml": "^4.0.9", "@types/node": "^22.7.6", "typescript": "^5.6.3" diff --git a/src/bin.ts b/src/bin.ts index 9d921bc..1fe010d 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import type { Item } from './types.js'; +import type { Item, RenderOpts } from './types.js'; import { cwd } from 'node:process'; import { resolve } from 'node:path'; @@ -49,6 +49,10 @@ arg_parser.add_argument('-o', '--out', { choices: ['tabular', 'csv', 'json'], help: 'set output format' }); +arg_parser.add_argument('-C', '--column-width', { + required: false, + help: 'set wrapping or truncation for tag: foo(25t)' +}); arg_parser.add_argument('path', { default: cwd(), help: 'working directory', @@ -67,6 +71,10 @@ const show_tags = cli_args.tags ? ['text', 'hours', 'file', 'date'] : ['text', 'done', 'file', 'date']; +const render_opts: RenderOpts = { + +}; + const renderItems = (items: Set) => { let as_arr = Array.from(items); if (filter) { @@ -77,14 +85,14 @@ const renderItems = (items: Set) => { } switch (cli_args.out) { case 'json': - console.log(renderJSON(as_arr, show_tags)); + console.log(renderJSON(as_arr, show_tags, render_opts)); break; case 'csv': - console.log(renderCSV(as_arr, show_tags)); + console.log(renderCSV(as_arr, show_tags, render_opts)); break; case 'tabular': default: - console.log(renderTabular(as_arr, show_tags)); + console.log(renderTabular(as_arr, show_tags, render_opts)); } }; diff --git a/src/parse.ts b/src/parse.ts index d4f0370..66adcbb 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -44,7 +44,7 @@ const parseTextNode = (node: Text, ctx: ParseFileContext, curr_task: Task | null let match; if (!('text' in curr_wlog.internal_tags) && (match = node.value.match(WL_REGEXP))) { const [full, hours] = match; - const text = node.value.slice(full.length); + const text = node.value.slice(full.length).trim(); curr_wlog.internal_tags.hours = hours; curr_wlog.internal_tags.text = text; extractTagsFromText(text, curr_wlog.tags); @@ -54,7 +54,7 @@ const parseTextNode = (node: Text, ctx: ParseFileContext, curr_task: Task | null } if (curr_task) { if (!('text' in curr_task.internal_tags)) { - curr_task.internal_tags.text = node.value; + curr_task.internal_tags.text = node.value.trim(); } extractTagsFromText(node.value, curr_task.tags); } diff --git a/src/render.ts b/src/render.ts index 40c42f1..ee099e4 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,8 +1,11 @@ import type { Item, RenderItemsFn } from './types.js'; +import { EOL } from 'node:os'; +import { wcswidth } from 'simple-wcswidth'; import { stringify } from 'csv-stringify/sync'; -import columnify from 'columnify'; + +import { wcslice } from './wcslice.js'; type Row = Record; @@ -17,17 +20,69 @@ const toRows = (items: Item[], show_tags: string[]): Row[] => { return items.map(item => toRow(item, show_tags)); }; -export const renderTabular: RenderItemsFn = (items, show_tags) => { - const rows = toRows(items, show_tags); - rows.unshift(show_tags.reduce((acc, col) => { - acc[col] = ''.padStart(col.length, '-'); - return acc; - }, {} as any)); - return columnify(rows, { - columns: show_tags, - columnSplitter: ' | ', - headingTransform: heading => heading, +const row_delimiter = '-'; +const column_delimiter = ' | '; + +/** + * This rendering function renders items in tabular / columnar form. + * If the "text" tag is selected for display, longer values are truncated and + * ellipsed in a best-effort to fit each line within the width of the terminal. + */ +export const renderTabular: RenderItemsFn = (items, show_tags, opts) => { + + // Total number of items to go through + let items_qty = items.length; + + // Maximum value lengths per tag to be displayed + const lengths: number[] = show_tags.map(() => 0); + + // Array of values per tag to be displayed + const values: string[][] = show_tags.map(() => new Array(items_qty)); + + // Populate lengths and values + items.forEach((item, i) => { + show_tags.forEach((tag, t) => { + const value = item.tags[tag]?.replaceAll(/\r?\n/g, '') ?? ''; + lengths[t] = Math.max(wcswidth(value), lengths[t]); + values[t][i] = value; + }); }); + + // Additional rows for headers and header delimiter + show_tags.forEach((tag, t) => { + const tag_width = wcswidth(tag); + lengths[t] = Math.max(tag_width, lengths[t]); + values[t].unshift(''.padStart(tag_width, row_delimiter)); + values[t].unshift(tag); + }); + items_qty += 2; + + // Special processing for values of the "text" tag + const text_tag_i = show_tags.findIndex(t => t === 'text'); + if (text_tag_i > -1) { + const line_length = lengths.reduce((acc, l) => acc + l, 0) + + (column_delimiter.length * (lengths.length - 1)); + if (line_length >= process.stdout.columns) { + const text_length = Math.max('text'.length, lengths[text_tag_i] + - (line_length - process.stdout.columns)); + values[text_tag_i] = values[text_tag_i] + .map(v => wcswidth(v) > text_length ? wcslice(v, 0, text_length - 1) + '…' : v); + lengths[text_tag_i] = text_length; + } + } + + const lines = new Array(items_qty); + + for (let i = 0; i < items_qty; i += 1) { + lines[i] = wcslice( + show_tags.map((tag, t) => values[t][i].padEnd(lengths[t], ' ')) + .join(column_delimiter), + 0, + process.stdout.columns, + ); + } + + return lines.join(EOL); }; export const renderCSV: RenderItemsFn = (items, show_tags) => { diff --git a/src/types.ts b/src/types.ts index 0598406..b823aec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,8 @@ export interface TagSortExpression extends BaseTagExpression { order: 'asc' | 'desc'; } +export interface RenderOpts {} + export interface RenderItemsFn { - (items: Item[], show_tags: string[]): string; -} \ No newline at end of file + (items: Item[], show_tags: string[], opts: RenderOpts): string; +} diff --git a/src/wcslice.ts b/src/wcslice.ts new file mode 100644 index 0000000..a983cfb --- /dev/null +++ b/src/wcslice.ts @@ -0,0 +1,49 @@ + +// Initially vendored from https://github.com/frouriojs/wcslice (MIT) +// at commit f2d133593b1df46aedbcd92ac9af37886a48c90e +// https://github.com/frouriojs/wcslice/blob/f2d133593b1df46aedbcd92ac9af37886a48c90e/src/index.ts + +import { wcswidth } from 'simple-wcswidth'; + +/** + * This treats last zero-length chars as infinitesimal length. + */ +export const wcslice = (str: string, start?: number | undefined, end?: number | undefined) => { + const wclens = str.split('').reduce( + (c, e) => { + c.push(c[c.length - 1] + wcswidth(e)); + return c; + }, + [0], + ); + const wclen = wclens[wclens.length - 1]; + if (start === undefined) { + start = 0; + } + if (end === undefined) { + end = wclen + 1; + } + if (end < 0) end = 0; + const strStart = (() => { + let lo = -1; + let hi = str.length; + while (lo + 1 < hi) { + const mi = (lo + hi + 1) >> 1; + if (wclens[mi] >= start) hi = mi; + else lo = mi; + } + return hi; + })(); + const strEnd = (() => { + let lo = -1; + let hi = str.length; + while (lo + 1 < hi) { + const mi = (lo + hi + 1) >> 1; + if (wclens[mi] >= end) hi = mi; + else lo = mi; + } + if (wcswidth(str.slice(0, hi)) > end) return hi - 1; + return hi; + })(); + return str.slice(strStart, strEnd); +}; diff --git a/tsconfig.json b/tsconfig.json index 421a5af..ad50d3e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,12 +45,12 @@ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + "allowJs": false, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + "checkJs": false, /* Enable error reporting in type-checked JavaScript files. */ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */