diff --git a/.gitignore b/.gitignore index d9cd699..162c9ce 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ yarn-error.log **/._* out dist +.claude/ diff --git a/Dockerfile b/Dockerfile index d7b837c..c68cf56 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.16.0-alpine3.21 +FROM node:24.18.0-alpine3.23 RUN addgroup -S gtt && adduser -S gtt -G gtt USER gtt diff --git a/documentation.md b/documentation.md index 499790f..f84142d 100644 --- a/documentation.md +++ b/documentation.md @@ -274,10 +274,9 @@ gtt report example-group example-group-2 --type=group gtt report --output=table gtt report --output=markdown gtt report --output=csv -gtt report --output=xlsx --file=filename.xlsx gtt report --output=invoice --file=invoice.md ``` -Defaults to `table`. `csv` and `markdown` can be printed to stdout, `xlsx` need the file parameter. +Defaults to `table`. `csv` and `markdown` can be printed to stdout. pdf output was dropped. you can use 3rd party tools to convert markdown to pdf, e.g ```shell @@ -599,7 +598,7 @@ timeFormat: timezone: "Europe/Berlin" # Output type -# Available: csv, table, markdown, pdf, xlsx +# Available: csv, table, markdown, invoice # defaults to table output: markdown diff --git a/package-lock.json b/package-lock.json index 25d85e5..48b94d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,31 +10,25 @@ "license": "GPL-2.0", "dependencies": { "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", "@inquirer/select": "^5.2.1", - "async": "^3.2.6", "camelcase": "^9.0.0", "cli-cursor": "^5.0.0", "cli-table": "^0.3.11", - "colors": "^1.4.0", "commander": "^15.0.0", "csv-string": "^4.1.1", + "dayjs": "^1.11.21", "env-paths": "^4.0.0", "hash-sum": "^2.0.0", "hashids": "^2.3.0", + "js-yaml": "^5.2.1", "markdown-table": "^3.0.4", - "moment": "^2.30.1", - "moment-timezone": "^0.6.2", - "node-spinner": "^0.0.4", "open": "^11.0.0", + "picocolors": "^1.1.1", "progress": "^2.0.3", - "prompt": "^1.3.0", - "read-yaml": "^1.1.0", - "shelljs": "^0.10.0", "swissqrbill": "^4.3.0", "tempfile": "^6.0.1", - "throttled-queue": "^3.0.0", - "underscore": "^1.13.8", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" + "throttled-queue": "^3.0.0" }, "bin": { "gtt": "dist/gtt.cjs" @@ -176,15 +170,6 @@ "node": ">=6.9.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -659,6 +644,27 @@ } } }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@inquirer/core": { "version": "11.2.1", "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", @@ -835,41 +841,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1017,15 +988,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -1196,18 +1160,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -1472,15 +1424,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -1501,6 +1444,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1520,13 +1464,11 @@ "node": ">=12.0" } }, - "node_modules/cycle": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", - "integrity": "sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==", - "engines": { - "node": ">=0.4.0" - } + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -1802,19 +1744,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -1825,50 +1754,6 @@ "bare-events": "^2.7.0" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1879,26 +1764,6 @@ "node": ">=6" } }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", - "engines": { - "node": "> 0.1.90" - } - }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -1906,22 +1771,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -1946,15 +1795,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1973,18 +1813,6 @@ } } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2071,18 +1899,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -2112,18 +1928,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2176,15 +1980,6 @@ "he": "bin/he" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2264,24 +2059,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2292,18 +2069,6 @@ "node": ">=8" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -2334,15 +2099,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -2365,18 +2121,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -2416,14 +2160,9 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "license": "MIT" - }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -2448,10 +2187,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", "funding": [ { "type": "github", @@ -2467,7 +2205,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsesc": { @@ -2512,12 +2250,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -2552,55 +2284,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -2750,6 +2433,29 @@ "dev": true, "license": "MIT" }, + "node_modules/mocha/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/mocha/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -2828,27 +2534,6 @@ "node": ">=12" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.2.tgz", - "integrity": "sha512-lDsQv8FoGdBUdf0+TjGsq2orxKuXdwFlQ6Zw6TX3xIcTwTfEpCLyKqvEauvCHJ8iu3KBV8+uPhlv70YsNGdUBQ==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2917,27 +2602,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-spinner": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/node-spinner/-/node-spinner-0.0.4.tgz", - "integrity": "sha512-W4ivgsaxoJr1wSppeEQGYrq23GuMniTwJNZWLpFadCIuxwu1srmdH2uH+VlaMnxvjBqZ4NlcCBQEOmB3mXF+cA==", - "license": "MIT", - "dependencies": { - "util-extend": "~1.0.1" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3036,6 +2700,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3069,7 +2734,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3204,28 +2868,6 @@ "node": ">=0.4.0" } }, - "node_modules/prompt": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/prompt/-/prompt-1.3.0.tgz", - "integrity": "sha512-ZkaRWtaLBZl7KKAKndKYUL8WqNT+cQHKRZnT4RYYms48jQkFw3rrBL+/N5K/KtdEveHkxs982MX2BkDKub2ZMg==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.5.0", - "async": "3.2.3", - "read": "1.0.x", - "revalidator": "0.1.x", - "winston": "2.x" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/prompt/node_modules/async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -3237,26 +2879,6 @@ "once": "^1.3.1" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -3293,59 +2915,6 @@ "node": ">=0.10.0" } }, - "node_modules/read": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", - "license": "ISC", - "dependencies": { - "mute-stream": "~0.0.4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/read-yaml": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-yaml/-/read-yaml-1.1.0.tgz", - "integrity": "sha512-bDA8pyfGurfEHlgy/wm4vPmcCNW4QPsf86u9JvOixYWc8YD2O985hmZwZrBlzfvf/J4vKGTOWq/CThixACPRPA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "js-yaml": "^3.8.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-yaml/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/read-yaml/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/read/node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "license": "ISC" - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3433,25 +3002,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/revalidator": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/revalidator/-/revalidator-0.1.8.tgz", - "integrity": "sha512-xcBILK2pA9oh4SiinPEZfhP8HfrB/ha+a2fTMyl7Om2WjlDVrOQy99N2MXXlUHqGJz4qEu2duXxHJjDWuK/0xg==", - "license": "Apache 2.0", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -3464,29 +3014,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3535,6 +3062,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3547,24 +3075,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/shelljs": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.10.0.tgz", - "integrity": "sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw==", - "license": "BSD-3-Clause", - "dependencies": { - "execa": "^5.1.1", - "fast-glob": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3651,21 +3167,6 @@ "node": ">=0.3.1" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/stream-meter": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", @@ -3835,15 +3336,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4029,18 +3521,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -4064,12 +3544,6 @@ "node": ">=4" } }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -4111,16 +3585,11 @@ "dev": true, "license": "MIT" }, - "node_modules/util-extend": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/util-extend/-/util-extend-1.0.3.tgz", - "integrity": "sha512-mLs5zAK+ctllYBj+iAQvlDCwoxU/WDOUaJkcFudeiAX6OajC6BKXJUa9a+tbtkC11dz2Ufb7h0lyvIOVn4LADA==", - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4132,41 +3601,6 @@ "node": ">= 8" } }, - "node_modules/winston": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.7.tgz", - "integrity": "sha512-vLB4BqzCKDnnZH9PHGoS2ycawueX4HLqENXQitvFHczhgW2vFpSOn31LZtVr1KU8YTw7DS4tM+cqyovxo8taVg==", - "license": "MIT", - "dependencies": { - "async": "^2.6.4", - "colors": "1.0.x", - "cycle": "1.0.x", - "eyes": "0.1.x", - "isstream": "0.1.x", - "stack-trace": "0.0.x" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/winston/node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" - } - }, - "node_modules/winston/node_modules/colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/workerpool": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", @@ -4261,18 +3695,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/xlsx": { - "version": "0.20.3", - "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", - "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", - "license": "Apache-2.0", - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 4cfe24c..26607e2 100755 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "prebuild": "echo \"let version = '${npm_package_version}'; export default version;\" > src/version.js", "test": "NODE_ENV=test mocha 'spec/**/*.spec.js'", - "esbuild": "esbuild 'src/gtt.js' --format=cjs --target=node20 --platform=node --bundle --outfile=dist/gtt.cjs --inject:./src/polyfill/import-meta-url.js --define:import.meta.url=import_meta_url", + "esbuild": "esbuild 'src/gtt.js' --format=cjs --target=node24 --platform=node --bundle --outfile=dist/gtt.cjs --inject:./src/polyfill/import-meta-url.js --define:import.meta.url=import_meta_url", "pkg": "pkg dist/gtt.cjs -o out/gtt -c package.json", "docker": "docker build . -t gitlab-time-tracker:${npm_package_version} -t gitlab-time-tracker:latest", "build": "npm run-script prebuild && npm run-script test && npm run-script esbuild && npm run-script pkg", @@ -38,31 +38,25 @@ "license": "GPL-2.0", "dependencies": { "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", "@inquirer/select": "^5.2.1", - "async": "^3.2.6", "camelcase": "^9.0.0", "cli-cursor": "^5.0.0", "cli-table": "^0.3.11", - "colors": "^1.4.0", "commander": "^15.0.0", "csv-string": "^4.1.1", + "dayjs": "^1.11.21", "env-paths": "^4.0.0", "hash-sum": "^2.0.0", "hashids": "^2.3.0", + "js-yaml": "^5.2.1", "markdown-table": "^3.0.4", - "moment": "^2.30.1", - "moment-timezone": "^0.6.2", - "node-spinner": "^0.0.4", "open": "^11.0.0", + "picocolors": "^1.1.1", "progress": "^2.0.3", - "prompt": "^1.3.0", - "read-yaml": "^1.1.0", - "shelljs": "^0.10.0", "swissqrbill": "^4.3.0", "tempfile": "^6.0.1", - "throttled-queue": "^3.0.0", - "underscore": "^1.13.8", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" + "throttled-queue": "^3.0.0" }, "devDependencies": { "@yao-pkg/pkg": "^6.21.0", diff --git a/spec/include/config.spec.js b/spec/include/config.spec.js index 0ff2a13..ba38709 100755 --- a/spec/include/config.spec.js +++ b/spec/include/config.spec.js @@ -32,7 +32,7 @@ describe('The config class', () => { expect(Config.get(key)).to.equal(undefined); }); - it('returns moment instances for dates', () => { + it('returns dayjs instances for dates', () => { let Config = new config(), dates = ['from', 'to']; diff --git a/spec/include/parallel.spec.js b/spec/include/parallel.spec.js new file mode 100644 index 0000000..e5935b9 --- /dev/null +++ b/spec/include/parallel.spec.js @@ -0,0 +1,70 @@ +import { expect } from 'chai'; +import Config from '../../src/core/config.js'; +import parallel from '../../src/core/parallel.js'; + +const tick = () => new Promise(resolve => setImmediate(resolve)); + +describe('parallel', () => { + let config; + + beforeEach(() => { + config = new Config(); + }); + + it('processes every task', async () => { + const seen = []; + + await parallel([1, 2, 3, 4, 5], async task => { + await tick(); + seen.push(task); + }, config); + + expect(seen.sort()).to.deep.equal([1, 2, 3, 4, 5]); + }); + + it('accepts plain sync workers', async () => { + const seen = []; + + await parallel([1, 2], task => seen.push(task), config); + + expect(seen).to.deep.equal([1, 2]); + }); + + it('never runs more workers than the runner limit', async () => { + let active = 0; + let peak = 0; + + await parallel(Array.from({ length: 12 }, (_, i) => i), async () => { + active++; + peak = Math.max(peak, active); + await tick(); + active--; + }, config, 3); + + expect(peak).to.be.at.most(3); + }); + + it('rejects with the first worker error and starts no further tasks', async () => { + const started = []; + let caught = null; + + try { + await parallel([1, 2, 3, 4, 5], async task => { + started.push(task); + await tick(); + if (task === 1) throw new Error('boom'); + }, config, 1); + } catch (error) { + caught = error; + } + + expect(caught).to.be.an('error').with.property('message', 'boom'); + expect(started).to.deep.equal([1]); + }); + + it('handles an empty task list', async () => { + await parallel([], () => { + throw new Error('should not run'); + }, config); + }); +}); diff --git a/spec/models/frame.spec.js b/spec/models/frame.spec.js new file mode 100644 index 0000000..fa09d84 --- /dev/null +++ b/spec/models/frame.spec.js @@ -0,0 +1,80 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { expect } from 'chai'; +import Config from '../../src/core/config.js'; +import Frame from '../../src/timekeeping/storage/frame.js'; + +describe('frame class', () => { + let config, dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gtt-frame-')); + config = new Config(); + config.frameDir = dir; + config.set('project', 'group/project'); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('marks frames for not-yet-existing resources as new', () => { + expect(new Frame(config, 'a new issue', 'issue').resource.new).to.equal(true); + expect(new Frame(config, 42, 'issue').resource.new).to.equal(undefined); + }); + + it('persists on start/stop and round-trips through fromFile', () => { + const frame = new Frame(config, 42, 'issue', 'a note').startMe(); + frame.stopMe(); + + const loaded = Frame.fromFile(config, frame.file); + + expect(loaded.id).to.equal(frame.id); + expect(loaded.project).to.equal('group/project'); + expect(loaded.resource).to.deep.equal({ id: 42, type: 'issue' }); + expect(loaded.note).to.equal('a note'); + expect(loaded.start.isSame(frame.start)).to.equal(true); + expect(loaded.stop.isSame(frame.stop)).to.equal(true); + }); + + it('computes the duration in seconds', () => { + const frame = Frame.fromJson(config, { + id: 'abc', + project: 'group/project', + resource: { id: 42, type: 'issue' }, + notes: [], + start: '2026-01-05T10:00:00Z', + stop: '2026-01-05T11:30:00Z', + timezone: 'UTC' + }); + + expect(frame.duration).to.equal(5400); + }); + + it('rejects invalid start and stop dates', () => { + const json = { + id: 'abc', + project: 'group/project', + resource: { id: 42, type: 'issue' }, + notes: [], + start: '2026-01-05T10:00:00Z', + stop: false, + timezone: 'UTC' + }; + + expect(() => Frame.fromJson(config, { ...json, start: 'not a date' })).to.throw(/Start date/); + expect(() => Frame.fromJson(config, { ...json, stop: 'not a date' })).to.throw(/Stop date/); + }); + + it('validates dates set through the setters', () => { + const frame = new Frame(config, 42, 'issue').startMe(); + + expect(() => { + frame.stop = 'not a date'; + }).to.throw(); + + frame.stop = '2026-01-05T11:30:00Z'; + expect(frame.stop.isSame('2026-01-05T11:30:00Z')).to.equal(true); + }); +}); diff --git a/spec/models/time.title.spec.js b/spec/models/time.title.spec.js index 77a46ae..62c31e1 100644 --- a/spec/models/time.title.spec.js +++ b/spec/models/time.title.spec.js @@ -1,6 +1,6 @@ -import moment from 'moment'; -import Config from '../../src/core/file-config.js'; +import dayjs from '../../src/core/dayjs.js'; +import Config from '../../src/core/config.js'; import Time from '../../src/core/time.js'; import task from '../../src/core/task.js'; import { expect } from 'chai'; @@ -9,7 +9,7 @@ describe('time class', () => { it('Returns title of parent Issue', () => { const config = new Config(process.cwd()); const parent = new task(config, {title: "Test title"}, undefined, 'issues') - const time = new Time('1h', moment(), {}, parent, config); + const time = new Time('1h', dayjs(), {}, parent, config); expect(time.title).to.be.equal("Test title"); }); @@ -17,7 +17,7 @@ describe('time class', () => { it('Returns title of parent MergeRequest', () => { const config = new Config(process.cwd()); const parent = new task(config, {title: "Test title"}, undefined, 'merge_requests') - const time = new Time('1h', moment(), {}, parent, config); + const time = new Time('1h', dayjs(), {}, parent, config); expect(time.title).to.be.equal("Test title"); }); @@ -26,10 +26,10 @@ describe('time class', () => { const config = new Config(process.cwd()); const parent = new task(config, {}, undefined, 'merge_requests'); let time; - time = new Time('1h', moment(), {}, parent, config); + time = new Time('1h', dayjs(), {}, parent, config); expect(time.title).to.be.equal(null); - time = new Time('1h', moment(), {}, null, config); + time = new Time('1h', dayjs(), {}, null, config); expect(time.title).to.be.equal(null); }); }); diff --git a/spec/output/base.spec.js b/spec/output/base.spec.js new file mode 100644 index 0000000..4e2ad29 --- /dev/null +++ b/spec/output/base.spec.js @@ -0,0 +1,134 @@ +import dayjs from '../../src/core/dayjs.js'; +import { expect } from 'chai'; +import Config from '../../src/core/config.js'; +import Output from '../../src/reporting/output/base.js'; +import calculateStats from '../../src/reporting/stats.js'; + +function makeTime({ user = 'alice', seconds = 0, date = '2026-01-05T10:00:00Z', iid = 1, project = 'group/project' } = {}) { + return { user, seconds, iid, project_namespace: project, date: dayjs(date) }; +} + +function makeIssue({ iid = 1, labels = [], times = [], days = {}, estimate = 0, spent = 0 } = {}) { + return { + iid, + labels, + times, + days, + stats: { time_estimate: estimate, total_time_spent: spent } + }; +} + +describe('calculateStats', () => { + let config; + + beforeEach(() => { + config = new Config(); + }); + + function calculate(issues = [], mergeRequests = []) { + return calculateStats(config, { issues, mergeRequests }); + } + + it('aggregates spent time per user and per project', () => { + const output = calculate([ + makeIssue({ + iid: 1, times: [ + makeTime({ user: 'alice', seconds: 3600 }), + makeTime({ user: 'bob', seconds: 1800 }) + ] + }), + makeIssue({ + iid: 2, times: [ + makeTime({ user: 'alice', seconds: 1800, project: 'group/other' }) + ] + }) + ]); + + expect(output.spent).to.equal(7200); + expect(output.users).to.deep.equal({ alice: '1h 30m', bob: '30m' }); + expect(output.projects).to.deep.equal({ 'group/project': '1h 30m', 'group/other': '30m' }); + expect(output.stats.spent).to.equal('2h'); + }); + + it('sums estimates and total spent across issues and merge requests', () => { + const output = calculate( + [makeIssue({ iid: 1, estimate: '3600', spent: '7200' })], + [makeIssue({ iid: 1, estimate: '1800', spent: '900' })] + ); + + expect(output.totalEstimate).to.equal(5400); + expect(output.totalSpent).to.equal(8100); + expect(output.stats['total estimate']).to.equal('1h 30m'); + expect(output.stats['total spent']).to.equal('2h 15m'); + }); + + it('tracks free and half price time by label', () => { + config.set('freeLabels', ['pro bono']); + config.set('halfPriceLabels', ['discount']); + + const output = calculate([ + makeIssue({ iid: 1, labels: ['pro bono'], times: [makeTime({ seconds: 3600 })] }), + makeIssue({ iid: 2, labels: ['discount', 'bug'], times: [makeTime({ seconds: 1800 })] }), + makeIssue({ iid: 3, labels: ['bug'], times: [makeTime({ seconds: 60 })] }) + ]); + + expect(output.spentFree).to.equal(3600); + expect(output.spentHalfPrice).to.equal(1800); + expect(output.spent).to.equal(5460); + }); + + it('treats missing free/half price label config as empty', () => { + const output = calculate([ + makeIssue({ iid: 1, labels: ['bug'], times: [makeTime({ seconds: 3600 })] }) + ]); + + expect(output.spentFree).to.equal(0); + expect(output.spentHalfPrice).to.equal(0); + }); + + it('sorts issues by iid and times by date, newest first', () => { + const report = { + issues: [ + makeIssue({ iid: 1, times: [makeTime({ date: '2026-01-01T10:00:00Z' })] }), + makeIssue({ iid: 3, times: [makeTime({ date: '2026-01-03T10:00:00Z' })] }), + makeIssue({ iid: 2, times: [makeTime({ date: '2026-01-02T10:00:00Z' })] }) + ], + mergeRequests: [] + }; + const output = calculateStats(config, report); + + expect(report.issues.map(issue => issue.iid)).to.deep.equal([3, 2, 1]); + expect(output.times.map(time => time.date.format('YYYY-MM-DD'))).to.deep.equal([ + '2026-01-03', '2026-01-02', '2026-01-01' + ]); + }); + + it('consolidates per-day data of all issues', () => { + const output = calculate([ + makeIssue({ iid: 1, days: { '2026-01-01': 'a' } }), + makeIssue({ iid: 2, days: { '2026-01-01': 'b', '2026-01-02': 'c' } }) + ]); + + expect(output.daysNew).to.deep.equal({ + '2026-01-01': ['a', 'b'], + '2026-01-02': ['c'] + }); + }); +}); + +describe('Output.prepare', () => { + it('formats dayjs dates, replaces null/undefined and picks columns in order', () => { + const config = new Config(); + config.set('dateFormat', 'YYYY-MM-DD'); + + const output = new Output(config, { issues: [], mergeRequests: [] }); + const row = output.prepare({ + iid: 7, + date: dayjs('2026-01-05T10:00:00Z'), + missing: null, + labels: ['a', 'b'] + }, ['iid', 'date', 'missing', 'undefined_column', 'labels']); + + expect(row).to.deep.equal([7, '2026-01-05', '', '', 'a,b']); + }); +}); diff --git a/src/core/cli.js b/src/core/cli.js index 16e6409..3b70aef 100755 --- a/src/core/cli.js +++ b/src/core/cli.js @@ -1,16 +1,16 @@ -import _ from 'underscore'; -import colors from 'colors'; -import prompt from 'prompt'; -import spinnerFactory from 'node-spinner'; -const spinner = spinnerFactory(); +import pc from 'picocolors'; +import confirm from '@inquirer/confirm'; import cursor from 'cli-cursor'; import progress from 'progress'; -spinner.set('|/-\\'); + +const spinnerFrames = ['|', '/', '-', '\\']; +let spinnerIndex = 0; +const spinner = { next: () => spinnerFrames[spinnerIndex++ % spinnerFrames.length] }; /** - * cli helper + * Cli helper */ -class cli { +class Cli { constructor(args) { this.args = args; this.data = []; @@ -56,24 +56,10 @@ class cli { * @param message * @returns {Promise} */ - static ask(message) { - return new Promise((resolve, reject) => { - prompt.start(); - - let question = { - name: 'yesno', - message: message, - validator: /y[es]*|n[o]?/, - warning: 'Must respond yes or no', - default: 'yes' - }; - - prompt.get(question, function (error, result) { - if (error || result.yesno === 'no' || result.yesno === 'n') return reject(error); - - resolve(); - }); - }); + static async ask(message) { + let answer = await confirm({ message, default: true }); + + if (!answer) throw new Error('Declined'); } /** @@ -81,7 +67,7 @@ class cli { * @param string */ static out(string) { - if (cli.quiet) return; + if (Cli.quiet) return; process.stdout.write(string); } @@ -89,7 +75,7 @@ class cli { * print done message */ static done() { - cli.out(`\n${cli.party} Finished!\n`.green); + Cli.out(pc.green(`\n${Cli.party} Finished!\n`)); } /** @@ -97,7 +83,7 @@ class cli { * @param message */ static warn(message) { - cli.out(` Warning: ${message} `.bgWhite.black + "\n"); + Cli.out(pc.bgWhite(pc.black(` Warning: ${message} `)) + "\n"); } /** @@ -107,13 +93,13 @@ class cli { * @returns {*} */ static bar(message, total) { - cli.resolve(false); + Cli.resolve(false); - if (cli.quiet) return cli.promise(); + if (Cli.quiet) return Cli.promise(); this.active = { started: new Date(), - message: `\r${message}... `.bold.grey, + message: pc.bold(pc.gray(`\r${message}... `)), bar: new progress(`${message} (:current/:total) [:bar] :percent - :minutesm left`, { total, clear: true, @@ -121,13 +107,13 @@ class cli { renderThrottle: 100 }), interval: setInterval(() => { - if (!cli.active.bar || cli.active.bar.complete) return clearInterval(cli.active.interval); - cli.tick(0); + if (!Cli.active.bar || Cli.active.bar.complete) return clearInterval(Cli.active.interval); + Cli.tick(0); }, 1000) }; this.tick(); - return cli.promise(); + return Cli.promise(); } /** @@ -135,19 +121,19 @@ class cli { * @param amount */ static tick(amount = 0) { - if (!cli.active.bar || !cli.active.started) return; + if (!Cli.active.bar || !Cli.active.started) return; let left; - if (cli.active.bar.curr > 0) { - let elapsed = Math.ceil((new Date() - cli.active.started) / 1000); - left = ((elapsed / cli.active.bar.curr) * (cli.active.bar.total - cli.active.bar.curr)) / 60; + if (Cli.active.bar.curr > 0) { + let elapsed = Math.ceil((new Date() - Cli.active.started) / 1000); + left = ((elapsed / Cli.active.bar.curr) * (Cli.active.bar.total - Cli.active.bar.curr)) / 60; left = left < 1 ? `<1` : Math.ceil(left); } else { left = 0; } - cli.active.bar.tick(amount, { + Cli.active.bar.tick(amount, { minutes: left }); } @@ -156,7 +142,7 @@ class cli { * advance an existing bar */ static advance() { - cli.tick(1); + Cli.tick(1); } /** @@ -165,14 +151,14 @@ class cli { * @returns {*} */ static list(message) { - cli.resolve(false); + Cli.resolve(false); - this.active = {message: `\r${message}... `.bold.grey}; + this.active = {message: pc.bold(pc.gray(`\r${message}... `))}; this.active.interval = setInterval(() => { - cli.out(cli.active.message + spinner.next().bold.blue); + Cli.out(Cli.active.message + pc.bold(pc.blue(spinner.next()))); }, 100); - return cli.promise(); + return Cli.promise(); } /** @@ -180,10 +166,10 @@ class cli { * @returns {*} */ static mark() { - cli.resolve(); - if (cli.active) cli.out(`${cli.active.message}` + `✓\n`.green); + Cli.resolve(); + if (Cli.active) Cli.out(`${Cli.active.message}` + pc.green(`✓\n`)); - return cli.promise(); + return Cli.promise(); } /** @@ -193,11 +179,11 @@ class cli { * @returns {*} */ static x(message = false, error = false) { - cli.resolve(); - if (cli.active) cli.out(`${cli.active.message}` + `✗\n`.red); + Cli.resolve(); + if (Cli.active) Cli.out(`${Cli.active.message}` + pc.red(`✗\n`)); - if (message) cli.error(message, error); - return cli.promise(); + if (message) Cli.error(message, error); + return Cli.promise(); } /** @@ -205,7 +191,7 @@ class cli { */ static resolve(show = true) { cursor.toggle(show); - if (cli.active && cli.active.interval) clearInterval(cli.active.interval); + if (Cli.active && Cli.active.interval) clearInterval(Cli.active.interval); } /** @@ -215,10 +201,15 @@ class cli { * @returns {*} */ static error(message, error) { - cli.resolve(); + Cli.resolve(); + + if (message instanceof Error) { + error = error ?? message; + message = message.message; + } - cli.out(`Error: ${message.red}` + '\n'); - if (error && cli.verbose) console.log(error); + Cli.out(`Error: ${pc.red(message)}` + '\n'); + if (error && Cli.verbose) console.log(error); process.exit(1); } @@ -242,8 +233,8 @@ class cli { if (this.data.project) return this.data.project; - let projects = _.uniq(_.filter(this.args, arg => isNaN(new Number(arg)))); - this.args = _.difference(this.args, projects); + let projects = [...new Set(this.args.filter(arg => isNaN(new Number(arg))))]; + this.args = this.args.filter(arg => !projects.includes(arg)); if(projects.length == 0) return null; @@ -258,10 +249,10 @@ class cli { iids() { if (this.data.iids) return this.data.iids; - this.data.iids = _.uniq(_.flatten(_.map(this.args, (issue) => { + this.data.iids = [...new Set(this.args.map((issue) => { if (issue.indexOf(',') === -1) return issue; return issue.split(','); - }))); + }).flat())]; if (this.data.iids.length === 0) return null; @@ -269,4 +260,4 @@ class cli { } } -export default cli; \ No newline at end of file +export default Cli; \ No newline at end of file diff --git a/src/timekeeping/commands/config.js b/src/core/commands/config.js similarity index 83% rename from src/timekeeping/commands/config.js rename to src/core/commands/config.js index dde13eb..766f80b 100755 --- a/src/timekeeping/commands/config.js +++ b/src/core/commands/config.js @@ -1,6 +1,6 @@ import {Command} from 'commander'; -import Config from '../../core/file-config.js'; -import Fs from '../../core/filesystem.js'; +import Config from '../file-config.js'; +import Fs from '../filesystem.js'; let config = new Config(process.cwd()); diff --git a/src/core/config.js b/src/core/config.js index ad8cdd6..4cd3cb7 100755 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,7 +1,5 @@ -import moment from 'moment'; -import _ from 'underscore'; +import dayjs from './dayjs.js'; import Time from './time.js'; -import EventEmitter from 'events'; const dates = ['from', 'to']; const objectsWithDefaults = ['timeFormat', 'columns']; @@ -12,7 +10,7 @@ const defaults = { token: false, project: false, from: "1970-01-01", - to: moment().format(), + to: dayjs().format(), iids: false, closed: false, milestone: false, @@ -50,14 +48,12 @@ const defaults = { /** * basic config */ -class config extends EventEmitter { +class Config { /** * construct */ constructor() { - super(); - - this.data = _.extend({}, defaults); + this.data = {...defaults}; } /** @@ -67,7 +63,7 @@ class config extends EventEmitter { * @param key * @param value * @param force - * @returns {config} + * @returns {Config} */ set(key, value, force = false) { if (!force && (value === null || value === undefined)) return this; @@ -85,9 +81,9 @@ class config extends EventEmitter { */ get(key, subKey = false) { if (dates.includes(key)) - return moment(this.data[key]); + return dayjs(this.data[key]); - if (objectsWithDefaults.includes(key) && _.isObject(this.data[key])) + if (objectsWithDefaults.includes(key) && typeof this.data[key] === 'object' && this.data[key] !== null) return subKey && this.data[key][subKey] ? this.data[key][subKey] : defaults[key]; return this.data[key]; @@ -104,4 +100,4 @@ class config extends EventEmitter { } } -export default config; +export default Config; diff --git a/src/core/dayjs.js b/src/core/dayjs.js new file mode 100644 index 0000000..671142d --- /dev/null +++ b/src/core/dayjs.js @@ -0,0 +1,14 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; +import timezone from 'dayjs/plugin/timezone.js'; +import relativeTime from 'dayjs/plugin/relativeTime.js'; +import advancedFormat from 'dayjs/plugin/advancedFormat.js'; + +// single place to configure dayjs: utc+timezone for frame timestamps, +// relativeTime for "x minutes ago", advancedFormat for the "Do" token +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(relativeTime); +dayjs.extend(advancedFormat); + +export default dayjs; diff --git a/src/core/file-config.js b/src/core/file-config.js index 9138e96..e412b30 100755 --- a/src/core/file-config.js +++ b/src/core/file-config.js @@ -1,18 +1,19 @@ import fs from 'fs'; -import shell from 'shelljs'; import path from 'path'; import os from 'os'; -import config from './config.js'; -import yaml from 'read-yaml'; +import Config from './config.js'; +import { load as yamlLoad } from 'js-yaml'; import hash from 'hash-sum'; import Fs from './filesystem.js'; import envPaths from 'env-paths'; +const readYaml = file => yamlLoad(fs.readFileSync(file, 'utf8')); + /** * file config with local and global configuration files */ -class fileConfig extends config { +class FileConfig extends Config { /** * construct * @param workDir @@ -22,7 +23,7 @@ class fileConfig extends config { this.assertGlobalConfig(); this.workDir = workDir; this.data = Object.assign(this.data, this.localExists() ? this.parseLocal() : this.parseGlobal()); - if (!fs.existsSync(this.frameDir)) shell.mkdir('-p', this.frameDir); + if (!fs.existsSync(this.frameDir)) fs.mkdirSync(this.frameDir, { recursive: true }); this.cache = { delete: this._cacheDelete, get: this._cacheGet, @@ -37,7 +38,7 @@ class fileConfig extends config { */ parseGlobal() { try { - return yaml.sync(this.global, {}); + return readYaml(this.global); } catch (e) { console.log(`Error parsing configuration: "${this.global}"`); process.exit(1); @@ -50,14 +51,14 @@ class fileConfig extends config { */ parseLocal() { try { - let local = Object.assign({extend: true}, yaml.sync(this.local, {})); + let local = Object.assign({extend: true}, readYaml(this.local)); if (local.extend === true) { let global = this.parseGlobal(); local = Object.assign(global ? global : {}, local); } else if (local.extend) { try { - local = Object.assign(yaml.sync(local.extend, {}), local); + local = Object.assign(readYaml(local.extend), local); } catch (e) { console.log(`Error parsing configuration: "${local.extend}"`); process.exit(1); @@ -93,8 +94,8 @@ class fileConfig extends config { - if (!fs.existsSync(this.globalDir)) shell.mkdir('-p', this.globalDir); - if (!fs.existsSync(this.cacheDir)) shell.mkdir('-p', this.cacheDir); + if (!fs.existsSync(this.globalDir)) fs.mkdirSync(this.globalDir, { recursive: true }); + if (!fs.existsSync(this.cacheDir)) fs.mkdirSync(this.cacheDir, { recursive: true }); if (!fs.existsSync(this.global)) fs.appendFileSync(this.global, ''); } @@ -157,4 +158,4 @@ class fileConfig extends config { } } -export default fileConfig; +export default FileConfig; diff --git a/src/core/filesystem.js b/src/core/filesystem.js index 5b50d42..3c7b23e 100755 --- a/src/core/filesystem.js +++ b/src/core/filesystem.js @@ -1,10 +1,9 @@ -import _ from 'underscore'; import fs from 'fs'; import path from 'path'; import open from 'open'; import child_process from 'child_process'; -class filesystem { +class Filesystem { static exists(file) { return fs.existsSync(file); } @@ -38,11 +37,16 @@ class filesystem { } static newest(dir) { - return _.max(filesystem.readDir(dir), file => (fs.statSync(path.join(dir, file.name)).ctime)); + let files = Filesystem.readDir(dir); + let ctime = file => fs.statSync(path.join(dir, file.name)).ctime; + + return files.reduce((newest, file) => (ctime(file) > ctime(newest) ? file : newest), files[0] ?? -Infinity); } static all(dir) { - return _.sortBy(filesystem.readDir(dir), file => (fs.statSync(path.join(dir, file.name)).ctime)); + let ctime = file => fs.statSync(path.join(dir, file.name)).ctime; + + return Filesystem.readDir(dir).sort((a, b) => ctime(a) - ctime(b)); } static readDir(dir) { @@ -50,4 +54,4 @@ class filesystem { } } -export default filesystem; +export default Filesystem; diff --git a/src/core/gitlab-client.js b/src/core/gitlab-client.js index d1578ee..1ab4ea5 100755 --- a/src/core/gitlab-client.js +++ b/src/core/gitlab-client.js @@ -35,64 +35,83 @@ class GitlabClient { * query the given path * @param path * @param data - * @returns {*} + * @returns {Promise} */ post(path, data) { - - data.private_token = this.token; - - - return new Promise((resolve, reject) => GitlabClient.throttle(() => { - fetch(`${this.url}${path}`, { + return GitlabClient.throttle(async () => { + const response = await fetch(`${this.url}${path}`, { method: 'POST', headers: { 'PRIVATE-TOKEN': this.token, 'Content-Type': 'application/json' }, body: JSON.stringify(data) - }).then(response => { - if(!response.ok) { - reject(`Error: response not OK`); - } - const isJson = response.headers.get("content-type").startsWith('application/json'); - return (Promise.all([isJson ? response.json(): Promise.resolve(undefined), Promise.resolve(response.headers)])); - }).then(response => { - resolve({body: response[0], headers: response[1]}); - }).catch(e => reject(e)); - })); + }); + + await GitlabClient.assertOk(response, 'POST', path); + + const isJson = (response.headers.get('content-type') ?? '').startsWith('application/json'); + const body = isJson ? await response.json() : undefined; + + return { body, headers: response.headers }; + }); } + /** + * throw a descriptive error if the response is not OK + * @param response + * @param method + * @param path + */ + static async assertOk(response, method, path) { + if (response.ok) return; + + let body = ''; + try { + body = (await response.text()).slice(0, 512); + } catch { /* body is optional error context */ } + + throw new Error(`${method} ${path} failed: ${response.status} ${response.statusText}${body ? ` — ${body}` : ''}`); + } /** - * query the given path + * throw if the response is not JSON + * @param response + * @param method * @param path + */ + static assertJson(response, method, path) { + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.startsWith('application/json')) + throw new Error(`${method} ${path} returned content-type "${contentType}", expected application/json`); + } + + + /** + * query the given path * @param data - * @returns {*} + * @returns {Promise} */ - graphQL(data) { + graphQL(data) { // remove v4/ from url, add graphql const path = this.url.substr(0, this.url.length-3) + 'graphql'; - - return new Promise((resolve, reject) => GitlabClient.throttle(() => { - fetch(`${path}`, { + + return GitlabClient.throttle(async () => { + const response = await fetch(`${path}`, { method: 'POST', headers: { - 'Authorization': 'Bearer '+this.token, + 'Authorization': 'Bearer '+this.token, 'Content-Type': 'application/json' }, body: JSON.stringify(data) - }).then(response => { - if(!response.ok) { - reject(`Error: response not OK`); - } - if(!response.headers.get("content-type").startsWith('application/json')) { - reject(`Error: response not application/json`); - } - return (Promise.all([response.json(), Promise.resolve(response.headers)])); - }).then(response => { - resolve({body: response[0], headers: response[1]}); - }).catch(e => reject(e)); - })); + }); + + await GitlabClient.assertOk(response, 'POST', path); + GitlabClient.assertJson(response, 'POST', path); + + return { body: await response.json(), headers: response.headers }; + }); } @@ -104,27 +123,20 @@ class GitlabClient { * @returns {Promise} */ get(path, page = 1, perPage = this._perPage) { + path += (path.includes('?') ? '&' : '?') + `page=${page}&per_page=${perPage}`; - path += (path.includes('?') ? '&' : '?') + `private_token=${this.token}`; - path += `&page=${page}&per_page=${perPage}`; - - return new Promise((resolve, reject) => GitlabClient.throttle(() => { - fetch(`${this.url}${path}`, { + return GitlabClient.throttle(async () => { + const response = await fetch(`${this.url}${path}`, { headers: { 'PRIVATE-TOKEN': this.token } - }).then(response => { - if(!response.ok) { - reject(`Error: response not OK`); - } - if(!response.headers.get("content-type").startsWith('application/json')) { - reject(`Error: response not application/json`); - } - return (Promise.all([response.json(), Promise.resolve(response.headers)])); - }).then(response => { - resolve({body: response[0], headers: response[1]}); - }).catch(e => reject(e)); - })); + }); + + await GitlabClient.assertOk(response, 'GET', path); + GitlabClient.assertJson(response, 'GET', path); + + return { body: await response.json(), headers: response.headers }; + }); } /** @@ -135,22 +147,17 @@ class GitlabClient { * @param runners * @returns {Promise} */ - all(path, perPage = this._perPage, runners = this._parallel) { - return new Promise((resolve, reject) => { - let collect = []; + async all(path, perPage = this._perPage, runners = this._parallel) { + const response = await this.get(path, 1, perPage); + const collect = [...response.body]; + const pages = parseInt(response.headers.get('x-total-pages')); - this.get(path, 1, perPage).then((response) => { - response.body.forEach(item => collect.push(item)); - let pages = parseInt(response.headers.get('x-total-pages')); + if (pages === 1) return collect; - if (pages === 1) return resolve(collect); + const tasks = GitlabClient.createGetTasks(path, pages, 2, perPage); + await this.getParallel(tasks, collect, runners); - let tasks = GitlabClient.createGetTasks(path, pages, 2, perPage); - this.getParallel(tasks, collect, runners).then(() => { - resolve(collect); - }).catch(error => reject(error)); - }).catch(err => reject(err)); - }); + return collect; } /** @@ -162,11 +169,9 @@ class GitlabClient { * @returns {Promise} */ getParallel(tasks, collect = [], runners = this._parallel) { - return parallel(tasks, (task, done) => { - this.get(task.path, task.page, task.perPage).then((response) => { - response.body.forEach(item => collect.push(item)); - done(); - }).catch(error => done(error)); + return parallel(tasks, async task => { + const response = await this.get(task.path, task.page, task.perPage); + response.body.forEach(item => collect.push(item)); }, this.config, runners); } diff --git a/src/core/owner.js b/src/core/owner.js index ee370f6..761aaf7 100755 --- a/src/core/owner.js +++ b/src/core/owner.js @@ -1,11 +1,10 @@ -import _ from 'underscore'; import GitlabClient from './gitlab-client.js'; import parallel from './parallel.js'; /** * owner model */ -class owner { +class Owner { constructor(config, client = new GitlabClient(config)) { this.config = config; this.client = client; @@ -33,16 +32,17 @@ class owner { /** * query and set the group + * @param fullPath group path, e.g. "group/subgroup" * @returns {Promise} */ - getGroup() { + getGroup(fullPath = this.config.get('project')) { return new Promise((resolve, reject) => { this.client.get(`groups`) .then(groups => { if (groups.body.length === 0) return reject('Group not found'); groups = groups.body; - let filtered = groups.filter(group => group.full_path === this.config.get('project')); + let filtered = groups.filter(group => group.full_path === fullPath); if (filtered.length === 0) return reject('Group not found'); this.groups = this.groups.concat(filtered); resolve(); @@ -121,15 +121,10 @@ class owner { * @returns {Promise} */ getProjectsByGroup() { - return parallel(this.groups, (group, done) => { - this.client.all(`groups/${group.id}/projects`) - .then(projects => { - this.projects = projects; - done(); - }) - .catch(e => done(e)); + return parallel(this.groups, async group => { + this.projects = await this.client.all(`groups/${group.id}/projects`); }, this.config); } } -export default owner; +export default Owner; diff --git a/src/core/parallel.js b/src/core/parallel.js index 2afb4d0..11ae70b 100644 --- a/src/core/parallel.js +++ b/src/core/parallel.js @@ -1,19 +1,30 @@ -import async from 'async'; - /** - * run the given async worker over the given tasks with a bounded - * number of concurrent runners. + * run the given worker over the given tasks with a bounded number of + * concurrent runners. Rejects with the first worker error; no new tasks + * are started after a failure. * @param tasks iterable of work items - * @param worker (task, done) => void — node-style async callback per item + * @param worker async (task) => void — may also be a plain sync function * @param config config instance, supplies the default runner count (_parallel) * @param runners max number of concurrent workers, defaults to config._parallel * @returns {Promise} */ -export default function parallel(tasks, worker, config, runners = config.get('_parallel')) { - return new Promise((resolve, reject) => { - async.eachLimit(Array.from(tasks), runners, worker, error => { - if (error) return reject(error); - resolve(); - }); - }); +export default async function parallel(tasks, worker, config, runners = config.get('_parallel')) { + const queue = Array.from(tasks); + let index = 0; + let failed = false; + + const runner = async () => { + while (index < queue.length && !failed) { + const task = queue[index++]; + try { + await worker(task); + } catch (error) { + failed = true; + throw error; + } + } + }; + + const count = Math.min(Math.max(parseInt(runners) || 1, 1), Math.max(queue.length, 1)); + await Promise.all(Array.from({ length: count }, runner)); } diff --git a/src/core/task.js b/src/core/task.js index 264b364..4890433 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -1,5 +1,4 @@ -import _ from 'underscore'; -import moment from 'moment'; +import dayjs from './dayjs.js'; import GitlabClient from './gitlab-client.js'; /** @@ -8,7 +7,7 @@ import GitlabClient from './gitlab-client.js'; * list() query live in timekeeping/api/*; read/aggregation in reporting/api/*. * @param type the GitLab resource type: 'issues' or 'merge_requests' */ -class task { +class Task { constructor(config, data = {}, client = new GitlabClient(config), type) { this.config = config; this.client = client; @@ -42,9 +41,12 @@ class task { } get labels() { - let labels = _.difference(this.data.labels, this.config.get('excludeLabels')); + let excludeLabels = this.config.get('excludeLabels'); + let labels = Array.isArray(excludeLabels) + ? (this.data.labels || []).filter(label => !excludeLabels.includes(label)) + : (this.data.labels || []); let include = this.config.get('includeLabels'); - return include.length > 0 ? _.intersection(labels, include) : labels; + return include.length > 0 ? labels.filter(label => include.includes(label)) : labels; } get milestone() { @@ -64,11 +66,11 @@ class task { } get updated_at() { - return moment(this.data.updated_at); + return dayjs(this.data.updated_at); } get created_at() { - return moment(this.data.created_at); + return dayjs(this.data.created_at); } get state() { @@ -80,7 +82,7 @@ class task { } get due_date() { - return this.data.due_date ? moment(this.data.due_date): null; + return this.data.due_date ? dayjs(this.data.due_date): null; } get total_spent() { @@ -100,4 +102,4 @@ class task { } } -export default task; +export default Task; diff --git a/src/core/time.js b/src/core/time.js index 5535f19..c4ddb8d 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -1,5 +1,4 @@ -import _ from 'underscore'; -import moment from 'moment'; +import dayjs from './dayjs.js'; const defaultTimeFormat = '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'; const mappings = ['complete', 'sign', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds']; @@ -16,7 +15,7 @@ Number.prototype.padLeft = function (n, str) { /** * time model */ -class time { +class Time { /** * construct * @param timeString @@ -34,7 +33,7 @@ class time { return; } - this.seconds = time.parse(timeString, this._hoursPerDay, this._daysPerWeek, this._weeksPerMonth); + this.seconds = Time.parse(timeString, this._hoursPerDay, this._daysPerWeek, this._weeksPerMonth); } /* @@ -49,7 +48,7 @@ class time { } get date() { - return this._date ? moment(this._date) : moment(this.data.created_at); + return this._date ? dayjs(this._date) : dayjs(this.data.created_at); } get type() { @@ -65,7 +64,7 @@ class time { } get time() { - return time.toHumanReadable(this.seconds, this._hoursPerDay, this._timeFormat); + return Time.toHumanReadable(this.seconds, this._hoursPerDay, this._timeFormat); } /** @@ -105,7 +104,7 @@ class time { let match, parsed; if ((match = regex.exec(string)) === null) return false; - parsed = _.object(mappings, match.map(i => i === undefined ? 0 : i)); + parsed = Object.fromEntries(mappings.map((key, i) => [key, match[i] === undefined ? 0 : match[i]])); return (parsed.sign ? -1 : 1) * (parseInt(parsed.seconds) + (parseInt(parsed.minutes) * 60) @@ -122,7 +121,7 @@ class time { * @param format * @returns {string} */ - static toHumanReadable(input, hoursPerDay = 8, format = time.defaultTimeFormat) { + static toHumanReadable(input, hoursPerDay = 8, format = Time.defaultTimeFormat) { let sign = parseInt(input) < 0 ? '-' : '', output = format, match; input = Math.abs(input); @@ -180,4 +179,4 @@ class time { } } -export default time; +export default Time; diff --git a/src/gtt.js b/src/gtt.js index bb05049..cd2a84c 100755 --- a/src/gtt.js +++ b/src/gtt.js @@ -4,6 +4,7 @@ import version from './version.js'; import { program } from 'commander'; +import config from './core/commands/config.js'; import start from './timekeeping/commands/start.js'; import create from './timekeeping/commands/create.js'; import status from './timekeeping/commands/status.js'; @@ -15,7 +16,6 @@ import log from './timekeeping/commands/log.js'; import sync from './timekeeping/commands/sync.js'; import edit from './timekeeping/commands/edit.js'; import delCmd from './timekeeping/commands/delete.js'; -import config from './timekeeping/commands/config.js'; import report from './reporting/commands/report.js'; program diff --git a/src/reporting/api/dayReport.js b/src/reporting/api/dayReport.js index 23d6c7f..6d8f71b 100644 --- a/src/reporting/api/dayReport.js +++ b/src/reporting/api/dayReport.js @@ -2,7 +2,7 @@ /** * day model of one item */ -class dayReport { +class DayReport { constructor(iid, title, spentAt, chargeRatio) { this.iid = iid; this.title = title; @@ -50,4 +50,4 @@ class dayReport { } -export default dayReport; +export default DayReport; diff --git a/src/reporting/api/issue.js b/src/reporting/api/issue.js index 830e41a..a70f93b 100644 --- a/src/reporting/api/issue.js +++ b/src/reporting/api/issue.js @@ -4,10 +4,10 @@ import reportable from './reportable.js'; /** * issue with reporting read/aggregation (getStats, recordTimelogs). */ -class issue extends reportable(CoreTask) { +class Issue extends reportable(CoreTask) { constructor(config, data, client) { super(config, data, client, 'issues'); } } -export default issue; +export default Issue; diff --git a/src/reporting/api/mergeRequest.js b/src/reporting/api/mergeRequest.js index a4e01d6..e4b7ad9 100644 --- a/src/reporting/api/mergeRequest.js +++ b/src/reporting/api/mergeRequest.js @@ -4,10 +4,10 @@ import reportable from './reportable.js'; /** * merge request with reporting read/aggregation (getStats, recordTimelogs). */ -class mergeRequest extends reportable(CoreTask) { +class MergeRequest extends reportable(CoreTask) { constructor(config, data, client) { super(config, data, client, 'merge_requests'); } } -export default mergeRequest; +export default MergeRequest; diff --git a/src/reporting/api/project.js b/src/reporting/api/project.js index b0b2507..e09ec93 100755 --- a/src/reporting/api/project.js +++ b/src/reporting/api/project.js @@ -3,7 +3,7 @@ import GitlabClient from '../../core/gitlab-client.js'; /** * project model */ -class project { +class Project { /** * construct * @param config @@ -69,4 +69,4 @@ class project { } } -export default project; \ No newline at end of file +export default Project; \ No newline at end of file diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 51a3d6c..3554980 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -1,5 +1,4 @@ -import _ from 'underscore'; -import moment from 'moment'; +import dayjs from '../../core/dayjs.js'; import GitlabClient from '../../core/gitlab-client.js'; import parallel from '../../core/parallel.js'; import Issue from './issue.js'; @@ -9,7 +8,7 @@ import Project from './project.js'; /** * report model */ -class report { +class Report { /** * constructor. * @param config @@ -67,10 +66,11 @@ class report { /** * query and set the project + * @param namespace project path, e.g. "group/project" * @returns {Promise} */ - getProject() { - let promise = this.client.get(`projects/${encodeURIComponent(this.config.get('project'))}`); + getProject(namespace = this.config.get('project')) { + let promise = this.client.get(`projects/${encodeURIComponent(namespace)}`); promise.then(project => this.setProject(project.body)); return promise; @@ -169,8 +169,8 @@ class report { "project": this.project.data.path_with_namespace, "after": (cursor===undefined)?'':cursor, "entryPerPage": 30, - "startTime": this.config.get('from').format("Y-M-D"), - "endTime": this.config.get('to').format("Y-M-D") + "startTime": this.config.get('from').format("YYYY-M-D"), + "endTime": this.config.get('to').format("YYYY-M-D") } }; @@ -234,8 +234,7 @@ class report { process(input, model, advance = false) { let collect = []; - let promise = parallel(this[input], (data, done) => { - + let promise = parallel(this[input], async data => { let item = new model(this.config, data, this.client); item.project_namespace = this.projects[item.project_id]; @@ -243,21 +242,14 @@ class report { timelog => timelog[input] && timelog[input].iid == data.iid && timelog[input].projectId == data.project_id)); - - item.getStats() - .catch(error => done(error)) - .then(() => { - if (this.config.get('showWithoutTimes') || item.times.length > 0) { - collect.push(item); - } - if (advance) advance(); - return done(); - }); + await item.getStats(); + if (this.config.get('showWithoutTimes') || item.times.length > 0) { + collect.push(item); + } - // collect items, query times & stats - collect.push(); + if (advance) advance(); }, this.config); promise.then(() => this[input] = this.filter(collect)); @@ -297,4 +289,4 @@ class report { } } -export default report; \ No newline at end of file +export default Report; \ No newline at end of file diff --git a/src/reporting/api/reportCollection.js b/src/reporting/api/reportCollection.js index d23bab3..6d13e50 100755 --- a/src/reporting/api/reportCollection.js +++ b/src/reporting/api/reportCollection.js @@ -1,20 +1,20 @@ import parallel from '../../core/parallel.js'; -let projlist = []; -class reportCollection { +class ReportCollection { constructor(config) { this.config = config; this.reports = []; + this.projectNames = []; } forEach(iterator) { - return parallel(this.reports, (report, done) => iterator(report, done), this.config); + return parallel(this.reports, iterator, this.config); } push(report) { - if (projlist.indexOf(report.project.name) === -1) { - projlist.push(report.project.name); + if (!this.projectNames.includes(report.project.name)) { + this.projectNames.push(report.project.name); this.reports.push(report); } } @@ -23,4 +23,4 @@ class reportCollection { } } -export default reportCollection; +export default ReportCollection; diff --git a/src/reporting/api/reportable.js b/src/reporting/api/reportable.js index af7a93c..d45d9c6 100644 --- a/src/reporting/api/reportable.js +++ b/src/reporting/api/reportable.js @@ -1,5 +1,4 @@ -import _ from 'underscore'; -import moment from 'moment'; +import dayjs from '../../core/dayjs.js'; import Time from '../../core/time.js'; import DayReport from './dayReport.js'; @@ -53,7 +52,7 @@ export default Base => class extends Base { timelogs.forEach( (timelog) => { - let spentAt = moment(timelog.spentAt); + let spentAt = dayjs(timelog.spentAt); let dateGrp = spentAt.format(this.config.get('dateFormatGroupReport')); if(!this.days[dateGrp]) { @@ -83,7 +82,7 @@ export default Base => class extends Base { times.push(time); }); - _.each(timeUsers, (time, name) => this[`time_${name}`] = Time.toHumanReadable(time, this.config.get('hoursPerDay'), timeFormat)); + Object.entries(timeUsers).forEach(([name, time]) => this[`time_${name}`] = Time.toHumanReadable(time, this.config.get('hoursPerDay'), timeFormat)); this.timeSpent = timeSpent; this.times = times; } diff --git a/src/reporting/commands/report.js b/src/reporting/commands/report.js index d8d89cd..ad7da93 100755 --- a/src/reporting/commands/report.js +++ b/src/reporting/commands/report.js @@ -1,7 +1,7 @@ -import _ from 'underscore'; import fs from 'fs'; +import pc from 'picocolors'; import {Command} from 'commander'; -import moment from 'moment'; +import dayjs from '../../core/dayjs.js'; import Cli from '../../core/cli.js'; import Config from '../../core/file-config.js'; import Report from '../api/report.js'; @@ -10,13 +10,12 @@ import ReportCollection from '../api/reportCollection.js'; import GitlabClient from '../../core/gitlab-client.js'; import parallel from '../../core/parallel.js'; // output backends are imported lazily so timekeeping commands never pull in -// the heavy deps (xlsx, swissqrbill, markdown-table, csv-string, cli-table) +// the heavy deps (swissqrbill, markdown-table, csv-string, cli-table) const Output = { table: () => import('../output/table.js'), csv: () => import('../output/csv.js'), markdown: () => import('../output/markdown.js'), - invoice: () => import('../output/invoice.js'), - xlsx: () => import('../output/xlsx.js') + invoice: () => import('../output/invoice.js') }; // this collects options @@ -24,7 +23,7 @@ function collect(val, arr) { if (!arr) arr = []; arr.push(val); - return _.uniq(arr); + return [...new Set(arr)]; } @@ -78,7 +77,7 @@ function report() { .option('--invoicePositionExtra ', 'extra invoice position: header text') .option('--invoicePositionExtraText ', 'extra invoice position: text') .option('--invoicePositionExtraValue ', 'extra invoice position: value') - .action((project, ids, options, program) => { + .action(async (project, ids, options, program) => { // init helpers let config = new Config(process.cwd()); @@ -135,20 +134,20 @@ config // date shortcuts if (program.opts().today) config - .set('from', moment().startOf('day')) - .set('to', moment().endOf('day')); + .set('from', dayjs().startOf('day')) + .set('to', dayjs().endOf('day')); if (program.opts().this_week) config - .set('from', moment().startOf('week')) - .set('to', moment().endOf('week')); + .set('from', dayjs().startOf('week')) + .set('to', dayjs().endOf('week')); if (program.opts().this_month) config - .set('from', moment().startOf('month')) - .set('to', moment().endOf('month')); + .set('from', dayjs().startOf('month')) + .set('to', dayjs().endOf('month')); if (program.opts().last_month) config - .set('from', moment().subtract(1, 'months').startOf('month')) - .set('to', moment().subtract(1, 'months').endOf('month')); + .set('from', dayjs().subtract(1, 'months').startOf('month')) + .set('to', dayjs().subtract(1, 'months').endOf('month')); Cli.quiet = config.get('quiet'); Cli.verbose = config.get('_verbose'); @@ -162,8 +161,8 @@ if(config.get('invoicePositionExtraText').length != config.get('invoicePositionE let client = new GitlabClient(config), reports = new ReportCollection(config), master = new Report(config, undefined, client), - projectLabels = _.isArray(config.get('project')) ? config.get('project').join('", "') : config.get('project'), - projects = _.isArray(config.get('project')) ? config.get('project') : [config.get('project')], + projectLabels = Array.isArray(config.get('project')) ? config.get('project').join('", "') : config.get('project'), + projects = Array.isArray(config.get('project')) ? config.get('project') : [config.get('project')], output; // warnings @@ -191,202 +190,177 @@ if (!config.get('from').isValid()) { if (!config.get('to').isValid()) { Cli.error(`TO is not a in valid ISO date format.`); } -if (config.get('output') === 'xlsx' && !config.get('file')) { - Cli.error(`Cannot output an xlsx to stdout. You probably forgot to use the --file parameter`); -} // file prompt -new Promise(resolve => { - if (config.get('file') && fs.existsSync(config.get('file'))) { - Cli.ask(`The file "${config.get('file')}" already exists. Overwrite?`) - .then(() => resolve()) - .catch(error => Cli.error(`can't write file.`, error)); - } else { - resolve(); +if (config.get('file') && fs.existsSync(config.get('file'))) { + try { + await Cli.ask(`The file "${config.get('file')}" already exists. Overwrite?`); + } catch (error) { + Cli.error(`can't write file.`, error); } -}) +} // get project(s) - .then(() => new Promise((resolve, reject) => { - Cli.list(`${Cli.look} Resolving "${projectLabels}"`); - let owner = new Owner(config, client); - - owner.authorized() - .catch(e => Cli.x(`Invalid access token!`, e)) - .then(() => parallel(projects, (project, done) => { - config.set('project', project); - - switch (config.get('type')) { - case 'project': - let report = new Report(config, undefined, client); - report.getProject() - .then(() => { - reports.push(report); - done(); - }) - .catch(e => Cli.x(`Project not found or no access rights "${projectLabels}".`, e)); - break; - - case 'group': - owner.getGroup() - .then(() => { - if (!config.get('subgroups')) return new Promise(r => r()); - return owner.getSubGroups(); - }) - .then(() => owner.getProjectsByGroup() - .then(() => { - owner.projects.forEach(project => reports.push(new Report(config, project, client))); - done(); - })) - .catch(e => done(e)); - break; +Cli.list(`${Cli.look} Resolving "${projectLabels}"`); +let owner = new Owner(config, client); + +try { + await owner.authorized(); +} catch (error) { + Cli.x(`Invalid access token!`, error); +} + +try { + await parallel(projects, async project => { + switch (config.get('type')) { + case 'project': { + let report = new Report(config, undefined, client); + try { + await report.getProject(project); + } catch (error) { + Cli.x(`Project not found or no access rights "${projectLabels}".`, error); } - }, config, 1)) - .catch(e => reject(e)) - .then(() => { - config.set('project', projects); - resolve(); - }); - })) - .then(() => Cli.out(`\r${Cli.look} Selected projects: ${reports.reports.map(r => r.project.name.bold.blue).join(', ')}\n`)) + reports.push(report); + break; + } + + case 'group': { + await owner.getGroup(project); + if (config.get('subgroups')) await owner.getSubGroups(); + await owner.getProjectsByGroup(); + owner.projects.forEach(project => reports.push(new Report(config, project, client))); + break; + } + } + }, config, 1); + + config.set('project', projects); + Cli.out(`\r${Cli.look} Selected projects: ${reports.reports.map(r => pc.bold(pc.blue(r.project.name))).join(', ')}\n`); // get members and user columns - .then(() => new Promise(resolve => { - if (!config.get('userColumns')) return resolve(); - reports - .forEach((report, done) => { - report.project.members() - .then(() => { - let columns = report.project.users.map(user => `time_${user}`); - - config.set('issueColumns', _.uniq(config.get('issueColumns').concat(columns))); - config.set('mergeRequestColumns', _.uniq(config.get('mergeRequestColumns').concat(columns))); - - done(); - }) - .catch(error => done(error)); - }) - .catch(error => Cli.x(`could not fetch project.`, error)) - .then(() => resolve()); - })) - .then(() => Cli.mark()) - .catch(error => Cli.x(`Could not resolve "${projectLabels}"`, error)) - - // get issues - .then(() => new Promise(resolve => { - if (!config.get('query').includes('issues')) return resolve(); - - Cli.list(`${Cli.fetch} Fetching issues`); - - reports - .forEach((report, done) => { - report.getIssues() - .then(() => done()); - }) - .catch(error => Cli.x(`could not fetch issues.`, error)) - .then(() => Cli.mark()) - .then(() => resolve()); - })) - - // get merge requests - .then(() => new Promise(resolve => { - if (!config.get('query').includes('merge_requests')) return resolve(); - - Cli.list(`${Cli.fetch} Fetching merge requests`); - - reports - .forEach((report, done) => { - report.getMergeRequests() - .catch(error => done(error)) - .then(() => done()); - }) - .catch(error => Cli.x(`could not fetch merge requests.`, error)) - .then(() => Cli.mark()) - .then(() => resolve()); - })) - - // get timelogs - .then(() => new Promise(resolve => { - Cli.list(`${Cli.fetch} Loading timelogs`); - - reports - .forEach((report, done) => { - report.getTimelogs() - .catch(error => done(error)) - .then(() => done()); - }) - .catch(error => Cli.x(`could not load timelogs.`, error)) - .then(() => Cli.mark()) - .then(() => resolve()); - })) - - // merge reports - .then(() => new Promise(resolve => { - Cli.list(`${Cli.merge} Merging reports`); - - reports - .forEach((report, done) => { - master.merge(report); - done(); - }) - .catch(error => Cli.x(`could not merge reports.`, error)) - .then(() => Cli.mark()) - .then(() => resolve()); - })) - - // process issues - .then(() => new Promise(resolve => { - if (!config.get('query').includes('issues') || master.issues.length === 0) return resolve(); - - Cli.bar(`${Cli.process}️ Processing issues`, master.issues.length); - master.processIssues(() => Cli.advance()) - .then(() => Cli.mark()) - .catch(error => Cli.x(`could not process issues.`, error)) - .then(() => resolve()); - })) - - // process merge requests - .then(() => new Promise(resolve => { - if (!config.get('query').includes('merge_requests') || master.mergeRequests.length === 0) return resolve(); - - Cli.bar(`${Cli.process}️️ Processing merge requests`, master.mergeRequests.length); - master.processMergeRequests(() => Cli.advance()) - .then(() => Cli.mark()) - .catch(error => Cli.x(`could not process merge requests.`, error)) - .then(() => resolve()); - })) - - // make report - .then(() => Output[config.get('output')]()) - .then(module => new Promise(resolve => { - if (master.issues.length === 0 && master.mergeRequests.length === 0) - Cli.error('No issues or merge requests matched your criteria.'); - - Cli.list(`${Cli.output} Making report`); - output = new module.default(config, master); - output.make(); - Cli.mark(); - resolve(); - })) - .catch(error => Cli.x(`could not make report.`, error)) - - // print report - .then(() => new Promise(resolve => { - Cli.list(`${Cli.print} Printing report`); - if (config.get('file')) { - output.toFile(config.get('file'), resolve); - } else { - output.toStdOut(); - resolve(); - } - })) - .catch(error => Cli.x(`could not print report.`, error)) - .then(() => Cli.mark()) + if (config.get('userColumns')) { + await reports.forEach(async report => { + await report.project.members(); + let columns = report.project.users.map(user => `time_${user}`); + + config.set('issueColumns', [...new Set(config.get('issueColumns').concat(columns))]); + config.set('mergeRequestColumns', [...new Set(config.get('mergeRequestColumns').concat(columns))]); + }); + } + + Cli.mark(); +} catch (error) { + Cli.x(`Could not resolve "${projectLabels}"`, error); +} + +// get issues +if (config.get('query').includes('issues')) { + Cli.list(`${Cli.fetch} Fetching issues`); + + try { + await reports.forEach(report => report.getIssues()); + } catch (error) { + Cli.x(`could not fetch issues.`, error); + } + + Cli.mark(); +} + +// get merge requests +if (config.get('query').includes('merge_requests')) { + Cli.list(`${Cli.fetch} Fetching merge requests`); + + try { + await reports.forEach(report => report.getMergeRequests()); + } catch (error) { + Cli.x(`could not fetch merge requests.`, error); + } + + Cli.mark(); +} + +// get timelogs +Cli.list(`${Cli.fetch} Loading timelogs`); + +try { + await reports.forEach(report => report.getTimelogs()); +} catch (error) { + Cli.x(`could not load timelogs.`, error); +} - // time for a beer - .then(() => Cli.done()); +Cli.mark(); + +// merge reports +Cli.list(`${Cli.merge} Merging reports`); + +try { + await reports.forEach(report => master.merge(report)); +} catch (error) { + Cli.x(`could not merge reports.`, error); +} + +Cli.mark(); + +// process issues +if (config.get('query').includes('issues') && master.issues.length > 0) { + Cli.bar(`${Cli.process}️ Processing issues`, master.issues.length); + + try { + await master.processIssues(() => Cli.advance()); + } catch (error) { + Cli.x(`could not process issues.`, error); + } + + Cli.mark(); +} + +// process merge requests +if (config.get('query').includes('merge_requests') && master.mergeRequests.length > 0) { + Cli.bar(`${Cli.process}️️ Processing merge requests`, master.mergeRequests.length); + + try { + await master.processMergeRequests(() => Cli.advance()); + } catch (error) { + Cli.x(`could not process merge requests.`, error); + } + + Cli.mark(); } -); + +// make report +if (master.issues.length === 0 && master.mergeRequests.length === 0) + Cli.error('No issues or merge requests matched your criteria.'); + +try { + let module = await Output[config.get('output')](); + + Cli.list(`${Cli.output} Making report`); + output = new module.default(config, master); + output.make(); +} catch (error) { + Cli.x(`could not make report.`, error); +} + +Cli.mark(); + +// print report +Cli.list(`${Cli.print} Printing report`); + +try { + if (config.get('file')) { + output.toFile(config.get('file')); + } else { + output.toStdOut(); + } +} catch (error) { + Cli.x(`could not print report.`, error); +} + +Cli.mark(); + +// time for a beer +Cli.done(); +}); return report; } diff --git a/src/reporting/output/base.js b/src/reporting/output/base.js index 5507b5a..9f61c3f 100755 --- a/src/reporting/output/base.js +++ b/src/reporting/output/base.js @@ -1,6 +1,6 @@ -import _ from 'underscore'; import fs from 'fs'; -import moment from 'moment'; +import dayjs from '../../core/dayjs.js'; +import calculateStats from '../stats.js'; const defaultFormats = { headline: h => `${h}\n`, @@ -9,7 +9,7 @@ const defaultFormats = { }; /** - * Base output renderer: takes a report, aggregates it via calculate(), and + * Base output renderer: renders a report aggregated by calculateStats and * exposes write/headline/toStdOut/toFile. Root of the output-format hierarchy. */ class Output { @@ -23,7 +23,7 @@ class Output { this.report = report; this.out = ''; this.formats = defaultFormats; - this.calculate(); + Object.assign(this, calculateStats(config, report)); } set format(value) { @@ -102,133 +102,17 @@ class Output { if (resolve) resolve(); } - /** - * calculate stats - */ - calculate() { - let totalEstimate = 0; - let totalSpent = 0; - let spent = 0; - let spentFree = 0; - let spentHalfPrice = 0; - let users = {}; - let projects = {}; - let times = []; - let days = {}; - let daysMoment = {}; - let daysNew = {}; - - let spentFreeLabels = this.config.get('freeLabels'); - if(undefined === spentFreeLabels) { - spentFreeLabels = []; - } - let spentHalfPriceLabels = this.config.get('halfPriceLabels'); - if(undefined === spentHalfPriceLabels) { - spentHalfPriceLabels = []; - } - - ['issues', 'mergeRequests'].forEach(type => { - this.report[type].forEach(issue => { - - let free = false; - let halfPrice = false; - issue.labels.forEach(label => { - spentFreeLabels.forEach(freeLabel => { - free |= (freeLabel == label); - }); - }); - issue.labels.forEach(label => { - spentHalfPriceLabels.forEach(halfPriceLabel => { - halfPrice |= (halfPriceLabel == label); - }); - }); - - // consolidate all issues back in one day - Object.keys(issue.days).forEach((key) => { - if(!daysNew[key]) { - daysNew[key] = []; - } - daysNew[key].push(issue.days[key]); - }); - - issue.times.forEach(time => { - let dateGrp = time.date.format(this.config.get('dateFormatGroupReport')); - if (!users[time.user]) users[time.user] = 0; - if (!projects[time.project_namespace]) projects[time.project_namespace] = 0; - if (!days[dateGrp]) { - days[dateGrp] = {} - daysMoment[dateGrp] = time.date; - }; - if(!days[dateGrp][time.project_namespace]) { - days[dateGrp][time.project_namespace] = {}; - } - if(!days[dateGrp][time.project_namespace][time.iid]) { - days[dateGrp][time.project_namespace][time.iid] = 0; - } - - - users[time.user] += time.seconds; - projects[time.project_namespace] += time.seconds; - days[dateGrp][time.project_namespace][time.iid] += time.seconds; - - spent += time.seconds; - - if(free) { - spentFree += time.seconds; - } - if(halfPrice) { - spentHalfPrice += time.seconds; - } - times.push(time); - }); - totalEstimate += parseInt(issue.stats.time_estimate); - totalSpent += parseInt(issue.stats.total_time_spent); - }); - - this.report[type].sort((a, b) => { - if (a.iid === b.iid) return 0; - - return (a.iid - b.iid) < 0 ? 1 : -1; - }); - }); - - - this.times = times; - this.times.sort((a, b) => { - if (a.date.isSame(b.date)) return 0; - - return a.date.isBefore(b.date) ? 1 : -1; - }); - - this.days = days; - this.daysMoment = daysMoment; - this.users = _.mapObject(users, user => this.config.toHumanReadable(user, 'stats')); - this.projects = _.mapObject(projects, project => this.config.toHumanReadable(project, 'stats')); - this.stats = { - 'total estimate': this.config.toHumanReadable(totalEstimate, 'stats'), - 'total spent': this.config.toHumanReadable(totalSpent, 'stats'), - 'spent': this.config.toHumanReadable(spent, 'stats'), - 'spent free': this.config.toHumanReadable(spentFree, 'stats'), - }; - this.totalEstimate = totalEstimate; - this.spent = spent; - this.spentFree = spentFree; - this.spentHalfPrice = spentHalfPrice; - this.totalSpent = totalSpent; - this.daysNew = daysNew; - } - /** * prepare the given object by only returning * the given columns/properties and formatting - * special properties like moment instances + * special properties like dayjs instances * @param obj * @param columns * @returns {Array} */ prepare(obj = {}, columns = []) { return columns.map(column => { - if (moment.isMoment(obj[column])) + if (dayjs.isDayjs(obj[column])) return obj[column].format(this.config.get('dateFormat')); if (obj[column] === undefined || obj[column] === null) diff --git a/src/reporting/output/csv.js b/src/reporting/output/csv.js index 0a63e7c..2054b88 100755 --- a/src/reporting/output/csv.js +++ b/src/reporting/output/csv.js @@ -1,4 +1,3 @@ -import _ from 'underscore'; import fs from 'fs'; import path from 'path'; import Csv from 'csv-string'; @@ -7,23 +6,23 @@ import Output from './base.js'; /** * csv output */ -class csv extends Output { +class CsvOutput extends Output { makeStats() { let stats = [[], []]; - _.each(this.stats, (time, name) => { + Object.entries(this.stats).forEach(([name, time]) => { stats[0].push(name); stats[1].push(time); }); if (this.projects.length > 1) { - _.each(this.projects, (time, name) => { + Object.entries(this.projects).forEach(([name, time]) => { stats[0].push(name); stats[1].push(time); }); } - _.each(this.users, (time, name) => { + Object.entries(this.users).forEach(([name, time]) => { stats[0].push(name); stats[1].push(time); }); @@ -100,4 +99,4 @@ class csv extends Output { } } -export default csv; \ No newline at end of file +export default CsvOutput; \ No newline at end of file diff --git a/src/reporting/output/invoice.js b/src/reporting/output/invoice.js index d2ea813..df9d36f 100644 --- a/src/reporting/output/invoice.js +++ b/src/reporting/output/invoice.js @@ -1,7 +1,7 @@ -import _ from 'underscore'; import {markdownTable as Table} from 'markdown-table' import Output from './base.js'; import { SwissQRBill } from "swissqrbill/svg"; +import pc from 'picocolors'; const format = { headline: h => `\n### ${h}\n`, @@ -11,7 +11,7 @@ const format = { /** * invoice, code heavily based on markdown.js */ -class invoice extends Output { +class InvoiceOutput extends Output { constructor(config, report) { super(config, report); this.format = format; @@ -78,15 +78,13 @@ class invoice extends Output { let stats = ''; - _.each(this.stats, (time, name) => stats += `\n* **${name}**: ${time}`); + Object.entries(this.stats).forEach(([name, time]) => stats += `\n* **${name}**: ${time}`); stats += `\n`; if (this.projects.length > 1) { - _.each(this.projects, (time, name) => stats += `\n* **${name.red}**: ${time}`); + Object.entries(this.projects).forEach(([name, time]) => stats += `\n* **${pc.red(name)}**: ${time}`); stats += `\n`; } - // REMOVE - // _.each(this.users, (time, name) => stats += `\n* **${name}**: ${time}`); let to = this.concat(this.config.get('invoiceAddress'), '
'); let from = this.concat(this.config.get('invoiceSettings')?.from, '
'); let opening = this.concat(this.config.get('invoiceSettings')?.opening, '
'); @@ -269,4 +267,4 @@ table th:nth-of-type(3) { width: 10%; } } -export default invoice; \ No newline at end of file +export default InvoiceOutput; \ No newline at end of file diff --git a/src/reporting/output/markdown.js b/src/reporting/output/markdown.js index 3f510af..549142f 100755 --- a/src/reporting/output/markdown.js +++ b/src/reporting/output/markdown.js @@ -1,6 +1,6 @@ -import _ from 'underscore'; import {markdownTable as Table} from 'markdown-table' import Output from './base.js'; +import pc from 'picocolors'; const format = { headline: h => `\n### ${h}\n`, @@ -10,7 +10,7 @@ const format = { /** * stdout table output */ -class markdown extends Output { +class MarkdownOutput extends Output { constructor(config, report) { super(config, report); this.format = format; @@ -21,15 +21,15 @@ class markdown extends Output { let stats = ''; - _.each(this.stats, (time, name) => stats += `\n* **${name}**: ${time}`); + Object.entries(this.stats).forEach(([name, time]) => stats += `\n* **${name}**: ${time}`); stats += `\n`; if (this.projects.length > 1) { - _.each(this.projects, (time, name) => stats += `\n* **${name.red}**: ${time}`); + Object.entries(this.projects).forEach(([name, time]) => stats += `\n* **${pc.red(name)}**: ${time}`); stats += `\n`; } - _.each(this.users, (time, name) => stats += `\n* **${name}**: ${time}`); + Object.entries(this.users).forEach(([name, time]) => stats += `\n* **${name}**: ${time}`); this.write(stats.substr(1)); } @@ -68,4 +68,4 @@ class markdown extends Output { } } -export default markdown; \ No newline at end of file +export default MarkdownOutput; \ No newline at end of file diff --git a/src/reporting/output/table.js b/src/reporting/output/table.js index eb77f7c..0bab106 100755 --- a/src/reporting/output/table.js +++ b/src/reporting/output/table.js @@ -1,17 +1,16 @@ -import _ from 'underscore'; import Table from 'cli-table'; import Output from './base.js'; -import Color from 'colors'; +import pc from 'picocolors'; const format = { - headline: h => `\n${h.bold.underline}\n`, - warning: w => w.yellow + headline: h => `\n${pc.bold(pc.underline(h))}\n`, + warning: w => pc.yellow(w) }; /** * stdout table output */ -class table extends Output { +class TableOutput extends Output { constructor(config, report) { super(config, report); this.format = format; @@ -22,15 +21,15 @@ class table extends Output { let stats = ''; - _.each(this.stats, (time, name) => stats += `\n* ${name.red}: ${time}`); + Object.entries(this.stats).forEach(([name, time]) => stats += `\n* ${pc.red(name)}: ${time}`); stats += `\n`; if (this.projects.length > 1) { - _.each(this.projects, (time, name) => stats += `\n* ${name.red}: ${time}`); + Object.entries(this.projects).forEach(([name, time]) => stats += `\n* ${pc.red(name)}: ${time}`); stats += `\n`; } - _.each(this.users, (time, name) => stats += `\n* ${name.red}: ${time}`); + Object.entries(this.users).forEach(([name, time]) => stats += `\n* ${pc.red(name)}: ${time}`); this.write(stats.substr(1)); } @@ -95,4 +94,4 @@ class table extends Output { } } -export default table; \ No newline at end of file +export default TableOutput; \ No newline at end of file diff --git a/src/reporting/output/xlsx.js b/src/reporting/output/xlsx.js deleted file mode 100644 index c942d06..0000000 --- a/src/reporting/output/xlsx.js +++ /dev/null @@ -1,107 +0,0 @@ -import _ from 'underscore'; -import fs from 'fs'; -import path from 'path'; -import XLSX from 'xlsx'; -import Output from './base.js'; -import Cli from '../../core/cli.js'; - -/** - * xlsx output - */ -class xlsx extends Output { - - makeStats() { - let stats = [[], []]; - - _.each(this.stats, (time, name) => { - stats[0].push(name); - stats[1].push(time); - }); - - if (this.projects.length > 1) { - _.each(this.projects, (time, name) => { - stats[0].push(name); - stats[1].push(time); - }); - } - - _.each(this.users, (time, name) => { - stats[0].push(name); - stats[1].push(time); - }); - - this.xlsxStats = XLSX.utils.aoa_to_sheet(stats); - } - - makeIssues() { - let issues = []; - issues.push(this.config.get('issueColumns')); - this.report.issues.forEach(issue => issues.push(this.prepare(issue, this.config.get('issueColumns')))); - - this.xlsxIssues = XLSX.utils.aoa_to_sheet(issues); - } - - makeMergeRequests() { - let mergeRequests = []; - mergeRequests.push(this.config.get('mergeRequestColumns')); - this.report.mergeRequests.forEach(mergeRequest => mergeRequests.push(this.prepare(mergeRequest, this.config.get('mergeRequestColumns')))); - - this.xlsxMergeRequests = XLSX.utils.aoa_to_sheet(mergeRequests); - } - - makeRecords() { - let times = []; - times.push(this.config.get('recordColumns')); - this.times.forEach(time => times.push(this.prepare(time, this.config.get('recordColumns')))); - - this.xlsxRecords = XLSX.utils.aoa_to_sheet(times); - } - - toFile(file, resolve) { - let fileName = path.basename(file); - let extName = path.extname(file); - let workbook = XLSX.utils.book_new(); - - if (this.config.get('report').includes('stats')) { - XLSX.utils.book_append_sheet(workbook, this.xlsxStats, 'Stats'); - } - - if (this.config.get('report').includes('issues')) { - XLSX.utils.book_append_sheet(workbook, this.xlsxIssues, 'Issues'); - } - - if (this.config.get('report').includes('merge_requests')) { - XLSX.utils.book_append_sheet(workbook, this.xlsxMergeRequests, 'Merge Requests'); - } - - if (this.config.get('report').includes('records')) { - XLSX.utils.book_append_sheet(workbook, this.xlsxRecords, 'Records'); - } - - XLSX.writeFile(workbook, fileName); - - resolve(); - } - - toStdOut() { - Cli.error(`Can't output xlsx to std out`); - } - - /** - * prepare the given object by converting numeric - * columns/properties as numbers instead of strings - * on top of what the parent method already does - * - * suboptimally done here to avoid impacts on other outputs - * - * @param obj - * @param columns - * @returns {Array} - */ - prepare(obj = {}, columns = []) { - let formattedObj = super.prepare(obj, columns); - return formattedObj.map(field => isNaN(field) ? field : Number(field)); - } -} - -export default xlsx; diff --git a/src/reporting/stats.js b/src/reporting/stats.js new file mode 100644 index 0000000..c20ff77 --- /dev/null +++ b/src/reporting/stats.js @@ -0,0 +1,108 @@ +/** + * Aggregate a merged report into the numbers the output formats render: + * per-user/per-project/per-day spent time, estimate and spent totals, + * free and half-price time by label, and the flat, date-sorted list of + * time records. + * + * Note: sorts report.issues and report.mergeRequests in place (newest + * iid first) as part of preparing the report for rendering. + * + * @param config + * @param report merged report with issues and mergeRequests + * @returns {Object} calculated fields, ready to assign to an output + */ +export default function calculateStats(config, report) { + let totalEstimate = 0; + let totalSpent = 0; + let spent = 0; + let spentFree = 0; + let spentHalfPrice = 0; + let users = {}; + let projects = {}; + let times = []; + let days = {}; + let daysMoment = {}; + let daysNew = {}; + + let spentFreeLabels = config.get('freeLabels') ?? []; + let spentHalfPriceLabels = config.get('halfPriceLabels') ?? []; + + ['issues', 'mergeRequests'].forEach(type => { + report[type].forEach(issue => { + let free = issue.labels.some(label => spentFreeLabels.includes(label)); + let halfPrice = issue.labels.some(label => spentHalfPriceLabels.includes(label)); + + // consolidate all issues back in one day + Object.keys(issue.days).forEach((key) => { + if (!daysNew[key]) { + daysNew[key] = []; + } + daysNew[key].push(issue.days[key]); + }); + + issue.times.forEach(time => { + let dateGrp = time.date.format(config.get('dateFormatGroupReport')); + if (!users[time.user]) users[time.user] = 0; + if (!projects[time.project_namespace]) projects[time.project_namespace] = 0; + if (!days[dateGrp]) { + days[dateGrp] = {}; + daysMoment[dateGrp] = time.date; + } + if (!days[dateGrp][time.project_namespace]) { + days[dateGrp][time.project_namespace] = {}; + } + if (!days[dateGrp][time.project_namespace][time.iid]) { + days[dateGrp][time.project_namespace][time.iid] = 0; + } + + users[time.user] += time.seconds; + projects[time.project_namespace] += time.seconds; + days[dateGrp][time.project_namespace][time.iid] += time.seconds; + + spent += time.seconds; + + if (free) { + spentFree += time.seconds; + } + if (halfPrice) { + spentHalfPrice += time.seconds; + } + times.push(time); + }); + totalEstimate += parseInt(issue.stats.time_estimate); + totalSpent += parseInt(issue.stats.total_time_spent); + }); + + report[type].sort((a, b) => { + if (a.iid === b.iid) return 0; + + return (a.iid - b.iid) < 0 ? 1 : -1; + }); + }); + + times.sort((a, b) => { + if (a.date.isSame(b.date)) return 0; + + return a.date.isBefore(b.date) ? 1 : -1; + }); + + return { + times, + days, + daysMoment, + daysNew, + users: Object.fromEntries(Object.entries(users).map(([name, user]) => [name, config.toHumanReadable(user, 'stats')])), + projects: Object.fromEntries(Object.entries(projects).map(([name, project]) => [name, config.toHumanReadable(project, 'stats')])), + stats: { + 'total estimate': config.toHumanReadable(totalEstimate, 'stats'), + 'total spent': config.toHumanReadable(totalSpent, 'stats'), + 'spent': config.toHumanReadable(spent, 'stats'), + 'spent free': config.toHumanReadable(spentFree, 'stats'), + }, + totalEstimate, + totalSpent, + spent, + spentFree, + spentHalfPrice, + }; +} diff --git a/src/timekeeping/api/issue.js b/src/timekeeping/api/issue.js index 6f762d6..fcb79ad 100644 --- a/src/timekeeping/api/issue.js +++ b/src/timekeeping/api/issue.js @@ -7,7 +7,7 @@ import writable from './writable.js'; * mixin). The collection-level list() query is a static — it needs no * instance state. */ -class issue extends writable(CoreTask) { +class Issue extends writable(CoreTask) { constructor(config, data, client) { super(config, data, client, 'issues'); } @@ -27,4 +27,4 @@ class issue extends writable(CoreTask) { } } -export default issue; +export default Issue; diff --git a/src/timekeeping/api/mergeRequest.js b/src/timekeeping/api/mergeRequest.js index 6f145ba..839a69c 100644 --- a/src/timekeeping/api/mergeRequest.js +++ b/src/timekeeping/api/mergeRequest.js @@ -6,7 +6,7 @@ import writable from './writable.js'; * merge request with timekeeping write operations (make/createTime) provided * by the writable mixin; make() targets merge_requests via the _type getter. */ -class mergeRequest extends writable(CoreTask) { +class MergeRequest extends writable(CoreTask) { constructor(config, data, client) { super(config, data, client, 'merge_requests'); } @@ -26,4 +26,4 @@ class mergeRequest extends writable(CoreTask) { } } -export default mergeRequest; +export default MergeRequest; diff --git a/src/timekeeping/commands/cancel.js b/src/timekeeping/commands/cancel.js index 253a2d4..d7ccc13 100755 --- a/src/timekeeping/commands/cancel.js +++ b/src/timekeeping/commands/cancel.js @@ -1,6 +1,6 @@ import {Command} from 'commander'; -import colors from 'colors'; -import moment from 'moment'; +import pc from 'picocolors'; +import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; @@ -19,9 +19,9 @@ timekeeper.cancel() .then(frames => { frames.forEach(frame => { if(!frame.resource.new) - return console.log(`Cancel project ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue}, started ${moment(frame.start).fromNow().green}`) + return console.log(`Cancel project ${pc.magenta(frame.project)} ${pc.blue(frame.resource.type)} ${pc.blue(('#' + frame.resource.id))}, started ${pc.green(dayjs(frame.start).fromNow())}`) - console.log(`Cancel project ${frame.project.magenta} for new ${frame.resource.type} "${(frame.resource.id).blue}", started ${moment(frame.start).fromNow().green}`) + console.log(`Cancel project ${pc.magenta(frame.project)} for new ${frame.resource.type} "${pc.blue((frame.resource.id))}", started ${pc.green(dayjs(frame.start).fromNow())}`) }) }) .catch(error => Cli.error(error)); diff --git a/src/timekeeping/commands/create.js b/src/timekeeping/commands/create.js index cbc3541..fd86cbb 100755 --- a/src/timekeeping/commands/create.js +++ b/src/timekeeping/commands/create.js @@ -1,5 +1,5 @@ -import colors from 'colors'; -import moment from 'moment'; +import pc from 'picocolors'; +import dayjs from '../../core/dayjs.js'; import {Command} from 'commander'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; @@ -28,7 +28,7 @@ if (!title) Cli.error('Wrong or missing title'); timekeeper.start(project, type, title) - .then(frame => console.log(`Starting project ${config.get('project').magenta} and create ${type} "${title.blue}" at ${moment().format('HH:mm').green}`)) + .then(frame => console.log(`Starting project ${pc.magenta(config.get('project'))} and create ${type} "${pc.blue(title)}" at ${pc.green(dayjs().format('HH:mm'))}`)) .catch(error => Cli.error(error)); } ); diff --git a/src/timekeeping/commands/delete.js b/src/timekeeping/commands/delete.js index 0fd0ba9..83e9d40 100755 --- a/src/timekeeping/commands/delete.js +++ b/src/timekeeping/commands/delete.js @@ -3,6 +3,7 @@ import Frame from '../storage/frame.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Fs from '../../core/filesystem.js'; +import pc from 'picocolors'; function delCmd() { const delCmd = new Command('delete', 'delete time record by the given id') @@ -20,7 +21,7 @@ function delCmd() { } else { let frame = Frame.fromFile(config, file).stopMe(); Fs.remove(file); - console.log(`Deleting record ${frame.id.magenta}`); + console.log(`Deleting record ${pc.magenta(frame.id)}`); } } ); diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index 901be4f..489f291 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -2,13 +2,13 @@ import {Command} from 'commander'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Fs from '../../core/filesystem.js'; -import _ from 'underscore'; import Time from '../../core/time.js'; import Frame from '../storage/frame.js'; import select from '@inquirer/checkbox'; -import moment from 'moment'; +import dayjs from '../../core/dayjs.js'; import readline from 'readline'; import Timekeeper from '../timekeeper.js'; +import pc from 'picocolors'; const SHIFT_MINUTE = 1; const SHIFT_MINUTES = 15; @@ -128,16 +128,16 @@ function showMenu() { } let lastFramesDetails = frames.map((frame) => { let issue = `${ - (frame.resource.type + " #" + frame.resource.id).padEnd(20).blue + pc.blue((frame.resource.type + " #" + frame.resource.id).padEnd(20)) }${column(frame.title != null ? frame.title : "", 50)}`; return { name: - ` ${frame.id} ${frame.start.clone().format("MMMM Do YYYY HH:mm").green} ${ + ` ${frame.id} ${pc.green(frame.start.clone().format("MMMM Do YYYY HH:mm"))} ${ frame.stop - ? "to " + frame.stop.clone().format("HH:mm").green + ? "to " + pc.green(frame.stop.clone().format("HH:mm")) : "(running)" }\t` + - `${column(frame.project, 50).magenta}${issue}${ + `${pc.magenta(column(frame.project, 50))}${issue}${ frame.note != null ? frame.note : "" }`, value: frame.id, diff --git a/src/timekeeping/commands/list.js b/src/timekeeping/commands/list.js index 3869af8..bbcf185 100755 --- a/src/timekeeping/commands/list.js +++ b/src/timekeeping/commands/list.js @@ -1,6 +1,5 @@ import {Command} from 'commander'; -import colors from 'colors'; -import moment from 'moment'; +import pc from 'picocolors'; import Table from 'cli-table'; @@ -43,7 +42,7 @@ timekeeper.list(project, type, program.opts().closed ? 'closed' : 'opened', prog console.log("No issues or merge requests found."); } tasks.forEach(issue => { - table.push([issue.iid.toString().magenta, issue.title.green + "\n" + issue.data.web_url.gray, issue.state]) + table.push([pc.magenta(issue.iid.toString()), pc.green(issue.title) + "\n" + pc.gray(issue.data.web_url), issue.state]) }) console.log(table.toString()); }) diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index f4c7ae9..2ae0551 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -1,7 +1,6 @@ -import _ from 'underscore'; import {Command} from 'commander'; -import colors from 'colors'; -import moment from 'moment-timezone'; +import pc from 'picocolors'; +import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Time from '../../core/time.js'; @@ -39,7 +38,7 @@ const logCSV = (frames, times) => { if (!frames.hasOwnProperty(date)) return; frames[date].sort((a, b) => a.start.isBefore(b.start) ? -1 : 1) .forEach(frame => { - console.log(`${frame.id}, ${frame.project}, ${frame.resource.id}, ${moment(date).format('YYYY-MM-DD')}, ${frame.start.clone().format('HH:mm')}, ${frame.stop.clone().format('HH:mm')}, ${frame.duration}, ${frame.title!=null?frame.title:''}, ${frame.note!=null?frame.note:''}`); + console.log(`${frame.id}, ${frame.project}, ${frame.resource.id}, ${dayjs(date).format('YYYY-MM-DD')}, ${frame.start.clone().format('HH:mm')}, ${frame.stop.clone().format('HH:mm')}, ${frame.duration}, ${frame.title!=null?frame.title:''}, ${frame.note!=null?frame.note:''}`); }); }); }; @@ -52,27 +51,27 @@ const logCli = (frames, times) => { let hpd = config.get('hoursPerDay'); if(times[date] > hpd*3600*2) { - dayNote = ` - worked over ${hpd*2} hours`.red; + dayNote = pc.red(` - worked over ${hpd*2} hours`); }else if(times[date] > hpd*3600*1.5) { - dayNote = ` - worked over ${hpd*1.5} hours`.yellow; + dayNote = pc.yellow(` - worked over ${hpd*1.5} hours`); }else if(times[date] > hpd*3600*1.1) { - dayNote = ` - worked over ${hpd*1.1} hours`.green; + dayNote = pc.green(` - worked over ${hpd*1.1} hours`); } - console.log(`${moment(date).format('MMMM Do YYYY')} (${toHumanReadable(times[date])})`.green + dayNote); + console.log(pc.green(`${dayjs(date).format('MMMM Do YYYY')} (${toHumanReadable(times[date])})`) + dayNote); frames[date] .sort((a, b) => a.start.isBefore(b.start) ? -1 : 1) .forEach(frame => { - let toSync = (Math.ceil(frame.duration) - parseInt(_.reduce(frame.notes, (n, m) => (n + m.time), 0))) != 0; - let durationText = toSync ? toHumanReadable(frame.duration).padEnd(14).yellow : toHumanReadable(frame.duration).padEnd(14); + let toSync = (Math.ceil(frame.duration) - parseInt(frame.notes.reduce((n, m) => (n + m.time), 0))) != 0; + let durationText = toSync ? pc.yellow(toHumanReadable(frame.duration).padEnd(14)) : toHumanReadable(frame.duration).padEnd(14); let issue = frame.resource.new ? column(`(new ${frame.resource.type + ' "' + frame.resource.id}")`, 70).bgBlue: - `${(frame.resource.type + ' #' + frame.resource.id).padEnd(20).blue}${column(frame.title!=null?frame.title:'', 50)}`; - console.log(` ${frame.id} ${frame.start.clone().format('HH:mm').green} to ${frame.stop.clone().format('HH:mm').green}\t${durationText}`+ - `${column(frame.project, 50).magenta}${issue}${frame.note!=null?frame.note:''}`); + `${pc.blue((frame.resource.type + ' #' + frame.resource.id).padEnd(20))}${column(frame.title!=null?frame.title:'', 50)}`; + console.log(` ${frame.id} ${pc.green(frame.start.clone().format('HH:mm'))} to ${pc.green(frame.stop.clone().format('HH:mm'))}\t${durationText}`+ + `${pc.magenta(column(frame.project, 50))}${issue}${frame.note!=null?frame.note:''}`); }); }); }; diff --git a/src/timekeeping/commands/resume.js b/src/timekeeping/commands/resume.js index 9751352..ff7cfef 100755 --- a/src/timekeeping/commands/resume.js +++ b/src/timekeeping/commands/resume.js @@ -1,6 +1,6 @@ import { Command } from 'commander'; -import colors from 'colors'; -import moment from 'moment'; +import pc from 'picocolors'; +import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; @@ -19,7 +19,7 @@ function column(str, n) { function resumeFrame(timekeeper, frame) { timekeeper.resume(frame) - .then(frame => console.log(`Starting project ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} ${frame.note?frame.note:''} at ${moment().format('HH:mm').green}`)) + .then(frame => console.log(`Starting project ${pc.magenta(frame.project)} ${pc.blue(frame.resource.type)} ${pc.blue(('#' + frame.resource.id))} ${frame.note?frame.note:''} at ${pc.green(dayjs().format('HH:mm'))}`)) .catch(error => Cli.error(error)); } @@ -42,7 +42,7 @@ function resume() { lastFrames = lastFrames.map((file) => Frame.fromFile(config, Fs.join(config.frameDir, file.name)) ); - lastFrames = lastFrames.sort((a, b) => moment(a.stop || moment()).isBefore(moment(b.stop || moment())) ? 1 : -1); + lastFrames = lastFrames.sort((a, b) => dayjs(a.stop || dayjs()).isBefore(dayjs(b.stop || dayjs())) ? 1 : -1); if (!options.ask) { let project = config.get('project'); @@ -52,12 +52,12 @@ function resume() { let lastFramesDetails = lastFrames .sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1)) .map((frame) => { - let issue = `${(frame.resource.type + " #" + frame.resource.id).padEnd(20).blue + let issue = `${pc.blue((frame.resource.type + " #" + frame.resource.id).padEnd(20)) }${column(frame.title != null ? frame.title : "", 50)}`; return { name: - ` ${frame.id} ${frame.start.clone().format("MMMM Do YYYY HH:mm").green} ${frame.stop ? "to " + frame.stop.clone().format("HH:mm").green : "(running)"}\t` + - `${column(frame.project, 50).magenta}${issue}${frame.note != null ? frame.note : "" + ` ${frame.id} ${pc.green(frame.start.clone().format("MMMM Do YYYY HH:mm"))} ${frame.stop ? "to " + pc.green(frame.stop.clone().format("HH:mm")) : "(running)"}\t` + + `${pc.magenta(column(frame.project, 50))}${issue}${frame.note != null ? frame.note : "" }`, value: frame, }; diff --git a/src/timekeeping/commands/start.js b/src/timekeeping/commands/start.js index 6db0e98..5673172 100755 --- a/src/timekeeping/commands/start.js +++ b/src/timekeeping/commands/start.js @@ -1,5 +1,5 @@ -import colors from 'colors'; -import moment from 'moment'; +import pc from 'picocolors'; +import dayjs from '../../core/dayjs.js'; import {Command} from 'commander'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; @@ -41,7 +41,7 @@ if (!id) Cli.error('Wrong or missing issue/merge_request id'); timekeeper.start(project, type, id, note) - .then(frame => console.log(`Starting project ${config.get('project').magenta} ${type.blue} ${('#' + id).blue} at ${moment().format('HH:mm').green}`)) + .then(frame => console.log(`Starting project ${pc.magenta(config.get('project'))} ${pc.blue(type)} ${pc.blue(('#' + id))} at ${pc.green(dayjs().format('HH:mm'))}`)) .catch(error => Cli.error(error)); } ); diff --git a/src/timekeeping/commands/status.js b/src/timekeeping/commands/status.js index 80c4804..601c325 100755 --- a/src/timekeeping/commands/status.js +++ b/src/timekeeping/commands/status.js @@ -1,6 +1,6 @@ import {Command} from 'commander'; -import colors from 'colors'; -import moment from 'moment'; +import pc from 'picocolors'; +import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; @@ -28,9 +28,9 @@ timekeeper.status() return; } if (program.opts().s) { - frames.forEach(frame => console.log(`${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} ${moment(frame.start).fromNow().green} (id: ${frame.id})`)); + frames.forEach(frame => console.log(`${pc.magenta(frame.project)} ${pc.blue(frame.resource.type)} ${pc.blue(('#' + frame.resource.id))} ${pc.green(dayjs(frame.start).fromNow())} (id: ${frame.id})`)); } else { - frames.forEach(frame => console.log(`Project ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} ${frame.note?frame.note:''} is running, started ${moment(frame.start).fromNow().green} (id: ${frame.id})`)); + frames.forEach(frame => console.log(`Project ${pc.magenta(frame.project)} ${pc.blue(frame.resource.type)} ${pc.blue(('#' + frame.resource.id))} ${frame.note?frame.note:''} is running, started ${pc.green(dayjs(frame.start).fromNow())} (id: ${frame.id})`)); } }) .catch(error => Cli.error('Could not read frames.', error)); diff --git a/src/timekeeping/commands/stop.js b/src/timekeeping/commands/stop.js index 6291fee..3dcd953 100755 --- a/src/timekeeping/commands/stop.js +++ b/src/timekeeping/commands/stop.js @@ -1,6 +1,6 @@ import {Command} from 'commander'; -import colors from 'colors'; -import moment from 'moment'; +import pc from 'picocolors'; +import dayjs from '../../core/dayjs.js'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; import Timekeeper from '../timekeeper.js'; @@ -20,9 +20,9 @@ timekeeper.stop() .then(frames => { frames.forEach(frame => { if(!frame.resource.new) - return console.log(`Stopping project ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue}, started ${moment(frame.start).fromNow().green} (id: ${frame.id})`) + return console.log(`Stopping project ${pc.magenta(frame.project)} ${pc.blue(frame.resource.type)} ${pc.blue(('#' + frame.resource.id))}, started ${pc.green(dayjs(frame.start).fromNow())} (id: ${frame.id})`) - console.log(`Stopping project ${frame.project.magenta} for new ${frame.resource.type} "${(frame.resource.id).blue}", started ${moment(frame.start).fromNow().green} (id: ${frame.id})`) + console.log(`Stopping project ${pc.magenta(frame.project)} for new ${frame.resource.type} "${pc.blue((frame.resource.id))}", started ${pc.green(dayjs(frame.start).fromNow())} (id: ${frame.id})`) }); }) .catch(error => Cli.error(error)); diff --git a/src/timekeeping/commands/sync.js b/src/timekeeping/commands/sync.js index 0ce8be3..2415549 100755 --- a/src/timekeeping/commands/sync.js +++ b/src/timekeeping/commands/sync.js @@ -1,4 +1,3 @@ -import moment from 'moment'; import {Command} from 'commander'; import Config from '../../core/file-config.js'; import Cli from '../../core/cli.js'; diff --git a/src/timekeeping/storage/frame.js b/src/timekeeping/storage/frame.js index 6e48103..14712ce 100755 --- a/src/timekeeping/storage/frame.js +++ b/src/timekeeping/storage/frame.js @@ -1,10 +1,10 @@ import fs from 'fs'; import path from 'path'; -import moment from 'moment-timezone'; +import dayjs from '../../core/dayjs.js'; import Hashids from 'hashids'; const hashids = new Hashids(); -class frame { +class Frame { /** * constructor. * @param config @@ -20,7 +20,7 @@ class frame { if(typeof id === 'string' || id instanceof String) this.resource.new = true; - this.id = frame.generateId(); + this.id = Frame.generateId(); this._start = false; this._stop = false; this.timezone = config.get('timezone'); @@ -30,7 +30,7 @@ class frame { } static fromFile(config, file) { - return frame.fromJson(config, JSON.parse(fs.readFileSync(file))); + return Frame.fromJson(config, JSON.parse(fs.readFileSync(file))); } static fromJson(config, json) { @@ -49,22 +49,18 @@ class frame { } validate() { - moment.suppressDeprecationWarnings = true; + if(!dayjs(this._start).isValid()) + throw new Error(`Start date is not in a valid ISO date format!`); - if(!moment(this._start).isValid()) - throw `Error: Start date is not in a valid ISO date format!`; - - if(this._stop && !moment(this._stop).isValid()) - throw `Error: Stop date is not in a valid ISO date format!`; - - moment.suppressDeprecationWarnings = false; + if(this._stop && !dayjs(this._stop).isValid()) + throw new Error(`Stop date is not in a valid ISO date format!`); } _getCurrentDate() { if(this.timezone) - return moment().tz(this.timezone).format(); + return dayjs().tz(this.timezone).format(); - return moment(); + return dayjs(); } startMe() { @@ -82,18 +78,14 @@ class frame { } /** - * assert file exists - */ - assertFile() { - if (!fs.existsSync(this.file)) fs.appendFileSync(this.file, ''); - } - - /** - * write data to file + * write data to file atomically: write to a temp file in the same + * directory, then rename over the target so a crash can never leave + * a partially written or missing frame behind. */ write(skipModified) { - if (fs.existsSync(this.file)) fs.unlinkSync(this.file); - fs.appendFileSync(this.file, JSON.stringify({ + const tmpFile = `${this.file}.tmp`; + + fs.writeFileSync(tmpFile, JSON.stringify({ id: this.id, project: this.project, resource: this.resource, @@ -101,14 +93,15 @@ class frame { start: this._start, stop: this._stop, timezone: this.timezone, - modified: skipModified ? this.modified : moment(), + modified: skipModified ? this.modified : dayjs(), title: this._title, note: this._note }, null, "\t")); + fs.renameSync(tmpFile, this.file); } get duration() { - return moment(this.stop).diff(this.start) / 1000; + return dayjs(this.stop).diff(this.start) / 1000; } get date() { @@ -116,20 +109,20 @@ class frame { } get start() { - return this.timezone ? moment(this._start).tz(this.timezone) : moment(this._start); + return this.timezone ? dayjs(this._start).tz(this.timezone) : dayjs(this._start); } set start(value) { - this._start = moment.isMoment(value) ? value.format() : value; + this._start = dayjs.isDayjs(value) ? value.format() : value; this.validate(); } get stop() { - return this.timezone ? this._stop ? moment(this._stop).tz(this.timezone) : false : (this._stop ? moment(this._stop) : false ); + return this.timezone ? this._stop ? dayjs(this._stop).tz(this.timezone) : false : (this._stop ? dayjs(this._stop) : false ); } set stop(value) { - this._stop = value ? (moment.isMoment(value) ? value.format() : value) : false; + this._stop = value ? (dayjs.isDayjs(value) ? value.format() : value) : false; this.validate(); } @@ -158,4 +151,4 @@ class frame { } } -export default frame; +export default Frame; diff --git a/src/timekeeping/storage/frameCollection.js b/src/timekeeping/storage/frameCollection.js index 13ac7c2..5cc78d4 100755 --- a/src/timekeeping/storage/frameCollection.js +++ b/src/timekeeping/storage/frameCollection.js @@ -2,7 +2,7 @@ import parallel from '../../core/parallel.js'; import Frame from './frame.js'; import Fs from '../../core/filesystem.js'; -class frameCollection { +class FrameCollection { constructor(config) { this.config = config; @@ -13,7 +13,7 @@ class frameCollection { return Frame.fromFile(this.config, Fs.join(this.config.frameDir, file.name)); } catch (e) { console.log(e); - throw `Error parsing frame file: ${file.name}` + throw new Error(`Error parsing frame file: ${file.name}`) } }) .filter(frame => frame); @@ -54,4 +54,4 @@ class frameCollection { } } -export default frameCollection; \ No newline at end of file +export default FrameCollection; \ No newline at end of file diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index e3881aa..4446001 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -1,5 +1,3 @@ -import _ from 'underscore'; -import moment from 'moment'; import Fs from '../core/filesystem.js'; import Frame from './storage/frame.js'; import Issue from './api/issue.js'; @@ -44,13 +42,11 @@ class Timekeeper { * Filter frames that need an update * @returns {Promise} */ - syncInit() { + async syncInit() { this.sync.frames = new FrameCollection(this.config); // filter out frames, that don't need an update - this.sync.frames.filter(frame => !(Math.ceil(frame.duration) === _.reduce(frame.notes, (n, m) => (n + m.time), 0))); - - return new Promise(r => r()); + this.sync.frames.filter(frame => !(Math.ceil(frame.duration) === frame.notes.reduce((n, m) => (n + m.time), 0))); } /** @@ -60,9 +56,9 @@ class Timekeeper { */ syncResolve(callback) { this.sync.resources = {} - + // resolve issues and merge requests - return this.sync.frames.forEach((frame, done) => { + return this.sync.frames.forEach(async frame => { let project = frame.project, type = frame.resource.type, id = frame.resource.id; @@ -76,16 +72,17 @@ class Timekeeper { } if(id in this.sync.resources[project][type]) { - return done(); + return; } this.sync.resources[project][type][id] = new classes[type](this.config, {}); - this.sync.resources[project][type][id] - .make(project, id, frame.resource.new) - .then(() => { - if (callback) callback(); - done(); - }) - .catch(error => done(`Could not resolve ${type} ${id} on "${project}"`)); + + try { + await this.sync.resources[project][type][id].make(project, id, frame.resource.new); + } catch (error) { + throw new Error(`Could not resolve ${type} ${id} on "${project}": ${error.message ?? error}`); + } + + if (callback) callback(); }) } @@ -93,140 +90,117 @@ class Timekeeper { * sync details to frames. */ syncDetails(callback) { - return this.sync.frames.forEach((frame, done) => { + return this.sync.frames.forEach(frame => { let project = frame.project, type = frame.resource.type, id = frame.resource.id; - + if(id in this.sync.resources[project][type]) { frame.title = this.sync.resources[project][type][id].data.title; } - return done(); }); } syncUpdate(callback) { - return this.sync.frames.forEach((frame, done) => { + return this.sync.frames.forEach(async frame => { let time = frame.duration, project = frame.project, type = frame.resource.type, id = frame.resource.id; if (frame.notes.length > 0) - time = Math.ceil(frame.duration) - parseInt(_.reduce(frame.notes, (n, m) => (n + m.time), 0)); - - this._addTime(frame, time) - .then(() => { - if (callback) callback(); - done(); - }) - .catch(error => done(`Could not update ${type} ${id} on ${project}`)) + time = Math.ceil(frame.duration) - parseInt(frame.notes.reduce((n, m) => (n + m.time), 0)); + + try { + await this._addTime(frame, time); + } catch (error) { + throw new Error(`Could not update ${type} ${id} on ${project}: ${error.message ?? error}`); + } + + if (callback) callback(); }); } - _addTime(frame, time) { - return new Promise((resolve, reject) => { - let resource = this.sync.resources[frame.project][frame.resource.type][frame.resource.id]; - - resource.createTime(Math.ceil(time), frame._stop, frame.note) - .then(() => resource.getNotes()) - .then(() => { - if (frame.resource.new) { - delete frame.resource.new; - frame.resource.title = frame.resource.id; - frame.resource.id = resource.data.iid; - } - - frame.notes.push({ - id: resource.notes[0].id, - time: Math.ceil(time) - }); - - frame.write(true); - resolve(); - }) - .catch(error => reject(error)); + async _addTime(frame, time) { + let resource = this.sync.resources[frame.project][frame.resource.type][frame.resource.id]; + + await resource.createTime(Math.ceil(time), frame._stop, frame.note); + await resource.getNotes(); + + if (frame.resource.new) { + delete frame.resource.new; + frame.resource.title = frame.resource.id; + frame.resource.id = resource.data.iid; + } + + frame.notes.push({ + id: resource.notes[0].id, + time: Math.ceil(time) }); + + frame.write(true); } /** * * @returns {Promise} */ - status() { - return new Promise((resolve, reject) => { - let id = this._currentId(); + async status() { + let id = this._currentId(); - if (!id) return resolve([]); + if (!id) return []; - resolve([Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json'))]); - }); + return [Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json'))]; } /** * * @returns {Promise} */ - log() { - return new Promise((resolve, reject) => { - let frames = {}, - times = {}; - - new FrameCollection(this.config) - .forEach((frame, done) => { - if (frame.stop === false) return done(); - let date = frame.date.format('YYYY-MM-DD'); - - if (!frames[date]) frames[date] = []; - if (!times[date]) times[date] = 0; - - frames[date].push(frame); - times[date] += Math.ceil(frame.duration); - - done(); - }) - .then(() => new Promise(r => { - resolve({frames, times}); - r(); - })) - .catch(error => reject(error)); - }); + async log() { + let frames = {}, + times = {}; + + await new FrameCollection(this.config) + .forEach(frame => { + if (frame.stop === false) return; + let date = frame.date.format('YYYY-MM-DD'); + + if (!frames[date]) frames[date] = []; + if (!times[date]) times[date] = 0; + + frames[date].push(frame); + times[date] += Math.ceil(frame.duration); + }); + + return {frames, times}; } /** * * @returns {Promise} */ - all() { - return new Promise((resolve, reject) => { - let frames = []; - - new FrameCollection(this.config) - .forEach((frame, done) => { - frames.push(frame); - done(); - }) - .then(() => new Promise(r => { - resolve({frames}); - r(); - })) - .catch(error => reject(error)); - }); + async all() { + let frames = []; + + await new FrameCollection(this.config) + .forEach(frame => { + frames.push(frame); + }); + + return {frames}; } /** * * @returns {Promise} */ - resume(frame) { - return new Promise((resolve, reject) => { - if (!frame) { - return reject("No task found to resume."); - } - this.start(frame.project, frame.resource.type, frame.resource.id, frame.note) - .then(frame => resolve(frame)) - .catch(error => reject(error)); - }); + async resume(frame) { + if (!frame) { + throw new Error("No task found to resume."); + } + + return this.start(frame.project, frame.resource.type, frame.resource.id, frame.note); } list(project, type, state, my) { @@ -241,54 +215,48 @@ class Timekeeper { * @param id * @returns {Promise} */ - start(project, type, id, note) { + async start(project, type, id, note) { this.config.set('project', project); - return new Promise((resolve, reject) => { - if (this._currentId()) - return reject("Already running. Please stop it first with 'gtt stop'."); + if (this._currentId()) + throw new Error("Already running. Please stop it first with 'gtt stop'."); - let frame = new Frame(this.config, id, type, note).startMe(); - Fs.writeText(this._currentFile(), frame.id); + let frame = new Frame(this.config, id, type, note).startMe(); + Fs.writeText(this._currentFile(), frame.id); - resolve(frame); - }) + return frame; } /** * * @returns {Promise} */ - stop() { - return new Promise((resolve, reject) => { - let id = this._currentId(); + async stop() { + let id = this._currentId(); - if (!id) return reject('No projects started.'); + if (!id) throw new Error('No projects started.'); - let frame = Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json')).stopMe(); - Fs.remove(this._currentFile()); + let frame = Frame.fromFile(this.config, Fs.join(this.config.frameDir, id + '.json')).stopMe(); + Fs.remove(this._currentFile()); - resolve([frame]); - }); + return [frame]; } /** * * @returns {Promise} */ - cancel() { - return new Promise((resolve, reject) => { - let id = this._currentId(); + async cancel() { + let id = this._currentId(); - if (!id) return reject('No projects started.'); + if (!id) throw new Error('No projects started.'); - let file = Fs.join(this.config.frameDir, id + '.json'); - let frame = Frame.fromFile(this.config, file); - Fs.remove(file); - Fs.remove(this._currentFile()); + let file = Fs.join(this.config.frameDir, id + '.json'); + let frame = Frame.fromFile(this.config, file); + Fs.remove(file); + Fs.remove(this._currentFile()); - resolve([frame]); - }); + return [frame]; } }